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">
@ -263,27 +268,47 @@ const Contact = () => {
/> />
</div> </div>
<div> <div>
<label htmlFor="service_interest" className="block text-sm font-medium text-text mb-1.5"> <label htmlFor="zip" className="block text-sm font-medium text-text mb-1.5">
Service Interest <span className="text-soft-text font-normal">(optional)</span> ZIP Code <span className="text-red-500">*</span>
</label> </label>
<Select <Input
id="service_interest" type="text"
name="service_interest" id="zip"
value={formState.service_interest} name="zip"
value={formState.zip}
onChange={handleChange} onChange={handleChange}
> required
<option value="">Select a service...</option> autoComplete="postal-code"
<option value="unified-communications">Unified Communications</option> inputMode="numeric"
<option value="contact-center">Contact Center</option> placeholder="33702"
<option value="managed-support">Managed Support</option> className={debouncedErrors.zip ? 'border-red-500 focus-visible:ring-red-500' : ''}
<option value="consulting-training">Consulting & Training</option> />
<option value="infrastructure-cabling">Infrastructure Cabling</option> {debouncedErrors.zip && <p className="text-xs text-red-500 mt-1">{debouncedErrors.zip}</p>}
<option value="wireless-access">Wireless Access</option>
<option value="local-networking">Local Networking</option>
</Select>
</div> </div>
</div> </div>
{/* Service Interest */}
<div>
<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>
</label>
<Select
id="service_interest"
name="service_interest"
value={formState.service_interest}
onChange={handleChange}
>
<option value="">Select a service...</option>
<option value="unified-communications">Unified Communications</option>
<option value="contact-center">Contact Center</option>
<option value="managed-support">Managed Support</option>
<option value="consulting-training">Consulting & Training</option>
<option value="infrastructure-cabling">Infrastructure Cabling</option>
<option value="wireless-access">Wireless Access</option>
<option value="local-networking">Local Networking</option>
</Select>
</div>
{/* Message */} {/* Message */}
<div> <div>
<label htmlFor="message" className="block text-sm font-medium text-text mb-1.5"> <label htmlFor="message" className="block text-sm font-medium text-text mb-1.5">