Skip to content

fix: jail page stuck on notifications — add 3-step waitlist flow#1726

Merged
Hugo0 merged 14 commits intomainfrom
feat/jail-fix-multistep-waitlist
Mar 10, 2026
Merged

fix: jail page stuck on notifications — add 3-step waitlist flow#1726
Hugo0 merged 14 commits intomainfrom
feat/jail-fix-multistep-waitlist

Conversation

@Hugo0
Copy link
Contributor

@Hugo0 Hugo0 commented Mar 10, 2026

Summary

  • Fixes critical bug where users get permanently stuck on "Enable notifications" if they deny or can't grant permission
  • Replaces single-screen jail page with a 3-step flow:
    1. Email collection (required) — so we can contact users when unlocked
    2. Notifications (skippable) — soft prompt with "Not now" option
    3. Jail screen — always accessible with invite code input
  • Adds subtle notification banner on jail screen for users who skipped step 2

Companion PR

Backend: peanutprotocol/peanut-api-ts#599 — auto-unlock service

Test plan

  • New user without email → sees email step first
  • User with email already → skips to notifications step
  • User with email + notifications granted → skips to jail screen
  • Denying notifications → still advances to jail screen
  • Clicking "Not now" → advances to jail screen
  • Email validation rejects invalid formats
  • Duplicate email shows error from API
  • Invite code flow still works on jail screen
  • Logout button works on jail screen

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.
@vercel
Copy link

vercel bot commented Mar 10, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
peanut-wallet Ready Ready Preview, Comment Mar 10, 2026 8:05pm

Request Review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 10, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Reworks JoinWaitlistPage into a URL-backed, three-step flow (email → notifications → jail) with persistent step state, email validation and submission, notification handling, invite-code validation/acceptance, and conditional data fetching. Adds isValidEmail utility and replaces an inline regex with it.

Changes

Cohort / File(s) Summary
Multi-step Waitlist Flow
src/components/Invites/JoinWaitlistPage.tsx
Reimplemented page as a URL-backed three-step flow (emailnotificationsjail) using useQueryState. Added per-step rendering, dynamic header/image selection, local email state and validation, server submission (updateUserById), notification opt-in handling, invite-code validation/acceptance with loading/error states, step invariants enforcement, and step-gated waitlist-position fetching.
Email Validation Utility
src/utils/format.utils.ts
Added exported isValidEmail(email: string): boolean using a regex.
Utility Integration
src/utils/withdraw.utils.ts
Replaced an inline email regex check with the imported isValidEmail in validatePixKey; preserved existing length checks.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main fix: resolving the bug where users get stuck on notifications and introducing the 3-step waitlist flow solution.
Description check ✅ Passed The description clearly relates to the changeset, detailing the bug fix, the new 3-step flow implementation, and providing a comprehensive test plan.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/jail-fix-multistep-waitlist

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
src/components/Invites/JoinWaitlistPage.tsx (1)

91-95: The validateInviteCode function doesn't actually risk stranding the loading state.

The underlying invitesApi.validateInviteCode (in src/services/invites.ts) wraps the server action in a try/catch and always returns { success: false, username: '' } on error—it never throws. However, for consistency and defensive coding, this function should follow the same error handling pattern as handleAcceptInvite (which uses try/finally). Consider adding try/finally or try/catch to handle any unexpected errors:

