import { useCallback, useEffect, useRef, useState } from 'react'; import { environment } from '../environments/environment'; // The demo entry's Turnstile widget. With no site key for this stack it renders nothing and holds no token, so // entry stays closed. A challenge belongs to one entry opening and one load: a new role, mount or origin, or a // deliberate reload, replaces the widget, and nothing a replaced widget says reaches its replacement. A token is // single use, so an attempt takes it and the widget is asked for a fresh one at once. export const DEMO_TURNSTILE_ACTION = 'demo-lease'; const SCRIPT = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; export const DEMO_TURNSTILE_LOAD_MS = 15_000; interface TurnstileApi { render(container: HTMLElement, options: Record): string; reset(widgetId: string): void; remove(widgetId: string): void; } declare global { interface Window { turnstile?: TurnstileApi } } let loading: Promise | undefined; const loadTurnstile = (): Promise => { if (window.turnstile) return Promise.resolve(window.turnstile); if (!loading) { const script = document.createElement('script'); const attempt = new Promise((resolve, reject) => { const timer = window.setTimeout(() => reject(new Error('Turnstile did not load in time')), DEMO_TURNSTILE_LOAD_MS); script.src = SCRIPT; script.async = true; script.onload = () => { window.clearTimeout(timer); if (window.turnstile) resolve(window.turnstile); else reject(new Error('Turnstile loaded without its API')); }; script.onerror = () => { window.clearTimeout(timer); reject(new Error('Turnstile did not load')); }; document.head.appendChild(script); }); loading = attempt; // Only a pending load is shared. A loaded API is read from the window, and a failed load is forgotten, // script and all, so a deliberate retry fetches it again. attempt.then(() => { if (loading === attempt) loading = undefined; }, () => { if (loading === attempt) loading = undefined; script.remove(); }); } return loading; }; export type DemoTurnstileStatus = 'idle' | 'loading' | 'ready' | 'failed'; export interface DemoTurnstile { /** The current opening's unspent token, if its challenge has been passed. */ token: string | undefined; /** Spends the current token for an attempt and asks the widget for a fresh challenge. */ take: () => string | undefined; /** Spends the current token without using it. */ reset: () => void; /** Loads and renders the challenge again after it failed. */ retry: () => void; /** Where the widget renders. */ ref: (element: HTMLElement | null) => void; /** Whether this stack asks for a challenge at all. */ required: boolean; status: DemoTurnstileStatus; } type Held = { key: string; status: DemoTurnstileStatus; token?: string }; export const useDemoTurnstile = (opening: string, siteKey: string | undefined = environment.demoTurnstileSiteKey): DemoTurnstile => { const [container, setContainer] = useState(null); const [round, setRound] = useState(0); // Every change of opening is a new challenge, even a return to an earlier one. const seen = useRef({ opening, epoch: 0 }); if (seen.current.opening !== opening) seen.current = { opening, epoch: seen.current.epoch + 1 }; const key = `${siteKey ?? ''}|${opening}|${seen.current.epoch}|${round}`; const [held, setHeld] = useState({ key: '', status: 'idle' }); const widget = useRef<{ api: TurnstileApi; id: string; key: string } | undefined>(undefined); const active = !!siteKey && !!opening; useEffect(() => { if (!active || !container) return undefined; let live = true; const set = (status: DemoTurnstileStatus, token?: string) => { if (live) setHeld({ key, status, token }); }; set('loading'); loadTurnstile().then((api) => { if (!live) return; try { const id = api.render(container, { sitekey: siteKey, action: DEMO_TURNSTILE_ACTION, callback: (value: string) => set('ready', value), 'expired-callback': () => set('ready'), 'timeout-callback': () => set('ready'), 'error-callback': () => set('failed'), }); widget.current = { api, id, key }; setHeld((now) => (now.key === key && now.status === 'loading' ? { key, status: 'ready' } : now)); } catch { container.replaceChildren(); set('failed'); } }, () => set('failed')); return () => { live = false; const mine = widget.current; if (mine?.key === key) { widget.current = undefined; try { mine.api.remove(mine.id); } catch { /* already gone with its page */ } } container.replaceChildren(); }; }, [active, container, key, siteKey]); const current: Held = held.key === key ? held : { key, status: active ? 'loading' : 'idle' }; const reset = useCallback(() => { setHeld((now) => (now.key === key ? { ...now, token: undefined } : now)); const mine = widget.current; if (mine?.key === key) mine.api.reset(mine.id); }, [key]); const take = useCallback((): string | undefined => { const token = held.key === key ? held.token : undefined; if (token) reset(); return token; }, [held, key, reset]); const retry = useCallback(() => setRound((value) => value + 1), []); return { token: current.token, take, reset, retry, ref: setContainer, required: !!siteKey, status: current.status }; };