"use client" import { useEffect, useRef } from "react" import { cn } from "@/lib/utils" declare global { interface Window { turnstile?: { render: (el: HTMLElement, opts: Record) => string remove: (id: string) => void reset: (id?: string) => void } } } const SCRIPT_ID = "cf-turnstile-script" const SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" /** * Cloudflare Turnstile widget (explicit render). * * Renders inside the enclosing
; on success Turnstile injects a hidden * that is submitted with the form and * verified server-side by verifyTurnstile(). Renders nothing when the public * site key is not configured, so the forms still work without Turnstile. */ export function TurnstileWidget({ className }: { className?: string }) { const siteKey = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY const containerRef = useRef(null) const widgetIdRef = useRef(null) useEffect(() => { if (!siteKey) return let cancelled = false const render = () => { if (cancelled || widgetIdRef.current || !containerRef.current || !window.turnstile) return widgetIdRef.current = window.turnstile.render(containerRef.current, { sitekey: siteKey, theme: "dark", // A Turnstile token is only valid for ~5 minutes. Without these the // widget goes quietly stale on a form left open, and the submit fails // server-side with "complete the verification challenge" even though // the challenge visibly passed. Re-running it keeps the hidden // cf-turnstile-response input fresh. "refresh-expired": "auto", "expired-callback": () => { if (widgetIdRef.current) window.turnstile?.reset(widgetIdRef.current) }, "timeout-callback": () => { if (widgetIdRef.current) window.turnstile?.reset(widgetIdRef.current) }, "error-callback": () => { // Returning false lets Turnstile surface its own error UI rather than // leaving an empty box the user can't act on. return false }, }) } if (!document.getElementById(SCRIPT_ID)) { const script = document.createElement("script") script.id = SCRIPT_ID script.src = SCRIPT_SRC script.async = true script.defer = true script.onload = render document.head.appendChild(script) } else { render() } // Fallback: the script may already be cached/loaded so onload won't fire. const poll = window.setInterval(() => { if (window.turnstile) { render() window.clearInterval(poll) } }, 150) return () => { cancelled = true window.clearInterval(poll) if (widgetIdRef.current && window.turnstile) { try { window.turnstile.remove(widgetIdRef.current) } catch { // widget already removed } widgetIdRef.current = null } } }, [siteKey]) if (!siteKey) return null return
}