42 Commits

Author SHA1 Message Date
Jose Selesan
c3e26f8f0e feat(admin): add maxSports field to plan rules and limits schemas 2026-06-10 13:07:56 -03:00
Jose Selesan
ef926c63a2 feat(admin): implement pricing overrides for plans and update pricing logic 2026-06-10 11:22:57 -03:00
Jose Selesan
dc8a10a612 feat: add geolocation fields to session and implement geo stats endpoint
- Added country, city, countryCode, latitude, and longitude fields to the Session model.
- Updated geoip service to fetch and store geolocation data based on user IP.
- Created a new admin endpoint to retrieve geolocation statistics, including user counts by country and city.
- Enhanced admin page to display geolocation statistics with expandable city details.
- Introduced caching for geolocation data to optimize performance.
2026-06-10 10:18:31 -03:00
Jose Selesan
00f0cf511f feat(admin): add geolocation information to user sessions and implement geo IP fetching 2026-06-10 10:01:36 -03:00
Jose Selesan
93b3a82638 feat(admin): implement user search functionality in admin portal 2026-06-10 09:44:02 -03:00
Jose Selesan
c1c2f18471 Add admin portal 2026-06-10 09:39:20 -03:00
Jose Selesan
c278c78e3d feat(booking): replace filter toggle with dedicated list view for today's bookings 2026-06-08 23:21:35 -03:00
Jose Selesan
d767ac3ac7 fix(booking): prevent past time slots from being selectable for today 2026-06-08 22:50:56 -03:00
Jose Selesan
8c3ca0e7e1 Fix booking cancelation 2026-06-08 16:53:28 -03:00
Jose Selesan
72f273354e fix(public-booking): enhance focus behavior for customer name input on slot selection 2026-06-08 16:47:13 -03:00
Jose Selesan
ef2ded9d64 Fix input focus on time select 2026-06-08 16:41:24 -03:00
Jose Selesan
4c7b825129 refactor(public-booking): remove MobileBottomNav component and adjust layout styles 2026-06-08 16:34:36 -03:00
339e6a70d7 Merge pull request 'refactor(public-booking): remove CourtDetails component and simplify canGoBack logic' (#15) from feat/remove-attributes into development
Reviewed-on: #15
2026-06-08 19:16:04 +00:00
Jose Selesan
c70541b4f5 refactor(public-booking): remove CourtDetails component and simplify canGoBack logic 2026-06-08 16:14:27 -03:00
89673c3844 Merge pull request 'feat/court-maintenace' (#14) from feat/court-maintenace into development
Reviewed-on: #14
2026-06-08 18:47:02 +00:00
Jose Selesan
630dedb507 feat(booking): add validation to prevent past bookings and implement slot validation logic 2026-06-08 15:46:17 -03:00
Jose Selesan
49d2a13672 feat(booking): allow empty customer email in booking forms and schemas 2026-06-08 15:34:53 -03:00
Jose Selesan
b2f9a14b87 refactor(booking): remove maintenance-related metrics and UI components 2026-06-08 15:28:49 -03:00
Jose Selesan
b48dd4f15d feat(public-booking): update logo and improve layout in confirmation page 2026-06-05 17:06:24 -03:00
Jose Selesan
fee0be7e1f feat(booking-ui): add date navigation buttons in MobileFilters component 2026-06-05 15:52:56 -03:00
Jose Selesan
721ebaa775 fix(booking-ui): adjust height units in booking dialog styling 2026-06-05 15:24:02 -03:00
2ccff7dcaa Merge pull request 'feat(booking-ui): enhance mobile booking dialog styling and layout' (#11) from codex/fix-mobile-booking-dlg into development
Reviewed-on: #11
2026-06-05 17:37:52 +00:00
Jose Selesan
4b1b06f00f feat(booking-ui): enhance mobile booking dialog styling and layout 2026-06-05 14:37:27 -03:00
Jose Selesan
af687fe2d8 refactor(tests): simplify prisma mock imports and ensure sport mock is included 2026-06-05 11:36:21 -03:00
9875f22d5a Merge pull request 'feat/improve-booking-ui' (#10) from feat/improve-booking-ui into development
Reviewed-on: #10
2026-06-05 14:25:06 +00:00
Jose Selesan
260d79fc99 feat: implement public booking cancellation feature
- Added cancelPublicBooking handler to manage booking cancellations.
- Updated public booking service to include cancellation logic.
- Created cancelPublicBooking schema for input validation.
- Modified public booking confirmation page to allow users to cancel their bookings.
- Enhanced email service to send cancellation confirmation emails.
- Made customerEmail field required in booking schemas and updated related components.
- Updated API client to support cancellation requests.
- Added migration to enforce non-nullable customer_email fields in the database.
2026-06-05 11:24:02 -03:00
Jose Selesan
1210854c22 feat(booking-ui): add confirmation dialog for cancelling bookings and improve mobile UI 2026-06-04 16:39:45 -03:00
Jose Selesan
7ca784d5f5 feat(booking-ui): conditionally render action buttons based on booking status 2026-06-04 16:33:51 -03:00
Jose Selesan
e441f15ee4 feat(complex-selection): enhance auto-selection logic and error handling for complex selection 2026-06-04 14:06:23 -03:00
Jose Selesan
228004a7e0 feat(emails): update email layout padding and remove min-height for improved design consistency 2026-06-03 10:57:23 -03:00
b20b5c2b8b Merge pull request 'feat/improve-emails' (#8) from feat/improve-emails into development
Reviewed-on: #8
2026-06-03 13:44:12 +00:00
Jose Selesan
457accfbfa feat(emails): add standardized email templates and layout guidelines 2026-06-03 10:43:17 -03:00
Jose Selesan
43287a4baa feat(emails): integrate email verification layout into auth email sending process 2026-06-03 10:41:56 -03:00
Jose Selesan
93fea8ecad feat(emails): enhance booking cancellation and no-show email templates with improved layout and styling 2026-06-03 10:37:07 -03:00
Jose Selesan
ba7b0322ff Refactor code structure for improved readability and maintainability 2026-06-03 10:31:01 -03:00
Jose Selesan
473686528e feat(tests): update test configurations and improve mocking setup 2026-06-03 09:35:04 -03:00
c1b47674c8 Merge pull request 'feat: add customer email functionality for booking confirmations' (#7) from feat/email-booking-confirmation into development
Reviewed-on: #7
2026-06-02 22:21:39 +00:00
Jose Selesan
85f234b05e feat: add customer email functionality for booking confirmations
- Implemented email confirmation for admin and public bookings.
- Added customerEmail field to booking schemas and services.
- Created email templates for booking confirmation, cancellation, and no-show notifications.
- Updated booking handlers to send emails upon booking creation and status updates.
- Enhanced frontend forms to capture customer email during booking creation.
2026-06-02 19:21:04 -03:00
50fa4ed9a5 Merge pull request 'feat/improve-public-booking' (#6) from feat/improve-public-booking into development
Reviewed-on: #6
2026-06-02 19:56:28 +00:00
Jose Selesan
07496e6673 feat(public-booking): enhance booking confirmation with price and address details 2026-06-02 16:55:47 -03:00
Jose Selesan
9045a8c017 feat(public-booking): add pricing logic to booking slots and update schemas 2026-06-02 16:37:11 -03:00
Jose Selesan
3e314a9b9a feat(public-booking): refactor court selection and available slots components for improved usability 2026-06-02 16:26:47 -03:00
109 changed files with 6206 additions and 2204 deletions

View File

@@ -193,3 +193,61 @@ 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

@@ -1,3 +1,6 @@
# deps # deps
node_modules/ node_modules/
prisma.config.prod.ts prisma.config.prod.ts
# build output
dist/

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", "test": "bun test --preload ./test/support/prisma.mock.ts ./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

@@ -5,6 +5,9 @@ model User {
emailVerified Boolean @default(false) emailVerified Boolean @default(false)
image String? image String?
phone String? phone String?
banned Boolean @default(false)
bannedAt DateTime?
banReason String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
sessions Session[] sessions Session[]
@@ -17,15 +20,20 @@ model User {
} }
model Session { model Session {
id String @id id String @id
expiresAt DateTime expiresAt DateTime
token String token String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
ipAddress String? ipAddress String?
userAgent String? userAgent String?
userId String country String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade) city String?
countryCode String?
latitude Float?
longitude Float?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([token]) @@unique([token])
@@index([userId]) @@index([userId])

View File

@@ -34,6 +34,8 @@ model Court {
name String name String
slotDurationMinutes Int @map("slot_duration_minutes") slotDurationMinutes Int @map("slot_duration_minutes")
basePrice Decimal @map("base_price") @db.Decimal(19, 2) basePrice Decimal @map("base_price") @db.Decimal(19, 2)
isUnderMaintenance Boolean @default(false) @map("is_under_maintenance")
maintenanceReason String? @map("maintenance_reason") @db.VarChar(500)
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")
complex Complex @relation(fields: [complexId], references: [id], onDelete: Cascade) complex Complex @relation(fields: [complexId], references: [id], onDelete: Cascade)
@@ -41,12 +43,28 @@ model Court {
availabilities CourtAvailability[] availabilities CourtAvailability[]
priceRules CourtPriceRule[] priceRules CourtPriceRule[]
bookings CourtBooking[] bookings CourtBooking[]
maintenances CourtMaintenance[]
@@index([complexId]) @@index([complexId])
@@index([sportId]) @@index([sportId])
@@map("courts") @@map("courts")
} }
model CourtMaintenance {
id String @id @db.Uuid
courtId String @map("court_id") @db.Uuid
startDate DateTime @map("start_date") @db.Date
startTime String? @map("start_time") @db.VarChar(5)
endTime String? @map("end_time") @db.VarChar(5)
reason String? @db.VarChar(500)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
court Court @relation(fields: [courtId], references: [id], onDelete: Cascade)
@@index([courtId, startDate])
@@map("court_maintenances")
}
model CourtAvailability { model CourtAvailability {
id String @id @db.Uuid id String @id @db.Uuid
courtId String @map("court_id") @db.Uuid courtId String @map("court_id") @db.Uuid
@@ -85,6 +103,7 @@ model CourtBooking {
endTime String @map("end_time") @db.VarChar(5) endTime String @map("end_time") @db.VarChar(5)
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")
@@ -107,6 +126,7 @@ model CourtBookingLog {
newStatus CourtBookingStatus @map("new_status") newStatus CourtBookingStatus @map("new_status")
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)
changedAt DateTime @default(now()) @map("changed_at") changedAt DateTime @default(now()) @map("changed_at")
@@map("court_booking_logs") @@map("court_booking_logs")

View File

@@ -0,0 +1,14 @@
/*
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

@@ -0,0 +1,16 @@
/*
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,23 @@
-- CreateTable
CREATE TABLE "court_maintenances" (
"id" UUID NOT NULL,
"court_id" UUID NOT NULL,
"start_date" DATE NOT NULL,
"start_time" VARCHAR(5),
"end_time" VARCHAR(5),
"reason" VARCHAR(500),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "court_maintenances_pkey" PRIMARY KEY ("id")
);
-- AlterTable
ALTER TABLE "courts" ADD COLUMN "is_under_maintenance" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "courts" ADD COLUMN "maintenance_reason" VARCHAR(500);
-- CreateIndex
CREATE INDEX "court_maintenances_court_id_start_date_idx" ON "court_maintenances"("court_id", "start_date");
-- AddForeignKey
ALTER TABLE "court_maintenances" ADD CONSTRAINT "court_maintenances_court_id_fkey" FOREIGN KEY ("court_id") REFERENCES "courts"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "banReason" TEXT,
ADD COLUMN "banned" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "bannedAt" TIMESTAMP(3);

View File

@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "sessions" ADD COLUMN "city" TEXT,
ADD COLUMN "country" TEXT,
ADD COLUMN "countryCode" TEXT,
ADD COLUMN "latitude" DOUBLE PRECISION,
ADD COLUMN "longitude" DOUBLE PRECISION;

View File

@@ -4,7 +4,7 @@ export const planSeeds = [
name: 'Basic', name: 'Basic',
price: '49.00', price: '49.00',
rules: { rules: {
version: 'v1', version: 'v2',
limits: { limits: {
maxCourts: 2, maxCourts: 2,
maxBookingsPerDay: 80, maxBookingsPerDay: 80,
@@ -22,6 +22,11 @@ export const planSeeds = [
advancedReports: false, advancedReports: false,
whatsappReminders: false, whatsappReminders: false,
}, },
pricing: {
overrides: {
AR: { amount: 14999, currency: 'ARS' },
},
},
}, },
}, },
{ {
@@ -29,7 +34,7 @@ export const planSeeds = [
name: 'Advanced', name: 'Advanced',
price: '99.00', price: '99.00',
rules: { rules: {
version: 'v1', version: 'v2',
limits: { limits: {
maxCourts: 5, maxCourts: 5,
maxBookingsPerDay: 220, maxBookingsPerDay: 220,
@@ -47,6 +52,11 @@ export const planSeeds = [
advancedReports: true, advancedReports: true,
whatsappReminders: true, whatsappReminders: true,
}, },
pricing: {
overrides: {
AR: { amount: 29999, currency: 'ARS' },
},
},
}, },
}, },
{ {
@@ -54,7 +64,7 @@ export const planSeeds = [
name: 'Enterprise', name: 'Enterprise',
price: '199.00', price: '199.00',
rules: { rules: {
version: 'v1', version: 'v2',
limits: { limits: {
maxCourts: 20, maxCourts: 20,
maxBookingsPerDay: 2000, maxBookingsPerDay: 2000,
@@ -72,6 +82,11 @@ export const planSeeds = [
advancedReports: true, advancedReports: true,
whatsappReminders: true, whatsappReminders: true,
}, },
pricing: {
overrides: {
AR: { amount: 59999, currency: 'ARS' },
},
},
}, },
}, },
] as const; ] as const;

View File

@@ -0,0 +1,401 @@
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

@@ -62,6 +62,11 @@ export type Sport = Prisma.SportModel
* *
*/ */
export type Court = Prisma.CourtModel export type Court = Prisma.CourtModel
/**
* Model CourtMaintenance
*
*/
export type CourtMaintenance = Prisma.CourtMaintenanceModel
/** /**
* Model CourtAvailability * Model CourtAvailability
* *
@@ -82,11 +87,6 @@ export type CourtBooking = Prisma.CourtBookingModel
* *
*/ */
export type CourtBookingLog = Prisma.CourtBookingLogModel export type CourtBookingLog = Prisma.CourtBookingLogModel
/**
* Model OnboardingRequest
*
*/
export type OnboardingRequest = Prisma.OnboardingRequestModel
/** /**
* Model PasswordResetRequest * Model PasswordResetRequest
* *

View File

@@ -86,6 +86,11 @@ export type Sport = Prisma.SportModel
* *
*/ */
export type Court = Prisma.CourtModel export type Court = Prisma.CourtModel
/**
* Model CourtMaintenance
*
*/
export type CourtMaintenance = Prisma.CourtMaintenanceModel
/** /**
* Model CourtAvailability * Model CourtAvailability
* *
@@ -106,11 +111,6 @@ export type CourtBooking = Prisma.CourtBookingModel
* *
*/ */
export type CourtBookingLog = Prisma.CourtBookingLogModel export type CourtBookingLog = Prisma.CourtBookingLogModel
/**
* Model OnboardingRequest
*
*/
export type OnboardingRequest = Prisma.OnboardingRequestModel
/** /**
* Model PasswordResetRequest * Model PasswordResetRequest
* *

View File

@@ -49,6 +49,17 @@ export type StringNullableFilter<$PrismaModel = never> = {
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
} }
export type DateTimeNullableFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
}
export type DateTimeFilter<$PrismaModel = never> = { export type DateTimeFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
@@ -109,6 +120,20 @@ export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
_max?: Prisma.NestedStringNullableFilter<$PrismaModel> _max?: Prisma.NestedStringNullableFilter<$PrismaModel>
} }
export type DateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
}
export type DateTimeWithAggregatesFilter<$PrismaModel = never> = { export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
@@ -123,29 +148,31 @@ export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
_max?: Prisma.NestedDateTimeFilter<$PrismaModel> _max?: Prisma.NestedDateTimeFilter<$PrismaModel>
} }
export type DateTimeNullableFilter<$PrismaModel = never> = { export type FloatNullableFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null not?: Prisma.NestedFloatNullableFilter<$PrismaModel> | number | null
} }
export type DateTimeNullableWithAggregatesFilter<$PrismaModel = never> = { export type FloatNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null not?: Prisma.NestedFloatNullableWithAggregatesFilter<$PrismaModel> | number | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel> _count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> _avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> _sum?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_min?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_max?: Prisma.NestedFloatNullableFilter<$PrismaModel>
} }
export type UuidFilter<$PrismaModel = never> = { export type UuidFilter<$PrismaModel = never> = {
@@ -381,6 +408,17 @@ export type NestedStringNullableFilter<$PrismaModel = never> = {
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
} }
export type NestedDateTimeNullableFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
}
export type NestedDateTimeFilter<$PrismaModel = never> = { export type NestedDateTimeFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
@@ -456,6 +494,20 @@ export type NestedIntNullableFilter<$PrismaModel = never> = {
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
} }
export type NestedDateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
}
export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = { export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
@@ -470,29 +522,31 @@ export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
_max?: Prisma.NestedDateTimeFilter<$PrismaModel> _max?: Prisma.NestedDateTimeFilter<$PrismaModel>
} }
export type NestedDateTimeNullableFilter<$PrismaModel = never> = { export type NestedFloatNullableFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null not?: Prisma.NestedFloatNullableFilter<$PrismaModel> | number | null
} }
export type NestedDateTimeNullableWithAggregatesFilter<$PrismaModel = never> = { export type NestedFloatNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null not?: Prisma.NestedFloatNullableWithAggregatesFilter<$PrismaModel> | number | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel> _count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> _avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> _sum?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_min?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_max?: Prisma.NestedFloatNullableFilter<$PrismaModel>
} }
export type NestedUuidFilter<$PrismaModel = never> = { export type NestedUuidFilter<$PrismaModel = never> = {

File diff suppressed because one or more lines are too long

View File

@@ -393,11 +393,11 @@ export const ModelName = {
ComplexInvitation: 'ComplexInvitation', ComplexInvitation: 'ComplexInvitation',
Sport: 'Sport', Sport: 'Sport',
Court: 'Court', Court: 'Court',
CourtMaintenance: 'CourtMaintenance',
CourtAvailability: 'CourtAvailability', CourtAvailability: 'CourtAvailability',
CourtPriceRule: 'CourtPriceRule', CourtPriceRule: 'CourtPriceRule',
CourtBooking: 'CourtBooking', CourtBooking: 'CourtBooking',
CourtBookingLog: 'CourtBookingLog', CourtBookingLog: 'CourtBookingLog',
OnboardingRequest: 'OnboardingRequest',
PasswordResetRequest: 'PasswordResetRequest', PasswordResetRequest: 'PasswordResetRequest',
Plan: 'Plan' Plan: 'Plan'
} as const } as const
@@ -415,7 +415,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" | "onboardingRequest" | "passwordResetRequest" | "plan" modelProps: "user" | "session" | "account" | "verification" | "complex" | "complexUser" | "complexInvitation" | "sport" | "court" | "courtMaintenance" | "courtAvailability" | "courtPriceRule" | "courtBooking" | "courtBookingLog" | "passwordResetRequest" | "plan"
txIsolationLevel: TransactionIsolationLevel txIsolationLevel: TransactionIsolationLevel
} }
model: { model: {
@@ -1085,6 +1085,80 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
} }
} }
} }
CourtMaintenance: {
payload: Prisma.$CourtMaintenancePayload<ExtArgs>
fields: Prisma.CourtMaintenanceFieldRefs
operations: {
findUnique: {
args: Prisma.CourtMaintenanceFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtMaintenancePayload> | null
}
findUniqueOrThrow: {
args: Prisma.CourtMaintenanceFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtMaintenancePayload>
}
findFirst: {
args: Prisma.CourtMaintenanceFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtMaintenancePayload> | null
}
findFirstOrThrow: {
args: Prisma.CourtMaintenanceFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtMaintenancePayload>
}
findMany: {
args: Prisma.CourtMaintenanceFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtMaintenancePayload>[]
}
create: {
args: Prisma.CourtMaintenanceCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtMaintenancePayload>
}
createMany: {
args: Prisma.CourtMaintenanceCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.CourtMaintenanceCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtMaintenancePayload>[]
}
delete: {
args: Prisma.CourtMaintenanceDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtMaintenancePayload>
}
update: {
args: Prisma.CourtMaintenanceUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtMaintenancePayload>
}
deleteMany: {
args: Prisma.CourtMaintenanceDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.CourtMaintenanceUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.CourtMaintenanceUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtMaintenancePayload>[]
}
upsert: {
args: Prisma.CourtMaintenanceUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtMaintenancePayload>
}
aggregate: {
args: Prisma.CourtMaintenanceAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateCourtMaintenance>
}
groupBy: {
args: Prisma.CourtMaintenanceGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.CourtMaintenanceGroupByOutputType>[]
}
count: {
args: Prisma.CourtMaintenanceCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.CourtMaintenanceCountAggregateOutputType> | number
}
}
}
CourtAvailability: { CourtAvailability: {
payload: Prisma.$CourtAvailabilityPayload<ExtArgs> payload: Prisma.$CourtAvailabilityPayload<ExtArgs>
fields: Prisma.CourtAvailabilityFieldRefs fields: Prisma.CourtAvailabilityFieldRefs
@@ -1381,80 +1455,6 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
} }
} }
} }
OnboardingRequest: {
payload: Prisma.$OnboardingRequestPayload<ExtArgs>
fields: Prisma.OnboardingRequestFieldRefs
operations: {
findUnique: {
args: Prisma.OnboardingRequestFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload> | null
}
findUniqueOrThrow: {
args: Prisma.OnboardingRequestFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload>
}
findFirst: {
args: Prisma.OnboardingRequestFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload> | null
}
findFirstOrThrow: {
args: Prisma.OnboardingRequestFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload>
}
findMany: {
args: Prisma.OnboardingRequestFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload>[]
}
create: {
args: Prisma.OnboardingRequestCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload>
}
createMany: {
args: Prisma.OnboardingRequestCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.OnboardingRequestCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload>[]
}
delete: {
args: Prisma.OnboardingRequestDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload>
}
update: {
args: Prisma.OnboardingRequestUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload>
}
deleteMany: {
args: Prisma.OnboardingRequestDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.OnboardingRequestUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.OnboardingRequestUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload>[]
}
upsert: {
args: Prisma.OnboardingRequestUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload>
}
aggregate: {
args: Prisma.OnboardingRequestAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateOnboardingRequest>
}
groupBy: {
args: Prisma.OnboardingRequestGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.OnboardingRequestGroupByOutputType>[]
}
count: {
args: Prisma.OnboardingRequestCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.OnboardingRequestCountAggregateOutputType> | number
}
}
}
PasswordResetRequest: { PasswordResetRequest: {
payload: Prisma.$PasswordResetRequestPayload<ExtArgs> payload: Prisma.$PasswordResetRequestPayload<ExtArgs>
fields: Prisma.PasswordResetRequestFieldRefs fields: Prisma.PasswordResetRequestFieldRefs
@@ -1649,6 +1649,9 @@ export const UserScalarFieldEnum = {
emailVerified: 'emailVerified', emailVerified: 'emailVerified',
image: 'image', image: 'image',
phone: 'phone', phone: 'phone',
banned: 'banned',
bannedAt: 'bannedAt',
banReason: 'banReason',
createdAt: 'createdAt', createdAt: 'createdAt',
updatedAt: 'updatedAt', updatedAt: 'updatedAt',
role: 'role' role: 'role'
@@ -1665,6 +1668,11 @@ export const SessionScalarFieldEnum = {
updatedAt: 'updatedAt', updatedAt: 'updatedAt',
ipAddress: 'ipAddress', ipAddress: 'ipAddress',
userAgent: 'userAgent', userAgent: 'userAgent',
country: 'country',
city: 'city',
countryCode: 'countryCode',
latitude: 'latitude',
longitude: 'longitude',
userId: 'userId' userId: 'userId'
} as const } as const
@@ -1763,6 +1771,8 @@ export const CourtScalarFieldEnum = {
name: 'name', name: 'name',
slotDurationMinutes: 'slotDurationMinutes', slotDurationMinutes: 'slotDurationMinutes',
basePrice: 'basePrice', basePrice: 'basePrice',
isUnderMaintenance: 'isUnderMaintenance',
maintenanceReason: 'maintenanceReason',
createdAt: 'createdAt', createdAt: 'createdAt',
updatedAt: 'updatedAt' updatedAt: 'updatedAt'
} as const } as const
@@ -1770,6 +1780,20 @@ export const CourtScalarFieldEnum = {
export type CourtScalarFieldEnum = (typeof CourtScalarFieldEnum)[keyof typeof CourtScalarFieldEnum] export type CourtScalarFieldEnum = (typeof CourtScalarFieldEnum)[keyof typeof CourtScalarFieldEnum]
export const CourtMaintenanceScalarFieldEnum = {
id: 'id',
courtId: 'courtId',
startDate: 'startDate',
startTime: 'startTime',
endTime: 'endTime',
reason: 'reason',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type CourtMaintenanceScalarFieldEnum = (typeof CourtMaintenanceScalarFieldEnum)[keyof typeof CourtMaintenanceScalarFieldEnum]
export const CourtAvailabilityScalarFieldEnum = { export const CourtAvailabilityScalarFieldEnum = {
id: 'id', id: 'id',
courtId: 'courtId', courtId: 'courtId',
@@ -1806,6 +1830,7 @@ export const CourtBookingScalarFieldEnum = {
endTime: 'endTime', endTime: 'endTime',
customerName: 'customerName', customerName: 'customerName',
customerPhone: 'customerPhone', customerPhone: 'customerPhone',
customerEmail: 'customerEmail',
status: 'status', status: 'status',
createdAt: 'createdAt', createdAt: 'createdAt',
updatedAt: 'updatedAt' updatedAt: 'updatedAt'
@@ -1825,30 +1850,13 @@ export const CourtBookingLogScalarFieldEnum = {
newStatus: 'newStatus', newStatus: 'newStatus',
customerName: 'customerName', customerName: 'customerName',
customerPhone: 'customerPhone', customerPhone: 'customerPhone',
customerEmail: 'customerEmail',
changedAt: 'changedAt' changedAt: 'changedAt'
} as const } as const
export type CourtBookingLogScalarFieldEnum = (typeof CourtBookingLogScalarFieldEnum)[keyof typeof CourtBookingLogScalarFieldEnum] export type CourtBookingLogScalarFieldEnum = (typeof CourtBookingLogScalarFieldEnum)[keyof typeof CourtBookingLogScalarFieldEnum]
export const OnboardingRequestScalarFieldEnum = {
id: 'id',
fullName: 'fullName',
email: 'email',
otpHash: 'otpHash',
otpExpiresAt: 'otpExpiresAt',
otpAttempts: 'otpAttempts',
otpLastSentAt: 'otpLastSentAt',
otpResendCount: 'otpResendCount',
emailVerifiedAt: 'emailVerifiedAt',
completedAt: 'completedAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type OnboardingRequestScalarFieldEnum = (typeof OnboardingRequestScalarFieldEnum)[keyof typeof OnboardingRequestScalarFieldEnum]
export const PasswordResetRequestScalarFieldEnum = { export const PasswordResetRequestScalarFieldEnum = {
id: 'id', id: 'id',
email: 'email', email: 'email',
@@ -1957,6 +1965,20 @@ export type ListDateTimeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaM
/**
* Reference to a field of type 'Float'
*/
export type FloatFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Float'>
/**
* Reference to a field of type 'Float[]'
*/
export type ListFloatFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Float[]'>
/** /**
* Reference to a field of type 'ComplexUserRole' * Reference to a field of type 'ComplexUserRole'
*/ */
@@ -2040,20 +2062,6 @@ export type JsonFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'J
export type EnumQueryModeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'QueryMode'> export type EnumQueryModeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'QueryMode'>
/**
* Reference to a field of type 'Float'
*/
export type FloatFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Float'>
/**
* Reference to a field of type 'Float[]'
*/
export type ListFloatFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Float[]'>
/** /**
* Batch Payload for updateMany & deleteMany & createMany * Batch Payload for updateMany & deleteMany & createMany
*/ */
@@ -2158,11 +2166,11 @@ export type GlobalOmitConfig = {
complexInvitation?: Prisma.ComplexInvitationOmit complexInvitation?: Prisma.ComplexInvitationOmit
sport?: Prisma.SportOmit sport?: Prisma.SportOmit
court?: Prisma.CourtOmit court?: Prisma.CourtOmit
courtMaintenance?: Prisma.CourtMaintenanceOmit
courtAvailability?: Prisma.CourtAvailabilityOmit courtAvailability?: Prisma.CourtAvailabilityOmit
courtPriceRule?: Prisma.CourtPriceRuleOmit courtPriceRule?: Prisma.CourtPriceRuleOmit
courtBooking?: Prisma.CourtBookingOmit courtBooking?: Prisma.CourtBookingOmit
courtBookingLog?: Prisma.CourtBookingLogOmit courtBookingLog?: Prisma.CourtBookingLogOmit
onboardingRequest?: Prisma.OnboardingRequestOmit
passwordResetRequest?: Prisma.PasswordResetRequestOmit passwordResetRequest?: Prisma.PasswordResetRequestOmit
plan?: Prisma.PlanOmit plan?: Prisma.PlanOmit
} }

View File

@@ -60,11 +60,11 @@ export const ModelName = {
ComplexInvitation: 'ComplexInvitation', ComplexInvitation: 'ComplexInvitation',
Sport: 'Sport', Sport: 'Sport',
Court: 'Court', Court: 'Court',
CourtMaintenance: 'CourtMaintenance',
CourtAvailability: 'CourtAvailability', CourtAvailability: 'CourtAvailability',
CourtPriceRule: 'CourtPriceRule', CourtPriceRule: 'CourtPriceRule',
CourtBooking: 'CourtBooking', CourtBooking: 'CourtBooking',
CourtBookingLog: 'CourtBookingLog', CourtBookingLog: 'CourtBookingLog',
OnboardingRequest: 'OnboardingRequest',
PasswordResetRequest: 'PasswordResetRequest', PasswordResetRequest: 'PasswordResetRequest',
Plan: 'Plan' Plan: 'Plan'
} as const } as const
@@ -92,6 +92,9 @@ export const UserScalarFieldEnum = {
emailVerified: 'emailVerified', emailVerified: 'emailVerified',
image: 'image', image: 'image',
phone: 'phone', phone: 'phone',
banned: 'banned',
bannedAt: 'bannedAt',
banReason: 'banReason',
createdAt: 'createdAt', createdAt: 'createdAt',
updatedAt: 'updatedAt', updatedAt: 'updatedAt',
role: 'role' role: 'role'
@@ -108,6 +111,11 @@ export const SessionScalarFieldEnum = {
updatedAt: 'updatedAt', updatedAt: 'updatedAt',
ipAddress: 'ipAddress', ipAddress: 'ipAddress',
userAgent: 'userAgent', userAgent: 'userAgent',
country: 'country',
city: 'city',
countryCode: 'countryCode',
latitude: 'latitude',
longitude: 'longitude',
userId: 'userId' userId: 'userId'
} as const } as const
@@ -206,6 +214,8 @@ export const CourtScalarFieldEnum = {
name: 'name', name: 'name',
slotDurationMinutes: 'slotDurationMinutes', slotDurationMinutes: 'slotDurationMinutes',
basePrice: 'basePrice', basePrice: 'basePrice',
isUnderMaintenance: 'isUnderMaintenance',
maintenanceReason: 'maintenanceReason',
createdAt: 'createdAt', createdAt: 'createdAt',
updatedAt: 'updatedAt' updatedAt: 'updatedAt'
} as const } as const
@@ -213,6 +223,20 @@ export const CourtScalarFieldEnum = {
export type CourtScalarFieldEnum = (typeof CourtScalarFieldEnum)[keyof typeof CourtScalarFieldEnum] export type CourtScalarFieldEnum = (typeof CourtScalarFieldEnum)[keyof typeof CourtScalarFieldEnum]
export const CourtMaintenanceScalarFieldEnum = {
id: 'id',
courtId: 'courtId',
startDate: 'startDate',
startTime: 'startTime',
endTime: 'endTime',
reason: 'reason',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type CourtMaintenanceScalarFieldEnum = (typeof CourtMaintenanceScalarFieldEnum)[keyof typeof CourtMaintenanceScalarFieldEnum]
export const CourtAvailabilityScalarFieldEnum = { export const CourtAvailabilityScalarFieldEnum = {
id: 'id', id: 'id',
courtId: 'courtId', courtId: 'courtId',
@@ -249,6 +273,7 @@ export const CourtBookingScalarFieldEnum = {
endTime: 'endTime', endTime: 'endTime',
customerName: 'customerName', customerName: 'customerName',
customerPhone: 'customerPhone', customerPhone: 'customerPhone',
customerEmail: 'customerEmail',
status: 'status', status: 'status',
createdAt: 'createdAt', createdAt: 'createdAt',
updatedAt: 'updatedAt' updatedAt: 'updatedAt'
@@ -268,30 +293,13 @@ export const CourtBookingLogScalarFieldEnum = {
newStatus: 'newStatus', newStatus: 'newStatus',
customerName: 'customerName', customerName: 'customerName',
customerPhone: 'customerPhone', customerPhone: 'customerPhone',
customerEmail: 'customerEmail',
changedAt: 'changedAt' changedAt: 'changedAt'
} as const } as const
export type CourtBookingLogScalarFieldEnum = (typeof CourtBookingLogScalarFieldEnum)[keyof typeof CourtBookingLogScalarFieldEnum] export type CourtBookingLogScalarFieldEnum = (typeof CourtBookingLogScalarFieldEnum)[keyof typeof CourtBookingLogScalarFieldEnum]
export const OnboardingRequestScalarFieldEnum = {
id: 'id',
fullName: 'fullName',
email: 'email',
otpHash: 'otpHash',
otpExpiresAt: 'otpExpiresAt',
otpAttempts: 'otpAttempts',
otpLastSentAt: 'otpLastSentAt',
otpResendCount: 'otpResendCount',
emailVerifiedAt: 'emailVerifiedAt',
completedAt: 'completedAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type OnboardingRequestScalarFieldEnum = (typeof OnboardingRequestScalarFieldEnum)[keyof typeof OnboardingRequestScalarFieldEnum]
export const PasswordResetRequestScalarFieldEnum = { export const PasswordResetRequestScalarFieldEnum = {
id: 'id', id: 'id',
email: 'email', email: 'email',

View File

@@ -17,11 +17,11 @@ export type * from './models/ComplexUser'
export type * from './models/ComplexInvitation' export type * from './models/ComplexInvitation'
export type * from './models/Sport' export type * from './models/Sport'
export type * from './models/Court' export type * from './models/Court'
export type * from './models/CourtMaintenance'
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/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

@@ -43,6 +43,8 @@ export type CourtMinAggregateOutputType = {
name: string | null name: string | null
slotDurationMinutes: number | null slotDurationMinutes: number | null
basePrice: runtime.Decimal | null basePrice: runtime.Decimal | null
isUnderMaintenance: boolean | null
maintenanceReason: string | null
createdAt: Date | null createdAt: Date | null
updatedAt: Date | null updatedAt: Date | null
} }
@@ -54,6 +56,8 @@ export type CourtMaxAggregateOutputType = {
name: string | null name: string | null
slotDurationMinutes: number | null slotDurationMinutes: number | null
basePrice: runtime.Decimal | null basePrice: runtime.Decimal | null
isUnderMaintenance: boolean | null
maintenanceReason: string | null
createdAt: Date | null createdAt: Date | null
updatedAt: Date | null updatedAt: Date | null
} }
@@ -65,6 +69,8 @@ export type CourtCountAggregateOutputType = {
name: number name: number
slotDurationMinutes: number slotDurationMinutes: number
basePrice: number basePrice: number
isUnderMaintenance: number
maintenanceReason: number
createdAt: number createdAt: number
updatedAt: number updatedAt: number
_all: number _all: number
@@ -88,6 +94,8 @@ export type CourtMinAggregateInputType = {
name?: true name?: true
slotDurationMinutes?: true slotDurationMinutes?: true
basePrice?: true basePrice?: true
isUnderMaintenance?: true
maintenanceReason?: true
createdAt?: true createdAt?: true
updatedAt?: true updatedAt?: true
} }
@@ -99,6 +107,8 @@ export type CourtMaxAggregateInputType = {
name?: true name?: true
slotDurationMinutes?: true slotDurationMinutes?: true
basePrice?: true basePrice?: true
isUnderMaintenance?: true
maintenanceReason?: true
createdAt?: true createdAt?: true
updatedAt?: true updatedAt?: true
} }
@@ -110,6 +120,8 @@ export type CourtCountAggregateInputType = {
name?: true name?: true
slotDurationMinutes?: true slotDurationMinutes?: true
basePrice?: true basePrice?: true
isUnderMaintenance?: true
maintenanceReason?: true
createdAt?: true createdAt?: true
updatedAt?: true updatedAt?: true
_all?: true _all?: true
@@ -208,6 +220,8 @@ export type CourtGroupByOutputType = {
name: string name: string
slotDurationMinutes: number slotDurationMinutes: number
basePrice: runtime.Decimal basePrice: runtime.Decimal
isUnderMaintenance: boolean
maintenanceReason: string | null
createdAt: Date createdAt: Date
updatedAt: Date updatedAt: Date
_count: CourtCountAggregateOutputType | null _count: CourtCountAggregateOutputType | null
@@ -242,6 +256,8 @@ export type CourtWhereInput = {
name?: Prisma.StringFilter<"Court"> | string name?: Prisma.StringFilter<"Court"> | string
slotDurationMinutes?: Prisma.IntFilter<"Court"> | number slotDurationMinutes?: Prisma.IntFilter<"Court"> | number
basePrice?: Prisma.DecimalFilter<"Court"> | runtime.Decimal | runtime.DecimalJsLike | number | string basePrice?: Prisma.DecimalFilter<"Court"> | runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: Prisma.BoolFilter<"Court"> | boolean
maintenanceReason?: Prisma.StringNullableFilter<"Court"> | string | null
createdAt?: Prisma.DateTimeFilter<"Court"> | Date | string createdAt?: Prisma.DateTimeFilter<"Court"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"Court"> | Date | string updatedAt?: Prisma.DateTimeFilter<"Court"> | Date | string
complex?: Prisma.XOR<Prisma.ComplexScalarRelationFilter, Prisma.ComplexWhereInput> complex?: Prisma.XOR<Prisma.ComplexScalarRelationFilter, Prisma.ComplexWhereInput>
@@ -249,6 +265,7 @@ export type CourtWhereInput = {
availabilities?: Prisma.CourtAvailabilityListRelationFilter availabilities?: Prisma.CourtAvailabilityListRelationFilter
priceRules?: Prisma.CourtPriceRuleListRelationFilter priceRules?: Prisma.CourtPriceRuleListRelationFilter
bookings?: Prisma.CourtBookingListRelationFilter bookings?: Prisma.CourtBookingListRelationFilter
maintenances?: Prisma.CourtMaintenanceListRelationFilter
} }
export type CourtOrderByWithRelationInput = { export type CourtOrderByWithRelationInput = {
@@ -258,6 +275,8 @@ export type CourtOrderByWithRelationInput = {
name?: Prisma.SortOrder name?: Prisma.SortOrder
slotDurationMinutes?: Prisma.SortOrder slotDurationMinutes?: Prisma.SortOrder
basePrice?: Prisma.SortOrder basePrice?: Prisma.SortOrder
isUnderMaintenance?: Prisma.SortOrder
maintenanceReason?: Prisma.SortOrderInput | Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
complex?: Prisma.ComplexOrderByWithRelationInput complex?: Prisma.ComplexOrderByWithRelationInput
@@ -265,6 +284,7 @@ export type CourtOrderByWithRelationInput = {
availabilities?: Prisma.CourtAvailabilityOrderByRelationAggregateInput availabilities?: Prisma.CourtAvailabilityOrderByRelationAggregateInput
priceRules?: Prisma.CourtPriceRuleOrderByRelationAggregateInput priceRules?: Prisma.CourtPriceRuleOrderByRelationAggregateInput
bookings?: Prisma.CourtBookingOrderByRelationAggregateInput bookings?: Prisma.CourtBookingOrderByRelationAggregateInput
maintenances?: Prisma.CourtMaintenanceOrderByRelationAggregateInput
} }
export type CourtWhereUniqueInput = Prisma.AtLeast<{ export type CourtWhereUniqueInput = Prisma.AtLeast<{
@@ -277,6 +297,8 @@ export type CourtWhereUniqueInput = Prisma.AtLeast<{
name?: Prisma.StringFilter<"Court"> | string name?: Prisma.StringFilter<"Court"> | string
slotDurationMinutes?: Prisma.IntFilter<"Court"> | number slotDurationMinutes?: Prisma.IntFilter<"Court"> | number
basePrice?: Prisma.DecimalFilter<"Court"> | runtime.Decimal | runtime.DecimalJsLike | number | string basePrice?: Prisma.DecimalFilter<"Court"> | runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: Prisma.BoolFilter<"Court"> | boolean
maintenanceReason?: Prisma.StringNullableFilter<"Court"> | string | null
createdAt?: Prisma.DateTimeFilter<"Court"> | Date | string createdAt?: Prisma.DateTimeFilter<"Court"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"Court"> | Date | string updatedAt?: Prisma.DateTimeFilter<"Court"> | Date | string
complex?: Prisma.XOR<Prisma.ComplexScalarRelationFilter, Prisma.ComplexWhereInput> complex?: Prisma.XOR<Prisma.ComplexScalarRelationFilter, Prisma.ComplexWhereInput>
@@ -284,6 +306,7 @@ export type CourtWhereUniqueInput = Prisma.AtLeast<{
availabilities?: Prisma.CourtAvailabilityListRelationFilter availabilities?: Prisma.CourtAvailabilityListRelationFilter
priceRules?: Prisma.CourtPriceRuleListRelationFilter priceRules?: Prisma.CourtPriceRuleListRelationFilter
bookings?: Prisma.CourtBookingListRelationFilter bookings?: Prisma.CourtBookingListRelationFilter
maintenances?: Prisma.CourtMaintenanceListRelationFilter
}, "id"> }, "id">
export type CourtOrderByWithAggregationInput = { export type CourtOrderByWithAggregationInput = {
@@ -293,6 +316,8 @@ export type CourtOrderByWithAggregationInput = {
name?: Prisma.SortOrder name?: Prisma.SortOrder
slotDurationMinutes?: Prisma.SortOrder slotDurationMinutes?: Prisma.SortOrder
basePrice?: Prisma.SortOrder basePrice?: Prisma.SortOrder
isUnderMaintenance?: Prisma.SortOrder
maintenanceReason?: Prisma.SortOrderInput | Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
_count?: Prisma.CourtCountOrderByAggregateInput _count?: Prisma.CourtCountOrderByAggregateInput
@@ -312,6 +337,8 @@ export type CourtScalarWhereWithAggregatesInput = {
name?: Prisma.StringWithAggregatesFilter<"Court"> | string name?: Prisma.StringWithAggregatesFilter<"Court"> | string
slotDurationMinutes?: Prisma.IntWithAggregatesFilter<"Court"> | number slotDurationMinutes?: Prisma.IntWithAggregatesFilter<"Court"> | number
basePrice?: Prisma.DecimalWithAggregatesFilter<"Court"> | runtime.Decimal | runtime.DecimalJsLike | number | string basePrice?: Prisma.DecimalWithAggregatesFilter<"Court"> | runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: Prisma.BoolWithAggregatesFilter<"Court"> | boolean
maintenanceReason?: Prisma.StringNullableWithAggregatesFilter<"Court"> | string | null
createdAt?: Prisma.DateTimeWithAggregatesFilter<"Court"> | Date | string createdAt?: Prisma.DateTimeWithAggregatesFilter<"Court"> | Date | string
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"Court"> | Date | string updatedAt?: Prisma.DateTimeWithAggregatesFilter<"Court"> | Date | string
} }
@@ -321,6 +348,8 @@ export type CourtCreateInput = {
name: string name: string
slotDurationMinutes: number slotDurationMinutes: number
basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: boolean
maintenanceReason?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
complex: Prisma.ComplexCreateNestedOneWithoutCourtsInput complex: Prisma.ComplexCreateNestedOneWithoutCourtsInput
@@ -328,6 +357,7 @@ export type CourtCreateInput = {
availabilities?: Prisma.CourtAvailabilityCreateNestedManyWithoutCourtInput availabilities?: Prisma.CourtAvailabilityCreateNestedManyWithoutCourtInput
priceRules?: Prisma.CourtPriceRuleCreateNestedManyWithoutCourtInput priceRules?: Prisma.CourtPriceRuleCreateNestedManyWithoutCourtInput
bookings?: Prisma.CourtBookingCreateNestedManyWithoutCourtInput bookings?: Prisma.CourtBookingCreateNestedManyWithoutCourtInput
maintenances?: Prisma.CourtMaintenanceCreateNestedManyWithoutCourtInput
} }
export type CourtUncheckedCreateInput = { export type CourtUncheckedCreateInput = {
@@ -337,11 +367,14 @@ export type CourtUncheckedCreateInput = {
name: string name: string
slotDurationMinutes: number slotDurationMinutes: number
basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: boolean
maintenanceReason?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
availabilities?: Prisma.CourtAvailabilityUncheckedCreateNestedManyWithoutCourtInput availabilities?: Prisma.CourtAvailabilityUncheckedCreateNestedManyWithoutCourtInput
priceRules?: Prisma.CourtPriceRuleUncheckedCreateNestedManyWithoutCourtInput priceRules?: Prisma.CourtPriceRuleUncheckedCreateNestedManyWithoutCourtInput
bookings?: Prisma.CourtBookingUncheckedCreateNestedManyWithoutCourtInput bookings?: Prisma.CourtBookingUncheckedCreateNestedManyWithoutCourtInput
maintenances?: Prisma.CourtMaintenanceUncheckedCreateNestedManyWithoutCourtInput
} }
export type CourtUpdateInput = { export type CourtUpdateInput = {
@@ -349,6 +382,8 @@ export type CourtUpdateInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string name?: Prisma.StringFieldUpdateOperationsInput | string
slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number
basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: Prisma.BoolFieldUpdateOperationsInput | boolean
maintenanceReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
complex?: Prisma.ComplexUpdateOneRequiredWithoutCourtsNestedInput complex?: Prisma.ComplexUpdateOneRequiredWithoutCourtsNestedInput
@@ -356,6 +391,7 @@ export type CourtUpdateInput = {
availabilities?: Prisma.CourtAvailabilityUpdateManyWithoutCourtNestedInput availabilities?: Prisma.CourtAvailabilityUpdateManyWithoutCourtNestedInput
priceRules?: Prisma.CourtPriceRuleUpdateManyWithoutCourtNestedInput priceRules?: Prisma.CourtPriceRuleUpdateManyWithoutCourtNestedInput
bookings?: Prisma.CourtBookingUpdateManyWithoutCourtNestedInput bookings?: Prisma.CourtBookingUpdateManyWithoutCourtNestedInput
maintenances?: Prisma.CourtMaintenanceUpdateManyWithoutCourtNestedInput
} }
export type CourtUncheckedUpdateInput = { export type CourtUncheckedUpdateInput = {
@@ -365,11 +401,14 @@ export type CourtUncheckedUpdateInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string name?: Prisma.StringFieldUpdateOperationsInput | string
slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number
basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: Prisma.BoolFieldUpdateOperationsInput | boolean
maintenanceReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
availabilities?: Prisma.CourtAvailabilityUncheckedUpdateManyWithoutCourtNestedInput availabilities?: Prisma.CourtAvailabilityUncheckedUpdateManyWithoutCourtNestedInput
priceRules?: Prisma.CourtPriceRuleUncheckedUpdateManyWithoutCourtNestedInput priceRules?: Prisma.CourtPriceRuleUncheckedUpdateManyWithoutCourtNestedInput
bookings?: Prisma.CourtBookingUncheckedUpdateManyWithoutCourtNestedInput bookings?: Prisma.CourtBookingUncheckedUpdateManyWithoutCourtNestedInput
maintenances?: Prisma.CourtMaintenanceUncheckedUpdateManyWithoutCourtNestedInput
} }
export type CourtCreateManyInput = { export type CourtCreateManyInput = {
@@ -379,6 +418,8 @@ export type CourtCreateManyInput = {
name: string name: string
slotDurationMinutes: number slotDurationMinutes: number
basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: boolean
maintenanceReason?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
} }
@@ -388,6 +429,8 @@ export type CourtUpdateManyMutationInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string name?: Prisma.StringFieldUpdateOperationsInput | string
slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number
basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: Prisma.BoolFieldUpdateOperationsInput | boolean
maintenanceReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
} }
@@ -399,6 +442,8 @@ export type CourtUncheckedUpdateManyInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string name?: Prisma.StringFieldUpdateOperationsInput | string
slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number
basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: Prisma.BoolFieldUpdateOperationsInput | boolean
maintenanceReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
} }
@@ -420,6 +465,8 @@ export type CourtCountOrderByAggregateInput = {
name?: Prisma.SortOrder name?: Prisma.SortOrder
slotDurationMinutes?: Prisma.SortOrder slotDurationMinutes?: Prisma.SortOrder
basePrice?: Prisma.SortOrder basePrice?: Prisma.SortOrder
isUnderMaintenance?: Prisma.SortOrder
maintenanceReason?: Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
} }
@@ -436,6 +483,8 @@ export type CourtMaxOrderByAggregateInput = {
name?: Prisma.SortOrder name?: Prisma.SortOrder
slotDurationMinutes?: Prisma.SortOrder slotDurationMinutes?: Prisma.SortOrder
basePrice?: Prisma.SortOrder basePrice?: Prisma.SortOrder
isUnderMaintenance?: Prisma.SortOrder
maintenanceReason?: Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
} }
@@ -447,6 +496,8 @@ export type CourtMinOrderByAggregateInput = {
name?: Prisma.SortOrder name?: Prisma.SortOrder
slotDurationMinutes?: Prisma.SortOrder slotDurationMinutes?: Prisma.SortOrder
basePrice?: Prisma.SortOrder basePrice?: Prisma.SortOrder
isUnderMaintenance?: Prisma.SortOrder
maintenanceReason?: Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
} }
@@ -561,6 +612,20 @@ export type DecimalFieldUpdateOperationsInput = {
divide?: runtime.Decimal | runtime.DecimalJsLike | number | string divide?: runtime.Decimal | runtime.DecimalJsLike | number | string
} }
export type CourtCreateNestedOneWithoutMaintenancesInput = {
create?: Prisma.XOR<Prisma.CourtCreateWithoutMaintenancesInput, Prisma.CourtUncheckedCreateWithoutMaintenancesInput>
connectOrCreate?: Prisma.CourtCreateOrConnectWithoutMaintenancesInput
connect?: Prisma.CourtWhereUniqueInput
}
export type CourtUpdateOneRequiredWithoutMaintenancesNestedInput = {
create?: Prisma.XOR<Prisma.CourtCreateWithoutMaintenancesInput, Prisma.CourtUncheckedCreateWithoutMaintenancesInput>
connectOrCreate?: Prisma.CourtCreateOrConnectWithoutMaintenancesInput
upsert?: Prisma.CourtUpsertWithoutMaintenancesInput
connect?: Prisma.CourtWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.CourtUpdateToOneWithWhereWithoutMaintenancesInput, Prisma.CourtUpdateWithoutMaintenancesInput>, Prisma.CourtUncheckedUpdateWithoutMaintenancesInput>
}
export type CourtCreateNestedOneWithoutAvailabilitiesInput = { export type CourtCreateNestedOneWithoutAvailabilitiesInput = {
create?: Prisma.XOR<Prisma.CourtCreateWithoutAvailabilitiesInput, Prisma.CourtUncheckedCreateWithoutAvailabilitiesInput> create?: Prisma.XOR<Prisma.CourtCreateWithoutAvailabilitiesInput, Prisma.CourtUncheckedCreateWithoutAvailabilitiesInput>
connectOrCreate?: Prisma.CourtCreateOrConnectWithoutAvailabilitiesInput connectOrCreate?: Prisma.CourtCreateOrConnectWithoutAvailabilitiesInput
@@ -608,12 +673,15 @@ export type CourtCreateWithoutComplexInput = {
name: string name: string
slotDurationMinutes: number slotDurationMinutes: number
basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: boolean
maintenanceReason?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
sport: Prisma.SportCreateNestedOneWithoutCourtsInput sport: Prisma.SportCreateNestedOneWithoutCourtsInput
availabilities?: Prisma.CourtAvailabilityCreateNestedManyWithoutCourtInput availabilities?: Prisma.CourtAvailabilityCreateNestedManyWithoutCourtInput
priceRules?: Prisma.CourtPriceRuleCreateNestedManyWithoutCourtInput priceRules?: Prisma.CourtPriceRuleCreateNestedManyWithoutCourtInput
bookings?: Prisma.CourtBookingCreateNestedManyWithoutCourtInput bookings?: Prisma.CourtBookingCreateNestedManyWithoutCourtInput
maintenances?: Prisma.CourtMaintenanceCreateNestedManyWithoutCourtInput
} }
export type CourtUncheckedCreateWithoutComplexInput = { export type CourtUncheckedCreateWithoutComplexInput = {
@@ -622,11 +690,14 @@ export type CourtUncheckedCreateWithoutComplexInput = {
name: string name: string
slotDurationMinutes: number slotDurationMinutes: number
basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: boolean
maintenanceReason?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
availabilities?: Prisma.CourtAvailabilityUncheckedCreateNestedManyWithoutCourtInput availabilities?: Prisma.CourtAvailabilityUncheckedCreateNestedManyWithoutCourtInput
priceRules?: Prisma.CourtPriceRuleUncheckedCreateNestedManyWithoutCourtInput priceRules?: Prisma.CourtPriceRuleUncheckedCreateNestedManyWithoutCourtInput
bookings?: Prisma.CourtBookingUncheckedCreateNestedManyWithoutCourtInput bookings?: Prisma.CourtBookingUncheckedCreateNestedManyWithoutCourtInput
maintenances?: Prisma.CourtMaintenanceUncheckedCreateNestedManyWithoutCourtInput
} }
export type CourtCreateOrConnectWithoutComplexInput = { export type CourtCreateOrConnectWithoutComplexInput = {
@@ -665,6 +736,8 @@ export type CourtScalarWhereInput = {
name?: Prisma.StringFilter<"Court"> | string name?: Prisma.StringFilter<"Court"> | string
slotDurationMinutes?: Prisma.IntFilter<"Court"> | number slotDurationMinutes?: Prisma.IntFilter<"Court"> | number
basePrice?: Prisma.DecimalFilter<"Court"> | runtime.Decimal | runtime.DecimalJsLike | number | string basePrice?: Prisma.DecimalFilter<"Court"> | runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: Prisma.BoolFilter<"Court"> | boolean
maintenanceReason?: Prisma.StringNullableFilter<"Court"> | string | null
createdAt?: Prisma.DateTimeFilter<"Court"> | Date | string createdAt?: Prisma.DateTimeFilter<"Court"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"Court"> | Date | string updatedAt?: Prisma.DateTimeFilter<"Court"> | Date | string
} }
@@ -674,12 +747,15 @@ export type CourtCreateWithoutSportInput = {
name: string name: string
slotDurationMinutes: number slotDurationMinutes: number
basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: boolean
maintenanceReason?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
complex: Prisma.ComplexCreateNestedOneWithoutCourtsInput complex: Prisma.ComplexCreateNestedOneWithoutCourtsInput
availabilities?: Prisma.CourtAvailabilityCreateNestedManyWithoutCourtInput availabilities?: Prisma.CourtAvailabilityCreateNestedManyWithoutCourtInput
priceRules?: Prisma.CourtPriceRuleCreateNestedManyWithoutCourtInput priceRules?: Prisma.CourtPriceRuleCreateNestedManyWithoutCourtInput
bookings?: Prisma.CourtBookingCreateNestedManyWithoutCourtInput bookings?: Prisma.CourtBookingCreateNestedManyWithoutCourtInput
maintenances?: Prisma.CourtMaintenanceCreateNestedManyWithoutCourtInput
} }
export type CourtUncheckedCreateWithoutSportInput = { export type CourtUncheckedCreateWithoutSportInput = {
@@ -688,11 +764,14 @@ export type CourtUncheckedCreateWithoutSportInput = {
name: string name: string
slotDurationMinutes: number slotDurationMinutes: number
basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: boolean
maintenanceReason?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
availabilities?: Prisma.CourtAvailabilityUncheckedCreateNestedManyWithoutCourtInput availabilities?: Prisma.CourtAvailabilityUncheckedCreateNestedManyWithoutCourtInput
priceRules?: Prisma.CourtPriceRuleUncheckedCreateNestedManyWithoutCourtInput priceRules?: Prisma.CourtPriceRuleUncheckedCreateNestedManyWithoutCourtInput
bookings?: Prisma.CourtBookingUncheckedCreateNestedManyWithoutCourtInput bookings?: Prisma.CourtBookingUncheckedCreateNestedManyWithoutCourtInput
maintenances?: Prisma.CourtMaintenanceUncheckedCreateNestedManyWithoutCourtInput
} }
export type CourtCreateOrConnectWithoutSportInput = { export type CourtCreateOrConnectWithoutSportInput = {
@@ -721,17 +800,100 @@ export type CourtUpdateManyWithWhereWithoutSportInput = {
data: Prisma.XOR<Prisma.CourtUpdateManyMutationInput, Prisma.CourtUncheckedUpdateManyWithoutSportInput> data: Prisma.XOR<Prisma.CourtUpdateManyMutationInput, Prisma.CourtUncheckedUpdateManyWithoutSportInput>
} }
export type CourtCreateWithoutMaintenancesInput = {
id: string
name: string
slotDurationMinutes: number
basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: boolean
maintenanceReason?: string | null
createdAt?: Date | string
updatedAt?: Date | string
complex: Prisma.ComplexCreateNestedOneWithoutCourtsInput
sport: Prisma.SportCreateNestedOneWithoutCourtsInput
availabilities?: Prisma.CourtAvailabilityCreateNestedManyWithoutCourtInput
priceRules?: Prisma.CourtPriceRuleCreateNestedManyWithoutCourtInput
bookings?: Prisma.CourtBookingCreateNestedManyWithoutCourtInput
}
export type CourtUncheckedCreateWithoutMaintenancesInput = {
id: string
complexId: string
sportId: string
name: string
slotDurationMinutes: number
basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: boolean
maintenanceReason?: string | null
createdAt?: Date | string
updatedAt?: Date | string
availabilities?: Prisma.CourtAvailabilityUncheckedCreateNestedManyWithoutCourtInput
priceRules?: Prisma.CourtPriceRuleUncheckedCreateNestedManyWithoutCourtInput
bookings?: Prisma.CourtBookingUncheckedCreateNestedManyWithoutCourtInput
}
export type CourtCreateOrConnectWithoutMaintenancesInput = {
where: Prisma.CourtWhereUniqueInput
create: Prisma.XOR<Prisma.CourtCreateWithoutMaintenancesInput, Prisma.CourtUncheckedCreateWithoutMaintenancesInput>
}
export type CourtUpsertWithoutMaintenancesInput = {
update: Prisma.XOR<Prisma.CourtUpdateWithoutMaintenancesInput, Prisma.CourtUncheckedUpdateWithoutMaintenancesInput>
create: Prisma.XOR<Prisma.CourtCreateWithoutMaintenancesInput, Prisma.CourtUncheckedCreateWithoutMaintenancesInput>
where?: Prisma.CourtWhereInput
}
export type CourtUpdateToOneWithWhereWithoutMaintenancesInput = {
where?: Prisma.CourtWhereInput
data: Prisma.XOR<Prisma.CourtUpdateWithoutMaintenancesInput, Prisma.CourtUncheckedUpdateWithoutMaintenancesInput>
}
export type CourtUpdateWithoutMaintenancesInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number
basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: Prisma.BoolFieldUpdateOperationsInput | boolean
maintenanceReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
complex?: Prisma.ComplexUpdateOneRequiredWithoutCourtsNestedInput
sport?: Prisma.SportUpdateOneRequiredWithoutCourtsNestedInput
availabilities?: Prisma.CourtAvailabilityUpdateManyWithoutCourtNestedInput
priceRules?: Prisma.CourtPriceRuleUpdateManyWithoutCourtNestedInput
bookings?: Prisma.CourtBookingUpdateManyWithoutCourtNestedInput
}
export type CourtUncheckedUpdateWithoutMaintenancesInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
complexId?: Prisma.StringFieldUpdateOperationsInput | string
sportId?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number
basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: Prisma.BoolFieldUpdateOperationsInput | boolean
maintenanceReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
availabilities?: Prisma.CourtAvailabilityUncheckedUpdateManyWithoutCourtNestedInput
priceRules?: Prisma.CourtPriceRuleUncheckedUpdateManyWithoutCourtNestedInput
bookings?: Prisma.CourtBookingUncheckedUpdateManyWithoutCourtNestedInput
}
export type CourtCreateWithoutAvailabilitiesInput = { export type CourtCreateWithoutAvailabilitiesInput = {
id: string id: string
name: string name: string
slotDurationMinutes: number slotDurationMinutes: number
basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: boolean
maintenanceReason?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
complex: Prisma.ComplexCreateNestedOneWithoutCourtsInput complex: Prisma.ComplexCreateNestedOneWithoutCourtsInput
sport: Prisma.SportCreateNestedOneWithoutCourtsInput sport: Prisma.SportCreateNestedOneWithoutCourtsInput
priceRules?: Prisma.CourtPriceRuleCreateNestedManyWithoutCourtInput priceRules?: Prisma.CourtPriceRuleCreateNestedManyWithoutCourtInput
bookings?: Prisma.CourtBookingCreateNestedManyWithoutCourtInput bookings?: Prisma.CourtBookingCreateNestedManyWithoutCourtInput
maintenances?: Prisma.CourtMaintenanceCreateNestedManyWithoutCourtInput
} }
export type CourtUncheckedCreateWithoutAvailabilitiesInput = { export type CourtUncheckedCreateWithoutAvailabilitiesInput = {
@@ -741,10 +903,13 @@ export type CourtUncheckedCreateWithoutAvailabilitiesInput = {
name: string name: string
slotDurationMinutes: number slotDurationMinutes: number
basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: boolean
maintenanceReason?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
priceRules?: Prisma.CourtPriceRuleUncheckedCreateNestedManyWithoutCourtInput priceRules?: Prisma.CourtPriceRuleUncheckedCreateNestedManyWithoutCourtInput
bookings?: Prisma.CourtBookingUncheckedCreateNestedManyWithoutCourtInput bookings?: Prisma.CourtBookingUncheckedCreateNestedManyWithoutCourtInput
maintenances?: Prisma.CourtMaintenanceUncheckedCreateNestedManyWithoutCourtInput
} }
export type CourtCreateOrConnectWithoutAvailabilitiesInput = { export type CourtCreateOrConnectWithoutAvailabilitiesInput = {
@@ -768,12 +933,15 @@ export type CourtUpdateWithoutAvailabilitiesInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string name?: Prisma.StringFieldUpdateOperationsInput | string
slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number
basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: Prisma.BoolFieldUpdateOperationsInput | boolean
maintenanceReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
complex?: Prisma.ComplexUpdateOneRequiredWithoutCourtsNestedInput complex?: Prisma.ComplexUpdateOneRequiredWithoutCourtsNestedInput
sport?: Prisma.SportUpdateOneRequiredWithoutCourtsNestedInput sport?: Prisma.SportUpdateOneRequiredWithoutCourtsNestedInput
priceRules?: Prisma.CourtPriceRuleUpdateManyWithoutCourtNestedInput priceRules?: Prisma.CourtPriceRuleUpdateManyWithoutCourtNestedInput
bookings?: Prisma.CourtBookingUpdateManyWithoutCourtNestedInput bookings?: Prisma.CourtBookingUpdateManyWithoutCourtNestedInput
maintenances?: Prisma.CourtMaintenanceUpdateManyWithoutCourtNestedInput
} }
export type CourtUncheckedUpdateWithoutAvailabilitiesInput = { export type CourtUncheckedUpdateWithoutAvailabilitiesInput = {
@@ -783,10 +951,13 @@ export type CourtUncheckedUpdateWithoutAvailabilitiesInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string name?: Prisma.StringFieldUpdateOperationsInput | string
slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number
basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: Prisma.BoolFieldUpdateOperationsInput | boolean
maintenanceReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
priceRules?: Prisma.CourtPriceRuleUncheckedUpdateManyWithoutCourtNestedInput priceRules?: Prisma.CourtPriceRuleUncheckedUpdateManyWithoutCourtNestedInput
bookings?: Prisma.CourtBookingUncheckedUpdateManyWithoutCourtNestedInput bookings?: Prisma.CourtBookingUncheckedUpdateManyWithoutCourtNestedInput
maintenances?: Prisma.CourtMaintenanceUncheckedUpdateManyWithoutCourtNestedInput
} }
export type CourtCreateWithoutPriceRulesInput = { export type CourtCreateWithoutPriceRulesInput = {
@@ -794,12 +965,15 @@ export type CourtCreateWithoutPriceRulesInput = {
name: string name: string
slotDurationMinutes: number slotDurationMinutes: number
basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: boolean
maintenanceReason?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
complex: Prisma.ComplexCreateNestedOneWithoutCourtsInput complex: Prisma.ComplexCreateNestedOneWithoutCourtsInput
sport: Prisma.SportCreateNestedOneWithoutCourtsInput sport: Prisma.SportCreateNestedOneWithoutCourtsInput
availabilities?: Prisma.CourtAvailabilityCreateNestedManyWithoutCourtInput availabilities?: Prisma.CourtAvailabilityCreateNestedManyWithoutCourtInput
bookings?: Prisma.CourtBookingCreateNestedManyWithoutCourtInput bookings?: Prisma.CourtBookingCreateNestedManyWithoutCourtInput
maintenances?: Prisma.CourtMaintenanceCreateNestedManyWithoutCourtInput
} }
export type CourtUncheckedCreateWithoutPriceRulesInput = { export type CourtUncheckedCreateWithoutPriceRulesInput = {
@@ -809,10 +983,13 @@ export type CourtUncheckedCreateWithoutPriceRulesInput = {
name: string name: string
slotDurationMinutes: number slotDurationMinutes: number
basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: boolean
maintenanceReason?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
availabilities?: Prisma.CourtAvailabilityUncheckedCreateNestedManyWithoutCourtInput availabilities?: Prisma.CourtAvailabilityUncheckedCreateNestedManyWithoutCourtInput
bookings?: Prisma.CourtBookingUncheckedCreateNestedManyWithoutCourtInput bookings?: Prisma.CourtBookingUncheckedCreateNestedManyWithoutCourtInput
maintenances?: Prisma.CourtMaintenanceUncheckedCreateNestedManyWithoutCourtInput
} }
export type CourtCreateOrConnectWithoutPriceRulesInput = { export type CourtCreateOrConnectWithoutPriceRulesInput = {
@@ -836,12 +1013,15 @@ export type CourtUpdateWithoutPriceRulesInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string name?: Prisma.StringFieldUpdateOperationsInput | string
slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number
basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: Prisma.BoolFieldUpdateOperationsInput | boolean
maintenanceReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
complex?: Prisma.ComplexUpdateOneRequiredWithoutCourtsNestedInput complex?: Prisma.ComplexUpdateOneRequiredWithoutCourtsNestedInput
sport?: Prisma.SportUpdateOneRequiredWithoutCourtsNestedInput sport?: Prisma.SportUpdateOneRequiredWithoutCourtsNestedInput
availabilities?: Prisma.CourtAvailabilityUpdateManyWithoutCourtNestedInput availabilities?: Prisma.CourtAvailabilityUpdateManyWithoutCourtNestedInput
bookings?: Prisma.CourtBookingUpdateManyWithoutCourtNestedInput bookings?: Prisma.CourtBookingUpdateManyWithoutCourtNestedInput
maintenances?: Prisma.CourtMaintenanceUpdateManyWithoutCourtNestedInput
} }
export type CourtUncheckedUpdateWithoutPriceRulesInput = { export type CourtUncheckedUpdateWithoutPriceRulesInput = {
@@ -851,10 +1031,13 @@ export type CourtUncheckedUpdateWithoutPriceRulesInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string name?: Prisma.StringFieldUpdateOperationsInput | string
slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number
basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: Prisma.BoolFieldUpdateOperationsInput | boolean
maintenanceReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
availabilities?: Prisma.CourtAvailabilityUncheckedUpdateManyWithoutCourtNestedInput availabilities?: Prisma.CourtAvailabilityUncheckedUpdateManyWithoutCourtNestedInput
bookings?: Prisma.CourtBookingUncheckedUpdateManyWithoutCourtNestedInput bookings?: Prisma.CourtBookingUncheckedUpdateManyWithoutCourtNestedInput
maintenances?: Prisma.CourtMaintenanceUncheckedUpdateManyWithoutCourtNestedInput
} }
export type CourtCreateWithoutBookingsInput = { export type CourtCreateWithoutBookingsInput = {
@@ -862,12 +1045,15 @@ export type CourtCreateWithoutBookingsInput = {
name: string name: string
slotDurationMinutes: number slotDurationMinutes: number
basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: boolean
maintenanceReason?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
complex: Prisma.ComplexCreateNestedOneWithoutCourtsInput complex: Prisma.ComplexCreateNestedOneWithoutCourtsInput
sport: Prisma.SportCreateNestedOneWithoutCourtsInput sport: Prisma.SportCreateNestedOneWithoutCourtsInput
availabilities?: Prisma.CourtAvailabilityCreateNestedManyWithoutCourtInput availabilities?: Prisma.CourtAvailabilityCreateNestedManyWithoutCourtInput
priceRules?: Prisma.CourtPriceRuleCreateNestedManyWithoutCourtInput priceRules?: Prisma.CourtPriceRuleCreateNestedManyWithoutCourtInput
maintenances?: Prisma.CourtMaintenanceCreateNestedManyWithoutCourtInput
} }
export type CourtUncheckedCreateWithoutBookingsInput = { export type CourtUncheckedCreateWithoutBookingsInput = {
@@ -877,10 +1063,13 @@ export type CourtUncheckedCreateWithoutBookingsInput = {
name: string name: string
slotDurationMinutes: number slotDurationMinutes: number
basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: boolean
maintenanceReason?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
availabilities?: Prisma.CourtAvailabilityUncheckedCreateNestedManyWithoutCourtInput availabilities?: Prisma.CourtAvailabilityUncheckedCreateNestedManyWithoutCourtInput
priceRules?: Prisma.CourtPriceRuleUncheckedCreateNestedManyWithoutCourtInput priceRules?: Prisma.CourtPriceRuleUncheckedCreateNestedManyWithoutCourtInput
maintenances?: Prisma.CourtMaintenanceUncheckedCreateNestedManyWithoutCourtInput
} }
export type CourtCreateOrConnectWithoutBookingsInput = { export type CourtCreateOrConnectWithoutBookingsInput = {
@@ -904,12 +1093,15 @@ export type CourtUpdateWithoutBookingsInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string name?: Prisma.StringFieldUpdateOperationsInput | string
slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number
basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: Prisma.BoolFieldUpdateOperationsInput | boolean
maintenanceReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
complex?: Prisma.ComplexUpdateOneRequiredWithoutCourtsNestedInput complex?: Prisma.ComplexUpdateOneRequiredWithoutCourtsNestedInput
sport?: Prisma.SportUpdateOneRequiredWithoutCourtsNestedInput sport?: Prisma.SportUpdateOneRequiredWithoutCourtsNestedInput
availabilities?: Prisma.CourtAvailabilityUpdateManyWithoutCourtNestedInput availabilities?: Prisma.CourtAvailabilityUpdateManyWithoutCourtNestedInput
priceRules?: Prisma.CourtPriceRuleUpdateManyWithoutCourtNestedInput priceRules?: Prisma.CourtPriceRuleUpdateManyWithoutCourtNestedInput
maintenances?: Prisma.CourtMaintenanceUpdateManyWithoutCourtNestedInput
} }
export type CourtUncheckedUpdateWithoutBookingsInput = { export type CourtUncheckedUpdateWithoutBookingsInput = {
@@ -919,10 +1111,13 @@ export type CourtUncheckedUpdateWithoutBookingsInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string name?: Prisma.StringFieldUpdateOperationsInput | string
slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number
basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: Prisma.BoolFieldUpdateOperationsInput | boolean
maintenanceReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
availabilities?: Prisma.CourtAvailabilityUncheckedUpdateManyWithoutCourtNestedInput availabilities?: Prisma.CourtAvailabilityUncheckedUpdateManyWithoutCourtNestedInput
priceRules?: Prisma.CourtPriceRuleUncheckedUpdateManyWithoutCourtNestedInput priceRules?: Prisma.CourtPriceRuleUncheckedUpdateManyWithoutCourtNestedInput
maintenances?: Prisma.CourtMaintenanceUncheckedUpdateManyWithoutCourtNestedInput
} }
export type CourtCreateManyComplexInput = { export type CourtCreateManyComplexInput = {
@@ -931,6 +1126,8 @@ export type CourtCreateManyComplexInput = {
name: string name: string
slotDurationMinutes: number slotDurationMinutes: number
basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: boolean
maintenanceReason?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
} }
@@ -940,12 +1137,15 @@ export type CourtUpdateWithoutComplexInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string name?: Prisma.StringFieldUpdateOperationsInput | string
slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number
basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: Prisma.BoolFieldUpdateOperationsInput | boolean
maintenanceReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
sport?: Prisma.SportUpdateOneRequiredWithoutCourtsNestedInput sport?: Prisma.SportUpdateOneRequiredWithoutCourtsNestedInput
availabilities?: Prisma.CourtAvailabilityUpdateManyWithoutCourtNestedInput availabilities?: Prisma.CourtAvailabilityUpdateManyWithoutCourtNestedInput
priceRules?: Prisma.CourtPriceRuleUpdateManyWithoutCourtNestedInput priceRules?: Prisma.CourtPriceRuleUpdateManyWithoutCourtNestedInput
bookings?: Prisma.CourtBookingUpdateManyWithoutCourtNestedInput bookings?: Prisma.CourtBookingUpdateManyWithoutCourtNestedInput
maintenances?: Prisma.CourtMaintenanceUpdateManyWithoutCourtNestedInput
} }
export type CourtUncheckedUpdateWithoutComplexInput = { export type CourtUncheckedUpdateWithoutComplexInput = {
@@ -954,11 +1154,14 @@ export type CourtUncheckedUpdateWithoutComplexInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string name?: Prisma.StringFieldUpdateOperationsInput | string
slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number
basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: Prisma.BoolFieldUpdateOperationsInput | boolean
maintenanceReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
availabilities?: Prisma.CourtAvailabilityUncheckedUpdateManyWithoutCourtNestedInput availabilities?: Prisma.CourtAvailabilityUncheckedUpdateManyWithoutCourtNestedInput
priceRules?: Prisma.CourtPriceRuleUncheckedUpdateManyWithoutCourtNestedInput priceRules?: Prisma.CourtPriceRuleUncheckedUpdateManyWithoutCourtNestedInput
bookings?: Prisma.CourtBookingUncheckedUpdateManyWithoutCourtNestedInput bookings?: Prisma.CourtBookingUncheckedUpdateManyWithoutCourtNestedInput
maintenances?: Prisma.CourtMaintenanceUncheckedUpdateManyWithoutCourtNestedInput
} }
export type CourtUncheckedUpdateManyWithoutComplexInput = { export type CourtUncheckedUpdateManyWithoutComplexInput = {
@@ -967,6 +1170,8 @@ export type CourtUncheckedUpdateManyWithoutComplexInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string name?: Prisma.StringFieldUpdateOperationsInput | string
slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number
basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: Prisma.BoolFieldUpdateOperationsInput | boolean
maintenanceReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
} }
@@ -977,6 +1182,8 @@ export type CourtCreateManySportInput = {
name: string name: string
slotDurationMinutes: number slotDurationMinutes: number
basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: boolean
maintenanceReason?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
} }
@@ -986,12 +1193,15 @@ export type CourtUpdateWithoutSportInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string name?: Prisma.StringFieldUpdateOperationsInput | string
slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number
basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: Prisma.BoolFieldUpdateOperationsInput | boolean
maintenanceReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
complex?: Prisma.ComplexUpdateOneRequiredWithoutCourtsNestedInput complex?: Prisma.ComplexUpdateOneRequiredWithoutCourtsNestedInput
availabilities?: Prisma.CourtAvailabilityUpdateManyWithoutCourtNestedInput availabilities?: Prisma.CourtAvailabilityUpdateManyWithoutCourtNestedInput
priceRules?: Prisma.CourtPriceRuleUpdateManyWithoutCourtNestedInput priceRules?: Prisma.CourtPriceRuleUpdateManyWithoutCourtNestedInput
bookings?: Prisma.CourtBookingUpdateManyWithoutCourtNestedInput bookings?: Prisma.CourtBookingUpdateManyWithoutCourtNestedInput
maintenances?: Prisma.CourtMaintenanceUpdateManyWithoutCourtNestedInput
} }
export type CourtUncheckedUpdateWithoutSportInput = { export type CourtUncheckedUpdateWithoutSportInput = {
@@ -1000,11 +1210,14 @@ export type CourtUncheckedUpdateWithoutSportInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string name?: Prisma.StringFieldUpdateOperationsInput | string
slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number
basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: Prisma.BoolFieldUpdateOperationsInput | boolean
maintenanceReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
availabilities?: Prisma.CourtAvailabilityUncheckedUpdateManyWithoutCourtNestedInput availabilities?: Prisma.CourtAvailabilityUncheckedUpdateManyWithoutCourtNestedInput
priceRules?: Prisma.CourtPriceRuleUncheckedUpdateManyWithoutCourtNestedInput priceRules?: Prisma.CourtPriceRuleUncheckedUpdateManyWithoutCourtNestedInput
bookings?: Prisma.CourtBookingUncheckedUpdateManyWithoutCourtNestedInput bookings?: Prisma.CourtBookingUncheckedUpdateManyWithoutCourtNestedInput
maintenances?: Prisma.CourtMaintenanceUncheckedUpdateManyWithoutCourtNestedInput
} }
export type CourtUncheckedUpdateManyWithoutSportInput = { export type CourtUncheckedUpdateManyWithoutSportInput = {
@@ -1013,6 +1226,8 @@ export type CourtUncheckedUpdateManyWithoutSportInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string name?: Prisma.StringFieldUpdateOperationsInput | string
slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number
basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string
isUnderMaintenance?: Prisma.BoolFieldUpdateOperationsInput | boolean
maintenanceReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
} }
@@ -1026,12 +1241,14 @@ export type CourtCountOutputType = {
availabilities: number availabilities: number
priceRules: number priceRules: number
bookings: number bookings: number
maintenances: number
} }
export type CourtCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type CourtCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
availabilities?: boolean | CourtCountOutputTypeCountAvailabilitiesArgs availabilities?: boolean | CourtCountOutputTypeCountAvailabilitiesArgs
priceRules?: boolean | CourtCountOutputTypeCountPriceRulesArgs priceRules?: boolean | CourtCountOutputTypeCountPriceRulesArgs
bookings?: boolean | CourtCountOutputTypeCountBookingsArgs bookings?: boolean | CourtCountOutputTypeCountBookingsArgs
maintenances?: boolean | CourtCountOutputTypeCountMaintenancesArgs
} }
/** /**
@@ -1065,6 +1282,13 @@ export type CourtCountOutputTypeCountBookingsArgs<ExtArgs extends runtime.Types.
where?: Prisma.CourtBookingWhereInput where?: Prisma.CourtBookingWhereInput
} }
/**
* CourtCountOutputType without action
*/
export type CourtCountOutputTypeCountMaintenancesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.CourtMaintenanceWhereInput
}
export type CourtSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{ export type CourtSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean id?: boolean
@@ -1073,6 +1297,8 @@ export type CourtSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs =
name?: boolean name?: boolean
slotDurationMinutes?: boolean slotDurationMinutes?: boolean
basePrice?: boolean basePrice?: boolean
isUnderMaintenance?: boolean
maintenanceReason?: boolean
createdAt?: boolean createdAt?: boolean
updatedAt?: boolean updatedAt?: boolean
complex?: boolean | Prisma.ComplexDefaultArgs<ExtArgs> complex?: boolean | Prisma.ComplexDefaultArgs<ExtArgs>
@@ -1080,6 +1306,7 @@ export type CourtSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs =
availabilities?: boolean | Prisma.Court$availabilitiesArgs<ExtArgs> availabilities?: boolean | Prisma.Court$availabilitiesArgs<ExtArgs>
priceRules?: boolean | Prisma.Court$priceRulesArgs<ExtArgs> priceRules?: boolean | Prisma.Court$priceRulesArgs<ExtArgs>
bookings?: boolean | Prisma.Court$bookingsArgs<ExtArgs> bookings?: boolean | Prisma.Court$bookingsArgs<ExtArgs>
maintenances?: boolean | Prisma.Court$maintenancesArgs<ExtArgs>
_count?: boolean | Prisma.CourtCountOutputTypeDefaultArgs<ExtArgs> _count?: boolean | Prisma.CourtCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["court"]> }, ExtArgs["result"]["court"]>
@@ -1090,6 +1317,8 @@ export type CourtSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensi
name?: boolean name?: boolean
slotDurationMinutes?: boolean slotDurationMinutes?: boolean
basePrice?: boolean basePrice?: boolean
isUnderMaintenance?: boolean
maintenanceReason?: boolean
createdAt?: boolean createdAt?: boolean
updatedAt?: boolean updatedAt?: boolean
complex?: boolean | Prisma.ComplexDefaultArgs<ExtArgs> complex?: boolean | Prisma.ComplexDefaultArgs<ExtArgs>
@@ -1103,6 +1332,8 @@ export type CourtSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensi
name?: boolean name?: boolean
slotDurationMinutes?: boolean slotDurationMinutes?: boolean
basePrice?: boolean basePrice?: boolean
isUnderMaintenance?: boolean
maintenanceReason?: boolean
createdAt?: boolean createdAt?: boolean
updatedAt?: boolean updatedAt?: boolean
complex?: boolean | Prisma.ComplexDefaultArgs<ExtArgs> complex?: boolean | Prisma.ComplexDefaultArgs<ExtArgs>
@@ -1116,17 +1347,20 @@ export type CourtSelectScalar = {
name?: boolean name?: boolean
slotDurationMinutes?: boolean slotDurationMinutes?: boolean
basePrice?: boolean basePrice?: boolean
isUnderMaintenance?: boolean
maintenanceReason?: boolean
createdAt?: boolean createdAt?: boolean
updatedAt?: boolean updatedAt?: boolean
} }
export type CourtOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "complexId" | "sportId" | "name" | "slotDurationMinutes" | "basePrice" | "createdAt" | "updatedAt", ExtArgs["result"]["court"]> export type CourtOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "complexId" | "sportId" | "name" | "slotDurationMinutes" | "basePrice" | "isUnderMaintenance" | "maintenanceReason" | "createdAt" | "updatedAt", ExtArgs["result"]["court"]>
export type CourtInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type CourtInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
complex?: boolean | Prisma.ComplexDefaultArgs<ExtArgs> complex?: boolean | Prisma.ComplexDefaultArgs<ExtArgs>
sport?: boolean | Prisma.SportDefaultArgs<ExtArgs> sport?: boolean | Prisma.SportDefaultArgs<ExtArgs>
availabilities?: boolean | Prisma.Court$availabilitiesArgs<ExtArgs> availabilities?: boolean | Prisma.Court$availabilitiesArgs<ExtArgs>
priceRules?: boolean | Prisma.Court$priceRulesArgs<ExtArgs> priceRules?: boolean | Prisma.Court$priceRulesArgs<ExtArgs>
bookings?: boolean | Prisma.Court$bookingsArgs<ExtArgs> bookings?: boolean | Prisma.Court$bookingsArgs<ExtArgs>
maintenances?: boolean | Prisma.Court$maintenancesArgs<ExtArgs>
_count?: boolean | Prisma.CourtCountOutputTypeDefaultArgs<ExtArgs> _count?: boolean | Prisma.CourtCountOutputTypeDefaultArgs<ExtArgs>
} }
export type CourtIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type CourtIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
@@ -1146,6 +1380,7 @@ export type $CourtPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs
availabilities: Prisma.$CourtAvailabilityPayload<ExtArgs>[] availabilities: Prisma.$CourtAvailabilityPayload<ExtArgs>[]
priceRules: Prisma.$CourtPriceRulePayload<ExtArgs>[] priceRules: Prisma.$CourtPriceRulePayload<ExtArgs>[]
bookings: Prisma.$CourtBookingPayload<ExtArgs>[] bookings: Prisma.$CourtBookingPayload<ExtArgs>[]
maintenances: Prisma.$CourtMaintenancePayload<ExtArgs>[]
} }
scalars: runtime.Types.Extensions.GetPayloadResult<{ scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string id: string
@@ -1154,6 +1389,8 @@ export type $CourtPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs
name: string name: string
slotDurationMinutes: number slotDurationMinutes: number
basePrice: runtime.Decimal basePrice: runtime.Decimal
isUnderMaintenance: boolean
maintenanceReason: string | null
createdAt: Date createdAt: Date
updatedAt: Date updatedAt: Date
}, ExtArgs["result"]["court"]> }, ExtArgs["result"]["court"]>
@@ -1555,6 +1792,7 @@ export interface Prisma__CourtClient<T, Null = never, ExtArgs extends runtime.Ty
availabilities<T extends Prisma.Court$availabilitiesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Court$availabilitiesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$CourtAvailabilityPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null> availabilities<T extends Prisma.Court$availabilitiesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Court$availabilitiesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$CourtAvailabilityPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
priceRules<T extends Prisma.Court$priceRulesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Court$priceRulesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$CourtPriceRulePayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null> priceRules<T extends Prisma.Court$priceRulesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Court$priceRulesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$CourtPriceRulePayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
bookings<T extends Prisma.Court$bookingsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Court$bookingsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$CourtBookingPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null> bookings<T extends Prisma.Court$bookingsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Court$bookingsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$CourtBookingPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
maintenances<T extends Prisma.Court$maintenancesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Court$maintenancesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$CourtMaintenancePayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
/** /**
* Attaches callbacks for the resolution and/or rejection of the Promise. * Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved. * @param onfulfilled The callback to execute when the Promise is resolved.
@@ -1590,6 +1828,8 @@ export interface CourtFieldRefs {
readonly name: Prisma.FieldRef<"Court", 'String'> readonly name: Prisma.FieldRef<"Court", 'String'>
readonly slotDurationMinutes: Prisma.FieldRef<"Court", 'Int'> readonly slotDurationMinutes: Prisma.FieldRef<"Court", 'Int'>
readonly basePrice: Prisma.FieldRef<"Court", 'Decimal'> readonly basePrice: Prisma.FieldRef<"Court", 'Decimal'>
readonly isUnderMaintenance: Prisma.FieldRef<"Court", 'Boolean'>
readonly maintenanceReason: Prisma.FieldRef<"Court", 'String'>
readonly createdAt: Prisma.FieldRef<"Court", 'DateTime'> readonly createdAt: Prisma.FieldRef<"Court", 'DateTime'>
readonly updatedAt: Prisma.FieldRef<"Court", 'DateTime'> readonly updatedAt: Prisma.FieldRef<"Court", 'DateTime'>
} }
@@ -2064,6 +2304,30 @@ export type Court$bookingsArgs<ExtArgs extends runtime.Types.Extensions.Internal
distinct?: Prisma.CourtBookingScalarFieldEnum | Prisma.CourtBookingScalarFieldEnum[] distinct?: Prisma.CourtBookingScalarFieldEnum | Prisma.CourtBookingScalarFieldEnum[]
} }
/**
* Court.maintenances
*/
export type Court$maintenancesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the CourtMaintenance
*/
select?: Prisma.CourtMaintenanceSelect<ExtArgs> | null
/**
* Omit specific fields from the CourtMaintenance
*/
omit?: Prisma.CourtMaintenanceOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.CourtMaintenanceInclude<ExtArgs> | null
where?: Prisma.CourtMaintenanceWhereInput
orderBy?: Prisma.CourtMaintenanceOrderByWithRelationInput | Prisma.CourtMaintenanceOrderByWithRelationInput[]
cursor?: Prisma.CourtMaintenanceWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.CourtMaintenanceScalarFieldEnum | Prisma.CourtMaintenanceScalarFieldEnum[]
}
/** /**
* Court without action * Court without action
*/ */

View File

@@ -33,6 +33,7 @@ export type CourtBookingMinAggregateOutputType = {
endTime: string | null endTime: string | 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
@@ -47,6 +48,7 @@ export type CourtBookingMaxAggregateOutputType = {
endTime: string | null endTime: string | 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,6 +63,7 @@ export type CourtBookingCountAggregateOutputType = {
endTime: number endTime: number
customerName: number customerName: number
customerPhone: number customerPhone: number
customerEmail: number
status: number status: number
createdAt: number createdAt: number
updatedAt: number updatedAt: number
@@ -77,6 +80,7 @@ export type CourtBookingMinAggregateInputType = {
endTime?: true endTime?: true
customerName?: true customerName?: true
customerPhone?: true customerPhone?: true
customerEmail?: true
status?: true status?: true
createdAt?: true createdAt?: true
updatedAt?: true updatedAt?: true
@@ -91,6 +95,7 @@ export type CourtBookingMaxAggregateInputType = {
endTime?: true endTime?: true
customerName?: true customerName?: true
customerPhone?: true customerPhone?: true
customerEmail?: true
status?: true status?: true
createdAt?: true createdAt?: true
updatedAt?: true updatedAt?: true
@@ -105,6 +110,7 @@ export type CourtBookingCountAggregateInputType = {
endTime?: true endTime?: true
customerName?: true customerName?: true
customerPhone?: true customerPhone?: true
customerEmail?: true
status?: true status?: true
createdAt?: true createdAt?: true
updatedAt?: true updatedAt?: true
@@ -192,6 +198,7 @@ export type CourtBookingGroupByOutputType = {
endTime: string endTime: string
customerName: string customerName: string
customerPhone: string customerPhone: string
customerEmail: string
status: $Enums.CourtBookingStatus status: $Enums.CourtBookingStatus
createdAt: Date createdAt: Date
updatedAt: Date updatedAt: Date
@@ -227,6 +234,7 @@ export type CourtBookingWhereInput = {
endTime?: Prisma.StringFilter<"CourtBooking"> | string endTime?: Prisma.StringFilter<"CourtBooking"> | string
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
@@ -242,6 +250,7 @@ export type CourtBookingOrderByWithRelationInput = {
endTime?: Prisma.SortOrder endTime?: 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
@@ -261,6 +270,7 @@ export type CourtBookingWhereUniqueInput = Prisma.AtLeast<{
endTime?: Prisma.StringFilter<"CourtBooking"> | string endTime?: Prisma.StringFilter<"CourtBooking"> | string
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
@@ -276,6 +286,7 @@ export type CourtBookingOrderByWithAggregationInput = {
endTime?: Prisma.SortOrder endTime?: 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
@@ -296,6 +307,7 @@ export type CourtBookingScalarWhereWithAggregatesInput = {
endTime?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string endTime?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
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
@@ -309,6 +321,7 @@ export type CourtBookingCreateInput = {
endTime: string endTime: string
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
@@ -324,6 +337,7 @@ export type CourtBookingUncheckedCreateInput = {
endTime: string endTime: string
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
@@ -337,6 +351,7 @@ export type CourtBookingUpdateInput = {
endTime?: Prisma.StringFieldUpdateOperationsInput | string endTime?: Prisma.StringFieldUpdateOperationsInput | string
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
@@ -352,6 +367,7 @@ export type CourtBookingUncheckedUpdateInput = {
endTime?: Prisma.StringFieldUpdateOperationsInput | string endTime?: Prisma.StringFieldUpdateOperationsInput | string
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
@@ -366,6 +382,7 @@ export type CourtBookingCreateManyInput = {
endTime: string endTime: string
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
@@ -379,6 +396,7 @@ export type CourtBookingUpdateManyMutationInput = {
endTime?: Prisma.StringFieldUpdateOperationsInput | string endTime?: Prisma.StringFieldUpdateOperationsInput | string
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
@@ -393,6 +411,7 @@ export type CourtBookingUncheckedUpdateManyInput = {
endTime?: Prisma.StringFieldUpdateOperationsInput | string endTime?: Prisma.StringFieldUpdateOperationsInput | string
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
@@ -423,6 +442,7 @@ export type CourtBookingCountOrderByAggregateInput = {
endTime?: Prisma.SortOrder endTime?: 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
@@ -437,6 +457,7 @@ export type CourtBookingMaxOrderByAggregateInput = {
endTime?: Prisma.SortOrder endTime?: 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
@@ -451,6 +472,7 @@ export type CourtBookingMinOrderByAggregateInput = {
endTime?: Prisma.SortOrder endTime?: 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
@@ -510,6 +532,7 @@ export type CourtBookingCreateWithoutCourtInput = {
endTime: string endTime: string
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
@@ -523,6 +546,7 @@ export type CourtBookingUncheckedCreateWithoutCourtInput = {
endTime: string endTime: string
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
@@ -566,6 +590,7 @@ export type CourtBookingScalarWhereInput = {
endTime?: Prisma.StringFilter<"CourtBooking"> | string endTime?: Prisma.StringFilter<"CourtBooking"> | string
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
@@ -579,6 +604,7 @@ export type CourtBookingCreateManyCourtInput = {
endTime: string endTime: string
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
@@ -592,6 +618,7 @@ export type CourtBookingUpdateWithoutCourtInput = {
endTime?: Prisma.StringFieldUpdateOperationsInput | string endTime?: Prisma.StringFieldUpdateOperationsInput | string
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
@@ -605,6 +632,7 @@ export type CourtBookingUncheckedUpdateWithoutCourtInput = {
endTime?: Prisma.StringFieldUpdateOperationsInput | string endTime?: Prisma.StringFieldUpdateOperationsInput | string
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
@@ -618,6 +646,7 @@ export type CourtBookingUncheckedUpdateManyWithoutCourtInput = {
endTime?: Prisma.StringFieldUpdateOperationsInput | string endTime?: Prisma.StringFieldUpdateOperationsInput | string
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
@@ -634,6 +663,7 @@ export type CourtBookingSelect<ExtArgs extends runtime.Types.Extensions.Internal
endTime?: boolean endTime?: boolean
customerName?: boolean customerName?: boolean
customerPhone?: boolean customerPhone?: boolean
customerEmail?: boolean
status?: boolean status?: boolean
createdAt?: boolean createdAt?: boolean
updatedAt?: boolean updatedAt?: boolean
@@ -649,6 +679,7 @@ export type CourtBookingSelectCreateManyAndReturn<ExtArgs extends runtime.Types.
endTime?: boolean endTime?: boolean
customerName?: boolean customerName?: boolean
customerPhone?: boolean customerPhone?: boolean
customerEmail?: boolean
status?: boolean status?: boolean
createdAt?: boolean createdAt?: boolean
updatedAt?: boolean updatedAt?: boolean
@@ -664,6 +695,7 @@ export type CourtBookingSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.
endTime?: boolean endTime?: boolean
customerName?: boolean customerName?: boolean
customerPhone?: boolean customerPhone?: boolean
customerEmail?: boolean
status?: boolean status?: boolean
createdAt?: boolean createdAt?: boolean
updatedAt?: boolean updatedAt?: boolean
@@ -679,12 +711,13 @@ export type CourtBookingSelectScalar = {
endTime?: boolean endTime?: 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" | "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" | "customerName" | "customerPhone" | "customerEmail" | "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>
} }
@@ -709,6 +742,7 @@ export type $CourtBookingPayload<ExtArgs extends runtime.Types.Extensions.Intern
endTime: string endTime: string
customerName: string customerName: string
customerPhone: string customerPhone: string
customerEmail: string
status: $Enums.CourtBookingStatus status: $Enums.CourtBookingStatus
createdAt: Date createdAt: Date
updatedAt: Date updatedAt: Date
@@ -1144,6 +1178,7 @@ export interface CourtBookingFieldRefs {
readonly endTime: Prisma.FieldRef<"CourtBooking", 'String'> readonly endTime: Prisma.FieldRef<"CourtBooking", 'String'>
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

@@ -31,6 +31,9 @@ export type UserMinAggregateOutputType = {
emailVerified: boolean | null emailVerified: boolean | null
image: string | null image: string | null
phone: string | null phone: string | null
banned: boolean | null
bannedAt: Date | null
banReason: string | null
createdAt: Date | null createdAt: Date | null
updatedAt: Date | null updatedAt: Date | null
role: string | null role: string | null
@@ -43,6 +46,9 @@ export type UserMaxAggregateOutputType = {
emailVerified: boolean | null emailVerified: boolean | null
image: string | null image: string | null
phone: string | null phone: string | null
banned: boolean | null
bannedAt: Date | null
banReason: string | null
createdAt: Date | null createdAt: Date | null
updatedAt: Date | null updatedAt: Date | null
role: string | null role: string | null
@@ -55,6 +61,9 @@ export type UserCountAggregateOutputType = {
emailVerified: number emailVerified: number
image: number image: number
phone: number phone: number
banned: number
bannedAt: number
banReason: number
createdAt: number createdAt: number
updatedAt: number updatedAt: number
role: number role: number
@@ -69,6 +78,9 @@ export type UserMinAggregateInputType = {
emailVerified?: true emailVerified?: true
image?: true image?: true
phone?: true phone?: true
banned?: true
bannedAt?: true
banReason?: true
createdAt?: true createdAt?: true
updatedAt?: true updatedAt?: true
role?: true role?: true
@@ -81,6 +93,9 @@ export type UserMaxAggregateInputType = {
emailVerified?: true emailVerified?: true
image?: true image?: true
phone?: true phone?: true
banned?: true
bannedAt?: true
banReason?: true
createdAt?: true createdAt?: true
updatedAt?: true updatedAt?: true
role?: true role?: true
@@ -93,6 +108,9 @@ export type UserCountAggregateInputType = {
emailVerified?: true emailVerified?: true
image?: true image?: true
phone?: true phone?: true
banned?: true
bannedAt?: true
banReason?: true
createdAt?: true createdAt?: true
updatedAt?: true updatedAt?: true
role?: true role?: true
@@ -178,6 +196,9 @@ export type UserGroupByOutputType = {
emailVerified: boolean emailVerified: boolean
image: string | null image: string | null
phone: string | null phone: string | null
banned: boolean
bannedAt: Date | null
banReason: string | null
createdAt: Date createdAt: Date
updatedAt: Date updatedAt: Date
role: string role: string
@@ -211,6 +232,9 @@ export type UserWhereInput = {
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 phone?: Prisma.StringNullableFilter<"User"> | string | null
banned?: Prisma.BoolFilter<"User"> | boolean
bannedAt?: Prisma.DateTimeNullableFilter<"User"> | Date | string | null
banReason?: 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
@@ -226,6 +250,9 @@ export type UserOrderByWithRelationInput = {
emailVerified?: Prisma.SortOrder emailVerified?: Prisma.SortOrder
image?: Prisma.SortOrderInput | Prisma.SortOrder image?: Prisma.SortOrderInput | Prisma.SortOrder
phone?: Prisma.SortOrderInput | Prisma.SortOrder phone?: Prisma.SortOrderInput | Prisma.SortOrder
banned?: Prisma.SortOrder
bannedAt?: Prisma.SortOrderInput | Prisma.SortOrder
banReason?: Prisma.SortOrderInput | Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
role?: Prisma.SortOrder role?: Prisma.SortOrder
@@ -244,6 +271,9 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{
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 phone?: Prisma.StringNullableFilter<"User"> | string | null
banned?: Prisma.BoolFilter<"User"> | boolean
bannedAt?: Prisma.DateTimeNullableFilter<"User"> | Date | string | null
banReason?: 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
@@ -259,6 +289,9 @@ export type UserOrderByWithAggregationInput = {
emailVerified?: Prisma.SortOrder emailVerified?: Prisma.SortOrder
image?: Prisma.SortOrderInput | Prisma.SortOrder image?: Prisma.SortOrderInput | Prisma.SortOrder
phone?: Prisma.SortOrderInput | Prisma.SortOrder phone?: Prisma.SortOrderInput | Prisma.SortOrder
banned?: Prisma.SortOrder
bannedAt?: Prisma.SortOrderInput | Prisma.SortOrder
banReason?: Prisma.SortOrderInput | Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
role?: Prisma.SortOrder role?: Prisma.SortOrder
@@ -277,6 +310,9 @@ export type UserScalarWhereWithAggregatesInput = {
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 phone?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null
banned?: Prisma.BoolWithAggregatesFilter<"User"> | boolean
bannedAt?: Prisma.DateTimeNullableWithAggregatesFilter<"User"> | Date | string | null
banReason?: 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
@@ -289,6 +325,9 @@ export type UserCreateInput = {
emailVerified?: boolean emailVerified?: boolean
image?: string | null image?: string | null
phone?: string | null phone?: string | null
banned?: boolean
bannedAt?: Date | string | null
banReason?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
role?: string role?: string
@@ -304,6 +343,9 @@ export type UserUncheckedCreateInput = {
emailVerified?: boolean emailVerified?: boolean
image?: string | null image?: string | null
phone?: string | null phone?: string | null
banned?: boolean
bannedAt?: Date | string | null
banReason?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
role?: string role?: string
@@ -319,6 +361,9 @@ export type UserUpdateInput = {
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
banned?: Prisma.BoolFieldUpdateOperationsInput | boolean
bannedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
banReason?: 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
@@ -334,6 +379,9 @@ export type UserUncheckedUpdateInput = {
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
banned?: Prisma.BoolFieldUpdateOperationsInput | boolean
bannedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
banReason?: 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
@@ -349,6 +397,9 @@ export type UserCreateManyInput = {
emailVerified?: boolean emailVerified?: boolean
image?: string | null image?: string | null
phone?: string | null phone?: string | null
banned?: boolean
bannedAt?: Date | string | null
banReason?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
role?: string role?: string
@@ -361,6 +412,9 @@ export type UserUpdateManyMutationInput = {
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
banned?: Prisma.BoolFieldUpdateOperationsInput | boolean
bannedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
banReason?: 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
@@ -373,6 +427,9 @@ export type UserUncheckedUpdateManyInput = {
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
banned?: Prisma.BoolFieldUpdateOperationsInput | boolean
bannedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
banReason?: 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
@@ -385,6 +442,9 @@ export type UserCountOrderByAggregateInput = {
emailVerified?: Prisma.SortOrder emailVerified?: Prisma.SortOrder
image?: Prisma.SortOrder image?: Prisma.SortOrder
phone?: Prisma.SortOrder phone?: Prisma.SortOrder
banned?: Prisma.SortOrder
bannedAt?: Prisma.SortOrder
banReason?: Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
role?: Prisma.SortOrder role?: Prisma.SortOrder
@@ -397,6 +457,9 @@ export type UserMaxOrderByAggregateInput = {
emailVerified?: Prisma.SortOrder emailVerified?: Prisma.SortOrder
image?: Prisma.SortOrder image?: Prisma.SortOrder
phone?: Prisma.SortOrder phone?: Prisma.SortOrder
banned?: Prisma.SortOrder
bannedAt?: Prisma.SortOrder
banReason?: Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
role?: Prisma.SortOrder role?: Prisma.SortOrder
@@ -409,6 +472,9 @@ export type UserMinOrderByAggregateInput = {
emailVerified?: Prisma.SortOrder emailVerified?: Prisma.SortOrder
image?: Prisma.SortOrder image?: Prisma.SortOrder
phone?: Prisma.SortOrder phone?: Prisma.SortOrder
banned?: Prisma.SortOrder
bannedAt?: Prisma.SortOrder
banReason?: Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
role?: Prisma.SortOrder role?: Prisma.SortOrder
@@ -431,6 +497,10 @@ export type NullableStringFieldUpdateOperationsInput = {
set?: string | null set?: string | null
} }
export type NullableDateTimeFieldUpdateOperationsInput = {
set?: Date | string | null
}
export type DateTimeFieldUpdateOperationsInput = { export type DateTimeFieldUpdateOperationsInput = {
set?: Date | string set?: Date | string
} }
@@ -484,6 +554,9 @@ export type UserCreateWithoutSessionsInput = {
emailVerified?: boolean emailVerified?: boolean
image?: string | null image?: string | null
phone?: string | null phone?: string | null
banned?: boolean
bannedAt?: Date | string | null
banReason?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
role?: string role?: string
@@ -498,6 +571,9 @@ export type UserUncheckedCreateWithoutSessionsInput = {
emailVerified?: boolean emailVerified?: boolean
image?: string | null image?: string | null
phone?: string | null phone?: string | null
banned?: boolean
bannedAt?: Date | string | null
banReason?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
role?: string role?: string
@@ -528,6 +604,9 @@ export type UserUpdateWithoutSessionsInput = {
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
banned?: Prisma.BoolFieldUpdateOperationsInput | boolean
bannedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
banReason?: 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
@@ -542,6 +621,9 @@ export type UserUncheckedUpdateWithoutSessionsInput = {
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
banned?: Prisma.BoolFieldUpdateOperationsInput | boolean
bannedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
banReason?: 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
@@ -556,6 +638,9 @@ export type UserCreateWithoutAccountsInput = {
emailVerified?: boolean emailVerified?: boolean
image?: string | null image?: string | null
phone?: string | null phone?: string | null
banned?: boolean
bannedAt?: Date | string | null
banReason?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
role?: string role?: string
@@ -570,6 +655,9 @@ export type UserUncheckedCreateWithoutAccountsInput = {
emailVerified?: boolean emailVerified?: boolean
image?: string | null image?: string | null
phone?: string | null phone?: string | null
banned?: boolean
bannedAt?: Date | string | null
banReason?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
role?: string role?: string
@@ -600,6 +688,9 @@ export type UserUpdateWithoutAccountsInput = {
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
banned?: Prisma.BoolFieldUpdateOperationsInput | boolean
bannedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
banReason?: 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
@@ -614,6 +705,9 @@ export type UserUncheckedUpdateWithoutAccountsInput = {
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
banned?: Prisma.BoolFieldUpdateOperationsInput | boolean
bannedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
banReason?: 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
@@ -628,6 +722,9 @@ export type UserCreateWithoutComplexesInput = {
emailVerified?: boolean emailVerified?: boolean
image?: string | null image?: string | null
phone?: string | null phone?: string | null
banned?: boolean
bannedAt?: Date | string | null
banReason?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
role?: string role?: string
@@ -642,6 +739,9 @@ export type UserUncheckedCreateWithoutComplexesInput = {
emailVerified?: boolean emailVerified?: boolean
image?: string | null image?: string | null
phone?: string | null phone?: string | null
banned?: boolean
bannedAt?: Date | string | null
banReason?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
role?: string role?: string
@@ -672,6 +772,9 @@ export type UserUpdateWithoutComplexesInput = {
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
banned?: Prisma.BoolFieldUpdateOperationsInput | boolean
bannedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
banReason?: 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
@@ -686,6 +789,9 @@ export type UserUncheckedUpdateWithoutComplexesInput = {
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
banned?: Prisma.BoolFieldUpdateOperationsInput | boolean
bannedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
banReason?: 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
@@ -749,6 +855,9 @@ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
emailVerified?: boolean emailVerified?: boolean
image?: boolean image?: boolean
phone?: boolean phone?: boolean
banned?: boolean
bannedAt?: boolean
banReason?: boolean
createdAt?: boolean createdAt?: boolean
updatedAt?: boolean updatedAt?: boolean
role?: boolean role?: boolean
@@ -765,6 +874,9 @@ export type UserSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensio
emailVerified?: boolean emailVerified?: boolean
image?: boolean image?: boolean
phone?: boolean phone?: boolean
banned?: boolean
bannedAt?: boolean
banReason?: boolean
createdAt?: boolean createdAt?: boolean
updatedAt?: boolean updatedAt?: boolean
role?: boolean role?: boolean
@@ -777,6 +889,9 @@ export type UserSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensio
emailVerified?: boolean emailVerified?: boolean
image?: boolean image?: boolean
phone?: boolean phone?: boolean
banned?: boolean
bannedAt?: boolean
banReason?: boolean
createdAt?: boolean createdAt?: boolean
updatedAt?: boolean updatedAt?: boolean
role?: boolean role?: boolean
@@ -789,12 +904,15 @@ export type UserSelectScalar = {
emailVerified?: boolean emailVerified?: boolean
image?: boolean image?: boolean
phone?: boolean phone?: boolean
banned?: boolean
bannedAt?: boolean
banReason?: 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" | "phone" | "banned" | "bannedAt" | "banReason" | "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>
@@ -818,6 +936,9 @@ export type $UserPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
emailVerified: boolean emailVerified: boolean
image: string | null image: string | null
phone: string | null phone: string | null
banned: boolean
bannedAt: Date | null
banReason: string | null
createdAt: Date createdAt: Date
updatedAt: Date updatedAt: Date
role: string role: string
@@ -1253,6 +1374,9 @@ export interface UserFieldRefs {
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 phone: Prisma.FieldRef<"User", 'String'>
readonly banned: Prisma.FieldRef<"User", 'Boolean'>
readonly bannedAt: Prisma.FieldRef<"User", 'DateTime'>
readonly banReason: 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,3 +1,4 @@
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';
@@ -21,10 +22,26 @@ export const auth = betterAuth({
}, },
user: { user: {
additionalFields: { additionalFields: {
role: {
type: 'string',
required: false,
},
phone: { phone: {
type: 'string', type: 'string',
required: false, required: false,
}, },
banned: {
type: 'boolean',
required: false,
},
bannedAt: {
type: 'string',
required: false,
},
banReason: {
type: 'string',
required: false,
},
}, },
}, },
emailVerification: { emailVerification: {
@@ -34,10 +51,73 @@ export const auth = betterAuth({
const verificationUrl = new URL(url); const verificationUrl = new URL(url);
const appUrl = process.env.APP_BASE_URL ?? 'http://localhost:5173'; const appUrl = process.env.APP_BASE_URL ?? 'http://localhost:5173';
verificationUrl.searchParams.set('callbackURL', appUrl); 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({ await sendMail({
to: user.email, to: user.email,
subject: 'Verificá tu email en Playzer', subject: 'Verificá tu email en Playzer',
html: `Hacé click para verificar tu email: <a href="${verificationUrl.toString()}">${verificationUrl.toString()}</a>`, html: wrapLayout(content),
text: `Hacé click para verificar tu email: ${verificationUrl.toString()}`, text: `Hacé click para verificar tu email: ${verificationUrl.toString()}`,
}); });
}, },

View File

@@ -0,0 +1,68 @@
type IpGeoInfo = {
ip: string;
city: string;
country: string;
countryCode: string;
lat: number | null;
lon: number | null;
};
const cache = new Map<string, { data: IpGeoInfo; expiresAt: number }>();
const CACHE_TTL_MS = 60 * 60 * 1000;
function getCached(ip: string): IpGeoInfo | undefined {
const entry = cache.get(ip);
if (!entry) return undefined;
if (Date.now() > entry.expiresAt) {
cache.delete(ip);
return undefined;
}
return entry.data;
}
function setCache(ip: string, info: IpGeoInfo): void {
cache.set(ip, { data: info, expiresAt: Date.now() + CACHE_TTL_MS });
}
async function fetchGeoInfo(ip: string): Promise<IpGeoInfo | null> {
if (ip === '127.0.0.1' || ip === '::1' || ip.startsWith('192.168.') || ip.startsWith('10.')) {
return {
ip,
city: 'Red local',
country: 'Red local',
countryCode: 'LOCAL',
lat: null,
lon: null,
};
}
const cached = getCached(ip);
if (cached) return cached;
try {
const response = await fetch(
`http://ip-api.com/json/${ip}?fields=status,country,countryCode,city,lat,lon,query`
);
if (!response.ok) return null;
const data = await response.json();
if (data.status !== 'success') return null;
const info: IpGeoInfo = {
ip: data.query,
city: data.city || 'Desconocida',
country: data.country || 'Desconocido',
countryCode: data.countryCode || '',
lat: data.lat ?? null,
lon: data.lon ?? null,
};
setCache(ip, info);
return info;
} catch {
return null;
}
}
export type { IpGeoInfo };
export { fetchGeoInfo };

View File

@@ -0,0 +1,34 @@
function toMinutes(value: string): number {
const [hours, minutes] = value.split(':').map((part) => Number(part));
return hours * 60 + minutes;
}
export function isSlotInPast(
bookingDate: Date,
startTime: string,
slotDurationMinutes: number,
now?: Date
): boolean {
const currentTime = now ?? new Date();
const todayStart = new Date(
currentTime.getFullYear(),
currentTime.getMonth(),
currentTime.getDate()
);
const bookingLocalStart = new Date(
bookingDate.getUTCFullYear(),
bookingDate.getUTCMonth(),
bookingDate.getUTCDate()
);
if (bookingLocalStart < todayStart) return true;
if (bookingLocalStart > todayStart) return false;
const currentMinutes = currentTime.getHours() * 60 + currentTime.getMinutes();
const slotStartMinutes = toMinutes(startTime);
const elapsed = currentMinutes - slotStartMinutes;
return elapsed > slotDurationMinutes / 2;
}

View File

@@ -1,7 +1,20 @@
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { fetchGeoInfo } from '@/lib/geoip';
import { db } from '@/lib/prisma';
import type { AppEnv } from '@/types/hono'; import type { AppEnv } from '@/types/hono';
import { createMiddleware } from 'hono/factory'; import { createMiddleware } from 'hono/factory';
type SessionWithGeo = {
id: string;
ipAddress: string | null;
userAgent: string | null;
country: string | null;
city: string | null;
countryCode: string | null;
latitude: number | null;
longitude: number | null;
};
export const requireAuth = createMiddleware<AppEnv>(async (c, next) => { export const requireAuth = createMiddleware<AppEnv>(async (c, next) => {
const session = await auth.api.getSession({ const session = await auth.api.getSession({
headers: c.req.raw.headers, headers: c.req.raw.headers,
@@ -11,8 +24,45 @@ export const requireAuth = createMiddleware<AppEnv>(async (c, next) => {
return c.json({ message: 'Unauthorized' }, 401); return c.json({ message: 'Unauthorized' }, 401);
} }
const user = session.user as { banned?: boolean; banReason?: string | null };
if (user.banned) {
return c.json(
{
message: 'Tu cuenta ha sido bloqueada.',
...(user.banReason ? { reason: user.banReason } : {}),
},
403
);
}
const s = session.session as unknown as SessionWithGeo;
c.set('user', session.user); c.set('user', session.user);
c.set('session', session.session); c.set('session', session.session);
if (s.ipAddress && !s.country) {
resolveSessionGeo(s.id, s.ipAddress);
}
await next(); await next();
}); });
async function resolveSessionGeo(sessionId: string, ipAddress: string) {
try {
const geo = await fetchGeoInfo(ipAddress);
if (!geo) return;
await db.session.update({
where: { id: sessionId },
data: {
country: geo.country,
city: geo.city,
countryCode: geo.countryCode,
latitude: geo.lat,
longitude: geo.lon,
},
});
} catch {
// Silently fail — geo enrichment is best-effort
}
}

View File

@@ -1,7 +1,9 @@
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';
@@ -14,6 +16,26 @@ 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,6 +2,7 @@ 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';
@@ -14,6 +15,33 @@ 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

@@ -1,7 +1,9 @@
import { randomInt } from 'node:crypto'; 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 { isSlotInPast } from '@/lib/slot-validator';
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,
@@ -156,6 +158,40 @@ 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;
@@ -164,6 +200,7 @@ function mapBookingResponse(booking: {
endTime: string; endTime: string;
customerName: string; customerName: string;
customerPhone: string; customerPhone: string;
customerEmail: string;
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NOSHOW'; status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NOSHOW';
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
@@ -180,6 +217,7 @@ function mapBookingResponse(booking: {
slug: string; slug: string;
}; };
}; };
price?: number;
}): AdminBooking { }): AdminBooking {
return { return {
id: booking.id, id: booking.id,
@@ -198,6 +236,8 @@ 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(),
@@ -292,6 +332,10 @@ export async function createAdminBooking(
slug: true, slug: true,
}, },
}, },
priceRules: {
where: { isActive: true },
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
},
}, },
}); });
@@ -318,6 +362,10 @@ export async function createAdminBooking(
); );
} }
if (isSlotInPast(bookingDate, input.startTime, court.slotDurationMinutes)) {
throw new AdminBookingServiceError('No se pueden crear reservas en el pasado.', 400);
}
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) => {
@@ -381,6 +429,7 @@ 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: {
@@ -407,7 +456,9 @@ export async function createAdminBooking(
}); });
}); });
return mapBookingResponse(booking); const price = resolvePrice(court, dayOfWeek, selectedSlot.startTime, selectedSlot.endTime);
return mapBookingResponse({ ...booking, price });
} catch (error) { } catch (error) {
if (error instanceof AdminBookingServiceError) { if (error instanceof AdminBookingServiceError) {
throw error; throw error;
@@ -521,6 +572,7 @@ export async function updateAdminBookingStatus(
endTime: booking.endTime, endTime: booking.endTime,
customerName: booking.customerName, customerName: booking.customerName,
customerPhone: booking.customerPhone, customerPhone: booking.customerPhone,
customerEmail: booking.customerEmail,
previousStatus: booking.status, previousStatus: booking.status,
newStatus: input.status, newStatus: input.status,
changedAt: new Date(), changedAt: new Date(),

View File

@@ -0,0 +1,67 @@
import { validate } from '@/lib/http/validate';
import { requireAuth } from '@/middlewares/require-auth.middleware';
import { requireSuperAdmin } from '@/middlewares/require-super-admin.middleware';
import { blockUserHandler } from '@/modules/admin/handlers/block-user.handler';
import { createPlanHandler } from '@/modules/admin/handlers/create-plan.handler';
import { deletePlanHandler } from '@/modules/admin/handlers/delete-plan.handler';
import { getGeoStatsHandler } from '@/modules/admin/handlers/get-geo-stats.handler';
import { getUserSessionsHandler } from '@/modules/admin/handlers/get-user-sessions.handler';
import { listComplexesHandler } from '@/modules/admin/handlers/list-complexes.handler';
import { listPlansAdminHandler } from '@/modules/admin/handlers/list-plans-admin.handler';
import { listUsersHandler } from '@/modules/admin/handlers/list-users.handler';
import { revokeAllSessionsHandler } from '@/modules/admin/handlers/revoke-all-sessions.handler';
import { unblockUserHandler } from '@/modules/admin/handlers/unblock-user.handler';
import { updatePlanHandler } from '@/modules/admin/handlers/update-plan.handler';
import type { AppEnv } from '@/types/hono';
import {
adminBlockUserSchema,
adminCreatePlanSchema,
adminUpdatePlanSchema,
} from '@repo/api-contract';
import { Hono } from 'hono';
import { z } from 'zod';
export const adminRoutes = new Hono<AppEnv>();
adminRoutes.use('*', requireAuth, requireSuperAdmin);
adminRoutes.get('/complexes', listComplexesHandler);
adminRoutes.get('/plans', listPlansAdminHandler);
adminRoutes.post('/plans', validate.json(adminCreatePlanSchema), createPlanHandler);
adminRoutes.patch(
'/plans/:code',
validate.param(z.object({ code: z.string().min(1).max(10) })),
validate.json(adminUpdatePlanSchema),
updatePlanHandler
);
adminRoutes.delete(
'/plans/:code',
validate.param(z.object({ code: z.string().min(1).max(10) })),
deletePlanHandler
);
adminRoutes.get('/geo-stats', getGeoStatsHandler);
adminRoutes.get('/users', listUsersHandler);
adminRoutes.post(
'/users/:id/block',
validate.param(z.object({ id: z.string().min(1) })),
validate.json(adminBlockUserSchema),
blockUserHandler
);
adminRoutes.post(
'/users/:id/unblock',
validate.param(z.object({ id: z.string().min(1) })),
unblockUserHandler
);
adminRoutes.get(
'/users/:id/sessions',
validate.param(z.object({ id: z.string().min(1) })),
getUserSessionsHandler
);
adminRoutes.post(
'/users/:id/sessions/revoke-all',
validate.param(z.object({ id: z.string().min(1) })),
revokeAllSessionsHandler
);

View File

@@ -0,0 +1,20 @@
import { AdminServiceError, blockUser } from '@/modules/admin/services/admin-users.service';
import type { AppContext } from '@/types/hono';
import type { AdminBlockUserInput } from '@repo/api-contract';
type BlockUserParams = { id: string };
export async function blockUserHandler(c: AppContext) {
const { id } = c.req.valid('param' as never) as BlockUserParams;
const body = c.req.valid('json' as never) as AdminBlockUserInput;
try {
await blockUser(id, body.banReason);
return c.json({ message: 'Usuario bloqueado correctamente.' });
} catch (error) {
if (error instanceof AdminServiceError) {
return c.json({ message: error.message }, error.status);
}
throw error;
}
}

View File

@@ -0,0 +1,34 @@
import { db } from '@/lib/prisma';
import type { AppContext } from '@/types/hono';
import type { AdminCreatePlanInput } from '@repo/api-contract';
export async function createPlanHandler(c: AppContext) {
const body = c.req.valid('json' as never) as AdminCreatePlanInput;
const existing = await db.plan.findUnique({
where: { code: body.code },
});
if (existing) {
return c.json({ message: 'Ya existe un plan con ese código.' }, 409);
}
const plan = await db.plan.create({
data: {
code: body.code,
name: body.name,
price: body.price,
rules: body.rules,
},
});
return c.json(
{
code: plan.code,
name: plan.name,
price: Number(plan.price),
rules: plan.rules,
},
201
);
}

View File

@@ -0,0 +1,31 @@
import { db } from '@/lib/prisma';
import type { AppContext } from '@/types/hono';
type DeletePlanParams = { code: string };
export async function deletePlanHandler(c: AppContext) {
const { code } = c.req.valid('param' as never) as DeletePlanParams;
const existing = await db.plan.findUnique({
where: { code },
include: {
_count: {
select: { complexes: true },
},
},
});
if (!existing) {
return c.json({ message: 'Plan no encontrado.' }, 404);
}
if (existing._count.complexes > 0) {
return c.json({ message: 'No se puede eliminar un plan que tiene complejos asignados.' }, 409);
}
await db.plan.delete({
where: { code },
});
return c.json({ message: 'Plan eliminado correctamente.' });
}

View File

@@ -0,0 +1,8 @@
import { getGeoStats } from '@/modules/admin/services/admin-geo.service';
import type { AppEnv } from '@/types/hono';
import type { Handler } from 'hono';
export const getGeoStatsHandler: Handler<AppEnv> = async (c) => {
const stats = await getGeoStats();
return c.json(stats);
};

View File

@@ -0,0 +1,12 @@
import { getUserSessions } from '@/modules/admin/services/admin-users.service';
import type { AppContext } from '@/types/hono';
type UserSessionsParams = { id: string };
export async function getUserSessionsHandler(c: AppContext) {
const { id } = c.req.valid('param' as never) as UserSessionsParams;
const sessions = await getUserSessions(id);
return c.json(sessions);
}

View File

@@ -0,0 +1,7 @@
import { getComplexStatsList } from '@/modules/admin/services/complex-stats.service';
import type { AppContext } from '@/types/hono';
export async function listComplexesHandler(c: AppContext) {
const stats = await getComplexStatsList();
return c.json(stats);
}

View File

@@ -0,0 +1,24 @@
import { db } from '@/lib/prisma';
import type { AppContext } from '@/types/hono';
export async function listPlansAdminHandler(c: AppContext) {
const plans = await db.plan.findMany({
orderBy: { price: 'asc' },
include: {
_count: {
select: { complexes: true },
},
},
});
return c.json(
plans.map((plan) => ({
code: plan.code,
name: plan.name,
price: Number(plan.price),
rules: plan.rules,
lastUpdatedAt: plan.lastUpdatedAt.toISOString(),
complexCount: plan._count.complexes,
}))
);
}

View File

@@ -0,0 +1,8 @@
import { listAdminUsers } from '@/modules/admin/services/admin-users.service';
import type { AppContext } from '@/types/hono';
export async function listUsersHandler(c: AppContext) {
const search = c.req.query('search');
const users = await listAdminUsers(search);
return c.json(users);
}

View File

@@ -0,0 +1,12 @@
import { revokeAllUserSessions } from '@/modules/admin/services/admin-users.service';
import type { AppContext } from '@/types/hono';
type RevokeSessionsParams = { id: string };
export async function revokeAllSessionsHandler(c: AppContext) {
const { id } = c.req.valid('param' as never) as RevokeSessionsParams;
const count = await revokeAllUserSessions(id);
return c.json({ message: `${count} sesiones cerradas correctamente.` });
}

View File

@@ -0,0 +1,12 @@
import { unblockUser } from '@/modules/admin/services/admin-users.service';
import type { AppContext } from '@/types/hono';
type UnblockUserParams = { id: string };
export async function unblockUserHandler(c: AppContext) {
const { id } = c.req.valid('param' as never) as UnblockUserParams;
await unblockUser(id);
return c.json({ message: 'Usuario desbloqueado correctamente.' });
}

View File

@@ -0,0 +1,35 @@
import { db } from '@/lib/prisma';
import type { AppContext } from '@/types/hono';
import type { AdminUpdatePlanInput } from '@repo/api-contract';
type UpdatePlanParams = { code: string };
export async function updatePlanHandler(c: AppContext) {
const { code } = c.req.valid('param' as never) as UpdatePlanParams;
const body = c.req.valid('json' as never) as AdminUpdatePlanInput;
const existing = await db.plan.findUnique({
where: { code },
});
if (!existing) {
return c.json({ message: 'Plan no encontrado.' }, 404);
}
const plan = await db.plan.update({
where: { code },
data: {
...(body.name !== undefined && { name: body.name }),
...(body.price !== undefined && { price: body.price }),
...(body.rules !== undefined && { rules: body.rules }),
lastUpdatedAt: new Date(),
},
});
return c.json({
code: plan.code,
name: plan.name,
price: Number(plan.price),
rules: plan.rules,
});
}

View File

@@ -0,0 +1,67 @@
import { db } from '@/lib/prisma';
type GeoStatsCountry = {
country: string;
countryCode: string;
totalUsers: number;
cities: { city: string; userCount: number }[];
};
export async function getGeoStats(): Promise<{
countries: GeoStatsCountry[];
totalUniqueCountries: number;
}> {
const sessions = await db.session.findMany({
where: {
country: { not: null },
expiresAt: { gt: new Date() },
},
select: {
country: true,
countryCode: true,
city: true,
userId: true,
},
});
const countryMap = new Map<
string,
{ country: string; countryCode: string; users: Set<string>; cities: Map<string, Set<string>> }
>();
for (const s of sessions) {
const code = s.countryCode || 'XX';
let entry = countryMap.get(code);
if (!entry) {
entry = {
country: s.country || 'Desconocido',
countryCode: code,
users: new Set(),
cities: new Map(),
};
countryMap.set(code, entry);
}
entry.users.add(s.userId);
const cityName = s.city || 'Desconocida';
let cityUsers = entry.cities.get(cityName);
if (!cityUsers) {
cityUsers = new Set();
entry.cities.set(cityName, cityUsers);
}
cityUsers.add(s.userId);
}
const countries = Array.from(countryMap.values())
.map((entry) => ({
country: entry.country,
countryCode: entry.countryCode,
totalUsers: entry.users.size,
cities: Array.from(entry.cities.entries())
.map(([city, users]) => ({ city, userCount: users.size }))
.sort((a, b) => b.userCount - a.userCount),
}))
.sort((a, b) => b.totalUsers - a.totalUsers);
return { countries, totalUniqueCountries: countries.length };
}

View File

@@ -0,0 +1,146 @@
import { fetchGeoInfo } from '@/lib/geoip';
import { db } from '@/lib/prisma';
type AdminUser = {
id: string;
name: string;
email: string;
role: string;
banned: boolean;
bannedAt: string | null;
banReason: string | null;
createdAt: string;
complexCount: number;
activeSessions: number;
};
export async function listAdminUsers(search?: string): Promise<AdminUser[]> {
const users = await db.user.findMany({
where: search
? {
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } },
],
}
: undefined,
include: {
_count: {
select: { complexes: true, sessions: true },
},
},
orderBy: { createdAt: 'desc' },
});
return users.map((user) => ({
id: user.id,
name: user.name,
email: user.email,
role: user.role,
banned: user.banned ?? false,
bannedAt: user.bannedAt?.toISOString() ?? null,
banReason: user.banReason ?? null,
createdAt: user.createdAt.toISOString(),
complexCount: user._count.complexes,
activeSessions: user._count.sessions,
}));
}
type AdminUserSession = {
id: string;
createdAt: string;
expiresAt: string;
ipAddress: string | null;
userAgent: string | null;
city: string | null;
country: string | null;
countryCode: string | null;
};
export async function getUserSessions(userId: string): Promise<AdminUserSession[]> {
const sessions = await db.session.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
});
const uniqueIps = [...new Set(sessions.map((s) => s.ipAddress).filter(Boolean))] as string[];
const geoResults = await Promise.all(uniqueIps.map((ip) => fetchGeoInfo(ip)));
const geoMap = new Map<
string,
{ city: string | null; country: string | null; countryCode: string | null }
>();
for (let i = 0; i < uniqueIps.length; i++) {
const info = geoResults[i];
geoMap.set(uniqueIps[i], {
city: info?.city ?? null,
country: info?.country ?? null,
countryCode: info?.countryCode ?? null,
});
}
return sessions.map((s) => {
const geo = s.ipAddress ? geoMap.get(s.ipAddress) : null;
return {
id: s.id,
createdAt: s.createdAt.toISOString(),
expiresAt: s.expiresAt.toISOString(),
ipAddress: s.ipAddress,
userAgent: s.userAgent,
city: geo?.city ?? null,
country: geo?.country ?? null,
countryCode: geo?.countryCode ?? null,
};
});
}
export class AdminServiceError extends Error {
status: 400 | 403 | 404;
constructor(message: string, status: 400 | 403 | 404 = 400) {
super(message);
this.status = status;
this.name = 'AdminServiceError';
}
}
export async function blockUser(userId: string, banReason?: string): Promise<void> {
const target = await db.user.findUnique({
where: { id: userId },
select: { role: true },
});
if (!target) {
throw new AdminServiceError('Usuario no encontrado.', 404);
}
if (target.role === 'super_admin') {
throw new AdminServiceError('No se puede bloquear un super_admin.', 403);
}
await db.user.update({
where: { id: userId },
data: {
banned: true,
bannedAt: new Date(),
banReason: banReason ?? null,
},
});
}
export async function unblockUser(userId: string): Promise<void> {
await db.user.update({
where: { id: userId },
data: {
banned: false,
bannedAt: null,
banReason: null,
},
});
}
export async function revokeAllUserSessions(userId: string): Promise<number> {
const result = await db.session.deleteMany({
where: { userId },
});
return result.count;
}

View File

@@ -0,0 +1,80 @@
import { db } from '@/lib/prisma';
type ComplexStats = {
id: string;
complexName: string;
complexSlug: string;
city: string | null;
planCode: string | null;
planName: string | null;
userCount: number;
courtCount: number;
totalBookings: number;
avgBookingsPerDay: number;
bookingsByStatus: {
confirmed: number;
cancelled: number;
completed: number;
noshow: number;
};
paymentStatus: 'active' | 'no_plan' | 'expired';
};
export async function getComplexStatsList(): Promise<ComplexStats[]> {
const complexes = await db.complex.findMany({
include: {
plan: true,
users: true,
courts: {
include: {
bookings: true,
},
},
},
});
return complexes.map((complex) => {
const courtCount = complex.courts.length;
const allBookings = complex.courts.flatMap((c) => c.bookings);
const bookingsByStatus = {
confirmed: allBookings.filter((b) => b.status === 'CONFIRMED').length,
cancelled: allBookings.filter((b) => b.status === 'CANCELLED').length,
completed: allBookings.filter((b) => b.status === 'COMPLETED').length,
noshow: allBookings.filter((b) => b.status === 'NOSHOW').length,
};
const totalBookings = allBookings.length;
let avgBookingsPerDay = 0;
if (allBookings.length > 0) {
const dates = allBookings.map((b) => b.bookingDate);
const minDate = new Date(Math.min(...dates.map((d) => d.getTime())));
const daysDiff = Math.max(
1,
Math.ceil((Date.now() - minDate.getTime()) / (1000 * 60 * 60 * 24))
);
avgBookingsPerDay = Math.round((totalBookings / daysDiff) * 100) / 100;
}
let paymentStatus: 'active' | 'no_plan' | 'expired' = 'no_plan';
if (complex.plan) {
paymentStatus = 'active';
}
return {
id: complex.id,
complexName: complex.complexName,
complexSlug: complex.complexSlug,
city: complex.city,
planCode: complex.planCode,
planName: complex.plan?.name ?? null,
userCount: complex.users.length,
courtCount,
totalBookings,
avgBookingsPerDay,
bookingsByStatus,
paymentStatus,
};
});
}

View File

@@ -1,5 +1,6 @@
import { createHash, randomInt } from 'node:crypto'; import { createHash, randomInt } from 'node:crypto';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { type IpGeoInfo, fetchGeoInfo } from '@/lib/geoip';
import { sendMail } from '@/lib/mailer'; import { sendMail } from '@/lib/mailer';
import { db } from '@/lib/prisma'; import { db } from '@/lib/prisma';
import type { import type {
@@ -82,13 +83,6 @@ type UserAgentInfo = {
device: string; device: string;
}; };
type IpGeoInfo = {
ip: string;
city: string;
country: string;
countryCode: string;
};
function parseUserAgent(ua: string | undefined): UserAgentInfo { function parseUserAgent(ua: string | undefined): UserAgentInfo {
if (!ua) { if (!ua) {
return { browser: 'Desconocido', os: 'Desconocido', device: 'Desconocido' }; return { browser: 'Desconocido', os: 'Desconocido', device: 'Desconocido' };
@@ -118,31 +112,6 @@ function parseUserAgent(ua: string | undefined): UserAgentInfo {
return { browser, os, device }; return { browser, os, device };
} }
async function fetchGeoInfo(ip: string): Promise<IpGeoInfo | null> {
if (ip === '127.0.0.1' || ip === '::1' || ip.startsWith('192.168.') || ip.startsWith('10.')) {
return { ip, city: 'Red local', country: 'Red local', countryCode: 'LOCAL' };
}
try {
const response = await fetch(
`http://ip-api.com/json/${ip}?fields=status,country,countryCode,city,query`
);
if (!response.ok) return null;
const data = await response.json();
if (data.status !== 'success') return null;
return {
ip: data.query,
city: data.city || 'Desconocida',
country: data.country || 'Desconocido',
countryCode: data.countryCode || '',
};
} catch {
return null;
}
}
async function sendResetOtpEmail(email: string, otpCode: string) { async function sendResetOtpEmail(email: string, otpCode: string) {
await sendMail({ await sendMail({
to: email, to: email,

View File

@@ -1,4 +1,6 @@
import { fetchGeoInfo } from '@/lib/geoip';
import { db } from '@/lib/prisma'; import { db } from '@/lib/prisma';
import { resolvePlanPrice } from '@/modules/plan/services/plan-pricing.service';
import { parsePlanRules } from '@/modules/plan/services/plan-rules.service'; import { parsePlanRules } from '@/modules/plan/services/plan-rules.service';
import type { AppContext } from '@/types/hono'; import type { AppContext } from '@/types/hono';
@@ -9,13 +11,26 @@ export async function listPlansHandler(c: AppContext) {
}, },
}); });
const countryParam = c.req.query('country');
let countryCode: string | undefined;
if (countryParam) {
countryCode = countryParam;
} else {
const ip = c.req.header('x-forwarded-for')?.split(',')[0]?.trim() || '127.0.0.1';
const geo = await fetchGeoInfo(ip);
countryCode = geo?.countryCode || undefined;
}
return c.json( return c.json(
plans.map((plan) => { plans.map((plan) => {
const rules = parsePlanRules(plan.rules); const rules = parsePlanRules(plan.rules);
const resolved = resolvePlanPrice(Number(plan.price), rules, countryCode);
return { return {
code: plan.code, code: plan.code,
name: plan.name, name: plan.name,
price: Number(plan.price), price: resolved.amount,
currency: resolved.currency,
features: rules.features, features: rules.features,
limits: rules.limits, limits: rules.limits,
}; };

View File

@@ -0,0 +1,25 @@
import type { PlanRules } from '@repo/api-contract';
export type PlanPrice = {
amount: number;
currency: string;
};
export function resolvePlanPrice(
defaultPrice: number,
rules: PlanRules,
countryCode?: string
): PlanPrice {
if (!countryCode) {
return { amount: defaultPrice, currency: 'USD' };
}
if (rules.version === 'v2' && rules.pricing?.overrides) {
const override = rules.pricing.overrides[countryCode];
if (override) {
return { amount: override.amount, currency: override.currency };
}
}
return { amount: defaultPrice, currency: 'USD' };
}

View File

@@ -0,0 +1,55 @@
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,6 +3,7 @@ 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';
@@ -30,6 +31,20 @@ 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,9 +1,14 @@
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 { createPublicBookingSchema, publicAvailabilityQuerySchema } from '@repo/api-contract'; import {
cancelPublicBookingSchema,
createPublicBookingSchema,
publicAvailabilityQuerySchema,
} from '@repo/api-contract';
import { Hono } from 'hono'; import { Hono } from 'hono';
import { z } from 'zod'; import { z } from 'zod';
@@ -33,3 +38,10 @@ publicBookingRoutes.get(
zValidator('param', confirmationParamsSchema), zValidator('param', confirmationParamsSchema),
getPublicBookingConfirmationHandler getPublicBookingConfirmationHandler
); );
publicBookingRoutes.post(
'/complex/:complexSlug/cancel',
zValidator('param', complexSlugParamsSchema),
zValidator('json', cancelPublicBookingSchema),
cancelPublicBookingHandler
);

View File

@@ -1,7 +1,9 @@
import { randomInt } from 'node:crypto'; import { randomInt } from 'node:crypto';
import { db } from '@/lib/prisma'; import { db } from '@/lib/prisma';
import { isSlotInPast } from '@/lib/slot-validator';
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,
@@ -13,6 +15,16 @@ 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[] = [
@@ -32,7 +44,6 @@ 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;
} }
} }
@@ -202,6 +213,10 @@ async function getComplexWithBookingData(complexSlug: string) {
availabilities: { availabilities: {
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }], orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
}, },
priceRules: {
where: { isActive: true },
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
},
}, },
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
}, },
@@ -226,6 +241,33 @@ 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;
@@ -235,6 +277,8 @@ function mapBookingResponse(input: {
endTime: string; endTime: string;
customerName: string; customerName: string;
customerPhone: string; customerPhone: string;
customerEmail: string;
price: number;
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NOSHOW'; status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NOSHOW';
court: { court: {
id: string; id: string;
@@ -263,7 +307,9 @@ function mapBookingResponse(input: {
endTime: input.endTime, endTime: input.endTime,
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(),
}; };
} }
@@ -284,11 +330,17 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC
bookingDate: true, bookingDate: true,
startTime: true, startTime: true,
endTime: true, endTime: true,
customerEmail: 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,
@@ -299,6 +351,7 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC
complex: { complex: {
select: { select: {
complexName: true, complexName: true,
physicalAddress: true,
complexSlug: true, complexSlug: true,
}, },
}, },
@@ -311,13 +364,22 @@ 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: formatIsoDate(booking.bookingDate), date,
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,
@@ -325,6 +387,7 @@ 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,
createdAt: booking.createdAt.toISOString(), createdAt: booking.createdAt.toISOString(),
}; };
} }
@@ -432,7 +495,10 @@ export async function listPublicAvailability(complexSlug: string, query: PublicA
}, },
slotDurationMinutes: court.slotDurationMinutes, slotDurationMinutes: court.slotDurationMinutes,
availabilityDay: dayOfWeek, availabilityDay: dayOfWeek,
availableSlots, availableSlots: availableSlots.map((slot) => ({
...slot,
price: resolveSlotPrice(court, dayOfWeek, slot),
})),
}; };
}) })
.filter((court) => court.availableSlots.length > 0); .filter((court) => court.availableSlots.length > 0);
@@ -509,6 +575,10 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
); );
} }
if (isSlotInPast(bookingDate, input.startTime, selectedCourt.slotDurationMinutes)) {
throw new PublicBookingServiceError('No se pueden crear reservas en el pasado.', 400);
}
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) => {
@@ -566,6 +636,7 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
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',
}, },
select: { select: {
@@ -577,11 +648,14 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
endTime: true, endTime: 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,
@@ -591,6 +665,8 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
endTime: booking.endTime, endTime: booking.endTime,
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,
@@ -641,3 +717,108 @@ 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.', 400);
}
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,5 +1,6 @@
import path from 'node:path'; import path from 'node:path';
import { adminBookingRoutes } from '@/modules/admin-booking/admin-booking.routes'; import { adminBookingRoutes } from '@/modules/admin-booking/admin-booking.routes';
import { adminRoutes } from '@/modules/admin/admin.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';
@@ -17,6 +18,7 @@ import { healthCheckRoutes } from './modules/health-check/health-check.routes';
export function registerRoutes(app: Hono<AppEnv>) { export function registerRoutes(app: Hono<AppEnv>) {
app app
.route('', authRoutes) .route('', authRoutes)
.route('/api/admin', adminRoutes)
.route('/api/user', userRoutes) .route('/api/user', userRoutes)
.route('/api/plans', planRoutes) .route('/api/plans', planRoutes)
.route('/api/password-reset', passwordResetRoutes) .route('/api/password-reset', passwordResetRoutes)

View File

@@ -0,0 +1,88 @@
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,6 +1,5 @@
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);
@@ -15,6 +14,10 @@ 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,
@@ -59,7 +62,6 @@ 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,14 +1,15 @@
import { beforeEach, expect, mock, test } from 'bun:test'; import { beforeEach, expect, 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();
mock.clearAllMocks(); sendMailMock.mockClear();
transactionMock.mockClear();
}); });
test('creates a pending invitation and sends the email', async () => { test('creates a pending invitation and sends the email', async () => {
@@ -21,10 +22,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('2026-04-29T00:00:00.000Z'), expiresAt: new Date('2027-01-01T00:00:00.000Z'),
acceptedAt: null, acceptedAt: null,
revokedAt: null, revokedAt: null,
createdAt: new Date('2026-04-22T00:00:00.000Z'), createdAt: new Date('2026-12-01T00:00:00.000Z'),
} as never); } as never);
const result = await inviteComplexUser('admin-1', 'complex-1', { const result = await inviteComplexUser('admin-1', 'complex-1', {
@@ -79,7 +80,7 @@ test('rejects when the invite target already belongs to the complex', async () =
} }
expect(caught).toBeInstanceOf(ComplexMembersError); expect(caught).toBeInstanceOf(ComplexMembersError);
expect((caught as ComplexMembersError).status).toBe(409); expect((caught as InstanceType<typeof ComplexMembersError>).status).toBe(409);
expect(transactionMock).not.toHaveBeenCalled(); expect(transactionMock).not.toHaveBeenCalled();
expect(sendMailMock).not.toHaveBeenCalled(); expect(sendMailMock).not.toHaveBeenCalled();
}); });

View File

@@ -0,0 +1,87 @@
import { expect, test } from 'bun:test';
import type { PlanRules } from '@repo/api-contract';
const DEFAULT_PRICE = 29.99;
function makeV1Rules(): PlanRules {
return {
version: 'v1',
limits: { maxCourts: 3, maxBookingsPerDay: 50 },
policies: { allowOverbooking: false },
features: {
onlinePayments: false,
publicBookingPage: false,
advancedReports: false,
whatsappReminders: false,
},
};
}
function makeV2Rules(overrides?: Record<string, { amount: number; currency: string }>): PlanRules {
return {
version: 'v2',
limits: { maxCourts: 5, maxBookingsPerDay: 100 },
policies: { allowOverbooking: false },
features: {
onlinePayments: true,
publicBookingPage: true,
advancedReports: true,
whatsappReminders: true,
},
...(overrides ? { pricing: { overrides } } : {}),
};
}
const { resolvePlanPrice } = await import('@/modules/plan/services/plan-pricing.service');
test('returns default price in USD when no country code is provided', () => {
const result = resolvePlanPrice(
DEFAULT_PRICE,
makeV2Rules({ AR: { amount: 14999, currency: 'ARS' } })
);
expect(result).toEqual({ amount: DEFAULT_PRICE, currency: 'USD' });
});
test('returns overridden price for matching country code', () => {
const rules = makeV2Rules({ AR: { amount: 14999, currency: 'ARS' } });
const result = resolvePlanPrice(DEFAULT_PRICE, rules, 'AR');
expect(result).toEqual({ amount: 14999, currency: 'ARS' });
});
test('returns default price for country without override', () => {
const rules = makeV2Rules({ AR: { amount: 14999, currency: 'ARS' } });
const result = resolvePlanPrice(DEFAULT_PRICE, rules, 'CL');
expect(result).toEqual({ amount: DEFAULT_PRICE, currency: 'USD' });
});
test('returns default price for v1 rules (no pricing config)', () => {
const result = resolvePlanPrice(DEFAULT_PRICE, makeV1Rules(), 'AR');
expect(result).toEqual({ amount: DEFAULT_PRICE, currency: 'USD' });
});
test('returns default price for v2 rules with empty overrides', () => {
const rules = makeV2Rules({});
const result = resolvePlanPrice(DEFAULT_PRICE, rules, 'AR');
expect(result).toEqual({ amount: DEFAULT_PRICE, currency: 'USD' });
});
test('returns default price when v2 rules has no pricing section', () => {
const rules = makeV2Rules();
const result = resolvePlanPrice(DEFAULT_PRICE, rules, 'AR');
expect(result).toEqual({ amount: DEFAULT_PRICE, currency: 'USD' });
});
test('selects correct override among multiple countries', () => {
const rules = makeV2Rules({
AR: { amount: 24999, currency: 'ARS' },
BR: { amount: 59.99, currency: 'USD' },
});
expect(resolvePlanPrice(DEFAULT_PRICE, rules, 'AR')).toEqual({ amount: 24999, currency: 'ARS' });
expect(resolvePlanPrice(DEFAULT_PRICE, rules, 'BR')).toEqual({ amount: 59.99, currency: 'USD' });
expect(resolvePlanPrice(DEFAULT_PRICE, rules, 'US')).toEqual({
amount: DEFAULT_PRICE,
currency: 'USD',
});
});

View File

@@ -0,0 +1,90 @@
import { expect, test } from 'bun:test';
import { isSlotInPast } from '@/lib/slot-validator';
/**
* Helper to create a bookingDate like parseIsoDate does (UTC midnight).
*/
function bookingDate(year: number, month: number, day: number): Date {
return new Date(Date.UTC(year, month - 1, day));
}
/**
* Helper to create a "now" date at a specific local time.
* Using local date constructor guarantees the date parts
* (getFullYear, getMonth, getDate) match the arguments
* regardless of the test runner's timezone.
*/
function localDate(year: number, month: number, day: number, hours: number, minutes: number): Date {
return new Date(year, month - 1, day, hours, minutes, 0, 0);
}
test('future date — always allowed', () => {
const future = bookingDate(2027, 6, 10); // 2027-06-10
const now = localDate(2026, 6, 8, 12, 0); // 2026-06-08 12:00
expect(isSlotInPast(future, '10:00', 60, now)).toBe(false);
});
test('past date — rejected', () => {
const past = bookingDate(2025, 6, 8); // 2025-06-08
const now = localDate(2026, 6, 8, 12, 0); // 2026-06-08 12:00
expect(isSlotInPast(past, '10:00', 60, now)).toBe(true);
});
test('today, slot not started yet — allowed', () => {
const today = bookingDate(2026, 6, 8); // 2026-06-08
const now = localDate(2026, 6, 8, 9, 0); // 09:00, slot empieza a las 10:00
expect(isSlotInPast(today, '10:00', 60, now)).toBe(false);
});
test('today, slot in progress less than half elapsed — allowed', () => {
const today = bookingDate(2026, 6, 8);
const now = localDate(2026, 6, 8, 10, 10); // 10:10, slot 10:00-11:00 (60 min), 10 min elapsed
expect(isSlotInPast(today, '10:00', 60, now)).toBe(false);
});
test('today, slot in progress more than half elapsed — rejected', () => {
const today = bookingDate(2026, 6, 8);
const now = localDate(2026, 6, 8, 10, 40); // 10:40, slot 10:00-11:00 (60 min), 40 min elapsed
expect(isSlotInPast(today, '10:00', 60, now)).toBe(true);
});
test('today, slot already ended — rejected', () => {
const today = bookingDate(2026, 6, 8);
const now = localDate(2026, 6, 8, 11, 1); // 11:01, slot 10:00-11:00 terminó
expect(isSlotInPast(today, '10:00', 60, now)).toBe(true);
});
test('today, exactly half elapsed — allowed (inclusive boundary)', () => {
const today = bookingDate(2026, 6, 8);
const now = localDate(2026, 6, 8, 10, 30); // 10:30, slot 10:00-11:00 (60 min), exactamente 30 min
expect(isSlotInPast(today, '10:00', 60, now)).toBe(false);
});
test('today, slot starts exactly now — allowed', () => {
const today = bookingDate(2026, 6, 8);
const now = localDate(2026, 6, 8, 10, 0); // 10:00 exacto
expect(isSlotInPast(today, '10:00', 60, now)).toBe(false);
});
test('90 min slot with 10 min elapsed — allowed', () => {
const today = bookingDate(2026, 6, 8);
const now = localDate(2026, 6, 8, 12, 10); // 12:10, slot 12:00-13:30, 10/90 elapsed
expect(isSlotInPast(today, '12:00', 90, now)).toBe(false);
});
test('90 min slot with 50 min elapsed — rejected', () => {
const today = bookingDate(2026, 6, 8);
const now = localDate(2026, 6, 8, 12, 50); // 12:50, slot 12:00-13:30, 50/90 elapsed
expect(isSlotInPast(today, '12:00', 90, now)).toBe(true);
});

View File

@@ -49,7 +49,6 @@ function createContext(body: unknown) {
beforeEach(() => { beforeEach(() => {
createSportMock.mockReset(); createSportMock.mockReset();
mock.clearAllMocks();
}); });
test('returns 201 with the created sport', async () => { test('returns 201 with the created sport', async () => {

View File

@@ -1,24 +1,6 @@
import { beforeEach, expect, mock, test } from 'bun:test'; import { beforeEach, expect, test } from 'bun:test';
import { createPrismaMock } from 'bun-mock-prisma'; import { prismaMock, uuidV7Mock } from '../support/prisma.mock';
import type { PrismaClient } from '@/generated/prisma/client';
import type { PrismaClientMock } from 'bun-mock-prisma';
const prismaMock = createPrismaMock<PrismaClient>() as PrismaClientMock<PrismaClient>;
const uuidV7Mock = mock(() => '00000000-0000-4000-8000-000000000001');
mock.module('@/lib/prisma', () => ({
__esModule: true,
db: {
sport: prismaMock.sport,
},
}));
mock.module('uuid', () => ({
__esModule: true,
v7: uuidV7Mock,
}));
const { createSport, getSportById, listSports, updateSport } = await import( const { createSport, getSportById, listSports, updateSport } = await import(
'@/modules/sport/services/sport.service' '@/modules/sport/services/sport.service'
@@ -26,7 +8,7 @@ const { createSport, getSportById, listSports, updateSport } = await import(
beforeEach(() => { beforeEach(() => {
prismaMock._reset(); prismaMock._reset();
mock.clearAllMocks(); uuidV7Mock.mockClear();
}); });
test('listSports returns sports ordered by name', async () => { test('listSports returns sports ordered by name', async () => {

View File

@@ -21,6 +21,7 @@ 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,
}; };
@@ -33,3 +34,10 @@ 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,
}));

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

View File

@@ -6,6 +6,7 @@ import {
Menu, Menu,
Moon, Moon,
Settings, Settings,
Shield,
Sun, Sun,
User, User,
X, X,
@@ -241,6 +242,18 @@ export function Header() {
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{user?.role === 'super_admin' && (
<DropdownMenuItem
className="cursor-pointer rounded-xl"
onSelect={() => {
void navigate({ to: '/admin' });
}}
>
<Shield className="mr-2 size-4 text-emerald-600" />
Admin
</DropdownMenuItem>
)}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
@@ -405,6 +418,20 @@ export function Header() {
</Button> </Button>
)} )}
{user?.role === 'super_admin' && (
<Button
variant="ghost"
className="justify-start rounded-2xl"
onClick={() => {
setOpen(false);
void navigate({ to: '/admin' });
}}
>
<Shield className="mr-2 size-4 text-emerald-600" />
Admin
</Button>
)}
{isAuthenticated && ( {isAuthenticated && (
<Button <Button
variant="ghost" variant="ghost"

View File

@@ -0,0 +1,31 @@
import { type VariantProps, cva } from 'class-variance-authority';
import type { HTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground shadow-sm',
secondary: 'border-transparent bg-secondary text-secondary-foreground',
destructive: 'border-transparent bg-destructive text-destructive-foreground shadow-sm',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
}
);
export interface BadgeProps
extends HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };

View File

@@ -35,7 +35,7 @@ function SelectTrigger({
data-slot="select-trigger" data-slot="select-trigger"
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-full 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",
className className
)} )}
{...props} {...props}

View File

@@ -0,0 +1,68 @@
import { Button } from '@/components/ui/button';
import { Link } from '@tanstack/react-router';
import { BarChart3, Building2, Home, LayoutDashboard, Shield, Users } from 'lucide-react';
import type { ReactNode } from 'react';
type NavItem = {
label: string;
href: string;
icon: ReactNode;
};
const navItems: NavItem[] = [
{ label: 'Dashboard', href: '/admin', icon: <LayoutDashboard className="size-4" /> },
{ label: 'Complejos', href: '/admin/complexes', icon: <Building2 className="size-4" /> },
{ label: 'Planes', href: '/admin/plans', icon: <BarChart3 className="size-4" /> },
{ label: 'Usuarios', href: '/admin/users', icon: <Users className="size-4" /> },
];
interface AdminLayoutProps {
children: ReactNode;
currentPath: string;
}
export function AdminLayout({ children, currentPath }: AdminLayoutProps) {
return (
<div className="flex min-h-[calc(100dvh-4rem)] gap-0">
<aside className="hidden w-56 shrink-0 border-r border-border/60 md:block">
<nav className="sticky top-20 flex flex-col gap-1 p-4">
<div className="mb-4 flex items-center gap-2 px-3">
<Shield className="size-4 text-emerald-600" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Admin
</span>
</div>
{navItems.map((item) => {
const isActive = currentPath === item.href;
return (
<Button
key={item.href}
variant={isActive ? 'secondary' : 'ghost'}
className="justify-start gap-3 rounded-xl"
asChild
>
<Link to={item.href}>
{item.icon}
{item.label}
</Link>
</Button>
);
})}
<div className="mt-6 border-t border-border/60 pt-4">
<Button variant="ghost" className="w-full justify-start gap-3 rounded-xl" asChild>
<Link to="/">
<Home className="size-4" />
Volver a Playzer
</Link>
</Button>
</div>
</nav>
</aside>
<div className="flex-1 overflow-hidden p-4 sm:p-6 lg:p-8">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,254 @@
import { apiClient } from '@/lib/api-client';
import { useQuery } from '@tanstack/react-query';
import { Link, useLocation } from '@tanstack/react-router';
import {
Building2,
CalendarDays,
ChevronDown,
ChevronRight,
Crosshair,
Globe,
Users,
} from 'lucide-react';
import { Fragment, useState } from 'react';
import { AdminLayout } from './admin-layout';
function countryFlag(countryCode: string): string {
if (countryCode === 'LOCAL' || countryCode === 'XX') return '';
const codePoints = countryCode
.toUpperCase()
.split('')
.map((c) => 0x1f1e6 + c.charCodeAt(0) - 65);
return String.fromCodePoint(...codePoints);
}
export function AdminPage() {
const location = useLocation();
const [expandedCountry, setExpandedCountry] = useState<string | null>(null);
const complexesQuery = useQuery({
queryKey: ['admin-complexes'],
queryFn: () => apiClient.admin.listComplexes(),
});
const usersQuery = useQuery({
queryKey: ['admin-users'],
queryFn: () => apiClient.admin.listUsers(),
});
const geoQuery = useQuery({
queryKey: ['admin-geo-stats'],
queryFn: () => apiClient.admin.getGeoStats(),
});
const stats = {
totalComplexes: complexesQuery.data?.length ?? 0,
totalUsers: usersQuery.data?.length ?? 0,
totalCourts: complexesQuery.data?.reduce((sum, c) => sum + c.courtCount, 0) ?? 0,
totalBookings:
complexesQuery.data?.reduce((sum, c) => sum + Math.round(c.avgBookingsPerDay * 30), 0) ?? 0,
};
const cards = [
{
label: 'Complejos',
value: stats.totalComplexes,
icon: Building2,
color: 'text-blue-600',
bg: 'bg-blue-100 dark:bg-blue-900/30',
},
{
label: 'Usuarios',
value: stats.totalUsers,
icon: Users,
color: 'text-emerald-600',
bg: 'bg-emerald-100 dark:bg-emerald-900/30',
},
{
label: 'Canchas',
value: stats.totalCourts,
icon: Crosshair,
color: 'text-purple-600',
bg: 'bg-purple-100 dark:bg-purple-900/30',
},
{
label: 'Reservas (30d)',
value: stats.totalBookings.toLocaleString(),
icon: CalendarDays,
color: 'text-amber-600',
bg: 'bg-amber-100 dark:bg-amber-900/30',
},
];
return (
<AdminLayout currentPath={location.pathname}>
<div className="mb-8">
<h1 className="text-2xl font-bold tracking-tight">Panel de Administración</h1>
<p className="mt-1 text-sm text-muted-foreground">
Gestioná complejos, planes y usuarios de Playzer.
</p>
</div>
<div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{cards.map((card) => {
const Icon = card.icon;
return (
<div
key={card.label}
className="rounded-2xl border border-border/60 bg-card p-5 shadow-sm"
>
<div className="flex items-center gap-3">
<div className={`rounded-xl p-2.5 ${card.bg}`}>
<Icon className={`size-5 ${card.color}`} />
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{card.label}
</p>
<p className="text-2xl font-bold">{card.value}</p>
</div>
</div>
</div>
);
})}
</div>
<div className="mb-6">
<h2 className="mb-3 text-lg font-semibold">Acceso rápido</h2>
<div className="grid gap-3 sm:grid-cols-3">
<Link
to="/admin/complexes"
className="rounded-2xl border border-border/60 bg-card p-4 shadow-sm transition-colors hover:bg-accent"
>
<Building2 className="mb-2 size-5 text-blue-600" />
<p className="font-medium">Complejos</p>
<p className="text-xs text-muted-foreground">
{stats.totalComplexes} complejos registrados
</p>
</Link>
<Link
to="/admin/plans"
className="rounded-2xl border border-border/60 bg-card p-4 shadow-sm transition-colors hover:bg-accent"
>
<CalendarDays className="mb-2 size-5 text-emerald-600" />
<p className="font-medium">Planes</p>
<p className="text-xs text-muted-foreground">Administrar suscripciones</p>
</Link>
<Link
to="/admin/users"
className="rounded-2xl border border-border/60 bg-card p-4 shadow-sm transition-colors hover:bg-accent"
>
<Users className="mb-2 size-5 text-purple-600" />
<p className="font-medium">Usuarios</p>
<p className="text-xs text-muted-foreground">{stats.totalUsers} usuarios registrados</p>
</Link>
</div>
</div>
<div>
<h2 className="mb-3 text-lg font-semibold">Usuarios por país / ciudad</h2>
{geoQuery.isLoading && <p className="text-sm text-muted-foreground">Cargando...</p>}
{geoQuery.data && geoQuery.data.countries.length === 0 && (
<div className="rounded-2xl border border-border/60 bg-card p-8 text-center shadow-sm">
<Globe className="mx-auto mb-2 size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Aún no hay datos de geolocalización. Aparecerán a medida que los usuarios inicien
sesión.
</p>
</div>
)}
{geoQuery.data && geoQuery.data.countries.length > 0 && (
<div className="overflow-hidden rounded-2xl border border-border/60 bg-card shadow-sm">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/60">
<th className="w-8 px-4 py-3" />
<th className="px-4 py-3 text-left font-medium text-muted-foreground">País</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">
Usuarios
</th>
</tr>
</thead>
<tbody>
{geoQuery.data.countries.map((country) => {
const isExpanded = expandedCountry === country.countryCode;
const flag = countryFlag(country.countryCode);
return (
<Fragment key={country.countryCode}>
<tr
className={`cursor-pointer border-b border-border/40 transition-colors hover:bg-accent/50 ${isExpanded ? 'bg-accent/30' : ''}`}
onClick={() => setExpandedCountry(isExpanded ? null : country.countryCode)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setExpandedCountry(isExpanded ? null : country.countryCode);
}
}}
tabIndex={0}
role="button"
>
<td className="px-4 py-3">
{country.cities.length > 1 ||
country.cities[0]?.city !== country.country ? (
<button type="button" className="text-muted-foreground">
{isExpanded ? (
<ChevronDown className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
</button>
) : (
<span className="inline-block w-4" />
)}
</td>
<td className="px-4 py-3">
<span className="mr-2">{flag}</span>
{country.country}
</td>
<td className="px-4 py-3 text-right font-medium">{country.totalUsers}</td>
</tr>
{isExpanded && (
<tr>
<td colSpan={3} className="bg-accent/20 p-0">
<table className="w-full text-xs">
<tbody>
{country.cities.map((city) => (
<tr key={city.city} className="border-b border-border/20">
<td className="w-8 px-4 py-2" />
<td className="px-4 py-2 text-muted-foreground">{city.city}</td>
<td className="px-4 py-2 text-right font-medium">
{city.userCount}
</td>
</tr>
))}
</tbody>
</table>
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
<div className="border-t border-border/40 px-4 py-2 text-xs text-muted-foreground">
{geoQuery.data.totalUniqueCountries}{' '}
{geoQuery.data.totalUniqueCountries === 1 ? 'país' : 'países'} · solo sesiones activas
con geolocalización resuelta
</div>
</div>
)}
</div>
</AdminLayout>
);
}

View File

@@ -0,0 +1,167 @@
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { apiClient } from '@/lib/api-client';
import { useQuery } from '@tanstack/react-query';
import { useLocation } from '@tanstack/react-router';
import { Building2, ChevronDown, ChevronUp, MapPin, RefreshCw } from 'lucide-react';
import { useState } from 'react';
import { AdminLayout } from './admin-layout';
function PaymentBadge({ status }: { status: string }) {
if (status === 'active') {
return (
<Badge
variant="outline"
className="border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
>
Activo
</Badge>
);
}
if (status === 'no_plan') {
return (
<Badge variant="outline" className="border-muted-foreground/30 text-muted-foreground">
Sin plan
</Badge>
);
}
return (
<Badge
variant="outline"
className="border-red-300 bg-red-50 text-red-700 dark:border-red-700 dark:bg-red-950 dark:text-red-400"
>
Expirado
</Badge>
);
}
export function ComplexesPage() {
const location = useLocation();
const [expandedId, setExpandedId] = useState<string | null>(null);
const { data, isLoading, refetch } = useQuery({
queryKey: ['admin-complexes'],
queryFn: () => apiClient.admin.listComplexes(),
});
return (
<AdminLayout currentPath={location.pathname}>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Complejos</h1>
<p className="mt-1 text-sm text-muted-foreground">
{data?.length ?? 0} complejos registrados en Playzer.
</p>
</div>
<Button variant="outline" size="icon" className="rounded-xl" onClick={() => refetch()}>
<RefreshCw className="size-4" />
</Button>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-20">
<p className="text-sm text-muted-foreground">Cargando complejos...</p>
</div>
) : data?.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20">
<Building2 className="mb-3 size-10 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">No hay complejos registrados.</p>
</div>
) : (
<div className="space-y-3">
{data?.map((complex) => {
const isExpanded = expandedId === complex.id;
return (
<div
key={complex.id}
className="rounded-2xl border border-border/60 bg-card shadow-sm"
>
<button
type="button"
onClick={() => setExpandedId(isExpanded ? null : complex.id)}
className="flex w-full items-center gap-4 px-5 py-4 text-left transition-colors hover:bg-accent/50"
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<div className="rounded-xl bg-emerald-100 p-2.5 dark:bg-emerald-900/30">
<Building2 className="size-5 text-emerald-600" />
</div>
<div className="min-w-0">
<p className="truncate font-medium">{complex.complexName}</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{complex.complexSlug}</span>
{complex.city && (
<>
<span>·</span>
<MapPin className="size-3" />
<span>{complex.city}</span>
</>
)}
</div>
</div>
</div>
<PaymentBadge status={complex.paymentStatus} />
<div className="hidden items-center gap-4 text-xs text-muted-foreground sm:flex">
<span className="text-center">
<span className="block text-sm font-semibold text-foreground">
{complex.courtCount}
</span>
Canchas
</span>
<span className="text-center">
<span className="block text-sm font-semibold text-foreground">
{complex.userCount}
</span>
Usuarios
</span>
<span className="text-center">
<span className="block text-sm font-semibold text-foreground">
{complex.avgBookingsPerDay.toFixed(1)}
</span>
Res/día
</span>
</div>
{isExpanded ? (
<ChevronUp className="size-4 shrink-0 text-muted-foreground" />
) : (
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
)}
</button>
{isExpanded && (
<div className="border-t border-border/40 px-5 py-4">
<div className="grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
<div>
<p className="text-xs text-muted-foreground">Plan</p>
<p className="font-medium">{complex.planName ?? 'Sin plan'}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Canchas</p>
<p className="font-medium">{complex.courtCount}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Usuarios</p>
<p className="font-medium">{complex.userCount}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Prom. reservas/día</p>
<p className="font-medium">{complex.avgBookingsPerDay.toFixed(1)}</p>
</div>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</AdminLayout>
);
}

View File

@@ -0,0 +1,806 @@
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { AdminLayout } from '@/features/admin/admin-layout';
import { ApiClientError, apiClient } from '@/lib/api-client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useLocation } from '@tanstack/react-router';
import { BarChart3, Pencil, Plus, RefreshCw, Trash2, X } from 'lucide-react';
import { useState } from 'react';
type Plan = {
code: string;
name: string;
price: number;
rules: unknown;
lastUpdatedAt: string;
complexCount: number;
};
type PriceOverride = {
country: string;
amount: string;
currency: string;
};
type PlanFormRules = {
limits: {
maxCourts: number;
maxBookingsPerDay: number;
maxActiveUsers: number | '';
maxSports: number | '';
};
features: {
onlinePayments: boolean;
publicBookingPage: boolean;
advancedReports: boolean;
whatsappReminders: boolean;
};
policies: {
maxAdvanceBookingDays: number | '';
minCancellationNoticeHours: number | '';
allowOverbooking: boolean;
};
};
function initOverrides(rules: unknown): PriceOverride[] {
const r = rules as Record<string, unknown> | null | undefined;
const pricing = r?.pricing as Record<string, unknown> | undefined;
const overrides = pricing?.overrides as
| Record<string, { amount: number; currency: string }>
| undefined;
if (!overrides) return [];
return Object.entries(overrides).map(([country, data]) => ({
country,
amount: String(data.amount),
currency: data.currency,
}));
}
function getDefaultRules(): PlanFormRules {
return {
limits: {
maxCourts: 10,
maxBookingsPerDay: 100,
maxActiveUsers: '',
maxSports: '',
},
features: {
onlinePayments: false,
publicBookingPage: false,
advancedReports: false,
whatsappReminders: false,
},
policies: {
maxAdvanceBookingDays: '',
minCancellationNoticeHours: '',
allowOverbooking: false,
},
};
}
function initRules(plan?: Plan): PlanFormRules {
if (!plan) return getDefaultRules();
const r = plan.rules as Record<string, unknown> | null | undefined;
const limits = r?.limits as Record<string, unknown> | undefined;
const features = r?.features as Record<string, unknown> | undefined;
const policies = r?.policies as Record<string, unknown> | undefined;
return {
limits: {
maxCourts: (limits?.maxCourts as number) ?? 10,
maxBookingsPerDay: (limits?.maxBookingsPerDay as number) ?? 100,
maxActiveUsers: (limits?.maxActiveUsers as number | undefined) ?? '',
maxSports: (limits?.maxSports as number | undefined) ?? '',
},
features: {
onlinePayments: (features?.onlinePayments as boolean) ?? false,
publicBookingPage: (features?.publicBookingPage as boolean) ?? false,
advancedReports: (features?.advancedReports as boolean) ?? false,
whatsappReminders: (features?.whatsappReminders as boolean) ?? false,
},
policies: {
maxAdvanceBookingDays: (policies?.maxAdvanceBookingDays as number | undefined) ?? '',
minCancellationNoticeHours:
(policies?.minCancellationNoticeHours as number | undefined) ?? '',
allowOverbooking: (policies?.allowOverbooking as boolean) ?? false,
},
};
}
function buildRulesData(formRules: PlanFormRules, priceOverrides: PriceOverride[]): unknown {
const validOverrides = priceOverrides.filter((o) => o.country.trim());
const { limits, features, policies } = formRules;
const rules: Record<string, unknown> = {
version: validOverrides.length > 0 ? 'v2' : 'v1',
limits: {
maxCourts: limits.maxCourts,
maxBookingsPerDay: limits.maxBookingsPerDay,
...(limits.maxActiveUsers !== '' && { maxActiveUsers: limits.maxActiveUsers }),
...(limits.maxSports !== '' && { maxSports: limits.maxSports }),
},
features,
policies: {
...(policies.maxAdvanceBookingDays !== '' && {
maxAdvanceBookingDays: policies.maxAdvanceBookingDays,
}),
...(policies.minCancellationNoticeHours !== '' && {
minCancellationNoticeHours: policies.minCancellationNoticeHours,
}),
allowOverbooking: policies.allowOverbooking,
},
};
if (validOverrides.length > 0) {
const overrides: Record<string, { amount: number; currency: string }> = {};
for (const o of validOverrides) {
overrides[o.country.trim().toUpperCase()] = {
amount: Number.parseFloat(o.amount),
currency: o.currency,
};
}
rules.pricing = { overrides };
}
return rules;
}
const TABS = [
{ id: 'basico', label: 'Básico' },
{ id: 'limites', label: 'Límites' },
{ id: 'features', label: 'Funcionalidades' },
{ id: 'policies', label: 'Políticas' },
{ id: 'pricing', label: 'Precios x país' },
] as const;
function Toggle({
checked,
onChange,
label,
}: {
checked: boolean;
onChange: (v: boolean) => void;
label: string;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-6 w-10 shrink-0 items-center rounded-full transition-colors ${
checked ? 'bg-primary' : 'bg-input'
}`}
>
<span
className={`inline-block size-4 rounded-full bg-white shadow-sm transition-transform ${
checked ? 'translate-x-5' : 'translate-x-1'
}`}
/>
<span className="sr-only">{label}</span>
</button>
);
}
function PlanFormDialog({
mode,
plan,
onClose,
}: {
mode: 'create' | 'edit';
plan?: Plan;
onClose: () => void;
}) {
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState('basico');
const [code, setCode] = useState(plan?.code ?? '');
const [name, setName] = useState(plan?.name ?? '');
const [price, setPrice] = useState(String(plan?.price ?? ''));
const [formRules, setFormRules] = useState<PlanFormRules>(() => initRules(plan));
const [priceOverrides, setPriceOverrides] = useState<PriceOverride[]>(() =>
mode === 'edit' && plan ? initOverrides(plan.rules) : []
);
const createMutation = useMutation({
mutationFn: (data: { code: string; name: string; price: number; rules: object }) =>
apiClient.admin.createPlan(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-plans'] });
onClose();
},
});
const updateMutation = useMutation({
mutationFn: (data: { name?: string; price?: number; rules?: unknown }) =>
apiClient.admin.updatePlan(plan!.code, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-plans'] });
onClose();
},
});
const isPending = createMutation.isPending || updateMutation.isPending;
const error = createMutation.error ?? updateMutation.error;
function updateRule<S extends keyof PlanFormRules>(
section: S,
field: keyof PlanFormRules[S],
value: PlanFormRules[S][keyof PlanFormRules[S]]
) {
setFormRules((prev) => ({
...prev,
[section]: { ...prev[section], [field]: value },
}));
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const rules = buildRulesData(formRules, priceOverrides);
if (mode === 'create') {
createMutation.mutate({
code,
name,
price: Number.parseFloat(price),
rules: rules as object,
});
} else {
updateMutation.mutate({
name,
price: Number.parseFloat(price),
rules,
});
}
};
const addOverride = () => {
setPriceOverrides((prev) => [...prev, { country: '', amount: '', currency: 'ARS' }]);
};
const removeOverride = (index: number) => {
setPriceOverrides((prev) => prev.filter((_, i) => i !== index));
};
const updateOverride = (index: number, field: keyof PriceOverride, value: string) => {
setPriceOverrides((prev) => prev.map((o, i) => (i === index ? { ...o, [field]: value } : o)));
};
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="rounded-2xl sm:max-w-xl">
<DialogHeader>
<DialogTitle>{mode === 'create' ? 'Crear plan' : `Editar: ${plan!.name}`}</DialogTitle>
<DialogDescription>
{mode === 'create'
? 'Agregá un nuevo plan de suscripción.'
: 'Actualizá los datos del plan.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="-mx-6 flex gap-0 border-b border-border/60 px-6">
{TABS.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={`-mb-px border-b-2 px-3 pb-2 pt-1 text-sm font-medium transition-colors ${
activeTab === tab.id
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
{tab.label}
</button>
))}
</div>
{activeTab === 'basico' && (
<div className="space-y-4">
{mode === 'create' && (
<div className="space-y-2">
<Label htmlFor="code">Código</Label>
<Input
id="code"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="pro"
maxLength={10}
required
/>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name">Nombre</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Pro"
maxLength={30}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="price">
Precio USD
<span className="text-xs text-muted-foreground">(fallback global)</span>
</Label>
<Input
id="price"
type="number"
step="0.01"
min="0"
value={price}
onChange={(e) => setPrice(e.target.value)}
placeholder="29.99"
required
/>
</div>
</div>
</div>
)}
{activeTab === 'limites' && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="maxCourts">Máx. canchas</Label>
<Input
id="maxCourts"
type="number"
min="1"
value={formRules.limits.maxCourts}
onChange={(e) => updateRule('limits', 'maxCourts', Number(e.target.value))}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxBookingsPerDay">Máx. turnos/día</Label>
<Input
id="maxBookingsPerDay"
type="number"
min="1"
value={formRules.limits.maxBookingsPerDay}
onChange={(e) =>
updateRule('limits', 'maxBookingsPerDay', Number(e.target.value))
}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxActiveUsers">
Máx. usuarios activos
<span className="text-xs text-muted-foreground">(opcional)</span>
</Label>
<Input
id="maxActiveUsers"
type="number"
min="1"
value={formRules.limits.maxActiveUsers}
onChange={(e) =>
updateRule(
'limits',
'maxActiveUsers',
e.target.value === '' ? '' : Number(e.target.value)
)
}
placeholder="Ilimitado"
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxSports">
Máx. deportes
<span className="text-xs text-muted-foreground">(opcional)</span>
</Label>
<Input
id="maxSports"
type="number"
min="1"
value={formRules.limits.maxSports}
onChange={(e) =>
updateRule(
'limits',
'maxSports',
e.target.value === '' ? '' : Number(e.target.value)
)
}
placeholder="Ilimitado"
/>
</div>
</div>
</div>
)}
{activeTab === 'features' && (
<div className="space-y-4">
{(
[
['onlinePayments', 'Pagos online'],
['publicBookingPage', 'Página de reservas pública'],
['advancedReports', 'Reportes avanzados'],
['whatsappReminders', 'Recordatorios por WhatsApp'],
] as const
).map(([key, label]) => (
<div key={key} className="flex items-center justify-between">
<Label className="cursor-pointer">{label}</Label>
<Toggle
checked={formRules.features[key]}
onChange={(v) => updateRule('features', key, v)}
label={label}
/>
</div>
))}
</div>
)}
{activeTab === 'policies' && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="maxAdvanceBookingDays">
Anticipación máx. (días)
<span className="text-xs text-muted-foreground">(opcional)</span>
</Label>
<Input
id="maxAdvanceBookingDays"
type="number"
min="1"
value={formRules.policies.maxAdvanceBookingDays}
onChange={(e) =>
updateRule(
'policies',
'maxAdvanceBookingDays',
e.target.value === '' ? '' : Number(e.target.value)
)
}
placeholder="Sin límite"
/>
</div>
<div className="space-y-2">
<Label htmlFor="minCancellationNoticeHours">
Cancelación mín. (horas)
<span className="text-xs text-muted-foreground">(opcional)</span>
</Label>
<Input
id="minCancellationNoticeHours"
type="number"
min="0"
value={formRules.policies.minCancellationNoticeHours}
onChange={(e) =>
updateRule(
'policies',
'minCancellationNoticeHours',
e.target.value === '' ? '' : Number(e.target.value)
)
}
placeholder="Sin restricción"
/>
</div>
</div>
<div className="flex items-center justify-between">
<Label className="cursor-pointer" htmlFor="allowOverbooking">
Permitir sobreventa
</Label>
<Toggle
checked={formRules.policies.allowOverbooking}
onChange={(v) => updateRule('policies', 'allowOverbooking', v)}
label="Permitir sobreventa"
/>
</div>
</div>
)}
{activeTab === 'pricing' && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>Precios por país</Label>
<Button
type="button"
variant="outline"
size="sm"
className="rounded-xl"
onClick={addOverride}
>
<Plus className="mr-1 size-3" />
Agregar
</Button>
</div>
{priceOverrides.length === 0 && (
<p className="text-sm text-muted-foreground">
Sin precios personalizados. Se usa el precio global USD.
</p>
)}
{priceOverrides.map((override, index) => (
<div key={index} className="flex items-end gap-2">
<div className="flex-1 space-y-1">
<Label className="text-xs">País</Label>
<Input
value={override.country}
onChange={(e) => updateOverride(index, 'country', e.target.value)}
placeholder="AR"
maxLength={2}
className="uppercase"
/>
</div>
<div className="flex-1 space-y-1">
<Label className="text-xs">Monto</Label>
<Input
type="number"
step="0.01"
min="0"
value={override.amount}
onChange={(e) => updateOverride(index, 'amount', e.target.value)}
placeholder="14999"
/>
</div>
<div className="w-24 space-y-1">
<Label className="text-xs">Moneda</Label>
<Select
value={override.currency}
onValueChange={(v) => updateOverride(index, 'currency', v)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="USD">USD</SelectItem>
<SelectItem value="ARS">ARS</SelectItem>
</SelectContent>
</Select>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="size-9 shrink-0 rounded-lg text-destructive hover:bg-destructive/10"
onClick={() => removeOverride(index)}
>
<X className="size-3.5" />
</Button>
</div>
))}
</div>
)}
{error && (
<p className="text-sm text-destructive">
{error instanceof ApiClientError
? String(error.details ?? error.message)
: error.message}
</p>
)}
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" className="rounded-xl" onClick={onClose}>
Cancelar
</Button>
<Button type="submit" className="rounded-xl" disabled={isPending}>
{isPending ? 'Guardando...' : mode === 'create' ? 'Crear' : 'Guardar'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
function DeleteConfirmDialog({
plan,
onClose,
}: {
plan: Plan;
onClose: () => void;
}) {
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: () => apiClient.admin.deletePlan(plan.code),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-plans'] });
onClose();
},
});
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="rounded-2xl sm:max-w-md">
<DialogHeader>
<DialogTitle>Eliminar plan</DialogTitle>
<DialogDescription>
¿Estás seguro de eliminar el plan <strong>{plan.name}</strong>?
{plan.complexCount > 0 && (
<span className="mt-2 block text-destructive">
No se puede eliminar porque tiene {plan.complexCount} complejo
{plan.complexCount > 1 ? 's' : ''} asignado{plan.complexCount > 1 ? 's' : ''}.
</span>
)}
</DialogDescription>
</DialogHeader>
{deleteMutation.error && (
<p className="text-sm text-destructive">
{deleteMutation.error instanceof ApiClientError
? String(deleteMutation.error.details ?? deleteMutation.error.message)
: deleteMutation.error.message}
</p>
)}
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" className="rounded-xl" onClick={onClose}>
Cancelar
</Button>
<Button
type="button"
variant="destructive"
className="rounded-xl"
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending || plan.complexCount > 0}
>
{deleteMutation.isPending ? 'Eliminando...' : 'Eliminar'}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
export function PlansPage() {
const location = useLocation();
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [editingPlan, setEditingPlan] = useState<Plan | null>(null);
const [deletingPlan, setDeletingPlan] = useState<Plan | null>(null);
const { data, isLoading } = useQuery({
queryKey: ['admin-plans'],
queryFn: () => apiClient.admin.listPlans(),
});
return (
<AdminLayout currentPath={location.pathname}>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Planes</h1>
<p className="mt-1 text-sm text-muted-foreground">
{data?.length ?? 0} planes de suscripción.
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
className="rounded-xl"
onClick={() => queryClient.invalidateQueries({ queryKey: ['admin-plans'] })}
>
<RefreshCw className="size-4" />
</Button>
<Button className="rounded-xl" onClick={() => setCreateOpen(true)}>
<Plus className="mr-2 size-4" />
Nuevo plan
</Button>
</div>
</div>
{createOpen && <PlanFormDialog mode="create" onClose={() => setCreateOpen(false)} />}
{editingPlan && (
<PlanFormDialog mode="edit" plan={editingPlan} onClose={() => setEditingPlan(null)} />
)}
{deletingPlan && (
<DeleteConfirmDialog plan={deletingPlan} onClose={() => setDeletingPlan(null)} />
)}
{isLoading ? (
<div className="flex items-center justify-center py-20">
<p className="text-sm text-muted-foreground">Cargando planes...</p>
</div>
) : data?.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20">
<BarChart3 className="mb-3 size-10 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">No hay planes creados.</p>
</div>
) : (
<div className="overflow-hidden rounded-2xl border border-border/60">
<table className="w-full">
<thead>
<tr className="border-b border-border/60 bg-muted/50">
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Código
</th>
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Nombre
</th>
<th className="px-5 py-3 text-right text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Precio
</th>
<th className="hidden px-5 py-3 text-center text-xs font-semibold uppercase tracking-wider text-muted-foreground sm:table-cell">
Complejos
</th>
<th className="px-5 py-3 text-right text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Acciones
</th>
</tr>
</thead>
<tbody className="divide-y divide-border/40">
{data?.map((plan) => {
const rules = plan.rules as Record<string, unknown> | null;
const pricing = rules?.pricing as Record<string, unknown> | undefined;
const overrides = pricing?.overrides as Record<string, unknown> | undefined;
const overrideCountries = overrides ? Object.keys(overrides) : [];
return (
<tr key={plan.code} className="transition-colors hover:bg-accent/30">
<td className="px-5 py-4">
<Badge variant="secondary" className="font-mono text-xs">
{plan.code}
</Badge>
</td>
<td className="px-5 py-4 font-medium">{plan.name}</td>
<td className="px-5 py-4 text-right">
<div className="flex flex-col items-end gap-1">
<span className="font-medium">${Number(plan.price).toFixed(2)} USD</span>
{overrideCountries.length > 0 && (
<div className="flex flex-wrap gap-1">
{overrideCountries.map((country) => (
<Badge key={country} variant="secondary" className="text-xs">
{country}
</Badge>
))}
</div>
)}
</div>
</td>
<td className="hidden px-5 py-4 text-center text-sm text-muted-foreground sm:table-cell">
{plan.complexCount}
</td>
<td className="px-5 py-4 text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="size-8 rounded-lg"
onClick={() => setEditingPlan(plan)}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-8 rounded-lg text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => setDeletingPlan(plan)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</AdminLayout>
);
}

View File

@@ -0,0 +1,467 @@
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { AdminLayout } from '@/features/admin/admin-layout';
import { ApiClientError, apiClient } from '@/lib/api-client';
import type { AdminUser, AdminUserSession } from '@repo/api-contract';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useLocation } from '@tanstack/react-router';
import {
Ban,
CheckCircle2,
Globe,
LogIn,
MapPin,
Monitor,
RefreshCw,
Search,
ShieldAlert,
Users,
} from 'lucide-react';
import { useEffect, useState } from 'react';
function SessionsDialog({
user,
onClose,
}: {
user: AdminUser;
onClose: () => void;
}) {
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['admin-user-sessions', user.id],
queryFn: () => apiClient.admin.getUserSessions(user.id),
});
const revokeMutation = useMutation({
mutationFn: () => apiClient.admin.revokeAllSessions(user.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-user-sessions', user.id] });
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
},
});
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="rounded-2xl sm:max-w-lg">
<DialogHeader>
<DialogTitle>Sesiones de {user.name}</DialogTitle>
<DialogDescription>{data?.length ?? 0} sesiones activas</DialogDescription>
</DialogHeader>
<div className="max-h-80 space-y-3 overflow-y-auto">
{isLoading ? (
<p className="py-8 text-center text-sm text-muted-foreground">Cargando sesiones...</p>
) : data?.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">Sin sesiones activas.</p>
) : (
data?.map((session) => <SessionRow key={session.id} session={session} />)
)}
</div>
<div className="flex items-center justify-between gap-3">
{revokeMutation.error && (
<p className="flex-1 text-sm text-destructive">
{revokeMutation.error instanceof ApiClientError
? String(revokeMutation.error.details ?? revokeMutation.error.message)
: revokeMutation.error.message}
</p>
)}
<div className="flex gap-3">
<Button variant="outline" className="rounded-xl" onClick={onClose}>
Cerrar
</Button>
{data && data.length > 0 && (
<Button
variant="destructive"
className="rounded-xl"
onClick={() => revokeMutation.mutate()}
disabled={revokeMutation.isPending}
>
{revokeMutation.isPending ? 'Cerrando...' : 'Cerrar todas las sesiones'}
</Button>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
function SessionRow({ session }: { session: AdminUserSession }) {
const isExpired = new Date(session.expiresAt) < new Date();
const userAgent = session.userAgent ?? 'Desconocido';
const ip = session.ipAddress ?? '—';
const location =
session.city || session.country
? [session.city, session.country].filter(Boolean).join(', ')
: null;
return (
<div className="rounded-xl border border-border/60 p-4">
<div className="mb-2 flex items-center gap-2">
<Monitor className="size-4 text-muted-foreground" />
<span className="flex-1 truncate text-sm font-medium">{userAgent}</span>
{isExpired ? (
<Badge
variant="outline"
className="border-gray-300 bg-gray-50 text-gray-600 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-400"
>
Expirada
</Badge>
) : (
<Badge
variant="outline"
className="border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
>
Activa
</Badge>
)}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Globe className="size-3" />
{ip}
</span>
{location && (
<span className="flex items-center gap-1">
<MapPin className="size-3" />
{location}
</span>
)}
<span className="flex items-center gap-1">
<LogIn className="size-3" />
{new Date(session.createdAt).toLocaleString()}
</span>
</div>
</div>
);
}
function BlockDialog({
user,
onClose,
}: {
user: AdminUser;
onClose: () => void;
}) {
const queryClient = useQueryClient();
const [reason, setReason] = useState('');
const mutation = useMutation({
mutationFn: (banReason?: string) => apiClient.admin.blockUser(user.id, { banReason }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
onClose();
},
});
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="rounded-2xl sm:max-w-md">
<DialogHeader>
<DialogTitle>Bloquear usuario</DialogTitle>
<DialogDescription>
¿Estás seguro de bloquear a <strong>{user.name}</strong> ({user.email})? No podrá
iniciar sesión en Playzer.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="reason">Motivo (opcional)</Label>
<Input
id="reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Violación de términos..."
maxLength={500}
/>
</div>
{mutation.error && (
<p className="text-sm text-destructive">
{mutation.error instanceof ApiClientError
? String(mutation.error.details ?? mutation.error.message)
: mutation.error.message}
</p>
)}
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" className="rounded-xl" onClick={onClose}>
Cancelar
</Button>
<Button
type="button"
variant="destructive"
className="rounded-xl"
onClick={() => mutation.mutate(reason || undefined)}
disabled={mutation.isPending}
>
{mutation.isPending ? 'Bloqueando...' : 'Bloquear'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
function UnblockDialog({
user,
onClose,
}: {
user: AdminUser;
onClose: () => void;
}) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: () => apiClient.admin.unblockUser(user.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
onClose();
},
});
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="rounded-2xl sm:max-w-md">
<DialogHeader>
<DialogTitle>Desbloquear usuario</DialogTitle>
<DialogDescription>
¿Estás seguro de desbloquear a <strong>{user.name}</strong>? Podrá volver a iniciar
sesión en Playzer.
</DialogDescription>
</DialogHeader>
{mutation.error && (
<p className="text-sm text-destructive">
{mutation.error instanceof ApiClientError
? String(mutation.error.details ?? mutation.error.message)
: mutation.error.message}
</p>
)}
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" className="rounded-xl" onClick={onClose}>
Cancelar
</Button>
<Button
type="button"
variant="default"
className="rounded-xl"
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
>
{mutation.isPending ? 'Desbloqueando...' : 'Desbloquear'}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
function RoleBadge({ role }: { role: string }) {
if (role === 'super_admin') {
return (
<Badge
variant="outline"
className="border-purple-300 bg-purple-50 text-purple-700 dark:border-purple-700 dark:bg-purple-950 dark:text-purple-400"
>
<ShieldAlert className="mr-1 size-3" />
Super Admin
</Badge>
);
}
return (
<Badge variant="secondary" className="text-xs">
{role}
</Badge>
);
}
export function UsersPage() {
const location = useLocation();
const queryClient = useQueryClient();
const [blockingUser, setBlockingUser] = useState<AdminUser | null>(null);
const [unblockingUser, setUnblockingUser] = useState<AdminUser | null>(null);
const [sessionsUser, setSessionsUser] = useState<AdminUser | null>(null);
const [searchInput, setSearchInput] = useState('');
const [search, setSearch] = useState('');
useEffect(() => {
const timer = setTimeout(() => setSearch(searchInput), 300);
return () => clearTimeout(timer);
}, [searchInput]);
const { data, isLoading } = useQuery({
queryKey: ['admin-users', search],
queryFn: () => apiClient.admin.listUsers(search || undefined),
});
return (
<AdminLayout currentPath={location.pathname}>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Usuarios</h1>
<p className="mt-1 text-sm text-muted-foreground">
{data?.length ?? 0} usuarios registrados en Playzer.
</p>
</div>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Buscar por nombre o email..."
className="h-9 w-56 rounded-xl pl-9 text-sm"
/>
</div>
<Button
variant="outline"
size="icon"
className="rounded-xl"
onClick={() => queryClient.invalidateQueries({ queryKey: ['admin-users'] })}
>
<RefreshCw className="size-4" />
</Button>
</div>
</div>
{blockingUser && <BlockDialog user={blockingUser} onClose={() => setBlockingUser(null)} />}
{unblockingUser && (
<UnblockDialog user={unblockingUser} onClose={() => setUnblockingUser(null)} />
)}
{sessionsUser && <SessionsDialog user={sessionsUser} onClose={() => setSessionsUser(null)} />}
{isLoading ? (
<div className="flex items-center justify-center py-20">
<p className="text-sm text-muted-foreground">Cargando usuarios...</p>
</div>
) : data?.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20">
<Users className="mb-3 size-10 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">No hay usuarios registrados.</p>
</div>
) : (
<div className="overflow-hidden rounded-2xl border border-border/60">
<table className="w-full">
<thead>
<tr className="border-b border-border/60 bg-muted/50">
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Usuario
</th>
<th className="hidden px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground md:table-cell">
Rol
</th>
<th className="hidden px-5 py-3 text-center text-xs font-semibold uppercase tracking-wider text-muted-foreground sm:table-cell">
Complejos
</th>
<th className="hidden px-5 py-3 text-center text-xs font-semibold uppercase tracking-wider text-muted-foreground lg:table-cell">
Sesiones
</th>
<th className="hidden px-5 py-3 text-center text-xs font-semibold uppercase tracking-wider text-muted-foreground lg:table-cell">
Registro
</th>
<th className="px-5 py-3 text-center text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Estado
</th>
<th className="px-5 py-3 text-right text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Acción
</th>
</tr>
</thead>
<tbody className="divide-y divide-border/40">
{data?.map((user) => (
<tr key={user.id} className="transition-colors hover:bg-accent/30">
<td className="px-5 py-4">
<div className="min-w-0">
<p className="truncate font-medium">{user.name}</p>
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
</div>
</td>
<td className="hidden px-5 py-4 md:table-cell">
<RoleBadge role={user.role} />
</td>
<td className="hidden px-5 py-4 text-center text-sm text-muted-foreground sm:table-cell">
{user.complexCount}
</td>
<td className="hidden px-5 py-4 text-center lg:table-cell">
<button
type="button"
className="cursor-pointer text-sm font-medium text-emerald-600 underline-offset-2 hover:underline"
onClick={() => setSessionsUser(user)}
>
{user.activeSessions}
</button>
</td>
<td className="hidden px-5 py-4 text-sm text-muted-foreground lg:table-cell">
{new Date(user.createdAt).toLocaleDateString()}
</td>
<td className="px-5 py-4 text-center">
{user.banned ? (
<Badge
variant="outline"
className="border-red-300 bg-red-50 text-red-700 dark:border-red-700 dark:bg-red-950 dark:text-red-400"
>
Bloqueado
</Badge>
) : (
<Badge
variant="outline"
className="border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
>
Activo
</Badge>
)}
</td>
<td className="px-5 py-4 text-right">
<div className="flex items-center justify-end gap-1">
{user.banned ? (
<Button
variant="ghost"
size="sm"
className="rounded-xl text-xs"
disabled={user.role === 'super_admin'}
onClick={() => setUnblockingUser(user)}
>
<CheckCircle2 className="mr-1.5 size-3.5 text-emerald-600" />
Desbloquear
</Button>
) : (
<Button
variant="ghost"
size="sm"
className="rounded-xl text-xs text-destructive hover:bg-destructive/10 hover:text-destructive disabled:opacity-40"
disabled={user.role === 'super_admin'}
onClick={() => setBlockingUser(user)}
>
<Ban className="mr-1.5 size-3.5" />
Bloquear
</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</AdminLayout>
);
}

View File

@@ -41,6 +41,7 @@ interface CreateManualBookingPayload {
startTime: string; startTime: string;
customerName: string; customerName: string;
customerPhone: string; customerPhone: string;
customerEmail: string;
} }
interface BookingContextValue { interface BookingContextValue {
@@ -146,6 +147,9 @@ function buildSegmentsForCourt(
const segments: BookingTimelineSegment[] = []; const segments: BookingTimelineSegment[] = [];
const slotDuration = court.slotDurationMinutes; const slotDuration = court.slotDurationMinutes;
const nowMinutes = timeToMinutes(getNowTime());
const isToday = isTodayIso(selectedDate);
const availabilityRanges = court.availability const availabilityRanges = court.availability
.filter((range) => range.dayOfWeek === dayOfWeek) .filter((range) => range.dayOfWeek === dayOfWeek)
.map((range) => ({ .map((range) => ({
@@ -180,6 +184,9 @@ function buildSegmentsForCourt(
slotStart += slotDuration slotStart += slotDuration
) { ) {
const slotEnd = slotStart + slotDuration; const slotEnd = slotStart + slotDuration;
if (isToday && slotEnd <= nowMinutes) continue;
const isBooked = courtBookings.some((booking) => const isBooked = courtBookings.some((booking) =>
overlapsBooking(slotStart, slotEnd, booking) overlapsBooking(slotStart, slotEnd, booking)
); );
@@ -209,20 +216,14 @@ function buildSegmentsForCourt(
function getSummary(schedules: BookingCourtSchedule[]): BookingSummary { function getSummary(schedules: BookingCourtSchedule[]): BookingSummary {
const freeSlots = schedules.reduce((total, schedule) => total + schedule.metrics.free, 0); const freeSlots = schedules.reduce((total, schedule) => total + schedule.metrics.free, 0);
const reservedSlots = schedules.reduce((total, schedule) => total + schedule.metrics.reserved, 0); const reservedSlots = schedules.reduce((total, schedule) => total + schedule.metrics.reserved, 0);
const maintenanceSlots = schedules.reduce( const totalSegments = freeSlots + reservedSlots || 1;
(total, schedule) => total + schedule.metrics.maintenance,
0
);
const totalSegments = freeSlots + reservedSlots + maintenanceSlots || 1;
return { return {
totalReservations: reservedSlots + maintenanceSlots + freeSlots, totalReservations: freeSlots + reservedSlots,
freeSlots, freeSlots,
reservedSlots, reservedSlots,
maintenanceSlots,
freePercent: Math.round((freeSlots / totalSegments) * 100), freePercent: Math.round((freeSlots / totalSegments) * 100),
reservedPercent: Math.round((reservedSlots / totalSegments) * 100), reservedPercent: Math.round((reservedSlots / totalSegments) * 100),
maintenancePercent: Math.round((maintenanceSlots / totalSegments) * 100),
}; };
} }
@@ -274,7 +275,6 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
const metrics = { const metrics = {
free: segments.filter((segment) => segment.status === 'free').length, free: segments.filter((segment) => segment.status === 'free').length,
reserved: segments.filter((segment) => segment.status === 'reserved').length, reserved: segments.filter((segment) => segment.status === 'reserved').length,
maintenance: segments.filter((segment) => segment.status === 'maintenance').length,
}; };
return { court, segments, metrics }; return { court, segments, metrics };
@@ -315,6 +315,7 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
startTime: payload.startTime, startTime: payload.startTime,
customerName: payload.customerName, customerName: payload.customerName,
customerPhone: payload.customerPhone, customerPhone: payload.customerPhone,
customerEmail: payload.customerEmail,
}), }),
onSuccess: async () => { onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['admin-bookings', complex.id] }); await queryClient.invalidateQueries({ queryKey: ['admin-bookings', complex.id] });

View File

@@ -3,10 +3,11 @@ import { authClient } from '@/lib/api-client';
import { useAuth } from '@/lib/auth'; import { useAuth } from '@/lib/auth';
import type { ComplexWithRole } from '@repo/api-contract'; import type { ComplexWithRole } from '@repo/api-contract';
import { useState } from 'react'; import { useState } from 'react';
import { BookingProvider } from './booking-provider'; import { BookingProvider, useBooking } from './booking-provider';
import { BookingCreateDialog } from './components/booking-create-dialog'; import { BookingCreateDialog } from './components/booking-create-dialog';
import { BookingDaySummary, BookingQuickActions } from './components/booking-day-summary'; import { BookingDaySummary, BookingQuickActions } from './components/booking-day-summary';
import { BookingHeader } from './components/booking-header'; import { BookingHeader } from './components/booking-header';
import { BookingListView } from './components/booking-list-view';
import { BookingMobile } from './components/booking-mobile'; import { BookingMobile } from './components/booking-mobile';
import { BookingTimeline } from './components/booking-timeline'; import { BookingTimeline } from './components/booking-timeline';
import { BookingToolbar } from './components/booking-toolbar'; import { BookingToolbar } from './components/booking-toolbar';
@@ -17,7 +18,6 @@ interface BookingProps {
} }
export function Booking({ complex }: BookingProps) { export function Booking({ complex }: BookingProps) {
const isMobile = useIsMobile();
const { user } = useAuth(); const { user } = useAuth();
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [emailSent, setEmailSent] = useState(false); const [emailSent, setEmailSent] = useState(false);
@@ -56,21 +56,34 @@ export function Booking({ complex }: BookingProps) {
</button> </button>
</div> </div>
)} )}
{isMobile ? ( <BookingInner />
<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 /> <BookingCreateDialog />
<BookingToolsDialog /> <BookingToolsDialog />
</BookingProvider> </BookingProvider>
); );
} }
function BookingInner() {
const { viewMode } = useBooking();
const isMobile = useIsMobile();
if (viewMode === 'list') {
return <BookingListView />;
}
if (isMobile) {
return <BookingMobile />;
}
return (
<div className="flex flex-col gap-5">
<BookingHeader />
<BookingToolbar />
<BookingTimeline />
<div className="grid gap-5 xl:grid-cols-4">
<BookingDaySummary />
<BookingQuickActions />
</div>
</div>
);
}

View File

@@ -1,8 +1,8 @@
import type { AdminBooking, Court } from '@repo/api-contract'; import type { AdminBooking, Court } from '@repo/api-contract';
export type BookingViewMode = 'panel' | 'status'; export type BookingViewMode = 'panel' | 'status' | 'list';
export type BookingStatusFilter = 'all' | 'free' | 'reserved' | 'maintenance'; export type BookingStatusFilter = 'all' | 'free' | 'reserved';
export type BookingSegmentStatus = 'free' | 'reserved' | 'maintenance'; export type BookingSegmentStatus = 'free' | 'reserved';
export interface BookingTimeRange { export interface BookingTimeRange {
start: string; start: string;
@@ -13,16 +13,13 @@ export interface BookingSummary {
totalReservations: number; totalReservations: number;
freeSlots: number; freeSlots: number;
reservedSlots: number; reservedSlots: number;
maintenanceSlots: number;
freePercent: number; freePercent: number;
reservedPercent: number; reservedPercent: number;
maintenancePercent: number;
} }
export interface BookingCourtMetrics { export interface BookingCourtMetrics {
free: number; free: number;
reserved: number; reserved: number;
maintenance: number;
} }
export interface BookingTimelineSegment { export interface BookingTimelineSegment {

View File

@@ -26,6 +26,8 @@ import { useBooking } from '../booking-provider';
import { import {
fromIsoDateLocal, fromIsoDateLocal,
getDayOfWeek, getDayOfWeek,
getNowTime,
isTodayIso,
minutesToTime, minutesToTime,
timeToMinutes, timeToMinutes,
toIsoDateLocal, toIsoDateLocal,
@@ -42,6 +44,7 @@ const bookingFormSchema = z.object({
.trim() .trim()
.min(6, 'Ingresa un telefono válido.') .min(6, 'Ingresa un telefono válido.')
.max(30, 'El telefono no puede superar los 30 caracteres.'), .max(30, 'El telefono no puede superar los 30 caracteres.'),
customerEmail: z.string().email('Ingresá un email válido.').or(z.literal('')),
}); });
type BookingForm = z.infer<typeof bookingFormSchema>; type BookingForm = z.infer<typeof bookingFormSchema>;
@@ -76,6 +79,7 @@ export function BookingCreateDialog() {
defaultValues: { defaultValues: {
customerName: '', customerName: '',
customerPhone: '', customerPhone: '',
customerEmail: '',
}, },
}); });
@@ -126,6 +130,9 @@ export function BookingCreateDialog() {
minute += selectedCourt.slotDurationMinutes minute += selectedCourt.slotDurationMinutes
) { ) {
const slotEnd = minute + selectedCourt.slotDurationMinutes; const slotEnd = minute + selectedCourt.slotDurationMinutes;
if (isTodayIso(date) && minute <= timeToMinutes(getNowTime())) continue;
const isBooked = courtBookings.some((booking) => { const isBooked = courtBookings.some((booking) => {
const bookingStart = timeToMinutes(booking.startTime); const bookingStart = timeToMinutes(booking.startTime);
const bookingEnd = timeToMinutes(booking.endTime); const bookingEnd = timeToMinutes(booking.endTime);
@@ -154,6 +161,7 @@ export function BookingCreateDialog() {
startTime, startTime,
customerName: values.customerName, customerName: values.customerName,
customerPhone: values.customerPhone, customerPhone: values.customerPhone,
customerEmail: values.customerEmail,
}); });
}; };
@@ -163,17 +171,21 @@ export function BookingCreateDialog() {
onOpenChange={(open) => !open && closeCreateBooking()} onOpenChange={(open) => !open && closeCreateBooking()}
> >
<ResponsiveDialogContent <ResponsiveDialogContent
className="data-[variant=dialog]:max-w-lg" className="group/create-booking data-[variant=dialog]:max-w-lg data-[variant=drawer]:!h-[92svh] data-[variant=drawer]:!max-h-[92svh] data-[variant=drawer]:overflow-hidden data-[variant=drawer]:px-0 data-[variant=drawer]:pb-0"
onOpenAutoFocus={(event) => event.preventDefault()} onOpenAutoFocus={(event) => event.preventDefault()}
> >
<ResponsiveDialogHeader> <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> <ResponsiveDialogTitle>Nueva reserva</ResponsiveDialogTitle>
<ResponsiveDialogDescription> <ResponsiveDialogDescription>
Crea un turno para atención telefónica o mostrador. Crea un turno para atención telefónica o mostrador.
</ResponsiveDialogDescription> </ResponsiveDialogDescription>
</ResponsiveDialogHeader> </ResponsiveDialogHeader>
<form id="booking-create-form" className="grid gap-4" onSubmit={handleSubmit(onSubmit)}> <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"> <div className="grid gap-3 md:grid-cols-2">
<Field> <Field>
<FieldLabel>Fecha</FieldLabel> <FieldLabel>Fecha</FieldLabel>
@@ -271,10 +283,27 @@ export function BookingCreateDialog() {
<FieldError errors={[errors.customerPhone]} /> <FieldError errors={[errors.customerPhone]} />
</Field> </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>} {createBookingError && <p className="text-sm text-destructive">{createBookingError}</p>}
</form> </form>
<ResponsiveDialogFooter> <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> <ResponsiveDialogClose asChild>
<Button variant="outline">Cancelar</Button> <Button variant="outline">Cancelar</Button>
</ResponsiveDialogClose> </ResponsiveDialogClose>

View File

@@ -1,4 +1,4 @@
import { CalendarDays, Download, Drill, ShieldCheck, UsersRound } from 'lucide-react'; import { CalendarDays, Download, ShieldCheck, UsersRound } from 'lucide-react';
import { useBooking } from '../booking-provider'; import { useBooking } from '../booking-provider';
import { formatBookingDate, toIsoDateLocal } from '../lib/booking-time'; import { formatBookingDate, toIsoDateLocal } from '../lib/booking-time';
@@ -14,7 +14,7 @@ export function BookingDaySummary() {
</p> </p>
</div> </div>
<div className="grid gap-3 md:grid-cols-4"> <div className="grid gap-3 md:grid-cols-3">
<SummaryCard <SummaryCard
label="Total de bloques" label="Total de bloques"
value={summary.totalReservations} value={summary.totalReservations}
@@ -35,13 +35,6 @@ export function BookingDaySummary() {
icon={ShieldCheck} icon={ShieldCheck}
className="border-reserved/35 bg-reserved/10 text-reserved" 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> </div>
</section> </section>
); );
@@ -73,11 +66,8 @@ function SummaryCard({ label, value, detail, icon: Icon, className }: SummaryCar
} }
export function BookingQuickActions() { export function BookingQuickActions() {
const { selectedDate, selectedStatus, setSelectedDate, setSelectedStatus, exportDayReport } = const { setSelectedDate, setViewMode, exportDayReport } = useBooking();
useBooking();
const todayIso = toIsoDateLocal(new Date()); const todayIso = toIsoDateLocal(new Date());
const isViewingTodayReservations = selectedDate === todayIso && selectedStatus === 'reserved';
const isViewingMaintenance = selectedStatus === 'maintenance';
return ( return (
<section className="rounded-lg border bg-card/85 p-5 shadow-sm"> <section className="rounded-lg border bg-card/85 p-5 shadow-sm">
@@ -87,22 +77,12 @@ export function BookingQuickActions() {
type="button" type="button"
onClick={() => { onClick={() => {
setSelectedDate(todayIso); setSelectedDate(todayIso);
setSelectedStatus(isViewingTodayReservations ? 'all' : 'reserved'); setViewMode('list');
}} }}
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"
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" /> <CalendarDays className="size-4 text-muted-foreground" />
{isViewingTodayReservations ? 'Ver todos los estados' : 'Ver reservas de hoy'} 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>
<button <button
type="button" type="button"

View File

@@ -0,0 +1,249 @@
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import {
ArrowLeft,
CalendarDays,
Clock,
MapPin,
Phone,
ShieldCheck,
User,
UserX,
} from 'lucide-react';
import { useMemo } from 'react';
import { useBooking } from '../booking-provider';
import type { BookingTimelineSegment } from '../booking.types';
import { formatBookingDate } from '../lib/booking-time';
const statusConfig: Record<string, { label: string; className: string }> = {
CONFIRMED: {
label: 'Reservada',
className: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30',
},
COMPLETED: {
label: 'Completada',
className: 'bg-sky-500/15 text-sky-600 dark:text-sky-400 border-sky-500/30',
},
CANCELLED: {
label: 'Cancelada',
className: 'bg-red-500/15 text-red-600 dark:text-red-400 border-red-500/30',
},
NOSHOW: {
label: 'No show',
className: 'bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/30',
},
};
const statusIconConfig: Record<string, typeof ShieldCheck> = {
CONFIRMED: ShieldCheck,
COMPLETED: ShieldCheck,
CANCELLED: UserX,
NOSHOW: UserX,
};
export function BookingListView() {
const {
selectedDate,
setViewMode,
schedules,
openBookingTools,
isLoading,
isError,
errorMessage,
} = useBooking();
const reservedSegments = useMemo(() => {
return schedules
.flatMap((schedule) =>
schedule.segments
.filter((segment) => segment.booking)
.map((segment) => ({ schedule, segment }))
)
.sort((a, b) => {
const timeDiff = a.segment.startMinutes - b.segment.startMinutes;
if (timeDiff !== 0) return timeDiff;
return a.schedule.court.name.localeCompare(b.schedule.court.name);
});
}, [schedules]);
return (
<section className="rounded-lg border bg-card/85 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-4 border-b px-4 py-4">
<div className="flex items-center gap-3">
<Button
type="button"
variant="ghost"
size="icon"
className="rounded-full"
onClick={() => setViewMode('panel')}
>
<ArrowLeft className="size-5" />
</Button>
<div>
<h2 className="text-base font-semibold">Reservas del día</h2>
<p className="text-sm capitalize text-muted-foreground">
{formatBookingDate(selectedDate, { year: 'numeric' })}
</p>
</div>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CalendarDays className="size-4" />
<span>
{reservedSegments.length} {reservedSegments.length === 1 ? 'reserva' : 'reservas'}
</span>
</div>
</div>
{isLoading && (
<div className="px-4 py-10 text-sm text-muted-foreground">Cargando reservas...</div>
)}
{isError && !isLoading && (
<div className="px-4 py-10 text-sm text-destructive">{errorMessage}</div>
)}
{!isLoading && !isError && reservedSegments.length === 0 && (
<div className="px-4 py-10 text-center text-sm text-muted-foreground">
<CalendarDays className="mx-auto mb-3 size-10 opacity-40" />
<p>No hay reservas para este día.</p>
</div>
)}
{!isLoading && !isError && reservedSegments.length > 0 && (
<div className="overflow-x-auto">
{/* Desktop table */}
<table className="hidden w-full sm:table">
<thead>
<tr className="border-b text-left text-xs font-medium uppercase tracking-wider text-muted-foreground">
<th className="px-4 py-3 font-medium">Horario</th>
<th className="px-4 py-3 font-medium">Cancha</th>
<th className="px-4 py-3 font-medium">Cliente</th>
<th className="hidden px-4 py-3 font-medium md:table-cell">Teléfono</th>
<th className="px-4 py-3 font-medium">Estado</th>
</tr>
</thead>
<tbody className="divide-y">
{reservedSegments.map(({ schedule, segment }) => (
<BookingRow
key={segment.id}
segment={segment}
courtName={schedule.court.name}
sportName={schedule.court.sport.name}
onClick={() => openBookingTools(segment)}
/>
))}
</tbody>
</table>
{/* Mobile cards */}
<div className="divide-y sm:hidden">
{reservedSegments.map(({ schedule, segment }) => (
<button
key={segment.id}
type="button"
className="flex w-full items-center gap-4 px-4 py-4 text-left transition-colors hover:bg-muted/50"
onClick={() => openBookingTools(segment)}
>
<div className="flex size-12 shrink-0 items-center justify-center rounded-lg border border-border/60 bg-secondary/40 text-muted-foreground">
<Clock className="size-5" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">
{segment.booking?.customerName ?? 'Sin información'}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{schedule.court.name} · {schedule.court.sport.name}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{segment.startTime} - {segment.endTime}
</p>
</div>
<StatusBadge status={segment.booking?.status ?? 'CONFIRMED'} />
</button>
))}
</div>
</div>
)}
</section>
);
}
interface BookingRowProps {
segment: BookingTimelineSegment;
courtName: string;
sportName: string;
onClick: () => void;
}
function BookingRow({ segment, courtName, sportName, onClick }: BookingRowProps) {
const booking = segment.booking;
return (
<tr
className="cursor-pointer transition-colors hover:bg-muted/50"
onClick={onClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
}}
tabIndex={0}
role="button"
>
<td className="px-4 py-4">
<div className="flex items-center gap-2">
<Clock className="size-4 shrink-0 text-muted-foreground" />
<span className="text-sm font-medium">
{segment.startTime} - {segment.endTime}
</span>
</div>
</td>
<td className="px-4 py-4">
<div className="flex items-center gap-2">
<MapPin className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<p className="truncate text-sm font-medium">{courtName}</p>
<p className="truncate text-xs text-muted-foreground">{sportName}</p>
</div>
</div>
</td>
<td className="px-4 py-4">
<div className="flex items-center gap-2">
<User className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate text-sm">{booking?.customerName ?? 'Sin información'}</span>
</div>
</td>
<td className="hidden px-4 py-4 md:table-cell">
<div className="flex items-center gap-2">
<Phone className="size-4 shrink-0 text-muted-foreground" />
<span className="text-sm text-muted-foreground">{booking?.customerPhone ?? '-'}</span>
</div>
</td>
<td className="px-4 py-4">
<StatusBadge status={booking?.status ?? 'CONFIRMED'} />
</td>
</tr>
);
}
function StatusBadge({ status }: { status: string }) {
const config = statusConfig[status] ?? {
label: status,
className: 'bg-slate-500/15 text-slate-600 dark:text-slate-400 border-slate-500/30',
};
const Icon = statusIconConfig[status] ?? ShieldCheck;
return (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-semibold',
config.className
)}
>
<Icon className="size-3" />
{config.label}
</span>
);
}

View File

@@ -31,7 +31,6 @@ import {
Sun, Sun,
User, User,
Users, Users,
Wrench,
} from 'lucide-react'; } from 'lucide-react';
import type React from 'react'; import type React from 'react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
@@ -352,18 +351,33 @@ function MobilePanelHeader() {
} }
function MobileFilters() { function MobileFilters() {
const { selectedDate, setSelectedDate } = useBooking(); const { selectedDate, setSelectedDate, moveSelectedDate } = useBooking();
return ( return (
<div> <div className="grid grid-cols-[52px_1fr_52px] gap-3">
<Button
type="button"
variant="outline"
className="h-12 rounded-lg"
onClick={() => moveSelectedDate(-1)}
>
<ChevronLeft className="size-5" />
</Button>
<DatePicker <DatePicker
value={fromIsoDateLocal(selectedDate)} value={fromIsoDateLocal(selectedDate)}
onChange={(date) => onChange={(date) =>
setSelectedDate(date ? toIsoDateLocal(date) : toIsoDateLocal(new Date())) setSelectedDate(date ? toIsoDateLocal(date) : toIsoDateLocal(new Date()))
} }
className="h-12 w-full justify-center rounded-lg border-border/70 bg-card/75 px-4 text-sm font-medium" className="h-12 rounded-lg border-border/70 bg-card/75 text-sm"
placeholder="Fecha"
/> />
<Button
type="button"
variant="outline"
className="h-12 rounded-lg"
onClick={() => moveSelectedDate(1)}
>
<ChevronRight className="size-5" />
</Button>
</div> </div>
); );
} }
@@ -385,11 +399,6 @@ function MobileSummary() {
label="Reservados" label="Reservados"
className="bg-reserved/15 text-reserved" className="bg-reserved/15 text-reserved"
/> />
<SummaryPill
value={summary.maintenanceSlots}
label="Mantenim."
className="bg-maintenance/15 text-maintenance"
/>
</div> </div>
</section> </section>
); );
@@ -411,7 +420,6 @@ function MobileStatusTab() {
{ value: 'all', label: 'Todos' }, { value: 'all', label: 'Todos' },
{ value: 'free', label: 'Libres' }, { value: 'free', label: 'Libres' },
{ value: 'reserved', label: 'Reservados' }, { value: 'reserved', label: 'Reservados' },
{ value: 'maintenance', label: 'Mantenimiento' },
] as const; ] as const;
const timeRangeOptions = [ const timeRangeOptions = [
{ label: '08:00 - 22:00', start: '08:00', end: '22:00' }, { label: '08:00 - 22:00', start: '08:00', end: '22:00' },
@@ -835,14 +843,10 @@ function MobileSegmentRow({
) : ( ) : (
<div className="max-w-[132px] text-right"> <div className="max-w-[132px] text-right">
<p className="truncate text-sm font-medium"> <p className="truncate text-sm font-medium">
{segment.booking?.customerName ?? 'Mantenimiento'} {segment.booking?.customerName ?? 'Sin información'}
</p> </p>
<p className="mt-2 inline-flex items-center gap-1 text-sm text-muted-foreground"> <p className="mt-2 inline-flex items-center gap-1 text-sm text-muted-foreground">
{segment.status === 'maintenance' ? ( <Users className="size-4" />
<Wrench className="size-4" />
) : (
<Users className="size-4" />
)}
{segment.booking?.customerPhone ?? 'Personal'} {segment.booking?.customerPhone ?? 'Personal'}
</p> </p>
</div> </div>
@@ -853,23 +857,21 @@ function MobileSegmentRow({
function SegmentStatusLine({ segment }: { segment: BookingTimelineSegment }) { function SegmentStatusLine({ segment }: { segment: BookingTimelineSegment }) {
const status = segment.status; const status = segment.status;
const label = status === 'free' ? 'Libre' : status === 'reserved' ? 'Reservado' : 'Mantenimiento'; const label = status === 'free' ? 'Libre' : 'Reservado';
return ( return (
<p <p
className={cn( className={cn(
'mt-2 inline-flex items-center gap-2 text-sm', 'mt-2 inline-flex items-center gap-2 text-sm',
status === 'free' && 'text-primary', status === 'free' && 'text-primary',
status === 'reserved' && 'text-reserved', status === 'reserved' && 'text-reserved'
status === 'maintenance' && 'text-maintenance'
)} )}
> >
<span <span
className={cn( className={cn(
'size-3 rounded-full', 'size-3 rounded-full',
status === 'free' && 'bg-primary', status === 'free' && 'bg-primary',
status === 'reserved' && 'bg-reserved', status === 'reserved' && 'bg-reserved'
status === 'maintenance' && 'bg-maintenance'
)} )}
/> />
{label} {label}
@@ -882,7 +884,6 @@ function StatusLegend() {
<div className="flex items-center justify-between gap-3 text-sm"> <div className="flex items-center justify-between gap-3 text-sm">
<LegendItem label="Libre" className="bg-primary" /> <LegendItem label="Libre" className="bg-primary" />
<LegendItem label="Reservado" className="bg-reserved" /> <LegendItem label="Reservado" className="bg-reserved" />
<LegendItem label="Mantenimiento" className="bg-maintenance" />
</div> </div>
); );
} }
@@ -914,9 +915,7 @@ function MiniSlot({
className={cn( className={cn(
'min-w-0 rounded-lg border px-1.5 py-2 text-center transition active:scale-[0.98] disabled:opacity-60', 'min-w-0 rounded-lg border px-1.5 py-2 text-center transition active:scale-[0.98] disabled:opacity-60',
segment.status === 'free' && 'border-primary/50 bg-primary/15 text-primary', segment.status === 'free' && 'border-primary/50 bg-primary/15 text-primary',
segment.status === 'reserved' && 'border-reserved/50 bg-reserved/15 text-reserved', segment.status === 'reserved' && 'border-reserved/50 bg-reserved/15 text-reserved'
segment.status === 'maintenance' &&
'border-maintenance/50 bg-maintenance/15 text-maintenance'
)} )}
disabled={!isActionable} disabled={!isActionable}
onClick={() => { onClick={() => {
@@ -966,10 +965,6 @@ function MetricDots({ schedule }: { schedule: BookingCourtSchedule }) {
<span className="size-3 rounded-full bg-reserved" /> <span className="size-3 rounded-full bg-reserved" />
{schedule.metrics.reserved} {schedule.metrics.reserved}
</span> </span>
<span className="inline-flex items-center gap-1 text-maintenance">
<span className="size-3 rounded-full bg-maintenance" />
{schedule.metrics.maintenance}
</span>
</div> </div>
); );
} }

View File

@@ -1,7 +1,6 @@
const items = [ const items = [
{ label: 'Libre', className: 'bg-primary' }, { label: 'Libre', className: 'bg-primary' },
{ label: 'Reservado', className: 'bg-reserved' }, { label: 'Reservado', className: 'bg-reserved' },
{ label: 'Mantenimiento', className: 'bg-maintenance' },
]; ];
export function BookingStatusLegend() { export function BookingStatusLegend() {

View File

@@ -215,10 +215,6 @@ function BookingCourtInfo({ schedule }: { schedule: BookingCourtSchedule }) {
<span className="size-2 rounded-full bg-reserved" /> <span className="size-2 rounded-full bg-reserved" />
{schedule.metrics.reserved} {schedule.metrics.reserved}
</span> </span>
<span className="inline-flex items-center gap-1">
<span className="size-2 rounded-full bg-maintenance" />
{schedule.metrics.maintenance}
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -247,8 +243,7 @@ function BookingSlotBlock({ segment, schedule, rangeStart, totalMinutes }: Booki
'border-primary/70 bg-primary/20 text-primary hover:bg-primary/25', 'border-primary/70 bg-primary/20 text-primary hover:bg-primary/25',
segment.status === 'reserved' && segment.status === 'reserved' &&
'border-reserved/70 bg-reserved/80 text-reserved-foreground', 'border-reserved/70 bg-reserved/80 text-reserved-foreground',
segment.status === 'maintenance' &&
'border-maintenance/80 bg-maintenance/75 text-maintenance-foreground',
segment.booking?.status === 'COMPLETED' && segment.booking?.status === 'COMPLETED' &&
'border-reserved/70 bg-reserved/25 dark:text-reserved-foreground text-gray-600 line-through', 'border-reserved/70 bg-reserved/25 dark:text-reserved-foreground text-gray-600 line-through',
segment.booking?.status === 'NOSHOW' && segment.booking?.status === 'NOSHOW' &&
@@ -300,7 +295,7 @@ function BookingSlotBlock({ segment, schedule, rangeStart, totalMinutes }: Booki
</div> </div>
<span className="mt-1 flex items-center gap-1 truncate opacity-90"> <span className="mt-1 flex items-center gap-1 truncate opacity-90">
<Users className="size-3" /> <Users className="size-3" />
{segment.booking?.customerName ?? 'Mantenimiento'} {segment.booking?.customerName ?? 'Sin información'}
</span> </span>
</> </>
)} )}

View File

@@ -8,7 +8,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { ChevronLeft, ChevronRight, Clock, SlidersHorizontal } from 'lucide-react'; import { ChevronLeft, ChevronRight, Clock } from 'lucide-react';
import { useBooking } from '../booking-provider'; import { useBooking } from '../booking-provider';
import type { BookingStatusFilter } from '../booking.types'; import type { BookingStatusFilter } from '../booking.types';
import { fromIsoDateLocal, toIsoDateLocal } from '../lib/booking-time'; import { fromIsoDateLocal, toIsoDateLocal } from '../lib/booking-time';
@@ -24,7 +24,6 @@ const statusOptions: Array<{ value: BookingStatusFilter; label: string }> = [
{ value: 'all', label: 'Todos' }, { value: 'all', label: 'Todos' },
{ value: 'free', label: 'Libre' }, { value: 'free', label: 'Libre' },
{ value: 'reserved', label: 'Reservado' }, { value: 'reserved', label: 'Reservado' },
{ value: 'maintenance', label: 'Mantenimiento' },
]; ];
export function BookingToolbar() { export function BookingToolbar() {
@@ -46,7 +45,7 @@ export function BookingToolbar() {
return ( return (
<section className="rounded-lg border bg-card/85 p-4 shadow-sm"> <section className="rounded-lg border bg-card/85 p-4 shadow-sm">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-[1fr_1fr_1.6fr_1.1fr_auto] xl:items-end"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-[1fr_1fr_1.6fr_1.1fr] xl:items-end">
<Field> <Field>
<FieldLabel>Deporte</FieldLabel> <FieldLabel>Deporte</FieldLabel>
<Select value={selectedSportId} onValueChange={setSelectedSportId}> <Select value={selectedSportId} onValueChange={setSelectedSportId}>
@@ -133,11 +132,6 @@ export function BookingToolbar() {
</SelectContent> </SelectContent>
</Select> </Select>
</Field> </Field>
<Button variant="outline" className="xl:self-end">
<SlidersHorizontal className="size-4" />
Filtros avanzados
</Button>
</div> </div>
</section> </section>
); );

View File

@@ -24,6 +24,7 @@ import {
Wrench, Wrench,
XCircle, XCircle,
} from 'lucide-react'; } from 'lucide-react';
import { useState } from 'react';
import { useBooking } from '../booking-provider'; import { useBooking } from '../booking-provider';
function formatDate(date: string | undefined) { function formatDate(date: string | undefined) {
@@ -70,6 +71,7 @@ export function BookingToolsDialog() {
useBooking(); useBooking();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const booking = selectedSegment?.booking; const booking = selectedSegment?.booking;
const [confirmCancelOpen, setConfirmCancelOpen] = useState(false);
const status = statusConfig[booking?.status ?? 'CONFIRMED'] ?? { const status = statusConfig[booking?.status ?? 'CONFIRMED'] ?? {
label: booking?.status, label: booking?.status,
@@ -93,149 +95,211 @@ export function BookingToolsDialog() {
closeBookingTools(); closeBookingTools();
}; };
const cancelConfirmDialog = (
<ResponsiveDialog
open={confirmCancelOpen}
onOpenChange={(open) => !open && setConfirmCancelOpen(false)}
>
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>¿Cancelar reserva?</ResponsiveDialogTitle>
<ResponsiveDialogDescription>
Esta acción no se puede deshacer. Se eliminará la reserva y la cancha volverá a estar
disponible.
</ResponsiveDialogDescription>
</ResponsiveDialogHeader>
<div className="space-y-2 rounded-lg border bg-card p-4">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Cliente</span>
<span className="text-sm font-medium">{booking?.customerName ?? '-'}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Cancha</span>
<span className="text-sm font-medium">{booking?.courtName ?? '-'}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Fecha</span>
<span className="text-sm font-medium">{formatDate(booking?.date) || '-'}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Horario</span>
<span className="text-sm font-medium">
{booking?.startTime ?? '--:--'} a {booking?.endTime ?? '--:--'}
</span>
</div>
</div>
<ResponsiveDialogFooter>
<ResponsiveDialogClose asChild>
<Button variant="outline">Volver</Button>
</ResponsiveDialogClose>
<Button
variant="destructive"
onClick={() => {
setConfirmCancelOpen(false);
updateStatus('CANCELLED');
}}
>
, cancelar reserva
</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
);
if (isMobile) { if (isMobile) {
return ( return (
<>
<ResponsiveDialog
open={bookingToolsOpen}
onOpenChange={(open) => !open && closeBookingTools()}
>
<ResponsiveDialogContent
className="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()}
>
<MobileBookingToolsSheet
status={status}
onComplete={() => updateStatus('COMPLETED')}
onCancel={() => setConfirmCancelOpen(true)}
onReminder={sendWhatsappReminder}
onNoShow={() => updateStatus('NOSHOW')}
/>
</ResponsiveDialogContent>
</ResponsiveDialog>
{cancelConfirmDialog}
</>
);
}
return (
<>
<ResponsiveDialog <ResponsiveDialog
open={bookingToolsOpen} open={bookingToolsOpen}
onOpenChange={(open) => !open && closeBookingTools()} onOpenChange={(open) => !open && closeBookingTools()}
> >
<ResponsiveDialogContent <ResponsiveDialogContent
className="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" className="data-[variant=dialog]:max-w-5xl data-[variant=drawer]:max-h-[92dvh] data-[variant=drawer]:overflow-y-auto data-[variant=drawer]:px-4 data-[variant=drawer]:pb-5"
onOpenAutoFocus={(event) => event.preventDefault()} onOpenAutoFocus={(event) => event.preventDefault()}
> >
<MobileBookingToolsSheet <ResponsiveDialogHeader className="data-[variant=drawer]:px-0 data-[variant=drawer]:text-left">
status={status} <ResponsiveDialogTitle>Detalles de la reserva</ResponsiveDialogTitle>
onComplete={() => updateStatus('COMPLETED')} <ResponsiveDialogDescription>
onCancel={() => updateStatus('CANCELLED')} Administra la reserva y su estado.
onReminder={sendWhatsappReminder} </ResponsiveDialogDescription>
onNoShow={() => updateStatus('NOSHOW')} </ResponsiveDialogHeader>
/>
<section className="mx-0 px-0 pt-3 pb-3 sm:-mx-6 sm:px-6 sm:pb-5">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-0">
<DetailItem
icon={<MapPin className="h-5 w-5" />}
label="Cancha"
value={selectedSegment?.booking?.courtName ?? '-'}
helper={selectedSegment?.booking?.sport?.name}
withDivider
/>
<DetailItem
icon={<BadgeCheck className="h-5 w-5" />}
label="Estado actual"
value={status.label}
valueClassName="text-emerald-400 uppercase dark:text-emerald-500"
withDivider
/>
<DetailItem
icon={<User className="h-5 w-5" />}
label="Cliente"
value={selectedSegment?.booking?.customerName ?? '-'}
/>
<DetailItem
icon={<Clock className="h-5 w-5" />}
label="Fecha"
value={`${formatDate(selectedSegment?.booking?.date)}`}
withDivider
className="sm:pt-10"
/>
<DetailItem
icon={<Clock className="h-5 w-5" />}
label="Hora"
value={`${selectedSegment?.booking?.startTime} a ${selectedSegment?.booking?.endTime}`}
withDivider
className="sm:pt-10"
/>
<DetailItem
icon={<Phone className="h-5 w-5" />}
label="Teléfono"
value={selectedSegment?.booking?.customerPhone ?? '-'}
className="sm:pt-10"
/>
</div>
</section>
<Separator className="dark:bg-slate-800 bg-slate-300" />
{booking?.status !== 'COMPLETED' && (
<section className="space-y-3">
<div>
<h3 className="font-medium text-slate-100">Acciones disponibles</h3>
<p className="text-sm text-slate-400">
Elegí qué hacer según lo que ocurrió con el cliente.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<ActionButton
icon={<CheckCircle2 className="h-5 w-5" />}
title="Completar reserva"
description="El cliente se presentó para usar la cancha."
className="border-emerald-500/40 bg-emerald-500/10 text-lg text-emerald-600 hover:bg-emerald-500/15 hover:text-emerald-800"
onClick={() => {
updateStatus('COMPLETED');
}}
/>
<ActionButton
icon={<XCircle className="h-5 w-5" />}
title="Cancelar reserva"
description="El cliente avisó que no va a venir. La cancha vuelve a estar disponible."
className="border-red-500/40 bg-red-500/10 text-lg text-red-600 hover:bg-red-500/15 hover:text-red-800"
onClick={() => setConfirmCancelOpen(true)}
/>
<ActionButton
icon={<MessageCircle className="h-5 w-5" />}
title="Enviar recordatorio"
description="Enviar un mensaje por WhatsApp al cliente."
className="border-sky-500/40 bg-sky-500/10 text-lg text-sky-600 hover:bg-sky-500/15 hover:text-sky-800"
onClick={sendWhatsappReminder}
/>
<ActionButton
icon={<AlertTriangle className="h-5 w-5" />}
title="Marcar como No Show"
description="El cliente no vino y no canceló."
className="border-amber-500/40 bg-amber-500/10 text-lg text-amber-600 hover:bg-amber-500/15 hover:text-amber-800"
onClick={() => {
updateStatus('NOSHOW');
}}
/>
</div>
</section>
)}
<ResponsiveDialogFooter className="data-[variant=drawer]:px-0">
<ResponsiveDialogClose asChild>
<Button variant="outline" className="data-[variant=drawer]:h-11">
Cerrar
</Button>
</ResponsiveDialogClose>
</ResponsiveDialogFooter>
</ResponsiveDialogContent> </ResponsiveDialogContent>
</ResponsiveDialog> </ResponsiveDialog>
); {cancelConfirmDialog}
} </>
return (
<ResponsiveDialog open={bookingToolsOpen} onOpenChange={(open) => !open && closeBookingTools()}>
<ResponsiveDialogContent
className="data-[variant=dialog]:max-w-5xl data-[variant=drawer]:max-h-[92dvh] data-[variant=drawer]:overflow-y-auto data-[variant=drawer]:px-4 data-[variant=drawer]:pb-5"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<ResponsiveDialogHeader className="data-[variant=drawer]:px-0 data-[variant=drawer]:text-left">
<ResponsiveDialogTitle>Detalles de la reserva</ResponsiveDialogTitle>
<ResponsiveDialogDescription>
Administra la reserva y su estado.
</ResponsiveDialogDescription>
</ResponsiveDialogHeader>
<section className="mx-0 px-0 pt-3 pb-3 sm:-mx-6 sm:px-6 sm:pb-5">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-0">
<DetailItem
icon={<MapPin className="h-5 w-5" />}
label="Cancha"
value={selectedSegment?.booking?.courtName ?? '-'}
helper={selectedSegment?.booking?.sport?.name}
withDivider
/>
<DetailItem
icon={<BadgeCheck className="h-5 w-5" />}
label="Estado actual"
value={status.label}
valueClassName="text-emerald-400 uppercase dark:text-emerald-500"
withDivider
/>
<DetailItem
icon={<User className="h-5 w-5" />}
label="Cliente"
value={selectedSegment?.booking?.customerName ?? '-'}
/>
<DetailItem
icon={<Clock className="h-5 w-5" />}
label="Fecha"
value={`${formatDate(selectedSegment?.booking?.date)}`}
withDivider
className="sm:pt-10"
/>
<DetailItem
icon={<Clock className="h-5 w-5" />}
label="Hora"
value={`${selectedSegment?.booking?.startTime} a ${selectedSegment?.booking?.endTime}`}
withDivider
className="sm:pt-10"
/>
<DetailItem
icon={<Phone className="h-5 w-5" />}
label="Teléfono"
value={selectedSegment?.booking?.customerPhone ?? '-'}
className="sm:pt-10"
/>
</div>
</section>
<Separator className="dark:bg-slate-800 bg-slate-300" />
<section className="space-y-3">
<div>
<h3 className="font-medium text-slate-100">Acciones disponibles</h3>
<p className="text-sm text-slate-400">
Elegí qué hacer según lo que ocurrió con el cliente.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<ActionButton
icon={<CheckCircle2 className="h-5 w-5" />}
title="Completar reserva"
description="El cliente se presentó para usar la cancha."
className="border-emerald-500/40 bg-emerald-500/10 text-lg text-emerald-600 hover:bg-emerald-500/15 hover:text-emerald-800"
onClick={() => {
updateStatus('COMPLETED');
}}
/>
<ActionButton
icon={<XCircle className="h-5 w-5" />}
title="Cancelar reserva"
description="El cliente avisó que no va a venir. La cancha vuelve a estar disponible."
className="border-red-500/40 bg-red-500/10 text-lg text-red-600 hover:bg-red-500/15 hover:text-red-800"
onClick={() => {
updateStatus('CANCELLED');
}}
/>
<ActionButton
icon={<MessageCircle className="h-5 w-5" />}
title="Enviar recordatorio"
description="Enviar un mensaje por WhatsApp al cliente."
className="border-sky-500/40 bg-sky-500/10 text-lg text-sky-600 hover:bg-sky-500/15 hover:text-sky-800"
onClick={sendWhatsappReminder}
/>
<ActionButton
icon={<AlertTriangle className="h-5 w-5" />}
title="Marcar como No Show"
description="El cliente no vino y no canceló."
className="border-amber-500/40 bg-amber-500/10 text-lg text-amber-600 hover:bg-amber-500/15 hover:text-amber-800"
onClick={() => {
updateStatus('NOSHOW');
}}
/>
</div>
</section>
<ResponsiveDialogFooter className="data-[variant=drawer]:px-0">
<ResponsiveDialogClose asChild>
<Button variant="outline" className="data-[variant=drawer]:h-11">
Cerrar
</Button>
</ResponsiveDialogClose>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
); );
} }
@@ -321,42 +385,44 @@ function MobileBookingToolsSheet({
</section> </section>
</div> </div>
<section className="shrink-0 border-t border-border/70 bg-popover/95 px-4 py-3"> {booking?.status !== 'COMPLETED' && (
<h3 className="text-sm font-medium">Acciones</h3> <section className="shrink-0 border-t border-border/70 bg-popover/95 px-4 py-3">
<div className="mt-3 grid grid-cols-2 gap-2"> <h3 className="text-sm font-medium">Acciones</h3>
<MobileActionButton <div className="mt-3 grid grid-cols-2 gap-2">
icon={<CheckCircle2 className="size-5" />} <MobileActionButton
label="Completar" icon={<CheckCircle2 className="size-5" />}
className="border-primary/45 bg-primary/12 text-primary" label="Completar"
onClick={onComplete} className="border-primary/45 bg-primary/12 text-primary"
/> onClick={onComplete}
<MobileActionButton />
icon={<MessageCircle className="size-5" />} <MobileActionButton
label="Recordar" icon={<MessageCircle className="size-5" />}
className="border-reserved/45 bg-reserved/12 text-reserved" label="Recordar"
onClick={onReminder} className="border-reserved/45 bg-reserved/12 text-reserved"
/> onClick={onReminder}
<MobileActionButton />
icon={<XCircle className="size-5" />} <MobileActionButton
label="Cancelar" icon={<XCircle className="size-5" />}
className="border-destructive/45 bg-destructive/12 text-destructive" label="Cancelar"
onClick={onCancel} className="border-destructive/45 bg-destructive/12 text-destructive"
/> onClick={onCancel}
<MobileActionButton />
icon={<AlertTriangle className="size-5" />} <MobileActionButton
label="No show" icon={<AlertTriangle className="size-5" />}
className="border-warning/50 bg-warning/12 text-warning" label="No show"
onClick={onNoShow} className="border-warning/50 bg-warning/12 text-warning"
/> onClick={onNoShow}
</div> />
<ResponsiveDialogFooter className="px-0 pb-0 pt-3"> </div>
<ResponsiveDialogClose asChild> <ResponsiveDialogFooter className="px-0 pb-0 pt-3">
<Button variant="outline" className="h-11 w-full rounded-lg"> <ResponsiveDialogClose asChild>
Cerrar <Button variant="outline" className="h-11 w-full rounded-lg">
</Button> Cerrar
</ResponsiveDialogClose> </Button>
</ResponsiveDialogFooter> </ResponsiveDialogClose>
</section> </ResponsiveDialogFooter>
</section>
)}
</div> </div>
); );
} }

View File

@@ -49,8 +49,14 @@ export function HomePage() {
useEffect(() => { useEffect(() => {
async function autoSelect() { async function autoSelect() {
if (myComplexesQuery.data && myComplexesQuery.data.length === 1) { if (myComplexesQuery.data && myComplexesQuery.data.length === 1) {
await apiClient.complexes.select({ complexId: myComplexesQuery.data[0].id }); try {
queryClient.invalidateQueries({ queryKey: ['current-complex'] }); const result = await apiClient.complexes.select({
complexId: myComplexesQuery.data[0].id,
});
queryClient.setQueryData(['current-complex'], result);
} catch (error) {
console.error('Error al auto-seleccionar complejo:', error);
}
} else if ( } else if (
myComplexesQuery.data && myComplexesQuery.data &&
myComplexesQuery.data.length > 1 && myComplexesQuery.data.length > 1 &&

View File

@@ -58,7 +58,11 @@ export function PlanSelectStep({ form, plans }: PlanSelectStepProps) {
<div className="mb-3"> <div className="mb-3">
<h3 className="text-xl font-bold">{plan.name}</h3> <h3 className="text-xl font-bold">{plan.name}</h3>
<div className="mt-1"> <div className="mt-1">
<span className="text-3xl font-bold">${plan.price}</span> <span className="text-3xl font-bold">
{plan.currency === 'ARS'
? `AR$${plan.price.toLocaleString('es-AR')}`
: `$${plan.price.toFixed(2)}`}
</span>
<span className="text-sm text-muted-foreground">/mes</span> <span className="text-sm text-muted-foreground">/mes</span>
</div> </div>
</div> </div>

View File

@@ -50,7 +50,14 @@ export function ReviewStep({ data, selectedPlan }: ReviewStepProps) {
<Section title="Plan seleccionado"> <Section title="Plan seleccionado">
{selectedPlan ? ( {selectedPlan ? (
<> <>
<Row label="Plan" value={`${selectedPlan.name} - $${selectedPlan.price}/mes`} /> <Row
label="Plan"
value={`${selectedPlan.name} - ${
selectedPlan.currency === 'ARS'
? `AR$${selectedPlan.price.toLocaleString('es-AR')}`
: `$${selectedPlan.price.toFixed(2)}`
}/mes`}
/>
<div className="mt-2 space-y-1"> <div className="mt-2 space-y-1">
{[ {[
['publicBookingPage', 'Página de booking pública'], ['publicBookingPage', 'Página de booking pública'],

View File

@@ -6,7 +6,8 @@ type ShareWhatsappButtonProps = {
confirmation: PublicBookingConfirmation; confirmation: PublicBookingConfirmation;
}; };
function formatDateLabel(isoDate: string) { function formatDateLabel(isoDate: string | null | undefined) {
if (!isoDate) return '';
const [year, month, day] = isoDate.split('-').map(Number); const [year, month, day] = isoDate.split('-').map(Number);
const date = new Date(year, (month ?? 1) - 1, day ?? 1); const date = new Date(year, (month ?? 1) - 1, day ?? 1);
@@ -18,16 +19,35 @@ function formatDateLabel(isoDate: string) {
}).format(date); }).format(date);
} }
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 createWhatsappMessage(confirmation: PublicBookingConfirmation) { function createWhatsappMessage(confirmation: PublicBookingConfirmation) {
const dateLabel = formatDateLabel(confirmation.date); const dateLabel = formatDateLabel(confirmation.date);
return [ const lines = ['Ya reservamos cancha para jugar.', `Complejo: ${confirmation.complexName}`];
'Ya reservamos cancha para jugar.',
`Complejo: ${confirmation.complexName}`, if (confirmation.complexAddress) {
lines.push(`Direccion: ${confirmation.complexAddress}`);
}
lines.push(
`Cancha: ${confirmation.courtName} (${confirmation.sport.name})`, `Cancha: ${confirmation.courtName} (${confirmation.sport.name})`,
`Fecha: ${dateLabel}`, `Fecha: ${dateLabel}`,
`Horario: ${confirmation.startTime} - ${confirmation.endTime}`, `Horario: ${confirmation.startTime} - ${confirmation.endTime}`,
`Codigo de reserva: ${confirmation.bookingCode}`, `Precio: ${formatBookingPrice(confirmation.price)}`,
].join('\n'); `Codigo de reserva: ${confirmation.bookingCode}`
);
return lines.join('\n');
} }
export function ShareWhatsappButton({ confirmation }: ShareWhatsappButtonProps) { export function ShareWhatsappButton({ confirmation }: ShareWhatsappButtonProps) {
@@ -36,17 +56,28 @@ export function ShareWhatsappButton({ confirmation }: ShareWhatsappButtonProps)
)}`; )}`;
return ( return (
<Button type="button" variant="outline" className="h-11 w-full text-sm sm:text-base" asChild> <Button
<a href={whatsappUrl} target="_blank" rel="noopener noreferrer"> type="button"
variant="outline"
className="h-12 w-full overflow-hidden rounded-xl border-slate-200 bg-white px-0 text-sm font-semibold text-slate-900 hover:bg-slate-50 sm:px-4 sm:text-base"
asChild
>
<a
href={whatsappUrl}
target="_blank"
rel="noopener noreferrer"
aria-label="Compartir por WhatsApp"
title="Compartir por WhatsApp"
>
<svg <svg
viewBox="0 0 24 24" viewBox="0 0 24 24"
aria-hidden="true" aria-hidden="true"
className="size-4" className="size-5"
style={{ color: `#${siWhatsapp.hex}` }} style={{ color: `#${siWhatsapp.hex}` }}
> >
<path d={siWhatsapp.path} fill="currentColor" /> <path d={siWhatsapp.path} fill="currentColor" />
</svg> </svg>
Compartir por WhatsApp <span className="hidden sm:inline">Compartir por WhatsApp</span>
</a> </a>
</Button> </Button>
); );

View File

@@ -1,7 +1,30 @@
import PlayzerLogo from '@/assets/playzer-logo-transparent.svg';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ApiClientError, apiClient } from '@/lib/api-client'; import { Input } from '@/components/ui/input';
import { useQuery } from '@tanstack/react-query'; import {
ResponsiveDialog,
ResponsiveDialogClose,
ResponsiveDialogContent,
ResponsiveDialogDescription,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from '@/components/ui/responsive-dialog';
import { apiClient } from '@/lib/api-client';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import {
AlertCircle,
ArrowRight,
CalendarDays,
CheckCircle2,
Clock3,
LoaderCircle,
Receipt,
Trophy,
XCircle,
} from 'lucide-react';
import { useState } from 'react';
import { ShareWhatsappButton } from './components/share-whatsapp-button'; import { ShareWhatsappButton } from './components/share-whatsapp-button';
type PublicBookingConfirmationPageProps = { type PublicBookingConfirmationPageProps = {
@@ -10,23 +33,34 @@ type PublicBookingConfirmationPageProps = {
}; };
function extractMessage(error: unknown, fallback: string) { function extractMessage(error: unknown, fallback: string) {
if (error instanceof ApiClientError) { const msg = (error as { message?: string } | undefined)?.message;
return error.message || fallback; return msg || fallback;
}
return fallback;
} }
function formatDateLabel(isoDate: string) { function formatDateLabel(isoDate: string | undefined | null) {
if (!isoDate) return '';
const [year, month, day] = isoDate.split('-').map(Number); const [year, month, day] = isoDate.split('-').map(Number);
const date = new Date(year, (month ?? 1) - 1, day ?? 1); const date = new Date(year, (month ?? 1) - 1, day ?? 1);
return new Intl.DateTimeFormat('es-AR', { const label = new Intl.DateTimeFormat('es-AR', {
weekday: 'long', weekday: 'long',
day: '2-digit', day: '2-digit',
month: 'long', month: 'long',
year: 'numeric',
}).format(date); }).format(date);
return label.charAt(0).toUpperCase() + label.slice(1);
}
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);
} }
export function PublicBookingConfirmationPage({ export function PublicBookingConfirmationPage({
@@ -34,82 +68,230 @@ export function PublicBookingConfirmationPage({
bookingCode, bookingCode,
}: PublicBookingConfirmationPageProps) { }: PublicBookingConfirmationPageProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [customerPhone, setCustomerPhone] = useState('');
const [cancelError, setCancelError] = useState<string | null>(null);
const [cancelledBooking, setCancelledBooking] = useState<{
date: string;
startTime: string;
endTime: string;
courtName: string;
sport: { name: string };
bookingCode: string;
price: number;
complexName: string;
} | null>(null);
const confirmationQuery = useQuery({ const confirmationQuery = useQuery({
queryKey: ['public-booking-confirmation', complexSlug, bookingCode], queryKey: ['public-booking-confirmation', complexSlug, bookingCode],
queryFn: () => apiClient.publicBookings.getConfirmation(complexSlug, bookingCode), queryFn: () => apiClient.publicBookings.getConfirmation(complexSlug, bookingCode),
}); });
const cancelMutation = useMutation({
mutationFn: (phone: string) =>
apiClient.publicBookings.cancelPublic(complexSlug, {
bookingCode,
customerPhone: phone,
}),
});
const handleCancelClick = () => {
cancelMutation.reset();
setCustomerPhone('');
setCancelError(null);
setCancelDialogOpen(true);
};
const handleConfirmCancel = async () => {
setCancelError(null);
try {
const data = await cancelMutation.mutateAsync(customerPhone);
setCancelledBooking(data);
setCancelDialogOpen(false);
setCustomerPhone('');
} catch (error: unknown) {
console.error('[Cancel]', error);
setCancelError(
(error as { message?: string } | undefined)?.message ?? 'No pudimos cancelar la reserva.'
);
}
};
const isCancelled = cancelledBooking !== null;
const displayData = cancelledBooking ?? confirmationQuery.data;
return ( return (
<main className="min-h-screen bg-linear-to-b from-background via-background to-primary/5"> <main className="min-h-screen bg-[#edf7f4] text-[#111827]">
<div className="mx-auto flex min-h-screen w-full max-w-3xl items-center px-3 py-8 sm:px-6"> <div className="mx-auto flex min-h-screen w-full max-w-3xl items-center px-4 py-8 sm:px-6">
<section className="w-full rounded-3xl border bg-card p-5 shadow-lg sm:p-8"> <section className="w-full rounded-[28px] border border-emerald-950/10 bg-white p-5 shadow-[0_24px_70px_rgba(15,23,42,0.12)] sm:p-8">
{confirmationQuery.isLoading && ( {confirmationQuery.isLoading && !cancelledBooking && (
<p className="text-sm text-muted-foreground">Cargando confirmacion...</p> <div className="flex items-center gap-3 text-sm text-slate-500">
<LoaderCircle className="size-5 animate-spin text-emerald-600" />
Cargando confirmación...
</div>
)} )}
{confirmationQuery.isError && ( {confirmationQuery.isError && !cancelledBooking && (
<p className="text-sm text-destructive"> <div className="flex gap-3 rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
{extractMessage( <AlertCircle className="mt-0.5 size-5 shrink-0" />
confirmationQuery.error, <p>
'No pudimos cargar la confirmacion de la reserva.' {extractMessage(
)} confirmationQuery.error,
</p> 'No pudimos cargar la confirmación de la reserva.'
)}
</p>
</div>
)} )}
{confirmationQuery.data && ( {displayData && (
<div className="space-y-6"> <div className="space-y-6">
<div> <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<p className="text-xs font-medium tracking-[0.2em] text-muted-foreground uppercase"> <div>
Reserva confirmada {isCancelled ? (
</p> <div className="inline-flex items-center gap-2 rounded-full bg-red-50 px-3 py-1 text-xs font-semibold tracking-[0.14em] text-red-600 uppercase">
<h1 className="mt-2 text-2xl font-semibold sm:text-3xl"> <XCircle className="size-4" />
{confirmationQuery.data.complexName} Reserva cancelada
</h1> </div>
) : (
<div className="inline-flex items-center gap-2 rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold tracking-[0.14em] text-emerald-700 uppercase">
<CheckCircle2 className="size-4" />
Reserva confirmada
</div>
)}
<h1 className="mt-3 text-2xl font-bold tracking-tight sm:text-3xl">
{displayData.complexName}
</h1>
</div>
<div className="flex items-center">
<img src={PlayzerLogo} alt="Playzer" className="h-9 w-auto sm:h-10" />
</div>
</div> </div>
<div className="rounded-2xl border border-primary/30 bg-primary/5 p-4 text-center sm:p-5"> <div className="rounded-[24px] border border-emerald-500/30 bg-emerald-50/80 p-5 sm:p-6">
<p className="text-xs text-muted-foreground">Codigo de reserva</p> <div className="flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
<p className="mt-1 text-3xl font-bold tracking-[0.25em] text-primary sm:text-4xl"> <div>
{confirmationQuery.data.bookingCode} <p className="flex items-center gap-2 text-sm font-semibold text-emerald-700">
</p> <CalendarDays className="size-4" />
Tu turno
</p>
<p className="mt-3 text-2xl font-black leading-tight tracking-tight text-slate-950 sm:text-4xl">
{formatDateLabel(displayData.date)}
</p>
<p className="mt-2 flex items-center gap-2 text-3xl font-black leading-none text-emerald-700 sm:text-5xl">
<Clock3 className="size-7 sm:size-9" />
{displayData.startTime} - {displayData.endTime}
</p>
</div>
<div className="rounded-2xl border border-emerald-600/20 bg-white/80 px-4 py-3 sm:min-w-40 sm:text-right">
<p className="text-xs font-medium text-slate-500">Precio del turno</p>
<p className="mt-1 text-2xl font-bold text-slate-950">
{formatBookingPrice(displayData.price)}
</p>
</div>
</div>
</div> </div>
<div className="rounded-2xl border bg-background p-4 sm:p-5"> <div className="overflow-hidden rounded-[22px] border border-slate-200 bg-slate-50/70">
<p className="text-sm text-muted-foreground">Fecha</p> <div className="grid gap-px bg-slate-200 sm:grid-cols-2">
<p className="mt-1 text-base font-medium capitalize sm:text-lg"> <div className="bg-white p-4">
{formatDateLabel(confirmationQuery.data.date)} <p className="flex items-center gap-2 text-sm font-medium text-slate-500">
</p> <Trophy className="size-4 text-emerald-600" />
Cancha
<p className="mt-4 text-sm text-muted-foreground">Horario</p> </p>
<p className="mt-1 text-base font-medium sm:text-lg"> <p className="mt-2 text-base font-semibold text-slate-950">
{confirmationQuery.data.startTime} - {confirmationQuery.data.endTime} {displayData.courtName} · {displayData.sport.name}
</p> </p>
</div>
<p className="mt-4 text-sm text-muted-foreground">Cancha</p> <div className="bg-white p-4">
<p className="mt-1 text-base font-medium sm:text-lg"> <p className="flex items-center gap-2 text-sm font-medium text-slate-500">
{confirmationQuery.data.courtName} · {confirmationQuery.data.sport.name} <Receipt className="size-4 text-emerald-600" />
</p> Código de reserva
</p>
<p className="mt-2 font-mono text-lg font-bold tracking-[0.18em] text-slate-950">
{displayData.bookingCode}
</p>
</div>
</div>
</div> </div>
<div className="grid gap-3 sm:grid-cols-2"> {isCancelled ? (
<ShareWhatsappButton confirmation={confirmationQuery.data} /> <div className="rounded-2xl bg-red-50 p-4 text-center text-sm text-red-700">
<Button Esta reserva fue cancelada. Te enviamos un email con los detalles de la
type="button" cancelación.
className="h-11 w-full text-sm sm:text-base" </div>
onClick={() => { ) : (
void navigate({ <div className="grid grid-cols-[48px_minmax(0,1fr)_minmax(0,1fr)] gap-2 sm:grid-cols-3 sm:gap-3">
to: '/$complexSlug/booking', <ShareWhatsappButton confirmation={confirmationQuery.data!} />
params: { complexSlug }, <Button
}); type="button"
}} variant="outline"
> className="h-12 w-full rounded-xl border-red-200 text-sm font-semibold text-red-600 hover:bg-red-50 hover:text-red-700 sm:text-base"
Hacer otra reserva onClick={handleCancelClick}
</Button> >
</div> Cancelar
</Button>
<Button
type="button"
className="h-12 w-full rounded-xl bg-emerald-600 text-sm font-semibold text-white hover:bg-emerald-500 sm:text-base"
onClick={() => {
void navigate({
to: '/$complexSlug/booking',
params: { complexSlug },
});
}}
>
<span className="hidden sm:inline">Hacer otra reserva</span>
<span className="sm:hidden">Otra</span>
<ArrowRight className="size-4" />
</Button>
</div>
)}
</div> </div>
)} )}
</section> </section>
</div> </div>
<ResponsiveDialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}>
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>Cancelar reserva</ResponsiveDialogTitle>
<ResponsiveDialogDescription>
Ingresá el número de teléfono que usaste al hacer la reserva para confirmar la
cancelación.
</ResponsiveDialogDescription>
</ResponsiveDialogHeader>
<div className="px-6 pb-2">
<Input
type="tel"
placeholder="Ej: 1134567890"
value={customerPhone}
onChange={(e) => {
setCustomerPhone(e.target.value);
setCancelError(null);
}}
/>
{cancelError && <p className="mt-2 text-sm text-red-600">{cancelError}</p>}
</div>
<ResponsiveDialogFooter>
<ResponsiveDialogClose asChild>
<Button variant="outline">Volver</Button>
</ResponsiveDialogClose>
<Button
variant="destructive"
disabled={!customerPhone.trim() || cancelMutation.isPending}
onClick={handleConfirmCancel}
>
{cancelMutation.isPending ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
'Sí, cancelar reserva'
)}
</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
</main> </main>
); );
} }

View File

@@ -2,13 +2,6 @@ import PlayzerIcon from '@/assets/playzer-favicon-512-transparent.png';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Field, FieldError, FieldLabel } from '@/components/ui/field'; import { Field, FieldError, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { import {
Stepper, Stepper,
StepperIndicator, StepperIndicator,
@@ -33,7 +26,6 @@ import {
Check, Check,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Clock3,
Dumbbell, Dumbbell,
Lock, Lock,
MapPin, MapPin,
@@ -41,12 +33,10 @@ import {
Moon, Moon,
Share2, Share2,
ShieldCheck, ShieldCheck,
Shirt,
Sparkles, Sparkles,
Sun, Sun,
Trophy, Trophy,
Users, Users,
Wrench,
Zap, Zap,
} from 'lucide-react'; } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
@@ -69,6 +59,7 @@ const bookingFormSchema = z.object({
.trim() .trim()
.min(6, 'Ingresá un teléfono válido.') .min(6, 'Ingresá un teléfono válido.')
.max(30, 'El teléfono no puede superar los 30 caracteres.'), .max(30, 'El teléfono no puede superar los 30 caracteres.'),
customerEmail: z.string().email('Ingresá un email válido.'),
}); });
type BookingFormValues = z.infer<typeof bookingFormSchema>; type BookingFormValues = z.infer<typeof bookingFormSchema>;
@@ -84,6 +75,7 @@ type SelectedSlot = {
sportName: string; sportName: string;
startTime: string; startTime: string;
endTime: string; endTime: string;
price: number;
}; };
type BookingShellProps = { type BookingShellProps = {
@@ -186,6 +178,18 @@ function extractMessage(error: unknown, fallback: string) {
return fallback; return fallback;
} }
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 getStep(selectedSlot: SelectedSlot | null, isValid: boolean) { function getStep(selectedSlot: SelectedSlot | null, isValid: boolean) {
if (!selectedSlot) return 1; if (!selectedSlot) return 1;
if (!isValid) return 3; if (!isValid) return 3;
@@ -369,7 +373,6 @@ function Legend() {
<div className="flex flex-wrap gap-4 text-xs text-white/70"> <div className="flex flex-wrap gap-4 text-xs text-white/70">
<LegendDot color="bg-emerald-500" label="Libre" /> <LegendDot color="bg-emerald-500" label="Libre" />
<LegendDot color="bg-blue-500" label="Reservado" /> <LegendDot color="bg-blue-500" label="Reservado" />
<LegendDot color="bg-red-500" label="Mantenimiento" />
</div> </div>
); );
} }
@@ -409,7 +412,7 @@ function PublicBookingPageChrome({
if (mobile) { if (mobile) {
return ( return (
<main className={`${themeClass} min-h-screen bg-[#050d14] text-white`}> <main className={`${themeClass} min-h-screen bg-[#050d14] text-white`}>
<div className="public-booking-frame mx-auto min-h-screen max-w-[430px] bg-[radial-gradient(circle_at_50%_0%,rgba(17,185,129,0.16),transparent_34%),linear-gradient(180deg,#07131d_0%,#071018_47%,#050b11_100%)] px-4 pb-24 pt-7 shadow-2xl shadow-black"> <div className="public-booking-frame mx-auto min-h-screen max-w-[430px] bg-[radial-gradient(circle_at_50%_0%,rgba(17,185,129,0.16),transparent_34%),linear-gradient(180deg,#07131d_0%,#071018_47%,#050b11_100%)] px-4 pb-0 pt-7 shadow-2xl shadow-black">
<header className="flex items-center justify-between"> <header className="flex items-center justify-between">
<PlayzerBrand compact /> <PlayzerBrand compact />
<PublicBookingThemeToggle /> <PublicBookingThemeToggle />
@@ -436,8 +439,6 @@ function PublicBookingPageChrome({
</div> </div>
{children} {children}
<MobileBottomNav />
</div> </div>
</main> </main>
); );
@@ -548,8 +549,7 @@ function PublicBookingDesktop(props: BookingShellProps) {
const currentStep = getStep(selectedSlot, props.form.formState.isValid); const currentStep = getStep(selectedSlot, props.form.formState.isValid);
const completedSteps = getCompletedSteps(selectedSlot, props.form.formState.isValid); const completedSteps = getCompletedSteps(selectedSlot, props.form.formState.isValid);
const selectedDay = dayOptions.find((option) => option.value === selectedDate); const selectedDay = dayOptions.find((option) => option.value === selectedDate);
const currentDayIndex = dayOptions.findIndex((option) => option.value === selectedDate); const canGoBackButton = canGoBack;
const canGoBackButton = canGoBack || currentDayIndex > 0;
return ( return (
<PublicBookingPageChrome <PublicBookingPageChrome
@@ -678,10 +678,7 @@ function PublicBookingDesktop(props: BookingShellProps) {
</div> </div>
</Panel> </Panel>
<div className="grid grid-cols-[minmax(0,1fr)_minmax(340px,0.95fr)] gap-4"> <SelectedSlotCard {...props} />
<CourtDetails court={selectedCourt} />
<SelectedSlotCard {...props} />
</div>
</> </>
)} )}
</section> </section>
@@ -716,15 +713,10 @@ function PublicBookingMobile(props: BookingShellProps) {
/> />
)} )}
{props.courts.length > 0 ? ( {props.courts.length > 0 ? (
<MobileSelect <MobileCourtPicker
label="Cancha" courts={props.courts}
value={selectedCourt?.courtId ?? ''} selectedCourtId={selectedCourt?.courtId}
placeholder="Seleccioná" onSelectCourt={props.onSelectCourt}
onValueChange={props.onSelectCourt}
options={props.courts.map((court) => ({
value: court.courtId,
label: court.courtName,
}))}
/> />
) : ( ) : (
!props.isLoading && ( !props.isLoading && (
@@ -772,16 +764,12 @@ function PublicBookingMobile(props: BookingShellProps) {
<Panel className="overflow-hidden"> <Panel className="overflow-hidden">
<div className="p-3"> <div className="p-3">
<CourtHeader court={selectedCourt} compact /> <CourtHeader court={selectedCourt} compact />
<div className="mt-3">
<Legend />
</div>
</div> </div>
<div className="border-t border-white/10 px-0 py-3"> <div className="border-t border-white/10 p-3">
<BookingTimeline <MobileAvailableSlots
court={selectedCourt} court={selectedCourt}
selectedSlot={props.selectedSlot} selectedSlot={props.selectedSlot}
onSelectSlot={props.onSelectSlot} onSelectSlot={props.onSelectSlot}
compact
/> />
</div> </div>
<div className="px-3 pb-3"> <div className="px-3 pb-3">
@@ -890,34 +878,119 @@ function MobileSportSelector({
); );
} }
function MobileSelect({ function MobileCourtPicker({
label, courts,
value, selectedCourtId,
placeholder, onSelectCourt,
options,
onValueChange,
}: { }: {
label: string; courts: PublicAvailabilityCourt[];
value: string; selectedCourtId?: string;
placeholder: string; onSelectCourt: (courtId: string) => void;
options: { value: string; label: string }[];
onValueChange: (value: string) => void;
}) { }) {
return ( return (
<div className="grid grid-cols-[88px_minmax(0,1fr)] items-center border-b border-white/10 bg-white/[0.025] px-3 py-2 last:border-b-0"> <div className="border-b border-white/10 bg-white/[0.025] px-3 py-3 last:border-b-0">
<span className="text-xs text-white/66">{label}</span> <div className="grid grid-cols-2 gap-2">
<Select value={value} onValueChange={onValueChange}> {courts.map((court) => {
<SelectTrigger className="h-8 border-0 bg-transparent px-0 text-xs text-white shadow-none"> const isSelected = selectedCourtId === court.courtId;
<SelectValue placeholder={placeholder} />
</SelectTrigger> return (
<SelectContent> <button
{options.map((option) => ( key={court.courtId}
<SelectItem key={option.value} value={option.value}> type="button"
{option.label} onClick={() => onSelectCourt(court.courtId)}
</SelectItem> aria-pressed={isSelected}
))} className={`flex min-h-16 items-start gap-2 rounded-md border p-3 text-left transition ${
</SelectContent> isSelected
</Select> ? 'border-emerald-400 bg-emerald-500/20 text-white shadow-[inset_0_0_18px_rgba(16,185,129,0.12)]'
: 'border-white/10 bg-white/[0.045] text-white/72 active:border-white/25 active:bg-white/[0.075]'
}`}
>
<span
className={`mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border ${
isSelected
? 'border-emerald-300 bg-emerald-400 text-[#061019]'
: 'border-white/18 text-transparent'
}`}
>
<Check className="size-3.5" />
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-semibold">{court.courtName}</span>
<span className="mt-0.5 block truncate text-xs text-white/52">
{court.sport.name}
</span>
</span>
</button>
);
})}
</div>
</div>
);
}
function MobileAvailableSlots({
court,
selectedSlot,
onSelectSlot,
}: {
court: PublicAvailabilityCourt;
selectedSlot: SelectedSlot | null;
onSelectSlot: (slot: SelectedSlot) => void;
}) {
const slots = court.availableSlots;
return (
<div>
<div className="flex items-end justify-between gap-3">
<div>
<h3 className="text-base font-semibold">Horarios disponibles</h3>
<p className="mt-0.5 text-xs text-white/58">
Turnos de {court.slotDurationMinutes} minutos
</p>
</div>
<span className="shrink-0 text-xs font-medium text-emerald-300/90">
{slots.length} {slots.length === 1 ? 'turno' : 'turnos'}
</span>
</div>
{slots.length > 0 ? (
<div className="mt-3 grid grid-cols-3 gap-2">
{slots.map((slot) => {
const selected = isSlotSelected(slot, court.courtId, selectedSlot);
return (
<button
key={`${court.courtId}-${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,
price: slot.price,
})
}
aria-pressed={selected}
className={`flex h-12 items-center justify-center gap-1.5 rounded-md border text-sm font-bold transition ${
selected
? 'border-emerald-300 bg-emerald-400 text-[#061019] shadow-lg shadow-emerald-500/20'
: 'border-emerald-500/45 bg-emerald-500/12 text-white active:bg-emerald-500/24'
}`}
>
{selected && <Check className="size-4" />}
{slot.startTime}
</button>
);
})}
</div>
) : (
<p className="mt-3 rounded-md border border-white/10 bg-white/[0.035] p-3 text-sm text-white/62">
No hay horarios disponibles para esta cancha.
</p>
)}
</div> </div>
); );
} }
@@ -1025,6 +1098,7 @@ function BookingTimeline({
sportName: court.sport.name, sportName: court.sport.name,
startTime: slot.startTime, startTime: slot.startTime,
endTime: slot.endTime, endTime: slot.endTime,
price: slot.price,
}); });
}} }}
className={`absolute top-7 flex h-12 min-w-[50px] items-center justify-center rounded border text-xs transition ${ className={`absolute top-7 flex h-12 min-w-[50px] items-center justify-center rounded border text-xs transition ${
@@ -1050,19 +1124,13 @@ function BookingTimeline({
const left = ((end - range.start) / total) * 100; const left = ((end - range.start) / total) * 100;
const width = ((Math.min(nextStart, range.end) - end) / total) * 100; const width = ((Math.min(nextStart, range.end) - end) / total) * 100;
const maintenance = index % 2 === 1;
return ( return (
<div <div
key={`${court?.courtId}-blocked-${slot.startTime}`} key={`${court?.courtId}-blocked-${slot.startTime}`}
className={`absolute top-7 flex h-12 min-w-[44px] items-center justify-center rounded border ${ className="absolute top-7 flex h-12 min-w-[44px] items-center justify-center rounded border border-blue-500/70 bg-blue-500/24 text-blue-100"
maintenance
? 'border-red-500/80 bg-red-500/28 text-red-100'
: 'border-blue-500/70 bg-blue-500/24 text-blue-100'
}`}
style={{ left: `${left}%`, width: `${Math.max(width - 0.8, 4.5)}%` }} style={{ left: `${left}%`, width: `${Math.max(width - 0.8, 4.5)}%` }}
> >
{maintenance ? <Wrench className="size-4" /> : <Lock className="size-4" />} <Lock className="size-4" />
</div> </div>
); );
})} })}
@@ -1085,41 +1153,6 @@ function BookingTimeline({
); );
} }
function CourtDetails({ court }: { court?: PublicAvailabilityCourt }) {
return (
<Panel className="p-6">
<div className="flex items-center gap-6">
<div className="hidden size-24 items-center justify-center rounded border border-white/16 text-white/45 md:flex">
<MapPin className="size-14" />
</div>
<div>
<h3 className="text-lg font-semibold">
{court?.courtName ? `${court.courtName} de ${court.sport.name}` : 'Cancha'}
</h3>
<div className="mt-4 space-y-2 text-sm text-white/62">
<p className="flex items-center gap-2">
<Users className="size-4 text-emerald-400" />
Superficie: Césped sintético
</p>
<p className="flex items-center gap-2">
<Dumbbell className="size-4 text-emerald-400" />
Turnos de {court?.slotDurationMinutes ?? 60} minutos
</p>
<p className="flex items-center gap-2">
<Sparkles className="size-4 text-emerald-400" />
Iluminación LED
</p>
<p className="flex items-center gap-2">
<Shirt className="size-4 text-emerald-400" />
Vestuarios disponibles
</p>
</div>
</div>
</div>
</Panel>
);
}
function SelectedSlotCard(props: BookingShellProps & { compact?: boolean }) { function SelectedSlotCard(props: BookingShellProps & { compact?: boolean }) {
const { const {
selectedSlot, selectedSlot,
@@ -1141,9 +1174,11 @@ function SelectedSlotCard(props: BookingShellProps & { compact?: boolean }) {
useEffect(() => { useEffect(() => {
if (!selectedSlot) return; if (!selectedSlot) return;
window.requestAnimationFrame(() => { const input = customerNameInputRef.current;
customerNameInputRef.current?.focus(); if (!input) return;
});
input.focus();
input.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, [selectedSlot]); }, [selectedSlot]);
return ( return (
@@ -1169,6 +1204,13 @@ function SelectedSlotCard(props: BookingShellProps & { compact?: boolean }) {
className={compact ? 'mt-4 space-y-3' : 'mt-5 grid gap-3'} className={compact ? 'mt-4 space-y-3' : 'mt-5 grid gap-3'}
onSubmit={handleSubmit(onSubmit)} onSubmit={handleSubmit(onSubmit)}
> >
<div className="flex items-center justify-between gap-3 rounded-md border border-emerald-400/20 bg-emerald-400/[0.06] px-3 py-2.5">
<span className="text-sm text-white/72">Precio del turno</span>
<span className="shrink-0 text-base font-bold text-white">
{formatBookingPrice(selectedSlot.price)}
</span>
</div>
<div className={compact ? 'space-y-3' : 'grid grid-cols-2 gap-3'}> <div className={compact ? 'space-y-3' : 'grid grid-cols-2 gap-3'}>
<Field data-invalid={Boolean(errors.customerName)}> <Field data-invalid={Boolean(errors.customerName)}>
<FieldLabel htmlFor="customerName" className="text-white/76"> <FieldLabel htmlFor="customerName" className="text-white/76">
@@ -1204,6 +1246,24 @@ function SelectedSlotCard(props: BookingShellProps & { compact?: boolean }) {
</Field> </Field>
</div> </div>
<Field data-invalid={Boolean(errors.customerEmail)}>
<FieldLabel htmlFor="customerEmail" className="text-white/76">
Email
</FieldLabel>
<Input
id="customerEmail"
type="email"
placeholder="Ej: juan@ejemplo.com"
aria-invalid={Boolean(errors.customerEmail)}
className="border-white/10 bg-white/[0.045] text-white placeholder:text-white/32"
{...register('customerEmail')}
/>
<p className="mt-1 text-xs text-white/40">
Te enviaremos la confirmación de la reserva por email.
</p>
<FieldError errors={[errors.customerEmail]} />
</Field>
{createError && <p className="text-sm text-red-300">{createError}</p>} {createError && <p className="text-sm text-red-300">{createError}</p>}
{navigationError && <p className="text-sm text-red-300">{navigationError}</p>} {navigationError && <p className="text-sm text-red-300">{navigationError}</p>}
@@ -1221,40 +1281,6 @@ function SelectedSlotCard(props: BookingShellProps & { compact?: boolean }) {
); );
} }
function MobileBottomNav() {
const items = [
{ label: 'Reservar', icon: CalendarDays, active: true },
{ label: 'Canchas', icon: Building2 },
{ label: 'Cómo funciona', icon: Clock3 },
{ label: 'Contacto', icon: Users },
];
return (
<nav className="fixed inset-x-0 bottom-0 z-40 mx-auto max-w-[430px] border-t border-emerald-500/30 bg-[#061019]/94 px-4 py-3 backdrop-blur">
<div className="grid grid-cols-4 gap-1">
{items.map((item) => {
const Icon = item.icon;
return (
<a
key={item.label}
href={
item.label === 'Reservar' ? '#' : `#${item.label.toLowerCase().replace(' ', '-')}`
}
className={`flex flex-col items-center gap-1 text-[11px] ${
item.active ? 'text-emerald-400' : 'text-white/58'
}`}
>
<Icon className="size-5" />
{item.label}
</a>
);
})}
</div>
</nav>
);
}
export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) { export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@@ -1319,6 +1345,7 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
defaultValues: { defaultValues: {
customerName: '', customerName: '',
customerPhone: '', customerPhone: '',
customerEmail: '',
}, },
}); });
@@ -1335,6 +1362,7 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
startTime: selectedSlot.startTime, startTime: selectedSlot.startTime,
customerName: payload.customerName, customerName: payload.customerName,
customerPhone: payload.customerPhone, customerPhone: payload.customerPhone,
customerEmail: payload.customerEmail,
}); });
}, },
}); });
@@ -1476,7 +1504,8 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
visibleCourts.length, visibleCourts.length,
]); ]);
const canGoBack = windowStartOffset > 0; const currentDayIndex = dayOptions.findIndex((option) => option.value === selectedDate);
const canGoBack = windowStartOffset > 0 || currentDayIndex > 0;
const selectDate = (date: string) => { const selectDate = (date: string) => {
setSelectedDate(date); setSelectedDate(date);

View File

@@ -1,7 +1,7 @@
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import type { ComplexWithRole } from '@repo/api-contract'; import type { ComplexWithRole } from '@repo/api-contract';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import { Building2, Loader2 } from 'lucide-react'; import { Building2, Loader2 } from 'lucide-react';
@@ -13,12 +13,15 @@ export function SelectComplexPage() {
queryFn: () => apiClient.complexes.listMine(), queryFn: () => apiClient.complexes.listMine(),
}); });
const queryClient = useQueryClient();
const selectMutation = useMutation({ const selectMutation = useMutation({
mutationFn: async (complexId: string) => { mutationFn: async (complexId: string) => {
const response = await apiClient.complexes.select({ complexId }); const response = await apiClient.complexes.select({ complexId });
return response; return response;
}, },
onSuccess: () => { onSuccess: (data) => {
queryClient.setQueryData(['current-complex'], data);
navigate({ to: '/' }); navigate({ to: '/' });
}, },
}); });

View File

@@ -54,9 +54,6 @@ body,
--color-reserved: var(--reserved); --color-reserved: var(--reserved);
--color-reserved-foreground: var(--reserved-foreground); --color-reserved-foreground: var(--reserved-foreground);
--color-maintenance: var(--maintenance);
--color-maintenance-foreground: var(--maintenance-foreground);
--color-warning: var(--warning); --color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground); --color-warning-foreground: var(--warning-foreground);
@@ -117,9 +114,6 @@ body,
--reserved: oklch(0.58 0.19 255); --reserved: oklch(0.58 0.19 255);
--reserved-foreground: oklch(0.985 0.01 255); --reserved-foreground: oklch(0.985 0.01 255);
--maintenance: oklch(0.64 0.22 25);
--maintenance-foreground: oklch(0.985 0.01 25);
--warning: oklch(0.78 0.16 75); --warning: oklch(0.78 0.16 75);
--warning-foreground: oklch(0.18 0.04 75); --warning-foreground: oklch(0.18 0.04 75);
@@ -176,9 +170,6 @@ body,
--reserved: oklch(0.61 0.2 255); --reserved: oklch(0.61 0.2 255);
--reserved-foreground: oklch(0.97 0.012 255); --reserved-foreground: oklch(0.97 0.012 255);
--maintenance: oklch(0.66 0.23 25);
--maintenance-foreground: oklch(0.98 0.01 25);
--warning: oklch(0.78 0.17 75); --warning: oklch(0.78 0.17 75);
--warning-foreground: oklch(0.18 0.04 75); --warning-foreground: oklch(0.18 0.04 75);

View File

@@ -5,24 +5,40 @@ import * as api from './api';
const apiBaseUrl = api.apiBaseUrl; const apiBaseUrl = api.apiBaseUrl;
const plugins = [ const infraPlugins = import.meta.env.VITE_BETTER_AUTH_INFRA === 'true' ? [sentinelClient()] : [];
sentinelClient(),
inferAdditionalFields({
user: {
phone: {
type: 'string',
required: false,
},
},
}),
];
export const authClient = createAuthClient({ export const authClient = createAuthClient({
baseURL: apiBaseUrl, baseURL: apiBaseUrl,
fetchOptions: { fetchOptions: {
credentials: 'include', credentials: 'include',
}, },
plugins: import.meta.env.VITE_BETTER_AUTH_INFRA === 'true' ? plugins : [], plugins: [
...infraPlugins,
inferAdditionalFields({
user: {
phone: {
type: 'string',
required: false,
},
role: {
type: 'string',
required: false,
},
banned: {
type: 'boolean',
required: false,
},
bannedAt: {
type: 'string',
required: false,
},
banReason: {
type: 'string',
required: false,
},
},
}),
],
}); });
export class ApiClientError extends Error { export class ApiClientError extends Error {
@@ -48,10 +64,12 @@ export const apiClient = {
complexes: api.complexes, complexes: api.complexes,
sports: api.sports, sports: api.sports,
courts: api.courts, courts: api.courts,
admin: api.admin,
publicBookings: { publicBookings: {
getAvailability: api.getAvailability, getAvailability: api.getAvailability,
create: api.createPublic, create: api.createPublic,
getConfirmation: api.getConfirmation, getConfirmation: api.getConfirmation,
cancelPublic: api.cancelPublic,
}, },
adminBookings: { adminBookings: {
listByComplex: api.listByComplex, listByComplex: api.listByComplex,

View File

@@ -17,6 +17,7 @@ export class ApiClientError extends Error {
export type ErrorHandlers = { export type ErrorHandlers = {
onForbidden?: (error: ApiClientError) => void | Promise<void>; onForbidden?: (error: ApiClientError) => void | Promise<void>;
onUnauthorized?: (error: ApiClientError) => void | Promise<void>;
onError?: (error: ApiClientError) => void | Promise<void>; onError?: (error: ApiClientError) => void | Promise<void>;
}; };
@@ -28,6 +29,7 @@ export const apiBaseUrl =
export function configureApiClient(nextHandlers: ErrorHandlers) { export function configureApiClient(nextHandlers: ErrorHandlers) {
handlers.onForbidden = nextHandlers.onForbidden; handlers.onForbidden = nextHandlers.onForbidden;
handlers.onUnauthorized = nextHandlers.onUnauthorized;
handlers.onError = nextHandlers.onError; handlers.onError = nextHandlers.onError;
} }

View File

@@ -10,13 +10,35 @@ export const http: AxiosInstance = axios.create({
}); });
function extractMessage(details: unknown, fallback: string): string { function extractMessage(details: unknown, fallback: string): string {
if ( if (typeof details === 'object' && details) {
typeof details === 'object' && if ('message' in details && typeof details.message === 'string') {
details && return details.message;
'message' in details && }
typeof details.message === 'string' if ('detail' in details && typeof details.detail === 'string') {
) { return details.detail;
return details.message; }
if ('error' in details && typeof details.error === 'object' && details.error) {
const zodError = details.error as Record<string, unknown>;
if (Array.isArray(zodError.issues) && zodError.issues.length > 0) {
const firstIssue = zodError.issues[0] as { message?: string };
if (typeof firstIssue.message === 'string') return firstIssue.message;
}
if (typeof zodError.message === 'string') {
try {
const parsed = JSON.parse(zodError.message);
if (
Array.isArray(parsed) &&
parsed.length > 0 &&
typeof parsed[0]?.message === 'string'
) {
return parsed[0].message;
}
} catch {
/* not JSON, use raw string */
}
return zodError.message;
}
}
} }
return fallback; return fallback;
} }
@@ -47,6 +69,10 @@ http.interceptors.response.use(
async (error: AxiosError) => { async (error: AxiosError) => {
const normalized = normalizeError(error); const normalized = normalizeError(error);
if (normalized.status === 401 && handlers.onUnauthorized) {
await handlers.onUnauthorized(normalized);
}
if (normalized.status === 403 && handlers.onForbidden) { if (normalized.status === 403 && handlers.onForbidden) {
await handlers.onForbidden(normalized); await handlers.onForbidden(normalized);
} }

View File

@@ -6,8 +6,10 @@ export * as courts from './resources/courts';
export * as user from './resources/user'; export * as user from './resources/user';
export * as sports from './resources/sports'; export * as sports from './resources/sports';
export * as plans from './resources/plans'; export * as plans from './resources/plans';
export * as admin from './resources/admin';
export { export {
cancelPublic,
getAvailability, getAvailability,
createPublic, createPublic,
getConfirmation, getConfirmation,

View File

@@ -0,0 +1,96 @@
import type {
AdminBlockUserInput,
AdminComplexListItem,
AdminCreatePlanInput,
AdminGeoStats,
AdminGlobalStats,
AdminUpdatePlanInput,
AdminUser,
AdminUserSession,
} from '@repo/api-contract';
import { http } from '../http';
type AdminPlan = {
code: string;
name: string;
price: number;
rules: unknown;
lastUpdatedAt: string;
complexCount: number;
};
export async function listComplexes() {
const response = await http.get<AdminComplexListItem[]>('/api/admin/complexes');
return response.data;
}
export async function listPlans() {
const response = await http.get<AdminPlan[]>('/api/admin/plans');
return response.data;
}
export async function createPlan(data: AdminCreatePlanInput) {
const response = await http.post<{ code: string; name: string; price: number }>(
'/api/admin/plans',
JSON.stringify(data),
{ headers: { 'Content-Type': 'application/json' } }
);
return response.data;
}
export async function updatePlan(code: string, data: AdminUpdatePlanInput) {
const response = await http.patch<{ code: string; name: string; price: number }>(
`/api/admin/plans/${code}`,
JSON.stringify(data),
{ headers: { 'Content-Type': 'application/json' } }
);
return response.data;
}
export async function deletePlan(code: string) {
const response = await http.delete<{ message: string }>(`/api/admin/plans/${code}`);
return response.data;
}
export async function listUsers(search?: string) {
const response = await http.get<AdminUser[]>('/api/admin/users', {
params: search ? { search } : undefined,
});
return response.data;
}
export async function blockUser(userId: string, data?: AdminBlockUserInput) {
const response = await http.post<{ message: string }>(
`/api/admin/users/${userId}/block`,
JSON.stringify(data ?? {}),
{ headers: { 'Content-Type': 'application/json' } }
);
return response.data;
}
export async function unblockUser(userId: string) {
const response = await http.post<{ message: string }>(`/api/admin/users/${userId}/unblock`);
return response.data;
}
export async function getGlobalStats() {
const response = await http.get<AdminGlobalStats>('/api/admin/stats');
return response.data;
}
export async function getGeoStats() {
const response = await http.get<AdminGeoStats>('/api/admin/geo-stats');
return response.data;
}
export async function getUserSessions(userId: string) {
const response = await http.get<AdminUserSession[]>(`/api/admin/users/${userId}/sessions`);
return response.data;
}
export async function revokeAllSessions(userId: string) {
const response = await http.post<{ message: string }>(
`/api/admin/users/${userId}/sessions/revoke-all`
);
return response.data;
}

View File

@@ -1,5 +1,6 @@
import type { import type {
AdminBooking, AdminBooking,
CancelPublicBookingInput,
CreateAdminBookingInput, CreateAdminBookingInput,
CreatePublicBookingInput, CreatePublicBookingInput,
PublicAvailabilityResponse, PublicAvailabilityResponse,
@@ -51,6 +52,14 @@ export async function createAdmin(complexId: string, payload: CreateAdminBooking
return response.data; return response.data;
} }
export async function cancelPublic(complexSlug: string, payload: CancelPublicBookingInput) {
const response = await http.post<PublicBooking>(
`/api/public-bookings/complex/${complexSlug}/cancel`,
payload
);
return response.data;
}
export async function updateStatus(bookingId: string, payload: UpdateAdminBookingStatusInput) { export async function updateStatus(bookingId: string, payload: UpdateAdminBookingStatusInput) {
const response = await http.patch<AdminBooking>( const response = await http.patch<AdminBooking>(
`/api/admin-bookings/${bookingId}/status`, `/api/admin-bookings/${bookingId}/status`,

View File

@@ -27,6 +27,14 @@ function AppRouter() {
useEffect(() => { useEffect(() => {
configureApiClient({ configureApiClient({
onUnauthorized: async () => {
if (!auth.isAuthenticated) {
return;
}
await auth.signOut();
await router.navigate({ to: '/login' });
},
onForbidden: async (error) => { onForbidden: async (error) => {
if (error.message?.includes('verificar tu email')) { if (error.message?.includes('verificar tu email')) {
return; return;

View File

@@ -23,6 +23,11 @@ import { Route as ComplexSlugBookingRouteImport } from './routes/$complexSlug/bo
import { Route as AppAuthenticatedRouteRouteImport } from './routes/_app/_authenticated/route' import { Route as AppAuthenticatedRouteRouteImport } from './routes/_app/_authenticated/route'
import { Route as AppAuthenticatedIndexRouteImport } from './routes/_app/_authenticated/index' import { Route as AppAuthenticatedIndexRouteImport } from './routes/_app/_authenticated/index'
import { Route as ComplexSlugBookingIndexRouteImport } from './routes/$complexSlug/booking/index' import { Route as ComplexSlugBookingIndexRouteImport } from './routes/$complexSlug/booking/index'
import { Route as AppAuthenticatedAdminRouteRouteImport } from './routes/_app/_authenticated/admin/route'
import { Route as AppAuthenticatedAdminIndexRouteImport } from './routes/_app/_authenticated/admin/index'
import { Route as AppAuthenticatedAdminUsersRouteImport } from './routes/_app/_authenticated/admin/users'
import { Route as AppAuthenticatedAdminPlansRouteImport } from './routes/_app/_authenticated/admin/plans'
import { Route as AppAuthenticatedAdminComplexesRouteImport } from './routes/_app/_authenticated/admin/complexes'
import { Route as ComplexSlugBookingConfirmedBookingCodeRouteImport } from './routes/$complexSlug/booking/confirmed/$bookingCode' import { Route as ComplexSlugBookingConfirmedBookingCodeRouteImport } from './routes/$complexSlug/booking/confirmed/$bookingCode'
import { Route as AppAuthenticatedComplexSlugEditRouteImport } from './routes/_app/_authenticated/complex/$slug/edit' import { Route as AppAuthenticatedComplexSlugEditRouteImport } from './routes/_app/_authenticated/complex/$slug/edit'
@@ -94,6 +99,36 @@ const ComplexSlugBookingIndexRoute = ComplexSlugBookingIndexRouteImport.update({
path: '/', path: '/',
getParentRoute: () => ComplexSlugBookingRoute, getParentRoute: () => ComplexSlugBookingRoute,
} as any) } as any)
const AppAuthenticatedAdminRouteRoute =
AppAuthenticatedAdminRouteRouteImport.update({
id: '/admin',
path: '/admin',
getParentRoute: () => AppAuthenticatedRouteRoute,
} as any)
const AppAuthenticatedAdminIndexRoute =
AppAuthenticatedAdminIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AppAuthenticatedAdminRouteRoute,
} as any)
const AppAuthenticatedAdminUsersRoute =
AppAuthenticatedAdminUsersRouteImport.update({
id: '/users',
path: '/users',
getParentRoute: () => AppAuthenticatedAdminRouteRoute,
} as any)
const AppAuthenticatedAdminPlansRoute =
AppAuthenticatedAdminPlansRouteImport.update({
id: '/plans',
path: '/plans',
getParentRoute: () => AppAuthenticatedAdminRouteRoute,
} as any)
const AppAuthenticatedAdminComplexesRoute =
AppAuthenticatedAdminComplexesRouteImport.update({
id: '/complexes',
path: '/complexes',
getParentRoute: () => AppAuthenticatedAdminRouteRoute,
} as any)
const ComplexSlugBookingConfirmedBookingCodeRoute = const ComplexSlugBookingConfirmedBookingCodeRoute =
ComplexSlugBookingConfirmedBookingCodeRouteImport.update({ ComplexSlugBookingConfirmedBookingCodeRouteImport.update({
id: '/confirmed/$bookingCode', id: '/confirmed/$bookingCode',
@@ -119,8 +154,13 @@ export interface FileRoutesByFullPath {
'/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren '/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren
'/profile': typeof AppProfileRoute '/profile': typeof AppProfileRoute
'/onboard/setup': typeof OnboardSetupRoute '/onboard/setup': typeof OnboardSetupRoute
'/admin': typeof AppAuthenticatedAdminRouteRouteWithChildren
'/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute '/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute '/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
'/admin/complexes': typeof AppAuthenticatedAdminComplexesRoute
'/admin/plans': typeof AppAuthenticatedAdminPlansRoute
'/admin/users': typeof AppAuthenticatedAdminUsersRoute
'/admin/': typeof AppAuthenticatedAdminIndexRoute
'/complex/$slug/edit': typeof AppAuthenticatedComplexSlugEditRoute '/complex/$slug/edit': typeof AppAuthenticatedComplexSlugEditRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
@@ -136,6 +176,10 @@ export interface FileRoutesByTo {
'/onboard/setup': typeof OnboardSetupRoute '/onboard/setup': typeof OnboardSetupRoute
'/$complexSlug/booking': typeof ComplexSlugBookingIndexRoute '/$complexSlug/booking': typeof ComplexSlugBookingIndexRoute
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute '/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
'/admin/complexes': typeof AppAuthenticatedAdminComplexesRoute
'/admin/plans': typeof AppAuthenticatedAdminPlansRoute
'/admin/users': typeof AppAuthenticatedAdminUsersRoute
'/admin': typeof AppAuthenticatedAdminIndexRoute
'/complex/$slug/edit': typeof AppAuthenticatedComplexSlugEditRoute '/complex/$slug/edit': typeof AppAuthenticatedComplexSlugEditRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
@@ -152,9 +196,14 @@ export interface FileRoutesById {
'/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren '/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren
'/_app/profile': typeof AppProfileRoute '/_app/profile': typeof AppProfileRoute
'/onboard/setup': typeof OnboardSetupRoute '/onboard/setup': typeof OnboardSetupRoute
'/_app/_authenticated/admin': typeof AppAuthenticatedAdminRouteRouteWithChildren
'/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute '/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute
'/_app/_authenticated/': typeof AppAuthenticatedIndexRoute '/_app/_authenticated/': typeof AppAuthenticatedIndexRoute
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute '/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
'/_app/_authenticated/admin/complexes': typeof AppAuthenticatedAdminComplexesRoute
'/_app/_authenticated/admin/plans': typeof AppAuthenticatedAdminPlansRoute
'/_app/_authenticated/admin/users': typeof AppAuthenticatedAdminUsersRoute
'/_app/_authenticated/admin/': typeof AppAuthenticatedAdminIndexRoute
'/_app/_authenticated/complex/$slug/edit': typeof AppAuthenticatedComplexSlugEditRoute '/_app/_authenticated/complex/$slug/edit': typeof AppAuthenticatedComplexSlugEditRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
@@ -171,8 +220,13 @@ export interface FileRouteTypes {
| '/$complexSlug/booking' | '/$complexSlug/booking'
| '/profile' | '/profile'
| '/onboard/setup' | '/onboard/setup'
| '/admin'
| '/$complexSlug/booking/' | '/$complexSlug/booking/'
| '/$complexSlug/booking/confirmed/$bookingCode' | '/$complexSlug/booking/confirmed/$bookingCode'
| '/admin/complexes'
| '/admin/plans'
| '/admin/users'
| '/admin/'
| '/complex/$slug/edit' | '/complex/$slug/edit'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
@@ -188,6 +242,10 @@ export interface FileRouteTypes {
| '/onboard/setup' | '/onboard/setup'
| '/$complexSlug/booking' | '/$complexSlug/booking'
| '/$complexSlug/booking/confirmed/$bookingCode' | '/$complexSlug/booking/confirmed/$bookingCode'
| '/admin/complexes'
| '/admin/plans'
| '/admin/users'
| '/admin'
| '/complex/$slug/edit' | '/complex/$slug/edit'
id: id:
| '__root__' | '__root__'
@@ -203,9 +261,14 @@ export interface FileRouteTypes {
| '/$complexSlug/booking' | '/$complexSlug/booking'
| '/_app/profile' | '/_app/profile'
| '/onboard/setup' | '/onboard/setup'
| '/_app/_authenticated/admin'
| '/$complexSlug/booking/' | '/$complexSlug/booking/'
| '/_app/_authenticated/' | '/_app/_authenticated/'
| '/$complexSlug/booking/confirmed/$bookingCode' | '/$complexSlug/booking/confirmed/$bookingCode'
| '/_app/_authenticated/admin/complexes'
| '/_app/_authenticated/admin/plans'
| '/_app/_authenticated/admin/users'
| '/_app/_authenticated/admin/'
| '/_app/_authenticated/complex/$slug/edit' | '/_app/_authenticated/complex/$slug/edit'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
@@ -321,6 +384,41 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ComplexSlugBookingIndexRouteImport preLoaderRoute: typeof ComplexSlugBookingIndexRouteImport
parentRoute: typeof ComplexSlugBookingRoute parentRoute: typeof ComplexSlugBookingRoute
} }
'/_app/_authenticated/admin': {
id: '/_app/_authenticated/admin'
path: '/admin'
fullPath: '/admin'
preLoaderRoute: typeof AppAuthenticatedAdminRouteRouteImport
parentRoute: typeof AppAuthenticatedRouteRoute
}
'/_app/_authenticated/admin/': {
id: '/_app/_authenticated/admin/'
path: '/'
fullPath: '/admin/'
preLoaderRoute: typeof AppAuthenticatedAdminIndexRouteImport
parentRoute: typeof AppAuthenticatedAdminRouteRoute
}
'/_app/_authenticated/admin/users': {
id: '/_app/_authenticated/admin/users'
path: '/users'
fullPath: '/admin/users'
preLoaderRoute: typeof AppAuthenticatedAdminUsersRouteImport
parentRoute: typeof AppAuthenticatedAdminRouteRoute
}
'/_app/_authenticated/admin/plans': {
id: '/_app/_authenticated/admin/plans'
path: '/plans'
fullPath: '/admin/plans'
preLoaderRoute: typeof AppAuthenticatedAdminPlansRouteImport
parentRoute: typeof AppAuthenticatedAdminRouteRoute
}
'/_app/_authenticated/admin/complexes': {
id: '/_app/_authenticated/admin/complexes'
path: '/complexes'
fullPath: '/admin/complexes'
preLoaderRoute: typeof AppAuthenticatedAdminComplexesRouteImport
parentRoute: typeof AppAuthenticatedAdminRouteRoute
}
'/$complexSlug/booking/confirmed/$bookingCode': { '/$complexSlug/booking/confirmed/$bookingCode': {
id: '/$complexSlug/booking/confirmed/$bookingCode' id: '/$complexSlug/booking/confirmed/$bookingCode'
path: '/confirmed/$bookingCode' path: '/confirmed/$bookingCode'
@@ -338,12 +436,34 @@ declare module '@tanstack/react-router' {
} }
} }
interface AppAuthenticatedAdminRouteRouteChildren {
AppAuthenticatedAdminComplexesRoute: typeof AppAuthenticatedAdminComplexesRoute
AppAuthenticatedAdminPlansRoute: typeof AppAuthenticatedAdminPlansRoute
AppAuthenticatedAdminUsersRoute: typeof AppAuthenticatedAdminUsersRoute
AppAuthenticatedAdminIndexRoute: typeof AppAuthenticatedAdminIndexRoute
}
const AppAuthenticatedAdminRouteRouteChildren: AppAuthenticatedAdminRouteRouteChildren =
{
AppAuthenticatedAdminComplexesRoute: AppAuthenticatedAdminComplexesRoute,
AppAuthenticatedAdminPlansRoute: AppAuthenticatedAdminPlansRoute,
AppAuthenticatedAdminUsersRoute: AppAuthenticatedAdminUsersRoute,
AppAuthenticatedAdminIndexRoute: AppAuthenticatedAdminIndexRoute,
}
const AppAuthenticatedAdminRouteRouteWithChildren =
AppAuthenticatedAdminRouteRoute._addFileChildren(
AppAuthenticatedAdminRouteRouteChildren,
)
interface AppAuthenticatedRouteRouteChildren { interface AppAuthenticatedRouteRouteChildren {
AppAuthenticatedAdminRouteRoute: typeof AppAuthenticatedAdminRouteRouteWithChildren
AppAuthenticatedIndexRoute: typeof AppAuthenticatedIndexRoute AppAuthenticatedIndexRoute: typeof AppAuthenticatedIndexRoute
AppAuthenticatedComplexSlugEditRoute: typeof AppAuthenticatedComplexSlugEditRoute AppAuthenticatedComplexSlugEditRoute: typeof AppAuthenticatedComplexSlugEditRoute
} }
const AppAuthenticatedRouteRouteChildren: AppAuthenticatedRouteRouteChildren = { const AppAuthenticatedRouteRouteChildren: AppAuthenticatedRouteRouteChildren = {
AppAuthenticatedAdminRouteRoute: AppAuthenticatedAdminRouteRouteWithChildren,
AppAuthenticatedIndexRoute: AppAuthenticatedIndexRoute, AppAuthenticatedIndexRoute: AppAuthenticatedIndexRoute,
AppAuthenticatedComplexSlugEditRoute: AppAuthenticatedComplexSlugEditRoute, AppAuthenticatedComplexSlugEditRoute: AppAuthenticatedComplexSlugEditRoute,
} }

View File

@@ -0,0 +1,6 @@
import { ComplexesPage } from '@/features/admin/complexes-page';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/_app/_authenticated/admin/complexes')({
component: ComplexesPage,
});

View File

@@ -0,0 +1,6 @@
import { AdminPage } from '@/features/admin/admin-page';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/_app/_authenticated/admin/')({
component: AdminPage,
});

View File

@@ -0,0 +1,6 @@
import { PlansPage } from '@/features/admin/plans-page';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/_app/_authenticated/admin/plans')({
component: PlansPage,
});

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