Skip to content
File

Blob: src/client/components/turnstile-widget.tsx

typescript84 lines
1import { Turnstile, type TurnstileInstance } from "@marsidev/react-turnstile";
2import { useCallback, useEffect, useRef, useState } from "react";
3 
4interface TurnstileWidgetProps {
5 siteKey: string;
6 action: string;
7 onTokenChange: (token: string | null) => void;
8 onUnavailable?: () => void;
9 resetKey?: number;
10}
11 
12// Fallback window for the Turnstile script + widget to mount and call
13// onWidgetLoad. If we never hear from the widget within this window we
14// surface unavailable so the parent form can show an actionable error
15// instead of an indefinite "Loading verification..." spinner.
16const LOAD_TIMEOUT_MS = 10_000;
17 
18const TURNSTILE_OPTIONS = {
19 appearance: "always" as const,
20 refreshExpired: "auto" as const,
21 refreshTimeout: "auto" as const,
22 responseField: false,
23 size: "flexible" as const,
24 theme: "dark" as const,
25};
26 
27export const TurnstileWidget = ({ siteKey, action, onTokenChange, onUnavailable, resetKey }: TurnstileWidgetProps) => {
28 const turnstileRef = useRef<TurnstileInstance>(null);
29 const onTokenChangeRef = useRef(onTokenChange);
30 const onUnavailableRef = useRef(onUnavailable);
31 
32 useEffect(() => {
33 onTokenChangeRef.current = onTokenChange;
34 onUnavailableRef.current = onUnavailable;
35 });
36 const [loaded, setLoaded] = useState(false);
37 
38 const handleUnavailable = useCallback(() => {
39 onTokenChangeRef.current(null);
40 onUnavailableRef.current?.();
41 }, []);
42 
43 const handleTokenCleared = useCallback(() => {
44 onTokenChangeRef.current(null);
45 }, []);
46 
47 const handleWidgetLoad = useCallback(() => {
48 setLoaded(true);
49 }, []);
50 
51 // Don't pass scriptOptions.onError: @marsidev/react-turnstile can delete
52 // window.onloadTurnstileCallback when an onError handler is provided,
53 // racing Cloudflare api.js and producing a missing-callback console
54 // error. The load-fallback timeout below covers the same
55 // script-failed-to-load case at a UX level.
56 useEffect(() => {
57 if (loaded) return;
58 const timer = window.setTimeout(() => {
59 handleUnavailable();
60 }, LOAD_TIMEOUT_MS);
61 return () => window.clearTimeout(timer);
62 }, [loaded, handleUnavailable]);
63 
64 useEffect(() => {
65 onTokenChangeRef.current(null);
66 turnstileRef.current?.reset();
67 }, [resetKey]);
68 
69 return (
70 <Turnstile
71 id={`turnstile-${action}`}
72 ref={turnstileRef}
73 siteKey={siteKey}
74 options={{ ...TURNSTILE_OPTIONS, action }}
75 onWidgetLoad={handleWidgetLoad}
76 onSuccess={onTokenChange}
77 onExpire={handleTokenCleared}
78 onError={handleTokenCleared}
79 onTimeout={handleTokenCleared}
80 onUnsupported={handleUnavailable}
81 />
82 );
83};