fix: remove React Query, add HTTPS redirect, document CSP Zoho note (#128 #127 #129) (batch 10.0)

This commit is contained in:
null 2026-05-17 22:33:11 -05:00
parent 95917bc699
commit 2c002c2f82
7 changed files with 119 additions and 110 deletions

31
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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({

View File

@ -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++) {

View File

@ -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>
) )

View File

@ -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>

View File

@ -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>