refactor(onboarding): restructure onboarding routes and components

- Removed obsolete onboarding routes: complete, create-complex, index, verify-email, and verify.
- Introduced new setup stepper page for onboarding process.
- Created a multi-step setup component to handle complex creation with validation.
- Added new schemas for complex creation and plan features.
- Implemented detailed steps for complex name, location, plan selection, and court setup.
- Enhanced error handling and user feedback during the onboarding process.
This commit is contained in:
Jose Selesan
2026-06-02 10:02:28 -03:00
parent 2aaa91444d
commit 4544911f0e
51 changed files with 1006 additions and 2383 deletions

View File

@@ -1,18 +0,0 @@
model OnboardingRequest {
id String @id @db.Uuid
fullName String @map("full_name")
email String
otpHash String @map("otp_hash")
otpExpiresAt DateTime @map("otp_expires_at")
otpAttempts Int @default(0) @map("otp_attempts")
otpLastSentAt DateTime @map("otp_last_sent_at")
otpResendCount Int @default(0) @map("otp_resend_count")
emailVerifiedAt DateTime? @map("email_verified_at")
completedAt DateTime? @map("completed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([email])
@@index([otpExpiresAt])
@@map("onboarding_requests")
}

View File

@@ -40,6 +40,11 @@ export async function createComplexHandler(c: AppContext) {
city: payload.city,
state: payload.state,
country: payload.country,
setupCourts: payload.setupCourts,
courtSportId: payload.courtSportId,
courtStartTime: payload.courtStartTime,
courtEndTime: payload.courtEndTime,
courtDaysOfWeek: payload.courtDaysOfWeek,
});
return c.json(complex, 201);

View File

@@ -11,8 +11,47 @@ export type CreateComplexInput = {
city?: string;
state?: string;
country?: string;
setupCourts?: boolean;
courtSportId?: string;
courtStartTime?: string;
courtEndTime?: string;
courtDaysOfWeek?: string[];
};
type DayOfWeek = 'MONDAY' | 'TUESDAY' | 'WEDNESDAY' | 'THURSDAY' | 'FRIDAY' | 'SATURDAY' | 'SUNDAY';
function toMinutes(value: string): number {
const [hours, minutes] = value.split(':').map((part) => Number(part));
return hours * 60 + minutes;
}
function assertAvailabilityRanges(
availability: Array<{
dayOfWeek: DayOfWeek;
startTime: string;
endTime: string;
}>
) {
for (const range of availability) {
const start = toMinutes(range.startTime);
const end = toMinutes(range.endTime);
if (start >= end) {
throw new Error(`El rango ${range.startTime}-${range.endTime} es inválido.`);
}
}
}
async function ensureActiveSport(sportId: string) {
const sport = await db.sport.findFirst({
where: { id: sportId, isActive: true },
select: { id: true, name: true },
});
if (!sport) {
throw new Error('El deporte seleccionado no existe o está inactivo.');
}
return sport;
}
export type UpdateComplexInput = {
complexName?: string;
physicalAddress?: string | null;
@@ -94,6 +133,38 @@ export async function createComplex(input: CreateComplexInput) {
});
}
if (input.setupCourts && input.courtSportId) {
const sport = await ensureActiveSport(input.courtSportId);
const availability = (input.courtDaysOfWeek ?? []).map((day) => ({
dayOfWeek: day as DayOfWeek,
startTime: input.courtStartTime ?? '08:00',
endTime: input.courtEndTime ?? '22:00',
}));
assertAvailabilityRanges(availability);
const court = await tx.court.create({
data: {
id: uuidv7(),
complexId: complex.id,
sportId: input.courtSportId,
name: `${sport.name} 1`,
slotDurationMinutes: 60,
basePrice: 0,
},
});
await tx.courtAvailability.createMany({
data: availability.map((avail) => ({
id: uuidv7(),
courtId: court.id,
dayOfWeek: avail.dayOfWeek,
startTime: avail.startTime,
endTime: avail.endTime,
})),
});
}
return complex;
});
}

View File

@@ -1,27 +0,0 @@
import {
OnboardingError,
completeOnboarding,
} from '@/modules/onboarding/services/onboarding.service';
import type { AppContext } from '@/types/hono';
import type { OnboardingCompleteInput } from '@repo/api-contract';
export async function completeOnboardingHandler(c: AppContext) {
const payload = c.req.valid('json' as never) as OnboardingCompleteInput;
try {
const result = await completeOnboarding(payload);
return c.json({
message: 'Onboarding completado correctamente.',
...result,
});
} catch (error) {
if (error instanceof OnboardingError) {
return c.json({ message: error.message }, error.status);
}
if (error instanceof Error) {
return c.json({ message: error.message }, 400);
}
throw error;
}
}

View File

@@ -1,25 +0,0 @@
import {
OnboardingError,
resendOnboardingOtp,
} from '@/modules/onboarding/services/onboarding.service';
import type { AppContext } from '@/types/hono';
import type { OnboardingResendOtpInput } from '@repo/api-contract';
export async function resendOtpHandler(c: AppContext) {
const payload = c.req.valid('json' as never) as OnboardingResendOtpInput;
try {
const result = await resendOnboardingOtp(payload);
return c.json(result);
} catch (error) {
if (error instanceof OnboardingError) {
return c.json({ message: error.message }, error.status);
}
if (error instanceof Error) {
return c.json({ message: error.message }, 400);
}
throw error;
}
}

View File

@@ -1,10 +0,0 @@
import { startOnboarding } from '@/modules/onboarding/services/onboarding.service';
import type { AppContext } from '@/types/hono';
import type { OnboardingStartInput } from '@repo/api-contract';
export async function startOnboardingHandler(c: AppContext) {
const payload = c.req.valid('json' as never) as OnboardingStartInput;
const result = await startOnboarding(payload);
return c.json(result, 202);
}

View File

@@ -1,10 +0,0 @@
import { verifyOnboardingOtp } from '@/modules/onboarding/services/onboarding.service';
import type { AppContext } from '@/types/hono';
import type { OnboardingVerifyOtpInput } from '@repo/api-contract';
export async function verifyOtpHandler(c: AppContext) {
const payload = c.req.valid('json' as never) as OnboardingVerifyOtpInput;
const result = await verifyOnboardingOtp(payload);
return c.json(result);
}

View File

@@ -1,35 +0,0 @@
import { completeOnboardingHandler } from '@/modules/onboarding/handlers/complete-onboarding.handler';
import { resendOtpHandler } from '@/modules/onboarding/handlers/resend-otp.handler';
import { startOnboardingHandler } from '@/modules/onboarding/handlers/start-onboarding.handler';
import { verifyOtpHandler } from '@/modules/onboarding/handlers/verify-otp.handler';
import type { AppEnv } from '@/types/hono';
import { zValidator } from '@hono/zod-validator';
import {
onboardingCompleteSchema,
onboardingResendOtpSchema,
onboardingStartSchema,
onboardingVerifyOtpSchema,
} from '@repo/api-contract';
import { Hono } from 'hono';
export const onboardingRoutes = new Hono<AppEnv>();
onboardingRoutes.post('/start', zValidator('json', onboardingStartSchema), startOnboardingHandler);
onboardingRoutes.post(
'/verify-otp',
zValidator('json', onboardingVerifyOtpSchema),
verifyOtpHandler
);
onboardingRoutes.post(
'/resend-otp',
zValidator('json', onboardingResendOtpSchema),
resendOtpHandler
);
onboardingRoutes.post(
'/complete',
zValidator('json', onboardingCompleteSchema),
completeOnboardingHandler
);

View File

