feat: add city/state/country to complex, new settings page with sidebar, and Biome linting

- Added city, state, and country optional fields to Complex model
- Updated onboarding to include optional location fields
- Created new settings page with sidebar navigation (Datos del Complejo, Canchas)
- Replaced ESLint with Biome for frontend and backend linting
- Added parallel dev script with concurrently
- Migrated register-routes to use direct app.route() pattern
This commit is contained in:
Jose Selesan
2026-04-10 15:36:15 -03:00
parent 77004b14b0
commit 9ee98a4cb4
129 changed files with 2929 additions and 3186 deletions

View File

@@ -3,6 +3,9 @@
"scripts": { "scripts": {
"dev": "bun run --hot src/server.ts", "dev": "bun run --hot src/server.ts",
"start": "bun 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:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev", "prisma:migrate": "prisma migrate dev",
"prisma:migrate:deploy": "prisma migrate deploy", "prisma:migrate:deploy": "prisma migrate deploy",

View File

@@ -1,5 +1,5 @@
import 'dotenv/config' import 'dotenv/config';
import { defineConfig, env } from 'prisma/config' import { defineConfig, env } from 'prisma/config';
export default defineConfig({ export default defineConfig({
schema: 'prisma', schema: 'prisma',
@@ -10,4 +10,4 @@ export default defineConfig({
datasource: { datasource: {
url: env('DATABASE_URL'), url: env('DATABASE_URL'),
}, },
}) });

View File

@@ -74,4 +74,4 @@ export const planSeeds = [
}, },
}, },
}, },
] as const ] as const;

View File

@@ -1,33 +1,33 @@
import 'dotenv/config' import 'dotenv/config';
import { PrismaPg } from '@prisma/adapter-pg' import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../src/generated/prisma/client' import { PrismaClient } from '../src/generated/prisma/client';
import { planSeeds } from './plans.seed-data' import { planSeeds } from './plans.seed-data';
const databaseUrl = process.env.DATABASE_URL const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) { 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({ const adapter = new PrismaPg({
connectionString: databaseUrl, connectionString: databaseUrl,
}) });
const prisma = new PrismaClient({ adapter }) const prisma = new PrismaClient({ adapter });
async function run() { async function run() {
const complexesUsingPlans = await prisma.complex.count({ const complexesUsingPlans = await prisma.complex.count({
where: { planCode: { not: null } }, where: { planCode: { not: null } },
}) });
if (complexesUsingPlans > 0) { if (complexesUsingPlans > 0) {
throw new Error( 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 prisma.$transaction(async (tx) => {
await tx.plan.deleteMany({}) await tx.plan.deleteMany({});
for (const plan of planSeeds) { for (const plan of planSeeds) {
await tx.plan.create({ await tx.plan.create({
@@ -37,19 +37,19 @@ async function run() {
price: plan.price, price: plan.price,
rules: plan.rules, rules: plan.rules,
}, },
}) });
} }
}) });
console.log('Planes reseteados: BASIC, ADVANCED, ENTERPRISE') console.log('Planes reseteados: BASIC, ADVANCED, ENTERPRISE');
} }
run() run()
.then(async () => { .then(async () => {
await prisma.$disconnect() await prisma.$disconnect();
}) })
.catch(async (error) => { .catch(async (error) => {
console.error(error) console.error(error);
await prisma.$disconnect() await prisma.$disconnect();
process.exit(1) process.exit(1);
}) });

View File

@@ -1,21 +1,21 @@
import 'dotenv/config' import 'dotenv/config';
import { PrismaPg } from '@prisma/adapter-pg' import { PrismaPg } from '@prisma/adapter-pg';
import { v7 as uuidv7 } from 'uuid' import { v7 as uuidv7 } from 'uuid';
import { PrismaClient } from '../src/generated/prisma/client' import { PrismaClient } from '../src/generated/prisma/client';
import { planSeeds } from './plans.seed-data' import { planSeeds } from './plans.seed-data';
import { sportSeeds } from './sports.seed-data' import { sportSeeds } from './sports.seed-data';
const databaseUrl = process.env.DATABASE_URL const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) { 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({ const adapter = new PrismaPg({
connectionString: databaseUrl, connectionString: databaseUrl,
}) });
const prisma = new PrismaClient({ adapter }) const prisma = new PrismaClient({ adapter });
async function run() { async function run() {
for (const plan of planSeeds) { for (const plan of planSeeds) {
@@ -33,7 +33,7 @@ async function run() {
price: plan.price, price: plan.price,
rules: plan.rules, rules: plan.rules,
}, },
}) });
} }
for (const sport of sportSeeds) { for (const sport of sportSeeds) {
@@ -49,16 +49,16 @@ async function run() {
slug: sport.slug, slug: sport.slug,
isActive: true, isActive: true,
}, },
}) });
} }
} }
run() run()
.then(async () => { .then(async () => {
await prisma.$disconnect() await prisma.$disconnect();
}) })
.catch(async (error) => { .catch(async (error) => {
console.error(error) console.error(error);
await prisma.$disconnect() await prisma.$disconnect();
process.exit(1) process.exit(1);
}) });

View File

@@ -5,4 +5,4 @@ export const sportSeeds = [
{ name: 'Pickleball', slug: 'pickleball' }, { name: 'Pickleball', slug: 'pickleball' },
{ name: 'Basquet', slug: 'basquet' }, { name: 'Basquet', slug: 'basquet' },
{ name: 'Basquet 3', slug: 'basquet-3' }, { name: 'Basquet 3', slug: 'basquet-3' },
] as const ] as const;

View File

@@ -1,36 +1,33 @@
import { Hono } from 'hono' import { logger } from '@/lib/logger';
import { cors } from 'hono/cors' import type { AppEnv } from '@/types/hono';
import { logger } from '@/lib/logger' import { Hono } from 'hono';
import type { AppEnv } from '@/types/hono' import { cors } from 'hono/cors';
import { requestId } from 'hono/request-id' import { requestId } from 'hono/request-id';
export function createApp() { export function createApp() {
const app = new Hono<AppEnv>() const app = new Hono<AppEnv>();
const allowedOrigins = ( const allowedOrigins = (Bun.env.CORS_ORIGIN ?? 'http://localhost:5173,http://127.0.0.1:5173')
Bun.env.CORS_ORIGIN ??
'http://localhost:5173,http://127.0.0.1:5173'
)
.split(',') .split(',')
.map((value) => value.trim()) .map((value) => value.trim())
.filter(Boolean) .filter(Boolean);
app.use( app.use(
'*', '*',
cors({ cors({
origin: (origin) => { origin: (origin) => {
if (!origin) return allowedOrigins[0] ?? '' if (!origin) return allowedOrigins[0] ?? '';
return allowedOrigins.includes(origin) ? origin : '' return allowedOrigins.includes(origin) ? origin : '';
}, },
allowHeaders: ['Authorization', 'Content-Type'], allowHeaders: ['Authorization', 'Content-Type'],
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
}), })
) );
app.use('*', async (c, next) => { app.use('*', async (c, next) => {
const start = performance.now() const start = performance.now();
await next() await next();
const durationMs = Number((performance.now() - start).toFixed(1)) const durationMs = Number((performance.now() - start).toFixed(1));
logger.info( logger.info(
{ {
@@ -38,11 +35,11 @@ export function createApp() {
path: c.req.path, path: c.req.path,
status: c.res.status, status: c.res.status,
durationMs, durationMs,
requestId requestId,
}, },
'http_request', 'http_request'
) );
}) });
return app return app;
} }

View File

@@ -1,8 +1,8 @@
import { createApp } from '@/app' import { createApp } from '@/app';
import { registerRoutes } from '@/register-routes' import { registerRoutes } from '@/register-routes';
const app = createApp() const app = createApp();
registerRoutes(app) registerRoutes(app);
export type AppType = typeof app export type AppType = typeof app;
export default app export default app;

View File

@@ -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({ export const logger = pino({
level: Bun.env.LOG_LEVEL ?? (isProd ? 'info' : 'debug'), level: Bun.env.LOG_LEVEL ?? (isProd ? 'info' : 'debug'),
@@ -15,4 +15,4 @@ export const logger = pino({
ignore: 'pid,hostname', ignore: 'pid,hostname',
}, },
}, },
}) });

View File

@@ -1,19 +1,17 @@
import nodemailer from 'nodemailer' import nodemailer from 'nodemailer';
let cachedTransporter: ReturnType<typeof nodemailer.createTransport> | null = null let cachedTransporter: ReturnType<typeof nodemailer.createTransport> | null = null;
function getTransporter() { function getTransporter() {
if (cachedTransporter) return cachedTransporter if (cachedTransporter) return cachedTransporter;
const smtpHost = Bun.env.SMTP_HOST const smtpHost = Bun.env.SMTP_HOST;
const smtpPort = Number(Bun.env.SMTP_PORT ?? 587) const smtpPort = Number(Bun.env.SMTP_PORT ?? 587);
const smtpUser = Bun.env.SMTP_USER const smtpUser = Bun.env.SMTP_USER;
const smtpPass = Bun.env.SMTP_PASS const smtpPass = Bun.env.SMTP_PASS;
if (!smtpHost || !smtpUser || !smtpPass) { if (!smtpHost || !smtpUser || !smtpPass) {
throw new Error( throw new Error('Missing SMTP env vars. Set SMTP_HOST, SMTP_PORT, SMTP_USER and SMTP_PASS.');
'Missing SMTP env vars. Set SMTP_HOST, SMTP_PORT, SMTP_USER and SMTP_PASS.',
)
} }
cachedTransporter = nodemailer.createTransport({ cachedTransporter = nodemailer.createTransport({
@@ -24,21 +22,21 @@ function getTransporter() {
user: smtpUser, user: smtpUser,
pass: smtpPass, pass: smtpPass,
}, },
}) });
return cachedTransporter return cachedTransporter;
} }
export async function sendMail(input: { export async function sendMail(input: {
to: string to: string;
subject: string subject: string;
html: string html: string;
text: string text: string;
}) { }) {
const smtpFrom = Bun.env.SMTP_FROM const smtpFrom = Bun.env.SMTP_FROM;
if (!smtpFrom) { if (!smtpFrom) {
throw new Error('Missing SMTP_FROM env var.') throw new Error('Missing SMTP_FROM env var.');
} }
await getTransporter().sendMail({ await getTransporter().sendMail({
@@ -47,5 +45,5 @@ export async function sendMail(input: {
subject: input.subject, subject: input.subject,
html: input.html, html: input.html,
text: input.text, text: input.text,
}) });
} }

View File

@@ -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) { function hasExpectedDelegates(client: PrismaClient) {
const prismaClient = client as unknown as { const prismaClient = client as unknown as {
court?: { findMany?: unknown } court?: { findMany?: unknown };
sport?: { findMany?: unknown } sport?: { findMany?: unknown };
} };
return ( return (
typeof prismaClient.court?.findMany === 'function' && typeof prismaClient.court?.findMany === 'function' &&
typeof prismaClient.sport?.findMany === 'function' typeof prismaClient.sport?.findMany === 'function'
) );
} }
function buildPrismaClient() { function buildPrismaClient() {
const databaseUrl = Bun.env.DATABASE_URL const databaseUrl = Bun.env.DATABASE_URL;
if (!databaseUrl) { if (!databaseUrl) {
throw new Error('Missing DATABASE_URL in backend environment.') throw new Error('Missing DATABASE_URL in backend environment.');
} }
const adapter = new PrismaPg({ const adapter = new PrismaPg({
connectionString: databaseUrl, connectionString: databaseUrl,
}) });
return new PrismaClient({ adapter }) return new PrismaClient({ adapter });
} }
export function getPrismaClient() { export function getPrismaClient() {
if (globalForPrisma.prisma && hasExpectedDelegates(globalForPrisma.prisma)) { if (globalForPrisma.prisma && hasExpectedDelegates(globalForPrisma.prisma)) {
return globalForPrisma.prisma return globalForPrisma.prisma;
} }
if (globalForPrisma.prisma && !hasExpectedDelegates(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') { if (Bun.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma globalForPrisma.prisma = prisma;
} }
return prisma return prisma;
} }
export const db = getPrismaClient() export const db = getPrismaClient();

View File

@@ -1,17 +1,17 @@
import { createClient } from '@supabase/supabase-js' import { createClient } from '@supabase/supabase-js';
let cachedClient: ReturnType<typeof createClient> | null = null let cachedClient: ReturnType<typeof createClient> | null = null;
export function getSupabaseAdminClient() { export function getSupabaseAdminClient() {
if (cachedClient) return cachedClient if (cachedClient) return cachedClient;
const supabaseUrl = Bun.env.SUPABASE_URL ?? Bun.env.VITE_SUPABASE_URL const supabaseUrl = Bun.env.SUPABASE_URL ?? Bun.env.VITE_SUPABASE_URL;
const supabaseServiceRoleKey = Bun.env.SUPABASE_SERVICE_ROLE_KEY const supabaseServiceRoleKey = Bun.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !supabaseServiceRoleKey) { if (!supabaseUrl || !supabaseServiceRoleKey) {
throw new Error( 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, { cachedClient = createClient(supabaseUrl, supabaseServiceRoleKey, {
@@ -20,7 +20,7 @@ export function getSupabaseAdminClient() {
persistSession: false, persistSession: false,
detectSessionInUrl: false, detectSessionInUrl: false,
}, },
}) });
return cachedClient return cachedClient;
} }

View File

@@ -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 supabaseUrl = Bun.env.SUPABASE_URL ?? Bun.env.VITE_SUPABASE_URL;
const supabaseAnonKey = const supabaseAnonKey = Bun.env.SUPABASE_ANON_KEY ?? Bun.env.VITE_SUPABASE_ANON_KEY;
Bun.env.SUPABASE_ANON_KEY ?? Bun.env.VITE_SUPABASE_ANON_KEY
if (!supabaseUrl || !supabaseAnonKey) { if (!supabaseUrl || !supabaseAnonKey) {
throw new Error( 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, { export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
@@ -16,4 +15,4 @@ export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
persistSession: false, persistSession: false,
detectSessionInUrl: false, detectSessionInUrl: false,
}, },
}) });

View File

@@ -1,35 +1,32 @@
import type { MiddlewareHandler } from 'hono' import { db } from '@/lib/prisma';
import { db } from '@/lib/prisma' import { supabase } from '@/lib/supabase';
import { supabase } from '@/lib/supabase' import type { AppEnv } from '@/types/hono';
import type { AppEnv } from '@/types/hono' import type { MiddlewareHandler } from 'hono';
function getBearerToken(value: string | undefined): string | null { function getBearerToken(value: string | undefined): string | null {
if (!value) return null if (!value) return null;
const [scheme, token] = value.split(' ') const [scheme, token] = value.split(' ');
if (scheme?.toLowerCase() !== 'bearer' || !token) return null if (scheme?.toLowerCase() !== 'bearer' || !token) return null;
return token return token;
} }
export const requireAuth: MiddlewareHandler<AppEnv> = async ( export const requireAuth: MiddlewareHandler<AppEnv> = async (c, next) => {
c, const token = getBearerToken(c.req.header('authorization'));
next,
) => {
const token = getBearerToken(c.req.header('authorization'))
if (!token) { 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) { 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) { 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({ const appUser = await db.user.findFirst({
@@ -38,13 +35,13 @@ export const requireAuth: MiddlewareHandler<AppEnv> = async (
email, email,
}, },
select: { id: true }, select: { id: true },
}) });
if (!appUser) { 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('authUser', data.user);
c.set('appUserId', appUser.id) c.set('appUserId', appUser.id);
await next() await next();
} };

View File

@@ -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<string> { function getSuperAdminEmails(): Set<string> {
const raw = Bun.env.SUPER_ADMIN_EMAILS ?? '' const raw = Bun.env.SUPER_ADMIN_EMAILS ?? '';
const emails = raw const emails = raw
.split(',') .split(',')
.map((value) => value.trim().toLowerCase()) .map((value) => value.trim().toLowerCase())
.filter(Boolean) .filter(Boolean);
return new Set(emails) return new Set(emails);
} }
export const requireSuperAdmin: MiddlewareHandler<AppEnv> = async ( export const requireSuperAdmin: MiddlewareHandler<AppEnv> = async (c, next) => {
c, const authUser = c.get('authUser');
next, const role = typeof authUser.app_metadata?.role === 'string' ? authUser.app_metadata.role : null;
) => { const email = authUser.email?.toLowerCase();
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') { if (role === 'super_admin') {
await next() await next();
return return;
} }
if (email && getSuperAdminEmails().has(email)) { if (email && getSuperAdminEmails().has(email)) {
await next() await next();
return 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);
} };

View File

@@ -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 { import {
createAdminBookingSchema, createAdminBookingSchema,
listAdminBookingsQuerySchema, listAdminBookingsQuerySchema,
updateAdminBookingStatusSchema, updateAdminBookingStatusSchema,
} from '@repo/api-contract' } from '@repo/api-contract';
import { Hono } from 'hono' import { Hono } from 'hono';
import { z } from 'zod' 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'
export const adminBookingRoutes = new Hono<AppEnv>() export const adminBookingRoutes = new Hono<AppEnv>();
const complexIdParamsSchema = z.object({ complexId: z.uuid() }) const complexIdParamsSchema = z.object({ complexId: z.uuid() });
const bookingIdParamsSchema = z.object({ id: z.uuid() }) const bookingIdParamsSchema = z.object({ id: z.uuid() });
adminBookingRoutes.use('*', requireAuth) adminBookingRoutes.use('*', requireAuth);
adminBookingRoutes.get( adminBookingRoutes.get(
'/complex/:complexId', '/complex/:complexId',
zValidator('param', complexIdParamsSchema), zValidator('param', complexIdParamsSchema),
zValidator('query', listAdminBookingsQuerySchema), zValidator('query', listAdminBookingsQuerySchema),
listAdminBookingsHandler, listAdminBookingsHandler
) );
adminBookingRoutes.post( adminBookingRoutes.post(
'/complex/:complexId', '/complex/:complexId',
zValidator('param', complexIdParamsSchema), zValidator('param', complexIdParamsSchema),
zValidator('json', createAdminBookingSchema), zValidator('json', createAdminBookingSchema),
createAdminBookingHandler, createAdminBookingHandler
) );
adminBookingRoutes.patch( adminBookingRoutes.patch(
'/:id/status', '/:id/status',
zValidator('param', bookingIdParamsSchema), zValidator('param', bookingIdParamsSchema),
zValidator('json', updateAdminBookingStatusSchema), zValidator('json', updateAdminBookingStatusSchema),
updateAdminBookingStatusHandler, updateAdminBookingStatusHandler
) );

View File

@@ -1,24 +1,24 @@
import type { CreateAdminBookingInput } from '@repo/api-contract'
import { import {
AdminBookingServiceError, AdminBookingServiceError,
createAdminBooking, createAdminBooking,
} from '@/modules/admin-booking/services/admin-booking.service' } from '@/modules/admin-booking/services/admin-booking.service';
import type { AppContext } from '@/types/hono' 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) { export async function createAdminBookingHandler(c: AppContext) {
const { complexId } = c.req.valid('param' as never) as ComplexIdParams const { complexId } = c.req.valid('param' as never) as ComplexIdParams;
const payload = c.req.valid('json' as never) as CreateAdminBookingInput const payload = c.req.valid('json' as never) as CreateAdminBookingInput;
const appUserId = c.get('appUserId') const appUserId = c.get('appUserId');
try { try {
const booking = await createAdminBooking(appUserId, complexId, payload) const booking = await createAdminBooking(appUserId, complexId, payload);
return c.json(booking, 201) return c.json(booking, 201);
} catch (error) { } catch (error) {
if (error instanceof AdminBookingServiceError) { if (error instanceof AdminBookingServiceError) {
return c.json({ message: error.message }, error.status) return c.json({ message: error.message }, error.status);
} }
throw error throw error;
} }
} }

View File

@@ -1,24 +1,24 @@
import type { ListAdminBookingsQuery } from '@repo/api-contract'
import { import {
AdminBookingServiceError, AdminBookingServiceError,
listAdminBookings, listAdminBookings,
} from '@/modules/admin-booking/services/admin-booking.service' } from '@/modules/admin-booking/services/admin-booking.service';
import type { AppContext } from '@/types/hono' 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) { export async function listAdminBookingsHandler(c: AppContext) {
const { complexId } = c.req.valid('param' as never) as ComplexIdParams const { complexId } = c.req.valid('param' as never) as ComplexIdParams;
const query = c.req.valid('query' as never) as ListAdminBookingsQuery const query = c.req.valid('query' as never) as ListAdminBookingsQuery;
const appUserId = c.get('appUserId') const appUserId = c.get('appUserId');
try { try {
const response = await listAdminBookings(appUserId, complexId, query) const response = await listAdminBookings(appUserId, complexId, query);
return c.json(response) return c.json(response);
} catch (error) { } catch (error) {
if (error instanceof AdminBookingServiceError) { if (error instanceof AdminBookingServiceError) {
return c.json({ message: error.message }, error.status) return c.json({ message: error.message }, error.status);
} }
throw error throw error;
} }
} }

View File

@@ -1,24 +1,24 @@
import type { UpdateAdminBookingStatusInput } from '@repo/api-contract'
import { import {
AdminBookingServiceError, AdminBookingServiceError,
updateAdminBookingStatus, updateAdminBookingStatus,
} from '@/modules/admin-booking/services/admin-booking.service' } from '@/modules/admin-booking/services/admin-booking.service';
import type { AppContext } from '@/types/hono' 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) { export async function updateAdminBookingStatusHandler(c: AppContext) {
const { id } = c.req.valid('param' as never) as BookingIdParams const { id } = c.req.valid('param' as never) as BookingIdParams;
const payload = c.req.valid('json' as never) as UpdateAdminBookingStatusInput const payload = c.req.valid('json' as never) as UpdateAdminBookingStatusInput;
const appUserId = c.get('appUserId') const appUserId = c.get('appUserId');
try { try {
const booking = await updateAdminBookingStatus(appUserId, id, payload) const booking = await updateAdminBookingStatus(appUserId, id, payload);
return c.json(booking) return c.json(booking);
} catch (error) { } catch (error) {
if (error instanceof AdminBookingServiceError) { if (error instanceof AdminBookingServiceError) {
return c.json({ message: error.message }, error.status) return c.json({ message: error.message }, error.status);
} }
throw error throw error;
} }
} }

View File

