9 Commits

Author SHA1 Message Date
Jose Selesan
000277b312 refactor: remove role display from ComplexSettingsPage and adjust HomePage layout 2026-04-27 14:40:57 -03:00
Jose Selesan
7a3452c2d1 refactor: update home page UI to v2 patterns and introduce availability heatmap component 2026-04-27 08:46:32 -03:00
Jose Selesan
d6e32f3e84 feat: implement booking cancellation feature in HomePage 2026-04-24 15:44:46 -03:00
Jose Selesan
82dbc8ad99 feat: update styles for HomePage and RootLayout, adjust background color in CSS 2026-04-24 15:32:49 -03:00
Jose Selesan
fb19458ddf feat: add price field to CourtBooking model and related schemas
- Added `price` field to `CourtBookingScalarFieldEnum` in `prismaNamespace.ts` and `prismaNamespaceBrowser.ts`.
- Updated `CourtBooking` model to include average and sum aggregations for `price`.
- Enhanced input types and aggregate input types to support `price`.
- Modified `public-booking.service.ts` to calculate and return price information for bookings.
- Updated frontend components to display price information in booking confirmation and selection pages.
- Introduced `priceRangeSchema` in API contract to validate price ranges.
2026-04-24 15:22:11 -03:00
Jose Selesan
c63f0f2109 feat: update public booking page layout for improved responsiveness 2026-04-24 14:50:46 -03:00
Jose Selesan
f1a7e6c24a feat: enhance public booking page with improved layout and date formatting
- Added new date formatting function for better user experience.
- Updated layout of the public booking page for improved responsiveness and aesthetics.
- Integrated sport selection with visual icons for better clarity.
- Enhanced error handling and loading states for availability queries.

feat: revamp select complex page for better user interaction

- Redesigned the select complex page layout for a more modern look.
- Added contextual information about complex selection.
- Improved button interactions with loading indicators and icons.

style: update CSS variables for a cohesive sports-club theme

- Adjusted color variables to reflect a sports-club identity.
- Enhanced background styles for both light and dark modes.
- Improved overall styling consistency across components.
2026-04-24 14:47:39 -03:00
Jose Selesan
c63b5a0a07 feat: enhance sport selection UI in public booking page
Co-authored-by: Copilot <copilot@github.com>
2026-04-24 14:14:09 -03:00
Jose Selesan
503c29613b feat(interface-design): add comprehensive interface design skill documentation and references
- Introduced SKILL.md outlining the principles and processes for effective interface design.
- Added critique.md for evaluating design outputs and ensuring craft over correctness.
- Created example.md to illustrate the application of subtle layering principles in design decisions.
- Developed principles.md detailing core craft principles for consistent design quality.
- Implemented validation.md for memory management and pattern reuse in the design system.
- Established system.md for the Playzer design system, defining direction, domain concepts, and design decisions.
- Added StatusBadge component for displaying booking statuses with appropriate styles and labels.
- Updated skills-lock.json to include the new interface-design skill.
2026-04-24 11:23:45 -03:00
184 changed files with 7209 additions and 10842 deletions

211
.interface-design/system.md Normal file
View File

@@ -0,0 +1,211 @@
# Design System - Playzer
## Direction
**Feel:** Technical but warm. Like a well-organized sports facility — functional, clean, but with life. Not cold like a trading terminal, not playful like a toy app.
**Users:** Owners and employees managing sports complex bookings. They're juggling schedules, phone calls, and walk-ins. The interface should stay out of the way while they're working.
---
## Domain
**Concepts:**
- Turno (booking slot) — A scheduled time block
- Cancha (court) — Sports court/pitch
- Horario — Time slot
- Complex — The sports facility
- Estado — Booking status (confirmed, cancelled, etc.)
**Color world:**
- Court green (grass/tennis)
- Clay tan (paddle court)
- Pool blue (swimming)
- Wood grain (locker rooms)
- Concrete gray (tech floor)
**Signature:**
- Status pills use subtle borders + background tint, not bold badges
- Status communicates without demanding attention — whisper-level hierarchy
---
## Decisions
### Depth
**Approach:** Borders-only with surface elevation shifts
- No drop shadows
- Higher elevation = slightly lighter surface (in dark: lighter = higher)
- Subtle rgba borders define edges quietly
### Spacing
**Base unit:** 4px
- Micro: 2-4px (icon gaps)
- Component: 8-12px (inside buttons, badges)
- Section: 16-24px (between groups)
- Major: 32-40px (between distinct areas)
### Typography
**Font:** Geist Variable
**Scale:**
- Display: 28px / semibold / -0.02em tracking
- Heading: 20px / semibold / -0.01em tracking
- Body: 14px / normal / 0 tracking
- Label: 12px / medium / 0 tracking
- Caption: 11px / normal / 0.02em tracking
### Border Radius
**Scale:**
- `sm`: 4px (inputs, small badges)
- `md`: 6px (buttons, cards)
- `lg`: 10px (dialogs, large cards)
- `xl`: 14px (modals)
### Borders
**Progression:**
- Subtle: `oklch(1 0 0 / 8%)` (background edges)
- Default: `oklch(1 0 0 / 12%)` (card borders)
- Emphasis: `oklch(1 0 0 / 20%)` (active states)
### Status Semantic Colors
**Light mode:**
- Confirmed: `bg-emerald-50 border-emerald-200 text-emerald-700`
- Pending: `bg-amber-50 border-amber-200 text-amber-700`
- Cancelled: `bg-rose-50 border-rose-200 text-rose-700`
- No-show: `bg-orange-50 border-orange-200 text-orange-700`
- Completed: `bg-sky-50 border-sky-200 text-sky-700`
**Dark mode:**
- Slight desaturation, lower opacity backgrounds
---
## Patterns
### Card
- Background: `bg-card`
- Border: `border` token
- Radius: `md`
- Padding: `p-4` (compact) / `p-5` (default)
### Status Badge
```tsx
<span className="inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium">
{icon}
{label}
</span>
```
### Input
- Background: `bg-input` (darker than surface)
- No heavy borders by default
- Focus ring via `ring-3 ring-ring/50`
### Layout
- Sidebar: Same background as main, subtle border separation
- Content: Max-width 6xl, centered
- Padding: `px-4 sm:px-6`
---
## TODO
- [x] Status tokens in CSS variables
- [x] Refactor status badges to use semantic classes
- [x] StatusBadge component created
- [x] Home panel redesign with metrics + timeline
---
## Home Page Patterns (v2)
### Structure
- Header + URL Card: 2-column grid (lg:)
- Main: Timeline de reservas
- Sidebar: Stats grid consolidado (sticky)
### URL Card (destacada)
```tsx
<div className="group relative overflow-hidden rounded-2xl border border-blue-200/60 bg-blue-50/50 p-5">
<div className="absolute inset-0 bg-gradient-to-br from-blue-50/80 to-blue-100/30" />
<div className="relative flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-blue-600/70">
Reserva online
</p>
<a href={url} className="mt-1 block truncate text-sm font-medium text-blue-700 underline-offset-2 hover:underline">
{url}
</a>
</div>
<button className="relative flex shrink-0 items-center gap-2 rounded-lg bg-blue-500/10 px-3 py-2 text-sm font-medium text-blue-700 transition-colors hover:bg-blue-500/20">
<Copy className="size-4" />
<span>Copiar</span>
</button>
</div>
</div>
```
### Stats Pills (header)
```tsx
<span className="rounded-full border border-emerald-200/60 bg-emerald-50/60 px-3 py-1 text-xs font-medium text-emerald-700">
{count} hoy
</span>
```
### Stats Sidebar (grid 2x2)
```tsx
<section className="space-y-3 rounded-2xl border border-border/70 bg-card p-4">
<h2 className="text-xs font-semibold uppercase tracking-[0.28em] text-muted-foreground">
Resumen
</h2>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-xl border border-border/50 bg-background/80 p-3">
<p className="text-xs text-muted-foreground">Total</p>
<p className="mt-1 text-xl font-semibold tracking-tight">{value}</p>
</div>
<div className="rounded-xl border border-emerald-200/40 bg-emerald-50/40 p-3">
<p className="text-xs text-emerald-700/80">Hoy</p>
<p className="mt-1 text-xl font-semibold tracking-tight text-emerald-700">{value}</p>
</div>
{/* ... confirmed (blue), completed (sky) */}
</div>
</section>
```
### Timeline Item (v2 - mejorado)
```tsx
<div className={[
'group flex items-center justify-between gap-3 rounded-xl border border-border/70 bg-card/60 p-3.5 transition-all hover:bg-muted/50',
isPast && 'opacity-50',
isNow && 'border-l-2 border-l-emerald-500 bg-emerald-50/30',
].join(' ')}>
<div className="flex min-w-0 flex-1 items-center gap-3">
<div className="flex w-14 flex-col items-center">
<span className="text-sm font-medium tabular-nums">{startTime}</span>
<span className="text-[11px] text-muted-foreground">{endTime}</span>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{courtName}</p>
<p className="truncate text-xs text-muted-foreground">{customer}</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<StatusBadge status={...} />
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button size="icon-xs" variant="ghost" title="Marcar cumplida"><Check className="size-3.5" /></Button>
<Button size="icon-xs" variant="ghost" title="Cancelar">×</Button>
</div>
</div>
</div>
```
### Date Group Header
```tsx
<div className="flex items-center gap-3">
<h2 className="text-sm font-semibold capitalize">{dateLabel}</h2>
<div className="h-px flex-1 border-t border-dashed border-border/40" />
<span className="shrink-0 rounded-full border border-border/70 bg-background px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
{count}
</span>
</div>
```

View File

@@ -193,61 +193,3 @@ await getAvailability('my-club', { date: '2026-04-20' });
1. Create `lib/api/resources/[resource].ts` 1. Create `lib/api/resources/[resource].ts`
2. Export functions using `http` from `../http` 2. Export functions using `http` from `../http`
3. Add exports in `lib/api/index.ts` 3. Add exports in `lib/api/index.ts`
## Email Templates
Todos los emails deben usar la misma estética. El layout compartido está en `apps/backend/src/emails/booking-confirmation.ts`.
### Layout (`wrapLayout`)
Exportado como `wrapLayout(content)`. Proporciona:
- Fondo: `#edf7f4`
- Card blanca: `max-width: 520px`, `border-radius: 28px`, `box-shadow: 0 24px 70px rgba(15,23,42,0.12)`, `border: 1px solid rgba(5,9,20,0.1)`
- Playzer favicon: `{APP_BASE_URL}/playzer-favicon-512-transparent.png` (está en `apps/frontend/public/`)
- **Sin `min-height: 100vh`**: la card empieza arriba, no centrada verticalmente
```typescript
import { wrapLayout } from '@/emails/booking-confirmation';
const html = wrapLayout(`<tr>...contenido...</tr>`);
```
### Estructura de cada email
| Sección | Descripción |
|---------|-------------|
| **Header** | Dos columnas: badge pill a la izquierda + Playzer (favicon + texto) a la derecha. El badge usa `border-radius: 999px`, `padding: 4px 12px`, `font-size: 11px`, `font-weight: 700`, `letter-spacing: 0.14em`, `text-transform: uppercase`. |
| **Card fecha/hora** | Fondo `#f0fdf4`, borde `1px solid rgba(5,150,105,0.3)`, `border-radius: 24px`. Siempre verde aunque el email sea de cancelación. |
| **Grilla detalles** | `border: 1px solid #e5e7eb`, `border-radius: 22px`, dos celdas de 50% con `border-right` en la primera. |
| **Botón CTA** | Tabla con fondo `#059669`, `border-radius: 12px`, link blanco con `padding: 14px 32px`. |
| **Pie** | Sin pie de marca. Solo texto secundario opcional centrado si es necesario. |
| **Sin botones de acción** | Los emails de booking **no** incluyen los botones "Compartir por WhatsApp" ni "Hacer otra reserva". |
### Padding estándar
| Ubicación | Valor |
|-----------|-------|
| Wrapper (outer `<td>`) | `padding: 24px 12px` |
| Primer `<td>` del contenido (header) | `padding: 24px 20px 16px` |
| `<td>` intermedios (cards, texto) | `padding: 0 20px 16px` |
| Último `<td>` del contenido | `padding: 0 20px 24px` |
### Colores de badges según estado
| Estado | Fondo badge | Texto badge |
|--------|-------------|-------------|
| Confirmado | `#f0fdf4` | `#15803d` |
| Cancelado | `#fef2f2` | `#dc2626` |
| No concretado | `#fffbeb` | `#d97706` |
| Neutro (verificación, etc.) | `#f4f4f5` | `#71717a` |
### Archivos de templates
| Archivo | Templates |
|---------|-----------|
| `apps/backend/src/emails/booking-confirmation.ts` | `bookingConfirmationHtml`, `bookingCancelledHtml`, `bookingNoShowHtml` + exporta `wrapLayout` |
| `apps/backend/src/lib/auth.ts` | Email de verificación de Better Auth (usa `wrapLayout` inline) |
### Regla general
Para emails nuevos: importar `wrapLayout`, construir el HTML interno con `<tr>`s, usar la misma estructura de cabecera (badge + Playzer), y nunca incluir el pie "Playzer — Reserva de canchas online". Usar `APP_BASE_URL` para construir URLs absolutas al logo.

View File

@@ -32,19 +32,25 @@ COPY packages/api-contract/package.json packages/api-contract/
RUN bun install RUN bun install
# ============================================================================= # =============================================================================
# Stage 4: Build Backend (prisma generate + tsc) # Stage 4: Prisma Generate
# ============================================================================= # =============================================================================
FROM deps-backend AS build-backend FROM deps-backend AS prisma
WORKDIR /app WORKDIR /app
ARG DATABASE_URL ARG DATABASE_URL
ENV DATABASE_URL=${DATABASE_URL:-postgresql://dummy:dummy@localhost:5432/dummy} ENV DATABASE_URL=${DATABASE_URL}
COPY apps/backend ./apps/backend COPY apps/backend ./apps/backend
COPY packages ./packages
RUN bun --cwd apps/backend prisma:generate RUN bun --cwd apps/backend prisma:generate
# =============================================================================
# Stage 5: Build Backend
# =============================================================================
FROM prisma AS build-backend
WORKDIR /app
COPY packages ./packages
RUN bun --cwd apps/backend build RUN bun --cwd apps/backend build
# ============================================================================= # =============================================================================
# Stage 5: Runner - Production image # Stage 6: Runner - Production image
# ============================================================================= # =============================================================================
FROM oven/bun:1.3.11 AS runner FROM oven/bun:1.3.11 AS runner
WORKDIR /app WORKDIR /app
@@ -54,7 +60,7 @@ COPY --from=build-backend /app/node_modules ./node_modules
COPY --from=build-frontend /app/apps/frontend/dist ./apps/backend/public COPY --from=build-frontend /app/apps/frontend/dist ./apps/backend/public
COPY --from=build-backend /app/apps/backend ./apps/backend COPY --from=build-backend /app/apps/backend ./apps/backend
COPY --from=build-backend /app/packages ./packages COPY --from=build-backend /app/packages ./packages
COPY package.json bun.lock ./ COPY package.json ./
COPY entrypoint.sh /app/entrypoint.sh COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh

View File

@@ -1,3 +1,2 @@
[test] [test]
preload = ["./test/support/prisma.mock.ts"] preload = ["./test/support/prisma.mock.ts"]
coverageThreshold = 0.8

View File

@@ -4,7 +4,7 @@
"dev": "bun run --hot src/server.ts", "dev": "bun run --hot src/server.ts",
"start": "bun src/server.ts", "start": "bun src/server.ts",
"build": "tsc -b", "build": "tsc -b",
"test": "bun test --preload ./test/support/prisma.mock.ts ./test", "test": "bun test",
"lint": "biome check .", "lint": "biome check .",
"lint:fix": "biome check --write .", "lint:fix": "biome check --write .",
"format": "biome format --write .", "format": "biome format --write .",

View File

@@ -4,7 +4,6 @@ model User {
email String email String
emailVerified Boolean @default(false) emailVerified Boolean @default(false)
image String? image String?
phone String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
sessions Session[] sessions Session[]

View File

@@ -12,7 +12,6 @@ enum CourtBookingStatus {
CONFIRMED CONFIRMED
CANCELLED CANCELLED
COMPLETED COMPLETED
NOSHOW
} }
model Sport { model Sport {
@@ -83,9 +82,9 @@ model CourtBooking {
bookingDate DateTime @map("booking_date") @db.Date bookingDate DateTime @map("booking_date") @db.Date
startTime String @map("start_time") @db.VarChar(5) startTime String @map("start_time") @db.VarChar(5)
endTime String @map("end_time") @db.VarChar(5) endTime String @map("end_time") @db.VarChar(5)
price Decimal? @map("price") @db.Decimal(19, 2)
customerName String @map("customer_name") @db.VarChar(120) customerName String @map("customer_name") @db.VarChar(120)
customerPhone String @map("customer_phone") @db.VarChar(30) customerPhone String @map("customer_phone") @db.VarChar(30)
customerEmail String @map("customer_email") @db.VarChar(254)
status CourtBookingStatus @default(CONFIRMED) status CourtBookingStatus @default(CONFIRMED)
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@@ -96,21 +95,3 @@ model CourtBooking {
@@index([bookingDate]) @@index([bookingDate])
@@map("court_bookings") @@map("court_bookings")
} }
model CourtBookingLog {
id String @id @db.Uuid
bookingCode String @map("booking_code") @db.VarChar(8)
courtId String @map("court_id") @db.Uuid
bookingDate DateTime @map("booking_date") @db.Date
startTime String @map("start_time") @db.VarChar(5)
endTime String @map("end_time") @db.VarChar(5)
previousStatus CourtBookingStatus @map("previous_status")
newStatus CourtBookingStatus @map("new_status")
customerName String @map("customer_name") @db.VarChar(120)
customerPhone String @map("customer_phone") @db.VarChar(30)
customerEmail String @map("customer_email") @db.VarChar(254)
changedAt DateTime @default(now()) @map("changed_at")
@@map("court_booking_logs")
@@index([courtId, newStatus])
}

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "court_bookings" ADD COLUMN "price" DECIMAL(19,2);

View File

@@ -1,17 +0,0 @@
-- CreateTable
CREATE TABLE "court_booking_logs" (
"id" UUID NOT NULL,
"booking_code" UUID NOT NULL,
"court_id" UUID NOT NULL,
"booking_date" DATE NOT NULL,
"start_time" VARCHAR(5) NOT NULL,
"end_time" VARCHAR(5) NOT NULL,
"previous_status" "CourtBookingStatus" NOT NULL,
"new_status" "CourtBookingStatus" NOT NULL,
"changed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "court_booking_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "court_booking_logs_court_id_new_status_idx" ON "court_booking_logs"("court_id", "new_status");

View File

@@ -1,13 +0,0 @@
/*
Warnings:
- Added the required column `customer_name` to the `court_booking_logs` table without a default value. This is not possible if the table is not empty.
- Added the required column `customer_phone` to the `court_booking_logs` table without a default value. This is not possible if the table is not empty.
*/
-- AlterEnum
ALTER TYPE "CourtBookingStatus" ADD VALUE 'NOSHOW';
-- AlterTable
ALTER TABLE "court_booking_logs" ADD COLUMN "customer_name" VARCHAR(120) NOT NULL,
ADD COLUMN "customer_phone" VARCHAR(30) NOT NULL;

View File

@@ -1,9 +0,0 @@
/*
Warnings:
- Changed the type of `booking_code` on the `court_booking_logs` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
*/
-- AlterTable
ALTER TABLE "court_booking_logs" DROP COLUMN "booking_code",
ADD COLUMN "booking_code" VARCHAR(8) NOT NULL;

View File

@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "phone" TEXT;

View File

@@ -1,14 +0,0 @@
/*
Warnings:
- You are about to drop the `onboarding_requests` table. If the table is not empty, all the data it contains will be lost.
*/
-- AlterTable
ALTER TABLE "court_booking_logs" ADD COLUMN "customer_email" VARCHAR(254);
-- AlterTable
ALTER TABLE "court_bookings" ADD COLUMN "customer_email" VARCHAR(254);
-- DropTable
DROP TABLE "onboarding_requests";

View File

@@ -1,16 +0,0 @@
/*
Warnings:
- Made the column `customer_email` on table `court_booking_logs` required. This step will fail if there are existing NULL values in that column.
- Made the column `customer_email` on table `court_bookings` required. This step will fail if there are existing NULL values in that column.
*/
-- Backfill existing NULL values with placeholder
UPDATE "court_booking_logs" SET "customer_email" = 'missing@playzer.app' WHERE "customer_email" IS NULL;
UPDATE "court_bookings" SET "customer_email" = 'missing@playzer.app' WHERE "customer_email" IS NULL;
-- AlterTable
ALTER TABLE "court_booking_logs" ALTER COLUMN "customer_email" SET NOT NULL;
-- AlterTable
ALTER TABLE "court_bookings" ALTER COLUMN "customer_email" SET NOT NULL;

View File

@@ -0,0 +1,18 @@
model OnboardingRequest {
id String @id @db.Uuid
fullName String @map("full_name")
email String
otpHash String @map("otp_hash")
otpExpiresAt DateTime @map("otp_expires_at")
otpAttempts Int @default(0) @map("otp_attempts")
otpLastSentAt DateTime @map("otp_last_sent_at")
otpResendCount Int @default(0) @map("otp_resend_count")
emailVerifiedAt DateTime? @map("email_verified_at")
completedAt DateTime? @map("completed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([email])
@@index([otpExpiresAt])
@@map("onboarding_requests")
}

View File

@@ -3,15 +3,10 @@ import type { AppEnv } from '@/types/hono';
import { Hono } from 'hono'; import { Hono } from 'hono';
import { cors } from 'hono/cors'; import { cors } from 'hono/cors';
import { requestId } from 'hono/request-id'; import { requestId } from 'hono/request-id';
import { errorHandler } from './lib/http/error-handler';
import { requestIdMiddleware } from './lib/http/request-id';
export function createApp() { export function createApp() {
const app = new Hono<AppEnv>(); const app = new Hono<AppEnv>();
app.use('*', requestIdMiddleware);
app.use('*', errorHandler);
const allowedOrigins = (Bun.env.CORS_ORIGIN ?? 'http://localhost:5173,http://127.0.0.1:5173') const allowedOrigins = (Bun.env.CORS_ORIGIN ?? 'http://localhost:5173,http://127.0.0.1:5173')
.split(',') .split(',')
.map((value) => value.trim()) .map((value) => value.trim())

View File

@@ -1,401 +0,0 @@
type BookingEmailData = {
bookingCode: string;
complexSlug?: string;
complexName: string;
date: string;
startTime: string;
endTime: string;
courtName: string;
sportName: string;
customerName: string;
price?: number;
};
const APP_BASE_URL = process.env.APP_BASE_URL ?? 'http://localhost:5173';
function formatBookingPrice(price: number): string {
if (price === 0) {
return 'Sin cargo';
}
return new Intl.NumberFormat('es-AR', {
style: 'currency',
currency: 'ARS',
maximumFractionDigits: Number.isInteger(price) ? 0 : 2,
}).format(price);
}
function formatFriendlyDate(isoDate: string): string {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate);
if (!match) return isoDate;
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
const weekday = new Intl.DateTimeFormat('es-AR', { weekday: 'long' }).format(date);
const day = date.getDate();
const month = new Intl.DateTimeFormat('es-AR', { month: 'long' }).format(date);
return `${weekday.charAt(0).toUpperCase() + weekday.slice(1)}, ${day} de ${month}`;
}
const BASE_STYLES = `
body {
margin: 0;
padding: 0;
background-color: #edf7f4;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
* {
box-sizing: border-box;
}
`;
export function wrapLayout(content: string) {
return `<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Playzer</title>
<style>${BASE_STYLES}</style>
</head>
<body style="margin:0;padding:0;background-color:#edf7f4;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#edf7f4;">
<tr>
<td align="center" style="padding:24px 12px;">
<table role="presentation" width="100%" style="max-width:520px;background-color:#ffffff;border-radius:28px;overflow:hidden;box-shadow:0 24px 70px rgba(15,23,42,0.12);border:1px solid rgba(5,9,20,0.1);">
${content}
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
export function bookingConfirmationHtml(data: BookingEmailData): string {
const formattedPrice = formatBookingPrice(data.price ?? 0);
const friendlyDate = formatFriendlyDate(data.date);
const content = `
<tr>
<td style="padding:24px 20px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td valign="top">
<table role="presentation" cellpadding="0" cellspacing="0" style="display:inline-block;background-color:#f0fdf4;border-radius:999px;padding:4px 12px;">
<tr>
<td style="font-size:11px;font-weight:700;letter-spacing:0.14em;color:#15803d;text-transform:uppercase;line-height:1.25rem;">
&#10003; Reserva confirmada
</td>
</tr>
</table>
<h1 style="margin:12px 0 0;font-size:28px;font-weight:700;color:#111827;letter-spacing:-0.025em;">
${data.complexName}
</h1>
</td>
<td valign="top" align="right" style="white-space:nowrap;">
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td valign="middle" style="padding-right:8px;">
<img src="${APP_BASE_URL}/playzer-favicon-512-transparent.png" alt="Playzer" width="32" height="32" style="display:block;" />
</td>
<td valign="middle">
<span style="font-size:18px;font-weight:700;color:#475569;letter-spacing:-0.025em;">Playzer</span>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:0 20px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f0fdf4;border-radius:24px;border:1px solid rgba(5,150,105,0.3);">
<tr>
<td style="padding:20px 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td valign="bottom">
<p style="margin:0;font-size:13px;font-weight:600;color:#15803d;">
Tu turno
</p>
<p style="margin:12px 0 0;font-size:28px;font-weight:900;color:#111827;line-height:1.1;letter-spacing:-0.025em;">
${friendlyDate}
</p>
<p style="margin:8px 0 0;font-size:24px;font-weight:900;color:#059669;line-height:1;letter-spacing:-0.025em;">
${data.startTime} &mdash; ${data.endTime}
</p>
</td>
<td valign="bottom" align="right" style="padding-left:16px;">
<table role="presentation" cellpadding="0" cellspacing="0" style="background-color:rgba(255,255,255,0.8);border-radius:16px;border:1px solid rgba(5,150,105,0.2);">
<tr>
<td style="padding:12px 16px;text-align:right;min-width:120px;">
<p style="margin:0;font-size:11px;font-weight:500;color:#6b7280;">Precio del turno</p>
<p style="margin:4px 0 0;font-size:20px;font-weight:700;color:#111827;">${formattedPrice}</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:0 20px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb;border-radius:22px;overflow:hidden;">
<tr>
<td width="50%" style="padding:16px;border-right:1px solid #e5e7eb;background-color:#ffffff;">
<p style="margin:0;font-size:13px;font-weight:500;color:#6b7280;">
Cancha
</p>
<p style="margin:8px 0 0;font-size:14px;font-weight:600;color:#111827;">
${data.courtName} &mdash; ${data.sportName}
</p>
</td>
<td width="50%" style="padding:16px;background-color:#ffffff;">
<p style="margin:0;font-size:13px;font-weight:500;color:#6b7280;">
C&oacute;digo de reserva
</p>
<p style="margin:8px 0 0;font-family:monospace;font-size:16px;font-weight:700;letter-spacing:0.18em;color:#111827;">
${data.bookingCode}
</p>
</td>
</tr>
</table>
</td>
</tr>
${
data.complexSlug
? `
<tr>
<td style="padding:0 20px 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center">
<table role="presentation" cellpadding="0" cellspacing="0" style="background-color:#059669;border-radius:12px;">
<tr>
<td style="padding:14px 32px;font-size:15px;font-weight:600;">
<a href="${APP_BASE_URL}/${data.complexSlug}/booking/confirmed/${data.bookingCode}" style="color:#ffffff;text-decoration:none;display:inline-block;">
Cancelar reserva
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>`
: ''
}`;
return wrapLayout(content);
}
export function bookingCancelledHtml(data: BookingEmailData): string {
const friendlyDate = formatFriendlyDate(data.date);
const content = `
<tr>
<td style="padding:24px 20px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td valign="top">
<table role="presentation" cellpadding="0" cellspacing="0" style="display:inline-block;background-color:#fef2f2;border-radius:999px;padding:4px 12px;">
<tr>
<td style="font-size:11px;font-weight:700;letter-spacing:0.14em;color:#dc2626;text-transform:uppercase;line-height:1.25rem;">
&#10007; Reserva cancelada
</td>
</tr>
</table>
<h1 style="margin:12px 0 0;font-size:28px;font-weight:700;color:#111827;letter-spacing:-0.025em;">
${data.complexName}
</h1>
</td>
<td valign="top" align="right" style="white-space:nowrap;">
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td valign="middle" style="padding-right:8px;">
<img src="${APP_BASE_URL}/playzer-favicon-512-transparent.png" alt="Playzer" width="32" height="32" style="display:block;" />
</td>
<td valign="middle">
<span style="font-size:18px;font-weight:700;color:#475569;letter-spacing:-0.025em;">Playzer</span>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:0 20px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f0fdf4;border-radius:24px;border:1px solid rgba(5,150,105,0.3);">
<tr>
<td style="padding:20px 24px;">
<p style="margin:0;font-size:13px;font-weight:600;color:#15803d;">
Tu turno
</p>
<p style="margin:12px 0 0;font-size:28px;font-weight:900;color:#111827;line-height:1.1;letter-spacing:-0.025em;">
${friendlyDate}
</p>
<p style="margin:8px 0 0;font-size:24px;font-weight:900;color:#059669;line-height:1;letter-spacing:-0.025em;">
${data.startTime} &mdash; ${data.endTime}
</p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:0 20px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#fef2f2;border-radius:24px;border:1px solid rgba(220,38,38,0.3);">
<tr>
<td style="padding:20px 24px;">
<p style="margin:0;font-size:14px;color:#374151;line-height:1.6;">
La reserva <strong style="font-family:monospace;font-weight:700;letter-spacing:2px;">${data.bookingCode}</strong>
fue cancelada. Si ten&eacute;s dudas, contactate con el complejo.
</p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:0 20px 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb;border-radius:22px;overflow:hidden;">
<tr>
<td width="50%" style="padding:16px;border-right:1px solid #e5e7eb;background-color:#ffffff;">
<p style="margin:0;font-size:13px;font-weight:500;color:#6b7280;">
Cancha
</p>
<p style="margin:8px 0 0;font-size:14px;font-weight:600;color:#111827;">
${data.courtName} &mdash; ${data.sportName}
</p>
</td>
<td width="50%" style="padding:16px;background-color:#ffffff;">
<p style="margin:0;font-size:13px;font-weight:500;color:#6b7280;">
Cliente
</p>
<p style="margin:8px 0 0;font-size:14px;font-weight:600;color:#111827;">
${data.customerName}
</p>
</td>
</tr>
</table>
</td>
</tr>`;
return wrapLayout(content);
}
export function bookingNoShowHtml(data: BookingEmailData): string {
const friendlyDate = formatFriendlyDate(data.date);
const content = `
<tr>
<td style="padding:24px 20px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td valign="top">
<table role="presentation" cellpadding="0" cellspacing="0" style="display:inline-block;background-color:#fffbeb;border-radius:999px;padding:4px 12px;">
<tr>
<td style="font-size:11px;font-weight:700;letter-spacing:0.14em;color:#d97706;text-transform:uppercase;line-height:1.25rem;">
Reserva no concretada
</td>
</tr>
</table>
<h1 style="margin:12px 0 0;font-size:28px;font-weight:700;color:#111827;letter-spacing:-0.025em;">
${data.complexName}
</h1>
</td>
<td valign="top" align="right" style="white-space:nowrap;">
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td valign="middle" style="padding-right:8px;">
<img src="${APP_BASE_URL}/playzer-favicon-512-transparent.png" alt="Playzer" width="32" height="32" style="display:block;" />
</td>
<td valign="middle">
<span style="font-size:18px;font-weight:700;color:#475569;letter-spacing:-0.025em;">Playzer</span>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:0 20px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f0fdf4;border-radius:24px;border:1px solid rgba(5,150,105,0.3);">
<tr>
<td style="padding:20px 24px;">
<p style="margin:0;font-size:13px;font-weight:600;color:#15803d;">
Tu turno
</p>
<p style="margin:12px 0 0;font-size:28px;font-weight:900;color:#111827;line-height:1.1;letter-spacing:-0.025em;">
${friendlyDate}
</p>
<p style="margin:8px 0 0;font-size:24px;font-weight:900;color:#059669;line-height:1;letter-spacing:-0.025em;">
${data.startTime} &mdash; ${data.endTime}
</p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:0 20px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#fffbeb;border-radius:24px;border:1px solid rgba(217,119,6,0.3);">
<tr>
<td style="padding:20px 24px;">
<p style="margin:0;font-size:14px;color:#374151;line-height:1.6;">
La reserva <strong style="font-family:monospace;font-weight:700;letter-spacing:2px;">${data.bookingCode}</strong>
fue registrada como no concretada por falta de asistencia.
</p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:0 20px 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb;border-radius:22px;overflow:hidden;">
<tr>
<td width="50%" style="padding:16px;border-right:1px solid #e5e7eb;background-color:#ffffff;">
<p style="margin:0;font-size:13px;font-weight:500;color:#6b7280;">
Cancha
</p>
<p style="margin:8px 0 0;font-size:14px;font-weight:600;color:#111827;">
${data.courtName} &mdash; ${data.sportName}
</p>
</td>
<td width="50%" style="padding:16px;background-color:#ffffff;">
<p style="margin:0;font-size:13px;font-weight:500;color:#6b7280;">
Cliente
</p>
<p style="margin:8px 0 0;font-size:14px;font-weight:600;color:#111827;">
${data.customerName}
</p>
</td>
</tr>
</table>
</td>
</tr>`;
return wrapLayout(content);
}

View File

@@ -78,10 +78,10 @@ export type CourtPriceRule = Prisma.CourtPriceRuleModel
*/ */
export type CourtBooking = Prisma.CourtBookingModel export type CourtBooking = Prisma.CourtBookingModel
/** /**
* Model CourtBookingLog * Model OnboardingRequest
* *
*/ */
export type CourtBookingLog = Prisma.CourtBookingLogModel export type OnboardingRequest = Prisma.OnboardingRequestModel
/** /**
* Model PasswordResetRequest * Model PasswordResetRequest
* *

View File

@@ -102,10 +102,10 @@ export type CourtPriceRule = Prisma.CourtPriceRuleModel
*/ */
export type CourtBooking = Prisma.CourtBookingModel export type CourtBooking = Prisma.CourtBookingModel
/** /**
* Model CourtBookingLog * Model OnboardingRequest
* *
*/ */
export type CourtBookingLog = Prisma.CourtBookingLogModel export type OnboardingRequest = Prisma.OnboardingRequestModel
/** /**
* Model PasswordResetRequest * Model PasswordResetRequest
* *

View File

@@ -280,6 +280,17 @@ export type EnumDayOfWeekNullableWithAggregatesFilter<$PrismaModel = never> = {
_max?: Prisma.NestedEnumDayOfWeekNullableFilter<$PrismaModel> _max?: Prisma.NestedEnumDayOfWeekNullableFilter<$PrismaModel>
} }
export type DecimalNullableFilter<$PrismaModel = never> = {
equals?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel> | null
in?: runtime.Decimal[] | runtime.DecimalJsLike[] | number[] | string[] | Prisma.ListDecimalFieldRefInput<$PrismaModel> | null
notIn?: runtime.Decimal[] | runtime.DecimalJsLike[] | number[] | string[] | Prisma.ListDecimalFieldRefInput<$PrismaModel> | null
lt?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
lte?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
gt?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
gte?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
not?: Prisma.NestedDecimalNullableFilter<$PrismaModel> | runtime.Decimal | runtime.DecimalJsLike | number | string | null
}
export type EnumCourtBookingStatusFilter<$PrismaModel = never> = { export type EnumCourtBookingStatusFilter<$PrismaModel = never> = {
equals?: $Enums.CourtBookingStatus | Prisma.EnumCourtBookingStatusFieldRefInput<$PrismaModel> equals?: $Enums.CourtBookingStatus | Prisma.EnumCourtBookingStatusFieldRefInput<$PrismaModel>
in?: $Enums.CourtBookingStatus[] | Prisma.ListEnumCourtBookingStatusFieldRefInput<$PrismaModel> in?: $Enums.CourtBookingStatus[] | Prisma.ListEnumCourtBookingStatusFieldRefInput<$PrismaModel>
@@ -287,6 +298,22 @@ export type EnumCourtBookingStatusFilter<$PrismaModel = never> = {
not?: Prisma.NestedEnumCourtBookingStatusFilter<$PrismaModel> | $Enums.CourtBookingStatus not?: Prisma.NestedEnumCourtBookingStatusFilter<$PrismaModel> | $Enums.CourtBookingStatus
} }
export type DecimalNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel> | null
in?: runtime.Decimal[] | runtime.DecimalJsLike[] | number[] | string[] | Prisma.ListDecimalFieldRefInput<$PrismaModel> | null
notIn?: runtime.Decimal[] | runtime.DecimalJsLike[] | number[] | string[] | Prisma.ListDecimalFieldRefInput<$PrismaModel> | null
lt?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
lte?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
gt?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
gte?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
not?: Prisma.NestedDecimalNullableWithAggregatesFilter<$PrismaModel> | runtime.Decimal | runtime.DecimalJsLike | number | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_avg?: Prisma.NestedDecimalNullableFilter<$PrismaModel>
_sum?: Prisma.NestedDecimalNullableFilter<$PrismaModel>
_min?: Prisma.NestedDecimalNullableFilter<$PrismaModel>
_max?: Prisma.NestedDecimalNullableFilter<$PrismaModel>
}
export type EnumCourtBookingStatusWithAggregatesFilter<$PrismaModel = never> = { export type EnumCourtBookingStatusWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.CourtBookingStatus | Prisma.EnumCourtBookingStatusFieldRefInput<$PrismaModel> equals?: $Enums.CourtBookingStatus | Prisma.EnumCourtBookingStatusFieldRefInput<$PrismaModel>
in?: $Enums.CourtBookingStatus[] | Prisma.ListEnumCourtBookingStatusFieldRefInput<$PrismaModel> in?: $Enums.CourtBookingStatus[] | Prisma.ListEnumCourtBookingStatusFieldRefInput<$PrismaModel>
@@ -625,6 +652,17 @@ export type NestedEnumDayOfWeekNullableWithAggregatesFilter<$PrismaModel = never
_max?: Prisma.NestedEnumDayOfWeekNullableFilter<$PrismaModel> _max?: Prisma.NestedEnumDayOfWeekNullableFilter<$PrismaModel>
} }
export type NestedDecimalNullableFilter<$PrismaModel = never> = {
equals?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel> | null
in?: runtime.Decimal[] | runtime.DecimalJsLike[] | number[] | string[] | Prisma.ListDecimalFieldRefInput<$PrismaModel> | null
notIn?: runtime.Decimal[] | runtime.DecimalJsLike[] | number[] | string[] | Prisma.ListDecimalFieldRefInput<$PrismaModel> | null
lt?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
lte?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
gt?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
gte?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
not?: Prisma.NestedDecimalNullableFilter<$PrismaModel> | runtime.Decimal | runtime.DecimalJsLike | number | string | null
}
export type NestedEnumCourtBookingStatusFilter<$PrismaModel = never> = { export type NestedEnumCourtBookingStatusFilter<$PrismaModel = never> = {
equals?: $Enums.CourtBookingStatus | Prisma.EnumCourtBookingStatusFieldRefInput<$PrismaModel> equals?: $Enums.CourtBookingStatus | Prisma.EnumCourtBookingStatusFieldRefInput<$PrismaModel>
in?: $Enums.CourtBookingStatus[] | Prisma.ListEnumCourtBookingStatusFieldRefInput<$PrismaModel> in?: $Enums.CourtBookingStatus[] | Prisma.ListEnumCourtBookingStatusFieldRefInput<$PrismaModel>
@@ -632,6 +670,22 @@ export type NestedEnumCourtBookingStatusFilter<$PrismaModel = never> = {
not?: Prisma.NestedEnumCourtBookingStatusFilter<$PrismaModel> | $Enums.CourtBookingStatus not?: Prisma.NestedEnumCourtBookingStatusFilter<$PrismaModel> | $Enums.CourtBookingStatus
} }
export type NestedDecimalNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel> | null
in?: runtime.Decimal[] | runtime.DecimalJsLike[] | number[] | string[] | Prisma.ListDecimalFieldRefInput<$PrismaModel> | null
notIn?: runtime.Decimal[] | runtime.DecimalJsLike[] | number[] | string[] | Prisma.ListDecimalFieldRefInput<$PrismaModel> | null
lt?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
lte?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
gt?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
gte?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
not?: Prisma.NestedDecimalNullableWithAggregatesFilter<$PrismaModel> | runtime.Decimal | runtime.DecimalJsLike | number | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_avg?: Prisma.NestedDecimalNullableFilter<$PrismaModel>
_sum?: Prisma.NestedDecimalNullableFilter<$PrismaModel>
_min?: Prisma.NestedDecimalNullableFilter<$PrismaModel>
_max?: Prisma.NestedDecimalNullableFilter<$PrismaModel>
}
export type NestedEnumCourtBookingStatusWithAggregatesFilter<$PrismaModel = never> = { export type NestedEnumCourtBookingStatusWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.CourtBookingStatus | Prisma.EnumCourtBookingStatusFieldRefInput<$PrismaModel> equals?: $Enums.CourtBookingStatus | Prisma.EnumCourtBookingStatusFieldRefInput<$PrismaModel>
in?: $Enums.CourtBookingStatus[] | Prisma.ListEnumCourtBookingStatusFieldRefInput<$PrismaModel> in?: $Enums.CourtBookingStatus[] | Prisma.ListEnumCourtBookingStatusFieldRefInput<$PrismaModel>

View File

@@ -33,8 +33,7 @@ export type DayOfWeek = (typeof DayOfWeek)[keyof typeof DayOfWeek]
export const CourtBookingStatus = { export const CourtBookingStatus = {
CONFIRMED: 'CONFIRMED', CONFIRMED: 'CONFIRMED',
CANCELLED: 'CANCELLED', CANCELLED: 'CANCELLED',
COMPLETED: 'COMPLETED', COMPLETED: 'COMPLETED'
NOSHOW: 'NOSHOW'
} as const } as const
export type CourtBookingStatus = (typeof CourtBookingStatus)[keyof typeof CourtBookingStatus] export type CourtBookingStatus = (typeof CourtBookingStatus)[keyof typeof CourtBookingStatus]

File diff suppressed because one or more lines are too long

View File

@@ -396,7 +396,7 @@ export const ModelName = {
CourtAvailability: 'CourtAvailability', CourtAvailability: 'CourtAvailability',
CourtPriceRule: 'CourtPriceRule', CourtPriceRule: 'CourtPriceRule',
CourtBooking: 'CourtBooking', CourtBooking: 'CourtBooking',
CourtBookingLog: 'CourtBookingLog', OnboardingRequest: 'OnboardingRequest',
PasswordResetRequest: 'PasswordResetRequest', PasswordResetRequest: 'PasswordResetRequest',
Plan: 'Plan' Plan: 'Plan'
} as const } as const
@@ -414,7 +414,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions omit: GlobalOmitOptions
} }
meta: { meta: {
modelProps: "user" | "session" | "account" | "verification" | "complex" | "complexUser" | "complexInvitation" | "sport" | "court" | "courtAvailability" | "courtPriceRule" | "courtBooking" | "courtBookingLog" | "passwordResetRequest" | "plan" modelProps: "user" | "session" | "account" | "verification" | "complex" | "complexUser" | "complexInvitation" | "sport" | "court" | "courtAvailability" | "courtPriceRule" | "courtBooking" | "onboardingRequest" | "passwordResetRequest" | "plan"
txIsolationLevel: TransactionIsolationLevel txIsolationLevel: TransactionIsolationLevel
} }
model: { model: {
@@ -1306,77 +1306,77 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
} }
} }
} }
CourtBookingLog: { OnboardingRequest: {
payload: Prisma.$CourtBookingLogPayload<ExtArgs> payload: Prisma.$OnboardingRequestPayload<ExtArgs>
fields: Prisma.CourtBookingLogFieldRefs fields: Prisma.OnboardingRequestFieldRefs
operations: { operations: {
findUnique: { findUnique: {
args: Prisma.CourtBookingLogFindUniqueArgs<ExtArgs> args: Prisma.OnboardingRequestFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload> | null result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload> | null
} }
findUniqueOrThrow: { findUniqueOrThrow: {
args: Prisma.CourtBookingLogFindUniqueOrThrowArgs<ExtArgs> args: Prisma.OnboardingRequestFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload> result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload>
} }
findFirst: { findFirst: {
args: Prisma.CourtBookingLogFindFirstArgs<ExtArgs> args: Prisma.OnboardingRequestFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload> | null result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload> | null
} }
findFirstOrThrow: { findFirstOrThrow: {
args: Prisma.CourtBookingLogFindFirstOrThrowArgs<ExtArgs> args: Prisma.OnboardingRequestFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload> result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload>
} }
findMany: { findMany: {
args: Prisma.CourtBookingLogFindManyArgs<ExtArgs> args: Prisma.OnboardingRequestFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>[] result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload>[]
} }
create: { create: {
args: Prisma.CourtBookingLogCreateArgs<ExtArgs> args: Prisma.OnboardingRequestCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload> result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload>
} }
createMany: { createMany: {
args: Prisma.CourtBookingLogCreateManyArgs<ExtArgs> args: Prisma.OnboardingRequestCreateManyArgs<ExtArgs>
result: BatchPayload result: BatchPayload
} }
createManyAndReturn: { createManyAndReturn: {
args: Prisma.CourtBookingLogCreateManyAndReturnArgs<ExtArgs> args: Prisma.OnboardingRequestCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>[] result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload>[]
} }
delete: { delete: {
args: Prisma.CourtBookingLogDeleteArgs<ExtArgs> args: Prisma.OnboardingRequestDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload> result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload>
} }
update: { update: {
args: Prisma.CourtBookingLogUpdateArgs<ExtArgs> args: Prisma.OnboardingRequestUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload> result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload>
} }
deleteMany: { deleteMany: {
args: Prisma.CourtBookingLogDeleteManyArgs<ExtArgs> args: Prisma.OnboardingRequestDeleteManyArgs<ExtArgs>
result: BatchPayload result: BatchPayload
} }
updateMany: { updateMany: {
args: Prisma.CourtBookingLogUpdateManyArgs<ExtArgs> args: Prisma.OnboardingRequestUpdateManyArgs<ExtArgs>
result: BatchPayload result: BatchPayload
} }
updateManyAndReturn: { updateManyAndReturn: {
args: Prisma.CourtBookingLogUpdateManyAndReturnArgs<ExtArgs> args: Prisma.OnboardingRequestUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>[] result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload>[]
} }
upsert: { upsert: {
args: Prisma.CourtBookingLogUpsertArgs<ExtArgs> args: Prisma.OnboardingRequestUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload> result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload>
} }
aggregate: { aggregate: {
args: Prisma.CourtBookingLogAggregateArgs<ExtArgs> args: Prisma.OnboardingRequestAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateCourtBookingLog> result: runtime.Types.Utils.Optional<Prisma.AggregateOnboardingRequest>
} }
groupBy: { groupBy: {
args: Prisma.CourtBookingLogGroupByArgs<ExtArgs> args: Prisma.OnboardingRequestGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.CourtBookingLogGroupByOutputType>[] result: runtime.Types.Utils.Optional<Prisma.OnboardingRequestGroupByOutputType>[]
} }
count: { count: {
args: Prisma.CourtBookingLogCountArgs<ExtArgs> args: Prisma.OnboardingRequestCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.CourtBookingLogCountAggregateOutputType> | number result: runtime.Types.Utils.Optional<Prisma.OnboardingRequestCountAggregateOutputType> | number
} }
} }
} }
@@ -1573,7 +1573,6 @@ export const UserScalarFieldEnum = {
email: 'email', email: 'email',
emailVerified: 'emailVerified', emailVerified: 'emailVerified',
image: 'image', image: 'image',
phone: 'phone',
createdAt: 'createdAt', createdAt: 'createdAt',
updatedAt: 'updatedAt', updatedAt: 'updatedAt',
role: 'role' role: 'role'
@@ -1729,9 +1728,9 @@ export const CourtBookingScalarFieldEnum = {
bookingDate: 'bookingDate', bookingDate: 'bookingDate',
startTime: 'startTime', startTime: 'startTime',
endTime: 'endTime', endTime: 'endTime',
price: 'price',
customerName: 'customerName', customerName: 'customerName',
customerPhone: 'customerPhone', customerPhone: 'customerPhone',
customerEmail: 'customerEmail',
status: 'status', status: 'status',
createdAt: 'createdAt', createdAt: 'createdAt',
updatedAt: 'updatedAt' updatedAt: 'updatedAt'
@@ -1740,22 +1739,22 @@ export const CourtBookingScalarFieldEnum = {
export type CourtBookingScalarFieldEnum = (typeof CourtBookingScalarFieldEnum)[keyof typeof CourtBookingScalarFieldEnum] export type CourtBookingScalarFieldEnum = (typeof CourtBookingScalarFieldEnum)[keyof typeof CourtBookingScalarFieldEnum]
export const CourtBookingLogScalarFieldEnum = { export const OnboardingRequestScalarFieldEnum = {
id: 'id', id: 'id',
bookingCode: 'bookingCode', fullName: 'fullName',
courtId: 'courtId', email: 'email',
bookingDate: 'bookingDate', otpHash: 'otpHash',
startTime: 'startTime', otpExpiresAt: 'otpExpiresAt',
endTime: 'endTime', otpAttempts: 'otpAttempts',
previousStatus: 'previousStatus', otpLastSentAt: 'otpLastSentAt',
newStatus: 'newStatus', otpResendCount: 'otpResendCount',
customerName: 'customerName', emailVerifiedAt: 'emailVerifiedAt',
customerPhone: 'customerPhone', completedAt: 'completedAt',
customerEmail: 'customerEmail', createdAt: 'createdAt',
changedAt: 'changedAt' updatedAt: 'updatedAt'
} as const } as const
export type CourtBookingLogScalarFieldEnum = (typeof CourtBookingLogScalarFieldEnum)[keyof typeof CourtBookingLogScalarFieldEnum] export type OnboardingRequestScalarFieldEnum = (typeof OnboardingRequestScalarFieldEnum)[keyof typeof OnboardingRequestScalarFieldEnum]
export const PasswordResetRequestScalarFieldEnum = { export const PasswordResetRequestScalarFieldEnum = {
@@ -2070,7 +2069,7 @@ export type GlobalOmitConfig = {
courtAvailability?: Prisma.CourtAvailabilityOmit courtAvailability?: Prisma.CourtAvailabilityOmit
courtPriceRule?: Prisma.CourtPriceRuleOmit courtPriceRule?: Prisma.CourtPriceRuleOmit
courtBooking?: Prisma.CourtBookingOmit courtBooking?: Prisma.CourtBookingOmit
courtBookingLog?: Prisma.CourtBookingLogOmit onboardingRequest?: Prisma.OnboardingRequestOmit
passwordResetRequest?: Prisma.PasswordResetRequestOmit passwordResetRequest?: Prisma.PasswordResetRequestOmit
plan?: Prisma.PlanOmit plan?: Prisma.PlanOmit
} }

View File

@@ -63,7 +63,7 @@ export const ModelName = {
CourtAvailability: 'CourtAvailability', CourtAvailability: 'CourtAvailability',
CourtPriceRule: 'CourtPriceRule', CourtPriceRule: 'CourtPriceRule',
CourtBooking: 'CourtBooking', CourtBooking: 'CourtBooking',
CourtBookingLog: 'CourtBookingLog', OnboardingRequest: 'OnboardingRequest',
PasswordResetRequest: 'PasswordResetRequest', PasswordResetRequest: 'PasswordResetRequest',
Plan: 'Plan' Plan: 'Plan'
} as const } as const
@@ -90,7 +90,6 @@ export const UserScalarFieldEnum = {
email: 'email', email: 'email',
emailVerified: 'emailVerified', emailVerified: 'emailVerified',
image: 'image', image: 'image',
phone: 'phone',
createdAt: 'createdAt', createdAt: 'createdAt',
updatedAt: 'updatedAt', updatedAt: 'updatedAt',
role: 'role' role: 'role'
@@ -246,9 +245,9 @@ export const CourtBookingScalarFieldEnum = {
bookingDate: 'bookingDate', bookingDate: 'bookingDate',
startTime: 'startTime', startTime: 'startTime',
endTime: 'endTime', endTime: 'endTime',
price: 'price',
customerName: 'customerName', customerName: 'customerName',
customerPhone: 'customerPhone', customerPhone: 'customerPhone',
customerEmail: 'customerEmail',
status: 'status', status: 'status',
createdAt: 'createdAt', createdAt: 'createdAt',
updatedAt: 'updatedAt' updatedAt: 'updatedAt'
@@ -257,22 +256,22 @@ export const CourtBookingScalarFieldEnum = {
export type CourtBookingScalarFieldEnum = (typeof CourtBookingScalarFieldEnum)[keyof typeof CourtBookingScalarFieldEnum] export type CourtBookingScalarFieldEnum = (typeof CourtBookingScalarFieldEnum)[keyof typeof CourtBookingScalarFieldEnum]
export const CourtBookingLogScalarFieldEnum = { export const OnboardingRequestScalarFieldEnum = {
id: 'id', id: 'id',
bookingCode: 'bookingCode', fullName: 'fullName',
courtId: 'courtId', email: 'email',
bookingDate: 'bookingDate', otpHash: 'otpHash',
startTime: 'startTime', otpExpiresAt: 'otpExpiresAt',
endTime: 'endTime', otpAttempts: 'otpAttempts',
previousStatus: 'previousStatus', otpLastSentAt: 'otpLastSentAt',
newStatus: 'newStatus', otpResendCount: 'otpResendCount',
customerName: 'customerName', emailVerifiedAt: 'emailVerifiedAt',
customerPhone: 'customerPhone', completedAt: 'completedAt',
customerEmail: 'customerEmail', createdAt: 'createdAt',
changedAt: 'changedAt' updatedAt: 'updatedAt'
} as const } as const
export type CourtBookingLogScalarFieldEnum = (typeof CourtBookingLogScalarFieldEnum)[keyof typeof CourtBookingLogScalarFieldEnum] export type OnboardingRequestScalarFieldEnum = (typeof OnboardingRequestScalarFieldEnum)[keyof typeof OnboardingRequestScalarFieldEnum]
export const PasswordResetRequestScalarFieldEnum = { export const PasswordResetRequestScalarFieldEnum = {

View File

@@ -20,7 +20,7 @@ export type * from './models/Court'
export type * from './models/CourtAvailability' export type * from './models/CourtAvailability'
export type * from './models/CourtPriceRule' export type * from './models/CourtPriceRule'
export type * from './models/CourtBooking' export type * from './models/CourtBooking'
export type * from './models/CourtBookingLog' export type * from './models/OnboardingRequest'
export type * from './models/PasswordResetRequest' export type * from './models/PasswordResetRequest'
export type * from './models/Plan' export type * from './models/Plan'
export type * from './commonInputTypes' export type * from './commonInputTypes'

View File

@@ -20,10 +20,20 @@ export type CourtBookingModel = runtime.Types.Result.DefaultSelection<Prisma.$Co
export type AggregateCourtBooking = { export type AggregateCourtBooking = {
_count: CourtBookingCountAggregateOutputType | null _count: CourtBookingCountAggregateOutputType | null
_avg: CourtBookingAvgAggregateOutputType | null
_sum: CourtBookingSumAggregateOutputType | null
_min: CourtBookingMinAggregateOutputType | null _min: CourtBookingMinAggregateOutputType | null
_max: CourtBookingMaxAggregateOutputType | null _max: CourtBookingMaxAggregateOutputType | null
} }
export type CourtBookingAvgAggregateOutputType = {
price: runtime.Decimal | null
}
export type CourtBookingSumAggregateOutputType = {
price: runtime.Decimal | null
}
export type CourtBookingMinAggregateOutputType = { export type CourtBookingMinAggregateOutputType = {
id: string | null id: string | null
bookingCode: string | null bookingCode: string | null
@@ -31,9 +41,9 @@ export type CourtBookingMinAggregateOutputType = {
bookingDate: Date | null bookingDate: Date | null
startTime: string | null startTime: string | null
endTime: string | null endTime: string | null
price: runtime.Decimal | null
customerName: string | null customerName: string | null
customerPhone: string | null customerPhone: string | null
customerEmail: string | null
status: $Enums.CourtBookingStatus | null status: $Enums.CourtBookingStatus | null
createdAt: Date | null createdAt: Date | null
updatedAt: Date | null updatedAt: Date | null
@@ -46,9 +56,9 @@ export type CourtBookingMaxAggregateOutputType = {
bookingDate: Date | null bookingDate: Date | null
startTime: string | null startTime: string | null
endTime: string | null endTime: string | null
price: runtime.Decimal | null
customerName: string | null customerName: string | null
customerPhone: string | null customerPhone: string | null
customerEmail: string | null
status: $Enums.CourtBookingStatus | null status: $Enums.CourtBookingStatus | null
createdAt: Date | null createdAt: Date | null
updatedAt: Date | null updatedAt: Date | null
@@ -61,9 +71,9 @@ export type CourtBookingCountAggregateOutputType = {
bookingDate: number bookingDate: number
startTime: number startTime: number
endTime: number endTime: number
price: number
customerName: number customerName: number
customerPhone: number customerPhone: number
customerEmail: number
status: number status: number
createdAt: number createdAt: number
updatedAt: number updatedAt: number
@@ -71,6 +81,14 @@ export type CourtBookingCountAggregateOutputType = {
} }
export type CourtBookingAvgAggregateInputType = {
price?: true
}
export type CourtBookingSumAggregateInputType = {
price?: true
}
export type CourtBookingMinAggregateInputType = { export type CourtBookingMinAggregateInputType = {
id?: true id?: true
bookingCode?: true bookingCode?: true
@@ -78,9 +96,9 @@ export type CourtBookingMinAggregateInputType = {
bookingDate?: true bookingDate?: true
startTime?: true startTime?: true
endTime?: true endTime?: true
price?: true
customerName?: true customerName?: true
customerPhone?: true customerPhone?: true
customerEmail?: true
status?: true status?: true
createdAt?: true createdAt?: true
updatedAt?: true updatedAt?: true
@@ -93,9 +111,9 @@ export type CourtBookingMaxAggregateInputType = {
bookingDate?: true bookingDate?: true
startTime?: true startTime?: true
endTime?: true endTime?: true
price?: true
customerName?: true customerName?: true
customerPhone?: true customerPhone?: true
customerEmail?: true
status?: true status?: true
createdAt?: true createdAt?: true
updatedAt?: true updatedAt?: true
@@ -108,9 +126,9 @@ export type CourtBookingCountAggregateInputType = {
bookingDate?: true bookingDate?: true
startTime?: true startTime?: true
endTime?: true endTime?: true
price?: true
customerName?: true customerName?: true
customerPhone?: true customerPhone?: true
customerEmail?: true
status?: true status?: true
createdAt?: true createdAt?: true
updatedAt?: true updatedAt?: true
@@ -152,6 +170,18 @@ export type CourtBookingAggregateArgs<ExtArgs extends runtime.Types.Extensions.I
* Count returned CourtBookings * Count returned CourtBookings
**/ **/
_count?: true | CourtBookingCountAggregateInputType _count?: true | CourtBookingCountAggregateInputType
/**
* {@link https://www.prisma.io/docs/concepts/components/prisma-client/aggregations Aggregation Docs}
*
* Select which fields to average
**/
_avg?: CourtBookingAvgAggregateInputType
/**
* {@link https://www.prisma.io/docs/concepts/components/prisma-client/aggregations Aggregation Docs}
*
* Select which fields to sum
**/
_sum?: CourtBookingSumAggregateInputType
/** /**
* {@link https://www.prisma.io/docs/concepts/components/prisma-client/aggregations Aggregation Docs} * {@link https://www.prisma.io/docs/concepts/components/prisma-client/aggregations Aggregation Docs}
* *
@@ -185,6 +215,8 @@ export type CourtBookingGroupByArgs<ExtArgs extends runtime.Types.Extensions.Int
take?: number take?: number
skip?: number skip?: number
_count?: CourtBookingCountAggregateInputType | true _count?: CourtBookingCountAggregateInputType | true
_avg?: CourtBookingAvgAggregateInputType
_sum?: CourtBookingSumAggregateInputType
_min?: CourtBookingMinAggregateInputType _min?: CourtBookingMinAggregateInputType
_max?: CourtBookingMaxAggregateInputType _max?: CourtBookingMaxAggregateInputType
} }
@@ -196,13 +228,15 @@ export type CourtBookingGroupByOutputType = {
bookingDate: Date bookingDate: Date
startTime: string startTime: string
endTime: string endTime: string
price: runtime.Decimal | null
customerName: string customerName: string
customerPhone: string customerPhone: string
customerEmail: string
status: $Enums.CourtBookingStatus status: $Enums.CourtBookingStatus
createdAt: Date createdAt: Date
updatedAt: Date updatedAt: Date
_count: CourtBookingCountAggregateOutputType | null _count: CourtBookingCountAggregateOutputType | null
_avg: CourtBookingAvgAggregateOutputType | null
_sum: CourtBookingSumAggregateOutputType | null
_min: CourtBookingMinAggregateOutputType | null _min: CourtBookingMinAggregateOutputType | null
_max: CourtBookingMaxAggregateOutputType | null _max: CourtBookingMaxAggregateOutputType | null
} }
@@ -232,9 +266,9 @@ export type CourtBookingWhereInput = {
bookingDate?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string bookingDate?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
startTime?: Prisma.StringFilter<"CourtBooking"> | string startTime?: Prisma.StringFilter<"CourtBooking"> | string
endTime?: Prisma.StringFilter<"CourtBooking"> | string endTime?: Prisma.StringFilter<"CourtBooking"> | string
price?: Prisma.DecimalNullableFilter<"CourtBooking"> | runtime.Decimal | runtime.DecimalJsLike | number | string | null
customerName?: Prisma.StringFilter<"CourtBooking"> | string customerName?: Prisma.StringFilter<"CourtBooking"> | string
customerPhone?: Prisma.StringFilter<"CourtBooking"> | string customerPhone?: Prisma.StringFilter<"CourtBooking"> | string
customerEmail?: Prisma.StringFilter<"CourtBooking"> | string
status?: Prisma.EnumCourtBookingStatusFilter<"CourtBooking"> | $Enums.CourtBookingStatus status?: Prisma.EnumCourtBookingStatusFilter<"CourtBooking"> | $Enums.CourtBookingStatus
createdAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string createdAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string updatedAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
@@ -248,9 +282,9 @@ export type CourtBookingOrderByWithRelationInput = {
bookingDate?: Prisma.SortOrder bookingDate?: Prisma.SortOrder
startTime?: Prisma.SortOrder startTime?: Prisma.SortOrder
endTime?: Prisma.SortOrder endTime?: Prisma.SortOrder
price?: Prisma.SortOrderInput | Prisma.SortOrder
customerName?: Prisma.SortOrder customerName?: Prisma.SortOrder
customerPhone?: Prisma.SortOrder customerPhone?: Prisma.SortOrder
customerEmail?: Prisma.SortOrder
status?: Prisma.SortOrder status?: Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
@@ -268,9 +302,9 @@ export type CourtBookingWhereUniqueInput = Prisma.AtLeast<{
bookingDate?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string bookingDate?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
startTime?: Prisma.StringFilter<"CourtBooking"> | string startTime?: Prisma.StringFilter<"CourtBooking"> | string
endTime?: Prisma.StringFilter<"CourtBooking"> | string endTime?: Prisma.StringFilter<"CourtBooking"> | string
price?: Prisma.DecimalNullableFilter<"CourtBooking"> | runtime.Decimal | runtime.DecimalJsLike | number | string | null
customerName?: Prisma.StringFilter<"CourtBooking"> | string customerName?: Prisma.StringFilter<"CourtBooking"> | string
customerPhone?: Prisma.StringFilter<"CourtBooking"> | string customerPhone?: Prisma.StringFilter<"CourtBooking"> | string
customerEmail?: Prisma.StringFilter<"CourtBooking"> | string
status?: Prisma.EnumCourtBookingStatusFilter<"CourtBooking"> | $Enums.CourtBookingStatus status?: Prisma.EnumCourtBookingStatusFilter<"CourtBooking"> | $Enums.CourtBookingStatus
createdAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string createdAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string updatedAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
@@ -284,15 +318,17 @@ export type CourtBookingOrderByWithAggregationInput = {
bookingDate?: Prisma.SortOrder bookingDate?: Prisma.SortOrder
startTime?: Prisma.SortOrder startTime?: Prisma.SortOrder
endTime?: Prisma.SortOrder endTime?: Prisma.SortOrder
price?: Prisma.SortOrderInput | Prisma.SortOrder
customerName?: Prisma.SortOrder customerName?: Prisma.SortOrder
customerPhone?: Prisma.SortOrder customerPhone?: Prisma.SortOrder
customerEmail?: Prisma.SortOrder
status?: Prisma.SortOrder status?: Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
_count?: Prisma.CourtBookingCountOrderByAggregateInput _count?: Prisma.CourtBookingCountOrderByAggregateInput
_avg?: Prisma.CourtBookingAvgOrderByAggregateInput
_max?: Prisma.CourtBookingMaxOrderByAggregateInput _max?: Prisma.CourtBookingMaxOrderByAggregateInput
_min?: Prisma.CourtBookingMinOrderByAggregateInput _min?: Prisma.CourtBookingMinOrderByAggregateInput
_sum?: Prisma.CourtBookingSumOrderByAggregateInput
} }
export type CourtBookingScalarWhereWithAggregatesInput = { export type CourtBookingScalarWhereWithAggregatesInput = {
@@ -305,9 +341,9 @@ export type CourtBookingScalarWhereWithAggregatesInput = {
bookingDate?: Prisma.DateTimeWithAggregatesFilter<"CourtBooking"> | Date | string bookingDate?: Prisma.DateTimeWithAggregatesFilter<"CourtBooking"> | Date | string
startTime?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string startTime?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
endTime?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string endTime?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
price?: Prisma.DecimalNullableWithAggregatesFilter<"CourtBooking"> | runtime.Decimal | runtime.DecimalJsLike | number | string | null
customerName?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string customerName?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
customerPhone?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string customerPhone?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
customerEmail?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
status?: Prisma.EnumCourtBookingStatusWithAggregatesFilter<"CourtBooking"> | $Enums.CourtBookingStatus status?: Prisma.EnumCourtBookingStatusWithAggregatesFilter<"CourtBooking"> | $Enums.CourtBookingStatus
createdAt?: Prisma.DateTimeWithAggregatesFilter<"CourtBooking"> | Date | string createdAt?: Prisma.DateTimeWithAggregatesFilter<"CourtBooking"> | Date | string
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"CourtBooking"> | Date | string updatedAt?: Prisma.DateTimeWithAggregatesFilter<"CourtBooking"> | Date | string
@@ -319,9 +355,9 @@ export type CourtBookingCreateInput = {
bookingDate: Date | string bookingDate: Date | string
startTime: string startTime: string
endTime: string endTime: string
price?: runtime.Decimal | runtime.DecimalJsLike | number | string | null
customerName: string customerName: string
customerPhone: string customerPhone: string
customerEmail: string
status?: $Enums.CourtBookingStatus status?: $Enums.CourtBookingStatus
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
@@ -335,9 +371,9 @@ export type CourtBookingUncheckedCreateInput = {
bookingDate: Date | string bookingDate: Date | string
startTime: string startTime: string
endTime: string endTime: string
price?: runtime.Decimal | runtime.DecimalJsLike | number | string | null
customerName: string customerName: string
customerPhone: string customerPhone: string
customerEmail: string
status?: $Enums.CourtBookingStatus status?: $Enums.CourtBookingStatus
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
@@ -349,9 +385,9 @@ export type CourtBookingUpdateInput = {
bookingDate?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string bookingDate?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
startTime?: Prisma.StringFieldUpdateOperationsInput | string startTime?: Prisma.StringFieldUpdateOperationsInput | string
endTime?: Prisma.StringFieldUpdateOperationsInput | string endTime?: Prisma.StringFieldUpdateOperationsInput | string
price?: Prisma.NullableDecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string | null
customerName?: Prisma.StringFieldUpdateOperationsInput | string customerName?: Prisma.StringFieldUpdateOperationsInput | string
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -365,9 +401,9 @@ export type CourtBookingUncheckedUpdateInput = {
bookingDate?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string bookingDate?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
startTime?: Prisma.StringFieldUpdateOperationsInput | string startTime?: Prisma.StringFieldUpdateOperationsInput | string
endTime?: Prisma.StringFieldUpdateOperationsInput | string endTime?: Prisma.StringFieldUpdateOperationsInput | string
price?: Prisma.NullableDecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string | null
customerName?: Prisma.StringFieldUpdateOperationsInput | string customerName?: Prisma.StringFieldUpdateOperationsInput | string
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -380,9 +416,9 @@ export type CourtBookingCreateManyInput = {
bookingDate: Date | string bookingDate: Date | string
startTime: string startTime: string
endTime: string endTime: string
price?: runtime.Decimal | runtime.DecimalJsLike | number | string | null
customerName: string customerName: string
customerPhone: string customerPhone: string
customerEmail: string
status?: $Enums.CourtBookingStatus status?: $Enums.CourtBookingStatus
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
@@ -394,9 +430,9 @@ export type CourtBookingUpdateManyMutationInput = {
bookingDate?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string bookingDate?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
startTime?: Prisma.StringFieldUpdateOperationsInput | string startTime?: Prisma.StringFieldUpdateOperationsInput | string
endTime?: Prisma.StringFieldUpdateOperationsInput | string endTime?: Prisma.StringFieldUpdateOperationsInput | string
price?: Prisma.NullableDecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string | null
customerName?: Prisma.StringFieldUpdateOperationsInput | string customerName?: Prisma.StringFieldUpdateOperationsInput | string
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -409,9 +445,9 @@ export type CourtBookingUncheckedUpdateManyInput = {
bookingDate?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string bookingDate?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
startTime?: Prisma.StringFieldUpdateOperationsInput | string startTime?: Prisma.StringFieldUpdateOperationsInput | string
endTime?: Prisma.StringFieldUpdateOperationsInput | string endTime?: Prisma.StringFieldUpdateOperationsInput | string
price?: Prisma.NullableDecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string | null
customerName?: Prisma.StringFieldUpdateOperationsInput | string customerName?: Prisma.StringFieldUpdateOperationsInput | string
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -440,14 +476,18 @@ export type CourtBookingCountOrderByAggregateInput = {
bookingDate?: Prisma.SortOrder bookingDate?: Prisma.SortOrder
startTime?: Prisma.SortOrder startTime?: Prisma.SortOrder
endTime?: Prisma.SortOrder endTime?: Prisma.SortOrder
price?: Prisma.SortOrder
customerName?: Prisma.SortOrder customerName?: Prisma.SortOrder
customerPhone?: Prisma.SortOrder customerPhone?: Prisma.SortOrder
customerEmail?: Prisma.SortOrder
status?: Prisma.SortOrder status?: Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
} }
export type CourtBookingAvgOrderByAggregateInput = {
price?: Prisma.SortOrder
}
export type CourtBookingMaxOrderByAggregateInput = { export type CourtBookingMaxOrderByAggregateInput = {
id?: Prisma.SortOrder id?: Prisma.SortOrder
bookingCode?: Prisma.SortOrder bookingCode?: Prisma.SortOrder
@@ -455,9 +495,9 @@ export type CourtBookingMaxOrderByAggregateInput = {
bookingDate?: Prisma.SortOrder bookingDate?: Prisma.SortOrder
startTime?: Prisma.SortOrder startTime?: Prisma.SortOrder
endTime?: Prisma.SortOrder endTime?: Prisma.SortOrder
price?: Prisma.SortOrder
customerName?: Prisma.SortOrder customerName?: Prisma.SortOrder
customerPhone?: Prisma.SortOrder customerPhone?: Prisma.SortOrder
customerEmail?: Prisma.SortOrder
status?: Prisma.SortOrder status?: Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
@@ -470,14 +510,18 @@ export type CourtBookingMinOrderByAggregateInput = {
bookingDate?: Prisma.SortOrder bookingDate?: Prisma.SortOrder
startTime?: Prisma.SortOrder startTime?: Prisma.SortOrder
endTime?: Prisma.SortOrder endTime?: Prisma.SortOrder
price?: Prisma.SortOrder
customerName?: Prisma.SortOrder customerName?: Prisma.SortOrder
customerPhone?: Prisma.SortOrder customerPhone?: Prisma.SortOrder
customerEmail?: Prisma.SortOrder
status?: Prisma.SortOrder status?: Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
} }
export type CourtBookingSumOrderByAggregateInput = {
price?: Prisma.SortOrder
}
export type CourtBookingCreateNestedManyWithoutCourtInput = { export type CourtBookingCreateNestedManyWithoutCourtInput = {
create?: Prisma.XOR<Prisma.CourtBookingCreateWithoutCourtInput, Prisma.CourtBookingUncheckedCreateWithoutCourtInput> | Prisma.CourtBookingCreateWithoutCourtInput[] | Prisma.CourtBookingUncheckedCreateWithoutCourtInput[] create?: Prisma.XOR<Prisma.CourtBookingCreateWithoutCourtInput, Prisma.CourtBookingUncheckedCreateWithoutCourtInput> | Prisma.CourtBookingCreateWithoutCourtInput[] | Prisma.CourtBookingUncheckedCreateWithoutCourtInput[]
connectOrCreate?: Prisma.CourtBookingCreateOrConnectWithoutCourtInput | Prisma.CourtBookingCreateOrConnectWithoutCourtInput[] connectOrCreate?: Prisma.CourtBookingCreateOrConnectWithoutCourtInput | Prisma.CourtBookingCreateOrConnectWithoutCourtInput[]
@@ -520,6 +564,14 @@ export type CourtBookingUncheckedUpdateManyWithoutCourtNestedInput = {
deleteMany?: Prisma.CourtBookingScalarWhereInput | Prisma.CourtBookingScalarWhereInput[] deleteMany?: Prisma.CourtBookingScalarWhereInput | Prisma.CourtBookingScalarWhereInput[]
} }
export type NullableDecimalFieldUpdateOperationsInput = {
set?: runtime.Decimal | runtime.DecimalJsLike | number | string | null
increment?: runtime.Decimal | runtime.DecimalJsLike | number | string
decrement?: runtime.Decimal | runtime.DecimalJsLike | number | string
multiply?: runtime.Decimal | runtime.DecimalJsLike | number | string
divide?: runtime.Decimal | runtime.DecimalJsLike | number | string
}
export type EnumCourtBookingStatusFieldUpdateOperationsInput = { export type EnumCourtBookingStatusFieldUpdateOperationsInput = {
set?: $Enums.CourtBookingStatus set?: $Enums.CourtBookingStatus
} }
@@ -530,9 +582,9 @@ export type CourtBookingCreateWithoutCourtInput = {
bookingDate: Date | string bookingDate: Date | string
startTime: string startTime: string
endTime: string endTime: string
price?: runtime.Decimal | runtime.DecimalJsLike | number | string | null
customerName: string customerName: string
customerPhone: string customerPhone: string
customerEmail: string
status?: $Enums.CourtBookingStatus status?: $Enums.CourtBookingStatus
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
@@ -544,9 +596,9 @@ export type CourtBookingUncheckedCreateWithoutCourtInput = {
bookingDate: Date | string bookingDate: Date | string
startTime: string startTime: string
endTime: string endTime: string
price?: runtime.Decimal | runtime.DecimalJsLike | number | string | null
customerName: string customerName: string
customerPhone: string customerPhone: string
customerEmail: string
status?: $Enums.CourtBookingStatus status?: $Enums.CourtBookingStatus
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
@@ -588,9 +640,9 @@ export type CourtBookingScalarWhereInput = {
bookingDate?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string bookingDate?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
startTime?: Prisma.StringFilter<"CourtBooking"> | string startTime?: Prisma.StringFilter<"CourtBooking"> | string
endTime?: Prisma.StringFilter<"CourtBooking"> | string endTime?: Prisma.StringFilter<"CourtBooking"> | string
price?: Prisma.DecimalNullableFilter<"CourtBooking"> | runtime.Decimal | runtime.DecimalJsLike | number | string | null
customerName?: Prisma.StringFilter<"CourtBooking"> | string customerName?: Prisma.StringFilter<"CourtBooking"> | string
customerPhone?: Prisma.StringFilter<"CourtBooking"> | string customerPhone?: Prisma.StringFilter<"CourtBooking"> | string
customerEmail?: Prisma.StringFilter<"CourtBooking"> | string
status?: Prisma.EnumCourtBookingStatusFilter<"CourtBooking"> | $Enums.CourtBookingStatus status?: Prisma.EnumCourtBookingStatusFilter<"CourtBooking"> | $Enums.CourtBookingStatus
createdAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string createdAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string updatedAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
@@ -602,9 +654,9 @@ export type CourtBookingCreateManyCourtInput = {
bookingDate: Date | string bookingDate: Date | string
startTime: string startTime: string
endTime: string endTime: string
price?: runtime.Decimal | runtime.DecimalJsLike | number | string | null
customerName: string customerName: string
customerPhone: string customerPhone: string
customerEmail: string
status?: $Enums.CourtBookingStatus status?: $Enums.CourtBookingStatus
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
@@ -616,9 +668,9 @@ export type CourtBookingUpdateWithoutCourtInput = {
bookingDate?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string bookingDate?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
startTime?: Prisma.StringFieldUpdateOperationsInput | string startTime?: Prisma.StringFieldUpdateOperationsInput | string
endTime?: Prisma.StringFieldUpdateOperationsInput | string endTime?: Prisma.StringFieldUpdateOperationsInput | string
price?: Prisma.NullableDecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string | null
customerName?: Prisma.StringFieldUpdateOperationsInput | string customerName?: Prisma.StringFieldUpdateOperationsInput | string
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -630,9 +682,9 @@ export type CourtBookingUncheckedUpdateWithoutCourtInput = {
bookingDate?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string bookingDate?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
startTime?: Prisma.StringFieldUpdateOperationsInput | string startTime?: Prisma.StringFieldUpdateOperationsInput | string
endTime?: Prisma.StringFieldUpdateOperationsInput | string endTime?: Prisma.StringFieldUpdateOperationsInput | string
price?: Prisma.NullableDecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string | null
customerName?: Prisma.StringFieldUpdateOperationsInput | string customerName?: Prisma.StringFieldUpdateOperationsInput | string
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -644,9 +696,9 @@ export type CourtBookingUncheckedUpdateManyWithoutCourtInput = {
bookingDate?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string bookingDate?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
startTime?: Prisma.StringFieldUpdateOperationsInput | string startTime?: Prisma.StringFieldUpdateOperationsInput | string
endTime?: Prisma.StringFieldUpdateOperationsInput | string endTime?: Prisma.StringFieldUpdateOperationsInput | string
price?: Prisma.NullableDecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string | null
customerName?: Prisma.StringFieldUpdateOperationsInput | string customerName?: Prisma.StringFieldUpdateOperationsInput | string
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -661,9 +713,9 @@ export type CourtBookingSelect<ExtArgs extends runtime.Types.Extensions.Internal
bookingDate?: boolean bookingDate?: boolean
startTime?: boolean startTime?: boolean
endTime?: boolean endTime?: boolean
price?: boolean
customerName?: boolean customerName?: boolean
customerPhone?: boolean customerPhone?: boolean
customerEmail?: boolean
status?: boolean status?: boolean
createdAt?: boolean createdAt?: boolean
updatedAt?: boolean updatedAt?: boolean
@@ -677,9 +729,9 @@ export type CourtBookingSelectCreateManyAndReturn<ExtArgs extends runtime.Types.
bookingDate?: boolean bookingDate?: boolean
startTime?: boolean startTime?: boolean
endTime?: boolean endTime?: boolean
price?: boolean
customerName?: boolean customerName?: boolean
customerPhone?: boolean customerPhone?: boolean
customerEmail?: boolean
status?: boolean status?: boolean
createdAt?: boolean createdAt?: boolean
updatedAt?: boolean updatedAt?: boolean
@@ -693,9 +745,9 @@ export type CourtBookingSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.
bookingDate?: boolean bookingDate?: boolean
startTime?: boolean startTime?: boolean
endTime?: boolean endTime?: boolean
price?: boolean
customerName?: boolean customerName?: boolean
customerPhone?: boolean customerPhone?: boolean
customerEmail?: boolean
status?: boolean status?: boolean
createdAt?: boolean createdAt?: boolean
updatedAt?: boolean updatedAt?: boolean
@@ -709,15 +761,15 @@ export type CourtBookingSelectScalar = {
bookingDate?: boolean bookingDate?: boolean
startTime?: boolean startTime?: boolean
endTime?: boolean endTime?: boolean
price?: boolean
customerName?: boolean customerName?: boolean
customerPhone?: boolean customerPhone?: boolean
customerEmail?: boolean
status?: boolean status?: boolean
createdAt?: boolean createdAt?: boolean
updatedAt?: boolean updatedAt?: boolean
} }
export type CourtBookingOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "bookingCode" | "courtId" | "bookingDate" | "startTime" | "endTime" | "customerName" | "customerPhone" | "customerEmail" | "status" | "createdAt" | "updatedAt", ExtArgs["result"]["courtBooking"]> export type CourtBookingOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "bookingCode" | "courtId" | "bookingDate" | "startTime" | "endTime" | "price" | "customerName" | "customerPhone" | "status" | "createdAt" | "updatedAt", ExtArgs["result"]["courtBooking"]>
export type CourtBookingInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type CourtBookingInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
court?: boolean | Prisma.CourtDefaultArgs<ExtArgs> court?: boolean | Prisma.CourtDefaultArgs<ExtArgs>
} }
@@ -740,9 +792,9 @@ export type $CourtBookingPayload<ExtArgs extends runtime.Types.Extensions.Intern
bookingDate: Date bookingDate: Date
startTime: string startTime: string
endTime: string endTime: string
price: runtime.Decimal | null
customerName: string customerName: string
customerPhone: string customerPhone: string
customerEmail: string
status: $Enums.CourtBookingStatus status: $Enums.CourtBookingStatus
createdAt: Date createdAt: Date
updatedAt: Date updatedAt: Date
@@ -1176,9 +1228,9 @@ export interface CourtBookingFieldRefs {
readonly bookingDate: Prisma.FieldRef<"CourtBooking", 'DateTime'> readonly bookingDate: Prisma.FieldRef<"CourtBooking", 'DateTime'>
readonly startTime: Prisma.FieldRef<"CourtBooking", 'String'> readonly startTime: Prisma.FieldRef<"CourtBooking", 'String'>
readonly endTime: Prisma.FieldRef<"CourtBooking", 'String'> readonly endTime: Prisma.FieldRef<"CourtBooking", 'String'>
readonly price: Prisma.FieldRef<"CourtBooking", 'Decimal'>
readonly customerName: Prisma.FieldRef<"CourtBooking", 'String'> readonly customerName: Prisma.FieldRef<"CourtBooking", 'String'>
readonly customerPhone: Prisma.FieldRef<"CourtBooking", 'String'> readonly customerPhone: Prisma.FieldRef<"CourtBooking", 'String'>
readonly customerEmail: Prisma.FieldRef<"CourtBooking", 'String'>
readonly status: Prisma.FieldRef<"CourtBooking", 'CourtBookingStatus'> readonly status: Prisma.FieldRef<"CourtBooking", 'CourtBookingStatus'>
readonly createdAt: Prisma.FieldRef<"CourtBooking", 'DateTime'> readonly createdAt: Prisma.FieldRef<"CourtBooking", 'DateTime'>
readonly updatedAt: Prisma.FieldRef<"CourtBooking", 'DateTime'> readonly updatedAt: Prisma.FieldRef<"CourtBooking", 'DateTime'>

File diff suppressed because it is too large Load Diff

View File

@@ -30,7 +30,6 @@ export type UserMinAggregateOutputType = {
email: string | null email: string | null
emailVerified: boolean | null emailVerified: boolean | null
image: string | null image: string | null
phone: string | null
createdAt: Date | null createdAt: Date | null
updatedAt: Date | null updatedAt: Date | null
role: string | null role: string | null
@@ -42,7 +41,6 @@ export type UserMaxAggregateOutputType = {
email: string | null email: string | null
emailVerified: boolean | null emailVerified: boolean | null
image: string | null image: string | null
phone: string | null
createdAt: Date | null createdAt: Date | null
updatedAt: Date | null updatedAt: Date | null
role: string | null role: string | null
@@ -54,7 +52,6 @@ export type UserCountAggregateOutputType = {
email: number email: number
emailVerified: number emailVerified: number
image: number image: number
phone: number
createdAt: number createdAt: number
updatedAt: number updatedAt: number
role: number role: number
@@ -68,7 +65,6 @@ export type UserMinAggregateInputType = {
email?: true email?: true
emailVerified?: true emailVerified?: true
image?: true image?: true
phone?: true
createdAt?: true createdAt?: true
updatedAt?: true updatedAt?: true
role?: true role?: true
@@ -80,7 +76,6 @@ export type UserMaxAggregateInputType = {
email?: true email?: true
emailVerified?: true emailVerified?: true
image?: true image?: true
phone?: true
createdAt?: true createdAt?: true
updatedAt?: true updatedAt?: true
role?: true role?: true
@@ -92,7 +87,6 @@ export type UserCountAggregateInputType = {
email?: true email?: true
emailVerified?: true emailVerified?: true
image?: true image?: true
phone?: true
createdAt?: true createdAt?: true
updatedAt?: true updatedAt?: true
role?: true role?: true
@@ -177,7 +171,6 @@ export type UserGroupByOutputType = {
email: string email: string
emailVerified: boolean emailVerified: boolean
image: string | null image: string | null
phone: string | null
createdAt: Date createdAt: Date
updatedAt: Date updatedAt: Date
role: string role: string
@@ -210,7 +203,6 @@ export type UserWhereInput = {
email?: Prisma.StringFilter<"User"> | string email?: Prisma.StringFilter<"User"> | string
emailVerified?: Prisma.BoolFilter<"User"> | boolean emailVerified?: Prisma.BoolFilter<"User"> | boolean
image?: Prisma.StringNullableFilter<"User"> | string | null image?: Prisma.StringNullableFilter<"User"> | string | null
phone?: Prisma.StringNullableFilter<"User"> | string | null
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
role?: Prisma.StringFilter<"User"> | string role?: Prisma.StringFilter<"User"> | string
@@ -225,7 +217,6 @@ export type UserOrderByWithRelationInput = {
email?: Prisma.SortOrder email?: Prisma.SortOrder
emailVerified?: Prisma.SortOrder emailVerified?: Prisma.SortOrder
image?: Prisma.SortOrderInput | Prisma.SortOrder image?: Prisma.SortOrderInput | Prisma.SortOrder
phone?: Prisma.SortOrderInput | Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
role?: Prisma.SortOrder role?: Prisma.SortOrder
@@ -243,7 +234,6 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{
name?: Prisma.StringFilter<"User"> | string name?: Prisma.StringFilter<"User"> | string
emailVerified?: Prisma.BoolFilter<"User"> | boolean emailVerified?: Prisma.BoolFilter<"User"> | boolean
image?: Prisma.StringNullableFilter<"User"> | string | null image?: Prisma.StringNullableFilter<"User"> | string | null
phone?: Prisma.StringNullableFilter<"User"> | string | null
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
role?: Prisma.StringFilter<"User"> | string role?: Prisma.StringFilter<"User"> | string
@@ -258,7 +248,6 @@ export type UserOrderByWithAggregationInput = {
email?: Prisma.SortOrder email?: Prisma.SortOrder
emailVerified?: Prisma.SortOrder emailVerified?: Prisma.SortOrder
image?: Prisma.SortOrderInput | Prisma.SortOrder image?: Prisma.SortOrderInput | Prisma.SortOrder
phone?: Prisma.SortOrderInput | Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
role?: Prisma.SortOrder role?: Prisma.SortOrder
@@ -276,7 +265,6 @@ export type UserScalarWhereWithAggregatesInput = {
email?: Prisma.StringWithAggregatesFilter<"User"> | string email?: Prisma.StringWithAggregatesFilter<"User"> | string
emailVerified?: Prisma.BoolWithAggregatesFilter<"User"> | boolean emailVerified?: Prisma.BoolWithAggregatesFilter<"User"> | boolean
image?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null image?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null
phone?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null
createdAt?: Prisma.DateTimeWithAggregatesFilter<"User"> | Date | string createdAt?: Prisma.DateTimeWithAggregatesFilter<"User"> | Date | string
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"User"> | Date | string updatedAt?: Prisma.DateTimeWithAggregatesFilter<"User"> | Date | string
role?: Prisma.StringWithAggregatesFilter<"User"> | string role?: Prisma.StringWithAggregatesFilter<"User"> | string
@@ -288,7 +276,6 @@ export type UserCreateInput = {
email: string email: string
emailVerified?: boolean emailVerified?: boolean
image?: string | null image?: string | null
phone?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
role?: string role?: string
@@ -303,7 +290,6 @@ export type UserUncheckedCreateInput = {
email: string email: string
emailVerified?: boolean emailVerified?: boolean
image?: string | null image?: string | null
phone?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
role?: string role?: string
@@ -318,7 +304,6 @@ export type UserUpdateInput = {
email?: Prisma.StringFieldUpdateOperationsInput | string email?: Prisma.StringFieldUpdateOperationsInput | string
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
role?: Prisma.StringFieldUpdateOperationsInput | string role?: Prisma.StringFieldUpdateOperationsInput | string
@@ -333,7 +318,6 @@ export type UserUncheckedUpdateInput = {
email?: Prisma.StringFieldUpdateOperationsInput | string email?: Prisma.StringFieldUpdateOperationsInput | string
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
role?: Prisma.StringFieldUpdateOperationsInput | string role?: Prisma.StringFieldUpdateOperationsInput | string
@@ -348,7 +332,6 @@ export type UserCreateManyInput = {
email: string email: string
emailVerified?: boolean emailVerified?: boolean
image?: string | null image?: string | null
phone?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
role?: string role?: string
@@ -360,7 +343,6 @@ export type UserUpdateManyMutationInput = {
email?: Prisma.StringFieldUpdateOperationsInput | string email?: Prisma.StringFieldUpdateOperationsInput | string
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
role?: Prisma.StringFieldUpdateOperationsInput | string role?: Prisma.StringFieldUpdateOperationsInput | string
@@ -372,7 +354,6 @@ export type UserUncheckedUpdateManyInput = {
email?: Prisma.StringFieldUpdateOperationsInput | string email?: Prisma.StringFieldUpdateOperationsInput | string
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
role?: Prisma.StringFieldUpdateOperationsInput | string role?: Prisma.StringFieldUpdateOperationsInput | string
@@ -384,7 +365,6 @@ export type UserCountOrderByAggregateInput = {
email?: Prisma.SortOrder email?: Prisma.SortOrder
emailVerified?: Prisma.SortOrder emailVerified?: Prisma.SortOrder
image?: Prisma.SortOrder image?: Prisma.SortOrder
phone?: Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
role?: Prisma.SortOrder role?: Prisma.SortOrder
@@ -396,7 +376,6 @@ export type UserMaxOrderByAggregateInput = {
email?: Prisma.SortOrder email?: Prisma.SortOrder
emailVerified?: Prisma.SortOrder emailVerified?: Prisma.SortOrder
image?: Prisma.SortOrder image?: Prisma.SortOrder
phone?: Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
role?: Prisma.SortOrder role?: Prisma.SortOrder
@@ -408,7 +387,6 @@ export type UserMinOrderByAggregateInput = {
email?: Prisma.SortOrder email?: Prisma.SortOrder
emailVerified?: Prisma.SortOrder emailVerified?: Prisma.SortOrder
image?: Prisma.SortOrder image?: Prisma.SortOrder
phone?: Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
role?: Prisma.SortOrder role?: Prisma.SortOrder
@@ -483,7 +461,6 @@ export type UserCreateWithoutSessionsInput = {
email: string email: string
emailVerified?: boolean emailVerified?: boolean
image?: string | null image?: string | null
phone?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
role?: string role?: string
@@ -497,7 +474,6 @@ export type UserUncheckedCreateWithoutSessionsInput = {
email: string email: string
emailVerified?: boolean emailVerified?: boolean
image?: string | null image?: string | null
phone?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
role?: string role?: string
@@ -527,7 +503,6 @@ export type UserUpdateWithoutSessionsInput = {
email?: Prisma.StringFieldUpdateOperationsInput | string email?: Prisma.StringFieldUpdateOperationsInput | string
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
role?: Prisma.StringFieldUpdateOperationsInput | string role?: Prisma.StringFieldUpdateOperationsInput | string
@@ -541,7 +516,6 @@ export type UserUncheckedUpdateWithoutSessionsInput = {
email?: Prisma.StringFieldUpdateOperationsInput | string email?: Prisma.StringFieldUpdateOperationsInput | string
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
role?: Prisma.StringFieldUpdateOperationsInput | string role?: Prisma.StringFieldUpdateOperationsInput | string
@@ -555,7 +529,6 @@ export type UserCreateWithoutAccountsInput = {
email: string email: string
emailVerified?: boolean emailVerified?: boolean
image?: string | null image?: string | null
phone?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
role?: string role?: string
@@ -569,7 +542,6 @@ export type UserUncheckedCreateWithoutAccountsInput = {
email: string email: string
emailVerified?: boolean emailVerified?: boolean
image?: string | null image?: string | null
phone?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
role?: string role?: string
@@ -599,7 +571,6 @@ export type UserUpdateWithoutAccountsInput = {
email?: Prisma.StringFieldUpdateOperationsInput | string email?: Prisma.StringFieldUpdateOperationsInput | string
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
role?: Prisma.StringFieldUpdateOperationsInput | string role?: Prisma.StringFieldUpdateOperationsInput | string
@@ -613,7 +584,6 @@ export type UserUncheckedUpdateWithoutAccountsInput = {
email?: Prisma.StringFieldUpdateOperationsInput | string email?: Prisma.StringFieldUpdateOperationsInput | string
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
role?: Prisma.StringFieldUpdateOperationsInput | string role?: Prisma.StringFieldUpdateOperationsInput | string
@@ -627,7 +597,6 @@ export type UserCreateWithoutComplexesInput = {
email: string email: string
emailVerified?: boolean emailVerified?: boolean
image?: string | null image?: string | null
phone?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
role?: string role?: string
@@ -641,7 +610,6 @@ export type UserUncheckedCreateWithoutComplexesInput = {
email: string email: string
emailVerified?: boolean emailVerified?: boolean
image?: string | null image?: string | null
phone?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
role?: string role?: string
@@ -671,7 +639,6 @@ export type UserUpdateWithoutComplexesInput = {
email?: Prisma.StringFieldUpdateOperationsInput | string email?: Prisma.StringFieldUpdateOperationsInput | string
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
role?: Prisma.StringFieldUpdateOperationsInput | string role?: Prisma.StringFieldUpdateOperationsInput | string
@@ -685,7 +652,6 @@ export type UserUncheckedUpdateWithoutComplexesInput = {
email?: Prisma.StringFieldUpdateOperationsInput | string email?: Prisma.StringFieldUpdateOperationsInput | string
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
role?: Prisma.StringFieldUpdateOperationsInput | string role?: Prisma.StringFieldUpdateOperationsInput | string
@@ -748,7 +714,6 @@ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
email?: boolean email?: boolean
emailVerified?: boolean emailVerified?: boolean
image?: boolean image?: boolean
phone?: boolean
createdAt?: boolean createdAt?: boolean
updatedAt?: boolean updatedAt?: boolean
role?: boolean role?: boolean
@@ -764,7 +729,6 @@ export type UserSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensio
email?: boolean email?: boolean
emailVerified?: boolean emailVerified?: boolean
image?: boolean image?: boolean
phone?: boolean
createdAt?: boolean createdAt?: boolean
updatedAt?: boolean updatedAt?: boolean
role?: boolean role?: boolean
@@ -776,7 +740,6 @@ export type UserSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensio
email?: boolean email?: boolean
emailVerified?: boolean emailVerified?: boolean
image?: boolean image?: boolean
phone?: boolean
createdAt?: boolean createdAt?: boolean
updatedAt?: boolean updatedAt?: boolean
role?: boolean role?: boolean
@@ -788,13 +751,12 @@ export type UserSelectScalar = {
email?: boolean email?: boolean
emailVerified?: boolean emailVerified?: boolean
image?: boolean image?: boolean
phone?: boolean
createdAt?: boolean createdAt?: boolean
updatedAt?: boolean updatedAt?: boolean
role?: boolean role?: boolean
} }
export type UserOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "name" | "email" | "emailVerified" | "image" | "phone" | "createdAt" | "updatedAt" | "role", ExtArgs["result"]["user"]> export type UserOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "name" | "email" | "emailVerified" | "image" | "createdAt" | "updatedAt" | "role", ExtArgs["result"]["user"]>
export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
sessions?: boolean | Prisma.User$sessionsArgs<ExtArgs> sessions?: boolean | Prisma.User$sessionsArgs<ExtArgs>
accounts?: boolean | Prisma.User$accountsArgs<ExtArgs> accounts?: boolean | Prisma.User$accountsArgs<ExtArgs>
@@ -817,7 +779,6 @@ export type $UserPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
email: string email: string
emailVerified: boolean emailVerified: boolean
image: string | null image: string | null
phone: string | null
createdAt: Date createdAt: Date
updatedAt: Date updatedAt: Date
role: string role: string
@@ -1252,7 +1213,6 @@ export interface UserFieldRefs {
readonly email: Prisma.FieldRef<"User", 'String'> readonly email: Prisma.FieldRef<"User", 'String'>
readonly emailVerified: Prisma.FieldRef<"User", 'Boolean'> readonly emailVerified: Prisma.FieldRef<"User", 'Boolean'>
readonly image: Prisma.FieldRef<"User", 'String'> readonly image: Prisma.FieldRef<"User", 'String'>
readonly phone: Prisma.FieldRef<"User", 'String'>
readonly createdAt: Prisma.FieldRef<"User", 'DateTime'> readonly createdAt: Prisma.FieldRef<"User", 'DateTime'>
readonly updatedAt: Prisma.FieldRef<"User", 'DateTime'> readonly updatedAt: Prisma.FieldRef<"User", 'DateTime'>
readonly role: Prisma.FieldRef<"User", 'String'> readonly role: Prisma.FieldRef<"User", 'String'>

View File

@@ -1,9 +1,7 @@
import { wrapLayout } from '@/emails/booking-confirmation';
import { dash } from '@better-auth/infra'; import { dash } from '@better-auth/infra';
import { betterAuth } from 'better-auth'; import { betterAuth } from 'better-auth';
import { prismaAdapter } from 'better-auth/adapters/prisma'; import { prismaAdapter } from 'better-auth/adapters/prisma';
import { openAPI } from 'better-auth/plugins'; import { openAPI } from 'better-auth/plugins';
import { sendMail } from './mailer';
import { db } from './prisma'; import { db } from './prisma';
export const auth = betterAuth({ export const auth = betterAuth({
@@ -20,92 +18,6 @@ export const auth = betterAuth({
emailAndPassword: { emailAndPassword: {
enabled: true, enabled: true,
}, },
user: {
additionalFields: {
phone: {
type: 'string',
required: false,
},
},
},
emailVerification: {
sendOnSignUp: true,
autoSignInAfterVerification: true,
sendVerificationEmail: async ({ user, url }) => {
const verificationUrl = new URL(url);
const appUrl = process.env.APP_BASE_URL ?? 'http://localhost:5173';
verificationUrl.searchParams.set('callbackURL', appUrl);
const content = `
<tr>
<td style="padding:24px 20px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td valign="top">
<table role="presentation" cellpadding="0" cellspacing="0" style="display:inline-block;background-color:#f4f4f5;border-radius:999px;padding:4px 12px;">
<tr>
<td style="font-size:11px;font-weight:700;letter-spacing:0.14em;color:#71717a;text-transform:uppercase;line-height:1.25rem;">
Verificaci&oacute;n de email
</td>
</tr>
</table>
<h1 style="margin:16px 0 0;font-size:24px;font-weight:700;color:#111827;letter-spacing:-0.025em;">
Verific&aacute; tu direcci&oacute;n de correo electr&oacute;nico
</h1>
</td>
<td valign="top" align="right" style="white-space:nowrap;">
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td valign="middle" style="padding-right:8px;">
<img src="${appUrl}/playzer-favicon-512-transparent.png" alt="Playzer" width="32" height="32" style="display:block;" />
</td>
<td valign="middle">
<span style="font-size:18px;font-weight:700;color:#475569;letter-spacing:-0.025em;">Playzer</span>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
padding:0 20px 16px;
<p style="margin:0;font-size:15px;color:#374151;line-height:1.6;">
Hac&eacute; click en el bot&oacute;n de abajo para verificar tu direcci&oacute;n de correo electr&oacute;nico y empezar a usar Playzer.
</p>
</td>
</tr>
<tr>
<td style="padding:0 20px 24px;">
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="background-color:#059669;border-radius:12px;text-align:center;">
<a href="${verificationUrl.toString()}" style="display:block;padding:14px 32px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;line-height:1.25rem;">Verificar email</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:0 20px 24px;">
<p style="margin:0;font-size:13px;color:#9ca3af;text-align:center;line-height:1.5;">
Si no creaste una cuenta en Playzer, ignor&aacute; este mensaje.
</p>
</td>
</tr>`;
await sendMail({
to: user.email,
subject: 'Verificá tu email en Playzer',
html: wrapLayout(content),
text: `Hacé click para verificar tu email: ${verificationUrl.toString()}`,
});
},
},
socialProviders: { socialProviders: {
google: { google: {
clientId: process.env.GOOGLE_CLIENT_ID!, clientId: process.env.GOOGLE_CLIENT_ID!,

View File

@@ -1,58 +0,0 @@
// lib/errors.ts
export type ValidationIssue = {
path: string;
message: string;
};
export type AppError =
| {
type: 'validation';
message: string;
issues?: ValidationIssue[];
}
| {
type: 'not_found';
message: string;
}
| {
type: 'conflict';
message: string;
}
| {
type: 'unauthorized';
message: string;
}
| {
type: 'forbidden';
message: string;
}
| {
type: 'unexpected';
message: string;
};
export const Errors = {
validation(message: string, issues?: ValidationIssue[]): AppError {
return { type: 'validation', message, issues };
},
notFound(message = 'Resource not found'): AppError {
return { type: 'not_found', message };
},
conflict(message: string): AppError {
return { type: 'conflict', message };
},
unauthorized(message = 'Unauthorized'): AppError {
return { type: 'unauthorized', message };
},
forbidden(message = 'Forbidden'): AppError {
return { type: 'forbidden', message };
},
unexpected(message = 'Unexpected error'): AppError {
return { type: 'unexpected', message };
},
};

View File

@@ -1,20 +0,0 @@
// http/error-handler.ts
import type { MiddlewareHandler } from 'hono';
import { logger } from '../logger';
import { unexpectedProblem } from './problem-builders';
export const errorHandler: MiddlewareHandler = async (c, next) => {
try {
await next();
} catch (error) {
const requestId = c.get('requestId') as string | undefined;
const instance = requestId ? `/requests/${requestId}` : undefined;
logger.error({
requestId,
error,
});
return c.json(unexpectedProblem({ instance }), 500);
}
};

View File

@@ -1,20 +0,0 @@
import type { Result } from '@/lib/result';
import type { Context } from 'hono';
import type { ContentfulStatusCode } from 'hono/utils/http-status';
import { mapAppErrorToProblem } from './problem-mapper';
export function handleResult<T>(
c: Context,
result: Result<T>,
successStatus: ContentfulStatusCode = 200
) {
if (!result.ok) {
const requestId = c.get('requestId') as string | undefined;
const instance = requestId ? `/requests/${requestId}` : undefined;
const problem = mapAppErrorToProblem(result.error, instance);
return c.json(problem, problem.status);
}
return c.json(result.value, successStatus);
}

View File

@@ -1,29 +0,0 @@
// http/problem-builders.ts
import type { ProblemDetails } from './problem-details';
export function validationProblem(params: {
detail?: string;
instance?: string;
errors?: Record<string, string[]>;
}): ProblemDetails {
return {
type: 'https://api.myapp.dev/problems/validation',
title: 'Bad Request',
status: 400,
detail: params.detail ?? 'Invalid request data',
instance: params.instance,
errors: params.errors,
};
}
export function unexpectedProblem(params?: {
instance?: string;
}): ProblemDetails {
return {
type: 'https://api.myapp.dev/problems/unexpected',
title: 'Internal Server Error',
status: 500,
detail: 'Internal server error',
instance: params?.instance,
};
}

View File

@@ -1,10 +0,0 @@
import type { ContentfulStatusCode } from 'hono/utils/http-status';
export type ProblemDetails = {
type: string;
title: string;
status: ContentfulStatusCode;
detail?: string;
instance?: string;
errors?: Record<string, string[]>;
};

View File

@@ -1,51 +0,0 @@
import type { AppError } from '@/lib/errors';
import type { ContentfulStatusCode } from 'hono/utils/http-status';
import type { ProblemDetails } from './problem-details';
function statusFromError(error: AppError): ContentfulStatusCode {
switch (error.type) {
case 'validation':
return 400;
case 'unauthorized':
return 401;
case 'forbidden':
return 403;
case 'not_found':
return 404;
case 'conflict':
return 409;
case 'unexpected':
return 500;
default:
return 500;
}
}
function titleFromStatus(status: ContentfulStatusCode): string {
switch (status) {
case 400:
return 'Bad Request';
case 401:
return 'Unauthorized';
case 403:
return 'Forbidden';
case 404:
return 'Not Found';
case 409:
return 'Conflict';
default:
return 'Internal Server Error';
}
}
export function mapAppErrorToProblem(error: AppError, instance?: string): ProblemDetails {
const status = statusFromError(error);
return {
type: `https://api.myapp.dev/problems/${error.type}`,
title: titleFromStatus(status),
status,
detail: status === 500 ? 'Internal server error' : error.message,
instance,
};
}

View File

@@ -1,16 +0,0 @@
import type { MiddlewareHandler } from 'hono';
type Env = {
Variables: {
requestId: string;
};
};
export const requestIdMiddleware: MiddlewareHandler<Env> = async (c, next) => {
const requestId = crypto.randomUUID();
c.set('requestId', requestId);
c.header('X-Request-Id', requestId);
await next();
};

View File

@@ -1,48 +0,0 @@
import { zValidator } from '@hono/zod-validator';
import type { ZodSchema } from 'zod';
import { validationProblem } from './problem-builders';
import { zodIssuesToRecord } from './zod-issues';
type ValidationTarget = 'json' | 'query' | 'param' | 'header' | 'form';
function makeValidator<TSchema extends ZodSchema>(target: ValidationTarget, schema: TSchema) {
return zValidator(target, schema, (result, c) => {
if (result.success) {
return;
}
const requestId = c.get('requestId') as string | undefined;
const instance = requestId ? `/requests/${requestId}` : undefined;
return c.json(
validationProblem({
detail: `Invalid ${target} data`,
instance,
errors: zodIssuesToRecord(result.error.issues),
}),
400
);
});
}
export const validate = {
json<TSchema extends ZodSchema>(schema: TSchema) {
return makeValidator('json', schema);
},
query<TSchema extends ZodSchema>(schema: TSchema) {
return makeValidator('query', schema);
},
param<TSchema extends ZodSchema>(schema: TSchema) {
return makeValidator('param', schema);
},
header<TSchema extends ZodSchema>(schema: TSchema) {
return makeValidator('header', schema);
},
form<TSchema extends ZodSchema>(schema: TSchema) {
return makeValidator('form', schema);
},
};

View File

@@ -1,16 +0,0 @@
type IssueLike = {
path: PropertyKey[];
message: string;
};
export function zodIssuesToRecord(issues: IssueLike[]): Record<string, string[]> {
const out: Record<string, string[]> = {};
for (const issue of issues) {
const key = issue.path.length ? issue.path.join('.') : 'root';
out[key] ??= [];
out[key].push(issue.message);
}
return out;
}

View File

@@ -1,12 +0,0 @@
// lib/result.ts
import type { AppError } from '@/lib/errors';
export type Result<T, E = AppError> = { ok: true; value: T } | { ok: false; error: E };
export function ok<T>(value: T): Result<T> {
return { ok: true, value };
}
export function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}

View File

@@ -1,9 +1,7 @@
import { db } from '@/lib/prisma';
import { import {
AdminBookingServiceError, AdminBookingServiceError,
createAdminBooking, createAdminBooking,
} from '@/modules/admin-booking/services/admin-booking.service'; } from '@/modules/admin-booking/services/admin-booking.service';
import { sendBookingConfirmation } from '@/services/booking-email.service';
import type { AppContext } from '@/types/hono'; import type { AppContext } from '@/types/hono';
import type { CreateAdminBookingInput } from '@repo/api-contract'; import type { CreateAdminBookingInput } from '@repo/api-contract';
@@ -16,26 +14,6 @@ export async function createAdminBookingHandler(c: AppContext) {
try { try {
const booking = await createAdminBooking(user.id, complexId, payload); const booking = await createAdminBooking(user.id, complexId, payload);
const complex = await db.complex.findUnique({
where: { id: complexId },
select: { complexSlug: true },
});
void sendBookingConfirmation({
bookingCode: booking.bookingCode,
complexSlug: complex?.complexSlug ?? '',
complexName: booking.complexName,
date: booking.date,
startTime: booking.startTime,
endTime: booking.endTime,
courtName: booking.courtName,
sportName: booking.sport.name,
customerName: booking.customerName,
customerEmail: booking.customerEmail,
price: booking.price,
});
return c.json(booking, 201); return c.json(booking, 201);
} catch (error) { } catch (error) {
if (error instanceof AdminBookingServiceError) { if (error instanceof AdminBookingServiceError) {

View File

@@ -2,7 +2,6 @@ import {
AdminBookingServiceError, AdminBookingServiceError,
updateAdminBookingStatus, updateAdminBookingStatus,
} from '@/modules/admin-booking/services/admin-booking.service'; } from '@/modules/admin-booking/services/admin-booking.service';
import { sendBookingCancelled, sendBookingNoShow } from '@/services/booking-email.service';
import type { AppContext } from '@/types/hono'; import type { AppContext } from '@/types/hono';
import type { UpdateAdminBookingStatusInput } from '@repo/api-contract'; import type { UpdateAdminBookingStatusInput } from '@repo/api-contract';
@@ -15,33 +14,6 @@ export async function updateAdminBookingStatusHandler(c: AppContext) {
try { try {
const booking = await updateAdminBookingStatus(user.id, id, payload); const booking = await updateAdminBookingStatus(user.id, id, payload);
if (payload.status === 'CANCELLED') {
void sendBookingCancelled({
bookingCode: booking.bookingCode,
complexName: booking.complexName,
date: booking.date,
startTime: booking.startTime,
endTime: booking.endTime,
courtName: booking.courtName,
sportName: booking.sport.name,
customerName: booking.customerName,
customerEmail: booking.customerEmail,
});
} else if (payload.status === 'NOSHOW') {
void sendBookingNoShow({
bookingCode: booking.bookingCode,
complexName: booking.complexName,
date: booking.date,
startTime: booking.startTime,
endTime: booking.endTime,
courtName: booking.courtName,
sportName: booking.sport.name,
customerName: booking.customerName,
customerEmail: booking.customerEmail,
});
}
return c.json(booking); return c.json(booking);
} catch (error) { } catch (error) {
if (error instanceof AdminBookingServiceError) { if (error instanceof AdminBookingServiceError) {

View File

@@ -2,7 +2,6 @@ import { randomInt } from 'node:crypto';
import { CourtBookingStatus } from '@/generated/prisma/enums'; import { CourtBookingStatus } from '@/generated/prisma/enums';
import { db } from '@/lib/prisma'; import { db } from '@/lib/prisma';
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service'; import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service';
import type { DayOfWeek } from '@repo/api-contract';
import type { import type {
AdminBooking, AdminBooking,
CreateAdminBookingInput, CreateAdminBookingInput,
@@ -157,40 +156,6 @@ async function ensureComplexAccess(complexId: string, userId: string) {
return complexUser.complex; return complexUser.complex;
} }
function resolvePrice(
court: {
basePrice: unknown;
priceRules: Array<{
dayOfWeek: string | null;
startTime: string | null;
endTime: string | null;
price: unknown;
}>;
},
dayOfWeek: string,
startTime: string,
endTime: string
): number {
const slotStart = toMinutes(startTime);
const slotEnd = toMinutes(endTime);
const matchingRules = court.priceRules
.filter((rule) => {
if (rule.dayOfWeek && rule.dayOfWeek !== dayOfWeek) return false;
if (!rule.startTime || !rule.endTime) return true;
return slotStart >= toMinutes(rule.startTime) && slotEnd <= toMinutes(rule.endTime);
})
.sort((first, second) => {
const firstSpecificity =
(first.dayOfWeek ? 2 : 0) + (first.startTime && first.endTime ? 1 : 0);
const secondSpecificity =
(second.dayOfWeek ? 2 : 0) + (second.startTime && second.endTime ? 1 : 0);
return secondSpecificity - firstSpecificity;
});
return Number(matchingRules[0]?.price ?? court.basePrice);
}
function mapBookingResponse(booking: { function mapBookingResponse(booking: {
id: string; id: string;
bookingCode: string; bookingCode: string;
@@ -199,8 +164,7 @@ function mapBookingResponse(booking: {
endTime: string; endTime: string;
customerName: string; customerName: string;
customerPhone: string; customerPhone: string;
customerEmail: string; status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED';
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NOSHOW';
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
court: { court: {
@@ -216,7 +180,6 @@ function mapBookingResponse(booking: {
slug: string; slug: string;
}; };
}; };
price?: number;
}): AdminBooking { }): AdminBooking {
return { return {
id: booking.id, id: booking.id,
@@ -235,8 +198,6 @@ function mapBookingResponse(booking: {
endTime: booking.endTime, endTime: booking.endTime,
customerName: booking.customerName, customerName: booking.customerName,
customerPhone: booking.customerPhone, customerPhone: booking.customerPhone,
customerEmail: booking.customerEmail,
price: booking.price ?? 0,
status: booking.status, status: booking.status,
createdAt: booking.createdAt.toISOString(), createdAt: booking.createdAt.toISOString(),
updatedAt: booking.updatedAt.toISOString(), updatedAt: booking.updatedAt.toISOString(),
@@ -297,16 +258,6 @@ export async function createAdminBooking(
input: CreateAdminBookingInput input: CreateAdminBookingInput
) { ) {
const complex = await ensureComplexAccess(complexId, userId); const complex = await ensureComplexAccess(complexId, userId);
const adminUser = await db.user.findUnique({
where: { id: userId },
select: { emailVerified: true },
});
if (!adminUser?.emailVerified) {
throw new AdminBookingServiceError('Debés verificar tu email para poder crear reservas.', 403);
}
const bookingDate = parseIsoDate(input.date); const bookingDate = parseIsoDate(input.date);
const dayOfWeek = getDayOfWeek(bookingDate); const dayOfWeek = getDayOfWeek(bookingDate);
@@ -331,10 +282,6 @@ export async function createAdminBooking(
slug: true, slug: true,
}, },
}, },
priceRules: {
where: { isActive: true },
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
},
}, },
}); });
@@ -424,7 +371,6 @@ export async function createAdminBooking(
endTime: selectedSlot.endTime, endTime: selectedSlot.endTime,
customerName: input.customerName.trim(), customerName: input.customerName.trim(),
customerPhone: input.customerPhone.trim(), customerPhone: input.customerPhone.trim(),
customerEmail: input.customerEmail.trim(),
status: 'CONFIRMED', status: 'CONFIRMED',
}, },
include: { include: {
@@ -451,9 +397,7 @@ export async function createAdminBooking(
}); });
}); });
const price = resolvePrice(court, dayOfWeek, selectedSlot.startTime, selectedSlot.endTime); return mapBookingResponse(booking);
return mapBookingResponse({ ...booking, price });
} catch (error) { } catch (error) {
if (error instanceof AdminBookingServiceError) { if (error instanceof AdminBookingServiceError) {
throw error; throw error;
@@ -550,43 +494,34 @@ export async function updateAdminBookingStatus(
); );
} }
if (input.status === 'NOSHOW' && booking.status !== 'CONFIRMED') { const updated = await db.courtBooking.update({
throw new AdminBookingServiceError( where: { id: booking.id },
'Solo se pueden marcar como no show las reservas confirmadas.',
409
);
}
await db.courtBookingLog.create({
data: { data: {
id: uuidv7(), status:
bookingCode: booking.bookingCode, input.status === 'COMPLETED' ? CourtBookingStatus.COMPLETED : CourtBookingStatus.CANCELLED,
courtId: booking.court.id, },
bookingDate: booking.bookingDate, include: {
startTime: booking.startTime, court: {
endTime: booking.endTime, select: {
customerName: booking.customerName, id: true,
customerPhone: booking.customerPhone, name: true,
customerEmail: booking.customerEmail, sport: {
previousStatus: booking.status, select: {
newStatus: input.status, id: true,
changedAt: new Date(), name: true,
slug: true,
},
},
complex: {
select: {
id: true,
complexName: true,
},
},
},
},
}, },
}); });
if (input.status === 'COMPLETED' || input.status === 'NOSHOW') { return mapBookingResponse(updated);
await db.courtBooking.update({
where: { id: booking.id },
data: {
status: input.status,
},
});
} else {
// if the booking is cancelled we delete it to free up the slot, but we keep a log of it with the cancelled status
await db.courtBooking.delete({
where: { id: booking.id },
});
}
return mapBookingResponse({ ...booking, status: input.status });
} }

View File

@@ -40,11 +40,6 @@ export async function createComplexHandler(c: AppContext) {
city: payload.city, city: payload.city,
state: payload.state, state: payload.state,
country: payload.country, country: payload.country,
setupCourts: payload.setupCourts,
courtSportId: payload.courtSportId,
courtStartTime: payload.courtStartTime,
courtEndTime: payload.courtEndTime,
courtDaysOfWeek: payload.courtDaysOfWeek,
}); });
return c.json(complex, 201); return c.json(complex, 201);

View File

@@ -11,47 +11,8 @@ export type CreateComplexInput = {
city?: string; city?: string;
state?: string; state?: string;
country?: string; country?: string;
setupCourts?: boolean;
courtSportId?: string;
courtStartTime?: string;
courtEndTime?: string;
courtDaysOfWeek?: string[];
}; };
type DayOfWeek = 'MONDAY' | 'TUESDAY' | 'WEDNESDAY' | 'THURSDAY' | 'FRIDAY' | 'SATURDAY' | 'SUNDAY';
function toMinutes(value: string): number {
const [hours, minutes] = value.split(':').map((part) => Number(part));
return hours * 60 + minutes;
}
function assertAvailabilityRanges(
availability: Array<{
dayOfWeek: DayOfWeek;
startTime: string;
endTime: string;
}>
) {
for (const range of availability) {
const start = toMinutes(range.startTime);
const end = toMinutes(range.endTime);
if (start >= end) {
throw new Error(`El rango ${range.startTime}-${range.endTime} es inválido.`);
}
}
}
async function ensureActiveSport(sportId: string) {
const sport = await db.sport.findFirst({
where: { id: sportId, isActive: true },
select: { id: true, name: true },
});
if (!sport) {
throw new Error('El deporte seleccionado no existe o está inactivo.');
}
return sport;
}
export type UpdateComplexInput = { export type UpdateComplexInput = {
complexName?: string; complexName?: string;
physicalAddress?: string | null; physicalAddress?: string | null;
@@ -133,38 +94,6 @@ export async function createComplex(input: CreateComplexInput) {
}); });
} }
if (input.setupCourts && input.courtSportId) {
const sport = await ensureActiveSport(input.courtSportId);
const availability = (input.courtDaysOfWeek ?? []).map((day) => ({
dayOfWeek: day as DayOfWeek,
startTime: input.courtStartTime ?? '08:00',
endTime: input.courtEndTime ?? '22:00',
}));
assertAvailabilityRanges(availability);
const court = await tx.court.create({
data: {
id: uuidv7(),
complexId: complex.id,
sportId: input.courtSportId,
name: `${sport.name} 1`,
slotDurationMinutes: 60,
basePrice: 0,
},
});
await tx.courtAvailability.createMany({
data: availability.map((avail) => ({
id: uuidv7(),
courtId: court.id,
dayOfWeek: avail.dayOfWeek,
startTime: avail.startTime,
endTime: avail.endTime,
})),
});
}
return complex; return complex;
}); });
} }

View File

@@ -0,0 +1,27 @@
import {
OnboardingError,
completeOnboarding,
} from '@/modules/onboarding/services/onboarding.service';
import type { AppContext } from '@/types/hono';
import type { OnboardingCompleteInput } from '@repo/api-contract';
export async function completeOnboardingHandler(c: AppContext) {
const payload = c.req.valid('json' as never) as OnboardingCompleteInput;
try {
const result = await completeOnboarding(payload);
return c.json({
message: 'Onboarding completado correctamente.',
...result,
});
} catch (error) {
if (error instanceof OnboardingError) {
return c.json({ message: error.message }, error.status);
}
if (error instanceof Error) {
return c.json({ message: error.message }, 400);
}
throw error;
}
}

View File

@@ -0,0 +1,25 @@
import {
OnboardingError,
resendOnboardingOtp,
} from '@/modules/onboarding/services/onboarding.service';
import type { AppContext } from '@/types/hono';
import type { OnboardingResendOtpInput } from '@repo/api-contract';
export async function resendOtpHandler(c: AppContext) {
const payload = c.req.valid('json' as never) as OnboardingResendOtpInput;
try {
const result = await resendOnboardingOtp(payload);
return c.json(result);
} catch (error) {
if (error instanceof OnboardingError) {
return c.json({ message: error.message }, error.status);
}
if (error instanceof Error) {
return c.json({ message: error.message }, 400);
}
throw error;
}
}