@@ -1,415 +0,0 @@
import { createHash, randomInt } from 'node:crypto';
import { auth } from '@/lib/auth';
import { sendMail } from '@/lib/mailer';
import { db } from '@/lib/prisma';
import type {
OnboardingCompleteInput,
OnboardingResendOtpInput,
OnboardingStartInput,
OnboardingVerifyOtpInput,
} from '@repo/api-contract';
import { v7 as uuidv7 } from 'uuid';
const OTP_LENGTH = 6;
const OTP_TTL_MINUTES = Number(Bun.env.ONBOARDING_OTP_TTL_MINUTES ?? 10);
const OTP_MAX_ATTEMPTS = Number(Bun.env.ONBOARDING_OTP_MAX_ATTEMPTS ?? 5);
const OTP_RESEND_COOLDOWN_SECONDS = Number(Bun.env.ONBOARDING_OTP_RESEND_COOLDOWN_SECONDS ?? 30);
type VerifyOtpResult = {
message: string;
verified: boolean;
requestId: string;
email: string | null;
expiresAt: string | null;
remainingAttempts: number;
cooldownSeconds: number;
};
type ResendOtpResult = {
message: string;
requestId: string;
email: string;
expiresAt: string;
cooldownSeconds: number;
remainingAttempts: number;
};
type StartOnboardingResult = {
message: string;
requestId: string;
email: string;
expiresAt: string;
cooldownSeconds: number;
};
export class OnboardingError extends Error {
status: 400 | 404 | 409 | 429;
constructor(message: string, status: 400 | 404 | 409 | 429 = 400) {
super(message);
this.name = 'OnboardingError';
this.status = status;
}
}
function nowPlusMinutes(minutes: number): Date {
const date = new Date();
date.setMinutes(date.getMinutes() + minutes);
return date;
}
function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
function createOtpCode(): string {
return randomInt(0, 10 ** OTP_LENGTH)
.toString()
.padStart(OTP_LENGTH, '0');
}
function hashValue(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
function getRemainingAttempts(otpAttempts: number): number {
return Math.max(0, OTP_MAX_ATTEMPTS - otpAttempts);
}
function getCooldownSeconds(otpLastSentAt: Date): number {
const elapsedMs = Date.now() - otpLastSentAt.getTime();
const remainingMs = OTP_RESEND_COOLDOWN_SECONDS * 1000 - elapsedMs;
return Math.max(0, Math.ceil(remainingMs / 1000));
}
function slugify(value: string): string {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
}
async function buildUniqueComplexSlug(complexName: string): Promise<string> {
const base = slugify(complexName);
const fallback = base.length > 0 ? base : `complex-${uuidv7().slice(0, 8)}`;
let candidate = fallback;
let index = 1;
while (true) {
const existing = await db.complex.findFirst({
where: { complexSlug: candidate },
select: { id: true },
});
if (!existing) {
return candidate;
}
index += 1;
candidate = `${fallback}-${index}`;
}
}
async function sendOtpEmail(email: string, otpCode: string) {
await sendMail({
to: email,
subject: 'Codigo OTP para validar tu email',
text: `Tu codigo OTP es ${otpCode}. Vence en ${OTP_TTL_MINUTES} minutos.`,
html: `<p>Tu codigo OTP es <strong>${otpCode}</strong>.</p><p>Vence en ${OTP_TTL_MINUTES} minutos.</p>`,
});
}
export async function startOnboarding(input: OnboardingStartInput): Promise<StartOnboardingResult> {
const email = normalizeEmail(input.email);
const otpCode = createOtpCode();
const expiresAt = nowPlusMinutes(OTP_TTL_MINUTES);
const now = new Date();
const request = await db.onboardingRequest.create({
data: {
id: uuidv7(),
fullName: input.fullName.trim(),
email,
otpHash: hashValue(otpCode),
otpExpiresAt: expiresAt,
otpAttempts: 0,
otpLastSentAt: now,
otpResendCount: 0,
},
select: {
id: true,
email: true,
otpExpiresAt: true,
},
});
await sendOtpEmail(email, otpCode);
return {
message: 'Te enviamos un codigo OTP para validar tu direccion.',
requestId: request.id,
email: request.email,
expiresAt: request.otpExpiresAt.toISOString(),
cooldownSeconds: OTP_RESEND_COOLDOWN_SECONDS,
};
}
export async function verifyOnboardingOtp(
input: OnboardingVerifyOtpInput
): Promise<VerifyOtpResult> {
const request = await db.onboardingRequest.findUnique({
where: { id: input.requestId },
});
if (!request) {
return {
message: 'Solicitud de onboarding invalida o expirada.',
verified: false,
requestId: input.requestId,
email: null,
expiresAt: null,
remainingAttempts: 0,
cooldownSeconds: 0,
};
}
if (request.completedAt) {
return {
message: 'Este onboarding ya fue completado.',
verified: true,
requestId: request.id,
email: request.email,
expiresAt: request.otpExpiresAt.toISOString(),
remainingAttempts: getRemainingAttempts(request.otpAttempts),
cooldownSeconds: getCooldownSeconds(request.otpLastSentAt),
};
}
if (request.otpExpiresAt < new Date()) {
return {
message: 'El codigo OTP vencio. Solicita un nuevo codigo.',
verified: false,
requestId: request.id,
email: request.email,
expiresAt: request.otpExpiresAt.toISOString(),
remainingAttempts: getRemainingAttempts(request.otpAttempts),
cooldownSeconds: getCooldownSeconds(request.otpLastSentAt),
};
}
if (request.otpAttempts >= OTP_MAX_ATTEMPTS) {
return {
message: 'Se agotaron los intentos de OTP. Solicita un nuevo codigo.',
verified: false,
requestId: request.id,
email: request.email,
expiresAt: request.otpExpiresAt.toISOString(),
remainingAttempts: 0,
cooldownSeconds: getCooldownSeconds(request.otpLastSentAt),
};
}
const otpMatches = hashValue(input.otp) === request.otpHash;
if (!otpMatches) {
const updated = await db.onboardingRequest.update({
where: { id: request.id },
data: {
otpAttempts: {
increment: 1,
},
},
select: {
id: true,
email: true,
otpAttempts: true,
otpExpiresAt: true,
otpLastSentAt: true,
},
});
const remainingAttempts = getRemainingAttempts(updated.otpAttempts);
return {
message:
remainingAttempts > 0
? 'Codigo OTP invalido.'
: 'Se agotaron los intentos de OTP. Solicita un nuevo codigo.',
verified: false,
requestId: updated.id,
email: updated.email,
expiresAt: updated.otpExpiresAt.toISOString(),
remainingAttempts,
cooldownSeconds: getCooldownSeconds(updated.otpLastSentAt),
};
}
if (!request.emailVerifiedAt) {
await db.onboardingRequest.update({
where: { id: request.id },
data: { emailVerifiedAt: new Date() },
});
}
return {
message: 'Email verificado correctamente.',
verified: true,
requestId: request.id,
email: request.email,
expiresAt: request.otpExpiresAt.toISOString(),
remainingAttempts: getRemainingAttempts(request.otpAttempts),
cooldownSeconds: getCooldownSeconds(request.otpLastSentAt),
};
}
export async function resendOnboardingOtp(
input: OnboardingResendOtpInput
): Promise<ResendOtpResult> {
const request = await db.onboardingRequest.findUnique({
where: { id: input.requestId },
});
if (!request) {
throw new OnboardingError('Solicitud de onboarding invalida o expirada.', 404);
}
if (request.completedAt) {
throw new OnboardingError('Este onboarding ya fue completado.', 400);
}
if (request.emailVerifiedAt) {
throw new OnboardingError('El email ya fue verificado.', 400);
}
const cooldownSeconds = getCooldownSeconds(request.otpLastSentAt);
if (cooldownSeconds > 0) {
throw new OnboardingError(`Debes esperar ${cooldownSeconds}s antes de reenviar el OTP.`, 429);
}
const otpCode = createOtpCode();
const expiresAt = nowPlusMinutes(OTP_TTL_MINUTES);
const now = new Date();
const updated = await db.onboardingRequest.update({
where: { id: request.id },
data: {
otpHash: hashValue(otpCode),
otpExpiresAt: expiresAt,
otpAttempts: 0,
otpLastSentAt: now,
otpResendCount: {
increment: 1,
},
},
select: {
id: true,
email: true,
otpExpiresAt: true,
otpAttempts: true,
},
});
await sendOtpEmail(updated.email, otpCode);
return {
message: 'Te enviamos un nuevo codigo OTP.',
requestId: updated.id,
email: updated.email,
expiresAt: updated.otpExpiresAt.toISOString(),
cooldownSeconds: OTP_RESEND_COOLDOWN_SECONDS,
remainingAttempts: getRemainingAttempts(updated.otpAttempts),
};
}
export async function completeOnboarding(input: OnboardingCompleteInput) {
const onboardingRequest = await db.onboardingRequest.findUnique({
where: { id: input.onboardingRequestId },
});
if (!onboardingRequest) {
throw new OnboardingError('Solicitud de onboarding inválida o expirada.', 400);
}
if (onboardingRequest.otpExpiresAt < new Date()) {
throw new OnboardingError('La sesión de onboarding expiró. Solicita un nuevo OTP.', 400);
}
if (!onboardingRequest.emailVerifiedAt) {
throw new OnboardingError('Debes verificar el email antes de continuar.', 400);
}
if (onboardingRequest.completedAt) {
throw new OnboardingError('Este onboarding ya fue completado.', 400);
}
const plan = await db.plan.findUnique({
where: { code: input.planCode },
select: { code: true },
});
if (!plan) {
throw new OnboardingError('El plan seleccionado no existe.', 400);
}
const { user } = await auth.api.signUpEmail({
asResponse: false,
body: {
email: onboardingRequest.email,
password: input.password,
name: onboardingRequest.fullName,
},
});
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(),
complexName: input.complexName.trim(),
physicalAddress: input.physicalAddress.trim(),
city: input.city?.trim() || null,
state: input.state?.trim() || null,
country: input.country?.trim() || null,
complexSlug,
adminEmail: onboardingRequest.email,
planCode: input.planCode,
},
});
await tx.complexUser.create({
data: {
complexId: complex.id,
userId: user.id,
role: 'ADMIN',
},
});
await tx.onboardingRequest.update({
where: { id: onboardingRequest.id },
data: {
completedAt: new Date(),
},
});
return {
userId: user.id,
complexId: complex.id,
complexSlug: complex.complexSlug,
};
});
return {
...result,
};
}

View File

@@ -1,23 +1,24 @@
import { db } from '@/lib/prisma';
import { parsePlanRules } from '@/modules/plan/services/plan-rules.service';
import type { AppContext } from '@/types/hono';
export async function listPlansHandler(c: AppContext) {
const plans = await db.plan.findMany({
select: {
code: true,
name: true,
price: true,
},
orderBy: {
price: 'asc',
},
});
return c.json(
plans.map((plan) => ({
plans.map((plan) => {
const rules = parsePlanRules(plan.rules);
return {
code: plan.code,
name: plan.name,
price: Number(plan.price),
}))
features: rules.features,
limits: rules.limits,
};
})
);
}

View File

@@ -3,7 +3,6 @@ import { adminBookingRoutes } from '@/modules/admin-booking/admin-booking.routes
import { authRoutes } from '@/modules/auth/auth.routes';
import { complexRoutes } from '@/modules/complex/complex.routes';
import { courtRoutes } from '@/modules/court/court.routes';
import { onboardingRoutes } from '@/modules/onboarding/onboarding.routes';
import { passwordResetRoutes } from '@/modules/password-reset/password-reset.routes';
import { planRoutes } from '@/modules/plan/plan.routes';
import { publicBookingRoutes } from '@/modules/public-booking/public-booking.routes';
@@ -20,7 +19,6 @@ export function registerRoutes(app: Hono<AppEnv>) {
.route('', authRoutes)
.route('/api/user', userRoutes)
.route('/api/plans', planRoutes)
.route('/api/onboarding', onboardingRoutes)
.route('/api/password-reset', passwordResetRoutes)
.route('/api/complexes', complexRoutes)
.route('/api/sports', sportRoutes)

View File

@@ -60,13 +60,23 @@ export function HomePage() {
}
}
if (!currentComplexQuery.isLoading && !currentComplexQuery.data && myComplexesQuery.data) {
if (
!currentComplexQuery.isLoading &&
!currentComplexQuery.data &&
myComplexesQuery.data &&
!myComplexesQuery.isLoading
) {
if (myComplexesQuery.data.length === 0) {
navigate({ to: '/onboard/setup' });
return;
}
autoSelect();
}
}, [
currentComplexQuery.isLoading,
currentComplexQuery.data,
myComplexesQuery.data,
myComplexesQuery.isLoading,
navigate,
queryClient,
]);

View File

