diff --git a/apps/backend/package.json b/apps/backend/package.json index ff4cdf9..433e77f 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -3,6 +3,9 @@ "scripts": { "dev": "bun run --hot src/server.ts", "start": "bun src/server.ts", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "format": "biome format --write .", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", "prisma:migrate:deploy": "prisma migrate deploy", diff --git a/apps/backend/prisma.config.ts b/apps/backend/prisma.config.ts index a362ded..6bfd3b9 100644 --- a/apps/backend/prisma.config.ts +++ b/apps/backend/prisma.config.ts @@ -1,5 +1,5 @@ -import 'dotenv/config' -import { defineConfig, env } from 'prisma/config' +import 'dotenv/config'; +import { defineConfig, env } from 'prisma/config'; export default defineConfig({ schema: 'prisma', @@ -10,4 +10,4 @@ export default defineConfig({ datasource: { url: env('DATABASE_URL'), }, -}) +}); diff --git a/apps/backend/prisma/plans.seed-data.ts b/apps/backend/prisma/plans.seed-data.ts index ba65bb4..5660c11 100644 --- a/apps/backend/prisma/plans.seed-data.ts +++ b/apps/backend/prisma/plans.seed-data.ts @@ -74,4 +74,4 @@ export const planSeeds = [ }, }, }, -] as const +] as const; diff --git a/apps/backend/prisma/reset-plans.ts b/apps/backend/prisma/reset-plans.ts index f654110..5e8f878 100644 --- a/apps/backend/prisma/reset-plans.ts +++ b/apps/backend/prisma/reset-plans.ts @@ -1,33 +1,33 @@ -import 'dotenv/config' -import { PrismaPg } from '@prisma/adapter-pg' -import { PrismaClient } from '../src/generated/prisma/client' -import { planSeeds } from './plans.seed-data' +import 'dotenv/config'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '../src/generated/prisma/client'; +import { planSeeds } from './plans.seed-data'; -const databaseUrl = process.env.DATABASE_URL +const databaseUrl = process.env.DATABASE_URL; if (!databaseUrl) { - throw new Error('Missing DATABASE_URL in environment for plan reset.') + throw new Error('Missing DATABASE_URL in environment for plan reset.'); } const adapter = new PrismaPg({ connectionString: databaseUrl, -}) +}); -const prisma = new PrismaClient({ adapter }) +const prisma = new PrismaClient({ adapter }); async function run() { const complexesUsingPlans = await prisma.complex.count({ where: { planCode: { not: null } }, - }) + }); if (complexesUsingPlans > 0) { throw new Error( - `No se puede resetear planes: hay ${complexesUsingPlans} complejos con plan asignado.`, - ) + `No se puede resetear planes: hay ${complexesUsingPlans} complejos con plan asignado.` + ); } await prisma.$transaction(async (tx) => { - await tx.plan.deleteMany({}) + await tx.plan.deleteMany({}); for (const plan of planSeeds) { await tx.plan.create({ @@ -37,19 +37,19 @@ async function run() { price: plan.price, rules: plan.rules, }, - }) + }); } - }) + }); - console.log('Planes reseteados: BASIC, ADVANCED, ENTERPRISE') + console.log('Planes reseteados: BASIC, ADVANCED, ENTERPRISE'); } run() .then(async () => { - await prisma.$disconnect() + await prisma.$disconnect(); }) .catch(async (error) => { - console.error(error) - await prisma.$disconnect() - process.exit(1) - }) + console.error(error); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 3c7bddd..5a449c5 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -1,21 +1,21 @@ -import 'dotenv/config' -import { PrismaPg } from '@prisma/adapter-pg' -import { v7 as uuidv7 } from 'uuid' -import { PrismaClient } from '../src/generated/prisma/client' -import { planSeeds } from './plans.seed-data' -import { sportSeeds } from './sports.seed-data' +import 'dotenv/config'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { v7 as uuidv7 } from 'uuid'; +import { PrismaClient } from '../src/generated/prisma/client'; +import { planSeeds } from './plans.seed-data'; +import { sportSeeds } from './sports.seed-data'; -const databaseUrl = process.env.DATABASE_URL +const databaseUrl = process.env.DATABASE_URL; if (!databaseUrl) { - throw new Error('Missing DATABASE_URL in environment for seeding.') + throw new Error('Missing DATABASE_URL in environment for seeding.'); } const adapter = new PrismaPg({ connectionString: databaseUrl, -}) +}); -const prisma = new PrismaClient({ adapter }) +const prisma = new PrismaClient({ adapter }); async function run() { for (const plan of planSeeds) { @@ -33,7 +33,7 @@ async function run() { price: plan.price, rules: plan.rules, }, - }) + }); } for (const sport of sportSeeds) { @@ -49,16 +49,16 @@ async function run() { slug: sport.slug, isActive: true, }, - }) + }); } } run() .then(async () => { - await prisma.$disconnect() + await prisma.$disconnect(); }) .catch(async (error) => { - console.error(error) - await prisma.$disconnect() - process.exit(1) - }) + console.error(error); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/apps/backend/prisma/sports.seed-data.ts b/apps/backend/prisma/sports.seed-data.ts index 5f6e41e..ce84916 100644 --- a/apps/backend/prisma/sports.seed-data.ts +++ b/apps/backend/prisma/sports.seed-data.ts @@ -5,4 +5,4 @@ export const sportSeeds = [ { name: 'Pickleball', slug: 'pickleball' }, { name: 'Basquet', slug: 'basquet' }, { name: 'Basquet 3', slug: 'basquet-3' }, -] as const +] as const; diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 94f2590..a7bb377 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -1,36 +1,33 @@ -import { Hono } from 'hono' -import { cors } from 'hono/cors' -import { logger } from '@/lib/logger' -import type { AppEnv } from '@/types/hono' -import { requestId } from 'hono/request-id' +import { logger } from '@/lib/logger'; +import type { AppEnv } from '@/types/hono'; +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { requestId } from 'hono/request-id'; export function createApp() { - const app = new Hono() + const app = new Hono(); - const allowedOrigins = ( - Bun.env.CORS_ORIGIN ?? - 'http://localhost:5173,http://127.0.0.1:5173' - ) + const allowedOrigins = (Bun.env.CORS_ORIGIN ?? 'http://localhost:5173,http://127.0.0.1:5173') .split(',') .map((value) => value.trim()) - .filter(Boolean) + .filter(Boolean); app.use( '*', cors({ origin: (origin) => { - if (!origin) return allowedOrigins[0] ?? '' - return allowedOrigins.includes(origin) ? origin : '' + if (!origin) return allowedOrigins[0] ?? ''; + return allowedOrigins.includes(origin) ? origin : ''; }, allowHeaders: ['Authorization', 'Content-Type'], allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], - }), - ) + }) + ); app.use('*', async (c, next) => { - const start = performance.now() - await next() - const durationMs = Number((performance.now() - start).toFixed(1)) + const start = performance.now(); + await next(); + const durationMs = Number((performance.now() - start).toFixed(1)); logger.info( { @@ -38,11 +35,11 @@ export function createApp() { path: c.req.path, status: c.res.status, durationMs, - requestId + requestId, }, - 'http_request', - ) - }) + 'http_request' + ); + }); - return app + return app; } diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 1f3f7f4..f54a07b 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -1,8 +1,8 @@ -import { createApp } from '@/app' -import { registerRoutes } from '@/register-routes' +import { createApp } from '@/app'; +import { registerRoutes } from '@/register-routes'; -const app = createApp() -registerRoutes(app) +const app = createApp(); +registerRoutes(app); -export type AppType = typeof app -export default app +export type AppType = typeof app; +export default app; diff --git a/apps/backend/src/lib/logger.ts b/apps/backend/src/lib/logger.ts index 6df31f9..e36958f 100644 --- a/apps/backend/src/lib/logger.ts +++ b/apps/backend/src/lib/logger.ts @@ -1,6 +1,6 @@ -import pino from 'pino' +import pino from 'pino'; -const isProd = Bun.env.NODE_ENV === 'production' +const isProd = Bun.env.NODE_ENV === 'production'; export const logger = pino({ level: Bun.env.LOG_LEVEL ?? (isProd ? 'info' : 'debug'), @@ -15,4 +15,4 @@ export const logger = pino({ ignore: 'pid,hostname', }, }, -}) +}); diff --git a/apps/backend/src/lib/mailer.ts b/apps/backend/src/lib/mailer.ts index 57cc9f3..d10d38f 100644 --- a/apps/backend/src/lib/mailer.ts +++ b/apps/backend/src/lib/mailer.ts @@ -1,19 +1,17 @@ -import nodemailer from 'nodemailer' +import nodemailer from 'nodemailer'; -let cachedTransporter: ReturnType | null = null +let cachedTransporter: ReturnType | null = null; function getTransporter() { - if (cachedTransporter) return cachedTransporter + if (cachedTransporter) return cachedTransporter; - const smtpHost = Bun.env.SMTP_HOST - const smtpPort = Number(Bun.env.SMTP_PORT ?? 587) - const smtpUser = Bun.env.SMTP_USER - const smtpPass = Bun.env.SMTP_PASS + const smtpHost = Bun.env.SMTP_HOST; + const smtpPort = Number(Bun.env.SMTP_PORT ?? 587); + const smtpUser = Bun.env.SMTP_USER; + const smtpPass = Bun.env.SMTP_PASS; if (!smtpHost || !smtpUser || !smtpPass) { - throw new Error( - 'Missing SMTP env vars. Set SMTP_HOST, SMTP_PORT, SMTP_USER and SMTP_PASS.', - ) + throw new Error('Missing SMTP env vars. Set SMTP_HOST, SMTP_PORT, SMTP_USER and SMTP_PASS.'); } cachedTransporter = nodemailer.createTransport({ @@ -24,21 +22,21 @@ function getTransporter() { user: smtpUser, pass: smtpPass, }, - }) + }); - return cachedTransporter + return cachedTransporter; } export async function sendMail(input: { - to: string - subject: string - html: string - text: string + to: string; + subject: string; + html: string; + text: string; }) { - const smtpFrom = Bun.env.SMTP_FROM + const smtpFrom = Bun.env.SMTP_FROM; if (!smtpFrom) { - throw new Error('Missing SMTP_FROM env var.') + throw new Error('Missing SMTP_FROM env var.'); } await getTransporter().sendMail({ @@ -47,5 +45,5 @@ export async function sendMail(input: { subject: input.subject, html: input.html, text: input.text, - }) + }); } diff --git a/apps/backend/src/lib/prisma.ts b/apps/backend/src/lib/prisma.ts index 25d96a5..623676e 100644 --- a/apps/backend/src/lib/prisma.ts +++ b/apps/backend/src/lib/prisma.ts @@ -1,50 +1,50 @@ -import { PrismaPg } from '@prisma/adapter-pg' -import { PrismaClient } from '@/generated/prisma/client' +import { PrismaClient } from '@/generated/prisma/client'; +import { PrismaPg } from '@prisma/adapter-pg'; -const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient } +const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; function hasExpectedDelegates(client: PrismaClient) { const prismaClient = client as unknown as { - court?: { findMany?: unknown } - sport?: { findMany?: unknown } - } + court?: { findMany?: unknown }; + sport?: { findMany?: unknown }; + }; return ( typeof prismaClient.court?.findMany === 'function' && typeof prismaClient.sport?.findMany === 'function' - ) + ); } function buildPrismaClient() { - const databaseUrl = Bun.env.DATABASE_URL + const databaseUrl = Bun.env.DATABASE_URL; if (!databaseUrl) { - throw new Error('Missing DATABASE_URL in backend environment.') + throw new Error('Missing DATABASE_URL in backend environment.'); } const adapter = new PrismaPg({ connectionString: databaseUrl, - }) + }); - return new PrismaClient({ adapter }) + return new PrismaClient({ adapter }); } export function getPrismaClient() { if (globalForPrisma.prisma && hasExpectedDelegates(globalForPrisma.prisma)) { - return globalForPrisma.prisma + return globalForPrisma.prisma; } if (globalForPrisma.prisma && !hasExpectedDelegates(globalForPrisma.prisma)) { - void globalForPrisma.prisma.$disconnect() + void globalForPrisma.prisma.$disconnect(); } - const prisma = buildPrismaClient() + const prisma = buildPrismaClient(); if (Bun.env.NODE_ENV !== 'production') { - globalForPrisma.prisma = prisma + globalForPrisma.prisma = prisma; } - return prisma + return prisma; } -export const db = getPrismaClient() +export const db = getPrismaClient(); diff --git a/apps/backend/src/lib/supabase-admin.ts b/apps/backend/src/lib/supabase-admin.ts index f6cb5ba..f46eb4a 100644 --- a/apps/backend/src/lib/supabase-admin.ts +++ b/apps/backend/src/lib/supabase-admin.ts @@ -1,17 +1,17 @@ -import { createClient } from '@supabase/supabase-js' +import { createClient } from '@supabase/supabase-js'; -let cachedClient: ReturnType | null = null +let cachedClient: ReturnType | null = null; export function getSupabaseAdminClient() { - if (cachedClient) return cachedClient + if (cachedClient) return cachedClient; - const supabaseUrl = Bun.env.SUPABASE_URL ?? Bun.env.VITE_SUPABASE_URL - const supabaseServiceRoleKey = Bun.env.SUPABASE_SERVICE_ROLE_KEY + const supabaseUrl = Bun.env.SUPABASE_URL ?? Bun.env.VITE_SUPABASE_URL; + const supabaseServiceRoleKey = Bun.env.SUPABASE_SERVICE_ROLE_KEY; if (!supabaseUrl || !supabaseServiceRoleKey) { throw new Error( - 'Missing Supabase admin env vars. Set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in backend environment.', - ) + 'Missing Supabase admin env vars. Set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in backend environment.' + ); } cachedClient = createClient(supabaseUrl, supabaseServiceRoleKey, { @@ -20,7 +20,7 @@ export function getSupabaseAdminClient() { persistSession: false, detectSessionInUrl: false, }, - }) + }); - return cachedClient + return cachedClient; } diff --git a/apps/backend/src/lib/supabase.ts b/apps/backend/src/lib/supabase.ts index 82b5992..7c8e13e 100644 --- a/apps/backend/src/lib/supabase.ts +++ b/apps/backend/src/lib/supabase.ts @@ -1,13 +1,12 @@ -import { createClient } from '@supabase/supabase-js' +import { createClient } from '@supabase/supabase-js'; -const supabaseUrl = Bun.env.SUPABASE_URL ?? Bun.env.VITE_SUPABASE_URL -const supabaseAnonKey = - Bun.env.SUPABASE_ANON_KEY ?? Bun.env.VITE_SUPABASE_ANON_KEY +const supabaseUrl = Bun.env.SUPABASE_URL ?? Bun.env.VITE_SUPABASE_URL; +const supabaseAnonKey = Bun.env.SUPABASE_ANON_KEY ?? Bun.env.VITE_SUPABASE_ANON_KEY; if (!supabaseUrl || !supabaseAnonKey) { throw new Error( - 'Missing Supabase env vars. Set SUPABASE_URL/SUPABASE_ANON_KEY (or VITE_SUPABASE_URL/VITE_SUPABASE_ANON_KEY) in backend environment.', - ) + 'Missing Supabase env vars. Set SUPABASE_URL/SUPABASE_ANON_KEY (or VITE_SUPABASE_URL/VITE_SUPABASE_ANON_KEY) in backend environment.' + ); } export const supabase = createClient(supabaseUrl, supabaseAnonKey, { @@ -16,4 +15,4 @@ export const supabase = createClient(supabaseUrl, supabaseAnonKey, { persistSession: false, detectSessionInUrl: false, }, -}) +}); diff --git a/apps/backend/src/middlewares/require-auth.middleware.ts b/apps/backend/src/middlewares/require-auth.middleware.ts index c4a0132..ab4e306 100644 --- a/apps/backend/src/middlewares/require-auth.middleware.ts +++ b/apps/backend/src/middlewares/require-auth.middleware.ts @@ -1,35 +1,32 @@ -import type { MiddlewareHandler } from 'hono' -import { db } from '@/lib/prisma' -import { supabase } from '@/lib/supabase' -import type { AppEnv } from '@/types/hono' +import { db } from '@/lib/prisma'; +import { supabase } from '@/lib/supabase'; +import type { AppEnv } from '@/types/hono'; +import type { MiddlewareHandler } from 'hono'; function getBearerToken(value: string | undefined): string | null { - if (!value) return null - const [scheme, token] = value.split(' ') - if (scheme?.toLowerCase() !== 'bearer' || !token) return null - return token + if (!value) return null; + const [scheme, token] = value.split(' '); + if (scheme?.toLowerCase() !== 'bearer' || !token) return null; + return token; } -export const requireAuth: MiddlewareHandler = async ( - c, - next, -) => { - const token = getBearerToken(c.req.header('authorization')) +export const requireAuth: MiddlewareHandler = async (c, next) => { + const token = getBearerToken(c.req.header('authorization')); if (!token) { - return c.json({ message: 'Missing bearer token.' }, 403) + return c.json({ message: 'Missing bearer token.' }, 403); } - const { data, error } = await supabase.auth.getUser(token) + const { data, error } = await supabase.auth.getUser(token); if (error || !data.user) { - return c.json({ message: 'Invalid or expired token.' }, 403) + return c.json({ message: 'Invalid or expired token.' }, 403); } - const email = data.user.email?.toLowerCase() + const email = data.user.email?.toLowerCase(); if (!email) { - return c.json({ message: 'Auth user does not contain email.' }, 403) + return c.json({ message: 'Auth user does not contain email.' }, 403); } const appUser = await db.user.findFirst({ @@ -38,13 +35,13 @@ export const requireAuth: MiddlewareHandler = async ( email, }, select: { id: true }, - }) + }); if (!appUser) { - return c.json({ message: 'User not provisioned in backend.' }, 403) + return c.json({ message: 'User not provisioned in backend.' }, 403); } - c.set('authUser', data.user) - c.set('appUserId', appUser.id) - await next() -} + c.set('authUser', data.user); + c.set('appUserId', appUser.id); + await next(); +}; diff --git a/apps/backend/src/middlewares/require-super-admin.middleware.ts b/apps/backend/src/middlewares/require-super-admin.middleware.ts index a7b6569..9aac239 100644 --- a/apps/backend/src/middlewares/require-super-admin.middleware.ts +++ b/apps/backend/src/middlewares/require-super-admin.middleware.ts @@ -1,35 +1,29 @@ -import type { MiddlewareHandler } from 'hono' -import type { AppEnv } from '@/types/hono' +import type { AppEnv } from '@/types/hono'; +import type { MiddlewareHandler } from 'hono'; function getSuperAdminEmails(): Set { - const raw = Bun.env.SUPER_ADMIN_EMAILS ?? '' + const raw = Bun.env.SUPER_ADMIN_EMAILS ?? ''; const emails = raw .split(',') .map((value) => value.trim().toLowerCase()) - .filter(Boolean) - return new Set(emails) + .filter(Boolean); + return new Set(emails); } -export const requireSuperAdmin: MiddlewareHandler = async ( - c, - next, -) => { - const authUser = c.get('authUser') - const role = - typeof authUser.app_metadata?.role === 'string' - ? authUser.app_metadata.role - : null - const email = authUser.email?.toLowerCase() +export const requireSuperAdmin: MiddlewareHandler = async (c, next) => { + const authUser = c.get('authUser'); + const role = typeof authUser.app_metadata?.role === 'string' ? authUser.app_metadata.role : null; + const email = authUser.email?.toLowerCase(); if (role === 'super_admin') { - await next() - return + await next(); + return; } if (email && getSuperAdminEmails().has(email)) { - await next() - return + await next(); + return; } - return c.json({ message: 'Este recurso requiere permisos de super admin.' }, 403) -} + return c.json({ message: 'Este recurso requiere permisos de super admin.' }, 403); +}; diff --git a/apps/backend/src/modules/admin-booking/admin-booking.routes.ts b/apps/backend/src/modules/admin-booking/admin-booking.routes.ts index e9e283e..a3e3a08 100644 --- a/apps/backend/src/modules/admin-booking/admin-booking.routes.ts +++ b/apps/backend/src/modules/admin-booking/admin-booking.routes.ts @@ -1,40 +1,40 @@ -import { zValidator } from '@hono/zod-validator' +import { requireAuth } from '@/middlewares/require-auth.middleware'; +import { createAdminBookingHandler } from '@/modules/admin-booking/handlers/create-admin-booking.handler'; +import { listAdminBookingsHandler } from '@/modules/admin-booking/handlers/list-admin-bookings.handler'; +import { updateAdminBookingStatusHandler } from '@/modules/admin-booking/handlers/update-admin-booking-status.handler'; +import type { AppEnv } from '@/types/hono'; +import { zValidator } from '@hono/zod-validator'; import { createAdminBookingSchema, listAdminBookingsQuerySchema, updateAdminBookingStatusSchema, -} from '@repo/api-contract' -import { Hono } from 'hono' -import { z } from 'zod' -import { requireAuth } from '@/middlewares/require-auth.middleware' -import { createAdminBookingHandler } from '@/modules/admin-booking/handlers/create-admin-booking.handler' -import { listAdminBookingsHandler } from '@/modules/admin-booking/handlers/list-admin-bookings.handler' -import { updateAdminBookingStatusHandler } from '@/modules/admin-booking/handlers/update-admin-booking-status.handler' -import type { AppEnv } from '@/types/hono' +} from '@repo/api-contract'; +import { Hono } from 'hono'; +import { z } from 'zod'; -export const adminBookingRoutes = new Hono() -const complexIdParamsSchema = z.object({ complexId: z.uuid() }) -const bookingIdParamsSchema = z.object({ id: z.uuid() }) +export const adminBookingRoutes = new Hono(); +const complexIdParamsSchema = z.object({ complexId: z.uuid() }); +const bookingIdParamsSchema = z.object({ id: z.uuid() }); -adminBookingRoutes.use('*', requireAuth) +adminBookingRoutes.use('*', requireAuth); adminBookingRoutes.get( '/complex/:complexId', zValidator('param', complexIdParamsSchema), zValidator('query', listAdminBookingsQuerySchema), - listAdminBookingsHandler, -) + listAdminBookingsHandler +); adminBookingRoutes.post( '/complex/:complexId', zValidator('param', complexIdParamsSchema), zValidator('json', createAdminBookingSchema), - createAdminBookingHandler, -) + createAdminBookingHandler +); adminBookingRoutes.patch( '/:id/status', zValidator('param', bookingIdParamsSchema), zValidator('json', updateAdminBookingStatusSchema), - updateAdminBookingStatusHandler, -) + updateAdminBookingStatusHandler +); diff --git a/apps/backend/src/modules/admin-booking/handlers/create-admin-booking.handler.ts b/apps/backend/src/modules/admin-booking/handlers/create-admin-booking.handler.ts index 1c28b4b..9953fd4 100644 --- a/apps/backend/src/modules/admin-booking/handlers/create-admin-booking.handler.ts +++ b/apps/backend/src/modules/admin-booking/handlers/create-admin-booking.handler.ts @@ -1,24 +1,24 @@ -import type { CreateAdminBookingInput } from '@repo/api-contract' import { AdminBookingServiceError, createAdminBooking, -} from '@/modules/admin-booking/services/admin-booking.service' -import type { AppContext } from '@/types/hono' +} from '@/modules/admin-booking/services/admin-booking.service'; +import type { AppContext } from '@/types/hono'; +import type { CreateAdminBookingInput } from '@repo/api-contract'; -type ComplexIdParams = { complexId: string } +type ComplexIdParams = { complexId: string }; export async function createAdminBookingHandler(c: AppContext) { - const { complexId } = c.req.valid('param' as never) as ComplexIdParams - const payload = c.req.valid('json' as never) as CreateAdminBookingInput - const appUserId = c.get('appUserId') + const { complexId } = c.req.valid('param' as never) as ComplexIdParams; + const payload = c.req.valid('json' as never) as CreateAdminBookingInput; + const appUserId = c.get('appUserId'); try { - const booking = await createAdminBooking(appUserId, complexId, payload) - return c.json(booking, 201) + const booking = await createAdminBooking(appUserId, complexId, payload); + return c.json(booking, 201); } catch (error) { if (error instanceof AdminBookingServiceError) { - return c.json({ message: error.message }, error.status) + return c.json({ message: error.message }, error.status); } - throw error + throw error; } } diff --git a/apps/backend/src/modules/admin-booking/handlers/list-admin-bookings.handler.ts b/apps/backend/src/modules/admin-booking/handlers/list-admin-bookings.handler.ts index f5bcd66..85c486f 100644 --- a/apps/backend/src/modules/admin-booking/handlers/list-admin-bookings.handler.ts +++ b/apps/backend/src/modules/admin-booking/handlers/list-admin-bookings.handler.ts @@ -1,24 +1,24 @@ -import type { ListAdminBookingsQuery } from '@repo/api-contract' import { AdminBookingServiceError, listAdminBookings, -} from '@/modules/admin-booking/services/admin-booking.service' -import type { AppContext } from '@/types/hono' +} from '@/modules/admin-booking/services/admin-booking.service'; +import type { AppContext } from '@/types/hono'; +import type { ListAdminBookingsQuery } from '@repo/api-contract'; -type ComplexIdParams = { complexId: string } +type ComplexIdParams = { complexId: string }; export async function listAdminBookingsHandler(c: AppContext) { - const { complexId } = c.req.valid('param' as never) as ComplexIdParams - const query = c.req.valid('query' as never) as ListAdminBookingsQuery - const appUserId = c.get('appUserId') + const { complexId } = c.req.valid('param' as never) as ComplexIdParams; + const query = c.req.valid('query' as never) as ListAdminBookingsQuery; + const appUserId = c.get('appUserId'); try { - const response = await listAdminBookings(appUserId, complexId, query) - return c.json(response) + const response = await listAdminBookings(appUserId, complexId, query); + return c.json(response); } catch (error) { if (error instanceof AdminBookingServiceError) { - return c.json({ message: error.message }, error.status) + return c.json({ message: error.message }, error.status); } - throw error + throw error; } } diff --git a/apps/backend/src/modules/admin-booking/handlers/update-admin-booking-status.handler.ts b/apps/backend/src/modules/admin-booking/handlers/update-admin-booking-status.handler.ts index 32362f3..c943f9a 100644 --- a/apps/backend/src/modules/admin-booking/handlers/update-admin-booking-status.handler.ts +++ b/apps/backend/src/modules/admin-booking/handlers/update-admin-booking-status.handler.ts @@ -1,24 +1,24 @@ -import type { UpdateAdminBookingStatusInput } from '@repo/api-contract' import { AdminBookingServiceError, updateAdminBookingStatus, -} from '@/modules/admin-booking/services/admin-booking.service' -import type { AppContext } from '@/types/hono' +} from '@/modules/admin-booking/services/admin-booking.service'; +import type { AppContext } from '@/types/hono'; +import type { UpdateAdminBookingStatusInput } from '@repo/api-contract'; -type BookingIdParams = { id: string } +type BookingIdParams = { id: string }; export async function updateAdminBookingStatusHandler(c: AppContext) { - const { id } = c.req.valid('param' as never) as BookingIdParams - const payload = c.req.valid('json' as never) as UpdateAdminBookingStatusInput - const appUserId = c.get('appUserId') + const { id } = c.req.valid('param' as never) as BookingIdParams; + const payload = c.req.valid('json' as never) as UpdateAdminBookingStatusInput; + const appUserId = c.get('appUserId'); try { - const booking = await updateAdminBookingStatus(appUserId, id, payload) - return c.json(booking) + const booking = await updateAdminBookingStatus(appUserId, id, payload); + return c.json(booking); } catch (error) { if (error instanceof AdminBookingServiceError) { - return c.json({ message: error.message }, error.status) + return c.json({ message: error.message }, error.status); } - throw error + throw error; } } diff --git a/apps/backend/src/modules/admin-booking/services/admin-booking.service.ts b/apps/backend/src/modules/admin-booking/services/admin-booking.service.ts index 8f583dc..1fc4706 100644 --- a/apps/backend/src/modules/admin-booking/services/admin-booking.service.ts +++ b/apps/backend/src/modules/admin-booking/services/admin-booking.service.ts @@ -1,19 +1,19 @@ +import { randomInt } from 'node:crypto'; +import { CourtBookingStatus } from '@/generated/prisma/enums'; +import { db } from '@/lib/prisma'; +import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service'; import type { AdminBooking, CreateAdminBookingInput, ListAdminBookingsQuery, UpdateAdminBookingStatusInput, -} from '@repo/api-contract' -import { randomInt } from 'node:crypto' -import { v7 as uuidv7 } from 'uuid' -import { CourtBookingStatus } from '@/generated/prisma/enums' -import { db } from '@/lib/prisma' -import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service' +} from '@repo/api-contract'; +import { v7 as uuidv7 } from 'uuid'; type Slot = { - startTime: string - endTime: string -} + startTime: string; + endTime: string; +}; const DAY_OF_WEEK_BY_INDEX = [ 'SUNDAY', @@ -23,44 +23,44 @@ const DAY_OF_WEEK_BY_INDEX = [ 'THURSDAY', 'FRIDAY', 'SATURDAY', -] as const -const BOOKING_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' -const BOOKING_CODE_LENGTH = 6 +] as const; +const BOOKING_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; +const BOOKING_CODE_LENGTH = 6; export class AdminBookingServiceError extends Error { - status: 400 | 403 | 404 | 409 + status: 400 | 403 | 404 | 409; constructor(message: string, status: 400 | 403 | 404 | 409) { - super(message) - this.name = 'AdminBookingServiceError' - this.status = status + super(message); + this.name = 'AdminBookingServiceError'; + this.status = status; } } function toMinutes(value: string): number { - const [hours, minutes] = value.split(':').map((part) => Number(part)) - return hours * 60 + minutes + const [hours, minutes] = value.split(':').map((part) => Number(part)); + return hours * 60 + minutes; } function minutesToTime(minutes: number): string { - const safeMinutes = Math.max(0, minutes) - const hours = Math.floor(safeMinutes / 60) - const mins = safeMinutes % 60 - return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}` + const safeMinutes = Math.max(0, minutes); + const hours = Math.floor(safeMinutes / 60); + const mins = safeMinutes % 60; + return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`; } function parseIsoDate(date: string): Date { - const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date) + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date); if (!match) { - throw new AdminBookingServiceError('La fecha debe tener formato YYYY-MM-DD.', 400) + throw new AdminBookingServiceError('La fecha debe tener formato YYYY-MM-DD.', 400); } - const year = Number(match[1]) - const month = Number(match[2]) - const day = Number(match[3]) + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); - const bookingDate = new Date(Date.UTC(year, month - 1, day)) + const bookingDate = new Date(Date.UTC(year, month - 1, day)); if ( Number.isNaN(bookingDate.getTime()) || @@ -68,48 +68,48 @@ function parseIsoDate(date: string): Date { bookingDate.getUTCMonth() + 1 !== month || bookingDate.getUTCDate() !== day ) { - throw new AdminBookingServiceError('La fecha enviada no es valida.', 400) + throw new AdminBookingServiceError('La fecha enviada no es valida.', 400); } - return bookingDate + return bookingDate; } function getDayOfWeek(date: Date) { - const dayOfWeek = DAY_OF_WEEK_BY_INDEX[date.getUTCDay()] + const dayOfWeek = DAY_OF_WEEK_BY_INDEX[date.getUTCDay()]; if (!dayOfWeek) { - throw new AdminBookingServiceError('No se pudo resolver el dia de la semana.', 400) + throw new AdminBookingServiceError('No se pudo resolver el dia de la semana.', 400); } - return dayOfWeek + return dayOfWeek; } function formatIsoDate(date: Date): string { - return date.toISOString().slice(0, 10) + return date.toISOString().slice(0, 10); } function generateBookingCode(): string { - let code = '' + let code = ''; for (let index = 0; index < BOOKING_CODE_LENGTH; index += 1) { - code += BOOKING_CODE_ALPHABET[randomInt(0, BOOKING_CODE_ALPHABET.length)] + code += BOOKING_CODE_ALPHABET[randomInt(0, BOOKING_CODE_ALPHABET.length)]; } - return code + return code; } function buildSlots( availability: Array<{ - startTime: string - endTime: string + startTime: string; + endTime: string; }>, - slotDurationMinutes: number, + slotDurationMinutes: number ): Slot[] { - const slots: Slot[] = [] + const slots: Slot[] = []; for (const range of availability) { - const start = toMinutes(range.startTime) - const end = toMinutes(range.endTime) + const start = toMinutes(range.startTime); + const end = toMinutes(range.endTime); for ( let current = start; @@ -119,11 +119,11 @@ function buildSlots( slots.push({ startTime: minutesToTime(current), endTime: minutesToTime(current + slotDurationMinutes), - }) + }); } } - return slots + return slots; } async function ensureComplexAccess(complexId: string, appUserId: string) { @@ -147,39 +147,39 @@ async function ensureComplexAccess(complexId: string, appUserId: string) { }, }, }, - }) + }); if (!complexUser) { - throw new AdminBookingServiceError('No tienes permisos para administrar este complejo.', 403) + throw new AdminBookingServiceError('No tienes permisos para administrar este complejo.', 403); } - return complexUser.complex + return complexUser.complex; } function mapBookingResponse(booking: { - id: string - bookingCode: string - bookingDate: Date - startTime: string - endTime: string - customerName: string - customerPhone: string - status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' - createdAt: Date - updatedAt: Date + id: string; + bookingCode: string; + bookingDate: Date; + startTime: string; + endTime: string; + customerName: string; + customerPhone: string; + status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED'; + createdAt: Date; + updatedAt: Date; court: { - id: string - name: string + id: string; + name: string; complex: { - id: string - complexName: string - } + id: string; + complexName: string; + }; sport: { - id: string - name: string - slug: string - } - } + id: string; + name: string; + slug: string; + }; + }; }): AdminBooking { return { id: booking.id, @@ -201,16 +201,16 @@ function mapBookingResponse(booking: { status: booking.status, createdAt: booking.createdAt.toISOString(), updatedAt: booking.updatedAt.toISOString(), - } + }; } export async function listAdminBookings( appUserId: string, complexId: string, - query: ListAdminBookingsQuery, + query: ListAdminBookingsQuery ) { - await ensureComplexAccess(complexId, appUserId) - const fromDate = parseIsoDate(query.fromDate) + await ensureComplexAccess(complexId, appUserId); + const fromDate = parseIsoDate(query.fromDate); const bookings = await db.courtBooking.findMany({ where: { @@ -243,21 +243,21 @@ export async function listAdminBookings( }, }, orderBy: [{ bookingDate: 'asc' }, { startTime: 'asc' }, { createdAt: 'asc' }], - }) + }); return { bookings: bookings.map((booking) => mapBookingResponse(booking)), - } + }; } export async function createAdminBooking( appUserId: string, complexId: string, - input: CreateAdminBookingInput, + input: CreateAdminBookingInput ) { - const complex = await ensureComplexAccess(complexId, appUserId) - const bookingDate = parseIsoDate(input.date) - const dayOfWeek = getDayOfWeek(bookingDate) + const complex = await ensureComplexAccess(complexId, appUserId); + const bookingDate = parseIsoDate(input.date); + const dayOfWeek = getDayOfWeek(bookingDate); const court = await db.court.findFirst({ where: { @@ -281,36 +281,36 @@ export async function createAdminBooking( }, }, }, - }) + }); if (!court) { - throw new AdminBookingServiceError('La cancha seleccionada no existe en el complejo.', 404) + throw new AdminBookingServiceError('La cancha seleccionada no existe en el complejo.', 404); } - const validSlots = buildSlots(court.availabilities, court.slotDurationMinutes) - const selectedStartMinutes = toMinutes(input.startTime) - const selectedEndMinutes = selectedStartMinutes + court.slotDurationMinutes + const validSlots = buildSlots(court.availabilities, court.slotDurationMinutes); + const selectedStartMinutes = toMinutes(input.startTime); + const selectedEndMinutes = selectedStartMinutes + court.slotDurationMinutes; const selectedSlot = { startTime: input.startTime, endTime: minutesToTime(selectedEndMinutes), - } + }; const slotExists = validSlots.some( - (slot) => slot.startTime === selectedSlot.startTime && slot.endTime === selectedSlot.endTime, - ) + (slot) => slot.startTime === selectedSlot.startTime && slot.endTime === selectedSlot.endTime + ); if (!slotExists) { throw new AdminBookingServiceError( 'El horario seleccionado no esta disponible para esa cancha.', - 409, - ) + 409 + ); } for (let attempt = 0; attempt < 5; attempt += 1) { try { const booking = await db.$transaction(async (tx) => { if (complex.plan) { - const rules = parsePlanRules(complex.plan.rules) + const rules = parsePlanRules(complex.plan.rules); const bookingsForDate = await tx.courtBooking.count({ where: { bookingDate, @@ -319,25 +319,25 @@ export async function createAdminBooking( complexId, }, }, - }) + }); const courtsCount = await tx.court.count({ where: { complexId, }, - }) + }); const violations = evaluatePlanUsage(rules, { courtsCount, bookingsToday: bookingsForDate, - }) + }); const maxBookingsViolation = violations.find( - (violation) => violation.code === 'MAX_BOOKINGS_PER_DAY_REACHED', - ) + (violation) => violation.code === 'MAX_BOOKINGS_PER_DAY_REACHED' + ); if (maxBookingsViolation) { - throw new AdminBookingServiceError(maxBookingsViolation.message, 409) + throw new AdminBookingServiceError(maxBookingsViolation.message, 409); } } @@ -353,10 +353,10 @@ export async function createAdminBooking( gt: selectedSlot.startTime, }, }, - }) + }); if (overlappingBooking) { - throw new AdminBookingServiceError('El horario seleccionado ya fue reservado.', 409) + throw new AdminBookingServiceError('El horario seleccionado ya fue reservado.', 409); } return tx.courtBooking.create({ @@ -392,51 +392,51 @@ export async function createAdminBooking( }, }, }, - }) - }) + }); + }); - return mapBookingResponse(booking) + return mapBookingResponse(booking); } catch (error) { if (error instanceof AdminBookingServiceError) { - throw error + throw error; } const prismaError = error as { - code?: string + code?: string; meta?: { - target?: string[] | string - } - } + target?: string[] | string; + }; + }; if (prismaError.code === 'P2002') { const targets = Array.isArray(prismaError.meta?.target) ? prismaError.meta?.target - : [prismaError.meta?.target] + : [prismaError.meta?.target]; const isBookingCodeCollision = targets.some((target) => - String(target).includes('booking_code'), - ) + String(target).includes('booking_code') + ); if (isBookingCodeCollision) { - continue + continue; } - throw new AdminBookingServiceError('El horario seleccionado ya fue reservado.', 409) + throw new AdminBookingServiceError('El horario seleccionado ya fue reservado.', 409); } - throw error + throw error; } } throw new AdminBookingServiceError( 'No se pudo generar un codigo de reserva unico. Intenta nuevamente.', - 409, - ) + 409 + ); } export async function updateAdminBookingStatus( appUserId: string, bookingId: string, - input: UpdateAdminBookingStatusInput, + input: UpdateAdminBookingStatusInput ) { const booking = await db.courtBooking.findFirst({ where: { @@ -472,33 +472,31 @@ export async function updateAdminBookingStatus( }, }, }, - }) + }); if (!booking) { - throw new AdminBookingServiceError('Reserva no encontrada.', 404) + throw new AdminBookingServiceError('Reserva no encontrada.', 404); } if (input.status === 'CANCELLED' && booking.status !== 'CONFIRMED') { throw new AdminBookingServiceError( 'Solo se pueden cancelar reservas en estado confirmada.', - 409, - ) + 409 + ); } if (input.status === 'COMPLETED' && booking.status !== 'CONFIRMED') { throw new AdminBookingServiceError( 'Solo se pueden marcar como cumplidas las reservas confirmadas.', - 409, - ) + 409 + ); } const updated = await db.courtBooking.update({ where: { id: booking.id }, data: { status: - input.status === 'COMPLETED' - ? CourtBookingStatus.COMPLETED - : CourtBookingStatus.CANCELLED, + input.status === 'COMPLETED' ? CourtBookingStatus.COMPLETED : CourtBookingStatus.CANCELLED, }, include: { court: { @@ -521,7 +519,7 @@ export async function updateAdminBookingStatus( }, }, }, - }) + }); - return mapBookingResponse(updated) + return mapBookingResponse(updated); } diff --git a/apps/backend/src/modules/complex/complex.routes.ts b/apps/backend/src/modules/complex/complex.routes.ts index ac3946d..af9581c 100644 --- a/apps/backend/src/modules/complex/complex.routes.ts +++ b/apps/backend/src/modules/complex/complex.routes.ts @@ -1,37 +1,33 @@ -import { zValidator } from '@hono/zod-validator' -import { createComplexSchema, updateComplexSchema } from '@repo/api-contract' -import { Hono } from 'hono' -import { z } from 'zod' -import { requireAuth } from '@/middlewares/require-auth.middleware' -import { createComplexHandler } from '@/modules/complex/handlers/create-complex.handler' -import { getComplexByIdHandler } from '@/modules/complex/handlers/get-complex-by-id.handler' -import { getComplexBySlugHandler } from '@/modules/complex/handlers/get-complex-by-slug.handler' -import { listMyComplexesHandler } from '@/modules/complex/handlers/list-my-complexes.handler' -import { updateComplexHandler } from '@/modules/complex/handlers/update-complex.handler' -import type { AppEnv } from '@/types/hono' +import { requireAuth } from '@/middlewares/require-auth.middleware'; +import { createComplexHandler } from '@/modules/complex/handlers/create-complex.handler'; +import { getComplexByIdHandler } from '@/modules/complex/handlers/get-complex-by-id.handler'; +import { getComplexBySlugHandler } from '@/modules/complex/handlers/get-complex-by-slug.handler'; +import { listMyComplexesHandler } from '@/modules/complex/handlers/list-my-complexes.handler'; +import { updateComplexHandler } from '@/modules/complex/handlers/update-complex.handler'; +import type { AppEnv } from '@/types/hono'; +import { zValidator } from '@hono/zod-validator'; +import { createComplexSchema, updateComplexSchema } from '@repo/api-contract'; +import { Hono } from 'hono'; +import { z } from 'zod'; -export const complexRoutes = new Hono() -const complexIdParamsSchema = z.object({ id: z.uuid() }) -const complexSlugParamsSchema = z.object({ slug: z.string().trim().min(1) }) +export const complexRoutes = new Hono(); +const complexIdParamsSchema = z.object({ id: z.uuid() }); +const complexSlugParamsSchema = z.object({ slug: z.string().trim().min(1) }); -complexRoutes.use('*', requireAuth) +complexRoutes.use('*', requireAuth); -complexRoutes.post('/', zValidator('json', createComplexSchema), createComplexHandler) -complexRoutes.get('/mine', listMyComplexesHandler) +complexRoutes.post('/', zValidator('json', createComplexSchema), createComplexHandler); +complexRoutes.get('/mine', listMyComplexesHandler); complexRoutes.get( '/slug/:slug', zValidator('param', complexSlugParamsSchema), - getComplexBySlugHandler, -) + getComplexBySlugHandler +); -complexRoutes.get( - '/:id', - zValidator('param', complexIdParamsSchema), - getComplexByIdHandler, -) +complexRoutes.get('/:id', zValidator('param', complexIdParamsSchema), getComplexByIdHandler); complexRoutes.patch( '/:id', zValidator('param', complexIdParamsSchema), zValidator('json', updateComplexSchema), - updateComplexHandler, -) + updateComplexHandler +); 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 3f90ba7..6977264 100644 --- a/apps/backend/src/modules/complex/handlers/create-complex.handler.ts +++ b/apps/backend/src/modules/complex/handlers/create-complex.handler.ts @@ -1,15 +1,15 @@ -import type { CreateComplexInput } from '@repo/api-contract' -import type { AppContext } from '@/types/hono' -import { createComplex } from '@/modules/complex/services/complex.service' +import { createComplex } from '@/modules/complex/services/complex.service'; +import type { AppContext } from '@/types/hono'; +import type { CreateComplexInput } from '@repo/api-contract'; export async function createComplexHandler(c: AppContext) { - const payload = c.req.valid('json' as never) as CreateComplexInput + const payload = c.req.valid('json' as never) as CreateComplexInput; - const authUser = c.get('authUser') - const adminEmail = authUser.email + const authUser = c.get('authUser'); + const adminEmail = authUser.email; if (!adminEmail) { - return c.json({ message: 'El usuario autenticado no tiene email.' }, 400) + return c.json({ message: 'El usuario autenticado no tiene email.' }, 400); } const complex = await createComplex({ @@ -20,7 +20,7 @@ export async function createComplexHandler(c: AppContext) { city: payload.city, state: payload.state, country: payload.country, - }) + }); - return c.json(complex, 201) + return c.json(complex, 201); } diff --git a/apps/backend/src/modules/complex/handlers/get-complex-by-id.handler.ts b/apps/backend/src/modules/complex/handlers/get-complex-by-id.handler.ts index 43b07fe..4df8248 100644 --- a/apps/backend/src/modules/complex/handlers/get-complex-by-id.handler.ts +++ b/apps/backend/src/modules/complex/handlers/get-complex-by-id.handler.ts @@ -1,16 +1,16 @@ -import type { AppContext } from '@/types/hono' -import { getComplexById } from '@/modules/complex/services/complex.service' +import { getComplexById } from '@/modules/complex/services/complex.service'; +import type { AppContext } from '@/types/hono'; -type ComplexIdParams = { id: string } +type ComplexIdParams = { id: string }; export async function getComplexByIdHandler(c: AppContext) { - const { id } = c.req.valid('param' as never) as ComplexIdParams + const { id } = c.req.valid('param' as never) as ComplexIdParams; - const complex = await getComplexById(id) + const complex = await getComplexById(id); if (!complex) { - return c.json({ message: 'Complejo no encontrado.' }, 404) + return c.json({ message: 'Complejo no encontrado.' }, 404); } - return c.json(complex) + return c.json(complex); } diff --git a/apps/backend/src/modules/complex/handlers/get-complex-by-slug.handler.ts b/apps/backend/src/modules/complex/handlers/get-complex-by-slug.handler.ts index 22514d8..7990f6f 100644 --- a/apps/backend/src/modules/complex/handlers/get-complex-by-slug.handler.ts +++ b/apps/backend/src/modules/complex/handlers/get-complex-by-slug.handler.ts @@ -1,16 +1,16 @@ -import type { AppContext } from '@/types/hono' -import { getComplexBySlug } from '@/modules/complex/services/complex.service' +import { getComplexBySlug } from '@/modules/complex/services/complex.service'; +import type { AppContext } from '@/types/hono'; -type ComplexSlugParams = { slug: string } +type ComplexSlugParams = { slug: string }; export async function getComplexBySlugHandler(c: AppContext) { - const { slug } = c.req.valid('param' as never) as ComplexSlugParams + const { slug } = c.req.valid('param' as never) as ComplexSlugParams; - const complex = await getComplexBySlug(slug) + const complex = await getComplexBySlug(slug); if (!complex) { - return c.json({ message: 'Complejo no encontrado.' }, 404) + return c.json({ message: 'Complejo no encontrado.' }, 404); } - return c.json(complex) + return c.json(complex); } diff --git a/apps/backend/src/modules/complex/handlers/list-my-complexes.handler.ts b/apps/backend/src/modules/complex/handlers/list-my-complexes.handler.ts index 744f192..1760b6c 100644 --- a/apps/backend/src/modules/complex/handlers/list-my-complexes.handler.ts +++ b/apps/backend/src/modules/complex/handlers/list-my-complexes.handler.ts @@ -1,8 +1,8 @@ -import type { AppContext } from '@/types/hono' -import { listMyComplexes } from '@/modules/complex/services/complex.service' +import { listMyComplexes } from '@/modules/complex/services/complex.service'; +import type { AppContext } from '@/types/hono'; export async function listMyComplexesHandler(c: AppContext) { - const appUserId = c.get('appUserId') - const complexes = await listMyComplexes(appUserId) - return c.json(complexes) + const appUserId = c.get('appUserId'); + const complexes = await listMyComplexes(appUserId); + return c.json(complexes); } diff --git a/apps/backend/src/modules/complex/handlers/update-complex.handler.ts b/apps/backend/src/modules/complex/handlers/update-complex.handler.ts index c699f6e..42f9e2e 100644 --- a/apps/backend/src/modules/complex/handlers/update-complex.handler.ts +++ b/apps/backend/src/modules/complex/handlers/update-complex.handler.ts @@ -1,27 +1,27 @@ -import type { UpdateComplexInput } from '@repo/api-contract' -import { Prisma } from '@/generated/prisma/client' -import type { AppContext } from '@/types/hono' -import { getComplexById, updateComplex } from '@/modules/complex/services/complex.service' +import { Prisma } from '@/generated/prisma/client'; +import { getComplexById, updateComplex } from '@/modules/complex/services/complex.service'; +import type { AppContext } from '@/types/hono'; +import type { UpdateComplexInput } from '@repo/api-contract'; -type ComplexIdParams = { id: string } +type ComplexIdParams = { id: string }; export async function updateComplexHandler(c: AppContext) { - const { id } = c.req.valid('param' as never) as ComplexIdParams - const payload = c.req.valid('json' as never) as UpdateComplexInput + const { id } = c.req.valid('param' as never) as ComplexIdParams; + const payload = c.req.valid('json' as never) as UpdateComplexInput; - const existing = await getComplexById(id) + const existing = await getComplexById(id); if (!existing) { - return c.json({ message: 'Complejo no encontrado.' }, 404) + return c.json({ message: 'Complejo no encontrado.' }, 404); } try { - const complex = await updateComplex(id, payload) - return c.json(complex) + const complex = await updateComplex(id, payload); + return c.json(complex); } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { - return c.json({ message: 'No se pudo actualizar el complejo.' }, 409) + return c.json({ message: 'No se pudo actualizar el complejo.' }, 409); } - throw error + throw error; } } diff --git a/apps/backend/src/modules/complex/services/complex.service.ts b/apps/backend/src/modules/complex/services/complex.service.ts index 7f89397..4a74015 100644 --- a/apps/backend/src/modules/complex/services/complex.service.ts +++ b/apps/backend/src/modules/complex/services/complex.service.ts @@ -1,27 +1,27 @@ -import { v7 as uuidv7 } from 'uuid' -import { Prisma } from '@/generated/prisma/client' -import { db } from '@/lib/prisma' +import { Prisma } from '@/generated/prisma/client'; +import { db } from '@/lib/prisma'; +import { v7 as uuidv7 } from 'uuid'; export type CreateComplexInput = { - complexName: string - physicalAddress: string - adminEmail: string - planCode?: string - city?: string - state?: string - country?: string -} + complexName: string; + physicalAddress: string; + adminEmail: string; + planCode?: string; + city?: string; + state?: string; + country?: string; +}; export type UpdateComplexInput = { - complexName?: string - physicalAddress?: string | null - complexSlug?: string - adminEmail?: string - planCode?: string | null - city?: string | null - state?: string | null - country?: string | null -} + complexName?: string; + physicalAddress?: string | null; + complexSlug?: string; + adminEmail?: string; + planCode?: string | null; + city?: string | null; + state?: string | null; + country?: string | null; +}; function slugify(value: string): string { return value @@ -31,18 +31,15 @@ function slugify(value: string): string { .trim() .replace(/[^a-z0-9\s-]/g, '') .replace(/\s+/g, '-') - .replace(/-+/g, '-') + .replace(/-+/g, '-'); } -async function buildUniqueSlug( - source: string, - excludeComplexId?: string, -): Promise { - const base = slugify(source) - const fallback = base.length > 0 ? base : `complex-${uuidv7().slice(0, 8)}` +async function buildUniqueSlug(source: string, excludeComplexId?: string): Promise { + const base = slugify(source); + const fallback = base.length > 0 ? base : `complex-${uuidv7().slice(0, 8)}`; - let candidate = fallback - let index = 1 + let candidate = fallback; + let index = 1; while (true) { const existing = await db.complex.findFirst({ @@ -57,17 +54,17 @@ async function buildUniqueSlug( : {}), }, select: { id: true }, - }) + }); - if (!existing) return candidate + if (!existing) return candidate; - index += 1 - candidate = `${fallback}-${index}` + index += 1; + candidate = `${fallback}-${index}`; } } export async function createComplex(input: CreateComplexInput) { - const complexSlug = await buildUniqueSlug(input.complexName) + const complexSlug = await buildUniqueSlug(input.complexName); return db.complex.create({ data: { @@ -81,19 +78,19 @@ export async function createComplex(input: CreateComplexInput) { adminEmail: input.adminEmail, planCode: input.planCode, }, - }) + }); } export async function getComplexById(id: string) { return db.complex.findUnique({ where: { id }, - }) + }); } export async function getComplexBySlug(slug: string) { return db.complex.findUnique({ where: { complexSlug: slug }, - }) + }); } export async function listMyComplexes(appUserId: string) { @@ -105,49 +102,49 @@ export async function listMyComplexes(appUserId: string) { orderBy: { createdAt: 'asc', }, - }) + }); - return complexUsers.map((complexUser) => complexUser.complex) + return complexUsers.map((complexUser) => complexUser.complex); } export async function updateComplex(id: string, input: UpdateComplexInput) { - const data: Record = {} + const data: Record = {}; if (input.complexName) { - data.complexName = input.complexName - data.complexSlug = await buildUniqueSlug(input.complexName, id) + data.complexName = input.complexName; + data.complexSlug = await buildUniqueSlug(input.complexName, id); } if (input.complexSlug) { - data.complexSlug = await buildUniqueSlug(input.complexSlug, id) + data.complexSlug = await buildUniqueSlug(input.complexSlug, id); } if (input.adminEmail) { - data.adminEmail = input.adminEmail + data.adminEmail = input.adminEmail; } if (input.planCode !== undefined) { - data.planCode = input.planCode + data.planCode = input.planCode; } if (input.physicalAddress !== undefined) { - data.physicalAddress = input.physicalAddress?.trim() ?? null + data.physicalAddress = input.physicalAddress?.trim() ?? null; } if (input.city !== undefined) { - data.city = input.city?.trim() ?? null + data.city = input.city?.trim() ?? null; } if (input.state !== undefined) { - data.state = input.state?.trim() ?? null + data.state = input.state?.trim() ?? null; } if (input.country !== undefined) { - data.country = input.country?.trim() ?? null + data.country = input.country?.trim() ?? null; } return db.complex.update({ where: { id }, data: data as Prisma.ComplexUncheckedUpdateInput, - }) + }); } diff --git a/apps/backend/src/modules/court/court.routes.ts b/apps/backend/src/modules/court/court.routes.ts index afff7ff..0d63c58 100644 --- a/apps/backend/src/modules/court/court.routes.ts +++ b/apps/backend/src/modules/court/court.routes.ts @@ -1,33 +1,33 @@ -import { zValidator } from '@hono/zod-validator' -import { createCourtSchema, updateCourtSchema } from '@repo/api-contract' -import { Hono } from 'hono' -import { z } from 'zod' -import { requireAuth } from '@/middlewares/require-auth.middleware' -import { createCourtHandler } from '@/modules/court/handlers/create-court.handler' -import { listCourtsByComplexHandler } from '@/modules/court/handlers/list-courts-by-complex.handler' -import { updateCourtHandler } from '@/modules/court/handlers/update-court.handler' -import type { AppEnv } from '@/types/hono' +import { requireAuth } from '@/middlewares/require-auth.middleware'; +import { createCourtHandler } from '@/modules/court/handlers/create-court.handler'; +import { listCourtsByComplexHandler } from '@/modules/court/handlers/list-courts-by-complex.handler'; +import { updateCourtHandler } from '@/modules/court/handlers/update-court.handler'; +import type { AppEnv } from '@/types/hono'; +import { zValidator } from '@hono/zod-validator'; +import { createCourtSchema, updateCourtSchema } from '@repo/api-contract'; +import { Hono } from 'hono'; +import { z } from 'zod'; -export const courtRoutes = new Hono() -const complexIdParamsSchema = z.object({ complexId: z.uuid() }) -const courtIdParamsSchema = z.object({ id: z.uuid() }) +export const courtRoutes = new Hono(); +const complexIdParamsSchema = z.object({ complexId: z.uuid() }); +const courtIdParamsSchema = z.object({ id: z.uuid() }); -courtRoutes.use('*', requireAuth) +courtRoutes.use('*', requireAuth); courtRoutes.get( '/complex/:complexId', zValidator('param', complexIdParamsSchema), - listCourtsByComplexHandler, -) + listCourtsByComplexHandler +); courtRoutes.post( '/complex/:complexId', zValidator('param', complexIdParamsSchema), zValidator('json', createCourtSchema), - createCourtHandler, -) + createCourtHandler +); courtRoutes.patch( '/:id', zValidator('param', courtIdParamsSchema), zValidator('json', updateCourtSchema), - updateCourtHandler, -) + updateCourtHandler +); diff --git a/apps/backend/src/modules/court/handlers/create-court.handler.ts b/apps/backend/src/modules/court/handlers/create-court.handler.ts index 850fa7d..b910522 100644 --- a/apps/backend/src/modules/court/handlers/create-court.handler.ts +++ b/apps/backend/src/modules/court/handlers/create-court.handler.ts @@ -1,21 +1,21 @@ -import type { CreateCourtInput } from '@repo/api-contract' -import { CourtServiceError, createCourt } from '@/modules/court/services/court.service' -import type { AppContext } from '@/types/hono' +import { CourtServiceError, createCourt } from '@/modules/court/services/court.service'; +import type { AppContext } from '@/types/hono'; +import type { CreateCourtInput } from '@repo/api-contract'; -type ComplexIdParams = { complexId: string } +type ComplexIdParams = { complexId: string }; export async function createCourtHandler(c: AppContext) { - const { complexId } = c.req.valid('param' as never) as ComplexIdParams - const payload = c.req.valid('json' as never) as CreateCourtInput - const appUserId = c.get('appUserId') + const { complexId } = c.req.valid('param' as never) as ComplexIdParams; + const payload = c.req.valid('json' as never) as CreateCourtInput; + const appUserId = c.get('appUserId'); try { - const court = await createCourt(appUserId, complexId, payload) - return c.json(court, 201) + const court = await createCourt(appUserId, complexId, payload); + return c.json(court, 201); } catch (error) { if (error instanceof CourtServiceError) { - return c.json({ message: error.message }, error.status) + return c.json({ message: error.message }, error.status); } - throw error + throw error; } } diff --git a/apps/backend/src/modules/court/handlers/list-courts-by-complex.handler.ts b/apps/backend/src/modules/court/handlers/list-courts-by-complex.handler.ts index 19d5fe8..c436103 100644 --- a/apps/backend/src/modules/court/handlers/list-courts-by-complex.handler.ts +++ b/apps/backend/src/modules/court/handlers/list-courts-by-complex.handler.ts @@ -1,19 +1,19 @@ -import { CourtServiceError, listCourtsByComplex } from '@/modules/court/services/court.service' -import type { AppContext } from '@/types/hono' +import { CourtServiceError, listCourtsByComplex } from '@/modules/court/services/court.service'; +import type { AppContext } from '@/types/hono'; -type ComplexIdParams = { complexId: string } +type ComplexIdParams = { complexId: string }; export async function listCourtsByComplexHandler(c: AppContext) { - const { complexId } = c.req.valid('param' as never) as ComplexIdParams - const appUserId = c.get('appUserId') + const { complexId } = c.req.valid('param' as never) as ComplexIdParams; + const appUserId = c.get('appUserId'); try { - const courts = await listCourtsByComplex(complexId, appUserId) - return c.json(courts) + const courts = await listCourtsByComplex(complexId, appUserId); + return c.json(courts); } catch (error) { if (error instanceof CourtServiceError) { - return c.json({ message: error.message }, error.status) + return c.json({ message: error.message }, error.status); } - throw error + throw error; } } diff --git a/apps/backend/src/modules/court/handlers/update-court.handler.ts b/apps/backend/src/modules/court/handlers/update-court.handler.ts index 466065a..6ff5784 100644 --- a/apps/backend/src/modules/court/handlers/update-court.handler.ts +++ b/apps/backend/src/modules/court/handlers/update-court.handler.ts @@ -1,21 +1,21 @@ -import type { UpdateCourtInput } from '@repo/api-contract' -import { CourtServiceError, updateCourt } from '@/modules/court/services/court.service' -import type { AppContext } from '@/types/hono' +import { CourtServiceError, updateCourt } from '@/modules/court/services/court.service'; +import type { AppContext } from '@/types/hono'; +import type { UpdateCourtInput } from '@repo/api-contract'; -type CourtIdParams = { id: string } +type CourtIdParams = { id: string }; export async function updateCourtHandler(c: AppContext) { - const { id } = c.req.valid('param' as never) as CourtIdParams - const payload = c.req.valid('json' as never) as UpdateCourtInput - const appUserId = c.get('appUserId') + const { id } = c.req.valid('param' as never) as CourtIdParams; + const payload = c.req.valid('json' as never) as UpdateCourtInput; + const appUserId = c.get('appUserId'); try { - const court = await updateCourt(appUserId, id, payload) - return c.json(court) + const court = await updateCourt(appUserId, id, payload); + return c.json(court); } catch (error) { if (error instanceof CourtServiceError) { - return c.json({ message: error.message }, error.status) + return c.json({ message: error.message }, error.status); } - throw error + throw error; } } diff --git a/apps/backend/src/modules/court/services/court.service.ts b/apps/backend/src/modules/court/services/court.service.ts index 290f993..f8b02af 100644 --- a/apps/backend/src/modules/court/services/court.service.ts +++ b/apps/backend/src/modules/court/services/court.service.ts @@ -1,72 +1,61 @@ -import type { CreateCourtInput, DayOfWeek, UpdateCourtInput } from '@repo/api-contract' -import { v7 as uuidv7 } from 'uuid' -import type { - Court, - CourtAvailability, - CourtPriceRule, - Sport, -} from '@/generated/prisma/client' -import { db } from '@/lib/prisma' -import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service' +import type { Court, CourtAvailability, CourtPriceRule, Sport } from '@/generated/prisma/client'; +import { db } from '@/lib/prisma'; +import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service'; +import type { CreateCourtInput, DayOfWeek, UpdateCourtInput } from '@repo/api-contract'; +import { v7 as uuidv7 } from 'uuid'; type CourtWithRelations = Court & { - sport: Sport - availabilities: CourtAvailability[] - priceRules: CourtPriceRule[] -} + sport: Sport; + availabilities: CourtAvailability[]; + priceRules: CourtPriceRule[]; +}; export class CourtServiceError extends Error { - status: 400 | 403 | 404 | 409 + status: 400 | 403 | 404 | 409; constructor(message: string, status: 400 | 403 | 404 | 409) { - super(message) - this.name = 'CourtServiceError' - this.status = status + super(message); + this.name = 'CourtServiceError'; + this.status = status; } } function toMinutes(value: string): number { - const [hours, minutes] = value.split(':').map((part) => Number(part)) - return hours * 60 + minutes + const [hours, minutes] = value.split(':').map((part) => Number(part)); + return hours * 60 + minutes; } function assertAvailabilityRanges( availability: Array<{ - dayOfWeek: DayOfWeek - startTime: string - endTime: string - }>, + dayOfWeek: DayOfWeek; + startTime: string; + endTime: string; + }> ) { - const grouped = new Map>() + const grouped = new Map>(); for (const range of availability) { - const start = toMinutes(range.startTime) - const end = toMinutes(range.endTime) + const start = toMinutes(range.startTime); + const end = toMinutes(range.endTime); if (start >= end) { - throw new CourtServiceError( - `El rango ${range.startTime}-${range.endTime} es invalido.`, - 400, - ) + throw new CourtServiceError(`El rango ${range.startTime}-${range.endTime} es invalido.`, 400); } - const current = grouped.get(range.dayOfWeek) ?? [] - current.push({ start, end }) - grouped.set(range.dayOfWeek, current) + const current = grouped.get(range.dayOfWeek) ?? []; + current.push({ start, end }); + grouped.set(range.dayOfWeek, current); } for (const ranges of grouped.values()) { - ranges.sort((a, b) => a.start - b.start) + ranges.sort((a, b) => a.start - b.start); for (let index = 1; index < ranges.length; index += 1) { - const previous = ranges[index - 1] - const current = ranges[index] + const previous = ranges[index - 1]; + const current = ranges[index]; if (previous.end > current.start) { - throw new CourtServiceError( - 'Hay rangos horarios superpuestos para el mismo dia.', - 400, - ) + throw new CourtServiceError('Hay rangos horarios superpuestos para el mismo dia.', 400); } } } @@ -92,16 +81,13 @@ async function ensureComplexAccess(complexId: string, appUserId: string) { }, }, }, - }) + }); if (!complexUser) { - throw new CourtServiceError( - 'No tienes permisos para administrar este complejo.', - 403, - ) + throw new CourtServiceError('No tienes permisos para administrar este complejo.', 403); } - return complexUser.complex + return complexUser.complex; } async function ensureActiveSport(sportId: string) { @@ -111,10 +97,10 @@ async function ensureActiveSport(sportId: string) { isActive: true, }, select: { id: true }, - }) + }); if (!sport) { - throw new CourtServiceError('El deporte seleccionado no existe o esta inactivo.', 400) + throw new CourtServiceError('El deporte seleccionado no existe o esta inactivo.', 400); } } @@ -128,28 +114,26 @@ async function enforcePlanCourtLimit(complexId: string) { }, }, }, - }) + }); if (!complex?.plan) { - return + return; } - const rules = parsePlanRules(complex.plan.rules) + const rules = parsePlanRules(complex.plan.rules); const courtsCount = await db.court.count({ where: { complexId }, - }) + }); const violations = evaluatePlanUsage(rules, { courtsCount, bookingsToday: 0, - }) + }); - const maxCourtViolation = violations.find( - (violation) => violation.code === 'MAX_COURTS_REACHED', - ) + const maxCourtViolation = violations.find((violation) => violation.code === 'MAX_COURTS_REACHED'); if (maxCourtViolation) { - throw new CourtServiceError(maxCourtViolation.message, 409) + throw new CourtServiceError(maxCourtViolation.message, 409); } } @@ -175,7 +159,7 @@ async function getCourtByIdForUser(courtId: string, appUserId: string) { orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }], }, }, - }) + }); } function mapCourtResponse(court: CourtWithRelations) { @@ -208,11 +192,11 @@ function mapCourtResponse(court: CourtWithRelations) { hasCustomPricing: court.priceRules.length > 0, createdAt: court.createdAt.toISOString(), updatedAt: court.updatedAt.toISOString(), - } + }; } export async function listCourtsByComplex(complexId: string, appUserId: string) { - await ensureComplexAccess(complexId, appUserId) + await ensureComplexAccess(complexId, appUserId); const courts = await db.court.findMany({ where: { complexId }, @@ -229,20 +213,16 @@ export async function listCourtsByComplex(complexId: string, appUserId: string) orderBy: { createdAt: 'asc', }, - }) + }); - return courts.map((court) => mapCourtResponse(court)) + return courts.map((court) => mapCourtResponse(court)); } -export async function createCourt( - appUserId: string, - complexId: string, - input: CreateCourtInput, -) { - await ensureComplexAccess(complexId, appUserId) - await ensureActiveSport(input.sportId) - await enforcePlanCourtLimit(complexId) - assertAvailabilityRanges(input.availability) +export async function createCourt(appUserId: string, complexId: string, input: CreateCourtInput) { + await ensureComplexAccess(complexId, appUserId); + await ensureActiveSport(input.sportId); + await enforcePlanCourtLimit(complexId); + assertAvailabilityRanges(input.availability); const createdCourt = await db.$transaction(async (tx) => { const court = await tx.court.create({ @@ -254,7 +234,7 @@ export async function createCourt( slotDurationMinutes: input.slotDurationMinutes, basePrice: input.basePrice, }, - }) + }); await tx.courtAvailability.createMany({ data: input.availability.map((availability) => ({ @@ -264,37 +244,33 @@ export async function createCourt( startTime: availability.startTime, endTime: availability.endTime, })), - }) + }); - return court - }) + return court; + }); - const court = await getCourtByIdForUser(createdCourt.id, appUserId) + const court = await getCourtByIdForUser(createdCourt.id, appUserId); if (!court) { - throw new CourtServiceError('No se pudo obtener la cancha creada.', 404) + throw new CourtServiceError('No se pudo obtener la cancha creada.', 404); } - return mapCourtResponse(court) + return mapCourtResponse(court); } -export async function updateCourt( - appUserId: string, - courtId: string, - input: UpdateCourtInput, -) { - const existingCourt = await getCourtByIdForUser(courtId, appUserId) +export async function updateCourt(appUserId: string, courtId: string, input: UpdateCourtInput) { + const existingCourt = await getCourtByIdForUser(courtId, appUserId); if (!existingCourt) { - throw new CourtServiceError('Cancha no encontrada.', 404) + throw new CourtServiceError('Cancha no encontrada.', 404); } if (input.sportId) { - await ensureActiveSport(input.sportId) + await ensureActiveSport(input.sportId); } if (input.availability) { - assertAvailabilityRanges(input.availability) + assertAvailabilityRanges(input.availability); } await db.$transaction(async (tx) => { @@ -310,12 +286,12 @@ export async function updateCourt( : {}), ...(input.basePrice !== undefined ? { basePrice: input.basePrice } : {}), }, - }) + }); if (input.availability) { await tx.courtAvailability.deleteMany({ where: { courtId }, - }) + }); await tx.courtAvailability.createMany({ data: input.availability.map((availability) => ({ @@ -325,15 +301,15 @@ export async function updateCourt( startTime: availability.startTime, endTime: availability.endTime, })), - }) + }); } - }) + }); - const updatedCourt = await getCourtByIdForUser(courtId, appUserId) + const updatedCourt = await getCourtByIdForUser(courtId, appUserId); if (!updatedCourt) { - throw new CourtServiceError('Cancha no encontrada.', 404) + throw new CourtServiceError('Cancha no encontrada.', 404); } - return mapCourtResponse(updatedCourt) + return mapCourtResponse(updatedCourt); } diff --git a/apps/backend/src/modules/health-check/health-check.routes.ts b/apps/backend/src/modules/health-check/health-check.routes.ts index 2142a86..33222f5 100644 --- a/apps/backend/src/modules/health-check/health-check.routes.ts +++ b/apps/backend/src/modules/health-check/health-check.routes.ts @@ -1,12 +1,14 @@ -import { AppEnv } from "@/types/hono"; -import { Hono } from "hono"; - -export const healthCheckRoutes = new Hono() +import { AppEnv } from '@/types/hono'; +import { Hono } from 'hono'; +export const healthCheckRoutes = new Hono(); healthCheckRoutes.get('/', (c) => { - return c.json({ - status: 'healthy', - timeStamp: new Date() - }, 200) -}) \ No newline at end of file + return c.json( + { + status: 'healthy', + timeStamp: new Date(), + }, + 200 + ); +}); diff --git a/apps/backend/src/modules/onboarding/handlers/complete-onboarding.handler.ts b/apps/backend/src/modules/onboarding/handlers/complete-onboarding.handler.ts index af1a4b8..9b42b21 100644 --- a/apps/backend/src/modules/onboarding/handlers/complete-onboarding.handler.ts +++ b/apps/backend/src/modules/onboarding/handlers/complete-onboarding.handler.ts @@ -1,27 +1,27 @@ -import type { OnboardingCompleteInput } from '@repo/api-contract' -import type { AppContext } from '@/types/hono' import { OnboardingError, completeOnboarding, -} from '@/modules/onboarding/services/onboarding.service' +} 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 + const payload = c.req.valid('json' as never) as OnboardingCompleteInput; try { - const result = await completeOnboarding(payload) + 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) + return c.json({ message: error.message }, error.status); } if (error instanceof Error) { - return c.json({ message: error.message }, 400) + return c.json({ message: error.message }, 400); } - throw error + 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 index e51147f..98a5376 100644 --- a/apps/backend/src/modules/onboarding/handlers/resend-otp.handler.ts +++ b/apps/backend/src/modules/onboarding/handlers/resend-otp.handler.ts @@ -1,25 +1,25 @@ -import type { OnboardingResendOtpInput } from '@repo/api-contract' -import type { AppContext } from '@/types/hono' import { OnboardingError, resendOnboardingOtp, -} from '@/modules/onboarding/services/onboarding.service' +} 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 + const payload = c.req.valid('json' as never) as OnboardingResendOtpInput; try { - const result = await resendOnboardingOtp(payload) - return c.json(result) + const result = await resendOnboardingOtp(payload); + return c.json(result); } catch (error) { if (error instanceof OnboardingError) { - return c.json({ message: error.message }, error.status) + return c.json({ message: error.message }, error.status); } if (error instanceof Error) { - return c.json({ message: error.message }, 400) + return c.json({ message: error.message }, 400); } - throw error + 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 index 88bb3dd..598152f 100644 --- a/apps/backend/src/modules/onboarding/handlers/start-onboarding.handler.ts +++ b/apps/backend/src/modules/onboarding/handlers/start-onboarding.handler.ts @@ -1,10 +1,10 @@ -import type { OnboardingStartInput } from '@repo/api-contract' -import type { AppContext } from '@/types/hono' -import { startOnboarding } from '@/modules/onboarding/services/onboarding.service' +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 payload = c.req.valid('json' as never) as OnboardingStartInput; - const result = await startOnboarding(payload) - return c.json(result, 202) + 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 index 44927f0..285d3c3 100644 --- a/apps/backend/src/modules/onboarding/handlers/verify-otp.handler.ts +++ b/apps/backend/src/modules/onboarding/handlers/verify-otp.handler.ts @@ -1,10 +1,10 @@ -import type { OnboardingVerifyOtpInput } from '@repo/api-contract' -import type { AppContext } from '@/types/hono' -import { verifyOnboardingOtp } from '@/modules/onboarding/services/onboarding.service' +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) + const payload = c.req.valid('json' as never) as OnboardingVerifyOtpInput; + const result = await verifyOnboardingOtp(payload); - return c.json(result) + return c.json(result); } diff --git a/apps/backend/src/modules/onboarding/onboarding.routes.ts b/apps/backend/src/modules/onboarding/onboarding.routes.ts index a32a177..62c1029 100644 --- a/apps/backend/src/modules/onboarding/onboarding.routes.ts +++ b/apps/backend/src/modules/onboarding/onboarding.routes.ts @@ -1,39 +1,35 @@ -import { zValidator } from '@hono/zod-validator' +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' -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' +} from '@repo/api-contract'; +import { Hono } from 'hono'; -export const onboardingRoutes = new Hono() +export const onboardingRoutes = new Hono(); -onboardingRoutes.post( - '/start', - zValidator('json', onboardingStartSchema), - startOnboardingHandler, -) +onboardingRoutes.post('/start', zValidator('json', onboardingStartSchema), startOnboardingHandler); onboardingRoutes.post( '/verify-otp', zValidator('json', onboardingVerifyOtpSchema), - verifyOtpHandler, -) + verifyOtpHandler +); onboardingRoutes.post( '/resend-otp', zValidator('json', onboardingResendOtpSchema), - resendOtpHandler, -) + resendOtpHandler +); onboardingRoutes.post( '/complete', zValidator('json', onboardingCompleteSchema), - completeOnboardingHandler, -) + completeOnboardingHandler +); diff --git a/apps/backend/src/modules/onboarding/services/onboarding.service.ts b/apps/backend/src/modules/onboarding/services/onboarding.service.ts index 5c713d0..773c3ed 100644 --- a/apps/backend/src/modules/onboarding/services/onboarding.service.ts +++ b/apps/backend/src/modules/onboarding/services/onboarding.service.ts @@ -1,86 +1,86 @@ -import { createHash, randomInt } from 'node:crypto' -import { v7 as uuidv7 } from 'uuid' -import type { User as SupabaseAuthUser } from '@supabase/supabase-js' +import { createHash, randomInt } from 'node:crypto'; +import { sendMail } from '@/lib/mailer'; +import { db } from '@/lib/prisma'; +import { getSupabaseAdminClient } from '@/lib/supabase-admin'; import type { OnboardingCompleteInput, OnboardingResendOtpInput, OnboardingStartInput, OnboardingVerifyOtpInput, -} from '@repo/api-contract' -import { db } from '@/lib/prisma' -import { sendMail } from '@/lib/mailer' -import { getSupabaseAdminClient } from '@/lib/supabase-admin' +} from '@repo/api-contract'; +import type { User as SupabaseAuthUser } from '@supabase/supabase-js'; +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, -) +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 -} + 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 -} + message: string; + requestId: string; + email: string; + expiresAt: string; + cooldownSeconds: number; + remainingAttempts: number; +}; type StartOnboardingResult = { - message: string - requestId: string - email: string - expiresAt: string - cooldownSeconds: number -} + message: string; + requestId: string; + email: string; + expiresAt: string; + cooldownSeconds: number; +}; export class OnboardingError extends Error { - status: 400 | 404 | 409 | 429 + status: 400 | 404 | 409 | 429; constructor(message: string, status: 400 | 404 | 409 | 429 = 400) { - super(message) - this.name = 'OnboardingError' - this.status = status + super(message); + this.name = 'OnboardingError'; + this.status = status; } } function nowPlusMinutes(minutes: number): Date { - const date = new Date() - date.setMinutes(date.getMinutes() + minutes) - return date + const date = new Date(); + date.setMinutes(date.getMinutes() + minutes); + return date; } function normalizeEmail(email: string): string { - return email.trim().toLowerCase() + return email.trim().toLowerCase(); } function createOtpCode(): string { - return randomInt(0, 10 ** OTP_LENGTH).toString().padStart(OTP_LENGTH, '0') + return randomInt(0, 10 ** OTP_LENGTH) + .toString() + .padStart(OTP_LENGTH, '0'); } function hashValue(value: string): string { - return createHash('sha256').update(value).digest('hex') + return createHash('sha256').update(value).digest('hex'); } function getRemainingAttempts(otpAttempts: number): number { - return Math.max(0, OTP_MAX_ATTEMPTS - otpAttempts) + 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)) + 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 { @@ -91,28 +91,28 @@ function slugify(value: string): string { .trim() .replace(/[^a-z0-9\s-]/g, '') .replace(/\s+/g, '-') - .replace(/-+/g, '-') + .replace(/-+/g, '-'); } async function buildUniqueComplexSlug(complexName: string): Promise { - const base = slugify(complexName) - const fallback = base.length > 0 ? base : `complex-${uuidv7().slice(0, 8)}` + const base = slugify(complexName); + const fallback = base.length > 0 ? base : `complex-${uuidv7().slice(0, 8)}`; - let candidate = fallback - let index = 1 + let candidate = fallback; + let index = 1; while (true) { const existing = await db.complex.findFirst({ where: { complexSlug: candidate }, select: { id: true }, - }) + }); if (!existing) { - return candidate + return candidate; } - index += 1 - candidate = `${fallback}-${index}` + index += 1; + candidate = `${fallback}-${index}`; } } @@ -122,70 +122,61 @@ async function sendOtpEmail(email: string, otpCode: string) { 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.

`, - }) + }); } -async function findSupabaseUserByEmail( - email: string, -): Promise { - let page = 1 - const perPage = 100 +async function findSupabaseUserByEmail(email: string): Promise { + let page = 1; + const perPage = 100; while (page <= 10) { const { data, error } = await getSupabaseAdminClient().auth.admin.listUsers({ page, perPage, - }) + }); if (error) { - throw new Error(`No se pudo consultar usuarios en Supabase: ${error.message}`) + throw new Error(`No se pudo consultar usuarios en Supabase: ${error.message}`); } - const found = data.users.find( - (user) => user.email?.toLowerCase() === email.toLowerCase(), - ) + const found = data.users.find((user) => user.email?.toLowerCase() === email.toLowerCase()); if (found) { - return found + return found; } if (data.users.length < perPage) { - return null + return null; } - page += 1 + page += 1; } - return null + return null; } async function ensureSupabaseUser(params: { - email: string - fullName: string - password: string + email: string; + fullName: string; + password: string; }): Promise { - const existingUser = await findSupabaseUserByEmail(params.email) + const existingUser = await findSupabaseUserByEmail(params.email); if (existingUser) { - const { error } = await getSupabaseAdminClient().auth.admin.updateUserById( - existingUser.id, - { - password: params.password, - email_confirm: true, - user_metadata: { - full_name: params.fullName, - name: params.fullName, - }, + const { error } = await getSupabaseAdminClient().auth.admin.updateUserById(existingUser.id, { + password: params.password, + email_confirm: true, + user_metadata: { + full_name: params.fullName, + name: params.fullName, }, - ) + }); if (error) { - throw new Error( - `No se pudo actualizar usuario existente en Supabase: ${error.message}`, - ) + throw new Error(`No se pudo actualizar usuario existente en Supabase: ${error.message}`); } - return existingUser.id + return existingUser.id; } const { data, error } = await getSupabaseAdminClient().auth.admin.createUser({ @@ -196,24 +187,20 @@ async function ensureSupabaseUser(params: { full_name: params.fullName, name: params.fullName, }, - }) + }); if (error || !data.user) { - throw new Error( - `No se pudo crear usuario en Supabase: ${error?.message ?? 'unknown error'}`, - ) + throw new Error(`No se pudo crear usuario en Supabase: ${error?.message ?? 'unknown error'}`); } - return data.user.id + return data.user.id; } -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() +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: { @@ -231,9 +218,9 @@ export async function startOnboarding( email: true, otpExpiresAt: true, }, - }) + }); - await sendOtpEmail(email, otpCode) + await sendOtpEmail(email, otpCode); return { message: 'Te enviamos un codigo OTP para validar tu direccion.', @@ -241,15 +228,15 @@ export async function startOnboarding( email: request.email, expiresAt: request.otpExpiresAt.toISOString(), cooldownSeconds: OTP_RESEND_COOLDOWN_SECONDS, - } + }; } export async function verifyOnboardingOtp( - input: OnboardingVerifyOtpInput, + input: OnboardingVerifyOtpInput ): Promise { const request = await db.onboardingRequest.findUnique({ where: { id: input.requestId }, - }) + }); if (!request) { return { @@ -260,7 +247,7 @@ export async function verifyOnboardingOtp( expiresAt: null, remainingAttempts: 0, cooldownSeconds: 0, - } + }; } if (request.completedAt) { @@ -272,7 +259,7 @@ export async function verifyOnboardingOtp( expiresAt: request.otpExpiresAt.toISOString(), remainingAttempts: getRemainingAttempts(request.otpAttempts), cooldownSeconds: getCooldownSeconds(request.otpLastSentAt), - } + }; } if (request.otpExpiresAt < new Date()) { @@ -284,7 +271,7 @@ export async function verifyOnboardingOtp( expiresAt: request.otpExpiresAt.toISOString(), remainingAttempts: getRemainingAttempts(request.otpAttempts), cooldownSeconds: getCooldownSeconds(request.otpLastSentAt), - } + }; } if (request.otpAttempts >= OTP_MAX_ATTEMPTS) { @@ -296,10 +283,10 @@ export async function verifyOnboardingOtp( expiresAt: request.otpExpiresAt.toISOString(), remainingAttempts: 0, cooldownSeconds: getCooldownSeconds(request.otpLastSentAt), - } + }; } - const otpMatches = hashValue(input.otp) === request.otpHash + const otpMatches = hashValue(input.otp) === request.otpHash; if (!otpMatches) { const updated = await db.onboardingRequest.update({ @@ -316,9 +303,9 @@ export async function verifyOnboardingOtp( otpExpiresAt: true, otpLastSentAt: true, }, - }) + }); - const remainingAttempts = getRemainingAttempts(updated.otpAttempts) + const remainingAttempts = getRemainingAttempts(updated.otpAttempts); return { message: @@ -331,14 +318,14 @@ export async function verifyOnboardingOtp( 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 { @@ -349,39 +336,36 @@ export async function verifyOnboardingOtp( expiresAt: request.otpExpiresAt.toISOString(), remainingAttempts: getRemainingAttempts(request.otpAttempts), cooldownSeconds: getCooldownSeconds(request.otpLastSentAt), - } + }; } export async function resendOnboardingOtp( - input: OnboardingResendOtpInput, + 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) + throw new OnboardingError('Solicitud de onboarding invalida o expirada.', 404); } if (request.completedAt) { - throw new OnboardingError('Este onboarding ya fue completado.', 400) + throw new OnboardingError('Este onboarding ya fue completado.', 400); } if (request.emailVerifiedAt) { - throw new OnboardingError('El email ya fue verificado.', 400) + throw new OnboardingError('El email ya fue verificado.', 400); } - const cooldownSeconds = getCooldownSeconds(request.otpLastSentAt) + const cooldownSeconds = getCooldownSeconds(request.otpLastSentAt); if (cooldownSeconds > 0) { - throw new OnboardingError( - `Debes esperar ${cooldownSeconds}s antes de reenviar el OTP.`, - 429, - ) + 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 otpCode = createOtpCode(); + const expiresAt = nowPlusMinutes(OTP_TTL_MINUTES); + const now = new Date(); const updated = await db.onboardingRequest.update({ where: { id: request.id }, @@ -400,9 +384,9 @@ export async function resendOnboardingOtp( otpExpiresAt: true, otpAttempts: true, }, - }) + }); - await sendOtpEmail(updated.email, otpCode) + await sendOtpEmail(updated.email, otpCode); return { message: 'Te enviamos un nuevo codigo OTP.', @@ -411,49 +395,46 @@ export async function resendOnboardingOtp( 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 invalida o expirada.', 400) + throw new OnboardingError('Solicitud de onboarding invalida o expirada.', 400); } if (onboardingRequest.otpExpiresAt < new Date()) { - throw new OnboardingError( - 'La sesion de onboarding expiro. Solicita un nuevo OTP.', - 400, - ) + throw new OnboardingError('La sesion de onboarding expiro. Solicita un nuevo OTP.', 400); } if (!onboardingRequest.emailVerifiedAt) { - throw new OnboardingError('Debes verificar el email antes de continuar.', 400) + throw new OnboardingError('Debes verificar el email antes de continuar.', 400); } if (onboardingRequest.completedAt) { - throw new OnboardingError('Este onboarding ya fue completado.', 400) + 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) + throw new OnboardingError('El plan seleccionado no existe.', 400); } const supabaseUserId = await ensureSupabaseUser({ email: onboardingRequest.email, fullName: onboardingRequest.fullName, password: input.password, - }) + }); - const complexSlug = await buildUniqueComplexSlug(input.complexName) + const complexSlug = await buildUniqueComplexSlug(input.complexName); const result = await db.$transaction(async (tx) => { const user = await tx.user.upsert({ @@ -468,7 +449,7 @@ export async function completeOnboarding(input: OnboardingCompleteInput) { email: onboardingRequest.email, supabaseUserId, }, - }) + }); const complex = await tx.complex.create({ data: { @@ -482,7 +463,7 @@ export async function completeOnboarding(input: OnboardingCompleteInput) { adminEmail: onboardingRequest.email, planCode: input.planCode, }, - }) + }); await tx.complexUser.create({ data: { @@ -490,24 +471,24 @@ export async function completeOnboarding(input: OnboardingCompleteInput) { 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, supabaseUserId, - } + }; } 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 c110348..ec2f6e9 100644 --- a/apps/backend/src/modules/plan/handlers/list-plans.handler.ts +++ b/apps/backend/src/modules/plan/handlers/list-plans.handler.ts @@ -1,5 +1,5 @@ -import type { AppContext } from '@/types/hono' -import { db } from '@/lib/prisma' +import { db } from '@/lib/prisma'; +import type { AppContext } from '@/types/hono'; export async function listPlansHandler(c: AppContext) { const plans = await db.plan.findMany({ @@ -11,13 +11,13 @@ export async function listPlansHandler(c: AppContext) { orderBy: { price: 'asc', }, - }) + }); return c.json( plans.map((plan) => ({ code: plan.code, name: plan.name, price: Number(plan.price), - })), - ) + })) + ); } diff --git a/apps/backend/src/modules/plan/plan.routes.ts b/apps/backend/src/modules/plan/plan.routes.ts index 8a3b16b..5bf26dd 100644 --- a/apps/backend/src/modules/plan/plan.routes.ts +++ b/apps/backend/src/modules/plan/plan.routes.ts @@ -1,7 +1,7 @@ -import { Hono } from 'hono' -import { listPlansHandler } from '@/modules/plan/handlers/list-plans.handler' -import type { AppEnv } from '@/types/hono' +import { listPlansHandler } from '@/modules/plan/handlers/list-plans.handler'; +import type { AppEnv } from '@/types/hono'; +import { Hono } from 'hono'; -export const planRoutes = new Hono() +export const planRoutes = new Hono(); -planRoutes.get('/', listPlansHandler) +planRoutes.get('/', listPlansHandler); diff --git a/apps/backend/src/modules/plan/services/plan-rules.service.ts b/apps/backend/src/modules/plan/services/plan-rules.service.ts index c3a21a0..7975056 100644 --- a/apps/backend/src/modules/plan/services/plan-rules.service.ts +++ b/apps/backend/src/modules/plan/services/plan-rules.service.ts @@ -1,44 +1,41 @@ -import type { PlanRules } from '@repo/api-contract' -import { planRulesSchema } from '@repo/api-contract' +import type { PlanRules } from '@repo/api-contract'; +import { planRulesSchema } from '@repo/api-contract'; export type PlanUsageSnapshot = { - courtsCount: number - bookingsToday: number - activeUsersCount?: number -} + courtsCount: number; + bookingsToday: number; + activeUsersCount?: number; +}; export type PlanViolationCode = | 'MAX_COURTS_REACHED' | 'MAX_BOOKINGS_PER_DAY_REACHED' - | 'MAX_ACTIVE_USERS_REACHED' + | 'MAX_ACTIVE_USERS_REACHED'; export type PlanViolation = { - code: PlanViolationCode - message: string -} + code: PlanViolationCode; + message: string; +}; export function parsePlanRules(input: unknown): PlanRules { - return planRulesSchema.parse(input) + return planRulesSchema.parse(input); } -export function evaluatePlanUsage( - rules: PlanRules, - usage: PlanUsageSnapshot, -): PlanViolation[] { - const violations: PlanViolation[] = [] +export function evaluatePlanUsage(rules: PlanRules, usage: PlanUsageSnapshot): PlanViolation[] { + const violations: PlanViolation[] = []; if (usage.courtsCount >= rules.limits.maxCourts) { violations.push({ code: 'MAX_COURTS_REACHED', message: `El plan permite hasta ${rules.limits.maxCourts} canchas.`, - }) + }); } if (usage.bookingsToday >= rules.limits.maxBookingsPerDay) { violations.push({ code: 'MAX_BOOKINGS_PER_DAY_REACHED', message: `El plan permite hasta ${rules.limits.maxBookingsPerDay} turnos por dia.`, - }) + }); } if ( @@ -49,15 +46,12 @@ export function evaluatePlanUsage( violations.push({ code: 'MAX_ACTIVE_USERS_REACHED', message: `El plan permite hasta ${rules.limits.maxActiveUsers} usuarios activos.`, - }) + }); } - return violations + return violations; } -export function isFeatureEnabled( - rules: PlanRules, - feature: keyof PlanRules['features'], -): boolean { - return rules.features[feature] +export function isFeatureEnabled(rules: PlanRules, feature: keyof PlanRules['features']): boolean { + return rules.features[feature]; } diff --git a/apps/backend/src/modules/public-booking/handlers/create-public-booking.handler.ts b/apps/backend/src/modules/public-booking/handlers/create-public-booking.handler.ts index e52ed5a..4c11372 100644 --- a/apps/backend/src/modules/public-booking/handlers/create-public-booking.handler.ts +++ b/apps/backend/src/modules/public-booking/handlers/create-public-booking.handler.ts @@ -1,24 +1,24 @@ -import type { CreatePublicBookingInput } from '@repo/api-contract' import { PublicBookingServiceError, createPublicBooking, -} from '@/modules/public-booking/services/public-booking.service' -import type { AppContext } from '@/types/hono' +} from '@/modules/public-booking/services/public-booking.service'; +import type { AppContext } from '@/types/hono'; +import type { CreatePublicBookingInput } from '@repo/api-contract'; -type ComplexSlugParams = { complexSlug: string } +type ComplexSlugParams = { complexSlug: string }; export async function createPublicBookingHandler(c: AppContext) { - const { complexSlug } = c.req.valid('param' as never) as ComplexSlugParams - const payload = c.req.valid('json' as never) as CreatePublicBookingInput + const { complexSlug } = c.req.valid('param' as never) as ComplexSlugParams; + const payload = c.req.valid('json' as never) as CreatePublicBookingInput; try { - const booking = await createPublicBooking(complexSlug, payload) - return c.json(booking, 201) + const booking = await createPublicBooking(complexSlug, payload); + return c.json(booking, 201); } catch (error) { if (error instanceof PublicBookingServiceError) { - return c.json({ message: error.message }, error.status) + return c.json({ message: error.message }, error.status); } - throw error + throw error; } } diff --git a/apps/backend/src/modules/public-booking/handlers/get-public-booking-confirmation.handler.ts b/apps/backend/src/modules/public-booking/handlers/get-public-booking-confirmation.handler.ts index 471ca6b..3911cc7 100644 --- a/apps/backend/src/modules/public-booking/handlers/get-public-booking-confirmation.handler.ts +++ b/apps/backend/src/modules/public-booking/handlers/get-public-booking-confirmation.handler.ts @@ -1,25 +1,25 @@ import { PublicBookingServiceError, getPublicBookingConfirmation, -} from '@/modules/public-booking/services/public-booking.service' -import type { AppContext } from '@/types/hono' +} from '@/modules/public-booking/services/public-booking.service'; +import type { AppContext } from '@/types/hono'; type BookingCodeParams = { - complexSlug: string - bookingCode: string -} + complexSlug: string; + bookingCode: string; +}; export async function getPublicBookingConfirmationHandler(c: AppContext) { - const { complexSlug, bookingCode } = c.req.valid('param' as never) as BookingCodeParams + const { complexSlug, bookingCode } = c.req.valid('param' as never) as BookingCodeParams; try { - const booking = await getPublicBookingConfirmation(complexSlug, bookingCode) - return c.json(booking) + const booking = await getPublicBookingConfirmation(complexSlug, bookingCode); + return c.json(booking); } catch (error) { if (error instanceof PublicBookingServiceError) { - return c.json({ message: error.message }, error.status) + return c.json({ message: error.message }, error.status); } - throw error + throw error; } } diff --git a/apps/backend/src/modules/public-booking/handlers/list-public-availability.handler.ts b/apps/backend/src/modules/public-booking/handlers/list-public-availability.handler.ts index 283d946..8ac4490 100644 --- a/apps/backend/src/modules/public-booking/handlers/list-public-availability.handler.ts +++ b/apps/backend/src/modules/public-booking/handlers/list-public-availability.handler.ts @@ -1,24 +1,24 @@ -import type { PublicAvailabilityQuery } from '@repo/api-contract' import { PublicBookingServiceError, listPublicAvailability, -} from '@/modules/public-booking/services/public-booking.service' -import type { AppContext } from '@/types/hono' +} from '@/modules/public-booking/services/public-booking.service'; +import type { AppContext } from '@/types/hono'; +import type { PublicAvailabilityQuery } from '@repo/api-contract'; -type ComplexSlugParams = { complexSlug: string } +type ComplexSlugParams = { complexSlug: string }; export async function listPublicAvailabilityHandler(c: AppContext) { - const { complexSlug } = c.req.valid('param' as never) as ComplexSlugParams - const query = c.req.valid('query' as never) as PublicAvailabilityQuery + const { complexSlug } = c.req.valid('param' as never) as ComplexSlugParams; + const query = c.req.valid('query' as never) as PublicAvailabilityQuery; try { - const availability = await listPublicAvailability(complexSlug, query) - return c.json(availability) + const availability = await listPublicAvailability(complexSlug, query); + return c.json(availability); } catch (error) { if (error instanceof PublicBookingServiceError) { - return c.json({ message: error.message }, error.status) + return c.json({ message: error.message }, error.status); } - throw error + throw error; } } diff --git a/apps/backend/src/modules/public-booking/public-booking.routes.ts b/apps/backend/src/modules/public-booking/public-booking.routes.ts index 17845a0..ab5732c 100644 --- a/apps/backend/src/modules/public-booking/public-booking.routes.ts +++ b/apps/backend/src/modules/public-booking/public-booking.routes.ts @@ -1,38 +1,35 @@ -import { zValidator } from '@hono/zod-validator' -import { - createPublicBookingSchema, - publicAvailabilityQuerySchema, -} from '@repo/api-contract' -import { Hono } from 'hono' -import { z } from 'zod' -import { createPublicBookingHandler } from '@/modules/public-booking/handlers/create-public-booking.handler' -import { getPublicBookingConfirmationHandler } from '@/modules/public-booking/handlers/get-public-booking-confirmation.handler' -import { listPublicAvailabilityHandler } from '@/modules/public-booking/handlers/list-public-availability.handler' -import type { AppEnv } from '@/types/hono' +import { createPublicBookingHandler } from '@/modules/public-booking/handlers/create-public-booking.handler'; +import { getPublicBookingConfirmationHandler } from '@/modules/public-booking/handlers/get-public-booking-confirmation.handler'; +import { listPublicAvailabilityHandler } from '@/modules/public-booking/handlers/list-public-availability.handler'; +import type { AppEnv } from '@/types/hono'; +import { zValidator } from '@hono/zod-validator'; +import { createPublicBookingSchema, publicAvailabilityQuerySchema } from '@repo/api-contract'; +import { Hono } from 'hono'; +import { z } from 'zod'; -export const publicBookingRoutes = new Hono() -const complexSlugParamsSchema = z.object({ complexSlug: z.string().trim().min(1) }) +export const publicBookingRoutes = new Hono(); +const complexSlugParamsSchema = z.object({ complexSlug: z.string().trim().min(1) }); const confirmationParamsSchema = z.object({ complexSlug: z.string().trim().min(1), bookingCode: z.string().trim().min(6).max(8), -}) +}); publicBookingRoutes.get( '/complex/:complexSlug/availability', zValidator('param', complexSlugParamsSchema), zValidator('query', publicAvailabilityQuerySchema), - listPublicAvailabilityHandler, -) + listPublicAvailabilityHandler +); publicBookingRoutes.post( '/complex/:complexSlug', zValidator('param', complexSlugParamsSchema), zValidator('json', createPublicBookingSchema), - createPublicBookingHandler, -) + createPublicBookingHandler +); publicBookingRoutes.get( '/complex/:complexSlug/confirmation/:bookingCode', zValidator('param', confirmationParamsSchema), - getPublicBookingConfirmationHandler, -) + getPublicBookingConfirmationHandler +); diff --git a/apps/backend/src/modules/public-booking/services/public-booking.service.ts b/apps/backend/src/modules/public-booking/services/public-booking.service.ts index 941b524..b27c4de 100644 --- a/apps/backend/src/modules/public-booking/services/public-booking.service.ts +++ b/apps/backend/src/modules/public-booking/services/public-booking.service.ts @@ -1,19 +1,19 @@ +import { randomInt } from 'node:crypto'; +import { db } from '@/lib/prisma'; +import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service'; import type { CreatePublicBookingInput, DayOfWeek, PublicAvailabilityQuery, -} from '@repo/api-contract' -import { randomInt } from 'node:crypto' -import { v7 as uuidv7 } from 'uuid' -import { db } from '@/lib/prisma' -import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service' +} from '@repo/api-contract'; +import { v7 as uuidv7 } from 'uuid'; type Slot = { - startTime: string - endTime: string -} + startTime: string; + endTime: string; +}; -type ComplexWithPublicBookingData = Awaited> +type ComplexWithPublicBookingData = Awaited>; const DAY_OF_WEEK_BY_INDEX: DayOfWeek[] = [ 'SUNDAY', @@ -23,44 +23,44 @@ const DAY_OF_WEEK_BY_INDEX: DayOfWeek[] = [ 'THURSDAY', 'FRIDAY', 'SATURDAY', -] -const BOOKING_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' -const BOOKING_CODE_LENGTH = 6 +]; +const BOOKING_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; +const BOOKING_CODE_LENGTH = 6; export class PublicBookingServiceError extends Error { - status: 400 | 403 | 404 | 409 + status: 400 | 403 | 404 | 409; constructor(message: string, status: 400 | 403 | 404 | 409) { - super(message) - this.name = 'PublicBookingServiceError' - this.status = status + super(message); + this.name = 'PublicBookingServiceError'; + this.status = status; } } function toMinutes(value: string): number { - const [hours, minutes] = value.split(':').map((part) => Number(part)) - return hours * 60 + minutes + const [hours, minutes] = value.split(':').map((part) => Number(part)); + return hours * 60 + minutes; } function minutesToTime(minutes: number): string { - const safeMinutes = Math.max(0, minutes) - const hours = Math.floor(safeMinutes / 60) - const mins = safeMinutes % 60 - return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}` + const safeMinutes = Math.max(0, minutes); + const hours = Math.floor(safeMinutes / 60); + const mins = safeMinutes % 60; + return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`; } function parseIsoDate(date: string): { bookingDate: Date; dayOfWeek: DayOfWeek } { - const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date) + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date); if (!match) { - throw new PublicBookingServiceError('La fecha debe tener formato YYYY-MM-DD.', 400) + throw new PublicBookingServiceError('La fecha debe tener formato YYYY-MM-DD.', 400); } - const year = Number(match[1]) - const month = Number(match[2]) - const day = Number(match[3]) + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); - const bookingDate = new Date(Date.UTC(year, month - 1, day)) + const bookingDate = new Date(Date.UTC(year, month - 1, day)); if ( Number.isNaN(bookingDate.getTime()) || @@ -68,48 +68,48 @@ function parseIsoDate(date: string): { bookingDate: Date; dayOfWeek: DayOfWeek } bookingDate.getUTCMonth() + 1 !== month || bookingDate.getUTCDate() !== day ) { - throw new PublicBookingServiceError('La fecha enviada no es valida.', 400) + throw new PublicBookingServiceError('La fecha enviada no es valida.', 400); } - const dayOfWeek = DAY_OF_WEEK_BY_INDEX[bookingDate.getUTCDay()] + const dayOfWeek = DAY_OF_WEEK_BY_INDEX[bookingDate.getUTCDay()]; if (!dayOfWeek) { - throw new PublicBookingServiceError('No se pudo resolver el dia de la semana.', 400) + throw new PublicBookingServiceError('No se pudo resolver el dia de la semana.', 400); } - return { bookingDate, dayOfWeek } + return { bookingDate, dayOfWeek }; } function formatIsoDate(date: Date): string { - return date.toISOString().slice(0, 10) + return date.toISOString().slice(0, 10); } function generateBookingCode(): string { - let code = '' + let code = ''; for (let index = 0; index < BOOKING_CODE_LENGTH; index += 1) { - code += BOOKING_CODE_ALPHABET[randomInt(0, BOOKING_CODE_ALPHABET.length)] + code += BOOKING_CODE_ALPHABET[randomInt(0, BOOKING_CODE_ALPHABET.length)]; } - return code + return code; } function hasOverlap(slot: Slot, existing: Slot): boolean { - return slot.startTime < existing.endTime && slot.endTime > existing.startTime + return slot.startTime < existing.endTime && slot.endTime > existing.startTime; } function buildSlots( availability: Array<{ - startTime: string - endTime: string + startTime: string; + endTime: string; }>, - slotDurationMinutes: number, + slotDurationMinutes: number ): Slot[] { - const slots: Slot[] = [] + const slots: Slot[] = []; for (const range of availability) { - const start = toMinutes(range.startTime) - const end = toMinutes(range.endTime) + const start = toMinutes(range.startTime); + const end = toMinutes(range.endTime); for ( let current = start; @@ -119,19 +119,19 @@ function buildSlots( slots.push({ startTime: minutesToTime(current), endTime: minutesToTime(current + slotDurationMinutes), - }) + }); } } - return slots + return slots; } function resolveSports(data: ComplexWithPublicBookingData) { - const map = new Map() + const map = new Map(); for (const court of data.courts) { if (!court.sport.isActive) { - continue + continue; } if (!map.has(court.sport.id)) { @@ -139,41 +139,41 @@ function resolveSports(data: ComplexWithPublicBookingData) { id: court.sport.id, name: court.sport.name, slug: court.sport.slug, - }) + }); } } - return [...map.values()].sort((a, b) => a.name.localeCompare(b.name, 'es')) + return [...map.values()].sort((a, b) => a.name.localeCompare(b.name, 'es')); } function validateSportSelection( availableSports: Array<{ id: string }>, - sportId: string | undefined, + sportId: string | undefined ): { sportSelectionRequired: boolean; selectedSportId: string | undefined } { - const sportSelectionRequired = availableSports.length > 1 + const sportSelectionRequired = availableSports.length > 1; if (sportSelectionRequired && !sportId) { throw new PublicBookingServiceError( 'Debes seleccionar un deporte para consultar disponibilidad.', - 400, - ) + 400 + ); } if (sportId && !availableSports.some((sport) => sport.id === sportId)) { - throw new PublicBookingServiceError('El deporte seleccionado no pertenece al complejo.', 400) + throw new PublicBookingServiceError('El deporte seleccionado no pertenece al complejo.', 400); } if (sportSelectionRequired) { return { sportSelectionRequired, selectedSportId: sportId, - } + }; } return { sportSelectionRequired, selectedSportId: sportId ?? availableSports[0]?.id, - } + }; } async function getComplexWithBookingData(complexSlug: string) { @@ -205,48 +205,48 @@ async function getComplexWithBookingData(complexSlug: string) { orderBy: { createdAt: 'asc' }, }, }, - }) + }); if (!complex) { - throw new PublicBookingServiceError('Complejo no encontrado.', 404) + throw new PublicBookingServiceError('Complejo no encontrado.', 404); } if (complex.plan) { - const rules = parsePlanRules(complex.plan.rules) + const rules = parsePlanRules(complex.plan.rules); if (!rules.features.publicBookingPage) { throw new PublicBookingServiceError( 'El complejo no tiene habilitada la reserva publica.', - 403, - ) + 403 + ); } } - return complex + return complex; } function mapBookingResponse(input: { - bookingId: string - bookingCode: string - bookingDate: Date - createdAt: Date - startTime: string - endTime: string - customerName: string - customerPhone: string - status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' + bookingId: string; + bookingCode: string; + bookingDate: Date; + createdAt: Date; + startTime: string; + endTime: string; + customerName: string; + customerPhone: string; + status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED'; court: { - id: string - name: string - complexId: string - complexName: string - complexSlug: string + id: string; + name: string; + complexId: string; + complexName: string; + complexSlug: string; sport: { - id: string - name: string - slug: string - } - } + id: string; + name: string; + slug: string; + }; + }; }) { return { id: input.bookingId, @@ -264,11 +264,11 @@ function mapBookingResponse(input: { customerPhone: input.customerPhone, status: input.status, createdAt: input.createdAt.toISOString(), - } + }; } export async function getPublicBookingConfirmation(complexSlug: string, bookingCode: string) { - const normalizedBookingCode = bookingCode.toUpperCase() + const normalizedBookingCode = bookingCode.toUpperCase(); const booking = await db.courtBooking.findFirst({ where: { bookingCode: normalizedBookingCode, @@ -304,10 +304,10 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC }, }, }, - }) + }); if (!booking) { - throw new PublicBookingServiceError('Reserva no encontrada.', 404) + throw new PublicBookingServiceError('Reserva no encontrada.', 404); } return { @@ -325,39 +325,34 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC }, status: booking.status, createdAt: booking.createdAt.toISOString(), - } + }; } -export async function listPublicAvailability( - complexSlug: string, - query: PublicAvailabilityQuery, -) { - const { bookingDate, dayOfWeek } = parseIsoDate(query.date) - const complex = await getComplexWithBookingData(complexSlug) - const sports = resolveSports(complex) - const sportSelectionRequired = sports.length > 1 +export async function listPublicAvailability(complexSlug: string, query: PublicAvailabilityQuery) { + const { bookingDate, dayOfWeek } = parseIsoDate(query.date); + const complex = await getComplexWithBookingData(complexSlug); + const sports = resolveSports(complex); + const sportSelectionRequired = sports.length > 1; if (query.sportId && !sports.some((sport) => sport.id === query.sportId)) { - throw new PublicBookingServiceError('El deporte seleccionado no pertenece al complejo.', 400) + throw new PublicBookingServiceError('El deporte seleccionado no pertenece al complejo.', 400); } - const selectedSportId = sportSelectionRequired - ? query.sportId - : (query.sportId ?? sports[0]?.id) + const selectedSportId = sportSelectionRequired ? query.sportId : (query.sportId ?? sports[0]?.id); const candidateCourts = complex.courts.filter((court) => { if (!court.sport.isActive) { - return false + return false; } if (selectedSportId) { - return court.sportId === selectedSportId + return court.sportId === selectedSportId; } - return true - }) + return true; + }); - const courtIds = candidateCourts.map((court) => court.id) + const courtIds = candidateCourts.map((court) => court.id); const bookings = courtIds.length > 0 @@ -375,31 +370,31 @@ export async function listPublicAvailability( endTime: true, }, }) - : [] + : []; - const bookingsByCourt = new Map() + const bookingsByCourt = new Map(); for (const booking of bookings) { - const current = bookingsByCourt.get(booking.courtId) ?? [] + const current = bookingsByCourt.get(booking.courtId) ?? []; current.push({ startTime: booking.startTime, endTime: booking.endTime, - }) - bookingsByCourt.set(booking.courtId, current) + }); + bookingsByCourt.set(booking.courtId, current); } const courts = candidateCourts .map((court) => { const dayAvailability = court.availabilities.filter( - (availability) => availability.dayOfWeek === dayOfWeek, - ) + (availability) => availability.dayOfWeek === dayOfWeek + ); - const allSlots = buildSlots(dayAvailability, court.slotDurationMinutes) - const occupiedSlots = bookingsByCourt.get(court.id) ?? [] + const allSlots = buildSlots(dayAvailability, court.slotDurationMinutes); + const occupiedSlots = bookingsByCourt.get(court.id) ?? []; const availableSlots = allSlots.filter( - (slot) => !occupiedSlots.some((occupied) => hasOverlap(slot, occupied)), - ) + (slot) => !occupiedSlots.some((occupied) => hasOverlap(slot, occupied)) + ); return { courtId: court.id, @@ -412,9 +407,9 @@ export async function listPublicAvailability( slotDurationMinutes: court.slotDurationMinutes, availabilityDay: dayOfWeek, availableSlots, - } + }; }) - .filter((court) => court.availableSlots.length > 0) + .filter((court) => court.availableSlots.length > 0); return { complexId: complex.id, @@ -424,69 +419,66 @@ export async function listPublicAvailability( sportSelectionRequired, sports, courts, - } + }; } -export async function createPublicBooking( - complexSlug: string, - input: CreatePublicBookingInput, -) { - const { bookingDate, dayOfWeek } = parseIsoDate(input.date) - const complex = await getComplexWithBookingData(complexSlug) - const sports = resolveSports(complex) - const { sportSelectionRequired } = validateSportSelection(sports, input.sportId) +export async function createPublicBooking(complexSlug: string, input: CreatePublicBookingInput) { + const { bookingDate, dayOfWeek } = parseIsoDate(input.date); + const complex = await getComplexWithBookingData(complexSlug); + const sports = resolveSports(complex); + const { sportSelectionRequired } = validateSportSelection(sports, input.sportId); const selectedCourt = complex.courts.find( - (court) => court.id === input.courtId && court.sport.isActive, - ) + (court) => court.id === input.courtId && court.sport.isActive + ); if (!selectedCourt) { - throw new PublicBookingServiceError('La cancha seleccionada no existe en el complejo.', 404) + throw new PublicBookingServiceError('La cancha seleccionada no existe en el complejo.', 404); } if (sportSelectionRequired && !input.sportId) { throw new PublicBookingServiceError( 'Debes seleccionar un deporte para reservar en este complejo.', - 400, - ) + 400 + ); } if (input.sportId && input.sportId !== selectedCourt.sportId) { throw new PublicBookingServiceError( 'La cancha seleccionada no corresponde al deporte indicado.', - 400, - ) + 400 + ); } const dayAvailability = selectedCourt.availabilities.filter( - (availability) => availability.dayOfWeek === dayOfWeek, - ) + (availability) => availability.dayOfWeek === dayOfWeek + ); - const validSlots = buildSlots(dayAvailability, selectedCourt.slotDurationMinutes) - const selectedStartMinutes = toMinutes(input.startTime) - const selectedEndMinutes = selectedStartMinutes + selectedCourt.slotDurationMinutes + const validSlots = buildSlots(dayAvailability, selectedCourt.slotDurationMinutes); + const selectedStartMinutes = toMinutes(input.startTime); + const selectedEndMinutes = selectedStartMinutes + selectedCourt.slotDurationMinutes; const selectedSlot = { startTime: input.startTime, endTime: minutesToTime(selectedEndMinutes), - } + }; const slotExists = validSlots.some( - (slot) => slot.startTime === selectedSlot.startTime && slot.endTime === selectedSlot.endTime, - ) + (slot) => slot.startTime === selectedSlot.startTime && slot.endTime === selectedSlot.endTime + ); if (!slotExists) { throw new PublicBookingServiceError( 'El horario seleccionado no esta disponible para esa cancha.', - 409, - ) + 409 + ); } for (let attempt = 0; attempt < 5; attempt += 1) { try { const booking = await db.$transaction(async (tx) => { if (complex.plan) { - const rules = parsePlanRules(complex.plan.rules) + const rules = parsePlanRules(complex.plan.rules); const bookingsForDate = await tx.courtBooking.count({ where: { bookingDate, @@ -495,19 +487,19 @@ export async function createPublicBooking( complexId: complex.id, }, }, - }) + }); const violations = evaluatePlanUsage(rules, { courtsCount: complex.courts.length, bookingsToday: bookingsForDate, - }) + }); const maxBookingsViolation = violations.find( - (violation) => violation.code === 'MAX_BOOKINGS_PER_DAY_REACHED', - ) + (violation) => violation.code === 'MAX_BOOKINGS_PER_DAY_REACHED' + ); if (maxBookingsViolation) { - throw new PublicBookingServiceError(maxBookingsViolation.message, 409) + throw new PublicBookingServiceError(maxBookingsViolation.message, 409); } } @@ -523,10 +515,10 @@ export async function createPublicBooking( gt: selectedSlot.startTime, }, }, - }) + }); if (overlappingBooking) { - throw new PublicBookingServiceError('El horario seleccionado ya fue reservado.', 409) + throw new PublicBookingServiceError('El horario seleccionado ya fue reservado.', 409); } return tx.courtBooking.create({ @@ -552,8 +544,8 @@ export async function createPublicBooking( customerPhone: true, status: true, }, - }) - }) + }); + }); return mapBookingResponse({ bookingId: booking.id, @@ -577,40 +569,40 @@ export async function createPublicBooking( slug: selectedCourt.sport.slug, }, }, - }) + }); } catch (error) { if (error instanceof PublicBookingServiceError) { - throw error + throw error; } const prismaError = error as { - code?: string + code?: string; meta?: { - target?: string[] | string - } - } + target?: string[] | string; + }; + }; if (prismaError.code === 'P2002') { const targets = Array.isArray(prismaError.meta?.target) ? prismaError.meta?.target - : [prismaError.meta?.target] + : [prismaError.meta?.target]; const isBookingCodeCollision = targets.some((target) => - String(target).includes('booking_code'), - ) + String(target).includes('booking_code') + ); if (isBookingCodeCollision) { - continue + continue; } - throw new PublicBookingServiceError('El horario seleccionado ya fue reservado.', 409) + throw new PublicBookingServiceError('El horario seleccionado ya fue reservado.', 409); } - throw error + throw error; } } throw new PublicBookingServiceError( 'No se pudo generar un codigo de reserva unico. Intenta nuevamente.', - 409, - ) + 409 + ); } diff --git a/apps/backend/src/modules/sport/handlers/create-sport.handler.ts b/apps/backend/src/modules/sport/handlers/create-sport.handler.ts index 2b5ea5f..b8b1c29 100644 --- a/apps/backend/src/modules/sport/handlers/create-sport.handler.ts +++ b/apps/backend/src/modules/sport/handlers/create-sport.handler.ts @@ -1,18 +1,18 @@ -import type { CreateSportInput } from '@repo/api-contract' -import { Prisma } from '@/generated/prisma/client' -import type { AppContext } from '@/types/hono' -import { createSport } from '@/modules/sport/services/sport.service' +import { Prisma } from '@/generated/prisma/client'; +import { createSport } from '@/modules/sport/services/sport.service'; +import type { AppContext } from '@/types/hono'; +import type { CreateSportInput } from '@repo/api-contract'; export async function createSportHandler(c: AppContext) { - const payload = c.req.valid('json' as never) as CreateSportInput + const payload = c.req.valid('json' as never) as CreateSportInput; try { - const sport = await createSport(payload) - return c.json(sport, 201) + const sport = await createSport(payload); + return c.json(sport, 201); } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { - return c.json({ message: 'No se pudo crear el deporte.' }, 409) + return c.json({ message: 'No se pudo crear el deporte.' }, 409); } - throw error + throw error; } } diff --git a/apps/backend/src/modules/sport/handlers/list-sports.handler.ts b/apps/backend/src/modules/sport/handlers/list-sports.handler.ts index 415b3e0..337d7ff 100644 --- a/apps/backend/src/modules/sport/handlers/list-sports.handler.ts +++ b/apps/backend/src/modules/sport/handlers/list-sports.handler.ts @@ -1,7 +1,7 @@ -import type { AppContext } from '@/types/hono' -import { listSports } from '@/modules/sport/services/sport.service' +import { listSports } from '@/modules/sport/services/sport.service'; +import type { AppContext } from '@/types/hono'; export async function listSportsHandler(c: AppContext) { - const sports = await listSports() - return c.json(sports) + const sports = await listSports(); + return c.json(sports); } diff --git a/apps/backend/src/modules/sport/handlers/update-sport.handler.ts b/apps/backend/src/modules/sport/handlers/update-sport.handler.ts index ebf3517..467a0ff 100644 --- a/apps/backend/src/modules/sport/handlers/update-sport.handler.ts +++ b/apps/backend/src/modules/sport/handlers/update-sport.handler.ts @@ -1,27 +1,27 @@ -import type { UpdateSportInput } from '@repo/api-contract' -import { Prisma } from '@/generated/prisma/client' -import type { AppContext } from '@/types/hono' -import { getSportById, updateSport } from '@/modules/sport/services/sport.service' +import { Prisma } from '@/generated/prisma/client'; +import { getSportById, updateSport } from '@/modules/sport/services/sport.service'; +import type { AppContext } from '@/types/hono'; +import type { UpdateSportInput } from '@repo/api-contract'; -type SportIdParams = { id: string } +type SportIdParams = { id: string }; export async function updateSportHandler(c: AppContext) { - const { id } = c.req.valid('param' as never) as SportIdParams - const payload = c.req.valid('json' as never) as UpdateSportInput + const { id } = c.req.valid('param' as never) as SportIdParams; + const payload = c.req.valid('json' as never) as UpdateSportInput; - const existing = await getSportById(id) + const existing = await getSportById(id); if (!existing) { - return c.json({ message: 'Deporte no encontrado.' }, 404) + return c.json({ message: 'Deporte no encontrado.' }, 404); } try { - const sport = await updateSport(id, payload) - return c.json(sport) + const sport = await updateSport(id, payload); + return c.json(sport); } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { - return c.json({ message: 'No se pudo actualizar el deporte.' }, 409) + return c.json({ message: 'No se pudo actualizar el deporte.' }, 409); } - throw error + throw error; } } diff --git a/apps/backend/src/modules/sport/services/sport.service.ts b/apps/backend/src/modules/sport/services/sport.service.ts index ff49d26..4b4d90c 100644 --- a/apps/backend/src/modules/sport/services/sport.service.ts +++ b/apps/backend/src/modules/sport/services/sport.service.ts @@ -1,15 +1,15 @@ -import { v7 as uuidv7 } from 'uuid' -import { Prisma } from '@/generated/prisma/client' -import { db } from '@/lib/prisma' +import { Prisma } from '@/generated/prisma/client'; +import { db } from '@/lib/prisma'; +import { v7 as uuidv7 } from 'uuid'; export type CreateSportInput = { - name: string -} + name: string; +}; export type UpdateSportInput = { - name?: string - isActive?: boolean -} + name?: string; + isActive?: boolean; +}; function slugify(value: string): string { return value @@ -19,18 +19,15 @@ function slugify(value: string): string { .trim() .replace(/[^a-z0-9\s-]/g, '') .replace(/\s+/g, '-') - .replace(/-+/g, '-') + .replace(/-+/g, '-'); } -async function buildUniqueSlug( - source: string, - excludeSportId?: string, -): Promise { - const base = slugify(source) - const fallback = base.length > 0 ? base : `sport-${uuidv7().slice(0, 8)}` +async function buildUniqueSlug(source: string, excludeSportId?: string): Promise { + const base = slugify(source); + const fallback = base.length > 0 ? base : `sport-${uuidv7().slice(0, 8)}`; - let candidate = fallback - let index = 1 + let candidate = fallback; + let index = 1; while (true) { const existing = await db.sport.findFirst({ @@ -45,23 +42,23 @@ async function buildUniqueSlug( : {}), }, select: { id: true }, - }) + }); - if (!existing) return candidate + if (!existing) return candidate; - index += 1 - candidate = `${fallback}-${index}` + index += 1; + candidate = `${fallback}-${index}`; } } export async function listSports() { return db.sport.findMany({ orderBy: { name: 'asc' }, - }) + }); } export async function createSport(input: CreateSportInput) { - const slug = await buildUniqueSlug(input.name) + const slug = await buildUniqueSlug(input.name); return db.sport.create({ data: { @@ -70,27 +67,27 @@ export async function createSport(input: CreateSportInput) { slug, isActive: true, }, - }) + }); } export async function getSportById(id: string) { - return db.sport.findUnique({ where: { id } }) + return db.sport.findUnique({ where: { id } }); } export async function updateSport(id: string, input: UpdateSportInput) { - const data: Prisma.SportUncheckedUpdateInput = {} + const data: Prisma.SportUncheckedUpdateInput = {}; if (input.name) { - data.name = input.name - data.slug = await buildUniqueSlug(input.name, id) + data.name = input.name; + data.slug = await buildUniqueSlug(input.name, id); } if (input.isActive !== undefined) { - data.isActive = input.isActive + data.isActive = input.isActive; } return db.sport.update({ where: { id }, data, - }) + }); } diff --git a/apps/backend/src/modules/sport/sport.routes.ts b/apps/backend/src/modules/sport/sport.routes.ts index 05fffa5..bd23be1 100644 --- a/apps/backend/src/modules/sport/sport.routes.ts +++ b/apps/backend/src/modules/sport/sport.routes.ts @@ -1,30 +1,25 @@ -import { zValidator } from '@hono/zod-validator' -import { createSportSchema, updateSportSchema } from '@repo/api-contract' -import { Hono } from 'hono' -import { z } from 'zod' -import { requireAuth } from '@/middlewares/require-auth.middleware' -import { requireSuperAdmin } from '@/middlewares/require-super-admin.middleware' -import { createSportHandler } from '@/modules/sport/handlers/create-sport.handler' -import { listSportsHandler } from '@/modules/sport/handlers/list-sports.handler' -import { updateSportHandler } from '@/modules/sport/handlers/update-sport.handler' -import type { AppEnv } from '@/types/hono' +import { requireAuth } from '@/middlewares/require-auth.middleware'; +import { requireSuperAdmin } from '@/middlewares/require-super-admin.middleware'; +import { createSportHandler } from '@/modules/sport/handlers/create-sport.handler'; +import { listSportsHandler } from '@/modules/sport/handlers/list-sports.handler'; +import { updateSportHandler } from '@/modules/sport/handlers/update-sport.handler'; +import type { AppEnv } from '@/types/hono'; +import { zValidator } from '@hono/zod-validator'; +import { createSportSchema, updateSportSchema } from '@repo/api-contract'; +import { Hono } from 'hono'; +import { z } from 'zod'; -export const sportRoutes = new Hono() -const sportIdParamsSchema = z.object({ id: z.uuid() }) +export const sportRoutes = new Hono(); +const sportIdParamsSchema = z.object({ id: z.uuid() }); -sportRoutes.use('*', requireAuth) +sportRoutes.use('*', requireAuth); -sportRoutes.get('/', listSportsHandler) -sportRoutes.post( - '/', - requireSuperAdmin, - zValidator('json', createSportSchema), - createSportHandler, -) +sportRoutes.get('/', listSportsHandler); +sportRoutes.post('/', requireSuperAdmin, zValidator('json', createSportSchema), createSportHandler); sportRoutes.patch( '/:id', requireSuperAdmin, zValidator('param', sportIdParamsSchema), zValidator('json', updateSportSchema), - updateSportHandler, -) + updateSportHandler +); diff --git a/apps/backend/src/modules/user/handlers/get-user-profile.handler.ts b/apps/backend/src/modules/user/handlers/get-user-profile.handler.ts index 42e9cb6..f409544 100644 --- a/apps/backend/src/modules/user/handlers/get-user-profile.handler.ts +++ b/apps/backend/src/modules/user/handlers/get-user-profile.handler.ts @@ -1,8 +1,8 @@ -import type { AppContext } from '@/types/hono' -import { getUserProfile } from '@/modules/user/services/user.service' +import { getUserProfile } from '@/modules/user/services/user.service'; +import type { AppContext } from '@/types/hono'; export function getUserProfileHandler(c: AppContext) { - const authUser = c.get('authUser') - const profile = getUserProfile(authUser) - return c.json(profile) + const authUser = c.get('authUser'); + const profile = getUserProfile(authUser); + return c.json(profile); } diff --git a/apps/backend/src/modules/user/services/user.service.ts b/apps/backend/src/modules/user/services/user.service.ts index 97079ea..df5923d 100644 --- a/apps/backend/src/modules/user/services/user.service.ts +++ b/apps/backend/src/modules/user/services/user.service.ts @@ -1,21 +1,16 @@ -import type { AuthUser } from '@/types/auth-user' -import type { UserProfile } from '@repo/api-contract' +import type { AuthUser } from '@/types/auth-user'; +import type { UserProfile } from '@repo/api-contract'; export function getUserProfile(user: AuthUser): UserProfile { const fullName = - (typeof user.user_metadata?.full_name === 'string' && - user.user_metadata.full_name) || + (typeof user.user_metadata?.full_name === 'string' && user.user_metadata.full_name) || (typeof user.user_metadata?.name === 'string' && user.user_metadata.name) || - (user.email ?? 'Usuario') + (user.email ?? 'Usuario'); - const role = - (typeof user.app_metadata?.role === 'string' && user.app_metadata.role) || - 'member' + const role = (typeof user.app_metadata?.role === 'string' && user.app_metadata.role) || 'member'; const avatarUrl = - (typeof user.user_metadata?.avatar_url === 'string' && - user.user_metadata.avatar_url) || - null + (typeof user.user_metadata?.avatar_url === 'string' && user.user_metadata.avatar_url) || null; return { id: user.id, @@ -24,5 +19,5 @@ export function getUserProfile(user: AuthUser): UserProfile { role, avatarUrl, createdAt: user.created_at, - } + }; } diff --git a/apps/backend/src/modules/user/user.routes.ts b/apps/backend/src/modules/user/user.routes.ts index 80f7eba..a1a5af2 100644 --- a/apps/backend/src/modules/user/user.routes.ts +++ b/apps/backend/src/modules/user/user.routes.ts @@ -1,9 +1,9 @@ -import { Hono } from 'hono' -import { requireAuth } from '@/middlewares/require-auth.middleware' -import type { AppEnv } from '@/types/hono' -import { getUserProfileHandler } from '@/modules/user/handlers/get-user-profile.handler' +import { requireAuth } from '@/middlewares/require-auth.middleware'; +import { getUserProfileHandler } from '@/modules/user/handlers/get-user-profile.handler'; +import type { AppEnv } from '@/types/hono'; +import { Hono } from 'hono'; -export const userRoutes = new Hono() +export const userRoutes = new Hono(); -userRoutes.use('*', requireAuth) -userRoutes.get('/profile', getUserProfileHandler) +userRoutes.use('*', requireAuth); +userRoutes.get('/profile', getUserProfileHandler); diff --git a/apps/backend/src/register-routes.ts b/apps/backend/src/register-routes.ts index 684f284..2500289 100644 --- a/apps/backend/src/register-routes.ts +++ b/apps/backend/src/register-routes.ts @@ -1,16 +1,16 @@ -import type { Hono } from 'hono' -import { serveStatic } from 'hono/bun' -import path from 'node:path' -import { adminBookingRoutes } from '@/modules/admin-booking/admin-booking.routes' -import { complexRoutes } from '@/modules/complex/complex.routes' -import { courtRoutes } from '@/modules/court/court.routes' -import { onboardingRoutes } from '@/modules/onboarding/onboarding.routes' -import { planRoutes } from '@/modules/plan/plan.routes' -import { publicBookingRoutes } from '@/modules/public-booking/public-booking.routes' -import { sportRoutes } from '@/modules/sport/sport.routes' -import { userRoutes } from '@/modules/user/user.routes' -import type { AppEnv } from '@/types/hono' -import { healthCheckRoutes } from './modules/health-check/health-check.routes' +import path from 'node:path'; +import { adminBookingRoutes } from '@/modules/admin-booking/admin-booking.routes'; +import { complexRoutes } from '@/modules/complex/complex.routes'; +import { courtRoutes } from '@/modules/court/court.routes'; +import { onboardingRoutes } from '@/modules/onboarding/onboarding.routes'; +import { planRoutes } from '@/modules/plan/plan.routes'; +import { publicBookingRoutes } from '@/modules/public-booking/public-booking.routes'; +import { sportRoutes } from '@/modules/sport/sport.routes'; +import { userRoutes } from '@/modules/user/user.routes'; +import type { AppEnv } from '@/types/hono'; +import type { Hono } from 'hono'; +import { serveStatic } from 'hono/bun'; +import { healthCheckRoutes } from './modules/health-check/health-check.routes'; export function registerRoutes(app: Hono) { app @@ -22,24 +22,24 @@ export function registerRoutes(app: Hono) { .route('/api/courts', courtRoutes) .route('/api/public-bookings', publicBookingRoutes) .route('/api/admin-bookings', adminBookingRoutes) - .route('/api/health', healthCheckRoutes) + .route('/api/health', healthCheckRoutes); - app.use('*', serveStatic({ root: './public' })) + app.use('*', serveStatic({ root: './public' })); app.get('*', async (c) => { if (c.req.path === '/api' || c.req.path.startsWith('/api/')) { - return c.notFound() + return c.notFound(); } // Keep real asset URLs as 404s if the file does not exist. if (path.extname(c.req.path)) { - return c.notFound() + return c.notFound(); } - const indexFile = Bun.file('./public/index.html') + const indexFile = Bun.file('./public/index.html'); if (!(await indexFile.exists())) { - return c.notFound() + return c.notFound(); } - return c.html(await indexFile.text()) - }) + return c.html(await indexFile.text()); + }); } diff --git a/apps/backend/src/server.ts b/apps/backend/src/server.ts index 239560a..380766f 100644 --- a/apps/backend/src/server.ts +++ b/apps/backend/src/server.ts @@ -1,16 +1,13 @@ -import app from '@/index' -import { logger } from '@/lib/logger' +import app from '@/index'; +import { logger } from '@/lib/logger'; -const port = Number(Bun.env.PORT ?? 3000) -const hostname = Bun.env.HOST ?? '0.0.0.0' +const port = Number(Bun.env.PORT ?? 3000); +const hostname = Bun.env.HOST ?? '0.0.0.0'; const server = Bun.serve({ hostname, port, fetch: app.fetch, -}) +}); -logger.info( - { url: `http://${server.hostname}:${server.port}` }, - 'backend_listening', -) +logger.info({ url: `http://${server.hostname}:${server.port}` }, 'backend_listening'); diff --git a/apps/backend/src/types/auth-user.ts b/apps/backend/src/types/auth-user.ts index cf414ca..edd5e59 100644 --- a/apps/backend/src/types/auth-user.ts +++ b/apps/backend/src/types/auth-user.ts @@ -1,3 +1,3 @@ -import type { User } from '@supabase/supabase-js' +import type { User } from '@supabase/supabase-js'; -export type AuthUser = User +export type AuthUser = User; diff --git a/apps/backend/src/types/hono.ts b/apps/backend/src/types/hono.ts index 84c8448..3c548f3 100644 --- a/apps/backend/src/types/hono.ts +++ b/apps/backend/src/types/hono.ts @@ -1,13 +1,13 @@ -import type { Context } from 'hono' -import type { AuthUser } from '@/types/auth-user' +import type { AuthUser } from '@/types/auth-user'; +import type { Context } from 'hono'; export type AppVariables = { - authUser: AuthUser - appUserId: string -} + authUser: AuthUser; + appUserId: string; +}; export type AppEnv = { - Variables: AppVariables -} + Variables: AppVariables; +}; -export type AppContext = Context +export type AppContext = Context; diff --git a/apps/frontend/eslint.config.js b/apps/frontend/eslint.config.js deleted file mode 100644 index 5e6b472..0000000 --- a/apps/frontend/eslint.config.js +++ /dev/null @@ -1,23 +0,0 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import tseslint from 'typescript-eslint' -import { defineConfig, globalIgnores } from 'eslint/config' - -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - js.configs.recommended, - tseslint.configs.recommended, - reactHooks.configs.flat.recommended, - reactRefresh.configs.vite, - ], - languageOptions: { - ecmaVersion: 2020, - globals: globals.browser, - }, - }, -]) diff --git a/apps/frontend/package.json b/apps/frontend/package.json index dab34ee..940f2f8 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -6,7 +6,9 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "lint": "eslint .", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "format": "biome format --write .", "preview": "vite preview" }, "dependencies": { @@ -38,20 +40,14 @@ "tw-animate-css": "^1.4.0" }, "devDependencies": { - "@eslint/js": "^9.39.4", "@tailwindcss/vite": "^4.2.2", "@tanstack/router-plugin": "^1.167.12", "@types/node": "^24.12.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", - "eslint": "^9.39.4", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.5.2", - "globals": "^17.4.0", "tailwindcss": "^4.2.2", "typescript": "~5.9.3", - "typescript-eslint": "^8.57.0", "vite": "^8.0.1" } } diff --git a/apps/frontend/src/App.css b/apps/frontend/src/App.css index f90339d..e87c16a 100644 --- a/apps/frontend/src/App.css +++ b/apps/frontend/src/App.css @@ -42,8 +42,7 @@ z-index: 1; top: 34px; height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); + transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) scale(1.4); } .vite { @@ -51,8 +50,7 @@ top: 107px; height: 26px; width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); + transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) scale(0.8); } } @@ -167,7 +165,7 @@ &::before, &::after { - content: ''; + content: ""; position: absolute; top: -4.5px; border: 5px solid transparent; diff --git a/apps/frontend/src/components/ui/avatar.tsx b/apps/frontend/src/components/ui/avatar.tsx index 99f3ed2..fcd0f70 100644 --- a/apps/frontend/src/components/ui/avatar.tsx +++ b/apps/frontend/src/components/ui/avatar.tsx @@ -1,42 +1,36 @@ -import * as React from "react" -import { Avatar as AvatarPrimitive } from "radix-ui" +import { Avatar as AvatarPrimitive } from 'radix-ui'; +import * as React from 'react'; -import { cn } from "@/lib/utils" +import { cn } from '@/lib/utils'; function Avatar({ className, - size = "default", + size = 'default', ...props }: React.ComponentProps & { - size?: "default" | "sm" | "lg" + size?: 'default' | 'sm' | 'lg'; }) { return ( - ) + ); } -function AvatarImage({ - className, - ...props -}: React.ComponentProps) { +function AvatarImage({ className, ...props }: React.ComponentProps) { return ( - ) + ); } function AvatarFallback({ @@ -47,64 +41,54 @@ function AvatarFallback({ - ) + ); } -function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { +function AvatarBadge({ className, ...props }: React.ComponentProps<'span'>) { return ( svg]:hidden", - "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", - "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + 'absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none', + 'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden', + 'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2', + 'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2', className )} {...props} /> - ) + ); } -function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { +function AvatarGroup({ className, ...props }: React.ComponentProps<'div'>) { return (
- ) + ); } -function AvatarGroupCount({ - className, - ...props -}: React.ComponentProps<"div">) { +function AvatarGroupCount({ className, ...props }: React.ComponentProps<'div'>) { return (
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", + 'relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3', className )} {...props} /> - ) + ); } -export { - Avatar, - AvatarImage, - AvatarFallback, - AvatarGroup, - AvatarGroupCount, - AvatarBadge, -} +export { Avatar, AvatarImage, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarBadge }; diff --git a/apps/frontend/src/components/ui/button.tsx b/apps/frontend/src/components/ui/button.tsx index 6138844..008b34b 100644 --- a/apps/frontend/src/components/ui/button.tsx +++ b/apps/frontend/src/components/ui/button.tsx @@ -1,57 +1,57 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" -import { Slot } from "radix-ui" +import { type VariantProps, cva } from 'class-variance-authority'; +import { Slot } from 'radix-ui'; +import * as React from 'react'; -import { cn } from "@/lib/utils" +import { cn } from '@/lib/utils'; const buttonVariants = cva( "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { - default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80', outline: - "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50', secondary: - "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground', ghost: - "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50', destructive: - "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", - link: "text-primary underline-offset-4 hover:underline", + 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40', + link: 'text-primary underline-offset-4 hover:underline', }, size: { default: - "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", - lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - icon: "size-8", - "icon-xs": + lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', + icon: 'size-8', + 'icon-xs': "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", - "icon-sm": - "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", - "icon-lg": "size-9", + 'icon-sm': + 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg', + 'icon-lg': 'size-9', }, }, defaultVariants: { - variant: "default", - size: "default", + variant: 'default', + size: 'default', }, } -) +); function Button({ className, - variant = "default", - size = "default", + variant = 'default', + size = 'default', asChild = false, ...props -}: React.ComponentProps<"button"> & +}: React.ComponentProps<'button'> & VariantProps & { - asChild?: boolean + asChild?: boolean; }) { - const Comp = asChild ? Slot.Root : "button" + const Comp = asChild ? Slot.Root : 'button'; return ( - ) + ); } -export { Button, buttonVariants } +export { Button, buttonVariants }; diff --git a/apps/frontend/src/components/ui/calendar.tsx b/apps/frontend/src/components/ui/calendar.tsx index fc70708..5f8ad29 100644 --- a/apps/frontend/src/components/ui/calendar.tsx +++ b/apps/frontend/src/components/ui/calendar.tsx @@ -1,54 +1,47 @@ -import * as React from "react" -import { DayPicker } from "react-day-picker" -import { ChevronLeft, ChevronRight } from "lucide-react" +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import * as React from 'react'; +import { DayPicker } from 'react-day-picker'; -import { cn } from "@/lib/utils" -import { buttonVariants } from "@/components/ui/button" +import { buttonVariants } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; -export type CalendarProps = React.ComponentProps +export type CalendarProps = React.ComponentProps; -function Calendar({ - className, - classNames, - showOutsideDays = true, - ...props -}: CalendarProps) { +function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) { return ( - ) + ); } -Calendar.displayName = "Calendar" +Calendar.displayName = 'Calendar'; -export { Calendar } +export { Calendar }; diff --git a/apps/frontend/src/components/ui/date-picker.tsx b/apps/frontend/src/components/ui/date-picker.tsx index c218e48..46289b6 100644 --- a/apps/frontend/src/components/ui/date-picker.tsx +++ b/apps/frontend/src/components/ui/date-picker.tsx @@ -1,26 +1,22 @@ -"use client" +'use client'; -import * as React from "react" -import { format } from "date-fns" -import { es } from "date-fns/locale" -import { Calendar as CalendarIcon } from "lucide-react" +import { format } from 'date-fns'; +import { es } from 'date-fns/locale'; +import { Calendar as CalendarIcon } from 'lucide-react'; +import * as React from 'react'; -import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { Calendar } from "@/components/ui/calendar" -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover" +import { Button } from '@/components/ui/button'; +import { Calendar } from '@/components/ui/calendar'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; interface DatePickerProps { - value?: Date - onChange?: (date: Date | undefined) => void - disabled?: (date: Date) => boolean - className?: string - placeholder?: string - minDate?: Date + value?: Date; + onChange?: (date: Date | undefined) => void; + disabled?: (date: Date) => boolean; + className?: string; + placeholder?: string; + minDate?: Date; } export function DatePicker({ @@ -28,19 +24,19 @@ export function DatePicker({ onChange, disabled, className, - placeholder = "Selecciona una fecha", + placeholder = 'Selecciona una fecha', minDate, }: DatePickerProps) { - const [open, setOpen] = React.useState(false) + const [open, setOpen] = React.useState(false); const isDateDisabled = React.useCallback( (date: Date) => { - if (minDate && date < minDate) return true - if (disabled) return disabled(date) - return false + if (minDate && date < minDate) return true; + if (disabled) return disabled(date); + return false; }, [minDate, disabled] - ) + ); return ( @@ -48,18 +44,14 @@ export function DatePicker({ @@ -67,13 +59,13 @@ export function DatePicker({ mode="single" selected={value} onSelect={(date) => { - onChange?.(date) - setOpen(false) + onChange?.(date); + setOpen(false); }} disabled={isDateDisabled} initialFocus /> - ) + ); } diff --git a/apps/frontend/src/components/ui/dialog.tsx b/apps/frontend/src/components/ui/dialog.tsx index 527af74..7a74ddd 100644 --- a/apps/frontend/src/components/ui/dialog.tsx +++ b/apps/frontend/src/components/ui/dialog.tsx @@ -1,32 +1,24 @@ -import * as React from "react" -import { Dialog as DialogPrimitive } from "radix-ui" +import { Dialog as DialogPrimitive } from 'radix-ui'; +import * as React from 'react'; -import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { XIcon } from "lucide-react" +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { XIcon } from 'lucide-react'; -function Dialog({ - ...props -}: React.ComponentProps) { - return +function Dialog({ ...props }: React.ComponentProps) { + return ; } -function DialogTrigger({ - ...props -}: React.ComponentProps) { - return +function DialogTrigger({ ...props }: React.ComponentProps) { + return ; } -function DialogPortal({ - ...props -}: React.ComponentProps) { - return +function DialogPortal({ ...props }: React.ComponentProps) { + return ; } -function DialogClose({ - ...props -}: React.ComponentProps) { - return +function DialogClose({ ...props }: React.ComponentProps) { + return ; } function DialogOverlay({ @@ -37,12 +29,12 @@ function DialogOverlay({ - ) + ); } function DialogContent({ @@ -51,7 +43,7 @@ function DialogContent({ showCloseButton = true, ...props }: React.ComponentProps & { - showCloseButton?: boolean + showCloseButton?: boolean; }) { return ( @@ -59,7 +51,7 @@ function DialogContent({ - )} - ) + ); } -function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { +function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) { return ( -
- ) +
+ ); } function DialogFooter({ @@ -98,14 +81,14 @@ function DialogFooter({ showCloseButton = false, children, ...props -}: React.ComponentProps<"div"> & { - showCloseButton?: boolean +}: React.ComponentProps<'div'> & { + showCloseButton?: boolean; }) { return (
)}
- ) + ); } -function DialogTitle({ - className, - ...props -}: React.ComponentProps) { +function DialogTitle({ className, ...props }: React.ComponentProps) { return ( - ) + ); } function DialogDescription({ @@ -144,12 +121,12 @@ function DialogDescription({ - ) + ); } export { @@ -163,4 +140,4 @@ export { DialogPortal, DialogTitle, DialogTrigger, -} +}; diff --git a/apps/frontend/src/components/ui/dropdown-menu.tsx b/apps/frontend/src/components/ui/dropdown-menu.tsx index c263ad5..7727236 100644 --- a/apps/frontend/src/components/ui/dropdown-menu.tsx +++ b/apps/frontend/src/components/ui/dropdown-menu.tsx @@ -1,39 +1,30 @@ -"use client" +'use client'; -import * as React from "react" -import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui" +import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui'; +import * as React from 'react'; -import { cn } from "@/lib/utils" -import { CheckIcon, ChevronRightIcon } from "lucide-react" +import { cn } from '@/lib/utils'; +import { CheckIcon, ChevronRightIcon } from 'lucide-react'; -function DropdownMenu({ - ...props -}: React.ComponentProps) { - return +function DropdownMenu({ ...props }: React.ComponentProps) { + return ; } function DropdownMenuPortal({ ...props }: React.ComponentProps) { - return ( - - ) + return ; } function DropdownMenuTrigger({ ...props }: React.ComponentProps) { - return ( - - ) + return ; } function DropdownMenuContent({ className, - align = "start", + align = 'start', sideOffset = 4, ...props }: React.ComponentProps) { @@ -43,29 +34,28 @@ function DropdownMenuContent({ data-slot="dropdown-menu-content" sideOffset={sideOffset} align={align} - className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )} + className={cn( + 'z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95', + className + )} {...props} /> - ) + ); } -function DropdownMenuGroup({ - ...props -}: React.ComponentProps) { - return ( - - ) +function DropdownMenuGroup({ ...props }: React.ComponentProps) { + return ; } function DropdownMenuItem({ className, inset, - variant = "default", + variant = 'default', ...props }: React.ComponentProps & { - inset?: boolean - variant?: "default" | "destructive" + inset?: boolean; + variant?: 'default' | 'destructive'; }) { return ( - ) + ); } function DropdownMenuCheckboxItem({ @@ -88,7 +78,7 @@ function DropdownMenuCheckboxItem({ inset, ...props }: React.ComponentProps & { - inset?: boolean + inset?: boolean; }) { return ( - + {children} - ) + ); } function DropdownMenuRadioGroup({ ...props }: React.ComponentProps) { - return ( - - ) + return ; } function DropdownMenuRadioItem({ @@ -132,7 +116,7 @@ function DropdownMenuRadioItem({ inset, ...props }: React.ComponentProps & { - inset?: boolean + inset?: boolean; }) { return ( - + {children} - ) + ); } function DropdownMenuLabel({ @@ -163,19 +146,19 @@ function DropdownMenuLabel({ inset, ...props }: React.ComponentProps & { - inset?: boolean + inset?: boolean; }) { return ( - ) + ); } function DropdownMenuSeparator({ @@ -185,32 +168,27 @@ function DropdownMenuSeparator({ return ( - ) + ); } -function DropdownMenuShortcut({ - className, - ...props -}: React.ComponentProps<"span">) { +function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) { return ( - ) + ); } -function DropdownMenuSub({ - ...props -}: React.ComponentProps) { - return +function DropdownMenuSub({ ...props }: React.ComponentProps) { + return ; } function DropdownMenuSubTrigger({ @@ -219,7 +197,7 @@ function DropdownMenuSubTrigger({ children, ...props }: React.ComponentProps & { - inset?: boolean + inset?: boolean; }) { return ( - ) + ); } function DropdownMenuSubContent({ @@ -244,10 +222,13 @@ function DropdownMenuSubContent({ return ( - ) + ); } export { @@ -266,4 +247,4 @@ export { DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, -} +}; diff --git a/apps/frontend/src/components/ui/field.tsx b/apps/frontend/src/components/ui/field.tsx index 4f1426f..d61a213 100644 --- a/apps/frontend/src/components/ui/field.tsx +++ b/apps/frontend/src/components/ui/field.tsx @@ -1,77 +1,74 @@ -import { useMemo } from "react" -import { cva, type VariantProps } from "class-variance-authority" +import { type VariantProps, cva } from 'class-variance-authority'; +import { useMemo } from 'react'; -import { cn } from "@/lib/utils" -import { Label } from "@/components/ui/label" -import { Separator } from "@/components/ui/separator" +import { Label } from '@/components/ui/label'; +import { Separator } from '@/components/ui/separator'; +import { cn } from '@/lib/utils'; -function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { +function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) { return (
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", + 'flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3', className )} {...props} /> - ) + ); } function FieldLegend({ className, - variant = "legend", + variant = 'legend', ...props -}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { +}: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) { return ( - ) + ); } -function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { +function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) { return (
- ) + ); } -const fieldVariants = cva( - "group/field flex w-full gap-2 data-[invalid=true]:text-destructive", - { - variants: { - orientation: { - vertical: "flex-col *:w-full [&>.sr-only]:w-auto", - horizontal: - "flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", - responsive: - "flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", - }, +const fieldVariants = cva('group/field flex w-full gap-2 data-[invalid=true]:text-destructive', { + variants: { + orientation: { + vertical: 'flex-col *:w-full [&>.sr-only]:w-auto', + horizontal: + 'flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px', + responsive: + 'flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px', }, - defaultVariants: { - orientation: "vertical", - }, - } -) + }, + defaultVariants: { + orientation: 'vertical', + }, +}); function Field({ className, - orientation = "vertical", + orientation = 'vertical', ...props -}: React.ComponentProps<"div"> & VariantProps) { +}: React.ComponentProps<'div'> & VariantProps) { return (
- ) + ); } -function FieldContent({ className, ...props }: React.ComponentProps<"div">) { +function FieldContent({ className, ...props }: React.ComponentProps<'div'>) { return (
- ) + ); } -function FieldLabel({ - className, - ...props -}: React.ComponentProps) { +function FieldLabel({ className, ...props }: React.ComponentProps) { return (
- ) + ); } diff --git a/apps/frontend/src/features/complex/lib/court.constants.ts b/apps/frontend/src/features/complex/lib/court.constants.ts index 88d5468..f7f1c78 100644 --- a/apps/frontend/src/features/complex/lib/court.constants.ts +++ b/apps/frontend/src/features/complex/lib/court.constants.ts @@ -1,4 +1,4 @@ -import type { DayOfWeek } from '@repo/api-contract' +import type { DayOfWeek } from '@repo/api-contract'; export const DAY_OPTIONS: Array<{ value: DayOfWeek; label: string }> = [ { value: 'MONDAY', label: 'Lunes' }, @@ -8,10 +8,10 @@ export const DAY_OPTIONS: Array<{ value: DayOfWeek; label: string }> = [ { value: 'FRIDAY', label: 'Viernes' }, { value: 'SATURDAY', label: 'SÔbado' }, { value: 'SUNDAY', label: 'Domingo' }, -] +]; -export const DAY_ORDER: DayOfWeek[] = DAY_OPTIONS.map((option) => option.value) +export const DAY_ORDER: DayOfWeek[] = DAY_OPTIONS.map((option) => option.value); export const DAY_LABEL_BY_VALUE = new Map( - DAY_OPTIONS.map((option) => [option.value, option.label]), -) + DAY_OPTIONS.map((option) => [option.value, option.label]) +); diff --git a/apps/frontend/src/features/complex/lib/court.utils.ts b/apps/frontend/src/features/complex/lib/court.utils.ts index 3ab27cb..65b0c01 100644 --- a/apps/frontend/src/features/complex/lib/court.utils.ts +++ b/apps/frontend/src/features/complex/lib/court.utils.ts @@ -1,76 +1,76 @@ -import type { CreateCourtInput, DayOfWeek } from '@repo/api-contract' -import { DAY_LABEL_BY_VALUE, DAY_ORDER } from './court.constants' +import type { CreateCourtInput, DayOfWeek } from '@repo/api-contract'; +import { DAY_LABEL_BY_VALUE, DAY_ORDER } from './court.constants'; function formatTimeCompact(value: string): string { - const [rawHours = '00', rawMinutes = '00'] = value.split(':') - const hours = Number(rawHours) - return `${hours}:${rawMinutes}` + const [rawHours = '00', rawMinutes = '00'] = value.split(':'); + const hours = Number(rawHours); + return `${hours}:${rawMinutes}`; } function formatDayRange(dayIndexes: number[]): string { - if (dayIndexes.length === 0) return '' + if (dayIndexes.length === 0) return ''; - const sorted = [...dayIndexes].sort((a, b) => a - b) - const chunks: Array<{ start: number; end: number }> = [] - let start = sorted[0] - let previous = sorted[0] + const sorted = [...dayIndexes].sort((a, b) => a - b); + const chunks: Array<{ start: number; end: number }> = []; + let start = sorted[0]; + let previous = sorted[0]; for (let index = 1; index < sorted.length; index += 1) { - const current = sorted[index] + const current = sorted[index]; if (current === previous + 1) { - previous = current - continue + previous = current; + continue; } - chunks.push({ start, end: previous }) - start = current - previous = current + chunks.push({ start, end: previous }); + start = current; + previous = current; } - chunks.push({ start, end: previous }) + chunks.push({ start, end: previous }); return chunks .map((chunk) => { - const startDay = DAY_ORDER[chunk.start] - const endDay = DAY_ORDER[chunk.end] - const startLabel = startDay ? DAY_LABEL_BY_VALUE.get(startDay) : undefined - const endLabel = endDay ? DAY_LABEL_BY_VALUE.get(endDay) : undefined + const startDay = DAY_ORDER[chunk.start]; + const endDay = DAY_ORDER[chunk.end]; + const startLabel = startDay ? DAY_LABEL_BY_VALUE.get(startDay) : undefined; + const endLabel = endDay ? DAY_LABEL_BY_VALUE.get(endDay) : undefined; - if (!startLabel || !endLabel) return '' - if (chunk.start === chunk.end) return startLabel - return `${startLabel} a ${endLabel}` + if (!startLabel || !endLabel) return ''; + if (chunk.start === chunk.end) return startLabel; + return `${startLabel} a ${endLabel}`; }) .filter(Boolean) - .join(', ') + .join(', '); } export function summarizeAvailability( availability: Array<{ - dayOfWeek: DayOfWeek - startTime: string - endTime: string - }>, + dayOfWeek: DayOfWeek; + startTime: string; + endTime: string; + }> ) { - const grouped = new Map() + const grouped = new Map(); for (const slot of availability) { - const dayIndex = DAY_ORDER.indexOf(slot.dayOfWeek) - if (dayIndex < 0) continue + const dayIndex = DAY_ORDER.indexOf(slot.dayOfWeek); + if (dayIndex < 0) continue; - const key = `${slot.startTime}-${slot.endTime}` - const current = grouped.get(key) ?? [] - current.push(dayIndex) - grouped.set(key, current) + const key = `${slot.startTime}-${slot.endTime}`; + const current = grouped.get(key) ?? []; + current.push(dayIndex); + grouped.set(key, current); } return [...grouped.entries()] .map(([timeRange, dayIndexes]) => { - const [startTime = '00:00', endTime = '00:00'] = timeRange.split('-') - const dayLabel = formatDayRange(dayIndexes) - return `${dayLabel} · ${formatTimeCompact(startTime)} a ${formatTimeCompact(endTime)}` + const [startTime = '00:00', endTime = '00:00'] = timeRange.split('-'); + const dayLabel = formatDayRange(dayIndexes); + return `${dayLabel} · ${formatTimeCompact(startTime)} a ${formatTimeCompact(endTime)}`; }) - .filter(Boolean) + .filter(Boolean); } export function formatCurrency(amount: number): string { @@ -78,7 +78,7 @@ export function formatCurrency(amount: number): string { style: 'currency', currency: 'ARS', maximumFractionDigits: 2, - }).format(amount) + }).format(amount); } export function createDefaultCourtValues(): CreateCourtInput { @@ -94,5 +94,5 @@ export function createDefaultCourtValues(): CreateCourtInput { endTime: '22:00', }, ], - } + }; } diff --git a/apps/frontend/src/features/home/home-page.tsx b/apps/frontend/src/features/home/home-page.tsx index d727bd1..51f256f 100644 --- a/apps/frontend/src/features/home/home-page.tsx +++ b/apps/frontend/src/features/home/home-page.tsx @@ -1,153 +1,155 @@ -import { zodResolver } from '@hookform/resolvers/zod' -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { useEffect, useMemo, useState } from 'react' -import { useForm } from 'react-hook-form' -import { z } from 'zod' -import type { AdminBooking } from '@repo/api-contract' -import { Button } from '@/components/ui/button' -import { DatePicker } from '@/components/ui/date-picker' -import { Field, FieldError, FieldLabel } from '@/components/ui/field' -import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button'; +import { DatePicker } from '@/components/ui/date-picker'; +import { Field, FieldError, FieldLabel } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from '@/components/ui/select' -import { ApiClientError, apiClient } from '@/lib/api-client' +} from '@/components/ui/select'; +import { ApiClientError, apiClient } from '@/lib/api-client'; import { getCurrentComplexSlug, setCurrentComplexSlug as persistCurrentComplexSlug, -} from '@/lib/current-complex' +} from '@/lib/current-complex'; +import { zodResolver } from '@hookform/resolvers/zod'; +import type { AdminBooking } from '@repo/api-contract'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useMemo, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; const manualBookingSchema = z.object({ customerName: z .string() .trim() - .min(2, 'Ingresa un nombre valido.') + .min(2, 'Ingresa un nombre vÔlido.') .max(120, 'El nombre no puede superar los 120 caracteres.'), customerPhone: z .string() .trim() - .min(6, 'Ingresa un telefono valido.') + .min(6, 'Ingresa un telefono vÔlido.') .max(30, 'El telefono no puede superar los 30 caracteres.'), -}) +}); -type ManualBookingForm = z.infer +type ManualBookingForm = z.infer; function toIsoDateLocal(date: Date) { - const year = date.getFullYear() - const month = String(date.getMonth() + 1).padStart(2, '0') - const day = String(date.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; } function fromIsoDateLocal(dateIso: string): Date | undefined { - const [year, month, day] = dateIso.split('-').map(Number) - if (!year || !month || !day) return undefined - return new Date(year, month - 1, day) + const [year, month, day] = dateIso.split('-').map(Number); + if (!year || !month || !day) return undefined; + return new Date(year, month - 1, day); } function extractMessage(error: unknown, fallback: string) { if (error instanceof ApiClientError) { - return error.message || fallback + return error.message || fallback; } - return fallback + return fallback; } function formatDateLabel(dateIso: string) { - const [year, month, day] = dateIso.split('-').map(Number) + const [year, month, day] = dateIso.split('-').map(Number); - if (!year || !month || !day) return dateIso + if (!year || !month || !day) return dateIso; - const date = new Date(year, month - 1, day) + const date = new Date(year, month - 1, day); return new Intl.DateTimeFormat('es-AR', { weekday: 'long', day: '2-digit', month: 'long', - }).format(date) + }).format(date); } -function getEffectiveStatus(booking: AdminBooking): 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW' { +function getEffectiveStatus( + booking: AdminBooking +): 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW' { if (booking.status !== 'CONFIRMED') { - return booking.status + return booking.status; } // Check if booking is past end time and still confirmed - const now = new Date() - const bookingDateTime = new Date(`${booking.date}T${booking.endTime}:00`) + const now = new Date(); + const bookingDateTime = new Date(`${booking.date}T${booking.endTime}:00`); if (now > bookingDateTime) { - return 'NO_SHOW' + return 'NO_SHOW'; } - return 'CONFIRMED' + return 'CONFIRMED'; } function effectiveStatusLabel(status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW') { - if (status === 'NO_SHOW') return 'No show' - if (status === 'COMPLETED') return 'Cumplida' - if (status === 'CANCELLED') return 'Cancelada' - return 'Confirmada' + if (status === 'NO_SHOW') return 'No show'; + if (status === 'COMPLETED') return 'Cumplida'; + if (status === 'CANCELLED') return 'Cancelada'; + return 'Confirmada'; } function effectiveStatusClassName(status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW') { if (status === 'NO_SHOW') { - return 'border-orange-200 bg-orange-50 text-orange-700' + return 'border-orange-200 bg-orange-50 text-orange-700'; } if (status === 'COMPLETED') { - return 'border-emerald-200 bg-emerald-50 text-emerald-700' + return 'border-emerald-200 bg-emerald-50 text-emerald-700'; } if (status === 'CANCELLED') { - return 'border-rose-200 bg-rose-50 text-rose-700' + return 'border-rose-200 bg-rose-50 text-rose-700'; } - return 'border-sky-200 bg-sky-50 text-sky-700' + return 'border-sky-200 bg-sky-50 text-sky-700'; } export function HomePage() { - const queryClient = useQueryClient() - const todayIso = useMemo(() => toIsoDateLocal(new Date()), []) + const queryClient = useQueryClient(); + const todayIso = useMemo(() => toIsoDateLocal(new Date()), []); - const [currentComplexSlug, setCurrentComplexSlug] = useState(null) - const [fromDate, setFromDate] = useState(todayIso) - const [manualDate, setManualDate] = useState(todayIso) - const [selectedSportId, setSelectedSportId] = useState() - const [selectedCourtId, setSelectedCourtId] = useState('') - const [selectedStartTime, setSelectedStartTime] = useState('') + const [currentComplexSlug, setCurrentComplexSlug] = useState(null); + const [fromDate, setFromDate] = useState(todayIso); + const [manualDate, setManualDate] = useState(todayIso); + const [selectedSportId, setSelectedSportId] = useState(); + const [selectedCourtId, setSelectedCourtId] = useState(''); + const [selectedStartTime, setSelectedStartTime] = useState(''); useEffect(() => { - setCurrentComplexSlug(getCurrentComplexSlug()) - }, []) + setCurrentComplexSlug(getCurrentComplexSlug()); + }, []); const myComplexesQuery = useQuery({ queryKey: ['my-complexes'], queryFn: () => apiClient.complexes.listMine(), - }) + }); const selectedComplex = useMemo(() => { - const complexes = myComplexesQuery.data ?? [] + const complexes = myComplexesQuery.data ?? []; - if (complexes.length === 0) return null + if (complexes.length === 0) return null; if (currentComplexSlug) { - const match = complexes.find((complex) => complex.complexSlug === currentComplexSlug) + const match = complexes.find((complex) => complex.complexSlug === currentComplexSlug); - if (match) return match + if (match) return match; } - return complexes[0] - }, [currentComplexSlug, myComplexesQuery.data]) + return complexes[0]; + }, [currentComplexSlug, myComplexesQuery.data]); useEffect(() => { - if (!selectedComplex) return + if (!selectedComplex) return; - setCurrentComplexSlug(selectedComplex.complexSlug) - persistCurrentComplexSlug(selectedComplex.complexSlug) - }, [selectedComplex]) + setCurrentComplexSlug(selectedComplex.complexSlug); + persistCurrentComplexSlug(selectedComplex.complexSlug); + }, [selectedComplex]); const bookingsQuery = useQuery({ queryKey: ['admin-bookings', selectedComplex?.id, fromDate], @@ -156,84 +158,89 @@ export function HomePage() { apiClient.adminBookings.listByComplex(selectedComplex?.id as string, { fromDate, }), - }) + }); const manualAvailabilityQuery = useQuery({ - queryKey: ['manual-booking-availability', selectedComplex?.complexSlug, manualDate, selectedSportId], + queryKey: [ + 'manual-booking-availability', + selectedComplex?.complexSlug, + manualDate, + selectedSportId, + ], enabled: Boolean(selectedComplex?.complexSlug && manualDate), queryFn: () => apiClient.publicBookings.getAvailability(selectedComplex?.complexSlug as string, { date: manualDate, ...(selectedSportId ? { sportId: selectedSportId } : {}), }), - }) + }); useEffect(() => { - const availability = manualAvailabilityQuery.data + const availability = manualAvailabilityQuery.data; - if (!availability) return + if (!availability) return; if (!availability.sportSelectionRequired) { if (availability.sports[0] && !selectedSportId) { - setSelectedSportId(availability.sports[0].id) + setSelectedSportId(availability.sports[0].id); } } else if ( selectedSportId && !availability.sports.some((sport) => sport.id === selectedSportId) ) { - setSelectedSportId(undefined) + setSelectedSportId(undefined); } - }, [manualAvailabilityQuery.data, selectedSportId]) + }, [manualAvailabilityQuery.data, selectedSportId]); useEffect(() => { - if (!manualAvailabilityQuery.data) return + if (!manualAvailabilityQuery.data) return; if ( selectedCourtId && !manualAvailabilityQuery.data.courts.some((court) => court.courtId === selectedCourtId) ) { - setSelectedCourtId('') - setSelectedStartTime('') + setSelectedCourtId(''); + setSelectedStartTime(''); } - }, [manualAvailabilityQuery.data, selectedCourtId]) + }, [manualAvailabilityQuery.data, selectedCourtId]); const selectedCourt = useMemo(() => { - return manualAvailabilityQuery.data?.courts.find((court) => court.courtId === selectedCourtId) - }, [manualAvailabilityQuery.data?.courts, selectedCourtId]) + return manualAvailabilityQuery.data?.courts.find((court) => court.courtId === selectedCourtId); + }, [manualAvailabilityQuery.data?.courts, selectedCourtId]); useEffect(() => { if (!selectedCourt) { - setSelectedStartTime('') - return + setSelectedStartTime(''); + return; } if (!selectedCourt.availableSlots.some((slot) => slot.startTime === selectedStartTime)) { - setSelectedStartTime('') + setSelectedStartTime(''); } - }, [selectedCourt, selectedStartTime]) + }, [selectedCourt, selectedStartTime]); const groupedBookings = useMemo(() => { - const groups = new Map() + const groups = new Map(); for (const booking of bookingsQuery.data?.bookings ?? []) { - const current = groups.get(booking.date) ?? [] - current.push(booking) - groups.set(booking.date, current) + const current = groups.get(booking.date) ?? []; + current.push(booking); + groups.set(booking.date, current); } - return [...groups.entries()] - }, [bookingsQuery.data?.bookings]) + return [...groups.entries()]; + }, [bookingsQuery.data?.bookings]); const updateStatusMutation = useMutation({ mutationFn: (payload: { bookingId: string; status: 'CANCELLED' | 'COMPLETED' }) => apiClient.adminBookings.updateStatus(payload.bookingId, { status: payload.status }), onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ['admin-bookings', selectedComplex?.id] }) + void queryClient.invalidateQueries({ queryKey: ['admin-bookings', selectedComplex?.id] }); void queryClient.invalidateQueries({ queryKey: ['manual-booking-availability', selectedComplex?.complexSlug], - }) + }); }, - }) + }); const { register, @@ -247,16 +254,16 @@ export function HomePage() { customerName: '', customerPhone: '', }, - }) + }); const createManualBookingMutation = useMutation({ mutationFn: async (values: ManualBookingForm) => { if (!selectedComplex?.id) { - throw new Error('No se encontró el complejo actual.') + throw new Error('No se encontró el complejo actual.'); } if (!selectedCourtId || !selectedStartTime) { - throw new Error('Debes seleccionar cancha y horario.') + throw new Error('Debes seleccionar cancha y horario.'); } return apiClient.adminBookings.create(selectedComplex.id, { @@ -265,30 +272,30 @@ export function HomePage() { startTime: selectedStartTime, customerName: values.customerName, customerPhone: values.customerPhone, - }) + }); }, onSuccess: async () => { - reset() - setSelectedCourtId('') - setSelectedStartTime('') + reset(); + setSelectedCourtId(''); + setSelectedStartTime(''); - await queryClient.invalidateQueries({ queryKey: ['admin-bookings', selectedComplex?.id] }) + await queryClient.invalidateQueries({ queryKey: ['admin-bookings', selectedComplex?.id] }); await queryClient.invalidateQueries({ queryKey: ['manual-booking-availability', selectedComplex?.complexSlug], - }) + }); }, - }) + }); const onSubmitManualBooking = async (values: ManualBookingForm) => { - await createManualBookingMutation.mutateAsync(values) - } + await createManualBookingMutation.mutateAsync(values); + }; if (myComplexesQuery.isLoading) { return (

Cargando panel...

- ) + ); } if (myComplexesQuery.isError) { @@ -298,17 +305,15 @@ export function HomePage() { {extractMessage(myComplexesQuery.error, 'No pudimos cargar tus complejos.')}

- ) + ); } if (!selectedComplex) { return (
-

- No tienes complejos asignados todavĆ­a. -

+

No tienes complejos asignados todavĆ­a.

- ) + ); } return ( @@ -327,15 +332,15 @@ export function HomePage() { { - const isoDate = date ? toIsoDateLocal(date) : todayIso - setFromDate(isoDate) + const isoDate = date ? toIsoDateLocal(date) : todayIso; + setFromDate(isoDate); }} disabled={(date) => { - const today = new Date() - today.setHours(0, 0, 0, 0) - const checkDate = new Date(date) - checkDate.setHours(0, 0, 0, 0) - return checkDate < today + const today = new Date(); + today.setHours(0, 0, 0, 0); + const checkDate = new Date(date); + checkDate.setHours(0, 0, 0, 0); + return checkDate < today; }} placeholder="Selecciona una fecha" /> @@ -352,13 +357,11 @@ export function HomePage() {

)} - {!bookingsQuery.isLoading && - !bookingsQuery.isError && - groupedBookings.length === 0 && ( -

- No hay reservas para la fecha seleccionada en adelante. -

- )} + {!bookingsQuery.isLoading && !bookingsQuery.isError && groupedBookings.length === 0 && ( +

+ No hay reservas para la fecha seleccionada en adelante. +

+ )}
{groupedBookings.map(([date, bookings]) => ( @@ -366,9 +369,10 @@ export function HomePage() {

{formatDateLabel(date)}

{bookings.map((booking) => { - const effectiveStatus = getEffectiveStatus(booking) - const canManage = booking.status === 'CONFIRMED' && effectiveStatus === 'CONFIRMED' - const isMutating = updateStatusMutation.isPending + const effectiveStatus = getEffectiveStatus(booking); + const canManage = + booking.status === 'CONFIRMED' && effectiveStatus === 'CONFIRMED'; + const isMutating = updateStatusMutation.isPending; return (

- {booking.customerName} ({booking.customerPhone}) · Código {booking.bookingCode} + {booking.customerName} ({booking.customerPhone}) · Código{' '} + {booking.bookingCode}

@@ -402,7 +407,7 @@ export function HomePage() { updateStatusMutation.mutate({ bookingId: booking.id, status: 'COMPLETED', - }) + }); }} > Marcar cumplida @@ -416,7 +421,7 @@ export function HomePage() { updateStatusMutation.mutate({ bookingId: booking.id, status: 'CANCELLED', - }) + }); }} > Cancelar @@ -425,7 +430,7 @@ export function HomePage() { )}
- ) + ); })}
@@ -451,10 +456,10 @@ export function HomePage() { { - const isoDate = date ? toIsoDateLocal(date) : todayIso - setManualDate(isoDate) - setSelectedCourtId('') - setSelectedStartTime('') + const isoDate = date ? toIsoDateLocal(date) : todayIso; + setManualDate(isoDate); + setSelectedCourtId(''); + setSelectedStartTime(''); }} minDate={new Date()} placeholder="Selecciona una fecha" @@ -467,9 +472,9 @@ export function HomePage() { { - setSelectedCourtId(value) - setSelectedStartTime('') + setSelectedCourtId(value); + setSelectedStartTime(''); }} > @@ -535,12 +540,15 @@ export function HomePage() {

{extractMessage( manualAvailabilityQuery.error, - 'No pudimos cargar disponibilidad para la reserva manual.', + 'No pudimos cargar disponibilidad para la reserva manual.' )}

)} - + Nombre {extractMessage( createManualBookingMutation.error, - 'No pudimos crear la reserva manual.', + 'No pudimos crear la reserva manual.' )}

)} @@ -583,5 +591,5 @@ export function HomePage() { - ) + ); } diff --git a/apps/frontend/src/features/layout/root-layout.tsx b/apps/frontend/src/features/layout/root-layout.tsx index 9fb6665..f0b88a4 100644 --- a/apps/frontend/src/features/layout/root-layout.tsx +++ b/apps/frontend/src/features/layout/root-layout.tsx @@ -1,9 +1,5 @@ -import { Link, Outlet, useNavigate } from '@tanstack/react-router' -import { useEffect, useMemo, useState } from 'react' -import { useQuery } from '@tanstack/react-query' -import { LogOut, Menu, UserRound } from 'lucide-react' -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' -import { Button } from '@/components/ui/button' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuContent, @@ -11,54 +7,56 @@ import { DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' -import { useAuth } from '@/lib/auth' +} from '@/components/ui/dropdown-menu'; +import { apiClient } from '@/lib/api-client'; +import { useAuth } from '@/lib/auth'; import { getCurrentComplexSlug, setCurrentComplexSlug as persistCurrentComplexSlug, -} from '@/lib/current-complex' -import { apiClient } from '@/lib/api-client' -import { ThemeSwitcher } from './theme-switcher' +} from '@/lib/current-complex'; +import { useQuery } from '@tanstack/react-query'; +import { Link, Outlet, useNavigate } from '@tanstack/react-router'; +import { LogOut, Menu, UserRound } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { ThemeSwitcher } from './theme-switcher'; export function RootLayout() { - const navigate = useNavigate() - const { isAuthenticated, displayName, initials, avatarUrl, signOut } = useAuth() - const [currentComplexSlug, setCurrentComplexSlug] = useState(null) + const navigate = useNavigate(); + const { isAuthenticated, displayName, initials, avatarUrl, signOut } = useAuth(); + const [currentComplexSlug, setCurrentComplexSlug] = useState(null); const myComplexesQuery = useQuery({ queryKey: ['my-complexes'], enabled: isAuthenticated, queryFn: () => apiClient.complexes.listMine(), - }) + }); useEffect(() => { - setCurrentComplexSlug(getCurrentComplexSlug()) - }, []) + setCurrentComplexSlug(getCurrentComplexSlug()); + }, []); useEffect(() => { - if (currentComplexSlug) return + if (currentComplexSlug) return; - const fallbackSlug = myComplexesQuery.data?.[0]?.complexSlug + const fallbackSlug = myComplexesQuery.data?.[0]?.complexSlug; if (fallbackSlug) { - setCurrentComplexSlug(fallbackSlug) - persistCurrentComplexSlug(fallbackSlug) + setCurrentComplexSlug(fallbackSlug); + persistCurrentComplexSlug(fallbackSlug); } - }, [currentComplexSlug, myComplexesQuery.data]) + }, [currentComplexSlug, myComplexesQuery.data]); const currentComplexName = useMemo(() => { - const complexes = myComplexesQuery.data ?? [] - if (complexes.length === 0) return 'Mi complejo' + const complexes = myComplexesQuery.data ?? []; + if (complexes.length === 0) return 'Mi complejo'; - const selected = complexes.find( - (complex) => complex.complexSlug === currentComplexSlug, - ) + const selected = complexes.find((complex) => complex.complexSlug === currentComplexSlug); - return selected?.complexName ?? complexes[0]?.complexName ?? 'Mi complejo' - }, [currentComplexSlug, myComplexesQuery.data]) + return selected?.complexName ?? complexes[0]?.complexName ?? 'Mi complejo'; + }, [currentComplexSlug, myComplexesQuery.data]); const handleSignOut = async () => { - await signOut() - await navigate({ to: '/login' }) - } + await signOut(); + await navigate({ to: '/login' }); + }; return (
@@ -106,9 +104,7 @@ export function RootLayout() { {initials} - - {displayName} - + {displayName} @@ -116,7 +112,7 @@ export function RootLayout() { { - void navigate({ to: '/profile' }) + void navigate({ to: '/profile' }); }} > @@ -125,7 +121,7 @@ export function RootLayout() { { - void handleSignOut() + void handleSignOut(); }} > @@ -152,14 +148,14 @@ export function RootLayout() { { - void navigate({ to: '/' }) + void navigate({ to: '/' }); }} > Home { - void navigate({ to: '/about' }) + void navigate({ to: '/about' }); }} > About @@ -170,7 +166,7 @@ export function RootLayout() { void navigate({ to: '/complex/$slug/edit', params: { slug: currentComplexSlug }, - }) + }); }} > Configuración @@ -182,7 +178,7 @@ export function RootLayout() { <> { - void navigate({ to: '/profile' }) + void navigate({ to: '/profile' }); }} > @@ -191,7 +187,7 @@ export function RootLayout() { { - void handleSignOut() + void handleSignOut(); }} > @@ -201,7 +197,7 @@ export function RootLayout() { ) : ( { - void navigate({ to: '/login' }) + void navigate({ to: '/login' }); }} > Login @@ -215,5 +211,5 @@ export function RootLayout() {
- ) + ); } diff --git a/apps/frontend/src/features/layout/theme-switcher.tsx b/apps/frontend/src/features/layout/theme-switcher.tsx index 4b4ebda..031385e 100644 --- a/apps/frontend/src/features/layout/theme-switcher.tsx +++ b/apps/frontend/src/features/layout/theme-switcher.tsx @@ -1,5 +1,4 @@ -import { Laptop, Moon, Sun } from 'lucide-react' -import { Button } from '@/components/ui/button' +import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuContent, @@ -8,21 +7,18 @@ import { DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' -import { useTheme } from '@/lib/theme' +} from '@/components/ui/dropdown-menu'; +import { useTheme } from '@/lib/theme'; +import { Laptop, Moon, Sun } from 'lucide-react'; export function ThemeSwitcher() { - const { theme, resolvedTheme, setTheme } = useTheme() + const { theme, resolvedTheme, setTheme } = useTheme(); return ( @@ -47,5 +43,5 @@ export function ThemeSwitcher() { - ) + ); } diff --git a/apps/frontend/src/features/login/login-page.tsx b/apps/frontend/src/features/login/login-page.tsx index f35bd5d..f0a3af2 100644 --- a/apps/frontend/src/features/login/login-page.tsx +++ b/apps/frontend/src/features/login/login-page.tsx @@ -1,28 +1,28 @@ -import { useState } from 'react' -import { Link, useNavigate } from '@tanstack/react-router' -import { zodResolver } from '@hookform/resolvers/zod' -import { useForm } from 'react-hook-form' -import { z } from 'zod' -import { Button } from '@/components/ui/button' -import { Field, FieldError, FieldLabel } from '@/components/ui/field' -import { Input } from '@/components/ui/input' -import { useAuth } from '@/lib/auth' +import { Button } from '@/components/ui/button'; +import { Field, FieldError, FieldLabel } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; +import { useAuth } from '@/lib/auth'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Link, 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.'), -}) +}); -type LoginForm = z.infer +type LoginForm = z.infer; type LoginPageProps = { - redirectTo?: string -} + redirectTo?: string; +}; export function LoginPage({ redirectTo = '/' }: LoginPageProps) { - const navigate = useNavigate() - const { signInWithPassword } = useAuth() - const [submitError, setSubmitError] = useState(null) + const navigate = useNavigate(); + const { signInWithPassword } = useAuth(); + const [submitError, setSubmitError] = useState(null); const { register, @@ -35,18 +35,18 @@ export function LoginPage({ redirectTo = '/' }: LoginPageProps) { email: '', password: '', }, - }) + }); const onSubmit = async (values: LoginForm) => { - setSubmitError(null) + setSubmitError(null); try { - await signInWithPassword(values) - await navigate({ to: redirectTo }) + await signInWithPassword(values); + await navigate({ to: redirectTo }); } catch { - setSubmitError('No pudimos iniciar sesión. VerificÔ tus credenciales.') + setSubmitError('No pudimos iniciar sesión. VerificÔ tus credenciales.'); } - } + }; return (
@@ -96,5 +96,5 @@ export function LoginPage({ redirectTo = '/' }: LoginPageProps) {

- ) + ); } diff --git a/apps/frontend/src/features/not-found/not-found-page.tsx b/apps/frontend/src/features/not-found/not-found-page.tsx index 8f9489b..952b514 100644 --- a/apps/frontend/src/features/not-found/not-found-page.tsx +++ b/apps/frontend/src/features/not-found/not-found-page.tsx @@ -1,6 +1,6 @@ -import { Link } from '@tanstack/react-router' -import { Compass, Home, Sparkles } from 'lucide-react' -import { Button } from '@/components/ui/button' +import { Button } from '@/components/ui/button'; +import { Link } from '@tanstack/react-router'; +import { Compass, Home, Sparkles } from 'lucide-react'; export function NotFoundPage() { return ( @@ -16,7 +16,8 @@ export function NotFoundPage() {

404

PƔgina no encontrada

- La ruta que intentaste abrir no existe o fue movida. VolvƩ al inicio o andƔ al onboarding para continuar. + La ruta que intentaste abrir no existe o fue movida. VolvƩ al inicio o andƔ al onboarding + para continuar.

@@ -36,5 +37,5 @@ export function NotFoundPage() {
- ) + ); } 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 index 3814ba9..4ada26b 100644 --- a/apps/frontend/src/features/onboard/components/onboarding-check-email-step.tsx +++ b/apps/frontend/src/features/onboard/components/onboarding-check-email-step.tsx @@ -3,5 +3,5 @@ export function OnboardingCheckEmailStep() {

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 index 49c40ae..f9bd31a 100644 --- a/apps/frontend/src/features/onboard/components/onboarding-complete-step.tsx +++ b/apps/frontend/src/features/onboard/components/onboarding-complete-step.tsx @@ -1,19 +1,19 @@ -import type { PlanSummary } from '@repo/api-contract' -import { Controller, type UseFormReturn } from 'react-hook-form' -import { Button } from '@/components/ui/button' -import { Field, FieldError, FieldLabel } from '@/components/ui/field' -import { Input } from '@/components/ui/input' -import type { CompleteValues } from '@/features/onboard/onboarding.types' +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 -} + form: UseFormReturn; + plans: PlanSummary[]; + verifiedEmail: string | null; + errorMessage: string | null; + infoMessage: string | null; + planPlaceholder: string; + onSubmit: (values: CompleteValues) => Promise; +}; export function OnboardingCompleteStep({ form, @@ -81,7 +81,7 @@ export function OnboardingCompleteStep({ aria-invalid={Boolean(form.formState.errors.planCode)} value={field.value ?? ''} onChange={(event) => { - field.onChange(event.target.value) + field.onChange(event.target.value); }} disabled={plans.length === 0} > @@ -115,5 +115,5 @@ export function OnboardingCompleteStep({ {form.formState.isSubmitting ? 'Finalizando...' : 'Completar onboarding'} - ) + ); } diff --git a/apps/frontend/src/features/onboard/components/onboarding-layout.tsx b/apps/frontend/src/features/onboard/components/onboarding-layout.tsx index eafa7b6..9a7801f 100644 --- a/apps/frontend/src/features/onboard/components/onboarding-layout.tsx +++ b/apps/frontend/src/features/onboard/components/onboarding-layout.tsx @@ -1,15 +1,11 @@ -import type { PropsWithChildren } from 'react' +import type { PropsWithChildren } from 'react'; type OnboardingLayoutProps = PropsWithChildren<{ - title: string - description: string -}> + title: string; + description: string; +}>; -export function OnboardingLayout({ - title, - description, - children, -}: OnboardingLayoutProps) { +export function OnboardingLayout({ title, description, children }: OnboardingLayoutProps) { return (
@@ -18,5 +14,5 @@ export function OnboardingLayout({ {children}
- ) + ); } diff --git a/apps/frontend/src/features/onboard/components/onboarding-start-step.tsx b/apps/frontend/src/features/onboard/components/onboarding-start-step.tsx index 5d270c6..f8f980c 100644 --- a/apps/frontend/src/features/onboard/components/onboarding-start-step.tsx +++ b/apps/frontend/src/features/onboard/components/onboarding-start-step.tsx @@ -1,15 +1,15 @@ -import type { UseFormReturn } from 'react-hook-form' -import { Button } from '@/components/ui/button' -import { Field, FieldError, FieldLabel } from '@/components/ui/field' -import { Input } from '@/components/ui/input' -import type { StartValues } from '@/features/onboard/onboarding.types' +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 -} + form: UseFormReturn; + errorMessage: string | null; + infoMessage: string | null; + onSubmit: (values: StartValues) => Promise; +}; export function OnboardingStartStep({ form, @@ -54,5 +54,5 @@ export function OnboardingStartStep({ {form.formState.isSubmitting ? 'Enviando...' : 'Enviar codigo OTP'} - ) + ); } 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 index 2faf219..05a3a2f 100644 --- a/apps/frontend/src/features/onboard/components/onboarding-verify-otp-step.tsx +++ b/apps/frontend/src/features/onboard/components/onboarding-verify-otp-step.tsx @@ -1,26 +1,26 @@ -import { RefreshCw } from 'lucide-react' -import type { UseFormReturn } from 'react-hook-form' -import { Button } from '@/components/ui/button' -import { Field, FieldError, FieldLabel } from '@/components/ui/field' +import { 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' +} 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 -} + 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, @@ -33,7 +33,7 @@ export function OnboardingVerifyOtpStep({ onSubmit, onResend, }: OnboardingVerifyOtpStepProps) { - const resendDisabled = resendCooldownSeconds > 0 || isResending + const resendDisabled = resendCooldownSeconds > 0 || isResending; return (
@@ -51,7 +51,7 @@ export function OnboardingVerifyOtpStep({ size="sm" disabled={resendDisabled} onClick={() => { - void onResend() + void onResend(); }} > @@ -70,7 +70,7 @@ export function OnboardingVerifyOtpStep({ pattern="\d*" value={form.watch('otp')} onChange={(value) => { - form.setValue('otp', value, { shouldValidate: true }) + form.setValue('otp', value, { shouldValidate: true }); }} > @@ -104,5 +104,5 @@ export function OnboardingVerifyOtpStep({ {form.formState.isSubmitting ? 'Verificando...' : 'Verificar codigo'} - ) + ); } diff --git a/apps/frontend/src/features/onboard/components/onboarding-verifying-step.tsx b/apps/frontend/src/features/onboard/components/onboarding-verifying-step.tsx index 08d4dd4..c792468 100644 --- a/apps/frontend/src/features/onboard/components/onboarding-verifying-step.tsx +++ b/apps/frontend/src/features/onboard/components/onboarding-verifying-step.tsx @@ -1,7 +1,5 @@ export function OnboardingVerifyingStep() { return ( -

- Verificando tu email, espera un momento... -

- ) +

Verificando tu email, espera un momento...

+ ); } diff --git a/apps/frontend/src/features/onboard/onboard-page.tsx b/apps/frontend/src/features/onboard/onboard-page.tsx index 94c35ed..d092e22 100644 --- a/apps/frontend/src/features/onboard/onboard-page.tsx +++ b/apps/frontend/src/features/onboard/onboard-page.tsx @@ -1,43 +1,43 @@ -import { useEffect, useMemo, useState } from 'react' -import { useNavigate } from '@tanstack/react-router' -import { zodResolver } from '@hookform/resolvers/zod' -import { useForm } from 'react-hook-form' -import { - onboardingCompleteSchema, - onboardingStartSchema, - onboardingVerifyOtpSchema, - type PlanSummary, -} from '@repo/api-contract' -import { OnboardingCompleteStep } from '@/features/onboard/components/onboarding-complete-step' -import { OnboardingLayout } from '@/features/onboard/components/onboarding-layout' -import { OnboardingStartStep } from '@/features/onboard/components/onboarding-start-step' -import { OnboardingVerifyOtpStep } from '@/features/onboard/components/onboarding-verify-otp-step' +import { OnboardingCompleteStep } from '@/features/onboard/components/onboarding-complete-step'; +import { OnboardingLayout } from '@/features/onboard/components/onboarding-layout'; +import { OnboardingStartStep } from '@/features/onboard/components/onboarding-start-step'; +import { OnboardingVerifyOtpStep } from '@/features/onboard/components/onboarding-verify-otp-step'; import type { CompleteValues, StartValues, VerifyOtpValues, -} from '@/features/onboard/onboarding.types' -import { apiClient } from '@/lib/api-client' -import { useAuth } from '@/lib/auth' -import { setCurrentComplexSlug } from '@/lib/current-complex' +} from '@/features/onboard/onboarding.types'; +import { apiClient } from '@/lib/api-client'; +import { useAuth } from '@/lib/auth'; +import { setCurrentComplexSlug } from '@/lib/current-complex'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { + type PlanSummary, + onboardingCompleteSchema, + onboardingStartSchema, + onboardingVerifyOtpSchema, +} from '@repo/api-contract'; +import { useNavigate } from '@tanstack/react-router'; +import { useEffect, useMemo, useState } from 'react'; +import { useForm } from 'react-hook-form'; -const OTP_MAX_ATTEMPTS = 5 -type OnboardStep = 'start' | 'verify-otp' | 'complete' +const OTP_MAX_ATTEMPTS = 5; +type OnboardStep = 'start' | 'verify-otp' | 'complete'; export function OnboardPage() { - const navigate = useNavigate() - const { signInWithPassword } = useAuth() + const navigate = useNavigate(); + const { signInWithPassword } = useAuth(); - const [step, setStep] = useState('start') - const [requestId, setRequestId] = useState(null) - const [onboardingEmail, setOnboardingEmail] = useState(null) - const [verifiedEmail, setVerifiedEmail] = useState(null) - const [plans, setPlans] = useState([]) - const [remainingAttempts, setRemainingAttempts] = useState(OTP_MAX_ATTEMPTS) - const [resendCooldownSeconds, setResendCooldownSeconds] = useState(0) - const [isResending, setIsResending] = useState(false) - const [errorMessage, setErrorMessage] = useState(null) - const [infoMessage, setInfoMessage] = useState(null) + const [step, setStep] = useState('start'); + const [requestId, setRequestId] = useState(null); + const [onboardingEmail, setOnboardingEmail] = useState(null); + const [verifiedEmail, setVerifiedEmail] = useState(null); + const [plans, setPlans] = useState([]); + const [remainingAttempts, setRemainingAttempts] = useState(OTP_MAX_ATTEMPTS); + const [resendCooldownSeconds, setResendCooldownSeconds] = useState(0); + const [isResending, setIsResending] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [infoMessage, setInfoMessage] = useState(null); const startForm = useForm({ resolver: zodResolver(onboardingStartSchema), @@ -46,12 +46,10 @@ export function OnboardPage() { fullName: '', email: '', }, - }) + }); const completeForm = useForm({ - resolver: zodResolver( - onboardingCompleteSchema.omit({ onboardingRequestId: true }), - ), + resolver: zodResolver(onboardingCompleteSchema.omit({ onboardingRequestId: true })), mode: 'onChange', defaultValues: { password: '', @@ -59,7 +57,7 @@ export function OnboardPage() { physicalAddress: '', planCode: '', }, - }) + }); const verifyOtpForm = useForm({ resolver: zodResolver(onboardingVerifyOtpSchema.omit({ requestId: true })), @@ -67,114 +65,114 @@ export function OnboardPage() { defaultValues: { otp: '', }, - }) + }); useEffect(() => { - if (step !== 'complete') return + if (step !== 'complete') return; void (async () => { try { - const result = await apiClient.plans.list() - setPlans(result) + const result = await apiClient.plans.list(); + setPlans(result); } catch { - setPlans([]) + setPlans([]); } - })() - }, [step]) + })(); + }, [step]); const planPlaceholder = useMemo(() => { - if (plans.length === 0) return 'Codigo del plan (por ejemplo: BASIC)' - return 'Selecciona un plan' - }, [plans.length]) + if (plans.length === 0) return 'Codigo del plan (por ejemplo: BASIC)'; + return 'Selecciona un plan'; + }, [plans.length]); useEffect(() => { - if (resendCooldownSeconds <= 0) return + if (resendCooldownSeconds <= 0) return; const timeout = window.setTimeout(() => { - setResendCooldownSeconds((current) => Math.max(0, current - 1)) - }, 1000) + setResendCooldownSeconds((current) => Math.max(0, current - 1)); + }, 1000); return () => { - window.clearTimeout(timeout) - } - }, [resendCooldownSeconds]) + window.clearTimeout(timeout); + }; + }, [resendCooldownSeconds]); const onSubmitStart = async (values: StartValues) => { - setErrorMessage(null) - setInfoMessage(null) + setErrorMessage(null); + setInfoMessage(null); try { - const result = await apiClient.onboarding.start(values) - setRequestId(result.requestId) - setOnboardingEmail(result.email) - setRemainingAttempts(OTP_MAX_ATTEMPTS) - setResendCooldownSeconds(result.cooldownSeconds) - verifyOtpForm.reset({ otp: '' }) - setStep('verify-otp') - setInfoMessage(result.message) + const result = await apiClient.onboarding.start(values); + setRequestId(result.requestId); + setOnboardingEmail(result.email); + setRemainingAttempts(OTP_MAX_ATTEMPTS); + setResendCooldownSeconds(result.cooldownSeconds); + verifyOtpForm.reset({ otp: '' }); + setStep('verify-otp'); + setInfoMessage(result.message); } catch { - setErrorMessage('No pudimos iniciar el onboarding. Intenta nuevamente.') + setErrorMessage('No pudimos iniciar el onboarding. Intenta nuevamente.'); } - } + }; const onSubmitVerifyOtp = async (values: VerifyOtpValues) => { if (!requestId) { - setErrorMessage('No encontramos una solicitud de onboarding activa.') - return + setErrorMessage('No encontramos una solicitud de onboarding activa.'); + return; } - setErrorMessage(null) + setErrorMessage(null); try { const result = await apiClient.onboarding.verifyOtp({ requestId, otp: values.otp, - }) + }); - setRemainingAttempts(result.remainingAttempts) - setResendCooldownSeconds(result.cooldownSeconds) - setInfoMessage(result.message) + setRemainingAttempts(result.remainingAttempts); + setResendCooldownSeconds(result.cooldownSeconds); + setInfoMessage(result.message); if (result.verified) { - setVerifiedEmail(result.email ?? onboardingEmail) - setStep('complete') - return + setVerifiedEmail(result.email ?? onboardingEmail); + setStep('complete'); + return; } } catch { - setErrorMessage('No pudimos verificar el OTP. Intenta nuevamente.') + setErrorMessage('No pudimos verificar el OTP. Intenta nuevamente.'); } - } + }; const onResendOtp = async () => { if (!requestId) { - setErrorMessage('No encontramos una solicitud de onboarding activa.') - return + setErrorMessage('No encontramos una solicitud de onboarding activa.'); + return; } - setIsResending(true) - setErrorMessage(null) + setIsResending(true); + setErrorMessage(null); try { - const result = await apiClient.onboarding.resendOtp({ requestId }) - setOnboardingEmail(result.email) - setRemainingAttempts(result.remainingAttempts) - setResendCooldownSeconds(result.cooldownSeconds) - verifyOtpForm.reset({ otp: '' }) - setInfoMessage(result.message) + const result = await apiClient.onboarding.resendOtp({ requestId }); + setOnboardingEmail(result.email); + setRemainingAttempts(result.remainingAttempts); + setResendCooldownSeconds(result.cooldownSeconds); + verifyOtpForm.reset({ otp: '' }); + setInfoMessage(result.message); } catch { - setErrorMessage('No pudimos reenviar el OTP. Intenta nuevamente.') + setErrorMessage('No pudimos reenviar el OTP. Intenta nuevamente.'); } finally { - setIsResending(false) + setIsResending(false); } - } + }; const onSubmitComplete = async (values: CompleteValues) => { if (!requestId) { - setErrorMessage('No encontramos una solicitud de onboarding activa.') - return + setErrorMessage('No encontramos una solicitud de onboarding activa.'); + return; } - setErrorMessage(null) + setErrorMessage(null); try { const result = await apiClient.onboarding.complete({ @@ -183,19 +181,19 @@ export function OnboardPage() { complexName: values.complexName, physicalAddress: values.physicalAddress, planCode: values.planCode, - }) - setCurrentComplexSlug(result.complexSlug) + }); + setCurrentComplexSlug(result.complexSlug); - const emailForSignIn = verifiedEmail ?? onboardingEmail + const emailForSignIn = verifiedEmail ?? onboardingEmail; if (emailForSignIn) { - await signInWithPassword({ email: emailForSignIn, password: values.password }) + await signInWithPassword({ email: emailForSignIn, password: values.password }); } - await navigate({ to: '/complex/$slug/edit', params: { slug: result.complexSlug } }) + await navigate({ to: '/complex/$slug/edit', params: { slug: result.complexSlug } }); } catch { - setErrorMessage('No pudimos completar el onboarding. Intenta nuevamente.') + setErrorMessage('No pudimos completar el onboarding. Intenta nuevamente.'); } - } + }; return ( )} - ) + ); } diff --git a/apps/frontend/src/features/onboard/onboarding.types.ts b/apps/frontend/src/features/onboard/onboarding.types.ts index aecb69c..6810078 100644 --- a/apps/frontend/src/features/onboard/onboarding.types.ts +++ b/apps/frontend/src/features/onboard/onboarding.types.ts @@ -2,15 +2,9 @@ import { onboardingCompleteSchema, onboardingStartSchema, onboardingVerifyOtpSchema, -} from '@repo/api-contract' -import { z } from 'zod' +} from '@repo/api-contract'; +import { z } from 'zod'; -export type StartValues = z.infer -export type VerifyOtpValues = Omit< - z.infer, - 'requestId' -> -export type CompleteValues = Omit< - z.infer, - 'onboardingRequestId' -> +export type StartValues = z.infer; +export type VerifyOtpValues = Omit, 'requestId'>; +export type CompleteValues = Omit, 'onboardingRequestId'>; diff --git a/apps/frontend/src/features/profile/profile-page.tsx b/apps/frontend/src/features/profile/profile-page.tsx index c5f3972..a5ef6d4 100644 --- a/apps/frontend/src/features/profile/profile-page.tsx +++ b/apps/frontend/src/features/profile/profile-page.tsx @@ -1,11 +1,5 @@ -import { Link } from '@tanstack/react-router' -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { useForm } from 'react-hook-form' -import { zodResolver } from '@hookform/resolvers/zod' -import { z } from 'zod' -import type { UserProfileResponse } from '@repo/api-contract' -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' -import { Button } from '@/components/ui/button' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; import { Form, FormControl, @@ -13,42 +7,51 @@ import { FormItem, FormLabel, FormMessage, -} from '@/components/ui/form' -import { Input } from '@/components/ui/input' -import { apiClient } from '@/lib/api-client' -import { useAuth } from '@/lib/auth' +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { apiClient } from '@/lib/api-client'; +import { useAuth } from '@/lib/auth'; +import { zodResolver } from '@hookform/resolvers/zod'; +import type { UserProfileResponse } from '@repo/api-contract'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Link } from '@tanstack/react-router'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; const updateProfileSchema = z.object({ fullName: z.string().min(1, 'El nombre es requerido'), -}) +}); -const updatePasswordSchema = z.object({ - currentPassword: z.string().min(1, 'La contraseña actual es requerida'), - newPassword: z.string().min(6, 'La nueva contraseña debe tener al menos 6 caracteres'), - confirmPassword: z.string().min(1, 'La confirmación de contraseña es requerida'), -}).refine((data) => data.newPassword === data.confirmPassword, { - message: 'Las contraseñas no coinciden', - path: ['confirmPassword'], -}) +const updatePasswordSchema = z + .object({ + currentPassword: z.string().min(1, 'La contraseña actual es requerida'), + newPassword: z.string().min(6, 'La nueva contraseña debe tener al menos 6 caracteres'), + confirmPassword: z.string().min(1, 'La confirmación de contraseña es requerida'), + }) + .refine((data) => data.newPassword === data.confirmPassword, { + message: 'Las contraseñas no coinciden', + path: ['confirmPassword'], + }); -type UpdateProfileForm = z.infer -type UpdatePasswordForm = z.infer +type UpdateProfileForm = z.infer; +type UpdatePasswordForm = z.infer; export function ProfilePage() { - const { user, displayName, initials, avatarUrl, isAuthenticated, updateProfile, updatePassword } = useAuth() - const queryClient = useQueryClient() + const { user, displayName, initials, avatarUrl, isAuthenticated, updateProfile, updatePassword } = + useAuth(); + const queryClient = useQueryClient(); const profileQuery = useQuery({ queryKey: ['user-profile'], enabled: isAuthenticated, queryFn: (): Promise => apiClient.user.getProfile(), - }) + }); const updateProfileForm = useForm({ resolver: zodResolver(updateProfileSchema), defaultValues: { fullName: displayName, }, - }) + }); const updatePasswordForm = useForm({ resolver: zodResolver(updatePasswordSchema), @@ -57,34 +60,34 @@ export function ProfilePage() { newPassword: '', confirmPassword: '', }, - }) + }); const updateProfileMutation = useMutation({ mutationFn: updateProfile, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['user-profile'] }) - updateProfileForm.reset() + queryClient.invalidateQueries({ queryKey: ['user-profile'] }); + updateProfileForm.reset(); }, - }) + }); const updatePasswordMutation = useMutation({ mutationFn: async (data: UpdatePasswordForm) => { // Note: Supabase doesn't require current password for password update // The user needs to be recently authenticated - await updatePassword({ password: data.newPassword }) + await updatePassword({ password: data.newPassword }); }, onSuccess: () => { - updatePasswordForm.reset() + updatePasswordForm.reset(); }, - }) + }); const onUpdateProfile = (data: UpdateProfileForm) => { - updateProfileMutation.mutate(data) - } + updateProfileMutation.mutate(data); + }; const onUpdatePassword = (data: UpdatePasswordForm) => { - updatePasswordMutation.mutate(data) - } + updatePasswordMutation.mutate(data); + }; if (!isAuthenticated) { return ( @@ -99,7 +102,7 @@ export function ProfilePage() { - ) + ); } return ( @@ -122,9 +125,7 @@ export function ProfilePage() { {profileQuery.data?.email ?? user?.email}