@@ -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 { import type {
AdminBooking, AdminBooking,
CreateAdminBookingInput, CreateAdminBookingInput,
ListAdminBookingsQuery, ListAdminBookingsQuery,
UpdateAdminBookingStatusInput, UpdateAdminBookingStatusInput,
} from '@repo/api-contract' } from '@repo/api-contract';
import { randomInt } from 'node:crypto' import { v7 as uuidv7 } from 'uuid';
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'
type Slot = { type Slot = {
startTime: string startTime: string;
endTime: string endTime: string;
} };
const DAY_OF_WEEK_BY_INDEX = [ const DAY_OF_WEEK_BY_INDEX = [
'SUNDAY', 'SUNDAY',
@@ -23,44 +23,44 @@ const DAY_OF_WEEK_BY_INDEX = [
'THURSDAY', 'THURSDAY',
'FRIDAY', 'FRIDAY',
'SATURDAY', 'SATURDAY',
] as const ] as const;
const BOOKING_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' const BOOKING_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const BOOKING_CODE_LENGTH = 6 const BOOKING_CODE_LENGTH = 6;
export class AdminBookingServiceError extends Error { export class AdminBookingServiceError extends Error {
status: 400 | 403 | 404 | 409 status: 400 | 403 | 404 | 409;
constructor(message: string, status: 400 | 403 | 404 | 409) { constructor(message: string, status: 400 | 403 | 404 | 409) {
super(message) super(message);
this.name = 'AdminBookingServiceError' this.name = 'AdminBookingServiceError';
this.status = status this.status = status;
} }
} }
function toMinutes(value: string): number { function toMinutes(value: string): number {
const [hours, minutes] = value.split(':').map((part) => Number(part)) const [hours, minutes] = value.split(':').map((part) => Number(part));
return hours * 60 + minutes return hours * 60 + minutes;
} }
function minutesToTime(minutes: number): string { function minutesToTime(minutes: number): string {
const safeMinutes = Math.max(0, minutes) const safeMinutes = Math.max(0, minutes);
const hours = Math.floor(safeMinutes / 60) const hours = Math.floor(safeMinutes / 60);
const mins = safeMinutes % 60 const mins = safeMinutes % 60;
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}` return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
} }
function parseIsoDate(date: string): Date { 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) { 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 year = Number(match[1]);
const month = Number(match[2]) const month = Number(match[2]);
const day = Number(match[3]) 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 ( if (
Number.isNaN(bookingDate.getTime()) || Number.isNaN(bookingDate.getTime()) ||
@@ -68,48 +68,48 @@ function parseIsoDate(date: string): Date {
bookingDate.getUTCMonth() + 1 !== month || bookingDate.getUTCMonth() + 1 !== month ||
bookingDate.getUTCDate() !== day 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) { function getDayOfWeek(date: Date) {
const dayOfWeek = DAY_OF_WEEK_BY_INDEX[date.getUTCDay()] const dayOfWeek = DAY_OF_WEEK_BY_INDEX[date.getUTCDay()];
if (!dayOfWeek) { 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 { function formatIsoDate(date: Date): string {
return date.toISOString().slice(0, 10) return date.toISOString().slice(0, 10);
} }
function generateBookingCode(): string { function generateBookingCode(): string {
let code = '' let code = '';
for (let index = 0; index < BOOKING_CODE_LENGTH; index += 1) { 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( function buildSlots(
availability: Array<{ availability: Array<{
startTime: string startTime: string;
endTime: string endTime: string;
}>, }>,
slotDurationMinutes: number, slotDurationMinutes: number
): Slot[] { ): Slot[] {
const slots: Slot[] = [] const slots: Slot[] = [];
for (const range of availability) { for (const range of availability) {
const start = toMinutes(range.startTime) const start = toMinutes(range.startTime);
const end = toMinutes(range.endTime) const end = toMinutes(range.endTime);
for ( for (
let current = start; let current = start;
@@ -119,11 +119,11 @@ function buildSlots(
slots.push({ slots.push({
startTime: minutesToTime(current), startTime: minutesToTime(current),
endTime: minutesToTime(current + slotDurationMinutes), endTime: minutesToTime(current + slotDurationMinutes),
}) });
} }
} }
return slots return slots;
} }
async function ensureComplexAccess(complexId: string, appUserId: string) { async function ensureComplexAccess(complexId: string, appUserId: string) {
@@ -147,39 +147,39 @@ async function ensureComplexAccess(complexId: string, appUserId: string) {
}, },
}, },
}, },
}) });
if (!complexUser) { 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: { function mapBookingResponse(booking: {
id: string id: string;
bookingCode: string bookingCode: string;
bookingDate: Date bookingDate: Date;
startTime: string startTime: string;
endTime: string endTime: string;
customerName: string customerName: string;
customerPhone: string customerPhone: string;
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED';
createdAt: Date createdAt: Date;
updatedAt: Date updatedAt: Date;
court: { court: {
id: string id: string;
name: string name: string;
complex: { complex: {
id: string id: string;
complexName: string complexName: string;
} };
sport: { sport: {
id: string id: string;
name: string name: string;
slug: string slug: string;
} };
} };
}): AdminBooking { }): AdminBooking {
return { return {
id: booking.id, id: booking.id,
@@ -201,16 +201,16 @@ function mapBookingResponse(booking: {
status: booking.status, status: booking.status,
createdAt: booking.createdAt.toISOString(), createdAt: booking.createdAt.toISOString(),
updatedAt: booking.updatedAt.toISOString(), updatedAt: booking.updatedAt.toISOString(),
} };
} }
export async function listAdminBookings( export async function listAdminBookings(
appUserId: string, appUserId: string,
complexId: string, complexId: string,
query: ListAdminBookingsQuery, query: ListAdminBookingsQuery
) { ) {
await ensureComplexAccess(complexId, appUserId) await ensureComplexAccess(complexId, appUserId);
const fromDate = parseIsoDate(query.fromDate) const fromDate = parseIsoDate(query.fromDate);
const bookings = await db.courtBooking.findMany({ const bookings = await db.courtBooking.findMany({
where: { where: {
@@ -243,21 +243,21 @@ export async function listAdminBookings(
}, },
}, },
orderBy: [{ bookingDate: 'asc' }, { startTime: 'asc' }, { createdAt: 'asc' }], orderBy: [{ bookingDate: 'asc' }, { startTime: 'asc' }, { createdAt: 'asc' }],
}) });
return { return {
bookings: bookings.map((booking) => mapBookingResponse(booking)), bookings: bookings.map((booking) => mapBookingResponse(booking)),
} };
} }
export async function createAdminBooking( export async function createAdminBooking(
appUserId: string, appUserId: string,
complexId: string, complexId: string,
input: CreateAdminBookingInput, input: CreateAdminBookingInput
) { ) {
const complex = await ensureComplexAccess(complexId, appUserId) const complex = await ensureComplexAccess(complexId, appUserId);
const bookingDate = parseIsoDate(input.date) const bookingDate = parseIsoDate(input.date);
const dayOfWeek = getDayOfWeek(bookingDate) const dayOfWeek = getDayOfWeek(bookingDate);
const court = await db.court.findFirst({ const court = await db.court.findFirst({
where: { where: {
@@ -281,36 +281,36 @@ export async function createAdminBooking(
}, },
}, },
}, },
}) });
if (!court) { 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 validSlots = buildSlots(court.availabilities, court.slotDurationMinutes);
const selectedStartMinutes = toMinutes(input.startTime) const selectedStartMinutes = toMinutes(input.startTime);
const selectedEndMinutes = selectedStartMinutes + court.slotDurationMinutes const selectedEndMinutes = selectedStartMinutes + court.slotDurationMinutes;
const selectedSlot = { const selectedSlot = {
startTime: input.startTime, startTime: input.startTime,
endTime: minutesToTime(selectedEndMinutes), endTime: minutesToTime(selectedEndMinutes),
} };
const slotExists = validSlots.some( const slotExists = validSlots.some(
(slot) => slot.startTime === selectedSlot.startTime && slot.endTime === selectedSlot.endTime, (slot) => slot.startTime === selectedSlot.startTime && slot.endTime === selectedSlot.endTime
) );
if (!slotExists) { if (!slotExists) {
throw new AdminBookingServiceError( throw new AdminBookingServiceError(
'El horario seleccionado no esta disponible para esa cancha.', 'El horario seleccionado no esta disponible para esa cancha.',
409, 409
) );
} }
for (let attempt = 0; attempt < 5; attempt += 1) { for (let attempt = 0; attempt < 5; attempt += 1) {
try { try {
const booking = await db.$transaction(async (tx) => { const booking = await db.$transaction(async (tx) => {
if (complex.plan) { if (complex.plan) {
const rules = parsePlanRules(complex.plan.rules) const rules = parsePlanRules(complex.plan.rules);
const bookingsForDate = await tx.courtBooking.count({ const bookingsForDate = await tx.courtBooking.count({
where: { where: {
bookingDate, bookingDate,
@@ -319,25 +319,25 @@ export async function createAdminBooking(
complexId, complexId,
}, },
}, },
}) });
const courtsCount = await tx.court.count({ const courtsCount = await tx.court.count({
where: { where: {
complexId, complexId,
}, },
}) });
const violations = evaluatePlanUsage(rules, { const violations = evaluatePlanUsage(rules, {
courtsCount, courtsCount,
bookingsToday: bookingsForDate, bookingsToday: bookingsForDate,
}) });
const maxBookingsViolation = violations.find( const maxBookingsViolation = violations.find(
(violation) => violation.code === 'MAX_BOOKINGS_PER_DAY_REACHED', (violation) => violation.code === 'MAX_BOOKINGS_PER_DAY_REACHED'
) );
if (maxBookingsViolation) { 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, gt: selectedSlot.startTime,
}, },
}, },
}) });
if (overlappingBooking) { 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({ return tx.courtBooking.create({
@@ -392,51 +392,51 @@ export async function createAdminBooking(
}, },
}, },
}, },
}) });
}) });
return mapBookingResponse(booking) return mapBookingResponse(booking);
} catch (error) { } catch (error) {
if (error instanceof AdminBookingServiceError) { if (error instanceof AdminBookingServiceError) {
throw error throw error;
} }
const prismaError = error as { const prismaError = error as {
code?: string code?: string;
meta?: { meta?: {
target?: string[] | string target?: string[] | string;
} };
} };
if (prismaError.code === 'P2002') { if (prismaError.code === 'P2002') {
const targets = Array.isArray(prismaError.meta?.target) const targets = Array.isArray(prismaError.meta?.target)
? prismaError.meta?.target ? prismaError.meta?.target
: [prismaError.meta?.target] : [prismaError.meta?.target];
const isBookingCodeCollision = targets.some((target) => const isBookingCodeCollision = targets.some((target) =>
String(target).includes('booking_code'), String(target).includes('booking_code')
) );
if (isBookingCodeCollision) { 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( throw new AdminBookingServiceError(
'No se pudo generar un codigo de reserva unico. Intenta nuevamente.', 'No se pudo generar un codigo de reserva unico. Intenta nuevamente.',
409, 409
) );
} }
export async function updateAdminBookingStatus( export async function updateAdminBookingStatus(
appUserId: string, appUserId: string,
bookingId: string, bookingId: string,
input: UpdateAdminBookingStatusInput, input: UpdateAdminBookingStatusInput
) { ) {
const booking = await db.courtBooking.findFirst({ const booking = await db.courtBooking.findFirst({
where: { where: {
@@ -472,33 +472,31 @@ export async function updateAdminBookingStatus(
}, },
}, },
}, },
}) });
if (!booking) { if (!booking) {
throw new AdminBookingServiceError('Reserva no encontrada.', 404) throw new AdminBookingServiceError('Reserva no encontrada.', 404);
} }
if (input.status === 'CANCELLED' && booking.status !== 'CONFIRMED') { if (input.status === 'CANCELLED' && booking.status !== 'CONFIRMED') {
throw new AdminBookingServiceError( throw new AdminBookingServiceError(
'Solo se pueden cancelar reservas en estado confirmada.', 'Solo se pueden cancelar reservas en estado confirmada.',
409, 409
) );
} }
if (input.status === 'COMPLETED' && booking.status !== 'CONFIRMED') { if (input.status === 'COMPLETED' && booking.status !== 'CONFIRMED') {
throw new AdminBookingServiceError( throw new AdminBookingServiceError(
'Solo se pueden marcar como cumplidas las reservas confirmadas.', 'Solo se pueden marcar como cumplidas las reservas confirmadas.',
409, 409
) );
} }
const updated = await db.courtBooking.update({ const updated = await db.courtBooking.update({
where: { id: booking.id }, where: { id: booking.id },
data: { data: {
status: status:
input.status === 'COMPLETED' input.status === 'COMPLETED' ? CourtBookingStatus.COMPLETED : CourtBookingStatus.CANCELLED,
? CourtBookingStatus.COMPLETED
: CourtBookingStatus.CANCELLED,
}, },
include: { include: {
court: { court: {
@@ -521,7 +519,7 @@ export async function updateAdminBookingStatus(
}, },
}, },
}, },
}) });
return mapBookingResponse(updated) return mapBookingResponse(updated);
} }

View File

@@ -1,37 +1,33 @@
import { zValidator } from '@hono/zod-validator' import { requireAuth } from '@/middlewares/require-auth.middleware';
import { createComplexSchema, updateComplexSchema } from '@repo/api-contract' import { createComplexHandler } from '@/modules/complex/handlers/create-complex.handler';
import { Hono } from 'hono' import { getComplexByIdHandler } from '@/modules/complex/handlers/get-complex-by-id.handler';
import { z } from 'zod' import { getComplexBySlugHandler } from '@/modules/complex/handlers/get-complex-by-slug.handler';
import { requireAuth } from '@/middlewares/require-auth.middleware' import { listMyComplexesHandler } from '@/modules/complex/handlers/list-my-complexes.handler';
import { createComplexHandler } from '@/modules/complex/handlers/create-complex.handler' import { updateComplexHandler } from '@/modules/complex/handlers/update-complex.handler';
import { getComplexByIdHandler } from '@/modules/complex/handlers/get-complex-by-id.handler' import type { AppEnv } from '@/types/hono';
import { getComplexBySlugHandler } from '@/modules/complex/handlers/get-complex-by-slug.handler' import { zValidator } from '@hono/zod-validator';
import { listMyComplexesHandler } from '@/modules/complex/handlers/list-my-complexes.handler' import { createComplexSchema, updateComplexSchema } from '@repo/api-contract';
import { updateComplexHandler } from '@/modules/complex/handlers/update-complex.handler' import { Hono } from 'hono';
import type { AppEnv } from '@/types/hono' import { z } from 'zod';
export const complexRoutes = new Hono<AppEnv>() export const complexRoutes = new Hono<AppEnv>();
const complexIdParamsSchema = z.object({ id: z.uuid() }) const complexIdParamsSchema = z.object({ id: z.uuid() });
const complexSlugParamsSchema = z.object({ slug: z.string().trim().min(1) }) const complexSlugParamsSchema = z.object({ slug: z.string().trim().min(1) });
complexRoutes.use('*', requireAuth) complexRoutes.use('*', requireAuth);
complexRoutes.post('/', zValidator('json', createComplexSchema), createComplexHandler) complexRoutes.post('/', zValidator('json', createComplexSchema), createComplexHandler);
complexRoutes.get('/mine', listMyComplexesHandler) complexRoutes.get('/mine', listMyComplexesHandler);
complexRoutes.get( complexRoutes.get(
'/slug/:slug', '/slug/:slug',
zValidator('param', complexSlugParamsSchema), zValidator('param', complexSlugParamsSchema),
getComplexBySlugHandler, getComplexBySlugHandler
) );
complexRoutes.get( complexRoutes.get('/:id', zValidator('param', complexIdParamsSchema), getComplexByIdHandler);
'/:id',
zValidator('param', complexIdParamsSchema),
getComplexByIdHandler,
)
complexRoutes.patch( complexRoutes.patch(
'/:id', '/:id',
zValidator('param', complexIdParamsSchema), zValidator('param', complexIdParamsSchema),
zValidator('json', updateComplexSchema), zValidator('json', updateComplexSchema),
updateComplexHandler, updateComplexHandler
) );

View File

@@ -1,15 +1,15 @@
import type { CreateComplexInput } from '@repo/api-contract' import { createComplex } from '@/modules/complex/services/complex.service';
import type { AppContext } from '@/types/hono' import type { AppContext } from '@/types/hono';
import { createComplex } from '@/modules/complex/services/complex.service' import type { CreateComplexInput } from '@repo/api-contract';
export async function createComplexHandler(c: AppContext) { 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 authUser = c.get('authUser');
const adminEmail = authUser.email const adminEmail = authUser.email;
if (!adminEmail) { 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({ const complex = await createComplex({
@@ -20,7 +20,7 @@ export async function createComplexHandler(c: AppContext) {
city: payload.city, city: payload.city,
state: payload.state, state: payload.state,
country: payload.country, country: payload.country,
}) });
return c.json(complex, 201) return c.json(complex, 201);
} }

View File

@@ -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) { 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) { 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);
} }

View File

@@ -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) { 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) { 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);
} }

View File

@@ -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) { export async function listMyComplexesHandler(c: AppContext) {
const appUserId = c.get('appUserId') const appUserId = c.get('appUserId');
const complexes = await listMyComplexes(appUserId) const complexes = await listMyComplexes(appUserId);
return c.json(complexes) return c.json(complexes);
} }

View File

@@ -1,27 +1,27 @@
import type { UpdateComplexInput } from '@repo/api-contract' import { Prisma } from '@/generated/prisma/client';
import { Prisma } from '@/generated/prisma/client' import { getComplexById, updateComplex } from '@/modules/complex/services/complex.service';
import type { AppContext } from '@/types/hono' import type { AppContext } from '@/types/hono';
import { getComplexById, updateComplex } from '@/modules/complex/services/complex.service' import type { UpdateComplexInput } from '@repo/api-contract';
type ComplexIdParams = { id: string } type ComplexIdParams = { id: string };
export async function updateComplexHandler(c: AppContext) { export async function updateComplexHandler(c: AppContext) {
const { id } = c.req.valid('param' as never) as ComplexIdParams const { id } = c.req.valid('param' as never) as ComplexIdParams;
const payload = c.req.valid('json' as never) as UpdateComplexInput const payload = c.req.valid('json' as never) as UpdateComplexInput;
const existing = await getComplexById(id) const existing = await getComplexById(id);
if (!existing) { if (!existing) {
return c.json({ message: 'Complejo no encontrado.' }, 404) return c.json({ message: 'Complejo no encontrado.' }, 404);
} }
try { try {
const complex = await updateComplex(id, payload) const complex = await updateComplex(id, payload);
return c.json(complex) return c.json(complex);
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { 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;
} }
} }

View File

@@ -1,27 +1,27 @@
import { v7 as uuidv7 } from 'uuid' import { Prisma } from '@/generated/prisma/client';
import { Prisma } from '@/generated/prisma/client' import { db } from '@/lib/prisma';
import { db } from '@/lib/prisma' import { v7 as uuidv7 } from 'uuid';
export type CreateComplexInput = { export type CreateComplexInput = {
complexName: string complexName: string;
physicalAddress: string physicalAddress: string;
adminEmail: string adminEmail: string;
planCode?: string planCode?: string;
city?: string city?: string;
state?: string state?: string;
country?: string country?: string;
} };
export type UpdateComplexInput = { export type UpdateComplexInput = {
complexName?: string complexName?: string;
physicalAddress?: string | null physicalAddress?: string | null;
complexSlug?: string complexSlug?: string;
adminEmail?: string adminEmail?: string;
planCode?: string | null planCode?: string | null;
city?: string | null city?: string | null;
state?: string | null state?: string | null;
country?: string | null country?: string | null;
} };
function slugify(value: string): string { function slugify(value: string): string {
return value return value
@@ -31,18 +31,15 @@ function slugify(value: string): string {
.trim() .trim()
.replace(/[^a-z0-9\s-]/g, '') .replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-') .replace(/\s+/g, '-')
.replace(/-+/g, '-') .replace(/-+/g, '-');
} }
async function buildUniqueSlug( async function buildUniqueSlug(source: string, excludeComplexId?: string): Promise<string> {
source: string, const base = slugify(source);
excludeComplexId?: string, const fallback = base.length > 0 ? base : `complex-${uuidv7().slice(0, 8)}`;
): Promise<string> {
const base = slugify(source)
const fallback = base.length > 0 ? base : `complex-${uuidv7().slice(0, 8)}`
let candidate = fallback let candidate = fallback;
let index = 1 let index = 1;
while (true) { while (true) {
const existing = await db.complex.findFirst({ const existing = await db.complex.findFirst({
@@ -57,17 +54,17 @@ async function buildUniqueSlug(
: {}), : {}),
}, },
select: { id: true }, select: { id: true },
}) });
if (!existing) return candidate if (!existing) return candidate;
index += 1 index += 1;
candidate = `${fallback}-${index}` candidate = `${fallback}-${index}`;
} }
} }
export async function createComplex(input: CreateComplexInput) { export async function createComplex(input: CreateComplexInput) {
const complexSlug = await buildUniqueSlug(input.complexName) const complexSlug = await buildUniqueSlug(input.complexName);
return db.complex.create({ return db.complex.create({
data: { data: {
@@ -81,19 +78,19 @@ export async function createComplex(input: CreateComplexInput) {
adminEmail: input.adminEmail, adminEmail: input.adminEmail,
planCode: input.planCode, planCode: input.planCode,
}, },
}) });
} }
export async function getComplexById(id: string) { export async function getComplexById(id: string) {
return db.complex.findUnique({ return db.complex.findUnique({
where: { id }, where: { id },
}) });
} }
export async function getComplexBySlug(slug: string) { export async function getComplexBySlug(slug: string) {
return db.complex.findUnique({ return db.complex.findUnique({
where: { complexSlug: slug }, where: { complexSlug: slug },
}) });
} }
export async function listMyComplexes(appUserId: string) { export async function listMyComplexes(appUserId: string) {
@@ -105,49 +102,49 @@ export async function listMyComplexes(appUserId: string) {
orderBy: { orderBy: {
createdAt: 'asc', createdAt: 'asc',
}, },
}) });
return complexUsers.map((complexUser) => complexUser.complex) return complexUsers.map((complexUser) => complexUser.complex);
} }
export async function updateComplex(id: string, input: UpdateComplexInput) { export async function updateComplex(id: string, input: UpdateComplexInput) {
const data: Record<string, unknown> = {} const data: Record<string, unknown> = {};
if (input.complexName) { if (input.complexName) {
data.complexName = input.complexName data.complexName = input.complexName;
data.complexSlug = await buildUniqueSlug(input.complexName, id) data.complexSlug = await buildUniqueSlug(input.complexName, id);
} }
if (input.complexSlug) { if (input.complexSlug) {
data.complexSlug = await buildUniqueSlug(input.complexSlug, id) data.complexSlug = await buildUniqueSlug(input.complexSlug, id);
} }
if (input.adminEmail) { if (input.adminEmail) {
data.adminEmail = input.adminEmail data.adminEmail = input.adminEmail;
} }
if (input.planCode !== undefined) { if (input.planCode !== undefined) {
data.planCode = input.planCode data.planCode = input.planCode;
} }
if (input.physicalAddress !== undefined) { if (input.physicalAddress !== undefined) {
data.physicalAddress = input.physicalAddress?.trim() ?? null data.physicalAddress = input.physicalAddress?.trim() ?? null;
} }
if (input.city !== undefined) { if (input.city !== undefined) {
data.city = input.city?.trim() ?? null data.city = input.city?.trim() ?? null;
} }
if (input.state !== undefined) { if (input.state !== undefined) {
data.state = input.state?.trim() ?? null data.state = input.state?.trim() ?? null;
} }
if (input.country !== undefined) { if (input.country !== undefined) {
data.country = input.country?.trim() ?? null data.country = input.country?.trim() ?? null;
} }
return db.complex.update({ return db.complex.update({
where: { id }, where: { id },
data: data as Prisma.ComplexUncheckedUpdateInput, data: data as Prisma.ComplexUncheckedUpdateInput,
}) });
} }

View File

@@ -1,33 +1,33 @@
import { zValidator } from '@hono/zod-validator' import { requireAuth } from '@/middlewares/require-auth.middleware';
import { createCourtSchema, updateCourtSchema } from '@repo/api-contract' import { createCourtHandler } from '@/modules/court/handlers/create-court.handler';
import { Hono } from 'hono' import { listCourtsByComplexHandler } from '@/modules/court/handlers/list-courts-by-complex.handler';
import { z } from 'zod' import { updateCourtHandler } from '@/modules/court/handlers/update-court.handler';
import { requireAuth } from '@/middlewares/require-auth.middleware' import type { AppEnv } from '@/types/hono';
import { createCourtHandler } from '@/modules/court/handlers/create-court.handler' import { zValidator } from '@hono/zod-validator';
import { listCourtsByComplexHandler } from '@/modules/court/handlers/list-courts-by-complex.handler' import { createCourtSchema, updateCourtSchema } from '@repo/api-contract';
import { updateCourtHandler } from '@/modules/court/handlers/update-court.handler' import { Hono } from 'hono';
import type { AppEnv } from '@/types/hono' import { z } from 'zod';
export const courtRoutes = new Hono<AppEnv>() export const courtRoutes = new Hono<AppEnv>();
const complexIdParamsSchema = z.object({ complexId: z.uuid() }) const complexIdParamsSchema = z.object({ complexId: z.uuid() });
const courtIdParamsSchema = z.object({ id: z.uuid() }) const courtIdParamsSchema = z.object({ id: z.uuid() });
courtRoutes.use('*', requireAuth) courtRoutes.use('*', requireAuth);
courtRoutes.get( courtRoutes.get(
'/complex/:complexId', '/complex/:complexId',
zValidator('param', complexIdParamsSchema), zValidator('param', complexIdParamsSchema),
listCourtsByComplexHandler, listCourtsByComplexHandler
) );
courtRoutes.post( courtRoutes.post(
'/complex/:complexId', '/complex/:complexId',
zValidator('param', complexIdParamsSchema), zValidator('param', complexIdParamsSchema),
zValidator('json', createCourtSchema), zValidator('json', createCourtSchema),
createCourtHandler, createCourtHandler
) );
courtRoutes.patch( courtRoutes.patch(
'/:id', '/:id',
zValidator('param', courtIdParamsSchema), zValidator('param', courtIdParamsSchema),
zValidator('json', updateCourtSchema), zValidator('json', updateCourtSchema),
updateCourtHandler, updateCourtHandler
) );

View File

@@ -1,21 +1,21 @@
import type { CreateCourtInput } from '@repo/api-contract' import { CourtServiceError, createCourt } from '@/modules/court/services/court.service';
import { CourtServiceError, createCourt } from '@/modules/court/services/court.service' import type { AppContext } from '@/types/hono';
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) { export async function createCourtHandler(c: AppContext) {
const { complexId } = c.req.valid('param' as never) as ComplexIdParams const { complexId } = c.req.valid('param' as never) as ComplexIdParams;
const payload = c.req.valid('json' as never) as CreateCourtInput const payload = c.req.valid('json' as never) as CreateCourtInput;
const appUserId = c.get('appUserId') const appUserId = c.get('appUserId');
try { try {
const court = await createCourt(appUserId, complexId, payload) const court = await createCourt(appUserId, complexId, payload);
return c.json(court, 201) return c.json(court, 201);
} catch (error) { } catch (error) {
if (error instanceof CourtServiceError) { if (error instanceof CourtServiceError) {
return c.json({ message: error.message }, error.status) return c.json({ message: error.message }, error.status);
} }
throw error throw error;
} }
} }

View File

@@ -1,19 +1,19 @@
import { CourtServiceError, listCourtsByComplex } from '@/modules/court/services/court.service' import { CourtServiceError, listCourtsByComplex } from '@/modules/court/services/court.service';
import type { AppContext } from '@/types/hono' import type { AppContext } from '@/types/hono';
type ComplexIdParams = { complexId: string } type ComplexIdParams = { complexId: string };
export async function listCourtsByComplexHandler(c: AppContext) { export async function listCourtsByComplexHandler(c: AppContext) {
const { complexId } = c.req.valid('param' as never) as ComplexIdParams const { complexId } = c.req.valid('param' as never) as ComplexIdParams;
const appUserId = c.get('appUserId') const appUserId = c.get('appUserId');
try { try {
const courts = await listCourtsByComplex(complexId, appUserId) const courts = await listCourtsByComplex(complexId, appUserId);
return c.json(courts) return c.json(courts);
} catch (error) { } catch (error) {
if (error instanceof CourtServiceError) { if (error instanceof CourtServiceError) {
return c.json({ message: error.message }, error.status) return c.json({ message: error.message }, error.status);
} }
throw error throw error;
} }
} }

View File

@@ -1,21 +1,21 @@
import type { UpdateCourtInput } from '@repo/api-contract' import { CourtServiceError, updateCourt } from '@/modules/court/services/court.service';
import { CourtServiceError, updateCourt } from '@/modules/court/services/court.service' import type { AppContext } from '@/types/hono';
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) { export async function updateCourtHandler(c: AppContext) {
const { id } = c.req.valid('param' as never) as CourtIdParams const { id } = c.req.valid('param' as never) as CourtIdParams;
const payload = c.req.valid('json' as never) as UpdateCourtInput const payload = c.req.valid('json' as never) as UpdateCourtInput;
const appUserId = c.get('appUserId') const appUserId = c.get('appUserId');
try { try {
const court = await updateCourt(appUserId, id, payload) const court = await updateCourt(appUserId, id, payload);
return c.json(court) return c.json(court);
} catch (error) { } catch (error) {
if (error instanceof CourtServiceError) { if (error instanceof CourtServiceError) {
return c.json({ message: error.message }, error.status) return c.json({ message: error.message }, error.status);
} }
throw error throw error;
} }
} }

View File

@@ -1,72 +1,61 @@
import type { CreateCourtInput, DayOfWeek, UpdateCourtInput } from '@repo/api-contract' import type { Court, CourtAvailability, CourtPriceRule, Sport } from '@/generated/prisma/client';
import { v7 as uuidv7 } from 'uuid' import { db } from '@/lib/prisma';
import type { import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service';
Court, import type { CreateCourtInput, DayOfWeek, UpdateCourtInput } from '@repo/api-contract';
CourtAvailability, import { v7 as uuidv7 } from 'uuid';
CourtPriceRule,
Sport,
} from '@/generated/prisma/client'
import { db } from '@/lib/prisma'
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service'
type CourtWithRelations = Court & { type CourtWithRelations = Court & {
sport: Sport sport: Sport;
availabilities: CourtAvailability[] availabilities: CourtAvailability[];
priceRules: CourtPriceRule[] priceRules: CourtPriceRule[];
} };
export class CourtServiceError extends Error { export class CourtServiceError extends Error {
status: 400 | 403 | 404 | 409 status: 400 | 403 | 404 | 409;
constructor(message: string, status: 400 | 403 | 404 | 409) { constructor(message: string, status: 400 | 403 | 404 | 409) {
super(message) super(message);
this.name = 'CourtServiceError' this.name = 'CourtServiceError';
this.status = status this.status = status;
} }
} }
function toMinutes(value: string): number { function toMinutes(value: string): number {
const [hours, minutes] = value.split(':').map((part) => Number(part)) const [hours, minutes] = value.split(':').map((part) => Number(part));
return hours * 60 + minutes return hours * 60 + minutes;
} }
function assertAvailabilityRanges( function assertAvailabilityRanges(
availability: Array<{ availability: Array<{
dayOfWeek: DayOfWeek dayOfWeek: DayOfWeek;
startTime: string startTime: string;
endTime: string endTime: string;
}>, }>
) { ) {
const grouped = new Map<DayOfWeek, Array<{ start: number; end: number }>>() const grouped = new Map<DayOfWeek, Array<{ start: number; end: number }>>();
for (const range of availability) { for (const range of availability) {
const start = toMinutes(range.startTime) const start = toMinutes(range.startTime);
const end = toMinutes(range.endTime) const end = toMinutes(range.endTime);
if (start >= end) { if (start >= end) {
throw new CourtServiceError( throw new CourtServiceError(`El rango ${range.startTime}-${range.endTime} es invalido.`, 400);
`El rango ${range.startTime}-${range.endTime} es invalido.`,
400,
)
} }
const current = grouped.get(range.dayOfWeek) ?? [] const current = grouped.get(range.dayOfWeek) ?? [];
current.push({ start, end }) current.push({ start, end });
grouped.set(range.dayOfWeek, current) grouped.set(range.dayOfWeek, current);
} }
for (const ranges of grouped.values()) { 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) { for (let index = 1; index < ranges.length; index += 1) {
const previous = ranges[index - 1] const previous = ranges[index - 1];
const current = ranges[index] const current = ranges[index];
if (previous.end > current.start) { if (previous.end > current.start) {
throw new CourtServiceError( throw new CourtServiceError('Hay rangos horarios superpuestos para el mismo dia.', 400);
'Hay rangos horarios superpuestos para el mismo dia.',
400,
)
} }
} }
} }
@@ -92,16 +81,13 @@ async function ensureComplexAccess(complexId: string, appUserId: string) {
}, },
}, },
}, },
}) });
if (!complexUser) { if (!complexUser) {
throw new CourtServiceError( throw new CourtServiceError('No tienes permisos para administrar este complejo.', 403);
'No tienes permisos para administrar este complejo.',
403,
)
} }
return complexUser.complex return complexUser.complex;
} }
async function ensureActiveSport(sportId: string) { async function ensureActiveSport(sportId: string) {
@@ -111,10 +97,10 @@ async function ensureActiveSport(sportId: string) {
isActive: true, isActive: true,
}, },
select: { id: true }, select: { id: true },
}) });
if (!sport) { 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) { if (!complex?.plan) {
return return;
} }
const rules = parsePlanRules(complex.plan.rules) const rules = parsePlanRules(complex.plan.rules);
const courtsCount = await db.court.count({ const courtsCount = await db.court.count({
where: { complexId }, where: { complexId },
}) });
const violations = evaluatePlanUsage(rules, { const violations = evaluatePlanUsage(rules, {
courtsCount, courtsCount,
bookingsToday: 0, bookingsToday: 0,
}) });
const maxCourtViolation = violations.find( const maxCourtViolation = violations.find((violation) => violation.code === 'MAX_COURTS_REACHED');
(violation) => violation.code === 'MAX_COURTS_REACHED',
)
if (maxCourtViolation) { 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' }], orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
}, },
}, },
}) });
} }
function mapCourtResponse(court: CourtWithRelations) { function mapCourtResponse(court: CourtWithRelations) {
@@ -208,11 +192,11 @@ function mapCourtResponse(court: CourtWithRelations) {
hasCustomPricing: court.priceRules.length > 0, hasCustomPricing: court.priceRules.length > 0,
createdAt: court.createdAt.toISOString(), createdAt: court.createdAt.toISOString(),
updatedAt: court.updatedAt.toISOString(), updatedAt: court.updatedAt.toISOString(),
} };
} }
export async function listCourtsByComplex(complexId: string, appUserId: string) { export async function listCourtsByComplex(complexId: string, appUserId: string) {
await ensureComplexAccess(complexId, appUserId) await ensureComplexAccess(complexId, appUserId);
const courts = await db.court.findMany({ const courts = await db.court.findMany({
where: { complexId }, where: { complexId },
@@ -229,20 +213,16 @@ export async function listCourtsByComplex(complexId: string, appUserId: string)
orderBy: { orderBy: {
createdAt: 'asc', createdAt: 'asc',
}, },
}) });
return courts.map((court) => mapCourtResponse(court)) return courts.map((court) => mapCourtResponse(court));
} }
export async function createCourt( export async function createCourt(appUserId: string, complexId: string, input: CreateCourtInput) {
appUserId: string, await ensureComplexAccess(complexId, appUserId);
complexId: string, await ensureActiveSport(input.sportId);
input: CreateCourtInput, await enforcePlanCourtLimit(complexId);
) { assertAvailabilityRanges(input.availability);
await ensureComplexAccess(complexId, appUserId)
await ensureActiveSport(input.sportId)
await enforcePlanCourtLimit(complexId)
assertAvailabilityRanges(input.availability)
const createdCourt = await db.$transaction(async (tx) => { const createdCourt = await db.$transaction(async (tx) => {
const court = await tx.court.create({ const court = await tx.court.create({
@@ -254,7 +234,7 @@ export async function createCourt(
slotDurationMinutes: input.slotDurationMinutes, slotDurationMinutes: input.slotDurationMinutes,
basePrice: input.basePrice, basePrice: input.basePrice,
}, },
}) });
await tx.courtAvailability.createMany({ await tx.courtAvailability.createMany({
data: input.availability.map((availability) => ({ data: input.availability.map((availability) => ({
@@ -264,37 +244,33 @@ export async function createCourt(
startTime: availability.startTime, startTime: availability.startTime,
endTime: availability.endTime, endTime: availability.endTime,
})), })),
}) });
return court return court;
}) });
const court = await getCourtByIdForUser(createdCourt.id, appUserId) const court = await getCourtByIdForUser(createdCourt.id, appUserId);
if (!court) { 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( export async function updateCourt(appUserId: string, courtId: string, input: UpdateCourtInput) {
appUserId: string, const existingCourt = await getCourtByIdForUser(courtId, appUserId);
courtId: string,
input: UpdateCourtInput,
) {
const existingCourt = await getCourtByIdForUser(courtId, appUserId)
if (!existingCourt) { if (!existingCourt) {
throw new CourtServiceError('Cancha no encontrada.', 404) throw new CourtServiceError('Cancha no encontrada.', 404);
} }
if (input.sportId) { if (input.sportId) {
await ensureActiveSport(input.sportId) await ensureActiveSport(input.sportId);
} }
if (input.availability) { if (input.availability) {
assertAvailabilityRanges(input.availability) assertAvailabilityRanges(input.availability);
} }
await db.$transaction(async (tx) => { await db.$transaction(async (tx) => {
@@ -310,12 +286,12 @@ export async function updateCourt(
: {}), : {}),
...(input.basePrice !== undefined ? { basePrice: input.basePrice } : {}), ...(input.basePrice !== undefined ? { basePrice: input.basePrice } : {}),
}, },
}) });
if (input.availability) { if (input.availability) {
await tx.courtAvailability.deleteMany({ await tx.courtAvailability.deleteMany({
where: { courtId }, where: { courtId },
}) });
await tx.courtAvailability.createMany({ await tx.courtAvailability.createMany({
data: input.availability.map((availability) => ({ data: input.availability.map((availability) => ({
@@ -325,15 +301,15 @@ export async function updateCourt(
startTime: availability.startTime, startTime: availability.startTime,
endTime: availability.endTime, endTime: availability.endTime,
})), })),
}) });
} }
}) });
const updatedCourt = await getCourtByIdForUser(courtId, appUserId) const updatedCourt = await getCourtByIdForUser(courtId, appUserId);
if (!updatedCourt) { if (!updatedCourt) {
throw new CourtServiceError('Cancha no encontrada.', 404) throw new CourtServiceError('Cancha no encontrada.', 404);
} }
return mapCourtResponse(updatedCourt) return mapCourtResponse(updatedCourt);
} }

View File

@@ -1,12 +1,14 @@
import { AppEnv } from "@/types/hono"; import { AppEnv } from '@/types/hono';
import { Hono } from "hono"; import { Hono } from 'hono';
export const healthCheckRoutes = new Hono<AppEnv>()
export const healthCheckRoutes = new Hono<AppEnv>();
healthCheckRoutes.get('/', (c) => { healthCheckRoutes.get('/', (c) => {
return c.json({ return c.json(
status: 'healthy', {
timeStamp: new Date() status: 'healthy',
}, 200) timeStamp: new Date(),
}) },
200
);
});

View File

@@ -1,27 +1,27 @@
import type { OnboardingCompleteInput } from '@repo/api-contract'
import type { AppContext } from '@/types/hono'
import { import {
OnboardingError, OnboardingError,
completeOnboarding, 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) { 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 { try {
const result = await completeOnboarding(payload) const result = await completeOnboarding(payload);
return c.json({ return c.json({
message: 'Onboarding completado correctamente.', message: 'Onboarding completado correctamente.',
...result, ...result,
}) });
} catch (error) { } catch (error) {
if (error instanceof OnboardingError) { if (error instanceof OnboardingError) {
return c.json({ message: error.message }, error.status) return c.json({ message: error.message }, error.status);
} }
if (error instanceof Error) { if (error instanceof Error) {
return c.json({ message: error.message }, 400) return c.json({ message: error.message }, 400);
} }
throw error throw error;
} }
} }

View File

@@ -1,25 +1,25 @@
import type { OnboardingResendOtpInput } from '@repo/api-contract'
import type { AppContext } from '@/types/hono'
import { import {
OnboardingError, OnboardingError,
resendOnboardingOtp, 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) { 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 { try {
const result = await resendOnboardingOtp(payload) const result = await resendOnboardingOtp(payload);
return c.json(result) return c.json(result);
} catch (error) { } catch (error) {
if (error instanceof OnboardingError) { if (error instanceof OnboardingError) {
return c.json({ message: error.message }, error.status) return c.json({ message: error.message }, error.status);
} }
if (error instanceof Error) { if (error instanceof Error) {
return c.json({ message: error.message }, 400) return c.json({ message: error.message }, 400);
} }
throw error throw error;
} }
} }

View File

@@ -1,10 +1,10 @@
import type { OnboardingStartInput } from '@repo/api-contract' import { startOnboarding } from '@/modules/onboarding/services/onboarding.service';
import type { AppContext } from '@/types/hono' import type { AppContext } from '@/types/hono';
import { startOnboarding } from '@/modules/onboarding/services/onboarding.service' import type { OnboardingStartInput } from '@repo/api-contract';
export async function startOnboardingHandler(c: AppContext) { 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) const result = await startOnboarding(payload);
return c.json(result, 202) return c.json(result, 202);
} }

View File

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

View File

@@ -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 { import {
onboardingCompleteSchema, onboardingCompleteSchema,
onboardingResendOtpSchema, onboardingResendOtpSchema,
onboardingStartSchema, onboardingStartSchema,
onboardingVerifyOtpSchema, onboardingVerifyOtpSchema,
} from '@repo/api-contract' } from '@repo/api-contract';
import { Hono } from 'hono' 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'
export const onboardingRoutes = new Hono<AppEnv>() export const onboardingRoutes = new Hono<AppEnv>();
onboardingRoutes.post( onboardingRoutes.post('/start', zValidator('json', onboardingStartSchema), startOnboardingHandler);
'/start',
zValidator('json', onboardingStartSchema),
startOnboardingHandler,
)
onboardingRoutes.post( onboardingRoutes.post(
'/verify-otp', '/verify-otp',
zValidator('json', onboardingVerifyOtpSchema), zValidator('json', onboardingVerifyOtpSchema),
verifyOtpHandler, verifyOtpHandler
) );
onboardingRoutes.post( onboardingRoutes.post(
'/resend-otp', '/resend-otp',
zValidator('json', onboardingResendOtpSchema), zValidator('json', onboardingResendOtpSchema),
resendOtpHandler, resendOtpHandler
) );
onboardingRoutes.post( onboardingRoutes.post(
'/complete', '/complete',
zValidator('json', onboardingCompleteSchema), zValidator('json', onboardingCompleteSchema),
completeOnboardingHandler, completeOnboardingHandler
) );

View File

@@ -1,86 +1,86 @@
import { createHash, randomInt } from 'node:crypto' import { createHash, randomInt } from 'node:crypto';
import { v7 as uuidv7 } from 'uuid' import { sendMail } from '@/lib/mailer';
import type { User as SupabaseAuthUser } from '@supabase/supabase-js' import { db } from '@/lib/prisma';
import { getSupabaseAdminClient } from '@/lib/supabase-admin';
import type { import type {
OnboardingCompleteInput, OnboardingCompleteInput,
OnboardingResendOtpInput, OnboardingResendOtpInput,
OnboardingStartInput, OnboardingStartInput,
OnboardingVerifyOtpInput, OnboardingVerifyOtpInput,
} from '@repo/api-contract' } from '@repo/api-contract';
import { db } from '@/lib/prisma' import type { User as SupabaseAuthUser } from '@supabase/supabase-js';
import { sendMail } from '@/lib/mailer' import { v7 as uuidv7 } from 'uuid';
import { getSupabaseAdminClient } from '@/lib/supabase-admin'
const OTP_LENGTH = 6 const OTP_LENGTH = 6;
const OTP_TTL_MINUTES = Number(Bun.env.ONBOARDING_OTP_TTL_MINUTES ?? 10) 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_MAX_ATTEMPTS = Number(Bun.env.ONBOARDING_OTP_MAX_ATTEMPTS ?? 5);
const OTP_RESEND_COOLDOWN_SECONDS = Number( const OTP_RESEND_COOLDOWN_SECONDS = Number(Bun.env.ONBOARDING_OTP_RESEND_COOLDOWN_SECONDS ?? 30);
Bun.env.ONBOARDING_OTP_RESEND_COOLDOWN_SECONDS ?? 30,
)
type VerifyOtpResult = { type VerifyOtpResult = {
message: string message: string;
verified: boolean verified: boolean;
requestId: string requestId: string;
email: string | null email: string | null;
expiresAt: string | null expiresAt: string | null;
remainingAttempts: number remainingAttempts: number;
cooldownSeconds: number cooldownSeconds: number;
} };
type ResendOtpResult = { type ResendOtpResult = {
message: string message: string;
requestId: string requestId: string;
email: string email: string;
expiresAt: string expiresAt: string;
cooldownSeconds: number cooldownSeconds: number;
remainingAttempts: number remainingAttempts: number;
} };
type StartOnboardingResult = { type StartOnboardingResult = {
message: string message: string;
requestId: string requestId: string;
email: string email: string;
expiresAt: string expiresAt: string;
cooldownSeconds: number cooldownSeconds: number;
} };
export class OnboardingError extends Error { export class OnboardingError extends Error {
status: 400 | 404 | 409 | 429 status: 400 | 404 | 409 | 429;
constructor(message: string, status: 400 | 404 | 409 | 429 = 400) { constructor(message: string, status: 400 | 404 | 409 | 429 = 400) {
super(message) super(message);
this.name = 'OnboardingError' this.name = 'OnboardingError';
this.status = status this.status = status;
} }
} }
function nowPlusMinutes(minutes: number): Date { function nowPlusMinutes(minutes: number): Date {
const date = new Date() const date = new Date();
date.setMinutes(date.getMinutes() + minutes) date.setMinutes(date.getMinutes() + minutes);
return date return date;
} }
function normalizeEmail(email: string): string { function normalizeEmail(email: string): string {
return email.trim().toLowerCase() return email.trim().toLowerCase();
} }
function createOtpCode(): string { 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 { function hashValue(value: string): string {
return createHash('sha256').update(value).digest('hex') return createHash('sha256').update(value).digest('hex');
} }
function getRemainingAttempts(otpAttempts: number): number { 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 { function getCooldownSeconds(otpLastSentAt: Date): number {
const elapsedMs = Date.now() - otpLastSentAt.getTime() const elapsedMs = Date.now() - otpLastSentAt.getTime();
const remainingMs = OTP_RESEND_COOLDOWN_SECONDS * 1000 - elapsedMs const remainingMs = OTP_RESEND_COOLDOWN_SECONDS * 1000 - elapsedMs;
return Math.max(0, Math.ceil(remainingMs / 1000)) return Math.max(0, Math.ceil(remainingMs / 1000));
} }
function slugify(value: string): string { function slugify(value: string): string {
@@ -91,28 +91,28 @@ function slugify(value: string): string {
.trim() .trim()
.replace(/[^a-z0-9\s-]/g, '') .replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-') .replace(/\s+/g, '-')
.replace(/-+/g, '-') .replace(/-+/g, '-');
} }
async function buildUniqueComplexSlug(complexName: string): Promise<string> { async function buildUniqueComplexSlug(complexName: string): Promise<string> {
const base = slugify(complexName) const base = slugify(complexName);
const fallback = base.length > 0 ? base : `complex-${uuidv7().slice(0, 8)}` const fallback = base.length > 0 ? base : `complex-${uuidv7().slice(0, 8)}`;
let candidate = fallback let candidate = fallback;
let index = 1 let index = 1;
while (true) { while (true) {
const existing = await db.complex.findFirst({ const existing = await db.complex.findFirst({
where: { complexSlug: candidate }, where: { complexSlug: candidate },
select: { id: true }, select: { id: true },
}) });
if (!existing) { if (!existing) {
return candidate return candidate;
} }
index += 1 index += 1;
candidate = `${fallback}-${index}` candidate = `${fallback}-${index}`;
} }
} }
@@ -122,70 +122,61 @@ async function sendOtpEmail(email: string, otpCode: string) {
subject: 'Codigo OTP para validar tu email', subject: 'Codigo OTP para validar tu email',
text: `Tu codigo OTP es ${otpCode}. Vence en ${OTP_TTL_MINUTES} minutos.`, text: `Tu codigo OTP es ${otpCode}. Vence en ${OTP_TTL_MINUTES} minutos.`,
html: `<p>Tu codigo OTP es <strong>${otpCode}</strong>.</p><p>Vence en ${OTP_TTL_MINUTES} minutos.</p>`, html: `<p>Tu codigo OTP es <strong>${otpCode}</strong>.</p><p>Vence en ${OTP_TTL_MINUTES} minutos.</p>`,
}) });
} }
async function findSupabaseUserByEmail( async function findSupabaseUserByEmail(email: string): Promise<SupabaseAuthUser | null> {
email: string, let page = 1;
): Promise<SupabaseAuthUser | null> { const perPage = 100;
let page = 1
const perPage = 100
while (page <= 10) { while (page <= 10) {
const { data, error } = await getSupabaseAdminClient().auth.admin.listUsers({ const { data, error } = await getSupabaseAdminClient().auth.admin.listUsers({
page, page,
perPage, perPage,
}) });
if (error) { 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( const found = data.users.find((user) => user.email?.toLowerCase() === email.toLowerCase());
(user) => user.email?.toLowerCase() === email.toLowerCase(),
)
if (found) { if (found) {
return found return found;
} }
if (data.users.length < perPage) { if (data.users.length < perPage) {
return null return null;
} }
page += 1 page += 1;
} }
return null return null;
} }
async function ensureSupabaseUser(params: { async function ensureSupabaseUser(params: {
email: string email: string;
fullName: string fullName: string;
password: string password: string;
}): Promise<string> { }): Promise<string> {
const existingUser = await findSupabaseUserByEmail(params.email) const existingUser = await findSupabaseUserByEmail(params.email);
if (existingUser) { if (existingUser) {
const { error } = await getSupabaseAdminClient().auth.admin.updateUserById( const { error } = await getSupabaseAdminClient().auth.admin.updateUserById(existingUser.id, {
existingUser.id, password: params.password,
{ email_confirm: true,
password: params.password, user_metadata: {
email_confirm: true, full_name: params.fullName,
user_metadata: { name: params.fullName,
full_name: params.fullName,
name: params.fullName,
},
}, },
) });
if (error) { if (error) {
throw new Error( throw new Error(`No se pudo actualizar usuario existente en Supabase: ${error.message}`);
`No se pudo actualizar usuario existente en Supabase: ${error.message}`,
)
} }
return existingUser.id return existingUser.id;
} }
const { data, error } = await getSupabaseAdminClient().auth.admin.createUser({ const { data, error } = await getSupabaseAdminClient().auth.admin.createUser({
@@ -196,24 +187,20 @@ async function ensureSupabaseUser(params: {
full_name: params.fullName, full_name: params.fullName,
name: params.fullName, name: params.fullName,
}, },
}) });
if (error || !data.user) { if (error || !data.user) {
throw new Error( throw new Error(`No se pudo crear usuario en Supabase: ${error?.message ?? 'unknown error'}`);
`No se pudo crear usuario en Supabase: ${error?.message ?? 'unknown error'}`,
)
} }
return data.user.id return data.user.id;
} }
export async function startOnboarding( export async function startOnboarding(input: OnboardingStartInput): Promise<StartOnboardingResult> {
input: OnboardingStartInput, const email = normalizeEmail(input.email);
): Promise<StartOnboardingResult> { const otpCode = createOtpCode();
const email = normalizeEmail(input.email) const expiresAt = nowPlusMinutes(OTP_TTL_MINUTES);
const otpCode = createOtpCode() const now = new Date();
const expiresAt = nowPlusMinutes(OTP_TTL_MINUTES)
const now = new Date()
const request = await db.onboardingRequest.create({ const request = await db.onboardingRequest.create({
data: { data: {
@@ -231,9 +218,9 @@ export async function startOnboarding(
email: true, email: true,
otpExpiresAt: true, otpExpiresAt: true,
}, },
}) });
await sendOtpEmail(email, otpCode) await sendOtpEmail(email, otpCode);
return { return {
message: 'Te enviamos un codigo OTP para validar tu direccion.', message: 'Te enviamos un codigo OTP para validar tu direccion.',
@@ -241,15 +228,15 @@ export async function startOnboarding(
email: request.email, email: request.email,
expiresAt: request.otpExpiresAt.toISOString(), expiresAt: request.otpExpiresAt.toISOString(),
cooldownSeconds: OTP_RESEND_COOLDOWN_SECONDS, cooldownSeconds: OTP_RESEND_COOLDOWN_SECONDS,
} };
} }
export async function verifyOnboardingOtp( export async function verifyOnboardingOtp(
input: OnboardingVerifyOtpInput, input: OnboardingVerifyOtpInput
): Promise<VerifyOtpResult> { ): Promise<VerifyOtpResult> {
const request = await db.onboardingRequest.findUnique({ const request = await db.onboardingRequest.findUnique({
where: { id: input.requestId }, where: { id: input.requestId },
}) });
if (!request) { if (!request) {
return { return {
@@ -260,7 +247,7 @@ export async function verifyOnboardingOtp(
expiresAt: null, expiresAt: null,
remainingAttempts: 0, remainingAttempts: 0,
cooldownSeconds: 0, cooldownSeconds: 0,
} };
} }
if (request.completedAt) { if (request.completedAt) {
@@ -272,7 +259,7 @@ export async function verifyOnboardingOtp(
expiresAt: request.otpExpiresAt.toISOString(), expiresAt: request.otpExpiresAt.toISOString(),
remainingAttempts: getRemainingAttempts(request.otpAttempts), remainingAttempts: getRemainingAttempts(request.otpAttempts),
cooldownSeconds: getCooldownSeconds(request.otpLastSentAt), cooldownSeconds: getCooldownSeconds(request.otpLastSentAt),
} };
} }
if (request.otpExpiresAt < new Date()) { if (request.otpExpiresAt < new Date()) {
@@ -284,7 +271,7 @@ export async function verifyOnboardingOtp(
expiresAt: request.otpExpiresAt.toISOString(), expiresAt: request.otpExpiresAt.toISOString(),
remainingAttempts: getRemainingAttempts(request.otpAttempts), remainingAttempts: getRemainingAttempts(request.otpAttempts),
cooldownSeconds: getCooldownSeconds(request.otpLastSentAt), cooldownSeconds: getCooldownSeconds(request.otpLastSentAt),
} };
} }
if (request.otpAttempts >= OTP_MAX_ATTEMPTS) { if (request.otpAttempts >= OTP_MAX_ATTEMPTS) {
@@ -296,10 +283,10 @@ export async function verifyOnboardingOtp(
expiresAt: request.otpExpiresAt.toISOString(), expiresAt: request.otpExpiresAt.toISOString(),
remainingAttempts: 0, remainingAttempts: 0,
cooldownSeconds: getCooldownSeconds(request.otpLastSentAt), cooldownSeconds: getCooldownSeconds(request.otpLastSentAt),
} };
} }
const otpMatches = hashValue(input.otp) === request.otpHash const otpMatches = hashValue(input.otp) === request.otpHash;
if (!otpMatches) { if (!otpMatches) {
const updated = await db.onboardingRequest.update({ const updated = await db.onboardingRequest.update({
@@ -316,9 +303,9 @@ export async function verifyOnboardingOtp(
otpExpiresAt: true, otpExpiresAt: true,
otpLastSentAt: true, otpLastSentAt: true,
}, },
}) });
const remainingAttempts = getRemainingAttempts(updated.otpAttempts) const remainingAttempts = getRemainingAttempts(updated.otpAttempts);
return { return {
message: message:
@@ -331,14 +318,14 @@ export async function verifyOnboardingOtp(
expiresAt: updated.otpExpiresAt.toISOString(), expiresAt: updated.otpExpiresAt.toISOString(),
remainingAttempts, remainingAttempts,
cooldownSeconds: getCooldownSeconds(updated.otpLastSentAt), cooldownSeconds: getCooldownSeconds(updated.otpLastSentAt),
} };
} }
if (!request.emailVerifiedAt) { if (!request.emailVerifiedAt) {
await db.onboardingRequest.update({ await db.onboardingRequest.update({
where: { id: request.id }, where: { id: request.id },
data: { emailVerifiedAt: new Date() }, data: { emailVerifiedAt: new Date() },
}) });
} }
return { return {
@@ -349,39 +336,36 @@ export async function verifyOnboardingOtp(
expiresAt: request.otpExpiresAt.toISOString(), expiresAt: request.otpExpiresAt.toISOString(),
remainingAttempts: getRemainingAttempts(request.otpAttempts), remainingAttempts: getRemainingAttempts(request.otpAttempts),
cooldownSeconds: getCooldownSeconds(request.otpLastSentAt), cooldownSeconds: getCooldownSeconds(request.otpLastSentAt),
} };
} }
export async function resendOnboardingOtp( export async function resendOnboardingOtp(
input: OnboardingResendOtpInput, input: OnboardingResendOtpInput
): Promise<ResendOtpResult> { ): Promise<ResendOtpResult> {
const request = await db.onboardingRequest.findUnique({ const request = await db.onboardingRequest.findUnique({
where: { id: input.requestId }, where: { id: input.requestId },
}) });
if (!request) { 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) { if (request.completedAt) {
throw new OnboardingError('Este onboarding ya fue completado.', 400) throw new OnboardingError('Este onboarding ya fue completado.', 400);
} }
if (request.emailVerifiedAt) { 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) { if (cooldownSeconds > 0) {
throw new OnboardingError( throw new OnboardingError(`Debes esperar ${cooldownSeconds}s antes de reenviar el OTP.`, 429);
`Debes esperar ${cooldownSeconds}s antes de reenviar el OTP.`,
429,
)
} }
const otpCode = createOtpCode() const otpCode = createOtpCode();
const expiresAt = nowPlusMinutes(OTP_TTL_MINUTES) const expiresAt = nowPlusMinutes(OTP_TTL_MINUTES);
const now = new Date() const now = new Date();
const updated = await db.onboardingRequest.update({ const updated = await db.onboardingRequest.update({
where: { id: request.id }, where: { id: request.id },
@@ -400,9 +384,9 @@ export async function resendOnboardingOtp(
otpExpiresAt: true, otpExpiresAt: true,
otpAttempts: true, otpAttempts: true,
}, },
}) });
await sendOtpEmail(updated.email, otpCode) await sendOtpEmail(updated.email, otpCode);
return { return {
message: 'Te enviamos un nuevo codigo OTP.', message: 'Te enviamos un nuevo codigo OTP.',
@@ -411,49 +395,46 @@ export async function resendOnboardingOtp(
expiresAt: updated.otpExpiresAt.toISOString(), expiresAt: updated.otpExpiresAt.toISOString(),
cooldownSeconds: OTP_RESEND_COOLDOWN_SECONDS, cooldownSeconds: OTP_RESEND_COOLDOWN_SECONDS,
remainingAttempts: getRemainingAttempts(updated.otpAttempts), remainingAttempts: getRemainingAttempts(updated.otpAttempts),
} };
} }
export async function completeOnboarding(input: OnboardingCompleteInput) { export async function completeOnboarding(input: OnboardingCompleteInput) {
const onboardingRequest = await db.onboardingRequest.findUnique({ const onboardingRequest = await db.onboardingRequest.findUnique({
where: { id: input.onboardingRequestId }, where: { id: input.onboardingRequestId },
}) });
if (!onboardingRequest) { 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()) { if (onboardingRequest.otpExpiresAt < new Date()) {
throw new OnboardingError( throw new OnboardingError('La sesion de onboarding expiro. Solicita un nuevo OTP.', 400);
'La sesion de onboarding expiro. Solicita un nuevo OTP.',
400,
)
} }
if (!onboardingRequest.emailVerifiedAt) { 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) { 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({ const plan = await db.plan.findUnique({
where: { code: input.planCode }, where: { code: input.planCode },
select: { code: true }, select: { code: true },
}) });
if (!plan) { if (!plan) {
throw new OnboardingError('El plan seleccionado no existe.', 400) throw new OnboardingError('El plan seleccionado no existe.', 400);
} }
const supabaseUserId = await ensureSupabaseUser({ const supabaseUserId = await ensureSupabaseUser({
email: onboardingRequest.email, email: onboardingRequest.email,
fullName: onboardingRequest.fullName, fullName: onboardingRequest.fullName,
password: input.password, password: input.password,
}) });
const complexSlug = await buildUniqueComplexSlug(input.complexName) const complexSlug = await buildUniqueComplexSlug(input.complexName);
const result = await db.$transaction(async (tx) => { const result = await db.$transaction(async (tx) => {
const user = await tx.user.upsert({ const user = await tx.user.upsert({
@@ -468,7 +449,7 @@ export async function completeOnboarding(input: OnboardingCompleteInput) {
email: onboardingRequest.email, email: onboardingRequest.email,
supabaseUserId, supabaseUserId,
}, },
}) });
const complex = await tx.complex.create({ const complex = await tx.complex.create({
data: { data: {
@@ -482,7 +463,7 @@ export async function completeOnboarding(input: OnboardingCompleteInput) {
adminEmail: onboardingRequest.email, adminEmail: onboardingRequest.email,
planCode: input.planCode, planCode: input.planCode,
}, },
}) });
await tx.complexUser.create({ await tx.complexUser.create({
data: { data: {
@@ -490,24 +471,24 @@ export async function completeOnboarding(input: OnboardingCompleteInput) {
userId: user.id, userId: user.id,
role: 'ADMIN', role: 'ADMIN',
}, },
}) });
await tx.onboardingRequest.update({ await tx.onboardingRequest.update({
where: { id: onboardingRequest.id }, where: { id: onboardingRequest.id },
data: { data: {
completedAt: new Date(), completedAt: new Date(),
}, },
}) });
return { return {
userId: user.id, userId: user.id,
complexId: complex.id, complexId: complex.id,
complexSlug: complex.complexSlug, complexSlug: complex.complexSlug,
} };
}) });
return { return {
...result, ...result,
supabaseUserId, supabaseUserId,
} };
} }

View File

@@ -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) { export async function listPlansHandler(c: AppContext) {
const plans = await db.plan.findMany({ const plans = await db.plan.findMany({
@@ -11,13 +11,13 @@ export async function listPlansHandler(c: AppContext) {
orderBy: { orderBy: {
price: 'asc', price: 'asc',
}, },
}) });
return c.json( return c.json(
plans.map((plan) => ({ plans.map((plan) => ({
code: plan.code, code: plan.code,
name: plan.name, name: plan.name,
price: Number(plan.price), price: Number(plan.price),
})), }))
) );
} }

View File

@@ -1,7 +1,7 @@
import { Hono } from 'hono' import { listPlansHandler } from '@/modules/plan/handlers/list-plans.handler';
import { listPlansHandler } from '@/modules/plan/handlers/list-plans.handler' import type { AppEnv } from '@/types/hono';
import type { AppEnv } from '@/types/hono' import { Hono } from 'hono';
export const planRoutes = new Hono<AppEnv>() export const planRoutes = new Hono<AppEnv>();
planRoutes.get('/', listPlansHandler) planRoutes.get('/', listPlansHandler);

View File

@@ -1,44 +1,41 @@
import type { PlanRules } from '@repo/api-contract' import type { PlanRules } from '@repo/api-contract';
import { planRulesSchema } from '@repo/api-contract' import { planRulesSchema } from '@repo/api-contract';
export type PlanUsageSnapshot = { export type PlanUsageSnapshot = {
courtsCount: number courtsCount: number;
bookingsToday: number bookingsToday: number;
activeUsersCount?: number activeUsersCount?: number;
} };
export type PlanViolationCode = export type PlanViolationCode =
| 'MAX_COURTS_REACHED' | 'MAX_COURTS_REACHED'
| 'MAX_BOOKINGS_PER_DAY_REACHED' | 'MAX_BOOKINGS_PER_DAY_REACHED'
| 'MAX_ACTIVE_USERS_REACHED' | 'MAX_ACTIVE_USERS_REACHED';
export type PlanViolation = { export type PlanViolation = {
code: PlanViolationCode code: PlanViolationCode;
message: string message: string;
} };
export function parsePlanRules(input: unknown): PlanRules { export function parsePlanRules(input: unknown): PlanRules {
return planRulesSchema.parse(input) return planRulesSchema.parse(input);
} }
export function evaluatePlanUsage( export function evaluatePlanUsage(rules: PlanRules, usage: PlanUsageSnapshot): PlanViolation[] {
rules: PlanRules, const violations: PlanViolation[] = [];
usage: PlanUsageSnapshot,
): PlanViolation[] {
const violations: PlanViolation[] = []
if (usage.courtsCount >= rules.limits.maxCourts) { if (usage.courtsCount >= rules.limits.maxCourts) {
violations.push({ violations.push({
code: 'MAX_COURTS_REACHED', code: 'MAX_COURTS_REACHED',
message: `El plan permite hasta ${rules.limits.maxCourts} canchas.`, message: `El plan permite hasta ${rules.limits.maxCourts} canchas.`,
}) });
} }
if (usage.bookingsToday >= rules.limits.maxBookingsPerDay) { if (usage.bookingsToday >= rules.limits.maxBookingsPerDay) {
violations.push({ violations.push({
code: 'MAX_BOOKINGS_PER_DAY_REACHED', code: 'MAX_BOOKINGS_PER_DAY_REACHED',
message: `El plan permite hasta ${rules.limits.maxBookingsPerDay} turnos por dia.`, message: `El plan permite hasta ${rules.limits.maxBookingsPerDay} turnos por dia.`,
}) });
} }
if ( if (
@@ -49,15 +46,12 @@ export function evaluatePlanUsage(
violations.push({ violations.push({
code: 'MAX_ACTIVE_USERS_REACHED', code: 'MAX_ACTIVE_USERS_REACHED',
message: `El plan permite hasta ${rules.limits.maxActiveUsers} usuarios activos.`, message: `El plan permite hasta ${rules.limits.maxActiveUsers} usuarios activos.`,
}) });
} }
return violations return violations;
} }
export function isFeatureEnabled( export function isFeatureEnabled(rules: PlanRules, feature: keyof PlanRules['features']): boolean {
rules: PlanRules, return rules.features[feature];
feature: keyof PlanRules['features'],
): boolean {
return rules.features[feature]
} }

View File

@@ -1,24 +1,24 @@
import type { CreatePublicBookingInput } from '@repo/api-contract'
import { import {
PublicBookingServiceError, PublicBookingServiceError,
createPublicBooking, createPublicBooking,
} from '@/modules/public-booking/services/public-booking.service' } from '@/modules/public-booking/services/public-booking.service';
import type { AppContext } from '@/types/hono' 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) { export async function createPublicBookingHandler(c: AppContext) {
const { complexSlug } = c.req.valid('param' as never) as ComplexSlugParams const { complexSlug } = c.req.valid('param' as never) as ComplexSlugParams;
const payload = c.req.valid('json' as never) as CreatePublicBookingInput const payload = c.req.valid('json' as never) as CreatePublicBookingInput;
try { try {
const booking = await createPublicBooking(complexSlug, payload) const booking = await createPublicBooking(complexSlug, payload);
return c.json(booking, 201) return c.json(booking, 201);
} catch (error) { } catch (error) {
if (error instanceof PublicBookingServiceError) { if (error instanceof PublicBookingServiceError) {
return c.json({ message: error.message }, error.status) return c.json({ message: error.message }, error.status);
} }
throw error throw error;
} }
} }

View File

@@ -1,25 +1,25 @@
import { import {
PublicBookingServiceError, PublicBookingServiceError,
getPublicBookingConfirmation, getPublicBookingConfirmation,
} from '@/modules/public-booking/services/public-booking.service' } from '@/modules/public-booking/services/public-booking.service';
import type { AppContext } from '@/types/hono' import type { AppContext } from '@/types/hono';
type BookingCodeParams = { type BookingCodeParams = {
complexSlug: string complexSlug: string;
bookingCode: string bookingCode: string;
} };
export async function getPublicBookingConfirmationHandler(c: AppContext) { 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 { try {
const booking = await getPublicBookingConfirmation(complexSlug, bookingCode) const booking = await getPublicBookingConfirmation(complexSlug, bookingCode);
return c.json(booking) return c.json(booking);
} catch (error) { } catch (error) {
if (error instanceof PublicBookingServiceError) { if (error instanceof PublicBookingServiceError) {
return c.json({ message: error.message }, error.status) return c.json({ message: error.message }, error.status);
} }
throw error throw error;
} }
} }

View File

@@ -1,24 +1,24 @@
import type { PublicAvailabilityQuery } from '@repo/api-contract'
import { import {
PublicBookingServiceError, PublicBookingServiceError,
listPublicAvailability, listPublicAvailability,
} from '@/modules/public-booking/services/public-booking.service' } from '@/modules/public-booking/services/public-booking.service';
import type { AppContext } from '@/types/hono' 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) { export async function listPublicAvailabilityHandler(c: AppContext) {
const { complexSlug } = c.req.valid('param' as never) as ComplexSlugParams const { complexSlug } = c.req.valid('param' as never) as ComplexSlugParams;
const query = c.req.valid('query' as never) as PublicAvailabilityQuery const query = c.req.valid('query' as never) as PublicAvailabilityQuery;
try { try {
const availability = await listPublicAvailability(complexSlug, query) const availability = await listPublicAvailability(complexSlug, query);
return c.json(availability) return c.json(availability);
} catch (error) { } catch (error) {
if (error instanceof PublicBookingServiceError) { if (error instanceof PublicBookingServiceError) {
return c.json({ message: error.message }, error.status) return c.json({ message: error.message }, error.status);
} }
throw error throw error;
} }
} }

View File

@@ -1,38 +1,35 @@
import { zValidator } from '@hono/zod-validator' import { createPublicBookingHandler } from '@/modules/public-booking/handlers/create-public-booking.handler';
import { import { getPublicBookingConfirmationHandler } from '@/modules/public-booking/handlers/get-public-booking-confirmation.handler';
createPublicBookingSchema, import { listPublicAvailabilityHandler } from '@/modules/public-booking/handlers/list-public-availability.handler';
publicAvailabilityQuerySchema, import type { AppEnv } from '@/types/hono';
} from '@repo/api-contract' import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono' import { createPublicBookingSchema, publicAvailabilityQuerySchema } from '@repo/api-contract';
import { z } from 'zod' import { Hono } from 'hono';
import { createPublicBookingHandler } from '@/modules/public-booking/handlers/create-public-booking.handler' import { z } from 'zod';
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'
export const publicBookingRoutes = new Hono<AppEnv>() export const publicBookingRoutes = new Hono<AppEnv>();
const complexSlugParamsSchema = z.object({ complexSlug: z.string().trim().min(1) }) const complexSlugParamsSchema = z.object({ complexSlug: z.string().trim().min(1) });
const confirmationParamsSchema = z.object({ const confirmationParamsSchema = z.object({
complexSlug: z.string().trim().min(1), complexSlug: z.string().trim().min(1),
bookingCode: z.string().trim().min(6).max(8), bookingCode: z.string().trim().min(6).max(8),
}) });
publicBookingRoutes.get( publicBookingRoutes.get(
'/complex/:complexSlug/availability', '/complex/:complexSlug/availability',
zValidator('param', complexSlugParamsSchema), zValidator('param', complexSlugParamsSchema),
zValidator('query', publicAvailabilityQuerySchema), zValidator('query', publicAvailabilityQuerySchema),
listPublicAvailabilityHandler, listPublicAvailabilityHandler
) );
publicBookingRoutes.post( publicBookingRoutes.post(
'/complex/:complexSlug', '/complex/:complexSlug',
zValidator('param', complexSlugParamsSchema), zValidator('param', complexSlugParamsSchema),
zValidator('json', createPublicBookingSchema), zValidator('json', createPublicBookingSchema),
createPublicBookingHandler, createPublicBookingHandler
) );
publicBookingRoutes.get( publicBookingRoutes.get(
'/complex/:complexSlug/confirmation/:bookingCode', '/complex/:complexSlug/confirmation/:bookingCode',
zValidator('param', confirmationParamsSchema), zValidator('param', confirmationParamsSchema),
getPublicBookingConfirmationHandler, getPublicBookingConfirmationHandler
) );

View File

@@ -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 { import type {
CreatePublicBookingInput, CreatePublicBookingInput,
DayOfWeek, DayOfWeek,
PublicAvailabilityQuery, PublicAvailabilityQuery,
} from '@repo/api-contract' } from '@repo/api-contract';
import { randomInt } from 'node:crypto' import { v7 as uuidv7 } from 'uuid';
import { v7 as uuidv7 } from 'uuid'
import { db } from '@/lib/prisma'
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service'
type Slot = { type Slot = {
startTime: string startTime: string;
endTime: string endTime: string;
} };
type ComplexWithPublicBookingData = Awaited<ReturnType<typeof getComplexWithBookingData>> type ComplexWithPublicBookingData = Awaited<ReturnType<typeof getComplexWithBookingData>>;
const DAY_OF_WEEK_BY_INDEX: DayOfWeek[] = [ const DAY_OF_WEEK_BY_INDEX: DayOfWeek[] = [
'SUNDAY', 'SUNDAY',
@@ -23,44 +23,44 @@ const DAY_OF_WEEK_BY_INDEX: DayOfWeek[] = [
'THURSDAY', 'THURSDAY',
'FRIDAY', 'FRIDAY',
'SATURDAY', 'SATURDAY',
] ];
const BOOKING_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' const BOOKING_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const BOOKING_CODE_LENGTH = 6 const BOOKING_CODE_LENGTH = 6;
export class PublicBookingServiceError extends Error { export class PublicBookingServiceError extends Error {
status: 400 | 403 | 404 | 409 status: 400 | 403 | 404 | 409;
constructor(message: string, status: 400 | 403 | 404 | 409) { constructor(message: string, status: 400 | 403 | 404 | 409) {
super(message) super(message);
this.name = 'PublicBookingServiceError' this.name = 'PublicBookingServiceError';
this.status = status this.status = status;
} }
} }
function toMinutes(value: string): number { function toMinutes(value: string): number {
const [hours, minutes] = value.split(':').map((part) => Number(part)) const [hours, minutes] = value.split(':').map((part) => Number(part));
return hours * 60 + minutes return hours * 60 + minutes;
} }
function minutesToTime(minutes: number): string { function minutesToTime(minutes: number): string {
const safeMinutes = Math.max(0, minutes) const safeMinutes = Math.max(0, minutes);
const hours = Math.floor(safeMinutes / 60) const hours = Math.floor(safeMinutes / 60);
const mins = safeMinutes % 60 const mins = safeMinutes % 60;
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}` return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
} }
function parseIsoDate(date: string): { bookingDate: Date; dayOfWeek: DayOfWeek } { 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) { 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 year = Number(match[1]);
const month = Number(match[2]) const month = Number(match[2]);
const day = Number(match[3]) 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 ( if (
Number.isNaN(bookingDate.getTime()) || Number.isNaN(bookingDate.getTime()) ||
@@ -68,48 +68,48 @@ function parseIsoDate(date: string): { bookingDate: Date; dayOfWeek: DayOfWeek }
bookingDate.getUTCMonth() + 1 !== month || bookingDate.getUTCMonth() + 1 !== month ||
bookingDate.getUTCDate() !== day 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) { 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 { function formatIsoDate(date: Date): string {
return date.toISOString().slice(0, 10) return date.toISOString().slice(0, 10);
} }
function generateBookingCode(): string { function generateBookingCode(): string {
let code = '' let code = '';
for (let index = 0; index < BOOKING_CODE_LENGTH; index += 1) { 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 { 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( function buildSlots(
availability: Array<{ availability: Array<{
startTime: string startTime: string;
endTime: string endTime: string;
}>, }>,
slotDurationMinutes: number, slotDurationMinutes: number
): Slot[] { ): Slot[] {
const slots: Slot[] = [] const slots: Slot[] = [];
for (const range of availability) { for (const range of availability) {
const start = toMinutes(range.startTime) const start = toMinutes(range.startTime);
const end = toMinutes(range.endTime) const end = toMinutes(range.endTime);
for ( for (
let current = start; let current = start;
@@ -119,19 +119,19 @@ function buildSlots(
slots.push({ slots.push({
startTime: minutesToTime(current), startTime: minutesToTime(current),
endTime: minutesToTime(current + slotDurationMinutes), endTime: minutesToTime(current + slotDurationMinutes),
}) });
} }
} }
return slots return slots;
} }
function resolveSports(data: ComplexWithPublicBookingData) { function resolveSports(data: ComplexWithPublicBookingData) {
const map = new Map<string, { id: string; name: string; slug: string }>() const map = new Map<string, { id: string; name: string; slug: string }>();
for (const court of data.courts) { for (const court of data.courts) {
if (!court.sport.isActive) { if (!court.sport.isActive) {
continue continue;
} }
if (!map.has(court.sport.id)) { if (!map.has(court.sport.id)) {
@@ -139,41 +139,41 @@ function resolveSports(data: ComplexWithPublicBookingData) {
id: court.sport.id, id: court.sport.id,
name: court.sport.name, name: court.sport.name,
slug: court.sport.slug, 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( function validateSportSelection(
availableSports: Array<{ id: string }>, availableSports: Array<{ id: string }>,
sportId: string | undefined, sportId: string | undefined
): { sportSelectionRequired: boolean; selectedSportId: string | undefined } { ): { sportSelectionRequired: boolean; selectedSportId: string | undefined } {
const sportSelectionRequired = availableSports.length > 1 const sportSelectionRequired = availableSports.length > 1;
if (sportSelectionRequired && !sportId) { if (sportSelectionRequired && !sportId) {
throw new PublicBookingServiceError( throw new PublicBookingServiceError(
'Debes seleccionar un deporte para consultar disponibilidad.', 'Debes seleccionar un deporte para consultar disponibilidad.',
400, 400
) );
} }
if (sportId && !availableSports.some((sport) => sport.id === sportId)) { 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) { if (sportSelectionRequired) {
return { return {
sportSelectionRequired, sportSelectionRequired,
selectedSportId: sportId, selectedSportId: sportId,
} };
} }
return { return {
sportSelectionRequired, sportSelectionRequired,
selectedSportId: sportId ?? availableSports[0]?.id, selectedSportId: sportId ?? availableSports[0]?.id,
} };
} }
async function getComplexWithBookingData(complexSlug: string) { async function getComplexWithBookingData(complexSlug: string) {
@@ -205,48 +205,48 @@ async function getComplexWithBookingData(complexSlug: string) {
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
}, },
}, },
}) });
if (!complex) { if (!complex) {
throw new PublicBookingServiceError('Complejo no encontrado.', 404) throw new PublicBookingServiceError('Complejo no encontrado.', 404);
} }
if (complex.plan) { if (complex.plan) {
const rules = parsePlanRules(complex.plan.rules) const rules = parsePlanRules(complex.plan.rules);
if (!rules.features.publicBookingPage) { if (!rules.features.publicBookingPage) {
throw new PublicBookingServiceError( throw new PublicBookingServiceError(
'El complejo no tiene habilitada la reserva publica.', 'El complejo no tiene habilitada la reserva publica.',
403, 403
) );
} }
} }
return complex return complex;
} }
function mapBookingResponse(input: { function mapBookingResponse(input: {
bookingId: string bookingId: string;
bookingCode: string bookingCode: string;
bookingDate: Date bookingDate: Date;
createdAt: Date createdAt: Date;
startTime: string startTime: string;
endTime: string endTime: string;
customerName: string customerName: string;
customerPhone: string customerPhone: string;
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED';
court: { court: {
id: string id: string;
name: string name: string;
complexId: string complexId: string;
complexName: string complexName: string;
complexSlug: string complexSlug: string;
sport: { sport: {
id: string id: string;
name: string name: string;
slug: string slug: string;
} };
} };
}) { }) {
return { return {
id: input.bookingId, id: input.bookingId,
@@ -264,11 +264,11 @@ function mapBookingResponse(input: {
customerPhone: input.customerPhone, customerPhone: input.customerPhone,
status: input.status, status: input.status,
createdAt: input.createdAt.toISOString(), createdAt: input.createdAt.toISOString(),
} };
} }
export async function getPublicBookingConfirmation(complexSlug: string, bookingCode: string) { export async function getPublicBookingConfirmation(complexSlug: string, bookingCode: string) {
const normalizedBookingCode = bookingCode.toUpperCase() const normalizedBookingCode = bookingCode.toUpperCase();
const booking = await db.courtBooking.findFirst({ const booking = await db.courtBooking.findFirst({
where: { where: {
bookingCode: normalizedBookingCode, bookingCode: normalizedBookingCode,
@@ -304,10 +304,10 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC
}, },
}, },
}, },
}) });
if (!booking) { if (!booking) {
throw new PublicBookingServiceError('Reserva no encontrada.', 404) throw new PublicBookingServiceError('Reserva no encontrada.', 404);
} }
return { return {
@@ -325,39 +325,34 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC
}, },
status: booking.status, status: booking.status,
createdAt: booking.createdAt.toISOString(), createdAt: booking.createdAt.toISOString(),
} };
} }
export async function listPublicAvailability( export async function listPublicAvailability(complexSlug: string, query: PublicAvailabilityQuery) {
complexSlug: string, const { bookingDate, dayOfWeek } = parseIsoDate(query.date);
query: PublicAvailabilityQuery, const complex = await getComplexWithBookingData(complexSlug);
) { const sports = resolveSports(complex);
const { bookingDate, dayOfWeek } = parseIsoDate(query.date) const sportSelectionRequired = sports.length > 1;
const complex = await getComplexWithBookingData(complexSlug)
const sports = resolveSports(complex)
const sportSelectionRequired = sports.length > 1
if (query.sportId && !sports.some((sport) => sport.id === query.sportId)) { 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 const selectedSportId = sportSelectionRequired ? query.sportId : (query.sportId ?? sports[0]?.id);
? query.sportId
: (query.sportId ?? sports[0]?.id)
const candidateCourts = complex.courts.filter((court) => { const candidateCourts = complex.courts.filter((court) => {
if (!court.sport.isActive) { if (!court.sport.isActive) {
return false return false;
} }
if (selectedSportId) { 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 = const bookings =
courtIds.length > 0 courtIds.length > 0
@@ -375,31 +370,31 @@ export async function listPublicAvailability(
endTime: true, endTime: true,
}, },
}) })
: [] : [];
const bookingsByCourt = new Map<string, Slot[]>() const bookingsByCourt = new Map<string, Slot[]>();
for (const booking of bookings) { for (const booking of bookings) {
const current = bookingsByCourt.get(booking.courtId) ?? [] const current = bookingsByCourt.get(booking.courtId) ?? [];
current.push({ current.push({
startTime: booking.startTime, startTime: booking.startTime,
endTime: booking.endTime, endTime: booking.endTime,
}) });
bookingsByCourt.set(booking.courtId, current) bookingsByCourt.set(booking.courtId, current);
} }
const courts = candidateCourts const courts = candidateCourts
.map((court) => { .map((court) => {
const dayAvailability = court.availabilities.filter( const dayAvailability = court.availabilities.filter(
(availability) => availability.dayOfWeek === dayOfWeek, (availability) => availability.dayOfWeek === dayOfWeek
) );
const allSlots = buildSlots(dayAvailability, court.slotDurationMinutes) const allSlots = buildSlots(dayAvailability, court.slotDurationMinutes);
const occupiedSlots = bookingsByCourt.get(court.id) ?? [] const occupiedSlots = bookingsByCourt.get(court.id) ?? [];
const availableSlots = allSlots.filter( const availableSlots = allSlots.filter(
(slot) => !occupiedSlots.some((occupied) => hasOverlap(slot, occupied)), (slot) => !occupiedSlots.some((occupied) => hasOverlap(slot, occupied))
) );
return { return {
courtId: court.id, courtId: court.id,
@@ -412,9 +407,9 @@ export async function listPublicAvailability(
slotDurationMinutes: court.slotDurationMinutes, slotDurationMinutes: court.slotDurationMinutes,
availabilityDay: dayOfWeek, availabilityDay: dayOfWeek,
availableSlots, availableSlots,
} };
}) })
.filter((court) => court.availableSlots.length > 0) .filter((court) => court.availableSlots.length > 0);
return { return {
complexId: complex.id, complexId: complex.id,
@@ -424,69 +419,66 @@ export async function listPublicAvailability(
sportSelectionRequired, sportSelectionRequired,
sports, sports,
courts, courts,
} };
} }
export async function createPublicBooking( export async function createPublicBooking(complexSlug: string, input: CreatePublicBookingInput) {
complexSlug: string, const { bookingDate, dayOfWeek } = parseIsoDate(input.date);
input: CreatePublicBookingInput, const complex = await getComplexWithBookingData(complexSlug);
) { const sports = resolveSports(complex);
const { bookingDate, dayOfWeek } = parseIsoDate(input.date) const { sportSelectionRequired } = validateSportSelection(sports, input.sportId);
const complex = await getComplexWithBookingData(complexSlug)
const sports = resolveSports(complex)
const { sportSelectionRequired } = validateSportSelection(sports, input.sportId)
const selectedCourt = complex.courts.find( const selectedCourt = complex.courts.find(
(court) => court.id === input.courtId && court.sport.isActive, (court) => court.id === input.courtId && court.sport.isActive
) );
if (!selectedCourt) { 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) { if (sportSelectionRequired && !input.sportId) {
throw new PublicBookingServiceError( throw new PublicBookingServiceError(
'Debes seleccionar un deporte para reservar en este complejo.', 'Debes seleccionar un deporte para reservar en este complejo.',
400, 400
) );
} }
if (input.sportId && input.sportId !== selectedCourt.sportId) { if (input.sportId && input.sportId !== selectedCourt.sportId) {
throw new PublicBookingServiceError( throw new PublicBookingServiceError(
'La cancha seleccionada no corresponde al deporte indicado.', 'La cancha seleccionada no corresponde al deporte indicado.',
400, 400
) );
} }
const dayAvailability = selectedCourt.availabilities.filter( const dayAvailability = selectedCourt.availabilities.filter(
(availability) => availability.dayOfWeek === dayOfWeek, (availability) => availability.dayOfWeek === dayOfWeek
) );
const validSlots = buildSlots(dayAvailability, selectedCourt.slotDurationMinutes) const validSlots = buildSlots(dayAvailability, selectedCourt.slotDurationMinutes);
const selectedStartMinutes = toMinutes(input.startTime) const selectedStartMinutes = toMinutes(input.startTime);
const selectedEndMinutes = selectedStartMinutes + selectedCourt.slotDurationMinutes const selectedEndMinutes = selectedStartMinutes + selectedCourt.slotDurationMinutes;
const selectedSlot = { const selectedSlot = {
startTime: input.startTime, startTime: input.startTime,
endTime: minutesToTime(selectedEndMinutes), endTime: minutesToTime(selectedEndMinutes),
} };
const slotExists = validSlots.some( const slotExists = validSlots.some(
(slot) => slot.startTime === selectedSlot.startTime && slot.endTime === selectedSlot.endTime, (slot) => slot.startTime === selectedSlot.startTime && slot.endTime === selectedSlot.endTime
) );
if (!slotExists) { if (!slotExists) {
throw new PublicBookingServiceError( throw new PublicBookingServiceError(
'El horario seleccionado no esta disponible para esa cancha.', 'El horario seleccionado no esta disponible para esa cancha.',
409, 409
) );
} }
for (let attempt = 0; attempt < 5; attempt += 1) { for (let attempt = 0; attempt < 5; attempt += 1) {
try { try {
const booking = await db.$transaction(async (tx) => { const booking = await db.$transaction(async (tx) => {
if (complex.plan) { if (complex.plan) {
const rules = parsePlanRules(complex.plan.rules) const rules = parsePlanRules(complex.plan.rules);
const bookingsForDate = await tx.courtBooking.count({ const bookingsForDate = await tx.courtBooking.count({
where: { where: {
bookingDate, bookingDate,
@@ -495,19 +487,19 @@ export async function createPublicBooking(
complexId: complex.id, complexId: complex.id,
}, },
}, },
}) });
const violations = evaluatePlanUsage(rules, { const violations = evaluatePlanUsage(rules, {
courtsCount: complex.courts.length, courtsCount: complex.courts.length,
bookingsToday: bookingsForDate, bookingsToday: bookingsForDate,
}) });
const maxBookingsViolation = violations.find( const maxBookingsViolation = violations.find(
(violation) => violation.code === 'MAX_BOOKINGS_PER_DAY_REACHED', (violation) => violation.code === 'MAX_BOOKINGS_PER_DAY_REACHED'
) );
if (maxBookingsViolation) { 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, gt: selectedSlot.startTime,
}, },
}, },
}) });
if (overlappingBooking) { 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({ return tx.courtBooking.create({
@@ -552,8 +544,8 @@ export async function createPublicBooking(
customerPhone: true, customerPhone: true,
status: true, status: true,
}, },
}) });
}) });
return mapBookingResponse({ return mapBookingResponse({
bookingId: booking.id, bookingId: booking.id,
@@ -577,40 +569,40 @@ export async function createPublicBooking(
slug: selectedCourt.sport.slug, slug: selectedCourt.sport.slug,
}, },
}, },
}) });
} catch (error) { } catch (error) {
if (error instanceof PublicBookingServiceError) { if (error instanceof PublicBookingServiceError) {
throw error throw error;
} }
const prismaError = error as { const prismaError = error as {
code?: string code?: string;
meta?: { meta?: {
target?: string[] | string target?: string[] | string;
} };
} };
if (prismaError.code === 'P2002') { if (prismaError.code === 'P2002') {
const targets = Array.isArray(prismaError.meta?.target) const targets = Array.isArray(prismaError.meta?.target)
? prismaError.meta?.target ? prismaError.meta?.target
: [prismaError.meta?.target] : [prismaError.meta?.target];
const isBookingCodeCollision = targets.some((target) => const isBookingCodeCollision = targets.some((target) =>
String(target).includes('booking_code'), String(target).includes('booking_code')
) );
if (isBookingCodeCollision) { 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( throw new PublicBookingServiceError(
'No se pudo generar un codigo de reserva unico. Intenta nuevamente.', 'No se pudo generar un codigo de reserva unico. Intenta nuevamente.',
409, 409
) );
} }

View File

@@ -1,18 +1,18 @@
import type { CreateSportInput } from '@repo/api-contract' import { Prisma } from '@/generated/prisma/client';
import { Prisma } from '@/generated/prisma/client' import { createSport } from '@/modules/sport/services/sport.service';
import type { AppContext } from '@/types/hono' import type { AppContext } from '@/types/hono';
import { createSport } from '@/modules/sport/services/sport.service' import type { CreateSportInput } from '@repo/api-contract';
export async function createSportHandler(c: AppContext) { 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 { try {
const sport = await createSport(payload) const sport = await createSport(payload);
return c.json(sport, 201) return c.json(sport, 201);
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { 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;
} }
} }

View File

@@ -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) { export async function listSportsHandler(c: AppContext) {
const sports = await listSports() const sports = await listSports();
return c.json(sports) return c.json(sports);
} }

View File

@@ -1,27 +1,27 @@
import type { UpdateSportInput } from '@repo/api-contract' import { Prisma } from '@/generated/prisma/client';
import { Prisma } from '@/generated/prisma/client' import { getSportById, updateSport } from '@/modules/sport/services/sport.service';
import type { AppContext } from '@/types/hono' import type { AppContext } from '@/types/hono';
import { getSportById, updateSport } from '@/modules/sport/services/sport.service' import type { UpdateSportInput } from '@repo/api-contract';
type SportIdParams = { id: string } type SportIdParams = { id: string };
export async function updateSportHandler(c: AppContext) { export async function updateSportHandler(c: AppContext) {
const { id } = c.req.valid('param' as never) as SportIdParams const { id } = c.req.valid('param' as never) as SportIdParams;
const payload = c.req.valid('json' as never) as UpdateSportInput const payload = c.req.valid('json' as never) as UpdateSportInput;
const existing = await getSportById(id) const existing = await getSportById(id);
if (!existing) { if (!existing) {
return c.json({ message: 'Deporte no encontrado.' }, 404) return c.json({ message: 'Deporte no encontrado.' }, 404);
} }
try { try {
const sport = await updateSport(id, payload) const sport = await updateSport(id, payload);
return c.json(sport) return c.json(sport);
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { 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;
} }
} }

View File

@@ -1,15 +1,15 @@
import { v7 as uuidv7 } from 'uuid' import { Prisma } from '@/generated/prisma/client';
import { Prisma } from '@/generated/prisma/client' import { db } from '@/lib/prisma';
import { db } from '@/lib/prisma' import { v7 as uuidv7 } from 'uuid';
export type CreateSportInput = { export type CreateSportInput = {
name: string name: string;
} };
export type UpdateSportInput = { export type UpdateSportInput = {
name?: string name?: string;
isActive?: boolean isActive?: boolean;
} };
function slugify(value: string): string { function slugify(value: string): string {
return value return value
@@ -19,18 +19,15 @@ function slugify(value: string): string {
.trim() .trim()
.replace(/[^a-z0-9\s-]/g, '') .replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-') .replace(/\s+/g, '-')
.replace(/-+/g, '-') .replace(/-+/g, '-');
} }
async function buildUniqueSlug( async function buildUniqueSlug(source: string, excludeSportId?: string): Promise<string> {
source: string, const base = slugify(source);
excludeSportId?: string, const fallback = base.length > 0 ? base : `sport-${uuidv7().slice(0, 8)}`;
): Promise<string> {
const base = slugify(source)
const fallback = base.length > 0 ? base : `sport-${uuidv7().slice(0, 8)}`
let candidate = fallback let candidate = fallback;
let index = 1 let index = 1;
while (true) { while (true) {
const existing = await db.sport.findFirst({ const existing = await db.sport.findFirst({
@@ -45,23 +42,23 @@ async function buildUniqueSlug(
: {}), : {}),
}, },
select: { id: true }, select: { id: true },
}) });
if (!existing) return candidate if (!existing) return candidate;
index += 1 index += 1;
candidate = `${fallback}-${index}` candidate = `${fallback}-${index}`;
} }
} }
export async function listSports() { export async function listSports() {
return db.sport.findMany({ return db.sport.findMany({
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
}) });
} }
export async function createSport(input: CreateSportInput) { export async function createSport(input: CreateSportInput) {
const slug = await buildUniqueSlug(input.name) const slug = await buildUniqueSlug(input.name);
return db.sport.create({ return db.sport.create({
data: { data: {
@@ -70,27 +67,27 @@ export async function createSport(input: CreateSportInput) {
slug, slug,
isActive: true, isActive: true,
}, },
}) });
} }
export async function getSportById(id: string) { 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) { export async function updateSport(id: string, input: UpdateSportInput) {
const data: Prisma.SportUncheckedUpdateInput = {} const data: Prisma.SportUncheckedUpdateInput = {};
if (input.name) { if (input.name) {
data.name = input.name data.name = input.name;
data.slug = await buildUniqueSlug(input.name, id) data.slug = await buildUniqueSlug(input.name, id);
} }
if (input.isActive !== undefined) { if (input.isActive !== undefined) {
data.isActive = input.isActive data.isActive = input.isActive;
} }
return db.sport.update({ return db.sport.update({
where: { id }, where: { id },
data, data,
}) });
} }

View File

@@ -1,30 +1,25 @@
import { zValidator } from '@hono/zod-validator' import { requireAuth } from '@/middlewares/require-auth.middleware';
import { createSportSchema, updateSportSchema } from '@repo/api-contract' import { requireSuperAdmin } from '@/middlewares/require-super-admin.middleware';
import { Hono } from 'hono' import { createSportHandler } from '@/modules/sport/handlers/create-sport.handler';
import { z } from 'zod' import { listSportsHandler } from '@/modules/sport/handlers/list-sports.handler';
import { requireAuth } from '@/middlewares/require-auth.middleware' import { updateSportHandler } from '@/modules/sport/handlers/update-sport.handler';
import { requireSuperAdmin } from '@/middlewares/require-super-admin.middleware' import type { AppEnv } from '@/types/hono';
import { createSportHandler } from '@/modules/sport/handlers/create-sport.handler' import { zValidator } from '@hono/zod-validator';
import { listSportsHandler } from '@/modules/sport/handlers/list-sports.handler' import { createSportSchema, updateSportSchema } from '@repo/api-contract';
import { updateSportHandler } from '@/modules/sport/handlers/update-sport.handler' import { Hono } from 'hono';
import type { AppEnv } from '@/types/hono' import { z } from 'zod';
export const sportRoutes = new Hono<AppEnv>() export const sportRoutes = new Hono<AppEnv>();
const sportIdParamsSchema = z.object({ id: z.uuid() }) const sportIdParamsSchema = z.object({ id: z.uuid() });
sportRoutes.use('*', requireAuth) sportRoutes.use('*', requireAuth);
sportRoutes.get('/', listSportsHandler) sportRoutes.get('/', listSportsHandler);
sportRoutes.post( sportRoutes.post('/', requireSuperAdmin, zValidator('json', createSportSchema), createSportHandler);
'/',
requireSuperAdmin,
zValidator('json', createSportSchema),
createSportHandler,
)
sportRoutes.patch( sportRoutes.patch(
'/:id', '/:id',
requireSuperAdmin, requireSuperAdmin,
zValidator('param', sportIdParamsSchema), zValidator('param', sportIdParamsSchema),
zValidator('json', updateSportSchema), zValidator('json', updateSportSchema),
updateSportHandler, updateSportHandler
) );

View File

@@ -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) { export function getUserProfileHandler(c: AppContext) {
const authUser = c.get('authUser') const authUser = c.get('authUser');
const profile = getUserProfile(authUser) const profile = getUserProfile(authUser);
return c.json(profile) return c.json(profile);
} }

View File

@@ -1,21 +1,16 @@
import type { AuthUser } from '@/types/auth-user' import type { AuthUser } from '@/types/auth-user';
import type { UserProfile } from '@repo/api-contract' import type { UserProfile } from '@repo/api-contract';
export function getUserProfile(user: AuthUser): UserProfile { export function getUserProfile(user: AuthUser): UserProfile {
const fullName = const fullName =
(typeof user.user_metadata?.full_name === 'string' && (typeof user.user_metadata?.full_name === 'string' && user.user_metadata.full_name) ||
user.user_metadata.full_name) ||
(typeof user.user_metadata?.name === 'string' && user.user_metadata.name) || (typeof user.user_metadata?.name === 'string' && user.user_metadata.name) ||
(user.email ?? 'Usuario') (user.email ?? 'Usuario');
const role = const role = (typeof user.app_metadata?.role === 'string' && user.app_metadata.role) || 'member';
(typeof user.app_metadata?.role === 'string' && user.app_metadata.role) ||
'member'
const avatarUrl = const avatarUrl =
(typeof user.user_metadata?.avatar_url === 'string' && (typeof user.user_metadata?.avatar_url === 'string' && user.user_metadata.avatar_url) || null;
user.user_metadata.avatar_url) ||
null
return { return {
id: user.id, id: user.id,
@@ -24,5 +19,5 @@ export function getUserProfile(user: AuthUser): UserProfile {
role, role,
avatarUrl, avatarUrl,
createdAt: user.created_at, createdAt: user.created_at,
} };
} }

View File

@@ -1,9 +1,9 @@
import { Hono } from 'hono' import { requireAuth } from '@/middlewares/require-auth.middleware';
import { requireAuth } from '@/middlewares/require-auth.middleware' import { getUserProfileHandler } from '@/modules/user/handlers/get-user-profile.handler';
import type { AppEnv } from '@/types/hono' import type { AppEnv } from '@/types/hono';
import { getUserProfileHandler } from '@/modules/user/handlers/get-user-profile.handler' import { Hono } from 'hono';
export const userRoutes = new Hono<AppEnv>() export const userRoutes = new Hono<AppEnv>();
userRoutes.use('*', requireAuth) userRoutes.use('*', requireAuth);
userRoutes.get('/profile', getUserProfileHandler) userRoutes.get('/profile', getUserProfileHandler);

View File

@@ -1,16 +1,16 @@
import type { Hono } from 'hono' import path from 'node:path';
import { serveStatic } from 'hono/bun' import { adminBookingRoutes } from '@/modules/admin-booking/admin-booking.routes';
import path from 'node:path' import { complexRoutes } from '@/modules/complex/complex.routes';
import { adminBookingRoutes } from '@/modules/admin-booking/admin-booking.routes' import { courtRoutes } from '@/modules/court/court.routes';
import { complexRoutes } from '@/modules/complex/complex.routes' import { onboardingRoutes } from '@/modules/onboarding/onboarding.routes';
import { courtRoutes } from '@/modules/court/court.routes' import { planRoutes } from '@/modules/plan/plan.routes';
import { onboardingRoutes } from '@/modules/onboarding/onboarding.routes' import { publicBookingRoutes } from '@/modules/public-booking/public-booking.routes';
import { planRoutes } from '@/modules/plan/plan.routes' import { sportRoutes } from '@/modules/sport/sport.routes';
import { publicBookingRoutes } from '@/modules/public-booking/public-booking.routes' import { userRoutes } from '@/modules/user/user.routes';
import { sportRoutes } from '@/modules/sport/sport.routes' import type { AppEnv } from '@/types/hono';
import { userRoutes } from '@/modules/user/user.routes' import type { Hono } from 'hono';
import type { AppEnv } from '@/types/hono' import { serveStatic } from 'hono/bun';
import { healthCheckRoutes } from './modules/health-check/health-check.routes' import { healthCheckRoutes } from './modules/health-check/health-check.routes';
export function registerRoutes(app: Hono<AppEnv>) { export function registerRoutes(app: Hono<AppEnv>) {
app app
@@ -22,24 +22,24 @@ export function registerRoutes(app: Hono<AppEnv>) {
.route('/api/courts', courtRoutes) .route('/api/courts', courtRoutes)
.route('/api/public-bookings', publicBookingRoutes) .route('/api/public-bookings', publicBookingRoutes)
.route('/api/admin-bookings', adminBookingRoutes) .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) => { app.get('*', async (c) => {
if (c.req.path === '/api' || c.req.path.startsWith('/api/')) { 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. // Keep real asset URLs as 404s if the file does not exist.
if (path.extname(c.req.path)) { 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())) { if (!(await indexFile.exists())) {
return c.notFound() return c.notFound();
} }
return c.html(await indexFile.text()) return c.html(await indexFile.text());
}) });
} }

View File

@@ -1,16 +1,13 @@
import app from '@/index' import app from '@/index';
import { logger } from '@/lib/logger' import { logger } from '@/lib/logger';
const port = Number(Bun.env.PORT ?? 3000) const port = Number(Bun.env.PORT ?? 3000);
const hostname = Bun.env.HOST ?? '0.0.0.0' const hostname = Bun.env.HOST ?? '0.0.0.0';
const server = Bun.serve({ const server = Bun.serve({
hostname, hostname,
port, port,
fetch: app.fetch, fetch: app.fetch,
}) });
logger.info( logger.info({ url: `http://${server.hostname}:${server.port}` }, 'backend_listening');
{ url: `http://${server.hostname}:${server.port}` },
'backend_listening',
)

View File

@@ -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;

View File

@@ -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 = { export type AppVariables = {
authUser: AuthUser authUser: AuthUser;
appUserId: string appUserId: string;
} };
export type AppEnv = { export type AppEnv = {
Variables: AppVariables Variables: AppVariables;
} };
export type AppContext = Context<AppEnv> export type AppContext = Context<AppEnv>;

View File

@@ -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,
},
},
])

