From 3ae7332b37e7715b91d1087348990f7ba1796790 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 10 Mar 2026 15:08:16 +0000 Subject: [PATCH 01/12] fix: replace broken jail page with 3-step waitlist flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old JoinWaitlistPage gated the invite code input behind isPermissionGranted, permanently trapping users who denied notifications. New flow: 1. Email collection (required) — saved via updateUserById server action 2. Enable notifications (skippable) — soft prompt with "Not now" link 3. Jail screen — always shows invite code input regardless of notification state Also adds a subtle notification banner on the jail screen for users who skipped step 2. --- src/components/Invites/JoinWaitlistPage.tsx | 163 ++++++++++++++++---- 1 file changed, 134 insertions(+), 29 deletions(-) diff --git a/src/components/Invites/JoinWaitlistPage.tsx b/src/components/Invites/JoinWaitlistPage.tsx index 789cecc5a..7317eb73d 100644 --- a/src/components/Invites/JoinWaitlistPage.tsx +++ b/src/components/Invites/JoinWaitlistPage.tsx @@ -2,7 +2,7 @@ import { useAuth } from '@/context/authContext' import { invitesApi } from '@/services/invites' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import InvitesPageLayout from './InvitesPageLayout' import { twMerge } from 'tailwind-merge' import ValidatedInput from '../Global/ValidatedInput' @@ -15,35 +15,88 @@ import { useQuery } from '@tanstack/react-query' import PeanutLoading from '../Global/PeanutLoading' import { useSetupStore } from '@/redux/hooks' import { useNotifications } from '@/hooks/useNotifications' +import { updateUserById } from '@/app/actions/users' + +const isValidEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) const JoinWaitlistPage = () => { - const [isValid, setIsValid] = useState(false) - const [isChanging, setIsChanging] = useState(false) - const [isLoading, setisLoading] = useState(false) - const [error, setError] = useState('') const { fetchUser, isFetchingUser, logoutUser, user } = useAuth() - const [isLoggingOut, setisLoggingOut] = useState(false) const router = useRouter() const { inviteType, inviteCode: setupInviteCode } = useSetupStore() - const [inviteCode, setInviteCode] = useState(setupInviteCode) - const { requestPermission, afterPermissionAttempt, isPermissionGranted } = useNotifications() + // Determine initial step: skip email if already on file, skip notifications if already granted + const initialStep = useMemo<1 | 2 | 3>(() => { + if (user?.user.email) { + return isPermissionGranted ? 3 : 2 + } + return 1 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const [step, setStep] = useState<1 | 2 | 3>(initialStep) + + // Step 1: Email state + const [emailValue, setEmailValue] = useState('') + const [emailError, setEmailError] = useState('') + const [isSubmittingEmail, setIsSubmittingEmail] = useState(false) + + // Step 3: Invite code state + const [inviteCode, setInviteCode] = useState(setupInviteCode) + const [isValid, setIsValid] = useState(false) + const [isChanging, setIsChanging] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState('') + const [isLoggingOut, setIsLoggingOut] = useState(false) + const { data, isLoading: isLoadingWaitlistPosition } = useQuery({ queryKey: ['waitlist-position'], queryFn: () => invitesApi.getWaitlistQueuePosition(), enabled: !!user?.user.userId, }) - const validateInviteCode = async (inviteCode: string): Promise => { - setisLoading(true) - const res = await invitesApi.validateInviteCode(inviteCode) - setisLoading(false) + // Step 1: Submit email via server action + const handleEmailSubmit = async () => { + if (!isValidEmail(emailValue) || !user?.user.userId) return + + setIsSubmittingEmail(true) + setEmailError('') + + try { + const result = await updateUserById({ userId: user.user.userId, email: emailValue }) + if (result.error) { + setEmailError(result.error) + } else { + setStep(isPermissionGranted ? 3 : 2) + } + } catch { + setEmailError('Something went wrong. Please try again.') + } finally { + setIsSubmittingEmail(false) + } + } + + // Step 2: Enable notifications (always advances regardless of outcome) + const handleEnableNotifications = async () => { + try { + await requestPermission() + await afterPermissionAttempt() + } catch { + // permission denied or error — that's fine + } + setStep(3) + } + + // Step 3: Validate and accept invite code + const validateInviteCode = async (code: string): Promise => { + setIsLoading(true) + const res = await invitesApi.validateInviteCode(code) + setIsLoading(false) return res.success } const handleAcceptInvite = async () => { - setisLoading(true) + setIsLoading(true) try { const res = await invitesApi.acceptInvite(inviteCode, inviteType) if (res.success) { @@ -55,15 +108,15 @@ const JoinWaitlistPage = () => { } catch { setError('Something went wrong. Please try again or contact support.') } finally { - setisLoading(false) + setIsLoading(false) } } const handleLogout = async () => { - setisLoggingOut(true) + setIsLoggingOut(true) await logoutUser() router.push('/setup') - setisLoggingOut(false) + setIsLoggingOut(false) } useEffect(() => { @@ -76,34 +129,87 @@ const JoinWaitlistPage = () => { return } + const stepImage = step === 3 ? peanutAnim.src : chillPeanutAnim.src + return ( - +
- {!isPermissionGranted && ( + {/* Step 1: Email Collection */} + {step === 1 && (
-

Enable notifications

-

We'll send you an update as soon as you get access.

+

Stay in the loop

+

+ Enter your email so we can reach you when you get access. +

-
)} - {isPermissionGranted && ( + {/* Step 2: Enable Notifications (skippable) */} + {step === 2 && (
+

Want instant updates?

+

+ We'll notify you the moment you get access. +

+ + + + +
+ )} + + {/* Step 3: Jail Screen */} + {step === 3 && ( +
+ {!isPermissionGranted && ( +
+ + Enable notifications to get updates when you're unlocked + + +
+ )} +

You're still in Peanut jail

Prisoner #{data?.position}

@@ -149,10 +255,9 @@ const JoinWaitlistPage = () => {
{!isValid && !isChanging && !!inviteCode && ( - + )} - {/* Show error from the API call */} {error && } -
)} {/* Step 3: Jail Screen */} - {step === 3 && ( + {step === 'jail' && (
{!isPermissionGranted && (
From 6aa8d910e8fa867f9fead86c5a11d404260030bd Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 10 Mar 2026 16:12:08 +0000 Subject: [PATCH 06/12] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20fetchUser,=20email=20util,=20border,=20back=20nav?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Call fetchUser() after email save to keep user context in sync - Move isValidEmail to format.utils.ts for reuse (Kushagra review) - Fix border-n-2 → border-n-1 to match design system convention - Add back navigation from notifications step to email step --- src/components/Invites/JoinWaitlistPage.tsx | 12 ++++++++---- src/utils/format.utils.ts | 2 ++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/components/Invites/JoinWaitlistPage.tsx b/src/components/Invites/JoinWaitlistPage.tsx index 33c444616..f3beea6b2 100644 --- a/src/components/Invites/JoinWaitlistPage.tsx +++ b/src/components/Invites/JoinWaitlistPage.tsx @@ -17,8 +17,7 @@ import { useSetupStore } from '@/redux/hooks' import { useNotifications } from '@/hooks/useNotifications' import { updateUserById } from '@/app/actions/users' import { useQueryState, parseAsStringEnum } from 'nuqs' - -const isValidEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) +import { isValidEmail } from '@/utils/format.utils' type WaitlistStep = 'email' | 'notifications' | 'jail' @@ -80,6 +79,7 @@ const JoinWaitlistPage = () => { if (result.error) { setEmailError(result.error) } else { + await fetchUser() setStep(isPermissionGranted ? 'jail' : 'notifications') } } catch { @@ -179,7 +179,7 @@ const JoinWaitlistPage = () => { onKeyDown={(e) => { if (e.key === 'Enter' && isValidEmail(emailValue)) handleEmailSubmit() }} - className="h-12 w-full rounded-sm border border-n-2 px-4 text-base outline-none focus:border-black" + className="h-12 w-full rounded-sm border border-n-1 px-4 text-base outline-none focus:border-black" /> {emailError && } @@ -208,6 +208,10 @@ const JoinWaitlistPage = () => { + +
)} @@ -215,7 +219,7 @@ const JoinWaitlistPage = () => { {step === 'jail' && (
{!isPermissionGranted && ( -
+
Enable notifications to get updates when you're unlocked diff --git a/src/utils/format.utils.ts b/src/utils/format.utils.ts index c0f0b2742..968ae9fea 100644 --- a/src/utils/format.utils.ts +++ b/src/utils/format.utils.ts @@ -108,3 +108,5 @@ export const formatCurrencyWithIntl = ( return numericValue.toFixed(minDigits) } } + +export const isValidEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) From 5db52ffad11461b4bda365a27e7ac13e16990018 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 10 Mar 2026 16:22:40 +0000 Subject: [PATCH 07/12] =?UTF-8?q?refactor:=20simplify=20waitlist=20page=20?= =?UTF-8?q?=E2=80=94=20reuse=20BaseInput,=20dedupe=20email=20regex,=20opti?= =?UTF-8?q?mize=20query?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use BaseInput component instead of raw for design system consistency - Extract nextStepAfterEmail helper to deduplicate step advancement logic - Reuse isValidEmail from format.utils in withdraw.utils (remove duplicate regex) - Gate waitlist-position query on step === 'jail' to avoid unnecessary API calls - Move loading spinner inside step 3 so steps 1-2 render immediately - Simplify onClick handler (remove unnecessary arrow wrapper) --- src/components/Invites/JoinWaitlistPage.tsx | 29 ++++++++++----------- src/utils/withdraw.utils.ts | 4 +-- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/components/Invites/JoinWaitlistPage.tsx b/src/components/Invites/JoinWaitlistPage.tsx index f3beea6b2..db63af7ae 100644 --- a/src/components/Invites/JoinWaitlistPage.tsx +++ b/src/components/Invites/JoinWaitlistPage.tsx @@ -18,9 +18,13 @@ import { useNotifications } from '@/hooks/useNotifications' import { updateUserById } from '@/app/actions/users' import { useQueryState, parseAsStringEnum } from 'nuqs' import { isValidEmail } from '@/utils/format.utils' +import { BaseInput } from '@/components/0_Bruddle/BaseInput' type WaitlistStep = 'email' | 'notifications' | 'jail' +const nextStepAfterEmail = (isPermissionGranted: boolean): WaitlistStep => + isPermissionGranted ? 'jail' : 'notifications' + const JoinWaitlistPage = () => { const { fetchUser, isFetchingUser, logoutUser, user } = useAuth() const router = useRouter() @@ -31,9 +35,8 @@ const JoinWaitlistPage = () => { const [step, setStep] = useQueryState( 'step', parseAsStringEnum(['email', 'notifications', 'jail']).withDefault( - // Determine initial step: skip completed steps (() => { - if (user?.user.email) return isPermissionGranted ? 'jail' : 'notifications' + if (user?.user.email) return nextStepAfterEmail(isPermissionGranted) return 'email' })() ) @@ -55,13 +58,13 @@ const JoinWaitlistPage = () => { const { data, isLoading: isLoadingWaitlistPosition } = useQuery({ queryKey: ['waitlist-position'], queryFn: () => invitesApi.getWaitlistQueuePosition(), - enabled: !!user?.user.userId, + enabled: !!user?.user.userId && step === 'jail', }) // Redirect completed steps when user/permission state changes useEffect(() => { if (step === 'email' && user?.user.email) { - setStep(isPermissionGranted ? 'jail' : 'notifications') + setStep(nextStepAfterEmail(isPermissionGranted)) } else if (step === 'notifications' && isPermissionGranted) { setStep('jail') } @@ -80,7 +83,7 @@ const JoinWaitlistPage = () => { setEmailError(result.error) } else { await fetchUser() - setStep(isPermissionGranted ? 'jail' : 'notifications') + setStep(nextStepAfterEmail(isPermissionGranted)) } } catch { setEmailError('Something went wrong. Please try again.') @@ -144,10 +147,6 @@ const JoinWaitlistPage = () => { } }, [isFetchingUser, user, router]) - if (isLoadingWaitlistPosition) { - return - } - const stepImage = step === 'jail' ? peanutAnim.src : chillPeanutAnim.src return ( @@ -167,8 +166,9 @@ const JoinWaitlistPage = () => { Enter your email so we can reach you when you get access.

- { onKeyDown={(e) => { if (e.key === 'Enter' && isValidEmail(emailValue)) handleEmailSubmit() }} - className="h-12 w-full rounded-sm border border-n-1 px-4 text-base outline-none focus:border-black" + className="h-12" /> {emailError && } @@ -216,7 +216,8 @@ const JoinWaitlistPage = () => { )} {/* Step 3: Jail Screen */} - {step === 'jail' && ( + {step === 'jail' && isLoadingWaitlistPosition && } + {step === 'jail' && !isLoadingWaitlistPosition && (
{!isPermissionGranted && (
@@ -267,9 +268,7 @@ const JoinWaitlistPage = () => { className="h-12 w-4/12" loading={isLoading} shadowSize="4" - onClick={() => { - handleAcceptInvite() - }} + onClick={handleAcceptInvite} disabled={!isValid || isChanging || isLoading} > Next diff --git a/src/utils/withdraw.utils.ts b/src/utils/withdraw.utils.ts index bbdbc0396..508839158 100644 --- a/src/utils/withdraw.utils.ts +++ b/src/utils/withdraw.utils.ts @@ -1,4 +1,5 @@ import { countryData, ALL_COUNTRIES_ALPHA3_TO_ALPHA2 } from '@/components/AddMoney/consts' +import { isValidEmail } from '@/utils/format.utils' /** * Extracts the country name from an IBAN by parsing the first 2 characters (country code) @@ -271,8 +272,7 @@ export const validatePixKey = (pixKey: string): { valid: boolean; message?: stri } // 4. Email: Standard email format - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ - if (emailRegex.test(trimmed)) { + if (isValidEmail(trimmed)) { if (trimmed.length > 77) { return { valid: false, message: 'Email is too long (max 77 characters)' } } From 932a9ab1897027788e407f466d8452832bcebd28 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 10 Mar 2026 17:15:14 +0000 Subject: [PATCH 08/12] fix: remove notification banner from jail screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback: the notification hint on the jail screen was out of design system and visually distracting. Notifications are already offered in step 2 — no need to re-prompt on the jail screen. --- src/components/Invites/JoinWaitlistPage.tsx | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/src/components/Invites/JoinWaitlistPage.tsx b/src/components/Invites/JoinWaitlistPage.tsx index db63af7ae..74bf5a68f 100644 --- a/src/components/Invites/JoinWaitlistPage.tsx +++ b/src/components/Invites/JoinWaitlistPage.tsx @@ -208,10 +208,6 @@ const JoinWaitlistPage = () => { - -
)} @@ -219,20 +215,6 @@ const JoinWaitlistPage = () => { {step === 'jail' && isLoadingWaitlistPosition && } {step === 'jail' && !isLoadingWaitlistPosition && (
- {!isPermissionGranted && ( -
- - Enable notifications to get updates when you're unlocked - - -
- )} -

You're still in Peanut jail

Prisoner #{data?.position}

From 588f52b0ba67374725246c054062ef3c42999a67 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 10 Mar 2026 18:44:25 +0000 Subject: [PATCH 09/12] fix: address PR review comments - Prevent URL bypass: enforce email step when no email on file - Split shared isLoading into isValidating/isAccepting to prevent race condition between background validation and invite acceptance - Add userId to waitlist-position queryKey for proper cache isolation - Clear error state on logout --- src/components/Invites/JoinWaitlistPage.tsx | 29 ++++++++++++--------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/components/Invites/JoinWaitlistPage.tsx b/src/components/Invites/JoinWaitlistPage.tsx index 74bf5a68f..f5f899930 100644 --- a/src/components/Invites/JoinWaitlistPage.tsx +++ b/src/components/Invites/JoinWaitlistPage.tsx @@ -51,24 +51,28 @@ const JoinWaitlistPage = () => { const [inviteCode, setInviteCode] = useState(setupInviteCode) const [isValid, setIsValid] = useState(false) const [isChanging, setIsChanging] = useState(false) - const [isLoading, setIsLoading] = useState(false) + const [isValidating, setIsValidating] = useState(false) + const [isAccepting, setIsAccepting] = useState(false) const [error, setError] = useState('') const [isLoggingOut, setIsLoggingOut] = useState(false) const { data, isLoading: isLoadingWaitlistPosition } = useQuery({ - queryKey: ['waitlist-position'], + queryKey: ['waitlist-position', user?.user.userId], queryFn: () => invitesApi.getWaitlistQueuePosition(), enabled: !!user?.user.userId && step === 'jail', }) - // Redirect completed steps when user/permission state changes + // Enforce step invariants: prevent URL bypass and fast-forward completed steps useEffect(() => { - if (step === 'email' && user?.user.email) { + if (isFetchingUser) return + if (step !== 'email' && !user?.user.email) { + setStep('email') + } else if (step === 'email' && user?.user.email) { setStep(nextStepAfterEmail(isPermissionGranted)) } else if (step === 'notifications' && isPermissionGranted) { setStep('jail') } - }, [user?.user.email, isPermissionGranted, step, setStep]) + }, [user?.user.email, isPermissionGranted, isFetchingUser, step, setStep]) // Step 1: Submit email via server action const handleEmailSubmit = async () => { @@ -103,19 +107,19 @@ const JoinWaitlistPage = () => { setStep('jail') } - // Step 3: Validate and accept invite code + // Step 3: Validate and accept invite code (separate loading states to avoid race) const validateInviteCode = async (code: string): Promise => { - setIsLoading(true) + setIsValidating(true) try { const res = await invitesApi.validateInviteCode(code) return res.success } finally { - setIsLoading(false) + setIsValidating(false) } } const handleAcceptInvite = async () => { - setIsLoading(true) + setIsAccepting(true) try { const res = await invitesApi.acceptInvite(inviteCode, inviteType) if (res.success) { @@ -127,7 +131,7 @@ const JoinWaitlistPage = () => { } catch { setError('Something went wrong. Please try again or contact support.') } finally { - setIsLoading(false) + setIsAccepting(false) } } @@ -138,6 +142,7 @@ const JoinWaitlistPage = () => { router.push('/setup') } finally { setIsLoggingOut(false) + setError('') } } @@ -248,10 +253,10 @@ const JoinWaitlistPage = () => { From 8824d34c1643980857e71281c06ee7f1f7ff97a6 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 10 Mar 2026 18:51:57 +0000 Subject: [PATCH 10/12] fix: guard handleEmailSubmit against duplicate calls Add isSubmittingEmail check to the function itself so Enter key can't bypass the loading guard and fire duplicate requests. --- src/components/Invites/JoinWaitlistPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Invites/JoinWaitlistPage.tsx b/src/components/Invites/JoinWaitlistPage.tsx index f5f899930..6dc34bc5b 100644 --- a/src/components/Invites/JoinWaitlistPage.tsx +++ b/src/components/Invites/JoinWaitlistPage.tsx @@ -76,7 +76,7 @@ const JoinWaitlistPage = () => { // Step 1: Submit email via server action const handleEmailSubmit = async () => { - if (!isValidEmail(emailValue) || !user?.user.userId) return + if (!isValidEmail(emailValue) || !user?.user.userId || isSubmittingEmail) return setIsSubmittingEmail(true) setEmailError('') From b20d69fd66ded42e070e13e81fdd6be4cb5afa97 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 10 Mar 2026 19:45:16 +0000 Subject: [PATCH 11/12] fix: add error handling and skip option to email step The email submit had silent failure paths where no error was shown to the user. Now every failure path sets an error message, and a "Skip for now" link appears when the email step fails so users aren't permanently stuck. --- src/components/Invites/JoinWaitlistPage.tsx | 43 +++++++++++++++++---- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/src/components/Invites/JoinWaitlistPage.tsx b/src/components/Invites/JoinWaitlistPage.tsx index 6dc34bc5b..cb884ed23 100644 --- a/src/components/Invites/JoinWaitlistPage.tsx +++ b/src/components/Invites/JoinWaitlistPage.tsx @@ -62,21 +62,29 @@ const JoinWaitlistPage = () => { enabled: !!user?.user.userId && step === 'jail', }) + // Track whether user explicitly skipped the email step + const [emailSkipped, setEmailSkipped] = useState(false) + // Enforce step invariants: prevent URL bypass and fast-forward completed steps useEffect(() => { if (isFetchingUser) return - if (step !== 'email' && !user?.user.email) { + if (step !== 'email' && !user?.user.email && !emailSkipped) { setStep('email') } else if (step === 'email' && user?.user.email) { setStep(nextStepAfterEmail(isPermissionGranted)) } else if (step === 'notifications' && isPermissionGranted) { setStep('jail') } - }, [user?.user.email, isPermissionGranted, isFetchingUser, step, setStep]) + }, [user?.user.email, isPermissionGranted, isFetchingUser, step, setStep, emailSkipped]) // Step 1: Submit email via server action const handleEmailSubmit = async () => { - if (!isValidEmail(emailValue) || !user?.user.userId || isSubmittingEmail) return + if (!isValidEmail(emailValue) || isSubmittingEmail) return + + if (!user?.user.userId) { + setEmailError('Account not loaded yet. Please wait a moment and try again.') + return + } setIsSubmittingEmail(true) setEmailError('') @@ -85,17 +93,30 @@ const JoinWaitlistPage = () => { const result = await updateUserById({ userId: user.user.userId, email: emailValue }) if (result.error) { setEmailError(result.error) - } else { - await fetchUser() - setStep(nextStepAfterEmail(isPermissionGranted)) + return } - } catch { - setEmailError('Something went wrong. Please try again.') + + const refreshedUser = await fetchUser() + if (!refreshedUser?.user.email) { + console.error('[JoinWaitlist] Email update succeeded but fetchUser did not return email') + setEmailError('Email saved, but we had trouble loading your profile. Please try again.') + return + } + + setStep(nextStepAfterEmail(isPermissionGranted)) + } catch (e) { + console.error('[JoinWaitlist] handleEmailSubmit failed:', e) + setEmailError('Something went wrong. Please try again or skip this step.') } finally { setIsSubmittingEmail(false) } } + const handleSkipEmail = () => { + setEmailSkipped(true) + setStep(nextStepAfterEmail(isPermissionGranted)) + } + // Step 2: Enable notifications (always advances regardless of outcome) const handleEnableNotifications = async () => { try { @@ -197,6 +218,12 @@ const JoinWaitlistPage = () => { > Continue + + {emailError && ( + + )}
)} From 77b68cd8f54d570b5e6a9de28acca828cfcf0742 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 10 Mar 2026 19:58:51 +0000 Subject: [PATCH 12/12] fix: race condition between setStep and react-query user state After email submit succeeds, setStep('notifications') fires but the useEffect sees stale user data (no email yet) and resets to 'email'. Fix: track emailStepDone flag set synchronously before setStep, so the useEffect doesn't override the step transition. --- src/components/Invites/JoinWaitlistPage.tsx | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/components/Invites/JoinWaitlistPage.tsx b/src/components/Invites/JoinWaitlistPage.tsx index cb884ed23..3df66a37a 100644 --- a/src/components/Invites/JoinWaitlistPage.tsx +++ b/src/components/Invites/JoinWaitlistPage.tsx @@ -62,20 +62,26 @@ const JoinWaitlistPage = () => { enabled: !!user?.user.userId && step === 'jail', }) - // Track whether user explicitly skipped the email step - const [emailSkipped, setEmailSkipped] = useState(false) + // Track whether the email step has been completed or skipped this session, + // so the step invariant useEffect doesn't race with react-query state updates + const [emailStepDone, setEmailStepDone] = useState(!!user?.user.email) // Enforce step invariants: prevent URL bypass and fast-forward completed steps useEffect(() => { if (isFetchingUser) return - if (step !== 'email' && !user?.user.email && !emailSkipped) { + if (step !== 'email' && !user?.user.email && !emailStepDone) { setStep('email') - } else if (step === 'email' && user?.user.email) { + } else if (step === 'email' && (user?.user.email || emailStepDone)) { setStep(nextStepAfterEmail(isPermissionGranted)) } else if (step === 'notifications' && isPermissionGranted) { setStep('jail') } - }, [user?.user.email, isPermissionGranted, isFetchingUser, step, setStep, emailSkipped]) + }, [user?.user.email, isPermissionGranted, isFetchingUser, step, setStep, emailStepDone]) + + // Sync emailStepDone when user data loads with an existing email + useEffect(() => { + if (user?.user.email) setEmailStepDone(true) + }, [user?.user.email]) // Step 1: Submit email via server action const handleEmailSubmit = async () => { @@ -103,6 +109,9 @@ const JoinWaitlistPage = () => { return } + // Mark email step as done BEFORE setStep to prevent the useEffect + // from racing and resetting the step back to 'email' + setEmailStepDone(true) setStep(nextStepAfterEmail(isPermissionGranted)) } catch (e) { console.error('[JoinWaitlist] handleEmailSubmit failed:', e) @@ -113,7 +122,7 @@ const JoinWaitlistPage = () => { } const handleSkipEmail = () => { - setEmailSkipped(true) + setEmailStepDone(true) setStep(nextStepAfterEmail(isPermissionGranted)) }