This commit is contained in:
parent
95917bc699
commit
2c002c2f82
|
|
@ -1,16 +1,15 @@
|
|||
{
|
||||
"name": "queuenorth-website",
|
||||
"version": "0.6.6",
|
||||
"version": "0.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "queuenorth-website",
|
||||
"version": "0.6.6",
|
||||
"version": "0.7.0",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.0",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.4",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"better-sqlite3": "^11.8.0",
|
||||
"cors": "^2.8.6",
|
||||
"express": "^4.21.2",
|
||||
|
|
@ -1646,32 +1645,6 @@
|
|||
"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": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@
|
|||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.0",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.4",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"better-sqlite3": "^11.8.0",
|
||||
"cors": "^2.8.6",
|
||||
"express": "^4.21.2",
|
||||
|
|
|
|||
|
|
@ -74,6 +74,10 @@ const cspDirectives = {
|
|||
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({
|
||||
contentSecurityPolicy: {
|
||||
directives: cspDirectives,
|
||||
|
|
@ -95,6 +99,16 @@ app.use(helmet({
|
|||
|
||||
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 ---
|
||||
const corsOrigin = process.env.CORS_ORIGIN || 'https://queuenorth.com' // Default to production domain
|
||||
const corsConfig = cors({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,36 @@
|
|||
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 } = {}) => {
|
||||
let lastError
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
|
|
|
|||
15
src/main.jsx
15
src/main.jsx
|
|
@ -1,28 +1,17 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { RouterProvider } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { Toaster } from 'sonner'
|
||||
import { HelmetProvider } from 'react-helmet-async'
|
||||
import router from './router.jsx'
|
||||
import App from './App.jsx'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Wrap the router with providers
|
||||
const Root = () => (
|
||||
<StrictMode>
|
||||
<HelmetProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster position="top-right" />
|
||||
</QueryClientProvider>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster position="top-right" />
|
||||
</HelmetProvider>
|
||||
</StrictMode>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
import { Helmet } from 'react-helmet-async'
|
||||
import { useState } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '@/lib/api'
|
||||
import { submitLead } from '@/lib/api'
|
||||
import { useDebounce } from '@/hooks/useDebounce'
|
||||
|
||||
const Contact = () => {
|
||||
|
|
@ -29,36 +28,7 @@ const Contact = () => {
|
|||
})
|
||||
// Debounce validation errors so they don't flash on every keystroke
|
||||
const debouncedErrors = useDebounce(errors, 300)
|
||||
|
||||
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 [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors = {
|
||||
|
|
@ -95,7 +65,39 @@ const Contact = () => {
|
|||
const handleSubmit = (e) => {
|
||||
e.preventDefault()
|
||||
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) => {
|
||||
|
|
@ -206,7 +208,7 @@ const Contact = () => {
|
|||
|
||||
{/* Right - Form */}
|
||||
<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>
|
||||
<label htmlFor="company" className="block text-sm font-medium text-text mb-2">
|
||||
Company Name <span className="text-red-600">*</span>
|
||||
|
|
@ -346,9 +348,9 @@ const Contact = () => {
|
|||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={mutation.isPending}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{mutation.isPending ? 'Submitting...' : 'Request Consultation'}
|
||||
{isSubmitting ? 'Submitting...' : 'Request Consultation'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
import { Helmet } from 'react-helmet-async'
|
||||
import { useState } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '@/lib/api'
|
||||
import { submitSupport } from '@/lib/api'
|
||||
import { useDebounce } from '@/hooks/useDebounce'
|
||||
|
||||
const Support = () => {
|
||||
|
|
@ -28,30 +27,7 @@ const Support = () => {
|
|||
})
|
||||
// Debounce validation errors so they don't flash on every keystroke
|
||||
const debouncedErrors = useDebounce(errors, 300)
|
||||
|
||||
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 [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors = {
|
||||
|
|
@ -93,7 +69,33 @@ const Support = () => {
|
|||
const handleSubmit = (e) => {
|
||||
e.preventDefault()
|
||||
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) => {
|
||||
|
|
@ -232,7 +234,7 @@ const Support = () => {
|
|||
|
||||
{/* Right - Form */}
|
||||
<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>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-text mb-2">
|
||||
Name <span className="text-red-600">*</span>
|
||||
|
|
@ -353,9 +355,9 @@ const Support = () => {
|
|||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={mutation.isPending}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{mutation.isPending ? 'Submitting...' : 'Submit Request'}
|
||||
{isSubmitting ? 'Submitting...' : 'Submit Request'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue