Initial commit

This commit is contained in:
Jose Selesan
2026-04-08 22:53:11 -03:00
commit 9ae270609d
179 changed files with 28096 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
export function OnboardingCheckEmailStep() {
return (
<p className="text-sm text-muted-foreground">
Revisa tu correo y abre el link de verificacion para continuar.
</p>
)
}

View File

@@ -0,0 +1,119 @@
import type { PlanSummary } from '@repo/api-contract'
import { Controller, type UseFormReturn } from 'react-hook-form'
import { Button } from '@/components/ui/button'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import type { CompleteValues } from '@/features/onboard/onboarding.types'
type OnboardingCompleteStepProps = {
form: UseFormReturn<CompleteValues>
plans: PlanSummary[]
verifiedEmail: string | null
errorMessage: string | null
infoMessage: string | null
planPlaceholder: string
onSubmit: (values: CompleteValues) => Promise<void>
}
export function OnboardingCompleteStep({
form,
plans,
verifiedEmail,
errorMessage,
infoMessage,
planPlaceholder,
onSubmit,
}: OnboardingCompleteStepProps) {
return (
<form className="space-y-3" onSubmit={form.handleSubmit(onSubmit)}>
{verifiedEmail && (
<Field>
<FieldLabel htmlFor="verified-email">Email verificado</FieldLabel>
<Input id="verified-email" type="email" value={verifiedEmail} disabled readOnly />
</Field>
)}
<Field data-invalid={Boolean(form.formState.errors.password)}>
<FieldLabel htmlFor="onboard-password">Contrasena</FieldLabel>
<Input
id="onboard-password"
type="password"
placeholder="********"
aria-invalid={Boolean(form.formState.errors.password)}
{...form.register('password')}
/>
<FieldError errors={[form.formState.errors.password]} />
</Field>
<Field data-invalid={Boolean(form.formState.errors.complexName)}>
<FieldLabel htmlFor="complex-name">Nombre del complejo</FieldLabel>
<Input
id="complex-name"
type="text"
placeholder="Complejo Las Palmeras"
aria-invalid={Boolean(form.formState.errors.complexName)}
{...form.register('complexName')}
/>
<FieldError errors={[form.formState.errors.complexName]} />
</Field>
<Field data-invalid={Boolean(form.formState.errors.physicalAddress)}>
<FieldLabel htmlFor="physical-address">Direccion fisica</FieldLabel>
<Input
id="physical-address"
type="text"
placeholder="Ej: Av. San Martin 1234, Salta"
aria-invalid={Boolean(form.formState.errors.physicalAddress)}
{...form.register('physicalAddress')}
/>
<FieldError errors={[form.formState.errors.physicalAddress]} />
</Field>
<Field data-invalid={Boolean(form.formState.errors.planCode)}>
<FieldLabel htmlFor="plan-code">Plan</FieldLabel>
<Controller
control={form.control}
name="planCode"
render={({ field }) => (
<select
id="plan-code"
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
aria-invalid={Boolean(form.formState.errors.planCode)}
value={field.value ?? ''}
onChange={(event) => {
field.onChange(event.target.value)
}}
disabled={plans.length === 0}
>
<option value="">
{plans.length > 0 ? planPlaceholder : 'No hay planes disponibles'}
</option>
{plans.map((plan) => (
<option key={plan.code} value={plan.code}>
{plan.name} - ${plan.price.toFixed(2)}
</option>
))}
</select>
)}
/>
{plans.length === 0 && (
<p className="text-xs text-muted-foreground">
Carga planes en la base para continuar con el onboarding.
</p>
)}
<FieldError errors={[form.formState.errors.planCode]} />
</Field>
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
{infoMessage && <p className="text-sm text-muted-foreground">{infoMessage}</p>}
<Button
type="submit"
className="w-full"
disabled={form.formState.isSubmitting || !form.formState.isValid}
>
{form.formState.isSubmitting ? 'Finalizando...' : 'Completar onboarding'}
</Button>
</form>
)
}

View File

@@ -0,0 +1,22 @@
import type { PropsWithChildren } from 'react'
type OnboardingLayoutProps = PropsWithChildren<{
title: string
description: string
}>
export function OnboardingLayout({
title,
description,
children,
}: OnboardingLayoutProps) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-6xl items-center justify-center px-6">
<section className="w-full max-w-3xl rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
<h1 className="mb-2 text-2xl font-semibold">{title}</h1>
<p className="mb-4 text-sm text-muted-foreground">{description}</p>
{children}
</section>
</main>
)
}

View File

@@ -0,0 +1,58 @@
import type { UseFormReturn } from 'react-hook-form'
import { Button } from '@/components/ui/button'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import type { StartValues } from '@/features/onboard/onboarding.types'
type OnboardingStartStepProps = {
form: UseFormReturn<StartValues>
errorMessage: string | null
infoMessage: string | null
onSubmit: (values: StartValues) => Promise<void>
}
export function OnboardingStartStep({
form,
errorMessage,
infoMessage,
onSubmit,
}: OnboardingStartStepProps) {
return (
<form className="space-y-3" onSubmit={form.handleSubmit(onSubmit)}>
<Field data-invalid={Boolean(form.formState.errors.fullName)}>
<FieldLabel htmlFor="onboard-fullname">Nombre</FieldLabel>
<Input
id="onboard-fullname"
type="text"
placeholder="Nombre Apellido"
aria-invalid={Boolean(form.formState.errors.fullName)}
{...form.register('fullName')}
/>
<FieldError errors={[form.formState.errors.fullName]} />
</Field>
<Field data-invalid={Boolean(form.formState.errors.email)}>
<FieldLabel htmlFor="onboard-email">Email</FieldLabel>
<Input
id="onboard-email"
type="email"
placeholder="tu@email.com"
aria-invalid={Boolean(form.formState.errors.email)}
{...form.register('email')}
/>
<FieldError errors={[form.formState.errors.email]} />
</Field>
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
{infoMessage && <p className="text-sm text-muted-foreground">{infoMessage}</p>}
<Button
type="submit"
className="w-full"
disabled={form.formState.isSubmitting || !form.formState.isValid}
>
{form.formState.isSubmitting ? 'Enviando...' : 'Enviar codigo OTP'}
</Button>
</form>
)
}

View File

@@ -0,0 +1,108 @@
import { RefreshCw } from 'lucide-react'
import type { UseFormReturn } from 'react-hook-form'
import { Button } from '@/components/ui/button'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from '@/components/ui/input-otp'
import type { VerifyOtpValues } from '@/features/onboard/onboarding.types'
type OnboardingVerifyOtpStepProps = {
form: UseFormReturn<VerifyOtpValues>
email: string | null
errorMessage: string | null
infoMessage: string | null
remainingAttempts: number
resendCooldownSeconds: number
isResending: boolean
onSubmit: (values: VerifyOtpValues) => Promise<void>
onResend: () => Promise<void>
}
export function OnboardingVerifyOtpStep({
form,
email,
errorMessage,
infoMessage,
remainingAttempts,
resendCooldownSeconds,
isResending,
onSubmit,
onResend,
}: OnboardingVerifyOtpStepProps) {
const resendDisabled = resendCooldownSeconds > 0 || isResending
return (
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
<p className="text-sm text-muted-foreground">
Ingresa el codigo de 6 digitos que enviamos a{' '}
<span className="font-medium text-foreground">{email ?? 'tu email'}</span>.
</p>
<Field data-invalid={Boolean(form.formState.errors.otp)}>
<div className="mb-2 flex items-center justify-between gap-3">
<FieldLabel htmlFor="onboard-otp">Codigo de verificacion</FieldLabel>
<Button
type="button"
variant="outline"
size="sm"
disabled={resendDisabled}
onClick={() => {
void onResend()
}}
>
<RefreshCw className="mr-1 h-3.5 w-3.5" />
{isResending
? 'Reenviando...'
: resendCooldownSeconds > 0
? `Reenviar en ${resendCooldownSeconds}s`
: 'Reenviar codigo'}
</Button>
</div>
<InputOTP
id="onboard-otp"
maxLength={6}
inputMode="numeric"
pattern="\d*"
value={form.watch('otp')}
onChange={(value) => {
form.setValue('otp', value, { shouldValidate: true })
}}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
<FieldError errors={[form.formState.errors.otp]} />
</Field>
<p className="text-xs text-muted-foreground">
Intentos restantes: <span className="font-medium">{remainingAttempts}</span>
</p>
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
{infoMessage && <p className="text-sm text-muted-foreground">{infoMessage}</p>}
<Button
type="submit"
className="w-full"
disabled={form.formState.isSubmitting || !form.formState.isValid}
>
{form.formState.isSubmitting ? 'Verificando...' : 'Verificar codigo'}
</Button>
</form>
)
}

View File

@@ -0,0 +1,7 @@
export function OnboardingVerifyingStep() {
return (
<p className="text-sm text-muted-foreground">
Verificando tu email, espera un momento...
</p>
)
}

View File

@@ -0,0 +1,241 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import {
onboardingCompleteSchema,
onboardingStartSchema,
onboardingVerifyOtpSchema,
type PlanSummary,
} from '@repo/api-contract'
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'
const OTP_MAX_ATTEMPTS = 5
type OnboardStep = 'start' | 'verify-otp' | 'complete'
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 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: '',
},
})
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 })
}
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>
)
}

View File

@@ -0,0 +1,16 @@
import {
onboardingCompleteSchema,
onboardingStartSchema,
onboardingVerifyOtpSchema,
} from '@repo/api-contract'
import { z } from 'zod'
export type StartValues = z.infer<typeof onboardingStartSchema>
export type VerifyOtpValues = Omit<
z.infer<typeof onboardingVerifyOtpSchema>,
'requestId'
>
export type CompleteValues = Omit<
z.infer<typeof onboardingCompleteSchema>,
'onboardingRequestId'
>