View File

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

View File

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

View File

@@ -0,0 +1,35 @@
import { completeOnboardingHandler } from '@/modules/onboarding/handlers/complete-onboarding.handler';
import { resendOtpHandler } from '@/modules/onboarding/handlers/resend-otp.handler';
import { startOnboardingHandler } from '@/modules/onboarding/handlers/start-onboarding.handler';
import { verifyOtpHandler } from '@/modules/onboarding/handlers/verify-otp.handler';
import type { AppEnv } from '@/types/hono';
import { zValidator } from '@hono/zod-validator';
import {
onboardingCompleteSchema,
onboardingResendOtpSchema,
onboardingStartSchema,
onboardingVerifyOtpSchema,
} from '@repo/api-contract';
import { Hono } from 'hono';
export const onboardingRoutes = new Hono<AppEnv>();
onboardingRoutes.post('/start', zValidator('json', onboardingStartSchema), startOnboardingHandler);
onboardingRoutes.post(
'/verify-otp',
zValidator('json', onboardingVerifyOtpSchema),
verifyOtpHandler
);
onboardingRoutes.post(
'/resend-otp',
zValidator('json', onboardingResendOtpSchema),
resendOtpHandler
);
onboardingRoutes.post(
'/complete',
zValidator('json', onboardingCompleteSchema),
completeOnboardingHandler
);