@@ -6,7 +6,7 @@ import { apiClient, authClient } from '@/lib/api-client';
import { useAuth } from '@/lib/auth';
import { acceptStoredPendingInvite } from '@/lib/invitations';
import { zodResolver } from '@hookform/resolvers/zod';
import { Link, useNavigate } from '@tanstack/react-router';
import { Link } from '@tanstack/react-router';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
@@ -18,12 +18,7 @@ const loginSchema = z.object({
type LoginForm = z.infer<typeof loginSchema>;
type LoginPageProps = {
redirectTo?: string;
};
export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
const navigate = useNavigate();
export function LoginPage() {
const { signInWithPassword } = useAuth();
const [submitError, setSubmitError] = useState<string | null>(null);
@@ -42,16 +37,6 @@ export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
async function handlePostLogin() {
await acceptStoredPendingInvite(apiClient.complexes.acceptPendingInvitations);
const complexes = await apiClient.complexes.listMine();
if (complexes.length === 0) {
await navigate({ to: redirectTo });
} else if (complexes.length === 1) {
await apiClient.complexes.select({ complexId: complexes[0].id });
navigate({ to: redirectTo });
} else {
navigate({ to: '/select-complex' });
}
}
const onSubmit = async (values: LoginForm) => {

View File

@@ -1,7 +0,0 @@
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

@@ -1,119 +0,0 @@
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';
import type { PlanSummary } from '@repo/api-contract';
import { Controller, type UseFormReturn } from 'react-hook-form';
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">Contraseña</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

@@ -1,58 +0,0 @@
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';
import type { UseFormReturn } from 'react-hook-form';
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

@@ -1,108 +0,0 @@
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';
import { RefreshCw } from 'lucide-react';
import type { UseFormReturn } from 'react-hook-form';
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

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

View File

@@ -0,0 +1,161 @@
import { Button } from '@/components/ui/button';
import {
Stepper,
StepperContent,
StepperDescription,
StepperIndicator,
StepperItem,
StepperList,
StepperNext,
StepperPrev,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from '@/components/ui/stepper';
import { ComplexNameStep } from '@/features/onboard/components/steps/complex-name-step';
import { CourtsSetupStep } from '@/features/onboard/components/steps/courts-setup-step';
import { LocationStep } from '@/features/onboard/components/steps/location-step';
import { PlanSelectStep } from '@/features/onboard/components/steps/plan-select-step';
import { ReviewStep } from '@/features/onboard/components/steps/review-step';
import type { PlanWithFeatures, Sport } from '@repo/api-contract';
import { Loader2 } from 'lucide-react';
import { useCallback, useState } from 'react';
import type { UseFormReturn } from 'react-hook-form';
type SetupStepperProps = {
form: UseFormReturn<any>;
plans: PlanWithFeatures[];
sports: Sport[];
isSubmitting: boolean;
onSubmit: () => Promise<void>;
};
const STEPS = [
{ value: 'step-1', title: 'Nombre', description: 'Nombre del complejo' },
{ value: 'step-2', title: 'Ubicación', description: 'Dirección y ciudad' },
{ value: 'step-3', title: 'Plan', description: 'Elegí tu plan' },
{ value: 'step-4', title: 'Canchas', description: 'Crear cancha inicial' },
{ value: 'step-5', title: 'Revisar', description: 'Confirmar datos' },
];
const VALIDATION_MAP: Record<string, string[]> = {
'step-2': ['complexName'],
'step-3': ['physicalAddress'],
'step-4': ['planCode'],
'step-5': ['planCode'],
};
export function SetupStepper({ form, plans, sports, isSubmitting, onSubmit }: SetupStepperProps) {
const [currentStep, setCurrentStep] = useState('step-1');
const setupCourts = form.watch('setupCourts') as boolean | undefined;
const handleValidate = useCallback(
async (nextValue: string, direction: string) => {
if (direction === 'prev') return true;
let fields = VALIDATION_MAP[nextValue];
if (nextValue === 'step-5' && setupCourts) {
fields = [
...(fields ?? []),
'courtSportId',
'courtStartTime',
'courtEndTime',
'courtDaysOfWeek',
];
}
if (fields && fields.length > 0) {
return form.trigger(fields as never[]);
}
return true;
},
[form, setupCourts]
);
const selectedPlan = plans.find((p) => p.code === form.watch('planCode'));
const courtSportId = form.watch('courtSportId') as string | undefined;
const selectedSport = sports.find((s) => s.id === courtSportId);
return (
<Stepper value={currentStep} onValueChange={setCurrentStep} onValidate={handleValidate}>
<StepperList>
{STEPS.map((step) => (
<StepperItem key={step.value} value={step.value}>
<StepperTrigger>
<StepperIndicator />
<div className="flex flex-col gap-0.5 text-left max-sm:hidden">
<StepperTitle>{step.title}</StepperTitle>
<StepperDescription>{step.description}</StepperDescription>
</div>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
))}
</StepperList>
<div className="mt-6">
<StepperContent value="step-1">
<ComplexNameStep form={form} />
</StepperContent>
<StepperContent value="step-2">
<LocationStep form={form} />
</StepperContent>
<StepperContent value="step-3">
<PlanSelectStep form={form} plans={plans} />
</StepperContent>
<StepperContent value="step-4">
<CourtsSetupStep form={form} sports={sports} />
</StepperContent>
<StepperContent value="step-5">
<ReviewStep
data={{
complexName: (form.watch('complexName') as string) ?? '',
physicalAddress: (form.watch('physicalAddress') as string) ?? '',
city: form.watch('city') as string | undefined,
state: form.watch('state') as string | undefined,
country: form.watch('country') as string | undefined,
planCode: (form.watch('planCode') as string) ?? '',
setupCourts: setupCourts ?? false,
courtSportName: selectedSport?.name,
courtStartTime: (form.watch('courtStartTime') as string) ?? '08:00',
courtEndTime: (form.watch('courtEndTime') as string) ?? '22:00',
courtDaysOfWeek: form.watch('courtDaysOfWeek') as string[] | undefined,
}}
selectedPlan={selectedPlan}
/>
</StepperContent>
</div>
<div className="mt-6 flex items-center justify-between">
<StepperPrev asChild>
<Button variant="outline" disabled={isSubmitting}>
Anterior
</Button>
</StepperPrev>
{currentStep === 'step-5' ? (
<Button onClick={onSubmit} disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Creando complejo...
</>
) : (
'Confirmar y crear complejo'
)}
</Button>
) : (
<StepperNext asChild>
<Button disabled={isSubmitting}>Siguiente</Button>
</StepperNext>
)}
</div>
</Stepper>
);
}

View File

@@ -0,0 +1,32 @@
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import type { UseFormReturn } from 'react-hook-form';
type ComplexNameStepProps = {
form: UseFormReturn<any>;
};
export function ComplexNameStep({ form }: ComplexNameStepProps) {
const errors = form.formState.errors;
return (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold">Nombre del complejo</h2>
<p className="text-sm text-muted-foreground">¿Cómo se va a llamar tu complejo?</p>
</div>
<Field data-invalid={Boolean(errors.complexName)}>
<FieldLabel htmlFor="complex-name">Nombre del complejo</FieldLabel>
<Input
id="complex-name"
type="text"
placeholder="Ej: Complejo Las Palmeras"
aria-invalid={Boolean(errors.complexName)}
{...form.register('complexName')}
/>
<FieldError errors={[errors.complexName]} />
</Field>
</div>
);
}

View File

@@ -0,0 +1,158 @@
import { Field, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
import type { Sport } from '@repo/api-contract';
import { Check } from 'lucide-react';
import type { UseFormReturn } from 'react-hook-form';
type CourtsSetupStepProps = {
form: UseFormReturn<any>;
sports: Sport[];
};
const DAYS_OF_WEEK = [
{ value: 'MONDAY', label: 'Lun' },
{ value: 'TUESDAY', label: 'Mar' },
{ value: 'WEDNESDAY', label: 'Mié' },
{ value: 'THURSDAY', label: 'Jue' },
{ value: 'FRIDAY', label: 'Vie' },
{ value: 'SATURDAY', label: 'Sáb' },
{ value: 'SUNDAY', label: 'Dom' },
] as const;
export function CourtsSetupStep({ form, sports }: CourtsSetupStepProps) {
const setupCourts = form.watch('setupCourts') as boolean | undefined;
const courtDaysOfWeek = form.watch('courtDaysOfWeek') as string[] | undefined;
const errors = form.formState.errors;
const toggleDay = (day: string) => {
const current = courtDaysOfWeek ?? [];
const next = current.includes(day) ? current.filter((d) => d !== day) : [...current, day];
form.setValue('courtDaysOfWeek', next, { shouldValidate: true });
};
return (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold">Crear canchas</h2>
<p className="text-sm text-muted-foreground">
Configurá una cancha para empezar a recibir reservas.
</p>
</div>
<label className="flex items-center gap-3 rounded-lg border p-4 cursor-pointer hover:bg-muted/50 transition-colors">
<input
type="checkbox"
className="size-4 accent-primary"
checked={setupCourts ?? false}
onChange={(e) => {
form.setValue('setupCourts', e.target.checked, { shouldValidate: true });
if (!e.target.checked) {
form.setValue('courtSportId', undefined);
form.setValue('courtStartTime', undefined);
form.setValue('courtEndTime', undefined);
form.setValue('courtDaysOfWeek', undefined);
}
}}
/>
<div>
<p className="font-medium text-sm">, crear una cancha ahora</p>
<p className="text-xs text-muted-foreground">
Vas a poder agregar más canchas después desde el panel de administración.
</p>
</div>
</label>
{setupCourts && (
<div className="space-y-4 pl-6 border-l-2 border-primary/20">
<Field data-invalid={Boolean(errors.courtSportId)}>
<FieldLabel htmlFor="court-sport">Deporte</FieldLabel>
<select
id="court-sport"
className="h-10 w-full rounded-md border bg-background px-3 text-sm aria-invalid:border-destructive"
aria-invalid={Boolean(errors.courtSportId)}
{...form.register('courtSportId')}
>
<option value="">Seleccioná un deporte</option>
{sports.map((sport) => (
<option key={sport.id} value={sport.id}>
{sport.name}
</option>
))}
</select>
{errors.courtSportId && (
<p className="text-sm text-destructive mt-1">
{String(errors.courtSportId.message ?? '')}
</p>
)}
</Field>
<div className="grid grid-cols-2 gap-3">
<Field data-invalid={Boolean(errors.courtStartTime)}>
<FieldLabel htmlFor="court-start">Horario desde</FieldLabel>
<Input
id="court-start"
type="time"
defaultValue="08:00"
aria-invalid={Boolean(errors.courtStartTime)}
{...form.register('courtStartTime')}
/>
{errors.courtStartTime && (
<p className="text-sm text-destructive mt-1">
{String(errors.courtStartTime.message ?? '')}
</p>
)}
</Field>
<Field data-invalid={Boolean(errors.courtEndTime)}>
<FieldLabel htmlFor="court-end">Horario hasta</FieldLabel>
<Input
id="court-end"
type="time"
defaultValue="22:00"
aria-invalid={Boolean(errors.courtEndTime)}
{...form.register('courtEndTime')}
/>
{errors.courtEndTime && (
<p className="text-sm text-destructive mt-1">
{String(errors.courtEndTime.message ?? '')}
</p>
)}
</Field>
</div>
<div>
<Label className="mb-2 block text-sm">Días de la semana</Label>
<div className="flex flex-wrap gap-2">
{DAYS_OF_WEEK.map((day) => {
const isSelected = courtDaysOfWeek?.includes(day.value) ?? false;
return (
<button
key={day.value}
type="button"
onClick={() => toggleDay(day.value)}
className={cn(
'flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-all',
isSelected
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:border-muted-foreground/30'
)}
>
{isSelected && <Check className="size-3" />}
{day.label}
</button>
);
})}
</div>
{errors.courtDaysOfWeek && (
<p className="text-sm text-destructive mt-1">
{String(errors.courtDaysOfWeek.message ?? '')}
</p>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,70 @@
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import type { UseFormReturn } from 'react-hook-form';
type LocationStepProps = {
form: UseFormReturn<any>;
};
export function LocationStep({ form }: LocationStepProps) {
const errors = form.formState.errors;
return (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold">Ubicación</h2>
<p className="text-sm text-muted-foreground">¿Dónde está ubicado tu complejo?</p>
</div>
<Field data-invalid={Boolean(errors.physicalAddress)}>
<FieldLabel htmlFor="physical-address">Dirección</FieldLabel>
<Input
id="physical-address"
type="text"
placeholder="Ej: Av. San Martín 1234"
aria-invalid={Boolean(errors.physicalAddress)}
{...form.register('physicalAddress')}
/>
<FieldError errors={[errors.physicalAddress]} />
</Field>
<Field data-invalid={Boolean(errors.city)}>
<FieldLabel htmlFor="city">Ciudad</FieldLabel>
<Input
id="city"
type="text"
placeholder="Ej: Salta"
aria-invalid={Boolean(errors.city)}
{...form.register('city')}
/>
<FieldError errors={[errors.city]} />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field data-invalid={Boolean(errors.state)}>
<FieldLabel htmlFor="state">Provincia / Estado</FieldLabel>
<Input
id="state"
type="text"
placeholder="Ej: Salta"
aria-invalid={Boolean(errors.state)}
{...form.register('state')}
/>
<FieldError errors={[errors.state]} />
</Field>
<Field data-invalid={Boolean(errors.country)}>
<FieldLabel htmlFor="country">País</FieldLabel>
<Input
id="country"
type="text"
placeholder="Ej: Argentina"
aria-invalid={Boolean(errors.country)}
{...form.register('country')}
/>
<FieldError errors={[errors.country]} />
</Field>
</div>
</div>
);
}

View File

@@ -0,0 +1,100 @@
import { cn } from '@/lib/utils';
import type { PlanWithFeatures } from '@repo/api-contract';
import { Check, X } from 'lucide-react';
import type { UseFormReturn } from 'react-hook-form';
type PlanSelectStepProps = {
form: UseFormReturn<any>;
plans: PlanWithFeatures[];
};
const FEATURE_LABELS: Record<string, string> = {
publicBookingPage: 'Página de booking pública',
onlinePayments: 'Pagos online',
advancedReports: 'Reportes avanzados',
whatsappReminders: 'Recordatorios WhatsApp',
};
export function PlanSelectStep({ form, plans }: PlanSelectStepProps) {
const selectedCode = form.watch('planCode') as string | undefined;
const errors = form.formState.errors;
const featuresList = [
'publicBookingPage',
'onlinePayments',
'advancedReports',
'whatsappReminders',
] as const;
return (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold">Elegí tu plan</h2>
<p className="text-sm text-muted-foreground">
Seleccioná el plan que mejor se adapte a tu complejo.
</p>
</div>
<div className="grid gap-4 md:grid-cols-3">
{plans.map((plan) => {
const isSelected = selectedCode === plan.code;
return (
<button
key={plan.code}
type="button"
onClick={() => form.setValue('planCode', plan.code, { shouldValidate: true })}
className={cn(
'relative flex flex-col rounded-xl border-2 p-5 text-left transition-all',
'hover:border-primary/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
isSelected ? 'border-primary bg-primary/5 shadow-sm' : 'border-border bg-card'
)}
>
{isSelected && (
<div className="absolute right-3 top-3 flex size-6 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Check className="size-4" />
</div>
)}
<div className="mb-3">
<h3 className="text-xl font-bold">{plan.name}</h3>
<div className="mt-1">
<span className="text-3xl font-bold">${plan.price}</span>
<span className="text-sm text-muted-foreground">/mes</span>
</div>
</div>
<div className="mb-4 space-y-1.5 border-t pt-3 text-xs text-muted-foreground">
<p className="font-medium text-foreground">Incluye:</p>
<p>Hasta {plan.limits.maxCourts} canchas</p>
<p>Hasta {plan.limits.maxBookingsPerDay} reservas/día</p>
{plan.limits.maxActiveUsers && <p>Hasta {plan.limits.maxActiveUsers} usuarios</p>}
</div>
<div className="space-y-1.5 border-t pt-3">
{featuresList.map((feature) => {
const enabled = plan.features[feature as keyof typeof plan.features];
return (
<div key={feature} className="flex items-center gap-2 text-xs">
{enabled ? (
<Check className="size-3.5 shrink-0 text-green-600" />
) : (
<X className="size-3.5 shrink-0 text-muted-foreground" />
)}
<span className={enabled ? 'text-foreground' : 'text-muted-foreground'}>
{FEATURE_LABELS[feature]}
</span>
</div>
);
})}
</div>
</button>
);
})}
</div>
{errors.planCode && (
<p className="text-sm text-destructive">{String(errors.planCode.message ?? '')}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,123 @@
import type { PlanWithFeatures } from '@repo/api-contract';
import { Check, X } from 'lucide-react';
type ReviewStepProps = {
data: {
complexName: string;
physicalAddress: string;
city?: string;
state?: string;
country?: string;
planCode: string;
setupCourts: boolean;
courtSportName?: string;
courtStartTime?: string;
courtEndTime?: string;
courtDaysOfWeek?: string[];
};
selectedPlan: PlanWithFeatures | undefined;
};
const DAY_LABELS: Record<string, string> = {
MONDAY: 'Lunes',
TUESDAY: 'Martes',
WEDNESDAY: 'Miércoles',
THURSDAY: 'Jueves',
FRIDAY: 'Viernes',
SATURDAY: 'Sábado',
SUNDAY: 'Domingo',
};
export function ReviewStep({ data, selectedPlan }: ReviewStepProps) {
return (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold">Revisá tus datos</h2>
<p className="text-sm text-muted-foreground">
Antes de confirmar, revisá que todos los datos sean correctos.
</p>
</div>
<div className="space-y-3 rounded-lg border p-4">
<Section title="Complejo">
<Row label="Nombre" value={data.complexName} />
<Row label="Dirección" value={data.physicalAddress} />
{data.city && <Row label="Ciudad" value={data.city} />}
{data.state && <Row label="Provincia" value={data.state} />}
{data.country && <Row label="País" value={data.country} />}
</Section>
<Section title="Plan seleccionado">
{selectedPlan ? (
<>
<Row label="Plan" value={`${selectedPlan.name} - $${selectedPlan.price}/mes`} />
<div className="mt-2 space-y-1">
{[
['publicBookingPage', 'Página de booking pública'],
['onlinePayments', 'Pagos online'],
['advancedReports', 'Reportes avanzados'],
['whatsappReminders', 'Recordatorios WhatsApp'],
].map(([feature, label]) => {
const enabled =
selectedPlan.features[feature as keyof typeof selectedPlan.features];
return (
<div key={feature} className="flex items-center gap-2 text-xs">
{enabled ? (
<Check className="size-3.5 shrink-0 text-green-600" />
) : (
<X className="size-3.5 shrink-0 text-muted-foreground" />
)}
<span className={enabled ? '' : 'text-muted-foreground'}>{label}</span>
</div>
);
})}
</div>
</>
) : (
<p className="text-sm text-muted-foreground">No se seleccionó un plan.</p>
)}
</Section>
<Section title="Cancha">
{data.setupCourts ? (
<>
<Row label="Deporte" value={data.courtSportName ?? '-'} />
<Row
label="Horario"
value={`${data.courtStartTime ?? '08:00'} - ${data.courtEndTime ?? '22:00'}`}
/>
<Row
label="Días"
value={(data.courtDaysOfWeek ?? []).map((d) => DAY_LABELS[d] ?? d).join(', ')}
/>
</>
) : (
<p className="text-sm text-muted-foreground">
No se crearán canchas automáticamente. Podés agregarlas después.
</p>
)}
</Section>
</div>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div>
<h3 className="mb-2 text-sm font-semibold text-muted-foreground uppercase tracking-wide">
{title}
</h3>
<div className="space-y-1.5">{children}</div>
</div>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between gap-4 text-sm">
<span className="shrink-0 text-muted-foreground">{label}</span>
<span className="flex-1 text-end font-medium">{value}</span>
</div>
);
}

View File

@@ -1,222 +0,0 @@
import { Button } from '@/components/ui/button';
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
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 } from '@repo/api-contract';
import { useNavigate } from '@tanstack/react-router';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const createComplexSchema = z.object({
complexName: z
.string()
.trim()
.min(3, 'El nombre del complejo debe tener al menos 3 caracteres.')
.max(120, 'El nombre del complejo no puede superar los 120 caracteres.'),
physicalAddress: z
.string()
.trim()
.min(5, 'La direccion debe tener al menos 5 caracteres.')
.max(200, 'La direccion no puede superar los 200 caracteres.'),
city: z.string().trim().max(100, 'La ciudad no puede superar los 100 caracteres.').optional(),
state: z
.string()
.trim()
.max(100, 'La provincia/estado no puede superar los 100 caracteres.')
.optional(),
country: z.string().trim().max(100, 'El país no puede superar los 100 caracteres.').optional(),
planCode: z
.string()
.trim()
.min(1, 'El planCode no puede estar vacío.')
.max(10, 'El planCode no puede superar los 10 caracteres.')
.optional(),
});
type CreateComplexForm = z.infer<typeof createComplexSchema>;
export function CreateComplexPage() {
const navigate = useNavigate();
const { user } = useAuth();
const [plans, setPlans] = useState<PlanSummary[]>([]);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const form = useForm<CreateComplexForm>({
resolver: zodResolver(createComplexSchema),
mode: 'onChange',
defaultValues: {
complexName: '',
physicalAddress: '',
city: '',
state: '',
country: '',
planCode: '',
},
});
useEffect(() => {
void (async () => {
try {
const result = await apiClient.plans.list();
setPlans(result);
} catch {
setPlans([]);
}
})();
}, []);
const onSubmit = async (values: CreateComplexForm) => {
setErrorMessage(null);
setIsSubmitting(true);
try {
const pendingEmail = sessionStorage.getItem('pending-signup-email');
const payload: Parameters<typeof apiClient.complexes.create>[0] = {
complexName: values.complexName,
physicalAddress: values.physicalAddress,
city: values.city,
state: values.state,
country: values.country,
planCode: values.planCode,
};
if (pendingEmail) {
payload.adminEmail = pendingEmail;
}
const complex = await apiClient.complexes.create(payload);
sessionStorage.removeItem('pending-signup-email');
sessionStorage.removeItem('pending-signup-userId');
setCurrentComplexSlug(complex.complexSlug);
await navigate({
to: '/complex/$slug/edit',
params: { slug: complex.complexSlug },
});
} catch {
setErrorMessage('No pudimos crear el complejo. Intenta nuevamente.');
} finally {
setIsSubmitting(false);
}
};
return (
<main className="flex min-h-screen w-full items-center justify-center px-3 sm:px-6">
<section className="w-full max-w-sm rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
<h1 className="mb-2 text-2xl font-semibold">Crear tu complejo</h1>
<p className="mb-4 text-sm text-muted-foreground">
Completá los datos de tu complejo para comenzar.
</p>
<form className="space-y-3" onSubmit={form.handleSubmit(onSubmit)}>
{user?.email && (
<Field>
<FieldLabel htmlFor="user-email">Email</FieldLabel>
<Input id="user-email" type="email" value={user.email} disabled readOnly />
</Field>
)}
<Field data-invalid={Boolean(form.formState.errors.complexName)}>
<FieldLabel htmlFor="complexName">Nombre del complejo</FieldLabel>
<Input
id="complexName"
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="physicalAddress">Dirección física</FieldLabel>
<Input
id="physicalAddress"
type="text"
placeholder="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.city)}>
<FieldLabel htmlFor="city">Ciudad</FieldLabel>
<Input
id="city"
type="text"
placeholder="Salta"
aria-invalid={Boolean(form.formState.errors.city)}
{...form.register('city')}
/>
<FieldError errors={[form.formState.errors.city]} />
</Field>
<Field data-invalid={Boolean(form.formState.errors.state)}>
<FieldLabel htmlFor="state">Provincia/Estado</FieldLabel>
<Input
id="state"
type="text"
placeholder="Salta"
aria-invalid={Boolean(form.formState.errors.state)}
{...form.register('state')}
/>
<FieldError errors={[form.formState.errors.state]} />
</Field>
<Field data-invalid={Boolean(form.formState.errors.country)}>
<FieldLabel htmlFor="country">País</FieldLabel>
<Input
id="country"
type="text"
placeholder="Argentina"
aria-invalid={Boolean(form.formState.errors.country)}
{...form.register('country')}
/>
<FieldError errors={[form.formState.errors.country]} />
</Field>
<Field data-invalid={Boolean(form.formState.errors.planCode)}>
<FieldLabel htmlFor="planCode">Plan</FieldLabel>
<select
id="planCode"
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
aria-invalid={Boolean(form.formState.errors.planCode)}
{...form.register('planCode')}
>
<option value="">
{plans.length > 0 ? 'Selecciona un plan' : '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.
</p>
)}
<FieldError errors={[form.formState.errors.planCode]} />
</Field>
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
<Button
type="submit"
className="w-full"
disabled={isSubmitting || !form.formState.isValid}
>
{isSubmitting ? 'Creando complejo...' : 'Crear complejo'}
</Button>
</form>
</section>
</main>
);
}

View File

@@ -1,119 +0,0 @@
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>
);
}

View File

@@ -1,317 +0,0 @@
import { Button } from '@/components/ui/button';
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { apiClient, authClient } from '@/lib/api-client';
import { useAuth } from '@/lib/auth';
import { acceptStoredPendingInvite } from '@/lib/invitations';
import { zodResolver } from '@hookform/resolvers/zod';
import { useNavigate } from '@tanstack/react-router';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const loginSchema = z.object({
email: z.string().email('Ingresá un email válido.'),
password: z.string().min(6, 'La contraseña debe tener al menos 6 caracteres.'),
});
const signupSchema = z
.object({
fullName: z.string().min(2, 'El nombre debe tener al menos 2 caracteres.'),
email: z.string().email('Ingresá un email válido.'),
password: z.string().min(6, 'La contraseña debe tener al menos 6 caracteres.'),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Las contraseñas no coinciden.',
path: ['confirmPassword'],
});
type LoginForm = z.infer<typeof loginSchema>;
type SignupForm = z.infer<typeof signupSchema>;
type OnboardLoginPageProps = {
variant?: 'onboard' | 'invite';
inviteEmail?: string | null;
};
export function OnboardLoginPage({
variant = 'onboard',
inviteEmail = null,
}: OnboardLoginPageProps) {
const navigate = useNavigate();
const { signInWithPassword, signUp } = useAuth();
const [mode, setMode] = useState<'login' | 'signup'>(variant === 'invite' ? 'signup' : 'login');
const [submitError, setSubmitError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const loginForm = useForm<LoginForm>({
resolver: zodResolver(loginSchema),
mode: 'onChange',
defaultValues: { email: inviteEmail ?? '', password: '' },
});
const signupForm = useForm<SignupForm>({
resolver: zodResolver(signupSchema),
mode: 'onChange',
defaultValues: {
fullName: '',
email: inviteEmail ?? '',
password: '',
confirmPassword: '',
},
});
async function handlePostLogin() {
await acceptStoredPendingInvite(apiClient.complexes.acceptPendingInvitations);
if (variant === 'invite') {
await navigate({ to: '/' });
return;
}
const complexes = await apiClient.complexes.listMine();
if (complexes.length === 0) {
navigate({ to: '/onboard/create-complex' });
} else if (complexes.length === 1) {
await apiClient.complexes.select({ complexId: complexes[0].id });
navigate({ to: '/' });
} else {
navigate({ to: '/select-complex' });
}
}
const onLoginSubmit = async (values: LoginForm) => {
setSubmitError(null);
setIsSubmitting(true);
try {
await signInWithPassword(values);
await handlePostLogin();
} catch {
setSubmitError('No pudimos iniciar sesión. Verificá tus credenciales.');
} finally {
setIsSubmitting(false);
}
};
const onSignupSubmit = async (values: SignupForm) => {
setSubmitError(null);
setIsSubmitting(true);
try {
const result = await signUp({
email: values.email,
password: values.password,
fullName: values.fullName,
});
if (result.requiresEmailConfirmation) {
sessionStorage.setItem('pending-signup-email', values.email);
navigate({
to: '/onboard/verify-email',
search: { email: values.email },
});
} else {
if (variant === 'invite') {
await acceptStoredPendingInvite(apiClient.complexes.acceptPendingInvitations);
await navigate({ to: '/' });
} else {
await handlePostLogin();
}
}
} catch {
setSubmitError('No pudimos crear la cuenta. Intenta nuevamente.');
} finally {
setIsSubmitting(false);
}
};
const handleGoogleSignIn = async () => {
setSubmitError(null);
try {
await authClient.signIn.social({
provider: 'google',
callbackURL: `${window.location.origin}/auth-callback`,
});
} catch {
setSubmitError('No pudimos iniciar sesión con Google.');
}
};
return (
<main className="flex min-h-screen w-full items-center justify-center px-3 sm:px-6">
<section className="w-full max-w-sm rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
<h1 className="mb-2 text-2xl font-semibold">
{mode === 'login' ? 'Iniciar sesión' : 'Crear cuenta'}
</h1>
<p className="mb-4 text-sm text-muted-foreground">
{mode === 'login'
? variant === 'invite'
? 'Ingresá con tu cuenta para aceptar la invitación.'
: 'Ingresá con tu cuenta para continuar.'
: variant === 'invite'
? 'Creá tu cuenta para aceptar la invitación.'
: 'Creá tu cuenta para comenzar.'}
</p>
<Button type="button" variant="outline" className="w-full" onClick={handleGoogleSignIn}>
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Continuar con Google
</Button>
<div className="relative my-4">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">O</span>
</div>
</div>
{mode === 'login' ? (
<form className="space-y-3" onSubmit={loginForm.handleSubmit(onLoginSubmit)}>
<Field data-invalid={Boolean(loginForm.formState.errors.email)}>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
id="email"
type="email"
placeholder="tu@email.com"
aria-invalid={Boolean(loginForm.formState.errors.email)}
{...loginForm.register('email')}
/>
<FieldError errors={[loginForm.formState.errors.email]} />
</Field>
<Field data-invalid={Boolean(loginForm.formState.errors.password)}>
<FieldLabel htmlFor="password">Contraseña</FieldLabel>
<Input
id="password"
type="password"
placeholder="••••••••"
aria-invalid={Boolean(loginForm.formState.errors.password)}
{...loginForm.register('password')}
/>
<FieldError errors={[loginForm.formState.errors.password]} />
</Field>
{submitError && <p className="text-sm text-destructive">{submitError}</p>}
<Button
type="submit"
className="w-full"
disabled={!loginForm.formState.isValid || isSubmitting}
>
{isSubmitting ? 'Ingresando...' : 'Ingresar'}
</Button>
</form>
) : (
<form className="space-y-3" onSubmit={signupForm.handleSubmit(onSignupSubmit)}>
<Field data-invalid={Boolean(signupForm.formState.errors.fullName)}>
<FieldLabel htmlFor="fullName">Nombre completo</FieldLabel>
<Input
id="fullName"
type="text"
placeholder="Juan Pérez"
aria-invalid={Boolean(signupForm.formState.errors.fullName)}
{...signupForm.register('fullName')}
/>
<FieldError errors={[signupForm.formState.errors.fullName]} />
</Field>
<Field data-invalid={Boolean(signupForm.formState.errors.email)}>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
id="email"
type="email"
placeholder="tu@email.com"
aria-invalid={Boolean(signupForm.formState.errors.email)}
{...signupForm.register('email')}
/>
<FieldError errors={[signupForm.formState.errors.email]} />
</Field>
<Field data-invalid={Boolean(signupForm.formState.errors.password)}>
<FieldLabel htmlFor="password">Contraseña</FieldLabel>
<Input
id="password"
type="password"
placeholder="••••••••"
aria-invalid={Boolean(signupForm.formState.errors.password)}
{...signupForm.register('password')}
/>
<FieldError errors={[signupForm.formState.errors.password]} />
</Field>
<Field data-invalid={Boolean(signupForm.formState.errors.confirmPassword)}>
<FieldLabel htmlFor="confirmPassword">Confirmar contraseña</FieldLabel>
<Input
id="confirmPassword"
type="password"
placeholder="••••••••"
aria-invalid={Boolean(signupForm.formState.errors.confirmPassword)}
{...signupForm.register('confirmPassword')}
/>
<FieldError errors={[signupForm.formState.errors.confirmPassword]} />
</Field>
{submitError && <p className="text-sm text-destructive">{submitError}</p>}
<Button
type="submit"
className="w-full"
disabled={!signupForm.formState.isValid || isSubmitting}
>
{isSubmitting ? 'Creando cuenta...' : 'Crear cuenta'}
</Button>
</form>
)}
<p className="mt-4 text-center text-sm text-muted-foreground">
{mode === 'login' ? (
<>
¿No tenés cuenta?{' '}
<button
type="button"
className="text-primary hover:underline"
onClick={() => setMode('signup')}
>
Crear cuenta
</button>
</>
) : (
<>
¿Ya tenés cuenta?{' '}
<button
type="button"
className="text-primary hover:underline"
onClick={() => setMode('login')}
>
Iniciar sesión
</button>
</>
)}
</p>
</section>
</main>
);
}

View File

@@ -1,79 +0,0 @@
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>
);
}

View File

@@ -1,169 +0,0 @@
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>
);
}

View File

@@ -1,140 +0,0 @@
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);
}

View File

@@ -1,10 +0,0 @@
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'>;

View File

@@ -0,0 +1,114 @@
import { SetupStepper } from '@/features/onboard/components/setup-stepper';
import { apiClient } from '@/lib/api-client';
import { setCurrentComplexSlug } from '@/lib/current-complex';
import { zodResolver } from '@hookform/resolvers/zod';
import { createComplexSchema } from '@repo/api-contract';
import type { PlanWithFeatures, Sport } from '@repo/api-contract';
import { useNavigate } from '@tanstack/react-router';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
export function SetupStepperPage() {
const navigate = useNavigate();
const [plans, setPlans] = useState<PlanWithFeatures[]>([]);
const [sports, setSports] = useState<Sport[]>([]);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const form = useForm({
resolver: zodResolver(createComplexSchema),
mode: 'onChange',
defaultValues: {
complexName: '',
physicalAddress: '',
city: '',
state: '',
country: '',
planCode: '',
setupCourts: false,
courtSportId: undefined,
courtStartTime: '08:00',
courtEndTime: '22:00',
courtDaysOfWeek: [],
},
});
useEffect(() => {
void (async () => {
try {
// Safety: if user already has complexes (e.g., accepted invite between redirects),
// redirect to home
const myComplexes = await apiClient.complexes.listMine();
if (myComplexes.length > 0) {
navigate({ to: '/' });
return;
}
const [plansResult, sportsResult] = await Promise.all([
apiClient.plans.list(),
apiClient.sports.list(),
]);
setPlans(plansResult);
setSports(sportsResult);
} catch {
setErrorMessage('Error al cargar datos iniciales.');
}
})();
}, [navigate]);
const onSubmit = async () => {
const isValid = await form.trigger();
if (!isValid) return;
setErrorMessage(null);
setIsSubmitting(true);
try {
const values = form.getValues();
const result = await apiClient.complexes.create(values as any);
setCurrentComplexSlug(result.complexSlug);
await navigate({
to: '/complex/$slug/edit',
params: { slug: result.complexSlug },
});
} catch {
setErrorMessage('No pudimos crear el complejo. Intenta nuevamente.');
} finally {
setIsSubmitting(false);
}
};
if (errorMessage && plans.length === 0) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-6xl items-center justify-center px-3 sm:px-6">
<section className="w-full max-w-xl rounded-xl border bg-card p-6 text-card-foreground shadow-sm text-center">
<p className="text-destructive">{errorMessage}</p>
</section>
</main>
);
}
return (
<main className="mx-auto flex min-h-screen w-full max-w-6xl items-start justify-center px-3 py-12 sm:px-6">
<section className="w-full max-w-3xl rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
<div className="mb-6">
<h1 className="text-2xl font-semibold">Configurá tu complejo</h1>
<p className="text-sm text-muted-foreground">
Completá los datos para empezar a recibir reservas.
</p>
</div>
<SetupStepper
form={form}
plans={plans}
sports={sports}
isSubmitting={isSubmitting}
onSubmit={onSubmit}
/>
{errorMessage && <p className="mt-4 text-sm text-destructive">{errorMessage}</p>}
</section>
</main>
);
}

View File

@@ -1,68 +0,0 @@
import { Button } from '@/components/ui/button';
import { getPendingInvite } from '@/lib/invitations';
import { Route } from '@/routes/onboard/verify-email';
import { Link } from '@tanstack/react-router';
import { useEffect, useState } from 'react';
export function VerifyEmailPage() {
const search = Route.useSearch();
const email = search.email || sessionStorage.getItem('pending-signup-email') || '';
const pendingInvite = getPendingInvite();
const [resendCooldown, setResendCooldown] = useState(0);
useEffect(() => {
if (resendCooldown > 0) {
const timer = setTimeout(() => setResendCooldown((c) => c - 1), 1000);
return () => clearTimeout(timer);
}
}, [resendCooldown]);
const handleResend = async () => {
if (!email || resendCooldown > 0) return;
setResendCooldown(60);
};
return (
<main className="flex min-h-screen w-full items-center justify-center px-3 sm:px-6">
<section className="w-full max-w-sm rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
<h1 className="mb-2 text-2xl font-semibold">Verificá tu email</h1>
<p className="mb-4 text-sm text-muted-foreground">
Te enviamos un email de verificación a <strong>{email}</strong>. Hacé click en el link
para confirmar tu cuenta.
</p>
<div className="space-y-3">
<Button
type="button"
className="w-full"
onClick={handleResend}
disabled={resendCooldown > 0}
>
{resendCooldown > 0 ? `Reenviar en ${resendCooldown}s` : 'Reenviar email'}
</Button>
<p className="text-center text-sm text-muted-foreground">
¿Ya verificaste tu email?{' '}
{pendingInvite.token ? (
<Link
to="/invite"
search={{
email: pendingInvite.email ?? email,
inviteToken: pendingInvite.token,
}}
className="text-primary hover:underline"
>
Continuar con la invitación
</Link>
) : (
<Link to="/onboard/create-complex" className="text-primary hover:underline">
Continuar
</Link>
)}
</p>
</div>
</section>
</main>
);
}

View File

@@ -83,7 +83,7 @@ export function SignupPage() {
setIsSubmitting(true);
try {
await signUp({
const { requiresEmailConfirmation } = await signUp({
email: values.email,
password: values.password,
fullName: values.fullName,
@@ -91,7 +91,11 @@ export function SignupPage() {
});
sessionStorage.removeItem(STORAGE_KEY);
await navigate({ to: '/' });
if (requiresEmailConfirmation) {
sessionStorage.setItem('pending-signup-email', values.email);
await navigate({ to: '/onboard/setup' });
}
} catch {
setSubmitError('No pudimos crear la cuenta. Intenta nuevamente.');
} finally {

View File

@@ -43,7 +43,6 @@ export class ApiClientError extends Error {
}
export const apiClient = {
onboarding: api.onboarding,
plans: api.plans,
user: api.user,
complexes: api.complexes,

View File

@@ -6,7 +6,6 @@ export * as courts from './resources/courts';
export * as user from './resources/user';
export * as sports from './resources/sports';
export * as plans from './resources/plans';
export * as onboarding from './resources/onboarding';
export {
getAvailability,

View File

@@ -1,37 +0,0 @@
import type {
OnboardingCompleteInput,
OnboardingCompleteResponse,
OnboardingResendOtpInput,
OnboardingResendOtpResponse,
OnboardingStartInput,
OnboardingStartResponse,
OnboardingVerifyOtpInput,
OnboardingVerifyOtpResponse,
} from '@repo/api-contract';
import { http } from '../http';
export async function start(payload: OnboardingStartInput) {
const response = await http.post<OnboardingStartResponse>('/api/onboarding/start', payload);
return response.data;
}
export async function verifyOtp(payload: OnboardingVerifyOtpInput) {
const response = await http.post<OnboardingVerifyOtpResponse>(
'/api/onboarding/verify-otp',
payload
);
return response.data;
}
export async function resendOtp(payload: OnboardingResendOtpInput) {
const response = await http.post<OnboardingResendOtpResponse>(
'/api/onboarding/resend-otp',
payload
);
return response.data;
}
export async function complete(payload: OnboardingCompleteInput) {
const response = await http.post<OnboardingCompleteResponse>('/api/onboarding/complete', payload);
return response.data;
}

View File

@@ -1,7 +1,7 @@
import type { PlanSummary } from '@repo/api-contract';
import type { PlanWithFeatures } from '@repo/api-contract';
import { http } from '../http';
export async function list() {
const response = await http.get<PlanSummary[]>('/api/plans');
const response = await http.get<PlanWithFeatures[]>('/api/plans');
return response.data;
}

View File

@@ -17,11 +17,7 @@ import { Route as LoginRouteImport } from './routes/login'
import { Route as InviteRouteImport } from './routes/invite'
import { Route as AuthCallbackRouteImport } from './routes/auth-callback'
import { Route as AppRouteRouteImport } from './routes/_app/route'
import { Route as OnboardIndexRouteImport } from './routes/onboard/index'
import { Route as OnboardVerifyEmailRouteImport } from './routes/onboard/verify-email'
import { Route as OnboardVerifyRouteImport } from './routes/onboard/verify'
import { Route as OnboardCreateComplexRouteImport } from './routes/onboard/create-complex'
import { Route as OnboardCompleteRouteImport } from './routes/onboard/complete'
import { Route as OnboardSetupRouteImport } from './routes/onboard/setup'
import { Route as AppProfileRouteImport } from './routes/_app/profile'
import { Route as ComplexSlugBookingRouteImport } from './routes/$complexSlug/booking'
import { Route as AppAuthenticatedRouteRouteImport } from './routes/_app/_authenticated/route'
@@ -69,29 +65,9 @@ const AppRouteRoute = AppRouteRouteImport.update({
id: '/_app',
getParentRoute: () => rootRouteImport,
} as any)
const OnboardIndexRoute = OnboardIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => OnboardRoute,
} as any)
const OnboardVerifyEmailRoute = OnboardVerifyEmailRouteImport.update({
id: '/verify-email',
path: '/verify-email',
getParentRoute: () => OnboardRoute,
} as any)
const OnboardVerifyRoute = OnboardVerifyRouteImport.update({
id: '/verify',
path: '/verify',
getParentRoute: () => OnboardRoute,
} as any)
const OnboardCreateComplexRoute = OnboardCreateComplexRouteImport.update({
id: '/create-complex',
path: '/create-complex',
getParentRoute: () => OnboardRoute,
} as any)
const OnboardCompleteRoute = OnboardCompleteRouteImport.update({
id: '/complete',
path: '/complete',
const OnboardSetupRoute = OnboardSetupRouteImport.update({
id: '/setup',
path: '/setup',
getParentRoute: () => OnboardRoute,
} as any)
const AppProfileRoute = AppProfileRouteImport.update({
@@ -142,11 +118,7 @@ export interface FileRoutesByFullPath {
'/signup': typeof SignupRoute
'/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren
'/profile': typeof AppProfileRoute
'/onboard/complete': typeof OnboardCompleteRoute
'/onboard/create-complex': typeof OnboardCreateComplexRoute
'/onboard/verify': typeof OnboardVerifyRoute
'/onboard/verify-email': typeof OnboardVerifyEmailRoute
'/onboard/': typeof OnboardIndexRoute
'/onboard/setup': typeof OnboardSetupRoute
'/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
'/complex/$slug/edit': typeof AppAuthenticatedComplexSlugEditRoute
@@ -156,15 +128,12 @@ export interface FileRoutesByTo {
'/auth-callback': typeof AuthCallbackRoute
'/invite': typeof InviteRoute
'/login': typeof LoginRoute
'/onboard': typeof OnboardRouteWithChildren
'/reset-password': typeof ResetPasswordRoute
'/select-complex': typeof SelectComplexRoute
'/signup': typeof SignupRoute
'/profile': typeof AppProfileRoute
'/onboard/complete': typeof OnboardCompleteRoute
'/onboard/create-complex': typeof OnboardCreateComplexRoute
'/onboard/verify': typeof OnboardVerifyRoute
'/onboard/verify-email': typeof OnboardVerifyEmailRoute
'/onboard': typeof OnboardIndexRoute
'/onboard/setup': typeof OnboardSetupRoute
'/$complexSlug/booking': typeof ComplexSlugBookingIndexRoute
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
'/complex/$slug/edit': typeof AppAuthenticatedComplexSlugEditRoute
@@ -182,11 +151,7 @@ export interface FileRoutesById {
'/_app/_authenticated': typeof AppAuthenticatedRouteRouteWithChildren
'/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren
'/_app/profile': typeof AppProfileRoute
'/onboard/complete': typeof OnboardCompleteRoute
'/onboard/create-complex': typeof OnboardCreateComplexRoute
'/onboard/verify': typeof OnboardVerifyRoute
'/onboard/verify-email': typeof OnboardVerifyEmailRoute
'/onboard/': typeof OnboardIndexRoute
'/onboard/setup': typeof OnboardSetupRoute
'/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute
'/_app/_authenticated/': typeof AppAuthenticatedIndexRoute
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
@@ -205,11 +170,7 @@ export interface FileRouteTypes {
| '/signup'
| '/$complexSlug/booking'
| '/profile'
| '/onboard/complete'
| '/onboard/create-complex'
| '/onboard/verify'
| '/onboard/verify-email'
| '/onboard/'
| '/onboard/setup'
| '/$complexSlug/booking/'
| '/$complexSlug/booking/confirmed/$bookingCode'
| '/complex/$slug/edit'
@@ -219,15 +180,12 @@ export interface FileRouteTypes {
| '/auth-callback'
| '/invite'
| '/login'
| '/onboard'
| '/reset-password'
| '/select-complex'
| '/signup'
| '/profile'
| '/onboard/complete'
| '/onboard/create-complex'
| '/onboard/verify'
| '/onboard/verify-email'
| '/onboard'
| '/onboard/setup'
| '/$complexSlug/booking'
| '/$complexSlug/booking/confirmed/$bookingCode'
| '/complex/$slug/edit'
@@ -244,11 +202,7 @@ export interface FileRouteTypes {
| '/_app/_authenticated'
| '/$complexSlug/booking'
| '/_app/profile'
| '/onboard/complete'
| '/onboard/create-complex'
| '/onboard/verify'
| '/onboard/verify-email'
| '/onboard/'
| '/onboard/setup'
| '/$complexSlug/booking/'
| '/_app/_authenticated/'
| '/$complexSlug/booking/confirmed/$bookingCode'
@@ -325,39 +279,11 @@ 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-email': {
id: '/onboard/verify-email'
path: '/verify-email'
fullPath: '/onboard/verify-email'
preLoaderRoute: typeof OnboardVerifyEmailRouteImport
parentRoute: typeof OnboardRoute
}
'/onboard/verify': {
id: '/onboard/verify'
path: '/verify'
fullPath: '/onboard/verify'
preLoaderRoute: typeof OnboardVerifyRouteImport
parentRoute: typeof OnboardRoute
}
'/onboard/create-complex': {
id: '/onboard/create-complex'
path: '/create-complex'
fullPath: '/onboard/create-complex'
preLoaderRoute: typeof OnboardCreateComplexRouteImport
parentRoute: typeof OnboardRoute
}
'/onboard/complete': {
id: '/onboard/complete'
path: '/complete'
fullPath: '/onboard/complete'
preLoaderRoute: typeof OnboardCompleteRouteImport
'/onboard/setup': {
id: '/onboard/setup'
path: '/setup'
fullPath: '/onboard/setup'
preLoaderRoute: typeof OnboardSetupRouteImport
parentRoute: typeof OnboardRoute
}
'/_app/profile': {
@@ -442,19 +368,11 @@ const AppRouteRouteWithChildren = AppRouteRoute._addFileChildren(
)
interface OnboardRouteChildren {
OnboardCompleteRoute: typeof OnboardCompleteRoute
OnboardCreateComplexRoute: typeof OnboardCreateComplexRoute
OnboardVerifyRoute: typeof OnboardVerifyRoute
OnboardVerifyEmailRoute: typeof OnboardVerifyEmailRoute
OnboardIndexRoute: typeof OnboardIndexRoute
OnboardSetupRoute: typeof OnboardSetupRoute
}
const OnboardRouteChildren: OnboardRouteChildren = {
OnboardCompleteRoute: OnboardCompleteRoute,
OnboardCreateComplexRoute: OnboardCreateComplexRoute,
OnboardVerifyRoute: OnboardVerifyRoute,
OnboardVerifyEmailRoute: OnboardVerifyEmailRoute,
OnboardIndexRoute: OnboardIndexRoute,
OnboardSetupRoute: OnboardSetupRoute,
}
const OnboardRouteWithChildren =

View File

@@ -17,23 +17,9 @@ function AuthCallbackPage() {
try {
await acceptStoredPendingInvite(apiClient.complexes.acceptPendingInvitations);
const complexes = await apiClient.complexes.listMine();
if (complexes.length === 1) {
await apiClient.complexes.select({ complexId: complexes[0].id });
navigate({ to: '/' });
} else if (complexes.length > 1) {
const current = await apiClient.complexes.getCurrent();
if (current) {
navigate({ to: '/' });
} else {
navigate({ to: '/select-complex' });
}
} else {
navigate({ to: '/onboard/create-complex' });
}
} catch {
navigate({ to: '/onboard' });
navigate({ to: '/login' });
}
}

View File

@@ -1,4 +1,3 @@
import { OnboardLoginPage } from '@/features/onboard/onboard-login-page';
import { apiClient } from '@/lib/api-client';
import { useAuth } from '@/lib/auth';
import { acceptStoredPendingInvite, setPendingInvite } from '@/lib/invitations';
@@ -25,6 +24,7 @@ function InvitePage() {
useEffect(() => {
if (!isAuthenticated) {
navigate({ to: '/login' });
return;
}
@@ -34,7 +34,6 @@ function InvitePage() {
})();
}, [isAuthenticated, navigate]);
if (isAuthenticated) {
return (
<main className="flex min-h-screen w-full items-center justify-center px-3 sm:px-6">
<p className="text-sm text-muted-foreground">Aceptando invitación...</p>
@@ -42,9 +41,6 @@ function InvitePage() {
);
}
return <OnboardLoginPage variant="invite" inviteEmail={search.email ?? null} />;
}
export const Route = createFileRoute('/invite')({
validateSearch: inviteSearchSchema,
component: InvitePage,

View File

@@ -1,22 +1,11 @@
import { LoginPage } from '@/features/login/login-page';
import { createFileRoute, redirect } from '@tanstack/react-router';
import { z } from 'zod';
const loginSearchSchema = z.object({
redirect: z.string().optional(),
});
export const Route = createFileRoute('/login')({
validateSearch: loginSearchSchema,
beforeLoad: ({ context }) => {
if (context.auth.isAuthenticated) {
throw redirect({ to: '/' });
}
},
component: LoginRouteComponent,
component: LoginPage,
});
function LoginRouteComponent() {
const search = Route.useSearch();
return <LoginPage redirectTo={search.redirect} />;
}

View File

@@ -1,6 +0,0 @@
import { OnboardCompletePage } from '@/features/onboard/onboard-complete-page';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/onboard/complete')({
component: OnboardCompletePage,
});

View File

@@ -1,11 +0,0 @@
import { OnboardLoginPage } from '@/features/onboard/onboard-login-page';
import { createFileRoute, redirect } from '@tanstack/react-router';
export const Route = createFileRoute('/onboard/')({
beforeLoad: ({ context }) => {
if (!context.auth.isAuthenticated) {
throw redirect({ to: '/login', search: { redirect: '/onboard/create-complex' } });
}
},
component: OnboardLoginPage,
});

View File

@@ -1,14 +1,14 @@
import { CreateComplexPage } from '@/features/onboard/create-complex-page';
import { SetupStepperPage } from '@/features/onboard/setup-stepper-page';
import { createFileRoute, redirect } from '@tanstack/react-router';
export const Route = createFileRoute('/onboard/create-complex')({
export const Route = createFileRoute('/onboard/setup')({
beforeLoad: ({ context }) => {
const hasSession = context.auth.isAuthenticated;
const hasPendingSignup = Boolean(sessionStorage.getItem('pending-signup-email'));
if (!hasSession && !hasPendingSignup) {
throw redirect({ to: '/onboard' });
throw redirect({ to: '/login' });
}
},
component: CreateComplexPage,
component: SetupStepperPage,
});

View File

@@ -1,18 +0,0 @@
import { VerifyEmailPage } from '@/features/onboard/verify-email-page';
import { createFileRoute, redirect } from '@tanstack/react-router';
import { z } from 'zod';
const verifyEmailSearchSchema = z.object({
email: z.string().email().optional(),
});
export const Route = createFileRoute('/onboard/verify-email')({
validateSearch: verifyEmailSearchSchema,
beforeLoad: () => {
const hasPendingSignup = Boolean(sessionStorage.getItem('pending-signup-email'));
if (!hasPendingSignup) {
throw redirect({ to: '/onboard' });
}
},
component: VerifyEmailPage,
});

View File

@@ -1,6 +0,0 @@
import { OnboardVerifyPage } from '@/features/onboard/onboard-verify-page';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/onboard/verify')({
component: OnboardVerifyPage,
});

View File

@@ -1,5 +1,15 @@
import { z } from 'zod'
const dayOfWeekEnum = z.enum([
'MONDAY',
'TUESDAY',
'WEDNESDAY',
'THURSDAY',
'FRIDAY',
'SATURDAY',
'SUNDAY',
])
export const complexSchema = z.object({
id: z.uuid(),
complexName: z.string(),
@@ -14,7 +24,8 @@ export const complexSchema = z.object({
updatedAt: z.string().datetime(),
})
export const createComplexSchema = z.object({
export const createComplexSchema = z
.object({
complexName: z
.string()
.trim()
@@ -46,6 +57,43 @@ export const createComplexSchema = z.object({
.min(1, 'El planCode no puede estar vacío.')
.max(10, 'El planCode no puede superar los 10 caracteres.')
.optional(),
setupCourts: z.boolean().default(false),
courtSportId: z.string().uuid('El deporte seleccionado no es válido.').optional(),
courtStartTime: z.string().optional(),
courtEndTime: z.string().optional(),
courtDaysOfWeek: z.array(dayOfWeekEnum).optional(),
})
.superRefine((data, ctx) => {
if (data.setupCourts) {
if (!data.courtSportId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['courtSportId'],
message: 'Debes seleccionar un deporte.',
})
}
if (!data.courtStartTime || !/^\d{2}:\d{2}$/.test(data.courtStartTime)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['courtStartTime'],
message: 'La hora de inicio debe tener formato HH:mm.',
})
}
if (!data.courtEndTime || !/^\d{2}:\d{2}$/.test(data.courtEndTime)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['courtEndTime'],
message: 'La hora de fin debe tener formato HH:mm.',
})
}
if (!data.courtDaysOfWeek || data.courtDaysOfWeek.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['courtDaysOfWeek'],
message: 'Debes seleccionar al menos un día.',
})
}
}
})
export const updateComplexSchema = z

View File

@@ -4,28 +4,18 @@ export {
planRulesV1Schema,
planSchema,
planSummarySchema,
planWithFeaturesSchema,
planLimitsSchema,
} from './plan'
export type { Plan, PlanFeatureFlags, PlanRules, PlanRulesV1, PlanSummary } from './plan'
export {
onboardingCompleteResponseSchema,
onboardingCompleteSchema,
onboardingResendOtpResponseSchema,
onboardingResendOtpSchema,
onboardingStartResponseSchema,
onboardingStartSchema,
onboardingVerifyOtpResponseSchema,
onboardingVerifyOtpSchema,
} from './onboarding'
export type {
OnboardingCompleteInput,
OnboardingCompleteResponse,
OnboardingResendOtpInput,
OnboardingResendOtpResponse,
OnboardingStartInput,
OnboardingStartResponse,
OnboardingVerifyOtpInput,
OnboardingVerifyOtpResponse,
} from './onboarding'
Plan,
PlanFeatureFlags,
PlanRules,
PlanRulesV1,
PlanSummary,
PlanWithFeatures,
PlanLimits,
} from './plan'
export {
acceptComplexInvitationsSchema,
complexInvitationSchema,

View File

@@ -1,112 +0,0 @@
import { z } from 'zod'
const otpSchema = z
.string()
.trim()
.regex(/^\d{6}$/, 'El OTP debe tener 6 digitos numericos.')
export const onboardingStartSchema = z.object({
fullName: z
.string()
.trim()
.min(2, 'El nombre debe tener al menos 2 caracteres.')
.max(80, 'El nombre no puede superar los 80 caracteres.'),
email: z.email('Ingresa un email valido.'),
})
export const onboardingVerifyOtpSchema = z.object({
requestId: z.uuid('El requestId de onboarding no es valido.'),
otp: otpSchema,
})
export const onboardingResendOtpSchema = z.object({
requestId: z.uuid('El requestId de onboarding no es valido.'),
})
export const onboardingCompleteSchema = z.object({
onboardingRequestId: z.uuid('El onboardingRequestId no es valido.'),
password: z
.string()
.min(8, 'La contrasena debe tener al menos 8 caracteres.')
.max(72, 'La contrasena no puede superar los 72 caracteres.'),
complexName: z
.string()
.trim()
.min(3, 'El nombre del complejo debe tener al menos 3 caracteres.')
.max(120, 'El nombre del complejo no puede superar los 120 caracteres.'),
physicalAddress: z
.string()
.trim()
.min(5, 'La direccion debe tener al menos 5 caracteres.')
.max(200, 'La direccion no puede superar los 200 caracteres.'),
city: z
.string()
.trim()
.max(100, 'La ciudad no puede superar los 100 caracteres.')
.optional(),
state: z
.string()
.trim()
.max(100, 'La provincia/estado no puede superar los 100 caracteres.')
.optional(),
country: z
.string()
.trim()
.max(100, 'El país no puede superar los 100 caracteres.')
.optional(),
planCode: z
.string()
.trim()
.min(1, 'Debes seleccionar un plan.')
.max(10, 'El plan seleccionado no es valido.'),
})
export const onboardingStartResponseSchema = z.object({
message: z.string(),
requestId: z.uuid(),
email: z.email(),
expiresAt: z.string().datetime(),
cooldownSeconds: z.int().nonnegative(),
})
export const onboardingVerifyOtpResponseSchema = z.object({
message: z.string(),
verified: z.boolean(),
requestId: z.uuid(),
email: z.email().nullable(),
expiresAt: z.string().datetime().nullable(),
remainingAttempts: z.int().nonnegative(),
cooldownSeconds: z.int().nonnegative(),
})
export const onboardingResendOtpResponseSchema = z.object({
message: z.string(),
requestId: z.uuid(),
email: z.email(),
expiresAt: z.string().datetime(),
cooldownSeconds: z.int().nonnegative(),
remainingAttempts: z.int().nonnegative(),
})
export const onboardingCompleteResponseSchema = z.object({
message: z.string(),
userId: z.uuid(),
complexId: z.uuid(),
complexSlug: z.string(),
})
export type OnboardingStartInput = z.infer<typeof onboardingStartSchema>
export type OnboardingVerifyOtpInput = z.infer<typeof onboardingVerifyOtpSchema>
export type OnboardingResendOtpInput = z.infer<typeof onboardingResendOtpSchema>
export type OnboardingCompleteInput = z.infer<typeof onboardingCompleteSchema>
export type OnboardingStartResponse = z.infer<typeof onboardingStartResponseSchema>
export type OnboardingVerifyOtpResponse = z.infer<
typeof onboardingVerifyOtpResponseSchema
>
export type OnboardingResendOtpResponse = z.infer<
typeof onboardingResendOtpResponseSchema
>
export type OnboardingCompleteResponse = z.infer<
typeof onboardingCompleteResponseSchema
>

View File

@@ -49,8 +49,25 @@ export const planSummarySchema = z.object({
price: z.number().nonnegative(),
})
export const planLimitsSchema = z.object({
maxCourts: positiveInt,
maxBookingsPerDay: positiveInt,
maxActiveUsers: positiveInt.optional(),
maxConcurrentBookingsPerSlot: positiveInt.optional(),
})
export const planWithFeaturesSchema = z.object({
code: z.string().trim().min(1).max(10),
name: z.string().trim().min(1).max(30),
price: z.number().nonnegative(),
features: planFeatureFlagsSchema,
limits: planLimitsSchema,
})
export type PlanFeatureFlags = z.infer<typeof planFeatureFlagsSchema>
export type PlanRulesV1 = z.infer<typeof planRulesV1Schema>
export type PlanRules = z.infer<typeof planRulesSchema>
export type Plan = z.infer<typeof planSchema>
export type PlanSummary = z.infer<typeof planSummarySchema>
export type PlanWithFeatures = z.infer<typeof planWithFeaturesSchema>
export type PlanLimits = z.infer<typeof planLimitsSchema>