Skip to content
File

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

typescript237 lines
1import { useEffect, useRef, useState } from "react";
2import { Loader2, RefreshCw } from "lucide-react";
3import { getErrorMessage, getPublicConfig } from "@/client/lib/api";
4 
5interface TurnstileRenderOptions {
6 action: string;
7 appearance?: "always" | "execute" | "interaction-only";
8 callback: (token: string) => void;
9 "error-callback": () => void;
10 "expired-callback": () => void;
11 sitekey: string;
12 size?: "normal" | "compact" | "flexible";
13 theme?: "auto" | "light" | "dark";
14}
15 
16interface TurnstileApi {
17 remove(widgetId: string): void;
18 render(container: HTMLElement, options: TurnstileRenderOptions): string;
19 reset(widgetId: string): void;
20}
21 
22declare global {
23 interface Window {
24 turnstile?: TurnstileApi;
25 }
26}
27 
28let turnstileScriptPromise: Promise<TurnstileApi> | null = null;
29 
30function loadTurnstileScript() {
31 if (window.turnstile) {
32 return Promise.resolve(window.turnstile);
33 }
34 
35 if (turnstileScriptPromise) {
36 return turnstileScriptPromise;
37 }
38 
39 turnstileScriptPromise = new Promise<TurnstileApi>((resolve, reject) => {
40 const existingScript = document.querySelector<HTMLScriptElement>(
41 'script[src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"]',
42 );
43 
44 const handleLoad = () => {
45 if (window.turnstile) {
46 resolve(window.turnstile);
47 return;
48 }
49 
50 turnstileScriptPromise = null;
51 reject(new Error("Human verification is temporarily unavailable."));
52 };
53 
54 const handleError = () => {
55 turnstileScriptPromise = null;
56 reject(new Error("Human verification is temporarily unavailable."));
57 };
58 
59 if (existingScript) {
60 existingScript.addEventListener("load", handleLoad, { once: true });
61 existingScript.addEventListener("error", handleError, { once: true });
62 return;
63 }
64 
65 const script = document.createElement("script");
66 script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
67 script.async = true;
68 script.defer = true;
69 script.addEventListener("load", handleLoad, { once: true });
70 script.addEventListener("error", handleError, { once: true });
71 document.head.appendChild(script);
72 });
73 
74 return turnstileScriptPromise;
75}
76 
77interface TurnstileWidgetProps {
78 action: string;
79 onError?: (error: string | null) => void;
80 onTokenChange: (token: string | null) => void;
81 resetKey: number;
82}
83 
84export function TurnstileWidget({ action, onError, onTokenChange, resetKey }: TurnstileWidgetProps) {
85 const containerRef = useRef<HTMLDivElement | null>(null);
86 const widgetIdRef = useRef<string | null>(null);
87 const previousResetKeyRef = useRef(resetKey);
88 const [turnstile, setTurnstile] = useState<TurnstileApi | null>(null);
89 const [siteKey, setSiteKey] = useState<string | null>(null);
90 const [loading, setLoading] = useState(true);
91 const [error, setError] = useState<string | null>(null);
92 const [loadError, setLoadError] = useState(false);
93 const [loadKey, setLoadKey] = useState(0);
94 
95 useEffect(() => {
96 let active = true;
97 
98 const load = async () => {
99 setLoading(true);
100 setError(null);
101 setLoadError(false);
102 
103 try {
104 const [config, api] = await Promise.all([getPublicConfig(), loadTurnstileScript()]);
105 if (!active) {
106 return;
107 }
108 
109 setSiteKey(config.turnstileSiteKey);
110 setTurnstile(api);
111 } catch (nextError) {
112 if (!active) {
113 return;
114 }
115 
116 onTokenChange(null);
117 const message = getErrorMessage(nextError);
118 setError(message);
119 setLoadError(true);
120 onError?.(message);
121 } finally {
122 if (active) {
123 setLoading(false);
124 }
125 }
126 };
127 
128 void load();
129 
130 return () => {
131 active = false;
132 };
133 }, [loadKey, onError, onTokenChange]);
134 
135 useEffect(() => {
136 if (!containerRef.current || !turnstile || !siteKey || widgetIdRef.current) {
137 return;
138 }
139 
140 widgetIdRef.current = turnstile.render(containerRef.current, {
141 sitekey: siteKey,
142 action,
143 theme: "dark",
144 size: "flexible",
145 appearance: "interaction-only",
146 callback: (token) => {
147 setError(null);
148 onError?.(null);
149 onTokenChange(token);
150 },
151 "error-callback": () => {
152 const message = "Human verification failed. Please try again.";
153 onTokenChange(null);
154 setError(message);
155 onError?.(message);
156 },
157 "expired-callback": () => {
158 const message = "Human verification expired. Please try again.";
159 onTokenChange(null);
160 setError(message);
161 onError?.(message);
162 },
163 });
164 
165 // Cloudflare Turnstile can render its host on a fractional Y position
166 // (for example, on 1.5 DPR displays), which causes the widget to lose its
167 // bottom border by 1px. Snap the host upward by the fractional bottom
168 // offset after render and on resize to keep the border visible.
169 const alignWidgetHost = () => {
170 const container = containerRef.current;
171 const host = container?.firstElementChild;
172 if (!container || !(host instanceof HTMLElement)) {
173 return;
174 }
175 
176 const bottom = container.getBoundingClientRect().bottom;
177 const bottomFraction = bottom - Math.floor(bottom);
178 host.style.transform = bottomFraction > 0.001 ? `translateY(${-bottomFraction}px)` : "";
179 };
180 
181 const frameId = window.requestAnimationFrame(alignWidgetHost);
182 window.addEventListener("resize", alignWidgetHost);
183 
184 return () => {
185 window.cancelAnimationFrame(frameId);
186 window.removeEventListener("resize", alignWidgetHost);
187 if (widgetIdRef.current) {
188 turnstile.remove(widgetIdRef.current);
189 widgetIdRef.current = null;
190 }
191 };
192 }, [action, onError, onTokenChange, siteKey, turnstile]);
193 
194 useEffect(() => {
195 if (!turnstile || !widgetIdRef.current) {
196 previousResetKeyRef.current = resetKey;
197 return;
198 }
199 
200 if (resetKey === previousResetKeyRef.current) {
201 return;
202 }
203 
204 previousResetKeyRef.current = resetKey;
205 onTokenChange(null);
206 setError(null);
207 turnstile.reset(widgetIdRef.current);
208 }, [onTokenChange, resetKey, turnstile]);
209 
210 return (
211 <div className="space-y-2">
212 <div ref={containerRef} />
213 {loading ? (
214 <p className="flex items-center gap-2 text-sm text-zinc-400">
215 <Loader2 className="h-3.5 w-3.5 animate-spin" />
216 Loading human verification...
217 </p>
218 ) : null}
219 {error ? (
220 <div className="flex items-center gap-3">
221 <p className="text-sm text-red-400">{error}</p>
222 {loadError ? (
223 <button
224 type="button"
225 onClick={() => setLoadKey((value) => value + 1)}
226 className="flex shrink-0 items-center gap-1.5 rounded-lg border border-zinc-700/60 bg-zinc-800/60 px-2.5 py-1 text-xs font-medium text-zinc-300 transition-colors hover:bg-zinc-700/60"
227 >
228 <RefreshCw className="h-3 w-3" />
229 Retry
230 </button>
231 ) : null}
232 </div>
233 ) : null}
234 </div>
235 );
236}