'use client'; /** * Contact form (#702). Posts to /api/contact; see lib/contact.ts for what * happens to the message. * * Spam protection without a captcha, which would be another third party: a * honeypot field people never see, and `startedAt`, the time the form was * rendered, which the server compares against the submission. * * `startedAt` is the server's clock, handed down by the page. The check * subtracts it from the server's clock at submission, and a stamp from the * visitor's device made that a comparison between two clocks: a phone running * a few minutes fast looked like a bot posting before the form existed, and * its message was dropped behind a thank-you page. */ import { useState } from 'react'; import { useDictionary } from './I18nProvider'; interface Props { retentionDays: number; /** `Date.now()` on the server when the page was rendered. */ renderedAt: number; } type State = 'idle' | 'sending' | 'done'; export default function ContactForm({ retentionDays, renderedAt }: Props) { const t = useDictionary().contact; const [state, setState] = useState('idle'); const [error, setError] = useState(''); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); const data = new FormData(e.currentTarget); setError(''); setState('sending'); try { const res = await fetch('/api/contact', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: data.get('name'), email: data.get('email'), message: data.get('message'), website: data.get('website'), // A bot posting straight to the endpoint has no stamp; one filling // the form on load is faster than MIN_FILL_MS. startedAt: renderedAt, }), }); if (res.ok) { setState('done'); return; } setError(res.status === 400 ? t.invalid : res.status === 429 ? t.tooMany : t.failed); } catch { setError(t.failed); } setState('idle'); } if (state === 'done') { return (

{t.successTitle}

{t.successText}

); } const sending = state === 'sending'; return (