View File

@@ -6,7 +6,9 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write .",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
@@ -38,20 +40,14 @@
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4",
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.2.2",
"@tanstack/router-plugin": "^1.167.12", "@tanstack/router-plugin": "^1.167.12",
"@types/node": "^24.12.0", "@types/node": "^24.12.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1", "@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", "tailwindcss": "^4.2.2",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
"vite": "^8.0.1" "vite": "^8.0.1"
} }
} }

View File

@@ -42,8 +42,7 @@
z-index: 1; z-index: 1;
top: 34px; top: 34px;
height: 28px; height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) scale(1.4);
scale(1.4);
} }
.vite { .vite {
@@ -51,8 +50,7 @@
top: 107px; top: 107px;
height: 26px; height: 26px;
width: auto; width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) scale(0.8);
scale(0.8);
} }
} }
@@ -167,7 +165,7 @@
&::before, &::before,
&::after { &::after {
content: ''; content: "";
position: absolute; position: absolute;
top: -4.5px; top: -4.5px;
border: 5px solid transparent; border: 5px solid transparent;

View File

@@ -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({ function Avatar({
className, className,
size = "default", size = 'default',
...props ...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & { }: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg" size?: 'default' | 'sm' | 'lg';
}) { }) {
return ( return (
<AvatarPrimitive.Root <AvatarPrimitive.Root
data-slot="avatar" data-slot="avatar"
data-size={size} data-size={size}
className={cn( className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten", 'group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten',
className className
)} )}
{...props} {...props}
/> />
) );
} }
function AvatarImage({ function AvatarImage({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return ( return (
<AvatarPrimitive.Image <AvatarPrimitive.Image
data-slot="avatar-image" data-slot="avatar-image"
className={cn( className={cn('aspect-square size-full rounded-full object-cover', className)}
"aspect-square size-full rounded-full object-cover",
className
)}
{...props} {...props}
/> />
) );
} }
function AvatarFallback({ function AvatarFallback({
@@ -47,64 +41,54 @@ function AvatarFallback({
<AvatarPrimitive.Fallback <AvatarPrimitive.Fallback
data-slot="avatar-fallback" data-slot="avatar-fallback"
className={cn( className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs", 'flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs',
className className
)} )}
{...props} {...props}
/> />
) );
} }
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { function AvatarBadge({ className, ...props }: React.ComponentProps<'span'>) {
return ( return (
<span <span
data-slot="avatar-badge" data-slot="avatar-badge"
className={cn( className={cn(
"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", '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=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=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", 'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2',
className className
)} )}
{...props} {...props}
/> />
) );
} }
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { function AvatarGroup({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="avatar-group" data-slot="avatar-group"
className={cn( className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background", 'group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background',
className className
)} )}
{...props} {...props}
/> />
) );
} }
function AvatarGroupCount({ function AvatarGroupCount({ className, ...props }: React.ComponentProps<'div'>) {
className,
...props
}: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="avatar-group-count" data-slot="avatar-group-count"
className={cn( className={cn(
"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", '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 className
)} )}
{...props} {...props}
/> />
) );
} }
export { export { Avatar, AvatarImage, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarBadge };
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}

View File

@@ -1,57 +1,57 @@
import * as React from "react" import { type VariantProps, cva } from 'class-variance-authority';
import { cva, type VariantProps } from "class-variance-authority" import { Slot } from 'radix-ui';
import { Slot } from "radix-ui" import * as React from 'react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const buttonVariants = cva( 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", "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: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
outline: 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: 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: 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: 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", '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", link: 'text-primary underline-offset-4 hover:underline',
}, },
size: { size: {
default: 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", 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", 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", 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: 'size-8',
"icon-xs": 'icon-xs':
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm": 'icon-sm':
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
"icon-lg": "size-9", 'icon-lg': 'size-9',
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
size: "default", size: 'default',
}, },
} }
) );
function Button({ function Button({
className, className,
variant = "default", variant = 'default',
size = "default", size = 'default',
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"button"> & }: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & { VariantProps<typeof buttonVariants> & {
asChild?: boolean asChild?: boolean;
}) { }) {
const Comp = asChild ? Slot.Root : "button" const Comp = asChild ? Slot.Root : 'button';
return ( return (
<Comp <Comp
@@ -61,7 +61,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
{...props} {...props}
/> />
) );
} }
export { Button, buttonVariants } export { Button, buttonVariants };

View File

@@ -1,54 +1,47 @@
import * as React from "react" import { ChevronLeft, ChevronRight } from 'lucide-react';
import { DayPicker } from "react-day-picker" import * as React from 'react';
import { ChevronLeft, ChevronRight } from "lucide-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<typeof DayPicker> export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({ function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return ( return (
<DayPicker <DayPicker
showOutsideDays={showOutsideDays} showOutsideDays={showOutsideDays}
className={cn("p-3", className)} className={cn('p-3', className)}
classNames={{ classNames={{
months: "flex flex-col space-y-4 sm:flex-row sm:space-x-4 sm:space-y-0", months: 'flex flex-col space-y-4 sm:flex-row sm:space-x-4 sm:space-y-0',
month: "space-y-4", month: 'space-y-4',
caption: "flex items-center justify-center pt-1 relative", caption: 'flex items-center justify-center pt-1 relative',
caption_label: "text-sm font-medium", caption_label: 'text-sm font-medium',
nav: "space-x-1 flex items-center", nav: 'space-x-1 flex items-center',
nav_button: cn( nav_button: cn(
buttonVariants({ variant: "outline" }), buttonVariants({ variant: 'outline' }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100" 'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100'
), ),
nav_button_previous: "absolute left-0 top-0", nav_button_previous: 'absolute left-0 top-0',
nav_button_next: "absolute right-0 top-0", nav_button_next: 'absolute right-0 top-0',
table: "w-full border-collapse space-y-1", table: 'w-full border-collapse space-y-1',
head_row: "flex", head_row: 'flex',
head_cell: head_cell: 'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]", row: 'flex w-full mt-2',
row: "flex w-full mt-2", cell: 'text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20',
cell: "text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn( day: cn(
buttonVariants({ variant: "ghost" }), buttonVariants({ variant: 'ghost' }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100" 'h-9 w-9 p-0 font-normal aria-selected:opacity-100'
), ),
day_range_end: "day-range-end", day_range_end: 'day-range-end',
day_selected: day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
day_today: "bg-accent text-accent-foreground", day_today: 'bg-accent text-accent-foreground',
day_outside: day_outside:
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30", 'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',
day_disabled: "text-muted-foreground opacity-50", day_disabled: 'text-muted-foreground opacity-50',
day_range_middle: day_range_middle: 'aria-selected:bg-accent aria-selected:text-accent-foreground',
"aria-selected:bg-accent aria-selected:text-accent-foreground", day_hidden: 'invisible',
day_hidden: "invisible",
...classNames, ...classNames,
}} }}
components={{ components={{
@@ -57,8 +50,8 @@ function Calendar({
}} }}
{...props} {...props}
/> />
) );
} }
Calendar.displayName = "Calendar" Calendar.displayName = 'Calendar';
export { Calendar } export { Calendar };

View File

@@ -1,26 +1,22 @@
"use client" 'use client';
import * as React from "react" import { format } from 'date-fns';
import { format } from "date-fns" import { es } from 'date-fns/locale';
import { es } from "date-fns/locale" import { Calendar as CalendarIcon } from 'lucide-react';
import { Calendar as CalendarIcon } from "lucide-react" import * as React from 'react';
import { cn } from "@/lib/utils" import { Button } from '@/components/ui/button';
import { Button } from "@/components/ui/button" import { Calendar } from '@/components/ui/calendar';
import { Calendar } from "@/components/ui/calendar" import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { import { cn } from '@/lib/utils';
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
interface DatePickerProps { interface DatePickerProps {
value?: Date value?: Date;
onChange?: (date: Date | undefined) => void onChange?: (date: Date | undefined) => void;
disabled?: (date: Date) => boolean disabled?: (date: Date) => boolean;
className?: string className?: string;
placeholder?: string placeholder?: string;
minDate?: Date minDate?: Date;
} }
export function DatePicker({ export function DatePicker({
@@ -28,19 +24,19 @@ export function DatePicker({
onChange, onChange,
disabled, disabled,
className, className,
placeholder = "Selecciona una fecha", placeholder = 'Selecciona una fecha',
minDate, minDate,
}: DatePickerProps) { }: DatePickerProps) {
const [open, setOpen] = React.useState(false) const [open, setOpen] = React.useState(false);
const isDateDisabled = React.useCallback( const isDateDisabled = React.useCallback(
(date: Date) => { (date: Date) => {
if (minDate && date < minDate) return true if (minDate && date < minDate) return true;
if (disabled) return disabled(date) if (disabled) return disabled(date);
return false return false;
}, },
[minDate, disabled] [minDate, disabled]
) );
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
@@ -48,18 +44,14 @@ export function DatePicker({
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
"w-full justify-start text-left font-normal", 'w-full justify-start text-left font-normal',
!value && "text-muted-foreground", !value && 'text-muted-foreground',
className className
)} )}
data-empty={!value} data-empty={!value}
> >
<CalendarIcon className="mr-2 h-4 w-4" /> <CalendarIcon className="mr-2 h-4 w-4" />
{value ? ( {value ? format(value, 'PPP', { locale: es }) : <span>{placeholder}</span>}
format(value, "PPP", { locale: es })
) : (
<span>{placeholder}</span>
)}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start"> <PopoverContent className="w-auto p-0" align="start">
@@ -67,13 +59,13 @@ export function DatePicker({
mode="single" mode="single"
selected={value} selected={value}
onSelect={(date) => { onSelect={(date) => {
onChange?.(date) onChange?.(date);
setOpen(false) setOpen(false);
}} }}
disabled={isDateDisabled} disabled={isDateDisabled}
initialFocus initialFocus
/> />
</PopoverContent> </PopoverContent>
</Popover> </Popover>
) );
} }

View File

@@ -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 { Button } from "@/components/ui/button" import { cn } from '@/lib/utils';
import { XIcon } from "lucide-react" import { XIcon } from 'lucide-react';
function Dialog({ function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
...props return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
} }
function DialogTrigger({ function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
...props return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
} }
function DialogPortal({ function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
...props return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
} }
function DialogClose({ function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
...props return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
} }
function DialogOverlay({ function DialogOverlay({
@@ -37,12 +29,12 @@ function DialogOverlay({
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0", 'fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0',
className className
)} )}
{...props} {...props}
/> />
) );
} }
function DialogContent({ function DialogContent({
@@ -51,7 +43,7 @@ function DialogContent({
showCloseButton = true, showCloseButton = true,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & { }: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean showCloseButton?: boolean;
}) { }) {
return ( return (
<DialogPortal> <DialogPortal>
@@ -59,7 +51,7 @@ function DialogContent({
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm 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", 'fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm 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
)} )}
{...props} {...props}
@@ -67,30 +59,21 @@ function DialogContent({
{children} {children}
{showCloseButton && ( {showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild> <DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button <Button variant="ghost" className="absolute top-2 right-2" size="icon-sm">
variant="ghost" <XIcon />
className="absolute top-2 right-2"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</Button> </Button>
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
) );
} }
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div data-slot="dialog-header" className={cn('flex flex-col gap-2', className)} {...props} />
data-slot="dialog-header" );
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
} }
function DialogFooter({ function DialogFooter({
@@ -98,14 +81,14 @@ function DialogFooter({
showCloseButton = false, showCloseButton = false,
children, children,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<'div'> & {
showCloseButton?: boolean showCloseButton?: boolean;
}) { }) {
return ( return (
<div <div
data-slot="dialog-footer" data-slot="dialog-footer"
className={cn( className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end", '-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end',
className className
)} )}
{...props} {...props}
@@ -117,23 +100,17 @@ function DialogFooter({
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
</div> </div>
) );
} }
function DialogTitle({ function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return ( return (
<DialogPrimitive.Title <DialogPrimitive.Title
data-slot="dialog-title" data-slot="dialog-title"
className={cn( className={cn('font-heading text-base leading-none font-medium', className)}
"font-heading text-base leading-none font-medium",
className
)}
{...props} {...props}
/> />
) );
} }
function DialogDescription({ function DialogDescription({
@@ -144,12 +121,12 @@ function DialogDescription({
<DialogPrimitive.Description <DialogPrimitive.Description
data-slot="dialog-description" data-slot="dialog-description"
className={cn( className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground", 'text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground',
className className
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -163,4 +140,4 @@ export {
DialogPortal, DialogPortal,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} };

View File

@@ -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 { cn } from '@/lib/utils';
import { CheckIcon, ChevronRightIcon } from "lucide-react" import { CheckIcon, ChevronRightIcon } from 'lucide-react';
function DropdownMenu({ function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
...props return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
} }
function DropdownMenuPortal({ function DropdownMenuPortal({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return ( return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
} }
function DropdownMenuTrigger({ function DropdownMenuTrigger({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return ( return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
} }
function DropdownMenuContent({ function DropdownMenuContent({
className, className,
align = "start", align = 'start',
sideOffset = 4, sideOffset = 4,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
@@ -43,29 +34,28 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content" data-slot="dropdown-menu-content"
sideOffset={sideOffset} sideOffset={sideOffset}
align={align} 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} {...props}
/> />
</DropdownMenuPrimitive.Portal> </DropdownMenuPrimitive.Portal>
) );
} }
function DropdownMenuGroup({ function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
...props return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
} }
function DropdownMenuItem({ function DropdownMenuItem({
className, className,
inset, inset,
variant = "default", variant = 'default',
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean inset?: boolean;
variant?: "default" | "destructive" variant?: 'default' | 'destructive';
}) { }) {
return ( return (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
@@ -78,7 +68,7 @@ function DropdownMenuItem({
)} )}
{...props} {...props}
/> />
) );
} }
function DropdownMenuCheckboxItem({ function DropdownMenuCheckboxItem({
@@ -88,7 +78,7 @@ function DropdownMenuCheckboxItem({
inset, inset,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<DropdownMenuPrimitive.CheckboxItem <DropdownMenuPrimitive.CheckboxItem
@@ -106,24 +96,18 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item-indicator" data-slot="dropdown-menu-checkbox-item-indicator"
> >
<DropdownMenuPrimitive.ItemIndicator> <DropdownMenuPrimitive.ItemIndicator>
<CheckIcon <CheckIcon />
/>
</DropdownMenuPrimitive.ItemIndicator> </DropdownMenuPrimitive.ItemIndicator>
</span> </span>
{children} {children}
</DropdownMenuPrimitive.CheckboxItem> </DropdownMenuPrimitive.CheckboxItem>
) );
} }
function DropdownMenuRadioGroup({ function DropdownMenuRadioGroup({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return ( return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
} }
function DropdownMenuRadioItem({ function DropdownMenuRadioItem({
@@ -132,7 +116,7 @@ function DropdownMenuRadioItem({
inset, inset,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<DropdownMenuPrimitive.RadioItem <DropdownMenuPrimitive.RadioItem
@@ -149,13 +133,12 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item-indicator" data-slot="dropdown-menu-radio-item-indicator"
> >
<DropdownMenuPrimitive.ItemIndicator> <DropdownMenuPrimitive.ItemIndicator>
<CheckIcon <CheckIcon />
/>
</DropdownMenuPrimitive.ItemIndicator> </DropdownMenuPrimitive.ItemIndicator>
</span> </span>
{children} {children}
</DropdownMenuPrimitive.RadioItem> </DropdownMenuPrimitive.RadioItem>
) );
} }
function DropdownMenuLabel({ function DropdownMenuLabel({
@@ -163,19 +146,19 @@ function DropdownMenuLabel({
inset, inset,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label" data-slot="dropdown-menu-label"
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7", 'px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7',
className className
)} )}
{...props} {...props}
/> />
) );
} }
function DropdownMenuSeparator({ function DropdownMenuSeparator({
@@ -185,32 +168,27 @@ function DropdownMenuSeparator({
return ( return (
<DropdownMenuPrimitive.Separator <DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator" data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)} className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props} {...props}
/> />
) );
} }
function DropdownMenuShortcut({ function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
className,
...props
}: React.ComponentProps<"span">) {
return ( return (
<span <span
data-slot="dropdown-menu-shortcut" data-slot="dropdown-menu-shortcut"
className={cn( className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground", 'ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground',
className className
)} )}
{...props} {...props}
/> />
) );
} }
function DropdownMenuSub({ function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
...props return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
} }
function DropdownMenuSubTrigger({ function DropdownMenuSubTrigger({
@@ -219,7 +197,7 @@ function DropdownMenuSubTrigger({
children, children,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
@@ -234,7 +212,7 @@ function DropdownMenuSubTrigger({
{children} {children}
<ChevronRightIcon className="ml-auto" /> <ChevronRightIcon className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
) );
} }
function DropdownMenuSubContent({ function DropdownMenuSubContent({
@@ -244,10 +222,13 @@ function DropdownMenuSubContent({
return ( return (
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content" data-slot="dropdown-menu-sub-content"
className={cn("z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg 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-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 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg 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-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} {...props}
/> />
) );
} }
export { export {
@@ -266,4 +247,4 @@ export {
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuSubContent, DropdownMenuSubContent,
} };

View File

@@ -1,77 +1,74 @@
import { useMemo } from "react" import { type VariantProps, cva } from 'class-variance-authority';
import { cva, type VariantProps } from "class-variance-authority" import { useMemo } from 'react';
import { cn } from "@/lib/utils" import { Label } from '@/components/ui/label';
import { Label } from "@/components/ui/label" import { Separator } from '@/components/ui/separator';
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 ( return (
<fieldset <fieldset
data-slot="field-set" data-slot="field-set"
className={cn( className={cn(
"flex flex-col gap-4 has-[>[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 className
)} )}
{...props} {...props}
/> />
) );
} }
function FieldLegend({ function FieldLegend({
className, className,
variant = "legend", variant = 'legend',
...props ...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { }: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) {
return ( return (
<legend <legend
data-slot="field-legend" data-slot="field-legend"
data-variant={variant} data-variant={variant}
className={cn( className={cn(
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base", 'mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base',
className className
)} )}
{...props} {...props}
/> />
) );
} }
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="field-group" data-slot="field-group"
className={cn( className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4", 'group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4',
className className
)} )}
{...props} {...props}
/> />
) );
} }
const fieldVariants = cva( const fieldVariants = cva('group/field flex w-full gap-2 data-[invalid=true]:text-destructive', {
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive", variants: {
{ orientation: {
variants: { vertical: 'flex-col *:w-full [&>.sr-only]:w-auto',
orientation: { horizontal:
vertical: "flex-col *:w-full [&>.sr-only]:w-auto", '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',
horizontal: responsive:
"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", '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',
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({ function Field({
className, className,
orientation = "vertical", orientation = 'vertical',
...props ...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) { }: React.ComponentProps<'div'> & VariantProps<typeof fieldVariants>) {
return ( return (
<div <div
role="group" role="group"
@@ -80,80 +77,74 @@ function Field({
className={cn(fieldVariants({ orientation }), className)} className={cn(fieldVariants({ orientation }), className)}
{...props} {...props}
/> />
) );
} }
function FieldContent({ className, ...props }: React.ComponentProps<"div">) { function FieldContent({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="field-content" data-slot="field-content"
className={cn( className={cn('group/field-content flex flex-1 flex-col gap-0.5 leading-snug', className)}
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
className
)}
{...props} {...props}
/> />
) );
} }
function FieldLabel({ function FieldLabel({ className, ...props }: React.ComponentProps<typeof Label>) {
className,
...props
}: React.ComponentProps<typeof Label>) {
return ( return (
<Label <Label
data-slot="field-label" data-slot="field-label"
className={cn( className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10", 'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10',
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col", 'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col',
className className
)} )}
{...props} {...props}
/> />
) );
} }
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) { function FieldTitle({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="field-label" data-slot="field-label"
className={cn( className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50", 'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
className className
)} )}
{...props} {...props}
/> />
) );
} }
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) { function FieldDescription({ className, ...props }: React.ComponentProps<'p'>) {
return ( return (
<p <p
data-slot="field-description" data-slot="field-description"
className={cn( className={cn(
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5", 'text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5',
"last:mt-0 nth-last-2:-mt-1", 'last:mt-0 nth-last-2:-mt-1',
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary", '[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary',
className className
)} )}
{...props} {...props}
/> />
) );
} }
function FieldSeparator({ function FieldSeparator({
children, children,
className, className,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<'div'> & {
children?: React.ReactNode children?: React.ReactNode;
}) { }) {
return ( return (
<div <div
data-slot="field-separator" data-slot="field-separator"
data-content={!!children} data-content={!!children}
className={cn( className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2", 'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
className className
)} )}
{...props} {...props}
@@ -168,7 +159,7 @@ function FieldSeparator({
</span> </span>
)} )}
</div> </div>
) );
} }
function FieldError({ function FieldError({
@@ -176,50 +167,45 @@ function FieldError({
children, children,
errors, errors,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<'div'> & {
errors?: Array<{ message?: string } | undefined> errors?: Array<{ message?: string } | undefined>;
}) { }) {
const content = useMemo(() => { const content = useMemo(() => {
if (children) { if (children) {
return children return children;
} }
if (!errors?.length) { if (!errors?.length) {
return null return null;
} }
const uniqueErrors = [ const uniqueErrors = [...new Map(errors.map((error) => [error?.message, error])).values()];
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) { if (uniqueErrors?.length === 1) {
return uniqueErrors[0]?.message return uniqueErrors[0]?.message;
} }
return ( return (
<ul className="ml-4 flex list-disc flex-col gap-1"> <ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map( {uniqueErrors.map((error, index) => error?.message && <li key={index}>{error.message}</li>)}
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul> </ul>
) );
}, [children, errors]) }, [children, errors]);
if (!content) { if (!content) {
return null return null;
} }
return ( return (
<div <div
role="alert" role="alert"
data-slot="field-error" data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)} className={cn('text-sm font-normal text-destructive', className)}
{...props} {...props}
> >
{content} {content}
</div> </div>
) );
} }
export { export {
@@ -233,4 +219,4 @@ export {
FieldSet, FieldSet,
FieldContent, FieldContent,
FieldTitle, FieldTitle,
} };

View File

@@ -1,32 +1,26 @@
import * as React from "react" import * as LabelPrimitive from '@radix-ui/react-label';
import * as LabelPrimitive from "@radix-ui/react-label" import { Slot } from '@radix-ui/react-slot';
import { Slot } from "@radix-ui/react-slot" import * as React from 'react';
import type { import type { ControllerProps, FieldPath, FieldValues } from 'react-hook-form';
ControllerProps, import { Controller, FormProvider, useFormContext } from 'react-hook-form';
FieldPath,
FieldValues,
} from "react-hook-form"
import { Controller, FormProvider, useFormContext } from "react-hook-form"
import { cn } from "@/lib/utils" import { Label } from '@/components/ui/label';
import { Label } from "@/components/ui/label" import { cn } from '@/lib/utils';
const Form = FormProvider const Form = FormProvider;
type FormFieldContextValue< type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = { > = {
name: TName name: TName;
} };
const FormFieldContext = React.createContext<FormFieldContextValue>( const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
{} as FormFieldContextValue
)
const FormField = < const FormField = <
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({ >({
...props ...props
}: ControllerProps<TFieldValues, TName>) => { }: ControllerProps<TFieldValues, TName>) => {
@@ -34,21 +28,21 @@ const FormField = <
<FormFieldContext.Provider value={{ name: props.name }}> <FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} /> <Controller {...props} />
</FormFieldContext.Provider> </FormFieldContext.Provider>
) );
} };
const useFormField = () => { const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext) const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext) const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext() const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState) const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) { if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>") throw new Error('useFormField should be used within <FormField>');
} }
const { id } = itemContext const { id } = itemContext;
return { return {
id, id,
@@ -57,110 +51,103 @@ const useFormField = () => {
formDescriptionId: `${id}-form-item-description`, formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`, formMessageId: `${id}-form-item-message`,
...fieldState, ...fieldState,
} };
} };
type FormItemContextValue = { type FormItemContextValue = {
id: string id: string;
} };
const FormItemContext = React.createContext<FormItemContextValue>( const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
{} as FormItemContextValue
)
const FormItem = React.forwardRef< const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
HTMLDivElement, ({ className, ...props }, ref) => {
React.HTMLAttributes<HTMLDivElement> const id = React.useId();
>(({ className, ...props }, ref) => {
const id = React.useId()
return ( return (
<FormItemContext.Provider value={{ id }}> <FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} /> <div ref={ref} className={cn('space-y-2', className)} {...props} />
</FormItemContext.Provider> </FormItemContext.Provider>
) );
}) }
FormItem.displayName = "FormItem" );
FormItem.displayName = 'FormItem';
const FormLabel = React.forwardRef< const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>, React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => { >(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField() const { error, formItemId } = useFormField();
return ( return (
<Label <Label
ref={ref} ref={ref}
className={cn(error && "text-destructive", className)} className={cn(error && 'text-destructive', className)}
htmlFor={formItemId} htmlFor={formItemId}
{...props} {...props}
/> />
) );
}) });
FormLabel.displayName = "FormLabel" FormLabel.displayName = 'FormLabel';
const FormControl = React.forwardRef< const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>, React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot> React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => { >(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField() const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
return ( return (
<Slot <Slot
ref={ref} ref={ref}
id={formItemId} id={formItemId}
aria-describedby={ aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error} aria-invalid={!!error}
{...props} {...props}
/> />
) );
}) });
FormControl.displayName = "FormControl" FormControl.displayName = 'FormControl';
const FormDescription = React.forwardRef< const FormDescription = React.forwardRef<
HTMLParagraphElement, HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement> React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => { >(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField() const { formDescriptionId } = useFormField();
return ( return (
<p <p
ref={ref} ref={ref}
id={formDescriptionId} id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)} className={cn('text-sm text-muted-foreground', className)}
{...props} {...props}
/> />
) );
}) });
FormDescription.displayName = "FormDescription" FormDescription.displayName = 'FormDescription';
const FormMessage = React.forwardRef< const FormMessage = React.forwardRef<
HTMLParagraphElement, HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement> React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => { >(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField() const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children const body = error ? String(error?.message) : children;
if (!body) { if (!body) {
return null return null;
} }
return ( return (
<p <p
ref={ref} ref={ref}
id={formMessageId} id={formMessageId}
className={cn("text-sm font-medium text-destructive", className)} className={cn('text-sm font-medium text-destructive', className)}
{...props} {...props}
> >
{body} {body}
</p> </p>
) );
}) });
FormMessage.displayName = "FormMessage" FormMessage.displayName = 'FormMessage';
export { export {
useFormField, useFormField,
@@ -171,4 +158,4 @@ export {
FormDescription, FormDescription,
FormField, FormField,
FormMessage, FormMessage,
} };

View File

@@ -1,59 +1,59 @@
import * as React from "react" import { OTPInput, OTPInputContext } from 'input-otp';
import { OTPInput, OTPInputContext } from "input-otp" import * as React from 'react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
import { MinusIcon } from "lucide-react" import { MinusIcon } from 'lucide-react';
function InputOTP({ function InputOTP({
className, className,
containerClassName, containerClassName,
...props ...props
}: React.ComponentProps<typeof OTPInput> & { }: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string containerClassName?: string;
}) { }) {
return ( return (
<OTPInput <OTPInput
data-slot="input-otp" data-slot="input-otp"
containerClassName={cn( containerClassName={cn(
"cn-input-otp flex items-center has-disabled:opacity-50", 'cn-input-otp flex items-center has-disabled:opacity-50',
containerClassName containerClassName
)} )}
spellCheck={false} spellCheck={false}
className={cn("disabled:cursor-not-allowed", className)} className={cn('disabled:cursor-not-allowed', className)}
{...props} {...props}
/> />
) );
} }
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) { function InputOTPGroup({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="input-otp-group" data-slot="input-otp-group"
className={cn( className={cn(
"flex items-center rounded-lg has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40", 'flex items-center rounded-lg has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40',
className className
)} )}
{...props} {...props}
/> />
) );
} }
function InputOTPSlot({ function InputOTPSlot({
index, index,
className, className,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<'div'> & {
index: number index: number;
}) { }) {
const inputOTPContext = React.useContext(OTPInputContext) const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {} const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
return ( return (
<div <div
data-slot="input-otp-slot" data-slot="input-otp-slot"
data-active={isActive} data-active={isActive}
className={cn( className={cn(
"relative flex size-8 items-center justify-center border-y border-r border-input text-sm transition-all outline-none first:rounded-l-lg first:border-l last:rounded-r-lg aria-invalid:border-destructive data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:ring-3 data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:border-destructive data-[active=true]:aria-invalid:ring-destructive/20 dark:bg-input/30 dark:data-[active=true]:aria-invalid:ring-destructive/40", 'relative flex size-8 items-center justify-center border-y border-r border-input text-sm transition-all outline-none first:rounded-l-lg first:border-l last:rounded-r-lg aria-invalid:border-destructive data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:ring-3 data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:border-destructive data-[active=true]:aria-invalid:ring-destructive/20 dark:bg-input/30 dark:data-[active=true]:aria-invalid:ring-destructive/40',
className className
)} )}
{...props} {...props}
@@ -65,10 +65,10 @@ function InputOTPSlot({
</div> </div>
)} )}
</div> </div>
) );
} }
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) { function InputOTPSeparator({ ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="input-otp-separator" data-slot="input-otp-separator"
@@ -76,10 +76,9 @@ function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
role="separator" role="separator"
{...props} {...props}
> >
<MinusIcon <MinusIcon />
/>
</div> </div>
) );
} }
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View File

@@ -1,19 +1,19 @@
import * as React from "react" import * as React from 'react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
function Input({ className, type, ...props }: React.ComponentProps<"input">) { function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return ( return (
<input <input
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", 'h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
className className
)} )}
{...props} {...props}
/> />
) );
} }
export { Input } export { Input };

View File

@@ -1,22 +1,19 @@
import * as React from "react" import { Label as LabelPrimitive } from 'radix-ui';
import { Label as LabelPrimitive } from "radix-ui" import * as React from 'react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
function Label({ function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return ( return (
<LabelPrimitive.Root <LabelPrimitive.Root
data-slot="label" data-slot="label"
className={cn( className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", 'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className className
)} )}
{...props} {...props}
/> />
) );
} }
export { Label } export { Label };

View File

@@ -1,29 +1,29 @@
import * as React from "react" import * as PopoverPrimitive from '@radix-ui/react-popover';
import * as PopoverPrimitive from "@radix-ui/react-popover" import * as React from 'react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const Popover = PopoverPrimitive.Root const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef< const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>, React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( >(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal> <PopoverPrimitive.Portal>
<PopoverPrimitive.Content <PopoverPrimitive.Content
ref={ref} ref={ref}
align={align} align={align}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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", 'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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',
className className
)} )}
{...props} {...props}
/> />
</PopoverPrimitive.Portal> </PopoverPrimitive.Portal>
)) ));
PopoverContent.displayName = PopoverPrimitive.Content.displayName PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent } export { Popover, PopoverTrigger, PopoverContent };

View File

@@ -1,41 +1,34 @@
import * as React from "react" import { Select as SelectPrimitive } from 'radix-ui';
import { Select as SelectPrimitive } from "radix-ui" import * as React from 'react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react" import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
function Select({ function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
...props return <SelectPrimitive.Root data-slot="select" {...props} />;
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
} }
function SelectGroup({ function SelectGroup({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return ( return (
<SelectPrimitive.Group <SelectPrimitive.Group
data-slot="select-group" data-slot="select-group"
className={cn("scroll-my-1 p-1", className)} className={cn('scroll-my-1 p-1', className)}
{...props} {...props}
/> />
) );
} }
function SelectValue({ function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
...props return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
} }
function SelectTrigger({ function SelectTrigger({
className, className,
size = "default", size = 'default',
children, children,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & { }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default" size?: 'sm' | 'default';
}) { }) {
return ( return (
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
@@ -52,22 +45,27 @@ function SelectTrigger({
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" /> <ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</SelectPrimitive.Icon> </SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
) );
} }
function SelectContent({ function SelectContent({
className, className,
children, children,
position = "item-aligned", position = 'item-aligned',
align = "center", align = 'center',
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) { }: React.ComponentProps<typeof SelectPrimitive.Content>) {
return ( return (
<SelectPrimitive.Portal> <SelectPrimitive.Portal>
<SelectPrimitive.Content <SelectPrimitive.Content
data-slot="select-content" data-slot="select-content"
data-align-trigger={position === "item-aligned"} data-align-trigger={position === 'item-aligned'}
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none 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-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", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )} className={cn(
'relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none 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-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',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position} position={position}
align={align} align={align}
{...props} {...props}
@@ -76,8 +74,8 @@ function SelectContent({
<SelectPrimitive.Viewport <SelectPrimitive.Viewport
data-position={position} data-position={position}
className={cn( className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)", 'data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)',
position === "popper" && "" position === 'popper' && ''
)} )}
> >
{children} {children}
@@ -85,20 +83,17 @@ function SelectContent({
<SelectScrollDownButton /> <SelectScrollDownButton />
</SelectPrimitive.Content> </SelectPrimitive.Content>
</SelectPrimitive.Portal> </SelectPrimitive.Portal>
) );
} }
function SelectLabel({ function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return ( return (
<SelectPrimitive.Label <SelectPrimitive.Label
data-slot="select-label" data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)} className={cn('px-1.5 py-1 text-xs text-muted-foreground', className)}
{...props} {...props}
/> />
) );
} }
function SelectItem({ function SelectItem({
@@ -122,7 +117,7 @@ function SelectItem({
</span> </span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item> </SelectPrimitive.Item>
) );
} }
function SelectSeparator({ function SelectSeparator({
@@ -132,10 +127,10 @@ function SelectSeparator({
return ( return (
<SelectPrimitive.Separator <SelectPrimitive.Separator
data-slot="select-separator" data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)} className={cn('pointer-events-none -mx-1 my-1 h-px bg-border', className)}
{...props} {...props}
/> />
) );
} }
function SelectScrollUpButton({ function SelectScrollUpButton({
@@ -151,10 +146,9 @@ function SelectScrollUpButton({
)} )}
{...props} {...props}
> >
<ChevronUpIcon <ChevronUpIcon />
/>
</SelectPrimitive.ScrollUpButton> </SelectPrimitive.ScrollUpButton>
) );
} }
function SelectScrollDownButton({ function SelectScrollDownButton({
@@ -170,10 +164,9 @@ function SelectScrollDownButton({
)} )}
{...props} {...props}
> >
<ChevronDownIcon <ChevronDownIcon />
/>
</SelectPrimitive.ScrollDownButton> </SelectPrimitive.ScrollDownButton>
) );
} }
export { export {
@@ -187,4 +180,4 @@ export {
SelectSeparator, SelectSeparator,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} };

View File

@@ -1,13 +1,13 @@
"use client" 'use client';
import * as React from "react" import { Separator as SeparatorPrimitive } from 'radix-ui';
import { Separator as SeparatorPrimitive } from "radix-ui" import * as React from 'react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
function Separator({ function Separator({
className, className,
orientation = "horizontal", orientation = 'horizontal',
decorative = true, decorative = true,
...props ...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) { }: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
@@ -17,12 +17,12 @@ function Separator({
decorative={decorative} decorative={decorative}
orientation={orientation} orientation={orientation}
className={cn( className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch", 'shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch',
className className
)} )}
{...props} {...props}
/> />
) );
} }
export { Separator } export { Separator };

View File

@@ -8,5 +8,5 @@ export function AboutPage() {
</p> </p>
</section> </section>
</main> </main>
) );
} }

View File

@@ -1,35 +1,35 @@
import { useEffect, useState } from 'react' import { apiClient } from '@/lib/api-client';
import { useQuery } from '@tanstack/react-query' import { setCurrentComplexSlug } from '@/lib/current-complex';
import { type Court, type CreateCourtInput } from '@repo/api-contract' import { type Court, type CreateCourtInput } from '@repo/api-contract';
import { apiClient } from '@/lib/api-client' import { useQuery } from '@tanstack/react-query';
import { setCurrentComplexSlug } from '@/lib/current-complex' import { useEffect, useState } from 'react';
import { CourtFormSection } from './components/court-form-section' import { CourtFormSection } from './components/court-form-section';
import { CourtListSection } from './components/court-list-section' import { CourtListSection } from './components/court-list-section';
type ComplexCourtsPageProps = { type ComplexCourtsPageProps = {
complexSlug: string complexSlug: string;
} };
export function ComplexCourtsPage({ complexSlug }: ComplexCourtsPageProps) { export function ComplexCourtsPage({ complexSlug }: ComplexCourtsPageProps) {
const [editingCourt, setEditingCourt] = useState<Court | null>(null) const [editingCourt, setEditingCourt] = useState<Court | null>(null);
const [initialDraft, setInitialDraft] = useState<CreateCourtInput | null>(null) const [initialDraft, setInitialDraft] = useState<CreateCourtInput | null>(null);
useEffect(() => { useEffect(() => {
setCurrentComplexSlug(complexSlug) setCurrentComplexSlug(complexSlug);
}, [complexSlug]) }, [complexSlug]);
const complexQuery = useQuery({ const complexQuery = useQuery({
queryKey: ['complex-by-slug', complexSlug], queryKey: ['complex-by-slug', complexSlug],
queryFn: () => apiClient.complexes.getBySlug(complexSlug), queryFn: () => apiClient.complexes.getBySlug(complexSlug),
}) });
const complexId = complexQuery.data?.id ?? null const complexId = complexQuery.data?.id ?? null;
const courtsQuery = useQuery({ const courtsQuery = useQuery({
queryKey: ['courts', complexId], queryKey: ['courts', complexId],
enabled: Boolean(complexId), enabled: Boolean(complexId),
queryFn: () => apiClient.courts.listByComplex(complexId as string), queryFn: () => apiClient.courts.listByComplex(complexId as string),
}) });
return ( return (
<main className="mx-auto w-full max-w-6xl space-y-6 px-4 py-4 sm:px-6"> <main className="mx-auto w-full max-w-6xl space-y-6 px-4 py-4 sm:px-6">
@@ -47,7 +47,7 @@ export function ComplexCourtsPage({ complexSlug }: ComplexCourtsPageProps) {
editingCourt={editingCourt} editingCourt={editingCourt}
initialDraft={initialDraft} initialDraft={initialDraft}
onCancelEdit={() => { onCancelEdit={() => {
setEditingCourt(null) setEditingCourt(null);
}} }}
/> />
@@ -56,7 +56,7 @@ export function ComplexCourtsPage({ complexSlug }: ComplexCourtsPageProps) {
isLoading={courtsQuery.isLoading} isLoading={courtsQuery.isLoading}
isError={courtsQuery.isError} isError={courtsQuery.isError}
onDuplicateCourt={(court) => { onDuplicateCourt={(court) => {
setEditingCourt(null) setEditingCourt(null);
setInitialDraft({ setInitialDraft({
name: `${court.name} - Copy`, name: `${court.name} - Copy`,
sportId: court.sportId, sportId: court.sportId,
@@ -67,15 +67,15 @@ export function ComplexCourtsPage({ complexSlug }: ComplexCourtsPageProps) {
startTime: slot.startTime, startTime: slot.startTime,
endTime: slot.endTime, endTime: slot.endTime,
})), })),
}) });
window.scrollTo({ top: 0, behavior: 'smooth' }) window.scrollTo({ top: 0, behavior: 'smooth' });
}} }}
onEditCourt={(court) => { onEditCourt={(court) => {
setInitialDraft(null) setInitialDraft(null);
setEditingCourt(court) setEditingCourt(court);
window.scrollTo({ top: 0, behavior: 'smooth' }) window.scrollTo({ top: 0, behavior: 'smooth' });
}} }}
/> />
</main> </main>
) );
} }

View File

@@ -1,35 +1,35 @@
import { useEffect, useState } from 'react' import { apiClient } from '@/lib/api-client';
import { useQuery } from '@tanstack/react-query' import { setCurrentComplexSlug } from '@/lib/current-complex';
import { Building2, MapPin, Settings } from 'lucide-react' import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client' import { Building2, MapPin, Settings } from 'lucide-react';
import { setCurrentComplexSlug } from '@/lib/current-complex' import { useEffect, useState } from 'react';
import { ComplexDetailsSection } from './components/complex-details-section' import { ComplexCourtsSection } from './components/complex-courts-section';
import { ComplexCourtsSection } from './components/complex-courts-section' import { ComplexDetailsSection } from './components/complex-details-section';
type ComplexSettingsPageProps = { type ComplexSettingsPageProps = {
complexSlug: string complexSlug: string;
} };
type TabOption = 'details' | 'courts' type TabOption = 'details' | 'courts';
export function ComplexSettingsPage({ complexSlug }: ComplexSettingsPageProps) { export function ComplexSettingsPage({ complexSlug }: ComplexSettingsPageProps) {
const [activeTab, setActiveTab] = useState<TabOption>('details') const [activeTab, setActiveTab] = useState<TabOption>('details');
useEffect(() => { useEffect(() => {
setCurrentComplexSlug(complexSlug) setCurrentComplexSlug(complexSlug);
}, [complexSlug]) }, [complexSlug]);
const complexQuery = useQuery({ const complexQuery = useQuery({
queryKey: ['complex-by-slug', complexSlug], queryKey: ['complex-by-slug', complexSlug],
queryFn: () => apiClient.complexes.getBySlug(complexSlug), queryFn: () => apiClient.complexes.getBySlug(complexSlug),
}) });
const complexId = complexQuery.data?.id ?? null const complexId = complexQuery.data?.id ?? null;
const tabs = [ const tabs = [
{ id: 'details' as const, label: 'Datos del complejo', icon: Building2 }, { id: 'details' as const, label: 'Datos del complejo', icon: Building2 },
{ id: 'courts' as const, label: 'Canchas', icon: MapPin }, { id: 'courts' as const, label: 'Canchas', icon: MapPin },
] ];
return ( return (
<div className="mx-auto w-full max-w-6xl px-4 py-4 sm:px-6"> <div className="mx-auto w-full max-w-6xl px-4 py-4 sm:px-6">
@@ -46,8 +46,8 @@ export function ComplexSettingsPage({ complexSlug }: ComplexSettingsPageProps) {
<div className="mt-6 grid gap-6 lg:grid-cols-[200px_1fr]"> <div className="mt-6 grid gap-6 lg:grid-cols-[200px_1fr]">
<nav className="flex flex-row gap-1 overflow-x-auto lg:flex-col lg:overflow-visible"> <nav className="flex flex-row gap-1 overflow-x-auto lg:flex-col lg:overflow-visible">
{tabs.map((tab) => { {tabs.map((tab) => {
const Icon = tab.icon const Icon = tab.icon;
const isActive = activeTab === tab.id const isActive = activeTab === tab.id;
return ( return (
<button <button
key={tab.id} key={tab.id}
@@ -62,7 +62,7 @@ export function ComplexSettingsPage({ complexSlug }: ComplexSettingsPageProps) {
<Icon className="size-4" /> <Icon className="size-4" />
{tab.label} {tab.label}
</button> </button>
) );
})} })}
</nav> </nav>
@@ -75,13 +75,9 @@ export function ComplexSettingsPage({ complexSlug }: ComplexSettingsPageProps) {
/> />
)} )}
{activeTab === 'courts' && ( {activeTab === 'courts' && <ComplexCourtsSection complexId={complexId} />}
<ComplexCourtsSection
complexId={complexId}
/>
)}
</div> </div>
</div> </div>
</div> </div>
) );
} }

View File

@@ -1,27 +1,25 @@
import { useState } from 'react' import { Separator } from '@/components/ui/separator';
import { useQuery } from '@tanstack/react-query' import { apiClient } from '@/lib/api-client';
import { type Court, type CreateCourtInput } from '@repo/api-contract' import { type Court, type CreateCourtInput } from '@repo/api-contract';
import { MapPin } from 'lucide-react' import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client' import { MapPin } from 'lucide-react';
import { CourtFormSection } from './court-form-section' import { useState } from 'react';
import { CourtListSection } from './court-list-section' import { CourtFormSection } from './court-form-section';
import { Separator } from '@/components/ui/separator' import { CourtListSection } from './court-list-section';
type ComplexCourtsSectionProps = { type ComplexCourtsSectionProps = {
complexId: string | null complexId: string | null;
} };
export function ComplexCourtsSection({ export function ComplexCourtsSection({ complexId }: ComplexCourtsSectionProps) {
complexId, const [editingCourt, setEditingCourt] = useState<Court | null>(null);
}: ComplexCourtsSectionProps) { const [initialDraft, setInitialDraft] = useState<CreateCourtInput | null>(null);
const [editingCourt, setEditingCourt] = useState<Court | null>(null)
const [initialDraft, setInitialDraft] = useState<CreateCourtInput | null>(null)
const courtsQuery = useQuery({ const courtsQuery = useQuery({
queryKey: ['courts', complexId], queryKey: ['courts', complexId],
enabled: Boolean(complexId), enabled: Boolean(complexId),
queryFn: () => apiClient.courts.listByComplex(complexId as string), queryFn: () => apiClient.courts.listByComplex(complexId as string),
}) });
return ( return (
<section className="mt-6 rounded-xl border bg-card p-5"> <section className="mt-6 rounded-xl border bg-card p-5">
@@ -30,16 +28,14 @@ export function ComplexCourtsSection({
<h3 className="text-lg font-medium">Canchas</h3> <h3 className="text-lg font-medium">Canchas</h3>
</div> </div>
<Separator className="my-4" /> <Separator className="my-4" />
<p className="mb-4 text-sm text-muted-foreground"> <p className="mb-4 text-sm text-muted-foreground">Alta y edición de canchas del complejo.</p>
Alta y edición de canchas del complejo.
</p>
<CourtFormSection <CourtFormSection
complexId={complexId} complexId={complexId}
editingCourt={editingCourt} editingCourt={editingCourt}
initialDraft={initialDraft} initialDraft={initialDraft}
onCancelEdit={() => { onCancelEdit={() => {
setEditingCourt(null) setEditingCourt(null);
}} }}
/> />
@@ -48,7 +44,7 @@ export function ComplexCourtsSection({
isLoading={courtsQuery.isLoading} isLoading={courtsQuery.isLoading}
isError={courtsQuery.isError} isError={courtsQuery.isError}
onDuplicateCourt={(court) => { onDuplicateCourt={(court) => {
setEditingCourt(null) setEditingCourt(null);
setInitialDraft({ setInitialDraft({
name: `${court.name} - Copy`, name: `${court.name} - Copy`,
sportId: court.sportId, sportId: court.sportId,
@@ -59,15 +55,15 @@ export function ComplexCourtsSection({
startTime: slot.startTime, startTime: slot.startTime,
endTime: slot.endTime, endTime: slot.endTime,
})), })),
}) });
window.scrollTo({ top: 0, behavior: 'smooth' }) window.scrollTo({ top: 0, behavior: 'smooth' });
}} }}
onEditCourt={(court) => { onEditCourt={(court) => {
setInitialDraft(null) setInitialDraft(null);
setEditingCourt(court) setEditingCourt(court);
window.scrollTo({ top: 0, behavior: 'smooth' }) window.scrollTo({ top: 0, behavior: 'smooth' });
}} }}
/> />
</section> </section>
) );
} }

View File

@@ -1,43 +1,39 @@
import { useEffect, useState } from 'react' import { Button } from '@/components/ui/button';
import { useMutation, useQueryClient } from '@tanstack/react-query' import { Field, FieldLabel } from '@/components/ui/field';
import type { Complex, UpdateComplexInput } from '@repo/api-contract' import { Input } from '@/components/ui/input';
import { updateComplexSchema } from '@repo/api-contract' import { Separator } from '@/components/ui/separator';
import { Building2, Loader2 } from 'lucide-react' import { apiClient } from '@/lib/api-client';
import { apiClient } from '@/lib/api-client' import type { Complex, UpdateComplexInput } from '@repo/api-contract';
import { Button } from '@/components/ui/button' import { updateComplexSchema } from '@repo/api-contract';
import { Field, FieldLabel } from '@/components/ui/field' import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Input } from '@/components/ui/input' import { Building2, Loader2 } from 'lucide-react';
import { Separator } from '@/components/ui/separator' import { useEffect, useState } from 'react';
type ComplexDetailsSectionProps = { type ComplexDetailsSectionProps = {
complex: Complex | null complex: Complex | null;
isLoading: boolean isLoading: boolean;
isError: boolean isError: boolean;
} };
type FormValues = { type FormValues = {
complexName: string complexName: string;
physicalAddress: string physicalAddress: string;
city: string city: string;
state: string state: string;
country: string country: string;
} };
export function ComplexDetailsSection({ export function ComplexDetailsSection({ complex, isLoading, isError }: ComplexDetailsSectionProps) {
complex, const queryClient = useQueryClient();
isLoading,
isError,
}: ComplexDetailsSectionProps) {
const queryClient = useQueryClient()
const [formValues, setFormValues] = useState<FormValues>({ const [formValues, setFormValues] = useState<FormValues>({
complexName: '', complexName: '',
physicalAddress: '', physicalAddress: '',
city: '', city: '',
state: '', state: '',
country: '', country: '',
}) });
const [errorMessage, setErrorMessage] = useState<string | null>(null) const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null) const [successMessage, setSuccessMessage] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (complex) { if (complex) {
@@ -47,28 +43,27 @@ export function ComplexDetailsSection({
city: complex.city ?? '', city: complex.city ?? '',
state: complex.state ?? '', state: complex.state ?? '',
country: complex.country ?? '', country: complex.country ?? '',
}) });
} }
}, [complex]) }, [complex]);
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: (data: UpdateComplexInput) => mutationFn: (data: UpdateComplexInput) => apiClient.complexes.update(complex!.id, data),
apiClient.complexes.update(complex!.id, data),
onSuccess: (updatedComplex) => { onSuccess: (updatedComplex) => {
setSuccessMessage('Datos actualizados correctamente.') setSuccessMessage('Datos actualizados correctamente.');
setErrorMessage(null) setErrorMessage(null);
queryClient.setQueryData(['complex-by-slug', complex?.complexSlug], updatedComplex) queryClient.setQueryData(['complex-by-slug', complex?.complexSlug], updatedComplex);
}, },
onError: (error: Error) => { onError: (error: Error) => {
setErrorMessage(error.message || 'Error al actualizar los datos.') setErrorMessage(error.message || 'Error al actualizar los datos.');
setSuccessMessage(null) setSuccessMessage(null);
}, },
}) });
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault();
setErrorMessage(null) setErrorMessage(null);
setSuccessMessage(null) setSuccessMessage(null);
const result = updateComplexSchema.safeParse({ const result = updateComplexSchema.safeParse({
complexName: formValues.complexName, complexName: formValues.complexName,
@@ -76,15 +71,15 @@ export function ComplexDetailsSection({
city: formValues.city || null, city: formValues.city || null,
state: formValues.state || null, state: formValues.state || null,
country: formValues.country || null, country: formValues.country || null,
}) });
if (!result.success) { if (!result.success) {
setErrorMessage(result.error.issues[0]?.message || 'Datos inválidos.') setErrorMessage(result.error.issues[0]?.message || 'Datos inválidos.');
return return;
} }
updateMutation.mutate(result.data) updateMutation.mutate(result.data);
} };
if (isLoading) { if (isLoading) {
return ( return (
@@ -96,7 +91,7 @@ export function ComplexDetailsSection({
<Separator className="my-4" /> <Separator className="my-4" />
<p className="text-sm text-muted-foreground">Cargando...</p> <p className="text-sm text-muted-foreground">Cargando...</p>
</section> </section>
) );
} }
if (isError || !complex) { if (isError || !complex) {
@@ -107,11 +102,9 @@ export function ComplexDetailsSection({
<h3 className="text-lg font-medium">Datos del complejo</h3> <h3 className="text-lg font-medium">Datos del complejo</h3>
</div> </div>
<Separator className="my-4" /> <Separator className="my-4" />
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">No se pudieron cargar los datos del complejo.</p>
No se pudieron cargar los datos del complejo.
</p>
</section> </section>
) );
} }
return ( return (
@@ -129,9 +122,7 @@ export function ComplexDetailsSection({
id="complex-name" id="complex-name"
type="text" type="text"
value={formValues.complexName} value={formValues.complexName}
onChange={(e) => onChange={(e) => setFormValues((prev) => ({ ...prev, complexName: e.target.value }))}
setFormValues((prev) => ({ ...prev, complexName: e.target.value }))
}
/> />
</Field> </Field>
@@ -156,9 +147,7 @@ export function ComplexDetailsSection({
type="text" type="text"
placeholder="Ej: Salta" placeholder="Ej: Salta"
value={formValues.city} value={formValues.city}
onChange={(e) => onChange={(e) => setFormValues((prev) => ({ ...prev, city: e.target.value }))}
setFormValues((prev) => ({ ...prev, city: e.target.value }))
}
/> />
</Field> </Field>
@@ -169,9 +158,7 @@ export function ComplexDetailsSection({
type="text" type="text"
placeholder="Ej: Salta" placeholder="Ej: Salta"
value={formValues.state} value={formValues.state}
onChange={(e) => onChange={(e) => setFormValues((prev) => ({ ...prev, state: e.target.value }))}
setFormValues((prev) => ({ ...prev, state: e.target.value }))
}
/> />
</Field> </Field>
@@ -182,32 +169,23 @@ export function ComplexDetailsSection({
type="text" type="text"
placeholder="Ej: Argentina" placeholder="Ej: Argentina"
value={formValues.country} value={formValues.country}
onChange={(e) => onChange={(e) => setFormValues((prev) => ({ ...prev, country: e.target.value }))}
setFormValues((prev) => ({ ...prev, country: e.target.value }))
}
/> />
</Field> </Field>
</div> </div>
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>} {errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
{successMessage && ( {successMessage && (
<p className="text-sm text-green-600 dark:text-green-400"> <p className="text-sm text-green-600 dark:text-green-400">{successMessage}</p>
{successMessage}
</p>
)} )}
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button type="submit" disabled={updateMutation.isPending}>
type="submit" {updateMutation.isPending && <Loader2 className="mr-2 size-4 animate-spin" />}
disabled={updateMutation.isPending}
>
{updateMutation.isPending && (
<Loader2 className="mr-2 size-4 animate-spin" />
)}
Guardar cambios Guardar cambios
</Button> </Button>
</div> </div>
</form> </form>
</section> </section>
) );
} }

View File

@@ -1,9 +1,4 @@
import { useEffect, useMemo, useState } from 'react' import { Button } from '@/components/ui/button';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { zodResolver } from '@hookform/resolvers/zod'
import { createCourtSchema, type Court, type CreateCourtInput } from '@repo/api-contract'
import { Controller, useFieldArray, useForm } from 'react-hook-form'
import { Button } from '@/components/ui/button'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -11,26 +6,31 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog';
import { Field, FieldError, FieldLabel } from '@/components/ui/field' import { Field, FieldError, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input';
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select';
import { ApiClientError, apiClient } from '@/lib/api-client' import { ApiClientError, apiClient } from '@/lib/api-client';
import { DAY_OPTIONS } from '../lib/court.constants' import { zodResolver } from '@hookform/resolvers/zod';
import { createDefaultCourtValues } from '../lib/court.utils' import { type Court, type CreateCourtInput, createCourtSchema } from '@repo/api-contract';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { DAY_OPTIONS } from '../lib/court.constants';
import { createDefaultCourtValues } from '../lib/court.utils';
type CourtFormSectionProps = { type CourtFormSectionProps = {
complexId: string | null complexId: string | null;
editingCourt: Court | null editingCourt: Court | null;
initialDraft: CreateCourtInput | null initialDraft: CreateCourtInput | null;
onCancelEdit: () => void onCancelEdit: () => void;
} };
export function CourtFormSection({ export function CourtFormSection({
complexId, complexId,
@@ -38,67 +38,67 @@ export function CourtFormSection({
initialDraft, initialDraft,
onCancelEdit, onCancelEdit,
}: CourtFormSectionProps) { }: CourtFormSectionProps) {
const queryClient = useQueryClient() const queryClient = useQueryClient();
const [formError, setFormError] = useState<string | null>(null) const [formError, setFormError] = useState<string | null>(null);
const [isWeekModalOpen, setIsWeekModalOpen] = useState(false) const [isWeekModalOpen, setIsWeekModalOpen] = useState(false);
const [weekDefaultStartTime, setWeekDefaultStartTime] = useState('08:00') const [weekDefaultStartTime, setWeekDefaultStartTime] = useState('08:00');
const [weekDefaultEndTime, setWeekDefaultEndTime] = useState('22:00') const [weekDefaultEndTime, setWeekDefaultEndTime] = useState('22:00');
const [weekModalError, setWeekModalError] = useState<string | null>(null) const [weekModalError, setWeekModalError] = useState<string | null>(null);
const sportsQuery = useQuery({ const sportsQuery = useQuery({
queryKey: ['sports'], queryKey: ['sports'],
queryFn: () => apiClient.sports.list(), queryFn: () => apiClient.sports.list(),
}) });
const form = useForm<CreateCourtInput>({ const form = useForm<CreateCourtInput>({
resolver: zodResolver(createCourtSchema), resolver: zodResolver(createCourtSchema),
mode: 'onBlur', mode: 'onBlur',
defaultValues: createDefaultCourtValues(), defaultValues: createDefaultCourtValues(),
}) });
const availabilityFieldArray = useFieldArray({ const availabilityFieldArray = useFieldArray({
control: form.control, control: form.control,
name: 'availability', name: 'availability',
}) });
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: async (payload: CreateCourtInput) => { mutationFn: async (payload: CreateCourtInput) => {
if (editingCourt) { if (editingCourt) {
return apiClient.courts.update(editingCourt.id, payload) return apiClient.courts.update(editingCourt.id, payload);
} }
if (!complexId) { if (!complexId) {
throw new ApiClientError('No se pudo resolver el complejo para guardar la cancha.') throw new ApiClientError('No se pudo resolver el complejo para guardar la cancha.');
} }
return apiClient.courts.create(complexId, payload) return apiClient.courts.create(complexId, payload);
}, },
onSuccess: () => { onSuccess: () => {
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: ['courts', complexId], queryKey: ['courts', complexId],
}) });
setFormError(null) setFormError(null);
if (editingCourt) { if (editingCourt) {
onCancelEdit() onCancelEdit();
} }
form.reset(createDefaultCourtValues()) form.reset(createDefaultCourtValues());
}, },
onError: (error) => { onError: (error) => {
const message = const message =
error instanceof ApiClientError error instanceof ApiClientError
? error.message ? error.message
: 'No se pudo guardar la cancha. Intenta nuevamente.' : 'No se pudo guardar la cancha. Intenta nuevamente.';
setFormError(message) setFormError(message);
}, },
}) });
const activeSports = useMemo( const activeSports = useMemo(
() => (sportsQuery.data ?? []).filter((sport) => sport.isActive), () => (sportsQuery.data ?? []).filter((sport) => sport.isActive),
[sportsQuery.data], [sportsQuery.data]
) );
const onSubmit = async (values: CreateCourtInput) => { const onSubmit = async (values: CreateCourtInput) => {
setFormError(null) setFormError(null);
await saveMutation.mutateAsync(values) await saveMutation.mutateAsync(values);
} };
useEffect(() => { useEffect(() => {
if (editingCourt) { if (editingCourt) {
@@ -112,33 +112,33 @@ export function CourtFormSection({
startTime: slot.startTime, startTime: slot.startTime,
endTime: slot.endTime, endTime: slot.endTime,
})), })),
}) });
return return;
} }
if (initialDraft) { if (initialDraft) {
form.reset(initialDraft) form.reset(initialDraft);
return return;
} }
form.reset(createDefaultCourtValues()) form.reset(createDefaultCourtValues());
}, [editingCourt, form, initialDraft]) }, [editingCourt, form, initialDraft]);
const title = editingCourt ? 'Editar cancha' : 'Nueva cancha' const title = editingCourt ? 'Editar cancha' : 'Nueva cancha';
const buttonText = editingCourt ? 'Guardar cambios' : 'Agregar cancha' const buttonText = editingCourt ? 'Guardar cambios' : 'Agregar cancha';
return ( return (
<section className="rounded-xl border bg-card p-4 text-card-foreground shadow-sm sm:p-5 mb-4"> <section className="rounded-xl border bg-card p-4 text-card-foreground shadow-sm sm:p-5 mb-4">
<h3 className="text-lg font-semibold">{title}</h3> <h3 className="text-lg font-semibold">{title}</h3>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
Configura nombre, deporte, duración, precio base y horarios. El backend ya Configura nombre, deporte, duración, precio base y horarios. El backend ya contempla precios
contempla precios por franja horaria para la siguiente etapa. por franja horaria para la siguiente etapa.
</p> </p>
<form <form
className="mt-4 space-y-4" className="mt-4 space-y-4"
onSubmit={form.handleSubmit((values) => { onSubmit={form.handleSubmit((values) => {
void onSubmit(values) void onSubmit(values);
})} })}
> >
<Field data-invalid={Boolean(form.formState.errors.name)}> <Field data-invalid={Boolean(form.formState.errors.name)}>
@@ -211,9 +211,7 @@ export function CourtFormSection({
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h4 className="text-sm font-semibold">Rangos horarios</h4> <h4 className="text-sm font-semibold">Rangos horarios</h4>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">Define uno o más rangos por día.</p>
Define uno o más rangos por día.
</p>
</div> </div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center"> <div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
<Button <Button
@@ -221,11 +219,11 @@ export function CourtFormSection({
variant="outline" variant="outline"
className="w-full sm:w-auto" className="w-full sm:w-auto"
onClick={() => { onClick={() => {
const availability = form.getValues('availability') const availability = form.getValues('availability');
setWeekDefaultStartTime(availability[0]?.startTime ?? '08:00') setWeekDefaultStartTime(availability[0]?.startTime ?? '08:00');
setWeekDefaultEndTime(availability[0]?.endTime ?? '22:00') setWeekDefaultEndTime(availability[0]?.endTime ?? '22:00');
setWeekModalError(null) setWeekModalError(null);
setIsWeekModalOpen(true) setIsWeekModalOpen(true);
}} }}
> >
Agregar semana completa Agregar semana completa
@@ -239,7 +237,7 @@ export function CourtFormSection({
dayOfWeek: 'MONDAY', dayOfWeek: 'MONDAY',
startTime: '08:00', startTime: '08:00',
endTime: '22:00', endTime: '22:00',
}) });
}} }}
> >
Agregar rango Agregar rango
@@ -307,7 +305,7 @@ export function CourtFormSection({
className="w-full md:w-auto" className="w-full md:w-auto"
disabled={availabilityFieldArray.fields.length === 1} disabled={availabilityFieldArray.fields.length === 1}
onClick={() => { onClick={() => {
availabilityFieldArray.remove(index) availabilityFieldArray.remove(index);
}} }}
> >
Quitar Quitar
@@ -338,9 +336,9 @@ export function CourtFormSection({
<Dialog <Dialog
open={isWeekModalOpen} open={isWeekModalOpen}
onOpenChange={(nextOpen) => { onOpenChange={(nextOpen) => {
setIsWeekModalOpen(nextOpen) setIsWeekModalOpen(nextOpen);
if (!nextOpen) { if (!nextOpen) {
setWeekModalError(null) setWeekModalError(null);
} }
}} }}
> >
@@ -360,8 +358,8 @@ export function CourtFormSection({
type="time" type="time"
value={weekDefaultStartTime} value={weekDefaultStartTime}
onChange={(event) => { onChange={(event) => {
setWeekDefaultStartTime(event.target.value) setWeekDefaultStartTime(event.target.value);
setWeekModalError(null) setWeekModalError(null);
}} }}
/> />
</Field> </Field>
@@ -373,8 +371,8 @@ export function CourtFormSection({
type="time" type="time"
value={weekDefaultEndTime} value={weekDefaultEndTime}
onChange={(event) => { onChange={(event) => {
setWeekDefaultEndTime(event.target.value) setWeekDefaultEndTime(event.target.value);
setWeekModalError(null) setWeekModalError(null);
}} }}
/> />
</Field> </Field>
@@ -387,8 +385,8 @@ export function CourtFormSection({
type="button" type="button"
variant="outline" variant="outline"
onClick={() => { onClick={() => {
setIsWeekModalOpen(false) setIsWeekModalOpen(false);
setWeekModalError(null) setWeekModalError(null);
}} }}
> >
Cancelar Cancelar
@@ -397,29 +395,27 @@ export function CourtFormSection({
type="button" type="button"
onClick={() => { onClick={() => {
if (weekDefaultStartTime >= weekDefaultEndTime) { if (weekDefaultStartTime >= weekDefaultEndTime) {
setWeekModalError( setWeekModalError('El horario de inicio debe ser menor al horario de fin.');
'El horario de inicio debe ser menor al horario de fin.', return;
)
return
} }
const availability = form.getValues('availability') const availability = form.getValues('availability');
const existingDays = new Set(availability.map((item) => item.dayOfWeek)) const existingDays = new Set(availability.map((item) => item.dayOfWeek));
const missingDays = DAY_OPTIONS.filter( const missingDays = DAY_OPTIONS.filter(
(option) => !existingDays.has(option.value), (option) => !existingDays.has(option.value)
).map((option) => ({ ).map((option) => ({
dayOfWeek: option.value, dayOfWeek: option.value,
startTime: weekDefaultStartTime, startTime: weekDefaultStartTime,
endTime: weekDefaultEndTime, endTime: weekDefaultEndTime,
})) }));
if (missingDays.length > 0) { if (missingDays.length > 0) {
availabilityFieldArray.append(missingDays) availabilityFieldArray.append(missingDays);
} }
setIsWeekModalOpen(false) setIsWeekModalOpen(false);
setWeekModalError(null) setWeekModalError(null);
}} }}
> >
Agregar días faltantes Agregar días faltantes
@@ -428,5 +424,5 @@ export function CourtFormSection({
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</section> </section>
) );
} }

View File

@@ -1,14 +1,14 @@
import type { Court } from '@repo/api-contract' import { Button } from '@/components/ui/button';
import { Button } from '@/components/ui/button' import type { Court } from '@repo/api-contract';
import { formatCurrency, summarizeAvailability } from '../lib/court.utils' import { formatCurrency, summarizeAvailability } from '../lib/court.utils';
type CourtListSectionProps = { type CourtListSectionProps = {
courts: Court[] courts: Court[];
isLoading: boolean isLoading: boolean;
isError: boolean isError: boolean;
onDuplicateCourt: (court: Court) => void onDuplicateCourt: (court: Court) => void;
onEditCourt: (court: Court) => void onEditCourt: (court: Court) => void;
} };
export function CourtListSection({ export function CourtListSection({
courts, courts,
@@ -52,7 +52,7 @@ export function CourtListSection({
variant="outline" variant="outline"
className="w-full sm:w-auto" className="w-full sm:w-auto"
onClick={() => { onClick={() => {
onDuplicateCourt(court) onDuplicateCourt(court);
}} }}
> >
Duplicar cancha Duplicar cancha
@@ -62,7 +62,7 @@ export function CourtListSection({
variant="outline" variant="outline"
className="w-full sm:w-auto" className="w-full sm:w-auto"
onClick={() => { onClick={() => {
onEditCourt(court) onEditCourt(court);
}} }}
> >
Editar Editar
@@ -84,7 +84,7 @@ export function CourtListSection({
variant="outline" variant="outline"
className="w-full" className="w-full"
onClick={() => { onClick={() => {
onDuplicateCourt(court) onDuplicateCourt(court);
}} }}
> >
Duplicar cancha Duplicar cancha
@@ -94,7 +94,7 @@ export function CourtListSection({
variant="outline" variant="outline"
className="w-full" className="w-full"
onClick={() => { onClick={() => {
onEditCourt(court) onEditCourt(court);
}} }}
> >
Editar Editar
@@ -104,5 +104,5 @@ export function CourtListSection({
))} ))}
</div> </div>
</section> </section>
) );
} }

View File

@@ -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 }> = [ export const DAY_OPTIONS: Array<{ value: DayOfWeek; label: string }> = [
{ value: 'MONDAY', label: 'Lunes' }, { value: 'MONDAY', label: 'Lunes' },
@@ -8,10 +8,10 @@ export const DAY_OPTIONS: Array<{ value: DayOfWeek; label: string }> = [
{ value: 'FRIDAY', label: 'Viernes' }, { value: 'FRIDAY', label: 'Viernes' },
{ value: 'SATURDAY', label: 'Sábado' }, { value: 'SATURDAY', label: 'Sábado' },
{ value: 'SUNDAY', label: 'Domingo' }, { 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( export const DAY_LABEL_BY_VALUE = new Map(
DAY_OPTIONS.map((option) => [option.value, option.label]), DAY_OPTIONS.map((option) => [option.value, option.label])
) );

View File

@@ -1,76 +1,76 @@
import type { CreateCourtInput, DayOfWeek } from '@repo/api-contract' import type { CreateCourtInput, DayOfWeek } from '@repo/api-contract';
import { DAY_LABEL_BY_VALUE, DAY_ORDER } from './court.constants' import { DAY_LABEL_BY_VALUE, DAY_ORDER } from './court.constants';
function formatTimeCompact(value: string): string { function formatTimeCompact(value: string): string {
const [rawHours = '00', rawMinutes = '00'] = value.split(':') const [rawHours = '00', rawMinutes = '00'] = value.split(':');
const hours = Number(rawHours) const hours = Number(rawHours);
return `${hours}:${rawMinutes}` return `${hours}:${rawMinutes}`;
} }
function formatDayRange(dayIndexes: number[]): string { function formatDayRange(dayIndexes: number[]): string {
if (dayIndexes.length === 0) return '' if (dayIndexes.length === 0) return '';
const sorted = [...dayIndexes].sort((a, b) => a - b) const sorted = [...dayIndexes].sort((a, b) => a - b);
const chunks: Array<{ start: number; end: number }> = [] const chunks: Array<{ start: number; end: number }> = [];
let start = sorted[0] let start = sorted[0];
let previous = sorted[0] let previous = sorted[0];
for (let index = 1; index < sorted.length; index += 1) { for (let index = 1; index < sorted.length; index += 1) {
const current = sorted[index] const current = sorted[index];
if (current === previous + 1) { if (current === previous + 1) {
previous = current previous = current;
continue continue;
} }
chunks.push({ start, end: previous }) chunks.push({ start, end: previous });
start = current start = current;
previous = current previous = current;
} }
chunks.push({ start, end: previous }) chunks.push({ start, end: previous });
return chunks return chunks
.map((chunk) => { .map((chunk) => {
const startDay = DAY_ORDER[chunk.start] const startDay = DAY_ORDER[chunk.start];
const endDay = DAY_ORDER[chunk.end] const endDay = DAY_ORDER[chunk.end];
const startLabel = startDay ? DAY_LABEL_BY_VALUE.get(startDay) : undefined const startLabel = startDay ? DAY_LABEL_BY_VALUE.get(startDay) : undefined;
const endLabel = endDay ? DAY_LABEL_BY_VALUE.get(endDay) : undefined const endLabel = endDay ? DAY_LABEL_BY_VALUE.get(endDay) : undefined;
if (!startLabel || !endLabel) return '' if (!startLabel || !endLabel) return '';
if (chunk.start === chunk.end) return startLabel if (chunk.start === chunk.end) return startLabel;
return `${startLabel} a ${endLabel}` return `${startLabel} a ${endLabel}`;
}) })
.filter(Boolean) .filter(Boolean)
.join(', ') .join(', ');
} }
export function summarizeAvailability( export function summarizeAvailability(
availability: Array<{ availability: Array<{
dayOfWeek: DayOfWeek dayOfWeek: DayOfWeek;
startTime: string startTime: string;
endTime: string endTime: string;
}>, }>
) { ) {
const grouped = new Map<string, number[]>() const grouped = new Map<string, number[]>();
for (const slot of availability) { for (const slot of availability) {
const dayIndex = DAY_ORDER.indexOf(slot.dayOfWeek) const dayIndex = DAY_ORDER.indexOf(slot.dayOfWeek);
if (dayIndex < 0) continue if (dayIndex < 0) continue;
const key = `${slot.startTime}-${slot.endTime}` const key = `${slot.startTime}-${slot.endTime}`;
const current = grouped.get(key) ?? [] const current = grouped.get(key) ?? [];
current.push(dayIndex) current.push(dayIndex);
grouped.set(key, current) grouped.set(key, current);
} }
return [...grouped.entries()] return [...grouped.entries()]
.map(([timeRange, dayIndexes]) => { .map(([timeRange, dayIndexes]) => {
const [startTime = '00:00', endTime = '00:00'] = timeRange.split('-') const [startTime = '00:00', endTime = '00:00'] = timeRange.split('-');
const dayLabel = formatDayRange(dayIndexes) const dayLabel = formatDayRange(dayIndexes);
return `${dayLabel} · ${formatTimeCompact(startTime)} a ${formatTimeCompact(endTime)}` return `${dayLabel} · ${formatTimeCompact(startTime)} a ${formatTimeCompact(endTime)}`;
}) })
.filter(Boolean) .filter(Boolean);
} }
export function formatCurrency(amount: number): string { export function formatCurrency(amount: number): string {
@@ -78,7 +78,7 @@ export function formatCurrency(amount: number): string {
style: 'currency', style: 'currency',
currency: 'ARS', currency: 'ARS',
maximumFractionDigits: 2, maximumFractionDigits: 2,
}).format(amount) }).format(amount);
} }
export function createDefaultCourtValues(): CreateCourtInput { export function createDefaultCourtValues(): CreateCourtInput {
@@ -94,5 +94,5 @@ export function createDefaultCourtValues(): CreateCourtInput {
endTime: '22:00', endTime: '22:00',
}, },
], ],
} };
} }

View File

@@ -1,153 +1,155 @@
import { zodResolver } from '@hookform/resolvers/zod' import { Button } from '@/components/ui/button';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { DatePicker } from '@/components/ui/date-picker';
import { useEffect, useMemo, useState } from 'react' import { Field, FieldError, FieldLabel } from '@/components/ui/field';
import { useForm } from 'react-hook-form' import { Input } from '@/components/ui/input';
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 { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select';
import { ApiClientError, apiClient } from '@/lib/api-client' import { ApiClientError, apiClient } from '@/lib/api-client';
import { import {
getCurrentComplexSlug, getCurrentComplexSlug,
setCurrentComplexSlug as persistCurrentComplexSlug, 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({ const manualBookingSchema = z.object({
customerName: z customerName: z
.string() .string()
.trim() .trim()
.min(2, 'Ingresa un nombre valido.') .min(2, 'Ingresa un nombre válido.')
.max(120, 'El nombre no puede superar los 120 caracteres.'), .max(120, 'El nombre no puede superar los 120 caracteres.'),
customerPhone: z customerPhone: z
.string() .string()
.trim() .trim()
.min(6, 'Ingresa un telefono valido.') .min(6, 'Ingresa un telefono válido.')
.max(30, 'El telefono no puede superar los 30 caracteres.'), .max(30, 'El telefono no puede superar los 30 caracteres.'),
}) });
type ManualBookingForm = z.infer<typeof manualBookingSchema> type ManualBookingForm = z.infer<typeof manualBookingSchema>;
function toIsoDateLocal(date: Date) { function toIsoDateLocal(date: Date) {
const year = date.getFullYear() const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0') const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0') const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}` return `${year}-${month}-${day}`;
} }
function fromIsoDateLocal(dateIso: string): Date | undefined { function fromIsoDateLocal(dateIso: string): Date | undefined {
const [year, month, day] = dateIso.split('-').map(Number) const [year, month, day] = dateIso.split('-').map(Number);
if (!year || !month || !day) return undefined if (!year || !month || !day) return undefined;
return new Date(year, month - 1, day) return new Date(year, month - 1, day);
} }
function extractMessage(error: unknown, fallback: string) { function extractMessage(error: unknown, fallback: string) {
if (error instanceof ApiClientError) { if (error instanceof ApiClientError) {
return error.message || fallback return error.message || fallback;
} }
return fallback return fallback;
} }
function formatDateLabel(dateIso: string) { 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', { return new Intl.DateTimeFormat('es-AR', {
weekday: 'long', weekday: 'long',
day: '2-digit', day: '2-digit',
month: 'long', 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') { if (booking.status !== 'CONFIRMED') {
return booking.status return booking.status;
} }
// Check if booking is past end time and still confirmed // Check if booking is past end time and still confirmed
const now = new Date() const now = new Date();
const bookingDateTime = new Date(`${booking.date}T${booking.endTime}:00`) const bookingDateTime = new Date(`${booking.date}T${booking.endTime}:00`);
if (now > bookingDateTime) { if (now > bookingDateTime) {
return 'NO_SHOW' return 'NO_SHOW';
} }
return 'CONFIRMED' return 'CONFIRMED';
} }
function effectiveStatusLabel(status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW') { function effectiveStatusLabel(status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW') {
if (status === 'NO_SHOW') return 'No show' if (status === 'NO_SHOW') return 'No show';
if (status === 'COMPLETED') return 'Cumplida' if (status === 'COMPLETED') return 'Cumplida';
if (status === 'CANCELLED') return 'Cancelada' if (status === 'CANCELLED') return 'Cancelada';
return 'Confirmada' return 'Confirmada';
} }
function effectiveStatusClassName(status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW') { function effectiveStatusClassName(status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW') {
if (status === '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') { 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') { 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() { export function HomePage() {
const queryClient = useQueryClient() const queryClient = useQueryClient();
const todayIso = useMemo(() => toIsoDateLocal(new Date()), []) const todayIso = useMemo(() => toIsoDateLocal(new Date()), []);
const [currentComplexSlug, setCurrentComplexSlug] = useState<string | null>(null) const [currentComplexSlug, setCurrentComplexSlug] = useState<string | null>(null);
const [fromDate, setFromDate] = useState(todayIso) const [fromDate, setFromDate] = useState(todayIso);
const [manualDate, setManualDate] = useState(todayIso) const [manualDate, setManualDate] = useState(todayIso);
const [selectedSportId, setSelectedSportId] = useState<string | undefined>() const [selectedSportId, setSelectedSportId] = useState<string | undefined>();
const [selectedCourtId, setSelectedCourtId] = useState<string>('') const [selectedCourtId, setSelectedCourtId] = useState<string>('');
const [selectedStartTime, setSelectedStartTime] = useState<string>('') const [selectedStartTime, setSelectedStartTime] = useState<string>('');
useEffect(() => { useEffect(() => {
setCurrentComplexSlug(getCurrentComplexSlug()) setCurrentComplexSlug(getCurrentComplexSlug());
}, []) }, []);
const myComplexesQuery = useQuery({ const myComplexesQuery = useQuery({
queryKey: ['my-complexes'], queryKey: ['my-complexes'],
queryFn: () => apiClient.complexes.listMine(), queryFn: () => apiClient.complexes.listMine(),
}) });
const selectedComplex = useMemo(() => { 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) { 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] return complexes[0];
}, [currentComplexSlug, myComplexesQuery.data]) }, [currentComplexSlug, myComplexesQuery.data]);
useEffect(() => { useEffect(() => {
if (!selectedComplex) return if (!selectedComplex) return;
setCurrentComplexSlug(selectedComplex.complexSlug) setCurrentComplexSlug(selectedComplex.complexSlug);
persistCurrentComplexSlug(selectedComplex.complexSlug) persistCurrentComplexSlug(selectedComplex.complexSlug);
}, [selectedComplex]) }, [selectedComplex]);
const bookingsQuery = useQuery({ const bookingsQuery = useQuery({
queryKey: ['admin-bookings', selectedComplex?.id, fromDate], queryKey: ['admin-bookings', selectedComplex?.id, fromDate],
@@ -156,84 +158,89 @@ export function HomePage() {
apiClient.adminBookings.listByComplex(selectedComplex?.id as string, { apiClient.adminBookings.listByComplex(selectedComplex?.id as string, {
fromDate, fromDate,
}), }),
}) });
const manualAvailabilityQuery = useQuery({ const manualAvailabilityQuery = useQuery({
queryKey: ['manual-booking-availability', selectedComplex?.complexSlug, manualDate, selectedSportId], queryKey: [
'manual-booking-availability',
selectedComplex?.complexSlug,
manualDate,
selectedSportId,
],
enabled: Boolean(selectedComplex?.complexSlug && manualDate), enabled: Boolean(selectedComplex?.complexSlug && manualDate),
queryFn: () => queryFn: () =>
apiClient.publicBookings.getAvailability(selectedComplex?.complexSlug as string, { apiClient.publicBookings.getAvailability(selectedComplex?.complexSlug as string, {
date: manualDate, date: manualDate,
...(selectedSportId ? { sportId: selectedSportId } : {}), ...(selectedSportId ? { sportId: selectedSportId } : {}),
}), }),
}) });
useEffect(() => { useEffect(() => {
const availability = manualAvailabilityQuery.data const availability = manualAvailabilityQuery.data;
if (!availability) return if (!availability) return;
if (!availability.sportSelectionRequired) { if (!availability.sportSelectionRequired) {
if (availability.sports[0] && !selectedSportId) { if (availability.sports[0] && !selectedSportId) {
setSelectedSportId(availability.sports[0].id) setSelectedSportId(availability.sports[0].id);
} }
} else if ( } else if (
selectedSportId && selectedSportId &&
!availability.sports.some((sport) => sport.id === selectedSportId) !availability.sports.some((sport) => sport.id === selectedSportId)
) { ) {
setSelectedSportId(undefined) setSelectedSportId(undefined);
} }
}, [manualAvailabilityQuery.data, selectedSportId]) }, [manualAvailabilityQuery.data, selectedSportId]);
useEffect(() => { useEffect(() => {
if (!manualAvailabilityQuery.data) return if (!manualAvailabilityQuery.data) return;
if ( if (
selectedCourtId && selectedCourtId &&
!manualAvailabilityQuery.data.courts.some((court) => court.courtId === selectedCourtId) !manualAvailabilityQuery.data.courts.some((court) => court.courtId === selectedCourtId)
) { ) {
setSelectedCourtId('') setSelectedCourtId('');
setSelectedStartTime('') setSelectedStartTime('');
} }
}, [manualAvailabilityQuery.data, selectedCourtId]) }, [manualAvailabilityQuery.data, selectedCourtId]);
const selectedCourt = useMemo(() => { const selectedCourt = useMemo(() => {
return manualAvailabilityQuery.data?.courts.find((court) => court.courtId === selectedCourtId) return manualAvailabilityQuery.data?.courts.find((court) => court.courtId === selectedCourtId);
}, [manualAvailabilityQuery.data?.courts, selectedCourtId]) }, [manualAvailabilityQuery.data?.courts, selectedCourtId]);
useEffect(() => { useEffect(() => {
if (!selectedCourt) { if (!selectedCourt) {
setSelectedStartTime('') setSelectedStartTime('');
return return;
} }
if (!selectedCourt.availableSlots.some((slot) => slot.startTime === selectedStartTime)) { if (!selectedCourt.availableSlots.some((slot) => slot.startTime === selectedStartTime)) {
setSelectedStartTime('') setSelectedStartTime('');
} }
}, [selectedCourt, selectedStartTime]) }, [selectedCourt, selectedStartTime]);
const groupedBookings = useMemo(() => { const groupedBookings = useMemo(() => {
const groups = new Map<string, AdminBooking[]>() const groups = new Map<string, AdminBooking[]>();
for (const booking of bookingsQuery.data?.bookings ?? []) { for (const booking of bookingsQuery.data?.bookings ?? []) {
const current = groups.get(booking.date) ?? [] const current = groups.get(booking.date) ?? [];
current.push(booking) current.push(booking);
groups.set(booking.date, current) groups.set(booking.date, current);
} }
return [...groups.entries()] return [...groups.entries()];
}, [bookingsQuery.data?.bookings]) }, [bookingsQuery.data?.bookings]);
const updateStatusMutation = useMutation({ const updateStatusMutation = useMutation({
mutationFn: (payload: { bookingId: string; status: 'CANCELLED' | 'COMPLETED' }) => mutationFn: (payload: { bookingId: string; status: 'CANCELLED' | 'COMPLETED' }) =>
apiClient.adminBookings.updateStatus(payload.bookingId, { status: payload.status }), apiClient.adminBookings.updateStatus(payload.bookingId, { status: payload.status }),
onSuccess: () => { onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['admin-bookings', selectedComplex?.id] }) void queryClient.invalidateQueries({ queryKey: ['admin-bookings', selectedComplex?.id] });
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: ['manual-booking-availability', selectedComplex?.complexSlug], queryKey: ['manual-booking-availability', selectedComplex?.complexSlug],
}) });
}, },
}) });
const { const {
register, register,
@@ -247,16 +254,16 @@ export function HomePage() {
customerName: '', customerName: '',
customerPhone: '', customerPhone: '',
}, },
}) });
const createManualBookingMutation = useMutation({ const createManualBookingMutation = useMutation({
mutationFn: async (values: ManualBookingForm) => { mutationFn: async (values: ManualBookingForm) => {
if (!selectedComplex?.id) { if (!selectedComplex?.id) {
throw new Error('No se encontró el complejo actual.') throw new Error('No se encontró el complejo actual.');
} }
if (!selectedCourtId || !selectedStartTime) { 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, { return apiClient.adminBookings.create(selectedComplex.id, {
@@ -265,30 +272,30 @@ export function HomePage() {
startTime: selectedStartTime, startTime: selectedStartTime,
customerName: values.customerName, customerName: values.customerName,
customerPhone: values.customerPhone, customerPhone: values.customerPhone,
}) });
}, },
onSuccess: async () => { onSuccess: async () => {
reset() reset();
setSelectedCourtId('') setSelectedCourtId('');
setSelectedStartTime('') setSelectedStartTime('');
await queryClient.invalidateQueries({ queryKey: ['admin-bookings', selectedComplex?.id] }) await queryClient.invalidateQueries({ queryKey: ['admin-bookings', selectedComplex?.id] });
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: ['manual-booking-availability', selectedComplex?.complexSlug], queryKey: ['manual-booking-availability', selectedComplex?.complexSlug],
}) });
}, },
}) });
const onSubmitManualBooking = async (values: ManualBookingForm) => { const onSubmitManualBooking = async (values: ManualBookingForm) => {
await createManualBookingMutation.mutateAsync(values) await createManualBookingMutation.mutateAsync(values);
} };
if (myComplexesQuery.isLoading) { if (myComplexesQuery.isLoading) {
return ( return (
<main className="mx-auto w-full max-w-6xl px-6 py-8"> <main className="mx-auto w-full max-w-6xl px-6 py-8">
<p className="text-sm text-muted-foreground">Cargando panel...</p> <p className="text-sm text-muted-foreground">Cargando panel...</p>
</main> </main>
) );
} }
if (myComplexesQuery.isError) { if (myComplexesQuery.isError) {
@@ -298,17 +305,15 @@ export function HomePage() {
{extractMessage(myComplexesQuery.error, 'No pudimos cargar tus complejos.')} {extractMessage(myComplexesQuery.error, 'No pudimos cargar tus complejos.')}
</p> </p>
</main> </main>
) );
} }
if (!selectedComplex) { if (!selectedComplex) {
return ( return (
<main className="mx-auto w-full max-w-6xl px-6 py-8"> <main className="mx-auto w-full max-w-6xl px-6 py-8">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">No tienes complejos asignados todavía.</p>
No tienes complejos asignados todavía.
</p>
</main> </main>
) );
} }
return ( return (
@@ -327,15 +332,15 @@ export function HomePage() {
<DatePicker <DatePicker
value={fromIsoDateLocal(fromDate)} value={fromIsoDateLocal(fromDate)}
onChange={(date) => { onChange={(date) => {
const isoDate = date ? toIsoDateLocal(date) : todayIso const isoDate = date ? toIsoDateLocal(date) : todayIso;
setFromDate(isoDate) setFromDate(isoDate);
}} }}
disabled={(date) => { disabled={(date) => {
const today = new Date() const today = new Date();
today.setHours(0, 0, 0, 0) today.setHours(0, 0, 0, 0);
const checkDate = new Date(date) const checkDate = new Date(date);
checkDate.setHours(0, 0, 0, 0) checkDate.setHours(0, 0, 0, 0);
return checkDate < today return checkDate < today;
}} }}
placeholder="Selecciona una fecha" placeholder="Selecciona una fecha"
/> />
@@ -352,13 +357,11 @@ export function HomePage() {
</p> </p>
)} )}
{!bookingsQuery.isLoading && {!bookingsQuery.isLoading && !bookingsQuery.isError && groupedBookings.length === 0 && (
!bookingsQuery.isError && <p className="mt-4 text-sm text-muted-foreground">
groupedBookings.length === 0 && ( No hay reservas para la fecha seleccionada en adelante.
<p className="mt-4 text-sm text-muted-foreground"> </p>
No hay reservas para la fecha seleccionada en adelante. )}
</p>
)}
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
{groupedBookings.map(([date, bookings]) => ( {groupedBookings.map(([date, bookings]) => (
@@ -366,9 +369,10 @@ export function HomePage() {
<h2 className="text-sm font-semibold capitalize">{formatDateLabel(date)}</h2> <h2 className="text-sm font-semibold capitalize">{formatDateLabel(date)}</h2>
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
{bookings.map((booking) => { {bookings.map((booking) => {
const effectiveStatus = getEffectiveStatus(booking) const effectiveStatus = getEffectiveStatus(booking);
const canManage = booking.status === 'CONFIRMED' && effectiveStatus === 'CONFIRMED' const canManage =
const isMutating = updateStatusMutation.isPending booking.status === 'CONFIRMED' && effectiveStatus === 'CONFIRMED';
const isMutating = updateStatusMutation.isPending;
return ( return (
<div <div
@@ -380,7 +384,8 @@ export function HomePage() {
{booking.startTime} - {booking.endTime} · {booking.courtName} {booking.startTime} - {booking.endTime} · {booking.courtName}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{booking.customerName} ({booking.customerPhone}) · Código {booking.bookingCode} {booking.customerName} ({booking.customerPhone}) · Código{' '}
{booking.bookingCode}
</p> </p>
</div> </div>
@@ -402,7 +407,7 @@ export function HomePage() {
updateStatusMutation.mutate({ updateStatusMutation.mutate({
bookingId: booking.id, bookingId: booking.id,
status: 'COMPLETED', status: 'COMPLETED',
}) });
}} }}
> >
Marcar cumplida Marcar cumplida
@@ -416,7 +421,7 @@ export function HomePage() {
updateStatusMutation.mutate({ updateStatusMutation.mutate({
bookingId: booking.id, bookingId: booking.id,
status: 'CANCELLED', status: 'CANCELLED',
}) });
}} }}
> >
Cancelar Cancelar
@@ -425,7 +430,7 @@ export function HomePage() {
)} )}
</div> </div>
</div> </div>
) );
})} })}
</div> </div>
</article> </article>
@@ -451,10 +456,10 @@ export function HomePage() {
<DatePicker <DatePicker
value={fromIsoDateLocal(manualDate)} value={fromIsoDateLocal(manualDate)}
onChange={(date) => { onChange={(date) => {
const isoDate = date ? toIsoDateLocal(date) : todayIso const isoDate = date ? toIsoDateLocal(date) : todayIso;
setManualDate(isoDate) setManualDate(isoDate);
setSelectedCourtId('') setSelectedCourtId('');
setSelectedStartTime('') setSelectedStartTime('');
}} }}
minDate={new Date()} minDate={new Date()}
placeholder="Selecciona una fecha" placeholder="Selecciona una fecha"
@@ -467,9 +472,9 @@ export function HomePage() {
<Select <Select
value={selectedSportId ?? ''} value={selectedSportId ?? ''}
onValueChange={(value) => { onValueChange={(value) => {
setSelectedSportId(value) setSelectedSportId(value);
setSelectedCourtId('') setSelectedCourtId('');
setSelectedStartTime('') setSelectedStartTime('');
}} }}
> >
<SelectTrigger> <SelectTrigger>
@@ -491,8 +496,8 @@ export function HomePage() {
<Select <Select
value={selectedCourtId} value={selectedCourtId}
onValueChange={(value) => { onValueChange={(value) => {
setSelectedCourtId(value) setSelectedCourtId(value);
setSelectedStartTime('') setSelectedStartTime('');
}} }}
> >
<SelectTrigger> <SelectTrigger>
@@ -535,12 +540,15 @@ export function HomePage() {
<p className="mt-3 text-sm text-destructive"> <p className="mt-3 text-sm text-destructive">
{extractMessage( {extractMessage(
manualAvailabilityQuery.error, manualAvailabilityQuery.error,
'No pudimos cargar disponibilidad para la reserva manual.', 'No pudimos cargar disponibilidad para la reserva manual.'
)} )}
</p> </p>
)} )}
<form className="mt-4 grid gap-3 md:grid-cols-2" onSubmit={handleSubmit(onSubmitManualBooking)}> <form
className="mt-4 grid gap-3 md:grid-cols-2"
onSubmit={handleSubmit(onSubmitManualBooking)}
>
<Field data-invalid={Boolean(errors.customerName)}> <Field data-invalid={Boolean(errors.customerName)}>
<FieldLabel htmlFor="customerName">Nombre</FieldLabel> <FieldLabel htmlFor="customerName">Nombre</FieldLabel>
<Input <Input
@@ -568,7 +576,7 @@ export function HomePage() {
<p className="text-sm text-destructive md:col-span-2"> <p className="text-sm text-destructive md:col-span-2">
{extractMessage( {extractMessage(
createManualBookingMutation.error, createManualBookingMutation.error,
'No pudimos crear la reserva manual.', 'No pudimos crear la reserva manual.'
)} )}
</p> </p>
)} )}
@@ -583,5 +591,5 @@ export function HomePage() {
</form> </form>
</section> </section>
</main> </main>
) );
} }