{profileQuery.data?.role && ( -

- Rol: {profileQuery.data.role} -

+

Rol: {profileQuery.data.role}

)}
@@ -157,10 +158,7 @@ export function ProfilePage() { )} /> - @@ -171,16 +169,17 @@ export function ProfilePage() {

)} {updateProfileMutation.isSuccess && ( -

- Perfil actualizado exitosamente. -

+

Perfil actualizado exitosamente.

)}

Cambiar ContraseƱa

- + )} /> - @@ -234,12 +230,10 @@ export function ProfilePage() {

)} {updatePasswordMutation.isSuccess && ( -

- ContraseƱa cambiada exitosamente. -

+

ContraseƱa cambiada exitosamente.

)}
- ) + ); } diff --git a/apps/frontend/src/features/public-booking/components/share-whatsapp-button.tsx b/apps/frontend/src/features/public-booking/components/share-whatsapp-button.tsx index 2100583..d0a9261 100644 --- a/apps/frontend/src/features/public-booking/components/share-whatsapp-button.tsx +++ b/apps/frontend/src/features/public-booking/components/share-whatsapp-button.tsx @@ -1,25 +1,25 @@ -import type { PublicBookingConfirmation } from '@repo/api-contract' -import { siWhatsapp } from 'simple-icons' -import { Button } from '@/components/ui/button' +import { Button } from '@/components/ui/button'; +import type { PublicBookingConfirmation } from '@repo/api-contract'; +import { siWhatsapp } from 'simple-icons'; type ShareWhatsappButtonProps = { - confirmation: PublicBookingConfirmation -} + confirmation: PublicBookingConfirmation; +}; function formatDateLabel(isoDate: string) { - const [year, month, day] = isoDate.split('-').map(Number) - const date = new Date(year, (month ?? 1) - 1, day ?? 1) + const [year, month, day] = isoDate.split('-').map(Number); + const date = new Date(year, (month ?? 1) - 1, day ?? 1); return new Intl.DateTimeFormat('es-AR', { weekday: 'long', day: '2-digit', month: 'long', year: 'numeric', - }).format(date) + }).format(date); } function createWhatsappMessage(confirmation: PublicBookingConfirmation) { - const dateLabel = formatDateLabel(confirmation.date) + const dateLabel = formatDateLabel(confirmation.date); return [ 'Ya reservamos cancha para jugar.', `Complejo: ${confirmation.complexName}`, @@ -27,13 +27,13 @@ function createWhatsappMessage(confirmation: PublicBookingConfirmation) { `Fecha: ${dateLabel}`, `Horario: ${confirmation.startTime} - ${confirmation.endTime}`, `Codigo de reserva: ${confirmation.bookingCode}`, - ].join('\n') + ].join('\n'); } export function ShareWhatsappButton({ confirmation }: ShareWhatsappButtonProps) { const whatsappUrl = `https://wa.me/?text=${encodeURIComponent( - createWhatsappMessage(confirmation), - )}` + createWhatsappMessage(confirmation) + )}`; return ( - ) + ); } diff --git a/apps/frontend/src/features/public-booking/public-booking-confirmation-page.tsx b/apps/frontend/src/features/public-booking/public-booking-confirmation-page.tsx index 5dd63ef..28406f2 100644 --- a/apps/frontend/src/features/public-booking/public-booking-confirmation-page.tsx +++ b/apps/frontend/src/features/public-booking/public-booking-confirmation-page.tsx @@ -1,43 +1,43 @@ -import { useQuery } from '@tanstack/react-query' -import { useNavigate } from '@tanstack/react-router' -import { Button } from '@/components/ui/button' -import { ApiClientError, apiClient } from '@/lib/api-client' -import { ShareWhatsappButton } from './components/share-whatsapp-button' +import { Button } from '@/components/ui/button'; +import { ApiClientError, apiClient } from '@/lib/api-client'; +import { useQuery } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; +import { ShareWhatsappButton } from './components/share-whatsapp-button'; type PublicBookingConfirmationPageProps = { - complexSlug: string - bookingCode: string -} + complexSlug: string; + bookingCode: string; +}; function extractMessage(error: unknown, fallback: string) { if (error instanceof ApiClientError) { - return error.message || fallback + return error.message || fallback; } - return fallback + return fallback; } function formatDateLabel(isoDate: string) { - const [year, month, day] = isoDate.split('-').map(Number) - const date = new Date(year, (month ?? 1) - 1, day ?? 1) + const [year, month, day] = isoDate.split('-').map(Number); + const date = new Date(year, (month ?? 1) - 1, day ?? 1); return new Intl.DateTimeFormat('es-AR', { weekday: 'long', day: '2-digit', month: 'long', year: 'numeric', - }).format(date) + }).format(date); } export function PublicBookingConfirmationPage({ complexSlug, bookingCode, }: PublicBookingConfirmationPageProps) { - const navigate = useNavigate() + const navigate = useNavigate(); const confirmationQuery = useQuery({ queryKey: ['public-booking-confirmation', complexSlug, bookingCode], queryFn: () => apiClient.publicBookings.getConfirmation(complexSlug, bookingCode), - }) + }); return (
@@ -51,7 +51,7 @@ export function PublicBookingConfirmationPage({

{extractMessage( confirmationQuery.error, - 'No pudimos cargar la confirmacion de la reserva.', + 'No pudimos cargar la confirmacion de la reserva.' )}

)} @@ -100,7 +100,7 @@ export function PublicBookingConfirmationPage({ void navigate({ to: '/$complexSlug/booking', params: { complexSlug }, - }) + }); }} > Hacer otra reserva @@ -111,5 +111,5 @@ export function PublicBookingConfirmationPage({
- ) + ); } diff --git a/apps/frontend/src/features/public-booking/public-booking-page.tsx b/apps/frontend/src/features/public-booking/public-booking-page.tsx index efb3513..52f9b8e 100644 --- a/apps/frontend/src/features/public-booking/public-booking-page.tsx +++ b/apps/frontend/src/features/public-booking/public-booking-page.tsx @@ -1,22 +1,22 @@ -import { zodResolver } from '@hookform/resolvers/zod' -import { useMutation, useQuery } from '@tanstack/react-query' -import { useNavigate } from '@tanstack/react-router' -import { useEffect, useMemo, useState } from 'react' -import { useForm } from 'react-hook-form' -import { z } from 'zod' -import { Button } from '@/components/ui/button' -import { Field, FieldError, FieldLabel } from '@/components/ui/field' -import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button'; +import { Field, FieldError, FieldLabel } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from '@/components/ui/select' -import { ApiClientError, apiClient } from '@/lib/api-client' +} from '@/components/ui/select'; +import { ApiClientError, apiClient } from '@/lib/api-client'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; +import { useEffect, useMemo, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; -const DAYS_STEP = 5 +const DAYS_STEP = 5; const bookingFormSchema = z.object({ customerName: z @@ -29,93 +29,93 @@ const bookingFormSchema = z.object({ .trim() .min(6, 'Ingresa un telefono valido.') .max(30, 'El telefono no puede superar los 30 caracteres.'), -}) +}); -type BookingFormValues = z.infer +type BookingFormValues = z.infer; type PublicBookingPageProps = { - complexSlug: string -} + complexSlug: string; +}; type SelectedSlot = { - courtId: string - courtName: string - sportId: string - sportName: string - startTime: string - endTime: string -} + courtId: string; + courtName: string; + sportId: string; + sportName: string; + startTime: string; + endTime: string; +}; function addDays(baseDate: Date, amount: number) { - const next = new Date(baseDate) - next.setDate(next.getDate() + amount) - return next + const next = new Date(baseDate); + next.setDate(next.getDate() + amount); + return next; } function toIsoDateLocal(date: Date) { - const year = date.getFullYear() - const month = String(date.getMonth() + 1).padStart(2, '0') - const day = String(date.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; } function toMinutes(value: string): number { - const [hours, minutes] = value.split(':').map((part) => Number(part)) - return hours * 60 + minutes + const [hours, minutes] = value.split(':').map((part) => Number(part)); + return hours * 60 + minutes; } function formatDayLabel(date: Date) { - const weekday = new Intl.DateTimeFormat('es-AR', { weekday: 'short' }).format(date) + const weekday = new Intl.DateTimeFormat('es-AR', { weekday: 'short' }).format(date); const dayMonth = new Intl.DateTimeFormat('es-AR', { day: '2-digit', month: '2-digit', - }).format(date) + }).format(date); return { weekday: weekday.replace('.', ''), dayMonth, - } + }; } function extractMessage(error: unknown, fallback: string) { if (error instanceof ApiClientError) { - return error.message || fallback + return error.message || fallback; } - return fallback + return fallback; } export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) { - const navigate = useNavigate() - const today = useMemo(() => new Date(), []) - const todayIsoDate = useMemo(() => toIsoDateLocal(today), [today]) - const [windowStartOffset, setWindowStartOffset] = useState(0) - const [selectedSportId, setSelectedSportId] = useState() - const [selectedSlot, setSelectedSlot] = useState(null) - const [navigationError, setNavigationError] = useState(null) - const [hasAutoAdjustedInitialDate, setHasAutoAdjustedInitialDate] = useState(false) - const [autoAdjustedDateNotice, setAutoAdjustedDateNotice] = useState(null) + const navigate = useNavigate(); + const today = useMemo(() => new Date(), []); + const todayIsoDate = useMemo(() => toIsoDateLocal(today), [today]); + const [windowStartOffset, setWindowStartOffset] = useState(0); + const [selectedSportId, setSelectedSportId] = useState(); + const [selectedSlot, setSelectedSlot] = useState(null); + const [navigationError, setNavigationError] = useState(null); + const [hasAutoAdjustedInitialDate, setHasAutoAdjustedInitialDate] = useState(false); + const [autoAdjustedDateNotice, setAutoAdjustedDateNotice] = useState(null); const dayOptions = useMemo(() => { return Array.from({ length: DAYS_STEP }).map((_, index) => { - const current = addDays(today, windowStartOffset + index) + const current = addDays(today, windowStartOffset + index); return { value: toIsoDateLocal(current), date: current, ...formatDayLabel(current), - } - }) - }, [today, windowStartOffset]) + }; + }); + }, [today, windowStartOffset]); - const [selectedDate, setSelectedDate] = useState(dayOptions[0]?.value ?? '') + const [selectedDate, setSelectedDate] = useState(dayOptions[0]?.value ?? ''); useEffect(() => { if (dayOptions.some((option) => option.value === selectedDate)) { - return + return; } - setSelectedDate(dayOptions[0]?.value ?? '') - }, [dayOptions, selectedDate]) + setSelectedDate(dayOptions[0]?.value ?? ''); + }, [dayOptions, selectedDate]); const availabilityQuery = useQuery({ queryKey: ['public-booking-availability', complexSlug, selectedDate, selectedSportId], @@ -125,26 +125,23 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) { date: selectedDate, ...(selectedSportId ? { sportId: selectedSportId } : {}), }), - }) + }); useEffect(() => { - const availability = availabilityQuery.data - if (!availability) return + const availability = availabilityQuery.data; + if (!availability) return; if (!availability.sportSelectionRequired) { if (!selectedSportId && availability.sports[0]) { - setSelectedSportId(availability.sports[0].id) + setSelectedSportId(availability.sports[0].id); } - return + return; } - if ( - selectedSportId && - !availability.sports.some((sport) => sport.id === selectedSportId) - ) { - setSelectedSportId(undefined) + if (selectedSportId && !availability.sports.some((sport) => sport.id === selectedSportId)) { + setSelectedSportId(undefined); } - }, [availabilityQuery.data, selectedSportId]) + }, [availabilityQuery.data, selectedSportId]); const { register, @@ -158,12 +155,12 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) { customerName: '', customerPhone: '', }, - }) + }); const createBookingMutation = useMutation({ mutationFn: (payload: BookingFormValues) => { if (!selectedSlot) { - throw new Error('Debes seleccionar un horario.') + throw new Error('Debes seleccionar un horario.'); } return apiClient.publicBookings.create(complexSlug, { @@ -173,123 +170,125 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) { startTime: selectedSlot.startTime, customerName: payload.customerName, customerPhone: payload.customerPhone, - }) + }); }, - }) + }); const onSubmit = async (values: BookingFormValues) => { - setNavigationError(null) - const booking = await createBookingMutation.mutateAsync(values) + setNavigationError(null); + const booking = await createBookingMutation.mutateAsync(values); if (!booking.bookingCode) { - setNavigationError('No se pudo obtener el codigo de confirmacion de la reserva.') - return + setNavigationError('No se pudo obtener el codigo de confirmacion de la reserva.'); + return; } - reset() - setSelectedSlot(null) - await availabilityQuery.refetch() + reset(); + setSelectedSlot(null); + await availabilityQuery.refetch(); await navigate({ to: '/$complexSlug/booking/confirmed/$bookingCode', params: { complexSlug, bookingCode: booking.bookingCode, }, - }) - } + }); + }; const visibleCourts = useMemo(() => { - const courts = availabilityQuery.data?.courts ?? [] + const courts = availabilityQuery.data?.courts ?? []; if (selectedDate !== todayIsoDate) { - return courts + return courts; } - const now = new Date() - const nowMinutes = now.getHours() * 60 + now.getMinutes() + const now = new Date(); + const nowMinutes = now.getHours() * 60 + now.getMinutes(); return courts .map((court) => ({ ...court, availableSlots: court.availableSlots.filter( - (slot) => toMinutes(slot.startTime) >= nowMinutes, + (slot) => toMinutes(slot.startTime) >= nowMinutes ), })) - .filter((court) => court.availableSlots.length > 0) - }, [availabilityQuery.data?.courts, selectedDate, todayIsoDate]) + .filter((court) => court.availableSlots.length > 0); + }, [availabilityQuery.data?.courts, selectedDate, todayIsoDate]); useEffect(() => { - if (!selectedSlot) return + if (!selectedSlot) return; const stillAvailable = visibleCourts.some( (court) => court.courtId === selectedSlot.courtId && - court.availableSlots.some((slot) => slot.startTime === selectedSlot.startTime), - ) + court.availableSlots.some((slot) => slot.startTime === selectedSlot.startTime) + ); if (!stillAvailable) { - setSelectedSlot(null) + setSelectedSlot(null); } - }, [selectedSlot, visibleCourts]) + }, [selectedSlot, visibleCourts]); useEffect(() => { - if (hasAutoAdjustedInitialDate) return + if (hasAutoAdjustedInitialDate) return; if (selectedDate !== todayIsoDate) { - setHasAutoAdjustedInitialDate(true) - return + setHasAutoAdjustedInitialDate(true); + return; } if (availabilityQuery.isLoading || availabilityQuery.isError || !availabilityQuery.data) { - return + return; } if (availabilityQuery.data.sportSelectionRequired && !selectedSportId) { - return + return; } if (visibleCourts.length > 0) { - setHasAutoAdjustedInitialDate(true) - return + setHasAutoAdjustedInitialDate(true); + return; } - let cancelled = false + let cancelled = false; const findNextAvailableDate = async () => { for (let dayOffset = 1; dayOffset <= 30; dayOffset += 1) { - const candidateDate = toIsoDateLocal(addDays(today, dayOffset)) + const candidateDate = toIsoDateLocal(addDays(today, dayOffset)); try { const availability = await apiClient.publicBookings.getAvailability(complexSlug, { date: candidateDate, ...(selectedSportId ? { sportId: selectedSportId } : {}), - }) + }); if (availability.courts.length === 0) { - continue + continue; } - if (cancelled) return + if (cancelled) return; - setWindowStartOffset(dayOffset) - setSelectedDate(candidateDate) - setSelectedSlot(null) - setAutoAdjustedDateNotice('No habia turnos disponibles para hoy. Te mostramos el proximo dia con turnos.') - setHasAutoAdjustedInitialDate(true) - return + setWindowStartOffset(dayOffset); + setSelectedDate(candidateDate); + setSelectedSlot(null); + setAutoAdjustedDateNotice( + 'No habia turnos disponibles para hoy. Te mostramos el proximo dia con turnos.' + ); + setHasAutoAdjustedInitialDate(true); + return; } catch { - break + break; } } if (!cancelled) { - setHasAutoAdjustedInitialDate(true) + setHasAutoAdjustedInitialDate(true); } - } + }; - void findNextAvailableDate() + void findNextAvailableDate(); return () => { - cancelled = true - } + cancelled = true; + }; }, [ availabilityQuery.data, availabilityQuery.isError, @@ -301,33 +300,33 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) { today, todayIsoDate, visibleCourts.length, - ]) + ]); - const canGoBack = windowStartOffset > 0 + const canGoBack = windowStartOffset > 0; const goToPreviousFiveDays = () => { - if (!canGoBack) return + if (!canGoBack) return; setWindowStartOffset((previous) => { - const next = Math.max(0, previous - DAYS_STEP) - const nextStartDate = toIsoDateLocal(addDays(today, next)) - setSelectedDate(nextStartDate) - setSelectedSlot(null) - setAutoAdjustedDateNotice(null) - return next - }) - } + const next = Math.max(0, previous - DAYS_STEP); + const nextStartDate = toIsoDateLocal(addDays(today, next)); + setSelectedDate(nextStartDate); + setSelectedSlot(null); + setAutoAdjustedDateNotice(null); + return next; + }); + }; const goToNextFiveDays = () => { setWindowStartOffset((previous) => { - const next = previous + DAYS_STEP - const nextStartDate = toIsoDateLocal(addDays(today, next)) - setSelectedDate(nextStartDate) - setSelectedSlot(null) - setAutoAdjustedDateNotice(null) - return next - }) - } + const next = previous + DAYS_STEP; + const nextStartDate = toIsoDateLocal(addDays(today, next)); + setSelectedDate(nextStartDate); + setSelectedSlot(null); + setAutoAdjustedDateNotice(null); + return next; + }); + }; return (
@@ -372,9 +371,9 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) { key={option.value} type="button" onClick={() => { - setSelectedDate(option.value) - setSelectedSlot(null) - setAutoAdjustedDateNotice(null) + setSelectedDate(option.value); + setSelectedSlot(null); + setAutoAdjustedDateNotice(null); }} className={`rounded-xl border px-1 py-2 text-center transition-colors sm:px-2 ${ selectedDate === option.value @@ -416,9 +415,9 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {