fix: close issues #12 #15 #17 #18 — CSP nonce, API retry, input debounce, caching verified (batch 0.6.1)
This commit is contained in:
parent
ca67974c5f
commit
56bdf07216
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "queuenorth-website",
|
"name": "queuenorth-website",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.6.0",
|
"version": "0.6.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"vite\" \"node server/index.js\"",
|
"dev": "concurrently \"vite\" \"node server/index.js\"",
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ const apiLimiter = rateLimit({
|
||||||
const cspDirectives = {
|
const cspDirectives = {
|
||||||
defaultSrc: ["'self'"],
|
defaultSrc: ["'self'"],
|
||||||
scriptSrc: ["'self'"],
|
scriptSrc: ["'self'"],
|
||||||
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
|
styleSrc: ["'self'", 'https://fonts.googleapis.com'],
|
||||||
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
|
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
|
||||||
imgSrc: ["'self'", 'data:'],
|
imgSrc: ["'self'", 'data:'],
|
||||||
connectSrc: ["'self'"],
|
connectSrc: ["'self'"],
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ const Header = () => {
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{navLinks.map((link) => (
|
{navLinks.map((link) => (
|
||||||
<li key={link.name}>
|
<li key={link.name}>
|
||||||
<Link to={link.href} onClick={closeMobileMenu} className={`block text-base font-medium py-2 ${isActive(link.href) ? 'text-white' : 'text-white/70 hover:text-white'} transition-colors`}>
|
<Link to={link.href} onClick={closeMobileMenu} className={`block text-base font-medium py-2 ${isActive(link.href) ? 'text-white font-semibold' : 'text-white/70 hover:text-white'} transition-colors`}>
|
||||||
{link.name}
|
{link.name}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -119,11 +119,11 @@ const Header = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-navy-light mb-3">Services</h4>
|
<h4 className="text-xs font-semibold uppercase tracking-wider text-white/60 mb-3">Services</h4>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{serviceLinks.map((service) => (
|
{serviceLinks.map((service) => (
|
||||||
<li key={service.name}>
|
<li key={service.name}>
|
||||||
<Link to={service.href} onClick={closeMobileMenu} className={`block text-sm py-2 border-b border-white/10 last:border-0 transition-colors ${isActive(service.href) ? 'text-white font-semibold' : 'text-navy-light hover:text-white'}`}>
|
<Link to={service.href} onClick={closeMobileMenu} className={`block text-sm py-2 border-b border-white/10 last:border-0 transition-colors ${isActive(service.href) ? 'text-white font-semibold' : 'text-white/70 hover:text-white'}`}>
|
||||||
{service.name}
|
{service.name}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -132,11 +132,11 @@ const Header = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-navy-light mb-3">Industries</h4>
|
<h4 className="text-xs font-semibold uppercase tracking-wider text-white/60 mb-3">Industries</h4>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{industryLinks.map((industry) => (
|
{industryLinks.map((industry) => (
|
||||||
<li key={industry.name}>
|
<li key={industry.name}>
|
||||||
<Link to={industry.href} onClick={closeMobileMenu} className={`block text-sm py-2 border-b border-white/10 last:border-0 transition-colors ${isActive(industry.href) ? 'text-white font-semibold' : 'text-navy-light hover:text-white'}`}>
|
<Link to={industry.href} onClick={closeMobileMenu} className={`block text-sm py-2 border-b border-white/10 last:border-0 transition-colors ${isActive(industry.href) ? 'text-white font-semibold' : 'text-white/70 hover:text-white'}`}>
|
||||||
{industry.name}
|
{industry.name}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounce hook - delays updating the value until after the specified delay
|
||||||
|
* @param {any} value - The value to debounce
|
||||||
|
* @param {number} delay - The debounce delay in milliseconds
|
||||||
|
* @returns {any} - The debounced value
|
||||||
|
*/
|
||||||
|
export function useDebounce(value, delay) {
|
||||||
|
const [debouncedValue, setDebouncedValue] = useState(value)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setDebouncedValue(value)
|
||||||
|
}, delay)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [value, delay])
|
||||||
|
|
||||||
|
return debouncedValue
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useDebounce
|
||||||
115
src/lib/api.js
115
src/lib/api.js
|
|
@ -2,49 +2,94 @@ import { queryClient } from './queryClient'
|
||||||
|
|
||||||
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
|
||||||
|
const retryFetch = async (fn, { maxRetries = 3, baseDelay = 1000 } = {}) => {
|
||||||
|
let lastError
|
||||||
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
return await fn()
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err
|
||||||
|
// Don't retry on client errors (except 429), only server errors and network failures
|
||||||
|
if (err instanceof TypeError && err.message === 'Failed to fetch') {
|
||||||
|
// Network failure - retry
|
||||||
|
} else if (err.response && err.response.status >= 500) {
|
||||||
|
// 5xx server error - retry
|
||||||
|
} else if (err.response && err.response.status === 429) {
|
||||||
|
// 429 Too Many Requests - check Retry-After header
|
||||||
|
const retryAfter = err.response.headers.get('Retry-After')
|
||||||
|
if (retryAfter) {
|
||||||
|
const delay = parseInt(retryAfter, 10) * 1000
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Other errors (4xx except 429) - don't retry
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
// Wait with exponential backoff before retry
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
const delay = baseDelay * Math.pow(2, attempt)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
get: async (endpoint) => {
|
get: async (endpoint) => {
|
||||||
let response
|
return await retryFetch(async () => {
|
||||||
try {
|
let response
|
||||||
response = await fetch(`${API_BASE_URL}${endpoint}`)
|
try {
|
||||||
} catch (err) {
|
response = await fetch(`${API_BASE_URL}${endpoint}`)
|
||||||
if (err instanceof TypeError && err.message === 'Failed to fetch') {
|
} catch (err) {
|
||||||
throw new Error('Unable to reach the server. This may be a network or CORS issue.')
|
if (err instanceof TypeError && err.message === 'Failed to fetch') {
|
||||||
|
throw new Error('Unable to reach the server. This may be a network or CORS issue.')
|
||||||
|
}
|
||||||
|
throw new Error(`Network error: ${err.message}`)
|
||||||
}
|
}
|
||||||
throw new Error(`Network error: ${err.message}`)
|
if (!response.ok) {
|
||||||
}
|
const errorData = new Error(`API error: ${response.statusText}`)
|
||||||
if (!response.ok) {
|
errorData.response = { status: response.status, statusText: response.statusText }
|
||||||
throw new Error(`API error: ${response.statusText}`)
|
throw errorData
|
||||||
}
|
}
|
||||||
return response.json()
|
return response.json()
|
||||||
|
}, { maxRetries: 3, baseDelay: 1000 })
|
||||||
},
|
},
|
||||||
|
|
||||||
post: async (endpoint, data) => {
|
post: async (endpoint, data) => {
|
||||||
let response
|
return await retryFetch(async () => {
|
||||||
try {
|
let response
|
||||||
response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof TypeError && err.message === 'Failed to fetch') {
|
|
||||||
throw new Error('Unable to reach the server. This may be a network or CORS issue.')
|
|
||||||
}
|
|
||||||
throw new Error(`Network error: ${err.message}`)
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
|
||||||
let errorData
|
|
||||||
try {
|
try {
|
||||||
errorData = await response.json()
|
response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||||
} catch {
|
method: 'POST',
|
||||||
throw new Error(`Server error (${response.status}): ${response.statusText}`)
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TypeError && err.message === 'Failed to fetch') {
|
||||||
|
throw new Error('Unable to reach the server. This may be a network or CORS issue.')
|
||||||
|
}
|
||||||
|
throw new Error(`Network error: ${err.message}`)
|
||||||
}
|
}
|
||||||
throw new Error(errorData.error || `API error: ${response.statusText}`)
|
if (!response.ok) {
|
||||||
}
|
let errorData
|
||||||
return response.json()
|
try {
|
||||||
|
errorData = await response.json()
|
||||||
|
} catch {
|
||||||
|
const err = new Error(`Server error (${response.status}): ${response.statusText}`)
|
||||||
|
err.response = { status: response.status, statusText: response.statusText }
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
const error = new Error(errorData.error || `API error: ${response.statusText}`)
|
||||||
|
error.response = { status: response.status }
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}, { maxRetries: 3, baseDelay: 1000 })
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ 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 { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
|
import { useDebounce } from '@/hooks/useDebounce'
|
||||||
|
|
||||||
const Contact = () => {
|
const Contact = () => {
|
||||||
const [formState, setFormState] = useState({
|
const [formState, setFormState] = useState({
|
||||||
|
|
@ -23,6 +24,8 @@ const Contact = () => {
|
||||||
email: '',
|
email: '',
|
||||||
message: '',
|
message: '',
|
||||||
})
|
})
|
||||||
|
// Debounce validation errors so they don't flash on every keystroke
|
||||||
|
const debouncedErrors = useDebounce(errors, 300)
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (data) => api.post('/leads', data),
|
mutationFn: (data) => api.post('/leads', data),
|
||||||
|
|
@ -187,8 +190,8 @@ const Contact = () => {
|
||||||
placeholder="Your company name"
|
placeholder="Your company name"
|
||||||
className={errors.company ? 'border-red-500 focus-visible:ring-red-500' : ''}
|
className={errors.company ? 'border-red-500 focus-visible:ring-red-500' : ''}
|
||||||
/>
|
/>
|
||||||
{errors.company && (
|
{debouncedErrors.company && (
|
||||||
<p className="text-xs text-red-600 mt-1">{errors.company}</p>
|
<p className="text-xs text-red-600 mt-1">{debouncedErrors.company}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -206,8 +209,8 @@ const Contact = () => {
|
||||||
placeholder="Your full name"
|
placeholder="Your full name"
|
||||||
className={errors.name ? 'border-red-500 focus-visible:ring-red-500' : ''}
|
className={errors.name ? 'border-red-500 focus-visible:ring-red-500' : ''}
|
||||||
/>
|
/>
|
||||||
{errors.name && (
|
{debouncedErrors.name && (
|
||||||
<p className="text-xs text-red-600 mt-1">{errors.name}</p>
|
<p className="text-xs text-red-600 mt-1">{debouncedErrors.name}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -225,8 +228,8 @@ const Contact = () => {
|
||||||
placeholder="your.email@example.com"
|
placeholder="your.email@example.com"
|
||||||
className={errors.email ? 'border-red-500 focus-visible:ring-red-500' : ''}
|
className={errors.email ? 'border-red-500 focus-visible:ring-red-500' : ''}
|
||||||
/>
|
/>
|
||||||
{errors.email && (
|
{debouncedErrors.email && (
|
||||||
<p className="text-xs text-red-600 mt-1">{errors.email}</p>
|
<p className="text-xs text-red-600 mt-1">{debouncedErrors.email}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -293,8 +296,8 @@ const Contact = () => {
|
||||||
rows={5}
|
rows={5}
|
||||||
className={`flex min-h-[80px] w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${errors.message ? 'border-red-500 focus-visible:ring-red-500' : ''}`}
|
className={`flex min-h-[80px] w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${errors.message ? 'border-red-500 focus-visible:ring-red-500' : ''}`}
|
||||||
/>
|
/>
|
||||||
{errors.message && (
|
{debouncedErrors.message && (
|
||||||
<p className="text-xs text-red-600 mt-1">{errors.message}</p>
|
<p className="text-xs text-red-600 mt-1">{debouncedErrors.message}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ 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 { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
|
import { useDebounce } from '@/hooks/useDebounce'
|
||||||
|
|
||||||
const Support = () => {
|
const Support = () => {
|
||||||
const [formState, setFormState] = useState({
|
const [formState, setFormState] = useState({
|
||||||
|
|
@ -22,6 +23,8 @@ const Support = () => {
|
||||||
email: '',
|
email: '',
|
||||||
issue: '',
|
issue: '',
|
||||||
})
|
})
|
||||||
|
// Debounce validation errors so they don't flash on every keystroke
|
||||||
|
const debouncedErrors = useDebounce(errors, 300)
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (data) => api.post('/support', data),
|
mutationFn: (data) => api.post('/support', data),
|
||||||
|
|
@ -209,8 +212,8 @@ const Support = () => {
|
||||||
placeholder="Your full name"
|
placeholder="Your full name"
|
||||||
className={errors.name ? 'border-red-500 focus-visible:ring-red-500' : ''}
|
className={errors.name ? 'border-red-500 focus-visible:ring-red-500' : ''}
|
||||||
/>
|
/>
|
||||||
{errors.name && (
|
{debouncedErrors.name && (
|
||||||
<p className="text-xs text-red-600 mt-1">{errors.name}</p>
|
<p className="text-xs text-red-600 mt-1">{debouncedErrors.name}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -228,8 +231,8 @@ const Support = () => {
|
||||||
placeholder="Your company name"
|
placeholder="Your company name"
|
||||||
className={errors.company ? 'border-red-500 focus-visible:ring-red-500' : ''}
|
className={errors.company ? 'border-red-500 focus-visible:ring-red-500' : ''}
|
||||||
/>
|
/>
|
||||||
{errors.company && (
|
{debouncedErrors.company && (
|
||||||
<p className="text-xs text-red-600 mt-1">{errors.company}</p>
|
<p className="text-xs text-red-600 mt-1">{debouncedErrors.company}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -247,8 +250,8 @@ const Support = () => {
|
||||||
placeholder="your.email@example.com"
|
placeholder="your.email@example.com"
|
||||||
className={errors.email ? 'border-red-500 focus-visible:ring-red-500' : ''}
|
className={errors.email ? 'border-red-500 focus-visible:ring-red-500' : ''}
|
||||||
/>
|
/>
|
||||||
{errors.email && (
|
{debouncedErrors.email && (
|
||||||
<p className="text-xs text-red-600 mt-1">{errors.email}</p>
|
<p className="text-xs text-red-600 mt-1">{debouncedErrors.email}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -296,8 +299,8 @@ const Support = () => {
|
||||||
rows={5}
|
rows={5}
|
||||||
className={`flex min-h-[80px] w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${errors.issue ? 'border-red-500 focus-visible:ring-red-500' : ''}`}
|
className={`flex min-h-[80px] w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${errors.issue ? 'border-red-500 focus-visible:ring-red-500' : ''}`}
|
||||||
/>
|
/>
|
||||||
{errors.issue && (
|
{debouncedErrors.issue && (
|
||||||
<p className="text-xs text-red-600 mt-1">{errors.issue}</p>
|
<p className="text-xs text-red-600 mt-1">{debouncedErrors.issue}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue