import { useEffect, useRef, useState } from 'preact/hooks'; // Cloudflare Turnstile integration. // // Rendering strategy: Turnstile injects its layout stylesheet into document.head, // which does NOT cascade into our shadow DOM. When rendered inside our shadow DOM // the iframe exists with correct attributes but paints as zero pixels because the // wrapper Turnstile creates has no resolved styles. To work around this we mount // Turnstile into a portal div appended to document.body (light DOM), then use // getBoundingClientRect on the in-shadow placeholder to keep the portal visually // overlaid on top of the captcha gate area. type TurnstileOptions = { sitekey: string; callback?: (token: string) => void; 'error-callback'?: () => void; 'expired-callback'?: () => void; theme?: 'light' | 'dark' | 'auto'; size?: 'normal' | 'compact' | 'flexible' | 'invisible'; appearance?: 'always' | 'execute' | 'interaction-only'; }; declare global { interface Window { turnstile?: { render: (container: HTMLElement, opts: TurnstileOptions) => string; remove: (widgetId: string) => void; reset: (widgetId?: string) => void; }; __helixTurnstileLoading?: Promise; } } const SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; export const loadTurnstile = (): Promise => { if (typeof window === 'undefined') return Promise.resolve(); if (window.turnstile) return Promise.resolve(); if (window.__helixTurnstileLoading) return window.__helixTurnstileLoading; window.__helixTurnstileLoading = new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = SCRIPT_URL; script.async = true; script.defer = true; script.onload = () => { const poll = () => { if (window.turnstile) resolve(); else setTimeout(poll, 50); }; poll(); }; script.onerror = () => reject(new Error('Turnstile failed to load')); document.head.appendChild(script); }); return window.__helixTurnstileLoading; }; type CaptchaProps = { siteKey: string; onToken: (token: string) => void; onError?: () => void; }; export const Captcha = ({ siteKey, onToken, onError }: CaptchaProps) => { const placeholderRef = useRef(null); const portalRef = useRef(null); const widgetIdRef = useRef(null); const onTokenRef = useRef(onToken); const onErrorRef = useRef(onError); const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading'); onTokenRef.current = onToken; onErrorRef.current = onError; useEffect(() => { if (!siteKey || !placeholderRef.current) return; let cancelled = false; // Light-DOM portal so Turnstile's document.head styles actually apply. const portal = document.createElement('div'); portal.setAttribute('data-helix-turnstile', ''); portal.style.cssText = [ 'position:fixed', 'z-index:2147483647', 'width:300px', 'height:65px', 'pointer-events:auto', ].join(';'); document.body.appendChild(portal); portalRef.current = portal; const updatePosition = () => { if (!placeholderRef.current || !portalRef.current) return; const rect = placeholderRef.current.getBoundingClientRect(); portalRef.current.style.top = `${rect.top}px`; portalRef.current.style.left = `${rect.left}px`; }; updatePosition(); window.addEventListener('resize', updatePosition); window.addEventListener('scroll', updatePosition, true); // Reposition on animation frame while the gate is mounted, so the portal // tracks the placeholder through panel open animation and any layout shifts. let rafId = 0; const trackLoop = () => { updatePosition(); rafId = requestAnimationFrame(trackLoop); }; rafId = requestAnimationFrame(trackLoop); loadTurnstile() .then(() => { if (cancelled || !portalRef.current || !window.turnstile) return; try { widgetIdRef.current = window.turnstile.render(portalRef.current, { sitekey: siteKey, callback: (token) => onTokenRef.current(token), 'error-callback': () => { setStatus('error'); onErrorRef.current?.(); }, 'expired-callback': () => onTokenRef.current(''), theme: 'light', size: 'normal', }); setStatus('ready'); } catch { setStatus('error'); onErrorRef.current?.(); } }) .catch(() => { setStatus('error'); onErrorRef.current?.(); }); return () => { cancelled = true; cancelAnimationFrame(rafId); window.removeEventListener('resize', updatePosition); window.removeEventListener('scroll', updatePosition, true); if (widgetIdRef.current && window.turnstile) { try { window.turnstile.remove(widgetIdRef.current); } catch { // ignore — widget may already be gone } widgetIdRef.current = null; } portal.remove(); portalRef.current = null; }; }, [siteKey]); return (
{status === 'loading' &&
Loading verification…
} {status === 'error' &&
Verification failed to load. Please refresh.
}
); };