View File

@@ -0,0 +1,415 @@
import { createHash, randomInt } from 'node:crypto';
import { auth } from '@/lib/auth';
import { sendMail } from '@/lib/mailer';
import { db } from '@/lib/prisma';
import type {
OnboardingCompleteInput,
OnboardingResendOtpInput,
OnboardingStartInput,
OnboardingVerifyOtpInput,
} from '@repo/api-contract';
import { v7 as uuidv7 } from 'uuid';
const OTP_LENGTH = 6;
const OTP_TTL_MINUTES = Number(Bun.env.ONBOARDING_OTP_TTL_MINUTES ?? 10);
const OTP_MAX_ATTEMPTS = Number(Bun.env.ONBOARDING_OTP_MAX_ATTEMPTS ?? 5);
const OTP_RESEND_COOLDOWN_SECONDS = Number(Bun.env.ONBOARDING_OTP_RESEND_COOLDOWN_SECONDS ?? 30);
type VerifyOtpResult = {
message: string;
verified: boolean;
requestId: string;
email: string | null;
expiresAt: string | null;
remainingAttempts: number;
cooldownSeconds: number;
};
type ResendOtpResult = {
message: string;
requestId: string;
email: string;
expiresAt: string;
cooldownSeconds: number;
remainingAttempts: number;
};
type StartOnboardingResult = {
message: string;
requestId: string;
email: string;
expiresAt: string;
cooldownSeconds: number;
};
export class OnboardingError extends Error {
status: 400 | 404 | 409 | 429;
constructor(message: string, status: 400 | 404 | 409 | 429 = 400) {
super(message);
this.name = 'OnboardingError';
this.status = status;
}
}
function nowPlusMinutes(minutes: number): Date {
const date = new Date();
date.setMinutes(date.getMinutes() + minutes);
return date;
}
function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
function createOtpCode(): string {
return randomInt(0, 10 ** OTP_LENGTH)
.toString()
.padStart(OTP_LENGTH, '0');
}
function hashValue(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
function getRemainingAttempts(otpAttempts: number): number {
return Math.max(0, OTP_MAX_ATTEMPTS - otpAttempts);
}
function getCooldownSeconds(otpLastSentAt: Date): number {
const elapsedMs = Date.now() - otpLastSentAt.getTime();
const remainingMs = OTP_RESEND_COOLDOWN_SECONDS * 1000 - elapsedMs;
return Math.max(0, Math.ceil(remainingMs / 1000));
}
function slugify(value: string): string {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
}
async function buildUniqueComplexSlug(complexName: string): Promise<string> {
const base = slugify(complexName);
const fallback = base.length > 0 ? base : `complex-${uuidv7().slice(0, 8)}`;
let candidate = fallback;
let index = 1;
while (true) {
const existing = await db.complex.findFirst({
where: { complexSlug: candidate },
select: { id: true },
});
if (!existing) {
return candidate;
}
index += 1;
candidate = `${fallback}-${index}`;
}
}
async function sendOtpEmail(email: string, otpCode: string) {
await sendMail({
to: email,
subject: 'Codigo OTP para validar tu email',
text: `Tu codigo OTP es ${otpCode}. Vence en ${OTP_TTL_MINUTES} minutos.`,
html: `<p>Tu codigo OTP es <strong>${otpCode}</strong>.</p><p>Vence en ${OTP_TTL_MINUTES} minutos.</p>`,
});
}
export async function startOnboarding(input: OnboardingStartInput): Promise<StartOnboardingResult> {
const email = normalizeEmail(input.email);
const otpCode = createOtpCode();
const expiresAt = nowPlusMinutes(OTP_TTL_MINUTES);
const now = new Date();
const request = await db.onboardingRequest.create({
data: {
id: uuidv7(),
fullName: input.fullName.trim(),
email,
otpHash: hashValue(otpCode),
otpExpiresAt: expiresAt,
otpAttempts: 0,
otpLastSentAt: now,
otpResendCount: 0,
},
select: {
id: true,
email: true,
otpExpiresAt: true,
},
});
await sendOtpEmail(email, otpCode);
return {
message: 'Te enviamos un codigo OTP para validar tu direccion.',
requestId: request.id,
email: request.email,
expiresAt: request.otpExpiresAt.toISOString(),
cooldownSeconds: OTP_RESEND_COOLDOWN_SECONDS,
};
}
export async function verifyOnboardingOtp(
input: OnboardingVerifyOtpInput
): Promise<VerifyOtpResult> {
const request = await db.onboardingRequest.findUnique({
where: { id: input.requestId },
});
if (!request) {
return {
message: 'Solicitud de onboarding invalida o expirada.',
verified: false,
requestId: input.requestId,
email: null,
expiresAt: null,
remainingAttempts: 0,
cooldownSeconds: 0,
};
}
if (request.completedAt) {
return {
message: 'Este onboarding ya fue completado.',
verified: true,
requestId: request.id,
email: request.email,
expiresAt: request.otpExpiresAt.toISOString(),
remainingAttempts: getRemainingAttempts(request.otpAttempts),
cooldownSeconds: getCooldownSeconds(request.otpLastSentAt),
};
}
if (request.otpExpiresAt < new Date()) {
return {
message: 'El codigo OTP vencio. Solicita un nuevo codigo.',
verified: false,
requestId: request.id,
email: request.email,
expiresAt: request.otpExpiresAt.toISOString(),
remainingAttempts: getRemainingAttempts(request.otpAttempts),
cooldownSeconds: getCooldownSeconds(request.otpLastSentAt),
};
}
if (request.otpAttempts >= OTP_MAX_ATTEMPTS) {
return {
message: 'Se agotaron los intentos de OTP. Solicita un nuevo codigo.',
verified: false,
requestId: request.id,
email: request.email,
expiresAt: request.otpExpiresAt.toISOString(),
remainingAttempts: 0,
cooldownSeconds: getCooldownSeconds(request.otpLastSentAt),
};
}
const otpMatches = hashValue(input.otp) === request.otpHash;
if (!otpMatches) {
const updated = await db.onboardingRequest.update({
where: { id: request.id },
data: {
otpAttempts: {
increment: 1,
},
},
select: {
id: true,
email: true,
otpAttempts: true,
otpExpiresAt: true,
otpLastSentAt: true,
},
});
const remainingAttempts = getRemainingAttempts(updated.otpAttempts);
return {
message:
remainingAttempts > 0
? 'Codigo OTP invalido.'
: 'Se agotaron los intentos de OTP. Solicita un nuevo codigo.',
verified: false,
requestId: updated.id,
email: updated.email,
expiresAt: updated.otpExpiresAt.toISOString(),
remainingAttempts,
cooldownSeconds: getCooldownSeconds(updated.otpLastSentAt),
};
}
if (!request.emailVerifiedAt) {
await db.onboardingRequest.update({
where: { id: request.id },
data: { emailVerifiedAt: new Date() },
});
}
return {
message: 'Email verificado correctamente.',
verified: true,
requestId: request.id,
email: request.email,
expiresAt: request.otpExpiresAt.toISOString(),
remainingAttempts: getRemainingAttempts(request.otpAttempts),
cooldownSeconds: getCooldownSeconds(request.otpLastSentAt),
};
}
export async function resendOnboardingOtp(
input: OnboardingResendOtpInput
): Promise<ResendOtpResult> {
const request = await db.onboardingRequest.findUnique({
where: { id: input.requestId },
});
if (!request) {
throw new OnboardingError('Solicitud de onboarding invalida o expirada.', 404);
}
if (request.completedAt) {
throw new OnboardingError('Este onboarding ya fue completado.', 400);
}
if (request.emailVerifiedAt) {
throw new OnboardingError('El email ya fue verificado.', 400);
}
const cooldownSeconds = getCooldownSeconds(request.otpLastSentAt);
if (cooldownSeconds > 0) {
throw new OnboardingError(`Debes esperar ${cooldownSeconds}s antes de reenviar el OTP.`, 429);
}
const otpCode = createOtpCode();
const expiresAt = nowPlusMinutes(OTP_TTL_MINUTES);
const now = new Date();
const updated = await db.onboardingRequest.update({
where: { id: request.id },
data: {
otpHash: hashValue(otpCode),
otpExpiresAt: expiresAt,
otpAttempts: 0,
otpLastSentAt: now,
otpResendCount: {
increment: 1,
},
},
select: {
id: true,
email: true,
otpExpiresAt: true,
otpAttempts: true,
},
});
await sendOtpEmail(updated.email, otpCode);
return {
message: 'Te enviamos un nuevo codigo OTP.',
requestId: updated.id,
email: updated.email,
expiresAt: updated.otpExpiresAt.toISOString(),
cooldownSeconds: OTP_RESEND_COOLDOWN_SECONDS,
remainingAttempts: getRemainingAttempts(updated.otpAttempts),
};
}
export async function completeOnboarding(input: OnboardingCompleteInput) {
const onboardingRequest = await db.onboardingRequest.findUnique({
where: { id: input.onboardingRequestId },
});
if (!onboardingRequest) {
throw new OnboardingError('Solicitud de onboarding inválida o expirada.', 400);
}
if (onboardingRequest.otpExpiresAt < new Date()) {
throw new OnboardingError('La sesión de onboarding expiró. Solicita un nuevo OTP.', 400);
}
if (!onboardingRequest.emailVerifiedAt) {
throw new OnboardingError('Debes verificar el email antes de continuar.', 400);
}
if (onboardingRequest.completedAt) {
throw new OnboardingError('Este onboarding ya fue completado.', 400);
}
const plan = await db.plan.findUnique({
where: { code: input.planCode },
select: { code: true },
});
if (!plan) {
throw new OnboardingError('El plan seleccionado no existe.', 400);
}
const { user } = await auth.api.signUpEmail({
asResponse: false,
body: {
email: onboardingRequest.email,
password: input.password,
name: onboardingRequest.fullName,
},
});
const complexSlug = await buildUniqueComplexSlug(input.complexName);
const result = await db.$transaction(async (tx) => {
await tx.user.update({
where: { id: user.id },
data: { emailVerified: true },
});
const complex = await tx.complex.create({
data: {
id: uuidv7(),
complexName: input.complexName.trim(),
physicalAddress: input.physicalAddress.trim(),
city: input.city?.trim() || null,
state: input.state?.trim() || null,
country: input.country?.trim() || null,
complexSlug,
adminEmail: onboardingRequest.email,
planCode: input.planCode,
},
});
await tx.complexUser.create({
data: {
complexId: complex.id,
userId: user.id,
role: 'ADMIN',
},
});
await tx.onboardingRequest.update({
where: { id: onboardingRequest.id },
data: {
completedAt: new Date(),
},
});
return {
userId: user.id,
complexId: complex.id,
complexSlug: complex.complexSlug,
};
});
return {
...result,
};
}

View File

@@ -1,24 +1,23 @@
import { db } from '@/lib/prisma'; import { db } from '@/lib/prisma';
import { parsePlanRules } from '@/modules/plan/services/plan-rules.service';
import type { AppContext } from '@/types/hono'; import type { AppContext } from '@/types/hono';
export async function listPlansHandler(c: AppContext) { export async function listPlansHandler(c: AppContext) {
const plans = await db.plan.findMany({ const plans = await db.plan.findMany({
select: {
code: true,
name: true,
price: true,
},
orderBy: { orderBy: {
price: 'asc', price: 'asc',
}, },
}); });
return c.json( return c.json(
plans.map((plan) => { plans.map((plan) => ({
const rules = parsePlanRules(plan.rules); code: plan.code,
return { name: plan.name,
code: plan.code, price: Number(plan.price),
name: plan.name, }))
price: Number(plan.price),
features: rules.features,
limits: rules.limits,
};
})
); );
} }

View File

@@ -1,55 +0,0 @@
import { sseManager } from '@/lib/sse';
import {
PublicBookingServiceError,
cancelPublicBooking,
} from '@/modules/public-booking/services/public-booking.service';
import { sendBookingCancelled } from '@/services/booking-email.service';
import type { AppContext } from '@/types/hono';
import type { CancelPublicBookingInput } from '@repo/api-contract';
type ComplexSlugParams = { complexSlug: string };
export async function cancelPublicBookingHandler(c: AppContext) {
const { complexSlug } = c.req.valid('param' as never) as ComplexSlugParams;
const payload = c.req.valid('json' as never) as CancelPublicBookingInput;
try {
const booking = await cancelPublicBooking(complexSlug, payload);
const channel = `complex-${booking.complexId}`;
sseManager.emit(
channel,
JSON.stringify({
type: 'booking_cancelled',
booking: {
bookingCode: booking.bookingCode,
courtId: booking.courtId,
date: booking.date,
startTime: booking.startTime,
endTime: booking.endTime,
},
})
);
void sendBookingCancelled({
bookingCode: booking.bookingCode,
complexSlug: booking.complexSlug,
complexName: booking.complexName,
date: booking.date,
startTime: booking.startTime,
endTime: booking.endTime,
courtName: booking.courtName,
sportName: booking.sport.name,
customerName: booking.customerName,
customerEmail: booking.customerEmail,
});
return c.json(booking);
} catch (error) {
if (error instanceof PublicBookingServiceError) {
return c.json({ message: error.message }, error.status);
}
throw error;
}
}

View File

@@ -3,7 +3,6 @@ import {
PublicBookingServiceError, PublicBookingServiceError,
createPublicBooking, createPublicBooking,
} from '@/modules/public-booking/services/public-booking.service'; } from '@/modules/public-booking/services/public-booking.service';
import { sendBookingConfirmation } from '@/services/booking-email.service';
import type { AppContext } from '@/types/hono'; import type { AppContext } from '@/types/hono';
import type { CreatePublicBookingInput } from '@repo/api-contract'; import type { CreatePublicBookingInput } from '@repo/api-contract';
@@ -31,20 +30,6 @@ export async function createPublicBookingHandler(c: AppContext) {
}) })
); );
void sendBookingConfirmation({
bookingCode: booking.bookingCode,
complexSlug: booking.complexSlug,
complexName: booking.complexName,
date: booking.date,
startTime: booking.startTime,
endTime: booking.endTime,
courtName: booking.courtName,
sportName: booking.sport.name,
customerName: booking.customerName,
customerEmail: booking.customerEmail,
price: booking.price,
});
return c.json(booking, 201); return c.json(booking, 201);
} catch (error) { } catch (error) {
if (error instanceof PublicBookingServiceError) { if (error instanceof PublicBookingServiceError) {

View File

@@ -1,14 +1,9 @@
import { cancelPublicBookingHandler } from '@/modules/public-booking/handlers/cancel-public-booking.handler';
import { createPublicBookingHandler } from '@/modules/public-booking/handlers/create-public-booking.handler'; import { createPublicBookingHandler } from '@/modules/public-booking/handlers/create-public-booking.handler';
import { getPublicBookingConfirmationHandler } from '@/modules/public-booking/handlers/get-public-booking-confirmation.handler'; import { getPublicBookingConfirmationHandler } from '@/modules/public-booking/handlers/get-public-booking-confirmation.handler';
import { listPublicAvailabilityHandler } from '@/modules/public-booking/handlers/list-public-availability.handler'; import { listPublicAvailabilityHandler } from '@/modules/public-booking/handlers/list-public-availability.handler';
import type { AppEnv } from '@/types/hono'; import type { AppEnv } from '@/types/hono';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
import { import { createPublicBookingSchema, publicAvailabilityQuerySchema } from '@repo/api-contract';
cancelPublicBookingSchema,
createPublicBookingSchema,
publicAvailabilityQuerySchema,
} from '@repo/api-contract';
import { Hono } from 'hono'; import { Hono } from 'hono';
import { z } from 'zod'; import { z } from 'zod';
@@ -38,10 +33,3 @@ publicBookingRoutes.get(
zValidator('param', confirmationParamsSchema), zValidator('param', confirmationParamsSchema),
getPublicBookingConfirmationHandler getPublicBookingConfirmationHandler
); );
publicBookingRoutes.post(
'/complex/:complexSlug/cancel',
zValidator('param', complexSlugParamsSchema),
zValidator('json', cancelPublicBookingSchema),
cancelPublicBookingHandler
);

View File

@@ -2,7 +2,6 @@ import { randomInt } from 'node:crypto';
import { db } from '@/lib/prisma'; import { db } from '@/lib/prisma';
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service'; import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service';
import type { import type {
CancelPublicBookingInput,
CreatePublicBookingInput, CreatePublicBookingInput,
DayOfWeek, DayOfWeek,
PublicAvailabilityQuery, PublicAvailabilityQuery,
@@ -14,16 +13,6 @@ type Slot = {
endTime: string; endTime: string;
}; };
type PriceableCourt = {
basePrice: unknown;
priceRules: Array<{
dayOfWeek: DayOfWeek | null;
startTime: string | null;
endTime: string | null;
price: unknown;
}>;
};
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[] = [
@@ -43,6 +32,7 @@ export class PublicBookingServiceError extends Error {
constructor(message: string, status: 400 | 403 | 404 | 409) { constructor(message: string, status: 400 | 403 | 404 | 409) {
super(message); super(message);
this.name = 'PublicBookingServiceError';
this.status = status; this.status = status;
} }
} }
@@ -240,33 +230,6 @@ async function getComplexWithBookingData(complexSlug: string) {
return complex; return complex;
} }
function resolveSlotPrice(court: PriceableCourt, dayOfWeek: DayOfWeek, slot: Slot): number {
const slotStart = toMinutes(slot.startTime);
const slotEnd = toMinutes(slot.endTime);
const matchingRules = court.priceRules
.filter((rule) => {
if (rule.dayOfWeek && rule.dayOfWeek !== dayOfWeek) {
return false;
}
if (!rule.startTime || !rule.endTime) {
return true;
}
return slotStart >= toMinutes(rule.startTime) && slotEnd <= toMinutes(rule.endTime);
})
.sort((first, second) => {
const firstSpecificity =
(first.dayOfWeek ? 2 : 0) + (first.startTime && first.endTime ? 1 : 0);
const secondSpecificity =
(second.dayOfWeek ? 2 : 0) + (second.startTime && second.endTime ? 1 : 0);
return secondSpecificity - firstSpecificity;
});
return Number(matchingRules[0]?.price ?? court.basePrice);
}
function mapBookingResponse(input: { function mapBookingResponse(input: {
bookingId: string; bookingId: string;
bookingCode: string; bookingCode: string;
@@ -274,11 +237,10 @@ function mapBookingResponse(input: {
createdAt: Date; createdAt: Date;
startTime: string; startTime: string;
endTime: string; endTime: string;
price: number;
customerName: string; customerName: string;
customerPhone: string; customerPhone: string;
customerEmail: string; status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED';
price: number;
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NOSHOW';
court: { court: {
id: string; id: string;
name: string; name: string;
@@ -304,11 +266,10 @@ function mapBookingResponse(input: {
date: formatIsoDate(input.bookingDate), date: formatIsoDate(input.bookingDate),
startTime: input.startTime, startTime: input.startTime,
endTime: input.endTime, endTime: input.endTime,
price: input.price,
customerName: input.customerName, customerName: input.customerName,
customerPhone: input.customerPhone, customerPhone: input.customerPhone,
customerEmail: input.customerEmail,
status: input.status, status: input.status,
price: input.price,
createdAt: input.createdAt.toISOString(), createdAt: input.createdAt.toISOString(),
}; };
} }
@@ -329,17 +290,12 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC
bookingDate: true, bookingDate: true,
startTime: true, startTime: true,
endTime: true, endTime: true,
customerEmail: true, price: true,
status: true, status: true,
createdAt: true, createdAt: true,
court: { court: {
select: { select: {
name: true, name: true,
basePrice: true,
priceRules: {
where: { isActive: true },
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
},
sport: { sport: {
select: { select: {
id: true, id: true,
@@ -350,7 +306,6 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC
complex: { complex: {
select: { select: {
complexName: true, complexName: true,
physicalAddress: true,
complexSlug: true, complexSlug: true,
}, },
}, },
@@ -363,22 +318,13 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC
throw new PublicBookingServiceError('Reserva no encontrada.', 404); throw new PublicBookingServiceError('Reserva no encontrada.', 404);
} }
const date = formatIsoDate(booking.bookingDate);
const { dayOfWeek } = parseIsoDate(date);
const price = resolveSlotPrice(booking.court, dayOfWeek, {
startTime: booking.startTime,
endTime: booking.endTime,
});
return { return {
bookingCode: booking.bookingCode, bookingCode: booking.bookingCode,
complexName: booking.court.complex.complexName, complexName: booking.court.complex.complexName,
complexAddress: booking.court.complex.physicalAddress ?? undefined,
complexSlug: booking.court.complex.complexSlug, complexSlug: booking.court.complex.complexSlug,
date, date: formatIsoDate(booking.bookingDate),
startTime: booking.startTime, startTime: booking.startTime,
endTime: booking.endTime, endTime: booking.endTime,
price,
courtName: booking.court.name, courtName: booking.court.name,
sport: { sport: {
id: booking.court.sport.id, id: booking.court.sport.id,
@@ -386,39 +332,14 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC
slug: booking.court.sport.slug, slug: booking.court.sport.slug,
}, },
status: booking.status, status: booking.status,
customerEmail: booking.customerEmail, price: Number(booking.price),
createdAt: booking.createdAt.toISOString(), createdAt: booking.createdAt.toISOString(),
}; };
} }
async function hasVerifiedAdmin(complexId: string): Promise<boolean> {
const verifiedAdmin = await db.complexUser.findFirst({
where: {
complexId,
role: 'ADMIN',
user: { emailVerified: true },
},
});
return verifiedAdmin !== null;
}
export async function listPublicAvailability(complexSlug: string, query: PublicAvailabilityQuery) { export async function listPublicAvailability(complexSlug: string, query: PublicAvailabilityQuery) {
const { bookingDate, dayOfWeek } = parseIsoDate(query.date); const { bookingDate, dayOfWeek } = parseIsoDate(query.date);
const complex = await getComplexWithBookingData(complexSlug); const complex = await getComplexWithBookingData(complexSlug);
if (!(await hasVerifiedAdmin(complex.id))) {
return {
complexId: complex.id,
complexName: complex.complexName,
complexAddress: complex.physicalAddress ?? null,
complexSlug: complex.complexSlug,
date: query.date,
sportSelectionRequired: false,
sports: [],
courts: [],
};
}
const sports = resolveSports(complex); const sports = resolveSports(complex);
const sportSelectionRequired = sports.length > 1; const sportSelectionRequired = sports.length > 1;
@@ -484,6 +405,50 @@ export async function listPublicAvailability(complexSlug: string, query: PublicA
(slot) => !occupiedSlots.some((occupied) => hasOverlap(slot, occupied)) (slot) => !occupiedSlots.some((occupied) => hasOverlap(slot, occupied))
); );
const basePrice = Number(court.basePrice);
const priceRuleMap = new Map<string, number>();
for (const rule of court.priceRules) {
const ruleDay = rule.dayOfWeek;
const ruleStart = rule.startTime;
const ruleEnd = rule.endTime;
const price = Number(rule.price);
if (ruleStart && ruleEnd && ruleDay) {
const startMin = toMinutes(ruleStart);
const endMin = toMinutes(ruleEnd);
for (let mins = startMin; mins < endMin; mins += 30) {
priceRuleMap.set(`${ruleDay}-${minutesToTime(mins)}`, price);
}
} else if (ruleDay && !ruleStart && !ruleEnd) {
priceRuleMap.set(`day-${ruleDay}`, price);
}
}
const calcPrice = (startTime: string) => {
if (!basePrice || basePrice <= 0) {
return 0;
}
const timeKey = `${dayOfWeek}-${startTime}`;
const dayKey = `day-${dayOfWeek}`;
return priceRuleMap.get(timeKey) ?? priceRuleMap.get(dayKey) ?? basePrice;
};
const price =
availableSlots.length > 0
? (() => {
const prices = availableSlots.map((slot) => calcPrice(slot.startTime));
const validPrices = prices.filter((p) => p > 0);
if (validPrices.length === 0) {
return undefined;
}
return {
min: Math.min(...validPrices),
max: Math.max(...validPrices),
};
})()
: undefined;
return { return {
courtId: court.id, courtId: court.id,
courtName: court.name, courtName: court.name,
@@ -494,10 +459,8 @@ export async function listPublicAvailability(complexSlug: string, query: PublicA
}, },
slotDurationMinutes: court.slotDurationMinutes, slotDurationMinutes: court.slotDurationMinutes,
availabilityDay: dayOfWeek, availabilityDay: dayOfWeek,
availableSlots: availableSlots.map((slot) => ({ availableSlots,
...slot, price,
price: resolveSlotPrice(court, dayOfWeek, slot),
})),
}; };
}) })
.filter((court) => court.availableSlots.length > 0); .filter((court) => court.availableSlots.length > 0);
@@ -517,14 +480,6 @@ export async function listPublicAvailability(complexSlug: string, query: PublicA
export async function createPublicBooking(complexSlug: string, input: CreatePublicBookingInput) { export async function createPublicBooking(complexSlug: string, input: CreatePublicBookingInput) {
const { bookingDate, dayOfWeek } = parseIsoDate(input.date); const { bookingDate, dayOfWeek } = parseIsoDate(input.date);
const complex = await getComplexWithBookingData(complexSlug); const complex = await getComplexWithBookingData(complexSlug);
if (!(await hasVerifiedAdmin(complex.id))) {
throw new PublicBookingServiceError(
'El complejo no puede recibir reservas hasta que un administrador verifique su email.',
403
);
}
const sports = resolveSports(complex); const sports = resolveSports(complex);
const { sportSelectionRequired } = validateSportSelection(sports, input.sportId); const { sportSelectionRequired } = validateSportSelection(sports, input.sportId);
@@ -574,6 +529,60 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
); );
} }
function calcBookingPrice(
court: {
basePrice: number | null;
priceRules: Array<{
dayOfWeek: string | null;
startTime: string | null;
endTime: string | null;
price: number;
}>;
},
dayOfWeek: string,
startTime: string
): number {
if (!court.basePrice) {
return 0;
}
const slotMinutes = toMinutes(startTime);
const exactMatch = court.priceRules.find((rule) => {
if (!rule.dayOfWeek || rule.dayOfWeek !== dayOfWeek) {
return false;
}
if (!rule.startTime || !rule.endTime) {
return false;
}
const ruleStart = toMinutes(rule.startTime);
const ruleEnd = toMinutes(rule.endTime);
return slotMinutes >= ruleStart && slotMinutes < ruleEnd;
});
if (exactMatch) {
return Number(exactMatch.price);
}
const dayMatch = court.priceRules.find((rule) => {
if (!rule.dayOfWeek || rule.dayOfWeek !== dayOfWeek) {
return false;
}
if (rule.startTime || rule.endTime) {
return false;
}
return true;
});
if (dayMatch) {
return Number(dayMatch.price);
}
return Number(court.basePrice);
}
const bookingPrice = calcBookingPrice(selectedCourt, dayOfWeek, selectedSlot.startTime);
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) => {
@@ -629,9 +638,9 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
bookingDate, bookingDate,
startTime: selectedSlot.startTime, startTime: selectedSlot.startTime,
endTime: selectedSlot.endTime, endTime: selectedSlot.endTime,
price: bookingPrice,
customerName: input.customerName.trim(), customerName: input.customerName.trim(),
customerPhone: input.customerPhone.trim(), customerPhone: input.customerPhone.trim(),
customerEmail: input.customerEmail.trim(),
status: 'CONFIRMED', status: 'CONFIRMED',
}, },
select: { select: {
@@ -641,16 +650,14 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
createdAt: true, createdAt: true,
startTime: true, startTime: true,
endTime: true, endTime: true,
price: true,
customerName: true, customerName: true,
customerPhone: true, customerPhone: true,
customerEmail: true,
status: true, status: true,
}, },
}); });
}); });
const price = resolveSlotPrice(selectedCourt, dayOfWeek, selectedSlot);
return mapBookingResponse({ return mapBookingResponse({
bookingId: booking.id, bookingId: booking.id,
bookingCode: booking.bookingCode, bookingCode: booking.bookingCode,
@@ -658,10 +665,9 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
createdAt: booking.createdAt, createdAt: booking.createdAt,
startTime: booking.startTime, startTime: booking.startTime,
endTime: booking.endTime, endTime: booking.endTime,
price: Number(booking.price),
customerName: booking.customerName, customerName: booking.customerName,
customerPhone: booking.customerPhone, customerPhone: booking.customerPhone,
customerEmail: booking.customerEmail,
price,
status: booking.status, status: booking.status,
court: { court: {
id: selectedCourt.id, id: selectedCourt.id,
@@ -712,108 +718,3 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
409 409
); );
} }
export async function cancelPublicBooking(complexSlug: string, input: CancelPublicBookingInput) {
const normalizedBookingCode = input.bookingCode.toUpperCase();
const booking = await db.courtBooking.findFirst({
where: {
bookingCode: normalizedBookingCode,
court: {
complex: {
complexSlug,
},
},
},
include: {
court: {
select: {
id: true,
name: true,
basePrice: true,
priceRules: {
where: { isActive: true },
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
},
sport: {
select: {
id: true,
name: true,
slug: true,
},
},
complex: {
select: {
id: true,
complexName: true,
complexSlug: true,
},
},
},
},
},
});
if (!booking) {
throw new PublicBookingServiceError('Reserva no encontrada.', 404);
}
if (booking.status !== 'CONFIRMED') {
throw new PublicBookingServiceError(
'Solo se pueden cancelar reservas en estado confirmada.',
409
);
}
if (booking.customerPhone !== input.customerPhone.trim()) {
throw new PublicBookingServiceError('Los datos ingresados no coinciden con la reserva.', 403);
}
const date = formatIsoDate(booking.bookingDate);
const { dayOfWeek } = parseIsoDate(date);
const price = resolveSlotPrice(booking.court, dayOfWeek, {
startTime: booking.startTime,
endTime: booking.endTime,
});
await db.courtBookingLog.create({
data: {
id: uuidv7(),
bookingCode: booking.bookingCode,
courtId: booking.courtId,
bookingDate: booking.bookingDate,
startTime: booking.startTime,
endTime: booking.endTime,
customerName: booking.customerName,
customerPhone: booking.customerPhone,
customerEmail: booking.customerEmail,
previousStatus: booking.status,
newStatus: 'CANCELLED',
},
});
await db.courtBooking.delete({
where: { id: booking.id },
});
return mapBookingResponse({
bookingId: booking.id,
bookingCode: booking.bookingCode,
bookingDate: booking.bookingDate,
createdAt: booking.createdAt,
startTime: booking.startTime,
endTime: booking.endTime,
customerName: booking.customerName,
customerPhone: booking.customerPhone,
customerEmail: booking.customerEmail,
price,
status: 'CANCELLED',
court: {
id: booking.court.id,
name: booking.court.name,
complexId: booking.court.complex.id,
complexName: booking.court.complex.complexName,
complexSlug: booking.court.complex.complexSlug,
sport: booking.court.sport,
},
});
}

View File

@@ -1,10 +1,18 @@
import { Prisma } from '@/generated/prisma/client'; import { Prisma } from '@/generated/prisma/client';
import { handleResult } from '@/lib/http/handle-result';
import { createSport } from '@/modules/sport/services/sport.service'; import { createSport } from '@/modules/sport/services/sport.service';
import type { AppContext } from '@/types/hono'; import type { AppContext } from '@/types/hono';
import type { CreateSportInput } from '@repo/api-contract'; 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;
return handleResult(c, await createSport(payload), 201);
try {
const sport = await createSport(payload);
return c.json(sport, 201);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
return c.json({ message: 'No se pudo crear el deporte.' }, 409);
}
throw error;
}
} }

