Compare commits
23 Commits
feat/onboa
...
9875f22d5a
| Author | SHA1 | Date | |
|---|---|---|---|
| 9875f22d5a | |||
|
|
260d79fc99 | ||
|
|
1210854c22 | ||
|
|
7ca784d5f5 | ||
|
|
e441f15ee4 | ||
|
|
228004a7e0 | ||
| b20b5c2b8b | |||
|
|
457accfbfa | ||
|
|
43287a4baa | ||
|
|
93fea8ecad | ||
|
|
ba7b0322ff | ||
|
|
473686528e | ||
| c1b47674c8 | |||
|
|
85f234b05e | ||
| 50fa4ed9a5 | |||
|
|
07496e6673 | ||
|
|
9045a8c017 | ||
|
|
3e314a9b9a | ||
| bc4716c48f | |||
|
|
0e69759549 | ||
|
|
88ea7e70da | ||
|
|
7a30f9a29b | ||
| 83fa1ea020 |
58
AGENTS.md
58
AGENTS.md
@@ -193,3 +193,61 @@ await getAvailability('my-club', { date: '2026-04-20' });
|
||||
1. Create `lib/api/resources/[resource].ts`
|
||||
2. Export functions using `http` from `../http`
|
||||
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.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"dev": "bun run --hot src/server.ts",
|
||||
"start": "bun src/server.ts",
|
||||
"build": "tsc -b",
|
||||
"test": "bun test",
|
||||
"test": "bun test --preload ./test/support/prisma.mock.ts ./test",
|
||||
"lint": "biome check .",
|
||||
"lint:fix": "biome check --write .",
|
||||
"format": "biome format --write .",
|
||||
|
||||
@@ -85,6 +85,7 @@ model CourtBooking {
|
||||
endTime String @map("end_time") @db.VarChar(5)
|
||||
customerName String @map("customer_name") @db.VarChar(120)
|
||||
customerPhone String @map("customer_phone") @db.VarChar(30)
|
||||
customerEmail String @map("customer_email") @db.VarChar(254)
|
||||
status CourtBookingStatus @default(CONFIRMED)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
@@ -107,6 +108,7 @@ model CourtBookingLog {
|
||||
newStatus CourtBookingStatus @map("new_status")
|
||||
customerName String @map("customer_name") @db.VarChar(120)
|
||||
customerPhone String @map("customer_phone") @db.VarChar(30)
|
||||
customerEmail String @map("customer_email") @db.VarChar(254)
|
||||
changedAt DateTime @default(now()) @map("changed_at")
|
||||
|
||||
@@map("court_booking_logs")
|
||||
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
401
apps/backend/src/emails/booking-confirmation.ts
Normal file
401
apps/backend/src/emails/booking-confirmation.ts
Normal 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;">
|
||||
✓ 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} — ${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} — ${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ó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;">
|
||||
✗ 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} — ${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é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} — ${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} — ${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} — ${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);
|
||||
}
|
||||
@@ -82,11 +82,6 @@ export type CourtBooking = Prisma.CourtBookingModel
|
||||
*
|
||||
*/
|
||||
export type CourtBookingLog = Prisma.CourtBookingLogModel
|
||||
/**
|
||||
* Model OnboardingRequest
|
||||
*
|
||||
*/
|
||||
export type OnboardingRequest = Prisma.OnboardingRequestModel
|
||||
/**
|
||||
* Model PasswordResetRequest
|
||||
*
|
||||
|
||||
@@ -106,11 +106,6 @@ export type CourtBooking = Prisma.CourtBookingModel
|
||||
*
|
||||
*/
|
||||
export type CourtBookingLog = Prisma.CourtBookingLogModel
|
||||
/**
|
||||
* Model OnboardingRequest
|
||||
*
|
||||
*/
|
||||
export type OnboardingRequest = Prisma.OnboardingRequestModel
|
||||
/**
|
||||
* Model PasswordResetRequest
|
||||
*
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -397,7 +397,6 @@ export const ModelName = {
|
||||
CourtPriceRule: 'CourtPriceRule',
|
||||
CourtBooking: 'CourtBooking',
|
||||
CourtBookingLog: 'CourtBookingLog',
|
||||
OnboardingRequest: 'OnboardingRequest',
|
||||
PasswordResetRequest: 'PasswordResetRequest',
|
||||
Plan: 'Plan'
|
||||
} as const
|
||||
@@ -415,7 +414,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
||||
omit: GlobalOmitOptions
|
||||
}
|
||||
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
|
||||
}
|
||||
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: {
|
||||
payload: Prisma.$PasswordResetRequestPayload<ExtArgs>
|
||||
fields: Prisma.PasswordResetRequestFieldRefs
|
||||
@@ -1806,6 +1731,7 @@ export const CourtBookingScalarFieldEnum = {
|
||||
endTime: 'endTime',
|
||||
customerName: 'customerName',
|
||||
customerPhone: 'customerPhone',
|
||||
customerEmail: 'customerEmail',
|
||||
status: 'status',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
@@ -1825,30 +1751,13 @@ export const CourtBookingLogScalarFieldEnum = {
|
||||
newStatus: 'newStatus',
|
||||
customerName: 'customerName',
|
||||
customerPhone: 'customerPhone',
|
||||
customerEmail: 'customerEmail',
|
||||
changedAt: 'changedAt'
|
||||
} as const
|
||||
|
||||
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 = {
|
||||
id: 'id',
|
||||
email: 'email',
|
||||
@@ -2162,7 +2071,6 @@ export type GlobalOmitConfig = {
|
||||
courtPriceRule?: Prisma.CourtPriceRuleOmit
|
||||
courtBooking?: Prisma.CourtBookingOmit
|
||||
courtBookingLog?: Prisma.CourtBookingLogOmit
|
||||
onboardingRequest?: Prisma.OnboardingRequestOmit
|
||||
passwordResetRequest?: Prisma.PasswordResetRequestOmit
|
||||
plan?: Prisma.PlanOmit
|
||||
}
|
||||
|
||||
@@ -64,7 +64,6 @@ export const ModelName = {
|
||||
CourtPriceRule: 'CourtPriceRule',
|
||||
CourtBooking: 'CourtBooking',
|
||||
CourtBookingLog: 'CourtBookingLog',
|
||||
OnboardingRequest: 'OnboardingRequest',
|
||||
PasswordResetRequest: 'PasswordResetRequest',
|
||||
Plan: 'Plan'
|
||||
} as const
|
||||
@@ -249,6 +248,7 @@ export const CourtBookingScalarFieldEnum = {
|
||||
endTime: 'endTime',
|
||||
customerName: 'customerName',
|
||||
customerPhone: 'customerPhone',
|
||||
customerEmail: 'customerEmail',
|
||||
status: 'status',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
@@ -268,30 +268,13 @@ export const CourtBookingLogScalarFieldEnum = {
|
||||
newStatus: 'newStatus',
|
||||
customerName: 'customerName',
|
||||
customerPhone: 'customerPhone',
|
||||
customerEmail: 'customerEmail',
|
||||
changedAt: 'changedAt'
|
||||
} as const
|
||||
|
||||
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 = {
|
||||
id: 'id',
|
||||
email: 'email',
|
||||
|
||||
@@ -21,7 +21,6 @@ export type * from './models/CourtAvailability'
|
||||
export type * from './models/CourtPriceRule'
|
||||
export type * from './models/CourtBooking'
|
||||
export type * from './models/CourtBookingLog'
|
||||
export type * from './models/OnboardingRequest'
|
||||
export type * from './models/PasswordResetRequest'
|
||||
export type * from './models/Plan'
|
||||
export type * from './commonInputTypes'
|
||||
@@ -33,6 +33,7 @@ export type CourtBookingMinAggregateOutputType = {
|
||||
endTime: string | null
|
||||
customerName: string | null
|
||||
customerPhone: string | null
|
||||
customerEmail: string | null
|
||||
status: $Enums.CourtBookingStatus | null
|
||||
createdAt: Date | null
|
||||
updatedAt: Date | null
|
||||
@@ -47,6 +48,7 @@ export type CourtBookingMaxAggregateOutputType = {
|
||||
endTime: string | null
|
||||
customerName: string | null
|
||||
customerPhone: string | null
|
||||
customerEmail: string | null
|
||||
status: $Enums.CourtBookingStatus | null
|
||||
createdAt: Date | null
|
||||
updatedAt: Date | null
|
||||
@@ -61,6 +63,7 @@ export type CourtBookingCountAggregateOutputType = {
|
||||
endTime: number
|
||||
customerName: number
|
||||
customerPhone: number
|
||||
customerEmail: number
|
||||
status: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
@@ -77,6 +80,7 @@ export type CourtBookingMinAggregateInputType = {
|
||||
endTime?: true
|
||||
customerName?: true
|
||||
customerPhone?: true
|
||||
customerEmail?: true
|
||||
status?: true
|
||||
createdAt?: true
|
||||
updatedAt?: true
|
||||
@@ -91,6 +95,7 @@ export type CourtBookingMaxAggregateInputType = {
|
||||
endTime?: true
|
||||
customerName?: true
|
||||
customerPhone?: true
|
||||
customerEmail?: true
|
||||
status?: true
|
||||
createdAt?: true
|
||||
updatedAt?: true
|
||||
@@ -105,6 +110,7 @@ export type CourtBookingCountAggregateInputType = {
|
||||
endTime?: true
|
||||
customerName?: true
|
||||
customerPhone?: true
|
||||
customerEmail?: true
|
||||
status?: true
|
||||
createdAt?: true
|
||||
updatedAt?: true
|
||||
@@ -192,6 +198,7 @@ export type CourtBookingGroupByOutputType = {
|
||||
endTime: string
|
||||
customerName: string
|
||||
customerPhone: string
|
||||
customerEmail: string
|
||||
status: $Enums.CourtBookingStatus
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
@@ -227,6 +234,7 @@ export type CourtBookingWhereInput = {
|
||||
endTime?: Prisma.StringFilter<"CourtBooking"> | string
|
||||
customerName?: Prisma.StringFilter<"CourtBooking"> | string
|
||||
customerPhone?: Prisma.StringFilter<"CourtBooking"> | string
|
||||
customerEmail?: Prisma.StringFilter<"CourtBooking"> | string
|
||||
status?: Prisma.EnumCourtBookingStatusFilter<"CourtBooking"> | $Enums.CourtBookingStatus
|
||||
createdAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
||||
@@ -242,6 +250,7 @@ export type CourtBookingOrderByWithRelationInput = {
|
||||
endTime?: Prisma.SortOrder
|
||||
customerName?: Prisma.SortOrder
|
||||
customerPhone?: Prisma.SortOrder
|
||||
customerEmail?: Prisma.SortOrder
|
||||
status?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
@@ -261,6 +270,7 @@ export type CourtBookingWhereUniqueInput = Prisma.AtLeast<{
|
||||
endTime?: Prisma.StringFilter<"CourtBooking"> | string
|
||||
customerName?: Prisma.StringFilter<"CourtBooking"> | string
|
||||
customerPhone?: Prisma.StringFilter<"CourtBooking"> | string
|
||||
customerEmail?: Prisma.StringFilter<"CourtBooking"> | string
|
||||
status?: Prisma.EnumCourtBookingStatusFilter<"CourtBooking"> | $Enums.CourtBookingStatus
|
||||
createdAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
||||
@@ -276,6 +286,7 @@ export type CourtBookingOrderByWithAggregationInput = {
|
||||
endTime?: Prisma.SortOrder
|
||||
customerName?: Prisma.SortOrder
|
||||
customerPhone?: Prisma.SortOrder
|
||||
customerEmail?: Prisma.SortOrder
|
||||
status?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
@@ -296,6 +307,7 @@ export type CourtBookingScalarWhereWithAggregatesInput = {
|
||||
endTime?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
|
||||
customerName?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
|
||||
customerPhone?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
|
||||
customerEmail?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
|
||||
status?: Prisma.EnumCourtBookingStatusWithAggregatesFilter<"CourtBooking"> | $Enums.CourtBookingStatus
|
||||
createdAt?: Prisma.DateTimeWithAggregatesFilter<"CourtBooking"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"CourtBooking"> | Date | string
|
||||
@@ -309,6 +321,7 @@ export type CourtBookingCreateInput = {
|
||||
endTime: string
|
||||
customerName: string
|
||||
customerPhone: string
|
||||
customerEmail: string
|
||||
status?: $Enums.CourtBookingStatus
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
@@ -324,6 +337,7 @@ export type CourtBookingUncheckedCreateInput = {
|
||||
endTime: string
|
||||
customerName: string
|
||||
customerPhone: string
|
||||
customerEmail: string
|
||||
status?: $Enums.CourtBookingStatus
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
@@ -337,6 +351,7 @@ export type CourtBookingUpdateInput = {
|
||||
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
@@ -352,6 +367,7 @@ export type CourtBookingUncheckedUpdateInput = {
|
||||
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
@@ -366,6 +382,7 @@ export type CourtBookingCreateManyInput = {
|
||||
endTime: string
|
||||
customerName: string
|
||||
customerPhone: string
|
||||
customerEmail: string
|
||||
status?: $Enums.CourtBookingStatus
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
@@ -379,6 +396,7 @@ export type CourtBookingUpdateManyMutationInput = {
|
||||
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
@@ -393,6 +411,7 @@ export type CourtBookingUncheckedUpdateManyInput = {
|
||||
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
@@ -423,6 +442,7 @@ export type CourtBookingCountOrderByAggregateInput = {
|
||||
endTime?: Prisma.SortOrder
|
||||
customerName?: Prisma.SortOrder
|
||||
customerPhone?: Prisma.SortOrder
|
||||
customerEmail?: Prisma.SortOrder
|
||||
status?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
@@ -437,6 +457,7 @@ export type CourtBookingMaxOrderByAggregateInput = {
|
||||
endTime?: Prisma.SortOrder
|
||||
customerName?: Prisma.SortOrder
|
||||
customerPhone?: Prisma.SortOrder
|
||||
customerEmail?: Prisma.SortOrder
|
||||
status?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
@@ -451,6 +472,7 @@ export type CourtBookingMinOrderByAggregateInput = {
|
||||
endTime?: Prisma.SortOrder
|
||||
customerName?: Prisma.SortOrder
|
||||
customerPhone?: Prisma.SortOrder
|
||||
customerEmail?: Prisma.SortOrder
|
||||
status?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
@@ -510,6 +532,7 @@ export type CourtBookingCreateWithoutCourtInput = {
|
||||
endTime: string
|
||||
customerName: string
|
||||
customerPhone: string
|
||||
customerEmail: string
|
||||
status?: $Enums.CourtBookingStatus
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
@@ -523,6 +546,7 @@ export type CourtBookingUncheckedCreateWithoutCourtInput = {
|
||||
endTime: string
|
||||
customerName: string
|
||||
customerPhone: string
|
||||
customerEmail: string
|
||||
status?: $Enums.CourtBookingStatus
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
@@ -566,6 +590,7 @@ export type CourtBookingScalarWhereInput = {
|
||||
endTime?: Prisma.StringFilter<"CourtBooking"> | string
|
||||
customerName?: Prisma.StringFilter<"CourtBooking"> | string
|
||||
customerPhone?: Prisma.StringFilter<"CourtBooking"> | string
|
||||
customerEmail?: Prisma.StringFilter<"CourtBooking"> | string
|
||||
status?: Prisma.EnumCourtBookingStatusFilter<"CourtBooking"> | $Enums.CourtBookingStatus
|
||||
createdAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
||||
@@ -579,6 +604,7 @@ export type CourtBookingCreateManyCourtInput = {
|
||||
endTime: string
|
||||
customerName: string
|
||||
customerPhone: string
|
||||
customerEmail: string
|
||||
status?: $Enums.CourtBookingStatus
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
@@ -592,6 +618,7 @@ export type CourtBookingUpdateWithoutCourtInput = {
|
||||
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
@@ -605,6 +632,7 @@ export type CourtBookingUncheckedUpdateWithoutCourtInput = {
|
||||
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
@@ -618,6 +646,7 @@ export type CourtBookingUncheckedUpdateManyWithoutCourtInput = {
|
||||
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
@@ -634,6 +663,7 @@ export type CourtBookingSelect<ExtArgs extends runtime.Types.Extensions.Internal
|
||||
endTime?: boolean
|
||||
customerName?: boolean
|
||||
customerPhone?: boolean
|
||||
customerEmail?: boolean
|
||||
status?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
@@ -649,6 +679,7 @@ export type CourtBookingSelectCreateManyAndReturn<ExtArgs extends runtime.Types.
|
||||
endTime?: boolean
|
||||
customerName?: boolean
|
||||
customerPhone?: boolean
|
||||
customerEmail?: boolean
|
||||
status?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
@@ -664,6 +695,7 @@ export type CourtBookingSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.
|
||||
endTime?: boolean
|
||||
customerName?: boolean
|
||||
customerPhone?: boolean
|
||||
customerEmail?: boolean
|
||||
status?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
@@ -679,12 +711,13 @@ export type CourtBookingSelectScalar = {
|
||||
endTime?: boolean
|
||||
customerName?: boolean
|
||||
customerPhone?: boolean
|
||||
customerEmail?: boolean
|
||||
status?: boolean
|
||||
createdAt?: 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> = {
|
||||
court?: boolean | Prisma.CourtDefaultArgs<ExtArgs>
|
||||
}
|
||||
@@ -709,6 +742,7 @@ export type $CourtBookingPayload<ExtArgs extends runtime.Types.Extensions.Intern
|
||||
endTime: string
|
||||
customerName: string
|
||||
customerPhone: string
|
||||
customerEmail: string
|
||||
status: $Enums.CourtBookingStatus
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
@@ -1144,6 +1178,7 @@ export interface CourtBookingFieldRefs {
|
||||
readonly endTime: Prisma.FieldRef<"CourtBooking", 'String'>
|
||||
readonly customerName: Prisma.FieldRef<"CourtBooking", 'String'>
|
||||
readonly customerPhone: Prisma.FieldRef<"CourtBooking", 'String'>
|
||||
readonly customerEmail: Prisma.FieldRef<"CourtBooking", 'String'>
|
||||
readonly status: Prisma.FieldRef<"CourtBooking", 'CourtBookingStatus'>
|
||||
readonly createdAt: Prisma.FieldRef<"CourtBooking", 'DateTime'>
|
||||
readonly updatedAt: Prisma.FieldRef<"CourtBooking", 'DateTime'>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
import { wrapLayout } from '@/emails/booking-confirmation';
|
||||
import { dash } from '@better-auth/infra';
|
||||
import { betterAuth } from 'better-auth';
|
||||
import { prismaAdapter } from 'better-auth/adapters/prisma';
|
||||
@@ -31,11 +32,77 @@ export const auth = betterAuth({
|
||||
sendOnSignUp: true,
|
||||
autoSignInAfterVerification: true,
|
||||
sendVerificationEmail: async ({ user, url }) => {
|
||||
const verificationUrl = new URL(url);
|
||||
const appUrl = process.env.APP_BASE_URL ?? 'http://localhost:5173';
|
||||
verificationUrl.searchParams.set('callbackURL', appUrl);
|
||||
|
||||
const content = `
|
||||
<tr>
|
||||
<td style="padding:24px 20px 16px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td valign="top">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" style="display:inline-block;background-color:#f4f4f5;border-radius:999px;padding:4px 12px;">
|
||||
<tr>
|
||||
<td style="font-size:11px;font-weight:700;letter-spacing:0.14em;color:#71717a;text-transform:uppercase;line-height:1.25rem;">
|
||||
Verificació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á tu dirección de correo electró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é click en el botón de abajo para verificar tu dirección de correo electró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á este mensaje.
|
||||
</p>
|
||||
</td>
|
||||
</tr>`;
|
||||
|
||||
await sendMail({
|
||||
to: user.email,
|
||||
subject: 'Verificá tu email en Playzer',
|
||||
html: `Hacé click para verificar tu email: <a href="${url}">${url}</a>`,
|
||||
text: `Hacé click para verificar tu email: ${url}`,
|
||||
html: wrapLayout(content),
|
||||
text: `Hacé click para verificar tu email: ${verificationUrl.toString()}`,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { db } from '@/lib/prisma';
|
||||
import {
|
||||
AdminBookingServiceError,
|
||||
createAdminBooking,
|
||||
} from '@/modules/admin-booking/services/admin-booking.service';
|
||||
import { sendBookingConfirmation } from '@/services/booking-email.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
import type { CreateAdminBookingInput } from '@repo/api-contract';
|
||||
|
||||
@@ -14,6 +16,26 @@ export async function createAdminBookingHandler(c: AppContext) {
|
||||
|
||||
try {
|
||||
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);
|
||||
} catch (error) {
|
||||
if (error instanceof AdminBookingServiceError) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
AdminBookingServiceError,
|
||||
updateAdminBookingStatus,
|
||||
} from '@/modules/admin-booking/services/admin-booking.service';
|
||||
import { sendBookingCancelled, sendBookingNoShow } from '@/services/booking-email.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
import type { UpdateAdminBookingStatusInput } from '@repo/api-contract';
|
||||
|
||||
@@ -14,6 +15,33 @@ export async function updateAdminBookingStatusHandler(c: AppContext) {
|
||||
|
||||
try {
|
||||
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);
|
||||
} catch (error) {
|
||||
if (error instanceof AdminBookingServiceError) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomInt } from 'node:crypto';
|
||||
import { CourtBookingStatus } from '@/generated/prisma/enums';
|
||||
import { db } from '@/lib/prisma';
|
||||
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service';
|
||||
import type { DayOfWeek } from '@repo/api-contract';
|
||||
import type {
|
||||
AdminBooking,
|
||||
CreateAdminBookingInput,
|
||||
@@ -156,6 +157,40 @@ async function ensureComplexAccess(complexId: string, userId: string) {
|
||||
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: {
|
||||
id: string;
|
||||
bookingCode: string;
|
||||
@@ -164,6 +199,7 @@ function mapBookingResponse(booking: {
|
||||
endTime: string;
|
||||
customerName: string;
|
||||
customerPhone: string;
|
||||
customerEmail: string;
|
||||
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NOSHOW';
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
@@ -180,6 +216,7 @@ function mapBookingResponse(booking: {
|
||||
slug: string;
|
||||
};
|
||||
};
|
||||
price?: number;
|
||||
}): AdminBooking {
|
||||
return {
|
||||
id: booking.id,
|
||||
@@ -198,6 +235,8 @@ function mapBookingResponse(booking: {
|
||||
endTime: booking.endTime,
|
||||
customerName: booking.customerName,
|
||||
customerPhone: booking.customerPhone,
|
||||
customerEmail: booking.customerEmail,
|
||||
price: booking.price ?? 0,
|
||||
status: booking.status,
|
||||
createdAt: booking.createdAt.toISOString(),
|
||||
updatedAt: booking.updatedAt.toISOString(),
|
||||
@@ -258,6 +297,16 @@ export async function createAdminBooking(
|
||||
input: CreateAdminBookingInput
|
||||
) {
|
||||
const complex = await ensureComplexAccess(complexId, userId);
|
||||
|
||||
const adminUser = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { emailVerified: true },
|
||||
});
|
||||
|
||||
if (!adminUser?.emailVerified) {
|
||||
throw new AdminBookingServiceError('Debés verificar tu email para poder crear reservas.', 403);
|
||||
}
|
||||
|
||||
const bookingDate = parseIsoDate(input.date);
|
||||
const dayOfWeek = getDayOfWeek(bookingDate);
|
||||
|
||||
@@ -282,6 +331,10 @@ export async function createAdminBooking(
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
priceRules: {
|
||||
where: { isActive: true },
|
||||
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -371,6 +424,7 @@ export async function createAdminBooking(
|
||||
endTime: selectedSlot.endTime,
|
||||
customerName: input.customerName.trim(),
|
||||
customerPhone: input.customerPhone.trim(),
|
||||
customerEmail: input.customerEmail.trim(),
|
||||
status: 'CONFIRMED',
|
||||
},
|
||||
include: {
|
||||
@@ -397,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) {
|
||||
if (error instanceof AdminBookingServiceError) {
|
||||
throw error;
|
||||
@@ -511,6 +567,7 @@ export async function updateAdminBookingStatus(
|
||||
endTime: booking.endTime,
|
||||
customerName: booking.customerName,
|
||||
customerPhone: booking.customerPhone,
|
||||
customerEmail: booking.customerEmail,
|
||||
previousStatus: booking.status,
|
||||
newStatus: input.status,
|
||||
changedAt: new Date(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
PublicBookingServiceError,
|
||||
createPublicBooking,
|
||||
} from '@/modules/public-booking/services/public-booking.service';
|
||||
import { sendBookingConfirmation } from '@/services/booking-email.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
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);
|
||||
} catch (error) {
|
||||
if (error instanceof PublicBookingServiceError) {
|
||||
|
||||
@@ -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 { getPublicBookingConfirmationHandler } from '@/modules/public-booking/handlers/get-public-booking-confirmation.handler';
|
||||
import { listPublicAvailabilityHandler } from '@/modules/public-booking/handlers/list-public-availability.handler';
|
||||
import type { AppEnv } from '@/types/hono';
|
||||
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 { z } from 'zod';
|
||||
|
||||
@@ -33,3 +38,10 @@ publicBookingRoutes.get(
|
||||
zValidator('param', confirmationParamsSchema),
|
||||
getPublicBookingConfirmationHandler
|
||||
);
|
||||
|
||||
publicBookingRoutes.post(
|
||||
'/complex/:complexSlug/cancel',
|
||||
zValidator('param', complexSlugParamsSchema),
|
||||
zValidator('json', cancelPublicBookingSchema),
|
||||
cancelPublicBookingHandler
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomInt } from 'node:crypto';
|
||||
import { db } from '@/lib/prisma';
|
||||
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service';
|
||||
import type {
|
||||
CancelPublicBookingInput,
|
||||
CreatePublicBookingInput,
|
||||
DayOfWeek,
|
||||
PublicAvailabilityQuery,
|
||||
@@ -13,6 +14,16 @@ type Slot = {
|
||||
endTime: string;
|
||||
};
|
||||
|
||||
type PriceableCourt = {
|
||||
basePrice: unknown;
|
||||
priceRules: Array<{
|
||||
dayOfWeek: DayOfWeek | null;
|
||||
startTime: string | null;
|
||||
endTime: string | null;
|
||||
price: unknown;
|
||||
}>;
|
||||
};
|
||||
|
||||
type ComplexWithPublicBookingData = Awaited<ReturnType<typeof getComplexWithBookingData>>;
|
||||
|
||||
const DAY_OF_WEEK_BY_INDEX: DayOfWeek[] = [
|
||||
@@ -32,7 +43,6 @@ export class PublicBookingServiceError extends Error {
|
||||
|
||||
constructor(message: string, status: 400 | 403 | 404 | 409) {
|
||||
super(message);
|
||||
this.name = 'PublicBookingServiceError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
@@ -202,6 +212,10 @@ async function getComplexWithBookingData(complexSlug: string) {
|
||||
availabilities: {
|
||||
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
|
||||
},
|
||||
priceRules: {
|
||||
where: { isActive: true },
|
||||
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
@@ -226,6 +240,33 @@ async function getComplexWithBookingData(complexSlug: string) {
|
||||
return complex;
|
||||
}
|
||||
|
||||
function resolveSlotPrice(court: PriceableCourt, dayOfWeek: DayOfWeek, slot: Slot): number {
|
||||
const slotStart = toMinutes(slot.startTime);
|
||||
const slotEnd = toMinutes(slot.endTime);
|
||||
const matchingRules = court.priceRules
|
||||
.filter((rule) => {
|
||||
if (rule.dayOfWeek && rule.dayOfWeek !== dayOfWeek) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!rule.startTime || !rule.endTime) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return slotStart >= toMinutes(rule.startTime) && slotEnd <= toMinutes(rule.endTime);
|
||||
})
|
||||
.sort((first, second) => {
|
||||
const firstSpecificity =
|
||||
(first.dayOfWeek ? 2 : 0) + (first.startTime && first.endTime ? 1 : 0);
|
||||
const secondSpecificity =
|
||||
(second.dayOfWeek ? 2 : 0) + (second.startTime && second.endTime ? 1 : 0);
|
||||
|
||||
return secondSpecificity - firstSpecificity;
|
||||
});
|
||||
|
||||
return Number(matchingRules[0]?.price ?? court.basePrice);
|
||||
}
|
||||
|
||||
function mapBookingResponse(input: {
|
||||
bookingId: string;
|
||||
bookingCode: string;
|
||||
@@ -235,6 +276,8 @@ function mapBookingResponse(input: {
|
||||
endTime: string;
|
||||
customerName: string;
|
||||
customerPhone: string;
|
||||
customerEmail: string;
|
||||
price: number;
|
||||
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NOSHOW';
|
||||
court: {
|
||||
id: string;
|
||||
@@ -263,7 +306,9 @@ function mapBookingResponse(input: {
|
||||
endTime: input.endTime,
|
||||
customerName: input.customerName,
|
||||
customerPhone: input.customerPhone,
|
||||
customerEmail: input.customerEmail,
|
||||
status: input.status,
|
||||
price: input.price,
|
||||
createdAt: input.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -284,11 +329,17 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC
|
||||
bookingDate: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
customerEmail: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
court: {
|
||||
select: {
|
||||
name: true,
|
||||
basePrice: true,
|
||||
priceRules: {
|
||||
where: { isActive: true },
|
||||
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
|
||||
},
|
||||
sport: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -299,6 +350,7 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC
|
||||
complex: {
|
||||
select: {
|
||||
complexName: true,
|
||||
physicalAddress: true,
|
||||
complexSlug: true,
|
||||
},
|
||||
},
|
||||
@@ -311,13 +363,22 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC
|
||||
throw new PublicBookingServiceError('Reserva no encontrada.', 404);
|
||||
}
|
||||
|
||||
const date = formatIsoDate(booking.bookingDate);
|
||||
const { dayOfWeek } = parseIsoDate(date);
|
||||
const price = resolveSlotPrice(booking.court, dayOfWeek, {
|
||||
startTime: booking.startTime,
|
||||
endTime: booking.endTime,
|
||||
});
|
||||
|
||||
return {
|
||||
bookingCode: booking.bookingCode,
|
||||
complexName: booking.court.complex.complexName,
|
||||
complexAddress: booking.court.complex.physicalAddress ?? undefined,
|
||||
complexSlug: booking.court.complex.complexSlug,
|
||||
date: formatIsoDate(booking.bookingDate),
|
||||
date,
|
||||
startTime: booking.startTime,
|
||||
endTime: booking.endTime,
|
||||
price,
|
||||
courtName: booking.court.name,
|
||||
sport: {
|
||||
id: booking.court.sport.id,
|
||||
@@ -325,13 +386,39 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC
|
||||
slug: booking.court.sport.slug,
|
||||
},
|
||||
status: booking.status,
|
||||
customerEmail: booking.customerEmail,
|
||||
createdAt: booking.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function hasVerifiedAdmin(complexId: string): Promise<boolean> {
|
||||
const verifiedAdmin = await db.complexUser.findFirst({
|
||||
where: {
|
||||
complexId,
|
||||
role: 'ADMIN',
|
||||
user: { emailVerified: true },
|
||||
},
|
||||
});
|
||||
return verifiedAdmin !== null;
|
||||
}
|
||||
|
||||
export async function listPublicAvailability(complexSlug: string, query: PublicAvailabilityQuery) {
|
||||
const { bookingDate, dayOfWeek } = parseIsoDate(query.date);
|
||||
const complex = await getComplexWithBookingData(complexSlug);
|
||||
|
||||
if (!(await hasVerifiedAdmin(complex.id))) {
|
||||
return {
|
||||
complexId: complex.id,
|
||||
complexName: complex.complexName,
|
||||
complexAddress: complex.physicalAddress ?? null,
|
||||
complexSlug: complex.complexSlug,
|
||||
date: query.date,
|
||||
sportSelectionRequired: false,
|
||||
sports: [],
|
||||
courts: [],
|
||||
};
|
||||
}
|
||||
|
||||
const sports = resolveSports(complex);
|
||||
const sportSelectionRequired = sports.length > 1;
|
||||
|
||||
@@ -407,7 +494,10 @@ export async function listPublicAvailability(complexSlug: string, query: PublicA
|
||||
},
|
||||
slotDurationMinutes: court.slotDurationMinutes,
|
||||
availabilityDay: dayOfWeek,
|
||||
availableSlots,
|
||||
availableSlots: availableSlots.map((slot) => ({
|
||||
...slot,
|
||||
price: resolveSlotPrice(court, dayOfWeek, slot),
|
||||
})),
|
||||
};
|
||||
})
|
||||
.filter((court) => court.availableSlots.length > 0);
|
||||
@@ -427,6 +517,14 @@ export async function listPublicAvailability(complexSlug: string, query: PublicA
|
||||
export async function createPublicBooking(complexSlug: string, input: CreatePublicBookingInput) {
|
||||
const { bookingDate, dayOfWeek } = parseIsoDate(input.date);
|
||||
const complex = await getComplexWithBookingData(complexSlug);
|
||||
|
||||
if (!(await hasVerifiedAdmin(complex.id))) {
|
||||
throw new PublicBookingServiceError(
|
||||
'El complejo no puede recibir reservas hasta que un administrador verifique su email.',
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
const sports = resolveSports(complex);
|
||||
const { sportSelectionRequired } = validateSportSelection(sports, input.sportId);
|
||||
|
||||
@@ -533,6 +631,7 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
|
||||
endTime: selectedSlot.endTime,
|
||||
customerName: input.customerName.trim(),
|
||||
customerPhone: input.customerPhone.trim(),
|
||||
customerEmail: input.customerEmail.trim(),
|
||||
status: 'CONFIRMED',
|
||||
},
|
||||
select: {
|
||||
@@ -544,11 +643,14 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
|
||||
endTime: true,
|
||||
customerName: true,
|
||||
customerPhone: true,
|
||||
customerEmail: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const price = resolveSlotPrice(selectedCourt, dayOfWeek, selectedSlot);
|
||||
|
||||
return mapBookingResponse({
|
||||
bookingId: booking.id,
|
||||
bookingCode: booking.bookingCode,
|
||||
@@ -558,6 +660,8 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
|
||||
endTime: booking.endTime,
|
||||
customerName: booking.customerName,
|
||||
customerPhone: booking.customerPhone,
|
||||
customerEmail: booking.customerEmail,
|
||||
price,
|
||||
status: booking.status,
|
||||
court: {
|
||||
id: selectedCourt.id,
|
||||
@@ -608,3 +712,108 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
88
apps/backend/src/services/booking-email.service.ts
Normal file
88
apps/backend/src/services/booking-email.service.ts
Normal 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}.`,
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
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';
|
||||
|
||||
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({
|
||||
inviteComplexUser: inviteComplexUserMock,
|
||||
ComplexMembersError: MockComplexMembersError,
|
||||
@@ -59,7 +62,6 @@ function createContext(input: {
|
||||
|
||||
beforeEach(() => {
|
||||
inviteComplexUserMock.mockReset();
|
||||
mock.clearAllMocks();
|
||||
});
|
||||
|
||||
test('returns 201 with the service result', async () => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
const { ComplexMembersError, inviteComplexUser } = await import(
|
||||
'@/modules/complex/services/complex-members.service'
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
prismaMock._reset();
|
||||
mock.clearAllMocks();
|
||||
sendMailMock.mockClear();
|
||||
transactionMock.mockClear();
|
||||
});
|
||||
|
||||
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',
|
||||
email: 'new.member@example.com',
|
||||
tokenHash: 'token-hash',
|
||||
expiresAt: new Date('2026-04-29T00:00:00.000Z'),
|
||||
expiresAt: new Date('2027-01-01T00:00:00.000Z'),
|
||||
acceptedAt: null,
|
||||
revokedAt: null,
|
||||
createdAt: new Date('2026-04-22T00:00:00.000Z'),
|
||||
createdAt: new Date('2026-12-01T00:00:00.000Z'),
|
||||
} as never);
|
||||
|
||||
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 as ComplexMembersError).status).toBe(409);
|
||||
expect((caught as InstanceType<typeof ComplexMembersError>).status).toBe(409);
|
||||
expect(transactionMock).not.toHaveBeenCalled();
|
||||
expect(sendMailMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -49,7 +49,6 @@ function createContext(body: unknown) {
|
||||
|
||||
beforeEach(() => {
|
||||
createSportMock.mockReset();
|
||||
mock.clearAllMocks();
|
||||
});
|
||||
|
||||
test('returns 201 with the created sport', async () => {
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { PrismaClient } from '@/generated/prisma/client';
|
||||
import type { PrismaClientMock } from 'bun-mock-prisma';
|
||||
|
||||
const prismaMock = createPrismaMock<PrismaClient>() as PrismaClientMock<PrismaClient>;
|
||||
const uuidV7Mock = mock(() => '00000000-0000-4000-8000-000000000001');
|
||||
|
||||
mock.module('@/lib/prisma', () => ({
|
||||
__esModule: true,
|
||||
@@ -15,10 +14,7 @@ mock.module('@/lib/prisma', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('uuid', () => ({
|
||||
__esModule: true,
|
||||
v7: uuidV7Mock,
|
||||
}));
|
||||
const { uuidV7Mock } = await import('../support/prisma.mock');
|
||||
|
||||
const { createSport, getSportById, listSports, updateSport } = await import(
|
||||
'@/modules/sport/services/sport.service'
|
||||
@@ -26,7 +22,7 @@ const { createSport, getSportById, listSports, updateSport } = await import(
|
||||
|
||||
beforeEach(() => {
|
||||
prismaMock._reset();
|
||||
mock.clearAllMocks();
|
||||
uuidV7Mock.mockClear();
|
||||
});
|
||||
|
||||
test('listSports returns sports ordered by name', async () => {
|
||||
|
||||
@@ -33,3 +33,10 @@ mock.module('@/lib/mailer', () => ({
|
||||
__esModule: true,
|
||||
sendMail: sendMailMock,
|
||||
}));
|
||||
|
||||
export const uuidV7Mock = mock(() => '00000000-0000-4000-8000-000000000001');
|
||||
|
||||
mock.module('uuid', () => ({
|
||||
__esModule: true,
|
||||
v7: uuidV7Mock,
|
||||
}));
|
||||
|
||||
BIN
apps/frontend/public/playzer-favicon-512-transparent.png
Normal file
BIN
apps/frontend/public/playzer-favicon-512-transparent.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 151 KiB |
@@ -41,6 +41,7 @@ interface CreateManualBookingPayload {
|
||||
startTime: string;
|
||||
customerName: string;
|
||||
customerPhone: string;
|
||||
customerEmail: string;
|
||||
}
|
||||
|
||||
interface BookingContextValue {
|
||||
@@ -315,6 +316,7 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
|
||||
startTime: payload.startTime,
|
||||
customerName: payload.customerName,
|
||||
customerPhone: payload.customerPhone,
|
||||
customerEmail: payload.customerEmail,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-bookings', complex.id] });
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { authClient } from '@/lib/api-client';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import type { ComplexWithRole } from '@repo/api-contract';
|
||||
import { useState } from 'react';
|
||||
import { BookingProvider } from './booking-provider';
|
||||
import { BookingCreateDialog } from './components/booking-create-dialog';
|
||||
import { BookingDaySummary, BookingQuickActions } from './components/booking-day-summary';
|
||||
@@ -15,9 +18,44 @@ interface BookingProps {
|
||||
|
||||
export function Booking({ complex }: BookingProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const { user } = useAuth();
|
||||
const [sending, setSending] = useState(false);
|
||||
const [emailSent, setEmailSent] = useState(false);
|
||||
|
||||
const emailNotVerified = user && !user.emailVerified;
|
||||
|
||||
const handleResendVerification = async () => {
|
||||
if (!user?.email || sending) return;
|
||||
setSending(true);
|
||||
try {
|
||||
await authClient.sendVerificationEmail({ email: user.email });
|
||||
setEmailSent(true);
|
||||
setTimeout(() => setEmailSent(false), 6000);
|
||||
} catch {
|
||||
// Silently fail — the banner already shows the email to contact
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BookingProvider complex={complex}>
|
||||
{emailNotVerified && (
|
||||
<div className="mb-6 flex items-center justify-between gap-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-800/30 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
<span>
|
||||
No verificaste tu email. Para crear reservas necesitás verificar tu dirección de correo
|
||||
electrónico.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResendVerification}
|
||||
disabled={sending}
|
||||
className="shrink-0 rounded-lg bg-amber-200 px-3 py-1.5 text-xs font-medium text-amber-900 transition-colors hover:bg-amber-300 disabled:opacity-50 dark:bg-amber-800 dark:text-amber-50 dark:hover:bg-amber-700"
|
||||
>
|
||||
{sending ? 'Enviando...' : emailSent ? '¡Email enviado!' : 'Reenviar email'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isMobile ? (
|
||||
<BookingMobile />
|
||||
) : (
|
||||
|
||||
@@ -42,6 +42,7 @@ const bookingFormSchema = z.object({
|
||||
.trim()
|
||||
.min(6, 'Ingresa un telefono válido.')
|
||||
.max(30, 'El telefono no puede superar los 30 caracteres.'),
|
||||
customerEmail: z.string().email('Ingresá un email válido.'),
|
||||
});
|
||||
|
||||
type BookingForm = z.infer<typeof bookingFormSchema>;
|
||||
@@ -76,6 +77,7 @@ export function BookingCreateDialog() {
|
||||
defaultValues: {
|
||||
customerName: '',
|
||||
customerPhone: '',
|
||||
customerEmail: '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -154,6 +156,7 @@ export function BookingCreateDialog() {
|
||||
startTime,
|
||||
customerName: values.customerName,
|
||||
customerPhone: values.customerPhone,
|
||||
customerEmail: values.customerEmail,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -271,6 +274,23 @@ export function BookingCreateDialog() {
|
||||
<FieldError errors={[errors.customerPhone]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errors.customerEmail)}>
|
||||
<FieldLabel htmlFor="customerEmail">
|
||||
Email <span className="text-muted-foreground font-normal">(opcional)</span>
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="customerEmail"
|
||||
type="email"
|
||||
placeholder="Ej: juan@ejemplo.com"
|
||||
aria-invalid={Boolean(errors.customerEmail)}
|
||||
{...register('customerEmail')}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Se enviará la confirmación de la reserva por email.
|
||||
</p>
|
||||
<FieldError errors={[errors.customerEmail]} />
|
||||
</Field>
|
||||
|
||||
{createBookingError && <p className="text-sm text-destructive">{createBookingError}</p>}
|
||||
</form>
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Wrench,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useBooking } from '../booking-provider';
|
||||
|
||||
function formatDate(date: string | undefined) {
|
||||
@@ -70,6 +71,7 @@ export function BookingToolsDialog() {
|
||||
useBooking();
|
||||
const isMobile = useIsMobile();
|
||||
const booking = selectedSegment?.booking;
|
||||
const [confirmCancelOpen, setConfirmCancelOpen] = useState(false);
|
||||
|
||||
const status = statusConfig[booking?.status ?? 'CONFIRMED'] ?? {
|
||||
label: booking?.status,
|
||||
@@ -93,149 +95,211 @@ export function BookingToolsDialog() {
|
||||
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');
|
||||
}}
|
||||
>
|
||||
Sí, cancelar reserva
|
||||
</Button>
|
||||
</ResponsiveDialogFooter>
|
||||
</ResponsiveDialogContent>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
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
|
||||
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"
|
||||
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()}
|
||||
>
|
||||
<MobileBookingToolsSheet
|
||||
status={status}
|
||||
onComplete={() => updateStatus('COMPLETED')}
|
||||
onCancel={() => updateStatus('CANCELLED')}
|
||||
onReminder={sendWhatsappReminder}
|
||||
onNoShow={() => updateStatus('NOSHOW')}
|
||||
/>
|
||||
<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" />
|
||||
|
||||
{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>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
{cancelConfirmDialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -321,42 +385,44 @@ function MobileBookingToolsSheet({
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="shrink-0 border-t border-border/70 bg-popover/95 px-4 py-3">
|
||||
<h3 className="text-sm font-medium">Acciones</h3>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<MobileActionButton
|
||||
icon={<CheckCircle2 className="size-5" />}
|
||||
label="Completar"
|
||||
className="border-primary/45 bg-primary/12 text-primary"
|
||||
onClick={onComplete}
|
||||
/>
|
||||
<MobileActionButton
|
||||
icon={<MessageCircle className="size-5" />}
|
||||
label="Recordar"
|
||||
className="border-reserved/45 bg-reserved/12 text-reserved"
|
||||
onClick={onReminder}
|
||||
/>
|
||||
<MobileActionButton
|
||||
icon={<XCircle className="size-5" />}
|
||||
label="Cancelar"
|
||||
className="border-destructive/45 bg-destructive/12 text-destructive"
|
||||
onClick={onCancel}
|
||||
/>
|
||||
<MobileActionButton
|
||||
icon={<AlertTriangle className="size-5" />}
|
||||
label="No show"
|
||||
className="border-warning/50 bg-warning/12 text-warning"
|
||||
onClick={onNoShow}
|
||||
/>
|
||||
</div>
|
||||
<ResponsiveDialogFooter className="px-0 pb-0 pt-3">
|
||||
<ResponsiveDialogClose asChild>
|
||||
<Button variant="outline" className="h-11 w-full rounded-lg">
|
||||
Cerrar
|
||||
</Button>
|
||||
</ResponsiveDialogClose>
|
||||
</ResponsiveDialogFooter>
|
||||
</section>
|
||||
{booking?.status !== 'COMPLETED' && (
|
||||
<section className="shrink-0 border-t border-border/70 bg-popover/95 px-4 py-3">
|
||||
<h3 className="text-sm font-medium">Acciones</h3>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<MobileActionButton
|
||||
icon={<CheckCircle2 className="size-5" />}
|
||||
label="Completar"
|
||||
className="border-primary/45 bg-primary/12 text-primary"
|
||||
onClick={onComplete}
|
||||
/>
|
||||
<MobileActionButton
|
||||
icon={<MessageCircle className="size-5" />}
|
||||
label="Recordar"
|
||||
className="border-reserved/45 bg-reserved/12 text-reserved"
|
||||
onClick={onReminder}
|
||||
/>
|
||||
<MobileActionButton
|
||||
icon={<XCircle className="size-5" />}
|
||||
label="Cancelar"
|
||||
className="border-destructive/45 bg-destructive/12 text-destructive"
|
||||
onClick={onCancel}
|
||||
/>
|
||||
<MobileActionButton
|
||||
icon={<AlertTriangle className="size-5" />}
|
||||
label="No show"
|
||||
className="border-warning/50 bg-warning/12 text-warning"
|
||||
onClick={onNoShow}
|
||||
/>
|
||||
</div>
|
||||
<ResponsiveDialogFooter className="px-0 pb-0 pt-3">
|
||||
<ResponsiveDialogClose asChild>
|
||||
<Button variant="outline" className="h-11 w-full rounded-lg">
|
||||
Cerrar
|
||||
</Button>
|
||||
</ResponsiveDialogClose>
|
||||
</ResponsiveDialogFooter>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,8 +49,14 @@ export function HomePage() {
|
||||
useEffect(() => {
|
||||
async function autoSelect() {
|
||||
if (myComplexesQuery.data && myComplexesQuery.data.length === 1) {
|
||||
await apiClient.complexes.select({ complexId: myComplexesQuery.data[0].id });
|
||||
queryClient.invalidateQueries({ queryKey: ['current-complex'] });
|
||||
try {
|
||||
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 (
|
||||
myComplexesQuery.data &&
|
||||
myComplexesQuery.data.length > 1 &&
|
||||
|
||||
@@ -6,7 +6,8 @@ type ShareWhatsappButtonProps = {
|
||||
confirmation: PublicBookingConfirmation;
|
||||
};
|
||||
|
||||
function formatDateLabel(isoDate: string) {
|
||||
function formatDateLabel(isoDate: string | null | undefined) {
|
||||
if (!isoDate) return '';
|
||||
const [year, month, day] = isoDate.split('-').map(Number);
|
||||
const date = new Date(year, (month ?? 1) - 1, day ?? 1);
|
||||
|
||||
@@ -18,16 +19,35 @@ function formatDateLabel(isoDate: string) {
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function formatBookingPrice(price: number): string {
|
||||
if (price === 0) {
|
||||
return 'Sin cargo';
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat('es-AR', {
|
||||
style: 'currency',
|
||||
currency: 'ARS',
|
||||
maximumFractionDigits: Number.isInteger(price) ? 0 : 2,
|
||||
}).format(price);
|
||||
}
|
||||
|
||||
function createWhatsappMessage(confirmation: PublicBookingConfirmation) {
|
||||
const dateLabel = formatDateLabel(confirmation.date);
|
||||
return [
|
||||
'Ya reservamos cancha para jugar.',
|
||||
`Complejo: ${confirmation.complexName}`,
|
||||
const lines = ['Ya reservamos cancha para jugar.', `Complejo: ${confirmation.complexName}`];
|
||||
|
||||
if (confirmation.complexAddress) {
|
||||
lines.push(`Direccion: ${confirmation.complexAddress}`);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
`Cancha: ${confirmation.courtName} (${confirmation.sport.name})`,
|
||||
`Fecha: ${dateLabel}`,
|
||||
`Horario: ${confirmation.startTime} - ${confirmation.endTime}`,
|
||||
`Codigo de reserva: ${confirmation.bookingCode}`,
|
||||
].join('\n');
|
||||
`Precio: ${formatBookingPrice(confirmation.price)}`,
|
||||
`Codigo de reserva: ${confirmation.bookingCode}`
|
||||
);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function ShareWhatsappButton({ confirmation }: ShareWhatsappButtonProps) {
|
||||
@@ -36,17 +56,27 @@ export function ShareWhatsappButton({ confirmation }: ShareWhatsappButtonProps)
|
||||
)}`;
|
||||
|
||||
return (
|
||||
<Button type="button" variant="outline" className="h-11 w-full text-sm sm:text-base" asChild>
|
||||
<a href={whatsappUrl} target="_blank" rel="noopener noreferrer">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
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
|
||||
>
|
||||
<a
|
||||
href={whatsappUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Compartir por WhatsApp"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
className="size-4"
|
||||
className="size-5"
|
||||
style={{ color: `#${siWhatsapp.hex}` }}
|
||||
>
|
||||
<path d={siWhatsapp.path} fill="currentColor" />
|
||||
</svg>
|
||||
Compartir por WhatsApp
|
||||
<span className="sm:hidden">Compartir por WhatsApp</span>
|
||||
</a>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,30 @@
|
||||
import PlayzerIcon from '@/assets/playzer-favicon-512-transparent.png';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ApiClientError, apiClient } from '@/lib/api-client';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Input } from '@/components/ui/input';
|
||||
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 {
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
LoaderCircle,
|
||||
Receipt,
|
||||
Trophy,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { ShareWhatsappButton } from './components/share-whatsapp-button';
|
||||
|
||||
type PublicBookingConfirmationPageProps = {
|
||||
@@ -10,23 +33,34 @@ type PublicBookingConfirmationPageProps = {
|
||||
};
|
||||
|
||||
function extractMessage(error: unknown, fallback: string) {
|
||||
if (error instanceof ApiClientError) {
|
||||
return error.message || fallback;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
const msg = (error as { message?: string } | undefined)?.message;
|
||||
return msg || fallback;
|
||||
}
|
||||
|
||||
function formatDateLabel(isoDate: string) {
|
||||
function formatDateLabel(isoDate: string | undefined | null) {
|
||||
if (!isoDate) return '';
|
||||
const [year, month, day] = isoDate.split('-').map(Number);
|
||||
const date = new Date(year, (month ?? 1) - 1, day ?? 1);
|
||||
|
||||
return new Intl.DateTimeFormat('es-AR', {
|
||||
const label = new Intl.DateTimeFormat('es-AR', {
|
||||
weekday: 'long',
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).format(date);
|
||||
|
||||
return label.charAt(0).toUpperCase() + label.slice(1);
|
||||
}
|
||||
|
||||
function formatBookingPrice(price: number): string {
|
||||
if (price === 0) {
|
||||
return 'Sin cargo';
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat('es-AR', {
|
||||
style: 'currency',
|
||||
currency: 'ARS',
|
||||
maximumFractionDigits: Number.isInteger(price) ? 0 : 2,
|
||||
}).format(price);
|
||||
}
|
||||
|
||||
export function PublicBookingConfirmationPage({
|
||||
@@ -34,82 +68,231 @@ export function PublicBookingConfirmationPage({
|
||||
bookingCode,
|
||||
}: PublicBookingConfirmationPageProps) {
|
||||
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({
|
||||
queryKey: ['public-booking-confirmation', 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 (
|
||||
<main className="min-h-screen bg-linear-to-b from-background via-background to-primary/5">
|
||||
<div className="mx-auto flex min-h-screen w-full max-w-3xl items-center px-3 py-8 sm:px-6">
|
||||
<section className="w-full rounded-3xl border bg-card p-5 shadow-lg sm:p-8">
|
||||
{confirmationQuery.isLoading && (
|
||||
<p className="text-sm text-muted-foreground">Cargando confirmacion...</p>
|
||||
<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">
|
||||
<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 && !cancelledBooking && (
|
||||
<div className="flex items-center gap-3 text-sm text-slate-500">
|
||||
<LoaderCircle className="size-5 animate-spin text-emerald-600" />
|
||||
Cargando confirmación...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmationQuery.isError && (
|
||||
<p className="text-sm text-destructive">
|
||||
{extractMessage(
|
||||
confirmationQuery.error,
|
||||
'No pudimos cargar la confirmacion de la reserva.'
|
||||
)}
|
||||
</p>
|
||||
{confirmationQuery.isError && !cancelledBooking && (
|
||||
<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" />
|
||||
<p>
|
||||
{extractMessage(
|
||||
confirmationQuery.error,
|
||||
'No pudimos cargar la confirmación de la reserva.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmationQuery.data && (
|
||||
{displayData && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="text-xs font-medium tracking-[0.2em] text-muted-foreground uppercase">
|
||||
Reserva confirmada
|
||||
</p>
|
||||
<h1 className="mt-2 text-2xl font-semibold sm:text-3xl">
|
||||
{confirmationQuery.data.complexName}
|
||||
</h1>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
{isCancelled ? (
|
||||
<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">
|
||||
<XCircle className="size-4" />
|
||||
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">
|
||||
{displayData.complexName}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-slate-600">
|
||||
<img src={PlayzerIcon} alt="Playzer" className="size-8" />
|
||||
<span className="text-lg font-bold tracking-tight">Playzer</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-primary/30 bg-primary/5 p-4 text-center sm:p-5">
|
||||
<p className="text-xs text-muted-foreground">Codigo de reserva</p>
|
||||
<p className="mt-1 text-3xl font-bold tracking-[0.25em] text-primary sm:text-4xl">
|
||||
{confirmationQuery.data.bookingCode}
|
||||
</p>
|
||||
<div className="rounded-[24px] border border-emerald-500/30 bg-emerald-50/80 p-5 sm:p-6">
|
||||
<div className="flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="flex items-center gap-2 text-sm font-semibold text-emerald-700">
|
||||
<CalendarDays className="size-4" />
|
||||
Tu turno
|
||||
</p>
|
||||
<p className="mt-3 text-2xl font-black leading-tight tracking-tight text-slate-950 sm:text-4xl">
|
||||
{formatDateLabel(displayData.date)}
|
||||
</p>
|
||||
<p className="mt-2 flex items-center gap-2 text-3xl font-black leading-none text-emerald-700 sm:text-5xl">
|
||||
<Clock3 className="size-7 sm:size-9" />
|
||||
{displayData.startTime} - {displayData.endTime}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-emerald-600/20 bg-white/80 px-4 py-3 sm:min-w-40 sm:text-right">
|
||||
<p className="text-xs font-medium text-slate-500">Precio del turno</p>
|
||||
<p className="mt-1 text-2xl font-bold text-slate-950">
|
||||
{formatBookingPrice(displayData.price)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border bg-background p-4 sm:p-5">
|
||||
<p className="text-sm text-muted-foreground">Fecha</p>
|
||||
<p className="mt-1 text-base font-medium capitalize sm:text-lg">
|
||||
{formatDateLabel(confirmationQuery.data.date)}
|
||||
</p>
|
||||
|
||||
<p className="mt-4 text-sm text-muted-foreground">Horario</p>
|
||||
<p className="mt-1 text-base font-medium sm:text-lg">
|
||||
{confirmationQuery.data.startTime} - {confirmationQuery.data.endTime}
|
||||
</p>
|
||||
|
||||
<p className="mt-4 text-sm text-muted-foreground">Cancha</p>
|
||||
<p className="mt-1 text-base font-medium sm:text-lg">
|
||||
{confirmationQuery.data.courtName} · {confirmationQuery.data.sport.name}
|
||||
</p>
|
||||
<div className="overflow-hidden rounded-[22px] border border-slate-200 bg-slate-50/70">
|
||||
<div className="grid gap-px bg-slate-200 sm:grid-cols-2">
|
||||
<div className="bg-white p-4">
|
||||
<p className="flex items-center gap-2 text-sm font-medium text-slate-500">
|
||||
<Trophy className="size-4 text-emerald-600" />
|
||||
Cancha
|
||||
</p>
|
||||
<p className="mt-2 text-base font-semibold text-slate-950">
|
||||
{displayData.courtName} · {displayData.sport.name}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white p-4">
|
||||
<p className="flex items-center gap-2 text-sm font-medium text-slate-500">
|
||||
<Receipt className="size-4 text-emerald-600" />
|
||||
Código de reserva
|
||||
</p>
|
||||
<p className="mt-2 font-mono text-lg font-bold tracking-[0.18em] text-slate-950">
|
||||
{displayData.bookingCode}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<ShareWhatsappButton confirmation={confirmationQuery.data} />
|
||||
<Button
|
||||
type="button"
|
||||
className="h-11 w-full text-sm sm:text-base"
|
||||
onClick={() => {
|
||||
void navigate({
|
||||
to: '/$complexSlug/booking',
|
||||
params: { complexSlug },
|
||||
});
|
||||
}}
|
||||
>
|
||||
Hacer otra reserva
|
||||
</Button>
|
||||
</div>
|
||||
{isCancelled ? (
|
||||
<div className="rounded-2xl bg-red-50 p-4 text-center text-sm text-red-700">
|
||||
Esta reserva fue cancelada. Te enviamos un email con los detalles de la
|
||||
cancelación.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-2 sm:gap-3">
|
||||
<ShareWhatsappButton confirmation={confirmationQuery.data!} />
|
||||
<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"
|
||||
onClick={handleCancelClick}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="h-12 w-full rounded-xl bg-emerald-600 text-sm font-semibold text-white hover:bg-emerald-500 sm:text-base"
|
||||
onClick={() => {
|
||||
void navigate({
|
||||
to: '/$complexSlug/booking',
|
||||
params: { complexSlug },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className="hidden sm:inline">Hacer otra reserva</span>
|
||||
<span className="sm:hidden">Otra</span>
|
||||
<ArrowRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,13 +2,6 @@ import PlayzerIcon from '@/assets/playzer-favicon-512-transparent.png';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Stepper,
|
||||
StepperIndicator,
|
||||
@@ -69,6 +62,7 @@ const bookingFormSchema = z.object({
|
||||
.trim()
|
||||
.min(6, 'Ingresá un teléfono válido.')
|
||||
.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>;
|
||||
@@ -84,6 +78,7 @@ type SelectedSlot = {
|
||||
sportName: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
price: number;
|
||||
};
|
||||
|
||||
type BookingShellProps = {
|
||||
@@ -186,6 +181,18 @@ function extractMessage(error: unknown, fallback: string) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function formatBookingPrice(price: number): string {
|
||||
if (price === 0) {
|
||||
return 'Sin cargo';
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat('es-AR', {
|
||||
style: 'currency',
|
||||
currency: 'ARS',
|
||||
maximumFractionDigits: Number.isInteger(price) ? 0 : 2,
|
||||
}).format(price);
|
||||
}
|
||||
|
||||
function getStep(selectedSlot: SelectedSlot | null, isValid: boolean) {
|
||||
if (!selectedSlot) return 1;
|
||||
if (!isValid) return 3;
|
||||
@@ -638,48 +645,52 @@ function PublicBookingDesktop(props: BookingShellProps) {
|
||||
)}
|
||||
{isError && <StatusPanel>{errorMessage}</StatusPanel>}
|
||||
{isLoading && <StatusPanel>Buscando disponibilidad...</StatusPanel>}
|
||||
<Panel className="overflow-hidden">
|
||||
<div className="flex items-center border-b border-white/10 px-6 py-5">
|
||||
<CourtHeader court={selectedCourt} complexName={props.complexName} />
|
||||
<div className="ml-auto flex items-center overflow-hidden rounded-md border border-white/10 bg-white/[0.035] text-sm">
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-11 items-center justify-center border-r border-white/10 transition hover:bg-white/[0.06]"
|
||||
onClick={props.onPreviousDay}
|
||||
disabled={!canGoBackButton}
|
||||
aria-label="Día anterior"
|
||||
>
|
||||
<ChevronLeft className="size-5" />
|
||||
</button>
|
||||
<div className="flex h-11 min-w-[250px] items-center justify-center gap-2 px-4">
|
||||
<CalendarDays className="size-4 text-white/76" />
|
||||
<span>{selectedDay?.longLabel ?? selectedDate}</span>
|
||||
{selectedCourt && (
|
||||
<>
|
||||
<Panel className="overflow-hidden">
|
||||
<div className="flex items-center border-b border-white/10 px-6 py-5">
|
||||
<CourtHeader court={selectedCourt} complexName={props.complexName} />
|
||||
<div className="ml-auto flex items-center overflow-hidden rounded-md border border-white/10 bg-white/[0.035] text-sm">
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-11 items-center justify-center border-r border-white/10 transition hover:bg-white/[0.06]"
|
||||
onClick={props.onPreviousDay}
|
||||
disabled={!canGoBackButton}
|
||||
aria-label="Día anterior"
|
||||
>
|
||||
<ChevronLeft className="size-5" />
|
||||
</button>
|
||||
<div className="flex h-11 min-w-[250px] items-center justify-center gap-2 px-4">
|
||||
<CalendarDays className="size-4 text-white/76" />
|
||||
<span>{selectedDay?.longLabel ?? selectedDate}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-11 items-center justify-center border-l border-white/10 transition hover:bg-white/[0.06]"
|
||||
onClick={props.onNextDay}
|
||||
aria-label="Día siguiente"
|
||||
>
|
||||
<ChevronRight className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-11 items-center justify-center border-l border-white/10 transition hover:bg-white/[0.06]"
|
||||
onClick={props.onNextDay}
|
||||
aria-label="Día siguiente"
|
||||
>
|
||||
<ChevronRight className="size-5" />
|
||||
</button>
|
||||
|
||||
<div className="p-6">
|
||||
<Legend />
|
||||
<BookingTimeline
|
||||
court={selectedCourt}
|
||||
selectedSlot={selectedSlot}
|
||||
onSelectSlot={onSelectSlot}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_minmax(340px,0.95fr)] gap-4">
|
||||
<CourtDetails court={selectedCourt} />
|
||||
<SelectedSlotCard {...props} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<Legend />
|
||||
<BookingTimeline
|
||||
court={selectedCourt}
|
||||
selectedSlot={selectedSlot}
|
||||
onSelectSlot={onSelectSlot}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_minmax(340px,0.95fr)] gap-4">
|
||||
<CourtDetails court={selectedCourt} />
|
||||
<SelectedSlotCard {...props} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</PublicBookingPageChrome>
|
||||
@@ -711,16 +722,19 @@ function PublicBookingMobile(props: BookingShellProps) {
|
||||
options={props.sports.map((sport) => ({ value: sport.id, label: sport.name }))}
|
||||
/>
|
||||
)}
|
||||
<MobileSelect
|
||||
label="Cancha"
|
||||
value={selectedCourt?.courtId ?? ''}
|
||||
placeholder="Seleccioná"
|
||||
onValueChange={props.onSelectCourt}
|
||||
options={props.courts.map((court) => ({
|
||||
value: court.courtId,
|
||||
label: court.courtName,
|
||||
}))}
|
||||
/>
|
||||
{props.courts.length > 0 ? (
|
||||
<MobileCourtPicker
|
||||
courts={props.courts}
|
||||
selectedCourtId={selectedCourt?.courtId}
|
||||
onSelectCourt={props.onSelectCourt}
|
||||
/>
|
||||
) : (
|
||||
!props.isLoading && (
|
||||
<p className="rounded-md border border-white/10 bg-white/[0.035] p-3 text-sm text-white/62">
|
||||
No hay canchas disponibles para esta fecha.
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
@@ -756,25 +770,23 @@ function PublicBookingMobile(props: BookingShellProps) {
|
||||
)}
|
||||
{props.isLoading && <StatusPanel>Buscando disponibilidad...</StatusPanel>}
|
||||
{props.isError && <StatusPanel>{props.errorMessage}</StatusPanel>}
|
||||
<Panel className="overflow-hidden">
|
||||
<div className="p-3">
|
||||
<CourtHeader court={selectedCourt} compact />
|
||||
<div className="mt-3">
|
||||
<Legend />
|
||||
{selectedCourt && (
|
||||
<Panel className="overflow-hidden">
|
||||
<div className="p-3">
|
||||
<CourtHeader court={selectedCourt} compact />
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-white/10 px-0 py-3">
|
||||
<BookingTimeline
|
||||
court={selectedCourt}
|
||||
selectedSlot={props.selectedSlot}
|
||||
onSelectSlot={props.onSelectSlot}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<div className="px-3 pb-3">
|
||||
<SelectedSlotCard {...props} compact />
|
||||
</div>
|
||||
</Panel>
|
||||
<div className="border-t border-white/10 p-3">
|
||||
<MobileAvailableSlots
|
||||
court={selectedCourt}
|
||||
selectedSlot={props.selectedSlot}
|
||||
onSelectSlot={props.onSelectSlot}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-3 pb-3">
|
||||
<SelectedSlotCard {...props} compact />
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
</PublicBookingPageChrome>
|
||||
);
|
||||
@@ -876,34 +888,119 @@ function MobileSportSelector({
|
||||
);
|
||||
}
|
||||
|
||||
function MobileSelect({
|
||||
label,
|
||||
value,
|
||||
placeholder,
|
||||
options,
|
||||
onValueChange,
|
||||
function MobileCourtPicker({
|
||||
courts,
|
||||
selectedCourtId,
|
||||
onSelectCourt,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
placeholder: string;
|
||||
options: { value: string; label: string }[];
|
||||
onValueChange: (value: string) => void;
|
||||
courts: PublicAvailabilityCourt[];
|
||||
selectedCourtId?: string;
|
||||
onSelectCourt: (courtId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-[88px_minmax(0,1fr)] items-center border-b border-white/10 bg-white/[0.025] px-3 py-2 last:border-b-0">
|
||||
<span className="text-xs text-white/66">{label}</span>
|
||||
<Select value={value} onValueChange={onValueChange}>
|
||||
<SelectTrigger className="h-8 border-0 bg-transparent px-0 text-xs text-white shadow-none">
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="border-b border-white/10 bg-white/[0.025] px-3 py-3 last:border-b-0">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{courts.map((court) => {
|
||||
const isSelected = selectedCourtId === court.courtId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={court.courtId}
|
||||
type="button"
|
||||
onClick={() => onSelectCourt(court.courtId)}
|
||||
aria-pressed={isSelected}
|
||||
className={`flex min-h-16 items-start gap-2 rounded-md border p-3 text-left transition ${
|
||||
isSelected
|
||||
? 'border-emerald-400 bg-emerald-500/20 text-white shadow-[inset_0_0_18px_rgba(16,185,129,0.12)]'
|
||||
: 'border-white/10 bg-white/[0.045] text-white/72 active:border-white/25 active:bg-white/[0.075]'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border ${
|
||||
isSelected
|
||||
? 'border-emerald-300 bg-emerald-400 text-[#061019]'
|
||||
: 'border-white/18 text-transparent'
|
||||
}`}
|
||||
>
|
||||
<Check className="size-3.5" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-semibold">{court.courtName}</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-white/52">
|
||||
{court.sport.name}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileAvailableSlots({
|
||||
court,
|
||||
selectedSlot,
|
||||
onSelectSlot,
|
||||
}: {
|
||||
court: PublicAvailabilityCourt;
|
||||
selectedSlot: SelectedSlot | null;
|
||||
onSelectSlot: (slot: SelectedSlot) => void;
|
||||
}) {
|
||||
const slots = court.availableSlots;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">Horarios disponibles</h3>
|
||||
<p className="mt-0.5 text-xs text-white/58">
|
||||
Turnos de {court.slotDurationMinutes} minutos
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs font-medium text-emerald-300/90">
|
||||
{slots.length} {slots.length === 1 ? 'turno' : 'turnos'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{slots.length > 0 ? (
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
{slots.map((slot) => {
|
||||
const selected = isSlotSelected(slot, court.courtId, selectedSlot);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${court.courtId}-${slot.startTime}`}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onSelectSlot({
|
||||
courtId: court.courtId,
|
||||
courtName: court.courtName,
|
||||
sportId: court.sport.id,
|
||||
sportName: court.sport.name,
|
||||
startTime: slot.startTime,
|
||||
endTime: slot.endTime,
|
||||
price: slot.price,
|
||||
})
|
||||
}
|
||||
aria-pressed={selected}
|
||||
className={`flex h-12 items-center justify-center gap-1.5 rounded-md border text-sm font-bold transition ${
|
||||
selected
|
||||
? 'border-emerald-300 bg-emerald-400 text-[#061019] shadow-lg shadow-emerald-500/20'
|
||||
: 'border-emerald-500/45 bg-emerald-500/12 text-white active:bg-emerald-500/24'
|
||||
}`}
|
||||
>
|
||||
{selected && <Check className="size-4" />}
|
||||
{slot.startTime}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 rounded-md border border-white/10 bg-white/[0.035] p-3 text-sm text-white/62">
|
||||
No hay horarios disponibles para esta cancha.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1011,6 +1108,7 @@ function BookingTimeline({
|
||||
sportName: court.sport.name,
|
||||
startTime: slot.startTime,
|
||||
endTime: slot.endTime,
|
||||
price: slot.price,
|
||||
});
|
||||
}}
|
||||
className={`absolute top-7 flex h-12 min-w-[50px] items-center justify-center rounded border text-xs transition ${
|
||||
@@ -1155,6 +1253,13 @@ function SelectedSlotCard(props: BookingShellProps & { compact?: boolean }) {
|
||||
className={compact ? 'mt-4 space-y-3' : 'mt-5 grid gap-3'}
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border border-emerald-400/20 bg-emerald-400/[0.06] px-3 py-2.5">
|
||||
<span className="text-sm text-white/72">Precio del turno</span>
|
||||
<span className="shrink-0 text-base font-bold text-white">
|
||||
{formatBookingPrice(selectedSlot.price)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={compact ? 'space-y-3' : 'grid grid-cols-2 gap-3'}>
|
||||
<Field data-invalid={Boolean(errors.customerName)}>
|
||||
<FieldLabel htmlFor="customerName" className="text-white/76">
|
||||
@@ -1190,6 +1295,24 @@ function SelectedSlotCard(props: BookingShellProps & { compact?: boolean }) {
|
||||
</Field>
|
||||
</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>}
|
||||
{navigationError && <p className="text-sm text-red-300">{navigationError}</p>}
|
||||
|
||||
@@ -1305,6 +1428,7 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
|
||||
defaultValues: {
|
||||
customerName: '',
|
||||
customerPhone: '',
|
||||
customerEmail: '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1321,6 +1445,7 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
|
||||
startTime: selectedSlot.startTime,
|
||||
customerName: payload.customerName,
|
||||
customerPhone: payload.customerPhone,
|
||||
customerEmail: payload.customerEmail,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
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 { Building2, Loader2 } from 'lucide-react';
|
||||
|
||||
@@ -13,12 +13,15 @@ export function SelectComplexPage() {
|
||||
queryFn: () => apiClient.complexes.listMine(),
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const selectMutation = useMutation({
|
||||
mutationFn: async (complexId: string) => {
|
||||
const response = await apiClient.complexes.select({ complexId });
|
||||
return response;
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(['current-complex'], data);
|
||||
navigate({ to: '/' });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -52,6 +52,7 @@ export const apiClient = {
|
||||
getAvailability: api.getAvailability,
|
||||
create: api.createPublic,
|
||||
getConfirmation: api.getConfirmation,
|
||||
cancelPublic: api.cancelPublic,
|
||||
},
|
||||
adminBookings: {
|
||||
listByComplex: api.listByComplex,
|
||||
|
||||
@@ -10,13 +10,35 @@ export const http: AxiosInstance = axios.create({
|
||||
});
|
||||
|
||||
function extractMessage(details: unknown, fallback: string): string {
|
||||
if (
|
||||
typeof details === 'object' &&
|
||||
details &&
|
||||
'message' in details &&
|
||||
typeof details.message === 'string'
|
||||
) {
|
||||
return details.message;
|
||||
if (typeof details === 'object' && details) {
|
||||
if ('message' in details && typeof details.message === 'string') {
|
||||
return details.message;
|
||||
}
|
||||
if ('detail' in details && typeof details.detail === 'string') {
|
||||
return details.detail;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export * as sports from './resources/sports';
|
||||
export * as plans from './resources/plans';
|
||||
|
||||
export {
|
||||
cancelPublic,
|
||||
getAvailability,
|
||||
createPublic,
|
||||
getConfirmation,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
AdminBooking,
|
||||
CancelPublicBookingInput,
|
||||
CreateAdminBookingInput,
|
||||
CreatePublicBookingInput,
|
||||
PublicAvailabilityResponse,
|
||||
@@ -51,6 +52,14 @@ export async function createAdmin(complexId: string, payload: CreateAdminBooking
|
||||
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) {
|
||||
const response = await http.patch<AdminBooking>(
|
||||
`/api/admin-bookings/${bookingId}/status`,
|
||||
|
||||
@@ -6,21 +6,32 @@ import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import { RouterProvider } from '@tanstack/react-router';
|
||||
import { TanStackRouterDevtools } from '@tanstack/router-devtools';
|
||||
import { StrictMode, useEffect } from 'react';
|
||||
import { StrictMode, useEffect, useRef } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { router } from './router';
|
||||
import './index.css';
|
||||
|
||||
function AppRouter() {
|
||||
const auth = useAuth();
|
||||
const prevAuthRef = useRef(auth);
|
||||
|
||||
if (auth !== prevAuthRef.current) {
|
||||
prevAuthRef.current = auth;
|
||||
router.update({ context: { auth, api: apiClient } });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
router.update({ context: { auth, api: apiClient } });
|
||||
router.invalidate();
|
||||
}, [auth.isAuthenticated, auth.loading, auth.user]);
|
||||
|
||||
useEffect(() => {
|
||||
configureApiClient({
|
||||
onForbidden: async () => {
|
||||
onForbidden: async (error) => {
|
||||
if (error.message?.includes('verificar tu email')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!auth.isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { PublicBookingConfirmationPage } from '@/features/public-booking/public-booking-confirmation-page';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { createFileRoute, lazyRouteComponent } from '@tanstack/react-router';
|
||||
|
||||
const PublicBookingConfirmationPage = lazyRouteComponent(
|
||||
() => import('@/features/public-booking/public-booking-confirmation-page'),
|
||||
'PublicBookingConfirmationPage'
|
||||
);
|
||||
|
||||
export const Route = createFileRoute('/$complexSlug/booking/confirmed/$bookingCode')({
|
||||
component: PublicBookingConfirmationRouteComponent,
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function PublicBookingConfirmationRouteComponent() {
|
||||
function RouteComponent() {
|
||||
const { complexSlug, bookingCode } = Route.useParams();
|
||||
|
||||
return <PublicBookingConfirmationPage complexSlug={complexSlug} bookingCode={bookingCode} />;
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { PublicBookingPage } from '@/features/public-booking/public-booking-page';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { createFileRoute, lazyRouteComponent } from '@tanstack/react-router';
|
||||
|
||||
const PublicBookingPage = lazyRouteComponent(
|
||||
() => import('@/features/public-booking/public-booking-page'),
|
||||
'PublicBookingPage'
|
||||
);
|
||||
|
||||
export const Route = createFileRoute('/$complexSlug/booking/')({
|
||||
component: PublicBookingIndexRouteComponent,
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function PublicBookingIndexRouteComponent() {
|
||||
function RouteComponent() {
|
||||
const { complexSlug } = Route.useParams();
|
||||
|
||||
return <PublicBookingPage complexSlug={complexSlug} />;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { ComplexSettingsPage } from '@/features/complex/complex-settings-page';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { createFileRoute, lazyRouteComponent } from '@tanstack/react-router';
|
||||
|
||||
const ComplexSettingsPage = lazyRouteComponent(
|
||||
() => import('@/features/complex/complex-settings-page'),
|
||||
'ComplexSettingsPage'
|
||||
);
|
||||
|
||||
export const Route = createFileRoute('/_app/_authenticated/complex/$slug/edit')({
|
||||
component: RouteComponent,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { ProfilePage } from '@/features/profile/profile-page';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { createFileRoute, lazyRouteComponent } from '@tanstack/react-router';
|
||||
|
||||
const ProfilePage = lazyRouteComponent(
|
||||
() => import('@/features/profile/profile-page'),
|
||||
'ProfilePage'
|
||||
);
|
||||
|
||||
export const Route = createFileRoute('/_app/profile')({
|
||||
component: ProfilePage,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { SetupStepperPage } from '@/features/onboard/setup-stepper-page';
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router';
|
||||
import { createFileRoute, lazyRouteComponent, redirect } from '@tanstack/react-router';
|
||||
|
||||
const SetupStepperPage = lazyRouteComponent(
|
||||
() => import('@/features/onboard/setup-stepper-page'),
|
||||
'SetupStepperPage'
|
||||
);
|
||||
|
||||
export const Route = createFileRoute('/onboard/setup')({
|
||||
beforeLoad: ({ context }) => {
|
||||
|
||||
2
bunfig.toml
Normal file
2
bunfig.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[test]
|
||||
preload = ["./apps/backend/test/support/prisma.mock.ts"]
|
||||
@@ -3,4 +3,5 @@ set -e
|
||||
cd /app/apps/backend
|
||||
bun prisma migrate deploy
|
||||
bun prisma generate
|
||||
bun prisma/seed.ts
|
||||
bun run start
|
||||
@@ -19,7 +19,8 @@
|
||||
"lint:frontend": "bun --filter frontend lint",
|
||||
"lint:frontend:fix": "bun --filter frontend lint:fix",
|
||||
"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": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
|
||||
@@ -25,6 +25,8 @@ export const adminBookingSchema = z.object({
|
||||
endTime: z.string().regex(TIME_REGEX),
|
||||
customerName: z.string(),
|
||||
customerPhone: z.string(),
|
||||
customerEmail: z.string(),
|
||||
price: z.number().nonnegative(),
|
||||
status: bookingStatusSchema,
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
@@ -52,6 +54,9 @@ export const createAdminBookingSchema = z.object({
|
||||
.trim()
|
||||
.min(6, 'El telefono debe tener al menos 6 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({
|
||||
|
||||
@@ -98,9 +98,11 @@ export {
|
||||
publicBookingSchema,
|
||||
publicBookingSlotSchema,
|
||||
publicBookingSportSchema,
|
||||
cancelPublicBookingSchema,
|
||||
} from './public-booking'
|
||||
export type {
|
||||
CreatePublicBookingInput,
|
||||
CancelPublicBookingInput,
|
||||
PublicAvailabilityCourt,
|
||||
PublicAvailabilityQuery,
|
||||
PublicAvailabilityResponse,
|
||||
|
||||
@@ -19,6 +19,7 @@ export const publicBookingSportSchema = z.object({
|
||||
export const publicBookingSlotSchema = z.object({
|
||||
startTime: z.string().regex(TIME_REGEX),
|
||||
endTime: z.string().regex(TIME_REGEX),
|
||||
price: z.number().nonnegative(),
|
||||
})
|
||||
|
||||
export const publicAvailabilityCourtSchema = z.object({
|
||||
@@ -56,6 +57,18 @@ export const createPublicBookingSchema = z.object({
|
||||
.trim()
|
||||
.min(6, 'El telefono debe tener al menos 6 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({
|
||||
@@ -72,20 +85,25 @@ export const publicBookingSchema = z.object({
|
||||
endTime: z.string().regex(TIME_REGEX),
|
||||
customerName: z.string(),
|
||||
customerPhone: z.string(),
|
||||
customerEmail: z.string(),
|
||||
status: z.enum(['CONFIRMED', 'CANCELLED', 'COMPLETED']),
|
||||
price: z.number().nonnegative(),
|
||||
createdAt: z.string().datetime(),
|
||||
})
|
||||
|
||||
export const publicBookingConfirmationSchema = z.object({
|
||||
bookingCode: z.string().regex(BOOKING_CODE_REGEX),
|
||||
complexName: z.string(),
|
||||
complexAddress: z.string().optional(),
|
||||
complexSlug: z.string(),
|
||||
date: z.string().regex(ISO_DATE_REGEX),
|
||||
startTime: z.string().regex(TIME_REGEX),
|
||||
endTime: z.string().regex(TIME_REGEX),
|
||||
price: z.number().nonnegative(),
|
||||
courtName: z.string(),
|
||||
sport: publicBookingSportSchema,
|
||||
status: z.enum(['CONFIRMED', 'CANCELLED', 'COMPLETED']),
|
||||
customerEmail: z.string(),
|
||||
createdAt: z.string().datetime(),
|
||||
})
|
||||
|
||||
@@ -95,5 +113,6 @@ export type PublicBookingSlot = z.infer<typeof publicBookingSlotSchema>
|
||||
export type PublicAvailabilityCourt = z.infer<typeof publicAvailabilityCourtSchema>
|
||||
export type PublicAvailabilityResponse = z.infer<typeof publicAvailabilityResponseSchema>
|
||||
export type CreatePublicBookingInput = z.infer<typeof createPublicBookingSchema>
|
||||
export type CancelPublicBookingInput = z.infer<typeof cancelPublicBookingSchema>
|
||||
export type PublicBooking = z.infer<typeof publicBookingSchema>
|
||||
export type PublicBookingConfirmation = z.infer<typeof publicBookingConfirmationSchema>
|
||||
|
||||
Reference in New Issue
Block a user