feat: refactor onboarding into a multi-step flow with dedicated routes and session state management
This commit is contained in:
119
apps/frontend/src/features/onboard/onboard-complete-page.tsx
Normal file
119
apps/frontend/src/features/onboard/onboard-complete-page.tsx
Normal file
@@ -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<string | null>(initialState.requestId);
|
||||
const [verifiedEmail] = useState<string | null>(initialState.verifiedEmail);
|
||||
const [onboardingEmail] = useState<string | null>(initialState.onboardingEmail);
|
||||
const [plans, setPlans] = useState<PlanSummary[]>([]);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const form = useForm<CompleteValues>({
|
||||
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 (
|
||||
<OnboardingLayout
|
||||
title="Completa tu onboarding"
|
||||
description="Define tu contraseña y configura tu complejo."
|
||||
>
|
||||
<OnboardingCompleteStep
|
||||
form={form}
|
||||
plans={plans}
|
||||
verifiedEmail={verifiedEmail}
|
||||
errorMessage={errorMessage}
|
||||
infoMessage={null}
|
||||
planPlaceholder={planPlaceholder}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</OnboardingLayout>
|
||||
);
|
||||
}
|
||||
@@ -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<OnboardSessionState>;
|
||||
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<OnboardStep>('start');
|
||||
const [requestId, setRequestId] = useState<string | null>(null);
|
||||
const [onboardingEmail, setOnboardingEmail] = useState<string | null>(null);
|
||||
const [verifiedEmail, setVerifiedEmail] = useState<string | null>(null);
|
||||
const [plans, setPlans] = useState<PlanSummary[]>([]);
|
||||
const [remainingAttempts, setRemainingAttempts] = useState<number>(OTP_MAX_ATTEMPTS);
|
||||
const [resendCooldownSeconds, setResendCooldownSeconds] = useState<number>(0);
|
||||
const [isResending, setIsResending] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [infoMessage, setInfoMessage] = useState<string | null>(null);
|
||||
const [isSessionRestored, setIsSessionRestored] = useState(false);
|
||||
|
||||
const startForm = useForm<StartValues>({
|
||||
resolver: zodResolver(onboardingStartSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
fullName: '',
|
||||
email: '',
|
||||
},
|
||||
});
|
||||
|
||||
const completeForm = useForm<CompleteValues>({
|
||||
resolver: zodResolver(onboardingCompleteSchema.omit({ onboardingRequestId: true })),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
password: '',
|
||||
complexName: '',
|
||||
physicalAddress: '',
|
||||
planCode: '',
|
||||
},
|
||||
});
|
||||
|
||||
const verifyOtpForm = useForm<VerifyOtpValues>({
|
||||
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 (
|
||||
<OnboardingLayout
|
||||
title="Onboarding"
|
||||
description="Crea tu cuenta y configura tu complejo de canchas."
|
||||
>
|
||||
{step === 'start' && (
|
||||
<OnboardingStartStep
|
||||
form={startForm}
|
||||
errorMessage={errorMessage}
|
||||
infoMessage={infoMessage}
|
||||
onSubmit={onSubmitStart}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 'verify-otp' && (
|
||||
<OnboardingVerifyOtpStep
|
||||
form={verifyOtpForm}
|
||||
email={onboardingEmail}
|
||||
errorMessage={errorMessage}
|
||||
infoMessage={infoMessage}
|
||||
remainingAttempts={remainingAttempts}
|
||||
resendCooldownSeconds={resendCooldownSeconds}
|
||||
isResending={isResending}
|
||||
onSubmit={onSubmitVerifyOtp}
|
||||
onResend={onResendOtp}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 'complete' && (
|
||||
<OnboardingCompleteStep
|
||||
form={completeForm}
|
||||
plans={plans}
|
||||
verifiedEmail={verifiedEmail}
|
||||
errorMessage={errorMessage}
|
||||
infoMessage={infoMessage}
|
||||
planPlaceholder={planPlaceholder}
|
||||
onSubmit={onSubmitComplete}
|
||||
/>
|
||||
)}
|
||||
</OnboardingLayout>
|
||||
);
|
||||
}
|
||||
79
apps/frontend/src/features/onboard/onboard-start-page.tsx
Normal file
79
apps/frontend/src/features/onboard/onboard-start-page.tsx
Normal file
@@ -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<string | null>(null);
|
||||
const [infoMessage, setInfoMessage] = useState<string | null>(null);
|
||||
const initialState = useMemo(() => getOnboardingSessionState(), []);
|
||||
|
||||
const form = useForm<StartValues>({
|
||||
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 (
|
||||
<OnboardingLayout title="Onboarding" description="Ingresa tu nombre y email para comenzar.">
|
||||
<OnboardingStartStep
|
||||
form={form}
|
||||
errorMessage={errorMessage}
|
||||
infoMessage={infoMessage}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</OnboardingLayout>
|
||||
);
|
||||
}
|
||||
169
apps/frontend/src/features/onboard/onboard-verify-page.tsx
Normal file
169
apps/frontend/src/features/onboard/onboard-verify-page.tsx
Normal file
@@ -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<string | null>(initialState.requestId);
|
||||
const [onboardingEmail, setOnboardingEmail] = useState<string | null>(
|
||||
initialState.onboardingEmail
|
||||
);
|
||||
const [remainingAttempts, setRemainingAttempts] = useState<number>(
|
||||
initialState.remainingAttempts || OTP_MAX_ATTEMPTS
|
||||
);
|
||||
const [resendCooldownSeconds, setResendCooldownSeconds] = useState<number>(
|
||||
initialState.resendCooldownSeconds || 0
|
||||
);
|
||||
const [isResending, setIsResending] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [infoMessage, setInfoMessage] = useState<string | null>(null);
|
||||
|
||||
const form = useForm<VerifyOtpValues>({
|
||||
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 (
|
||||
<OnboardingLayout
|
||||
title="Verifica tu email"
|
||||
description="Ingresa el OTP para validar tu cuenta."
|
||||
>
|
||||
<OnboardingVerifyOtpStep
|
||||
form={form}
|
||||
email={onboardingEmail}
|
||||
errorMessage={errorMessage}
|
||||
infoMessage={infoMessage}
|
||||
remainingAttempts={remainingAttempts}
|
||||
resendCooldownSeconds={resendCooldownSeconds}
|
||||
isResending={isResending}
|
||||
onSubmit={onSubmit}
|
||||
onResend={onResend}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mt-2 w-full"
|
||||
onClick={() => {
|
||||
void navigate({ to: '/onboard' });
|
||||
}}
|
||||
>
|
||||
Volver al paso anterior
|
||||
</Button>
|
||||
</OnboardingLayout>
|
||||
);
|
||||
}
|
||||
140
apps/frontend/src/features/onboard/onboarding-session.ts
Normal file
140
apps/frontend/src/features/onboard/onboarding-session.ts
Normal file
@@ -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<string, unknown> {
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user