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:
@@ -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",
|
||||||
|
|||||||
@@ -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'),
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -74,4 +74,4 @@ export const planSeeds = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
] as const
|
] as const;
|
||||||
|
|||||||
@@ -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);
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
})),
|
}))
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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());
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ export function AboutPage() {
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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])
|
||||||
)
|
);
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
|
||||||
>
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user