View File

@@ -1,8 +1,7 @@
import { handleResult } from '@/lib/http/handle-result';
import { listSports } from '@/modules/sport/services/sport.service'; import { listSports } from '@/modules/sport/services/sport.service';
import type { AppContext } from '@/types/hono'; 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 handleResult(c, sports); return c.json(sports);
} }

View File

@@ -1,7 +1,5 @@
import { Prisma, Sport } from '@/generated/prisma/client'; import { Prisma } from '@/generated/prisma/client';
import { Errors } from '@/lib/errors';
import { db } from '@/lib/prisma'; import { db } from '@/lib/prisma';
import { Result, err, ok } from '@/lib/result';
import { v7 as uuidv7 } from 'uuid'; import { v7 as uuidv7 } from 'uuid';
export type CreateSportInput = { export type CreateSportInput = {
@@ -53,25 +51,16 @@ async function buildUniqueSlug(source: string, excludeSportId?: string): Promise
} }
} }
export async function listSports(): Promise<Result<Sport[]>> { export async function listSports() {
const sports = await db.sport.findMany({ return db.sport.findMany({
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
}); });
return ok(sports);
} }
export async function createSport(input: CreateSportInput) { export async function createSport(input: CreateSportInput) {
const slug = await buildUniqueSlug(input.name); const slug = await buildUniqueSlug(input.name);
const existing = await db.sport.count({ return db.sport.create({
where: { slug },
});
if (existing > 0) {
return err(Errors.conflict('Ya existe un deporte con ese nombre.'));
}
const created = await db.sport.create({
data: { data: {
id: uuidv7(), id: uuidv7(),
name: input.name.trim(), name: input.name.trim(),
@@ -79,8 +68,6 @@ export async function createSport(input: CreateSportInput) {
isActive: true, isActive: true,
}, },
}); });
return ok(created);
} }
export async function getSportById(id: string) { export async function getSportById(id: string) {

View File

@@ -1,4 +1,3 @@
import { validate } from '@/lib/http/validate';
import { requireAuth } from '@/middlewares/require-auth.middleware'; import { requireAuth } from '@/middlewares/require-auth.middleware';
import { requireSuperAdmin } from '@/middlewares/require-super-admin.middleware'; import { requireSuperAdmin } from '@/middlewares/require-super-admin.middleware';
import { createSportHandler } from '@/modules/sport/handlers/create-sport.handler'; import { createSportHandler } from '@/modules/sport/handlers/create-sport.handler';
@@ -9,17 +8,18 @@ import { zValidator } from '@hono/zod-validator';
import { createSportSchema, updateSportSchema } from '@repo/api-contract'; import { createSportSchema, updateSportSchema } from '@repo/api-contract';
import { Hono } from 'hono'; import { Hono } from 'hono';
import { z } from 'zod'; 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('/', requireSuperAdmin, validate.json(createSportSchema), createSportHandler); sportRoutes.post('/', requireSuperAdmin, zValidator('json', createSportSchema), createSportHandler);
sportRoutes.patch( sportRoutes.patch(
'/:id', '/:id',
requireSuperAdmin, requireSuperAdmin,
validate.param(sportIdParamsSchema), zValidator('param', sportIdParamsSchema),
validate.json(updateSportSchema), zValidator('json', updateSportSchema),
updateSportHandler updateSportHandler
); );

View File

@@ -3,6 +3,7 @@ import { adminBookingRoutes } from '@/modules/admin-booking/admin-booking.routes
import { authRoutes } from '@/modules/auth/auth.routes'; import { authRoutes } from '@/modules/auth/auth.routes';
import { complexRoutes } from '@/modules/complex/complex.routes'; import { complexRoutes } from '@/modules/complex/complex.routes';
import { courtRoutes } from '@/modules/court/court.routes'; import { courtRoutes } from '@/modules/court/court.routes';
import { onboardingRoutes } from '@/modules/onboarding/onboarding.routes';
import { passwordResetRoutes } from '@/modules/password-reset/password-reset.routes'; import { passwordResetRoutes } from '@/modules/password-reset/password-reset.routes';
import { planRoutes } from '@/modules/plan/plan.routes'; import { planRoutes } from '@/modules/plan/plan.routes';
import { publicBookingRoutes } from '@/modules/public-booking/public-booking.routes'; import { publicBookingRoutes } from '@/modules/public-booking/public-booking.routes';
@@ -19,6 +20,7 @@ export function registerRoutes(app: Hono<AppEnv>) {
.route('', authRoutes) .route('', authRoutes)
.route('/api/user', userRoutes) .route('/api/user', userRoutes)
.route('/api/plans', planRoutes) .route('/api/plans', planRoutes)
.route('/api/onboarding', onboardingRoutes)
.route('/api/password-reset', passwordResetRoutes) .route('/api/password-reset', passwordResetRoutes)
.route('/api/complexes', complexRoutes) .route('/api/complexes', complexRoutes)
.route('/api/sports', sportRoutes) .route('/api/sports', sportRoutes)

View File

@@ -1,88 +0,0 @@
import {
bookingCancelledHtml,
bookingConfirmationHtml,
bookingNoShowHtml,
} from '@/emails/booking-confirmation';
import { sendMail } from '@/lib/mailer';
type BookingEmailInput = {
bookingCode: string;
complexSlug?: string;
complexName: string;
date: string;
startTime: string;
endTime: string;
courtName: string;
sportName: string;
customerName: string;
customerEmail: string;
price?: number;
};
export async function sendBookingConfirmation(data: BookingEmailInput) {
if (!data.customerEmail) return;
const html = bookingConfirmationHtml({
bookingCode: data.bookingCode,
complexSlug: data.complexSlug,
complexName: data.complexName,
date: data.date,
startTime: data.startTime,
endTime: data.endTime,
courtName: data.courtName,
sportName: data.sportName,
customerName: data.customerName,
price: data.price,
});
await sendMail({
to: data.customerEmail,
subject: `Reserva confirmada en ${data.complexName} | Playzer`,
html,
text: `Reserva confirmada en ${data.complexName}. Codigo: ${data.bookingCode}. Cancha: ${data.courtName}. Fecha: ${data.date}. Horario: ${data.startTime} - ${data.endTime}.`,
});
}
export async function sendBookingCancelled(data: BookingEmailInput) {
if (!data.customerEmail) return;
const html = bookingCancelledHtml({
bookingCode: data.bookingCode,
complexName: data.complexName,
date: data.date,
startTime: data.startTime,
endTime: data.endTime,
courtName: data.courtName,
sportName: data.sportName,
customerName: data.customerName,
});
await sendMail({
to: data.customerEmail,
subject: `Reserva cancelada en ${data.complexName} | Playzer`,
html,
text: `Reserva cancelada en ${data.complexName}. Codigo: ${data.bookingCode}. Fecha: ${data.date}. Horario: ${data.startTime} - ${data.endTime}.`,
});
}
export async function sendBookingNoShow(data: BookingEmailInput) {
if (!data.customerEmail) return;
const html = bookingNoShowHtml({
bookingCode: data.bookingCode,
complexName: data.complexName,
date: data.date,
startTime: data.startTime,
endTime: data.endTime,
courtName: data.courtName,
sportName: data.sportName,
customerName: data.customerName,
});
await sendMail({
to: data.customerEmail,
subject: `Reserva no concretada en ${data.complexName} | Playzer`,
html,
text: `Reserva no concretada en ${data.complexName}. Codigo: ${data.bookingCode}. Fecha: ${data.date}. Horario: ${data.startTime} - ${data.endTime}.`,
});
}

View File

@@ -1,5 +1,6 @@
import { beforeEach, expect, mock, test } from 'bun:test'; import { beforeEach, expect, mock, test } from 'bun:test';
import { createInviteComplexUserHandler } from '@/modules/complex/handlers/invite-complex-user.handler';
import type { InviteComplexUserResponse } from '@repo/api-contract'; import type { InviteComplexUserResponse } from '@repo/api-contract';
const inviteComplexUserMock = mock(async () => undefined as unknown as InviteComplexUserResponse); const inviteComplexUserMock = mock(async () => undefined as unknown as InviteComplexUserResponse);
@@ -14,10 +15,6 @@ class MockComplexMembersError extends Error {
} }
} }
const { createInviteComplexUserHandler } = await import(
'@/modules/complex/handlers/invite-complex-user.handler'
);
const inviteComplexUserHandler = createInviteComplexUserHandler({ const inviteComplexUserHandler = createInviteComplexUserHandler({
inviteComplexUser: inviteComplexUserMock, inviteComplexUser: inviteComplexUserMock,
ComplexMembersError: MockComplexMembersError, ComplexMembersError: MockComplexMembersError,
@@ -62,6 +59,7 @@ function createContext(input: {
beforeEach(() => { beforeEach(() => {
inviteComplexUserMock.mockReset(); inviteComplexUserMock.mockReset();
mock.clearAllMocks();
}); });
test('returns 201 with the service result', async () => { test('returns 201 with the service result', async () => {

View File

@@ -1,15 +1,14 @@
import { beforeEach, expect, test } from 'bun:test'; import { beforeEach, expect, mock, test } from 'bun:test';
import {
ComplexMembersError,
inviteComplexUser,
} from '@/modules/complex/services/complex-members.service';
import { prismaMock, sendMailMock, transactionMock } from '../support/prisma.mock'; import { prismaMock, sendMailMock, transactionMock } from '../support/prisma.mock';
const { ComplexMembersError, inviteComplexUser } = await import(
'@/modules/complex/services/complex-members.service'
);
beforeEach(() => { beforeEach(() => {
prismaMock._reset(); prismaMock._reset();
sendMailMock.mockClear(); mock.clearAllMocks();
transactionMock.mockClear();
}); });
test('creates a pending invitation and sends the email', async () => { test('creates a pending invitation and sends the email', async () => {
@@ -22,10 +21,10 @@ test('creates a pending invitation and sends the email', async () => {
complexId: 'complex-1', complexId: 'complex-1',
email: 'new.member@example.com', email: 'new.member@example.com',
tokenHash: 'token-hash', tokenHash: 'token-hash',
expiresAt: new Date('2027-01-01T00:00:00.000Z'), expiresAt: new Date('2026-04-29T00:00:00.000Z'),
acceptedAt: null, acceptedAt: null,
revokedAt: null, revokedAt: null,
createdAt: new Date('2026-12-01T00:00:00.000Z'), createdAt: new Date('2026-04-22T00:00:00.000Z'),
} as never); } as never);
const result = await inviteComplexUser('admin-1', 'complex-1', { const result = await inviteComplexUser('admin-1', 'complex-1', {
@@ -80,7 +79,7 @@ test('rejects when the invite target already belongs to the complex', async () =
} }
expect(caught).toBeInstanceOf(ComplexMembersError); expect(caught).toBeInstanceOf(ComplexMembersError);
expect((caught as InstanceType<typeof ComplexMembersError>).status).toBe(409); expect((caught as ComplexMembersError).status).toBe(409);
expect(transactionMock).not.toHaveBeenCalled(); expect(transactionMock).not.toHaveBeenCalled();
expect(sendMailMock).not.toHaveBeenCalled(); expect(sendMailMock).not.toHaveBeenCalled();
}); });

View File

@@ -1,94 +0,0 @@
import { beforeEach, expect, mock, test } from 'bun:test';
import { Errors } from '@/lib/errors';
import { err, ok } from '@/lib/result';
import type { CreateSportInput } from '@repo/api-contract';
const createSportMock = mock(async (_input: CreateSportInput) =>
ok({
id: 'sport-1',
name: 'Tenis',
slug: 'tenis',
isActive: true,
createdAt: '2026-04-23T00:00:00.000Z',
updatedAt: '2026-04-23T00:00:00.000Z',
})
);
mock.module('@/modules/sport/services/sport.service', () => ({
__esModule: true,
createSport: createSportMock,
}));
const { createSportHandler } = await import('@/modules/sport/handlers/create-sport.handler');
type HandlerContext = {
get: (key: 'requestId') => string | undefined;
req: {
valid: () => unknown;
};
json: ReturnType<typeof mock>;
};
function createContext(body: unknown) {
const json = mock((payload: unknown, init?: unknown) => ({
payload,
init,
}));
const c = {
get: () => undefined,
req: {
valid: () => body,
},
json,
} as unknown as HandlerContext;
return { c, json };
}
beforeEach(() => {
createSportMock.mockReset();
});
test('returns 201 with the created sport', async () => {
const createdSport = {
id: 'sport-1',
name: 'Tenis',
slug: 'tenis',
isActive: true,
createdAt: '2026-04-23T00:00:00.000Z',
updatedAt: '2026-04-23T00:00:00.000Z',
} as const;
createSportMock.mockResolvedValue(ok(createdSport));
const payload: CreateSportInput = { name: ' Tenis ' };
const { c, json } = createContext(payload);
const response = await createSportHandler(c as never);
expect(createSportMock).toHaveBeenCalledWith(payload);
expect(json.mock.calls[0]?.[0]).toEqual(createdSport);
expect(json.mock.calls[0]?.[1]).toBe(201);
expect(response).toBeDefined();
});
test('returns a conflict problem when the service rejects with a conflict error', async () => {
createSportMock.mockResolvedValue(err(Errors.conflict('Ya existe un deporte con ese nombre.')));
const payload: CreateSportInput = { name: 'Tenis' };
const { c, json } = createContext(payload);
const response = await createSportHandler(c as never);
expect(createSportMock).toHaveBeenCalledWith(payload);
expect(json.mock.calls[0]?.[0]).toEqual({
type: 'https://api.myapp.dev/problems/conflict',
title: 'Conflict',
status: 409,
detail: 'Ya existe un deporte con ese nombre.',
});
expect(json.mock.calls[0]?.[1]).toBe(409);
expect(response).toBeDefined();
});

View File

@@ -1,181 +0,0 @@
import { beforeEach, expect, test } from 'bun:test';
import { prismaMock, uuidV7Mock } from '../support/prisma.mock';
const { createSport, getSportById, listSports, updateSport } = await import(
'@/modules/sport/services/sport.service'
);
beforeEach(() => {
prismaMock._reset();
uuidV7Mock.mockClear();
});
test('listSports returns sports ordered by name', async () => {
const sports = [
{
id: 'sport-2',
name: 'Tenis',
slug: 'tenis',
isActive: true,
createdAt: new Date('2026-04-22T00:00:00.000Z'),
updatedAt: new Date('2026-04-22T00:00:00.000Z'),
},
{
id: 'sport-1',
name: 'Básquet',
slug: 'basquet',
isActive: true,
createdAt: new Date('2026-04-21T00:00:00.000Z'),
updatedAt: new Date('2026-04-21T00:00:00.000Z'),
},
];
prismaMock.sport.findMany.mockResolvedValue(sports as never);
const result = await listSports();
expect(prismaMock.sport.findMany).toHaveBeenCalledWith({
orderBy: { name: 'asc' },
});
expect(result).toEqual({
ok: true,
value: sports,
});
});
test('getSportById returns the requested sport', async () => {
const sport = {
id: 'sport-1',
name: 'Tenis',
slug: 'tenis',
isActive: true,
createdAt: new Date('2026-04-22T00:00:00.000Z'),
updatedAt: new Date('2026-04-22T00:00:00.000Z'),
};
prismaMock.sport.findUnique.mockResolvedValue(sport as never);
const result = await getSportById('sport-1');
expect(prismaMock.sport.findUnique).toHaveBeenCalledWith({
where: { id: 'sport-1' },
});
expect(result).toEqual(sport);
});
test('createSport trims the name and creates a unique slug', async () => {
const createdSport = {
id: '00000000-0000-4000-8000-000000000001',
name: 'Fútbol 7!!',
slug: 'futbol-7',
isActive: true,
createdAt: new Date('2026-04-23T00:00:00.000Z'),
updatedAt: new Date('2026-04-23T00:00:00.000Z'),
};
prismaMock.sport.findFirst.mockResolvedValue(null as never);
prismaMock.sport.count.mockResolvedValue(0 as never);
prismaMock.sport.create.mockResolvedValue(createdSport as never);
const result = await createSport({ name: ' Fútbol 7!! ' });
expect(uuidV7Mock).toHaveBeenCalledTimes(1);
expect(prismaMock.sport.findFirst).toHaveBeenCalledWith({
where: { slug: 'futbol-7' },
select: { id: true },
});
expect(prismaMock.sport.count).toHaveBeenCalledWith({
where: { slug: 'futbol-7' },
});
expect(prismaMock.sport.create).toHaveBeenCalledWith({
data: {
id: '00000000-0000-4000-8000-000000000001',
name: 'Fútbol 7!!',
slug: 'futbol-7',
isActive: true,
},
});
expect(result).toEqual({
ok: true,
value: createdSport,
});
});
test('createSport retries with a numeric suffix when the slug already exists', async () => {
const createdSport = {
id: '00000000-0000-4000-8000-000000000001',
name: 'Tenis',
slug: 'tenis-2',
isActive: true,
createdAt: new Date('2026-04-23T00:00:00.000Z'),
updatedAt: new Date('2026-04-23T00:00:00.000Z'),
};
prismaMock.sport.findFirst
.mockResolvedValueOnce({ id: 'existing-sport' } as never)
.mockResolvedValueOnce(null as never);
prismaMock.sport.count.mockResolvedValue(0 as never);
prismaMock.sport.create.mockResolvedValue(createdSport as never);
const result = await createSport({ name: 'Tenis' });
expect(prismaMock.sport.findFirst).toHaveBeenNthCalledWith(1, {
where: { slug: 'tenis' },
select: { id: true },
});
expect(prismaMock.sport.findFirst).toHaveBeenNthCalledWith(2, {
where: { slug: 'tenis-2' },
select: { id: true },
});
expect(prismaMock.sport.create).toHaveBeenCalledWith({
data: {
id: '00000000-0000-4000-8000-000000000001',
name: 'Tenis',
slug: 'tenis-2',
isActive: true,
},
});
expect(result).toEqual({
ok: true,
value: createdSport,
});
});
test('updateSport updates the name, slug and active flag', async () => {
const updatedSport = {
id: 'sport-1',
name: 'Básquet 3x3',
slug: 'basquet-3x3',
isActive: false,
createdAt: new Date('2026-04-21T00:00:00.000Z'),
updatedAt: new Date('2026-04-23T00:00:00.000Z'),
};
prismaMock.sport.findFirst.mockResolvedValue(null as never);
prismaMock.sport.update.mockResolvedValue(updatedSport as never);
const result = await updateSport('sport-1', {
name: 'Básquet 3x3',
isActive: false,
});
expect(prismaMock.sport.findFirst).toHaveBeenCalledWith({
where: {
slug: 'basquet-3x3',
id: {
not: 'sport-1',
},
},
select: { id: true },
});
expect(prismaMock.sport.update).toHaveBeenCalledWith({
where: { id: 'sport-1' },
data: {
name: 'Básquet 3x3',
slug: 'basquet-3x3',
isActive: false,
},
});
expect(result).toEqual(updatedSport);
});

View File

@@ -21,7 +21,6 @@ export const dbMock = {
complex: prismaMock.complex, complex: prismaMock.complex,
user: prismaMock.user, user: prismaMock.user,
complexInvitation: prismaMock.complexInvitation, complexInvitation: prismaMock.complexInvitation,
sport: prismaMock.sport,
$transaction: transactionMock, $transaction: transactionMock,
}; };
@@ -34,10 +33,3 @@ mock.module('@/lib/mailer', () => ({
__esModule: true, __esModule: true,
sendMail: sendMailMock, sendMail: sendMailMock,
})); }));
export const uuidV7Mock = mock(() => '00000000-0000-4000-8000-000000000001');
mock.module('uuid', () => ({
__esModule: true,
v7: uuidV7Mock,
}));

View File

@@ -2,11 +2,11 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="iimage/x-icon" href="/src/assets/playzer-favicon-transparent.ico" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PlayZer</title> <title>PlayZer</title>
</head> </head>
<body lass="bg-background text-foreground antialiased"> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>

View File

@@ -15,10 +15,12 @@
"@better-auth/infra": "^0.2.4", "@better-auth/infra": "^0.2.4",
"@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist": "^5.2.8",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@repo/api-contract": "workspace:*", "@repo/api-contract": "workspace:*",
"@tabler/icons-react": "^3.41.1",
"@tanstack/react-query": "^5.96.1", "@tanstack/react-query": "^5.96.1",
"@tanstack/react-query-devtools": "^5.96.1", "@tanstack/react-query-devtools": "^5.96.1",
"@tanstack/react-router": "^1.168.10", "@tanstack/react-router": "^1.168.10",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

View File

@@ -0,0 +1,182 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: "";
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

View File

@@ -0,0 +1,34 @@
import { type LucideProps } from 'lucide-react';
import { forwardRef } from 'react';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const SoccerBallIcon = forwardRef<any, LucideProps>(({ className, ...props }, ref) => (
<svg
ref={ref}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
className={className}
{...props}
>
<circle cx="12" cy="12" r="10" />
<path d="M12 2c.8 2.2 2.2 4.2 4 5.8" />
<path d="M12 2c-.8 2.2-2.2 4.2-4 5.8" />
<path d="M2 12c2.2.8 4.2 2.2 5.8 4" />
<path d="M2 12c2.2-.8 4.2-2.2 5.8-4" />
<path d="M22 12c-2.2.8-4.2 2.2-5.8 4" />
<path d="M22 12c-2.2-.8-4.2-2.2-5.8-4" />
<path d="M12 22c.8-2.2 2.2-4.2 4-5.8" />
<path d="M12 22c-.8-2.2-2.2-4.2-4-5.8" />
</svg>
));
export { SoccerBallIcon as FootballIcon };

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 206 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 607 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 810 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -1,15 +0,0 @@
export function Background() {
return (
<div className="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-b from-[#040b11] to-[#09131c]" />
<div className="absolute left-0 top-0 h-[420px] w-[420px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/20 blur-3xl" />
<div className="absolute right-0 top-24 h-[360px] w-[360px] translate-x-1/3 rounded-full bg-reserved/15 blur-3xl" />
<div className="absolute bottom-0 left-1/2 h-[300px] w-[300px] -translate-x-1/2 translate-y-1/3 rounded-full bg-accent/10 blur-3xl" />
<div className="absolute inset-0 bg-[linear-gradient(to_right,oklch(1_0_0/0.055)_1px,transparent_1px),linear-gradient(to_bottom,oklch(1_0_0/0.055)_1px,transparent_1px)] bg-[size:64px_64px]" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,#09131c_78%)]" />
</div>
);
}

View File

@@ -1,431 +0,0 @@
import {
CalendarDays,
Check,
Laptop,
LogOut,
Menu,
Moon,
Settings,
Sun,
User,
X,
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import PlayzerIcon from '@/assets/playzer-favicon-512-transparent.png';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate } from '@tanstack/react-router';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { UserAvatar } from '@/components/user-avatar';
import { apiClient } from '@/lib/api-client';
import { useAuth } from '@/lib/auth';
import { acceptStoredPendingInvite } from '@/lib/invitations';
import { useCurrentComplexStore } from '@/lib/stores/current-complex-store';
import { useTheme } from '@/lib/theme';
export function Header() {
const [open, setOpen] = useState(false);
const navigate = useNavigate();
const queryClient = useQueryClient();
const { user, isAuthenticated, displayName, avatarUrl, signOut } = useAuth();
const { theme, setTheme } = useTheme();
const { currentComplexSlug, setCurrentComplex } = useCurrentComplexStore();
const processedInviteForUser = useRef<string | null>(null);
const myComplexesQuery = useQuery({
queryKey: ['my-complexes'],
enabled: isAuthenticated,
queryFn: () => apiClient.complexes.listMine(),
});
useEffect(() => {
if (!isAuthenticated) {
processedInviteForUser.current = null;
return;
}
const userKey = user?.email ?? displayName ?? 'authenticated-user';
if (processedInviteForUser.current === userKey) {
return;
}
processedInviteForUser.current = userKey;
void (async () => {
await acceptStoredPendingInvite(apiClient.complexes.acceptPendingInvitations);
await queryClient.invalidateQueries({ queryKey: ['my-complexes'] });
})();
}, [displayName, isAuthenticated, queryClient, user?.email]);
const complexSlug = currentComplexSlug || myComplexesQuery.data?.[0]?.complexSlug;
const currentComplexName = useMemo(() => {
const complexes = myComplexesQuery.data ?? [];
if (complexes.length === 0) return 'Mi complejo';
const selected = complexes.find((complex) => complex.complexSlug === complexSlug);
return selected?.complexName ?? complexes[0]?.complexName ?? 'Mi complejo';
}, [complexSlug, myComplexesQuery.data]);
const handleSelectComplex = async (complexId: string, nextSlug: string) => {
await apiClient.complexes.select({ complexId });
setCurrentComplex(nextSlug);
await queryClient.invalidateQueries({ queryKey: ['current-complex'] });
};
const handleSignOut = async () => {
await signOut();
await navigate({ to: '/login' });
};
const handleOpenBookingCreate = async () => {
if (location.pathname !== '/') {
await navigate({ to: '/' });
}
window.setTimeout(() => {
window.dispatchEvent(new Event('playzer:open-booking-create'));
}, 0);
};
return (
<header className="sticky top-0 z-50 w-full border-b border-border/60 bg-background/60 dark:border-primary/20 dark:bg-[#030a10]/90 backdrop-blur-xl">
<div className="absolute inset-0 -z-10 bg-gradient-to-b from-background/85 to-background/45 dark:from-[#030a10]/95 dark:to-[#030a10]/70" />
<div className="mx-auto flex h-16 w-full max-w-7xl items-center gap-4 px-3 sm:px-6 lg:px-8">
<Link to="/" className="group flex items-center gap-3">
<div className="flex size-10 items-center justify-center shadow-primary/10 ">
<img src={PlayzerIcon} alt="Playzer" className="size-7" />
</div>
<span className="bg-gradient-to-r from-primary via-primary to-reserved bg-clip-text text-xl font-bold tracking-tight text-transparent">
Playzer
</span>
</Link>
<div className="flex-1" />
<div className="hidden items-center gap-2 md:flex">
<Button
className="rounded-2xl px-5 shadow-lg shadow-primary/20"
onClick={() => {
void handleOpenBookingCreate();
}}
>
<CalendarDays className="mr-2 size-4" />
Reservar ahora
</Button>
</div>
{isAuthenticated ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="hidden size-10 rounded-full ring-2 ring-transparent transition-all duration-200 hover:ring-primary/30 focus:outline-none md:flex"
>
<UserAvatar
src={avatarUrl}
alt={displayName}
fallbackText={displayName}
className="size-10 border border-border/60 shadow-lg shadow-black/10"
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-64 rounded-2xl border-border/60 bg-popover/95 p-2 shadow-xl shadow-black/20 backdrop-blur-xl"
>
<DropdownMenuLabel className="px-2 py-2">
<div className="flex items-center gap-3">
<UserAvatar
src={avatarUrl}
alt={displayName}
fallbackText={displayName}
className="size-9"
/>
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{displayName}</span>
<span className="truncate text-xs text-muted-foreground">{user?.email}</span>
<span className="truncate text-xs text-muted-foreground">
{currentComplexName}
</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{myComplexesQuery.data && myComplexesQuery.data.length > 1 && (
<>
<DropdownMenuLabel className="px-2 text-xs font-normal text-muted-foreground">
Cambiar complejo
</DropdownMenuLabel>
{myComplexesQuery.data.map((complex) => (
<DropdownMenuItem
key={complex.id}
className="cursor-pointer rounded-xl"
onSelect={() => {
void handleSelectComplex(complex.id, complex.complexSlug);
}}
>
<span className="mr-2 flex size-4 items-center justify-center">
{complex.complexSlug === complexSlug && <Check className="size-4" />}
</span>
{complex.complexName}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</>
)}
<DropdownMenuLabel className="px-2 text-xs font-normal text-muted-foreground">
Apariencia
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={theme}
onValueChange={(value) => setTheme(value as 'light' | 'dark' | 'system')}
>
<DropdownMenuRadioItem value="light" className="cursor-pointer rounded-xl">
<Sun className="mr-2 size-4" />
Light
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="dark" className="cursor-pointer rounded-xl">
<Moon className="mr-2 size-4" />
Dark
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="system" className="cursor-pointer rounded-xl">
<Laptop className="mr-2 size-4" />
System
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer rounded-xl"
onSelect={() => {
void navigate({ to: '/profile' });
}}
>
<User className="mr-2 size-4" />
Mi Perfil
</DropdownMenuItem>
{currentComplexSlug && (
<DropdownMenuItem
className="cursor-pointer rounded-xl"
onSelect={() => {
void navigate({
to: '/complex/$slug/edit',
params: { slug: currentComplexSlug },
});
}}
>
<Settings className="mr-2 size-4" />
Configuración
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer rounded-xl text-destructive focus:bg-destructive/10 focus:text-destructive"
onSelect={() => {
void handleSignOut();
}}
>
<LogOut className="mr-2 size-4" />
Cerrar sesión
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button asChild size="sm" variant="outline" className="hidden rounded-2xl md:inline-flex">
<Link to="/login">Login</Link>
</Button>
)}
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button variant="outline" size="icon" className="rounded-2xl md:hidden">
{open ? <X className="size-5" /> : <Menu className="size-5" />}
</Button>
</SheetTrigger>
<SheetContent
side="right"
className="w-[88vw] border-border/60 bg-background/95 p-5 backdrop-blur-xl sm:max-w-sm"
>
<div className="mt-6 flex flex-col gap-6">
<div className="rounded-3xl border border-border/60 bg-card/70 p-4 shadow-xl shadow-black/10">
<div className="flex items-center gap-3">
<UserAvatar
src={avatarUrl}
alt={displayName}
fallbackText={displayName}
className="size-11"
/>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{displayName}</p>
<p className="truncate text-xs text-muted-foreground">{user?.email}</p>
{isAuthenticated && (
<p className="truncate text-xs text-muted-foreground">{currentComplexName}</p>
)}
</div>
</div>
</div>
<Button
className="rounded-2xl shadow-lg shadow-primary/20"
onClick={() => {
setOpen(false);
void handleOpenBookingCreate();
}}
>
<CalendarDays className="mr-2 size-4" />
Reservar ahora
</Button>
<div className="border-t border-border/60 pt-4">
<div className="mb-2 flex items-center justify-between px-2">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Cuenta
</p>
</div>
<nav className="flex flex-col gap-1">
{!isAuthenticated && (
<Button
variant="ghost"
className="justify-start rounded-2xl"
onClick={() => {
setOpen(false);
void navigate({ to: '/login' });
}}
>
Login
</Button>
)}
<div className="py-1">
<p className="px-3 py-1 text-xs text-muted-foreground">Apariencia</p>
{[
{ value: 'light', label: 'Light', icon: Sun },
{ value: 'dark', label: 'Dark', icon: Moon },
{ value: 'system', label: 'System', icon: Laptop },
].map((item) => {
const Icon = item.icon;
return (
<Button
key={item.value}
variant="ghost"
className="w-full justify-start rounded-2xl"
onClick={() => setTheme(item.value as 'light' | 'dark' | 'system')}
>
<span className="mr-2 flex size-4 items-center justify-center">
{theme === item.value ? (
<Check className="size-4" />
) : (
<Icon className="size-4" />
)}
</span>
{item.label}
</Button>
);
})}
</div>
{myComplexesQuery.data && myComplexesQuery.data.length > 1 && (
<div className="py-1">
<p className="px-3 py-1 text-xs text-muted-foreground">Cambiar complejo</p>
{myComplexesQuery.data.map((complex) => (
<Button
key={complex.id}
variant="ghost"
className="w-full justify-start rounded-2xl"
onClick={() => {
setOpen(false);
void handleSelectComplex(complex.id, complex.complexSlug);
}}
>
<span className="mr-2 flex size-4 items-center justify-center">
{complex.complexSlug === complexSlug && <Check className="size-4" />}
</span>
{complex.complexName}
</Button>
))}
</div>
)}
{isAuthenticated && (
<Button
variant="ghost"
className="justify-start rounded-2xl"
onClick={() => {
setOpen(false);
void navigate({ to: '/profile' });
}}
>
<User className="mr-2 size-4" />
Mi Perfil
</Button>
)}
{currentComplexSlug && (
<Button
variant="ghost"
className="justify-start rounded-2xl"
onClick={() => {
setOpen(false);
void navigate({
to: '/complex/$slug/edit',
params: { slug: currentComplexSlug },
});
}}
>
<Settings className="mr-2 size-4" />
Configuración
</Button>
)}
{isAuthenticated && (
<Button
variant="ghost"
className="justify-start rounded-2xl text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => {
setOpen(false);
void handleSignOut();
}}
>
<LogOut className="mr-2 size-4" />
Cerrar sesión
</Button>
)}
</nav>
</div>
</div>
</SheetContent>
</Sheet>
</div>
<div className="h-px bg-gradient-to-r from-transparent via-primary/40 to-transparent" />
</header>
);
}

View File

@@ -1,31 +0,0 @@
import { Background } from '@/components/background';
import { useIsMobile } from '@/hooks/use-mobile';
import { useLocation } from '@tanstack/react-router';
import type { ReactNode } from 'react';
import { Header } from './header';
interface LayoutProps {
children: ReactNode;
}
export function Layout({ children }: LayoutProps) {
const isMobile = useIsMobile();
const location = useLocation();
const isMobileHome = isMobile && location.pathname === '/';
return (
<div className="relative min-h-dvh overflow-x-hidden bg-background text-foreground">
<Background />
{!isMobileHome && <Header />}
<main
className={
isMobileHome
? 'relative z-10 mx-auto w-full max-w-7xl'
: 'relative z-10 mx-auto w-full max-w-7xl px-3 py-6 sm:px-6 lg:px-8'
}
>
{children}
</main>
</div>
);
}

View File

@@ -0,0 +1,113 @@
import React from 'react';
// Types for the component
type Slot = {
startTime: string;
endTime: string;
};
type Court = {
courtId: string;
courtName: string;
sport: { id: string; name: string; slug: string };
slotDurationMinutes: number;
availableSlots: Slot[];
};
type SelectedSlot = {
courtId: string;
startTime: string;
} | null;
type AvailabilityHeatMapProps = {
courts: Court[];
selectedSlot: SelectedSlot;
onSelectSlot: (payload: {
courtId: string;
courtName: string;
sportId: string;
sportName: string;
startTime: string;
endTime: string;
price?: { min: number; max: number };
}) => void;
};
/**
* AvailabilityHeatMap
*
* A visual heatmap style component that displays each court as a card and colours the
* slot buttons according to how many slots are available for that court. The colour scale
* goes from green (few slots) to red (many slots), providing an immediate visual cue of
* density the signature element requested for the bookings panel.
*/
export default function AvailabilityHeatMap({
courts,
selectedSlot,
onSelectSlot,
}: AvailabilityHeatMapProps) {
// Determine the maximum number of slots among all courts to normalise the heat scale.
const maxSlots = Math.max(1, ...courts.map((c) => c.availableSlots.length));
// Convert a slot count into an HSL colour ranging from green (low) to red (high).
const slotCountToHue = (count: number) => {
const ratio = count / maxSlots; // 0 => green, 1 => red
const hue = Math.round(120 - 120 * ratio); // 120° (green) to 0° (red)
return `hsl(${hue}, 70%, 55%)`;
};
return (
<div className="grid gap-4">
{courts.map((court) => {
const bg = slotCountToHue(court.availableSlots.length);
return (
<div
key={court.courtId}
className="rounded-2xl border border-border/70 bg-background p-4 shadow-sm"
>
<div className="flex items-center justify-between mb-2">
<p className="font-semibold text-foreground">{court.courtName}</p>
<span className="text-xs text-muted-foreground">
{court.availableSlots.length} horarios
</span>
</div>
<div className="grid grid-cols-4 gap-2">
{court.availableSlots.map((slot) => {
const isSelected =
selectedSlot?.courtId === court.courtId &&
selectedSlot?.startTime === slot.startTime;
return (
<button
key={slot.startTime}
type="button"
onClick={() =>
onSelectSlot({
courtId: court.courtId,
courtName: court.courtName,
sportId: court.sport.id,
sportName: court.sport.name,
startTime: slot.startTime,
endTime: slot.endTime,
})
}
className={`h-10 rounded-xl border text-sm font-medium transition-all ${
isSelected
? 'border-primary bg-primary text-primary-foreground shadow-sm'
: 'border-border/70 hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-sm'
}`}
style={{
backgroundColor: isSelected ? undefined : bg,
opacity: isSelected ? 1 : 0.8,
}}
>
{slot.startTime}
</button>
);
})}
</div>
</div>
);
})}
</div>
);
}

View File

@@ -5,13 +5,13 @@ 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-xl 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 shadow-sm hover:bg-primary/90',
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-card shadow-sm hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/20 dark:hover:bg-input/30',
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:
@@ -22,16 +22,15 @@ const buttonVariants = cva(
}, },
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-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5',
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-lg 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-8 gap-1.5 rounded-lg px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_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-10 gap-1.5 rounded-xl px-3.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3',
icon: 'size-8', icon: 'size-9 rounded-xl',
'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-lg in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'icon-sm': 'size-8 rounded-lg 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-10 rounded-xl',
'icon-lg': 'size-9',
}, },
}, },
defaultVariants: { defaultVariants: {

View File

@@ -4,14 +4,12 @@ import { DayPicker } from 'react-day-picker';
import { buttonVariants } from '@/components/ui/button'; import { buttonVariants } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { es } from 'date-fns/locale';
export type CalendarProps = React.ComponentProps<typeof DayPicker>; export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) { function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
return ( return (
<DayPicker <DayPicker
locale={es}
showOutsideDays={showOutsideDays} showOutsideDays={showOutsideDays}
className={cn('p-3', className)} className={cn('p-3', className)}
classNames={{ classNames={{

View File

@@ -90,7 +90,7 @@ function DialogFooter({
<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-green-50/70 p-4 sm:flex-row sm:justify-end dark:bg-muted/50', '-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}

View File

@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<'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-10 w-full min-w-0 rounded-xl border border-input bg-card px-3 py-2 text-base shadow-[0_1px_0_rgba(15,23,42,0.02)] 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/20 dark:disabled:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
className className
)} )}
{...props} {...props}

View File

@@ -36,6 +36,7 @@ function SelectTrigger({
data-size={size} data-size={size}
className={cn( className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 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", "flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 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",
"flex w-fit items-center justify-between gap-1.5 rounded-xl border border-input bg-card py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-10 data-[size=sm]:h-9 data-[size=sm]:rounded-lg *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/20 dark:hover:bg-input/30 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",
className className
)} )}
{...props} {...props}
@@ -61,7 +62,7 @@ function SelectContent({
data-slot="select-content" data-slot="select-content"
data-align-trigger={position === 'item-aligned'} data-align-trigger={position === 'item-aligned'}
className={cn( 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', '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-xl bg-popover text-popover-foreground shadow-lg 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' && 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', '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
@@ -105,7 +106,7 @@ function SelectItem({
<SelectPrimitive.Item <SelectPrimitive.Item
data-slot="select-item" data-slot="select-item"
className={cn( className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", "relative flex w-full cursor-default items-center gap-1.5 rounded-lg py-2 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className className
)} )}
{...props} {...props}

View File

@@ -1,128 +0,0 @@
import { Dialog as SheetPrimitive } from 'radix-ui';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { XIcon } from 'lucide-react';
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
'fixed inset-0 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
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = 'right',
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: 'top' | 'right' | 'bottom' | 'left';
showCloseButton?: boolean;
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
data-side={side}
className={cn(
'fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10',
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close data-slot="sheet-close" asChild>
<Button variant="ghost" className="absolute top-3 right-3" size="icon-sm">
<XIcon />
<span className="sr-only">Close</span>
</Button>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sheet-header"
className={cn('flex flex-col gap-0.5 p-4', className)}
{...props}
/>
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sheet-footer"
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
);
}
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn('font-heading text-base font-medium text-foreground', className)}
{...props}
/>
);
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

View File

@@ -0,0 +1,49 @@
import { cn } from '@/lib/utils';
import { type LucideIcon } from 'lucide-react';
type StatusType = 'CONFIRMED' | 'PENDING' | 'CANCELLED' | 'NO_SHOW' | 'COMPLETED';
type StatusBadgeProps = {
status: StatusType;
label: string;
icon?: LucideIcon;
className?: string;
};
const statusStyles: Record<StatusType, string> = {
CONFIRMED: 'bg-status-confirmed-bg border-status-confirmed-border text-status-confirmed-text',
PENDING: 'bg-status-pending-bg border-status-pending-border text-status-pending-text',
CANCELLED: 'bg-status-cancelled-bg border-status-cancelled-border text-status-cancelled-text',
NO_SHOW: 'bg-status-noshow-bg border-status-noshow-border text-status-noshow-text',
COMPLETED: 'bg-status-completed-bg border-status-completed-border text-status-completed-text',
};
export function StatusBadge({ status, label, icon: Icon, className }: StatusBadgeProps) {
return (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium',
statusStyles[status],
className
)}
>
{Icon && <Icon className="size-3" />}
{label}
</span>
);
}
export function effectiveStatusClassName(
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW'
): string {
return statusStyles[status];
}
export function effectiveStatusLabel(
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW'
): string {
if (status === 'NO_SHOW') return 'No show';
if (status === 'COMPLETED') return 'Cumplida';
if (status === 'CANCELLED') return 'Cancelada';
return 'Confirmada';
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useEffect, useState } from 'react';
type UserAvatarProps = { type UserAvatarProps = {
src?: string | null; src?: string | null;
@@ -21,9 +22,25 @@ export function UserAvatar({
size = 'default', size = 'default',
className, className,
}: UserAvatarProps) { }: UserAvatarProps) {
const [imageError, setImageError] = useState(false);
useEffect(() => {
setImageError(false);
}, [src]);
const canShowImage = Boolean(src) && !imageError;
return ( return (
<Avatar size={size} className={className}> <Avatar size={size} className={className}>
<AvatarImage src={src ?? undefined} alt={alt} /> {canShowImage && (
<AvatarImage
src={src ?? undefined}
alt={alt}
onError={() => {
setImageError(true);
}}
/>
)}
<AvatarFallback>{getInitials(fallbackText)}</AvatarFallback> <AvatarFallback>{getInitials(fallbackText)}</AvatarFallback>
</Avatar> </Avatar>
); );

View File

@@ -0,0 +1,12 @@
export function AboutPage() {
return (
<main className="mx-auto flex min-h-[60vh] 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">
<h2 className="text-2xl font-semibold">About</h2>
<p className="mt-2 text-sm text-muted-foreground">
Ruta de ejemplo funcionando con TanStack Router.
</p>
</section>
</main>
);
}

View File

@@ -1,508 +0,0 @@
import { ApiClientError, apiClient } from '@/lib/api-client';
import type { AdminBooking, ComplexWithRole, Court } from '@repo/api-contract';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
type ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import type {
BookingCourtSchedule,
BookingSlotDraft,
BookingStatusFilter,
BookingSummary,
BookingTimeRange,
BookingTimelineSegment,
BookingViewMode,
} from './booking.types';
import {
addDaysIso,
formatBookingDate,
getDayOfWeek,
getNowTime,
isTodayIso,
minutesToTime,
timeToMinutes,
toIsoDateLocal,
} from './lib/booking-time';
interface BookingProviderProps {
children: ReactNode;
complex: ComplexWithRole;
}
interface CreateManualBookingPayload {
courtId: string;
date: string;
startTime: string;
customerName: string;
customerPhone: string;
customerEmail: string;
}
interface BookingContextValue {
complex: ComplexWithRole;
courts: Court[];
bookings: AdminBooking[];
schedules: BookingCourtSchedule[];
selectedDate: string;
selectedSportId: string;
selectedStatus: BookingStatusFilter;
viewMode: BookingViewMode;
visibleTimeRange: BookingTimeRange;
timelineHours: string[];
summary: BookingSummary;
currentTime: string | null;
selectedSlot: BookingSlotDraft | null;
selectedSegment: BookingTimelineSegment | null;
isLoading: boolean;
isError: boolean;
errorMessage: string | null;
isCreateBookingOpen: boolean;
createBookingError: string | null;
isCreatingBooking: boolean;
bookingToolsOpen: boolean;
setSelectedDate: (date: string) => void;
moveSelectedDate: (amount: number) => void;
setSelectedSportId: (sportId: string) => void;
setSelectedStatus: (status: BookingStatusFilter) => void;
setViewMode: (mode: BookingViewMode) => void;
setVisibleTimeRange: (range: BookingTimeRange) => void;
openCreateBooking: (draft?: Partial<BookingSlotDraft>) => void;
closeCreateBooking: () => void;
createBooking: (payload: CreateManualBookingPayload) => Promise<void>;
updateBookingStatus: (bookingId: string, status: 'CANCELLED' | 'COMPLETED' | 'NOSHOW') => void;
exportDayReport: () => void;
openBookingTools: (segment?: BookingTimelineSegment) => void;
closeBookingTools: () => void;
}
const BookingContext = createContext<BookingContextValue | null>(null);
const DEFAULT_TIME_RANGE: BookingTimeRange = {
start: '08:00',
end: '22:00',
};
function extractMessage(error: unknown, fallback: string) {
if (error instanceof ApiClientError) {
return error.message || fallback;
}
if (error instanceof Error) {
return error.message || fallback;
}
return fallback;
}
function makeTimelineHours(range: BookingTimeRange) {
const start = timeToMinutes(range.start);
const end = timeToMinutes(range.end);
const hours: string[] = [];
for (let minute = start; minute <= end; minute += 60) {
hours.push(minutesToTime(minute));
}
return hours;
}
function isActiveBooking(booking: AdminBooking) {
return (
booking.status === 'CONFIRMED' || booking.status === 'COMPLETED' || booking.status === 'NOSHOW'
);
}
function getCourtBookings(court: Court, bookings: AdminBooking[], selectedDate: string) {
return bookings
.filter(
(booking) =>
booking.courtId === court.id && booking.date === selectedDate && isActiveBooking(booking)
)
.sort((a, b) => timeToMinutes(a.startTime) - timeToMinutes(b.startTime));
}
function overlapsBooking(start: number, end: number, booking: AdminBooking) {
const bookingStart = timeToMinutes(booking.startTime);
const bookingEnd = timeToMinutes(booking.endTime);
return start < bookingEnd && end > bookingStart;
}
function buildSegmentsForCourt(
court: Court,
bookings: AdminBooking[],
selectedDate: string,
visibleRange: BookingTimeRange
) {
const dayOfWeek = getDayOfWeek(selectedDate);
const rangeStart = timeToMinutes(visibleRange.start);
const rangeEnd = timeToMinutes(visibleRange.end);
const courtBookings = getCourtBookings(court, bookings, selectedDate);
const segments: BookingTimelineSegment[] = [];
const slotDuration = court.slotDurationMinutes;
const availabilityRanges = court.availability
.filter((range) => range.dayOfWeek === dayOfWeek)
.map((range) => ({
start: Math.max(timeToMinutes(range.startTime), rangeStart),
end: Math.min(timeToMinutes(range.endTime), rangeEnd),
}))
.filter((range) => range.start < range.end)
.sort((a, b) => a.start - b.start);
for (const booking of courtBookings) {
const bookingStart = timeToMinutes(booking.startTime);
const bookingEnd = timeToMinutes(booking.endTime);
if (bookingEnd > rangeStart && bookingStart < rangeEnd) {
segments.push({
id: booking.id,
courtId: court.id,
status: 'reserved',
startTime: booking.startTime,
endTime: booking.endTime,
startMinutes: Math.max(bookingStart, rangeStart),
endMinutes: Math.min(bookingEnd, rangeEnd),
booking,
});
}
}
for (const availability of availabilityRanges) {
for (
let slotStart = availability.start;
slotStart + slotDuration <= availability.end;
slotStart += slotDuration
) {
const slotEnd = slotStart + slotDuration;
const isBooked = courtBookings.some((booking) =>
overlapsBooking(slotStart, slotEnd, booking)
);
if (isBooked) continue;
const slotStartTime = minutesToTime(slotStart);
const slotEndTime = minutesToTime(slotEnd);
segments.push({
id: `${court.id}-free-${slotStartTime}-${slotEndTime}`,
courtId: court.id,
status: 'free',
startTime: slotStartTime,
endTime: slotEndTime,
startMinutes: slotStart,
endMinutes: slotEnd,
});
}
}
segments.sort((a, b) => a.startMinutes - b.startMinutes);
return segments;
}
function getSummary(schedules: BookingCourtSchedule[]): BookingSummary {
const freeSlots = schedules.reduce((total, schedule) => total + schedule.metrics.free, 0);
const reservedSlots = schedules.reduce((total, schedule) => total + schedule.metrics.reserved, 0);
const maintenanceSlots = schedules.reduce(
(total, schedule) => total + schedule.metrics.maintenance,
0
);
const totalSegments = freeSlots + reservedSlots + maintenanceSlots || 1;
return {
totalReservations: reservedSlots + maintenanceSlots + freeSlots,
freeSlots,
reservedSlots,
maintenanceSlots,
freePercent: Math.round((freeSlots / totalSegments) * 100),
reservedPercent: Math.round((reservedSlots / totalSegments) * 100),
maintenancePercent: Math.round((maintenanceSlots / totalSegments) * 100),
};
}
export function BookingProvider({ children, complex }: BookingProviderProps) {
const queryClient = useQueryClient();
const todayIso = useMemo(() => toIsoDateLocal(new Date()), []);
const [selectedDate, setSelectedDate] = useState(todayIso);
const [selectedSportId, setSelectedSportId] = useState('all');
const [selectedStatus, setSelectedStatus] = useState<BookingStatusFilter>('all');
const [viewMode, setViewMode] = useState<BookingViewMode>('panel');
const [visibleTimeRange, setVisibleTimeRange] = useState(DEFAULT_TIME_RANGE);
const [selectedSlot, setSelectedSlot] = useState<BookingSlotDraft | null>(null);
const [isCreateBookingOpen, setIsCreateBookingOpen] = useState(false);
const [selectedSegment, setSelectedSegment] = useState<BookingTimelineSegment | null>(null);
const [bookingToolsOpen, setBookingToolsOpen] = useState(false);
const courtsQuery = useQuery({
queryKey: ['courts', complex.id],
queryFn: () => apiClient.courts.listByComplex(complex.id),
});
const bookingsQuery = useQuery({
queryKey: ['admin-bookings', complex.id, selectedDate],
queryFn: () =>
apiClient.adminBookings.listByComplex(complex.id, {
fromDate: selectedDate,
}),
});
const bookings = useMemo(
() => (bookingsQuery.data?.bookings ?? []).filter((booking) => booking.date === selectedDate),
[bookingsQuery.data?.bookings, selectedDate]
);
const courts = useMemo(
() => courtsQuery.data?.sort((a, b) => a.name.localeCompare(b.name)) ?? [],
[courtsQuery.data]
);
const filteredCourts = useMemo(() => {
if (selectedSportId === 'all') return courts;
return courts.filter((court) => court.sportId === selectedSportId);
}, [courts, selectedSportId]);
const schedules = useMemo<BookingCourtSchedule[]>(() => {
return filteredCourts
.map((court) => {
const segments = buildSegmentsForCourt(court, bookings, selectedDate, visibleTimeRange);
const metrics = {
free: segments.filter((segment) => segment.status === 'free').length,
reserved: segments.filter((segment) => segment.status === 'reserved').length,
maintenance: segments.filter((segment) => segment.status === 'maintenance').length,
};
return { court, segments, metrics };
})
.filter((schedule) => {
if (selectedStatus === 'all') return true;
return schedule.segments.some((segment) => segment.status === selectedStatus);
});
}, [bookings, filteredCourts, selectedDate, selectedStatus, visibleTimeRange]);
const summary = useMemo(() => getSummary(schedules), [schedules]);
const timelineHours = useMemo(() => makeTimelineHours(visibleTimeRange), [visibleTimeRange]);
const [now, setNow] = useState(() => getNowTime());
useEffect(() => {
const interval = setInterval(() => {
setNow(getNowTime());
}, 60 * 1000);
return () => clearInterval(interval);
}, []);
const currentTime = useMemo(() => {
if (!isTodayIso(selectedDate)) return null;
const nowMinutes = timeToMinutes(now);
const start = timeToMinutes(visibleTimeRange.start);
const end = timeToMinutes(visibleTimeRange.end);
if (nowMinutes < start || nowMinutes > end) return null;
return now;
}, [selectedDate, visibleTimeRange, now]);
const createBookingMutation = useMutation({
mutationFn: (payload: CreateManualBookingPayload) =>
apiClient.adminBookings.create(complex.id, {
date: payload.date,
courtId: payload.courtId,
startTime: payload.startTime,
customerName: payload.customerName,
customerPhone: payload.customerPhone,
customerEmail: payload.customerEmail,
}),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['admin-bookings', complex.id] });
await queryClient.invalidateQueries({ queryKey: ['manual-booking-availability'] });
setIsCreateBookingOpen(false);
setSelectedSlot(null);
},
});
const updateStatusMutation = useMutation({
mutationFn: (payload: { bookingId: string; status: 'CANCELLED' | 'COMPLETED' | 'NOSHOW' }) =>
apiClient.adminBookings.updateStatus(payload.bookingId, { status: payload.status }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['admin-bookings', complex.id] });
},
});
const openCreateBooking = useCallback(
(draft?: Partial<BookingSlotDraft>) => {
setSelectedSlot({
date: draft?.date ?? selectedDate,
courtId: draft?.courtId,
startTime: draft?.startTime,
sportId: draft?.sportId,
});
setIsCreateBookingOpen(true);
},
[selectedDate]
);
const openBookingTools = useCallback(
(segment?: BookingTimelineSegment) => {
setSelectedSegment(segment ?? null);
setBookingToolsOpen(true);
console.log(JSON.stringify(segment, null, 2));
},
[selectedDate]
);
const closeBookingTools = () => setBookingToolsOpen(false);
useEffect(() => {
const handleOpenCreateBooking = () => {
openCreateBooking();
};
window.addEventListener('playzer:open-booking-create', handleOpenCreateBooking);
return () => {
window.removeEventListener('playzer:open-booking-create', handleOpenCreateBooking);
};
}, [openCreateBooking]);
const closeCreateBooking = useCallback(() => {
setIsCreateBookingOpen(false);
setSelectedSlot(null);
createBookingMutation.reset();
}, [createBookingMutation]);
const createBooking = useCallback(
async (payload: CreateManualBookingPayload) => {
await createBookingMutation.mutateAsync(payload);
},
[createBookingMutation]
);
const updateBookingStatus = useCallback(
(bookingId: string, status: 'CANCELLED' | 'COMPLETED' | 'NOSHOW') => {
updateStatusMutation.mutate({ bookingId, status });
},
[updateStatusMutation]
);
const exportDayReport = useCallback(() => {
const rows = [
['Fecha', 'Cancha', 'Deporte', 'Inicio', 'Fin', 'Cliente', 'Telefono', 'Estado'],
...bookings.map((booking) => [
booking.date,
booking.courtName,
booking.sport.name,
booking.startTime,
booking.endTime,
booking.customerName,
booking.customerPhone,
booking.status,
]),
];
const csv = rows.map((row) => row.map((cell) => `"${cell}"`).join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `reservas-${complex.complexSlug}-${selectedDate}.csv`;
link.click();
URL.revokeObjectURL(url);
}, [bookings, complex.complexSlug, selectedDate]);
const value = useMemo<BookingContextValue>(
() => ({
complex,
courts,
bookings,
schedules,
selectedDate,
selectedSportId,
selectedStatus,
viewMode,
visibleTimeRange,
timelineHours,
summary,
currentTime,
selectedSlot,
isLoading: courtsQuery.isLoading || bookingsQuery.isLoading,
isError: courtsQuery.isError || bookingsQuery.isError,
errorMessage:
courtsQuery.isError || bookingsQuery.isError
? extractMessage(
courtsQuery.error ?? bookingsQuery.error,
'No pudimos cargar el panel de reservas.'
)
: null,
isCreateBookingOpen,
createBookingError: createBookingMutation.isError
? extractMessage(createBookingMutation.error, 'No pudimos crear la reserva.')
: null,
isCreatingBooking: createBookingMutation.isPending,
setSelectedDate,
moveSelectedDate: (amount) => setSelectedDate((date) => addDaysIso(date, amount)),
setSelectedSportId,
setSelectedStatus,
setViewMode,
setVisibleTimeRange,
openCreateBooking,
closeCreateBooking,
createBooking,
updateBookingStatus,
exportDayReport,
bookingToolsOpen,
openBookingTools,
closeBookingTools,
selectedSegment,
}),
[
bookings,
bookingsQuery.error,
bookingsQuery.isError,
bookingsQuery.isLoading,
closeCreateBooking,
complex,
courts,
courtsQuery.error,
courtsQuery.isError,
courtsQuery.isLoading,
createBooking,
createBookingMutation.error,
createBookingMutation.isError,
createBookingMutation.isPending,
currentTime,
exportDayReport,
isCreateBookingOpen,
openCreateBooking,
schedules,
selectedDate,
selectedSlot,
selectedSportId,
selectedStatus,
summary,
timelineHours,
updateBookingStatus,
viewMode,
visibleTimeRange,
bookingToolsOpen,
]
);
return <BookingContext.Provider value={value}>{children}</BookingContext.Provider>;
}
export function useBooking() {
const context = useContext(BookingContext);
if (!context) {
throw new Error('useBooking must be used within BookingProvider');
}
return context;
}
export { formatBookingDate };

View File

@@ -1,76 +0,0 @@
import { useIsMobile } from '@/hooks/use-mobile';
import { authClient } from '@/lib/api-client';
import { useAuth } from '@/lib/auth';
import type { ComplexWithRole } from '@repo/api-contract';
import { useState } from 'react';
import { BookingProvider } from './booking-provider';
import { BookingCreateDialog } from './components/booking-create-dialog';
import { BookingDaySummary, BookingQuickActions } from './components/booking-day-summary';
import { BookingHeader } from './components/booking-header';
import { BookingMobile } from './components/booking-mobile';
import { BookingTimeline } from './components/booking-timeline';
import { BookingToolbar } from './components/booking-toolbar';
import { BookingToolsDialog } from './components/booking-tools-dialog';
interface BookingProps {
complex: ComplexWithRole;
}
export function Booking({ complex }: BookingProps) {
const isMobile = useIsMobile();
const { user } = useAuth();
const [sending, setSending] = useState(false);
const [emailSent, setEmailSent] = useState(false);
const emailNotVerified = user && !user.emailVerified;
const handleResendVerification = async () => {
if (!user?.email || sending) return;
setSending(true);
try {
await authClient.sendVerificationEmail({ email: user.email });
setEmailSent(true);
setTimeout(() => setEmailSent(false), 6000);
} catch {
// Silently fail — the banner already shows the email to contact
} finally {
setSending(false);
}
};
return (
<BookingProvider complex={complex}>
{emailNotVerified && (
<div className="mb-6 flex items-center justify-between gap-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-800/30 dark:bg-amber-950/30 dark:text-amber-200">
<span>
No verificaste tu email. Para crear reservas necesitás verificar tu dirección de correo
electrónico.
</span>
<button
type="button"
onClick={handleResendVerification}
disabled={sending}
className="shrink-0 rounded-lg bg-amber-200 px-3 py-1.5 text-xs font-medium text-amber-900 transition-colors hover:bg-amber-300 disabled:opacity-50 dark:bg-amber-800 dark:text-amber-50 dark:hover:bg-amber-700"
>
{sending ? 'Enviando...' : emailSent ? '¡Email enviado!' : 'Reenviar email'}
</button>
</div>
)}
{isMobile ? (
<BookingMobile />
) : (
<div className="flex flex-col gap-5">
<BookingHeader />
<BookingToolbar />
<BookingTimeline />
<div className="grid gap-5 xl:grid-cols-4">
<BookingDaySummary />
<BookingQuickActions />
</div>
</div>
)}
<BookingCreateDialog />
<BookingToolsDialog />
</BookingProvider>
);
}

View File

@@ -1,50 +0,0 @@
import type { AdminBooking, Court } from '@repo/api-contract';
export type BookingViewMode = 'panel' | 'status';
export type BookingStatusFilter = 'all' | 'free' | 'reserved' | 'maintenance';
export type BookingSegmentStatus = 'free' | 'reserved' | 'maintenance';
export interface BookingTimeRange {
start: string;
end: string;
}
export interface BookingSummary {
totalReservations: number;
freeSlots: number;
reservedSlots: number;
maintenanceSlots: number;
freePercent: number;
reservedPercent: number;
maintenancePercent: number;
}
export interface BookingCourtMetrics {
free: number;
reserved: number;
maintenance: number;
}
export interface BookingTimelineSegment {
id: string;
courtId: string;
status: BookingSegmentStatus;
startTime: string;
endTime: string;
startMinutes: number;
endMinutes: number;
booking?: AdminBooking;
}
export interface BookingCourtSchedule {
court: Court;
segments: BookingTimelineSegment[];
metrics: BookingCourtMetrics;
}
export interface BookingSlotDraft {
courtId?: string;
date: string;
startTime?: string;
sportId?: string;
}

View File

@@ -1,316 +0,0 @@
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 {
ResponsiveDialog,
ResponsiveDialogClose,
ResponsiveDialogContent,
ResponsiveDialogDescription,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from '@/components/ui/responsive-dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { zodResolver } from '@hookform/resolvers/zod';
import { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useBooking } from '../booking-provider';
import {
fromIsoDateLocal,
getDayOfWeek,
minutesToTime,
timeToMinutes,
toIsoDateLocal,
} from '../lib/booking-time';
const bookingFormSchema = z.object({
customerName: z
.string()
.trim()
.min(2, 'Ingresa un nombre válido.')
.max(120, 'El nombre no puede superar los 120 caracteres.'),
customerPhone: z
.string()
.trim()
.min(6, 'Ingresa un telefono válido.')
.max(30, 'El telefono no puede superar los 30 caracteres.'),
customerEmail: z.string().email('Ingresá un email válido.'),
});
type BookingForm = z.infer<typeof bookingFormSchema>;
export function BookingCreateDialog() {
const {
courts,
bookings,
selectedDate,
selectedSlot,
isCreateBookingOpen,
createBookingError,
isCreatingBooking,
closeCreateBooking,
createBooking,
} = useBooking();
const [date, setDate] = useState(selectedSlot?.date ?? selectedDate);
const [sportId, setSportId] = useState(selectedSlot?.sportId ?? 'all');
const [courtId, setCourtId] = useState(selectedSlot?.courtId ?? '');
const [startTime, setStartTime] = useState(selectedSlot?.startTime ?? '');
const {
register,
handleSubmit,
reset,
setFocus,
formState: { errors, isValid },
} = useForm<BookingForm>({
resolver: zodResolver(bookingFormSchema),
mode: 'onChange',
defaultValues: {
customerName: '',
customerPhone: '',
customerEmail: '',
},
});
useEffect(() => {
if (!isCreateBookingOpen) return;
setDate(selectedSlot?.date ?? selectedDate);
setSportId(selectedSlot?.sportId ?? 'all');
setCourtId(selectedSlot?.courtId ?? '');
setStartTime(selectedSlot?.startTime ?? '');
reset();
if (selectedSlot?.courtId && selectedSlot?.startTime) {
requestAnimationFrame(() => {
setFocus('customerName');
});
}
}, [isCreateBookingOpen, reset, selectedDate, selectedSlot, setFocus]);
const sports = [...new Map(courts.map((court) => [court.sport.id, court.sport])).values()];
const filteredCourts = useMemo(() => {
if (sportId === 'all') return courts;
return courts.filter((court) => court.sportId === sportId);
}, [courts, sportId]);
const selectedCourt = courts.find((court) => court.id === courtId);
const availableStartTimes = useMemo(() => {
if (!selectedCourt) return [];
const times: string[] = [];
const dayOfWeek = getDayOfWeek(date);
const dayAvailability = selectedCourt.availability
.filter((range) => range.dayOfWeek === dayOfWeek)
.sort((a, b) => timeToMinutes(a.startTime) - timeToMinutes(b.startTime));
const courtBookings = bookings.filter(
(booking) =>
booking.date === date &&
booking.courtId === selectedCourt.id &&
booking.status !== 'CANCELLED'
);
for (const range of dayAvailability) {
const start = timeToMinutes(range.startTime);
const end = timeToMinutes(range.endTime);
for (
let minute = start;
minute + selectedCourt.slotDurationMinutes <= end;
minute += selectedCourt.slotDurationMinutes
) {
const slotEnd = minute + selectedCourt.slotDurationMinutes;
const isBooked = courtBookings.some((booking) => {
const bookingStart = timeToMinutes(booking.startTime);
const bookingEnd = timeToMinutes(booking.endTime);
return minute < bookingEnd && slotEnd > bookingStart;
});
if (!isBooked) {
times.push(minutesToTime(minute));
}
}
}
return times;
}, [bookings, date, selectedCourt]);
useEffect(() => {
if (startTime && !availableStartTimes.includes(startTime)) {
setStartTime('');
}
}, [availableStartTimes, startTime]);
const onSubmit = async (values: BookingForm) => {
await createBooking({
courtId,
date,
startTime,
customerName: values.customerName,
customerPhone: values.customerPhone,
customerEmail: values.customerEmail,
});
};
return (
<ResponsiveDialog
open={isCreateBookingOpen}
onOpenChange={(open) => !open && closeCreateBooking()}
>
<ResponsiveDialogContent
className="group/create-booking data-[variant=dialog]:max-w-lg data-[variant=drawer]:!h-[92dvh] data-[variant=drawer]:!max-h-[92dvh] data-[variant=drawer]:overflow-hidden data-[variant=drawer]:px-0 data-[variant=drawer]:pb-0"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<ResponsiveDialogHeader className="group-data-[variant=drawer]/create-booking:shrink-0 group-data-[variant=drawer]/create-booking:px-4 group-data-[variant=drawer]/create-booking:pb-3">
<ResponsiveDialogTitle>Nueva reserva</ResponsiveDialogTitle>
<ResponsiveDialogDescription>
Crea un turno para atención telefónica o mostrador.
</ResponsiveDialogDescription>
</ResponsiveDialogHeader>
<form
id="booking-create-form"
className="grid gap-4 group-data-[variant=drawer]/create-booking:min-h-0 group-data-[variant=drawer]/create-booking:flex-1 group-data-[variant=drawer]/create-booking:overflow-y-auto group-data-[variant=drawer]/create-booking:px-4 group-data-[variant=drawer]/create-booking:pb-4"
onSubmit={handleSubmit(onSubmit)}
>
<div className="grid gap-3 md:grid-cols-2">
<Field>
<FieldLabel>Fecha</FieldLabel>
<DatePicker
value={fromIsoDateLocal(date)}
onChange={(nextDate) => {
setDate(nextDate ? toIsoDateLocal(nextDate) : selectedDate);
}}
/>
</Field>
<Field>
<FieldLabel>Deporte</FieldLabel>
<Select
value={sportId}
onValueChange={(value) => {
setSportId(value);
setCourtId('');
setStartTime('');
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos</SelectItem>
{sports.map((sport) => (
<SelectItem key={sport.id} value={sport.id}>
{sport.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Cancha</FieldLabel>
<Select
value={courtId}
onValueChange={(value) => {
setCourtId(value);
setStartTime('');
}}
>
<SelectTrigger>
<SelectValue placeholder="Selecciona una cancha" />
</SelectTrigger>
<SelectContent>
{filteredCourts.map((court) => (
<SelectItem key={court.id} value={court.id}>
{court.name} · {court.sport.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Horario</FieldLabel>
<Select value={startTime} onValueChange={setStartTime}>
<SelectTrigger>
<SelectValue placeholder="Selecciona un horario" />
</SelectTrigger>
<SelectContent>
{availableStartTimes.map((time) => (
<SelectItem key={time} value={time}>
{time}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
<Field data-invalid={Boolean(errors.customerName)}>
<FieldLabel htmlFor="customerName">Nombre</FieldLabel>
<Input
id="customerName"
placeholder="Ej: Juan Perez"
aria-invalid={Boolean(errors.customerName)}
{...register('customerName')}
/>
<FieldError errors={[errors.customerName]} />
</Field>
<Field data-invalid={Boolean(errors.customerPhone)}>
<FieldLabel htmlFor="customerPhone">Teléfono</FieldLabel>
<Input
id="customerPhone"
type="tel"
placeholder="Ej: 3875551234"
aria-invalid={Boolean(errors.customerPhone)}
{...register('customerPhone')}
/>
<FieldError errors={[errors.customerPhone]} />
</Field>
<Field data-invalid={Boolean(errors.customerEmail)}>
<FieldLabel htmlFor="customerEmail">
Email <span className="text-muted-foreground font-normal">(opcional)</span>
</FieldLabel>
<Input
id="customerEmail"
type="email"
placeholder="Ej: juan@ejemplo.com"
aria-invalid={Boolean(errors.customerEmail)}
{...register('customerEmail')}
/>
<p className="mt-1 text-xs text-muted-foreground">
Se enviará la confirmación de la reserva por email.
</p>
<FieldError errors={[errors.customerEmail]} />
</Field>
{createBookingError && <p className="text-sm text-destructive">{createBookingError}</p>}
</form>
<ResponsiveDialogFooter className="group-data-[variant=drawer]/create-booking:shrink-0 group-data-[variant=drawer]/create-booking:border-t group-data-[variant=drawer]/create-booking:border-border/70 group-data-[variant=drawer]/create-booking:bg-popover/95 group-data-[variant=drawer]/create-booking:px-4 group-data-[variant=drawer]/create-booking:pt-3 group-data-[variant=drawer]/create-booking:pb-[calc(12px+env(safe-area-inset-bottom))]">
<ResponsiveDialogClose asChild>
<Button variant="outline">Cancelar</Button>
</ResponsiveDialogClose>
<Button
type="submit"
form="booking-create-form"
disabled={!isValid || isCreatingBooking || !courtId || !startTime}
>
{isCreatingBooking ? 'Guardando...' : 'Crear reserva'}
</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
);
}

View File

@@ -1,118 +0,0 @@
import { CalendarDays, Download, Drill, ShieldCheck, UsersRound } from 'lucide-react';
import { useBooking } from '../booking-provider';
import { formatBookingDate, toIsoDateLocal } from '../lib/booking-time';
export function BookingDaySummary() {
const { selectedDate, summary } = useBooking();
return (
<section className="rounded-lg border bg-card/85 p-5 shadow-sm xl:col-span-3">
<div className="mb-4">
<h2 className="text-base font-semibold">Resumen del día</h2>
<p className="mt-1 text-sm capitalize text-muted-foreground">
{formatBookingDate(selectedDate, { year: 'numeric' })}
</p>
</div>
<div className="grid gap-3 md:grid-cols-4">
<SummaryCard
label="Total de bloques"
value={summary.totalReservations}
icon={CalendarDays}
className="border-border bg-background/45"
/>
<SummaryCard
label="Libres"
value={summary.freeSlots}
detail={`${summary.freePercent}% del total`}
icon={UsersRound}
className="border-primary/30 bg-primary/10 text-primary"
/>
<SummaryCard
label="Reservados"
value={summary.reservedSlots}
detail={`${summary.reservedPercent}% del total`}
icon={ShieldCheck}
className="border-reserved/35 bg-reserved/10 text-reserved"
/>
<SummaryCard
label="Mantenimiento"
value={summary.maintenanceSlots}
detail={`${summary.maintenancePercent}% del total`}
icon={Drill}
className="border-maintenance/35 bg-maintenance/10 text-maintenance"
/>
</div>
</section>
);
}
interface SummaryCardProps {
label: string;
value: number;
detail?: string;
icon: typeof CalendarDays;
className: string;
}
function SummaryCard({ label, value, detail, icon: Icon, className }: SummaryCardProps) {
return (
<article className={`rounded-lg border p-4 ${className}`}>
<div className="flex items-center gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-md bg-transparent">
<Icon className="size-5" />
</div>
<div className="min-w-0">
<p className="truncate text-sm text-muted-foreground">{label}</p>
<p className="text-2xl font-semibold leading-tight">{value}</p>
{detail && <p className="mt-1 truncate text-xs text-muted-foreground">{detail}</p>}
</div>
</div>
</article>
);
}
export function BookingQuickActions() {
const { selectedDate, selectedStatus, setSelectedDate, setSelectedStatus, exportDayReport } =
useBooking();
const todayIso = toIsoDateLocal(new Date());
const isViewingTodayReservations = selectedDate === todayIso && selectedStatus === 'reserved';
const isViewingMaintenance = selectedStatus === 'maintenance';
return (
<section className="rounded-lg border bg-card/85 p-5 shadow-sm">
<h2 className="text-base font-semibold">Acciones rápidas</h2>
<div className="mt-3 grid gap-2">
<button
type="button"
onClick={() => {
setSelectedDate(todayIso);
setSelectedStatus(isViewingTodayReservations ? 'all' : 'reserved');
}}
aria-pressed={isViewingTodayReservations}
className="flex h-10 items-center gap-3 rounded-md border bg-background/45 px-3 text-left text-sm transition-colors hover:bg-muted aria-pressed:border-reserved/50 aria-pressed:bg-reserved/10"
>
<CalendarDays className="size-4 text-muted-foreground" />
{isViewingTodayReservations ? 'Ver todos los estados' : 'Ver reservas de hoy'}
</button>
<button
type="button"
onClick={() => setSelectedStatus(isViewingMaintenance ? 'all' : 'maintenance')}
aria-pressed={isViewingMaintenance}
className="flex h-10 items-center gap-3 rounded-md border bg-background/45 px-3 text-left text-sm transition-colors hover:bg-muted aria-pressed:border-maintenance/50 aria-pressed:bg-maintenance/10"
>
<Drill className="size-4 text-maintenance" />
{isViewingMaintenance ? 'Ver todos los estados' : 'Ver mantenimiento'}
</button>
<button
type="button"
onClick={exportDayReport}
className="flex h-10 items-center gap-3 rounded-md border bg-background/45 px-3 text-left text-sm transition-colors hover:bg-muted"
>
<Download className="size-4 text-primary" />
Exportar reporte
</button>
</div>
</section>
);
}

View File

@@ -1,18 +0,0 @@
import { useBooking } from '../booking-provider';
export function BookingHeader() {
const { complex } = useBooking();
return (
<section>
<div>
<h1 className="text-3xl font-semibold tracking-normal text-foreground">
Panel de Reservas
</h1>
<p className="mt-2 text-sm text-muted-foreground">
Gestiona las canchas de {complex.complexName}
</p>
</div>
</section>
);
}

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