14 Commits

Author SHA1 Message Date
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
46 changed files with 1538 additions and 1779 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

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

@@ -85,6 +85,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 +108,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,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

@@ -82,11 +82,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

@@ -106,11 +106,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
* *

File diff suppressed because one or more lines are too long

View File

@@ -397,7 +397,6 @@ export const ModelName = {
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 +414,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions omit: GlobalOmitOptions
} }
meta: { meta: {
modelProps: "user" | "session" | "account" | "verification" | "complex" | "complexUser" | "complexInvitation" | "sport" | "court" | "courtAvailability" | "courtPriceRule" | "courtBooking" | "courtBookingLog" | "onboardingRequest" | "passwordResetRequest" | "plan" modelProps: "user" | "session" | "account" | "verification" | "complex" | "complexUser" | "complexInvitation" | "sport" | "court" | "courtAvailability" | "courtPriceRule" | "courtBooking" | "courtBookingLog" | "passwordResetRequest" | "plan"
txIsolationLevel: TransactionIsolationLevel txIsolationLevel: TransactionIsolationLevel
} }
model: { model: {
@@ -1381,80 +1380,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
@@ -1806,6 +1731,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 +1751,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',
@@ -2162,7 +2071,6 @@ export type GlobalOmitConfig = {
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

@@ -64,7 +64,6 @@ export const ModelName = {
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
@@ -249,6 +248,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 +268,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

@@ -21,7 +21,6 @@ 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

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

@@ -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';
@@ -34,10 +35,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

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

@@ -2,6 +2,7 @@ import { randomInt } from 'node:crypto';
import { CourtBookingStatus } from '@/generated/prisma/enums'; import { CourtBookingStatus } from '@/generated/prisma/enums';
import { db } from '@/lib/prisma'; import { db } from '@/lib/prisma';
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service'; import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service';
import type { DayOfWeek } from '@repo/api-contract';
import type { import type {
AdminBooking, AdminBooking,
CreateAdminBookingInput, CreateAdminBookingInput,
@@ -156,6 +157,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 +199,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 +216,7 @@ function mapBookingResponse(booking: {
slug: string; slug: string;
}; };
}; };
price?: number;
}): AdminBooking { }): AdminBooking {
return { return {
id: booking.id, id: booking.id,
@@ -198,6 +235,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 +331,10 @@ export async function createAdminBooking(
slug: true, slug: true,
}, },
}, },
priceRules: {
where: { isActive: true },
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
},
}, },
}); });
@@ -381,6 +424,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 +451,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 +567,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,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

@@ -2,6 +2,7 @@ import { randomInt } from 'node:crypto';
import { db } from '@/lib/prisma'; import { db } from '@/lib/prisma';
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service'; import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service';
import type { import type {
CancelPublicBookingInput,
CreatePublicBookingInput, CreatePublicBookingInput,
DayOfWeek, DayOfWeek,
PublicAvailabilityQuery, PublicAvailabilityQuery,
@@ -42,7 +43,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;
} }
} }
@@ -276,6 +276,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;
@@ -304,7 +306,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(),
}; };
} }
@@ -325,6 +329,7 @@ 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: {
@@ -381,6 +386,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(),
}; };
} }
@@ -625,6 +631,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: {
@@ -636,11 +643,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,
@@ -650,6 +660,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,
@@ -700,3 +712,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.', 403);
}
const date = formatIsoDate(booking.bookingDate);
const { dayOfWeek } = parseIsoDate(date);
const price = resolveSlotPrice(booking.court, dayOfWeek, {
startTime: booking.startTime,
endTime: booking.endTime,
});
await db.courtBookingLog.create({
data: {
id: uuidv7(),
bookingCode: booking.bookingCode,
courtId: booking.courtId,
bookingDate: booking.bookingDate,
startTime: booking.startTime,
endTime: booking.endTime,
customerName: booking.customerName,
customerPhone: booking.customerPhone,
customerEmail: booking.customerEmail,
previousStatus: booking.status,
newStatus: 'CANCELLED',
},
});
await db.courtBooking.delete({
where: { id: booking.id },
});
return mapBookingResponse({
bookingId: booking.id,
bookingCode: booking.bookingCode,
bookingDate: booking.bookingDate,
createdAt: booking.createdAt,
startTime: booking.startTime,
endTime: booking.endTime,
customerName: booking.customerName,
customerPhone: booking.customerPhone,
customerEmail: booking.customerEmail,
price,
status: 'CANCELLED',
court: {
id: booking.court.id,
name: booking.court.name,
complexId: booking.court.complex.id,
complexName: booking.court.complex.complexName,
complexSlug: booking.court.complex.complexSlug,
sport: booking.court.sport,
},
});
}

View File

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

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

@@ -6,7 +6,6 @@ import type { PrismaClient } from '@/generated/prisma/client';
import type { PrismaClientMock } from 'bun-mock-prisma'; import type { PrismaClientMock } from 'bun-mock-prisma';
const prismaMock = createPrismaMock<PrismaClient>() as PrismaClientMock<PrismaClient>; const prismaMock = createPrismaMock<PrismaClient>() as PrismaClientMock<PrismaClient>;
const uuidV7Mock = mock(() => '00000000-0000-4000-8000-000000000001');
mock.module('@/lib/prisma', () => ({ mock.module('@/lib/prisma', () => ({
__esModule: true, __esModule: true,
@@ -15,10 +14,7 @@ mock.module('@/lib/prisma', () => ({
}, },
})); }));
mock.module('uuid', () => ({ const { uuidV7Mock } = await import('../support/prisma.mock');
__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 +22,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

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

@@ -41,6 +41,7 @@ interface CreateManualBookingPayload {
startTime: string; startTime: string;
customerName: string; customerName: string;
customerPhone: string; customerPhone: string;
customerEmail: string;
} }
interface BookingContextValue { interface BookingContextValue {
@@ -315,6 +316,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

@@ -42,6 +42,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.'),
}); });
type BookingForm = z.infer<typeof bookingFormSchema>; type BookingForm = z.infer<typeof bookingFormSchema>;
@@ -76,6 +77,7 @@ export function BookingCreateDialog() {
defaultValues: { defaultValues: {
customerName: '', customerName: '',
customerPhone: '', customerPhone: '',
customerEmail: '',
}, },
}); });
@@ -154,6 +156,7 @@ export function BookingCreateDialog() {
startTime, startTime,
customerName: values.customerName, customerName: values.customerName,
customerPhone: values.customerPhone, customerPhone: values.customerPhone,
customerEmail: values.customerEmail,
}); });
}; };
@@ -271,6 +274,23 @@ 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>

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

@@ -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);
@@ -61,16 +62,21 @@ export function ShareWhatsappButton({ confirmation }: ShareWhatsappButtonProps)
className="h-12 w-full rounded-xl border-slate-200 bg-white text-sm font-semibold text-slate-900 hover:bg-slate-50 sm:text-base" className="h-12 w-full rounded-xl border-slate-200 bg-white text-sm font-semibold text-slate-900 hover:bg-slate-50 sm:text-base"
asChild asChild
> >
<a href={whatsappUrl} target="_blank" rel="noopener noreferrer"> <a
href={whatsappUrl}
target="_blank"
rel="noopener noreferrer"
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="sm:hidden">Compartir por WhatsApp</span>
</a> </a>
</Button> </Button>
); );

View File

