From 20b70c06c1a40bbe61ead1cf7adaf965d1956752 Mon Sep 17 00:00:00 2001 From: Jose Selesan Date: Fri, 17 Apr 2026 16:17:19 -0300 Subject: [PATCH] feat: refactor onboarding into a multi-step flow with dedicated routes and session state management --- .../onboarding/services/onboarding.service.ts | 5 + .../onboard/onboard-complete-page.tsx | 119 ++++++ .../src/features/onboard/onboard-page.tsx | 365 ------------------ .../features/onboard/onboard-start-page.tsx | 79 ++++ .../features/onboard/onboard-verify-page.tsx | 169 ++++++++ .../features/onboard/onboarding-session.ts | 140 +++++++ apps/frontend/src/lib/auth.tsx | 17 +- apps/frontend/src/routeTree.gen.ts | 82 +++- apps/frontend/src/routes/onboard.tsx | 5 +- apps/frontend/src/routes/onboard/complete.tsx | 6 + apps/frontend/src/routes/onboard/index.tsx | 6 + apps/frontend/src/routes/onboard/verify.tsx | 6 + 12 files changed, 617 insertions(+), 382 deletions(-) create mode 100644 apps/frontend/src/features/onboard/onboard-complete-page.tsx delete mode 100644 apps/frontend/src/features/onboard/onboard-page.tsx create mode 100644 apps/frontend/src/features/onboard/onboard-start-page.tsx create mode 100644 apps/frontend/src/features/onboard/onboard-verify-page.tsx create mode 100644 apps/frontend/src/features/onboard/onboarding-session.ts create mode 100644 apps/frontend/src/routes/onboard/complete.tsx create mode 100644 apps/frontend/src/routes/onboard/index.tsx create mode 100644 apps/frontend/src/routes/onboard/verify.tsx diff --git a/apps/backend/src/modules/onboarding/services/onboarding.service.ts b/apps/backend/src/modules/onboarding/services/onboarding.service.ts index d28410d..9af4550 100644 --- a/apps/backend/src/modules/onboarding/services/onboarding.service.ts +++ b/apps/backend/src/modules/onboarding/services/onboarding.service.ts @@ -368,6 +368,11 @@ export async function completeOnboarding(input: OnboardingCompleteInput) { const complexSlug = await buildUniqueComplexSlug(input.complexName); const result = await db.$transaction(async (tx) => { + await tx.user.update({ + where: { id: user.id }, + data: { emailVerified: true }, + }); + const complex = await tx.complex.create({ data: { id: uuidv7(), diff --git a/apps/frontend/src/features/onboard/onboard-complete-page.tsx b/apps/frontend/src/features/onboard/onboard-complete-page.tsx new file mode 100644 index 0000000..ead1b36 --- /dev/null +++ b/apps/frontend/src/features/onboard/onboard-complete-page.tsx @@ -0,0 +1,119 @@ +import { OnboardingCompleteStep } from '@/features/onboard/components/onboarding-complete-step'; +import { OnboardingLayout } from '@/features/onboard/components/onboarding-layout'; +import { + clearOnboardingSessionState, + getOnboardingSessionState, + updateOnboardingSessionState, +} from '@/features/onboard/onboarding-session'; +import type { CompleteValues } from '@/features/onboard/onboarding.types'; +import { apiClient } from '@/lib/api-client'; +import { useAuth } from '@/lib/auth'; +import { setCurrentComplexSlug } from '@/lib/current-complex'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { type PlanSummary, onboardingCompleteSchema } from '@repo/api-contract'; +import { useNavigate } from '@tanstack/react-router'; +import { useEffect, useMemo, useState } from 'react'; +import { useForm, useWatch } from 'react-hook-form'; + +export function OnboardCompletePage() { + const navigate = useNavigate(); + const { signInWithPassword } = useAuth(); + const initialState = useMemo(() => getOnboardingSessionState(), []); + + const [requestId] = useState(initialState.requestId); + const [verifiedEmail] = useState(initialState.verifiedEmail); + const [onboardingEmail] = useState(initialState.onboardingEmail); + const [plans, setPlans] = useState([]); + const [errorMessage, setErrorMessage] = useState(null); + + const form = useForm({ + resolver: zodResolver(onboardingCompleteSchema.omit({ onboardingRequestId: true })), + mode: 'onChange', + defaultValues: initialState.completeForm, + }); + const values = useWatch({ control: form.control }); + + useEffect(() => { + if (!requestId) { + void navigate({ to: '/onboard' }); + return; + } + + if (!verifiedEmail) { + void navigate({ to: '/onboard/verify' }); + } + }, [navigate, requestId, verifiedEmail]); + + useEffect(() => { + updateOnboardingSessionState((current) => ({ + ...current, + completeForm: { + password: values.password ?? '', + complexName: values.complexName ?? '', + physicalAddress: values.physicalAddress ?? '', + planCode: values.planCode ?? '', + }, + })); + }, [values.complexName, values.password, values.physicalAddress, values.planCode]); + + useEffect(() => { + void (async () => { + try { + const result = await apiClient.plans.list(); + setPlans(result); + } catch { + setPlans([]); + } + })(); + }, []); + + const onSubmit = async (payload: CompleteValues) => { + if (!requestId) { + setErrorMessage('No encontramos una solicitud de onboarding activa.'); + return; + } + + setErrorMessage(null); + + try { + const result = await apiClient.onboarding.complete({ + onboardingRequestId: requestId, + password: payload.password, + complexName: payload.complexName, + physicalAddress: payload.physicalAddress, + planCode: payload.planCode, + }); + + setCurrentComplexSlug(result.complexSlug); + const emailForSignIn = verifiedEmail ?? onboardingEmail; + if (emailForSignIn) { + await signInWithPassword({ email: emailForSignIn, password: payload.password }); + } + + clearOnboardingSessionState(); + await navigate({ to: '/complex/$slug/edit', params: { slug: result.complexSlug } }); + } catch { + setErrorMessage('No pudimos completar el onboarding. Intenta nuevamente.'); + } + }; + + const planPlaceholder = + plans.length === 0 ? 'Codigo del plan (por ejemplo: BASIC)' : 'Selecciona un plan'; + + return ( + + + + ); +} diff --git a/apps/frontend/src/features/onboard/onboard-page.tsx b/apps/frontend/src/features/onboard/onboard-page.tsx deleted file mode 100644 index f1f7dff..0000000 --- a/apps/frontend/src/features/onboard/onboard-page.tsx +++ /dev/null @@ -1,365 +0,0 @@ -import { OnboardingCompleteStep } from '@/features/onboard/components/onboarding-complete-step'; -import { OnboardingLayout } from '@/features/onboard/components/onboarding-layout'; -import { OnboardingStartStep } from '@/features/onboard/components/onboarding-start-step'; -import { OnboardingVerifyOtpStep } from '@/features/onboard/components/onboarding-verify-otp-step'; -import type { - CompleteValues, - StartValues, - VerifyOtpValues, -} from '@/features/onboard/onboarding.types'; -import { apiClient } from '@/lib/api-client'; -import { useAuth } from '@/lib/auth'; -import { setCurrentComplexSlug } from '@/lib/current-complex'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { - type PlanSummary, - onboardingCompleteSchema, - onboardingStartSchema, - onboardingVerifyOtpSchema, -} from '@repo/api-contract'; -import { useNavigate } from '@tanstack/react-router'; -import { useEffect, useMemo, useState } from 'react'; -import { useForm, useWatch } from 'react-hook-form'; - -const OTP_MAX_ATTEMPTS = 5; -type OnboardStep = 'start' | 'verify-otp' | 'complete'; -const ONBOARD_SESSION_KEY = 'playzer:onboarding-session'; - -type OnboardSessionState = { - step: OnboardStep; - requestId: string | null; - onboardingEmail: string | null; - verifiedEmail: string | null; - remainingAttempts: number; - resendCooldownSeconds: number; - startForm: StartValues; - verifyOtpForm: VerifyOtpValues; - completeForm: CompleteValues; -}; - -function isOnboardStep(value: unknown): value is OnboardStep { - return value === 'start' || value === 'verify-otp' || value === 'complete'; -} - -function readOnboardSession(): OnboardSessionState | null { - if (typeof window === 'undefined') return null; - - const raw = window.sessionStorage.getItem(ONBOARD_SESSION_KEY); - if (!raw) return null; - - try { - const parsed = JSON.parse(raw) as Partial; - if (!isOnboardStep(parsed.step)) return null; - - return { - step: parsed.step, - requestId: typeof parsed.requestId === 'string' ? parsed.requestId : null, - onboardingEmail: typeof parsed.onboardingEmail === 'string' ? parsed.onboardingEmail : null, - verifiedEmail: typeof parsed.verifiedEmail === 'string' ? parsed.verifiedEmail : null, - remainingAttempts: - typeof parsed.remainingAttempts === 'number' - ? parsed.remainingAttempts - : OTP_MAX_ATTEMPTS, - resendCooldownSeconds: - typeof parsed.resendCooldownSeconds === 'number' ? parsed.resendCooldownSeconds : 0, - startForm: { - fullName: parsed.startForm?.fullName ?? '', - email: parsed.startForm?.email ?? '', - }, - verifyOtpForm: { - otp: parsed.verifyOtpForm?.otp ?? '', - }, - completeForm: { - password: parsed.completeForm?.password ?? '', - complexName: parsed.completeForm?.complexName ?? '', - physicalAddress: parsed.completeForm?.physicalAddress ?? '', - planCode: parsed.completeForm?.planCode ?? '', - }, - }; - } catch { - return null; - } -} - -function clearOnboardSession() { - if (typeof window === 'undefined') return; - window.sessionStorage.removeItem(ONBOARD_SESSION_KEY); -} - -export function OnboardPage() { - const navigate = useNavigate(); - const { signInWithPassword } = useAuth(); - - const [step, setStep] = useState('start'); - const [requestId, setRequestId] = useState(null); - const [onboardingEmail, setOnboardingEmail] = useState(null); - const [verifiedEmail, setVerifiedEmail] = useState(null); - const [plans, setPlans] = useState([]); - const [remainingAttempts, setRemainingAttempts] = useState(OTP_MAX_ATTEMPTS); - const [resendCooldownSeconds, setResendCooldownSeconds] = useState(0); - const [isResending, setIsResending] = useState(false); - const [errorMessage, setErrorMessage] = useState(null); - const [infoMessage, setInfoMessage] = useState(null); - const [isSessionRestored, setIsSessionRestored] = useState(false); - - const startForm = useForm({ - resolver: zodResolver(onboardingStartSchema), - mode: 'onChange', - defaultValues: { - fullName: '', - email: '', - }, - }); - - const completeForm = useForm({ - resolver: zodResolver(onboardingCompleteSchema.omit({ onboardingRequestId: true })), - mode: 'onChange', - defaultValues: { - password: '', - complexName: '', - physicalAddress: '', - planCode: '', - }, - }); - - const verifyOtpForm = useForm({ - resolver: zodResolver(onboardingVerifyOtpSchema.omit({ requestId: true })), - mode: 'onChange', - defaultValues: { - otp: '', - }, - }); - - const startValues = useWatch({ control: startForm.control }); - const verifyOtpValues = useWatch({ control: verifyOtpForm.control }); - const completeValues = useWatch({ control: completeForm.control }); - - useEffect(() => { - const savedSession = readOnboardSession(); - if (!savedSession) { - setIsSessionRestored(true); - return; - } - - setStep(savedSession.step); - setRequestId(savedSession.requestId); - setOnboardingEmail(savedSession.onboardingEmail); - setVerifiedEmail(savedSession.verifiedEmail); - setRemainingAttempts(savedSession.remainingAttempts); - setResendCooldownSeconds(savedSession.resendCooldownSeconds); - startForm.reset(savedSession.startForm); - verifyOtpForm.reset(savedSession.verifyOtpForm); - completeForm.reset(savedSession.completeForm); - setIsSessionRestored(true); - }, [startForm, verifyOtpForm, completeForm]); - - useEffect(() => { - if (!isSessionRestored || typeof window === 'undefined') return; - - const sessionState: OnboardSessionState = { - step, - requestId, - onboardingEmail, - verifiedEmail, - remainingAttempts, - resendCooldownSeconds, - startForm: { - fullName: startValues.fullName ?? '', - email: startValues.email ?? '', - }, - verifyOtpForm: { - otp: verifyOtpValues.otp ?? '', - }, - completeForm: { - password: completeValues.password ?? '', - complexName: completeValues.complexName ?? '', - physicalAddress: completeValues.physicalAddress ?? '', - planCode: completeValues.planCode ?? '', - }, - }; - - window.sessionStorage.setItem(ONBOARD_SESSION_KEY, JSON.stringify(sessionState)); - }, [ - completeValues, - isSessionRestored, - onboardingEmail, - remainingAttempts, - requestId, - resendCooldownSeconds, - startValues, - step, - verifiedEmail, - verifyOtpValues, - ]); - - useEffect(() => { - if (step !== 'complete') return; - - void (async () => { - try { - const result = await apiClient.plans.list(); - setPlans(result); - } catch { - setPlans([]); - } - })(); - }, [step]); - - const planPlaceholder = useMemo(() => { - if (plans.length === 0) return 'Codigo del plan (por ejemplo: BASIC)'; - return 'Selecciona un plan'; - }, [plans.length]); - - useEffect(() => { - if (resendCooldownSeconds <= 0) return; - - const timeout = window.setTimeout(() => { - setResendCooldownSeconds((current) => Math.max(0, current - 1)); - }, 1000); - - return () => { - window.clearTimeout(timeout); - }; - }, [resendCooldownSeconds]); - - const onSubmitStart = async (values: StartValues) => { - setErrorMessage(null); - setInfoMessage(null); - - try { - const result = await apiClient.onboarding.start(values); - setRequestId(result.requestId); - setOnboardingEmail(result.email); - setRemainingAttempts(OTP_MAX_ATTEMPTS); - setResendCooldownSeconds(result.cooldownSeconds); - verifyOtpForm.reset({ otp: '' }); - setStep('verify-otp'); - setInfoMessage(result.message); - } catch { - setErrorMessage('No pudimos iniciar el onboarding. Intenta nuevamente.'); - } - }; - - const onSubmitVerifyOtp = async (values: VerifyOtpValues) => { - if (!requestId) { - setErrorMessage('No encontramos una solicitud de onboarding activa.'); - return; - } - - setErrorMessage(null); - - try { - const result = await apiClient.onboarding.verifyOtp({ - requestId, - otp: values.otp, - }); - - setRemainingAttempts(result.remainingAttempts); - setResendCooldownSeconds(result.cooldownSeconds); - setInfoMessage(result.message); - - if (result.verified) { - setVerifiedEmail(result.email ?? onboardingEmail); - setStep('complete'); - return; - } - } catch { - setErrorMessage('No pudimos verificar el OTP. Intenta nuevamente.'); - } - }; - - const onResendOtp = async () => { - if (!requestId) { - setErrorMessage('No encontramos una solicitud de onboarding activa.'); - return; - } - - setIsResending(true); - setErrorMessage(null); - - try { - const result = await apiClient.onboarding.resendOtp({ requestId }); - setOnboardingEmail(result.email); - setRemainingAttempts(result.remainingAttempts); - setResendCooldownSeconds(result.cooldownSeconds); - verifyOtpForm.reset({ otp: '' }); - setInfoMessage(result.message); - } catch { - setErrorMessage('No pudimos reenviar el OTP. Intenta nuevamente.'); - } finally { - setIsResending(false); - } - }; - - const onSubmitComplete = async (values: CompleteValues) => { - if (!requestId) { - setErrorMessage('No encontramos una solicitud de onboarding activa.'); - return; - } - - setErrorMessage(null); - - try { - const result = await apiClient.onboarding.complete({ - onboardingRequestId: requestId, - password: values.password, - complexName: values.complexName, - physicalAddress: values.physicalAddress, - planCode: values.planCode, - }); - setCurrentComplexSlug(result.complexSlug); - - const emailForSignIn = verifiedEmail ?? onboardingEmail; - if (emailForSignIn) { - await signInWithPassword({ email: emailForSignIn, password: values.password }); - } - - clearOnboardSession(); - await navigate({ to: '/complex/$slug/edit', params: { slug: result.complexSlug } }); - } catch { - setErrorMessage('No pudimos completar el onboarding. Intenta nuevamente.'); - } - }; - - return ( - - {step === 'start' && ( - - )} - - {step === 'verify-otp' && ( - - )} - - {step === 'complete' && ( - - )} - - ); -} diff --git a/apps/frontend/src/features/onboard/onboard-start-page.tsx b/apps/frontend/src/features/onboard/onboard-start-page.tsx new file mode 100644 index 0000000..8bd642b --- /dev/null +++ b/apps/frontend/src/features/onboard/onboard-start-page.tsx @@ -0,0 +1,79 @@ +import { OnboardingLayout } from '@/features/onboard/components/onboarding-layout'; +import { OnboardingStartStep } from '@/features/onboard/components/onboarding-start-step'; +import { + OTP_MAX_ATTEMPTS, + defaultCompleteFormValues, + defaultVerifyOtpFormValues, + getOnboardingSessionState, + updateOnboardingSessionState, +} from '@/features/onboard/onboarding-session'; +import type { StartValues } from '@/features/onboard/onboarding.types'; +import { apiClient } from '@/lib/api-client'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { onboardingStartSchema } from '@repo/api-contract'; +import { useNavigate } from '@tanstack/react-router'; +import { useEffect, useMemo, useState } from 'react'; +import { useForm, useWatch } from 'react-hook-form'; + +export function OnboardStartPage() { + const navigate = useNavigate(); + const [errorMessage, setErrorMessage] = useState(null); + const [infoMessage, setInfoMessage] = useState(null); + const initialState = useMemo(() => getOnboardingSessionState(), []); + + const form = useForm({ + resolver: zodResolver(onboardingStartSchema), + mode: 'onChange', + defaultValues: initialState.startForm, + }); + const values = useWatch({ control: form.control }); + + useEffect(() => { + updateOnboardingSessionState((current) => ({ + ...current, + startForm: { + fullName: values.fullName ?? '', + email: values.email ?? '', + }, + })); + }, [values.email, values.fullName]); + + const onSubmit = async (payload: StartValues) => { + setErrorMessage(null); + setInfoMessage(null); + + try { + const result = await apiClient.onboarding.start(payload); + + updateOnboardingSessionState((current) => ({ + ...current, + requestId: result.requestId, + onboardingEmail: result.email, + verifiedEmail: null, + remainingAttempts: OTP_MAX_ATTEMPTS, + resendCooldownSeconds: result.cooldownSeconds, + startForm: { + fullName: payload.fullName, + email: payload.email, + }, + verifyOtpForm: defaultVerifyOtpFormValues, + completeForm: defaultCompleteFormValues, + })); + + await navigate({ to: '/onboard/verify' }); + } catch { + setErrorMessage('No pudimos iniciar el onboarding. Intenta nuevamente.'); + } + }; + + return ( + + + + ); +} diff --git a/apps/frontend/src/features/onboard/onboard-verify-page.tsx b/apps/frontend/src/features/onboard/onboard-verify-page.tsx new file mode 100644 index 0000000..73ae5d2 --- /dev/null +++ b/apps/frontend/src/features/onboard/onboard-verify-page.tsx @@ -0,0 +1,169 @@ +import { Button } from '@/components/ui/button'; +import { OnboardingLayout } from '@/features/onboard/components/onboarding-layout'; +import { OnboardingVerifyOtpStep } from '@/features/onboard/components/onboarding-verify-otp-step'; +import { + OTP_MAX_ATTEMPTS, + getOnboardingSessionState, + updateOnboardingSessionState, +} from '@/features/onboard/onboarding-session'; +import type { VerifyOtpValues } from '@/features/onboard/onboarding.types'; +import { apiClient } from '@/lib/api-client'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { onboardingVerifyOtpSchema } from '@repo/api-contract'; +import { useNavigate } from '@tanstack/react-router'; +import { useEffect, useMemo, useState } from 'react'; +import { useForm, useWatch } from 'react-hook-form'; + +export function OnboardVerifyPage() { + const navigate = useNavigate(); + const initialState = useMemo(() => getOnboardingSessionState(), []); + const [requestId] = useState(initialState.requestId); + const [onboardingEmail, setOnboardingEmail] = useState( + initialState.onboardingEmail + ); + const [remainingAttempts, setRemainingAttempts] = useState( + initialState.remainingAttempts || OTP_MAX_ATTEMPTS + ); + const [resendCooldownSeconds, setResendCooldownSeconds] = useState( + initialState.resendCooldownSeconds || 0 + ); + const [isResending, setIsResending] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [infoMessage, setInfoMessage] = useState(null); + + const form = useForm({ + resolver: zodResolver(onboardingVerifyOtpSchema.omit({ requestId: true })), + mode: 'onChange', + defaultValues: initialState.verifyOtpForm, + }); + const values = useWatch({ control: form.control }); + + useEffect(() => { + if (requestId && onboardingEmail) return; + void navigate({ to: '/onboard' }); + }, [navigate, onboardingEmail, requestId]); + + useEffect(() => { + updateOnboardingSessionState((current) => ({ + ...current, + onboardingEmail, + remainingAttempts, + resendCooldownSeconds, + verifyOtpForm: { + otp: values.otp ?? '', + }, + })); + }, [onboardingEmail, remainingAttempts, resendCooldownSeconds, values.otp]); + + useEffect(() => { + if (resendCooldownSeconds <= 0) return; + + const timeout = window.setTimeout(() => { + setResendCooldownSeconds((current) => Math.max(0, current - 1)); + }, 1000); + + return () => { + window.clearTimeout(timeout); + }; + }, [resendCooldownSeconds]); + + const onSubmit = async (payload: VerifyOtpValues) => { + if (!requestId) { + setErrorMessage('No encontramos una solicitud de onboarding activa.'); + return; + } + + setErrorMessage(null); + + try { + const result = await apiClient.onboarding.verifyOtp({ + requestId, + otp: payload.otp, + }); + + setRemainingAttempts(result.remainingAttempts); + setResendCooldownSeconds(result.cooldownSeconds); + setInfoMessage(result.message); + + updateOnboardingSessionState((current) => ({ + ...current, + remainingAttempts: result.remainingAttempts, + resendCooldownSeconds: result.cooldownSeconds, + })); + + if (result.verified) { + const nextVerifiedEmail = result.email ?? onboardingEmail; + + updateOnboardingSessionState((current) => ({ + ...current, + verifiedEmail: nextVerifiedEmail, + remainingAttempts: result.remainingAttempts, + resendCooldownSeconds: result.cooldownSeconds, + })); + + await navigate({ to: '/onboard/complete' }); + } + } catch { + setErrorMessage('No pudimos verificar el OTP. Intenta nuevamente.'); + } + }; + + const onResend = async () => { + if (!requestId) { + setErrorMessage('No encontramos una solicitud de onboarding activa.'); + return; + } + + setIsResending(true); + setErrorMessage(null); + + try { + const result = await apiClient.onboarding.resendOtp({ requestId }); + setOnboardingEmail(result.email); + setRemainingAttempts(result.remainingAttempts); + setResendCooldownSeconds(result.cooldownSeconds); + form.reset({ otp: '' }); + setInfoMessage(result.message); + + updateOnboardingSessionState((current) => ({ + ...current, + onboardingEmail: result.email, + remainingAttempts: result.remainingAttempts, + resendCooldownSeconds: result.cooldownSeconds, + verifyOtpForm: { otp: '' }, + })); + } catch { + setErrorMessage('No pudimos reenviar el OTP. Intenta nuevamente.'); + } finally { + setIsResending(false); + } + }; + + return ( + + + + + ); +} diff --git a/apps/frontend/src/features/onboard/onboarding-session.ts b/apps/frontend/src/features/onboard/onboarding-session.ts new file mode 100644 index 0000000..453df75 --- /dev/null +++ b/apps/frontend/src/features/onboard/onboarding-session.ts @@ -0,0 +1,140 @@ +import type { + CompleteValues, + StartValues, + VerifyOtpValues, +} from '@/features/onboard/onboarding.types'; + +const ONBOARDING_SESSION_KEY = 'playzer:onboarding-session'; +export const OTP_MAX_ATTEMPTS = 5; + +export const defaultStartFormValues: StartValues = { + fullName: '', + email: '', +}; + +export const defaultVerifyOtpFormValues: VerifyOtpValues = { + otp: '', +}; + +export const defaultCompleteFormValues: CompleteValues = { + password: '', + complexName: '', + physicalAddress: '', + planCode: '', +}; + +export type OnboardingSessionState = { + requestId: string | null; + onboardingEmail: string | null; + verifiedEmail: string | null; + remainingAttempts: number; + resendCooldownSeconds: number; + startForm: StartValues; + verifyOtpForm: VerifyOtpValues; + completeForm: CompleteValues; +}; + +const defaultOnboardingSessionState: OnboardingSessionState = { + requestId: null, + onboardingEmail: null, + verifiedEmail: null, + remainingAttempts: OTP_MAX_ATTEMPTS, + resendCooldownSeconds: 0, + startForm: defaultStartFormValues, + verifyOtpForm: defaultVerifyOtpFormValues, + completeForm: defaultCompleteFormValues, +}; + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function normalizeSessionState(value: unknown): OnboardingSessionState { + if (!isObject(value)) { + return defaultOnboardingSessionState; + } + + return { + requestId: typeof value.requestId === 'string' ? value.requestId : null, + onboardingEmail: typeof value.onboardingEmail === 'string' ? value.onboardingEmail : null, + verifiedEmail: typeof value.verifiedEmail === 'string' ? value.verifiedEmail : null, + remainingAttempts: + typeof value.remainingAttempts === 'number' + ? value.remainingAttempts + : defaultOnboardingSessionState.remainingAttempts, + resendCooldownSeconds: + typeof value.resendCooldownSeconds === 'number' + ? value.resendCooldownSeconds + : defaultOnboardingSessionState.resendCooldownSeconds, + startForm: { + fullName: + isObject(value.startForm) && typeof value.startForm.fullName === 'string' + ? value.startForm.fullName + : defaultStartFormValues.fullName, + email: + isObject(value.startForm) && typeof value.startForm.email === 'string' + ? value.startForm.email + : defaultStartFormValues.email, + }, + verifyOtpForm: { + otp: + isObject(value.verifyOtpForm) && typeof value.verifyOtpForm.otp === 'string' + ? value.verifyOtpForm.otp + : defaultVerifyOtpFormValues.otp, + }, + completeForm: { + password: + isObject(value.completeForm) && typeof value.completeForm.password === 'string' + ? value.completeForm.password + : defaultCompleteFormValues.password, + complexName: + isObject(value.completeForm) && typeof value.completeForm.complexName === 'string' + ? value.completeForm.complexName + : defaultCompleteFormValues.complexName, + physicalAddress: + isObject(value.completeForm) && typeof value.completeForm.physicalAddress === 'string' + ? value.completeForm.physicalAddress + : defaultCompleteFormValues.physicalAddress, + planCode: + isObject(value.completeForm) && typeof value.completeForm.planCode === 'string' + ? value.completeForm.planCode + : defaultCompleteFormValues.planCode, + }, + }; +} + +export function getOnboardingSessionState(): OnboardingSessionState { + if (typeof window === 'undefined') { + return defaultOnboardingSessionState; + } + + const rawState = window.sessionStorage.getItem(ONBOARDING_SESSION_KEY); + if (!rawState) { + return defaultOnboardingSessionState; + } + + try { + return normalizeSessionState(JSON.parse(rawState)); + } catch { + return defaultOnboardingSessionState; + } +} + +export function setOnboardingSessionState(state: OnboardingSessionState) { + if (typeof window === 'undefined') return; + window.sessionStorage.setItem(ONBOARDING_SESSION_KEY, JSON.stringify(state)); +} + +export function updateOnboardingSessionState( + updater: (current: OnboardingSessionState) => OnboardingSessionState +): OnboardingSessionState { + const currentState = getOnboardingSessionState(); + const nextState = updater(currentState); + setOnboardingSessionState(nextState); + return nextState; +} + +export function clearOnboardingSessionState() { + if (typeof window === 'undefined') return; + window.sessionStorage.removeItem(ONBOARDING_SESSION_KEY); +} diff --git a/apps/frontend/src/lib/auth.tsx b/apps/frontend/src/lib/auth.tsx index 7386e54..c898dac 100644 --- a/apps/frontend/src/lib/auth.tsx +++ b/apps/frontend/src/lib/auth.tsx @@ -52,9 +52,9 @@ function md5(string: string) { let c = 0x98badcfe; let d = 0x10325476; const s = [ - 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, - 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, 6, 10, - 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, + 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, + 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, + 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, ]; const str = unescape(encodeURIComponent(string)); @@ -69,7 +69,7 @@ function md5(string: string) { words[wordLen * 16 - 2] = len * 8; for (let i = 0; i < words.length; i += 16) { - let [aa, bb, cc, dd] = [a, b, c, d]; + const [aa, bb, cc, dd] = [a, b, c, d]; for (let j = 0; j < 64; j++) { let f: number; let g: number; @@ -91,7 +91,8 @@ function md5(string: string) { c = b; b = (b + - ((a + f + k[j] + words[i + g]) << s[j] | (a + f + k[j] + words[i + g]) >>> (32 - s[j]))) | + (((a + f + k[j] + words[i + g]) << s[j]) | + ((a + f + k[j] + words[i + g]) >>> (32 - s[j])))) | 0; a = temp; } @@ -103,9 +104,9 @@ function md5(string: string) { return [a, b, c, d] .map((v) => - Array.from({ length: 4 }, (_, i) => ((v >> (i * 8)) & 0xff).toString(16).padStart(2, '0')).join( - '' - ) + Array.from({ length: 4 }, (_, i) => + ((v >> (i * 8)) & 0xff).toString(16).padStart(2, '0') + ).join('') ) .join(''); } diff --git a/apps/frontend/src/routeTree.gen.ts b/apps/frontend/src/routeTree.gen.ts index 300baa7..d39fd03 100644 --- a/apps/frontend/src/routeTree.gen.ts +++ b/apps/frontend/src/routeTree.gen.ts @@ -13,6 +13,9 @@ import { Route as ResetPasswordRouteImport } from './routes/reset-password' import { Route as OnboardRouteImport } from './routes/onboard' import { Route as LoginRouteImport } from './routes/login' import { Route as AppRouteRouteImport } from './routes/_app/route' +import { Route as OnboardIndexRouteImport } from './routes/onboard/index' +import { Route as OnboardVerifyRouteImport } from './routes/onboard/verify' +import { Route as OnboardCompleteRouteImport } from './routes/onboard/complete' import { Route as AppProfileRouteImport } from './routes/_app/profile' import { Route as AppAboutRouteImport } from './routes/_app/about' import { Route as ComplexSlugBookingRouteImport } from './routes/$complexSlug/booking' @@ -41,6 +44,21 @@ const AppRouteRoute = AppRouteRouteImport.update({ id: '/_app', getParentRoute: () => rootRouteImport, } as any) +const OnboardIndexRoute = OnboardIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => OnboardRoute, +} as any) +const OnboardVerifyRoute = OnboardVerifyRouteImport.update({ + id: '/verify', + path: '/verify', + getParentRoute: () => OnboardRoute, +} as any) +const OnboardCompleteRoute = OnboardCompleteRouteImport.update({ + id: '/complete', + path: '/complete', + getParentRoute: () => OnboardRoute, +} as any) const AppProfileRoute = AppProfileRouteImport.update({ id: '/profile', path: '/profile', @@ -86,11 +104,14 @@ const AppAuthenticatedComplexSlugEditRoute = export interface FileRoutesByFullPath { '/': typeof AppAuthenticatedIndexRoute '/login': typeof LoginRoute - '/onboard': typeof OnboardRoute + '/onboard': typeof OnboardRouteWithChildren '/reset-password': typeof ResetPasswordRoute '/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren '/about': typeof AppAboutRoute '/profile': typeof AppProfileRoute + '/onboard/complete': typeof OnboardCompleteRoute + '/onboard/verify': typeof OnboardVerifyRoute + '/onboard/': typeof OnboardIndexRoute '/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute '/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute '/complex/$slug/edit': typeof AppAuthenticatedComplexSlugEditRoute @@ -98,10 +119,12 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof AppAuthenticatedIndexRoute '/login': typeof LoginRoute - '/onboard': typeof OnboardRoute '/reset-password': typeof ResetPasswordRoute '/about': typeof AppAboutRoute '/profile': typeof AppProfileRoute + '/onboard/complete': typeof OnboardCompleteRoute + '/onboard/verify': typeof OnboardVerifyRoute + '/onboard': typeof OnboardIndexRoute '/$complexSlug/booking': typeof ComplexSlugBookingIndexRoute '/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute '/complex/$slug/edit': typeof AppAuthenticatedComplexSlugEditRoute @@ -110,12 +133,15 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/_app': typeof AppRouteRouteWithChildren '/login': typeof LoginRoute - '/onboard': typeof OnboardRoute + '/onboard': typeof OnboardRouteWithChildren '/reset-password': typeof ResetPasswordRoute '/_app/_authenticated': typeof AppAuthenticatedRouteRouteWithChildren '/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren '/_app/about': typeof AppAboutRoute '/_app/profile': typeof AppProfileRoute + '/onboard/complete': typeof OnboardCompleteRoute + '/onboard/verify': typeof OnboardVerifyRoute + '/onboard/': typeof OnboardIndexRoute '/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute '/_app/_authenticated/': typeof AppAuthenticatedIndexRoute '/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute @@ -131,6 +157,9 @@ export interface FileRouteTypes { | '/$complexSlug/booking' | '/about' | '/profile' + | '/onboard/complete' + | '/onboard/verify' + | '/onboard/' | '/$complexSlug/booking/' | '/$complexSlug/booking/confirmed/$bookingCode' | '/complex/$slug/edit' @@ -138,10 +167,12 @@ export interface FileRouteTypes { to: | '/' | '/login' - | '/onboard' | '/reset-password' | '/about' | '/profile' + | '/onboard/complete' + | '/onboard/verify' + | '/onboard' | '/$complexSlug/booking' | '/$complexSlug/booking/confirmed/$bookingCode' | '/complex/$slug/edit' @@ -155,6 +186,9 @@ export interface FileRouteTypes { | '/$complexSlug/booking' | '/_app/about' | '/_app/profile' + | '/onboard/complete' + | '/onboard/verify' + | '/onboard/' | '/$complexSlug/booking/' | '/_app/_authenticated/' | '/$complexSlug/booking/confirmed/$bookingCode' @@ -164,7 +198,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { AppRouteRoute: typeof AppRouteRouteWithChildren LoginRoute: typeof LoginRoute - OnboardRoute: typeof OnboardRoute + OnboardRoute: typeof OnboardRouteWithChildren ResetPasswordRoute: typeof ResetPasswordRoute ComplexSlugBookingRoute: typeof ComplexSlugBookingRouteWithChildren } @@ -199,6 +233,27 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppRouteRouteImport parentRoute: typeof rootRouteImport } + '/onboard/': { + id: '/onboard/' + path: '/' + fullPath: '/onboard/' + preLoaderRoute: typeof OnboardIndexRouteImport + parentRoute: typeof OnboardRoute + } + '/onboard/verify': { + id: '/onboard/verify' + path: '/verify' + fullPath: '/onboard/verify' + preLoaderRoute: typeof OnboardVerifyRouteImport + parentRoute: typeof OnboardRoute + } + '/onboard/complete': { + id: '/onboard/complete' + path: '/complete' + fullPath: '/onboard/complete' + preLoaderRoute: typeof OnboardCompleteRouteImport + parentRoute: typeof OnboardRoute + } '/_app/profile': { id: '/_app/profile' path: '/profile' @@ -289,6 +344,21 @@ const AppRouteRouteWithChildren = AppRouteRoute._addFileChildren( AppRouteRouteChildren, ) +interface OnboardRouteChildren { + OnboardCompleteRoute: typeof OnboardCompleteRoute + OnboardVerifyRoute: typeof OnboardVerifyRoute + OnboardIndexRoute: typeof OnboardIndexRoute +} + +const OnboardRouteChildren: OnboardRouteChildren = { + OnboardCompleteRoute: OnboardCompleteRoute, + OnboardVerifyRoute: OnboardVerifyRoute, + OnboardIndexRoute: OnboardIndexRoute, +} + +const OnboardRouteWithChildren = + OnboardRoute._addFileChildren(OnboardRouteChildren) + interface ComplexSlugBookingRouteChildren { ComplexSlugBookingIndexRoute: typeof ComplexSlugBookingIndexRoute ComplexSlugBookingConfirmedBookingCodeRoute: typeof ComplexSlugBookingConfirmedBookingCodeRoute @@ -306,7 +376,7 @@ const ComplexSlugBookingRouteWithChildren = const rootRouteChildren: RootRouteChildren = { AppRouteRoute: AppRouteRouteWithChildren, LoginRoute: LoginRoute, - OnboardRoute: OnboardRoute, + OnboardRoute: OnboardRouteWithChildren, ResetPasswordRoute: ResetPasswordRoute, ComplexSlugBookingRoute: ComplexSlugBookingRouteWithChildren, } diff --git a/apps/frontend/src/routes/onboard.tsx b/apps/frontend/src/routes/onboard.tsx index 5897863..ca141c7 100644 --- a/apps/frontend/src/routes/onboard.tsx +++ b/apps/frontend/src/routes/onboard.tsx @@ -1,6 +1,5 @@ -import { OnboardPage } from '@/features/onboard/onboard-page'; -import { createFileRoute } from '@tanstack/react-router'; +import { Outlet, createFileRoute } from '@tanstack/react-router'; export const Route = createFileRoute('/onboard')({ - component: OnboardPage, + component: Outlet, }); diff --git a/apps/frontend/src/routes/onboard/complete.tsx b/apps/frontend/src/routes/onboard/complete.tsx new file mode 100644 index 0000000..c12e9a9 --- /dev/null +++ b/apps/frontend/src/routes/onboard/complete.tsx @@ -0,0 +1,6 @@ +import { OnboardCompletePage } from '@/features/onboard/onboard-complete-page'; +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/onboard/complete')({ + component: OnboardCompletePage, +}); diff --git a/apps/frontend/src/routes/onboard/index.tsx b/apps/frontend/src/routes/onboard/index.tsx new file mode 100644 index 0000000..5710af4 --- /dev/null +++ b/apps/frontend/src/routes/onboard/index.tsx @@ -0,0 +1,6 @@ +import { OnboardStartPage } from '@/features/onboard/onboard-start-page'; +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/onboard/')({ + component: OnboardStartPage, +}); diff --git a/apps/frontend/src/routes/onboard/verify.tsx b/apps/frontend/src/routes/onboard/verify.tsx new file mode 100644 index 0000000..72e512e --- /dev/null +++ b/apps/frontend/src/routes/onboard/verify.tsx @@ -0,0 +1,6 @@ +import { OnboardVerifyPage } from '@/features/onboard/onboard-verify-page'; +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/onboard/verify')({ + component: OnboardVerifyPage, +});