Form Update

This commit is contained in:
Greg Kilmer
2026-07-07 15:32:56 -04:00
parent cec1078d0e
commit 2e31aac9bf
9 changed files with 440 additions and 26 deletions
+102 -4
View File
@@ -4,12 +4,18 @@
* 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).
* crude spam defense (the server also rate-limits and verifies a Turnstile
* token when configured).
*
* The form is keyboard-accessible, labels every field, and announces status
* changes via an aria-live region.
*
* Turnstile: when `turnstileSiteKey` is provided, Cloudflare's invisible
* challenge is rendered. The token it produces is sent with the submission and
* verified server-side. If no key is configured (local dev), Turnstile is
* skipped entirely so the form still works.
*/
import { useState, type FormEvent } from 'react';
import { useState, useRef, useEffect, type FormEvent } from 'react';
type Status = 'idle' | 'submitting' | 'success' | 'error';
@@ -21,10 +27,77 @@ interface FieldErrors {
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export default function ContactForm() {
// Declared by the Turnstile script on window once loaded.
declare global {
interface Window {
turnstile?: {
render: (el: string | HTMLElement, opts: Record<string, unknown>) => string;
reset: (id?: string) => void;
remove: (id: string) => void;
};
}
}
interface Props {
/** Public Cloudflare Turnstile site key. Omit to skip Turnstile (e.g. dev). */
turnstileSiteKey?: string;
}
export default function ContactForm({ turnstileSiteKey }: Props) {
const [status, setStatus] = useState<Status>('idle');
const [errors, setErrors] = useState<FieldErrors>({});
const [errorMsg, setErrorMsg] = useState<string>('');
const [turnstileReady, setTurnstileReady] = useState<boolean>(!turnstileSiteKey);
// Holds the live Turnstile token between renders (cleared on reset/expiry).
const turnstileTokenRef = useRef<string>('');
// Widget ID returned by turnstile.render(); used to reset on failure.
const turnstileWidgetIdRef = useRef<string | null>(null);
// Container div the widget mounts into.
const turnstileContainerRef = useRef<HTMLDivElement | null>(null);
// Load the Turnstile script once when a site key is configured.
useEffect(() => {
if (!turnstileSiteKey) return;
if (document.getElementById('cf-turnstile-script')) return;
const script = document.createElement('script');
script.id = 'cf-turnstile-script';
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
script.async = true;
script.defer = true;
document.head.appendChild(script);
}, [turnstileSiteKey]);
// Once the script + container are present, render the widget.
useEffect(() => {
if (!turnstileSiteKey) return;
if (!window.turnstile || !turnstileContainerRef.current) return;
// Avoid double-rendering if the effect fires twice in StrictMode/dev.
if (turnstileWidgetIdRef.current) return;
turnstileWidgetIdRef.current = window.turnstile.render(
turnstileContainerRef.current,
{
sitekey: turnstileSiteKey,
// Called by Turnstile once it has a token (success) or on expiry.
callback: (token: string) => {
turnstileTokenRef.current = token;
setTurnstileReady(true);
},
'expired-callback': () => {
turnstileTokenRef.current = '';
setTurnstileReady(false);
},
'error-callback': () => {
turnstileTokenRef.current = '';
setTurnstileReady(false);
},
theme: 'dark',
},
);
}, [turnstileSiteKey]);
function validate(values: { name: string; email: string; message: string }): FieldErrors {
const e: FieldErrors = {};
@@ -46,9 +119,15 @@ export default function ContactForm() {
message: String(data.get('message') ?? '').trim(),
// Honeypot: bots fill this, humans never see it.
company: String(data.get('company') ?? ''),
// Turnstile token (empty string if Turnstile is not configured).
turnstileToken: turnstileTokenRef.current,
};
const found = validate(values);
// If Turnstile is configured, require a token before we even hit the server.
if (turnstileSiteKey && !values.turnstileToken) {
found.message = found.message ?? 'Please complete the bot check and try again.';
}
setErrors(found);
if (Object.keys(found).length > 0) return;
@@ -62,10 +141,22 @@ export default function ContactForm() {
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
// Reset the Turnstile widget so the user can re-verify after a failure.
if (turnstileSiteKey && turnstileWidgetIdRef.current) {
window.turnstile?.reset(turnstileWidgetIdRef.current);
turnstileTokenRef.current = '';
setTurnstileReady(false);
}
throw new Error(body.error || 'Something went wrong. Please try again.');
}
setStatus('success');
form.reset();
// Fresh widget for the next submission.
if (turnstileSiteKey && turnstileWidgetIdRef.current) {
window.turnstile?.reset(turnstileWidgetIdRef.current);
turnstileTokenRef.current = '';
setTurnstileReady(false);
}
} catch (err) {
setStatus('error');
setErrorMsg(err instanceof Error ? err.message : 'Something went wrong. Please try again.');
@@ -176,9 +267,16 @@ export default function ContactForm() {
</p>
)}
{/* Cloudflare Turnstile widget. Rendered by the script via the ref.
Hidden container; Turnstile injects its iframe here. Skipped entirely
when no site key is configured (turnstileSiteKey is undefined). */}
{turnstileSiteKey && (
<div ref={turnstileContainerRef} className="min-h-[65px]" aria-label="Bot check" />
)}
<button
type="submit"
disabled={status === 'submitting'}
disabled={status === 'submitting' || (Boolean(turnstileSiteKey) && !turnstileReady)}
className="btn btn-primary self-start disabled:cursor-not-allowed disabled:opacity-60"
>
{status === 'submitting' ? 'Sending…' : 'Send message'}