@@ -1,7 +1,17 @@
import PlayzerIcon from '@/assets/playzer-favicon-512-transparent.png'; import PlayzerIcon from '@/assets/playzer-favicon-512-transparent.png';
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 { import {
AlertCircle, AlertCircle,
@@ -12,7 +22,9 @@ import {
LoaderCircle, LoaderCircle,
Receipt, Receipt,
Trophy, Trophy,
XCircle,
} from 'lucide-react'; } 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 = {
@@ -21,14 +33,12 @@ 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);
@@ -58,23 +68,70 @@ 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-[#edf7f4] text-[#111827]"> <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-4 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-[28px] border border-emerald-950/10 bg-white p-5 shadow-[0_24px_70px_rgba(15,23,42,0.12)] 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 && (
<div className="flex items-center gap-3 text-sm text-slate-500"> <div className="flex items-center gap-3 text-sm text-slate-500">
<LoaderCircle className="size-5 animate-spin text-emerald-600" /> <LoaderCircle className="size-5 animate-spin text-emerald-600" />
Cargando confirmación... Cargando confirmación...
</div> </div>
)} )}
{confirmationQuery.isError && ( {confirmationQuery.isError && !cancelledBooking && (
<div className="flex gap-3 rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-700"> <div className="flex gap-3 rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
<AlertCircle className="mt-0.5 size-5 shrink-0" /> <AlertCircle className="mt-0.5 size-5 shrink-0" />
<p> <p>
@@ -86,16 +143,23 @@ export function PublicBookingConfirmationPage({
</div> </div>
)} )}
{confirmationQuery.data && ( {displayData && (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"> <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div> <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"> {isCancelled ? (
<CheckCircle2 className="size-4" /> <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">
Reserva confirmada <XCircle className="size-4" />
</div> Reserva cancelada
</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"> <h1 className="mt-3 text-2xl font-bold tracking-tight sm:text-3xl">
{confirmationQuery.data.complexName} {displayData.complexName}
</h1> </h1>
</div> </div>
<div className="flex items-center gap-2 text-slate-600"> <div className="flex items-center gap-2 text-slate-600">
@@ -112,17 +176,17 @@ export function PublicBookingConfirmationPage({
Tu turno Tu turno
</p> </p>
<p className="mt-3 text-2xl font-black leading-tight tracking-tight text-slate-950 sm:text-4xl"> <p className="mt-3 text-2xl font-black leading-tight tracking-tight text-slate-950 sm:text-4xl">
{formatDateLabel(confirmationQuery.data.date)} {formatDateLabel(displayData.date)}
</p> </p>
<p className="mt-2 flex items-center gap-2 text-3xl font-black leading-none text-emerald-700 sm:text-5xl"> <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" /> <Clock3 className="size-7 sm:size-9" />
{confirmationQuery.data.startTime} - {confirmationQuery.data.endTime} {displayData.startTime} - {displayData.endTime}
</p> </p>
</div> </div>
<div className="rounded-2xl border border-emerald-600/20 bg-white/80 px-4 py-3 sm:min-w-40 sm:text-right"> <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="text-xs font-medium text-slate-500">Precio del turno</p>
<p className="mt-1 text-2xl font-bold text-slate-950"> <p className="mt-1 text-2xl font-bold text-slate-950">
{formatBookingPrice(confirmationQuery.data.price)} {formatBookingPrice(displayData.price)}
</p> </p>
</div> </div>
</div> </div>
@@ -136,7 +200,7 @@ export function PublicBookingConfirmationPage({
Cancha Cancha
</p> </p>
<p className="mt-2 text-base font-semibold text-slate-950"> <p className="mt-2 text-base font-semibold text-slate-950">
{confirmationQuery.data.courtName} · {confirmationQuery.data.sport.name} {displayData.courtName} · {displayData.sport.name}
</p> </p>
</div> </div>
<div className="bg-white p-4"> <div className="bg-white p-4">
@@ -145,32 +209,90 @@ export function PublicBookingConfirmationPage({
Código de reserva Código de reserva
</p> </p>
<p className="mt-2 font-mono text-lg font-bold tracking-[0.18em] text-slate-950"> <p className="mt-2 font-mono text-lg font-bold tracking-[0.18em] text-slate-950">
{confirmationQuery.data.bookingCode} {displayData.bookingCode}
</p> </p>
</div> </div>
</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-12 w-full rounded-xl bg-emerald-600 text-sm font-semibold text-white hover:bg-emerald-500 sm:text-base" </div>
onClick={() => { ) : (
void navigate({ <div className="grid grid-cols-3 gap-2 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}
<ArrowRight className="size-4" /> >
</Button> Cancelar
</div> </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

@@ -62,6 +62,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>;
@@ -1294,6 +1295,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>}
@@ -1409,6 +1428,7 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
defaultValues: { defaultValues: {
customerName: '', customerName: '',
customerPhone: '', customerPhone: '',
customerEmail: '',
}, },
}); });
@@ -1425,6 +1445,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,
}); });
}, },
}); });

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

@@ -52,6 +52,7 @@ export const apiClient = {
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

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

View File

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

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`,

2
bunfig.toml Normal file
View File

@@ -0,0 +1,2 @@
[test]
preload = ["./apps/backend/test/support/prisma.mock.ts"]

View File

@@ -19,7 +19,8 @@
"lint:frontend": "bun --filter frontend lint", "lint:frontend": "bun --filter frontend lint",
"lint:frontend:fix": "bun --filter frontend lint:fix", "lint:frontend:fix": "bun --filter frontend lint:fix",
"lint:backend": "bun --filter backend lint", "lint:backend": "bun --filter backend lint",
"lint:backend:fix": "bun --filter backend lint:fix" "lint:backend:fix": "bun --filter backend lint:fix",
"test": "bun --filter backend test"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^1.9.4", "@biomejs/biome": "^1.9.4",

View File

@@ -25,6 +25,8 @@ export const adminBookingSchema = z.object({
endTime: z.string().regex(TIME_REGEX), endTime: z.string().regex(TIME_REGEX),
customerName: z.string(), customerName: z.string(),
customerPhone: z.string(), customerPhone: z.string(),
customerEmail: z.string(),
price: z.number().nonnegative(),
status: bookingStatusSchema, status: bookingStatusSchema,
createdAt: z.string().datetime(), createdAt: z.string().datetime(),
updatedAt: z.string().datetime(), updatedAt: z.string().datetime(),
@@ -52,6 +54,9 @@ export const createAdminBookingSchema = z.object({
.trim() .trim()
.min(6, 'El telefono debe tener al menos 6 caracteres.') .min(6, 'El telefono debe tener al menos 6 caracteres.')
.max(30, 'El telefono no puede superar los 30 caracteres.'), .max(30, 'El telefono no puede superar los 30 caracteres.'),
customerEmail: z
.string()
.email('El email ingresado no es valido.'),
}) })
export const updateAdminBookingStatusSchema = z.object({ export const updateAdminBookingStatusSchema = z.object({

View File

@@ -98,9 +98,11 @@ export {
publicBookingSchema, publicBookingSchema,
publicBookingSlotSchema, publicBookingSlotSchema,
publicBookingSportSchema, publicBookingSportSchema,
cancelPublicBookingSchema,
} from './public-booking' } from './public-booking'
export type { export type {
CreatePublicBookingInput, CreatePublicBookingInput,
CancelPublicBookingInput,
PublicAvailabilityCourt, PublicAvailabilityCourt,
PublicAvailabilityQuery, PublicAvailabilityQuery,
PublicAvailabilityResponse, PublicAvailabilityResponse,

View File

@@ -57,6 +57,18 @@ export const createPublicBookingSchema = z.object({
.trim() .trim()
.min(6, 'El telefono debe tener al menos 6 caracteres.') .min(6, 'El telefono debe tener al menos 6 caracteres.')
.max(30, 'El telefono no puede superar los 30 caracteres.'), .max(30, 'El telefono no puede superar los 30 caracteres.'),
customerEmail: z
.string()
.email('El email ingresado no es valido.'),
})
export const cancelPublicBookingSchema = z.object({
bookingCode: z.string().regex(BOOKING_CODE_REGEX, 'El codigo de reserva no es valido.'),
customerPhone: z
.string()
.trim()
.min(6, 'El telefono debe tener al menos 6 caracteres.')
.max(30, 'El telefono no puede superar los 30 caracteres.'),
}) })
export const publicBookingSchema = z.object({ export const publicBookingSchema = z.object({
@@ -73,7 +85,9 @@ export const publicBookingSchema = z.object({
endTime: z.string().regex(TIME_REGEX), endTime: z.string().regex(TIME_REGEX),
customerName: z.string(), customerName: z.string(),
customerPhone: z.string(), customerPhone: z.string(),
customerEmail: z.string(),
status: z.enum(['CONFIRMED', 'CANCELLED', 'COMPLETED']), status: z.enum(['CONFIRMED', 'CANCELLED', 'COMPLETED']),
price: z.number().nonnegative(),
createdAt: z.string().datetime(), createdAt: z.string().datetime(),
}) })
@@ -89,6 +103,7 @@ export const publicBookingConfirmationSchema = z.object({
courtName: z.string(), courtName: z.string(),
sport: publicBookingSportSchema, sport: publicBookingSportSchema,
status: z.enum(['CONFIRMED', 'CANCELLED', 'COMPLETED']), status: z.enum(['CONFIRMED', 'CANCELLED', 'COMPLETED']),
customerEmail: z.string(),
createdAt: z.string().datetime(), createdAt: z.string().datetime(),
}) })
@@ -98,5 +113,6 @@ export type PublicBookingSlot = z.infer<typeof publicBookingSlotSchema>
export type PublicAvailabilityCourt = z.infer<typeof publicAvailabilityCourtSchema> export type PublicAvailabilityCourt = z.infer<typeof publicAvailabilityCourtSchema>
export type PublicAvailabilityResponse = z.infer<typeof publicAvailabilityResponseSchema> export type PublicAvailabilityResponse = z.infer<typeof publicAvailabilityResponseSchema>
export type CreatePublicBookingInput = z.infer<typeof createPublicBookingSchema> export type CreatePublicBookingInput = z.infer<typeof createPublicBookingSchema>
export type CancelPublicBookingInput = z.infer<typeof cancelPublicBookingSchema>
export type PublicBooking = z.infer<typeof publicBookingSchema> export type PublicBooking = z.infer<typeof publicBookingSchema>
export type PublicBookingConfirmation = z.infer<typeof publicBookingConfirmationSchema> export type PublicBookingConfirmation = z.infer<typeof publicBookingConfirmationSchema>