Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
231 changes: 190 additions & 41 deletions src/components/Invites/JoinWaitlistPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<WaitlistStep>(['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<boolean> => {
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<boolean> => {
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) {
Expand All @@ -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(() => {
Expand All @@ -72,37 +182,79 @@ const JoinWaitlistPage = () => {
}
}, [isFetchingUser, user, router])

if (isLoadingWaitlistPosition) {
return <PeanutLoading coverFullScreen />
}
const stepImage = step === 'jail' ? peanutAnim.src : chillPeanutAnim.src

return (
<InvitesPageLayout image={isPermissionGranted ? peanutAnim.src : chillPeanutAnim.src}>
<InvitesPageLayout image={stepImage}>
<div
className={twMerge(
'flex flex-grow flex-col justify-between overflow-hidden bg-white px-6 pb-8 pt-6 md:h-[100dvh] md:justify-center md:space-y-4',
'flex flex-col items-end justify-center gap-5 pt-8 '
'flex flex-col items-end justify-center gap-5 pt-8'
)}
>
<div className="mx-auto w-full md:max-w-xs">
{!isPermissionGranted && (
{/* Step 1: Email Collection */}
{step === 'email' && (
<div className="flex h-full flex-col justify-between gap-4 md:gap-10 md:pt-5">
<h1 className="text-xl font-extrabold">Enable notifications</h1>
<p className="text-base font-medium">We'll send you an update as soon as you get access.</p>
<h1 className="text-xl font-extrabold">Stay in the loop</h1>
<p className="text-base font-medium">
Enter your email so we can reach you when you get access.
</p>

<Button
onClick={async () => {
await requestPermission()
await afterPermissionAttempt()
<BaseInput
type="email"
variant="sm"
aria-label="Email address"
placeholder="you@example.com"
value={emailValue}
onChange={(e) => {
setEmailValue(e.target.value)
setEmailError('')
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && isValidEmail(emailValue)) handleEmailSubmit()
}}
className="h-12"
/>

{emailError && <ErrorAlert description={emailError} />}

<Button
shadowSize="4"
onClick={handleEmailSubmit}
disabled={!isValidEmail(emailValue) || isSubmittingEmail}
loading={isSubmittingEmail}
>
Yes, notify me
Continue
</Button>

{emailError && (
<button onClick={handleSkipEmail} className="text-sm underline">
Skip for now
</button>
)}
</div>
)}

{/* Step 2: Enable Notifications (skippable) */}
{step === 'notifications' && (
<div className="flex h-full flex-col justify-between gap-4 md:gap-10 md:pt-5">
<h1 className="text-xl font-extrabold">Want instant updates?</h1>
<p className="text-base font-medium">We&apos;ll notify you the moment you get access.</p>

<Button shadowSize="4" onClick={handleEnableNotifications}>
Enable notifications
</Button>

<button onClick={() => setStep('jail')} className="text-sm underline">
Not now
</button>
</div>
)}

{isPermissionGranted && (
{/* Step 3: Jail Screen */}
{step === 'jail' && isLoadingWaitlistPosition && <PeanutLoading coverFullScreen />}
{step === 'jail' && !isLoadingWaitlistPosition && (
<div className="flex h-full flex-col justify-between gap-4 md:gap-10 md:pt-5">
<h1 className="text-xl font-extrabold">You&apos;re still in Peanut jail</h1>

Expand Down Expand Up @@ -137,22 +289,19 @@ const JoinWaitlistPage = () => {

<Button
className="h-12 w-4/12"
loading={isLoading}
loading={isAccepting}
shadowSize="4"
onClick={() => {
handleAcceptInvite()
}}
disabled={!isValid || isChanging || isLoading}
onClick={handleAcceptInvite}
disabled={!isValid || isChanging || isValidating || isAccepting}
>
Next
</Button>
</div>

{!isValid && !isChanging && !!inviteCode && (
<ErrorAlert description="This code wont take you out of jail. Try another one!" />
<ErrorAlert description="This code won't take you out of jail. Try another one!" />
)}

{/* Show error from the API call */}
{error && <ErrorAlert description={error} />}

<button onClick={handleLogout} className="text-sm underline">
Expand Down
2 changes: 2 additions & 0 deletions src/utils/format.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,5 @@ export const formatCurrencyWithIntl = (
return numericValue.toFixed(minDigits)
}
}

export const isValidEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
4 changes: 2 additions & 2 deletions src/utils/withdraw.utils.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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)' }
}
Expand Down
Loading