|
1 | 1 | import { useRouteLoaderData } from "react-router"; |
| 2 | +import { useEffect, useRef } from "react"; |
2 | 3 |
|
3 | 4 | type RootData = { |
4 | 5 | publicConfig?: { |
5 | 6 | turnstileSiteKey?: string; |
6 | 7 | }; |
7 | 8 | }; |
8 | 9 |
|
| 10 | +declare global { |
| 11 | + interface Window { |
| 12 | + turnstile?: { |
| 13 | + render: (element: HTMLElement, options: Record<string, unknown>) => string; |
| 14 | + remove: (widgetId: string) => void; |
| 15 | + }; |
| 16 | + } |
| 17 | +} |
| 18 | + |
| 19 | +let turnstileScriptPromise: Promise<void> | null = null; |
| 20 | +let currentToken = ""; |
| 21 | + |
| 22 | +function loadTurnstileScript() { |
| 23 | + if (typeof window === "undefined") return Promise.resolve(); |
| 24 | + if (window.turnstile) return Promise.resolve(); |
| 25 | + if (turnstileScriptPromise) return turnstileScriptPromise; |
| 26 | + |
| 27 | + turnstileScriptPromise = new Promise((resolve, reject) => { |
| 28 | + const existing = document.querySelector<HTMLScriptElement>('script[data-turnstile-api="true"]'); |
| 29 | + if (existing) { |
| 30 | + existing.addEventListener("load", () => resolve(), { once: true }); |
| 31 | + existing.addEventListener("error", () => reject(new Error("Turnstile script failed to load")), { once: true }); |
| 32 | + return; |
| 33 | + } |
| 34 | + |
| 35 | + const script = document.createElement("script"); |
| 36 | + script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"; |
| 37 | + script.async = true; |
| 38 | + script.defer = true; |
| 39 | + script.dataset.turnstileApi = "true"; |
| 40 | + script.addEventListener("load", () => resolve(), { once: true }); |
| 41 | + script.addEventListener("error", () => reject(new Error("Turnstile script failed to load")), { once: true }); |
| 42 | + document.head.appendChild(script); |
| 43 | + }); |
| 44 | + return turnstileScriptPromise; |
| 45 | +} |
| 46 | + |
9 | 47 | export function TurnstileWidget() { |
10 | 48 | const data = useRouteLoaderData("root") as RootData | undefined; |
11 | 49 | const siteKey = data?.publicConfig?.turnstileSiteKey; |
| 50 | + const containerRef = useRef<HTMLDivElement | null>(null); |
| 51 | + |
| 52 | + useEffect(() => { |
| 53 | + if (!siteKey || !containerRef.current) return; |
| 54 | + let cancelled = false; |
| 55 | + let widgetId: string | null = null; |
| 56 | + |
| 57 | + loadTurnstileScript() |
| 58 | + .then(() => { |
| 59 | + if (cancelled || !window.turnstile || !containerRef.current) return; |
| 60 | + currentToken = ""; |
| 61 | + widgetId = window.turnstile.render(containerRef.current, { |
| 62 | + sitekey: siteKey, |
| 63 | + action: "turnstile-spin-v2", |
| 64 | + callback: (token: string) => { |
| 65 | + currentToken = token; |
| 66 | + }, |
| 67 | + "expired-callback": () => { |
| 68 | + currentToken = ""; |
| 69 | + }, |
| 70 | + "error-callback": () => { |
| 71 | + currentToken = ""; |
| 72 | + }, |
| 73 | + }); |
| 74 | + }) |
| 75 | + .catch(() => { |
| 76 | + currentToken = ""; |
| 77 | + }); |
| 78 | + |
| 79 | + return () => { |
| 80 | + cancelled = true; |
| 81 | + currentToken = ""; |
| 82 | + if (widgetId && window.turnstile) window.turnstile.remove(widgetId); |
| 83 | + }; |
| 84 | + }, [siteKey]); |
| 85 | + |
12 | 86 | if (!siteKey) return null; |
13 | | - return <div className="cf-turnstile" data-sitekey={siteKey} data-action="turnstile-spin-v2" />; |
| 87 | + return <div ref={containerRef} />; |
14 | 88 | } |
15 | 89 |
|
16 | 90 | export function getTurnstileToken() { |
17 | 91 | if (typeof document === "undefined") return ""; |
18 | | - return (document.querySelector('[name="cf-turnstile-response"]') as HTMLInputElement | null)?.value || ""; |
| 92 | + return currentToken || (document.querySelector('[name="cf-turnstile-response"]') as HTMLInputElement | null)?.value || ""; |
19 | 93 | } |
0 commit comments