const validateInviteCode = async (code: string): Promise<boolean> => {
    setIsLoading(true)
    try {
        const res = await invitesApi.validateInviteCode(code)
        return res.success
    } finally {
        setIsLoading(false)
    }
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Invites/JoinWaitlistPage.tsx` around lines 91 - 95, The
validateInviteCode function sets isLoading but can leave it true on unexpected
errors; update validateInviteCode to wrap the API call in try/finally (or
try/catch/finally) so setIsLoading(false) always runs, e.g., setIsLoading(true)
then try { await invitesApi.validateInviteCode(code); return res.success }
finally { setIsLoading(false) }; reference the existing validateInviteCode
function and mirror the defensive pattern used in handleAcceptInvite.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/components/Invites/JoinWaitlistPage.tsx`:
- Around line 151-163: The email input in JoinWaitlistPage lacks an accessible
name; add one by either giving the input an id (e.g., id="join-email") and
adding a corresponding <label htmlFor="join-email">Email address</label> placed
with the descriptive text above, or add aria-label="Email address" directly on
the input. Ensure the change is applied to the input that uses
value={emailValue}, onChange which calls setEmailValue/setEmailError, and
preserves the onKeyDown handler that calls handleEmailSubmit when Enter is
pressed.
- Around line 28-37: The current logic computes initialStep with useMemo and
stores it in local state (initialStep, useMemo, step, setStep, useState), which
freezes the flow to the pre-auth render; replace this with URL-backed state
using useQueryStates from nuqs so step reacts to changes in user and
isPermissionGranted and persists across navigation/refresh. Remove the useMemo +
useState pair and initialize step via useQueryStates with a typed step enum
(1|2|3), update any setters to use the query-state setter, and ensure initial
value logic mirrors: if user?.user.email => isPermissionGranted ? 3 : 2 else 1
so auth hydration updates the URL state.
- Around line 115-119: The handleLogout function sets isLoggingOut true but only
clears it after logoutUser succeeds; if logoutUser rejects the flag remains
true. Wrap the await logoutUser()/router.push('/setup') sequence in try/finally
(or use .finally) so that setIsLoggingOut(false) always runs, keeping references
to handleLogout, setIsLoggingOut, logoutUser and router.push to locate the
change.

---

Nitpick comments:
In `@src/components/Invites/JoinWaitlistPage.tsx`:
- Around line 91-95: The validateInviteCode function sets isLoading but can
leave it true on unexpected errors; update validateInviteCode to wrap the API
call in try/finally (or try/catch/finally) so setIsLoading(false) always runs,
e.g., setIsLoading(true) then try { await invitesApi.validateInviteCode(code);
return res.success } finally { setIsLoading(false) }; reference the existing
validateInviteCode function and mirror the defensive pattern used in
handleAcceptInvite.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4703ae5b-478b-42ea-afdc-75a010e4a791

📥 Commits

Reviewing files that changed from the base of the PR and between efa9424 and 09902c8.

📒 Files selected for processing (1)
  • src/components/Invites/JoinWaitlistPage.tsx

Hugo0 added 2 commits March 10, 2026 15:36
- validateInviteCode: wrap in try/finally so isLoading always clears
- handleLogout: wrap in try/finally so isLoggingOut always clears
- Email input: add aria-label for accessibility
Copy link
Contributor

@kushagrasarathe kushagrasarathe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm, approved, check comments pliz

}
return 1
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: add user and isPermissionGranted to dep array

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — useEffect now includes isFetchingUser in deps and also guards against URL bypass (forces email step when no email on file).

import { useNotifications } from '@/hooks/useNotifications'
import { updateUserById } from '@/app/actions/users'

const isValidEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: regex should go in utils

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — moved to src/utils/format.utils.ts as shared isValidEmail, also deduplicated from withdraw.utils.ts.


