mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
Contact form update
This commit is contained in:
@@ -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'}
|
||||
|
||||
Reference in New Issue
Block a user