diff --git a/src/components/professional/ContactForm.tsx b/src/components/professional/ContactForm.tsx index 74a91e3..8bf068e 100644 --- a/src/components/professional/ContactForm.tsx +++ b/src/components/professional/ContactForm.tsx @@ -47,7 +47,6 @@ export default function ContactForm({ turnstileSiteKey }: Props) { const [status, setStatus] = useState('idle'); const [errors, setErrors] = useState({}); const [errorMsg, setErrorMsg] = useState(''); - const [turnstileReady, setTurnstileReady] = useState(!turnstileSiteKey); // Holds the live Turnstile token between renders (cleared on reset/expiry). const turnstileTokenRef = useRef(''); @@ -56,48 +55,81 @@ export default function ContactForm({ turnstileSiteKey }: Props) { // Container div the widget mounts into. const turnstileContainerRef = useRef(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) {