View File

@@ -1,9 +1,5 @@
import { Link, Outlet, useNavigate } from '@tanstack/react-router' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useEffect, useMemo, useState } from 'react' import { Button } from '@/components/ui/button';
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 { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -11,54 +7,56 @@ import {
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu';
import { useAuth } from '@/lib/auth' import { apiClient } from '@/lib/api-client';
import { useAuth } from '@/lib/auth';
import { import {
getCurrentComplexSlug, getCurrentComplexSlug,
setCurrentComplexSlug as persistCurrentComplexSlug, setCurrentComplexSlug as persistCurrentComplexSlug,
} from '@/lib/current-complex' } from '@/lib/current-complex';
import { apiClient } from '@/lib/api-client' import { useQuery } from '@tanstack/react-query';
import { ThemeSwitcher } from './theme-switcher' 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() { export function RootLayout() {
const navigate = useNavigate() const navigate = useNavigate();
const { isAuthenticated, displayName, initials, avatarUrl, signOut } = useAuth() const { isAuthenticated, displayName, initials, avatarUrl, signOut } = useAuth();
const [currentComplexSlug, setCurrentComplexSlug] = useState<string | null>(null) const [currentComplexSlug, setCurrentComplexSlug] = useState<string | null>(null);
const myComplexesQuery = useQuery({ const myComplexesQuery = useQuery({
queryKey: ['my-complexes'], queryKey: ['my-complexes'],
enabled: isAuthenticated, enabled: isAuthenticated,
queryFn: () => apiClient.complexes.listMine(), queryFn: () => apiClient.complexes.listMine(),
}) });
useEffect(() => { useEffect(() => {
setCurrentComplexSlug(getCurrentComplexSlug()) setCurrentComplexSlug(getCurrentComplexSlug());
}, []) }, []);
useEffect(() => { useEffect(() => {
if (currentComplexSlug) return if (currentComplexSlug) return;
const fallbackSlug = myComplexesQuery.data?.[0]?.complexSlug const fallbackSlug = myComplexesQuery.data?.[0]?.complexSlug;
if (fallbackSlug) { if (fallbackSlug) {
setCurrentComplexSlug(fallbackSlug) setCurrentComplexSlug(fallbackSlug);
persistCurrentComplexSlug(fallbackSlug) persistCurrentComplexSlug(fallbackSlug);
} }
}, [currentComplexSlug, myComplexesQuery.data]) }, [currentComplexSlug, myComplexesQuery.data]);
const currentComplexName = useMemo(() => { const currentComplexName = useMemo(() => {
const complexes = myComplexesQuery.data ?? [] const complexes = myComplexesQuery.data ?? [];
if (complexes.length === 0) return 'Mi complejo' if (complexes.length === 0) return 'Mi complejo';
const selected = complexes.find( const selected = complexes.find((complex) => complex.complexSlug === currentComplexSlug);
(complex) => complex.complexSlug === currentComplexSlug,
)
return selected?.complexName ?? complexes[0]?.complexName ?? 'Mi complejo' return selected?.complexName ?? complexes[0]?.complexName ?? 'Mi complejo';
}, [currentComplexSlug, myComplexesQuery.data]) }, [currentComplexSlug, myComplexesQuery.data]);
const handleSignOut = async () => { const handleSignOut = async () => {
await signOut() await signOut();
await navigate({ to: '/login' }) await navigate({ to: '/login' });
} };
return ( return (
<div className="flex min-h-screen flex-col"> <div className="flex min-h-screen flex-col">
@@ -106,9 +104,7 @@ export function RootLayout() {
<AvatarImage src={avatarUrl ?? undefined} alt={displayName} /> <AvatarImage src={avatarUrl ?? undefined} alt={displayName} />
<AvatarFallback>{initials}</AvatarFallback> <AvatarFallback>{initials}</AvatarFallback>
</Avatar> </Avatar>
<span className="max-w-32 truncate text-sm text-foreground"> <span className="max-w-32 truncate text-sm text-foreground">{displayName}</span>
{displayName}
</span>
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52"> <DropdownMenuContent align="end" className="w-52">
@@ -116,7 +112,7 @@ export function RootLayout() {
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
onSelect={() => { onSelect={() => {
void navigate({ to: '/profile' }) void navigate({ to: '/profile' });
}} }}
> >
<UserRound className="size-4" /> <UserRound className="size-4" />
@@ -125,7 +121,7 @@ export function RootLayout() {
<DropdownMenuItem <DropdownMenuItem
variant="destructive" variant="destructive"
onSelect={() => { onSelect={() => {
void handleSignOut() void handleSignOut();
}} }}
> >
<LogOut className="size-4" /> <LogOut className="size-4" />
@@ -152,14 +148,14 @@ export function RootLayout() {
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
onSelect={() => { onSelect={() => {
void navigate({ to: '/' }) void navigate({ to: '/' });
}} }}
> >
Home Home
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onSelect={() => { onSelect={() => {
void navigate({ to: '/about' }) void navigate({ to: '/about' });
}} }}
> >
About About
@@ -170,7 +166,7 @@ export function RootLayout() {
void navigate({ void navigate({
to: '/complex/$slug/edit', to: '/complex/$slug/edit',
params: { slug: currentComplexSlug }, params: { slug: currentComplexSlug },
}) });
}} }}
> >
Configuración Configuración
@@ -182,7 +178,7 @@ export function RootLayout() {
<> <>
<DropdownMenuItem <DropdownMenuItem
onSelect={() => { onSelect={() => {
void navigate({ to: '/profile' }) void navigate({ to: '/profile' });
}} }}
> >
<UserRound className="size-4" /> <UserRound className="size-4" />
@@ -191,7 +187,7 @@ export function RootLayout() {
<DropdownMenuItem <DropdownMenuItem
variant="destructive" variant="destructive"
onSelect={() => { onSelect={() => {
void handleSignOut() void handleSignOut();
}} }}
> >
<LogOut className="size-4" /> <LogOut className="size-4" />
@@ -201,7 +197,7 @@ export function RootLayout() {
) : ( ) : (
<DropdownMenuItem <DropdownMenuItem
onSelect={() => { onSelect={() => {
void navigate({ to: '/login' }) void navigate({ to: '/login' });
}} }}
> >
Login Login
@@ -215,5 +211,5 @@ export function RootLayout() {
<Outlet /> <Outlet />
</div> </div>
</div> </div>
) );
} }

View File

@@ -1,5 +1,4 @@
import { Laptop, Moon, Sun } from 'lucide-react' import { Button } from '@/components/ui/button';
import { Button } from '@/components/ui/button'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -8,21 +7,18 @@ import {
DropdownMenuRadioItem, DropdownMenuRadioItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu';
import { useTheme } from '@/lib/theme' import { useTheme } from '@/lib/theme';
import { Laptop, Moon, Sun } from 'lucide-react';
export function ThemeSwitcher() { export function ThemeSwitcher() {
const { theme, resolvedTheme, setTheme } = useTheme() const { theme, resolvedTheme, setTheme } = useTheme();
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button size="sm" variant="outline" className="px-2"> <Button size="sm" variant="outline" className="px-2">
{resolvedTheme === 'dark' ? ( {resolvedTheme === 'dark' ? <Moon className="size-4" /> : <Sun className="size-4" />}
<Moon className="size-4" />
) : (
<Sun className="size-4" />
)}
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44"> <DropdownMenuContent align="end" className="w-44">
@@ -47,5 +43,5 @@ export function ThemeSwitcher() {
</DropdownMenuRadioGroup> </DropdownMenuRadioGroup>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) );
} }

View File

@@ -1,28 +1,28 @@
import { useState } from 'react' import { Button } from '@/components/ui/button';
import { Link, useNavigate } from '@tanstack/react-router' import { Field, FieldError, FieldLabel } from '@/components/ui/field';
import { zodResolver } from '@hookform/resolvers/zod' import { Input } from '@/components/ui/input';
import { useForm } from 'react-hook-form' import { useAuth } from '@/lib/auth';
import { z } from 'zod' import { zodResolver } from '@hookform/resolvers/zod';
import { Button } from '@/components/ui/button' import { Link, useNavigate } from '@tanstack/react-router';
import { Field, FieldError, FieldLabel } from '@/components/ui/field' import { useState } from 'react';
import { Input } from '@/components/ui/input' import { useForm } from 'react-hook-form';
import { useAuth } from '@/lib/auth' import { z } from 'zod';
const loginSchema = z.object({ const loginSchema = z.object({
email: z.string().email('Ingresá un email válido.'), email: z.string().email('Ingresá un email válido.'),
password: z.string().min(6, 'La contraseña debe tener al menos 6 caracteres.'), password: z.string().min(6, 'La contraseña debe tener al menos 6 caracteres.'),
}) });
type LoginForm = z.infer<typeof loginSchema> type LoginForm = z.infer<typeof loginSchema>;
type LoginPageProps = { type LoginPageProps = {
redirectTo?: string redirectTo?: string;
} };
export function LoginPage({ redirectTo = '/' }: LoginPageProps) { export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
const navigate = useNavigate() const navigate = useNavigate();
const { signInWithPassword } = useAuth() const { signInWithPassword } = useAuth();
const [submitError, setSubmitError] = useState<string | null>(null) const [submitError, setSubmitError] = useState<string | null>(null);
const { const {
register, register,
@@ -35,18 +35,18 @@ export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
email: '', email: '',
password: '', password: '',
}, },
}) });
const onSubmit = async (values: LoginForm) => { const onSubmit = async (values: LoginForm) => {
setSubmitError(null) setSubmitError(null);
try { try {
await signInWithPassword(values) await signInWithPassword(values);
await navigate({ to: redirectTo }) await navigate({ to: redirectTo });
} catch { } catch {
setSubmitError('No pudimos iniciar sesión. Verificá tus credenciales.') setSubmitError('No pudimos iniciar sesión. Verificá tus credenciales.');
} }
} };
return ( return (
<main className="mx-auto flex min-h-screen w-full max-w-6xl items-center px-6"> <main className="mx-auto flex min-h-screen w-full max-w-6xl items-center px-6">
@@ -96,5 +96,5 @@ export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
</p> </p>
</section> </section>
</main> </main>
) );
} }

View File

@@ -1,6 +1,6 @@
import { Link } from '@tanstack/react-router' import { Button } from '@/components/ui/button';
import { Compass, Home, Sparkles } from 'lucide-react' import { Link } from '@tanstack/react-router';
import { Button } from '@/components/ui/button' import { Compass, Home, Sparkles } from 'lucide-react';
export function NotFoundPage() { export function NotFoundPage() {
return ( return (
@@ -16,7 +16,8 @@ export function NotFoundPage() {
<p className="text-sm font-medium tracking-wide text-muted-foreground">404</p> <p className="text-sm font-medium tracking-wide text-muted-foreground">404</p>
<h1 className="mt-2 text-4xl font-semibold tracking-tight">Página no encontrada</h1> <h1 className="mt-2 text-4xl font-semibold tracking-tight">Página no encontrada</h1>
<p className="mt-4 text-sm leading-relaxed text-muted-foreground"> <p className="mt-4 text-sm leading-relaxed text-muted-foreground">
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.
</p> </p>
<div className="mt-8 flex flex-wrap gap-3"> <div className="mt-8 flex flex-wrap gap-3">
@@ -36,5 +37,5 @@ export function NotFoundPage() {
</div> </div>
</section> </section>
</main> </main>
) );
} }

View File

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

View File

@@ -1,19 +1,19 @@
import type { PlanSummary } from '@repo/api-contract' import { Button } from '@/components/ui/button';
import { Controller, type UseFormReturn } from 'react-hook-form' import { Field, FieldError, FieldLabel } from '@/components/ui/field';
import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input';
import { Field, FieldError, FieldLabel } from '@/components/ui/field' import type { CompleteValues } from '@/features/onboard/onboarding.types';
import { Input } from '@/components/ui/input' import type { PlanSummary } from '@repo/api-contract';
import type { CompleteValues } from '@/features/onboard/onboarding.types' import { Controller, type UseFormReturn } from 'react-hook-form';
type OnboardingCompleteStepProps = { type OnboardingCompleteStepProps = {
form: UseFormReturn<CompleteValues> form: UseFormReturn<CompleteValues>;
plans: PlanSummary[] plans: PlanSummary[];
verifiedEmail: string | null verifiedEmail: string | null;
errorMessage: string | null errorMessage: string | null;
infoMessage: string | null infoMessage: string | null;
planPlaceholder: string planPlaceholder: string;
onSubmit: (values: CompleteValues) => Promise<void> onSubmit: (values: CompleteValues) => Promise<void>;
} };
export function OnboardingCompleteStep({ export function OnboardingCompleteStep({
form, form,
@@ -81,7 +81,7 @@ export function OnboardingCompleteStep({
aria-invalid={Boolean(form.formState.errors.planCode)} aria-invalid={Boolean(form.formState.errors.planCode)}
value={field.value ?? ''} value={field.value ?? ''}
onChange={(event) => { onChange={(event) => {
field.onChange(event.target.value) field.onChange(event.target.value);
}} }}
disabled={plans.length === 0} disabled={plans.length === 0}
> >
@@ -115,5 +115,5 @@ export function OnboardingCompleteStep({
{form.formState.isSubmitting ? 'Finalizando...' : 'Completar onboarding'} {form.formState.isSubmitting ? 'Finalizando...' : 'Completar onboarding'}
</Button> </Button>
</form> </form>
) );
} }

View File

@@ -1,15 +1,11 @@
import type { PropsWithChildren } from 'react' import type { PropsWithChildren } from 'react';
type OnboardingLayoutProps = PropsWithChildren<{ type OnboardingLayoutProps = PropsWithChildren<{
title: string title: string;
description: string description: string;
}> }>;
export function OnboardingLayout({ export function OnboardingLayout({ title, description, children }: OnboardingLayoutProps) {
title,
description,
children,
}: OnboardingLayoutProps) {
return ( return (
<main className="mx-auto flex min-h-screen w-full max-w-6xl items-center justify-center px-6"> <main className="mx-auto flex min-h-screen w-full max-w-6xl items-center justify-center px-6">
<section className="w-full max-w-3xl rounded-xl border bg-card p-6 text-card-foreground shadow-sm"> <section className="w-full max-w-3xl rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
@@ -18,5 +14,5 @@ export function OnboardingLayout({
{children} {children}
</section> </section>
</main> </main>
) );
} }

View File

@@ -1,15 +1,15 @@
import type { UseFormReturn } from 'react-hook-form' import { Button } from '@/components/ui/button';
import { Button } from '@/components/ui/button' import { Field, FieldError, FieldLabel } from '@/components/ui/field';
import { Field, FieldError, FieldLabel } from '@/components/ui/field' import { Input } from '@/components/ui/input';
import { Input } from '@/components/ui/input' import type { StartValues } from '@/features/onboard/onboarding.types';
import type { StartValues } from '@/features/onboard/onboarding.types' import type { UseFormReturn } from 'react-hook-form';
type OnboardingStartStepProps = { type OnboardingStartStepProps = {
form: UseFormReturn<StartValues> form: UseFormReturn<StartValues>;
errorMessage: string | null errorMessage: string | null;
infoMessage: string | null infoMessage: string | null;
onSubmit: (values: StartValues) => Promise<void> onSubmit: (values: StartValues) => Promise<void>;
} };
export function OnboardingStartStep({ export function OnboardingStartStep({
form, form,
@@ -54,5 +54,5 @@ export function OnboardingStartStep({
{form.formState.isSubmitting ? 'Enviando...' : 'Enviar codigo OTP'} {form.formState.isSubmitting ? 'Enviando...' : 'Enviar codigo OTP'}
</Button> </Button>
</form> </form>
) );
} }

View File

@@ -1,26 +1,26 @@
import { RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button';
import type { UseFormReturn } from 'react-hook-form' import { Field, FieldError, FieldLabel } from '@/components/ui/field';
import { Button } from '@/components/ui/button'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import { import {
InputOTP, InputOTP,
InputOTPGroup, InputOTPGroup,
InputOTPSeparator, InputOTPSeparator,
InputOTPSlot, InputOTPSlot,
} from '@/components/ui/input-otp' } from '@/components/ui/input-otp';
import type { VerifyOtpValues } from '@/features/onboard/onboarding.types' import type { VerifyOtpValues } from '@/features/onboard/onboarding.types';
import { RefreshCw } from 'lucide-react';
import type { UseFormReturn } from 'react-hook-form';
type OnboardingVerifyOtpStepProps = { type OnboardingVerifyOtpStepProps = {
form: UseFormReturn<VerifyOtpValues> form: UseFormReturn<VerifyOtpValues>;
email: string | null email: string | null;
errorMessage: string | null errorMessage: string | null;
infoMessage: string | null infoMessage: string | null;
remainingAttempts: number remainingAttempts: number;
resendCooldownSeconds: number resendCooldownSeconds: number;
isResending: boolean isResending: boolean;
onSubmit: (values: VerifyOtpValues) => Promise<void> onSubmit: (values: VerifyOtpValues) => Promise<void>;
onResend: () => Promise<void> onResend: () => Promise<void>;
} };
export function OnboardingVerifyOtpStep({ export function OnboardingVerifyOtpStep({
form, form,
@@ -33,7 +33,7 @@ export function OnboardingVerifyOtpStep({
onSubmit, onSubmit,
onResend, onResend,
}: OnboardingVerifyOtpStepProps) { }: OnboardingVerifyOtpStepProps) {
const resendDisabled = resendCooldownSeconds > 0 || isResending const resendDisabled = resendCooldownSeconds > 0 || isResending;
return ( return (
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}> <form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
@@ -51,7 +51,7 @@ export function OnboardingVerifyOtpStep({
size="sm" size="sm"
disabled={resendDisabled} disabled={resendDisabled}
onClick={() => { onClick={() => {
void onResend() void onResend();
}} }}
> >
<RefreshCw className="mr-1 h-3.5 w-3.5" /> <RefreshCw className="mr-1 h-3.5 w-3.5" />
@@ -70,7 +70,7 @@ export function OnboardingVerifyOtpStep({
pattern="\d*" pattern="\d*"
value={form.watch('otp')} value={form.watch('otp')}
onChange={(value) => { onChange={(value) => {
form.setValue('otp', value, { shouldValidate: true }) form.setValue('otp', value, { shouldValidate: true });
}} }}
> >
<InputOTPGroup> <InputOTPGroup>
@@ -104,5 +104,5 @@ export function OnboardingVerifyOtpStep({
{form.formState.isSubmitting ? 'Verificando...' : 'Verificar codigo'} {form.formState.isSubmitting ? 'Verificando...' : 'Verificar codigo'}
</Button> </Button>
</form> </form>
) );
} }

View File

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

View File

@@ -1,43 +1,43 @@
import { useEffect, useMemo, useState } from 'react' import { OnboardingCompleteStep } from '@/features/onboard/components/onboarding-complete-step';
import { useNavigate } from '@tanstack/react-router' import { OnboardingLayout } from '@/features/onboard/components/onboarding-layout';
import { zodResolver } from '@hookform/resolvers/zod' import { OnboardingStartStep } from '@/features/onboard/components/onboarding-start-step';
import { useForm } from 'react-hook-form' import { OnboardingVerifyOtpStep } from '@/features/onboard/components/onboarding-verify-otp-step';
import {
onboardingCompleteSchema,
onboardingStartSchema,
onboardingVerifyOtpSchema,
type PlanSummary,
} from '@repo/api-contract'
import { OnboardingCompleteStep } from '@/features/onboard/components/onboarding-complete-step'
import { OnboardingLayout } from '@/features/onboard/components/onboarding-layout'
import { OnboardingStartStep } from '@/features/onboard/components/onboarding-start-step'
import { OnboardingVerifyOtpStep } from '@/features/onboard/components/onboarding-verify-otp-step'
import type { import type {
CompleteValues, CompleteValues,
StartValues, StartValues,
VerifyOtpValues, VerifyOtpValues,
} from '@/features/onboard/onboarding.types' } from '@/features/onboard/onboarding.types';
import { apiClient } from '@/lib/api-client' import { apiClient } from '@/lib/api-client';
import { useAuth } from '@/lib/auth' import { useAuth } from '@/lib/auth';
import { setCurrentComplexSlug } from '@/lib/current-complex' 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 const OTP_MAX_ATTEMPTS = 5;
type OnboardStep = 'start' | 'verify-otp' | 'complete' type OnboardStep = 'start' | 'verify-otp' | 'complete';
export function OnboardPage() { export function OnboardPage() {
const navigate = useNavigate() const navigate = useNavigate();
const { signInWithPassword } = useAuth() const { signInWithPassword } = useAuth();
const [step, setStep] = useState<OnboardStep>('start') const [step, setStep] = useState<OnboardStep>('start');
const [requestId, setRequestId] = useState<string | null>(null) const [requestId, setRequestId] = useState<string | null>(null);
const [onboardingEmail, setOnboardingEmail] = useState<string | null>(null) const [onboardingEmail, setOnboardingEmail] = useState<string | null>(null);
const [verifiedEmail, setVerifiedEmail] = useState<string | null>(null) const [verifiedEmail, setVerifiedEmail] = useState<string | null>(null);
const [plans, setPlans] = useState<PlanSummary[]>([]) const [plans, setPlans] = useState<PlanSummary[]>([]);
const [remainingAttempts, setRemainingAttempts] = useState<number>(OTP_MAX_ATTEMPTS) const [remainingAttempts, setRemainingAttempts] = useState<number>(OTP_MAX_ATTEMPTS);
const [resendCooldownSeconds, setResendCooldownSeconds] = useState<number>(0) const [resendCooldownSeconds, setResendCooldownSeconds] = useState<number>(0);
const [isResending, setIsResending] = useState(false) const [isResending, setIsResending] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null) const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [infoMessage, setInfoMessage] = useState<string | null>(null) const [infoMessage, setInfoMessage] = useState<string | null>(null);
const startForm = useForm<StartValues>({ const startForm = useForm<StartValues>({
resolver: zodResolver(onboardingStartSchema), resolver: zodResolver(onboardingStartSchema),
@@ -46,12 +46,10 @@ export function OnboardPage() {
fullName: '', fullName: '',
email: '', email: '',
}, },
}) });
const completeForm = useForm<CompleteValues>({ const completeForm = useForm<CompleteValues>({
resolver: zodResolver( resolver: zodResolver(onboardingCompleteSchema.omit({ onboardingRequestId: true })),
onboardingCompleteSchema.omit({ onboardingRequestId: true }),
),
mode: 'onChange', mode: 'onChange',
defaultValues: { defaultValues: {
password: '', password: '',
@@ -59,7 +57,7 @@ export function OnboardPage() {
physicalAddress: '', physicalAddress: '',
planCode: '', planCode: '',
}, },
}) });
const verifyOtpForm = useForm<VerifyOtpValues>({ const verifyOtpForm = useForm<VerifyOtpValues>({
resolver: zodResolver(onboardingVerifyOtpSchema.omit({ requestId: true })), resolver: zodResolver(onboardingVerifyOtpSchema.omit({ requestId: true })),
@@ -67,114 +65,114 @@ export function OnboardPage() {
defaultValues: { defaultValues: {
otp: '', otp: '',
}, },
}) });
useEffect(() => { useEffect(() => {
if (step !== 'complete') return if (step !== 'complete') return;
void (async () => { void (async () => {
try { try {
const result = await apiClient.plans.list() const result = await apiClient.plans.list();
setPlans(result) setPlans(result);
} catch { } catch {
setPlans([]) setPlans([]);
} }
})() })();
}, [step]) }, [step]);
const planPlaceholder = useMemo(() => { const planPlaceholder = useMemo(() => {
if (plans.length === 0) return 'Codigo del plan (por ejemplo: BASIC)' if (plans.length === 0) return 'Codigo del plan (por ejemplo: BASIC)';
return 'Selecciona un plan' return 'Selecciona un plan';
}, [plans.length]) }, [plans.length]);
useEffect(() => { useEffect(() => {
if (resendCooldownSeconds <= 0) return if (resendCooldownSeconds <= 0) return;
const timeout = window.setTimeout(() => { const timeout = window.setTimeout(() => {
setResendCooldownSeconds((current) => Math.max(0, current - 1)) setResendCooldownSeconds((current) => Math.max(0, current - 1));
}, 1000) }, 1000);
return () => { return () => {
window.clearTimeout(timeout) window.clearTimeout(timeout);
} };
}, [resendCooldownSeconds]) }, [resendCooldownSeconds]);
const onSubmitStart = async (values: StartValues) => { const onSubmitStart = async (values: StartValues) => {
setErrorMessage(null) setErrorMessage(null);
setInfoMessage(null) setInfoMessage(null);
try { try {
const result = await apiClient.onboarding.start(values) const result = await apiClient.onboarding.start(values);
setRequestId(result.requestId) setRequestId(result.requestId);
setOnboardingEmail(result.email) setOnboardingEmail(result.email);
setRemainingAttempts(OTP_MAX_ATTEMPTS) setRemainingAttempts(OTP_MAX_ATTEMPTS);
setResendCooldownSeconds(result.cooldownSeconds) setResendCooldownSeconds(result.cooldownSeconds);
verifyOtpForm.reset({ otp: '' }) verifyOtpForm.reset({ otp: '' });
setStep('verify-otp') setStep('verify-otp');
setInfoMessage(result.message) setInfoMessage(result.message);
} catch { } catch {
setErrorMessage('No pudimos iniciar el onboarding. Intenta nuevamente.') setErrorMessage('No pudimos iniciar el onboarding. Intenta nuevamente.');
} }
} };
const onSubmitVerifyOtp = async (values: VerifyOtpValues) => { const onSubmitVerifyOtp = async (values: VerifyOtpValues) => {
if (!requestId) { if (!requestId) {
setErrorMessage('No encontramos una solicitud de onboarding activa.') setErrorMessage('No encontramos una solicitud de onboarding activa.');
return return;
} }
setErrorMessage(null) setErrorMessage(null);
try { try {
const result = await apiClient.onboarding.verifyOtp({ const result = await apiClient.onboarding.verifyOtp({
requestId, requestId,
otp: values.otp, otp: values.otp,
}) });
setRemainingAttempts(result.remainingAttempts) setRemainingAttempts(result.remainingAttempts);
setResendCooldownSeconds(result.cooldownSeconds) setResendCooldownSeconds(result.cooldownSeconds);
setInfoMessage(result.message) setInfoMessage(result.message);
if (result.verified) { if (result.verified) {
setVerifiedEmail(result.email ?? onboardingEmail) setVerifiedEmail(result.email ?? onboardingEmail);
setStep('complete') setStep('complete');
return return;
} }
} catch { } catch {
setErrorMessage('No pudimos verificar el OTP. Intenta nuevamente.') setErrorMessage('No pudimos verificar el OTP. Intenta nuevamente.');
} }
} };
const onResendOtp = async () => { const onResendOtp = async () => {
if (!requestId) { if (!requestId) {
setErrorMessage('No encontramos una solicitud de onboarding activa.') setErrorMessage('No encontramos una solicitud de onboarding activa.');
return return;
} }
setIsResending(true) setIsResending(true);
setErrorMessage(null) setErrorMessage(null);
try { try {
const result = await apiClient.onboarding.resendOtp({ requestId }) const result = await apiClient.onboarding.resendOtp({ requestId });
setOnboardingEmail(result.email) setOnboardingEmail(result.email);
setRemainingAttempts(result.remainingAttempts) setRemainingAttempts(result.remainingAttempts);
setResendCooldownSeconds(result.cooldownSeconds) setResendCooldownSeconds(result.cooldownSeconds);
verifyOtpForm.reset({ otp: '' }) verifyOtpForm.reset({ otp: '' });
setInfoMessage(result.message) setInfoMessage(result.message);
} catch { } catch {
setErrorMessage('No pudimos reenviar el OTP. Intenta nuevamente.') setErrorMessage('No pudimos reenviar el OTP. Intenta nuevamente.');
} finally { } finally {
setIsResending(false) setIsResending(false);
} }
} };
const onSubmitComplete = async (values: CompleteValues) => { const onSubmitComplete = async (values: CompleteValues) => {
if (!requestId) { if (!requestId) {
setErrorMessage('No encontramos una solicitud de onboarding activa.') setErrorMessage('No encontramos una solicitud de onboarding activa.');
return return;
} }
setErrorMessage(null) setErrorMessage(null);
try { try {
const result = await apiClient.onboarding.complete({ const result = await apiClient.onboarding.complete({
@@ -183,19 +181,19 @@ export function OnboardPage() {
complexName: values.complexName, complexName: values.complexName,
physicalAddress: values.physicalAddress, physicalAddress: values.physicalAddress,
planCode: values.planCode, planCode: values.planCode,
}) });
setCurrentComplexSlug(result.complexSlug) setCurrentComplexSlug(result.complexSlug);
const emailForSignIn = verifiedEmail ?? onboardingEmail const emailForSignIn = verifiedEmail ?? onboardingEmail;
if (emailForSignIn) { 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 { } catch {
setErrorMessage('No pudimos completar el onboarding. Intenta nuevamente.') setErrorMessage('No pudimos completar el onboarding. Intenta nuevamente.');
} }
} };
return ( return (
<OnboardingLayout <OnboardingLayout
@@ -237,5 +235,5 @@ export function OnboardPage() {
/> />
)} )}
</OnboardingLayout> </OnboardingLayout>
) );
} }

View File

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

View File

@@ -1,11 +1,5 @@
import { Link } from '@tanstack/react-router' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Button } from '@/components/ui/button';
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 { import {
Form, Form,
FormControl, FormControl,
@@ -13,42 +7,51 @@ import {
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage, FormMessage,
} from '@/components/ui/form' } from '@/components/ui/form';
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input';
import { apiClient } from '@/lib/api-client' import { apiClient } from '@/lib/api-client';
import { useAuth } from '@/lib/auth' 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({ const updateProfileSchema = z.object({
fullName: z.string().min(1, 'El nombre es requerido'), fullName: z.string().min(1, 'El nombre es requerido'),
}) });
const updatePasswordSchema = z.object({ const updatePasswordSchema = z
currentPassword: z.string().min(1, 'La contraseña actual es requerida'), .object({
newPassword: z.string().min(6, 'La nueva contraseña debe tener al menos 6 caracteres'), currentPassword: z.string().min(1, 'La contraseña actual es requerida'),
confirmPassword: z.string().min(1, 'La confirmación de contraseña es requerida'), newPassword: z.string().min(6, 'La nueva contraseña debe tener al menos 6 caracteres'),
}).refine((data) => data.newPassword === data.confirmPassword, { confirmPassword: z.string().min(1, 'La confirmación de contraseña es requerida'),
message: 'Las contraseñas no coinciden', })
path: ['confirmPassword'], .refine((data) => data.newPassword === data.confirmPassword, {
}) message: 'Las contraseñas no coinciden',
path: ['confirmPassword'],
});
type UpdateProfileForm = z.infer<typeof updateProfileSchema> type UpdateProfileForm = z.infer<typeof updateProfileSchema>;
type UpdatePasswordForm = z.infer<typeof updatePasswordSchema> type UpdatePasswordForm = z.infer<typeof updatePasswordSchema>;
export function ProfilePage() { export function ProfilePage() {
const { user, displayName, initials, avatarUrl, isAuthenticated, updateProfile, updatePassword } = useAuth() const { user, displayName, initials, avatarUrl, isAuthenticated, updateProfile, updatePassword } =
const queryClient = useQueryClient() useAuth();
const queryClient = useQueryClient();
const profileQuery = useQuery({ const profileQuery = useQuery({
queryKey: ['user-profile'], queryKey: ['user-profile'],
enabled: isAuthenticated, enabled: isAuthenticated,
queryFn: (): Promise<UserProfileResponse> => apiClient.user.getProfile(), queryFn: (): Promise<UserProfileResponse> => apiClient.user.getProfile(),
}) });
const updateProfileForm = useForm<UpdateProfileForm>({ const updateProfileForm = useForm<UpdateProfileForm>({
resolver: zodResolver(updateProfileSchema), resolver: zodResolver(updateProfileSchema),
defaultValues: { defaultValues: {
fullName: displayName, fullName: displayName,
}, },
}) });
const updatePasswordForm = useForm<UpdatePasswordForm>({ const updatePasswordForm = useForm<UpdatePasswordForm>({
resolver: zodResolver(updatePasswordSchema), resolver: zodResolver(updatePasswordSchema),
@@ -57,34 +60,34 @@ export function ProfilePage() {
newPassword: '', newPassword: '',
confirmPassword: '', confirmPassword: '',
}, },
}) });
const updateProfileMutation = useMutation({ const updateProfileMutation = useMutation({
mutationFn: updateProfile, mutationFn: updateProfile,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['user-profile'] }) queryClient.invalidateQueries({ queryKey: ['user-profile'] });
updateProfileForm.reset() updateProfileForm.reset();
}, },
}) });
const updatePasswordMutation = useMutation({ const updatePasswordMutation = useMutation({
mutationFn: async (data: UpdatePasswordForm) => { mutationFn: async (data: UpdatePasswordForm) => {
// Note: Supabase doesn't require current password for password update // Note: Supabase doesn't require current password for password update
// The user needs to be recently authenticated // The user needs to be recently authenticated
await updatePassword({ password: data.newPassword }) await updatePassword({ password: data.newPassword });
}, },
onSuccess: () => { onSuccess: () => {
updatePasswordForm.reset() updatePasswordForm.reset();
}, },
}) });
const onUpdateProfile = (data: UpdateProfileForm) => { const onUpdateProfile = (data: UpdateProfileForm) => {
updateProfileMutation.mutate(data) updateProfileMutation.mutate(data);
} };
const onUpdatePassword = (data: UpdatePasswordForm) => { const onUpdatePassword = (data: UpdatePasswordForm) => {
updatePasswordMutation.mutate(data) updatePasswordMutation.mutate(data);
} };
if (!isAuthenticated) { if (!isAuthenticated) {
return ( return (
@@ -99,7 +102,7 @@ export function ProfilePage() {
</Button> </Button>
</section> </section>
</main> </main>
) );
} }
return ( return (
@@ -122,9 +125,7 @@ export function ProfilePage() {
{profileQuery.data?.email ?? user?.email} {profileQuery.data?.email ?? user?.email}
</p> </p>
{profileQuery.data?.role && ( {profileQuery.data?.role && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">Rol: {profileQuery.data.role}</p>
Rol: {profileQuery.data.role}
</p>
)} )}
</div> </div>
</div> </div>
@@ -157,10 +158,7 @@ export function ProfilePage() {
</FormItem> </FormItem>
)} )}
/> />
<Button <Button type="submit" disabled={updateProfileMutation.isPending}>
type="submit"
disabled={updateProfileMutation.isPending}
>
{updateProfileMutation.isPending ? 'Guardando...' : 'Guardar cambios'} {updateProfileMutation.isPending ? 'Guardando...' : 'Guardar cambios'}
</Button> </Button>
</form> </form>
@@ -171,16 +169,17 @@ export function ProfilePage() {
</p> </p>
)} )}
{updateProfileMutation.isSuccess && ( {updateProfileMutation.isSuccess && (
<p className="mt-4 text-sm text-green-600"> <p className="mt-4 text-sm text-green-600">Perfil actualizado exitosamente.</p>
Perfil actualizado exitosamente.
</p>
)} )}
</section> </section>
<section className="rounded-xl border bg-card p-6 text-card-foreground shadow-sm"> <section className="rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
<h2 className="text-xl font-semibold mb-4">Cambiar Contraseña</h2> <h2 className="text-xl font-semibold mb-4">Cambiar Contraseña</h2>
<Form {...updatePasswordForm}> <Form {...updatePasswordForm}>
<form onSubmit={updatePasswordForm.handleSubmit(onUpdatePassword)} className="space-y-4"> <form
onSubmit={updatePasswordForm.handleSubmit(onUpdatePassword)}
className="space-y-4"
>
<FormField <FormField
control={updatePasswordForm.control} control={updatePasswordForm.control}
name="currentPassword" name="currentPassword"
@@ -220,10 +219,7 @@ export function ProfilePage() {
</FormItem> </FormItem>
)} )}
/> />
<Button <Button type="submit" disabled={updatePasswordMutation.isPending}>
type="submit"
disabled={updatePasswordMutation.isPending}
>
{updatePasswordMutation.isPending ? 'Cambiando...' : 'Cambiar contraseña'} {updatePasswordMutation.isPending ? 'Cambiando...' : 'Cambiar contraseña'}
</Button> </Button>
</form> </form>
@@ -234,12 +230,10 @@ export function ProfilePage() {
</p> </p>
)} )}
{updatePasswordMutation.isSuccess && ( {updatePasswordMutation.isSuccess && (
<p className="mt-4 text-sm text-green-600"> <p className="mt-4 text-sm text-green-600">Contraseña cambiada exitosamente.</p>
Contraseña cambiada exitosamente.
</p>
)} )}
</section> </section>
</div> </div>
</main> </main>
) );
} }

View File

@@ -1,25 +1,25 @@
import type { PublicBookingConfirmation } from '@repo/api-contract' import { Button } from '@/components/ui/button';
import { siWhatsapp } from 'simple-icons' import type { PublicBookingConfirmation } from '@repo/api-contract';
import { Button } from '@/components/ui/button' import { siWhatsapp } from 'simple-icons';
type ShareWhatsappButtonProps = { type ShareWhatsappButtonProps = {
confirmation: PublicBookingConfirmation confirmation: PublicBookingConfirmation;
} };
function formatDateLabel(isoDate: string) { function formatDateLabel(isoDate: string) {
const [year, month, day] = isoDate.split('-').map(Number) const [year, month, day] = isoDate.split('-').map(Number);
const date = new Date(year, (month ?? 1) - 1, day ?? 1) const date = new Date(year, (month ?? 1) - 1, day ?? 1);
return new Intl.DateTimeFormat('es-AR', { return new Intl.DateTimeFormat('es-AR', {
weekday: 'long', weekday: 'long',
day: '2-digit', day: '2-digit',
month: 'long', month: 'long',
year: 'numeric', year: 'numeric',
}).format(date) }).format(date);
} }
function createWhatsappMessage(confirmation: PublicBookingConfirmation) { function createWhatsappMessage(confirmation: PublicBookingConfirmation) {
const dateLabel = formatDateLabel(confirmation.date) const dateLabel = formatDateLabel(confirmation.date);
return [ return [
'Ya reservamos cancha para jugar.', 'Ya reservamos cancha para jugar.',
`Complejo: ${confirmation.complexName}`, `Complejo: ${confirmation.complexName}`,
@@ -27,13 +27,13 @@ function createWhatsappMessage(confirmation: PublicBookingConfirmation) {
`Fecha: ${dateLabel}`, `Fecha: ${dateLabel}`,
`Horario: ${confirmation.startTime} - ${confirmation.endTime}`, `Horario: ${confirmation.startTime} - ${confirmation.endTime}`,
`Codigo de reserva: ${confirmation.bookingCode}`, `Codigo de reserva: ${confirmation.bookingCode}`,
].join('\n') ].join('\n');
} }
export function ShareWhatsappButton({ confirmation }: ShareWhatsappButtonProps) { export function ShareWhatsappButton({ confirmation }: ShareWhatsappButtonProps) {
const whatsappUrl = `https://wa.me/?text=${encodeURIComponent( const whatsappUrl = `https://wa.me/?text=${encodeURIComponent(
createWhatsappMessage(confirmation), createWhatsappMessage(confirmation)
)}` )}`;
return ( return (
<Button type="button" variant="outline" className="h-11 w-full text-sm sm:text-base" asChild> <Button type="button" variant="outline" className="h-11 w-full text-sm sm:text-base" asChild>
@@ -49,5 +49,5 @@ export function ShareWhatsappButton({ confirmation }: ShareWhatsappButtonProps)
Compartir por WhatsApp Compartir por WhatsApp
</a> </a>
</Button> </Button>
) );
} }

Some files were not shown because too many files have changed in this diff Show More