/** * ContactForm.tsx * --------------------------------------------------------------------------- * Accessible contact form island. Posts to /api/contact, which either sends an * email via SMTP or fires a webhook. Manages idle / submitting / success / error * states, does lightweight client validation, and includes a honeypot field for * crude spam defense (the server also rate-limits). * * The form is keyboard-accessible, labels every field, and announces status * changes via an aria-live region. */ import { useState, type FormEvent } from 'react'; type Status = 'idle' | 'submitting' | 'success' | 'error'; interface FieldErrors { name?: string; email?: string; message?: string; } const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; export default function ContactForm() { const [status, setStatus] = useState('idle'); const [errors, setErrors] = useState({}); const [errorMsg, setErrorMsg] = useState(''); function validate(values: { name: string; email: string; message: string }): FieldErrors { const e: FieldErrors = {}; if (!values.name.trim()) e.name = 'Please enter your name.'; if (!values.email.trim()) e.email = 'Please enter your email.'; else if (!EMAIL_RE.test(values.email)) e.email = 'That email address looks off.'; if (!values.message.trim()) e.message = 'Please enter a message.'; else if (values.message.trim().length < 10) e.message = 'A little more detail, please (10+ characters).'; return e; } async function onSubmit(ev: FormEvent) { ev.preventDefault(); const form = ev.currentTarget; const data = new FormData(form); const values = { name: String(data.get('name') ?? '').trim(), email: String(data.get('email') ?? '').trim(), message: String(data.get('message') ?? '').trim(), // Honeypot: bots fill this, humans never see it. company: String(data.get('company') ?? ''), }; const found = validate(values); setErrors(found); if (Object.keys(found).length > 0) return; setStatus('submitting'); setErrorMsg(''); try { const res = await fetch('/api/contact', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values), }); const body = await res.json().catch(() => ({})); if (!res.ok) { throw new Error(body.error || 'Something went wrong. Please try again.'); } setStatus('success'); form.reset(); } catch (err) { setStatus('error'); setErrorMsg(err instanceof Error ? err.message : 'Something went wrong. Please try again.'); } } const inputBase = 'w-full rounded-lg border bg-transparent px-3 py-2.5 text-sm outline-none transition-colors focus:border-[var(--color-accent)]'; const labelBase = 'mb-1.5 block font-sans text-sm font-medium'; if (status === 'success') { return (

Message sent.

Thanks for reaching out. I will get back to you soon.

); } return (
{/* Honeypot: visually hidden, not aria-hidden so screen readers can skip it. */}
{errors.name &&

{errors.name}

}
{errors.email &&

{errors.email}

}