diff --git a/apps/backend/prisma/onboarding.prisma b/apps/backend/prisma/onboarding.prisma deleted file mode 100644 index 13d6d46..0000000 --- a/apps/backend/prisma/onboarding.prisma +++ /dev/null @@ -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") -} diff --git a/apps/backend/src/modules/complex/handlers/create-complex.handler.ts b/apps/backend/src/modules/complex/handlers/create-complex.handler.ts index 2e738b6..eaeae6f 100644 --- a/apps/backend/src/modules/complex/handlers/create-complex.handler.ts +++ b/apps/backend/src/modules/complex/handlers/create-complex.handler.ts @@ -40,6 +40,11 @@ export async function createComplexHandler(c: AppContext) { city: payload.city, state: payload.state, country: payload.country, + setupCourts: payload.setupCourts, + courtSportId: payload.courtSportId, + courtStartTime: payload.courtStartTime, + courtEndTime: payload.courtEndTime, + courtDaysOfWeek: payload.courtDaysOfWeek, }); return c.json(complex, 201); diff --git a/apps/backend/src/modules/complex/services/complex.service.ts b/apps/backend/src/modules/complex/services/complex.service.ts index 2f37906..167b8ba 100644 --- a/apps/backend/src/modules/complex/services/complex.service.ts +++ b/apps/backend/src/modules/complex/services/complex.service.ts @@ -11,8 +11,47 @@ export type CreateComplexInput = { city?: string; state?: string; country?: string; + setupCourts?: boolean; + courtSportId?: string; + courtStartTime?: string; + courtEndTime?: string; + courtDaysOfWeek?: string[]; }; +type DayOfWeek = 'MONDAY' | 'TUESDAY' | 'WEDNESDAY' | 'THURSDAY' | 'FRIDAY' | 'SATURDAY' | 'SUNDAY'; + +function toMinutes(value: string): number { + const [hours, minutes] = value.split(':').map((part) => Number(part)); + return hours * 60 + minutes; +} + +function assertAvailabilityRanges( + availability: Array<{ + dayOfWeek: DayOfWeek; + startTime: string; + endTime: string; + }> +) { + for (const range of availability) { + const start = toMinutes(range.startTime); + const end = toMinutes(range.endTime); + if (start >= end) { + throw new Error(`El rango ${range.startTime}-${range.endTime} es inválido.`); + } + } +} + +async function ensureActiveSport(sportId: string) { + const sport = await db.sport.findFirst({ + where: { id: sportId, isActive: true }, + select: { id: true, name: true }, + }); + if (!sport) { + throw new Error('El deporte seleccionado no existe o está inactivo.'); + } + return sport; +} + export type UpdateComplexInput = { complexName?: string; physicalAddress?: string | null; @@ -94,6 +133,38 @@ export async function createComplex(input: CreateComplexInput) { }); } + if (input.setupCourts && input.courtSportId) { + const sport = await ensureActiveSport(input.courtSportId); + const availability = (input.courtDaysOfWeek ?? []).map((day) => ({ + dayOfWeek: day as DayOfWeek, + startTime: input.courtStartTime ?? '08:00', + endTime: input.courtEndTime ?? '22:00', + })); + + assertAvailabilityRanges(availability); + + const court = await tx.court.create({ + data: { + id: uuidv7(), + complexId: complex.id, + sportId: input.courtSportId, + name: `${sport.name} 1`, + slotDurationMinutes: 60, + basePrice: 0, + }, + }); + + await tx.courtAvailability.createMany({ + data: availability.map((avail) => ({ + id: uuidv7(), + courtId: court.id, + dayOfWeek: avail.dayOfWeek, + startTime: avail.startTime, + endTime: avail.endTime, + })), + }); + } + return complex; }); } diff --git a/apps/backend/src/modules/onboarding/handlers/complete-onboarding.handler.ts b/apps/backend/src/modules/onboarding/handlers/complete-onboarding.handler.ts deleted file mode 100644 index 9b42b21..0000000 --- a/apps/backend/src/modules/onboarding/handlers/complete-onboarding.handler.ts +++ /dev/null @@ -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; - } -} diff --git a/apps/backend/src/modules/onboarding/handlers/resend-otp.handler.ts b/apps/backend/src/modules/onboarding/handlers/resend-otp.handler.ts deleted file mode 100644 index 98a5376..0000000 --- a/apps/backend/src/modules/onboarding/handlers/resend-otp.handler.ts +++ /dev/null @@ -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; - } -} diff --git a/apps/backend/src/modules/onboarding/handlers/start-onboarding.handler.ts b/apps/backend/src/modules/onboarding/handlers/start-onboarding.handler.ts deleted file mode 100644 index 598152f..0000000 --- a/apps/backend/src/modules/onboarding/handlers/start-onboarding.handler.ts +++ /dev/null @@ -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); -} diff --git a/apps/backend/src/modules/onboarding/handlers/verify-otp.handler.ts b/apps/backend/src/modules/onboarding/handlers/verify-otp.handler.ts deleted file mode 100644 index 285d3c3..0000000 --- a/apps/backend/src/modules/onboarding/handlers/verify-otp.handler.ts +++ /dev/null @@ -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); -} diff --git a/apps/backend/src/modules/onboarding/onboarding.routes.ts b/apps/backend/src/modules/onboarding/onboarding.routes.ts deleted file mode 100644 index 62c1029..0000000 --- a/apps/backend/src/modules/onboarding/onboarding.routes.ts +++ /dev/null @@ -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(); - -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 -); diff --git a/apps/backend/src/modules/onboarding/services/onboarding.service.ts b/apps/backend/src/modules/onboarding/services/onboarding.service.ts deleted file mode 100644 index 9af4550..0000000 --- a/apps/backend/src/modules/onboarding/services/onboarding.service.ts +++ /dev/null @@ -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 { - 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: `

Tu codigo OTP es ${otpCode}.

Vence en ${OTP_TTL_MINUTES} minutos.

`, - }); -} - -export async function startOnboarding(input: OnboardingStartInput): Promise { - 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 { - 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 { - 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, - }; -} diff --git a/apps/backend/src/modules/plan/handlers/list-plans.handler.ts b/apps/backend/src/modules/plan/handlers/list-plans.handler.ts index ec2f6e9..de5d328 100644 --- a/apps/backend/src/modules/plan/handlers/list-plans.handler.ts +++ b/apps/backend/src/modules/plan/handlers/list-plans.handler.ts @@ -1,23 +1,24 @@ import { db } from '@/lib/prisma'; +import { parsePlanRules } from '@/modules/plan/services/plan-rules.service'; import type { AppContext } from '@/types/hono'; export async function listPlansHandler(c: AppContext) { const plans = await db.plan.findMany({ - select: { - code: true, - name: true, - price: true, - }, orderBy: { price: 'asc', }, }); return c.json( - plans.map((plan) => ({ - code: plan.code, - name: plan.name, - price: Number(plan.price), - })) + plans.map((plan) => { + const rules = parsePlanRules(plan.rules); + return { + code: plan.code, + name: plan.name, + price: Number(plan.price), + features: rules.features, + limits: rules.limits, + }; + }) ); } diff --git a/apps/backend/src/register-routes.ts b/apps/backend/src/register-routes.ts index c9a34fb..1d7c48d 100644 --- a/apps/backend/src/register-routes.ts +++ b/apps/backend/src/register-routes.ts @@ -3,7 +3,6 @@ import { adminBookingRoutes } from '@/modules/admin-booking/admin-booking.routes import { authRoutes } from '@/modules/auth/auth.routes'; import { complexRoutes } from '@/modules/complex/complex.routes'; import { courtRoutes } from '@/modules/court/court.routes'; -import { onboardingRoutes } from '@/modules/onboarding/onboarding.routes'; import { passwordResetRoutes } from '@/modules/password-reset/password-reset.routes'; import { planRoutes } from '@/modules/plan/plan.routes'; import { publicBookingRoutes } from '@/modules/public-booking/public-booking.routes'; @@ -20,7 +19,6 @@ export function registerRoutes(app: Hono) { .route('', authRoutes) .route('/api/user', userRoutes) .route('/api/plans', planRoutes) - .route('/api/onboarding', onboardingRoutes) .route('/api/password-reset', passwordResetRoutes) .route('/api/complexes', complexRoutes) .route('/api/sports', sportRoutes) diff --git a/apps/frontend/src/features/home/home-page.tsx b/apps/frontend/src/features/home/home-page.tsx index 29b7bac..6527810 100644 --- a/apps/frontend/src/features/home/home-page.tsx +++ b/apps/frontend/src/features/home/home-page.tsx @@ -60,13 +60,23 @@ export function HomePage() { } } - if (!currentComplexQuery.isLoading && !currentComplexQuery.data && myComplexesQuery.data) { + if ( + !currentComplexQuery.isLoading && + !currentComplexQuery.data && + myComplexesQuery.data && + !myComplexesQuery.isLoading + ) { + if (myComplexesQuery.data.length === 0) { + navigate({ to: '/onboard/setup' }); + return; + } autoSelect(); } }, [ currentComplexQuery.isLoading, currentComplexQuery.data, myComplexesQuery.data, + myComplexesQuery.isLoading, navigate, queryClient, ]); diff --git a/apps/frontend/src/features/login/login-page.tsx b/apps/frontend/src/features/login/login-page.tsx index c776c51..cb19214 100644 --- a/apps/frontend/src/features/login/login-page.tsx +++ b/apps/frontend/src/features/login/login-page.tsx @@ -6,7 +6,7 @@ import { apiClient, authClient } from '@/lib/api-client'; import { useAuth } from '@/lib/auth'; import { acceptStoredPendingInvite } from '@/lib/invitations'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Link, useNavigate } from '@tanstack/react-router'; +import { Link } from '@tanstack/react-router'; import { useState } from 'react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; @@ -18,12 +18,7 @@ const loginSchema = z.object({ type LoginForm = z.infer; -type LoginPageProps = { - redirectTo?: string; -}; - -export function LoginPage({ redirectTo = '/' }: LoginPageProps) { - const navigate = useNavigate(); +export function LoginPage() { const { signInWithPassword } = useAuth(); const [submitError, setSubmitError] = useState(null); @@ -42,16 +37,6 @@ export function LoginPage({ redirectTo = '/' }: LoginPageProps) { async function handlePostLogin() { await acceptStoredPendingInvite(apiClient.complexes.acceptPendingInvitations); - const complexes = await apiClient.complexes.listMine(); - - if (complexes.length === 0) { - await navigate({ to: redirectTo }); - } else if (complexes.length === 1) { - await apiClient.complexes.select({ complexId: complexes[0].id }); - navigate({ to: redirectTo }); - } else { - navigate({ to: '/select-complex' }); - } } const onSubmit = async (values: LoginForm) => { diff --git a/apps/frontend/src/features/onboard/components/onboarding-check-email-step.tsx b/apps/frontend/src/features/onboard/components/onboarding-check-email-step.tsx deleted file mode 100644 index 4ada26b..0000000 --- a/apps/frontend/src/features/onboard/components/onboarding-check-email-step.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export function OnboardingCheckEmailStep() { - return ( -

- Revisa tu correo y abre el link de verificacion para continuar. -

- ); -} diff --git a/apps/frontend/src/features/onboard/components/onboarding-complete-step.tsx b/apps/frontend/src/features/onboard/components/onboarding-complete-step.tsx deleted file mode 100644 index 99a2f6d..0000000 --- a/apps/frontend/src/features/onboard/components/onboarding-complete-step.tsx +++ /dev/null @@ -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; - plans: PlanSummary[]; - verifiedEmail: string | null; - errorMessage: string | null; - infoMessage: string | null; - planPlaceholder: string; - onSubmit: (values: CompleteValues) => Promise; -}; - -export function OnboardingCompleteStep({ - form, - plans, - verifiedEmail, - errorMessage, - infoMessage, - planPlaceholder, - onSubmit, -}: OnboardingCompleteStepProps) { - return ( -
- {verifiedEmail && ( - - Email verificado - - - )} - - - Contraseña - - - - - - Nombre del complejo - - - - - - Direccion fisica - - - - - - Plan - ( - - )} - /> - {plans.length === 0 && ( -

- Carga planes en la base para continuar con el onboarding. -

- )} - -
- - {errorMessage &&

{errorMessage}

} - {infoMessage &&

{infoMessage}

} - - -
- ); -} diff --git a/apps/frontend/src/features/onboard/components/onboarding-start-step.tsx b/apps/frontend/src/features/onboard/components/onboarding-start-step.tsx deleted file mode 100644 index f8f980c..0000000 --- a/apps/frontend/src/features/onboard/components/onboarding-start-step.tsx +++ /dev/null @@ -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; - errorMessage: string | null; - infoMessage: string | null; - onSubmit: (values: StartValues) => Promise; -}; - -export function OnboardingStartStep({ - form, - errorMessage, - infoMessage, - onSubmit, -}: OnboardingStartStepProps) { - return ( -
- - Nombre - - - - - - Email - - - - - {errorMessage &&

{errorMessage}

} - {infoMessage &&

{infoMessage}

} - - -
- ); -} diff --git a/apps/frontend/src/features/onboard/components/onboarding-verify-otp-step.tsx b/apps/frontend/src/features/onboard/components/onboarding-verify-otp-step.tsx deleted file mode 100644 index 05a3a2f..0000000 --- a/apps/frontend/src/features/onboard/components/onboarding-verify-otp-step.tsx +++ /dev/null @@ -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; - email: string | null; - errorMessage: string | null; - infoMessage: string | null; - remainingAttempts: number; - resendCooldownSeconds: number; - isResending: boolean; - onSubmit: (values: VerifyOtpValues) => Promise; - onResend: () => Promise; -}; - -export function OnboardingVerifyOtpStep({ - form, - email, - errorMessage, - infoMessage, - remainingAttempts, - resendCooldownSeconds, - isResending, - onSubmit, - onResend, -}: OnboardingVerifyOtpStepProps) { - const resendDisabled = resendCooldownSeconds > 0 || isResending; - - return ( -
-

- Ingresa el codigo de 6 digitos que enviamos a{' '} - {email ?? 'tu email'}. -

- - -
- Codigo de verificacion - -
- - { - form.setValue('otp', value, { shouldValidate: true }); - }} - > - - - - - - - - - - - - - - -
- -

- Intentos restantes: {remainingAttempts} -

- - {errorMessage &&

{errorMessage}

} - {infoMessage &&

{infoMessage}

} - - -
- ); -} diff --git a/apps/frontend/src/features/onboard/components/onboarding-verifying-step.tsx b/apps/frontend/src/features/onboard/components/onboarding-verifying-step.tsx deleted file mode 100644 index c792468..0000000 --- a/apps/frontend/src/features/onboard/components/onboarding-verifying-step.tsx +++ /dev/null @@ -1,5 +0,0 @@ -export function OnboardingVerifyingStep() { - return ( -

Verificando tu email, espera un momento...

- ); -} diff --git a/apps/frontend/src/features/onboard/components/setup-stepper.tsx b/apps/frontend/src/features/onboard/components/setup-stepper.tsx new file mode 100644 index 0000000..31d87d0 --- /dev/null +++ b/apps/frontend/src/features/onboard/components/setup-stepper.tsx @@ -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; + plans: PlanWithFeatures[]; + sports: Sport[]; + isSubmitting: boolean; + onSubmit: () => Promise; +}; + +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 = { + '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 ( + + + {STEPS.map((step) => ( + + + +
+ {step.title} + {step.description} +
+
+ +
+ ))} +
+ +
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + {currentStep === 'step-5' ? ( + + ) : ( + + + + )} +
+
+ ); +} diff --git a/apps/frontend/src/features/onboard/components/steps/complex-name-step.tsx b/apps/frontend/src/features/onboard/components/steps/complex-name-step.tsx new file mode 100644 index 0000000..d3c5057 --- /dev/null +++ b/apps/frontend/src/features/onboard/components/steps/complex-name-step.tsx @@ -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; +}; + +export function ComplexNameStep({ form }: ComplexNameStepProps) { + const errors = form.formState.errors; + + return ( +
+
+

Nombre del complejo

+

¿Cómo se va a llamar tu complejo?

+
+ + + Nombre del complejo + + + +
+ ); +} diff --git a/apps/frontend/src/features/onboard/components/steps/courts-setup-step.tsx b/apps/frontend/src/features/onboard/components/steps/courts-setup-step.tsx new file mode 100644 index 0000000..4eae6f9 --- /dev/null +++ b/apps/frontend/src/features/onboard/components/steps/courts-setup-step.tsx @@ -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; + 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 ( +
+
+

Crear canchas

+

+ Configurá una cancha para empezar a recibir reservas. +

+
+ + + + {setupCourts && ( +
+ + Deporte + + {errors.courtSportId && ( +

+ {String(errors.courtSportId.message ?? '')} +

+ )} +
+ +
+ + Horario desde + + {errors.courtStartTime && ( +

+ {String(errors.courtStartTime.message ?? '')} +

+ )} +
+ + + Horario hasta + + {errors.courtEndTime && ( +

+ {String(errors.courtEndTime.message ?? '')} +

+ )} +
+
+ +
+ +
+ {DAYS_OF_WEEK.map((day) => { + const isSelected = courtDaysOfWeek?.includes(day.value) ?? false; + return ( + + ); + })} +
+ {errors.courtDaysOfWeek && ( +

+ {String(errors.courtDaysOfWeek.message ?? '')} +

+ )} +
+
+ )} +
+ ); +} diff --git a/apps/frontend/src/features/onboard/components/steps/location-step.tsx b/apps/frontend/src/features/onboard/components/steps/location-step.tsx new file mode 100644 index 0000000..90cee97 --- /dev/null +++ b/apps/frontend/src/features/onboard/components/steps/location-step.tsx @@ -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; +}; + +export function LocationStep({ form }: LocationStepProps) { + const errors = form.formState.errors; + + return ( +
+
+

Ubicación

+

¿Dónde está ubicado tu complejo?

+
+ + + Dirección + + + + + + Ciudad + + + + +
+ + Provincia / Estado + + + + + + País + + + +
+
+ ); +} diff --git a/apps/frontend/src/features/onboard/components/steps/plan-select-step.tsx b/apps/frontend/src/features/onboard/components/steps/plan-select-step.tsx new file mode 100644 index 0000000..433d5fe --- /dev/null +++ b/apps/frontend/src/features/onboard/components/steps/plan-select-step.tsx @@ -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; + plans: PlanWithFeatures[]; +}; + +const FEATURE_LABELS: Record = { + 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 ( +
+
+

Elegí tu plan

+

+ Seleccioná el plan que mejor se adapte a tu complejo. +

+
+ +
+ {plans.map((plan) => { + const isSelected = selectedCode === plan.code; + return ( + + ); + })} +
+ + {errors.planCode && ( +

{String(errors.planCode.message ?? '')}

+ )} +
+ ); +} diff --git a/apps/frontend/src/features/onboard/components/steps/review-step.tsx b/apps/frontend/src/features/onboard/components/steps/review-step.tsx new file mode 100644 index 0000000..fb5f636 --- /dev/null +++ b/apps/frontend/src/features/onboard/components/steps/review-step.tsx @@ -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 = { + MONDAY: 'Lunes', + TUESDAY: 'Martes', + WEDNESDAY: 'Miércoles', + THURSDAY: 'Jueves', + FRIDAY: 'Viernes', + SATURDAY: 'Sábado', + SUNDAY: 'Domingo', +}; + +export function ReviewStep({ data, selectedPlan }: ReviewStepProps) { + return ( +
+
+

Revisá tus datos

+

+ Antes de confirmar, revisá que todos los datos sean correctos. +

+
+ +
+
+ + + {data.city && } + {data.state && } + {data.country && } +
+ +
+ {selectedPlan ? ( + <> + +
+ {[ + ['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 ( +
+ {enabled ? ( + + ) : ( + + )} + {label} +
+ ); + })} +
+ + ) : ( +

No se seleccionó un plan.

+ )} +
+ +
+ {data.setupCourts ? ( + <> + + + DAY_LABELS[d] ?? d).join(', ')} + /> + + ) : ( +

+ No se crearán canchas automáticamente. Podés agregarlas después. +

+ )} +
+
+
+ ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

+ {title} +

+
{children}
+
+ ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/apps/frontend/src/features/onboard/create-complex-page.tsx b/apps/frontend/src/features/onboard/create-complex-page.tsx deleted file mode 100644 index 15dd229..0000000 --- a/apps/frontend/src/features/onboard/create-complex-page.tsx +++ /dev/null @@ -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; - -export function CreateComplexPage() { - const navigate = useNavigate(); - const { user } = useAuth(); - const [plans, setPlans] = useState([]); - const [errorMessage, setErrorMessage] = useState(null); - const [isSubmitting, setIsSubmitting] = useState(false); - - const form = useForm({ - 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[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 ( -
-
-

Crear tu complejo

-

- Completá los datos de tu complejo para comenzar. -

- -
- {user?.email && ( - - Email - - - )} - - - Nombre del complejo - - - - - - Dirección física - - - - - - Ciudad - - - - - - Provincia/Estado - - - - - - País - - - - - - Plan - - {plans.length === 0 && ( -

- Carga planes en la base para continuar. -

- )} - -
- - {errorMessage &&

{errorMessage}

} - - -
-
-
- ); -} diff --git a/apps/frontend/src/features/onboard/onboard-complete-page.tsx b/apps/frontend/src/features/onboard/onboard-complete-page.tsx deleted file mode 100644 index ead1b36..0000000 --- a/apps/frontend/src/features/onboard/onboard-complete-page.tsx +++ /dev/null @@ -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(initialState.requestId); - const [verifiedEmail] = useState(initialState.verifiedEmail); - const [onboardingEmail] = useState(initialState.onboardingEmail); - const [plans, setPlans] = useState([]); - const [errorMessage, setErrorMessage] = useState(null); - - const form = useForm({ - resolver: zodResolver(onboardingCompleteSchema.omit({ onboardingRequestId: true })), - mode: 'onChange', - defaultValues: initialState.completeForm, - }); - const values = useWatch({ control: form.control }); - - useEffect(() => { - if (!requestId) { - void navigate({ to: '/onboard' }); - return; - } - - if (!verifiedEmail) { - void navigate({ to: '/onboard/verify' }); - } - }, [navigate, requestId, verifiedEmail]); - - useEffect(() => { - updateOnboardingSessionState((current) => ({ - ...current, - completeForm: { - password: values.password ?? '', - complexName: values.complexName ?? '', - physicalAddress: values.physicalAddress ?? '', - planCode: values.planCode ?? '', - }, - })); - }, [values.complexName, values.password, values.physicalAddress, values.planCode]); - - useEffect(() => { - void (async () => { - try { - const result = await apiClient.plans.list(); - setPlans(result); - } catch { - setPlans([]); - } - })(); - }, []); - - const onSubmit = async (payload: CompleteValues) => { - if (!requestId) { - setErrorMessage('No encontramos una solicitud de onboarding activa.'); - return; - } - - setErrorMessage(null); - - try { - const result = await apiClient.onboarding.complete({ - onboardingRequestId: requestId, - password: payload.password, - complexName: payload.complexName, - physicalAddress: payload.physicalAddress, - planCode: payload.planCode, - }); - - setCurrentComplexSlug(result.complexSlug); - const emailForSignIn = verifiedEmail ?? onboardingEmail; - if (emailForSignIn) { - await signInWithPassword({ email: emailForSignIn, password: payload.password }); - } - - clearOnboardingSessionState(); - await navigate({ to: '/complex/$slug/edit', params: { slug: result.complexSlug } }); - } catch { - setErrorMessage('No pudimos completar el onboarding. Intenta nuevamente.'); - } - }; - - const planPlaceholder = - plans.length === 0 ? 'Codigo del plan (por ejemplo: BASIC)' : 'Selecciona un plan'; - - return ( - - - - ); -} diff --git a/apps/frontend/src/features/onboard/onboard-login-page.tsx b/apps/frontend/src/features/onboard/onboard-login-page.tsx deleted file mode 100644 index 4f2ba0d..0000000 --- a/apps/frontend/src/features/onboard/onboard-login-page.tsx +++ /dev/null @@ -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; -type SignupForm = z.infer; - -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(null); - const [isSubmitting, setIsSubmitting] = useState(false); - - const loginForm = useForm({ - resolver: zodResolver(loginSchema), - mode: 'onChange', - defaultValues: { email: inviteEmail ?? '', password: '' }, - }); - - const signupForm = useForm({ - 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 ( -
-
-

- {mode === 'login' ? 'Iniciar sesión' : 'Crear cuenta'} -

-

- {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.'} -

- - - -
-
- -
-
- O -
-
- - {mode === 'login' ? ( -
- - Email - - - - - - Contraseña - - - - - {submitError &&

{submitError}

} - - -
- ) : ( -
- - Nombre completo - - - - - - Email - - - - - - Contraseña - - - - - - Confirmar contraseña - - - - - {submitError &&

{submitError}

} - - -
- )} - -

- {mode === 'login' ? ( - <> - ¿No tenés cuenta?{' '} - - - ) : ( - <> - ¿Ya tenés cuenta?{' '} - - - )} -

-
-
- ); -} diff --git a/apps/frontend/src/features/onboard/onboard-start-page.tsx b/apps/frontend/src/features/onboard/onboard-start-page.tsx deleted file mode 100644 index 8bd642b..0000000 --- a/apps/frontend/src/features/onboard/onboard-start-page.tsx +++ /dev/null @@ -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(null); - const [infoMessage, setInfoMessage] = useState(null); - const initialState = useMemo(() => getOnboardingSessionState(), []); - - const form = useForm({ - resolver: zodResolver(onboardingStartSchema), - mode: 'onChange', - defaultValues: initialState.startForm, - }); - const values = useWatch({ control: form.control }); - - useEffect(() => { - updateOnboardingSessionState((current) => ({ - ...current, - startForm: { - fullName: values.fullName ?? '', - email: values.email ?? '', - }, - })); - }, [values.email, values.fullName]); - - const onSubmit = async (payload: StartValues) => { - setErrorMessage(null); - setInfoMessage(null); - - try { - const result = await apiClient.onboarding.start(payload); - - updateOnboardingSessionState((current) => ({ - ...current, - requestId: result.requestId, - onboardingEmail: result.email, - verifiedEmail: null, - remainingAttempts: OTP_MAX_ATTEMPTS, - resendCooldownSeconds: result.cooldownSeconds, - startForm: { - fullName: payload.fullName, - email: payload.email, - }, - verifyOtpForm: defaultVerifyOtpFormValues, - completeForm: defaultCompleteFormValues, - })); - - await navigate({ to: '/onboard/verify' }); - } catch { - setErrorMessage('No pudimos iniciar el onboarding. Intenta nuevamente.'); - } - }; - - return ( - - - - ); -} diff --git a/apps/frontend/src/features/onboard/onboard-verify-page.tsx b/apps/frontend/src/features/onboard/onboard-verify-page.tsx deleted file mode 100644 index 73ae5d2..0000000 --- a/apps/frontend/src/features/onboard/onboard-verify-page.tsx +++ /dev/null @@ -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(initialState.requestId); - const [onboardingEmail, setOnboardingEmail] = useState( - initialState.onboardingEmail - ); - const [remainingAttempts, setRemainingAttempts] = useState( - initialState.remainingAttempts || OTP_MAX_ATTEMPTS - ); - const [resendCooldownSeconds, setResendCooldownSeconds] = useState( - initialState.resendCooldownSeconds || 0 - ); - const [isResending, setIsResending] = useState(false); - const [errorMessage, setErrorMessage] = useState(null); - const [infoMessage, setInfoMessage] = useState(null); - - const form = useForm({ - resolver: zodResolver(onboardingVerifyOtpSchema.omit({ requestId: true })), - mode: 'onChange', - defaultValues: initialState.verifyOtpForm, - }); - const values = useWatch({ control: form.control }); - - useEffect(() => { - if (requestId && onboardingEmail) return; - void navigate({ to: '/onboard' }); - }, [navigate, onboardingEmail, requestId]); - - useEffect(() => { - updateOnboardingSessionState((current) => ({ - ...current, - onboardingEmail, - remainingAttempts, - resendCooldownSeconds, - verifyOtpForm: { - otp: values.otp ?? '', - }, - })); - }, [onboardingEmail, remainingAttempts, resendCooldownSeconds, values.otp]); - - useEffect(() => { - if (resendCooldownSeconds <= 0) return; - - const timeout = window.setTimeout(() => { - setResendCooldownSeconds((current) => Math.max(0, current - 1)); - }, 1000); - - return () => { - window.clearTimeout(timeout); - }; - }, [resendCooldownSeconds]); - - const onSubmit = async (payload: VerifyOtpValues) => { - if (!requestId) { - setErrorMessage('No encontramos una solicitud de onboarding activa.'); - return; - } - - setErrorMessage(null); - - try { - const result = await apiClient.onboarding.verifyOtp({ - requestId, - otp: payload.otp, - }); - - setRemainingAttempts(result.remainingAttempts); - setResendCooldownSeconds(result.cooldownSeconds); - setInfoMessage(result.message); - - updateOnboardingSessionState((current) => ({ - ...current, - remainingAttempts: result.remainingAttempts, - resendCooldownSeconds: result.cooldownSeconds, - })); - - if (result.verified) { - const nextVerifiedEmail = result.email ?? onboardingEmail; - - updateOnboardingSessionState((current) => ({ - ...current, - verifiedEmail: nextVerifiedEmail, - remainingAttempts: result.remainingAttempts, - resendCooldownSeconds: result.cooldownSeconds, - })); - - await navigate({ to: '/onboard/complete' }); - } - } catch { - setErrorMessage('No pudimos verificar el OTP. Intenta nuevamente.'); - } - }; - - const onResend = async () => { - if (!requestId) { - setErrorMessage('No encontramos una solicitud de onboarding activa.'); - return; - } - - setIsResending(true); - setErrorMessage(null); - - try { - const result = await apiClient.onboarding.resendOtp({ requestId }); - setOnboardingEmail(result.email); - setRemainingAttempts(result.remainingAttempts); - setResendCooldownSeconds(result.cooldownSeconds); - form.reset({ otp: '' }); - setInfoMessage(result.message); - - updateOnboardingSessionState((current) => ({ - ...current, - onboardingEmail: result.email, - remainingAttempts: result.remainingAttempts, - resendCooldownSeconds: result.cooldownSeconds, - verifyOtpForm: { otp: '' }, - })); - } catch { - setErrorMessage('No pudimos reenviar el OTP. Intenta nuevamente.'); - } finally { - setIsResending(false); - } - }; - - return ( - - - - - ); -} diff --git a/apps/frontend/src/features/onboard/onboarding-session.ts b/apps/frontend/src/features/onboard/onboarding-session.ts deleted file mode 100644 index 453df75..0000000 --- a/apps/frontend/src/features/onboard/onboarding-session.ts +++ /dev/null @@ -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 { - return typeof value === 'object' && value !== null; -} - -function normalizeSessionState(value: unknown): OnboardingSessionState { - if (!isObject(value)) { - return defaultOnboardingSessionState; - } - - return { - requestId: typeof value.requestId === 'string' ? value.requestId : null, - onboardingEmail: typeof value.onboardingEmail === 'string' ? value.onboardingEmail : null, - verifiedEmail: typeof value.verifiedEmail === 'string' ? value.verifiedEmail : null, - remainingAttempts: - typeof value.remainingAttempts === 'number' - ? value.remainingAttempts - : defaultOnboardingSessionState.remainingAttempts, - resendCooldownSeconds: - typeof value.resendCooldownSeconds === 'number' - ? value.resendCooldownSeconds - : defaultOnboardingSessionState.resendCooldownSeconds, - startForm: { - fullName: - isObject(value.startForm) && typeof value.startForm.fullName === 'string' - ? value.startForm.fullName - : defaultStartFormValues.fullName, - email: - isObject(value.startForm) && typeof value.startForm.email === 'string' - ? value.startForm.email - : defaultStartFormValues.email, - }, - verifyOtpForm: { - otp: - isObject(value.verifyOtpForm) && typeof value.verifyOtpForm.otp === 'string' - ? value.verifyOtpForm.otp - : defaultVerifyOtpFormValues.otp, - }, - completeForm: { - password: - isObject(value.completeForm) && typeof value.completeForm.password === 'string' - ? value.completeForm.password - : defaultCompleteFormValues.password, - complexName: - isObject(value.completeForm) && typeof value.completeForm.complexName === 'string' - ? value.completeForm.complexName - : defaultCompleteFormValues.complexName, - physicalAddress: - isObject(value.completeForm) && typeof value.completeForm.physicalAddress === 'string' - ? value.completeForm.physicalAddress - : defaultCompleteFormValues.physicalAddress, - planCode: - isObject(value.completeForm) && typeof value.completeForm.planCode === 'string' - ? value.completeForm.planCode - : defaultCompleteFormValues.planCode, - }, - }; -} - -export function getOnboardingSessionState(): OnboardingSessionState { - if (typeof window === 'undefined') { - return defaultOnboardingSessionState; - } - - const rawState = window.sessionStorage.getItem(ONBOARDING_SESSION_KEY); - if (!rawState) { - return defaultOnboardingSessionState; - } - - try { - return normalizeSessionState(JSON.parse(rawState)); - } catch { - return defaultOnboardingSessionState; - } -} - -export function setOnboardingSessionState(state: OnboardingSessionState) { - if (typeof window === 'undefined') return; - window.sessionStorage.setItem(ONBOARDING_SESSION_KEY, JSON.stringify(state)); -} - -export function updateOnboardingSessionState( - updater: (current: OnboardingSessionState) => OnboardingSessionState -): OnboardingSessionState { - const currentState = getOnboardingSessionState(); - const nextState = updater(currentState); - setOnboardingSessionState(nextState); - return nextState; -} - -export function clearOnboardingSessionState() { - if (typeof window === 'undefined') return; - window.sessionStorage.removeItem(ONBOARDING_SESSION_KEY); -} diff --git a/apps/frontend/src/features/onboard/onboarding.types.ts b/apps/frontend/src/features/onboard/onboarding.types.ts deleted file mode 100644 index 6810078..0000000 --- a/apps/frontend/src/features/onboard/onboarding.types.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { - onboardingCompleteSchema, - onboardingStartSchema, - onboardingVerifyOtpSchema, -} from '@repo/api-contract'; -import { z } from 'zod'; - -export type StartValues = z.infer; -export type VerifyOtpValues = Omit, 'requestId'>; -export type CompleteValues = Omit, 'onboardingRequestId'>; diff --git a/apps/frontend/src/features/onboard/setup-stepper-page.tsx b/apps/frontend/src/features/onboard/setup-stepper-page.tsx new file mode 100644 index 0000000..4573765 --- /dev/null +++ b/apps/frontend/src/features/onboard/setup-stepper-page.tsx @@ -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([]); + const [sports, setSports] = useState([]); + const [errorMessage, setErrorMessage] = useState(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 ( +
+
+

{errorMessage}

+
+
+ ); + } + + return ( +
+
+
+

Configurá tu complejo

+

+ Completá los datos para empezar a recibir reservas. +

+
+ + + + {errorMessage &&

{errorMessage}

} +
+
+ ); +} diff --git a/apps/frontend/src/features/onboard/verify-email-page.tsx b/apps/frontend/src/features/onboard/verify-email-page.tsx deleted file mode 100644 index e8fe7fa..0000000 --- a/apps/frontend/src/features/onboard/verify-email-page.tsx +++ /dev/null @@ -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 ( -
-
-

Verificá tu email

-

- Te enviamos un email de verificación a {email}. Hacé click en el link - para confirmar tu cuenta. -

- -
- - -

- ¿Ya verificaste tu email?{' '} - {pendingInvite.token ? ( - - Continuar con la invitación - - ) : ( - - Continuar - - )} -

-
-
-
- ); -} diff --git a/apps/frontend/src/features/signup/signup-page.tsx b/apps/frontend/src/features/signup/signup-page.tsx index c3fcf52..414f8e3 100644 --- a/apps/frontend/src/features/signup/signup-page.tsx +++ b/apps/frontend/src/features/signup/signup-page.tsx @@ -83,7 +83,7 @@ export function SignupPage() { setIsSubmitting(true); try { - await signUp({ + const { requiresEmailConfirmation } = await signUp({ email: values.email, password: values.password, fullName: values.fullName, @@ -91,7 +91,11 @@ export function SignupPage() { }); sessionStorage.removeItem(STORAGE_KEY); - await navigate({ to: '/' }); + + if (requiresEmailConfirmation) { + sessionStorage.setItem('pending-signup-email', values.email); + await navigate({ to: '/onboard/setup' }); + } } catch { setSubmitError('No pudimos crear la cuenta. Intenta nuevamente.'); } finally { diff --git a/apps/frontend/src/lib/api-client.ts b/apps/frontend/src/lib/api-client.ts index cd51662..d8f475e 100644 --- a/apps/frontend/src/lib/api-client.ts +++ b/apps/frontend/src/lib/api-client.ts @@ -43,7 +43,6 @@ export class ApiClientError extends Error { } export const apiClient = { - onboarding: api.onboarding, plans: api.plans, user: api.user, complexes: api.complexes, diff --git a/apps/frontend/src/lib/api/index.ts b/apps/frontend/src/lib/api/index.ts index 861bcd6..292f256 100644 --- a/apps/frontend/src/lib/api/index.ts +++ b/apps/frontend/src/lib/api/index.ts @@ -6,7 +6,6 @@ export * as courts from './resources/courts'; export * as user from './resources/user'; export * as sports from './resources/sports'; export * as plans from './resources/plans'; -export * as onboarding from './resources/onboarding'; export { getAvailability, diff --git a/apps/frontend/src/lib/api/resources/onboarding.ts b/apps/frontend/src/lib/api/resources/onboarding.ts deleted file mode 100644 index 02b5d36..0000000 --- a/apps/frontend/src/lib/api/resources/onboarding.ts +++ /dev/null @@ -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('/api/onboarding/start', payload); - return response.data; -} - -export async function verifyOtp(payload: OnboardingVerifyOtpInput) { - const response = await http.post( - '/api/onboarding/verify-otp', - payload - ); - return response.data; -} - -export async function resendOtp(payload: OnboardingResendOtpInput) { - const response = await http.post( - '/api/onboarding/resend-otp', - payload - ); - return response.data; -} - -export async function complete(payload: OnboardingCompleteInput) { - const response = await http.post('/api/onboarding/complete', payload); - return response.data; -} diff --git a/apps/frontend/src/lib/api/resources/plans.ts b/apps/frontend/src/lib/api/resources/plans.ts index ebf115d..2cfa99c 100644 --- a/apps/frontend/src/lib/api/resources/plans.ts +++ b/apps/frontend/src/lib/api/resources/plans.ts @@ -1,7 +1,7 @@ -import type { PlanSummary } from '@repo/api-contract'; +import type { PlanWithFeatures } from '@repo/api-contract'; import { http } from '../http'; export async function list() { - const response = await http.get('/api/plans'); + const response = await http.get('/api/plans'); return response.data; } diff --git a/apps/frontend/src/routeTree.gen.ts b/apps/frontend/src/routeTree.gen.ts index 8dd2857..2de1645 100644 --- a/apps/frontend/src/routeTree.gen.ts +++ b/apps/frontend/src/routeTree.gen.ts @@ -17,11 +17,7 @@ import { Route as LoginRouteImport } from './routes/login' import { Route as InviteRouteImport } from './routes/invite' import { Route as AuthCallbackRouteImport } from './routes/auth-callback' import { Route as AppRouteRouteImport } from './routes/_app/route' -import { Route as OnboardIndexRouteImport } from './routes/onboard/index' -import { Route as OnboardVerifyEmailRouteImport } from './routes/onboard/verify-email' -import { Route as OnboardVerifyRouteImport } from './routes/onboard/verify' -import { Route as OnboardCreateComplexRouteImport } from './routes/onboard/create-complex' -import { Route as OnboardCompleteRouteImport } from './routes/onboard/complete' +import { Route as OnboardSetupRouteImport } from './routes/onboard/setup' import { Route as AppProfileRouteImport } from './routes/_app/profile' import { Route as ComplexSlugBookingRouteImport } from './routes/$complexSlug/booking' import { Route as AppAuthenticatedRouteRouteImport } from './routes/_app/_authenticated/route' @@ -69,29 +65,9 @@ const AppRouteRoute = AppRouteRouteImport.update({ id: '/_app', getParentRoute: () => rootRouteImport, } as any) -const OnboardIndexRoute = OnboardIndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => OnboardRoute, -} as any) -const OnboardVerifyEmailRoute = OnboardVerifyEmailRouteImport.update({ - id: '/verify-email', - path: '/verify-email', - getParentRoute: () => OnboardRoute, -} as any) -const OnboardVerifyRoute = OnboardVerifyRouteImport.update({ - id: '/verify', - path: '/verify', - getParentRoute: () => OnboardRoute, -} as any) -const OnboardCreateComplexRoute = OnboardCreateComplexRouteImport.update({ - id: '/create-complex', - path: '/create-complex', - getParentRoute: () => OnboardRoute, -} as any) -const OnboardCompleteRoute = OnboardCompleteRouteImport.update({ - id: '/complete', - path: '/complete', +const OnboardSetupRoute = OnboardSetupRouteImport.update({ + id: '/setup', + path: '/setup', getParentRoute: () => OnboardRoute, } as any) const AppProfileRoute = AppProfileRouteImport.update({ @@ -142,11 +118,7 @@ export interface FileRoutesByFullPath { '/signup': typeof SignupRoute '/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren '/profile': typeof AppProfileRoute - '/onboard/complete': typeof OnboardCompleteRoute - '/onboard/create-complex': typeof OnboardCreateComplexRoute - '/onboard/verify': typeof OnboardVerifyRoute - '/onboard/verify-email': typeof OnboardVerifyEmailRoute - '/onboard/': typeof OnboardIndexRoute + '/onboard/setup': typeof OnboardSetupRoute '/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute '/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute '/complex/$slug/edit': typeof AppAuthenticatedComplexSlugEditRoute @@ -156,15 +128,12 @@ export interface FileRoutesByTo { '/auth-callback': typeof AuthCallbackRoute '/invite': typeof InviteRoute '/login': typeof LoginRoute + '/onboard': typeof OnboardRouteWithChildren '/reset-password': typeof ResetPasswordRoute '/select-complex': typeof SelectComplexRoute '/signup': typeof SignupRoute '/profile': typeof AppProfileRoute - '/onboard/complete': typeof OnboardCompleteRoute - '/onboard/create-complex': typeof OnboardCreateComplexRoute - '/onboard/verify': typeof OnboardVerifyRoute - '/onboard/verify-email': typeof OnboardVerifyEmailRoute - '/onboard': typeof OnboardIndexRoute + '/onboard/setup': typeof OnboardSetupRoute '/$complexSlug/booking': typeof ComplexSlugBookingIndexRoute '/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute '/complex/$slug/edit': typeof AppAuthenticatedComplexSlugEditRoute @@ -182,11 +151,7 @@ export interface FileRoutesById { '/_app/_authenticated': typeof AppAuthenticatedRouteRouteWithChildren '/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren '/_app/profile': typeof AppProfileRoute - '/onboard/complete': typeof OnboardCompleteRoute - '/onboard/create-complex': typeof OnboardCreateComplexRoute - '/onboard/verify': typeof OnboardVerifyRoute - '/onboard/verify-email': typeof OnboardVerifyEmailRoute - '/onboard/': typeof OnboardIndexRoute + '/onboard/setup': typeof OnboardSetupRoute '/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute '/_app/_authenticated/': typeof AppAuthenticatedIndexRoute '/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute @@ -205,11 +170,7 @@ export interface FileRouteTypes { | '/signup' | '/$complexSlug/booking' | '/profile' - | '/onboard/complete' - | '/onboard/create-complex' - | '/onboard/verify' - | '/onboard/verify-email' - | '/onboard/' + | '/onboard/setup' | '/$complexSlug/booking/' | '/$complexSlug/booking/confirmed/$bookingCode' | '/complex/$slug/edit' @@ -219,15 +180,12 @@ export interface FileRouteTypes { | '/auth-callback' | '/invite' | '/login' + | '/onboard' | '/reset-password' | '/select-complex' | '/signup' | '/profile' - | '/onboard/complete' - | '/onboard/create-complex' - | '/onboard/verify' - | '/onboard/verify-email' - | '/onboard' + | '/onboard/setup' | '/$complexSlug/booking' | '/$complexSlug/booking/confirmed/$bookingCode' | '/complex/$slug/edit' @@ -244,11 +202,7 @@ export interface FileRouteTypes { | '/_app/_authenticated' | '/$complexSlug/booking' | '/_app/profile' - | '/onboard/complete' - | '/onboard/create-complex' - | '/onboard/verify' - | '/onboard/verify-email' - | '/onboard/' + | '/onboard/setup' | '/$complexSlug/booking/' | '/_app/_authenticated/' | '/$complexSlug/booking/confirmed/$bookingCode' @@ -325,39 +279,11 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppRouteRouteImport parentRoute: typeof rootRouteImport } - '/onboard/': { - id: '/onboard/' - path: '/' - fullPath: '/onboard/' - preLoaderRoute: typeof OnboardIndexRouteImport - parentRoute: typeof OnboardRoute - } - '/onboard/verify-email': { - id: '/onboard/verify-email' - path: '/verify-email' - fullPath: '/onboard/verify-email' - preLoaderRoute: typeof OnboardVerifyEmailRouteImport - parentRoute: typeof OnboardRoute - } - '/onboard/verify': { - id: '/onboard/verify' - path: '/verify' - fullPath: '/onboard/verify' - preLoaderRoute: typeof OnboardVerifyRouteImport - parentRoute: typeof OnboardRoute - } - '/onboard/create-complex': { - id: '/onboard/create-complex' - path: '/create-complex' - fullPath: '/onboard/create-complex' - preLoaderRoute: typeof OnboardCreateComplexRouteImport - parentRoute: typeof OnboardRoute - } - '/onboard/complete': { - id: '/onboard/complete' - path: '/complete' - fullPath: '/onboard/complete' - preLoaderRoute: typeof OnboardCompleteRouteImport + '/onboard/setup': { + id: '/onboard/setup' + path: '/setup' + fullPath: '/onboard/setup' + preLoaderRoute: typeof OnboardSetupRouteImport parentRoute: typeof OnboardRoute } '/_app/profile': { @@ -442,19 +368,11 @@ const AppRouteRouteWithChildren = AppRouteRoute._addFileChildren( ) interface OnboardRouteChildren { - OnboardCompleteRoute: typeof OnboardCompleteRoute - OnboardCreateComplexRoute: typeof OnboardCreateComplexRoute - OnboardVerifyRoute: typeof OnboardVerifyRoute - OnboardVerifyEmailRoute: typeof OnboardVerifyEmailRoute - OnboardIndexRoute: typeof OnboardIndexRoute + OnboardSetupRoute: typeof OnboardSetupRoute } const OnboardRouteChildren: OnboardRouteChildren = { - OnboardCompleteRoute: OnboardCompleteRoute, - OnboardCreateComplexRoute: OnboardCreateComplexRoute, - OnboardVerifyRoute: OnboardVerifyRoute, - OnboardVerifyEmailRoute: OnboardVerifyEmailRoute, - OnboardIndexRoute: OnboardIndexRoute, + OnboardSetupRoute: OnboardSetupRoute, } const OnboardRouteWithChildren = diff --git a/apps/frontend/src/routes/auth-callback.tsx b/apps/frontend/src/routes/auth-callback.tsx index eb04f97..39242f5 100644 --- a/apps/frontend/src/routes/auth-callback.tsx +++ b/apps/frontend/src/routes/auth-callback.tsx @@ -17,23 +17,9 @@ function AuthCallbackPage() { try { await acceptStoredPendingInvite(apiClient.complexes.acceptPendingInvitations); - const complexes = await apiClient.complexes.listMine(); - - if (complexes.length === 1) { - await apiClient.complexes.select({ complexId: complexes[0].id }); - navigate({ to: '/' }); - } else if (complexes.length > 1) { - const current = await apiClient.complexes.getCurrent(); - if (current) { - navigate({ to: '/' }); - } else { - navigate({ to: '/select-complex' }); - } - } else { - navigate({ to: '/onboard/create-complex' }); - } + navigate({ to: '/' }); } catch { - navigate({ to: '/onboard' }); + navigate({ to: '/login' }); } } diff --git a/apps/frontend/src/routes/invite.tsx b/apps/frontend/src/routes/invite.tsx index 8814638..44eebad 100644 --- a/apps/frontend/src/routes/invite.tsx +++ b/apps/frontend/src/routes/invite.tsx @@ -1,4 +1,3 @@ -import { OnboardLoginPage } from '@/features/onboard/onboard-login-page'; import { apiClient } from '@/lib/api-client'; import { useAuth } from '@/lib/auth'; import { acceptStoredPendingInvite, setPendingInvite } from '@/lib/invitations'; @@ -25,6 +24,7 @@ function InvitePage() { useEffect(() => { if (!isAuthenticated) { + navigate({ to: '/login' }); return; } @@ -34,15 +34,11 @@ function InvitePage() { })(); }, [isAuthenticated, navigate]); - if (isAuthenticated) { - return ( -
-

Aceptando invitación...

-
- ); - } - - return ; + return ( +
+

Aceptando invitación...

+
+ ); } export const Route = createFileRoute('/invite')({ diff --git a/apps/frontend/src/routes/login.tsx b/apps/frontend/src/routes/login.tsx index 45ee78d..50573ed 100644 --- a/apps/frontend/src/routes/login.tsx +++ b/apps/frontend/src/routes/login.tsx @@ -1,22 +1,11 @@ import { LoginPage } from '@/features/login/login-page'; import { createFileRoute, redirect } from '@tanstack/react-router'; -import { z } from 'zod'; - -const loginSearchSchema = z.object({ - redirect: z.string().optional(), -}); export const Route = createFileRoute('/login')({ - validateSearch: loginSearchSchema, beforeLoad: ({ context }) => { if (context.auth.isAuthenticated) { throw redirect({ to: '/' }); } }, - component: LoginRouteComponent, + component: LoginPage, }); - -function LoginRouteComponent() { - const search = Route.useSearch(); - return ; -} diff --git a/apps/frontend/src/routes/onboard/complete.tsx b/apps/frontend/src/routes/onboard/complete.tsx deleted file mode 100644 index c12e9a9..0000000 --- a/apps/frontend/src/routes/onboard/complete.tsx +++ /dev/null @@ -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, -}); diff --git a/apps/frontend/src/routes/onboard/index.tsx b/apps/frontend/src/routes/onboard/index.tsx deleted file mode 100644 index 003a135..0000000 --- a/apps/frontend/src/routes/onboard/index.tsx +++ /dev/null @@ -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, -}); diff --git a/apps/frontend/src/routes/onboard/create-complex.tsx b/apps/frontend/src/routes/onboard/setup.tsx similarity index 58% rename from apps/frontend/src/routes/onboard/create-complex.tsx rename to apps/frontend/src/routes/onboard/setup.tsx index 05b9bd5..4459962 100644 --- a/apps/frontend/src/routes/onboard/create-complex.tsx +++ b/apps/frontend/src/routes/onboard/setup.tsx @@ -1,14 +1,14 @@ -import { CreateComplexPage } from '@/features/onboard/create-complex-page'; +import { SetupStepperPage } from '@/features/onboard/setup-stepper-page'; import { createFileRoute, redirect } from '@tanstack/react-router'; -export const Route = createFileRoute('/onboard/create-complex')({ +export const Route = createFileRoute('/onboard/setup')({ beforeLoad: ({ context }) => { const hasSession = context.auth.isAuthenticated; const hasPendingSignup = Boolean(sessionStorage.getItem('pending-signup-email')); if (!hasSession && !hasPendingSignup) { - throw redirect({ to: '/onboard' }); + throw redirect({ to: '/login' }); } }, - component: CreateComplexPage, + component: SetupStepperPage, }); diff --git a/apps/frontend/src/routes/onboard/verify-email.tsx b/apps/frontend/src/routes/onboard/verify-email.tsx deleted file mode 100644 index 94c51a6..0000000 --- a/apps/frontend/src/routes/onboard/verify-email.tsx +++ /dev/null @@ -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, -}); diff --git a/apps/frontend/src/routes/onboard/verify.tsx b/apps/frontend/src/routes/onboard/verify.tsx deleted file mode 100644 index 72e512e..0000000 --- a/apps/frontend/src/routes/onboard/verify.tsx +++ /dev/null @@ -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, -}); diff --git a/packages/api-contract/src/complex.ts b/packages/api-contract/src/complex.ts index ec2bd1c..15c8bf9 100644 --- a/packages/api-contract/src/complex.ts +++ b/packages/api-contract/src/complex.ts @@ -1,5 +1,15 @@ import { z } from 'zod' +const dayOfWeekEnum = z.enum([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', +]) + export const complexSchema = z.object({ id: z.uuid(), complexName: z.string(), @@ -14,39 +24,77 @@ export const complexSchema = z.object({ updatedAt: z.string().datetime(), }) -export 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(), -}) +export 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(), + 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 .object({ diff --git a/packages/api-contract/src/index.ts b/packages/api-contract/src/index.ts index e7ea440..1696f37 100644 --- a/packages/api-contract/src/index.ts +++ b/packages/api-contract/src/index.ts @@ -4,28 +4,18 @@ export { planRulesV1Schema, planSchema, planSummarySchema, + planWithFeaturesSchema, + planLimitsSchema, } from './plan' -export type { Plan, PlanFeatureFlags, PlanRules, PlanRulesV1, PlanSummary } from './plan' -export { - onboardingCompleteResponseSchema, - onboardingCompleteSchema, - onboardingResendOtpResponseSchema, - onboardingResendOtpSchema, - onboardingStartResponseSchema, - onboardingStartSchema, - onboardingVerifyOtpResponseSchema, - onboardingVerifyOtpSchema, -} from './onboarding' export type { - OnboardingCompleteInput, - OnboardingCompleteResponse, - OnboardingResendOtpInput, - OnboardingResendOtpResponse, - OnboardingStartInput, - OnboardingStartResponse, - OnboardingVerifyOtpInput, - OnboardingVerifyOtpResponse, -} from './onboarding' + Plan, + PlanFeatureFlags, + PlanRules, + PlanRulesV1, + PlanSummary, + PlanWithFeatures, + PlanLimits, +} from './plan' export { acceptComplexInvitationsSchema, complexInvitationSchema, diff --git a/packages/api-contract/src/onboarding.ts b/packages/api-contract/src/onboarding.ts deleted file mode 100644 index 628bc37..0000000 --- a/packages/api-contract/src/onboarding.ts +++ /dev/null @@ -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 -export type OnboardingVerifyOtpInput = z.infer -export type OnboardingResendOtpInput = z.infer -export type OnboardingCompleteInput = z.infer - -export type OnboardingStartResponse = z.infer -export type OnboardingVerifyOtpResponse = z.infer< - typeof onboardingVerifyOtpResponseSchema -> -export type OnboardingResendOtpResponse = z.infer< - typeof onboardingResendOtpResponseSchema -> -export type OnboardingCompleteResponse = z.infer< - typeof onboardingCompleteResponseSchema -> diff --git a/packages/api-contract/src/plan.ts b/packages/api-contract/src/plan.ts index 51a1b89..27104ed 100644 --- a/packages/api-contract/src/plan.ts +++ b/packages/api-contract/src/plan.ts @@ -49,8 +49,25 @@ export const planSummarySchema = z.object({ price: z.number().nonnegative(), }) +export const planLimitsSchema = z.object({ + maxCourts: positiveInt, + maxBookingsPerDay: positiveInt, + maxActiveUsers: positiveInt.optional(), + maxConcurrentBookingsPerSlot: positiveInt.optional(), +}) + +export const planWithFeaturesSchema = z.object({ + code: z.string().trim().min(1).max(10), + name: z.string().trim().min(1).max(30), + price: z.number().nonnegative(), + features: planFeatureFlagsSchema, + limits: planLimitsSchema, +}) + export type PlanFeatureFlags = z.infer export type PlanRulesV1 = z.infer export type PlanRules = z.infer export type Plan = z.infer export type PlanSummary = z.infer +export type PlanWithFeatures = z.infer +export type PlanLimits = z.infer