This commit is contained in:
parent
95917bc699
commit
2c002c2f82
|
|
@ -1,16 +1,15 @@
|
||||||
{
|
{
|
||||||
"name": "queuenorth-website",
|
"name": "queuenorth-website",
|
||||||
"version": "0.6.6",
|
"version": "0.7.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "queuenorth-website",
|
"name": "queuenorth-website",
|
||||||
"version": "0.6.6",
|
"version": "0.7.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-dialog": "^1.1.0",
|
"@radix-ui/react-dialog": "^1.1.0",
|
||||||
"@radix-ui/react-visually-hidden": "^1.2.4",
|
"@radix-ui/react-visually-hidden": "^1.2.4",
|
||||||
"@tanstack/react-query": "^5.62.0",
|
|
||||||
"better-sqlite3": "^11.8.0",
|
"better-sqlite3": "^11.8.0",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
|
|
@ -1646,32 +1645,6 @@
|
||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/query-core": {
|
|
||||||
"version": "5.100.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.10.tgz",
|
|
||||||
"integrity": "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tanstack/react-query": {
|
|
||||||
"version": "5.100.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.10.tgz",
|
|
||||||
"integrity": "sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@tanstack/query-core": "5.100.10"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^18 || ^19"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-dialog": "^1.1.0",
|
"@radix-ui/react-dialog": "^1.1.0",
|
||||||
"@radix-ui/react-visually-hidden": "^1.2.4",
|
"@radix-ui/react-visually-hidden": "^1.2.4",
|
||||||
"@tanstack/react-query": "^5.62.0",
|
|
||||||
"better-sqlite3": "^11.8.0",
|
"better-sqlite3": "^11.8.0",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,10 @@ const cspDirectives = {
|
||||||
formAction: ["'self'"],
|
formAction: ["'self'"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Note: connectSrc currently allows 'self' only. Zoho API calls are server-to-server
|
||||||
|
// and are not affected by CSP. If client-side Zoho calls are added in the future,
|
||||||
|
// add Zoho domains here (e.g., 'https://www.zohoapis.com', 'https://accounts.zoho.com')
|
||||||
|
|
||||||
app.use(helmet({
|
app.use(helmet({
|
||||||
contentSecurityPolicy: {
|
contentSecurityPolicy: {
|
||||||
directives: cspDirectives,
|
directives: cspDirectives,
|
||||||
|
|
@ -95,6 +99,16 @@ app.use(helmet({
|
||||||
|
|
||||||
log.info('[Security] Helmet enabled with CSP configured')
|
log.info('[Security] Helmet enabled with CSP configured')
|
||||||
|
|
||||||
|
// Redirect HTTP to HTTPS in production
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (req.headers['x-forwarded-proto'] === 'http') {
|
||||||
|
return res.redirect(301, `https://${req.headers.host}${req.url}`)
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// --- CORS Configuration ---
|
// --- CORS Configuration ---
|
||||||
const corsOrigin = process.env.CORS_ORIGIN || 'https://queuenorth.com' // Default to production domain
|
const corsOrigin = process.env.CORS_ORIGIN || 'https://queuenorth.com' // Default to production domain
|
||||||
const corsConfig = cors({
|
const corsConfig = cors({
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,36 @@
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'
|
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'
|
||||||
|
|
||||||
// Exponential backoff retry helper
|
export async function submitLead(data) {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/leads`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}))
|
||||||
|
const error = new Error(errorData.error || `API error: ${response.status}`)
|
||||||
|
error.response = { status: response.status }
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitSupport(data) {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/support`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}))
|
||||||
|
const error = new Error(errorData.error || `API error: ${response.status}`)
|
||||||
|
error.response = { status: response.status }
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exponential backoff retry helper (deprecated, kept for other API calls)
|
||||||
const retryFetch = async (fn, { maxRetries = 3, baseDelay = 1000 } = {}) => {
|
const retryFetch = async (fn, { maxRetries = 3, baseDelay = 1000 } = {}) => {
|
||||||
let lastError
|
let lastError
|
||||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
|
|
|
||||||
15
src/main.jsx
15
src/main.jsx
|
|
@ -1,28 +1,17 @@
|
||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { RouterProvider } from 'react-router-dom'
|
import { RouterProvider } from 'react-router-dom'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
||||||
import { Toaster } from 'sonner'
|
import { Toaster } from 'sonner'
|
||||||
import { HelmetProvider } from 'react-helmet-async'
|
import { HelmetProvider } from 'react-helmet-async'
|
||||||
import router from './router.jsx'
|
import router from './router.jsx'
|
||||||
import App from './App.jsx'
|
import App from './App.jsx'
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Wrap the router with providers
|
// Wrap the router with providers
|
||||||
const Root = () => (
|
const Root = () => (
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<HelmetProvider>
|
<HelmetProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<RouterProvider router={router} />
|
||||||
<RouterProvider router={router} />
|
<Toaster position="top-right" />
|
||||||
<Toaster position="top-right" />
|
|
||||||
</QueryClientProvider>
|
|
||||||
</HelmetProvider>
|
</HelmetProvider>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useMutation } from '@tanstack/react-query'
|
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Input } from '@/components/ui/Input'
|
import { Input } from '@/components/ui/Input'
|
||||||
import { Textarea } from '@/components/ui/Textarea'
|
import { Textarea } from '@/components/ui/Textarea'
|
||||||
import { Select } from '@/components/ui/Select'
|
import { Select } from '@/components/ui/Select'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { api } from '@/lib/api'
|
import { submitLead } from '@/lib/api'
|
||||||
import { useDebounce } from '@/hooks/useDebounce'
|
import { useDebounce } from '@/hooks/useDebounce'
|
||||||
|
|
||||||
const Contact = () => {
|
const Contact = () => {
|
||||||
|
|
@ -29,36 +28,7 @@ const Contact = () => {
|
||||||
})
|
})
|
||||||
// Debounce validation errors so they don't flash on every keystroke
|
// Debounce validation errors so they don't flash on every keystroke
|
||||||
const debouncedErrors = useDebounce(errors, 300)
|
const debouncedErrors = useDebounce(errors, 300)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const mutation = useMutation({
|
|
||||||
mutationFn: (data) => api.post('/leads', data),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success('Thanks! We\'ll be in touch shortly.')
|
|
||||||
setFormState({
|
|
||||||
company: '',
|
|
||||||
name: '',
|
|
||||||
email: '',
|
|
||||||
phone: '',
|
|
||||||
zip: '',
|
|
||||||
message: '',
|
|
||||||
service_interest: '',
|
|
||||||
})
|
|
||||||
setErrors({
|
|
||||||
company: '',
|
|
||||||
name: '',
|
|
||||||
email: '',
|
|
||||||
message: '',
|
|
||||||
})
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
// 409 means duplicate email - this is actually good news
|
|
||||||
if (error.response?.status === 409) {
|
|
||||||
toast.success("We already have your submission! We'll be in touch.")
|
|
||||||
} else {
|
|
||||||
toast.error(error.message || 'Failed to submit form. Please try again.')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const validateForm = () => {
|
const validateForm = () => {
|
||||||
const newErrors = {
|
const newErrors = {
|
||||||
|
|
@ -95,7 +65,39 @@ const Contact = () => {
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!validateForm()) return
|
if (!validateForm()) return
|
||||||
mutation.mutate(formState)
|
handleSubmitForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmitForm = async () => {
|
||||||
|
setIsSubmitting(true)
|
||||||
|
try {
|
||||||
|
await submitLead(formState)
|
||||||
|
toast.success("Thanks! We\'ll be in touch shortly.")
|
||||||
|
setFormState({
|
||||||
|
company: '',
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
zip: '',
|
||||||
|
message: '',
|
||||||
|
service_interest: '',
|
||||||
|
})
|
||||||
|
setErrors({
|
||||||
|
company: '',
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
message: '',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
// 409 means duplicate email - this is actually good news
|
||||||
|
if (error.response?.status === 409) {
|
||||||
|
toast.success("We already have your submission! We\'ll be in touch.")
|
||||||
|
} else {
|
||||||
|
toast.error(error.message || 'Failed to submit form. Please try again.')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleChange = (e) => {
|
const handleChange = (e) => {
|
||||||
|
|
@ -206,7 +208,7 @@ const Contact = () => {
|
||||||
|
|
||||||
{/* Right - Form */}
|
{/* Right - Form */}
|
||||||
<div>
|
<div>
|
||||||
<form onSubmit={handleSubmit} noValidate className={`space-y-6 ${mutation.isPending ? 'opacity-70 pointer-events-none' : ''}`}>
|
<form onSubmit={handleSubmit} noValidate className={`space-y-6 ${isSubmitting ? 'opacity-70 pointer-events-none' : ''}`}>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="company" className="block text-sm font-medium text-text mb-2">
|
<label htmlFor="company" className="block text-sm font-medium text-text mb-2">
|
||||||
Company Name <span className="text-red-600">*</span>
|
Company Name <span className="text-red-600">*</span>
|
||||||
|
|
@ -346,9 +348,9 @@ const Contact = () => {
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={mutation.isPending}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
{mutation.isPending ? 'Submitting...' : 'Request Consultation'}
|
{isSubmitting ? 'Submitting...' : 'Request Consultation'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useMutation } from '@tanstack/react-query'
|
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Input } from '@/components/ui/Input'
|
import { Input } from '@/components/ui/Input'
|
||||||
import { Textarea } from '@/components/ui/Textarea'
|
import { Textarea } from '@/components/ui/Textarea'
|
||||||
import { Select } from '@/components/ui/Select'
|
import { Select } from '@/components/ui/Select'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { api } from '@/lib/api'
|
import { submitSupport } from '@/lib/api'
|
||||||
import { useDebounce } from '@/hooks/useDebounce'
|
import { useDebounce } from '@/hooks/useDebounce'
|
||||||
|
|
||||||
const Support = () => {
|
const Support = () => {
|
||||||
|
|
@ -28,30 +27,7 @@ const Support = () => {
|
||||||
})
|
})
|
||||||
// Debounce validation errors so they don't flash on every keystroke
|
// Debounce validation errors so they don't flash on every keystroke
|
||||||
const debouncedErrors = useDebounce(errors, 300)
|
const debouncedErrors = useDebounce(errors, 300)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const mutation = useMutation({
|
|
||||||
mutationFn: (data) => api.post('/support', data),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success('Thanks! We\'ll get back to you soon.')
|
|
||||||
setFormState({
|
|
||||||
name: '',
|
|
||||||
company: '',
|
|
||||||
email: '',
|
|
||||||
phone: '',
|
|
||||||
issue: '',
|
|
||||||
priority: 'medium',
|
|
||||||
})
|
|
||||||
setErrors({
|
|
||||||
name: '',
|
|
||||||
company: '',
|
|
||||||
email: '',
|
|
||||||
issue: '',
|
|
||||||
})
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error(error.message || 'Failed to submit form. Please try again.')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const validateForm = () => {
|
const validateForm = () => {
|
||||||
const newErrors = {
|
const newErrors = {
|
||||||
|
|
@ -93,7 +69,33 @@ const Support = () => {
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!validateForm()) return
|
if (!validateForm()) return
|
||||||
mutation.mutate(formState)
|
handleSubmitForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmitForm = async () => {
|
||||||
|
setIsSubmitting(true)
|
||||||
|
try {
|
||||||
|
await submitSupport(formState)
|
||||||
|
toast.success("Thanks! We\'ll get back to you soon.")
|
||||||
|
setFormState({
|
||||||
|
name: '',
|
||||||
|
company: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
issue: '',
|
||||||
|
priority: 'medium',
|
||||||
|
})
|
||||||
|
setErrors({
|
||||||
|
name: '',
|
||||||
|
company: '',
|
||||||
|
email: '',
|
||||||
|
issue: '',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error.message || 'Failed to submit form. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleChange = (e) => {
|
const handleChange = (e) => {
|
||||||
|
|
@ -232,7 +234,7 @@ const Support = () => {
|
||||||
|
|
||||||
{/* Right - Form */}
|
{/* Right - Form */}
|
||||||
<div>
|
<div>
|
||||||
<form onSubmit={handleSubmit} noValidate className={`space-y-6 ${mutation.isPending ? 'opacity-70 pointer-events-none' : ''}`}>
|
<form onSubmit={handleSubmit} noValidate className={`space-y-6 ${isSubmitting ? 'opacity-70 pointer-events-none' : ''}`}>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-text mb-2">
|
<label htmlFor="name" className="block text-sm font-medium text-text mb-2">
|
||||||
Name <span className="text-red-600">*</span>
|
Name <span className="text-red-600">*</span>
|
||||||
|
|
@ -353,9 +355,9 @@ const Support = () => {
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={mutation.isPending}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
{mutation.isPending ? 'Submitting...' : 'Submit Request'}
|
{isSubmitting ? 'Submitting...' : 'Submit Request'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue