@@ -16,7 +16,7 @@ import { FIELD_CONFIGS, validateName } from "@/lib/formValidation";
1616import { getStoredAttributionParams } from "@/utils/attributionUtils" ;
1717import { isTurnstileEnabled } from "@/utils/utils" ;
1818import { Turnstile , type TurnstileInstance } from "@marsidev/react-turnstile" ;
19- import { useCallback , useEffect , useRef , useState } from "react" ;
19+ import { useCallback , useEffect , useMemo , useRef , useState } from "react" ;
2020
2121interface SignUpFormProps {
2222 defaultValues ?: {
@@ -27,10 +27,13 @@ interface SignUpFormProps {
2727 error ?: string ;
2828}
2929
30- const TOKEN_TIMEOUT = 10000 ;
30+ const BACKGROUND_TOKEN_TIMEOUT = 10000 ;
31+ const INTERACTIVE_TOKEN_TIMEOUT = 120000 ;
3132const EXPIRED_MESSAGE = "Verification expired. Please complete it again." ;
3233const TIMEOUT_MESSAGE =
3334 "Security check didn’t complete. Try disabling ad blockers and then try again. If it still fails, try a different browser or network." ;
35+ const UNSUPPORTED_MESSAGE =
36+ "This browser can’t complete the security check. Please try a different browser or network." ;
3437
3538export default function SignUpForm ( {
3639 defaultValues = { } ,
@@ -41,60 +44,80 @@ export default function SignUpForm({
4144
4245 const [ captchaError , setCaptchaError ] = useState < string | null > ( null ) ;
4346 const [ isWaitingForToken , setIsWaitingForToken ] = useState ( false ) ;
44- const [ showTurnstileField , setShowTurnstileField ] = useState ( false ) ;
47+ const [ isTurnstileInteractive , setIsTurnstileInteractive ] = useState ( false ) ;
4548
4649 const turnstileRef = useRef < TurnstileInstance > ( null ) ;
4750 const tokenResolverRef = useRef < ( ( token : string ) => void ) | null > ( null ) ;
4851 const tokenRejecterRef = useRef < ( ( error : Error ) => void ) | null > ( null ) ;
52+ const tokenTimeoutRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
4953
54+ const turnstileEnabled = isTurnstileEnabled ( ) ;
5055 const hasFieldErrors = Boolean ( firstNameError || captchaError ) ;
5156
52- // Only show field wrapper when there's an error (invisible mode handles normal flow)
53- useEffect ( ( ) => {
54- setShowTurnstileField ( ! ! captchaError ) ;
55- } , [ captchaError ] ) ;
57+ const clearTokenTimeout = useCallback ( ( ) => {
58+ if ( tokenTimeoutRef . current ) {
59+ clearTimeout ( tokenTimeoutRef . current ) ;
60+ tokenTimeoutRef . current = null ;
61+ }
62+ } , [ ] ) ;
63+
64+ const clearTokenPromise = useCallback ( ( ) => {
65+ tokenResolverRef . current = null ;
66+ tokenRejecterRef . current = null ;
67+ clearTokenTimeout ( ) ;
68+ } , [ clearTokenTimeout ] ) ;
69+
70+ const rejectTokenPromise = useCallback (
71+ ( errorMessage : string ) => {
72+ if ( tokenRejecterRef . current ) {
73+ tokenRejecterRef . current ( new Error ( errorMessage ) ) ;
74+ }
75+ clearTokenPromise ( ) ;
76+ } ,
77+ [ clearTokenPromise ]
78+ ) ;
79+
80+ const scheduleTokenTimeout = useCallback (
81+ ( timeout : number , errorMessage = TIMEOUT_MESSAGE ) => {
82+ clearTokenTimeout ( ) ;
83+ tokenTimeoutRef . current = setTimeout ( ( ) => {
84+ rejectTokenPromise ( errorMessage ) ;
85+ } , timeout ) ;
86+ } ,
87+ [ clearTokenTimeout , rejectTokenPromise ]
88+ ) ;
5689
5790 // Promise-based token wait mechanism with timeout
5891 const waitForToken = useCallback (
59- ( timeout = TOKEN_TIMEOUT ) : Promise < string > => {
92+ ( timeout = BACKGROUND_TOKEN_TIMEOUT ) : Promise < string > => {
6093 return new Promise ( ( resolve , reject ) => {
6194 // Store resolvers for onSuccess/onError callbacks
6295 tokenResolverRef . current = resolve ;
6396 tokenRejecterRef . current = reject ;
6497
65- // Timeout after specified duration
66- setTimeout ( ( ) => {
67- if ( tokenRejecterRef . current ) {
68- tokenRejecterRef . current ( new Error ( TIMEOUT_MESSAGE ) ) ;
69- tokenResolverRef . current = null ;
70- tokenRejecterRef . current = null ;
71- }
72- } , timeout ) ;
98+ scheduleTokenTimeout ( timeout ) ;
7399 } ) ;
74100 } ,
75- [ ]
101+ [ scheduleTokenTimeout ]
76102 ) ;
77103
78- const resolveTokenPromise = useCallback ( ( token : string ) => {
79- if ( tokenResolverRef . current ) {
80- tokenResolverRef . current ( token ) ;
81- tokenResolverRef . current = null ;
82- tokenRejecterRef . current = null ;
83- }
84- } , [ ] ) ;
104+ const resolveTokenPromise = useCallback (
105+ ( token : string ) => {
106+ if ( tokenResolverRef . current ) {
107+ tokenResolverRef . current ( token ) ;
108+ }
109+ clearTokenPromise ( ) ;
110+ } ,
111+ [ clearTokenPromise ]
112+ ) ;
85113
86- const rejectTokenPromise = useCallback ( ( errorMessage : string ) => {
87- if ( tokenRejecterRef . current ) {
88- tokenRejecterRef . current ( new Error ( errorMessage ) ) ;
89- tokenResolverRef . current = null ;
90- tokenRejecterRef . current = null ;
91- }
92- } , [ ] ) ;
114+ useEffect ( ( ) => clearTokenTimeout , [ clearTokenTimeout ] ) ;
93115
94116 const handleTurnstileSuccess = useCallback (
95117 ( token : string ) => {
96118 setCaptchaError ( null ) ;
97119 setIsWaitingForToken ( false ) ;
120+ setIsTurnstileInteractive ( false ) ;
98121 resolveTokenPromise ( token ) ;
99122 } ,
100123 [ resolveTokenPromise ]
@@ -103,6 +126,7 @@ export default function SignUpForm({
103126 const handleTurnstileError = useCallback (
104127 ( error : string ) => {
105128 setIsWaitingForToken ( false ) ;
129+ setIsTurnstileInteractive ( false ) ;
106130
107131 const errorMessage = `Security verification failed with error #${ error } . Please try again or use a different browser.` ;
108132
@@ -114,10 +138,35 @@ export default function SignUpForm({
114138
115139 const handleTurnstileExpire = useCallback ( ( ) => {
116140 setIsWaitingForToken ( false ) ;
141+ setIsTurnstileInteractive ( false ) ;
117142 setCaptchaError ( EXPIRED_MESSAGE ) ;
118143 rejectTokenPromise ( EXPIRED_MESSAGE ) ;
119144 } , [ rejectTokenPromise ] ) ;
120145
146+ const handleTurnstileTimeout = useCallback ( ( ) => {
147+ setIsWaitingForToken ( false ) ;
148+ setIsTurnstileInteractive ( false ) ;
149+ setCaptchaError ( TIMEOUT_MESSAGE ) ;
150+ rejectTokenPromise ( TIMEOUT_MESSAGE ) ;
151+ } , [ rejectTokenPromise ] ) ;
152+
153+ const handleTurnstileUnsupported = useCallback ( ( ) => {
154+ setIsWaitingForToken ( false ) ;
155+ setIsTurnstileInteractive ( false ) ;
156+ setCaptchaError ( UNSUPPORTED_MESSAGE ) ;
157+ rejectTokenPromise ( UNSUPPORTED_MESSAGE ) ;
158+ } , [ rejectTokenPromise ] ) ;
159+
160+ const handleTurnstileBeforeInteractive = useCallback ( ( ) => {
161+ setCaptchaError ( null ) ;
162+ setIsTurnstileInteractive ( true ) ;
163+ scheduleTokenTimeout ( INTERACTIVE_TOKEN_TIMEOUT ) ;
164+ } , [ scheduleTokenTimeout ] ) ;
165+
166+ const handleTurnstileAfterInteractive = useCallback ( ( ) => {
167+ setIsTurnstileInteractive ( false ) ;
168+ } , [ ] ) ;
169+
121170 const handleSubmit = async ( event : React . FormEvent < HTMLFormElement > ) => {
122171 event . preventDefault ( ) ;
123172 if ( isSubmitting || isWaitingForToken ) return ;
@@ -137,18 +186,19 @@ export default function SignUpForm({
137186 // Handle Turnstile token generation if enabled
138187 // Always get a fresh token as they are single-use and never reused
139188 let tokenToUse : string | undefined ;
140- if ( isTurnstileEnabled ( ) ) {
189+ if ( turnstileEnabled ) {
141190 // Reset any existing token to ensure we get a fresh one
142-
143191 turnstileRef . current ?. reset ( ) ;
144192
145193 setIsWaitingForToken ( true ) ;
146194 try {
147- // Execute Turnstile verification
195+ // Start waiting before execution so a fast success callback cannot race us.
196+ const tokenPromise = waitForToken ( BACKGROUND_TOKEN_TIMEOUT ) ;
197+
148198 turnstileRef . current ?. execute ( ) ;
149199
150200 // Wait for token with timeout
151- tokenToUse = await waitForToken ( TOKEN_TIMEOUT ) ;
201+ tokenToUse = await tokenPromise ;
152202
153203 // Token obtained, proceed with submission
154204 setIsWaitingForToken ( false ) ;
@@ -189,8 +239,31 @@ export default function SignUpForm({
189239 onSuccess : handleTurnstileSuccess ,
190240 onError : handleTurnstileError ,
191241 onExpire : handleTurnstileExpire ,
242+ onTimeout : handleTurnstileTimeout ,
243+ onUnsupported : handleTurnstileUnsupported ,
244+ onBeforeInteractive : handleTurnstileBeforeInteractive ,
245+ onAfterInteractive : handleTurnstileAfterInteractive ,
246+ options : {
247+ appearance : "interaction-only" ,
248+ execution : "execute" ,
249+ responseField : false ,
250+ } as const ,
192251 } ;
193252
253+ const turnstileContainerStyle = useMemo (
254+ ( ) =>
255+ isTurnstileInteractive || captchaError
256+ ? undefined
257+ : ( {
258+ position : "absolute" ,
259+ width : 1 ,
260+ height : 1 ,
261+ overflow : "hidden" ,
262+ clipPath : "inset(50%)" ,
263+ } as const ) ,
264+ [ captchaError , isTurnstileInteractive ]
265+ ) ;
266+
194267 return (
195268 < Form onSubmit = { handleSubmit } >
196269 < Field >
@@ -236,21 +309,14 @@ export default function SignUpForm({
236309 </ CheckboxRow >
237310 </ CheckboxCluster >
238311
239- { isTurnstileEnabled ( ) &&
240- ( showTurnstileField ? (
241- < Field >
242- < Turnstile { ...turnstileProps } options = { { execution : "execute" } } />
243- { captchaError && (
244- < InputHint variant = "error" > { captchaError } </ InputHint >
245- ) }
246- </ Field >
247- ) : (
248- < Turnstile
249- { ...turnstileProps }
250- options = { { size : "invisible" , execution : "execute" } }
251- style = { { display : "none" } }
252- />
253- ) ) }
312+ { turnstileEnabled && (
313+ < Field style = { turnstileContainerStyle } >
314+ < Turnstile { ...turnstileProps } />
315+ { captchaError && (
316+ < InputHint variant = "error" > { captchaError } </ InputHint >
317+ ) }
318+ </ Field >
319+ ) }
254320
255321 { ( error || hasFieldErrors ) && (
256322 < FormMessage
0 commit comments