diff --git a/src/components/Invites/JoinWaitlistPage.tsx b/src/components/Invites/JoinWaitlistPage.tsx index 789cecc5a..3df66a37a 100644 --- a/src/components/Invites/JoinWaitlistPage.tsx +++ b/src/components/Invites/JoinWaitlistPage.tsx @@ -15,35 +15,141 @@ 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' +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 [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() + // URL-backed step state — survives refresh, enables deep-linking + const [step, setStep] = useQueryState( + 'step', + parseAsStringEnum(['email', 'notifications', 'jail']).withDefault( + (() => { + if (user?.user.email) return nextStepAfterEmail(isPermissionGranted) + return 'email' + })() + ) + ) + + // 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 [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, + enabled: !!user?.user.userId && step === 'jail', }) - const validateInviteCode = async (inviteCode: string): Promise => { - setisLoading(true) - const res = await invitesApi.validateInviteCode(inviteCode) - setisLoading(false) - return res.success + // 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 && !emailStepDone) { + setStep('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, 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 () => { + 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('') + + try { + const result = await updateUserById({ userId: user.user.userId, email: emailValue }) + if (result.error) { + setEmailError(result.error) + return + } + + 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 + } + + // 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) + setEmailError('Something went wrong. Please try again or skip this step.') + } finally { + setIsSubmittingEmail(false) + } + } + + const handleSkipEmail = () => { + setEmailStepDone(true) + setStep(nextStepAfterEmail(isPermissionGranted)) + } + + // 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('jail') + } + + // Step 3: Validate and accept invite code (separate loading states to avoid race) + const validateInviteCode = async (code: string): Promise => { + setIsValidating(true) + try { + const res = await invitesApi.validateInviteCode(code) + return res.success + } finally { + setIsValidating(false) + } } const handleAcceptInvite = async () => { - setisLoading(true) + setIsAccepting(true) try { const res = await invitesApi.acceptInvite(inviteCode, inviteType) if (res.success) { @@ -55,15 +161,19 @@ const JoinWaitlistPage = () => { } catch { setError('Something went wrong. Please try again or contact support.') } finally { - setisLoading(false) + setIsAccepting(false) } } const handleLogout = async () => { - setisLoggingOut(true) - await logoutUser() - router.push('/setup') - setisLoggingOut(false) + setIsLoggingOut(true) + try { + await logoutUser() + router.push('/setup') + } finally { + setIsLoggingOut(false) + setError('') + } } useEffect(() => { @@ -72,37 +182,79 @@ const JoinWaitlistPage = () => { } }, [isFetchingUser, user, router]) - if (isLoadingWaitlistPosition) { - return - } + const stepImage = step === 'jail' ? peanutAnim.src : chillPeanutAnim.src return ( - +
- {!isPermissionGranted && ( + {/* Step 1: Email Collection */} + {step === 'email' && (
-

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. +

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

Want instant updates?

+

We'll notify you the moment you get access.

+ + + +
)} - {isPermissionGranted && ( + {/* Step 3: Jail Screen */} + {step === 'jail' && isLoadingWaitlistPosition && } + {step === 'jail' && !isLoadingWaitlistPosition && (

You're still in Peanut jail

@@ -137,22 +289,19 @@ const JoinWaitlistPage = () => {
{!isValid && !isChanging && !!inviteCode && ( - + )} - {/* Show error from the API call */} {error && }