{isPermissionGranted && (
{/* Step 2: Enable Notifications (skippable) */}
{step === 2 && (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: no back navigation in steps, i might want to have it if i want to change my email after reaching step 2

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentionally omitted for now — once email is submitted it's saved server-side, so going back to change it would need a different UX (edit vs re-enter). Can add in a follow-up if needed.

{step === 3 && (
<div className="flex h-full flex-col justify-between gap-4 md:gap-10 md:pt-5">
{!isPermissionGranted && (
<div className="flex items-center justify-between rounded-sm border border-n-2 px-3 py-2">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: iirc we dont use rounded-sm, we have hard edges, not sure how this looks in ui, but please check

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rounded-sm is the design system standard per CLAUDE.md (2px radius). It's used across the app on inputs and cards. The ValidatedInput here inherits it from the existing jail screen — no change from before.

- Step 1: email collection (required, server action)
- Step 2: enable notifications (skippable)
- Step 3: jail screen with invite code input (always accessible)

Uses useQueryState from nuqs for step management, matching the
codebase convention for multi-step flows. Step survives refresh,
supports deep-linking, and reacts to auth state changes via useEffect.

Fixes bug where users got permanently stuck on "Enable notifications"
screen when denying permission.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/components/Invites/JoinWaitlistPage.tsx`:
- Around line 71-90: handleEmailSubmit currently calls updateUserById but never
refreshes the user context, causing stale user.user.email; after a successful
update (i.e., result.error is falsy) call and await fetchUser() (the context
refresh function used elsewhere) before calling setStep so the local user state
reflects the new email; handle any fetchUser errors (setEmailError) and ensure
isSubmittingEmail is still toggled in the finally block.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fb6fa601-59fd-4353-afbb-b5521187a251

📥 Commits

Reviewing files that changed from the base of the PR and between 09902c8 and 631020a.

📒 Files selected for processing (2)
  • src/components/Invites/JoinWaitlistPage.tsx
  • src/content
✅ Files skipped from review due to trivial changes (1)
  • src/content

- 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
…x, optimize query

- Use BaseInput component instead of raw <input> 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)
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
src/components/Invites/JoinWaitlistPage.tsx (2)

106-115: Shared isLoading state between validation and acceptance could cause subtle UX confusion.

isLoading is set by both validateInviteCode (debounced validation) and handleAcceptInvite (submission). While the button is disabled during loading which prevents overlap, the shared state means:

  • The "Next" button shows a spinner during background validation (not just submission)
  • If validation completes while user is deciding to click, state changes unexpectedly

Consider using separate flags (e.g., isValidating and isAccepting) for clearer semantics, or keep as-is if the current UX is acceptable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Invites/JoinWaitlistPage.tsx` around lines 106 - 115, The
shared isLoading flag causes background validation (validateInviteCode) to
trigger the same UI state as submission (handleAcceptInvite); introduce two
booleans (isValidating and isAccepting) in the component, update
validateInviteCode to set isValidating true/false, update handleAcceptInvite to
set isAccepting true/false, and change any UI bindings (button disabled state
and spinner rendering) to use the combined state (disabled when isValidating ||
isAccepting) but show the spinner only when isAccepting so validation doesn't
display submission loading.

34-43: Default step computed at hook initialization — may not reflect late-arriving auth state.

The withDefault callback is evaluated once when useQueryState initializes. If user or isPermissionGranted are not yet hydrated (e.g., SSR → client transition), the default will be computed with stale values and persisted to the URL. While the useEffect on lines 64-71 corrects this after hydration, users may briefly see the wrong step or experience a flash.

This is likely acceptable given the useEffect auto-correction, but worth noting if you observe UI flicker during initial load.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Invites/JoinWaitlistPage.tsx` around lines 34 - 43, The
default step is being computed inside the useQueryState withDefault initializer
using potentially stale user/isPermissionGranted values; instead, stop computing
the final default during hook initialization (remove the dynamic withDefault
computation) and initialize step to a neutral default (e.g., 'email' or leave
unset) and rely on the existing effect that runs after auth hydrates to call
setStep(nextStepAfterEmail(isPermissionGranted)) when user and
isPermissionGranted are available; update the code around useQueryState, step,
setStep, and the useEffect that currently corrects the step so the URL-driven
state is only adjusted after reliable auth state (user) and permission
(isPermissionGranted) are present, preventing the early incorrect default from
being persisted.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/components/Invites/JoinWaitlistPage.tsx`:
- Around line 106-115: The shared isLoading flag causes background validation
(validateInviteCode) to trigger the same UI state as submission
(handleAcceptInvite); introduce two booleans (isValidating and isAccepting) in
the component, update validateInviteCode to set isValidating true/false, update
handleAcceptInvite to set isAccepting true/false, and change any UI bindings
(button disabled state and spinner rendering) to use the combined state
(disabled when isValidating || isAccepting) but show the spinner only when
isAccepting so validation doesn't display submission loading.
- Around line 34-43: The default step is being computed inside the useQueryState
withDefault initializer using potentially stale user/isPermissionGranted values;
instead, stop computing the final default during hook initialization (remove the
dynamic withDefault computation) and initialize step to a neutral default (e.g.,
'email' or leave unset) and rely on the existing effect that runs after auth
hydrates to call setStep(nextStepAfterEmail(isPermissionGranted)) when user and
isPermissionGranted are available; update the code around useQueryState, step,
setStep, and the useEffect that currently corrects the step so the URL-driven
state is only adjusted after reliable auth state (user) and permission
(isPermissionGranted) are present, preventing the early incorrect default from
being persisted.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c3cdf7a2-d135-4e7f-ab51-9eadf908ff7b

📥 Commits

Reviewing files that changed from the base of the PR and between 631020a and 6ed0875.

📒 Files selected for processing (3)
  • src/components/Invites/JoinWaitlistPage.tsx
  • src/utils/format.utils.ts
  • src/utils/withdraw.utils.ts

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.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/components/Invites/JoinWaitlistPage.tsx`:
- Around line 64-71: The effect that advances steps (useEffect in
JoinWaitlistPage) only fast-forwards and allows a URL-backed step like
?step=jail to skip the required email step; update the effect to also enforce
the email requirement by checking if user?.user.email is falsy and step !==
'email' then calling setStep('email') so users without an email are always sent
back to the email step, while keeping the existing forward logic (use the same
variables: step, user?.user.email, isPermissionGranted, setStep,
nextStepAfterEmail).
- Around line 106-114: The validateInviteCode function is incorrectly sharing
the global isLoading state with acceptInvite, which lets a finishing validation
clear the accept spinner; change validation to use a separate loading state
(e.g., isValidating) instead of setIsLoading inside validateInviteCode, and
ensure acceptInvite continues to use isLoading (or isAccepting) so their
lifecycles don't interfere; also after invitesApi.acceptInvite() succeeds, call
fetchUser() to refresh user state (replace any direct user state mutation in
acceptInvite with fetchUser())—update references in validateInviteCode,
acceptInvite, and any other invite-related handlers (lines around 117-130 and
249-255) to use the new distinct flags and fetchUser().
- Around line 58-62: The waitlist-position queryKey should include the current
user's id for consistency and cache isolation; update the useQuery call
(queryKey currently ['waitlist-position']) to include user?.user.userId (e.g.,
['waitlist-position', user?.user.userId]) while leaving queryFn as
invitesApi.getWaitlistQueuePosition() and the enabled condition (enabled:
!!user?.user.userId && step === 'jail') unchanged so the cache key matches other
user-specific queries.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 053c5f15-95ac-4668-9b9d-86a5853818b2

📥 Commits

Reviewing files that changed from the base of the PR and between 6ed0875 and 932a9ab.

📒 Files selected for processing (1)
  • src/components/Invites/JoinWaitlistPage.tsx

Hugo0 added 2 commits March 10, 2026 18:44
- 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
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
src/components/Invites/JoinWaitlistPage.tsx (1)

121-135: ⚠️ Potential issue | 🟠 Major

Keep invite acceptance locked until fetchUser() finishes.

Line 127 starts fetchUser() and Line 134 immediately clears isAccepting. If the auth refresh is slow, the jail screen stays mounted and the user can tap Next again, sending a second acceptInvite() request for the same code. Await the refresh before dropping the loading state.

🩹 Suggested fix
 const handleAcceptInvite = async () => {
     setIsAccepting(true)
     try {
         const res = await invitesApi.acceptInvite(inviteCode, inviteType)
         if (res.success) {
             sessionStorage.setItem('showNoMoreJailModal', 'true')
-            fetchUser()
+            await fetchUser()
         } else {
             setError('Something went wrong. Please try again or contact support.')
         }
     } catch {
         setError('Something went wrong. Please try again or contact support.')
Based on learnings: In the JoinWaitlistPage component, after successfully accepting an invite via invitesApi.acceptInvite(), calling fetchUser() is sufficient to update the user state and automatically display the app.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Invites/JoinWaitlistPage.tsx` around lines 121 - 135,
handleAcceptInvite currently calls fetchUser() but does not await it before
clearing the loading flag, allowing duplicate acceptInvite calls; modify
handleAcceptInvite so that after invitesApi.acceptInvite(inviteCode, inviteType)
returns success you await fetchUser() (e.g., await fetchUser()) before calling
setIsAccepting(false), keeping the loading state locked until the auth refresh
finishes; ensure error paths (res.success false and the catch block) still set
isAccepting(false) and setError appropriately.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/components/Invites/JoinWaitlistPage.tsx`:
- Around line 78-79: handleEmailSubmit silently returns if user.user.userId is
missing, and other entry points (the Enter key handler and the submit button)
can bypass loading/validation guards; make all entry points use the same guard
logic: inside handleEmailSubmit, validate isValidEmail(emailValue), ensure
user?.user.userId exists, and return if isSubmittingEmail is true; mirror that
exact guard in the Enter/onKeyDown handler and the button onClick path (and
ensure the button's disabled prop uses the same conditions:
!isValidEmail(emailValue) || isSubmittingEmail || !user?.user.userId); also
preventDefault in the form submit/Enter handler before checking guards to avoid
duplicate requests.
- Around line 99-107: The jail view never knows the user skipped/denied
notifications; update the flow to record that and render the reminder when step
=== 'jail'. Add a boolean state like notificationsSkipped via useState, set it
to true inside the catch block of handleEnableNotifications (and also set it
true from the "Skip" click handler used in the Step 2 branch), and set it false
when permission is successfully granted (e.g., afterPermissionAttempt resolves).
Then update the jail rendering (the component branch that checks step ===
'jail') to conditionally show the skipped-notifications banner/CTA when
notificationsSkipped is true. Ensure afterPermissionAttempt does not overwrite
notificationsSkipped unless permission succeeded.

---

Duplicate comments:
In `@src/components/Invites/JoinWaitlistPage.tsx`:
- Around line 121-135: handleAcceptInvite currently calls fetchUser() but does
not await it before clearing the loading flag, allowing duplicate acceptInvite
calls; modify handleAcceptInvite so that after
invitesApi.acceptInvite(inviteCode, inviteType) returns success you await
fetchUser() (e.g., await fetchUser()) before calling setIsAccepting(false),
keeping the loading state locked until the auth refresh finishes; ensure error
paths (res.success false and the catch block) still set isAccepting(false) and
setError appropriately.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: cb50d6a8-c51d-40ab-9b37-16fd70edc1b2

📥 Commits

Reviewing files that changed from the base of the PR and between 932a9ab and 588f52b.

📒 Files selected for processing (1)
  • src/components/Invites/JoinWaitlistPage.tsx

Add isSubmittingEmail check to the function itself so Enter key
can't bypass the loading guard and fire duplicate requests.
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.
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.
@Hugo0 Hugo0 merged commit cc42b35 into main Mar 10, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants