Contact form update

This commit is contained in:
Greg Kilmer
2026-07-07 21:58:37 -04:00
parent 738f978120
commit e7c5e9d66e
4 changed files with 176 additions and 44 deletions
+56 -23
View File
@@ -47,7 +47,6 @@ 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>('');
@@ -56,48 +55,81 @@ export default function ContactForm({ turnstileSiteKey }: Props) {
// Container div the widget mounts into.
const turnstileContainerRef = useRef<HTMLDivElement | null>(null);
// Load the Turnstile script once when a site key is configured.
// Load the Turnstile script once when a site key is configured, then render
// the widget once it's ready. Two effects coordinate via a script onload
// callback + a readiness poll, because the script loads async and
// window.turnstile isn't defined the moment the script tag is inserted.
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);
// If the script is already present (e.g. HMR / re-mount), just render.
if (window.turnstile) {
renderWidget();
return;
}
// Avoid injecting a second script tag on re-mount.
let script = document.getElementById(
'cf-turnstile-script',
) as HTMLScriptElement | null;
if (!script) {
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);
}
// When the script finishes loading, render the widget. onload fires only
// once even if multiple components share the same script tag.
script.addEventListener('load', renderWidget);
return () => {
script?.removeEventListener('load', renderWidget);
};
}, [turnstileSiteKey]);
// Once the script + container are present, render the widget.
useEffect(() => {
function renderWidget() {
if (!turnstileSiteKey) return;
if (!window.turnstile || !turnstileContainerRef.current) return;
// Avoid double-rendering if the effect fires twice in StrictMode/dev.
// Avoid double-rendering if this fires twice (StrictMode / onload + poll).
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.
// Called by Turnstile once it has a token. For invisible/Managed mode
// this fires automatically once the challenge resolves.
callback: (token: string) => {
turnstileTokenRef.current = token;
setTurnstileReady(true);
},
// On expiry/error, clear the token so the server rejects the stale
// submission — but DON'T re-disable the button, because an invisible
// widget gives the user nothing to click to re-solve it. Instead we
// reset the widget, which re-runs the challenge and fires callback
// again with a fresh token.
// On token expiry: clear it. The next submit will detect the empty
// token and reset the widget for a fresh challenge. We don't auto-reset
// here to keep behavior simple and avoid any retry churn.
'expired-callback': () => {
turnstileTokenRef.current = '';
setTurnstileReady(false);
},
// On widget error: clear the token but DO NOT call turnstile.reset().
// Resetting from inside error-callback causes Turnstile to immediately
// retry, hit the same error (e.g. 110200 domain-not-authorized), and
// fire error-callback again — an infinite loop that manifests as the
// widget rapidly flashing. Instead we leave the widget in its errored
// state; the next form submission will reset it for a fresh attempt.
// The error surfaces as the token being empty, which onSubmit reports.
'error-callback': () => {
turnstileTokenRef.current = '';
setTurnstileReady(false);
},
theme: 'dark',
},
);
}, [turnstileSiteKey]);
}
function validate(values: { name: string; email: string; message: string }): FieldErrors {
const e: FieldErrors = {};
@@ -141,21 +173,22 @@ export default function ContactForm({ turnstileSiteKey }: Props) {
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
// Reset the Turnstile widget so the user can re-verify after a failure.
// Reset the Turnstile widget so a fresh challenge runs. The widget's
// callback will populate the token ref with a new value when it
// resolves; the button is never disabled on Turnstile state, so there's
// no UI flag to manage here.
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.
// Fresh challenge for the next submission.
if (turnstileSiteKey && turnstileWidgetIdRef.current) {
window.turnstile?.reset(turnstileWidgetIdRef.current);
turnstileTokenRef.current = '';
setTurnstileReady(false);
}
} catch (err) {
setStatus('error');
@@ -276,7 +309,7 @@ export default function ContactForm({ turnstileSiteKey }: Props) {
<button
type="submit"
disabled={status === 'submitting' || (Boolean(turnstileSiteKey) && !turnstileReady)}
disabled={status === 'submitting'}
className="btn btn-primary self-start disabled:cursor-not-allowed disabled:opacity-60"
>
{status === 'submitting' ? 'Sending…' : 'Send message'}