feat/onboard-redesign #4

Merged
jselesan merged 2 commits from feat/onboard-redesign into development 2026-06-02 13:03:44 +00:00
61 changed files with 1356 additions and 2390 deletions
Showing only changes of commit 4544911f0e - Show all commits

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, city: payload.city,
state: payload.state, state: payload.state,
country: payload.country, country: payload.country,
setupCourts: payload.setupCourts,
courtSportId: payload.courtSportId,
courtStartTime: payload.courtStartTime,
courtEndTime: payload.courtEndTime,
courtDaysOfWeek: payload.courtDaysOfWeek,
}); });
return c.json(complex, 201); return c.json(complex, 201);

View File

@@ -11,8 +11,47 @@ export type CreateComplexInput = {
city?: string; city?: string;
state?: string; state?: string;
country?: 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 = { export type UpdateComplexInput = {
complexName?: string; complexName?: string;
physicalAddress?: string | null; 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; 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 { db } from '@/lib/prisma';
import { parsePlanRules } from '@/modules/plan/services/plan-rules.service';
import type { AppContext } from '@/types/hono'; import type { AppContext } from '@/types/hono';
export async function listPlansHandler(c: AppContext) { export async function listPlansHandler(c: AppContext) {
const plans = await db.plan.findMany({ const plans = await db.plan.findMany({
select: {
code: true,
name: true,
price: true,
},
orderBy: { orderBy: {
price: 'asc', price: 'asc',
}, },
}); });
return c.json( return c.json(
plans.map((plan) => ({ plans.map((plan) => {
const rules = parsePlanRules(plan.rules);
return {
code: plan.code, code: plan.code,
name: plan.name, name: plan.name,
price: Number(plan.price), 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 { authRoutes } from '@/modules/auth/auth.routes';
import { complexRoutes } from '@/modules/complex/complex.routes'; import { complexRoutes } from '@/modules/complex/complex.routes';
import { courtRoutes } from '@/modules/court/court.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 { passwordResetRoutes } from '@/modules/password-reset/password-reset.routes';
import { planRoutes } from '@/modules/plan/plan.routes'; import { planRoutes } from '@/modules/plan/plan.routes';
import { publicBookingRoutes } from '@/modules/public-booking/public-booking.routes'; import { publicBookingRoutes } from '@/modules/public-booking/public-booking.routes';
@@ -20,7 +19,6 @@ export function registerRoutes(app: Hono<AppEnv>) {
.route('', authRoutes) .route('', authRoutes)
.route('/api/user', userRoutes) .route('/api/user', userRoutes)
.route('/api/plans', planRoutes) .route('/api/plans', planRoutes)
.route('/api/onboarding', onboardingRoutes)
.route('/api/password-reset', passwordResetRoutes) .route('/api/password-reset', passwordResetRoutes)
.route('/api/complexes', complexRoutes) .route('/api/complexes', complexRoutes)
.route('/api/sports', sportRoutes) .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(); autoSelect();
} }
}, [ }, [
currentComplexQuery.isLoading, currentComplexQuery.isLoading,
currentComplexQuery.data, currentComplexQuery.data,
myComplexesQuery.data, myComplexesQuery.data,
myComplexesQuery.isLoading,
navigate, navigate,
queryClient, queryClient,
]); ]);

View File

@@ -6,7 +6,7 @@ import { apiClient, authClient } from '@/lib/api-client';
import { useAuth } from '@/lib/auth'; import { useAuth } from '@/lib/auth';
import { acceptStoredPendingInvite } from '@/lib/invitations'; import { acceptStoredPendingInvite } from '@/lib/invitations';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Link, useNavigate } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { useState } from 'react'; import { useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
@@ -18,12 +18,7 @@ const loginSchema = z.object({
type LoginForm = z.infer<typeof loginSchema>; type LoginForm = z.infer<typeof loginSchema>;
type LoginPageProps = { export function LoginPage() {
redirectTo?: string;
};
export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
const navigate = useNavigate();
const { signInWithPassword } = useAuth(); const { signInWithPassword } = useAuth();
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
@@ -42,16 +37,6 @@ export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
async function handlePostLogin() { async function handlePostLogin() {
await acceptStoredPendingInvite(apiClient.complexes.acceptPendingInvitations); 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) => { 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); setIsSubmitting(true);
try { try {
await signUp({ const { requiresEmailConfirmation } = await signUp({
email: values.email, email: values.email,
password: values.password, password: values.password,
fullName: values.fullName, fullName: values.fullName,
@@ -91,7 +91,11 @@ export function SignupPage() {
}); });
sessionStorage.removeItem(STORAGE_KEY); sessionStorage.removeItem(STORAGE_KEY);
await navigate({ to: '/' });
if (requiresEmailConfirmation) {
sessionStorage.setItem('pending-signup-email', values.email);
await navigate({ to: '/onboard/setup' });
}
} catch { } catch {
setSubmitError('No pudimos crear la cuenta. Intenta nuevamente.'); setSubmitError('No pudimos crear la cuenta. Intenta nuevamente.');
} finally { } finally {

View File

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

View File

@@ -6,7 +6,6 @@ export * as courts from './resources/courts';
export * as user from './resources/user'; export * as user from './resources/user';
export * as sports from './resources/sports'; export * as sports from './resources/sports';
export * as plans from './resources/plans'; export * as plans from './resources/plans';
export * as onboarding from './resources/onboarding';
export { export {
getAvailability, 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'; import { http } from '../http';
export async function list() { export async function list() {
const response = await http.get<PlanSummary[]>('/api/plans'); const response = await http.get<PlanWithFeatures[]>('/api/plans');
return response.data; 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 InviteRouteImport } from './routes/invite'
import { Route as AuthCallbackRouteImport } from './routes/auth-callback' import { Route as AuthCallbackRouteImport } from './routes/auth-callback'
import { Route as AppRouteRouteImport } from './routes/_app/route' import { Route as AppRouteRouteImport } from './routes/_app/route'
import { Route as OnboardIndexRouteImport } from './routes/onboard/index' import { Route as OnboardSetupRouteImport } from './routes/onboard/setup'
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 AppProfileRouteImport } from './routes/_app/profile' import { Route as AppProfileRouteImport } from './routes/_app/profile'
import { Route as ComplexSlugBookingRouteImport } from './routes/$complexSlug/booking' import { Route as ComplexSlugBookingRouteImport } from './routes/$complexSlug/booking'
import { Route as AppAuthenticatedRouteRouteImport } from './routes/_app/_authenticated/route' import { Route as AppAuthenticatedRouteRouteImport } from './routes/_app/_authenticated/route'
@@ -69,29 +65,9 @@ const AppRouteRoute = AppRouteRouteImport.update({
id: '/_app', id: '/_app',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const OnboardIndexRoute = OnboardIndexRouteImport.update({ const OnboardSetupRoute = OnboardSetupRouteImport.update({
id: '/', id: '/setup',
path: '/', path: '/setup',
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',
getParentRoute: () => OnboardRoute, getParentRoute: () => OnboardRoute,
} as any) } as any)
const AppProfileRoute = AppProfileRouteImport.update({ const AppProfileRoute = AppProfileRouteImport.update({
@@ -142,11 +118,7 @@ export interface FileRoutesByFullPath {
'/signup': typeof SignupRoute '/signup': typeof SignupRoute
'/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren '/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren
'/profile': typeof AppProfileRoute '/profile': typeof AppProfileRoute
'/onboard/complete': typeof OnboardCompleteRoute '/onboard/setup': typeof OnboardSetupRoute
'/onboard/create-complex': typeof OnboardCreateComplexRoute
'/onboard/verify': typeof OnboardVerifyRoute
'/onboard/verify-email': typeof OnboardVerifyEmailRoute
'/onboard/': typeof OnboardIndexRoute
'/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute '/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute '/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
'/complex/$slug/edit': typeof AppAuthenticatedComplexSlugEditRoute '/complex/$slug/edit': typeof AppAuthenticatedComplexSlugEditRoute
@@ -156,15 +128,12 @@ export interface FileRoutesByTo {
'/auth-callback': typeof AuthCallbackRoute '/auth-callback': typeof AuthCallbackRoute
'/invite': typeof InviteRoute '/invite': typeof InviteRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/onboard': typeof OnboardRouteWithChildren
'/reset-password': typeof ResetPasswordRoute '/reset-password': typeof ResetPasswordRoute
'/select-complex': typeof SelectComplexRoute '/select-complex': typeof SelectComplexRoute
'/signup': typeof SignupRoute '/signup': typeof SignupRoute
'/profile': typeof AppProfileRoute '/profile': typeof AppProfileRoute
'/onboard/complete': typeof OnboardCompleteRoute '/onboard/setup': typeof OnboardSetupRoute
'/onboard/create-complex': typeof OnboardCreateComplexRoute
'/onboard/verify': typeof OnboardVerifyRoute
'/onboard/verify-email': typeof OnboardVerifyEmailRoute
'/onboard': typeof OnboardIndexRoute
'/$complexSlug/booking': typeof ComplexSlugBookingIndexRoute '/$complexSlug/booking': typeof ComplexSlugBookingIndexRoute
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute '/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
'/complex/$slug/edit': typeof AppAuthenticatedComplexSlugEditRoute '/complex/$slug/edit': typeof AppAuthenticatedComplexSlugEditRoute
@@ -182,11 +151,7 @@ export interface FileRoutesById {
'/_app/_authenticated': typeof AppAuthenticatedRouteRouteWithChildren '/_app/_authenticated': typeof AppAuthenticatedRouteRouteWithChildren
'/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren '/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren
'/_app/profile': typeof AppProfileRoute '/_app/profile': typeof AppProfileRoute
'/onboard/complete': typeof OnboardCompleteRoute '/onboard/setup': typeof OnboardSetupRoute
'/onboard/create-complex': typeof OnboardCreateComplexRoute
'/onboard/verify': typeof OnboardVerifyRoute
'/onboard/verify-email': typeof OnboardVerifyEmailRoute
'/onboard/': typeof OnboardIndexRoute
'/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute '/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute
'/_app/_authenticated/': typeof AppAuthenticatedIndexRoute '/_app/_authenticated/': typeof AppAuthenticatedIndexRoute
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute '/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
@@ -205,11 +170,7 @@ export interface FileRouteTypes {
| '/signup' | '/signup'
| '/$complexSlug/booking' | '/$complexSlug/booking'
| '/profile' | '/profile'
| '/onboard/complete' | '/onboard/setup'
| '/onboard/create-complex'
| '/onboard/verify'
| '/onboard/verify-email'
| '/onboard/'
| '/$complexSlug/booking/' | '/$complexSlug/booking/'
| '/$complexSlug/booking/confirmed/$bookingCode' | '/$complexSlug/booking/confirmed/$bookingCode'
| '/complex/$slug/edit' | '/complex/$slug/edit'
@@ -219,15 +180,12 @@ export interface FileRouteTypes {
| '/auth-callback' | '/auth-callback'
| '/invite' | '/invite'
| '/login' | '/login'
| '/onboard'
| '/reset-password' | '/reset-password'
| '/select-complex' | '/select-complex'
| '/signup' | '/signup'
| '/profile' | '/profile'
| '/onboard/complete' | '/onboard/setup'
| '/onboard/create-complex'
| '/onboard/verify'
| '/onboard/verify-email'
| '/onboard'
| '/$complexSlug/booking' | '/$complexSlug/booking'
| '/$complexSlug/booking/confirmed/$bookingCode' | '/$complexSlug/booking/confirmed/$bookingCode'
| '/complex/$slug/edit' | '/complex/$slug/edit'
@@ -244,11 +202,7 @@ export interface FileRouteTypes {
| '/_app/_authenticated' | '/_app/_authenticated'
| '/$complexSlug/booking' | '/$complexSlug/booking'
| '/_app/profile' | '/_app/profile'
| '/onboard/complete' | '/onboard/setup'
| '/onboard/create-complex'
| '/onboard/verify'
| '/onboard/verify-email'
| '/onboard/'
| '/$complexSlug/booking/' | '/$complexSlug/booking/'
| '/_app/_authenticated/' | '/_app/_authenticated/'
| '/$complexSlug/booking/confirmed/$bookingCode' | '/$complexSlug/booking/confirmed/$bookingCode'
@@ -325,39 +279,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppRouteRouteImport preLoaderRoute: typeof AppRouteRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/onboard/': { '/onboard/setup': {
id: '/onboard/' id: '/onboard/setup'
path: '/' path: '/setup'
fullPath: '/onboard/' fullPath: '/onboard/setup'
preLoaderRoute: typeof OnboardIndexRouteImport preLoaderRoute: typeof OnboardSetupRouteImport
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
parentRoute: typeof OnboardRoute parentRoute: typeof OnboardRoute
} }
'/_app/profile': { '/_app/profile': {
@@ -442,19 +368,11 @@ const AppRouteRouteWithChildren = AppRouteRoute._addFileChildren(
) )
interface OnboardRouteChildren { interface OnboardRouteChildren {
OnboardCompleteRoute: typeof OnboardCompleteRoute OnboardSetupRoute: typeof OnboardSetupRoute
OnboardCreateComplexRoute: typeof OnboardCreateComplexRoute
OnboardVerifyRoute: typeof OnboardVerifyRoute
OnboardVerifyEmailRoute: typeof OnboardVerifyEmailRoute
OnboardIndexRoute: typeof OnboardIndexRoute
} }
const OnboardRouteChildren: OnboardRouteChildren = { const OnboardRouteChildren: OnboardRouteChildren = {
OnboardCompleteRoute: OnboardCompleteRoute, OnboardSetupRoute: OnboardSetupRoute,
OnboardCreateComplexRoute: OnboardCreateComplexRoute,
OnboardVerifyRoute: OnboardVerifyRoute,
OnboardVerifyEmailRoute: OnboardVerifyEmailRoute,
OnboardIndexRoute: OnboardIndexRoute,
} }
const OnboardRouteWithChildren = const OnboardRouteWithChildren =

View File

@@ -17,23 +17,9 @@ function AuthCallbackPage() {
try { try {
await acceptStoredPendingInvite(apiClient.complexes.acceptPendingInvitations); 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: '/' }); 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 { } 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 { apiClient } from '@/lib/api-client';
import { useAuth } from '@/lib/auth'; import { useAuth } from '@/lib/auth';
import { acceptStoredPendingInvite, setPendingInvite } from '@/lib/invitations'; import { acceptStoredPendingInvite, setPendingInvite } from '@/lib/invitations';
@@ -25,6 +24,7 @@ function InvitePage() {
useEffect(() => { useEffect(() => {
if (!isAuthenticated) { if (!isAuthenticated) {
navigate({ to: '/login' });
return; return;
} }
@@ -34,7 +34,6 @@ function InvitePage() {
})(); })();
}, [isAuthenticated, navigate]); }, [isAuthenticated, navigate]);
if (isAuthenticated) {
return ( return (
<main className="flex min-h-screen w-full items-center justify-center px-3 sm:px-6"> <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> <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')({ export const Route = createFileRoute('/invite')({
validateSearch: inviteSearchSchema, validateSearch: inviteSearchSchema,
component: InvitePage, component: InvitePage,

View File

@@ -1,22 +1,11 @@
import { LoginPage } from '@/features/login/login-page'; import { LoginPage } from '@/features/login/login-page';
import { createFileRoute, redirect } from '@tanstack/react-router'; import { createFileRoute, redirect } from '@tanstack/react-router';
import { z } from 'zod';
const loginSearchSchema = z.object({
redirect: z.string().optional(),
});
export const Route = createFileRoute('/login')({ export const Route = createFileRoute('/login')({
validateSearch: loginSearchSchema,
beforeLoad: ({ context }) => { beforeLoad: ({ context }) => {
if (context.auth.isAuthenticated) { if (context.auth.isAuthenticated) {
throw redirect({ to: '/' }); 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'; import { createFileRoute, redirect } from '@tanstack/react-router';
export const Route = createFileRoute('/onboard/create-complex')({ export const Route = createFileRoute('/onboard/setup')({
beforeLoad: ({ context }) => { beforeLoad: ({ context }) => {
const hasSession = context.auth.isAuthenticated; const hasSession = context.auth.isAuthenticated;
const hasPendingSignup = Boolean(sessionStorage.getItem('pending-signup-email')); const hasPendingSignup = Boolean(sessionStorage.getItem('pending-signup-email'));
if (!hasSession && !hasPendingSignup) { 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' import { z } from 'zod'
const dayOfWeekEnum = z.enum([
'MONDAY',
'TUESDAY',
'WEDNESDAY',
'THURSDAY',
'FRIDAY',
'SATURDAY',
'SUNDAY',
])
export const complexSchema = z.object({ export const complexSchema = z.object({
id: z.uuid(), id: z.uuid(),
complexName: z.string(), complexName: z.string(),
@@ -14,7 +24,8 @@ export const complexSchema = z.object({
updatedAt: z.string().datetime(), updatedAt: z.string().datetime(),
}) })
export const createComplexSchema = z.object({ export const createComplexSchema = z
.object({
complexName: z complexName: z
.string() .string()
.trim() .trim()
@@ -46,6 +57,43 @@ export const createComplexSchema = z.object({
.min(1, 'El planCode no puede estar vacío.') .min(1, 'El planCode no puede estar vacío.')
.max(10, 'El planCode no puede superar los 10 caracteres.') .max(10, 'El planCode no puede superar los 10 caracteres.')
.optional(), .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 export const updateComplexSchema = z

View File

@@ -4,28 +4,18 @@ export {
planRulesV1Schema, planRulesV1Schema,
planSchema, planSchema,
planSummarySchema, planSummarySchema,
planWithFeaturesSchema,
planLimitsSchema,
} from './plan' } from './plan'
export type { Plan, PlanFeatureFlags, PlanRules, PlanRulesV1, PlanSummary } from './plan'
export {
onboardingCompleteResponseSchema,
onboardingCompleteSchema,
onboardingResendOtpResponseSchema,
onboardingResendOtpSchema,
onboardingStartResponseSchema,
onboardingStartSchema,
onboardingVerifyOtpResponseSchema,
onboardingVerifyOtpSchema,
} from './onboarding'
export type { export type {
OnboardingCompleteInput, Plan,
OnboardingCompleteResponse, PlanFeatureFlags,
OnboardingResendOtpInput, PlanRules,
OnboardingResendOtpResponse, PlanRulesV1,
OnboardingStartInput, PlanSummary,
OnboardingStartResponse, PlanWithFeatures,
OnboardingVerifyOtpInput, PlanLimits,
OnboardingVerifyOtpResponse, } from './plan'
} from './onboarding'
export { export {
acceptComplexInvitationsSchema, acceptComplexInvitationsSchema,
complexInvitationSchema, 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(), 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 PlanFeatureFlags = z.infer<typeof planFeatureFlagsSchema>
export type PlanRulesV1 = z.infer<typeof planRulesV1Schema> export type PlanRulesV1 = z.infer<typeof planRulesV1Schema>
export type PlanRules = z.infer<typeof planRulesSchema> export type PlanRules = z.infer<typeof planRulesSchema>
export type Plan = z.infer<typeof planSchema> export type Plan = z.infer<typeof planSchema>
export type PlanSummary = z.infer<typeof planSummarySchema> export type PlanSummary = z.infer<typeof planSummarySchema>
export type PlanWithFeatures = z.infer<typeof planWithFeaturesSchema>
export type PlanLimits = z.infer<typeof planLimitsSchema>