Contact.jsx (line 23): added ZIP to validation/error state, rendered it as required, and shows ZIP code is required.

server/index.js (line 253): backend Zod schema now rejects missing or blank ZIP.
api.js (line 3): preserves backend field errors for display.
This commit is contained in:
null 2026-05-25 18:17:16 -05:00
parent 529cce7ec0
commit 09926fed6d
3 changed files with 47 additions and 21 deletions

View File

@ -255,7 +255,7 @@ const leadSchema = z.object({
name: z.string().min(1, 'Name is required').trim().max(100, 'Name must be 100 characters or less'), name: z.string().min(1, 'Name is required').trim().max(100, 'Name must be 100 characters or less'),
email: z.string().email('Valid email is required').trim().max(254, 'Email must be 254 characters or less'), email: z.string().email('Valid email is required').trim().max(254, 'Email must be 254 characters or less'),
phone: z.string().trim().max(50, 'Phone must be 50 characters or less').optional().or(z.literal('').transform(() => undefined)), phone: z.string().trim().max(50, 'Phone must be 50 characters or less').optional().or(z.literal('').transform(() => undefined)),
zip: z.string().trim().max(10, 'ZIP code must be 10 characters or less').optional().or(z.literal('').transform(() => undefined)), zip: z.string({ required_error: 'ZIP code is required' }).trim().min(1, 'ZIP code is required').max(10, 'ZIP code must be 10 characters or less'),
message: z.string().trim().max(5000, 'Message must be 5000 characters or less').optional().or(z.literal('').transform(() => undefined)), message: z.string().trim().max(5000, 'Message must be 5000 characters or less').optional().or(z.literal('').transform(() => undefined)),
service_interest: z.string().trim().max(50, 'Service interest must be 50 characters or less').optional().or(z.literal('').transform(() => undefined)), service_interest: z.string().trim().max(50, 'Service interest must be 50 characters or less').optional().or(z.literal('').transform(() => undefined)),
company_website: z.string().optional(), // Honeypot field - bots fill this, humans don't see it company_website: z.string().optional(), // Honeypot field - bots fill this, humans don't see it

View File

@ -10,6 +10,7 @@ export async function submitLead(data) {
const errorData = await response.json().catch(() => ({})) const errorData = await response.json().catch(() => ({}))
const error = new Error(errorData.error || `API error: ${response.status}`) const error = new Error(errorData.error || `API error: ${response.status}`)
error.response = { status: response.status } error.response = { status: response.status }
error.fields = errorData.fields
throw error throw error
} }
return response.json() return response.json()

View File

@ -23,15 +23,17 @@ const Contact = () => {
company: '', company: '',
name: '', name: '',
email: '', email: '',
zip: '',
message: '', message: '',
}) })
const debouncedErrors = useDebounce(errors, 300) const debouncedErrors = useDebounce(errors, 300)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const validateForm = () => { const validateForm = () => {
const newErrors = { company: '', name: '', email: '', message: '' } const newErrors = { company: '', name: '', email: '', zip: '', message: '' }
if (!formState.company.trim()) newErrors.company = 'Company name is required' if (!formState.company.trim()) newErrors.company = 'Company name is required'
if (!formState.name.trim()) newErrors.name = 'Name is required' if (!formState.name.trim()) newErrors.name = 'Name is required'
if (!formState.zip.trim()) newErrors.zip = 'ZIP code is required'
if (!formState.message.trim()) newErrors.message = 'Message is required' if (!formState.message.trim()) newErrors.message = 'Message is required'
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!formState.email.trim()) { if (!formState.email.trim()) {
@ -59,11 +61,14 @@ const Contact = () => {
try { try {
await submitLead(formState) await submitLead(formState)
toast.success("Thanks! We'll be in touch shortly.") toast.success("Thanks! We'll be in touch shortly.")
setFormState({ company: '', name: '', email: '', phone: '', zip: '', message: '', service_interest: '' }) setFormState({ company: '', name: '', email: '', phone: '', zip: '', message: '', service_interest: '', company_website: '' })
setErrors({ company: '', name: '', email: '', message: '' }) setErrors({ company: '', name: '', email: '', zip: '', message: '' })
} catch (error) { } catch (error) {
if (error.response?.status === 409) { if (error.response?.status === 409) {
toast.success("We already have your submission! We'll be in touch.") toast.success("We already have your submission! We'll be in touch.")
} else if (error.response?.status === 400 && error.fields) {
setErrors(prev => ({ ...prev, ...error.fields }))
toast.error('Please fix the errors in the form')
} else { } else {
toast.error(error.message || 'Failed to submit form. Please try again.') toast.error(error.message || 'Failed to submit form. Please try again.')
} }
@ -247,7 +252,7 @@ const Contact = () => {
</div> </div>
</div> </div>
{/* Phone + Service Interest */} {/* Phone + ZIP */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label htmlFor="phone" className="block text-sm font-medium text-text mb-1.5"> <label htmlFor="phone" className="block text-sm font-medium text-text mb-1.5">
@ -262,6 +267,27 @@ const Contact = () => {
placeholder="(555) 123-4567" placeholder="(555) 123-4567"
/> />
</div> </div>
<div>
<label htmlFor="zip" className="block text-sm font-medium text-text mb-1.5">
ZIP Code <span className="text-red-500">*</span>
</label>
<Input
type="text"
id="zip"
name="zip"
value={formState.zip}
onChange={handleChange}
required
autoComplete="postal-code"
inputMode="numeric"
placeholder="33702"
className={debouncedErrors.zip ? 'border-red-500 focus-visible:ring-red-500' : ''}
/>
{debouncedErrors.zip && <p className="text-xs text-red-500 mt-1">{debouncedErrors.zip}</p>}
</div>
</div>
{/* Service Interest */}
<div> <div>
<label htmlFor="service_interest" className="block text-sm font-medium text-text mb-1.5"> <label htmlFor="service_interest" className="block text-sm font-medium text-text mb-1.5">
Service Interest <span className="text-soft-text font-normal">(optional)</span> Service Interest <span className="text-soft-text font-normal">(optional)</span>
@ -282,7 +308,6 @@ const Contact = () => {
<option value="local-networking">Local Networking</option> <option value="local-networking">Local Networking</option>
</Select> </Select>
</div> </div>
</div>
{/* Message */} {/* Message */}
<div> <div>