Compare commits
36 Commits
78df95e985
...
feat/court
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
630dedb507 | ||
|
|
49d2a13672 | ||
|
|
b2f9a14b87 | ||
|
|
b48dd4f15d | ||
|
|
fee0be7e1f | ||
|
|
721ebaa775 | ||
| 2ccff7dcaa | |||
|
|
4b1b06f00f | ||
|
|
af687fe2d8 | ||
| 9875f22d5a | |||
|
|
260d79fc99 | ||
|
|
1210854c22 | ||
|
|
7ca784d5f5 | ||
|
|
e441f15ee4 | ||
|
|
228004a7e0 | ||
| b20b5c2b8b | |||
|
|
457accfbfa | ||
|
|
43287a4baa | ||
|
|
93fea8ecad | ||
|
|
ba7b0322ff | ||
|
|
473686528e | ||
| c1b47674c8 | |||
|
|
85f234b05e | ||
| 50fa4ed9a5 | |||
|
|
07496e6673 | ||
|
|
9045a8c017 | ||
|
|
3e314a9b9a | ||
| bc4716c48f | |||
|
|
0e69759549 | ||
|
|
88ea7e70da | ||
|
|
7a30f9a29b | ||
| 83fa1ea020 | |||
|
|
4544911f0e | ||
|
|
2aaa91444d | ||
| 4d3867614d | |||
|
|
c18db76c7a |
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`
|
1. Create `lib/api/resources/[resource].ts`
|
||||||
2. Export functions using `http` from `../http`
|
2. Export functions using `http` from `../http`
|
||||||
3. Add exports in `lib/api/index.ts`
|
3. Add exports in `lib/api/index.ts`
|
||||||
|
|
||||||
|
## Email Templates
|
||||||
|
|
||||||
|
Todos los emails deben usar la misma estética. El layout compartido está en `apps/backend/src/emails/booking-confirmation.ts`.
|
||||||
|
|
||||||
|
### Layout (`wrapLayout`)
|
||||||
|
|
||||||
|
Exportado como `wrapLayout(content)`. Proporciona:
|
||||||
|
- Fondo: `#edf7f4`
|
||||||
|
- Card blanca: `max-width: 520px`, `border-radius: 28px`, `box-shadow: 0 24px 70px rgba(15,23,42,0.12)`, `border: 1px solid rgba(5,9,20,0.1)`
|
||||||
|
- Playzer favicon: `{APP_BASE_URL}/playzer-favicon-512-transparent.png` (está en `apps/frontend/public/`)
|
||||||
|
- **Sin `min-height: 100vh`**: la card empieza arriba, no centrada verticalmente
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { wrapLayout } from '@/emails/booking-confirmation';
|
||||||
|
|
||||||
|
const html = wrapLayout(`<tr>...contenido...</tr>`);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Estructura de cada email
|
||||||
|
|
||||||
|
| Sección | Descripción |
|
||||||
|
|---------|-------------|
|
||||||
|
| **Header** | Dos columnas: badge pill a la izquierda + Playzer (favicon + texto) a la derecha. El badge usa `border-radius: 999px`, `padding: 4px 12px`, `font-size: 11px`, `font-weight: 700`, `letter-spacing: 0.14em`, `text-transform: uppercase`. |
|
||||||
|
| **Card fecha/hora** | Fondo `#f0fdf4`, borde `1px solid rgba(5,150,105,0.3)`, `border-radius: 24px`. Siempre verde aunque el email sea de cancelación. |
|
||||||
|
| **Grilla detalles** | `border: 1px solid #e5e7eb`, `border-radius: 22px`, dos celdas de 50% con `border-right` en la primera. |
|
||||||
|
| **Botón CTA** | Tabla con fondo `#059669`, `border-radius: 12px`, link blanco con `padding: 14px 32px`. |
|
||||||
|
| **Pie** | Sin pie de marca. Solo texto secundario opcional centrado si es necesario. |
|
||||||
|
| **Sin botones de acción** | Los emails de booking **no** incluyen los botones "Compartir por WhatsApp" ni "Hacer otra reserva". |
|
||||||
|
|
||||||
|
### Padding estándar
|
||||||
|
|
||||||
|
| Ubicación | Valor |
|
||||||
|
|-----------|-------|
|
||||||
|
| Wrapper (outer `<td>`) | `padding: 24px 12px` |
|
||||||
|
| Primer `<td>` del contenido (header) | `padding: 24px 20px 16px` |
|
||||||
|
| `<td>` intermedios (cards, texto) | `padding: 0 20px 16px` |
|
||||||
|
| Último `<td>` del contenido | `padding: 0 20px 24px` |
|
||||||
|
|
||||||
|
### Colores de badges según estado
|
||||||
|
|
||||||
|
| Estado | Fondo badge | Texto badge |
|
||||||
|
|--------|-------------|-------------|
|
||||||
|
| Confirmado | `#f0fdf4` | `#15803d` |
|
||||||
|
| Cancelado | `#fef2f2` | `#dc2626` |
|
||||||
|
| No concretado | `#fffbeb` | `#d97706` |
|
||||||
|
| Neutro (verificación, etc.) | `#f4f4f5` | `#71717a` |
|
||||||
|
|
||||||
|
### Archivos de templates
|
||||||
|
|
||||||
|
| Archivo | Templates |
|
||||||
|
|---------|-----------|
|
||||||
|
| `apps/backend/src/emails/booking-confirmation.ts` | `bookingConfirmationHtml`, `bookingCancelledHtml`, `bookingNoShowHtml` + exporta `wrapLayout` |
|
||||||
|
| `apps/backend/src/lib/auth.ts` | Email de verificación de Better Auth (usa `wrapLayout` inline) |
|
||||||
|
|
||||||
|
### Regla general
|
||||||
|
|
||||||
|
Para emails nuevos: importar `wrapLayout`, construir el HTML interno con `<tr>`s, usar la misma estructura de cabecera (badge + Playzer), y nunca incluir el pie "Playzer — Reserva de canchas online". Usar `APP_BASE_URL` para construir URLs absolutas al logo.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"dev": "bun run --hot src/server.ts",
|
"dev": "bun run --hot src/server.ts",
|
||||||
"start": "bun src/server.ts",
|
"start": "bun src/server.ts",
|
||||||
"build": "tsc -b",
|
"build": "tsc -b",
|
||||||
"test": "bun test",
|
"test": "bun test --preload ./test/support/prisma.mock.ts ./test",
|
||||||
"lint": "biome check .",
|
"lint": "biome check .",
|
||||||
"lint:fix": "biome check --write .",
|
"lint:fix": "biome check --write .",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ model User {
|
|||||||
email String
|
email String
|
||||||
emailVerified Boolean @default(false)
|
emailVerified Boolean @default(false)
|
||||||
image String?
|
image String?
|
||||||
|
phone String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ model CourtBooking {
|
|||||||
endTime String @map("end_time") @db.VarChar(5)
|
endTime String @map("end_time") @db.VarChar(5)
|
||||||
customerName String @map("customer_name") @db.VarChar(120)
|
customerName String @map("customer_name") @db.VarChar(120)
|
||||||
customerPhone String @map("customer_phone") @db.VarChar(30)
|
customerPhone String @map("customer_phone") @db.VarChar(30)
|
||||||
|
customerEmail String @map("customer_email") @db.VarChar(254)
|
||||||
status CourtBookingStatus @default(CONFIRMED)
|
status CourtBookingStatus @default(CONFIRMED)
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
@@ -107,6 +108,7 @@ model CourtBookingLog {
|
|||||||
newStatus CourtBookingStatus @map("new_status")
|
newStatus CourtBookingStatus @map("new_status")
|
||||||
customerName String @map("customer_name") @db.VarChar(120)
|
customerName String @map("customer_name") @db.VarChar(120)
|
||||||
customerPhone String @map("customer_phone") @db.VarChar(30)
|
customerPhone String @map("customer_phone") @db.VarChar(30)
|
||||||
|
customerEmail String @map("customer_email") @db.VarChar(254)
|
||||||
changedAt DateTime @default(now()) @map("changed_at")
|
changedAt DateTime @default(now()) @map("changed_at")
|
||||||
|
|
||||||
@@map("court_booking_logs")
|
@@map("court_booking_logs")
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" ADD COLUMN "phone" TEXT;
|
||||||
@@ -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;
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
model OnboardingRequest {
|
|
||||||
id String @id @db.Uuid
|
|
||||||
fullName String @map("full_name")
|
|
||||||
email String
|
|
||||||
otpHash String @map("otp_hash")
|
|
||||||
otpExpiresAt DateTime @map("otp_expires_at")
|
|
||||||
otpAttempts Int @default(0) @map("otp_attempts")
|
|
||||||
otpLastSentAt DateTime @map("otp_last_sent_at")
|
|
||||||
otpResendCount Int @default(0) @map("otp_resend_count")
|
|
||||||
emailVerifiedAt DateTime? @map("email_verified_at")
|
|
||||||
completedAt DateTime? @map("completed_at")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
|
||||||
|
|
||||||
@@index([email])
|
|
||||||
@@index([otpExpiresAt])
|
|
||||||
@@map("onboarding_requests")
|
|
||||||
}
|
|
||||||
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
|
export type CourtBookingLog = Prisma.CourtBookingLogModel
|
||||||
/**
|
|
||||||
* Model OnboardingRequest
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
export type OnboardingRequest = Prisma.OnboardingRequestModel
|
|
||||||
/**
|
/**
|
||||||
* Model PasswordResetRequest
|
* Model PasswordResetRequest
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -106,11 +106,6 @@ export type CourtBooking = Prisma.CourtBookingModel
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export type CourtBookingLog = Prisma.CourtBookingLogModel
|
export type CourtBookingLog = Prisma.CourtBookingLogModel
|
||||||
/**
|
|
||||||
* Model OnboardingRequest
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
export type OnboardingRequest = Prisma.OnboardingRequestModel
|
|
||||||
/**
|
/**
|
||||||
* Model PasswordResetRequest
|
* Model PasswordResetRequest
|
||||||
*
|
*
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -397,7 +397,6 @@ export const ModelName = {
|
|||||||
CourtPriceRule: 'CourtPriceRule',
|
CourtPriceRule: 'CourtPriceRule',
|
||||||
CourtBooking: 'CourtBooking',
|
CourtBooking: 'CourtBooking',
|
||||||
CourtBookingLog: 'CourtBookingLog',
|
CourtBookingLog: 'CourtBookingLog',
|
||||||
OnboardingRequest: 'OnboardingRequest',
|
|
||||||
PasswordResetRequest: 'PasswordResetRequest',
|
PasswordResetRequest: 'PasswordResetRequest',
|
||||||
Plan: 'Plan'
|
Plan: 'Plan'
|
||||||
} as const
|
} as const
|
||||||
@@ -415,7 +414,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
|||||||
omit: GlobalOmitOptions
|
omit: GlobalOmitOptions
|
||||||
}
|
}
|
||||||
meta: {
|
meta: {
|
||||||
modelProps: "user" | "session" | "account" | "verification" | "complex" | "complexUser" | "complexInvitation" | "sport" | "court" | "courtAvailability" | "courtPriceRule" | "courtBooking" | "courtBookingLog" | "onboardingRequest" | "passwordResetRequest" | "plan"
|
modelProps: "user" | "session" | "account" | "verification" | "complex" | "complexUser" | "complexInvitation" | "sport" | "court" | "courtAvailability" | "courtPriceRule" | "courtBooking" | "courtBookingLog" | "passwordResetRequest" | "plan"
|
||||||
txIsolationLevel: TransactionIsolationLevel
|
txIsolationLevel: TransactionIsolationLevel
|
||||||
}
|
}
|
||||||
model: {
|
model: {
|
||||||
@@ -1381,80 +1380,6 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
OnboardingRequest: {
|
|
||||||
payload: Prisma.$OnboardingRequestPayload<ExtArgs>
|
|
||||||
fields: Prisma.OnboardingRequestFieldRefs
|
|
||||||
operations: {
|
|
||||||
findUnique: {
|
|
||||||
args: Prisma.OnboardingRequestFindUniqueArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload> | null
|
|
||||||
}
|
|
||||||
findUniqueOrThrow: {
|
|
||||||
args: Prisma.OnboardingRequestFindUniqueOrThrowArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload>
|
|
||||||
}
|
|
||||||
findFirst: {
|
|
||||||
args: Prisma.OnboardingRequestFindFirstArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload> | null
|
|
||||||
}
|
|
||||||
findFirstOrThrow: {
|
|
||||||
args: Prisma.OnboardingRequestFindFirstOrThrowArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload>
|
|
||||||
}
|
|
||||||
findMany: {
|
|
||||||
args: Prisma.OnboardingRequestFindManyArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload>[]
|
|
||||||
}
|
|
||||||
create: {
|
|
||||||
args: Prisma.OnboardingRequestCreateArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload>
|
|
||||||
}
|
|
||||||
createMany: {
|
|
||||||
args: Prisma.OnboardingRequestCreateManyArgs<ExtArgs>
|
|
||||||
result: BatchPayload
|
|
||||||
}
|
|
||||||
createManyAndReturn: {
|
|
||||||
args: Prisma.OnboardingRequestCreateManyAndReturnArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload>[]
|
|
||||||
}
|
|
||||||
delete: {
|
|
||||||
args: Prisma.OnboardingRequestDeleteArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload>
|
|
||||||
}
|
|
||||||
update: {
|
|
||||||
args: Prisma.OnboardingRequestUpdateArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload>
|
|
||||||
}
|
|
||||||
deleteMany: {
|
|
||||||
args: Prisma.OnboardingRequestDeleteManyArgs<ExtArgs>
|
|
||||||
result: BatchPayload
|
|
||||||
}
|
|
||||||
updateMany: {
|
|
||||||
args: Prisma.OnboardingRequestUpdateManyArgs<ExtArgs>
|
|
||||||
result: BatchPayload
|
|
||||||
}
|
|
||||||
updateManyAndReturn: {
|
|
||||||
args: Prisma.OnboardingRequestUpdateManyAndReturnArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload>[]
|
|
||||||
}
|
|
||||||
upsert: {
|
|
||||||
args: Prisma.OnboardingRequestUpsertArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload>
|
|
||||||
}
|
|
||||||
aggregate: {
|
|
||||||
args: Prisma.OnboardingRequestAggregateArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.Optional<Prisma.AggregateOnboardingRequest>
|
|
||||||
}
|
|
||||||
groupBy: {
|
|
||||||
args: Prisma.OnboardingRequestGroupByArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.Optional<Prisma.OnboardingRequestGroupByOutputType>[]
|
|
||||||
}
|
|
||||||
count: {
|
|
||||||
args: Prisma.OnboardingRequestCountArgs<ExtArgs>
|
|
||||||
result: runtime.Types.Utils.Optional<Prisma.OnboardingRequestCountAggregateOutputType> | number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PasswordResetRequest: {
|
PasswordResetRequest: {
|
||||||
payload: Prisma.$PasswordResetRequestPayload<ExtArgs>
|
payload: Prisma.$PasswordResetRequestPayload<ExtArgs>
|
||||||
fields: Prisma.PasswordResetRequestFieldRefs
|
fields: Prisma.PasswordResetRequestFieldRefs
|
||||||
@@ -1648,6 +1573,7 @@ export const UserScalarFieldEnum = {
|
|||||||
email: 'email',
|
email: 'email',
|
||||||
emailVerified: 'emailVerified',
|
emailVerified: 'emailVerified',
|
||||||
image: 'image',
|
image: 'image',
|
||||||
|
phone: 'phone',
|
||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
updatedAt: 'updatedAt',
|
updatedAt: 'updatedAt',
|
||||||
role: 'role'
|
role: 'role'
|
||||||
@@ -1805,6 +1731,7 @@ export const CourtBookingScalarFieldEnum = {
|
|||||||
endTime: 'endTime',
|
endTime: 'endTime',
|
||||||
customerName: 'customerName',
|
customerName: 'customerName',
|
||||||
customerPhone: 'customerPhone',
|
customerPhone: 'customerPhone',
|
||||||
|
customerEmail: 'customerEmail',
|
||||||
status: 'status',
|
status: 'status',
|
||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
updatedAt: 'updatedAt'
|
updatedAt: 'updatedAt'
|
||||||
@@ -1824,30 +1751,13 @@ export const CourtBookingLogScalarFieldEnum = {
|
|||||||
newStatus: 'newStatus',
|
newStatus: 'newStatus',
|
||||||
customerName: 'customerName',
|
customerName: 'customerName',
|
||||||
customerPhone: 'customerPhone',
|
customerPhone: 'customerPhone',
|
||||||
|
customerEmail: 'customerEmail',
|
||||||
changedAt: 'changedAt'
|
changedAt: 'changedAt'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type CourtBookingLogScalarFieldEnum = (typeof CourtBookingLogScalarFieldEnum)[keyof typeof CourtBookingLogScalarFieldEnum]
|
export type CourtBookingLogScalarFieldEnum = (typeof CourtBookingLogScalarFieldEnum)[keyof typeof CourtBookingLogScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
export const OnboardingRequestScalarFieldEnum = {
|
|
||||||
id: 'id',
|
|
||||||
fullName: 'fullName',
|
|
||||||
email: 'email',
|
|
||||||
otpHash: 'otpHash',
|
|
||||||
otpExpiresAt: 'otpExpiresAt',
|
|
||||||
otpAttempts: 'otpAttempts',
|
|
||||||
otpLastSentAt: 'otpLastSentAt',
|
|
||||||
otpResendCount: 'otpResendCount',
|
|
||||||
emailVerifiedAt: 'emailVerifiedAt',
|
|
||||||
completedAt: 'completedAt',
|
|
||||||
createdAt: 'createdAt',
|
|
||||||
updatedAt: 'updatedAt'
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export type OnboardingRequestScalarFieldEnum = (typeof OnboardingRequestScalarFieldEnum)[keyof typeof OnboardingRequestScalarFieldEnum]
|
|
||||||
|
|
||||||
|
|
||||||
export const PasswordResetRequestScalarFieldEnum = {
|
export const PasswordResetRequestScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
email: 'email',
|
email: 'email',
|
||||||
@@ -2161,7 +2071,6 @@ export type GlobalOmitConfig = {
|
|||||||
courtPriceRule?: Prisma.CourtPriceRuleOmit
|
courtPriceRule?: Prisma.CourtPriceRuleOmit
|
||||||
courtBooking?: Prisma.CourtBookingOmit
|
courtBooking?: Prisma.CourtBookingOmit
|
||||||
courtBookingLog?: Prisma.CourtBookingLogOmit
|
courtBookingLog?: Prisma.CourtBookingLogOmit
|
||||||
onboardingRequest?: Prisma.OnboardingRequestOmit
|
|
||||||
passwordResetRequest?: Prisma.PasswordResetRequestOmit
|
passwordResetRequest?: Prisma.PasswordResetRequestOmit
|
||||||
plan?: Prisma.PlanOmit
|
plan?: Prisma.PlanOmit
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ export const ModelName = {
|
|||||||
CourtPriceRule: 'CourtPriceRule',
|
CourtPriceRule: 'CourtPriceRule',
|
||||||
CourtBooking: 'CourtBooking',
|
CourtBooking: 'CourtBooking',
|
||||||
CourtBookingLog: 'CourtBookingLog',
|
CourtBookingLog: 'CourtBookingLog',
|
||||||
OnboardingRequest: 'OnboardingRequest',
|
|
||||||
PasswordResetRequest: 'PasswordResetRequest',
|
PasswordResetRequest: 'PasswordResetRequest',
|
||||||
Plan: 'Plan'
|
Plan: 'Plan'
|
||||||
} as const
|
} as const
|
||||||
@@ -91,6 +90,7 @@ export const UserScalarFieldEnum = {
|
|||||||
email: 'email',
|
email: 'email',
|
||||||
emailVerified: 'emailVerified',
|
emailVerified: 'emailVerified',
|
||||||
image: 'image',
|
image: 'image',
|
||||||
|
phone: 'phone',
|
||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
updatedAt: 'updatedAt',
|
updatedAt: 'updatedAt',
|
||||||
role: 'role'
|
role: 'role'
|
||||||
@@ -248,6 +248,7 @@ export const CourtBookingScalarFieldEnum = {
|
|||||||
endTime: 'endTime',
|
endTime: 'endTime',
|
||||||
customerName: 'customerName',
|
customerName: 'customerName',
|
||||||
customerPhone: 'customerPhone',
|
customerPhone: 'customerPhone',
|
||||||
|
customerEmail: 'customerEmail',
|
||||||
status: 'status',
|
status: 'status',
|
||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
updatedAt: 'updatedAt'
|
updatedAt: 'updatedAt'
|
||||||
@@ -267,30 +268,13 @@ export const CourtBookingLogScalarFieldEnum = {
|
|||||||
newStatus: 'newStatus',
|
newStatus: 'newStatus',
|
||||||
customerName: 'customerName',
|
customerName: 'customerName',
|
||||||
customerPhone: 'customerPhone',
|
customerPhone: 'customerPhone',
|
||||||
|
customerEmail: 'customerEmail',
|
||||||
changedAt: 'changedAt'
|
changedAt: 'changedAt'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type CourtBookingLogScalarFieldEnum = (typeof CourtBookingLogScalarFieldEnum)[keyof typeof CourtBookingLogScalarFieldEnum]
|
export type CourtBookingLogScalarFieldEnum = (typeof CourtBookingLogScalarFieldEnum)[keyof typeof CourtBookingLogScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
export const OnboardingRequestScalarFieldEnum = {
|
|
||||||
id: 'id',
|
|
||||||
fullName: 'fullName',
|
|
||||||
email: 'email',
|
|
||||||
otpHash: 'otpHash',
|
|
||||||
otpExpiresAt: 'otpExpiresAt',
|
|
||||||
otpAttempts: 'otpAttempts',
|
|
||||||
otpLastSentAt: 'otpLastSentAt',
|
|
||||||
otpResendCount: 'otpResendCount',
|
|
||||||
emailVerifiedAt: 'emailVerifiedAt',
|
|
||||||
completedAt: 'completedAt',
|
|
||||||
createdAt: 'createdAt',
|
|
||||||
updatedAt: 'updatedAt'
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export type OnboardingRequestScalarFieldEnum = (typeof OnboardingRequestScalarFieldEnum)[keyof typeof OnboardingRequestScalarFieldEnum]
|
|
||||||
|
|
||||||
|
|
||||||
export const PasswordResetRequestScalarFieldEnum = {
|
export const PasswordResetRequestScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
email: 'email',
|
email: 'email',
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ export type * from './models/CourtAvailability'
|
|||||||
export type * from './models/CourtPriceRule'
|
export type * from './models/CourtPriceRule'
|
||||||
export type * from './models/CourtBooking'
|
export type * from './models/CourtBooking'
|
||||||
export type * from './models/CourtBookingLog'
|
export type * from './models/CourtBookingLog'
|
||||||
export type * from './models/OnboardingRequest'
|
|
||||||
export type * from './models/PasswordResetRequest'
|
export type * from './models/PasswordResetRequest'
|
||||||
export type * from './models/Plan'
|
export type * from './models/Plan'
|
||||||
export type * from './commonInputTypes'
|
export type * from './commonInputTypes'
|
||||||
@@ -33,6 +33,7 @@ export type CourtBookingMinAggregateOutputType = {
|
|||||||
endTime: string | null
|
endTime: string | null
|
||||||
customerName: string | null
|
customerName: string | null
|
||||||
customerPhone: string | null
|
customerPhone: string | null
|
||||||
|
customerEmail: string | null
|
||||||
status: $Enums.CourtBookingStatus | null
|
status: $Enums.CourtBookingStatus | null
|
||||||
createdAt: Date | null
|
createdAt: Date | null
|
||||||
updatedAt: Date | null
|
updatedAt: Date | null
|
||||||
@@ -47,6 +48,7 @@ export type CourtBookingMaxAggregateOutputType = {
|
|||||||
endTime: string | null
|
endTime: string | null
|
||||||
customerName: string | null
|
customerName: string | null
|
||||||
customerPhone: string | null
|
customerPhone: string | null
|
||||||
|
customerEmail: string | null
|
||||||
status: $Enums.CourtBookingStatus | null
|
status: $Enums.CourtBookingStatus | null
|
||||||
createdAt: Date | null
|
createdAt: Date | null
|
||||||
updatedAt: Date | null
|
updatedAt: Date | null
|
||||||
@@ -61,6 +63,7 @@ export type CourtBookingCountAggregateOutputType = {
|
|||||||
endTime: number
|
endTime: number
|
||||||
customerName: number
|
customerName: number
|
||||||
customerPhone: number
|
customerPhone: number
|
||||||
|
customerEmail: number
|
||||||
status: number
|
status: number
|
||||||
createdAt: number
|
createdAt: number
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
@@ -77,6 +80,7 @@ export type CourtBookingMinAggregateInputType = {
|
|||||||
endTime?: true
|
endTime?: true
|
||||||
customerName?: true
|
customerName?: true
|
||||||
customerPhone?: true
|
customerPhone?: true
|
||||||
|
customerEmail?: true
|
||||||
status?: true
|
status?: true
|
||||||
createdAt?: true
|
createdAt?: true
|
||||||
updatedAt?: true
|
updatedAt?: true
|
||||||
@@ -91,6 +95,7 @@ export type CourtBookingMaxAggregateInputType = {
|
|||||||
endTime?: true
|
endTime?: true
|
||||||
customerName?: true
|
customerName?: true
|
||||||
customerPhone?: true
|
customerPhone?: true
|
||||||
|
customerEmail?: true
|
||||||
status?: true
|
status?: true
|
||||||
createdAt?: true
|
createdAt?: true
|
||||||
updatedAt?: true
|
updatedAt?: true
|
||||||
@@ -105,6 +110,7 @@ export type CourtBookingCountAggregateInputType = {
|
|||||||
endTime?: true
|
endTime?: true
|
||||||
customerName?: true
|
customerName?: true
|
||||||
customerPhone?: true
|
customerPhone?: true
|
||||||
|
customerEmail?: true
|
||||||
status?: true
|
status?: true
|
||||||
createdAt?: true
|
createdAt?: true
|
||||||
updatedAt?: true
|
updatedAt?: true
|
||||||
@@ -192,6 +198,7 @@ export type CourtBookingGroupByOutputType = {
|
|||||||
endTime: string
|
endTime: string
|
||||||
customerName: string
|
customerName: string
|
||||||
customerPhone: string
|
customerPhone: string
|
||||||
|
customerEmail: string
|
||||||
status: $Enums.CourtBookingStatus
|
status: $Enums.CourtBookingStatus
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
@@ -227,6 +234,7 @@ export type CourtBookingWhereInput = {
|
|||||||
endTime?: Prisma.StringFilter<"CourtBooking"> | string
|
endTime?: Prisma.StringFilter<"CourtBooking"> | string
|
||||||
customerName?: Prisma.StringFilter<"CourtBooking"> | string
|
customerName?: Prisma.StringFilter<"CourtBooking"> | string
|
||||||
customerPhone?: Prisma.StringFilter<"CourtBooking"> | string
|
customerPhone?: Prisma.StringFilter<"CourtBooking"> | string
|
||||||
|
customerEmail?: Prisma.StringFilter<"CourtBooking"> | string
|
||||||
status?: Prisma.EnumCourtBookingStatusFilter<"CourtBooking"> | $Enums.CourtBookingStatus
|
status?: Prisma.EnumCourtBookingStatusFilter<"CourtBooking"> | $Enums.CourtBookingStatus
|
||||||
createdAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
createdAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
updatedAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
||||||
@@ -242,6 +250,7 @@ export type CourtBookingOrderByWithRelationInput = {
|
|||||||
endTime?: Prisma.SortOrder
|
endTime?: Prisma.SortOrder
|
||||||
customerName?: Prisma.SortOrder
|
customerName?: Prisma.SortOrder
|
||||||
customerPhone?: Prisma.SortOrder
|
customerPhone?: Prisma.SortOrder
|
||||||
|
customerEmail?: Prisma.SortOrder
|
||||||
status?: Prisma.SortOrder
|
status?: Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
@@ -261,6 +270,7 @@ export type CourtBookingWhereUniqueInput = Prisma.AtLeast<{
|
|||||||
endTime?: Prisma.StringFilter<"CourtBooking"> | string
|
endTime?: Prisma.StringFilter<"CourtBooking"> | string
|
||||||
customerName?: Prisma.StringFilter<"CourtBooking"> | string
|
customerName?: Prisma.StringFilter<"CourtBooking"> | string
|
||||||
customerPhone?: Prisma.StringFilter<"CourtBooking"> | string
|
customerPhone?: Prisma.StringFilter<"CourtBooking"> | string
|
||||||
|
customerEmail?: Prisma.StringFilter<"CourtBooking"> | string
|
||||||
status?: Prisma.EnumCourtBookingStatusFilter<"CourtBooking"> | $Enums.CourtBookingStatus
|
status?: Prisma.EnumCourtBookingStatusFilter<"CourtBooking"> | $Enums.CourtBookingStatus
|
||||||
createdAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
createdAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
updatedAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
||||||
@@ -276,6 +286,7 @@ export type CourtBookingOrderByWithAggregationInput = {
|
|||||||
endTime?: Prisma.SortOrder
|
endTime?: Prisma.SortOrder
|
||||||
customerName?: Prisma.SortOrder
|
customerName?: Prisma.SortOrder
|
||||||
customerPhone?: Prisma.SortOrder
|
customerPhone?: Prisma.SortOrder
|
||||||
|
customerEmail?: Prisma.SortOrder
|
||||||
status?: Prisma.SortOrder
|
status?: Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
@@ -296,6 +307,7 @@ export type CourtBookingScalarWhereWithAggregatesInput = {
|
|||||||
endTime?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
|
endTime?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
|
||||||
customerName?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
|
customerName?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
|
||||||
customerPhone?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
|
customerPhone?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
|
||||||
|
customerEmail?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
|
||||||
status?: Prisma.EnumCourtBookingStatusWithAggregatesFilter<"CourtBooking"> | $Enums.CourtBookingStatus
|
status?: Prisma.EnumCourtBookingStatusWithAggregatesFilter<"CourtBooking"> | $Enums.CourtBookingStatus
|
||||||
createdAt?: Prisma.DateTimeWithAggregatesFilter<"CourtBooking"> | Date | string
|
createdAt?: Prisma.DateTimeWithAggregatesFilter<"CourtBooking"> | Date | string
|
||||||
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"CourtBooking"> | Date | string
|
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"CourtBooking"> | Date | string
|
||||||
@@ -309,6 +321,7 @@ export type CourtBookingCreateInput = {
|
|||||||
endTime: string
|
endTime: string
|
||||||
customerName: string
|
customerName: string
|
||||||
customerPhone: string
|
customerPhone: string
|
||||||
|
customerEmail: string
|
||||||
status?: $Enums.CourtBookingStatus
|
status?: $Enums.CourtBookingStatus
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
@@ -324,6 +337,7 @@ export type CourtBookingUncheckedCreateInput = {
|
|||||||
endTime: string
|
endTime: string
|
||||||
customerName: string
|
customerName: string
|
||||||
customerPhone: string
|
customerPhone: string
|
||||||
|
customerEmail: string
|
||||||
status?: $Enums.CourtBookingStatus
|
status?: $Enums.CourtBookingStatus
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
@@ -337,6 +351,7 @@ export type CourtBookingUpdateInput = {
|
|||||||
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -352,6 +367,7 @@ export type CourtBookingUncheckedUpdateInput = {
|
|||||||
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -366,6 +382,7 @@ export type CourtBookingCreateManyInput = {
|
|||||||
endTime: string
|
endTime: string
|
||||||
customerName: string
|
customerName: string
|
||||||
customerPhone: string
|
customerPhone: string
|
||||||
|
customerEmail: string
|
||||||
status?: $Enums.CourtBookingStatus
|
status?: $Enums.CourtBookingStatus
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
@@ -379,6 +396,7 @@ export type CourtBookingUpdateManyMutationInput = {
|
|||||||
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -393,6 +411,7 @@ export type CourtBookingUncheckedUpdateManyInput = {
|
|||||||
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -423,6 +442,7 @@ export type CourtBookingCountOrderByAggregateInput = {
|
|||||||
endTime?: Prisma.SortOrder
|
endTime?: Prisma.SortOrder
|
||||||
customerName?: Prisma.SortOrder
|
customerName?: Prisma.SortOrder
|
||||||
customerPhone?: Prisma.SortOrder
|
customerPhone?: Prisma.SortOrder
|
||||||
|
customerEmail?: Prisma.SortOrder
|
||||||
status?: Prisma.SortOrder
|
status?: Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
@@ -437,6 +457,7 @@ export type CourtBookingMaxOrderByAggregateInput = {
|
|||||||
endTime?: Prisma.SortOrder
|
endTime?: Prisma.SortOrder
|
||||||
customerName?: Prisma.SortOrder
|
customerName?: Prisma.SortOrder
|
||||||
customerPhone?: Prisma.SortOrder
|
customerPhone?: Prisma.SortOrder
|
||||||
|
customerEmail?: Prisma.SortOrder
|
||||||
status?: Prisma.SortOrder
|
status?: Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
@@ -451,6 +472,7 @@ export type CourtBookingMinOrderByAggregateInput = {
|
|||||||
endTime?: Prisma.SortOrder
|
endTime?: Prisma.SortOrder
|
||||||
customerName?: Prisma.SortOrder
|
customerName?: Prisma.SortOrder
|
||||||
customerPhone?: Prisma.SortOrder
|
customerPhone?: Prisma.SortOrder
|
||||||
|
customerEmail?: Prisma.SortOrder
|
||||||
status?: Prisma.SortOrder
|
status?: Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
@@ -510,6 +532,7 @@ export type CourtBookingCreateWithoutCourtInput = {
|
|||||||
endTime: string
|
endTime: string
|
||||||
customerName: string
|
customerName: string
|
||||||
customerPhone: string
|
customerPhone: string
|
||||||
|
customerEmail: string
|
||||||
status?: $Enums.CourtBookingStatus
|
status?: $Enums.CourtBookingStatus
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
@@ -523,6 +546,7 @@ export type CourtBookingUncheckedCreateWithoutCourtInput = {
|
|||||||
endTime: string
|
endTime: string
|
||||||
customerName: string
|
customerName: string
|
||||||
customerPhone: string
|
customerPhone: string
|
||||||
|
customerEmail: string
|
||||||
status?: $Enums.CourtBookingStatus
|
status?: $Enums.CourtBookingStatus
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
@@ -566,6 +590,7 @@ export type CourtBookingScalarWhereInput = {
|
|||||||
endTime?: Prisma.StringFilter<"CourtBooking"> | string
|
endTime?: Prisma.StringFilter<"CourtBooking"> | string
|
||||||
customerName?: Prisma.StringFilter<"CourtBooking"> | string
|
customerName?: Prisma.StringFilter<"CourtBooking"> | string
|
||||||
customerPhone?: Prisma.StringFilter<"CourtBooking"> | string
|
customerPhone?: Prisma.StringFilter<"CourtBooking"> | string
|
||||||
|
customerEmail?: Prisma.StringFilter<"CourtBooking"> | string
|
||||||
status?: Prisma.EnumCourtBookingStatusFilter<"CourtBooking"> | $Enums.CourtBookingStatus
|
status?: Prisma.EnumCourtBookingStatusFilter<"CourtBooking"> | $Enums.CourtBookingStatus
|
||||||
createdAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
createdAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
updatedAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
||||||
@@ -579,6 +604,7 @@ export type CourtBookingCreateManyCourtInput = {
|
|||||||
endTime: string
|
endTime: string
|
||||||
customerName: string
|
customerName: string
|
||||||
customerPhone: string
|
customerPhone: string
|
||||||
|
customerEmail: string
|
||||||
status?: $Enums.CourtBookingStatus
|
status?: $Enums.CourtBookingStatus
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
@@ -592,6 +618,7 @@ export type CourtBookingUpdateWithoutCourtInput = {
|
|||||||
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -605,6 +632,7 @@ export type CourtBookingUncheckedUpdateWithoutCourtInput = {
|
|||||||
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -618,6 +646,7 @@ export type CourtBookingUncheckedUpdateManyWithoutCourtInput = {
|
|||||||
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -634,6 +663,7 @@ export type CourtBookingSelect<ExtArgs extends runtime.Types.Extensions.Internal
|
|||||||
endTime?: boolean
|
endTime?: boolean
|
||||||
customerName?: boolean
|
customerName?: boolean
|
||||||
customerPhone?: boolean
|
customerPhone?: boolean
|
||||||
|
customerEmail?: boolean
|
||||||
status?: boolean
|
status?: boolean
|
||||||
createdAt?: boolean
|
createdAt?: boolean
|
||||||
updatedAt?: boolean
|
updatedAt?: boolean
|
||||||
@@ -649,6 +679,7 @@ export type CourtBookingSelectCreateManyAndReturn<ExtArgs extends runtime.Types.
|
|||||||
endTime?: boolean
|
endTime?: boolean
|
||||||
customerName?: boolean
|
customerName?: boolean
|
||||||
customerPhone?: boolean
|
customerPhone?: boolean
|
||||||
|
customerEmail?: boolean
|
||||||
status?: boolean
|
status?: boolean
|
||||||
createdAt?: boolean
|
createdAt?: boolean
|
||||||
updatedAt?: boolean
|
updatedAt?: boolean
|
||||||
@@ -664,6 +695,7 @@ export type CourtBookingSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.
|
|||||||
endTime?: boolean
|
endTime?: boolean
|
||||||
customerName?: boolean
|
customerName?: boolean
|
||||||
customerPhone?: boolean
|
customerPhone?: boolean
|
||||||
|
customerEmail?: boolean
|
||||||
status?: boolean
|
status?: boolean
|
||||||
createdAt?: boolean
|
createdAt?: boolean
|
||||||
updatedAt?: boolean
|
updatedAt?: boolean
|
||||||
@@ -679,12 +711,13 @@ export type CourtBookingSelectScalar = {
|
|||||||
endTime?: boolean
|
endTime?: boolean
|
||||||
customerName?: boolean
|
customerName?: boolean
|
||||||
customerPhone?: boolean
|
customerPhone?: boolean
|
||||||
|
customerEmail?: boolean
|
||||||
status?: boolean
|
status?: boolean
|
||||||
createdAt?: boolean
|
createdAt?: boolean
|
||||||
updatedAt?: boolean
|
updatedAt?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CourtBookingOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "bookingCode" | "courtId" | "bookingDate" | "startTime" | "endTime" | "customerName" | "customerPhone" | "status" | "createdAt" | "updatedAt", ExtArgs["result"]["courtBooking"]>
|
export type CourtBookingOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "bookingCode" | "courtId" | "bookingDate" | "startTime" | "endTime" | "customerName" | "customerPhone" | "customerEmail" | "status" | "createdAt" | "updatedAt", ExtArgs["result"]["courtBooking"]>
|
||||||
export type CourtBookingInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
export type CourtBookingInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
court?: boolean | Prisma.CourtDefaultArgs<ExtArgs>
|
court?: boolean | Prisma.CourtDefaultArgs<ExtArgs>
|
||||||
}
|
}
|
||||||
@@ -709,6 +742,7 @@ export type $CourtBookingPayload<ExtArgs extends runtime.Types.Extensions.Intern
|
|||||||
endTime: string
|
endTime: string
|
||||||
customerName: string
|
customerName: string
|
||||||
customerPhone: string
|
customerPhone: string
|
||||||
|
customerEmail: string
|
||||||
status: $Enums.CourtBookingStatus
|
status: $Enums.CourtBookingStatus
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
@@ -1144,6 +1178,7 @@ export interface CourtBookingFieldRefs {
|
|||||||
readonly endTime: Prisma.FieldRef<"CourtBooking", 'String'>
|
readonly endTime: Prisma.FieldRef<"CourtBooking", 'String'>
|
||||||
readonly customerName: Prisma.FieldRef<"CourtBooking", 'String'>
|
readonly customerName: Prisma.FieldRef<"CourtBooking", 'String'>
|
||||||
readonly customerPhone: Prisma.FieldRef<"CourtBooking", 'String'>
|
readonly customerPhone: Prisma.FieldRef<"CourtBooking", 'String'>
|
||||||
|
readonly customerEmail: Prisma.FieldRef<"CourtBooking", 'String'>
|
||||||
readonly status: Prisma.FieldRef<"CourtBooking", 'CourtBookingStatus'>
|
readonly status: Prisma.FieldRef<"CourtBooking", 'CourtBookingStatus'>
|
||||||
readonly createdAt: Prisma.FieldRef<"CourtBooking", 'DateTime'>
|
readonly createdAt: Prisma.FieldRef<"CourtBooking", 'DateTime'>
|
||||||
readonly updatedAt: Prisma.FieldRef<"CourtBooking", 'DateTime'>
|
readonly updatedAt: Prisma.FieldRef<"CourtBooking", 'DateTime'>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,7 @@ export type UserMinAggregateOutputType = {
|
|||||||
email: string | null
|
email: string | null
|
||||||
emailVerified: boolean | null
|
emailVerified: boolean | null
|
||||||
image: string | null
|
image: string | null
|
||||||
|
phone: string | null
|
||||||
createdAt: Date | null
|
createdAt: Date | null
|
||||||
updatedAt: Date | null
|
updatedAt: Date | null
|
||||||
role: string | null
|
role: string | null
|
||||||
@@ -41,6 +42,7 @@ export type UserMaxAggregateOutputType = {
|
|||||||
email: string | null
|
email: string | null
|
||||||
emailVerified: boolean | null
|
emailVerified: boolean | null
|
||||||
image: string | null
|
image: string | null
|
||||||
|
phone: string | null
|
||||||
createdAt: Date | null
|
createdAt: Date | null
|
||||||
updatedAt: Date | null
|
updatedAt: Date | null
|
||||||
role: string | null
|
role: string | null
|
||||||
@@ -52,6 +54,7 @@ export type UserCountAggregateOutputType = {
|
|||||||
email: number
|
email: number
|
||||||
emailVerified: number
|
emailVerified: number
|
||||||
image: number
|
image: number
|
||||||
|
phone: number
|
||||||
createdAt: number
|
createdAt: number
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
role: number
|
role: number
|
||||||
@@ -65,6 +68,7 @@ export type UserMinAggregateInputType = {
|
|||||||
email?: true
|
email?: true
|
||||||
emailVerified?: true
|
emailVerified?: true
|
||||||
image?: true
|
image?: true
|
||||||
|
phone?: true
|
||||||
createdAt?: true
|
createdAt?: true
|
||||||
updatedAt?: true
|
updatedAt?: true
|
||||||
role?: true
|
role?: true
|
||||||
@@ -76,6 +80,7 @@ export type UserMaxAggregateInputType = {
|
|||||||
email?: true
|
email?: true
|
||||||
emailVerified?: true
|
emailVerified?: true
|
||||||
image?: true
|
image?: true
|
||||||
|
phone?: true
|
||||||
createdAt?: true
|
createdAt?: true
|
||||||
updatedAt?: true
|
updatedAt?: true
|
||||||
role?: true
|
role?: true
|
||||||
@@ -87,6 +92,7 @@ export type UserCountAggregateInputType = {
|
|||||||
email?: true
|
email?: true
|
||||||
emailVerified?: true
|
emailVerified?: true
|
||||||
image?: true
|
image?: true
|
||||||
|
phone?: true
|
||||||
createdAt?: true
|
createdAt?: true
|
||||||
updatedAt?: true
|
updatedAt?: true
|
||||||
role?: true
|
role?: true
|
||||||
@@ -171,6 +177,7 @@ export type UserGroupByOutputType = {
|
|||||||
email: string
|
email: string
|
||||||
emailVerified: boolean
|
emailVerified: boolean
|
||||||
image: string | null
|
image: string | null
|
||||||
|
phone: string | null
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
role: string
|
role: string
|
||||||
@@ -203,6 +210,7 @@ export type UserWhereInput = {
|
|||||||
email?: Prisma.StringFilter<"User"> | string
|
email?: Prisma.StringFilter<"User"> | string
|
||||||
emailVerified?: Prisma.BoolFilter<"User"> | boolean
|
emailVerified?: Prisma.BoolFilter<"User"> | boolean
|
||||||
image?: Prisma.StringNullableFilter<"User"> | string | null
|
image?: Prisma.StringNullableFilter<"User"> | string | null
|
||||||
|
phone?: Prisma.StringNullableFilter<"User"> | string | null
|
||||||
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||||
role?: Prisma.StringFilter<"User"> | string
|
role?: Prisma.StringFilter<"User"> | string
|
||||||
@@ -217,6 +225,7 @@ export type UserOrderByWithRelationInput = {
|
|||||||
email?: Prisma.SortOrder
|
email?: Prisma.SortOrder
|
||||||
emailVerified?: Prisma.SortOrder
|
emailVerified?: Prisma.SortOrder
|
||||||
image?: Prisma.SortOrderInput | Prisma.SortOrder
|
image?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
|
phone?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
role?: Prisma.SortOrder
|
role?: Prisma.SortOrder
|
||||||
@@ -234,6 +243,7 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{
|
|||||||
name?: Prisma.StringFilter<"User"> | string
|
name?: Prisma.StringFilter<"User"> | string
|
||||||
emailVerified?: Prisma.BoolFilter<"User"> | boolean
|
emailVerified?: Prisma.BoolFilter<"User"> | boolean
|
||||||
image?: Prisma.StringNullableFilter<"User"> | string | null
|
image?: Prisma.StringNullableFilter<"User"> | string | null
|
||||||
|
phone?: Prisma.StringNullableFilter<"User"> | string | null
|
||||||
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||||
role?: Prisma.StringFilter<"User"> | string
|
role?: Prisma.StringFilter<"User"> | string
|
||||||
@@ -248,6 +258,7 @@ export type UserOrderByWithAggregationInput = {
|
|||||||
email?: Prisma.SortOrder
|
email?: Prisma.SortOrder
|
||||||
emailVerified?: Prisma.SortOrder
|
emailVerified?: Prisma.SortOrder
|
||||||
image?: Prisma.SortOrderInput | Prisma.SortOrder
|
image?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
|
phone?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
role?: Prisma.SortOrder
|
role?: Prisma.SortOrder
|
||||||
@@ -265,6 +276,7 @@ export type UserScalarWhereWithAggregatesInput = {
|
|||||||
email?: Prisma.StringWithAggregatesFilter<"User"> | string
|
email?: Prisma.StringWithAggregatesFilter<"User"> | string
|
||||||
emailVerified?: Prisma.BoolWithAggregatesFilter<"User"> | boolean
|
emailVerified?: Prisma.BoolWithAggregatesFilter<"User"> | boolean
|
||||||
image?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null
|
image?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null
|
||||||
|
phone?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null
|
||||||
createdAt?: Prisma.DateTimeWithAggregatesFilter<"User"> | Date | string
|
createdAt?: Prisma.DateTimeWithAggregatesFilter<"User"> | Date | string
|
||||||
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"User"> | Date | string
|
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"User"> | Date | string
|
||||||
role?: Prisma.StringWithAggregatesFilter<"User"> | string
|
role?: Prisma.StringWithAggregatesFilter<"User"> | string
|
||||||
@@ -276,6 +288,7 @@ export type UserCreateInput = {
|
|||||||
email: string
|
email: string
|
||||||
emailVerified?: boolean
|
emailVerified?: boolean
|
||||||
image?: string | null
|
image?: string | null
|
||||||
|
phone?: string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
role?: string
|
role?: string
|
||||||
@@ -290,6 +303,7 @@ export type UserUncheckedCreateInput = {
|
|||||||
email: string
|
email: string
|
||||||
emailVerified?: boolean
|
emailVerified?: boolean
|
||||||
image?: string | null
|
image?: string | null
|
||||||
|
phone?: string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
role?: string
|
role?: string
|
||||||
@@ -304,6 +318,7 @@ export type UserUpdateInput = {
|
|||||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
role?: Prisma.StringFieldUpdateOperationsInput | string
|
role?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
@@ -318,6 +333,7 @@ export type UserUncheckedUpdateInput = {
|
|||||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
role?: Prisma.StringFieldUpdateOperationsInput | string
|
role?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
@@ -332,6 +348,7 @@ export type UserCreateManyInput = {
|
|||||||
email: string
|
email: string
|
||||||
emailVerified?: boolean
|
emailVerified?: boolean
|
||||||
image?: string | null
|
image?: string | null
|
||||||
|
phone?: string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
role?: string
|
role?: string
|
||||||
@@ -343,6 +360,7 @@ export type UserUpdateManyMutationInput = {
|
|||||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
role?: Prisma.StringFieldUpdateOperationsInput | string
|
role?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
@@ -354,6 +372,7 @@ export type UserUncheckedUpdateManyInput = {
|
|||||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
role?: Prisma.StringFieldUpdateOperationsInput | string
|
role?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
@@ -365,6 +384,7 @@ export type UserCountOrderByAggregateInput = {
|
|||||||
email?: Prisma.SortOrder
|
email?: Prisma.SortOrder
|
||||||
emailVerified?: Prisma.SortOrder
|
emailVerified?: Prisma.SortOrder
|
||||||
image?: Prisma.SortOrder
|
image?: Prisma.SortOrder
|
||||||
|
phone?: Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
role?: Prisma.SortOrder
|
role?: Prisma.SortOrder
|
||||||
@@ -376,6 +396,7 @@ export type UserMaxOrderByAggregateInput = {
|
|||||||
email?: Prisma.SortOrder
|
email?: Prisma.SortOrder
|
||||||
emailVerified?: Prisma.SortOrder
|
emailVerified?: Prisma.SortOrder
|
||||||
image?: Prisma.SortOrder
|
image?: Prisma.SortOrder
|
||||||
|
phone?: Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
role?: Prisma.SortOrder
|
role?: Prisma.SortOrder
|
||||||
@@ -387,6 +408,7 @@ export type UserMinOrderByAggregateInput = {
|
|||||||
email?: Prisma.SortOrder
|
email?: Prisma.SortOrder
|
||||||
emailVerified?: Prisma.SortOrder
|
emailVerified?: Prisma.SortOrder
|
||||||
image?: Prisma.SortOrder
|
image?: Prisma.SortOrder
|
||||||
|
phone?: Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
role?: Prisma.SortOrder
|
role?: Prisma.SortOrder
|
||||||
@@ -461,6 +483,7 @@ export type UserCreateWithoutSessionsInput = {
|
|||||||
email: string
|
email: string
|
||||||
emailVerified?: boolean
|
emailVerified?: boolean
|
||||||
image?: string | null
|
image?: string | null
|
||||||
|
phone?: string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
role?: string
|
role?: string
|
||||||
@@ -474,6 +497,7 @@ export type UserUncheckedCreateWithoutSessionsInput = {
|
|||||||
email: string
|
email: string
|
||||||
emailVerified?: boolean
|
emailVerified?: boolean
|
||||||
image?: string | null
|
image?: string | null
|
||||||
|
phone?: string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
role?: string
|
role?: string
|
||||||
@@ -503,6 +527,7 @@ export type UserUpdateWithoutSessionsInput = {
|
|||||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
role?: Prisma.StringFieldUpdateOperationsInput | string
|
role?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
@@ -516,6 +541,7 @@ export type UserUncheckedUpdateWithoutSessionsInput = {
|
|||||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
role?: Prisma.StringFieldUpdateOperationsInput | string
|
role?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
@@ -529,6 +555,7 @@ export type UserCreateWithoutAccountsInput = {
|
|||||||
email: string
|
email: string
|
||||||
emailVerified?: boolean
|
emailVerified?: boolean
|
||||||
image?: string | null
|
image?: string | null
|
||||||
|
phone?: string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
role?: string
|
role?: string
|
||||||
@@ -542,6 +569,7 @@ export type UserUncheckedCreateWithoutAccountsInput = {
|
|||||||
email: string
|
email: string
|
||||||
emailVerified?: boolean
|
emailVerified?: boolean
|
||||||
image?: string | null
|
image?: string | null
|
||||||
|
phone?: string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
role?: string
|
role?: string
|
||||||
@@ -571,6 +599,7 @@ export type UserUpdateWithoutAccountsInput = {
|
|||||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
role?: Prisma.StringFieldUpdateOperationsInput | string
|
role?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
@@ -584,6 +613,7 @@ export type UserUncheckedUpdateWithoutAccountsInput = {
|
|||||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
role?: Prisma.StringFieldUpdateOperationsInput | string
|
role?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
@@ -597,6 +627,7 @@ export type UserCreateWithoutComplexesInput = {
|
|||||||
email: string
|
email: string
|
||||||
emailVerified?: boolean
|
emailVerified?: boolean
|
||||||
image?: string | null
|
image?: string | null
|
||||||
|
phone?: string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
role?: string
|
role?: string
|
||||||
@@ -610,6 +641,7 @@ export type UserUncheckedCreateWithoutComplexesInput = {
|
|||||||
email: string
|
email: string
|
||||||
emailVerified?: boolean
|
emailVerified?: boolean
|
||||||
image?: string | null
|
image?: string | null
|
||||||
|
phone?: string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
role?: string
|
role?: string
|
||||||
@@ -639,6 +671,7 @@ export type UserUpdateWithoutComplexesInput = {
|
|||||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
role?: Prisma.StringFieldUpdateOperationsInput | string
|
role?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
@@ -652,6 +685,7 @@ export type UserUncheckedUpdateWithoutComplexesInput = {
|
|||||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
role?: Prisma.StringFieldUpdateOperationsInput | string
|
role?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
@@ -714,6 +748,7 @@ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
|
|||||||
email?: boolean
|
email?: boolean
|
||||||
emailVerified?: boolean
|
emailVerified?: boolean
|
||||||
image?: boolean
|
image?: boolean
|
||||||
|
phone?: boolean
|
||||||
createdAt?: boolean
|
createdAt?: boolean
|
||||||
updatedAt?: boolean
|
updatedAt?: boolean
|
||||||
role?: boolean
|
role?: boolean
|
||||||
@@ -729,6 +764,7 @@ export type UserSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensio
|
|||||||
email?: boolean
|
email?: boolean
|
||||||
emailVerified?: boolean
|
emailVerified?: boolean
|
||||||
image?: boolean
|
image?: boolean
|
||||||
|
phone?: boolean
|
||||||
createdAt?: boolean
|
createdAt?: boolean
|
||||||
updatedAt?: boolean
|
updatedAt?: boolean
|
||||||
role?: boolean
|
role?: boolean
|
||||||
@@ -740,6 +776,7 @@ export type UserSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensio
|
|||||||
email?: boolean
|
email?: boolean
|
||||||
emailVerified?: boolean
|
emailVerified?: boolean
|
||||||
image?: boolean
|
image?: boolean
|
||||||
|
phone?: boolean
|
||||||
createdAt?: boolean
|
createdAt?: boolean
|
||||||
updatedAt?: boolean
|
updatedAt?: boolean
|
||||||
role?: boolean
|
role?: boolean
|
||||||
@@ -751,12 +788,13 @@ export type UserSelectScalar = {
|
|||||||
email?: boolean
|
email?: boolean
|
||||||
emailVerified?: boolean
|
emailVerified?: boolean
|
||||||
image?: boolean
|
image?: boolean
|
||||||
|
phone?: boolean
|
||||||
createdAt?: boolean
|
createdAt?: boolean
|
||||||
updatedAt?: boolean
|
updatedAt?: boolean
|
||||||
role?: boolean
|
role?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "name" | "email" | "emailVerified" | "image" | "createdAt" | "updatedAt" | "role", ExtArgs["result"]["user"]>
|
export type UserOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "name" | "email" | "emailVerified" | "image" | "phone" | "createdAt" | "updatedAt" | "role", ExtArgs["result"]["user"]>
|
||||||
export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
sessions?: boolean | Prisma.User$sessionsArgs<ExtArgs>
|
sessions?: boolean | Prisma.User$sessionsArgs<ExtArgs>
|
||||||
accounts?: boolean | Prisma.User$accountsArgs<ExtArgs>
|
accounts?: boolean | Prisma.User$accountsArgs<ExtArgs>
|
||||||
@@ -779,6 +817,7 @@ export type $UserPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
|
|||||||
email: string
|
email: string
|
||||||
emailVerified: boolean
|
emailVerified: boolean
|
||||||
image: string | null
|
image: string | null
|
||||||
|
phone: string | null
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
role: string
|
role: string
|
||||||
@@ -1213,6 +1252,7 @@ export interface UserFieldRefs {
|
|||||||
readonly email: Prisma.FieldRef<"User", 'String'>
|
readonly email: Prisma.FieldRef<"User", 'String'>
|
||||||
readonly emailVerified: Prisma.FieldRef<"User", 'Boolean'>
|
readonly emailVerified: Prisma.FieldRef<"User", 'Boolean'>
|
||||||
readonly image: Prisma.FieldRef<"User", 'String'>
|
readonly image: Prisma.FieldRef<"User", 'String'>
|
||||||
|
readonly phone: Prisma.FieldRef<"User", 'String'>
|
||||||
readonly createdAt: Prisma.FieldRef<"User", 'DateTime'>
|
readonly createdAt: Prisma.FieldRef<"User", 'DateTime'>
|
||||||
readonly updatedAt: Prisma.FieldRef<"User", 'DateTime'>
|
readonly updatedAt: Prisma.FieldRef<"User", 'DateTime'>
|
||||||
readonly role: Prisma.FieldRef<"User", 'String'>
|
readonly role: Prisma.FieldRef<"User", 'String'>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { wrapLayout } from '@/emails/booking-confirmation';
|
||||||
import { dash } from '@better-auth/infra';
|
import { dash } from '@better-auth/infra';
|
||||||
import { betterAuth } from 'better-auth';
|
import { betterAuth } from 'better-auth';
|
||||||
import { prismaAdapter } from 'better-auth/adapters/prisma';
|
import { prismaAdapter } from 'better-auth/adapters/prisma';
|
||||||
import { openAPI } from 'better-auth/plugins';
|
import { openAPI } from 'better-auth/plugins';
|
||||||
|
import { sendMail } from './mailer';
|
||||||
import { db } from './prisma';
|
import { db } from './prisma';
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
@@ -18,6 +20,92 @@ export const auth = betterAuth({
|
|||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
|
user: {
|
||||||
|
additionalFields: {
|
||||||
|
phone: {
|
||||||
|
type: 'string',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emailVerification: {
|
||||||
|
sendOnSignUp: true,
|
||||||
|
autoSignInAfterVerification: true,
|
||||||
|
sendVerificationEmail: async ({ user, url }) => {
|
||||||
|
const verificationUrl = new URL(url);
|
||||||
|
const appUrl = process.env.APP_BASE_URL ?? 'http://localhost:5173';
|
||||||
|
verificationUrl.searchParams.set('callbackURL', appUrl);
|
||||||
|
|
||||||
|
const content = `
|
||||||
|
<tr>
|
||||||
|
<td style="padding:24px 20px 16px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td valign="top">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" style="display:inline-block;background-color:#f4f4f5;border-radius:999px;padding:4px 12px;">
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:11px;font-weight:700;letter-spacing:0.14em;color:#71717a;text-transform:uppercase;line-height:1.25rem;">
|
||||||
|
Verificació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: wrapLayout(content),
|
||||||
|
text: `Hacé click para verificar tu email: ${verificationUrl.toString()}`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
socialProviders: {
|
socialProviders: {
|
||||||
google: {
|
google: {
|
||||||
clientId: process.env.GOOGLE_CLIENT_ID!,
|
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ function statusFromError(error: AppError): ContentfulStatusCode {
|
|||||||
case 'unexpected':
|
case 'unexpected':
|
||||||
return 500;
|
return 500;
|
||||||
default:
|
default:
|
||||||
|
|
||||||
return 500;
|
return 500;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
34
apps/backend/src/lib/slot-validator.ts
Normal file
34
apps/backend/src/lib/slot-validator.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
function toMinutes(value: string): number {
|
||||||
|
const [hours, minutes] = value.split(':').map((part) => Number(part));
|
||||||
|
return hours * 60 + minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSlotInPast(
|
||||||
|
bookingDate: Date,
|
||||||
|
startTime: string,
|
||||||
|
slotDurationMinutes: number,
|
||||||
|
now?: Date
|
||||||
|
): boolean {
|
||||||
|
const currentTime = now ?? new Date();
|
||||||
|
|
||||||
|
const todayStart = new Date(
|
||||||
|
currentTime.getFullYear(),
|
||||||
|
currentTime.getMonth(),
|
||||||
|
currentTime.getDate()
|
||||||
|
);
|
||||||
|
|
||||||
|
const bookingLocalStart = new Date(
|
||||||
|
bookingDate.getUTCFullYear(),
|
||||||
|
bookingDate.getUTCMonth(),
|
||||||
|
bookingDate.getUTCDate()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (bookingLocalStart < todayStart) return true;
|
||||||
|
if (bookingLocalStart > todayStart) return false;
|
||||||
|
|
||||||
|
const currentMinutes = currentTime.getHours() * 60 + currentTime.getMinutes();
|
||||||
|
const slotStartMinutes = toMinutes(startTime);
|
||||||
|
const elapsed = currentMinutes - slotStartMinutes;
|
||||||
|
|
||||||
|
return elapsed > slotDurationMinutes / 2;
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { db } from '@/lib/prisma';
|
||||||
import {
|
import {
|
||||||
AdminBookingServiceError,
|
AdminBookingServiceError,
|
||||||
createAdminBooking,
|
createAdminBooking,
|
||||||
} from '@/modules/admin-booking/services/admin-booking.service';
|
} from '@/modules/admin-booking/services/admin-booking.service';
|
||||||
|
import { sendBookingConfirmation } from '@/services/booking-email.service';
|
||||||
import type { AppContext } from '@/types/hono';
|
import type { AppContext } from '@/types/hono';
|
||||||
import type { CreateAdminBookingInput } from '@repo/api-contract';
|
import type { CreateAdminBookingInput } from '@repo/api-contract';
|
||||||
|
|
||||||
@@ -14,6 +16,26 @@ export async function createAdminBookingHandler(c: AppContext) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const booking = await createAdminBooking(user.id, complexId, payload);
|
const booking = await createAdminBooking(user.id, complexId, payload);
|
||||||
|
|
||||||
|
const complex = await db.complex.findUnique({
|
||||||
|
where: { id: complexId },
|
||||||
|
select: { complexSlug: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
void sendBookingConfirmation({
|
||||||
|
bookingCode: booking.bookingCode,
|
||||||
|
complexSlug: complex?.complexSlug ?? '',
|
||||||
|
complexName: booking.complexName,
|
||||||
|
date: booking.date,
|
||||||
|
startTime: booking.startTime,
|
||||||
|
endTime: booking.endTime,
|
||||||
|
courtName: booking.courtName,
|
||||||
|
sportName: booking.sport.name,
|
||||||
|
customerName: booking.customerName,
|
||||||
|
customerEmail: booking.customerEmail,
|
||||||
|
price: booking.price,
|
||||||
|
});
|
||||||
|
|
||||||
return c.json(booking, 201);
|
return c.json(booking, 201);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AdminBookingServiceError) {
|
if (error instanceof AdminBookingServiceError) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
AdminBookingServiceError,
|
AdminBookingServiceError,
|
||||||
updateAdminBookingStatus,
|
updateAdminBookingStatus,
|
||||||
} from '@/modules/admin-booking/services/admin-booking.service';
|
} from '@/modules/admin-booking/services/admin-booking.service';
|
||||||
|
import { sendBookingCancelled, sendBookingNoShow } from '@/services/booking-email.service';
|
||||||
import type { AppContext } from '@/types/hono';
|
import type { AppContext } from '@/types/hono';
|
||||||
import type { UpdateAdminBookingStatusInput } from '@repo/api-contract';
|
import type { UpdateAdminBookingStatusInput } from '@repo/api-contract';
|
||||||
|
|
||||||
@@ -14,6 +15,33 @@ export async function updateAdminBookingStatusHandler(c: AppContext) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const booking = await updateAdminBookingStatus(user.id, id, payload);
|
const booking = await updateAdminBookingStatus(user.id, id, payload);
|
||||||
|
|
||||||
|
if (payload.status === 'CANCELLED') {
|
||||||
|
void sendBookingCancelled({
|
||||||
|
bookingCode: booking.bookingCode,
|
||||||
|
complexName: booking.complexName,
|
||||||
|
date: booking.date,
|
||||||
|
startTime: booking.startTime,
|
||||||
|
endTime: booking.endTime,
|
||||||
|
courtName: booking.courtName,
|
||||||
|
sportName: booking.sport.name,
|
||||||
|
customerName: booking.customerName,
|
||||||
|
customerEmail: booking.customerEmail,
|
||||||
|
});
|
||||||
|
} else if (payload.status === 'NOSHOW') {
|
||||||
|
void sendBookingNoShow({
|
||||||
|
bookingCode: booking.bookingCode,
|
||||||
|
complexName: booking.complexName,
|
||||||
|
date: booking.date,
|
||||||
|
startTime: booking.startTime,
|
||||||
|
endTime: booking.endTime,
|
||||||
|
courtName: booking.courtName,
|
||||||
|
sportName: booking.sport.name,
|
||||||
|
customerName: booking.customerName,
|
||||||
|
customerEmail: booking.customerEmail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return c.json(booking);
|
return c.json(booking);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AdminBookingServiceError) {
|
if (error instanceof AdminBookingServiceError) {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { randomInt } from 'node:crypto';
|
import { randomInt } from 'node:crypto';
|
||||||
import { CourtBookingStatus } from '@/generated/prisma/enums';
|
import { CourtBookingStatus } from '@/generated/prisma/enums';
|
||||||
import { db } from '@/lib/prisma';
|
import { db } from '@/lib/prisma';
|
||||||
|
import { isSlotInPast } from '@/lib/slot-validator';
|
||||||
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service';
|
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service';
|
||||||
|
import type { DayOfWeek } from '@repo/api-contract';
|
||||||
import type {
|
import type {
|
||||||
AdminBooking,
|
AdminBooking,
|
||||||
CreateAdminBookingInput,
|
CreateAdminBookingInput,
|
||||||
@@ -156,6 +158,40 @@ async function ensureComplexAccess(complexId: string, userId: string) {
|
|||||||
return complexUser.complex;
|
return complexUser.complex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolvePrice(
|
||||||
|
court: {
|
||||||
|
basePrice: unknown;
|
||||||
|
priceRules: Array<{
|
||||||
|
dayOfWeek: string | null;
|
||||||
|
startTime: string | null;
|
||||||
|
endTime: string | null;
|
||||||
|
price: unknown;
|
||||||
|
}>;
|
||||||
|
},
|
||||||
|
dayOfWeek: string,
|
||||||
|
startTime: string,
|
||||||
|
endTime: string
|
||||||
|
): number {
|
||||||
|
const slotStart = toMinutes(startTime);
|
||||||
|
const slotEnd = toMinutes(endTime);
|
||||||
|
|
||||||
|
const matchingRules = court.priceRules
|
||||||
|
.filter((rule) => {
|
||||||
|
if (rule.dayOfWeek && rule.dayOfWeek !== dayOfWeek) return false;
|
||||||
|
if (!rule.startTime || !rule.endTime) return true;
|
||||||
|
return slotStart >= toMinutes(rule.startTime) && slotEnd <= toMinutes(rule.endTime);
|
||||||
|
})
|
||||||
|
.sort((first, second) => {
|
||||||
|
const firstSpecificity =
|
||||||
|
(first.dayOfWeek ? 2 : 0) + (first.startTime && first.endTime ? 1 : 0);
|
||||||
|
const secondSpecificity =
|
||||||
|
(second.dayOfWeek ? 2 : 0) + (second.startTime && second.endTime ? 1 : 0);
|
||||||
|
return secondSpecificity - firstSpecificity;
|
||||||
|
});
|
||||||
|
|
||||||
|
return Number(matchingRules[0]?.price ?? court.basePrice);
|
||||||
|
}
|
||||||
|
|
||||||
function mapBookingResponse(booking: {
|
function mapBookingResponse(booking: {
|
||||||
id: string;
|
id: string;
|
||||||
bookingCode: string;
|
bookingCode: string;
|
||||||
@@ -164,6 +200,7 @@ function mapBookingResponse(booking: {
|
|||||||
endTime: string;
|
endTime: string;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
customerPhone: string;
|
customerPhone: string;
|
||||||
|
customerEmail: string;
|
||||||
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NOSHOW';
|
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NOSHOW';
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
@@ -180,6 +217,7 @@ function mapBookingResponse(booking: {
|
|||||||
slug: string;
|
slug: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
price?: number;
|
||||||
}): AdminBooking {
|
}): AdminBooking {
|
||||||
return {
|
return {
|
||||||
id: booking.id,
|
id: booking.id,
|
||||||
@@ -198,6 +236,8 @@ function mapBookingResponse(booking: {
|
|||||||
endTime: booking.endTime,
|
endTime: booking.endTime,
|
||||||
customerName: booking.customerName,
|
customerName: booking.customerName,
|
||||||
customerPhone: booking.customerPhone,
|
customerPhone: booking.customerPhone,
|
||||||
|
customerEmail: booking.customerEmail,
|
||||||
|
price: booking.price ?? 0,
|
||||||
status: booking.status,
|
status: booking.status,
|
||||||
createdAt: booking.createdAt.toISOString(),
|
createdAt: booking.createdAt.toISOString(),
|
||||||
updatedAt: booking.updatedAt.toISOString(),
|
updatedAt: booking.updatedAt.toISOString(),
|
||||||
@@ -258,6 +298,16 @@ export async function createAdminBooking(
|
|||||||
input: CreateAdminBookingInput
|
input: CreateAdminBookingInput
|
||||||
) {
|
) {
|
||||||
const complex = await ensureComplexAccess(complexId, userId);
|
const complex = await ensureComplexAccess(complexId, userId);
|
||||||
|
|
||||||
|
const adminUser = await db.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { emailVerified: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!adminUser?.emailVerified) {
|
||||||
|
throw new AdminBookingServiceError('Debés verificar tu email para poder crear reservas.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
const bookingDate = parseIsoDate(input.date);
|
const bookingDate = parseIsoDate(input.date);
|
||||||
const dayOfWeek = getDayOfWeek(bookingDate);
|
const dayOfWeek = getDayOfWeek(bookingDate);
|
||||||
|
|
||||||
@@ -282,6 +332,10 @@ export async function createAdminBooking(
|
|||||||
slug: true,
|
slug: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
priceRules: {
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -308,6 +362,10 @@ export async function createAdminBooking(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSlotInPast(bookingDate, input.startTime, court.slotDurationMinutes)) {
|
||||||
|
throw new AdminBookingServiceError('No se pueden crear reservas en el pasado.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
const booking = await db.$transaction(async (tx) => {
|
const booking = await db.$transaction(async (tx) => {
|
||||||
@@ -371,6 +429,7 @@ export async function createAdminBooking(
|
|||||||
endTime: selectedSlot.endTime,
|
endTime: selectedSlot.endTime,
|
||||||
customerName: input.customerName.trim(),
|
customerName: input.customerName.trim(),
|
||||||
customerPhone: input.customerPhone.trim(),
|
customerPhone: input.customerPhone.trim(),
|
||||||
|
customerEmail: input.customerEmail?.trim() ?? '',
|
||||||
status: 'CONFIRMED',
|
status: 'CONFIRMED',
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
@@ -397,7 +456,9 @@ export async function createAdminBooking(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return mapBookingResponse(booking);
|
const price = resolvePrice(court, dayOfWeek, selectedSlot.startTime, selectedSlot.endTime);
|
||||||
|
|
||||||
|
return mapBookingResponse({ ...booking, price });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AdminBookingServiceError) {
|
if (error instanceof AdminBookingServiceError) {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -511,6 +572,7 @@ export async function updateAdminBookingStatus(
|
|||||||
endTime: booking.endTime,
|
endTime: booking.endTime,
|
||||||
customerName: booking.customerName,
|
customerName: booking.customerName,
|
||||||
customerPhone: booking.customerPhone,
|
customerPhone: booking.customerPhone,
|
||||||
|
customerEmail: booking.customerEmail,
|
||||||
previousStatus: booking.status,
|
previousStatus: booking.status,
|
||||||
newStatus: input.status,
|
newStatus: input.status,
|
||||||
changedAt: new Date(),
|
changedAt: new Date(),
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ export async function createComplexHandler(c: AppContext) {
|
|||||||
city: payload.city,
|
city: payload.city,
|
||||||
state: payload.state,
|
state: payload.state,
|
||||||
country: payload.country,
|
country: payload.country,
|
||||||
|
setupCourts: payload.setupCourts,
|
||||||
|
courtSportId: payload.courtSportId,
|
||||||
|
courtStartTime: payload.courtStartTime,
|
||||||
|
courtEndTime: payload.courtEndTime,
|
||||||
|
courtDaysOfWeek: payload.courtDaysOfWeek,
|
||||||
});
|
});
|
||||||
|
|
||||||
return c.json(complex, 201);
|
return c.json(complex, 201);
|
||||||
|
|||||||
@@ -11,8 +11,47 @@ export type CreateComplexInput = {
|
|||||||
city?: string;
|
city?: string;
|
||||||
state?: string;
|
state?: string;
|
||||||
country?: string;
|
country?: string;
|
||||||
|
setupCourts?: boolean;
|
||||||
|
courtSportId?: string;
|
||||||
|
courtStartTime?: string;
|
||||||
|
courtEndTime?: string;
|
||||||
|
courtDaysOfWeek?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DayOfWeek = 'MONDAY' | 'TUESDAY' | 'WEDNESDAY' | 'THURSDAY' | 'FRIDAY' | 'SATURDAY' | 'SUNDAY';
|
||||||
|
|
||||||
|
function toMinutes(value: string): number {
|
||||||
|
const [hours, minutes] = value.split(':').map((part) => Number(part));
|
||||||
|
return hours * 60 + minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertAvailabilityRanges(
|
||||||
|
availability: Array<{
|
||||||
|
dayOfWeek: DayOfWeek;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
}>
|
||||||
|
) {
|
||||||
|
for (const range of availability) {
|
||||||
|
const start = toMinutes(range.startTime);
|
||||||
|
const end = toMinutes(range.endTime);
|
||||||
|
if (start >= end) {
|
||||||
|
throw new Error(`El rango ${range.startTime}-${range.endTime} es inválido.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureActiveSport(sportId: string) {
|
||||||
|
const sport = await db.sport.findFirst({
|
||||||
|
where: { id: sportId, isActive: true },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
if (!sport) {
|
||||||
|
throw new Error('El deporte seleccionado no existe o está inactivo.');
|
||||||
|
}
|
||||||
|
return sport;
|
||||||
|
}
|
||||||
|
|
||||||
export type UpdateComplexInput = {
|
export type UpdateComplexInput = {
|
||||||
complexName?: string;
|
complexName?: string;
|
||||||
physicalAddress?: string | null;
|
physicalAddress?: string | null;
|
||||||
@@ -94,6 +133,38 @@ export async function createComplex(input: CreateComplexInput) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (input.setupCourts && input.courtSportId) {
|
||||||
|
const sport = await ensureActiveSport(input.courtSportId);
|
||||||
|
const availability = (input.courtDaysOfWeek ?? []).map((day) => ({
|
||||||
|
dayOfWeek: day as DayOfWeek,
|
||||||
|
startTime: input.courtStartTime ?? '08:00',
|
||||||
|
endTime: input.courtEndTime ?? '22:00',
|
||||||
|
}));
|
||||||
|
|
||||||
|
assertAvailabilityRanges(availability);
|
||||||
|
|
||||||
|
const court = await tx.court.create({
|
||||||
|
data: {
|
||||||
|
id: uuidv7(),
|
||||||
|
complexId: complex.id,
|
||||||
|
sportId: input.courtSportId,
|
||||||
|
name: `${sport.name} 1`,
|
||||||
|
slotDurationMinutes: 60,
|
||||||
|
basePrice: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.courtAvailability.createMany({
|
||||||
|
data: availability.map((avail) => ({
|
||||||
|
id: uuidv7(),
|
||||||
|
courtId: court.id,
|
||||||
|
dayOfWeek: avail.dayOfWeek,
|
||||||
|
startTime: avail.startTime,
|
||||||
|
endTime: avail.endTime,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return complex;
|
return complex;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import {
|
|
||||||
OnboardingError,
|
|
||||||
completeOnboarding,
|
|
||||||
} from '@/modules/onboarding/services/onboarding.service';
|
|
||||||
import type { AppContext } from '@/types/hono';
|
|
||||||
import type { OnboardingCompleteInput } from '@repo/api-contract';
|
|
||||||
|
|
||||||
export async function completeOnboardingHandler(c: AppContext) {
|
|
||||||
const payload = c.req.valid('json' as never) as OnboardingCompleteInput;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await completeOnboarding(payload);
|
|
||||||
return c.json({
|
|
||||||
message: 'Onboarding completado correctamente.',
|
|
||||||
...result,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof OnboardingError) {
|
|
||||||
return c.json({ message: error.message }, error.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof Error) {
|
|
||||||
return c.json({ message: error.message }, 400);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import {
|
|
||||||
OnboardingError,
|
|
||||||
resendOnboardingOtp,
|
|
||||||
} from '@/modules/onboarding/services/onboarding.service';
|
|
||||||
import type { AppContext } from '@/types/hono';
|
|
||||||
import type { OnboardingResendOtpInput } from '@repo/api-contract';
|
|
||||||
|
|
||||||
export async function resendOtpHandler(c: AppContext) {
|
|
||||||
const payload = c.req.valid('json' as never) as OnboardingResendOtpInput;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await resendOnboardingOtp(payload);
|
|
||||||
return c.json(result);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof OnboardingError) {
|
|
||||||
return c.json({ message: error.message }, error.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof Error) {
|
|
||||||
return c.json({ message: error.message }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { startOnboarding } from '@/modules/onboarding/services/onboarding.service';
|
|
||||||
import type { AppContext } from '@/types/hono';
|
|
||||||
import type { OnboardingStartInput } from '@repo/api-contract';
|
|
||||||
|
|
||||||
export async function startOnboardingHandler(c: AppContext) {
|
|
||||||
const payload = c.req.valid('json' as never) as OnboardingStartInput;
|
|
||||||
|
|
||||||
const result = await startOnboarding(payload);
|
|
||||||
return c.json(result, 202);
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { verifyOnboardingOtp } from '@/modules/onboarding/services/onboarding.service';
|
|
||||||
import type { AppContext } from '@/types/hono';
|
|
||||||
import type { OnboardingVerifyOtpInput } from '@repo/api-contract';
|
|
||||||
|
|
||||||
export async function verifyOtpHandler(c: AppContext) {
|
|
||||||
const payload = c.req.valid('json' as never) as OnboardingVerifyOtpInput;
|
|
||||||
const result = await verifyOnboardingOtp(payload);
|
|
||||||
|
|
||||||
return c.json(result);
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { completeOnboardingHandler } from '@/modules/onboarding/handlers/complete-onboarding.handler';
|
|
||||||
import { resendOtpHandler } from '@/modules/onboarding/handlers/resend-otp.handler';
|
|
||||||
import { startOnboardingHandler } from '@/modules/onboarding/handlers/start-onboarding.handler';
|
|
||||||
import { verifyOtpHandler } from '@/modules/onboarding/handlers/verify-otp.handler';
|
|
||||||
import type { AppEnv } from '@/types/hono';
|
|
||||||
import { zValidator } from '@hono/zod-validator';
|
|
||||||
import {
|
|
||||||
onboardingCompleteSchema,
|
|
||||||
onboardingResendOtpSchema,
|
|
||||||
onboardingStartSchema,
|
|
||||||
onboardingVerifyOtpSchema,
|
|
||||||
} from '@repo/api-contract';
|
|
||||||
import { Hono } from 'hono';
|
|
||||||
|
|
||||||
export const onboardingRoutes = new Hono<AppEnv>();
|
|
||||||
|
|
||||||
onboardingRoutes.post('/start', zValidator('json', onboardingStartSchema), startOnboardingHandler);
|
|
||||||
|
|
||||||
onboardingRoutes.post(
|
|
||||||
'/verify-otp',
|
|
||||||
zValidator('json', onboardingVerifyOtpSchema),
|
|
||||||
verifyOtpHandler
|
|
||||||
);
|
|
||||||
|
|
||||||
onboardingRoutes.post(
|
|
||||||
'/resend-otp',
|
|
||||||
zValidator('json', onboardingResendOtpSchema),
|
|
||||||
resendOtpHandler
|
|
||||||
);
|
|
||||||
|
|
||||||
onboardingRoutes.post(
|
|
||||||
'/complete',
|
|
||||||
zValidator('json', onboardingCompleteSchema),
|
|
||||||
completeOnboardingHandler
|
|
||||||
);
|
|
||||||
@@ -1,415 +0,0 @@
|
|||||||
import { createHash, randomInt } from 'node:crypto';
|
|
||||||
import { auth } from '@/lib/auth';
|
|
||||||
import { sendMail } from '@/lib/mailer';
|
|
||||||
import { db } from '@/lib/prisma';
|
|
||||||
import type {
|
|
||||||
OnboardingCompleteInput,
|
|
||||||
OnboardingResendOtpInput,
|
|
||||||
OnboardingStartInput,
|
|
||||||
OnboardingVerifyOtpInput,
|
|
||||||
} from '@repo/api-contract';
|
|
||||||
import { v7 as uuidv7 } from 'uuid';
|
|
||||||
|
|
||||||
const OTP_LENGTH = 6;
|
|
||||||
const OTP_TTL_MINUTES = Number(Bun.env.ONBOARDING_OTP_TTL_MINUTES ?? 10);
|
|
||||||
const OTP_MAX_ATTEMPTS = Number(Bun.env.ONBOARDING_OTP_MAX_ATTEMPTS ?? 5);
|
|
||||||
const OTP_RESEND_COOLDOWN_SECONDS = Number(Bun.env.ONBOARDING_OTP_RESEND_COOLDOWN_SECONDS ?? 30);
|
|
||||||
|
|
||||||
type VerifyOtpResult = {
|
|
||||||
message: string;
|
|
||||||
verified: boolean;
|
|
||||||
requestId: string;
|
|
||||||
email: string | null;
|
|
||||||
expiresAt: string | null;
|
|
||||||
remainingAttempts: number;
|
|
||||||
cooldownSeconds: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ResendOtpResult = {
|
|
||||||
message: string;
|
|
||||||
requestId: string;
|
|
||||||
email: string;
|
|
||||||
expiresAt: string;
|
|
||||||
cooldownSeconds: number;
|
|
||||||
remainingAttempts: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type StartOnboardingResult = {
|
|
||||||
message: string;
|
|
||||||
requestId: string;
|
|
||||||
email: string;
|
|
||||||
expiresAt: string;
|
|
||||||
cooldownSeconds: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class OnboardingError extends Error {
|
|
||||||
status: 400 | 404 | 409 | 429;
|
|
||||||
|
|
||||||
constructor(message: string, status: 400 | 404 | 409 | 429 = 400) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'OnboardingError';
|
|
||||||
this.status = status;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function nowPlusMinutes(minutes: number): Date {
|
|
||||||
const date = new Date();
|
|
||||||
date.setMinutes(date.getMinutes() + minutes);
|
|
||||||
return date;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeEmail(email: string): string {
|
|
||||||
return email.trim().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function createOtpCode(): string {
|
|
||||||
return randomInt(0, 10 ** OTP_LENGTH)
|
|
||||||
.toString()
|
|
||||||
.padStart(OTP_LENGTH, '0');
|
|
||||||
}
|
|
||||||
|
|
||||||
function hashValue(value: string): string {
|
|
||||||
return createHash('sha256').update(value).digest('hex');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRemainingAttempts(otpAttempts: number): number {
|
|
||||||
return Math.max(0, OTP_MAX_ATTEMPTS - otpAttempts);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCooldownSeconds(otpLastSentAt: Date): number {
|
|
||||||
const elapsedMs = Date.now() - otpLastSentAt.getTime();
|
|
||||||
const remainingMs = OTP_RESEND_COOLDOWN_SECONDS * 1000 - elapsedMs;
|
|
||||||
return Math.max(0, Math.ceil(remainingMs / 1000));
|
|
||||||
}
|
|
||||||
|
|
||||||
function slugify(value: string): string {
|
|
||||||
return value
|
|
||||||
.normalize('NFD')
|
|
||||||
.replace(/[\u0300-\u036f]/g, '')
|
|
||||||
.toLowerCase()
|
|
||||||
.trim()
|
|
||||||
.replace(/[^a-z0-9\s-]/g, '')
|
|
||||||
.replace(/\s+/g, '-')
|
|
||||||
.replace(/-+/g, '-');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildUniqueComplexSlug(complexName: string): Promise<string> {
|
|
||||||
const base = slugify(complexName);
|
|
||||||
const fallback = base.length > 0 ? base : `complex-${uuidv7().slice(0, 8)}`;
|
|
||||||
|
|
||||||
let candidate = fallback;
|
|
||||||
let index = 1;
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const existing = await db.complex.findFirst({
|
|
||||||
where: { complexSlug: candidate },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
|
|
||||||
index += 1;
|
|
||||||
candidate = `${fallback}-${index}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendOtpEmail(email: string, otpCode: string) {
|
|
||||||
await sendMail({
|
|
||||||
to: email,
|
|
||||||
subject: 'Codigo OTP para validar tu email',
|
|
||||||
text: `Tu codigo OTP es ${otpCode}. Vence en ${OTP_TTL_MINUTES} minutos.`,
|
|
||||||
html: `<p>Tu codigo OTP es <strong>${otpCode}</strong>.</p><p>Vence en ${OTP_TTL_MINUTES} minutos.</p>`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function startOnboarding(input: OnboardingStartInput): Promise<StartOnboardingResult> {
|
|
||||||
const email = normalizeEmail(input.email);
|
|
||||||
const otpCode = createOtpCode();
|
|
||||||
const expiresAt = nowPlusMinutes(OTP_TTL_MINUTES);
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
const request = await db.onboardingRequest.create({
|
|
||||||
data: {
|
|
||||||
id: uuidv7(),
|
|
||||||
fullName: input.fullName.trim(),
|
|
||||||
email,
|
|
||||||
otpHash: hashValue(otpCode),
|
|
||||||
otpExpiresAt: expiresAt,
|
|
||||||
otpAttempts: 0,
|
|
||||||
otpLastSentAt: now,
|
|
||||||
otpResendCount: 0,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
email: true,
|
|
||||||
otpExpiresAt: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await sendOtpEmail(email, otpCode);
|
|
||||||
|
|
||||||
return {
|
|
||||||
message: 'Te enviamos un codigo OTP para validar tu direccion.',
|
|
||||||
requestId: request.id,
|
|
||||||
email: request.email,
|
|
||||||
expiresAt: request.otpExpiresAt.toISOString(),
|
|
||||||
cooldownSeconds: OTP_RESEND_COOLDOWN_SECONDS,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function verifyOnboardingOtp(
|
|
||||||
input: OnboardingVerifyOtpInput
|
|
||||||
): Promise<VerifyOtpResult> {
|
|
||||||
const request = await db.onboardingRequest.findUnique({
|
|
||||||
where: { id: input.requestId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!request) {
|
|
||||||
return {
|
|
||||||
message: 'Solicitud de onboarding invalida o expirada.',
|
|
||||||
verified: false,
|
|
||||||
requestId: input.requestId,
|
|
||||||
email: null,
|
|
||||||
expiresAt: null,
|
|
||||||
remainingAttempts: 0,
|
|
||||||
cooldownSeconds: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.completedAt) {
|
|
||||||
return {
|
|
||||||
message: 'Este onboarding ya fue completado.',
|
|
||||||
verified: true,
|
|
||||||
requestId: request.id,
|
|
||||||
email: request.email,
|
|
||||||
expiresAt: request.otpExpiresAt.toISOString(),
|
|
||||||
remainingAttempts: getRemainingAttempts(request.otpAttempts),
|
|
||||||
cooldownSeconds: getCooldownSeconds(request.otpLastSentAt),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.otpExpiresAt < new Date()) {
|
|
||||||
return {
|
|
||||||
message: 'El codigo OTP vencio. Solicita un nuevo codigo.',
|
|
||||||
verified: false,
|
|
||||||
requestId: request.id,
|
|
||||||
email: request.email,
|
|
||||||
expiresAt: request.otpExpiresAt.toISOString(),
|
|
||||||
remainingAttempts: getRemainingAttempts(request.otpAttempts),
|
|
||||||
cooldownSeconds: getCooldownSeconds(request.otpLastSentAt),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.otpAttempts >= OTP_MAX_ATTEMPTS) {
|
|
||||||
return {
|
|
||||||
message: 'Se agotaron los intentos de OTP. Solicita un nuevo codigo.',
|
|
||||||
verified: false,
|
|
||||||
requestId: request.id,
|
|
||||||
email: request.email,
|
|
||||||
expiresAt: request.otpExpiresAt.toISOString(),
|
|
||||||
remainingAttempts: 0,
|
|
||||||
cooldownSeconds: getCooldownSeconds(request.otpLastSentAt),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const otpMatches = hashValue(input.otp) === request.otpHash;
|
|
||||||
|
|
||||||
if (!otpMatches) {
|
|
||||||
const updated = await db.onboardingRequest.update({
|
|
||||||
where: { id: request.id },
|
|
||||||
data: {
|
|
||||||
otpAttempts: {
|
|
||||||
increment: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
email: true,
|
|
||||||
otpAttempts: true,
|
|
||||||
otpExpiresAt: true,
|
|
||||||
otpLastSentAt: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const remainingAttempts = getRemainingAttempts(updated.otpAttempts);
|
|
||||||
|
|
||||||
return {
|
|
||||||
message:
|
|
||||||
remainingAttempts > 0
|
|
||||||
? 'Codigo OTP invalido.'
|
|
||||||
: 'Se agotaron los intentos de OTP. Solicita un nuevo codigo.',
|
|
||||||
verified: false,
|
|
||||||
requestId: updated.id,
|
|
||||||
email: updated.email,
|
|
||||||
expiresAt: updated.otpExpiresAt.toISOString(),
|
|
||||||
remainingAttempts,
|
|
||||||
cooldownSeconds: getCooldownSeconds(updated.otpLastSentAt),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!request.emailVerifiedAt) {
|
|
||||||
await db.onboardingRequest.update({
|
|
||||||
where: { id: request.id },
|
|
||||||
data: { emailVerifiedAt: new Date() },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
message: 'Email verificado correctamente.',
|
|
||||||
verified: true,
|
|
||||||
requestId: request.id,
|
|
||||||
email: request.email,
|
|
||||||
expiresAt: request.otpExpiresAt.toISOString(),
|
|
||||||
remainingAttempts: getRemainingAttempts(request.otpAttempts),
|
|
||||||
cooldownSeconds: getCooldownSeconds(request.otpLastSentAt),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resendOnboardingOtp(
|
|
||||||
input: OnboardingResendOtpInput
|
|
||||||
): Promise<ResendOtpResult> {
|
|
||||||
const request = await db.onboardingRequest.findUnique({
|
|
||||||
where: { id: input.requestId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!request) {
|
|
||||||
throw new OnboardingError('Solicitud de onboarding invalida o expirada.', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.completedAt) {
|
|
||||||
throw new OnboardingError('Este onboarding ya fue completado.', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.emailVerifiedAt) {
|
|
||||||
throw new OnboardingError('El email ya fue verificado.', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cooldownSeconds = getCooldownSeconds(request.otpLastSentAt);
|
|
||||||
if (cooldownSeconds > 0) {
|
|
||||||
throw new OnboardingError(`Debes esperar ${cooldownSeconds}s antes de reenviar el OTP.`, 429);
|
|
||||||
}
|
|
||||||
|
|
||||||
const otpCode = createOtpCode();
|
|
||||||
const expiresAt = nowPlusMinutes(OTP_TTL_MINUTES);
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
const updated = await db.onboardingRequest.update({
|
|
||||||
where: { id: request.id },
|
|
||||||
data: {
|
|
||||||
otpHash: hashValue(otpCode),
|
|
||||||
otpExpiresAt: expiresAt,
|
|
||||||
otpAttempts: 0,
|
|
||||||
otpLastSentAt: now,
|
|
||||||
otpResendCount: {
|
|
||||||
increment: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
email: true,
|
|
||||||
otpExpiresAt: true,
|
|
||||||
otpAttempts: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await sendOtpEmail(updated.email, otpCode);
|
|
||||||
|
|
||||||
return {
|
|
||||||
message: 'Te enviamos un nuevo codigo OTP.',
|
|
||||||
requestId: updated.id,
|
|
||||||
email: updated.email,
|
|
||||||
expiresAt: updated.otpExpiresAt.toISOString(),
|
|
||||||
cooldownSeconds: OTP_RESEND_COOLDOWN_SECONDS,
|
|
||||||
remainingAttempts: getRemainingAttempts(updated.otpAttempts),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function completeOnboarding(input: OnboardingCompleteInput) {
|
|
||||||
const onboardingRequest = await db.onboardingRequest.findUnique({
|
|
||||||
where: { id: input.onboardingRequestId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!onboardingRequest) {
|
|
||||||
throw new OnboardingError('Solicitud de onboarding inválida o expirada.', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onboardingRequest.otpExpiresAt < new Date()) {
|
|
||||||
throw new OnboardingError('La sesión de onboarding expiró. Solicita un nuevo OTP.', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!onboardingRequest.emailVerifiedAt) {
|
|
||||||
throw new OnboardingError('Debes verificar el email antes de continuar.', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onboardingRequest.completedAt) {
|
|
||||||
throw new OnboardingError('Este onboarding ya fue completado.', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const plan = await db.plan.findUnique({
|
|
||||||
where: { code: input.planCode },
|
|
||||||
select: { code: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!plan) {
|
|
||||||
throw new OnboardingError('El plan seleccionado no existe.', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { user } = await auth.api.signUpEmail({
|
|
||||||
asResponse: false,
|
|
||||||
body: {
|
|
||||||
email: onboardingRequest.email,
|
|
||||||
password: input.password,
|
|
||||||
name: onboardingRequest.fullName,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const complexSlug = await buildUniqueComplexSlug(input.complexName);
|
|
||||||
|
|
||||||
const result = await db.$transaction(async (tx) => {
|
|
||||||
await tx.user.update({
|
|
||||||
where: { id: user.id },
|
|
||||||
data: { emailVerified: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const complex = await tx.complex.create({
|
|
||||||
data: {
|
|
||||||
id: uuidv7(),
|
|
||||||
complexName: input.complexName.trim(),
|
|
||||||
physicalAddress: input.physicalAddress.trim(),
|
|
||||||
city: input.city?.trim() || null,
|
|
||||||
state: input.state?.trim() || null,
|
|
||||||
country: input.country?.trim() || null,
|
|
||||||
complexSlug,
|
|
||||||
adminEmail: onboardingRequest.email,
|
|
||||||
planCode: input.planCode,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.complexUser.create({
|
|
||||||
data: {
|
|
||||||
complexId: complex.id,
|
|
||||||
userId: user.id,
|
|
||||||
role: 'ADMIN',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.onboardingRequest.update({
|
|
||||||
where: { id: onboardingRequest.id },
|
|
||||||
data: {
|
|
||||||
completedAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
userId: user.id,
|
|
||||||
complexId: complex.id,
|
|
||||||
complexSlug: complex.complexSlug,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...result,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,23 +1,24 @@
|
|||||||
import { db } from '@/lib/prisma';
|
import { db } from '@/lib/prisma';
|
||||||
|
import { parsePlanRules } from '@/modules/plan/services/plan-rules.service';
|
||||||
import type { AppContext } from '@/types/hono';
|
import type { AppContext } from '@/types/hono';
|
||||||
|
|
||||||
export async function listPlansHandler(c: AppContext) {
|
export async function listPlansHandler(c: AppContext) {
|
||||||
const plans = await db.plan.findMany({
|
const plans = await db.plan.findMany({
|
||||||
select: {
|
|
||||||
code: true,
|
|
||||||
name: true,
|
|
||||||
price: true,
|
|
||||||
},
|
|
||||||
orderBy: {
|
orderBy: {
|
||||||
price: 'asc',
|
price: 'asc',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return c.json(
|
return c.json(
|
||||||
plans.map((plan) => ({
|
plans.map((plan) => {
|
||||||
code: plan.code,
|
const rules = parsePlanRules(plan.rules);
|
||||||
name: plan.name,
|
return {
|
||||||
price: Number(plan.price),
|
code: plan.code,
|
||||||
}))
|
name: plan.name,
|
||||||
|
price: Number(plan.price),
|
||||||
|
features: rules.features,
|
||||||
|
limits: rules.limits,
|
||||||
|
};
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
PublicBookingServiceError,
|
||||||
createPublicBooking,
|
createPublicBooking,
|
||||||
} from '@/modules/public-booking/services/public-booking.service';
|
} from '@/modules/public-booking/services/public-booking.service';
|
||||||
|
import { sendBookingConfirmation } from '@/services/booking-email.service';
|
||||||
import type { AppContext } from '@/types/hono';
|
import type { AppContext } from '@/types/hono';
|
||||||
import type { CreatePublicBookingInput } from '@repo/api-contract';
|
import type { CreatePublicBookingInput } from '@repo/api-contract';
|
||||||
|
|
||||||
@@ -30,6 +31,20 @@ export async function createPublicBookingHandler(c: AppContext) {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
void sendBookingConfirmation({
|
||||||
|
bookingCode: booking.bookingCode,
|
||||||
|
complexSlug: booking.complexSlug,
|
||||||
|
complexName: booking.complexName,
|
||||||
|
date: booking.date,
|
||||||
|
startTime: booking.startTime,
|
||||||
|
endTime: booking.endTime,
|
||||||
|
courtName: booking.courtName,
|
||||||
|
sportName: booking.sport.name,
|
||||||
|
customerName: booking.customerName,
|
||||||
|
customerEmail: booking.customerEmail,
|
||||||
|
price: booking.price,
|
||||||
|
});
|
||||||
|
|
||||||
return c.json(booking, 201);
|
return c.json(booking, 201);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof PublicBookingServiceError) {
|
if (error instanceof PublicBookingServiceError) {
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
|
import { cancelPublicBookingHandler } from '@/modules/public-booking/handlers/cancel-public-booking.handler';
|
||||||
import { createPublicBookingHandler } from '@/modules/public-booking/handlers/create-public-booking.handler';
|
import { createPublicBookingHandler } from '@/modules/public-booking/handlers/create-public-booking.handler';
|
||||||
import { getPublicBookingConfirmationHandler } from '@/modules/public-booking/handlers/get-public-booking-confirmation.handler';
|
import { getPublicBookingConfirmationHandler } from '@/modules/public-booking/handlers/get-public-booking-confirmation.handler';
|
||||||
import { listPublicAvailabilityHandler } from '@/modules/public-booking/handlers/list-public-availability.handler';
|
import { listPublicAvailabilityHandler } from '@/modules/public-booking/handlers/list-public-availability.handler';
|
||||||
import type { AppEnv } from '@/types/hono';
|
import type { AppEnv } from '@/types/hono';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
import { createPublicBookingSchema, publicAvailabilityQuerySchema } from '@repo/api-contract';
|
import {
|
||||||
|
cancelPublicBookingSchema,
|
||||||
|
createPublicBookingSchema,
|
||||||
|
publicAvailabilityQuerySchema,
|
||||||
|
} from '@repo/api-contract';
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -33,3 +38,10 @@ publicBookingRoutes.get(
|
|||||||
zValidator('param', confirmationParamsSchema),
|
zValidator('param', confirmationParamsSchema),
|
||||||
getPublicBookingConfirmationHandler
|
getPublicBookingConfirmationHandler
|
||||||
);
|
);
|
||||||
|
|
||||||
|
publicBookingRoutes.post(
|
||||||
|
'/complex/:complexSlug/cancel',
|
||||||
|
zValidator('param', complexSlugParamsSchema),
|
||||||
|
zValidator('json', cancelPublicBookingSchema),
|
||||||
|
cancelPublicBookingHandler
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { randomInt } from 'node:crypto';
|
import { randomInt } from 'node:crypto';
|
||||||
import { db } from '@/lib/prisma';
|
import { db } from '@/lib/prisma';
|
||||||
|
import { isSlotInPast } from '@/lib/slot-validator';
|
||||||
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service';
|
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service';
|
||||||
import type {
|
import type {
|
||||||
|
CancelPublicBookingInput,
|
||||||
CreatePublicBookingInput,
|
CreatePublicBookingInput,
|
||||||
DayOfWeek,
|
DayOfWeek,
|
||||||
PublicAvailabilityQuery,
|
PublicAvailabilityQuery,
|
||||||
@@ -13,6 +15,16 @@ type Slot = {
|
|||||||
endTime: string;
|
endTime: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PriceableCourt = {
|
||||||
|
basePrice: unknown;
|
||||||
|
priceRules: Array<{
|
||||||
|
dayOfWeek: DayOfWeek | null;
|
||||||
|
startTime: string | null;
|
||||||
|
endTime: string | null;
|
||||||
|
price: unknown;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
type ComplexWithPublicBookingData = Awaited<ReturnType<typeof getComplexWithBookingData>>;
|
type ComplexWithPublicBookingData = Awaited<ReturnType<typeof getComplexWithBookingData>>;
|
||||||
|
|
||||||
const DAY_OF_WEEK_BY_INDEX: DayOfWeek[] = [
|
const DAY_OF_WEEK_BY_INDEX: DayOfWeek[] = [
|
||||||
@@ -32,7 +44,6 @@ export class PublicBookingServiceError extends Error {
|
|||||||
|
|
||||||
constructor(message: string, status: 400 | 403 | 404 | 409) {
|
constructor(message: string, status: 400 | 403 | 404 | 409) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'PublicBookingServiceError';
|
|
||||||
this.status = status;
|
this.status = status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,6 +213,10 @@ async function getComplexWithBookingData(complexSlug: string) {
|
|||||||
availabilities: {
|
availabilities: {
|
||||||
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
|
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
|
||||||
},
|
},
|
||||||
|
priceRules: {
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'asc' },
|
orderBy: { createdAt: 'asc' },
|
||||||
},
|
},
|
||||||
@@ -226,6 +241,33 @@ async function getComplexWithBookingData(complexSlug: string) {
|
|||||||
return complex;
|
return complex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveSlotPrice(court: PriceableCourt, dayOfWeek: DayOfWeek, slot: Slot): number {
|
||||||
|
const slotStart = toMinutes(slot.startTime);
|
||||||
|
const slotEnd = toMinutes(slot.endTime);
|
||||||
|
const matchingRules = court.priceRules
|
||||||
|
.filter((rule) => {
|
||||||
|
if (rule.dayOfWeek && rule.dayOfWeek !== dayOfWeek) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rule.startTime || !rule.endTime) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return slotStart >= toMinutes(rule.startTime) && slotEnd <= toMinutes(rule.endTime);
|
||||||
|
})
|
||||||
|
.sort((first, second) => {
|
||||||
|
const firstSpecificity =
|
||||||
|
(first.dayOfWeek ? 2 : 0) + (first.startTime && first.endTime ? 1 : 0);
|
||||||
|
const secondSpecificity =
|
||||||
|
(second.dayOfWeek ? 2 : 0) + (second.startTime && second.endTime ? 1 : 0);
|
||||||
|
|
||||||
|
return secondSpecificity - firstSpecificity;
|
||||||
|
});
|
||||||
|
|
||||||
|
return Number(matchingRules[0]?.price ?? court.basePrice);
|
||||||
|
}
|
||||||
|
|
||||||
function mapBookingResponse(input: {
|
function mapBookingResponse(input: {
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
bookingCode: string;
|
bookingCode: string;
|
||||||
@@ -235,6 +277,8 @@ function mapBookingResponse(input: {
|
|||||||
endTime: string;
|
endTime: string;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
customerPhone: string;
|
customerPhone: string;
|
||||||
|
customerEmail: string;
|
||||||
|
price: number;
|
||||||
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NOSHOW';
|
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NOSHOW';
|
||||||
court: {
|
court: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -263,7 +307,9 @@ function mapBookingResponse(input: {
|
|||||||
endTime: input.endTime,
|
endTime: input.endTime,
|
||||||
customerName: input.customerName,
|
customerName: input.customerName,
|
||||||
customerPhone: input.customerPhone,
|
customerPhone: input.customerPhone,
|
||||||
|
customerEmail: input.customerEmail,
|
||||||
status: input.status,
|
status: input.status,
|
||||||
|
price: input.price,
|
||||||
createdAt: input.createdAt.toISOString(),
|
createdAt: input.createdAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -284,11 +330,17 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC
|
|||||||
bookingDate: true,
|
bookingDate: true,
|
||||||
startTime: true,
|
startTime: true,
|
||||||
endTime: true,
|
endTime: true,
|
||||||
|
customerEmail: true,
|
||||||
status: true,
|
status: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
court: {
|
court: {
|
||||||
select: {
|
select: {
|
||||||
name: true,
|
name: true,
|
||||||
|
basePrice: true,
|
||||||
|
priceRules: {
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
|
||||||
|
},
|
||||||
sport: {
|
sport: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -299,6 +351,7 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC
|
|||||||
complex: {
|
complex: {
|
||||||
select: {
|
select: {
|
||||||
complexName: true,
|
complexName: true,
|
||||||
|
physicalAddress: true,
|
||||||
complexSlug: true,
|
complexSlug: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -311,13 +364,22 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC
|
|||||||
throw new PublicBookingServiceError('Reserva no encontrada.', 404);
|
throw new PublicBookingServiceError('Reserva no encontrada.', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const date = formatIsoDate(booking.bookingDate);
|
||||||
|
const { dayOfWeek } = parseIsoDate(date);
|
||||||
|
const price = resolveSlotPrice(booking.court, dayOfWeek, {
|
||||||
|
startTime: booking.startTime,
|
||||||
|
endTime: booking.endTime,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bookingCode: booking.bookingCode,
|
bookingCode: booking.bookingCode,
|
||||||
complexName: booking.court.complex.complexName,
|
complexName: booking.court.complex.complexName,
|
||||||
|
complexAddress: booking.court.complex.physicalAddress ?? undefined,
|
||||||
complexSlug: booking.court.complex.complexSlug,
|
complexSlug: booking.court.complex.complexSlug,
|
||||||
date: formatIsoDate(booking.bookingDate),
|
date,
|
||||||
startTime: booking.startTime,
|
startTime: booking.startTime,
|
||||||
endTime: booking.endTime,
|
endTime: booking.endTime,
|
||||||
|
price,
|
||||||
courtName: booking.court.name,
|
courtName: booking.court.name,
|
||||||
sport: {
|
sport: {
|
||||||
id: booking.court.sport.id,
|
id: booking.court.sport.id,
|
||||||
@@ -325,13 +387,39 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC
|
|||||||
slug: booking.court.sport.slug,
|
slug: booking.court.sport.slug,
|
||||||
},
|
},
|
||||||
status: booking.status,
|
status: booking.status,
|
||||||
|
customerEmail: booking.customerEmail,
|
||||||
createdAt: booking.createdAt.toISOString(),
|
createdAt: booking.createdAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function hasVerifiedAdmin(complexId: string): Promise<boolean> {
|
||||||
|
const verifiedAdmin = await db.complexUser.findFirst({
|
||||||
|
where: {
|
||||||
|
complexId,
|
||||||
|
role: 'ADMIN',
|
||||||
|
user: { emailVerified: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return verifiedAdmin !== null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function listPublicAvailability(complexSlug: string, query: PublicAvailabilityQuery) {
|
export async function listPublicAvailability(complexSlug: string, query: PublicAvailabilityQuery) {
|
||||||
const { bookingDate, dayOfWeek } = parseIsoDate(query.date);
|
const { bookingDate, dayOfWeek } = parseIsoDate(query.date);
|
||||||
const complex = await getComplexWithBookingData(complexSlug);
|
const complex = await getComplexWithBookingData(complexSlug);
|
||||||
|
|
||||||
|
if (!(await hasVerifiedAdmin(complex.id))) {
|
||||||
|
return {
|
||||||
|
complexId: complex.id,
|
||||||
|
complexName: complex.complexName,
|
||||||
|
complexAddress: complex.physicalAddress ?? null,
|
||||||
|
complexSlug: complex.complexSlug,
|
||||||
|
date: query.date,
|
||||||
|
sportSelectionRequired: false,
|
||||||
|
sports: [],
|
||||||
|
courts: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const sports = resolveSports(complex);
|
const sports = resolveSports(complex);
|
||||||
const sportSelectionRequired = sports.length > 1;
|
const sportSelectionRequired = sports.length > 1;
|
||||||
|
|
||||||
@@ -407,7 +495,10 @@ export async function listPublicAvailability(complexSlug: string, query: PublicA
|
|||||||
},
|
},
|
||||||
slotDurationMinutes: court.slotDurationMinutes,
|
slotDurationMinutes: court.slotDurationMinutes,
|
||||||
availabilityDay: dayOfWeek,
|
availabilityDay: dayOfWeek,
|
||||||
availableSlots,
|
availableSlots: availableSlots.map((slot) => ({
|
||||||
|
...slot,
|
||||||
|
price: resolveSlotPrice(court, dayOfWeek, slot),
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((court) => court.availableSlots.length > 0);
|
.filter((court) => court.availableSlots.length > 0);
|
||||||
@@ -427,6 +518,14 @@ export async function listPublicAvailability(complexSlug: string, query: PublicA
|
|||||||
export async function createPublicBooking(complexSlug: string, input: CreatePublicBookingInput) {
|
export async function createPublicBooking(complexSlug: string, input: CreatePublicBookingInput) {
|
||||||
const { bookingDate, dayOfWeek } = parseIsoDate(input.date);
|
const { bookingDate, dayOfWeek } = parseIsoDate(input.date);
|
||||||
const complex = await getComplexWithBookingData(complexSlug);
|
const complex = await getComplexWithBookingData(complexSlug);
|
||||||
|
|
||||||
|
if (!(await hasVerifiedAdmin(complex.id))) {
|
||||||
|
throw new PublicBookingServiceError(
|
||||||
|
'El complejo no puede recibir reservas hasta que un administrador verifique su email.',
|
||||||
|
403
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const sports = resolveSports(complex);
|
const sports = resolveSports(complex);
|
||||||
const { sportSelectionRequired } = validateSportSelection(sports, input.sportId);
|
const { sportSelectionRequired } = validateSportSelection(sports, input.sportId);
|
||||||
|
|
||||||
@@ -476,6 +575,10 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSlotInPast(bookingDate, input.startTime, selectedCourt.slotDurationMinutes)) {
|
||||||
|
throw new PublicBookingServiceError('No se pueden crear reservas en el pasado.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
const booking = await db.$transaction(async (tx) => {
|
const booking = await db.$transaction(async (tx) => {
|
||||||
@@ -533,6 +636,7 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
|
|||||||
endTime: selectedSlot.endTime,
|
endTime: selectedSlot.endTime,
|
||||||
customerName: input.customerName.trim(),
|
customerName: input.customerName.trim(),
|
||||||
customerPhone: input.customerPhone.trim(),
|
customerPhone: input.customerPhone.trim(),
|
||||||
|
customerEmail: input.customerEmail.trim(),
|
||||||
status: 'CONFIRMED',
|
status: 'CONFIRMED',
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
@@ -544,11 +648,14 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
|
|||||||
endTime: true,
|
endTime: true,
|
||||||
customerName: true,
|
customerName: true,
|
||||||
customerPhone: true,
|
customerPhone: true,
|
||||||
|
customerEmail: true,
|
||||||
status: true,
|
status: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const price = resolveSlotPrice(selectedCourt, dayOfWeek, selectedSlot);
|
||||||
|
|
||||||
return mapBookingResponse({
|
return mapBookingResponse({
|
||||||
bookingId: booking.id,
|
bookingId: booking.id,
|
||||||
bookingCode: booking.bookingCode,
|
bookingCode: booking.bookingCode,
|
||||||
@@ -558,6 +665,8 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
|
|||||||
endTime: booking.endTime,
|
endTime: booking.endTime,
|
||||||
customerName: booking.customerName,
|
customerName: booking.customerName,
|
||||||
customerPhone: booking.customerPhone,
|
customerPhone: booking.customerPhone,
|
||||||
|
customerEmail: booking.customerEmail,
|
||||||
|
price,
|
||||||
status: booking.status,
|
status: booking.status,
|
||||||
court: {
|
court: {
|
||||||
id: selectedCourt.id,
|
id: selectedCourt.id,
|
||||||
@@ -608,3 +717,108 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
|
|||||||
409
|
409
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function cancelPublicBooking(complexSlug: string, input: CancelPublicBookingInput) {
|
||||||
|
const normalizedBookingCode = input.bookingCode.toUpperCase();
|
||||||
|
const booking = await db.courtBooking.findFirst({
|
||||||
|
where: {
|
||||||
|
bookingCode: normalizedBookingCode,
|
||||||
|
court: {
|
||||||
|
complex: {
|
||||||
|
complexSlug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
court: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
basePrice: true,
|
||||||
|
priceRules: {
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
|
||||||
|
},
|
||||||
|
sport: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
complex: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
complexName: true,
|
||||||
|
complexSlug: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
throw new PublicBookingServiceError('Reserva no encontrada.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (booking.status !== 'CONFIRMED') {
|
||||||
|
throw new PublicBookingServiceError(
|
||||||
|
'Solo se pueden cancelar reservas en estado confirmada.',
|
||||||
|
409
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (booking.customerPhone !== input.customerPhone.trim()) {
|
||||||
|
throw new PublicBookingServiceError('Los datos ingresados no coinciden con la reserva.', 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { adminBookingRoutes } from '@/modules/admin-booking/admin-booking.routes
|
|||||||
import { authRoutes } from '@/modules/auth/auth.routes';
|
import { authRoutes } from '@/modules/auth/auth.routes';
|
||||||
import { complexRoutes } from '@/modules/complex/complex.routes';
|
import { complexRoutes } from '@/modules/complex/complex.routes';
|
||||||
import { courtRoutes } from '@/modules/court/court.routes';
|
import { courtRoutes } from '@/modules/court/court.routes';
|
||||||
import { onboardingRoutes } from '@/modules/onboarding/onboarding.routes';
|
|
||||||
import { passwordResetRoutes } from '@/modules/password-reset/password-reset.routes';
|
import { passwordResetRoutes } from '@/modules/password-reset/password-reset.routes';
|
||||||
import { planRoutes } from '@/modules/plan/plan.routes';
|
import { planRoutes } from '@/modules/plan/plan.routes';
|
||||||
import { publicBookingRoutes } from '@/modules/public-booking/public-booking.routes';
|
import { publicBookingRoutes } from '@/modules/public-booking/public-booking.routes';
|
||||||
@@ -20,7 +19,6 @@ export function registerRoutes(app: Hono<AppEnv>) {
|
|||||||
.route('', authRoutes)
|
.route('', authRoutes)
|
||||||
.route('/api/user', userRoutes)
|
.route('/api/user', userRoutes)
|
||||||
.route('/api/plans', planRoutes)
|
.route('/api/plans', planRoutes)
|
||||||
.route('/api/onboarding', onboardingRoutes)
|
|
||||||
.route('/api/password-reset', passwordResetRoutes)
|
.route('/api/password-reset', passwordResetRoutes)
|
||||||
.route('/api/complexes', complexRoutes)
|
.route('/api/complexes', complexRoutes)
|
||||||
.route('/api/sports', sportRoutes)
|
.route('/api/sports', sportRoutes)
|
||||||
|
|||||||
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 { beforeEach, expect, mock, test } from 'bun:test';
|
||||||
|
|
||||||
import { createInviteComplexUserHandler } from '@/modules/complex/handlers/invite-complex-user.handler';
|
|
||||||
import type { InviteComplexUserResponse } from '@repo/api-contract';
|
import type { InviteComplexUserResponse } from '@repo/api-contract';
|
||||||
|
|
||||||
const inviteComplexUserMock = mock(async () => undefined as unknown as InviteComplexUserResponse);
|
const inviteComplexUserMock = mock(async () => undefined as unknown as InviteComplexUserResponse);
|
||||||
@@ -15,6 +14,10 @@ class MockComplexMembersError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { createInviteComplexUserHandler } = await import(
|
||||||
|
'@/modules/complex/handlers/invite-complex-user.handler'
|
||||||
|
);
|
||||||
|
|
||||||
const inviteComplexUserHandler = createInviteComplexUserHandler({
|
const inviteComplexUserHandler = createInviteComplexUserHandler({
|
||||||
inviteComplexUser: inviteComplexUserMock,
|
inviteComplexUser: inviteComplexUserMock,
|
||||||
ComplexMembersError: MockComplexMembersError,
|
ComplexMembersError: MockComplexMembersError,
|
||||||
@@ -59,7 +62,6 @@ function createContext(input: {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
inviteComplexUserMock.mockReset();
|
inviteComplexUserMock.mockReset();
|
||||||
mock.clearAllMocks();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('returns 201 with the service result', async () => {
|
test('returns 201 with the service result', async () => {
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { beforeEach, expect, mock, test } from 'bun:test';
|
import { beforeEach, expect, test } from 'bun:test';
|
||||||
|
|
||||||
import {
|
|
||||||
ComplexMembersError,
|
|
||||||
inviteComplexUser,
|
|
||||||
} from '@/modules/complex/services/complex-members.service';
|
|
||||||
import { prismaMock, sendMailMock, transactionMock } from '../support/prisma.mock';
|
import { prismaMock, sendMailMock, transactionMock } from '../support/prisma.mock';
|
||||||
|
|
||||||
|
const { ComplexMembersError, inviteComplexUser } = await import(
|
||||||
|
'@/modules/complex/services/complex-members.service'
|
||||||
|
);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
prismaMock._reset();
|
prismaMock._reset();
|
||||||
mock.clearAllMocks();
|
sendMailMock.mockClear();
|
||||||
|
transactionMock.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('creates a pending invitation and sends the email', async () => {
|
test('creates a pending invitation and sends the email', async () => {
|
||||||
@@ -21,10 +22,10 @@ test('creates a pending invitation and sends the email', async () => {
|
|||||||
complexId: 'complex-1',
|
complexId: 'complex-1',
|
||||||
email: 'new.member@example.com',
|
email: 'new.member@example.com',
|
||||||
tokenHash: 'token-hash',
|
tokenHash: 'token-hash',
|
||||||
expiresAt: new Date('2026-04-29T00:00:00.000Z'),
|
expiresAt: new Date('2027-01-01T00:00:00.000Z'),
|
||||||
acceptedAt: null,
|
acceptedAt: null,
|
||||||
revokedAt: null,
|
revokedAt: null,
|
||||||
createdAt: new Date('2026-04-22T00:00:00.000Z'),
|
createdAt: new Date('2026-12-01T00:00:00.000Z'),
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
const result = await inviteComplexUser('admin-1', 'complex-1', {
|
const result = await inviteComplexUser('admin-1', 'complex-1', {
|
||||||
@@ -79,7 +80,7 @@ test('rejects when the invite target already belongs to the complex', async () =
|
|||||||
}
|
}
|
||||||
|
|
||||||
expect(caught).toBeInstanceOf(ComplexMembersError);
|
expect(caught).toBeInstanceOf(ComplexMembersError);
|
||||||
expect((caught as ComplexMembersError).status).toBe(409);
|
expect((caught as InstanceType<typeof ComplexMembersError>).status).toBe(409);
|
||||||
expect(transactionMock).not.toHaveBeenCalled();
|
expect(transactionMock).not.toHaveBeenCalled();
|
||||||
expect(sendMailMock).not.toHaveBeenCalled();
|
expect(sendMailMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
90
apps/backend/test/public-booking/slot-validator.test.ts
Normal file
90
apps/backend/test/public-booking/slot-validator.test.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { expect, test } from 'bun:test';
|
||||||
|
|
||||||
|
import { isSlotInPast } from '@/lib/slot-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create a bookingDate like parseIsoDate does (UTC midnight).
|
||||||
|
*/
|
||||||
|
function bookingDate(year: number, month: number, day: number): Date {
|
||||||
|
return new Date(Date.UTC(year, month - 1, day));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create a "now" date at a specific local time.
|
||||||
|
* Using local date constructor guarantees the date parts
|
||||||
|
* (getFullYear, getMonth, getDate) match the arguments
|
||||||
|
* regardless of the test runner's timezone.
|
||||||
|
*/
|
||||||
|
function localDate(year: number, month: number, day: number, hours: number, minutes: number): Date {
|
||||||
|
return new Date(year, month - 1, day, hours, minutes, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('future date — always allowed', () => {
|
||||||
|
const future = bookingDate(2027, 6, 10); // 2027-06-10
|
||||||
|
const now = localDate(2026, 6, 8, 12, 0); // 2026-06-08 12:00
|
||||||
|
|
||||||
|
expect(isSlotInPast(future, '10:00', 60, now)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('past date — rejected', () => {
|
||||||
|
const past = bookingDate(2025, 6, 8); // 2025-06-08
|
||||||
|
const now = localDate(2026, 6, 8, 12, 0); // 2026-06-08 12:00
|
||||||
|
|
||||||
|
expect(isSlotInPast(past, '10:00', 60, now)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('today, slot not started yet — allowed', () => {
|
||||||
|
const today = bookingDate(2026, 6, 8); // 2026-06-08
|
||||||
|
const now = localDate(2026, 6, 8, 9, 0); // 09:00, slot empieza a las 10:00
|
||||||
|
|
||||||
|
expect(isSlotInPast(today, '10:00', 60, now)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('today, slot in progress less than half elapsed — allowed', () => {
|
||||||
|
const today = bookingDate(2026, 6, 8);
|
||||||
|
const now = localDate(2026, 6, 8, 10, 10); // 10:10, slot 10:00-11:00 (60 min), 10 min elapsed
|
||||||
|
|
||||||
|
expect(isSlotInPast(today, '10:00', 60, now)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('today, slot in progress more than half elapsed — rejected', () => {
|
||||||
|
const today = bookingDate(2026, 6, 8);
|
||||||
|
const now = localDate(2026, 6, 8, 10, 40); // 10:40, slot 10:00-11:00 (60 min), 40 min elapsed
|
||||||
|
|
||||||
|
expect(isSlotInPast(today, '10:00', 60, now)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('today, slot already ended — rejected', () => {
|
||||||
|
const today = bookingDate(2026, 6, 8);
|
||||||
|
const now = localDate(2026, 6, 8, 11, 1); // 11:01, slot 10:00-11:00 terminó
|
||||||
|
|
||||||
|
expect(isSlotInPast(today, '10:00', 60, now)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('today, exactly half elapsed — allowed (inclusive boundary)', () => {
|
||||||
|
const today = bookingDate(2026, 6, 8);
|
||||||
|
const now = localDate(2026, 6, 8, 10, 30); // 10:30, slot 10:00-11:00 (60 min), exactamente 30 min
|
||||||
|
|
||||||
|
expect(isSlotInPast(today, '10:00', 60, now)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('today, slot starts exactly now — allowed', () => {
|
||||||
|
const today = bookingDate(2026, 6, 8);
|
||||||
|
const now = localDate(2026, 6, 8, 10, 0); // 10:00 exacto
|
||||||
|
|
||||||
|
expect(isSlotInPast(today, '10:00', 60, now)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('90 min slot with 10 min elapsed — allowed', () => {
|
||||||
|
const today = bookingDate(2026, 6, 8);
|
||||||
|
const now = localDate(2026, 6, 8, 12, 10); // 12:10, slot 12:00-13:30, 10/90 elapsed
|
||||||
|
|
||||||
|
expect(isSlotInPast(today, '12:00', 90, now)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('90 min slot with 50 min elapsed — rejected', () => {
|
||||||
|
const today = bookingDate(2026, 6, 8);
|
||||||
|
const now = localDate(2026, 6, 8, 12, 50); // 12:50, slot 12:00-13:30, 50/90 elapsed
|
||||||
|
|
||||||
|
expect(isSlotInPast(today, '12:00', 90, now)).toBe(true);
|
||||||
|
});
|
||||||
@@ -49,7 +49,6 @@ function createContext(body: unknown) {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
createSportMock.mockReset();
|
createSportMock.mockReset();
|
||||||
mock.clearAllMocks();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('returns 201 with the created sport', async () => {
|
test('returns 201 with the created sport', async () => {
|
||||||
|
|||||||
@@ -1,24 +1,6 @@
|
|||||||
import { beforeEach, expect, mock, test } from 'bun:test';
|
import { beforeEach, expect, test } from 'bun:test';
|
||||||
|
|
||||||
import { createPrismaMock } from 'bun-mock-prisma';
|
import { prismaMock, uuidV7Mock } from '../support/prisma.mock';
|
||||||
|
|
||||||
import type { PrismaClient } from '@/generated/prisma/client';
|
|
||||||
import type { PrismaClientMock } from 'bun-mock-prisma';
|
|
||||||
|
|
||||||
const prismaMock = createPrismaMock<PrismaClient>() as PrismaClientMock<PrismaClient>;
|
|
||||||
const uuidV7Mock = mock(() => '00000000-0000-4000-8000-000000000001');
|
|
||||||
|
|
||||||
mock.module('@/lib/prisma', () => ({
|
|
||||||
__esModule: true,
|
|
||||||
db: {
|
|
||||||
sport: prismaMock.sport,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
mock.module('uuid', () => ({
|
|
||||||
__esModule: true,
|
|
||||||
v7: uuidV7Mock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { createSport, getSportById, listSports, updateSport } = await import(
|
const { createSport, getSportById, listSports, updateSport } = await import(
|
||||||
'@/modules/sport/services/sport.service'
|
'@/modules/sport/services/sport.service'
|
||||||
@@ -26,7 +8,7 @@ const { createSport, getSportById, listSports, updateSport } = await import(
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
prismaMock._reset();
|
prismaMock._reset();
|
||||||
mock.clearAllMocks();
|
uuidV7Mock.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('listSports returns sports ordered by name', async () => {
|
test('listSports returns sports ordered by name', async () => {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export const dbMock = {
|
|||||||
complex: prismaMock.complex,
|
complex: prismaMock.complex,
|
||||||
user: prismaMock.user,
|
user: prismaMock.user,
|
||||||
complexInvitation: prismaMock.complexInvitation,
|
complexInvitation: prismaMock.complexInvitation,
|
||||||
|
sport: prismaMock.sport,
|
||||||
$transaction: transactionMock,
|
$transaction: transactionMock,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -33,3 +34,10 @@ mock.module('@/lib/mailer', () => ({
|
|||||||
__esModule: true,
|
__esModule: true,
|
||||||
sendMail: sendMailMock,
|
sendMail: sendMailMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const uuidV7Mock = mock(() => '00000000-0000-4000-8000-000000000001');
|
||||||
|
|
||||||
|
mock.module('uuid', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
v7: uuidV7Mock,
|
||||||
|
}));
|
||||||
|
|||||||
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 |
15
apps/frontend/src/components/background.tsx
Normal file
15
apps/frontend/src/components/background.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export function Background() {
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-[#040b11] to-[#09131c]" />
|
||||||
|
|
||||||
|
<div className="absolute left-0 top-0 h-[420px] w-[420px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/20 blur-3xl" />
|
||||||
|
<div className="absolute right-0 top-24 h-[360px] w-[360px] translate-x-1/3 rounded-full bg-reserved/15 blur-3xl" />
|
||||||
|
<div className="absolute bottom-0 left-1/2 h-[300px] w-[300px] -translate-x-1/2 translate-y-1/3 rounded-full bg-accent/10 blur-3xl" />
|
||||||
|
|
||||||
|
<div className="absolute inset-0 bg-[linear-gradient(to_right,oklch(1_0_0/0.055)_1px,transparent_1px),linear-gradient(to_bottom,oklch(1_0_0/0.055)_1px,transparent_1px)] bg-[size:64px_64px]" />
|
||||||
|
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,#09131c_78%)]" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
|
|
||||||
import PlayzerIcon from '@/assets/playzer-favicon-512-transparent.png';
|
import PlayzerIcon from '@/assets/playzer-favicon-512-transparent.png';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Link, useLocation, useNavigate } from '@tanstack/react-router';
|
import { Link, useNavigate } from '@tanstack/react-router';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -35,40 +35,6 @@ import { acceptStoredPendingInvite } from '@/lib/invitations';
|
|||||||
import { useCurrentComplexStore } from '@/lib/stores/current-complex-store';
|
import { useCurrentComplexStore } from '@/lib/stores/current-complex-store';
|
||||||
import { useTheme } from '@/lib/theme';
|
import { useTheme } from '@/lib/theme';
|
||||||
|
|
||||||
interface NavItemProps {
|
|
||||||
to: '/' | '/about';
|
|
||||||
label: string;
|
|
||||||
isMobile?: boolean;
|
|
||||||
onClick?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function NavItem({ to, label, isMobile, onClick }: NavItemProps) {
|
|
||||||
const location = useLocation();
|
|
||||||
const currentPath = location.pathname;
|
|
||||||
const isActive = to === '/' ? currentPath === '/' : currentPath.startsWith(to);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link to={to} onClick={onClick}>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size={isMobile ? 'default' : 'sm'}
|
|
||||||
className={[
|
|
||||||
'rounded-none transition-all duration-200 hover:bg-transparent',
|
|
||||||
'focus-visible:ring-2 focus-visible:ring-primary/50',
|
|
||||||
isMobile && 'w-full justify-start rounded-2xl',
|
|
||||||
isActive
|
|
||||||
? 'text-primary relative after:absolute after:bottom-0 after:left-0 after:right-0 after:h-0.5 after:bg-primary'
|
|
||||||
: 'text-muted-foreground hover:text-foreground relative hover:after:absolute hover:after:bottom-0 hover:after:left-0 hover:after:right-0 hover:after:h-0.5 hover:after:bg-primary/50',
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -113,11 +79,6 @@ export function Header() {
|
|||||||
return selected?.complexName ?? complexes[0]?.complexName ?? 'Mi complejo';
|
return selected?.complexName ?? complexes[0]?.complexName ?? 'Mi complejo';
|
||||||
}, [complexSlug, myComplexesQuery.data]);
|
}, [complexSlug, myComplexesQuery.data]);
|
||||||
|
|
||||||
const menuItems = [
|
|
||||||
{ label: 'Inicio', to: '/' },
|
|
||||||
{ label: 'About', to: '/about' },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const handleSelectComplex = async (complexId: string, nextSlug: string) => {
|
const handleSelectComplex = async (complexId: string, nextSlug: string) => {
|
||||||
await apiClient.complexes.select({ complexId });
|
await apiClient.complexes.select({ complexId });
|
||||||
setCurrentComplex(nextSlug);
|
setCurrentComplex(nextSlug);
|
||||||
@@ -154,12 +115,6 @@ export function Header() {
|
|||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className="hidden absolute left-1/2 -translate-x-1/2 items-center gap-1 md:flex">
|
|
||||||
{menuItems.map((item) => (
|
|
||||||
<NavItem key={item.label} to={item.to} label={item.label} />
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
<div className="hidden items-center gap-2 md:flex">
|
<div className="hidden items-center gap-2 md:flex">
|
||||||
@@ -336,18 +291,6 @@ export function Header() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex flex-col gap-1">
|
|
||||||
{menuItems.map((item) => (
|
|
||||||
<NavItem
|
|
||||||
key={item.label}
|
|
||||||
to={item.to}
|
|
||||||
label={item.label}
|
|
||||||
isMobile
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
className="rounded-2xl shadow-lg shadow-primary/20"
|
className="rounded-2xl shadow-lg shadow-primary/20"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Background } from '@/components/background';
|
||||||
import { useIsMobile } from '@/hooks/use-mobile';
|
import { useIsMobile } from '@/hooks/use-mobile';
|
||||||
import { useLocation } from '@tanstack/react-router';
|
import { useLocation } from '@tanstack/react-router';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
@@ -28,19 +29,3 @@ export function Layout({ children }: LayoutProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Background() {
|
|
||||||
return (
|
|
||||||
<div className="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-[#040b11] to-[#09131c]" />
|
|
||||||
|
|
||||||
<div className="absolute left-0 top-0 h-[420px] w-[420px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/20 blur-3xl" />
|
|
||||||
<div className="absolute right-0 top-24 h-[360px] w-[360px] translate-x-1/3 rounded-full bg-reserved/15 blur-3xl" />
|
|
||||||
<div className="absolute bottom-0 left-1/2 h-[300px] w-[300px] -translate-x-1/2 translate-y-1/3 rounded-full bg-accent/10 blur-3xl" />
|
|
||||||
|
|
||||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,oklch(1_0_0/0.055)_1px,transparent_1px),linear-gradient(to_bottom,oklch(1_0_0/0.055)_1px,transparent_1px)] bg-[size:64px_64px]" />
|
|
||||||
|
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,#09131c_78%)]" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function SelectTrigger({
|
|||||||
data-slot="select-trigger"
|
data-slot="select-trigger"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"flex w-full items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
export function AboutPage() {
|
|
||||||
return (
|
|
||||||
<main className="mx-auto flex min-h-[60vh] w-full max-w-6xl items-center justify-center px-3 sm:px-6">
|
|
||||||
<section className="w-full max-w-3xl rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
|
||||||
<h2 className="text-2xl font-semibold">About</h2>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
Ruta de ejemplo funcionando con TanStack Router.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -41,6 +41,7 @@ interface CreateManualBookingPayload {
|
|||||||
startTime: string;
|
startTime: string;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
customerPhone: string;
|
customerPhone: string;
|
||||||
|
customerEmail: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BookingContextValue {
|
interface BookingContextValue {
|
||||||
@@ -209,20 +210,14 @@ function buildSegmentsForCourt(
|
|||||||
function getSummary(schedules: BookingCourtSchedule[]): BookingSummary {
|
function getSummary(schedules: BookingCourtSchedule[]): BookingSummary {
|
||||||
const freeSlots = schedules.reduce((total, schedule) => total + schedule.metrics.free, 0);
|
const freeSlots = schedules.reduce((total, schedule) => total + schedule.metrics.free, 0);
|
||||||
const reservedSlots = schedules.reduce((total, schedule) => total + schedule.metrics.reserved, 0);
|
const reservedSlots = schedules.reduce((total, schedule) => total + schedule.metrics.reserved, 0);
|
||||||
const maintenanceSlots = schedules.reduce(
|
const totalSegments = freeSlots + reservedSlots || 1;
|
||||||
(total, schedule) => total + schedule.metrics.maintenance,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
const totalSegments = freeSlots + reservedSlots + maintenanceSlots || 1;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalReservations: reservedSlots + maintenanceSlots + freeSlots,
|
totalReservations: freeSlots + reservedSlots,
|
||||||
freeSlots,
|
freeSlots,
|
||||||
reservedSlots,
|
reservedSlots,
|
||||||
maintenanceSlots,
|
|
||||||
freePercent: Math.round((freeSlots / totalSegments) * 100),
|
freePercent: Math.round((freeSlots / totalSegments) * 100),
|
||||||
reservedPercent: Math.round((reservedSlots / totalSegments) * 100),
|
reservedPercent: Math.round((reservedSlots / totalSegments) * 100),
|
||||||
maintenancePercent: Math.round((maintenanceSlots / totalSegments) * 100),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,7 +269,6 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
|
|||||||
const metrics = {
|
const metrics = {
|
||||||
free: segments.filter((segment) => segment.status === 'free').length,
|
free: segments.filter((segment) => segment.status === 'free').length,
|
||||||
reserved: segments.filter((segment) => segment.status === 'reserved').length,
|
reserved: segments.filter((segment) => segment.status === 'reserved').length,
|
||||||
maintenance: segments.filter((segment) => segment.status === 'maintenance').length,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return { court, segments, metrics };
|
return { court, segments, metrics };
|
||||||
@@ -315,6 +309,7 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
|
|||||||
startTime: payload.startTime,
|
startTime: payload.startTime,
|
||||||
customerName: payload.customerName,
|
customerName: payload.customerName,
|
||||||
customerPhone: payload.customerPhone,
|
customerPhone: payload.customerPhone,
|
||||||
|
customerEmail: payload.customerEmail,
|
||||||
}),
|
}),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ['admin-bookings', complex.id] });
|
await queryClient.invalidateQueries({ queryKey: ['admin-bookings', complex.id] });
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { useIsMobile } from '@/hooks/use-mobile';
|
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 type { ComplexWithRole } from '@repo/api-contract';
|
||||||
|
import { useState } from 'react';
|
||||||
import { BookingProvider } from './booking-provider';
|
import { BookingProvider } from './booking-provider';
|
||||||
import { BookingCreateDialog } from './components/booking-create-dialog';
|
import { BookingCreateDialog } from './components/booking-create-dialog';
|
||||||
import { BookingDaySummary, BookingQuickActions } from './components/booking-day-summary';
|
import { BookingDaySummary, BookingQuickActions } from './components/booking-day-summary';
|
||||||
@@ -15,9 +18,44 @@ interface BookingProps {
|
|||||||
|
|
||||||
export function Booking({ complex }: BookingProps) {
|
export function Booking({ complex }: BookingProps) {
|
||||||
const isMobile = useIsMobile();
|
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 (
|
return (
|
||||||
<BookingProvider complex={complex}>
|
<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 ? (
|
{isMobile ? (
|
||||||
<BookingMobile />
|
<BookingMobile />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { AdminBooking, Court } from '@repo/api-contract';
|
import type { AdminBooking, Court } from '@repo/api-contract';
|
||||||
|
|
||||||
export type BookingViewMode = 'panel' | 'status';
|
export type BookingViewMode = 'panel' | 'status';
|
||||||
export type BookingStatusFilter = 'all' | 'free' | 'reserved' | 'maintenance';
|
export type BookingStatusFilter = 'all' | 'free' | 'reserved';
|
||||||
export type BookingSegmentStatus = 'free' | 'reserved' | 'maintenance';
|
export type BookingSegmentStatus = 'free' | 'reserved';
|
||||||
|
|
||||||
export interface BookingTimeRange {
|
export interface BookingTimeRange {
|
||||||
start: string;
|
start: string;
|
||||||
@@ -13,16 +13,13 @@ export interface BookingSummary {
|
|||||||
totalReservations: number;
|
totalReservations: number;
|
||||||
freeSlots: number;
|
freeSlots: number;
|
||||||
reservedSlots: number;
|
reservedSlots: number;
|
||||||
maintenanceSlots: number;
|
|
||||||
freePercent: number;
|
freePercent: number;
|
||||||
reservedPercent: number;
|
reservedPercent: number;
|
||||||
maintenancePercent: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BookingCourtMetrics {
|
export interface BookingCourtMetrics {
|
||||||
free: number;
|
free: number;
|
||||||
reserved: number;
|
reserved: number;
|
||||||
maintenance: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BookingTimelineSegment {
|
export interface BookingTimelineSegment {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const bookingFormSchema = z.object({
|
|||||||
.trim()
|
.trim()
|
||||||
.min(6, 'Ingresa un telefono válido.')
|
.min(6, 'Ingresa un telefono válido.')
|
||||||
.max(30, 'El telefono no puede superar los 30 caracteres.'),
|
.max(30, 'El telefono no puede superar los 30 caracteres.'),
|
||||||
|
customerEmail: z.string().email('Ingresá un email válido.').or(z.literal('')),
|
||||||
});
|
});
|
||||||
|
|
||||||
type BookingForm = z.infer<typeof bookingFormSchema>;
|
type BookingForm = z.infer<typeof bookingFormSchema>;
|
||||||
@@ -76,6 +77,7 @@ export function BookingCreateDialog() {
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
customerName: '',
|
customerName: '',
|
||||||
customerPhone: '',
|
customerPhone: '',
|
||||||
|
customerEmail: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -154,6 +156,7 @@ export function BookingCreateDialog() {
|
|||||||
startTime,
|
startTime,
|
||||||
customerName: values.customerName,
|
customerName: values.customerName,
|
||||||
customerPhone: values.customerPhone,
|
customerPhone: values.customerPhone,
|
||||||
|
customerEmail: values.customerEmail,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -163,17 +166,21 @@ export function BookingCreateDialog() {
|
|||||||
onOpenChange={(open) => !open && closeCreateBooking()}
|
onOpenChange={(open) => !open && closeCreateBooking()}
|
||||||
>
|
>
|
||||||
<ResponsiveDialogContent
|
<ResponsiveDialogContent
|
||||||
className="data-[variant=dialog]:max-w-lg"
|
className="group/create-booking data-[variant=dialog]:max-w-lg data-[variant=drawer]:!h-[92svh] data-[variant=drawer]:!max-h-[92svh] data-[variant=drawer]:overflow-hidden data-[variant=drawer]:px-0 data-[variant=drawer]:pb-0"
|
||||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
>
|
>
|
||||||
<ResponsiveDialogHeader>
|
<ResponsiveDialogHeader className="group-data-[variant=drawer]/create-booking:shrink-0 group-data-[variant=drawer]/create-booking:px-4 group-data-[variant=drawer]/create-booking:pb-3">
|
||||||
<ResponsiveDialogTitle>Nueva reserva</ResponsiveDialogTitle>
|
<ResponsiveDialogTitle>Nueva reserva</ResponsiveDialogTitle>
|
||||||
<ResponsiveDialogDescription>
|
<ResponsiveDialogDescription>
|
||||||
Crea un turno para atención telefónica o mostrador.
|
Crea un turno para atención telefónica o mostrador.
|
||||||
</ResponsiveDialogDescription>
|
</ResponsiveDialogDescription>
|
||||||
</ResponsiveDialogHeader>
|
</ResponsiveDialogHeader>
|
||||||
|
|
||||||
<form id="booking-create-form" className="grid gap-4" onSubmit={handleSubmit(onSubmit)}>
|
<form
|
||||||
|
id="booking-create-form"
|
||||||
|
className="grid gap-4 group-data-[variant=drawer]/create-booking:min-h-0 group-data-[variant=drawer]/create-booking:flex-1 group-data-[variant=drawer]/create-booking:overflow-y-auto group-data-[variant=drawer]/create-booking:px-4 group-data-[variant=drawer]/create-booking:pb-4"
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
>
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel>Fecha</FieldLabel>
|
<FieldLabel>Fecha</FieldLabel>
|
||||||
@@ -271,10 +278,27 @@ export function BookingCreateDialog() {
|
|||||||
<FieldError errors={[errors.customerPhone]} />
|
<FieldError errors={[errors.customerPhone]} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<Field data-invalid={Boolean(errors.customerEmail)}>
|
||||||
|
<FieldLabel htmlFor="customerEmail">
|
||||||
|
Email <span className="text-muted-foreground font-normal">(opcional)</span>
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="customerEmail"
|
||||||
|
type="email"
|
||||||
|
placeholder="Ej: juan@ejemplo.com"
|
||||||
|
aria-invalid={Boolean(errors.customerEmail)}
|
||||||
|
{...register('customerEmail')}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Se enviará la confirmación de la reserva por email.
|
||||||
|
</p>
|
||||||
|
<FieldError errors={[errors.customerEmail]} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
{createBookingError && <p className="text-sm text-destructive">{createBookingError}</p>}
|
{createBookingError && <p className="text-sm text-destructive">{createBookingError}</p>}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<ResponsiveDialogFooter>
|
<ResponsiveDialogFooter className="group-data-[variant=drawer]/create-booking:shrink-0 group-data-[variant=drawer]/create-booking:border-t group-data-[variant=drawer]/create-booking:border-border/70 group-data-[variant=drawer]/create-booking:bg-popover/95 group-data-[variant=drawer]/create-booking:px-4 group-data-[variant=drawer]/create-booking:pt-3 group-data-[variant=drawer]/create-booking:pb-[calc(12px+env(safe-area-inset-bottom))]">
|
||||||
<ResponsiveDialogClose asChild>
|
<ResponsiveDialogClose asChild>
|
||||||
<Button variant="outline">Cancelar</Button>
|
<Button variant="outline">Cancelar</Button>
|
||||||
</ResponsiveDialogClose>
|
</ResponsiveDialogClose>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CalendarDays, Download, Drill, ShieldCheck, UsersRound } from 'lucide-react';
|
import { CalendarDays, Download, ShieldCheck, UsersRound } from 'lucide-react';
|
||||||
import { useBooking } from '../booking-provider';
|
import { useBooking } from '../booking-provider';
|
||||||
import { formatBookingDate, toIsoDateLocal } from '../lib/booking-time';
|
import { formatBookingDate, toIsoDateLocal } from '../lib/booking-time';
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ export function BookingDaySummary() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-4">
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
label="Total de bloques"
|
label="Total de bloques"
|
||||||
value={summary.totalReservations}
|
value={summary.totalReservations}
|
||||||
@@ -35,13 +35,6 @@ export function BookingDaySummary() {
|
|||||||
icon={ShieldCheck}
|
icon={ShieldCheck}
|
||||||
className="border-reserved/35 bg-reserved/10 text-reserved"
|
className="border-reserved/35 bg-reserved/10 text-reserved"
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
|
||||||
label="Mantenimiento"
|
|
||||||
value={summary.maintenanceSlots}
|
|
||||||
detail={`${summary.maintenancePercent}% del total`}
|
|
||||||
icon={Drill}
|
|
||||||
className="border-maintenance/35 bg-maintenance/10 text-maintenance"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -77,7 +70,6 @@ export function BookingQuickActions() {
|
|||||||
useBooking();
|
useBooking();
|
||||||
const todayIso = toIsoDateLocal(new Date());
|
const todayIso = toIsoDateLocal(new Date());
|
||||||
const isViewingTodayReservations = selectedDate === todayIso && selectedStatus === 'reserved';
|
const isViewingTodayReservations = selectedDate === todayIso && selectedStatus === 'reserved';
|
||||||
const isViewingMaintenance = selectedStatus === 'maintenance';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-lg border bg-card/85 p-5 shadow-sm">
|
<section className="rounded-lg border bg-card/85 p-5 shadow-sm">
|
||||||
@@ -95,15 +87,6 @@ export function BookingQuickActions() {
|
|||||||
<CalendarDays className="size-4 text-muted-foreground" />
|
<CalendarDays className="size-4 text-muted-foreground" />
|
||||||
{isViewingTodayReservations ? 'Ver todos los estados' : 'Ver reservas de hoy'}
|
{isViewingTodayReservations ? 'Ver todos los estados' : 'Ver reservas de hoy'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelectedStatus(isViewingMaintenance ? 'all' : 'maintenance')}
|
|
||||||
aria-pressed={isViewingMaintenance}
|
|
||||||
className="flex h-10 items-center gap-3 rounded-md border bg-background/45 px-3 text-left text-sm transition-colors hover:bg-muted aria-pressed:border-maintenance/50 aria-pressed:bg-maintenance/10"
|
|
||||||
>
|
|
||||||
<Drill className="size-4 text-maintenance" />
|
|
||||||
{isViewingMaintenance ? 'Ver todos los estados' : 'Ver mantenimiento'}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={exportDayReport}
|
onClick={exportDayReport}
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import {
|
|||||||
Sun,
|
Sun,
|
||||||
User,
|
User,
|
||||||
Users,
|
Users,
|
||||||
Wrench,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
@@ -209,29 +208,6 @@ function MobileTopBar() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex flex-col gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="justify-start rounded-lg"
|
|
||||||
onClick={() => {
|
|
||||||
setOpen(false);
|
|
||||||
void navigate({ to: '/' });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Inicio
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="justify-start rounded-lg"
|
|
||||||
onClick={() => {
|
|
||||||
setOpen(false);
|
|
||||||
void navigate({ to: '/about' });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
About
|
|
||||||
</Button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
className="rounded-lg shadow-lg shadow-primary/20"
|
className="rounded-lg shadow-lg shadow-primary/20"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -375,18 +351,33 @@ function MobilePanelHeader() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function MobileFilters() {
|
function MobileFilters() {
|
||||||
const { selectedDate, setSelectedDate } = useBooking();
|
const { selectedDate, setSelectedDate, moveSelectedDate } = useBooking();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="grid grid-cols-[52px_1fr_52px] gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-12 rounded-lg"
|
||||||
|
onClick={() => moveSelectedDate(-1)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-5" />
|
||||||
|
</Button>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
value={fromIsoDateLocal(selectedDate)}
|
value={fromIsoDateLocal(selectedDate)}
|
||||||
onChange={(date) =>
|
onChange={(date) =>
|
||||||
setSelectedDate(date ? toIsoDateLocal(date) : toIsoDateLocal(new Date()))
|
setSelectedDate(date ? toIsoDateLocal(date) : toIsoDateLocal(new Date()))
|
||||||
}
|
}
|
||||||
className="h-12 w-full justify-center rounded-lg border-border/70 bg-card/75 px-4 text-sm font-medium"
|
className="h-12 rounded-lg border-border/70 bg-card/75 text-sm"
|
||||||
placeholder="Fecha"
|
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-12 rounded-lg"
|
||||||
|
onClick={() => moveSelectedDate(1)}
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-5" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -408,11 +399,6 @@ function MobileSummary() {
|
|||||||
label="Reservados"
|
label="Reservados"
|
||||||
className="bg-reserved/15 text-reserved"
|
className="bg-reserved/15 text-reserved"
|
||||||
/>
|
/>
|
||||||
<SummaryPill
|
|
||||||
value={summary.maintenanceSlots}
|
|
||||||
label="Mantenim."
|
|
||||||
className="bg-maintenance/15 text-maintenance"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -434,7 +420,6 @@ function MobileStatusTab() {
|
|||||||
{ value: 'all', label: 'Todos' },
|
{ value: 'all', label: 'Todos' },
|
||||||
{ value: 'free', label: 'Libres' },
|
{ value: 'free', label: 'Libres' },
|
||||||
{ value: 'reserved', label: 'Reservados' },
|
{ value: 'reserved', label: 'Reservados' },
|
||||||
{ value: 'maintenance', label: 'Mantenimiento' },
|
|
||||||
] as const;
|
] as const;
|
||||||
const timeRangeOptions = [
|
const timeRangeOptions = [
|
||||||
{ label: '08:00 - 22:00', start: '08:00', end: '22:00' },
|
{ label: '08:00 - 22:00', start: '08:00', end: '22:00' },
|
||||||
@@ -858,14 +843,10 @@ function MobileSegmentRow({
|
|||||||
) : (
|
) : (
|
||||||
<div className="max-w-[132px] text-right">
|
<div className="max-w-[132px] text-right">
|
||||||
<p className="truncate text-sm font-medium">
|
<p className="truncate text-sm font-medium">
|
||||||
{segment.booking?.customerName ?? 'Mantenimiento'}
|
{segment.booking?.customerName ?? 'Sin información'}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 inline-flex items-center gap-1 text-sm text-muted-foreground">
|
<p className="mt-2 inline-flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
{segment.status === 'maintenance' ? (
|
<Users className="size-4" />
|
||||||
<Wrench className="size-4" />
|
|
||||||
) : (
|
|
||||||
<Users className="size-4" />
|
|
||||||
)}
|
|
||||||
{segment.booking?.customerPhone ?? 'Personal'}
|
{segment.booking?.customerPhone ?? 'Personal'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -876,23 +857,21 @@ function MobileSegmentRow({
|
|||||||
|
|
||||||
function SegmentStatusLine({ segment }: { segment: BookingTimelineSegment }) {
|
function SegmentStatusLine({ segment }: { segment: BookingTimelineSegment }) {
|
||||||
const status = segment.status;
|
const status = segment.status;
|
||||||
const label = status === 'free' ? 'Libre' : status === 'reserved' ? 'Reservado' : 'Mantenimiento';
|
const label = status === 'free' ? 'Libre' : 'Reservado';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
'mt-2 inline-flex items-center gap-2 text-sm',
|
'mt-2 inline-flex items-center gap-2 text-sm',
|
||||||
status === 'free' && 'text-primary',
|
status === 'free' && 'text-primary',
|
||||||
status === 'reserved' && 'text-reserved',
|
status === 'reserved' && 'text-reserved'
|
||||||
status === 'maintenance' && 'text-maintenance'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'size-3 rounded-full',
|
'size-3 rounded-full',
|
||||||
status === 'free' && 'bg-primary',
|
status === 'free' && 'bg-primary',
|
||||||
status === 'reserved' && 'bg-reserved',
|
status === 'reserved' && 'bg-reserved'
|
||||||
status === 'maintenance' && 'bg-maintenance'
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{label}
|
{label}
|
||||||
@@ -905,7 +884,6 @@ function StatusLegend() {
|
|||||||
<div className="flex items-center justify-between gap-3 text-sm">
|
<div className="flex items-center justify-between gap-3 text-sm">
|
||||||
<LegendItem label="Libre" className="bg-primary" />
|
<LegendItem label="Libre" className="bg-primary" />
|
||||||
<LegendItem label="Reservado" className="bg-reserved" />
|
<LegendItem label="Reservado" className="bg-reserved" />
|
||||||
<LegendItem label="Mantenimiento" className="bg-maintenance" />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -937,9 +915,7 @@ function MiniSlot({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'min-w-0 rounded-lg border px-1.5 py-2 text-center transition active:scale-[0.98] disabled:opacity-60',
|
'min-w-0 rounded-lg border px-1.5 py-2 text-center transition active:scale-[0.98] disabled:opacity-60',
|
||||||
segment.status === 'free' && 'border-primary/50 bg-primary/15 text-primary',
|
segment.status === 'free' && 'border-primary/50 bg-primary/15 text-primary',
|
||||||
segment.status === 'reserved' && 'border-reserved/50 bg-reserved/15 text-reserved',
|
segment.status === 'reserved' && 'border-reserved/50 bg-reserved/15 text-reserved'
|
||||||
segment.status === 'maintenance' &&
|
|
||||||
'border-maintenance/50 bg-maintenance/15 text-maintenance'
|
|
||||||
)}
|
)}
|
||||||
disabled={!isActionable}
|
disabled={!isActionable}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -989,10 +965,6 @@ function MetricDots({ schedule }: { schedule: BookingCourtSchedule }) {
|
|||||||
<span className="size-3 rounded-full bg-reserved" />
|
<span className="size-3 rounded-full bg-reserved" />
|
||||||
{schedule.metrics.reserved}
|
{schedule.metrics.reserved}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1 text-maintenance">
|
|
||||||
<span className="size-3 rounded-full bg-maintenance" />
|
|
||||||
{schedule.metrics.maintenance}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
const items = [
|
const items = [
|
||||||
{ label: 'Libre', className: 'bg-primary' },
|
{ label: 'Libre', className: 'bg-primary' },
|
||||||
{ label: 'Reservado', className: 'bg-reserved' },
|
{ label: 'Reservado', className: 'bg-reserved' },
|
||||||
{ label: 'Mantenimiento', className: 'bg-maintenance' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function BookingStatusLegend() {
|
export function BookingStatusLegend() {
|
||||||
|
|||||||
@@ -215,10 +215,6 @@ function BookingCourtInfo({ schedule }: { schedule: BookingCourtSchedule }) {
|
|||||||
<span className="size-2 rounded-full bg-reserved" />
|
<span className="size-2 rounded-full bg-reserved" />
|
||||||
{schedule.metrics.reserved}
|
{schedule.metrics.reserved}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<span className="size-2 rounded-full bg-maintenance" />
|
|
||||||
{schedule.metrics.maintenance}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -247,8 +243,7 @@ function BookingSlotBlock({ segment, schedule, rangeStart, totalMinutes }: Booki
|
|||||||
'border-primary/70 bg-primary/20 text-primary hover:bg-primary/25',
|
'border-primary/70 bg-primary/20 text-primary hover:bg-primary/25',
|
||||||
segment.status === 'reserved' &&
|
segment.status === 'reserved' &&
|
||||||
'border-reserved/70 bg-reserved/80 text-reserved-foreground',
|
'border-reserved/70 bg-reserved/80 text-reserved-foreground',
|
||||||
segment.status === 'maintenance' &&
|
|
||||||
'border-maintenance/80 bg-maintenance/75 text-maintenance-foreground',
|
|
||||||
segment.booking?.status === 'COMPLETED' &&
|
segment.booking?.status === 'COMPLETED' &&
|
||||||
'border-reserved/70 bg-reserved/25 dark:text-reserved-foreground text-gray-600 line-through',
|
'border-reserved/70 bg-reserved/25 dark:text-reserved-foreground text-gray-600 line-through',
|
||||||
segment.booking?.status === 'NOSHOW' &&
|
segment.booking?.status === 'NOSHOW' &&
|
||||||
@@ -300,7 +295,7 @@ function BookingSlotBlock({ segment, schedule, rangeStart, totalMinutes }: Booki
|
|||||||
</div>
|
</div>
|
||||||
<span className="mt-1 flex items-center gap-1 truncate opacity-90">
|
<span className="mt-1 flex items-center gap-1 truncate opacity-90">
|
||||||
<Users className="size-3" />
|
<Users className="size-3" />
|
||||||
{segment.booking?.customerName ?? 'Mantenimiento'}
|
{segment.booking?.customerName ?? 'Sin información'}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { ChevronLeft, ChevronRight, Clock, SlidersHorizontal } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, Clock } from 'lucide-react';
|
||||||
import { useBooking } from '../booking-provider';
|
import { useBooking } from '../booking-provider';
|
||||||
import type { BookingStatusFilter } from '../booking.types';
|
import type { BookingStatusFilter } from '../booking.types';
|
||||||
import { fromIsoDateLocal, toIsoDateLocal } from '../lib/booking-time';
|
import { fromIsoDateLocal, toIsoDateLocal } from '../lib/booking-time';
|
||||||
@@ -24,7 +24,6 @@ const statusOptions: Array<{ value: BookingStatusFilter; label: string }> = [
|
|||||||
{ value: 'all', label: 'Todos' },
|
{ value: 'all', label: 'Todos' },
|
||||||
{ value: 'free', label: 'Libre' },
|
{ value: 'free', label: 'Libre' },
|
||||||
{ value: 'reserved', label: 'Reservado' },
|
{ value: 'reserved', label: 'Reservado' },
|
||||||
{ value: 'maintenance', label: 'Mantenimiento' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function BookingToolbar() {
|
export function BookingToolbar() {
|
||||||
@@ -46,7 +45,7 @@ export function BookingToolbar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-lg border bg-card/85 p-4 shadow-sm">
|
<section className="rounded-lg border bg-card/85 p-4 shadow-sm">
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-[1fr_1fr_1.6fr_1.1fr_auto] xl:items-end">
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-[1fr_1fr_1.6fr_1.1fr] xl:items-end">
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel>Deporte</FieldLabel>
|
<FieldLabel>Deporte</FieldLabel>
|
||||||
<Select value={selectedSportId} onValueChange={setSelectedSportId}>
|
<Select value={selectedSportId} onValueChange={setSelectedSportId}>
|
||||||
@@ -133,11 +132,6 @@ export function BookingToolbar() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Button variant="outline" className="xl:self-end">
|
|
||||||
<SlidersHorizontal className="size-4" />
|
|
||||||
Filtros avanzados
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
Wrench,
|
Wrench,
|
||||||
XCircle,
|
XCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
import { useBooking } from '../booking-provider';
|
import { useBooking } from '../booking-provider';
|
||||||
|
|
||||||
function formatDate(date: string | undefined) {
|
function formatDate(date: string | undefined) {
|
||||||
@@ -70,6 +71,7 @@ export function BookingToolsDialog() {
|
|||||||
useBooking();
|
useBooking();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const booking = selectedSegment?.booking;
|
const booking = selectedSegment?.booking;
|
||||||
|
const [confirmCancelOpen, setConfirmCancelOpen] = useState(false);
|
||||||
|
|
||||||
const status = statusConfig[booking?.status ?? 'CONFIRMED'] ?? {
|
const status = statusConfig[booking?.status ?? 'CONFIRMED'] ?? {
|
||||||
label: booking?.status,
|
label: booking?.status,
|
||||||
@@ -93,149 +95,211 @@ export function BookingToolsDialog() {
|
|||||||
closeBookingTools();
|
closeBookingTools();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cancelConfirmDialog = (
|
||||||
|
<ResponsiveDialog
|
||||||
|
open={confirmCancelOpen}
|
||||||
|
onOpenChange={(open) => !open && setConfirmCancelOpen(false)}
|
||||||
|
>
|
||||||
|
<ResponsiveDialogContent>
|
||||||
|
<ResponsiveDialogHeader>
|
||||||
|
<ResponsiveDialogTitle>¿Cancelar reserva?</ResponsiveDialogTitle>
|
||||||
|
<ResponsiveDialogDescription>
|
||||||
|
Esta acción no se puede deshacer. Se eliminará la reserva y la cancha volverá a estar
|
||||||
|
disponible.
|
||||||
|
</ResponsiveDialogDescription>
|
||||||
|
</ResponsiveDialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-2 rounded-lg border bg-card p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Cliente</span>
|
||||||
|
<span className="text-sm font-medium">{booking?.customerName ?? '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Cancha</span>
|
||||||
|
<span className="text-sm font-medium">{booking?.courtName ?? '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Fecha</span>
|
||||||
|
<span className="text-sm font-medium">{formatDate(booking?.date) || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Horario</span>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{booking?.startTime ?? '--:--'} a {booking?.endTime ?? '--:--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ResponsiveDialogFooter>
|
||||||
|
<ResponsiveDialogClose asChild>
|
||||||
|
<Button variant="outline">Volver</Button>
|
||||||
|
</ResponsiveDialogClose>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
setConfirmCancelOpen(false);
|
||||||
|
updateStatus('CANCELLED');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sí, cancelar reserva
|
||||||
|
</Button>
|
||||||
|
</ResponsiveDialogFooter>
|
||||||
|
</ResponsiveDialogContent>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<ResponsiveDialog
|
||||||
|
open={bookingToolsOpen}
|
||||||
|
onOpenChange={(open) => !open && closeBookingTools()}
|
||||||
|
>
|
||||||
|
<ResponsiveDialogContent
|
||||||
|
className="data-[variant=drawer]:!h-[92dvh] data-[variant=drawer]:!max-h-[92dvh] data-[variant=drawer]:overflow-hidden data-[variant=drawer]:px-0 data-[variant=drawer]:pb-0"
|
||||||
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
<MobileBookingToolsSheet
|
||||||
|
status={status}
|
||||||
|
onComplete={() => updateStatus('COMPLETED')}
|
||||||
|
onCancel={() => setConfirmCancelOpen(true)}
|
||||||
|
onReminder={sendWhatsappReminder}
|
||||||
|
onNoShow={() => updateStatus('NOSHOW')}
|
||||||
|
/>
|
||||||
|
</ResponsiveDialogContent>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
{cancelConfirmDialog}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
<ResponsiveDialog
|
<ResponsiveDialog
|
||||||
open={bookingToolsOpen}
|
open={bookingToolsOpen}
|
||||||
onOpenChange={(open) => !open && closeBookingTools()}
|
onOpenChange={(open) => !open && closeBookingTools()}
|
||||||
>
|
>
|
||||||
<ResponsiveDialogContent
|
<ResponsiveDialogContent
|
||||||
className="data-[variant=drawer]:!h-[92dvh] data-[variant=drawer]:!max-h-[92dvh] data-[variant=drawer]:overflow-hidden data-[variant=drawer]:px-0 data-[variant=drawer]:pb-0"
|
className="data-[variant=dialog]:max-w-5xl data-[variant=drawer]:max-h-[92dvh] data-[variant=drawer]:overflow-y-auto data-[variant=drawer]:px-4 data-[variant=drawer]:pb-5"
|
||||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
>
|
>
|
||||||
<MobileBookingToolsSheet
|
<ResponsiveDialogHeader className="data-[variant=drawer]:px-0 data-[variant=drawer]:text-left">
|
||||||
status={status}
|
<ResponsiveDialogTitle>Detalles de la reserva</ResponsiveDialogTitle>
|
||||||
onComplete={() => updateStatus('COMPLETED')}
|
<ResponsiveDialogDescription>
|
||||||
onCancel={() => updateStatus('CANCELLED')}
|
Administra la reserva y su estado.
|
||||||
onReminder={sendWhatsappReminder}
|
</ResponsiveDialogDescription>
|
||||||
onNoShow={() => updateStatus('NOSHOW')}
|
</ResponsiveDialogHeader>
|
||||||
/>
|
|
||||||
|
<section className="mx-0 px-0 pt-3 pb-3 sm:-mx-6 sm:px-6 sm:pb-5">
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-0">
|
||||||
|
<DetailItem
|
||||||
|
icon={<MapPin className="h-5 w-5" />}
|
||||||
|
label="Cancha"
|
||||||
|
value={selectedSegment?.booking?.courtName ?? '-'}
|
||||||
|
helper={selectedSegment?.booking?.sport?.name}
|
||||||
|
withDivider
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DetailItem
|
||||||
|
icon={<BadgeCheck className="h-5 w-5" />}
|
||||||
|
label="Estado actual"
|
||||||
|
value={status.label}
|
||||||
|
valueClassName="text-emerald-400 uppercase dark:text-emerald-500"
|
||||||
|
withDivider
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DetailItem
|
||||||
|
icon={<User className="h-5 w-5" />}
|
||||||
|
label="Cliente"
|
||||||
|
value={selectedSegment?.booking?.customerName ?? '-'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DetailItem
|
||||||
|
icon={<Clock className="h-5 w-5" />}
|
||||||
|
label="Fecha"
|
||||||
|
value={`${formatDate(selectedSegment?.booking?.date)}`}
|
||||||
|
withDivider
|
||||||
|
className="sm:pt-10"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DetailItem
|
||||||
|
icon={<Clock className="h-5 w-5" />}
|
||||||
|
label="Hora"
|
||||||
|
value={`${selectedSegment?.booking?.startTime} a ${selectedSegment?.booking?.endTime}`}
|
||||||
|
withDivider
|
||||||
|
className="sm:pt-10"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DetailItem
|
||||||
|
icon={<Phone className="h-5 w-5" />}
|
||||||
|
label="Teléfono"
|
||||||
|
value={selectedSegment?.booking?.customerPhone ?? '-'}
|
||||||
|
className="sm:pt-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Separator className="dark:bg-slate-800 bg-slate-300" />
|
||||||
|
|
||||||
|
{booking?.status !== 'COMPLETED' && (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-slate-100">Acciones disponibles</h3>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
Elegí qué hacer según lo que ocurrió con el cliente.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<ActionButton
|
||||||
|
icon={<CheckCircle2 className="h-5 w-5" />}
|
||||||
|
title="Completar reserva"
|
||||||
|
description="El cliente se presentó para usar la cancha."
|
||||||
|
className="border-emerald-500/40 bg-emerald-500/10 text-lg text-emerald-600 hover:bg-emerald-500/15 hover:text-emerald-800"
|
||||||
|
onClick={() => {
|
||||||
|
updateStatus('COMPLETED');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ActionButton
|
||||||
|
icon={<XCircle className="h-5 w-5" />}
|
||||||
|
title="Cancelar reserva"
|
||||||
|
description="El cliente avisó que no va a venir. La cancha vuelve a estar disponible."
|
||||||
|
className="border-red-500/40 bg-red-500/10 text-lg text-red-600 hover:bg-red-500/15 hover:text-red-800"
|
||||||
|
onClick={() => setConfirmCancelOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ActionButton
|
||||||
|
icon={<MessageCircle className="h-5 w-5" />}
|
||||||
|
title="Enviar recordatorio"
|
||||||
|
description="Enviar un mensaje por WhatsApp al cliente."
|
||||||
|
className="border-sky-500/40 bg-sky-500/10 text-lg text-sky-600 hover:bg-sky-500/15 hover:text-sky-800"
|
||||||
|
onClick={sendWhatsappReminder}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ActionButton
|
||||||
|
icon={<AlertTriangle className="h-5 w-5" />}
|
||||||
|
title="Marcar como No Show"
|
||||||
|
description="El cliente no vino y no canceló."
|
||||||
|
className="border-amber-500/40 bg-amber-500/10 text-lg text-amber-600 hover:bg-amber-500/15 hover:text-amber-800"
|
||||||
|
onClick={() => {
|
||||||
|
updateStatus('NOSHOW');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ResponsiveDialogFooter className="data-[variant=drawer]:px-0">
|
||||||
|
<ResponsiveDialogClose asChild>
|
||||||
|
<Button variant="outline" className="data-[variant=drawer]:h-11">
|
||||||
|
Cerrar
|
||||||
|
</Button>
|
||||||
|
</ResponsiveDialogClose>
|
||||||
|
</ResponsiveDialogFooter>
|
||||||
</ResponsiveDialogContent>
|
</ResponsiveDialogContent>
|
||||||
</ResponsiveDialog>
|
</ResponsiveDialog>
|
||||||
);
|
{cancelConfirmDialog}
|
||||||
}
|
</>
|
||||||
|
|
||||||
return (
|
|
||||||
<ResponsiveDialog open={bookingToolsOpen} onOpenChange={(open) => !open && closeBookingTools()}>
|
|
||||||
<ResponsiveDialogContent
|
|
||||||
className="data-[variant=dialog]:max-w-5xl data-[variant=drawer]:max-h-[92dvh] data-[variant=drawer]:overflow-y-auto data-[variant=drawer]:px-4 data-[variant=drawer]:pb-5"
|
|
||||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
|
||||||
>
|
|
||||||
<ResponsiveDialogHeader className="data-[variant=drawer]:px-0 data-[variant=drawer]:text-left">
|
|
||||||
<ResponsiveDialogTitle>Detalles de la reserva</ResponsiveDialogTitle>
|
|
||||||
<ResponsiveDialogDescription>
|
|
||||||
Administra la reserva y su estado.
|
|
||||||
</ResponsiveDialogDescription>
|
|
||||||
</ResponsiveDialogHeader>
|
|
||||||
|
|
||||||
<section className="mx-0 px-0 pt-3 pb-3 sm:-mx-6 sm:px-6 sm:pb-5">
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-0">
|
|
||||||
<DetailItem
|
|
||||||
icon={<MapPin className="h-5 w-5" />}
|
|
||||||
label="Cancha"
|
|
||||||
value={selectedSegment?.booking?.courtName ?? '-'}
|
|
||||||
helper={selectedSegment?.booking?.sport?.name}
|
|
||||||
withDivider
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DetailItem
|
|
||||||
icon={<BadgeCheck className="h-5 w-5" />}
|
|
||||||
label="Estado actual"
|
|
||||||
value={status.label}
|
|
||||||
valueClassName="text-emerald-400 uppercase dark:text-emerald-500"
|
|
||||||
withDivider
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DetailItem
|
|
||||||
icon={<User className="h-5 w-5" />}
|
|
||||||
label="Cliente"
|
|
||||||
value={selectedSegment?.booking?.customerName ?? '-'}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DetailItem
|
|
||||||
icon={<Clock className="h-5 w-5" />}
|
|
||||||
label="Fecha"
|
|
||||||
value={`${formatDate(selectedSegment?.booking?.date)}`}
|
|
||||||
withDivider
|
|
||||||
className="sm:pt-10"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DetailItem
|
|
||||||
icon={<Clock className="h-5 w-5" />}
|
|
||||||
label="Hora"
|
|
||||||
value={`${selectedSegment?.booking?.startTime} a ${selectedSegment?.booking?.endTime}`}
|
|
||||||
withDivider
|
|
||||||
className="sm:pt-10"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DetailItem
|
|
||||||
icon={<Phone className="h-5 w-5" />}
|
|
||||||
label="Teléfono"
|
|
||||||
value={selectedSegment?.booking?.customerPhone ?? '-'}
|
|
||||||
className="sm:pt-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<Separator className="dark:bg-slate-800 bg-slate-300" />
|
|
||||||
|
|
||||||
<section className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium text-slate-100">Acciones disponibles</h3>
|
|
||||||
<p className="text-sm text-slate-400">
|
|
||||||
Elegí qué hacer según lo que ocurrió con el cliente.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<ActionButton
|
|
||||||
icon={<CheckCircle2 className="h-5 w-5" />}
|
|
||||||
title="Completar reserva"
|
|
||||||
description="El cliente se presentó para usar la cancha."
|
|
||||||
className="border-emerald-500/40 bg-emerald-500/10 text-lg text-emerald-600 hover:bg-emerald-500/15 hover:text-emerald-800"
|
|
||||||
onClick={() => {
|
|
||||||
updateStatus('COMPLETED');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ActionButton
|
|
||||||
icon={<XCircle className="h-5 w-5" />}
|
|
||||||
title="Cancelar reserva"
|
|
||||||
description="El cliente avisó que no va a venir. La cancha vuelve a estar disponible."
|
|
||||||
className="border-red-500/40 bg-red-500/10 text-lg text-red-600 hover:bg-red-500/15 hover:text-red-800"
|
|
||||||
onClick={() => {
|
|
||||||
updateStatus('CANCELLED');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ActionButton
|
|
||||||
icon={<MessageCircle className="h-5 w-5" />}
|
|
||||||
title="Enviar recordatorio"
|
|
||||||
description="Enviar un mensaje por WhatsApp al cliente."
|
|
||||||
className="border-sky-500/40 bg-sky-500/10 text-lg text-sky-600 hover:bg-sky-500/15 hover:text-sky-800"
|
|
||||||
onClick={sendWhatsappReminder}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ActionButton
|
|
||||||
icon={<AlertTriangle className="h-5 w-5" />}
|
|
||||||
title="Marcar como No Show"
|
|
||||||
description="El cliente no vino y no canceló."
|
|
||||||
className="border-amber-500/40 bg-amber-500/10 text-lg text-amber-600 hover:bg-amber-500/15 hover:text-amber-800"
|
|
||||||
onClick={() => {
|
|
||||||
updateStatus('NOSHOW');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<ResponsiveDialogFooter className="data-[variant=drawer]:px-0">
|
|
||||||
<ResponsiveDialogClose asChild>
|
|
||||||
<Button variant="outline" className="data-[variant=drawer]:h-11">
|
|
||||||
Cerrar
|
|
||||||
</Button>
|
|
||||||
</ResponsiveDialogClose>
|
|
||||||
</ResponsiveDialogFooter>
|
|
||||||
</ResponsiveDialogContent>
|
|
||||||
</ResponsiveDialog>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,42 +385,44 @@ function MobileBookingToolsSheet({
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="shrink-0 border-t border-border/70 bg-popover/95 px-4 py-3">
|
{booking?.status !== 'COMPLETED' && (
|
||||||
<h3 className="text-sm font-medium">Acciones</h3>
|
<section className="shrink-0 border-t border-border/70 bg-popover/95 px-4 py-3">
|
||||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
<h3 className="text-sm font-medium">Acciones</h3>
|
||||||
<MobileActionButton
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
icon={<CheckCircle2 className="size-5" />}
|
<MobileActionButton
|
||||||
label="Completar"
|
icon={<CheckCircle2 className="size-5" />}
|
||||||
className="border-primary/45 bg-primary/12 text-primary"
|
label="Completar"
|
||||||
onClick={onComplete}
|
className="border-primary/45 bg-primary/12 text-primary"
|
||||||
/>
|
onClick={onComplete}
|
||||||
<MobileActionButton
|
/>
|
||||||
icon={<MessageCircle className="size-5" />}
|
<MobileActionButton
|
||||||
label="Recordar"
|
icon={<MessageCircle className="size-5" />}
|
||||||
className="border-reserved/45 bg-reserved/12 text-reserved"
|
label="Recordar"
|
||||||
onClick={onReminder}
|
className="border-reserved/45 bg-reserved/12 text-reserved"
|
||||||
/>
|
onClick={onReminder}
|
||||||
<MobileActionButton
|
/>
|
||||||
icon={<XCircle className="size-5" />}
|
<MobileActionButton
|
||||||
label="Cancelar"
|
icon={<XCircle className="size-5" />}
|
||||||
className="border-destructive/45 bg-destructive/12 text-destructive"
|
label="Cancelar"
|
||||||
onClick={onCancel}
|
className="border-destructive/45 bg-destructive/12 text-destructive"
|
||||||
/>
|
onClick={onCancel}
|
||||||
<MobileActionButton
|
/>
|
||||||
icon={<AlertTriangle className="size-5" />}
|
<MobileActionButton
|
||||||
label="No show"
|
icon={<AlertTriangle className="size-5" />}
|
||||||
className="border-warning/50 bg-warning/12 text-warning"
|
label="No show"
|
||||||
onClick={onNoShow}
|
className="border-warning/50 bg-warning/12 text-warning"
|
||||||
/>
|
onClick={onNoShow}
|
||||||
</div>
|
/>
|
||||||
<ResponsiveDialogFooter className="px-0 pb-0 pt-3">
|
</div>
|
||||||
<ResponsiveDialogClose asChild>
|
<ResponsiveDialogFooter className="px-0 pb-0 pt-3">
|
||||||
<Button variant="outline" className="h-11 w-full rounded-lg">
|
<ResponsiveDialogClose asChild>
|
||||||
Cerrar
|
<Button variant="outline" className="h-11 w-full rounded-lg">
|
||||||
</Button>
|
Cerrar
|
||||||
</ResponsiveDialogClose>
|
</Button>
|
||||||
</ResponsiveDialogFooter>
|
</ResponsiveDialogClose>
|
||||||
</section>
|
</ResponsiveDialogFooter>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,8 +49,14 @@ export function HomePage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function autoSelect() {
|
async function autoSelect() {
|
||||||
if (myComplexesQuery.data && myComplexesQuery.data.length === 1) {
|
if (myComplexesQuery.data && myComplexesQuery.data.length === 1) {
|
||||||
await apiClient.complexes.select({ complexId: myComplexesQuery.data[0].id });
|
try {
|
||||||
queryClient.invalidateQueries({ queryKey: ['current-complex'] });
|
const result = await apiClient.complexes.select({
|
||||||
|
complexId: myComplexesQuery.data[0].id,
|
||||||
|
});
|
||||||
|
queryClient.setQueryData(['current-complex'], result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al auto-seleccionar complejo:', error);
|
||||||
|
}
|
||||||
} else if (
|
} else if (
|
||||||
myComplexesQuery.data &&
|
myComplexesQuery.data &&
|
||||||
myComplexesQuery.data.length > 1 &&
|
myComplexesQuery.data.length > 1 &&
|
||||||
@@ -60,13 +66,23 @@ export function HomePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentComplexQuery.isLoading && !currentComplexQuery.data && myComplexesQuery.data) {
|
if (
|
||||||
|
!currentComplexQuery.isLoading &&
|
||||||
|
!currentComplexQuery.data &&
|
||||||
|
myComplexesQuery.data &&
|
||||||
|
!myComplexesQuery.isLoading
|
||||||
|
) {
|
||||||
|
if (myComplexesQuery.data.length === 0) {
|
||||||
|
navigate({ to: '/onboard/setup' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
autoSelect();
|
autoSelect();
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
currentComplexQuery.isLoading,
|
currentComplexQuery.isLoading,
|
||||||
currentComplexQuery.data,
|
currentComplexQuery.data,
|
||||||
myComplexesQuery.data,
|
myComplexesQuery.data,
|
||||||
|
myComplexesQuery.isLoading,
|
||||||
navigate,
|
navigate,
|
||||||
queryClient,
|
queryClient,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import PlayzerIcon from '@/assets/playzer-favicon-512-transparent.png';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -5,7 +6,7 @@ import { apiClient, authClient } from '@/lib/api-client';
|
|||||||
import { useAuth } from '@/lib/auth';
|
import { useAuth } from '@/lib/auth';
|
||||||
import { acceptStoredPendingInvite } from '@/lib/invitations';
|
import { acceptStoredPendingInvite } from '@/lib/invitations';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Link, useNavigate } from '@tanstack/react-router';
|
import { Link } from '@tanstack/react-router';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -17,12 +18,7 @@ const loginSchema = z.object({
|
|||||||
|
|
||||||
type LoginForm = z.infer<typeof loginSchema>;
|
type LoginForm = z.infer<typeof loginSchema>;
|
||||||
|
|
||||||
type LoginPageProps = {
|
export function LoginPage() {
|
||||||
redirectTo?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { signInWithPassword } = useAuth();
|
const { signInWithPassword } = useAuth();
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -41,16 +37,6 @@ export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
|
|||||||
|
|
||||||
async function handlePostLogin() {
|
async function handlePostLogin() {
|
||||||
await acceptStoredPendingInvite(apiClient.complexes.acceptPendingInvitations);
|
await acceptStoredPendingInvite(apiClient.complexes.acceptPendingInvitations);
|
||||||
const complexes = await apiClient.complexes.listMine();
|
|
||||||
|
|
||||||
if (complexes.length === 0) {
|
|
||||||
await navigate({ to: redirectTo });
|
|
||||||
} else if (complexes.length === 1) {
|
|
||||||
await apiClient.complexes.select({ complexId: complexes[0].id });
|
|
||||||
navigate({ to: redirectTo });
|
|
||||||
} else {
|
|
||||||
navigate({ to: '/select-complex' });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onSubmit = async (values: LoginForm) => {
|
const onSubmit = async (values: LoginForm) => {
|
||||||
@@ -78,11 +64,19 @@ export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen w-full items-center justify-center px-3 sm:px-6">
|
<main className="flex min-h-screen w-full flex-col items-center justify-center gap-6 px-3 sm:px-6">
|
||||||
|
<Link to="/" className="group flex items-center gap-3">
|
||||||
|
<div className="flex size-10 items-center justify-center">
|
||||||
|
<img src={PlayzerIcon} alt="Playzer" className="size-7" />
|
||||||
|
</div>
|
||||||
|
<span className="bg-gradient-to-r from-primary via-primary to-reserved bg-clip-text text-xl font-bold tracking-tight text-transparent">
|
||||||
|
Playzer
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
<section className="w-full max-w-sm rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
<section className="w-full max-w-sm rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
||||||
<h1 className="mb-2 text-2xl font-semibold">Iniciar sesión</h1>
|
<h1 className="mb-2 text-2xl font-semibold">Iniciar sesión</h1>
|
||||||
<p className="mb-4 text-sm text-muted-foreground">
|
<p className="mb-4 text-sm text-muted-foreground">
|
||||||
Ingresá con tu cuenta para acceder a Home.
|
Ingresá con tu cuenta para acceder a Playzer.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Button type="button" variant="outline" className="w-full" onClick={handleGoogleSignIn}>
|
<Button type="button" variant="outline" className="w-full" onClick={handleGoogleSignIn}>
|
||||||
@@ -152,6 +146,10 @@ export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
|
|||||||
<Link to="/reset-password" className="text-primary hover:underline">
|
<Link to="/reset-password" className="text-primary hover:underline">
|
||||||
¿Olvidaste tu contraseña?
|
¿Olvidaste tu contraseña?
|
||||||
</Link>
|
</Link>
|
||||||
|
<span className="mx-3 text-muted-foreground">|</span>
|
||||||
|
<Link to="/signup" className="text-primary hover:underline">
|
||||||
|
Crear cuenta
|
||||||
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
export function OnboardingCheckEmailStep() {
|
|
||||||
return (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Revisa tu correo y abre el link de verificacion para continuar.
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import type { CompleteValues } from '@/features/onboard/onboarding.types';
|
|
||||||
import type { PlanSummary } from '@repo/api-contract';
|
|
||||||
import { Controller, type UseFormReturn } from 'react-hook-form';
|
|
||||||
|
|
||||||
type OnboardingCompleteStepProps = {
|
|
||||||
form: UseFormReturn<CompleteValues>;
|
|
||||||
plans: PlanSummary[];
|
|
||||||
verifiedEmail: string | null;
|
|
||||||
errorMessage: string | null;
|
|
||||||
infoMessage: string | null;
|
|
||||||
planPlaceholder: string;
|
|
||||||
onSubmit: (values: CompleteValues) => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function OnboardingCompleteStep({
|
|
||||||
form,
|
|
||||||
plans,
|
|
||||||
verifiedEmail,
|
|
||||||
errorMessage,
|
|
||||||
infoMessage,
|
|
||||||
planPlaceholder,
|
|
||||||
onSubmit,
|
|
||||||
}: OnboardingCompleteStepProps) {
|
|
||||||
return (
|
|
||||||
<form className="space-y-3" onSubmit={form.handleSubmit(onSubmit)}>
|
|
||||||
{verifiedEmail && (
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="verified-email">Email verificado</FieldLabel>
|
|
||||||
<Input id="verified-email" type="email" value={verifiedEmail} disabled readOnly />
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Field data-invalid={Boolean(form.formState.errors.password)}>
|
|
||||||
<FieldLabel htmlFor="onboard-password">Contraseña</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="onboard-password"
|
|
||||||
type="password"
|
|
||||||
placeholder="********"
|
|
||||||
aria-invalid={Boolean(form.formState.errors.password)}
|
|
||||||
{...form.register('password')}
|
|
||||||
/>
|
|
||||||
<FieldError errors={[form.formState.errors.password]} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field data-invalid={Boolean(form.formState.errors.complexName)}>
|
|
||||||
<FieldLabel htmlFor="complex-name">Nombre del complejo</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="complex-name"
|
|
||||||
type="text"
|
|
||||||
placeholder="Complejo Las Palmeras"
|
|
||||||
aria-invalid={Boolean(form.formState.errors.complexName)}
|
|
||||||
{...form.register('complexName')}
|
|
||||||
/>
|
|
||||||
<FieldError errors={[form.formState.errors.complexName]} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field data-invalid={Boolean(form.formState.errors.physicalAddress)}>
|
|
||||||
<FieldLabel htmlFor="physical-address">Direccion fisica</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="physical-address"
|
|
||||||
type="text"
|
|
||||||
placeholder="Ej: Av. San Martin 1234, Salta"
|
|
||||||
aria-invalid={Boolean(form.formState.errors.physicalAddress)}
|
|
||||||
{...form.register('physicalAddress')}
|
|
||||||
/>
|
|
||||||
<FieldError errors={[form.formState.errors.physicalAddress]} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field data-invalid={Boolean(form.formState.errors.planCode)}>
|
|
||||||
<FieldLabel htmlFor="plan-code">Plan</FieldLabel>
|
|
||||||
<Controller
|
|
||||||
control={form.control}
|
|
||||||
name="planCode"
|
|
||||||
render={({ field }) => (
|
|
||||||
<select
|
|
||||||
id="plan-code"
|
|
||||||
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
|
|
||||||
aria-invalid={Boolean(form.formState.errors.planCode)}
|
|
||||||
value={field.value ?? ''}
|
|
||||||
onChange={(event) => {
|
|
||||||
field.onChange(event.target.value);
|
|
||||||
}}
|
|
||||||
disabled={plans.length === 0}
|
|
||||||
>
|
|
||||||
<option value="">
|
|
||||||
{plans.length > 0 ? planPlaceholder : 'No hay planes disponibles'}
|
|
||||||
</option>
|
|
||||||
{plans.map((plan) => (
|
|
||||||
<option key={plan.code} value={plan.code}>
|
|
||||||
{plan.name} - ${plan.price.toFixed(2)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{plans.length === 0 && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Carga planes en la base para continuar con el onboarding.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<FieldError errors={[form.formState.errors.planCode]} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
|
||||||
{infoMessage && <p className="text-sm text-muted-foreground">{infoMessage}</p>}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="w-full"
|
|
||||||
disabled={form.formState.isSubmitting || !form.formState.isValid}
|
|
||||||
>
|
|
||||||
{form.formState.isSubmitting ? 'Finalizando...' : 'Completar onboarding'}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import type { StartValues } from '@/features/onboard/onboarding.types';
|
|
||||||
import type { UseFormReturn } from 'react-hook-form';
|
|
||||||
|
|
||||||
type OnboardingStartStepProps = {
|
|
||||||
form: UseFormReturn<StartValues>;
|
|
||||||
errorMessage: string | null;
|
|
||||||
infoMessage: string | null;
|
|
||||||
onSubmit: (values: StartValues) => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function OnboardingStartStep({
|
|
||||||
form,
|
|
||||||
errorMessage,
|
|
||||||
infoMessage,
|
|
||||||
onSubmit,
|
|
||||||
}: OnboardingStartStepProps) {
|
|
||||||
return (
|
|
||||||
<form className="space-y-3" onSubmit={form.handleSubmit(onSubmit)}>
|
|
||||||
<Field data-invalid={Boolean(form.formState.errors.fullName)}>
|
|
||||||
<FieldLabel htmlFor="onboard-fullname">Nombre</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="onboard-fullname"
|
|
||||||
type="text"
|
|
||||||
placeholder="Nombre Apellido"
|
|
||||||
aria-invalid={Boolean(form.formState.errors.fullName)}
|
|
||||||
{...form.register('fullName')}
|
|
||||||
/>
|
|
||||||
<FieldError errors={[form.formState.errors.fullName]} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field data-invalid={Boolean(form.formState.errors.email)}>
|
|
||||||
<FieldLabel htmlFor="onboard-email">Email</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="onboard-email"
|
|
||||||
type="email"
|
|
||||||
placeholder="tu@email.com"
|
|
||||||
aria-invalid={Boolean(form.formState.errors.email)}
|
|
||||||
{...form.register('email')}
|
|
||||||
/>
|
|
||||||
<FieldError errors={[form.formState.errors.email]} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
|
||||||
{infoMessage && <p className="text-sm text-muted-foreground">{infoMessage}</p>}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="w-full"
|
|
||||||
disabled={form.formState.isSubmitting || !form.formState.isValid}
|
|
||||||
>
|
|
||||||
{form.formState.isSubmitting ? 'Enviando...' : 'Enviar codigo OTP'}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
|
||||||
import {
|
|
||||||
InputOTP,
|
|
||||||
InputOTPGroup,
|
|
||||||
InputOTPSeparator,
|
|
||||||
InputOTPSlot,
|
|
||||||
} from '@/components/ui/input-otp';
|
|
||||||
import type { VerifyOtpValues } from '@/features/onboard/onboarding.types';
|
|
||||||
import { RefreshCw } from 'lucide-react';
|
|
||||||
import type { UseFormReturn } from 'react-hook-form';
|
|
||||||
|
|
||||||
type OnboardingVerifyOtpStepProps = {
|
|
||||||
form: UseFormReturn<VerifyOtpValues>;
|
|
||||||
email: string | null;
|
|
||||||
errorMessage: string | null;
|
|
||||||
infoMessage: string | null;
|
|
||||||
remainingAttempts: number;
|
|
||||||
resendCooldownSeconds: number;
|
|
||||||
isResending: boolean;
|
|
||||||
onSubmit: (values: VerifyOtpValues) => Promise<void>;
|
|
||||||
onResend: () => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function OnboardingVerifyOtpStep({
|
|
||||||
form,
|
|
||||||
email,
|
|
||||||
errorMessage,
|
|
||||||
infoMessage,
|
|
||||||
remainingAttempts,
|
|
||||||
resendCooldownSeconds,
|
|
||||||
isResending,
|
|
||||||
onSubmit,
|
|
||||||
onResend,
|
|
||||||
}: OnboardingVerifyOtpStepProps) {
|
|
||||||
const resendDisabled = resendCooldownSeconds > 0 || isResending;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Ingresa el codigo de 6 digitos que enviamos a{' '}
|
|
||||||
<span className="font-medium text-foreground">{email ?? 'tu email'}</span>.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Field data-invalid={Boolean(form.formState.errors.otp)}>
|
|
||||||
<div className="mb-2 flex items-center justify-between gap-3">
|
|
||||||
<FieldLabel htmlFor="onboard-otp">Codigo de verificacion</FieldLabel>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={resendDisabled}
|
|
||||||
onClick={() => {
|
|
||||||
void onResend();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
|
||||||
{isResending
|
|
||||||
? 'Reenviando...'
|
|
||||||
: resendCooldownSeconds > 0
|
|
||||||
? `Reenviar en ${resendCooldownSeconds}s`
|
|
||||||
: 'Reenviar codigo'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<InputOTP
|
|
||||||
id="onboard-otp"
|
|
||||||
maxLength={6}
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="\d*"
|
|
||||||
value={form.watch('otp')}
|
|
||||||
onChange={(value) => {
|
|
||||||
form.setValue('otp', value, { shouldValidate: true });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<InputOTPGroup>
|
|
||||||
<InputOTPSlot index={0} />
|
|
||||||
<InputOTPSlot index={1} />
|
|
||||||
<InputOTPSlot index={2} />
|
|
||||||
</InputOTPGroup>
|
|
||||||
<InputOTPSeparator />
|
|
||||||
<InputOTPGroup>
|
|
||||||
<InputOTPSlot index={3} />
|
|
||||||
<InputOTPSlot index={4} />
|
|
||||||
<InputOTPSlot index={5} />
|
|
||||||
</InputOTPGroup>
|
|
||||||
</InputOTP>
|
|
||||||
|
|
||||||
<FieldError errors={[form.formState.errors.otp]} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Intentos restantes: <span className="font-medium">{remainingAttempts}</span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
|
||||||
{infoMessage && <p className="text-sm text-muted-foreground">{infoMessage}</p>}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="w-full"
|
|
||||||
disabled={form.formState.isSubmitting || !form.formState.isValid}
|
|
||||||
>
|
|
||||||
{form.formState.isSubmitting ? 'Verificando...' : 'Verificar codigo'}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export function OnboardingVerifyingStep() {
|
|
||||||
return (
|
|
||||||
<p className="text-sm text-muted-foreground">Verificando tu email, espera un momento...</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
161
apps/frontend/src/features/onboard/components/setup-stepper.tsx
Normal file
161
apps/frontend/src/features/onboard/components/setup-stepper.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Stepper,
|
||||||
|
StepperContent,
|
||||||
|
StepperDescription,
|
||||||
|
StepperIndicator,
|
||||||
|
StepperItem,
|
||||||
|
StepperList,
|
||||||
|
StepperNext,
|
||||||
|
StepperPrev,
|
||||||
|
StepperSeparator,
|
||||||
|
StepperTitle,
|
||||||
|
StepperTrigger,
|
||||||
|
} from '@/components/ui/stepper';
|
||||||
|
import { ComplexNameStep } from '@/features/onboard/components/steps/complex-name-step';
|
||||||
|
import { CourtsSetupStep } from '@/features/onboard/components/steps/courts-setup-step';
|
||||||
|
import { LocationStep } from '@/features/onboard/components/steps/location-step';
|
||||||
|
import { PlanSelectStep } from '@/features/onboard/components/steps/plan-select-step';
|
||||||
|
import { ReviewStep } from '@/features/onboard/components/steps/review-step';
|
||||||
|
import type { PlanWithFeatures, Sport } from '@repo/api-contract';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import type { UseFormReturn } from 'react-hook-form';
|
||||||
|
|
||||||
|
type SetupStepperProps = {
|
||||||
|
form: UseFormReturn<any>;
|
||||||
|
plans: PlanWithFeatures[];
|
||||||
|
sports: Sport[];
|
||||||
|
isSubmitting: boolean;
|
||||||
|
onSubmit: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ value: 'step-1', title: 'Nombre', description: 'Nombre del complejo' },
|
||||||
|
{ value: 'step-2', title: 'Ubicación', description: 'Dirección y ciudad' },
|
||||||
|
{ value: 'step-3', title: 'Plan', description: 'Elegí tu plan' },
|
||||||
|
{ value: 'step-4', title: 'Canchas', description: 'Crear cancha inicial' },
|
||||||
|
{ value: 'step-5', title: 'Revisar', description: 'Confirmar datos' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const VALIDATION_MAP: Record<string, string[]> = {
|
||||||
|
'step-2': ['complexName'],
|
||||||
|
'step-3': ['physicalAddress'],
|
||||||
|
'step-4': ['planCode'],
|
||||||
|
'step-5': ['planCode'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SetupStepper({ form, plans, sports, isSubmitting, onSubmit }: SetupStepperProps) {
|
||||||
|
const [currentStep, setCurrentStep] = useState('step-1');
|
||||||
|
const setupCourts = form.watch('setupCourts') as boolean | undefined;
|
||||||
|
|
||||||
|
const handleValidate = useCallback(
|
||||||
|
async (nextValue: string, direction: string) => {
|
||||||
|
if (direction === 'prev') return true;
|
||||||
|
|
||||||
|
let fields = VALIDATION_MAP[nextValue];
|
||||||
|
|
||||||
|
if (nextValue === 'step-5' && setupCourts) {
|
||||||
|
fields = [
|
||||||
|
...(fields ?? []),
|
||||||
|
'courtSportId',
|
||||||
|
'courtStartTime',
|
||||||
|
'courtEndTime',
|
||||||
|
'courtDaysOfWeek',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields && fields.length > 0) {
|
||||||
|
return form.trigger(fields as never[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[form, setupCourts]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedPlan = plans.find((p) => p.code === form.watch('planCode'));
|
||||||
|
const courtSportId = form.watch('courtSportId') as string | undefined;
|
||||||
|
const selectedSport = sports.find((s) => s.id === courtSportId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stepper value={currentStep} onValueChange={setCurrentStep} onValidate={handleValidate}>
|
||||||
|
<StepperList>
|
||||||
|
{STEPS.map((step) => (
|
||||||
|
<StepperItem key={step.value} value={step.value}>
|
||||||
|
<StepperTrigger>
|
||||||
|
<StepperIndicator />
|
||||||
|
<div className="flex flex-col gap-0.5 text-left max-sm:hidden">
|
||||||
|
<StepperTitle>{step.title}</StepperTitle>
|
||||||
|
<StepperDescription>{step.description}</StepperDescription>
|
||||||
|
</div>
|
||||||
|
</StepperTrigger>
|
||||||
|
<StepperSeparator />
|
||||||
|
</StepperItem>
|
||||||
|
))}
|
||||||
|
</StepperList>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<StepperContent value="step-1">
|
||||||
|
<ComplexNameStep form={form} />
|
||||||
|
</StepperContent>
|
||||||
|
|
||||||
|
<StepperContent value="step-2">
|
||||||
|
<LocationStep form={form} />
|
||||||
|
</StepperContent>
|
||||||
|
|
||||||
|
<StepperContent value="step-3">
|
||||||
|
<PlanSelectStep form={form} plans={plans} />
|
||||||
|
</StepperContent>
|
||||||
|
|
||||||
|
<StepperContent value="step-4">
|
||||||
|
<CourtsSetupStep form={form} sports={sports} />
|
||||||
|
</StepperContent>
|
||||||
|
|
||||||
|
<StepperContent value="step-5">
|
||||||
|
<ReviewStep
|
||||||
|
data={{
|
||||||
|
complexName: (form.watch('complexName') as string) ?? '',
|
||||||
|
physicalAddress: (form.watch('physicalAddress') as string) ?? '',
|
||||||
|
city: form.watch('city') as string | undefined,
|
||||||
|
state: form.watch('state') as string | undefined,
|
||||||
|
country: form.watch('country') as string | undefined,
|
||||||
|
planCode: (form.watch('planCode') as string) ?? '',
|
||||||
|
setupCourts: setupCourts ?? false,
|
||||||
|
courtSportName: selectedSport?.name,
|
||||||
|
courtStartTime: (form.watch('courtStartTime') as string) ?? '08:00',
|
||||||
|
courtEndTime: (form.watch('courtEndTime') as string) ?? '22:00',
|
||||||
|
courtDaysOfWeek: form.watch('courtDaysOfWeek') as string[] | undefined,
|
||||||
|
}}
|
||||||
|
selectedPlan={selectedPlan}
|
||||||
|
/>
|
||||||
|
</StepperContent>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex items-center justify-between">
|
||||||
|
<StepperPrev asChild>
|
||||||
|
<Button variant="outline" disabled={isSubmitting}>
|
||||||
|
Anterior
|
||||||
|
</Button>
|
||||||
|
</StepperPrev>
|
||||||
|
|
||||||
|
{currentStep === 'step-5' ? (
|
||||||
|
<Button onClick={onSubmit} disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
Creando complejo...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Confirmar y crear complejo'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<StepperNext asChild>
|
||||||
|
<Button disabled={isSubmitting}>Siguiente</Button>
|
||||||
|
</StepperNext>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Stepper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import type { UseFormReturn } from 'react-hook-form';
|
||||||
|
|
||||||
|
type ComplexNameStepProps = {
|
||||||
|
form: UseFormReturn<any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ComplexNameStep({ form }: ComplexNameStepProps) {
|
||||||
|
const errors = form.formState.errors;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">Nombre del complejo</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">¿Cómo se va a llamar tu complejo?</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field data-invalid={Boolean(errors.complexName)}>
|
||||||
|
<FieldLabel htmlFor="complex-name">Nombre del complejo</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="complex-name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ej: Complejo Las Palmeras"
|
||||||
|
aria-invalid={Boolean(errors.complexName)}
|
||||||
|
{...form.register('complexName')}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.complexName]} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { Field, FieldLabel } from '@/components/ui/field';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { Sport } from '@repo/api-contract';
|
||||||
|
import { Check } from 'lucide-react';
|
||||||
|
import type { UseFormReturn } from 'react-hook-form';
|
||||||
|
|
||||||
|
type CourtsSetupStepProps = {
|
||||||
|
form: UseFormReturn<any>;
|
||||||
|
sports: Sport[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const DAYS_OF_WEEK = [
|
||||||
|
{ value: 'MONDAY', label: 'Lun' },
|
||||||
|
{ value: 'TUESDAY', label: 'Mar' },
|
||||||
|
{ value: 'WEDNESDAY', label: 'Mié' },
|
||||||
|
{ value: 'THURSDAY', label: 'Jue' },
|
||||||
|
{ value: 'FRIDAY', label: 'Vie' },
|
||||||
|
{ value: 'SATURDAY', label: 'Sáb' },
|
||||||
|
{ value: 'SUNDAY', label: 'Dom' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function CourtsSetupStep({ form, sports }: CourtsSetupStepProps) {
|
||||||
|
const setupCourts = form.watch('setupCourts') as boolean | undefined;
|
||||||
|
const courtDaysOfWeek = form.watch('courtDaysOfWeek') as string[] | undefined;
|
||||||
|
const errors = form.formState.errors;
|
||||||
|
|
||||||
|
const toggleDay = (day: string) => {
|
||||||
|
const current = courtDaysOfWeek ?? [];
|
||||||
|
const next = current.includes(day) ? current.filter((d) => d !== day) : [...current, day];
|
||||||
|
form.setValue('courtDaysOfWeek', next, { shouldValidate: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">Crear canchas</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Configurá una cancha para empezar a recibir reservas.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 rounded-lg border p-4 cursor-pointer hover:bg-muted/50 transition-colors">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="size-4 accent-primary"
|
||||||
|
checked={setupCourts ?? false}
|
||||||
|
onChange={(e) => {
|
||||||
|
form.setValue('setupCourts', e.target.checked, { shouldValidate: true });
|
||||||
|
if (!e.target.checked) {
|
||||||
|
form.setValue('courtSportId', undefined);
|
||||||
|
form.setValue('courtStartTime', undefined);
|
||||||
|
form.setValue('courtEndTime', undefined);
|
||||||
|
form.setValue('courtDaysOfWeek', undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">Sí, crear una cancha ahora</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Vas a poder agregar más canchas después desde el panel de administración.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{setupCourts && (
|
||||||
|
<div className="space-y-4 pl-6 border-l-2 border-primary/20">
|
||||||
|
<Field data-invalid={Boolean(errors.courtSportId)}>
|
||||||
|
<FieldLabel htmlFor="court-sport">Deporte</FieldLabel>
|
||||||
|
<select
|
||||||
|
id="court-sport"
|
||||||
|
className="h-10 w-full rounded-md border bg-background px-3 text-sm aria-invalid:border-destructive"
|
||||||
|
aria-invalid={Boolean(errors.courtSportId)}
|
||||||
|
{...form.register('courtSportId')}
|
||||||
|
>
|
||||||
|
<option value="">Seleccioná un deporte</option>
|
||||||
|
{sports.map((sport) => (
|
||||||
|
<option key={sport.id} value={sport.id}>
|
||||||
|
{sport.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{errors.courtSportId && (
|
||||||
|
<p className="text-sm text-destructive mt-1">
|
||||||
|
{String(errors.courtSportId.message ?? '')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field data-invalid={Boolean(errors.courtStartTime)}>
|
||||||
|
<FieldLabel htmlFor="court-start">Horario desde</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="court-start"
|
||||||
|
type="time"
|
||||||
|
defaultValue="08:00"
|
||||||
|
aria-invalid={Boolean(errors.courtStartTime)}
|
||||||
|
{...form.register('courtStartTime')}
|
||||||
|
/>
|
||||||
|
{errors.courtStartTime && (
|
||||||
|
<p className="text-sm text-destructive mt-1">
|
||||||
|
{String(errors.courtStartTime.message ?? '')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field data-invalid={Boolean(errors.courtEndTime)}>
|
||||||
|
<FieldLabel htmlFor="court-end">Horario hasta</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="court-end"
|
||||||
|
type="time"
|
||||||
|
defaultValue="22:00"
|
||||||
|
aria-invalid={Boolean(errors.courtEndTime)}
|
||||||
|
{...form.register('courtEndTime')}
|
||||||
|
/>
|
||||||
|
{errors.courtEndTime && (
|
||||||
|
<p className="text-sm text-destructive mt-1">
|
||||||
|
{String(errors.courtEndTime.message ?? '')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="mb-2 block text-sm">Días de la semana</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{DAYS_OF_WEEK.map((day) => {
|
||||||
|
const isSelected = courtDaysOfWeek?.includes(day.value) ?? false;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={day.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleDay(day.value)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-all',
|
||||||
|
isSelected
|
||||||
|
? 'border-primary bg-primary/10 text-primary'
|
||||||
|
: 'border-border text-muted-foreground hover:border-muted-foreground/30'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isSelected && <Check className="size-3" />}
|
||||||
|
{day.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{errors.courtDaysOfWeek && (
|
||||||
|
<p className="text-sm text-destructive mt-1">
|
||||||
|
{String(errors.courtDaysOfWeek.message ?? '')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import type { UseFormReturn } from 'react-hook-form';
|
||||||
|
|
||||||
|
type LocationStepProps = {
|
||||||
|
form: UseFormReturn<any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LocationStep({ form }: LocationStepProps) {
|
||||||
|
const errors = form.formState.errors;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">Ubicación</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">¿Dónde está ubicado tu complejo?</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field data-invalid={Boolean(errors.physicalAddress)}>
|
||||||
|
<FieldLabel htmlFor="physical-address">Dirección</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="physical-address"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ej: Av. San Martín 1234"
|
||||||
|
aria-invalid={Boolean(errors.physicalAddress)}
|
||||||
|
{...form.register('physicalAddress')}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.physicalAddress]} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field data-invalid={Boolean(errors.city)}>
|
||||||
|
<FieldLabel htmlFor="city">Ciudad</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="city"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ej: Salta"
|
||||||
|
aria-invalid={Boolean(errors.city)}
|
||||||
|
{...form.register('city')}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.city]} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field data-invalid={Boolean(errors.state)}>
|
||||||
|
<FieldLabel htmlFor="state">Provincia / Estado</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="state"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ej: Salta"
|
||||||
|
aria-invalid={Boolean(errors.state)}
|
||||||
|
{...form.register('state')}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.state]} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field data-invalid={Boolean(errors.country)}>
|
||||||
|
<FieldLabel htmlFor="country">País</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="country"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ej: Argentina"
|
||||||
|
aria-invalid={Boolean(errors.country)}
|
||||||
|
{...form.register('country')}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.country]} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { PlanWithFeatures } from '@repo/api-contract';
|
||||||
|
import { Check, X } from 'lucide-react';
|
||||||
|
import type { UseFormReturn } from 'react-hook-form';
|
||||||
|
|
||||||
|
type PlanSelectStepProps = {
|
||||||
|
form: UseFormReturn<any>;
|
||||||
|
plans: PlanWithFeatures[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const FEATURE_LABELS: Record<string, string> = {
|
||||||
|
publicBookingPage: 'Página de booking pública',
|
||||||
|
onlinePayments: 'Pagos online',
|
||||||
|
advancedReports: 'Reportes avanzados',
|
||||||
|
whatsappReminders: 'Recordatorios WhatsApp',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PlanSelectStep({ form, plans }: PlanSelectStepProps) {
|
||||||
|
const selectedCode = form.watch('planCode') as string | undefined;
|
||||||
|
const errors = form.formState.errors;
|
||||||
|
|
||||||
|
const featuresList = [
|
||||||
|
'publicBookingPage',
|
||||||
|
'onlinePayments',
|
||||||
|
'advancedReports',
|
||||||
|
'whatsappReminders',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">Elegí tu plan</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Seleccioná el plan que mejor se adapte a tu complejo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
{plans.map((plan) => {
|
||||||
|
const isSelected = selectedCode === plan.code;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={plan.code}
|
||||||
|
type="button"
|
||||||
|
onClick={() => form.setValue('planCode', plan.code, { shouldValidate: true })}
|
||||||
|
className={cn(
|
||||||
|
'relative flex flex-col rounded-xl border-2 p-5 text-left transition-all',
|
||||||
|
'hover:border-primary/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
isSelected ? 'border-primary bg-primary/5 shadow-sm' : 'border-border bg-card'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isSelected && (
|
||||||
|
<div className="absolute right-3 top-3 flex size-6 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||||
|
<Check className="size-4" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<h3 className="text-xl font-bold">{plan.name}</h3>
|
||||||
|
<div className="mt-1">
|
||||||
|
<span className="text-3xl font-bold">${plan.price}</span>
|
||||||
|
<span className="text-sm text-muted-foreground">/mes</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 space-y-1.5 border-t pt-3 text-xs text-muted-foreground">
|
||||||
|
<p className="font-medium text-foreground">Incluye:</p>
|
||||||
|
<p>Hasta {plan.limits.maxCourts} canchas</p>
|
||||||
|
<p>Hasta {plan.limits.maxBookingsPerDay} reservas/día</p>
|
||||||
|
{plan.limits.maxActiveUsers && <p>Hasta {plan.limits.maxActiveUsers} usuarios</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5 border-t pt-3">
|
||||||
|
{featuresList.map((feature) => {
|
||||||
|
const enabled = plan.features[feature as keyof typeof plan.features];
|
||||||
|
return (
|
||||||
|
<div key={feature} className="flex items-center gap-2 text-xs">
|
||||||
|
{enabled ? (
|
||||||
|
<Check className="size-3.5 shrink-0 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<X className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span className={enabled ? 'text-foreground' : 'text-muted-foreground'}>
|
||||||
|
{FEATURE_LABELS[feature]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errors.planCode && (
|
||||||
|
<p className="text-sm text-destructive">{String(errors.planCode.message ?? '')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import type { PlanWithFeatures } from '@repo/api-contract';
|
||||||
|
import { Check, X } from 'lucide-react';
|
||||||
|
|
||||||
|
type ReviewStepProps = {
|
||||||
|
data: {
|
||||||
|
complexName: string;
|
||||||
|
physicalAddress: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
country?: string;
|
||||||
|
planCode: string;
|
||||||
|
setupCourts: boolean;
|
||||||
|
courtSportName?: string;
|
||||||
|
courtStartTime?: string;
|
||||||
|
courtEndTime?: string;
|
||||||
|
courtDaysOfWeek?: string[];
|
||||||
|
};
|
||||||
|
selectedPlan: PlanWithFeatures | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DAY_LABELS: Record<string, string> = {
|
||||||
|
MONDAY: 'Lunes',
|
||||||
|
TUESDAY: 'Martes',
|
||||||
|
WEDNESDAY: 'Miércoles',
|
||||||
|
THURSDAY: 'Jueves',
|
||||||
|
FRIDAY: 'Viernes',
|
||||||
|
SATURDAY: 'Sábado',
|
||||||
|
SUNDAY: 'Domingo',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ReviewStep({ data, selectedPlan }: ReviewStepProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">Revisá tus datos</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Antes de confirmar, revisá que todos los datos sean correctos.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 rounded-lg border p-4">
|
||||||
|
<Section title="Complejo">
|
||||||
|
<Row label="Nombre" value={data.complexName} />
|
||||||
|
<Row label="Dirección" value={data.physicalAddress} />
|
||||||
|
{data.city && <Row label="Ciudad" value={data.city} />}
|
||||||
|
{data.state && <Row label="Provincia" value={data.state} />}
|
||||||
|
{data.country && <Row label="País" value={data.country} />}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Plan seleccionado">
|
||||||
|
{selectedPlan ? (
|
||||||
|
<>
|
||||||
|
<Row label="Plan" value={`${selectedPlan.name} - $${selectedPlan.price}/mes`} />
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{[
|
||||||
|
['publicBookingPage', 'Página de booking pública'],
|
||||||
|
['onlinePayments', 'Pagos online'],
|
||||||
|
['advancedReports', 'Reportes avanzados'],
|
||||||
|
['whatsappReminders', 'Recordatorios WhatsApp'],
|
||||||
|
].map(([feature, label]) => {
|
||||||
|
const enabled =
|
||||||
|
selectedPlan.features[feature as keyof typeof selectedPlan.features];
|
||||||
|
return (
|
||||||
|
<div key={feature} className="flex items-center gap-2 text-xs">
|
||||||
|
{enabled ? (
|
||||||
|
<Check className="size-3.5 shrink-0 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<X className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span className={enabled ? '' : 'text-muted-foreground'}>{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No se seleccionó un plan.</p>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Cancha">
|
||||||
|
{data.setupCourts ? (
|
||||||
|
<>
|
||||||
|
<Row label="Deporte" value={data.courtSportName ?? '-'} />
|
||||||
|
<Row
|
||||||
|
label="Horario"
|
||||||
|
value={`${data.courtStartTime ?? '08:00'} - ${data.courtEndTime ?? '22:00'}`}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
label="Días"
|
||||||
|
value={(data.courtDaysOfWeek ?? []).map((d) => DAY_LABELS[d] ?? d).join(', ')}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No se crearán canchas automáticamente. Podés agregarlas después.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-2 text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-1.5">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between gap-4 text-sm">
|
||||||
|
<span className="shrink-0 text-muted-foreground">{label}</span>
|
||||||
|
<span className="flex-1 text-end font-medium">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { apiClient } from '@/lib/api-client';
|
|
||||||
import { useAuth } from '@/lib/auth';
|
|
||||||
import { setCurrentComplexSlug } from '@/lib/current-complex';
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import type { PlanSummary } from '@repo/api-contract';
|
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
const createComplexSchema = z.object({
|
|
||||||
complexName: z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.min(3, 'El nombre del complejo debe tener al menos 3 caracteres.')
|
|
||||||
.max(120, 'El nombre del complejo no puede superar los 120 caracteres.'),
|
|
||||||
physicalAddress: z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.min(5, 'La direccion debe tener al menos 5 caracteres.')
|
|
||||||
.max(200, 'La direccion no puede superar los 200 caracteres.'),
|
|
||||||
city: z.string().trim().max(100, 'La ciudad no puede superar los 100 caracteres.').optional(),
|
|
||||||
state: z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.max(100, 'La provincia/estado no puede superar los 100 caracteres.')
|
|
||||||
.optional(),
|
|
||||||
country: z.string().trim().max(100, 'El país no puede superar los 100 caracteres.').optional(),
|
|
||||||
planCode: z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.min(1, 'El planCode no puede estar vacío.')
|
|
||||||
.max(10, 'El planCode no puede superar los 10 caracteres.')
|
|
||||||
.optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type CreateComplexForm = z.infer<typeof createComplexSchema>;
|
|
||||||
|
|
||||||
export function CreateComplexPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { user } = useAuth();
|
|
||||||
const [plans, setPlans] = useState<PlanSummary[]>([]);
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const form = useForm<CreateComplexForm>({
|
|
||||||
resolver: zodResolver(createComplexSchema),
|
|
||||||
mode: 'onChange',
|
|
||||||
defaultValues: {
|
|
||||||
complexName: '',
|
|
||||||
physicalAddress: '',
|
|
||||||
city: '',
|
|
||||||
state: '',
|
|
||||||
country: '',
|
|
||||||
planCode: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const result = await apiClient.plans.list();
|
|
||||||
setPlans(result);
|
|
||||||
} catch {
|
|
||||||
setPlans([]);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onSubmit = async (values: CreateComplexForm) => {
|
|
||||||
setErrorMessage(null);
|
|
||||||
setIsSubmitting(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const pendingEmail = sessionStorage.getItem('pending-signup-email');
|
|
||||||
const payload: Parameters<typeof apiClient.complexes.create>[0] = {
|
|
||||||
complexName: values.complexName,
|
|
||||||
physicalAddress: values.physicalAddress,
|
|
||||||
city: values.city,
|
|
||||||
state: values.state,
|
|
||||||
country: values.country,
|
|
||||||
planCode: values.planCode,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (pendingEmail) {
|
|
||||||
payload.adminEmail = pendingEmail;
|
|
||||||
}
|
|
||||||
|
|
||||||
const complex = await apiClient.complexes.create(payload);
|
|
||||||
|
|
||||||
sessionStorage.removeItem('pending-signup-email');
|
|
||||||
sessionStorage.removeItem('pending-signup-userId');
|
|
||||||
setCurrentComplexSlug(complex.complexSlug);
|
|
||||||
await navigate({
|
|
||||||
to: '/complex/$slug/edit',
|
|
||||||
params: { slug: complex.complexSlug },
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
setErrorMessage('No pudimos crear el complejo. Intenta nuevamente.');
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-screen w-full items-center justify-center px-3 sm:px-6">
|
|
||||||
<section className="w-full max-w-sm rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
|
||||||
<h1 className="mb-2 text-2xl font-semibold">Crear tu complejo</h1>
|
|
||||||
<p className="mb-4 text-sm text-muted-foreground">
|
|
||||||
Completá los datos de tu complejo para comenzar.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form className="space-y-3" onSubmit={form.handleSubmit(onSubmit)}>
|
|
||||||
{user?.email && (
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="user-email">Email</FieldLabel>
|
|
||||||
<Input id="user-email" type="email" value={user.email} disabled readOnly />
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Field data-invalid={Boolean(form.formState.errors.complexName)}>
|
|
||||||
<FieldLabel htmlFor="complexName">Nombre del complejo</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="complexName"
|
|
||||||
type="text"
|
|
||||||
placeholder="Complejo Las Palmeras"
|
|
||||||
aria-invalid={Boolean(form.formState.errors.complexName)}
|
|
||||||
{...form.register('complexName')}
|
|
||||||
/>
|
|
||||||
<FieldError errors={[form.formState.errors.complexName]} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field data-invalid={Boolean(form.formState.errors.physicalAddress)}>
|
|
||||||
<FieldLabel htmlFor="physicalAddress">Dirección física</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="physicalAddress"
|
|
||||||
type="text"
|
|
||||||
placeholder="Av. San Martin 1234, Salta"
|
|
||||||
aria-invalid={Boolean(form.formState.errors.physicalAddress)}
|
|
||||||
{...form.register('physicalAddress')}
|
|
||||||
/>
|
|
||||||
<FieldError errors={[form.formState.errors.physicalAddress]} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field data-invalid={Boolean(form.formState.errors.city)}>
|
|
||||||
<FieldLabel htmlFor="city">Ciudad</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="city"
|
|
||||||
type="text"
|
|
||||||
placeholder="Salta"
|
|
||||||
aria-invalid={Boolean(form.formState.errors.city)}
|
|
||||||
{...form.register('city')}
|
|
||||||
/>
|
|
||||||
<FieldError errors={[form.formState.errors.city]} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field data-invalid={Boolean(form.formState.errors.state)}>
|
|
||||||
<FieldLabel htmlFor="state">Provincia/Estado</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="state"
|
|
||||||
type="text"
|
|
||||||
placeholder="Salta"
|
|
||||||
aria-invalid={Boolean(form.formState.errors.state)}
|
|
||||||
{...form.register('state')}
|
|
||||||
/>
|
|
||||||
<FieldError errors={[form.formState.errors.state]} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field data-invalid={Boolean(form.formState.errors.country)}>
|
|
||||||
<FieldLabel htmlFor="country">País</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="country"
|
|
||||||
type="text"
|
|
||||||
placeholder="Argentina"
|
|
||||||
aria-invalid={Boolean(form.formState.errors.country)}
|
|
||||||
{...form.register('country')}
|
|
||||||
/>
|
|
||||||
<FieldError errors={[form.formState.errors.country]} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field data-invalid={Boolean(form.formState.errors.planCode)}>
|
|
||||||
<FieldLabel htmlFor="planCode">Plan</FieldLabel>
|
|
||||||
<select
|
|
||||||
id="planCode"
|
|
||||||
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
|
|
||||||
aria-invalid={Boolean(form.formState.errors.planCode)}
|
|
||||||
{...form.register('planCode')}
|
|
||||||
>
|
|
||||||
<option value="">
|
|
||||||
{plans.length > 0 ? 'Selecciona un plan' : 'No hay planes disponibles'}
|
|
||||||
</option>
|
|
||||||
{plans.map((plan) => (
|
|
||||||
<option key={plan.code} value={plan.code}>
|
|
||||||
{plan.name} - ${plan.price.toFixed(2)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{plans.length === 0 && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Carga planes en la base para continuar.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<FieldError errors={[form.formState.errors.planCode]} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="w-full"
|
|
||||||
disabled={isSubmitting || !form.formState.isValid}
|
|
||||||
>
|
|
||||||
{isSubmitting ? 'Creando complejo...' : 'Crear complejo'}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import { OnboardingCompleteStep } from '@/features/onboard/components/onboarding-complete-step';
|
|
||||||
import { OnboardingLayout } from '@/features/onboard/components/onboarding-layout';
|
|
||||||
import {
|
|
||||||
clearOnboardingSessionState,
|
|
||||||
getOnboardingSessionState,
|
|
||||||
updateOnboardingSessionState,
|
|
||||||
} from '@/features/onboard/onboarding-session';
|
|
||||||
import type { CompleteValues } from '@/features/onboard/onboarding.types';
|
|
||||||
import { apiClient } from '@/lib/api-client';
|
|
||||||
import { useAuth } from '@/lib/auth';
|
|
||||||
import { setCurrentComplexSlug } from '@/lib/current-complex';
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { type PlanSummary, onboardingCompleteSchema } from '@repo/api-contract';
|
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import { useForm, useWatch } from 'react-hook-form';
|
|
||||||
|
|
||||||
export function OnboardCompletePage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { signInWithPassword } = useAuth();
|
|
||||||
const initialState = useMemo(() => getOnboardingSessionState(), []);
|
|
||||||
|
|
||||||
const [requestId] = useState<string | null>(initialState.requestId);
|
|
||||||
const [verifiedEmail] = useState<string | null>(initialState.verifiedEmail);
|
|
||||||
const [onboardingEmail] = useState<string | null>(initialState.onboardingEmail);
|
|
||||||
const [plans, setPlans] = useState<PlanSummary[]>([]);
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const form = useForm<CompleteValues>({
|
|
||||||
resolver: zodResolver(onboardingCompleteSchema.omit({ onboardingRequestId: true })),
|
|
||||||
mode: 'onChange',
|
|
||||||
defaultValues: initialState.completeForm,
|
|
||||||
});
|
|
||||||
const values = useWatch({ control: form.control });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!requestId) {
|
|
||||||
void navigate({ to: '/onboard' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!verifiedEmail) {
|
|
||||||
void navigate({ to: '/onboard/verify' });
|
|
||||||
}
|
|
||||||
}, [navigate, requestId, verifiedEmail]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
updateOnboardingSessionState((current) => ({
|
|
||||||
...current,
|
|
||||||
completeForm: {
|
|
||||||
password: values.password ?? '',
|
|
||||||
complexName: values.complexName ?? '',
|
|
||||||
physicalAddress: values.physicalAddress ?? '',
|
|
||||||
planCode: values.planCode ?? '',
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}, [values.complexName, values.password, values.physicalAddress, values.planCode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const result = await apiClient.plans.list();
|
|
||||||
setPlans(result);
|
|
||||||
} catch {
|
|
||||||
setPlans([]);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onSubmit = async (payload: CompleteValues) => {
|
|
||||||
if (!requestId) {
|
|
||||||
setErrorMessage('No encontramos una solicitud de onboarding activa.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrorMessage(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await apiClient.onboarding.complete({
|
|
||||||
onboardingRequestId: requestId,
|
|
||||||
password: payload.password,
|
|
||||||
complexName: payload.complexName,
|
|
||||||
physicalAddress: payload.physicalAddress,
|
|
||||||
planCode: payload.planCode,
|
|
||||||
});
|
|
||||||
|
|
||||||
setCurrentComplexSlug(result.complexSlug);
|
|
||||||
const emailForSignIn = verifiedEmail ?? onboardingEmail;
|
|
||||||
if (emailForSignIn) {
|
|
||||||
await signInWithPassword({ email: emailForSignIn, password: payload.password });
|
|
||||||
}
|
|
||||||
|
|
||||||
clearOnboardingSessionState();
|
|
||||||
await navigate({ to: '/complex/$slug/edit', params: { slug: result.complexSlug } });
|
|
||||||
} catch {
|
|
||||||
setErrorMessage('No pudimos completar el onboarding. Intenta nuevamente.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const planPlaceholder =
|
|
||||||
plans.length === 0 ? 'Codigo del plan (por ejemplo: BASIC)' : 'Selecciona un plan';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<OnboardingLayout
|
|
||||||
title="Completa tu onboarding"
|
|
||||||
description="Define tu contraseña y configura tu complejo."
|
|
||||||
>
|
|
||||||
<OnboardingCompleteStep
|
|
||||||
form={form}
|
|
||||||
plans={plans}
|
|
||||||
verifiedEmail={verifiedEmail}
|
|
||||||
errorMessage={errorMessage}
|
|
||||||
infoMessage={null}
|
|
||||||
planPlaceholder={planPlaceholder}
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
/>
|
|
||||||
</OnboardingLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,317 +0,0 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { apiClient, authClient } from '@/lib/api-client';
|
|
||||||
import { useAuth } from '@/lib/auth';
|
|
||||||
import { acceptStoredPendingInvite } from '@/lib/invitations';
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
const loginSchema = z.object({
|
|
||||||
email: z.string().email('Ingresá un email válido.'),
|
|
||||||
password: z.string().min(6, 'La contraseña debe tener al menos 6 caracteres.'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const signupSchema = z
|
|
||||||
.object({
|
|
||||||
fullName: z.string().min(2, 'El nombre debe tener al menos 2 caracteres.'),
|
|
||||||
email: z.string().email('Ingresá un email válido.'),
|
|
||||||
password: z.string().min(6, 'La contraseña debe tener al menos 6 caracteres.'),
|
|
||||||
confirmPassword: z.string(),
|
|
||||||
})
|
|
||||||
.refine((data) => data.password === data.confirmPassword, {
|
|
||||||
message: 'Las contraseñas no coinciden.',
|
|
||||||
path: ['confirmPassword'],
|
|
||||||
});
|
|
||||||
|
|
||||||
type LoginForm = z.infer<typeof loginSchema>;
|
|
||||||
type SignupForm = z.infer<typeof signupSchema>;
|
|
||||||
|
|
||||||
type OnboardLoginPageProps = {
|
|
||||||
variant?: 'onboard' | 'invite';
|
|
||||||
inviteEmail?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function OnboardLoginPage({
|
|
||||||
variant = 'onboard',
|
|
||||||
inviteEmail = null,
|
|
||||||
}: OnboardLoginPageProps) {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { signInWithPassword, signUp } = useAuth();
|
|
||||||
const [mode, setMode] = useState<'login' | 'signup'>(variant === 'invite' ? 'signup' : 'login');
|
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const loginForm = useForm<LoginForm>({
|
|
||||||
resolver: zodResolver(loginSchema),
|
|
||||||
mode: 'onChange',
|
|
||||||
defaultValues: { email: inviteEmail ?? '', password: '' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const signupForm = useForm<SignupForm>({
|
|
||||||
resolver: zodResolver(signupSchema),
|
|
||||||
mode: 'onChange',
|
|
||||||
defaultValues: {
|
|
||||||
fullName: '',
|
|
||||||
email: inviteEmail ?? '',
|
|
||||||
password: '',
|
|
||||||
confirmPassword: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handlePostLogin() {
|
|
||||||
await acceptStoredPendingInvite(apiClient.complexes.acceptPendingInvitations);
|
|
||||||
|
|
||||||
if (variant === 'invite') {
|
|
||||||
await navigate({ to: '/' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const complexes = await apiClient.complexes.listMine();
|
|
||||||
|
|
||||||
if (complexes.length === 0) {
|
|
||||||
navigate({ to: '/onboard/create-complex' });
|
|
||||||
} else if (complexes.length === 1) {
|
|
||||||
await apiClient.complexes.select({ complexId: complexes[0].id });
|
|
||||||
navigate({ to: '/' });
|
|
||||||
} else {
|
|
||||||
navigate({ to: '/select-complex' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onLoginSubmit = async (values: LoginForm) => {
|
|
||||||
setSubmitError(null);
|
|
||||||
setIsSubmitting(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await signInWithPassword(values);
|
|
||||||
await handlePostLogin();
|
|
||||||
} catch {
|
|
||||||
setSubmitError('No pudimos iniciar sesión. Verificá tus credenciales.');
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSignupSubmit = async (values: SignupForm) => {
|
|
||||||
setSubmitError(null);
|
|
||||||
setIsSubmitting(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await signUp({
|
|
||||||
email: values.email,
|
|
||||||
password: values.password,
|
|
||||||
fullName: values.fullName,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.requiresEmailConfirmation) {
|
|
||||||
sessionStorage.setItem('pending-signup-email', values.email);
|
|
||||||
navigate({
|
|
||||||
to: '/onboard/verify-email',
|
|
||||||
search: { email: values.email },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
if (variant === 'invite') {
|
|
||||||
await acceptStoredPendingInvite(apiClient.complexes.acceptPendingInvitations);
|
|
||||||
await navigate({ to: '/' });
|
|
||||||
} else {
|
|
||||||
await handlePostLogin();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setSubmitError('No pudimos crear la cuenta. Intenta nuevamente.');
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGoogleSignIn = async () => {
|
|
||||||
setSubmitError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await authClient.signIn.social({
|
|
||||||
provider: 'google',
|
|
||||||
callbackURL: `${window.location.origin}/auth-callback`,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
setSubmitError('No pudimos iniciar sesión con Google.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-screen w-full items-center justify-center px-3 sm:px-6">
|
|
||||||
<section className="w-full max-w-sm rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
|
||||||
<h1 className="mb-2 text-2xl font-semibold">
|
|
||||||
{mode === 'login' ? 'Iniciar sesión' : 'Crear cuenta'}
|
|
||||||
</h1>
|
|
||||||
<p className="mb-4 text-sm text-muted-foreground">
|
|
||||||
{mode === 'login'
|
|
||||||
? variant === 'invite'
|
|
||||||
? 'Ingresá con tu cuenta para aceptar la invitación.'
|
|
||||||
: 'Ingresá con tu cuenta para continuar.'
|
|
||||||
: variant === 'invite'
|
|
||||||
? 'Creá tu cuenta para aceptar la invitación.'
|
|
||||||
: 'Creá tu cuenta para comenzar.'}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button type="button" variant="outline" className="w-full" onClick={handleGoogleSignIn}>
|
|
||||||
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Continuar con Google
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="relative my-4">
|
|
||||||
<div className="absolute inset-0 flex items-center">
|
|
||||||
<span className="w-full border-t" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center text-xs uppercase">
|
|
||||||
<span className="bg-card px-2 text-muted-foreground">O</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{mode === 'login' ? (
|
|
||||||
<form className="space-y-3" onSubmit={loginForm.handleSubmit(onLoginSubmit)}>
|
|
||||||
<Field data-invalid={Boolean(loginForm.formState.errors.email)}>
|
|
||||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
placeholder="tu@email.com"
|
|
||||||
aria-invalid={Boolean(loginForm.formState.errors.email)}
|
|
||||||
{...loginForm.register('email')}
|
|
||||||
/>
|
|
||||||
<FieldError errors={[loginForm.formState.errors.email]} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field data-invalid={Boolean(loginForm.formState.errors.password)}>
|
|
||||||
<FieldLabel htmlFor="password">Contraseña</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
placeholder="••••••••"
|
|
||||||
aria-invalid={Boolean(loginForm.formState.errors.password)}
|
|
||||||
{...loginForm.register('password')}
|
|
||||||
/>
|
|
||||||
<FieldError errors={[loginForm.formState.errors.password]} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
{submitError && <p className="text-sm text-destructive">{submitError}</p>}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="w-full"
|
|
||||||
disabled={!loginForm.formState.isValid || isSubmitting}
|
|
||||||
>
|
|
||||||
{isSubmitting ? 'Ingresando...' : 'Ingresar'}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<form className="space-y-3" onSubmit={signupForm.handleSubmit(onSignupSubmit)}>
|
|
||||||
<Field data-invalid={Boolean(signupForm.formState.errors.fullName)}>
|
|
||||||
<FieldLabel htmlFor="fullName">Nombre completo</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="fullName"
|
|
||||||
type="text"
|
|
||||||
placeholder="Juan Pérez"
|
|
||||||
aria-invalid={Boolean(signupForm.formState.errors.fullName)}
|
|
||||||
{...signupForm.register('fullName')}
|
|
||||||
/>
|
|
||||||
<FieldError errors={[signupForm.formState.errors.fullName]} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field data-invalid={Boolean(signupForm.formState.errors.email)}>
|
|
||||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
placeholder="tu@email.com"
|
|
||||||
aria-invalid={Boolean(signupForm.formState.errors.email)}
|
|
||||||
{...signupForm.register('email')}
|
|
||||||
/>
|
|
||||||
<FieldError errors={[signupForm.formState.errors.email]} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field data-invalid={Boolean(signupForm.formState.errors.password)}>
|
|
||||||
<FieldLabel htmlFor="password">Contraseña</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
placeholder="••••••••"
|
|
||||||
aria-invalid={Boolean(signupForm.formState.errors.password)}
|
|
||||||
{...signupForm.register('password')}
|
|
||||||
/>
|
|
||||||
<FieldError errors={[signupForm.formState.errors.password]} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field data-invalid={Boolean(signupForm.formState.errors.confirmPassword)}>
|
|
||||||
<FieldLabel htmlFor="confirmPassword">Confirmar contraseña</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="confirmPassword"
|
|
||||||
type="password"
|
|
||||||
placeholder="••••••••"
|
|
||||||
aria-invalid={Boolean(signupForm.formState.errors.confirmPassword)}
|
|
||||||
{...signupForm.register('confirmPassword')}
|
|
||||||
/>
|
|
||||||
<FieldError errors={[signupForm.formState.errors.confirmPassword]} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
{submitError && <p className="text-sm text-destructive">{submitError}</p>}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="w-full"
|
|
||||||
disabled={!signupForm.formState.isValid || isSubmitting}
|
|
||||||
>
|
|
||||||
{isSubmitting ? 'Creando cuenta...' : 'Crear cuenta'}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
|
||||||
{mode === 'login' ? (
|
|
||||||
<>
|
|
||||||
¿No tenés cuenta?{' '}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-primary hover:underline"
|
|
||||||
onClick={() => setMode('signup')}
|
|
||||||
>
|
|
||||||
Crear cuenta
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
¿Ya tenés cuenta?{' '}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-primary hover:underline"
|
|
||||||
onClick={() => setMode('login')}
|
|
||||||
>
|
|
||||||
Iniciar sesión
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import { OnboardingLayout } from '@/features/onboard/components/onboarding-layout';
|
|
||||||
import { OnboardingStartStep } from '@/features/onboard/components/onboarding-start-step';
|
|
||||||
import {
|
|
||||||
OTP_MAX_ATTEMPTS,
|
|
||||||
defaultCompleteFormValues,
|
|
||||||
defaultVerifyOtpFormValues,
|
|
||||||
getOnboardingSessionState,
|
|
||||||
updateOnboardingSessionState,
|
|
||||||
} from '@/features/onboard/onboarding-session';
|
|
||||||
import type { StartValues } from '@/features/onboard/onboarding.types';
|
|
||||||
import { apiClient } from '@/lib/api-client';
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { onboardingStartSchema } from '@repo/api-contract';
|
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import { useForm, useWatch } from 'react-hook-form';
|
|
||||||
|
|
||||||
export function OnboardStartPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
||||||
const [infoMessage, setInfoMessage] = useState<string | null>(null);
|
|
||||||
const initialState = useMemo(() => getOnboardingSessionState(), []);
|
|
||||||
|
|
||||||
const form = useForm<StartValues>({
|
|
||||||
resolver: zodResolver(onboardingStartSchema),
|
|
||||||
mode: 'onChange',
|
|
||||||
defaultValues: initialState.startForm,
|
|
||||||
});
|
|
||||||
const values = useWatch({ control: form.control });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
updateOnboardingSessionState((current) => ({
|
|
||||||
...current,
|
|
||||||
startForm: {
|
|
||||||
fullName: values.fullName ?? '',
|
|
||||||
email: values.email ?? '',
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}, [values.email, values.fullName]);
|
|
||||||
|
|
||||||
const onSubmit = async (payload: StartValues) => {
|
|
||||||
setErrorMessage(null);
|
|
||||||
setInfoMessage(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await apiClient.onboarding.start(payload);
|
|
||||||
|
|
||||||
updateOnboardingSessionState((current) => ({
|
|
||||||
...current,
|
|
||||||
requestId: result.requestId,
|
|
||||||
onboardingEmail: result.email,
|
|
||||||
verifiedEmail: null,
|
|
||||||
remainingAttempts: OTP_MAX_ATTEMPTS,
|
|
||||||
resendCooldownSeconds: result.cooldownSeconds,
|
|
||||||
startForm: {
|
|
||||||
fullName: payload.fullName,
|
|
||||||
email: payload.email,
|
|
||||||
},
|
|
||||||
verifyOtpForm: defaultVerifyOtpFormValues,
|
|
||||||
completeForm: defaultCompleteFormValues,
|
|
||||||
}));
|
|
||||||
|
|
||||||
await navigate({ to: '/onboard/verify' });
|
|
||||||
} catch {
|
|
||||||
setErrorMessage('No pudimos iniciar el onboarding. Intenta nuevamente.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<OnboardingLayout title="Onboarding" description="Ingresa tu nombre y email para comenzar.">
|
|
||||||
<OnboardingStartStep
|
|
||||||
form={form}
|
|
||||||
errorMessage={errorMessage}
|
|
||||||
infoMessage={infoMessage}
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
/>
|
|
||||||
</OnboardingLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { OnboardingLayout } from '@/features/onboard/components/onboarding-layout';
|
|
||||||
import { OnboardingVerifyOtpStep } from '@/features/onboard/components/onboarding-verify-otp-step';
|
|
||||||
import {
|
|
||||||
OTP_MAX_ATTEMPTS,
|
|
||||||
getOnboardingSessionState,
|
|
||||||
updateOnboardingSessionState,
|
|
||||||
} from '@/features/onboard/onboarding-session';
|
|
||||||
import type { VerifyOtpValues } from '@/features/onboard/onboarding.types';
|
|
||||||
import { apiClient } from '@/lib/api-client';
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { onboardingVerifyOtpSchema } from '@repo/api-contract';
|
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import { useForm, useWatch } from 'react-hook-form';
|
|
||||||
|
|
||||||
export function OnboardVerifyPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const initialState = useMemo(() => getOnboardingSessionState(), []);
|
|
||||||
const [requestId] = useState<string | null>(initialState.requestId);
|
|
||||||
const [onboardingEmail, setOnboardingEmail] = useState<string | null>(
|
|
||||||
initialState.onboardingEmail
|
|
||||||
);
|
|
||||||
const [remainingAttempts, setRemainingAttempts] = useState<number>(
|
|
||||||
initialState.remainingAttempts || OTP_MAX_ATTEMPTS
|
|
||||||
);
|
|
||||||
const [resendCooldownSeconds, setResendCooldownSeconds] = useState<number>(
|
|
||||||
initialState.resendCooldownSeconds || 0
|
|
||||||
);
|
|
||||||
const [isResending, setIsResending] = useState(false);
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
||||||
const [infoMessage, setInfoMessage] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const form = useForm<VerifyOtpValues>({
|
|
||||||
resolver: zodResolver(onboardingVerifyOtpSchema.omit({ requestId: true })),
|
|
||||||
mode: 'onChange',
|
|
||||||
defaultValues: initialState.verifyOtpForm,
|
|
||||||
});
|
|
||||||
const values = useWatch({ control: form.control });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (requestId && onboardingEmail) return;
|
|
||||||
void navigate({ to: '/onboard' });
|
|
||||||
}, [navigate, onboardingEmail, requestId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
updateOnboardingSessionState((current) => ({
|
|
||||||
...current,
|
|
||||||
onboardingEmail,
|
|
||||||
remainingAttempts,
|
|
||||||
resendCooldownSeconds,
|
|
||||||
verifyOtpForm: {
|
|
||||||
otp: values.otp ?? '',
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}, [onboardingEmail, remainingAttempts, resendCooldownSeconds, values.otp]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (resendCooldownSeconds <= 0) return;
|
|
||||||
|
|
||||||
const timeout = window.setTimeout(() => {
|
|
||||||
setResendCooldownSeconds((current) => Math.max(0, current - 1));
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.clearTimeout(timeout);
|
|
||||||
};
|
|
||||||
}, [resendCooldownSeconds]);
|
|
||||||
|
|
||||||
const onSubmit = async (payload: VerifyOtpValues) => {
|
|
||||||
if (!requestId) {
|
|
||||||
setErrorMessage('No encontramos una solicitud de onboarding activa.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrorMessage(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await apiClient.onboarding.verifyOtp({
|
|
||||||
requestId,
|
|
||||||
otp: payload.otp,
|
|
||||||
});
|
|
||||||
|
|
||||||
setRemainingAttempts(result.remainingAttempts);
|
|
||||||
setResendCooldownSeconds(result.cooldownSeconds);
|
|
||||||
setInfoMessage(result.message);
|
|
||||||
|
|
||||||
updateOnboardingSessionState((current) => ({
|
|
||||||
...current,
|
|
||||||
remainingAttempts: result.remainingAttempts,
|
|
||||||
resendCooldownSeconds: result.cooldownSeconds,
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (result.verified) {
|
|
||||||
const nextVerifiedEmail = result.email ?? onboardingEmail;
|
|
||||||
|
|
||||||
updateOnboardingSessionState((current) => ({
|
|
||||||
...current,
|
|
||||||
verifiedEmail: nextVerifiedEmail,
|
|
||||||
remainingAttempts: result.remainingAttempts,
|
|
||||||
resendCooldownSeconds: result.cooldownSeconds,
|
|
||||||
}));
|
|
||||||
|
|
||||||
await navigate({ to: '/onboard/complete' });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setErrorMessage('No pudimos verificar el OTP. Intenta nuevamente.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onResend = async () => {
|
|
||||||
if (!requestId) {
|
|
||||||
setErrorMessage('No encontramos una solicitud de onboarding activa.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsResending(true);
|
|
||||||
setErrorMessage(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await apiClient.onboarding.resendOtp({ requestId });
|
|
||||||
setOnboardingEmail(result.email);
|
|
||||||
setRemainingAttempts(result.remainingAttempts);
|
|
||||||
setResendCooldownSeconds(result.cooldownSeconds);
|
|
||||||
form.reset({ otp: '' });
|
|
||||||
setInfoMessage(result.message);
|
|
||||||
|
|
||||||
updateOnboardingSessionState((current) => ({
|
|
||||||
...current,
|
|
||||||
onboardingEmail: result.email,
|
|
||||||
remainingAttempts: result.remainingAttempts,
|
|
||||||
resendCooldownSeconds: result.cooldownSeconds,
|
|
||||||
verifyOtpForm: { otp: '' },
|
|
||||||
}));
|
|
||||||
} catch {
|
|
||||||
setErrorMessage('No pudimos reenviar el OTP. Intenta nuevamente.');
|
|
||||||
} finally {
|
|
||||||
setIsResending(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<OnboardingLayout
|
|
||||||
title="Verifica tu email"
|
|
||||||
description="Ingresa el OTP para validar tu cuenta."
|
|
||||||
>
|
|
||||||
<OnboardingVerifyOtpStep
|
|
||||||
form={form}
|
|
||||||
email={onboardingEmail}
|
|
||||||
errorMessage={errorMessage}
|
|
||||||
infoMessage={infoMessage}
|
|
||||||
remainingAttempts={remainingAttempts}
|
|
||||||
resendCooldownSeconds={resendCooldownSeconds}
|
|
||||||
isResending={isResending}
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
onResend={onResend}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="mt-2 w-full"
|
|
||||||
onClick={() => {
|
|
||||||
void navigate({ to: '/onboard' });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Volver al paso anterior
|
|
||||||
</Button>
|
|
||||||
</OnboardingLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
import type {
|
|
||||||
CompleteValues,
|
|
||||||
StartValues,
|
|
||||||
VerifyOtpValues,
|
|
||||||
} from '@/features/onboard/onboarding.types';
|
|
||||||
|
|
||||||
const ONBOARDING_SESSION_KEY = 'playzer:onboarding-session';
|
|
||||||
export const OTP_MAX_ATTEMPTS = 5;
|
|
||||||
|
|
||||||
export const defaultStartFormValues: StartValues = {
|
|
||||||
fullName: '',
|
|
||||||
email: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const defaultVerifyOtpFormValues: VerifyOtpValues = {
|
|
||||||
otp: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const defaultCompleteFormValues: CompleteValues = {
|
|
||||||
password: '',
|
|
||||||
complexName: '',
|
|
||||||
physicalAddress: '',
|
|
||||||
planCode: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OnboardingSessionState = {
|
|
||||||
requestId: string | null;
|
|
||||||
onboardingEmail: string | null;
|
|
||||||
verifiedEmail: string | null;
|
|
||||||
remainingAttempts: number;
|
|
||||||
resendCooldownSeconds: number;
|
|
||||||
startForm: StartValues;
|
|
||||||
verifyOtpForm: VerifyOtpValues;
|
|
||||||
completeForm: CompleteValues;
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultOnboardingSessionState: OnboardingSessionState = {
|
|
||||||
requestId: null,
|
|
||||||
onboardingEmail: null,
|
|
||||||
verifiedEmail: null,
|
|
||||||
remainingAttempts: OTP_MAX_ATTEMPTS,
|
|
||||||
resendCooldownSeconds: 0,
|
|
||||||
startForm: defaultStartFormValues,
|
|
||||||
verifyOtpForm: defaultVerifyOtpFormValues,
|
|
||||||
completeForm: defaultCompleteFormValues,
|
|
||||||
};
|
|
||||||
|
|
||||||
function isObject(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === 'object' && value !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSessionState(value: unknown): OnboardingSessionState {
|
|
||||||
if (!isObject(value)) {
|
|
||||||
return defaultOnboardingSessionState;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
requestId: typeof value.requestId === 'string' ? value.requestId : null,
|
|
||||||
onboardingEmail: typeof value.onboardingEmail === 'string' ? value.onboardingEmail : null,
|
|
||||||
verifiedEmail: typeof value.verifiedEmail === 'string' ? value.verifiedEmail : null,
|
|
||||||
remainingAttempts:
|
|
||||||
typeof value.remainingAttempts === 'number'
|
|
||||||
? value.remainingAttempts
|
|
||||||
: defaultOnboardingSessionState.remainingAttempts,
|
|
||||||
resendCooldownSeconds:
|
|
||||||
typeof value.resendCooldownSeconds === 'number'
|
|
||||||
? value.resendCooldownSeconds
|
|
||||||
: defaultOnboardingSessionState.resendCooldownSeconds,
|
|
||||||
startForm: {
|
|
||||||
fullName:
|
|
||||||
isObject(value.startForm) && typeof value.startForm.fullName === 'string'
|
|
||||||
? value.startForm.fullName
|
|
||||||
: defaultStartFormValues.fullName,
|
|
||||||
email:
|
|
||||||
isObject(value.startForm) && typeof value.startForm.email === 'string'
|
|
||||||
? value.startForm.email
|
|
||||||
: defaultStartFormValues.email,
|
|
||||||
},
|
|
||||||
verifyOtpForm: {
|
|
||||||
otp:
|
|
||||||
isObject(value.verifyOtpForm) && typeof value.verifyOtpForm.otp === 'string'
|
|
||||||
? value.verifyOtpForm.otp
|
|
||||||
: defaultVerifyOtpFormValues.otp,
|
|
||||||
},
|
|
||||||
completeForm: {
|
|
||||||
password:
|
|
||||||
isObject(value.completeForm) && typeof value.completeForm.password === 'string'
|
|
||||||
? value.completeForm.password
|
|
||||||
: defaultCompleteFormValues.password,
|
|
||||||
complexName:
|
|
||||||
isObject(value.completeForm) && typeof value.completeForm.complexName === 'string'
|
|
||||||
? value.completeForm.complexName
|
|
||||||
: defaultCompleteFormValues.complexName,
|
|
||||||
physicalAddress:
|
|
||||||
isObject(value.completeForm) && typeof value.completeForm.physicalAddress === 'string'
|
|
||||||
? value.completeForm.physicalAddress
|
|
||||||
: defaultCompleteFormValues.physicalAddress,
|
|
||||||
planCode:
|
|
||||||
isObject(value.completeForm) && typeof value.completeForm.planCode === 'string'
|
|
||||||
? value.completeForm.planCode
|
|
||||||
: defaultCompleteFormValues.planCode,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getOnboardingSessionState(): OnboardingSessionState {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return defaultOnboardingSessionState;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawState = window.sessionStorage.getItem(ONBOARDING_SESSION_KEY);
|
|
||||||
if (!rawState) {
|
|
||||||
return defaultOnboardingSessionState;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return normalizeSessionState(JSON.parse(rawState));
|
|
||||||
} catch {
|
|
||||||
return defaultOnboardingSessionState;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setOnboardingSessionState(state: OnboardingSessionState) {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
window.sessionStorage.setItem(ONBOARDING_SESSION_KEY, JSON.stringify(state));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateOnboardingSessionState(
|
|
||||||
updater: (current: OnboardingSessionState) => OnboardingSessionState
|
|
||||||
): OnboardingSessionState {
|
|
||||||
const currentState = getOnboardingSessionState();
|
|
||||||
const nextState = updater(currentState);
|
|
||||||
setOnboardingSessionState(nextState);
|
|
||||||
return nextState;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearOnboardingSessionState() {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
window.sessionStorage.removeItem(ONBOARDING_SESSION_KEY);
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import {
|
|
||||||
onboardingCompleteSchema,
|
|
||||||
onboardingStartSchema,
|
|
||||||
onboardingVerifyOtpSchema,
|
|
||||||
} from '@repo/api-contract';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
export type StartValues = z.infer<typeof onboardingStartSchema>;
|
|
||||||
export type VerifyOtpValues = Omit<z.infer<typeof onboardingVerifyOtpSchema>, 'requestId'>;
|
|
||||||
export type CompleteValues = Omit<z.infer<typeof onboardingCompleteSchema>, 'onboardingRequestId'>;
|
|
||||||
114
apps/frontend/src/features/onboard/setup-stepper-page.tsx
Normal file
114
apps/frontend/src/features/onboard/setup-stepper-page.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { SetupStepper } from '@/features/onboard/components/setup-stepper';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { setCurrentComplexSlug } from '@/lib/current-complex';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { createComplexSchema } from '@repo/api-contract';
|
||||||
|
import type { PlanWithFeatures, Sport } from '@repo/api-contract';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
|
export function SetupStepperPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [plans, setPlans] = useState<PlanWithFeatures[]>([]);
|
||||||
|
const [sports, setSports] = useState<Sport[]>([]);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(createComplexSchema),
|
||||||
|
mode: 'onChange',
|
||||||
|
defaultValues: {
|
||||||
|
complexName: '',
|
||||||
|
physicalAddress: '',
|
||||||
|
city: '',
|
||||||
|
state: '',
|
||||||
|
country: '',
|
||||||
|
planCode: '',
|
||||||
|
setupCourts: false,
|
||||||
|
courtSportId: undefined,
|
||||||
|
courtStartTime: '08:00',
|
||||||
|
courtEndTime: '22:00',
|
||||||
|
courtDaysOfWeek: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
// Safety: if user already has complexes (e.g., accepted invite between redirects),
|
||||||
|
// redirect to home
|
||||||
|
const myComplexes = await apiClient.complexes.listMine();
|
||||||
|
if (myComplexes.length > 0) {
|
||||||
|
navigate({ to: '/' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [plansResult, sportsResult] = await Promise.all([
|
||||||
|
apiClient.plans.list(),
|
||||||
|
apiClient.sports.list(),
|
||||||
|
]);
|
||||||
|
setPlans(plansResult);
|
||||||
|
setSports(sportsResult);
|
||||||
|
} catch {
|
||||||
|
setErrorMessage('Error al cargar datos iniciales.');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
const isValid = await form.trigger();
|
||||||
|
if (!isValid) return;
|
||||||
|
|
||||||
|
setErrorMessage(null);
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const values = form.getValues();
|
||||||
|
const result = await apiClient.complexes.create(values as any);
|
||||||
|
|
||||||
|
setCurrentComplexSlug(result.complexSlug);
|
||||||
|
await navigate({
|
||||||
|
to: '/complex/$slug/edit',
|
||||||
|
params: { slug: result.complexSlug },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setErrorMessage('No pudimos crear el complejo. Intenta nuevamente.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (errorMessage && plans.length === 0) {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto flex min-h-screen w-full max-w-6xl items-center justify-center px-3 sm:px-6">
|
||||||
|
<section className="w-full max-w-xl rounded-xl border bg-card p-6 text-card-foreground shadow-sm text-center">
|
||||||
|
<p className="text-destructive">{errorMessage}</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto flex min-h-screen w-full max-w-6xl items-start justify-center px-3 py-12 sm:px-6">
|
||||||
|
<section className="w-full max-w-3xl rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-semibold">Configurá tu complejo</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Completá los datos para empezar a recibir reservas.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SetupStepper
|
||||||
|
form={form}
|
||||||
|
plans={plans}
|
||||||
|
sports={sports}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{errorMessage && <p className="mt-4 text-sm text-destructive">{errorMessage}</p>}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { getPendingInvite } from '@/lib/invitations';
|
|
||||||
import { Route } from '@/routes/onboard/verify-email';
|
|
||||||
import { Link } from '@tanstack/react-router';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
export function VerifyEmailPage() {
|
|
||||||
const search = Route.useSearch();
|
|
||||||
const email = search.email || sessionStorage.getItem('pending-signup-email') || '';
|
|
||||||
const pendingInvite = getPendingInvite();
|
|
||||||
const [resendCooldown, setResendCooldown] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (resendCooldown > 0) {
|
|
||||||
const timer = setTimeout(() => setResendCooldown((c) => c - 1), 1000);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [resendCooldown]);
|
|
||||||
|
|
||||||
const handleResend = async () => {
|
|
||||||
if (!email || resendCooldown > 0) return;
|
|
||||||
|
|
||||||
setResendCooldown(60);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-screen w-full items-center justify-center px-3 sm:px-6">
|
|
||||||
<section className="w-full max-w-sm rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
|
||||||
<h1 className="mb-2 text-2xl font-semibold">Verificá tu email</h1>
|
|
||||||
<p className="mb-4 text-sm text-muted-foreground">
|
|
||||||
Te enviamos un email de verificación a <strong>{email}</strong>. Hacé click en el link
|
|
||||||
para confirmar tu cuenta.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="w-full"
|
|
||||||
onClick={handleResend}
|
|
||||||
disabled={resendCooldown > 0}
|
|
||||||
>
|
|
||||||
{resendCooldown > 0 ? `Reenviar en ${resendCooldown}s` : 'Reenviar email'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<p className="text-center text-sm text-muted-foreground">
|
|
||||||
¿Ya verificaste tu email?{' '}
|
|
||||||
{pendingInvite.token ? (
|
|
||||||
<Link
|
|
||||||
to="/invite"
|
|
||||||
search={{
|
|
||||||
email: pendingInvite.email ?? email,
|
|
||||||
inviteToken: pendingInvite.token,
|
|
||||||
}}
|
|
||||||
className="text-primary hover:underline"
|
|
||||||
>
|
|
||||||
Continuar con la invitación
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<Link to="/onboard/create-complex" className="text-primary hover:underline">
|
|
||||||
Continuar
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,8 @@ type ShareWhatsappButtonProps = {
|
|||||||
confirmation: PublicBookingConfirmation;
|
confirmation: PublicBookingConfirmation;
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatDateLabel(isoDate: string) {
|
function formatDateLabel(isoDate: string | null | undefined) {
|
||||||
|
if (!isoDate) return '';
|
||||||
const [year, month, day] = isoDate.split('-').map(Number);
|
const [year, month, day] = isoDate.split('-').map(Number);
|
||||||
const date = new Date(year, (month ?? 1) - 1, day ?? 1);
|
const date = new Date(year, (month ?? 1) - 1, day ?? 1);
|
||||||
|
|
||||||
@@ -18,16 +19,35 @@ function formatDateLabel(isoDate: string) {
|
|||||||
}).format(date);
|
}).format(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBookingPrice(price: number): string {
|
||||||
|
if (price === 0) {
|
||||||
|
return 'Sin cargo';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.NumberFormat('es-AR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'ARS',
|
||||||
|
maximumFractionDigits: Number.isInteger(price) ? 0 : 2,
|
||||||
|
}).format(price);
|
||||||
|
}
|
||||||
|
|
||||||
function createWhatsappMessage(confirmation: PublicBookingConfirmation) {
|
function createWhatsappMessage(confirmation: PublicBookingConfirmation) {
|
||||||
const dateLabel = formatDateLabel(confirmation.date);
|
const dateLabel = formatDateLabel(confirmation.date);
|
||||||
return [
|
const lines = ['Ya reservamos cancha para jugar.', `Complejo: ${confirmation.complexName}`];
|
||||||
'Ya reservamos cancha para jugar.',
|
|
||||||
`Complejo: ${confirmation.complexName}`,
|
if (confirmation.complexAddress) {
|
||||||
|
lines.push(`Direccion: ${confirmation.complexAddress}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(
|
||||||
`Cancha: ${confirmation.courtName} (${confirmation.sport.name})`,
|
`Cancha: ${confirmation.courtName} (${confirmation.sport.name})`,
|
||||||
`Fecha: ${dateLabel}`,
|
`Fecha: ${dateLabel}`,
|
||||||
`Horario: ${confirmation.startTime} - ${confirmation.endTime}`,
|
`Horario: ${confirmation.startTime} - ${confirmation.endTime}`,
|
||||||
`Codigo de reserva: ${confirmation.bookingCode}`,
|
`Precio: ${formatBookingPrice(confirmation.price)}`,
|
||||||
].join('\n');
|
`Codigo de reserva: ${confirmation.bookingCode}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ShareWhatsappButton({ confirmation }: ShareWhatsappButtonProps) {
|
export function ShareWhatsappButton({ confirmation }: ShareWhatsappButtonProps) {
|
||||||
@@ -36,17 +56,28 @@ export function ShareWhatsappButton({ confirmation }: ShareWhatsappButtonProps)
|
|||||||
)}`;
|
)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button type="button" variant="outline" className="h-11 w-full text-sm sm:text-base" asChild>
|
<Button
|
||||||
<a href={whatsappUrl} target="_blank" rel="noopener noreferrer">
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-12 w-full overflow-hidden rounded-xl border-slate-200 bg-white px-0 text-sm font-semibold text-slate-900 hover:bg-slate-50 sm:px-4 sm:text-base"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={whatsappUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="Compartir por WhatsApp"
|
||||||
|
title="Compartir por WhatsApp"
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="size-4"
|
className="size-5"
|
||||||
style={{ color: `#${siWhatsapp.hex}` }}
|
style={{ color: `#${siWhatsapp.hex}` }}
|
||||||
>
|
>
|
||||||
<path d={siWhatsapp.path} fill="currentColor" />
|
<path d={siWhatsapp.path} fill="currentColor" />
|
||||||
</svg>
|
</svg>
|
||||||
Compartir por WhatsApp
|
<span className="hidden sm:inline">Compartir por WhatsApp</span>
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,30 @@
|
|||||||
|
import PlayzerLogo from '@/assets/playzer-logo-transparent.svg';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ApiClientError, apiClient } from '@/lib/api-client';
|
import { Input } from '@/components/ui/input';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import {
|
||||||
|
ResponsiveDialog,
|
||||||
|
ResponsiveDialogClose,
|
||||||
|
ResponsiveDialogContent,
|
||||||
|
ResponsiveDialogDescription,
|
||||||
|
ResponsiveDialogFooter,
|
||||||
|
ResponsiveDialogHeader,
|
||||||
|
ResponsiveDialogTitle,
|
||||||
|
} from '@/components/ui/responsive-dialog';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
ArrowRight,
|
||||||
|
CalendarDays,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock3,
|
||||||
|
LoaderCircle,
|
||||||
|
Receipt,
|
||||||
|
Trophy,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
import { ShareWhatsappButton } from './components/share-whatsapp-button';
|
import { ShareWhatsappButton } from './components/share-whatsapp-button';
|
||||||
|
|
||||||
type PublicBookingConfirmationPageProps = {
|
type PublicBookingConfirmationPageProps = {
|
||||||
@@ -10,23 +33,34 @@ type PublicBookingConfirmationPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function extractMessage(error: unknown, fallback: string) {
|
function extractMessage(error: unknown, fallback: string) {
|
||||||
if (error instanceof ApiClientError) {
|
const msg = (error as { message?: string } | undefined)?.message;
|
||||||
return error.message || fallback;
|
return msg || fallback;
|
||||||
}
|
|
||||||
|
|
||||||
return fallback;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateLabel(isoDate: string) {
|
function formatDateLabel(isoDate: string | undefined | null) {
|
||||||
|
if (!isoDate) return '';
|
||||||
const [year, month, day] = isoDate.split('-').map(Number);
|
const [year, month, day] = isoDate.split('-').map(Number);
|
||||||
const date = new Date(year, (month ?? 1) - 1, day ?? 1);
|
const date = new Date(year, (month ?? 1) - 1, day ?? 1);
|
||||||
|
|
||||||
return new Intl.DateTimeFormat('es-AR', {
|
const label = new Intl.DateTimeFormat('es-AR', {
|
||||||
weekday: 'long',
|
weekday: 'long',
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
year: 'numeric',
|
|
||||||
}).format(date);
|
}).format(date);
|
||||||
|
|
||||||
|
return label.charAt(0).toUpperCase() + label.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBookingPrice(price: number): string {
|
||||||
|
if (price === 0) {
|
||||||
|
return 'Sin cargo';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.NumberFormat('es-AR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'ARS',
|
||||||
|
maximumFractionDigits: Number.isInteger(price) ? 0 : 2,
|
||||||
|
}).format(price);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PublicBookingConfirmationPage({
|
export function PublicBookingConfirmationPage({
|
||||||
@@ -34,82 +68,230 @@ export function PublicBookingConfirmationPage({
|
|||||||
bookingCode,
|
bookingCode,
|
||||||
}: PublicBookingConfirmationPageProps) {
|
}: PublicBookingConfirmationPageProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
||||||
|
const [customerPhone, setCustomerPhone] = useState('');
|
||||||
|
const [cancelError, setCancelError] = useState<string | null>(null);
|
||||||
|
const [cancelledBooking, setCancelledBooking] = useState<{
|
||||||
|
date: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
courtName: string;
|
||||||
|
sport: { name: string };
|
||||||
|
bookingCode: string;
|
||||||
|
price: number;
|
||||||
|
complexName: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const confirmationQuery = useQuery({
|
const confirmationQuery = useQuery({
|
||||||
queryKey: ['public-booking-confirmation', complexSlug, bookingCode],
|
queryKey: ['public-booking-confirmation', complexSlug, bookingCode],
|
||||||
queryFn: () => apiClient.publicBookings.getConfirmation(complexSlug, bookingCode),
|
queryFn: () => apiClient.publicBookings.getConfirmation(complexSlug, bookingCode),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const cancelMutation = useMutation({
|
||||||
|
mutationFn: (phone: string) =>
|
||||||
|
apiClient.publicBookings.cancelPublic(complexSlug, {
|
||||||
|
bookingCode,
|
||||||
|
customerPhone: phone,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCancelClick = () => {
|
||||||
|
cancelMutation.reset();
|
||||||
|
setCustomerPhone('');
|
||||||
|
setCancelError(null);
|
||||||
|
setCancelDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmCancel = async () => {
|
||||||
|
setCancelError(null);
|
||||||
|
try {
|
||||||
|
const data = await cancelMutation.mutateAsync(customerPhone);
|
||||||
|
setCancelledBooking(data);
|
||||||
|
setCancelDialogOpen(false);
|
||||||
|
setCustomerPhone('');
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('[Cancel]', error);
|
||||||
|
setCancelError(
|
||||||
|
(error as { message?: string } | undefined)?.message ?? 'No pudimos cancelar la reserva.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCancelled = cancelledBooking !== null;
|
||||||
|
const displayData = cancelledBooking ?? confirmationQuery.data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-linear-to-b from-background via-background to-primary/5">
|
<main className="min-h-screen bg-[#edf7f4] text-[#111827]">
|
||||||
<div className="mx-auto flex min-h-screen w-full max-w-3xl items-center px-3 py-8 sm:px-6">
|
<div className="mx-auto flex min-h-screen w-full max-w-3xl items-center px-4 py-8 sm:px-6">
|
||||||
<section className="w-full rounded-3xl border bg-card p-5 shadow-lg sm:p-8">
|
<section className="w-full rounded-[28px] border border-emerald-950/10 bg-white p-5 shadow-[0_24px_70px_rgba(15,23,42,0.12)] sm:p-8">
|
||||||
{confirmationQuery.isLoading && (
|
{confirmationQuery.isLoading && !cancelledBooking && (
|
||||||
<p className="text-sm text-muted-foreground">Cargando confirmacion...</p>
|
<div className="flex items-center gap-3 text-sm text-slate-500">
|
||||||
|
<LoaderCircle className="size-5 animate-spin text-emerald-600" />
|
||||||
|
Cargando confirmación...
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{confirmationQuery.isError && (
|
{confirmationQuery.isError && !cancelledBooking && (
|
||||||
<p className="text-sm text-destructive">
|
<div className="flex gap-3 rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||||
{extractMessage(
|
<AlertCircle className="mt-0.5 size-5 shrink-0" />
|
||||||
confirmationQuery.error,
|
<p>
|
||||||
'No pudimos cargar la confirmacion de la reserva.'
|
{extractMessage(
|
||||||
)}
|
confirmationQuery.error,
|
||||||
</p>
|
'No pudimos cargar la confirmación de la reserva.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{confirmationQuery.data && (
|
{displayData && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<p className="text-xs font-medium tracking-[0.2em] text-muted-foreground uppercase">
|
<div>
|
||||||
Reserva confirmada
|
{isCancelled ? (
|
||||||
</p>
|
<div className="inline-flex items-center gap-2 rounded-full bg-red-50 px-3 py-1 text-xs font-semibold tracking-[0.14em] text-red-600 uppercase">
|
||||||
<h1 className="mt-2 text-2xl font-semibold sm:text-3xl">
|
<XCircle className="size-4" />
|
||||||
{confirmationQuery.data.complexName}
|
Reserva cancelada
|
||||||
</h1>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold tracking-[0.14em] text-emerald-700 uppercase">
|
||||||
|
<CheckCircle2 className="size-4" />
|
||||||
|
Reserva confirmada
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h1 className="mt-3 text-2xl font-bold tracking-tight sm:text-3xl">
|
||||||
|
{displayData.complexName}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<img src={PlayzerLogo} alt="Playzer" className="h-9 w-auto sm:h-10" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-primary/30 bg-primary/5 p-4 text-center sm:p-5">
|
<div className="rounded-[24px] border border-emerald-500/30 bg-emerald-50/80 p-5 sm:p-6">
|
||||||
<p className="text-xs text-muted-foreground">Codigo de reserva</p>
|
<div className="flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
|
||||||
<p className="mt-1 text-3xl font-bold tracking-[0.25em] text-primary sm:text-4xl">
|
<div>
|
||||||
{confirmationQuery.data.bookingCode}
|
<p className="flex items-center gap-2 text-sm font-semibold text-emerald-700">
|
||||||
</p>
|
<CalendarDays className="size-4" />
|
||||||
|
Tu turno
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-2xl font-black leading-tight tracking-tight text-slate-950 sm:text-4xl">
|
||||||
|
{formatDateLabel(displayData.date)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 flex items-center gap-2 text-3xl font-black leading-none text-emerald-700 sm:text-5xl">
|
||||||
|
<Clock3 className="size-7 sm:size-9" />
|
||||||
|
{displayData.startTime} - {displayData.endTime}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-emerald-600/20 bg-white/80 px-4 py-3 sm:min-w-40 sm:text-right">
|
||||||
|
<p className="text-xs font-medium text-slate-500">Precio del turno</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-slate-950">
|
||||||
|
{formatBookingPrice(displayData.price)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl border bg-background p-4 sm:p-5">
|
<div className="overflow-hidden rounded-[22px] border border-slate-200 bg-slate-50/70">
|
||||||
<p className="text-sm text-muted-foreground">Fecha</p>
|
<div className="grid gap-px bg-slate-200 sm:grid-cols-2">
|
||||||
<p className="mt-1 text-base font-medium capitalize sm:text-lg">
|
<div className="bg-white p-4">
|
||||||
{formatDateLabel(confirmationQuery.data.date)}
|
<p className="flex items-center gap-2 text-sm font-medium text-slate-500">
|
||||||
</p>
|
<Trophy className="size-4 text-emerald-600" />
|
||||||
|
Cancha
|
||||||
<p className="mt-4 text-sm text-muted-foreground">Horario</p>
|
</p>
|
||||||
<p className="mt-1 text-base font-medium sm:text-lg">
|
<p className="mt-2 text-base font-semibold text-slate-950">
|
||||||
{confirmationQuery.data.startTime} - {confirmationQuery.data.endTime}
|
{displayData.courtName} · {displayData.sport.name}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
<p className="mt-4 text-sm text-muted-foreground">Cancha</p>
|
<div className="bg-white p-4">
|
||||||
<p className="mt-1 text-base font-medium sm:text-lg">
|
<p className="flex items-center gap-2 text-sm font-medium text-slate-500">
|
||||||
{confirmationQuery.data.courtName} · {confirmationQuery.data.sport.name}
|
<Receipt className="size-4 text-emerald-600" />
|
||||||
</p>
|
Código de reserva
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 font-mono text-lg font-bold tracking-[0.18em] text-slate-950">
|
||||||
|
{displayData.bookingCode}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
{isCancelled ? (
|
||||||
<ShareWhatsappButton confirmation={confirmationQuery.data} />
|
<div className="rounded-2xl bg-red-50 p-4 text-center text-sm text-red-700">
|
||||||
<Button
|
Esta reserva fue cancelada. Te enviamos un email con los detalles de la
|
||||||
type="button"
|
cancelación.
|
||||||
className="h-11 w-full text-sm sm:text-base"
|
</div>
|
||||||
onClick={() => {
|
) : (
|
||||||
void navigate({
|
<div className="grid grid-cols-[48px_minmax(0,1fr)_minmax(0,1fr)] gap-2 sm:grid-cols-3 sm:gap-3">
|
||||||
to: '/$complexSlug/booking',
|
<ShareWhatsappButton confirmation={confirmationQuery.data!} />
|
||||||
params: { complexSlug },
|
<Button
|
||||||
});
|
type="button"
|
||||||
}}
|
variant="outline"
|
||||||
>
|
className="h-12 w-full rounded-xl border-red-200 text-sm font-semibold text-red-600 hover:bg-red-50 hover:text-red-700 sm:text-base"
|
||||||
Hacer otra reserva
|
onClick={handleCancelClick}
|
||||||
</Button>
|
>
|
||||||
</div>
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="h-12 w-full rounded-xl bg-emerald-600 text-sm font-semibold text-white hover:bg-emerald-500 sm:text-base"
|
||||||
|
onClick={() => {
|
||||||
|
void navigate({
|
||||||
|
to: '/$complexSlug/booking',
|
||||||
|
params: { complexSlug },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="hidden sm:inline">Hacer otra reserva</span>
|
||||||
|
<span className="sm:hidden">Otra</span>
|
||||||
|
<ArrowRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ResponsiveDialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}>
|
||||||
|
<ResponsiveDialogContent>
|
||||||
|
<ResponsiveDialogHeader>
|
||||||
|
<ResponsiveDialogTitle>Cancelar reserva</ResponsiveDialogTitle>
|
||||||
|
<ResponsiveDialogDescription>
|
||||||
|
Ingresá el número de teléfono que usaste al hacer la reserva para confirmar la
|
||||||
|
cancelación.
|
||||||
|
</ResponsiveDialogDescription>
|
||||||
|
</ResponsiveDialogHeader>
|
||||||
|
|
||||||
|
<div className="px-6 pb-2">
|
||||||
|
<Input
|
||||||
|
type="tel"
|
||||||
|
placeholder="Ej: 1134567890"
|
||||||
|
value={customerPhone}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCustomerPhone(e.target.value);
|
||||||
|
setCancelError(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{cancelError && <p className="mt-2 text-sm text-red-600">{cancelError}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ResponsiveDialogFooter>
|
||||||
|
<ResponsiveDialogClose asChild>
|
||||||
|
<Button variant="outline">Volver</Button>
|
||||||
|
</ResponsiveDialogClose>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={!customerPhone.trim() || cancelMutation.isPending}
|
||||||
|
onClick={handleConfirmCancel}
|
||||||
|
>
|
||||||
|
{cancelMutation.isPending ? (
|
||||||
|
<LoaderCircle className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
'Sí, cancelar reserva'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</ResponsiveDialogFooter>
|
||||||
|
</ResponsiveDialogContent>
|
||||||
|
</ResponsiveDialog>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,6 @@ import PlayzerIcon from '@/assets/playzer-favicon-512-transparent.png';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import {
|
import {
|
||||||
Stepper,
|
Stepper,
|
||||||
StepperIndicator,
|
StepperIndicator,
|
||||||
@@ -46,7 +39,6 @@ import {
|
|||||||
Sun,
|
Sun,
|
||||||
Trophy,
|
Trophy,
|
||||||
Users,
|
Users,
|
||||||
Wrench,
|
|
||||||
Zap,
|
Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
@@ -69,6 +61,7 @@ const bookingFormSchema = z.object({
|
|||||||
.trim()
|
.trim()
|
||||||
.min(6, 'Ingresá un teléfono válido.')
|
.min(6, 'Ingresá un teléfono válido.')
|
||||||
.max(30, 'El teléfono no puede superar los 30 caracteres.'),
|
.max(30, 'El teléfono no puede superar los 30 caracteres.'),
|
||||||
|
customerEmail: z.string().email('Ingresá un email válido.'),
|
||||||
});
|
});
|
||||||
|
|
||||||
type BookingFormValues = z.infer<typeof bookingFormSchema>;
|
type BookingFormValues = z.infer<typeof bookingFormSchema>;
|
||||||
@@ -84,6 +77,7 @@ type SelectedSlot = {
|
|||||||
sportName: string;
|
sportName: string;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
endTime: string;
|
endTime: string;
|
||||||
|
price: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type BookingShellProps = {
|
type BookingShellProps = {
|
||||||
@@ -186,6 +180,18 @@ function extractMessage(error: unknown, fallback: string) {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBookingPrice(price: number): string {
|
||||||
|
if (price === 0) {
|
||||||
|
return 'Sin cargo';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.NumberFormat('es-AR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'ARS',
|
||||||
|
maximumFractionDigits: Number.isInteger(price) ? 0 : 2,
|
||||||
|
}).format(price);
|
||||||
|
}
|
||||||
|
|
||||||
function getStep(selectedSlot: SelectedSlot | null, isValid: boolean) {
|
function getStep(selectedSlot: SelectedSlot | null, isValid: boolean) {
|
||||||
if (!selectedSlot) return 1;
|
if (!selectedSlot) return 1;
|
||||||
if (!isValid) return 3;
|
if (!isValid) return 3;
|
||||||
@@ -369,7 +375,6 @@ function Legend() {
|
|||||||
<div className="flex flex-wrap gap-4 text-xs text-white/70">
|
<div className="flex flex-wrap gap-4 text-xs text-white/70">
|
||||||
<LegendDot color="bg-emerald-500" label="Libre" />
|
<LegendDot color="bg-emerald-500" label="Libre" />
|
||||||
<LegendDot color="bg-blue-500" label="Reservado" />
|
<LegendDot color="bg-blue-500" label="Reservado" />
|
||||||
<LegendDot color="bg-red-500" label="Mantenimiento" />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -638,48 +643,52 @@ function PublicBookingDesktop(props: BookingShellProps) {
|
|||||||
)}
|
)}
|
||||||
{isError && <StatusPanel>{errorMessage}</StatusPanel>}
|
{isError && <StatusPanel>{errorMessage}</StatusPanel>}
|
||||||
{isLoading && <StatusPanel>Buscando disponibilidad...</StatusPanel>}
|
{isLoading && <StatusPanel>Buscando disponibilidad...</StatusPanel>}
|
||||||
<Panel className="overflow-hidden">
|
{selectedCourt && (
|
||||||
<div className="flex items-center border-b border-white/10 px-6 py-5">
|
<>
|
||||||
<CourtHeader court={selectedCourt} complexName={props.complexName} />
|
<Panel className="overflow-hidden">
|
||||||
<div className="ml-auto flex items-center overflow-hidden rounded-md border border-white/10 bg-white/[0.035] text-sm">
|
<div className="flex items-center border-b border-white/10 px-6 py-5">
|
||||||
<button
|
<CourtHeader court={selectedCourt} complexName={props.complexName} />
|
||||||
type="button"
|
<div className="ml-auto flex items-center overflow-hidden rounded-md border border-white/10 bg-white/[0.035] text-sm">
|
||||||
className="flex size-11 items-center justify-center border-r border-white/10 transition hover:bg-white/[0.06]"
|
<button
|
||||||
onClick={props.onPreviousDay}
|
type="button"
|
||||||
disabled={!canGoBackButton}
|
className="flex size-11 items-center justify-center border-r border-white/10 transition hover:bg-white/[0.06]"
|
||||||
aria-label="Día anterior"
|
onClick={props.onPreviousDay}
|
||||||
>
|
disabled={!canGoBackButton}
|
||||||
<ChevronLeft className="size-5" />
|
aria-label="Día anterior"
|
||||||
</button>
|
>
|
||||||
<div className="flex h-11 min-w-[250px] items-center justify-center gap-2 px-4">
|
<ChevronLeft className="size-5" />
|
||||||
<CalendarDays className="size-4 text-white/76" />
|
</button>
|
||||||
<span>{selectedDay?.longLabel ?? selectedDate}</span>
|
<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>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
<div className="p-6">
|
||||||
className="flex size-11 items-center justify-center border-l border-white/10 transition hover:bg-white/[0.06]"
|
<Legend />
|
||||||
onClick={props.onNextDay}
|
<BookingTimeline
|
||||||
aria-label="Día siguiente"
|
court={selectedCourt}
|
||||||
>
|
selectedSlot={selectedSlot}
|
||||||
<ChevronRight className="size-5" />
|
onSelectSlot={onSelectSlot}
|
||||||
</button>
|
/>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[minmax(0,1fr)_minmax(340px,0.95fr)] gap-4">
|
||||||
|
<CourtDetails court={selectedCourt} />
|
||||||
|
<SelectedSlotCard {...props} />
|
||||||
</div>
|
</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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</PublicBookingPageChrome>
|
</PublicBookingPageChrome>
|
||||||
@@ -711,16 +720,19 @@ function PublicBookingMobile(props: BookingShellProps) {
|
|||||||
options={props.sports.map((sport) => ({ value: sport.id, label: sport.name }))}
|
options={props.sports.map((sport) => ({ value: sport.id, label: sport.name }))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<MobileSelect
|
{props.courts.length > 0 ? (
|
||||||
label="Cancha"
|
<MobileCourtPicker
|
||||||
value={selectedCourt?.courtId ?? ''}
|
courts={props.courts}
|
||||||
placeholder="Seleccioná"
|
selectedCourtId={selectedCourt?.courtId}
|
||||||
onValueChange={props.onSelectCourt}
|
onSelectCourt={props.onSelectCourt}
|
||||||
options={props.courts.map((court) => ({
|
/>
|
||||||
value: court.courtId,
|
) : (
|
||||||
label: court.courtName,
|
!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>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
@@ -756,25 +768,23 @@ function PublicBookingMobile(props: BookingShellProps) {
|
|||||||
)}
|
)}
|
||||||
{props.isLoading && <StatusPanel>Buscando disponibilidad...</StatusPanel>}
|
{props.isLoading && <StatusPanel>Buscando disponibilidad...</StatusPanel>}
|
||||||
{props.isError && <StatusPanel>{props.errorMessage}</StatusPanel>}
|
{props.isError && <StatusPanel>{props.errorMessage}</StatusPanel>}
|
||||||
<Panel className="overflow-hidden">
|
{selectedCourt && (
|
||||||
<div className="p-3">
|
<Panel className="overflow-hidden">
|
||||||
<CourtHeader court={selectedCourt} compact />
|
<div className="p-3">
|
||||||
<div className="mt-3">
|
<CourtHeader court={selectedCourt} compact />
|
||||||
<Legend />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="border-t border-white/10 p-3">
|
||||||
<div className="border-t border-white/10 px-0 py-3">
|
<MobileAvailableSlots
|
||||||
<BookingTimeline
|
court={selectedCourt}
|
||||||
court={selectedCourt}
|
selectedSlot={props.selectedSlot}
|
||||||
selectedSlot={props.selectedSlot}
|
onSelectSlot={props.onSelectSlot}
|
||||||
onSelectSlot={props.onSelectSlot}
|
/>
|
||||||
compact
|
</div>
|
||||||
/>
|
<div className="px-3 pb-3">
|
||||||
</div>
|
<SelectedSlotCard {...props} compact />
|
||||||
<div className="px-3 pb-3">
|
</div>
|
||||||
<SelectedSlotCard {...props} compact />
|
</Panel>
|
||||||
</div>
|
)}
|
||||||
</Panel>
|
|
||||||
</div>
|
</div>
|
||||||
</PublicBookingPageChrome>
|
</PublicBookingPageChrome>
|
||||||
);
|
);
|
||||||
@@ -876,34 +886,119 @@ function MobileSportSelector({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MobileSelect({
|
function MobileCourtPicker({
|
||||||
label,
|
courts,
|
||||||
value,
|
selectedCourtId,
|
||||||
placeholder,
|
onSelectCourt,
|
||||||
options,
|
|
||||||
onValueChange,
|
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
courts: PublicAvailabilityCourt[];
|
||||||
value: string;
|
selectedCourtId?: string;
|
||||||
placeholder: string;
|
onSelectCourt: (courtId: string) => void;
|
||||||
options: { value: string; label: string }[];
|
|
||||||
onValueChange: (value: string) => void;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-[88px_minmax(0,1fr)] items-center border-b border-white/10 bg-white/[0.025] px-3 py-2 last:border-b-0">
|
<div className="border-b border-white/10 bg-white/[0.025] px-3 py-3 last:border-b-0">
|
||||||
<span className="text-xs text-white/66">{label}</span>
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<Select value={value} onValueChange={onValueChange}>
|
{courts.map((court) => {
|
||||||
<SelectTrigger className="h-8 border-0 bg-transparent px-0 text-xs text-white shadow-none">
|
const isSelected = selectedCourtId === court.courtId;
|
||||||
<SelectValue placeholder={placeholder} />
|
|
||||||
</SelectTrigger>
|
return (
|
||||||
<SelectContent>
|
<button
|
||||||
{options.map((option) => (
|
key={court.courtId}
|
||||||
<SelectItem key={option.value} value={option.value}>
|
type="button"
|
||||||
{option.label}
|
onClick={() => onSelectCourt(court.courtId)}
|
||||||
</SelectItem>
|
aria-pressed={isSelected}
|
||||||
))}
|
className={`flex min-h-16 items-start gap-2 rounded-md border p-3 text-left transition ${
|
||||||
</SelectContent>
|
isSelected
|
||||||
</Select>
|
? 'border-emerald-400 bg-emerald-500/20 text-white shadow-[inset_0_0_18px_rgba(16,185,129,0.12)]'
|
||||||
|
: 'border-white/10 bg-white/[0.045] text-white/72 active:border-white/25 active:bg-white/[0.075]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border ${
|
||||||
|
isSelected
|
||||||
|
? 'border-emerald-300 bg-emerald-400 text-[#061019]'
|
||||||
|
: 'border-white/18 text-transparent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Check className="size-3.5" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-sm font-semibold">{court.courtName}</span>
|
||||||
|
<span className="mt-0.5 block truncate text-xs text-white/52">
|
||||||
|
{court.sport.name}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileAvailableSlots({
|
||||||
|
court,
|
||||||
|
selectedSlot,
|
||||||
|
onSelectSlot,
|
||||||
|
}: {
|
||||||
|
court: PublicAvailabilityCourt;
|
||||||
|
selectedSlot: SelectedSlot | null;
|
||||||
|
onSelectSlot: (slot: SelectedSlot) => void;
|
||||||
|
}) {
|
||||||
|
const slots = court.availableSlots;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold">Horarios disponibles</h3>
|
||||||
|
<p className="mt-0.5 text-xs text-white/58">
|
||||||
|
Turnos de {court.slotDurationMinutes} minutos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 text-xs font-medium text-emerald-300/90">
|
||||||
|
{slots.length} {slots.length === 1 ? 'turno' : 'turnos'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{slots.length > 0 ? (
|
||||||
|
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||||
|
{slots.map((slot) => {
|
||||||
|
const selected = isSlotSelected(slot, court.courtId, selectedSlot);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${court.courtId}-${slot.startTime}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
onSelectSlot({
|
||||||
|
courtId: court.courtId,
|
||||||
|
courtName: court.courtName,
|
||||||
|
sportId: court.sport.id,
|
||||||
|
sportName: court.sport.name,
|
||||||
|
startTime: slot.startTime,
|
||||||
|
endTime: slot.endTime,
|
||||||
|
price: slot.price,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
aria-pressed={selected}
|
||||||
|
className={`flex h-12 items-center justify-center gap-1.5 rounded-md border text-sm font-bold transition ${
|
||||||
|
selected
|
||||||
|
? 'border-emerald-300 bg-emerald-400 text-[#061019] shadow-lg shadow-emerald-500/20'
|
||||||
|
: 'border-emerald-500/45 bg-emerald-500/12 text-white active:bg-emerald-500/24'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selected && <Check className="size-4" />}
|
||||||
|
{slot.startTime}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-3 rounded-md border border-white/10 bg-white/[0.035] p-3 text-sm text-white/62">
|
||||||
|
No hay horarios disponibles para esta cancha.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1011,6 +1106,7 @@ function BookingTimeline({
|
|||||||
sportName: court.sport.name,
|
sportName: court.sport.name,
|
||||||
startTime: slot.startTime,
|
startTime: slot.startTime,
|
||||||
endTime: slot.endTime,
|
endTime: slot.endTime,
|
||||||
|
price: slot.price,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className={`absolute top-7 flex h-12 min-w-[50px] items-center justify-center rounded border text-xs transition ${
|
className={`absolute top-7 flex h-12 min-w-[50px] items-center justify-center rounded border text-xs transition ${
|
||||||
@@ -1036,19 +1132,13 @@ function BookingTimeline({
|
|||||||
|
|
||||||
const left = ((end - range.start) / total) * 100;
|
const left = ((end - range.start) / total) * 100;
|
||||||
const width = ((Math.min(nextStart, range.end) - end) / total) * 100;
|
const width = ((Math.min(nextStart, range.end) - end) / total) * 100;
|
||||||
const maintenance = index % 2 === 1;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${court?.courtId}-blocked-${slot.startTime}`}
|
key={`${court?.courtId}-blocked-${slot.startTime}`}
|
||||||
className={`absolute top-7 flex h-12 min-w-[44px] items-center justify-center rounded border ${
|
className="absolute top-7 flex h-12 min-w-[44px] items-center justify-center rounded border border-blue-500/70 bg-blue-500/24 text-blue-100"
|
||||||
maintenance
|
|
||||||
? 'border-red-500/80 bg-red-500/28 text-red-100'
|
|
||||||
: 'border-blue-500/70 bg-blue-500/24 text-blue-100'
|
|
||||||
}`}
|
|
||||||
style={{ left: `${left}%`, width: `${Math.max(width - 0.8, 4.5)}%` }}
|
style={{ left: `${left}%`, width: `${Math.max(width - 0.8, 4.5)}%` }}
|
||||||
>
|
>
|
||||||
{maintenance ? <Wrench className="size-4" /> : <Lock className="size-4" />}
|
<Lock className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1155,6 +1245,13 @@ function SelectedSlotCard(props: BookingShellProps & { compact?: boolean }) {
|
|||||||
className={compact ? 'mt-4 space-y-3' : 'mt-5 grid gap-3'}
|
className={compact ? 'mt-4 space-y-3' : 'mt-5 grid gap-3'}
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
>
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3 rounded-md border border-emerald-400/20 bg-emerald-400/[0.06] px-3 py-2.5">
|
||||||
|
<span className="text-sm text-white/72">Precio del turno</span>
|
||||||
|
<span className="shrink-0 text-base font-bold text-white">
|
||||||
|
{formatBookingPrice(selectedSlot.price)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={compact ? 'space-y-3' : 'grid grid-cols-2 gap-3'}>
|
<div className={compact ? 'space-y-3' : 'grid grid-cols-2 gap-3'}>
|
||||||
<Field data-invalid={Boolean(errors.customerName)}>
|
<Field data-invalid={Boolean(errors.customerName)}>
|
||||||
<FieldLabel htmlFor="customerName" className="text-white/76">
|
<FieldLabel htmlFor="customerName" className="text-white/76">
|
||||||
@@ -1190,6 +1287,24 @@ function SelectedSlotCard(props: BookingShellProps & { compact?: boolean }) {
|
|||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Field data-invalid={Boolean(errors.customerEmail)}>
|
||||||
|
<FieldLabel htmlFor="customerEmail" className="text-white/76">
|
||||||
|
Email
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="customerEmail"
|
||||||
|
type="email"
|
||||||
|
placeholder="Ej: juan@ejemplo.com"
|
||||||
|
aria-invalid={Boolean(errors.customerEmail)}
|
||||||
|
className="border-white/10 bg-white/[0.045] text-white placeholder:text-white/32"
|
||||||
|
{...register('customerEmail')}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-white/40">
|
||||||
|
Te enviaremos la confirmación de la reserva por email.
|
||||||
|
</p>
|
||||||
|
<FieldError errors={[errors.customerEmail]} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
{createError && <p className="text-sm text-red-300">{createError}</p>}
|
{createError && <p className="text-sm text-red-300">{createError}</p>}
|
||||||
{navigationError && <p className="text-sm text-red-300">{navigationError}</p>}
|
{navigationError && <p className="text-sm text-red-300">{navigationError}</p>}
|
||||||
|
|
||||||
@@ -1305,6 +1420,7 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
customerName: '',
|
customerName: '',
|
||||||
customerPhone: '',
|
customerPhone: '',
|
||||||
|
customerEmail: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1321,6 +1437,7 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
|
|||||||
startTime: selectedSlot.startTime,
|
startTime: selectedSlot.startTime,
|
||||||
customerName: payload.customerName,
|
customerName: payload.customerName,
|
||||||
customerPhone: payload.customerPhone,
|
customerPhone: payload.customerPhone,
|
||||||
|
customerEmail: payload.customerEmail,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import type { ComplexWithRole } from '@repo/api-contract';
|
import type { ComplexWithRole } from '@repo/api-contract';
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { Building2, Loader2 } from 'lucide-react';
|
import { Building2, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
@@ -13,12 +13,15 @@ export function SelectComplexPage() {
|
|||||||
queryFn: () => apiClient.complexes.listMine(),
|
queryFn: () => apiClient.complexes.listMine(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const selectMutation = useMutation({
|
const selectMutation = useMutation({
|
||||||
mutationFn: async (complexId: string) => {
|
mutationFn: async (complexId: string) => {
|
||||||
const response = await apiClient.complexes.select({ complexId });
|
const response = await apiClient.complexes.select({ complexId });
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: (data) => {
|
||||||
|
queryClient.setQueryData(['current-complex'], data);
|
||||||
navigate({ to: '/' });
|
navigate({ to: '/' });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
232
apps/frontend/src/features/signup/signup-page.tsx
Normal file
232
apps/frontend/src/features/signup/signup-page.tsx
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
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 { authClient } from '@/lib/api-client';
|
||||||
|
import { useAuth } from '@/lib/auth';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Link, useNavigate } from '@tanstack/react-router';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const signupSchema = z
|
||||||
|
.object({
|
||||||
|
fullName: z.string().min(2, 'El nombre debe tener al menos 2 caracteres.'),
|
||||||
|
email: z.string().email('Ingresá un email válido.'),
|
||||||
|
phone: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.refine(
|
||||||
|
(val) => !val || val.replace(/\D/g, '').length >= 7,
|
||||||
|
'El teléfono debe tener al menos 7 dígitos.'
|
||||||
|
),
|
||||||
|
password: z.string().min(6, 'La contraseña debe tener al menos 6 caracteres.'),
|
||||||
|
confirmPassword: z.string(),
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
|
message: 'Las contraseñas no coinciden.',
|
||||||
|
path: ['confirmPassword'],
|
||||||
|
});
|
||||||
|
|
||||||
|
type SignupForm = z.infer<typeof signupSchema>;
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'signup-form-pending';
|
||||||
|
|
||||||
|
function loadFormState(): Partial<SignupForm> {
|
||||||
|
try {
|
||||||
|
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) return JSON.parse(saved);
|
||||||
|
} catch {}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SignupPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { signUp } = useAuth();
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors, isValid },
|
||||||
|
watch,
|
||||||
|
} = useForm<SignupForm>({
|
||||||
|
resolver: zodResolver(signupSchema),
|
||||||
|
mode: 'onChange',
|
||||||
|
defaultValues: loadFormState(),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sub = watch((values) => {
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(values));
|
||||||
|
});
|
||||||
|
return () => sub.unsubscribe();
|
||||||
|
}, [watch]);
|
||||||
|
|
||||||
|
const handleGoogleSignIn = async () => {
|
||||||
|
setSubmitError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authClient.signIn.social({
|
||||||
|
provider: 'google',
|
||||||
|
callbackURL: `${window.location.origin}/auth-callback`,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setSubmitError('No pudimos iniciar sesión con Google.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (values: SignupForm) => {
|
||||||
|
setSubmitError(null);
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { requiresEmailConfirmation } = await signUp({
|
||||||
|
email: values.email,
|
||||||
|
password: values.password,
|
||||||
|
fullName: values.fullName,
|
||||||
|
phone: values.phone || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
sessionStorage.removeItem(STORAGE_KEY);
|
||||||
|
|
||||||
|
if (requiresEmailConfirmation) {
|
||||||
|
sessionStorage.setItem('pending-signup-email', values.email);
|
||||||
|
await navigate({ to: '/onboard/setup' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setSubmitError('No pudimos crear la cuenta. Intenta nuevamente.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen w-full flex-col items-center justify-center gap-6 px-3 sm:px-6">
|
||||||
|
<Link to="/" className="group flex items-center gap-3">
|
||||||
|
<div className="flex size-10 items-center justify-center">
|
||||||
|
<img src={PlayzerIcon} alt="Playzer" className="size-7" />
|
||||||
|
</div>
|
||||||
|
<span className="bg-gradient-to-r from-primary via-primary to-reserved bg-clip-text text-xl font-bold tracking-tight text-transparent">
|
||||||
|
Playzer
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<section className="w-full max-w-sm rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
||||||
|
<h1 className="mb-2 text-2xl font-semibold">Crear cuenta</h1>
|
||||||
|
<p className="mb-4 text-sm text-muted-foreground">
|
||||||
|
Creá tu cuenta para comenzar a usar Playzer.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button type="button" variant="outline" className="w-full" onClick={handleGoogleSignIn}>
|
||||||
|
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Continuar con Google
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="relative my-4">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<span className="w-full border-t" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-xs uppercase">
|
||||||
|
<span className="bg-card px-2 text-muted-foreground">O</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="space-y-3" onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<Field data-invalid={Boolean(errors.fullName)}>
|
||||||
|
<FieldLabel htmlFor="fullName">Nombre completo</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="fullName"
|
||||||
|
type="text"
|
||||||
|
placeholder="Juan Pérez"
|
||||||
|
aria-invalid={Boolean(errors.fullName)}
|
||||||
|
{...register('fullName')}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.fullName]} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field data-invalid={Boolean(errors.email)}>
|
||||||
|
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="tu@email.com"
|
||||||
|
aria-invalid={Boolean(errors.email)}
|
||||||
|
{...register('email')}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.email]} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field data-invalid={Boolean(errors.phone)}>
|
||||||
|
<FieldLabel htmlFor="phone">
|
||||||
|
Teléfono <span className="text-muted-foreground">(opcional)</span>
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
type="tel"
|
||||||
|
placeholder="+54 11 1234-5678"
|
||||||
|
aria-invalid={Boolean(errors.phone)}
|
||||||
|
{...register('phone')}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.phone]} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field data-invalid={Boolean(errors.password)}>
|
||||||
|
<FieldLabel htmlFor="password">Contraseña</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
aria-invalid={Boolean(errors.password)}
|
||||||
|
{...register('password')}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.password]} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field data-invalid={Boolean(errors.confirmPassword)}>
|
||||||
|
<FieldLabel htmlFor="confirmPassword">Confirmar contraseña</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
aria-invalid={Boolean(errors.confirmPassword)}
|
||||||
|
{...register('confirmPassword')}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.confirmPassword]} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{submitError && <p className="text-sm text-destructive">{submitError}</p>}
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={!isValid || isSubmitting}>
|
||||||
|
{isSubmitting ? 'Creando cuenta...' : 'Crear cuenta'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||||
|
¿Ya tenés cuenta?{' '}
|
||||||
|
<Link to="/login" className="text-primary hover:underline">
|
||||||
|
Iniciar sesión
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -54,9 +54,6 @@ body,
|
|||||||
--color-reserved: var(--reserved);
|
--color-reserved: var(--reserved);
|
||||||
--color-reserved-foreground: var(--reserved-foreground);
|
--color-reserved-foreground: var(--reserved-foreground);
|
||||||
|
|
||||||
--color-maintenance: var(--maintenance);
|
|
||||||
--color-maintenance-foreground: var(--maintenance-foreground);
|
|
||||||
|
|
||||||
--color-warning: var(--warning);
|
--color-warning: var(--warning);
|
||||||
--color-warning-foreground: var(--warning-foreground);
|
--color-warning-foreground: var(--warning-foreground);
|
||||||
|
|
||||||
@@ -117,9 +114,6 @@ body,
|
|||||||
--reserved: oklch(0.58 0.19 255);
|
--reserved: oklch(0.58 0.19 255);
|
||||||
--reserved-foreground: oklch(0.985 0.01 255);
|
--reserved-foreground: oklch(0.985 0.01 255);
|
||||||
|
|
||||||
--maintenance: oklch(0.64 0.22 25);
|
|
||||||
--maintenance-foreground: oklch(0.985 0.01 25);
|
|
||||||
|
|
||||||
--warning: oklch(0.78 0.16 75);
|
--warning: oklch(0.78 0.16 75);
|
||||||
--warning-foreground: oklch(0.18 0.04 75);
|
--warning-foreground: oklch(0.18 0.04 75);
|
||||||
|
|
||||||
@@ -176,9 +170,6 @@ body,
|
|||||||
--reserved: oklch(0.61 0.2 255);
|
--reserved: oklch(0.61 0.2 255);
|
||||||
--reserved-foreground: oklch(0.97 0.012 255);
|
--reserved-foreground: oklch(0.97 0.012 255);
|
||||||
|
|
||||||
--maintenance: oklch(0.66 0.23 25);
|
|
||||||
--maintenance-foreground: oklch(0.98 0.01 25);
|
|
||||||
|
|
||||||
--warning: oklch(0.78 0.17 75);
|
--warning: oklch(0.78 0.17 75);
|
||||||
--warning-foreground: oklch(0.18 0.04 75);
|
--warning-foreground: oklch(0.18 0.04 75);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
import { sentinelClient } from '@better-auth/infra/client';
|
import { sentinelClient } from '@better-auth/infra/client';
|
||||||
|
import { inferAdditionalFields } from 'better-auth/client/plugins';
|
||||||
import { createAuthClient } from 'better-auth/react';
|
import { createAuthClient } from 'better-auth/react';
|
||||||
import * as api from './api';
|
import * as api from './api';
|
||||||
|
|
||||||
const apiBaseUrl = api.apiBaseUrl;
|
const apiBaseUrl = api.apiBaseUrl;
|
||||||
|
|
||||||
const plugins = [sentinelClient()];
|
const plugins = [
|
||||||
|
sentinelClient(),
|
||||||
|
inferAdditionalFields({
|
||||||
|
user: {
|
||||||
|
phone: {
|
||||||
|
type: 'string',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
export const authClient = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
baseURL: apiBaseUrl,
|
baseURL: apiBaseUrl,
|
||||||
@@ -32,7 +43,6 @@ export class ApiClientError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const apiClient = {
|
export const apiClient = {
|
||||||
onboarding: api.onboarding,
|
|
||||||
plans: api.plans,
|
plans: api.plans,
|
||||||
user: api.user,
|
user: api.user,
|
||||||
complexes: api.complexes,
|
complexes: api.complexes,
|
||||||
@@ -42,6 +52,7 @@ export const apiClient = {
|
|||||||
getAvailability: api.getAvailability,
|
getAvailability: api.getAvailability,
|
||||||
create: api.createPublic,
|
create: api.createPublic,
|
||||||
getConfirmation: api.getConfirmation,
|
getConfirmation: api.getConfirmation,
|
||||||
|
cancelPublic: api.cancelPublic,
|
||||||
},
|
},
|
||||||
adminBookings: {
|
adminBookings: {
|
||||||
listByComplex: api.listByComplex,
|
listByComplex: api.listByComplex,
|
||||||
|
|||||||
@@ -10,13 +10,35 @@ export const http: AxiosInstance = axios.create({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function extractMessage(details: unknown, fallback: string): string {
|
function extractMessage(details: unknown, fallback: string): string {
|
||||||
if (
|
if (typeof details === 'object' && details) {
|
||||||
typeof details === 'object' &&
|
if ('message' in details && typeof details.message === 'string') {
|
||||||
details &&
|
return details.message;
|
||||||
'message' in details &&
|
}
|
||||||
typeof details.message === 'string'
|
if ('detail' in details && typeof details.detail === 'string') {
|
||||||
) {
|
return details.detail;
|
||||||
return details.message;
|
}
|
||||||
|
if ('error' in details && typeof details.error === 'object' && details.error) {
|
||||||
|
const zodError = details.error as Record<string, unknown>;
|
||||||
|
if (Array.isArray(zodError.issues) && zodError.issues.length > 0) {
|
||||||
|
const firstIssue = zodError.issues[0] as { message?: string };
|
||||||
|
if (typeof firstIssue.message === 'string') return firstIssue.message;
|
||||||
|
}
|
||||||
|
if (typeof zodError.message === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(zodError.message);
|
||||||
|
if (
|
||||||
|
Array.isArray(parsed) &&
|
||||||
|
parsed.length > 0 &&
|
||||||
|
typeof parsed[0]?.message === 'string'
|
||||||
|
) {
|
||||||
|
return parsed[0].message;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* not JSON, use raw string */
|
||||||
|
}
|
||||||
|
return zodError.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ export * as courts from './resources/courts';
|
|||||||
export * as user from './resources/user';
|
export * as user from './resources/user';
|
||||||
export * as sports from './resources/sports';
|
export * as sports from './resources/sports';
|
||||||
export * as plans from './resources/plans';
|
export * as plans from './resources/plans';
|
||||||
export * as onboarding from './resources/onboarding';
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
cancelPublic,
|
||||||
getAvailability,
|
getAvailability,
|
||||||
createPublic,
|
createPublic,
|
||||||
getConfirmation,
|
getConfirmation,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
AdminBooking,
|
AdminBooking,
|
||||||
|
CancelPublicBookingInput,
|
||||||
CreateAdminBookingInput,
|
CreateAdminBookingInput,
|
||||||
CreatePublicBookingInput,
|
CreatePublicBookingInput,
|
||||||
PublicAvailabilityResponse,
|
PublicAvailabilityResponse,
|
||||||
@@ -51,6 +52,14 @@ export async function createAdmin(complexId: string, payload: CreateAdminBooking
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function cancelPublic(complexSlug: string, payload: CancelPublicBookingInput) {
|
||||||
|
const response = await http.post<PublicBooking>(
|
||||||
|
`/api/public-bookings/complex/${complexSlug}/cancel`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateStatus(bookingId: string, payload: UpdateAdminBookingStatusInput) {
|
export async function updateStatus(bookingId: string, payload: UpdateAdminBookingStatusInput) {
|
||||||
const response = await http.patch<AdminBooking>(
|
const response = await http.patch<AdminBooking>(
|
||||||
`/api/admin-bookings/${bookingId}/status`,
|
`/api/admin-bookings/${bookingId}/status`,
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import type {
|
|
||||||
OnboardingCompleteInput,
|
|
||||||
OnboardingCompleteResponse,
|
|
||||||
OnboardingResendOtpInput,
|
|
||||||
OnboardingResendOtpResponse,
|
|
||||||
OnboardingStartInput,
|
|
||||||
OnboardingStartResponse,
|
|
||||||
OnboardingVerifyOtpInput,
|
|
||||||
OnboardingVerifyOtpResponse,
|
|
||||||
} from '@repo/api-contract';
|
|
||||||
import { http } from '../http';
|
|
||||||
|
|
||||||
export async function start(payload: OnboardingStartInput) {
|
|
||||||
const response = await http.post<OnboardingStartResponse>('/api/onboarding/start', payload);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function verifyOtp(payload: OnboardingVerifyOtpInput) {
|
|
||||||
const response = await http.post<OnboardingVerifyOtpResponse>(
|
|
||||||
'/api/onboarding/verify-otp',
|
|
||||||
payload
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resendOtp(payload: OnboardingResendOtpInput) {
|
|
||||||
const response = await http.post<OnboardingResendOtpResponse>(
|
|
||||||
'/api/onboarding/resend-otp',
|
|
||||||
payload
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function complete(payload: OnboardingCompleteInput) {
|
|
||||||
const response = await http.post<OnboardingCompleteResponse>('/api/onboarding/complete', payload);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { PlanSummary } from '@repo/api-contract';
|
import type { PlanWithFeatures } from '@repo/api-contract';
|
||||||
import { http } from '../http';
|
import { http } from '../http';
|
||||||
|
|
||||||
export async function list() {
|
export async function list() {
|
||||||
const response = await http.get<PlanSummary[]>('/api/plans');
|
const response = await http.get<PlanWithFeatures[]>('/api/plans');
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type SignUpParams = {
|
|||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
|
phone?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UpdateProfileParams = {
|
type UpdateProfileParams = {
|
||||||
@@ -162,11 +163,12 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
signUp: async ({ email, password, fullName }) => {
|
signUp: async ({ email, password, fullName, phone }) => {
|
||||||
const { data: signUpData, error } = await authClient.signUp.email({
|
const { data: signUpData, error } = await authClient.signUp.email({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
name: fullName,
|
name: fullName,
|
||||||
|
phone,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
@@ -6,21 +6,32 @@ import { QueryClientProvider } from '@tanstack/react-query';
|
|||||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||||
import { RouterProvider } from '@tanstack/react-router';
|
import { RouterProvider } from '@tanstack/react-router';
|
||||||
import { TanStackRouterDevtools } from '@tanstack/router-devtools';
|
import { TanStackRouterDevtools } from '@tanstack/router-devtools';
|
||||||
import { StrictMode, useEffect } from 'react';
|
import { StrictMode, useEffect, useRef } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { router } from './router';
|
import { router } from './router';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
function AppRouter() {
|
function AppRouter() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
|
const prevAuthRef = useRef(auth);
|
||||||
|
|
||||||
|
if (auth !== prevAuthRef.current) {
|
||||||
|
prevAuthRef.current = auth;
|
||||||
|
router.update({ context: { auth, api: apiClient } });
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
router.update({ context: { auth, api: apiClient } });
|
||||||
router.invalidate();
|
router.invalidate();
|
||||||
}, [auth.isAuthenticated, auth.loading, auth.user]);
|
}, [auth.isAuthenticated, auth.loading, auth.user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
configureApiClient({
|
configureApiClient({
|
||||||
onForbidden: async () => {
|
onForbidden: async (error) => {
|
||||||
|
if (error.message?.includes('verificar tu email')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!auth.isAuthenticated) {
|
if (!auth.isAuthenticated) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
|
import { Route as SignupRouteImport } from './routes/signup'
|
||||||
import { Route as SelectComplexRouteImport } from './routes/select-complex'
|
import { Route as SelectComplexRouteImport } from './routes/select-complex'
|
||||||
import { Route as ResetPasswordRouteImport } from './routes/reset-password'
|
import { Route as ResetPasswordRouteImport } from './routes/reset-password'
|
||||||
import { Route as OnboardRouteImport } from './routes/onboard'
|
import { Route as OnboardRouteImport } from './routes/onboard'
|
||||||
@@ -16,13 +17,8 @@ import { Route as LoginRouteImport } from './routes/login'
|
|||||||
import { Route as InviteRouteImport } from './routes/invite'
|
import { Route as InviteRouteImport } from './routes/invite'
|
||||||
import { Route as AuthCallbackRouteImport } from './routes/auth-callback'
|
import { Route as AuthCallbackRouteImport } from './routes/auth-callback'
|
||||||
import { Route as AppRouteRouteImport } from './routes/_app/route'
|
import { Route as AppRouteRouteImport } from './routes/_app/route'
|
||||||
import { Route as OnboardIndexRouteImport } from './routes/onboard/index'
|
import { Route as OnboardSetupRouteImport } from './routes/onboard/setup'
|
||||||
import { Route as OnboardVerifyEmailRouteImport } from './routes/onboard/verify-email'
|
|
||||||
import { Route as OnboardVerifyRouteImport } from './routes/onboard/verify'
|
|
||||||
import { Route as OnboardCreateComplexRouteImport } from './routes/onboard/create-complex'
|
|
||||||
import { Route as OnboardCompleteRouteImport } from './routes/onboard/complete'
|
|
||||||
import { Route as AppProfileRouteImport } from './routes/_app/profile'
|
import { Route as AppProfileRouteImport } from './routes/_app/profile'
|
||||||
import { Route as AppAboutRouteImport } from './routes/_app/about'
|
|
||||||
import { Route as ComplexSlugBookingRouteImport } from './routes/$complexSlug/booking'
|
import { Route as ComplexSlugBookingRouteImport } from './routes/$complexSlug/booking'
|
||||||
import { Route as AppAuthenticatedRouteRouteImport } from './routes/_app/_authenticated/route'
|
import { Route as AppAuthenticatedRouteRouteImport } from './routes/_app/_authenticated/route'
|
||||||
import { Route as AppAuthenticatedIndexRouteImport } from './routes/_app/_authenticated/index'
|
import { Route as AppAuthenticatedIndexRouteImport } from './routes/_app/_authenticated/index'
|
||||||
@@ -30,6 +26,11 @@ import { Route as ComplexSlugBookingIndexRouteImport } from './routes/$complexSl
|
|||||||
import { Route as ComplexSlugBookingConfirmedBookingCodeRouteImport } from './routes/$complexSlug/booking/confirmed/$bookingCode'
|
import { Route as ComplexSlugBookingConfirmedBookingCodeRouteImport } from './routes/$complexSlug/booking/confirmed/$bookingCode'
|
||||||
import { Route as AppAuthenticatedComplexSlugEditRouteImport } from './routes/_app/_authenticated/complex/$slug/edit'
|
import { Route as AppAuthenticatedComplexSlugEditRouteImport } from './routes/_app/_authenticated/complex/$slug/edit'
|
||||||
|
|
||||||
|
const SignupRoute = SignupRouteImport.update({
|
||||||
|
id: '/signup',
|
||||||
|
path: '/signup',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const SelectComplexRoute = SelectComplexRouteImport.update({
|
const SelectComplexRoute = SelectComplexRouteImport.update({
|
||||||
id: '/select-complex',
|
id: '/select-complex',
|
||||||
path: '/select-complex',
|
path: '/select-complex',
|
||||||
@@ -64,29 +65,9 @@ const AppRouteRoute = AppRouteRouteImport.update({
|
|||||||
id: '/_app',
|
id: '/_app',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const OnboardIndexRoute = OnboardIndexRouteImport.update({
|
const OnboardSetupRoute = OnboardSetupRouteImport.update({
|
||||||
id: '/',
|
id: '/setup',
|
||||||
path: '/',
|
path: '/setup',
|
||||||
getParentRoute: () => OnboardRoute,
|
|
||||||
} as any)
|
|
||||||
const OnboardVerifyEmailRoute = OnboardVerifyEmailRouteImport.update({
|
|
||||||
id: '/verify-email',
|
|
||||||
path: '/verify-email',
|
|
||||||
getParentRoute: () => OnboardRoute,
|
|
||||||
} as any)
|
|
||||||
const OnboardVerifyRoute = OnboardVerifyRouteImport.update({
|
|
||||||
id: '/verify',
|
|
||||||
path: '/verify',
|
|
||||||
getParentRoute: () => OnboardRoute,
|
|
||||||
} as any)
|
|
||||||
const OnboardCreateComplexRoute = OnboardCreateComplexRouteImport.update({
|
|
||||||
id: '/create-complex',
|
|
||||||
path: '/create-complex',
|
|
||||||
getParentRoute: () => OnboardRoute,
|
|
||||||
} as any)
|
|
||||||
const OnboardCompleteRoute = OnboardCompleteRouteImport.update({
|
|
||||||
id: '/complete',
|
|
||||||
path: '/complete',
|
|
||||||
getParentRoute: () => OnboardRoute,
|
getParentRoute: () => OnboardRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const AppProfileRoute = AppProfileRouteImport.update({
|
const AppProfileRoute = AppProfileRouteImport.update({
|
||||||
@@ -94,11 +75,6 @@ const AppProfileRoute = AppProfileRouteImport.update({
|
|||||||
path: '/profile',
|
path: '/profile',
|
||||||
getParentRoute: () => AppRouteRoute,
|
getParentRoute: () => AppRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const AppAboutRoute = AppAboutRouteImport.update({
|
|
||||||
id: '/about',
|
|
||||||
path: '/about',
|
|
||||||
getParentRoute: () => AppRouteRoute,
|
|
||||||
} as any)
|
|
||||||
const ComplexSlugBookingRoute = ComplexSlugBookingRouteImport.update({
|
const ComplexSlugBookingRoute = ComplexSlugBookingRouteImport.update({
|
||||||
id: '/$complexSlug/booking',
|
id: '/$complexSlug/booking',
|
||||||
path: '/$complexSlug/booking',
|
path: '/$complexSlug/booking',
|
||||||
@@ -139,14 +115,10 @@ export interface FileRoutesByFullPath {
|
|||||||
'/onboard': typeof OnboardRouteWithChildren
|
'/onboard': typeof OnboardRouteWithChildren
|
||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/select-complex': typeof SelectComplexRoute
|
'/select-complex': typeof SelectComplexRoute
|
||||||
|
'/signup': typeof SignupRoute
|
||||||
'/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren
|
'/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren
|
||||||
'/about': typeof AppAboutRoute
|
|
||||||
'/profile': typeof AppProfileRoute
|
'/profile': typeof AppProfileRoute
|
||||||
'/onboard/complete': typeof OnboardCompleteRoute
|
'/onboard/setup': typeof OnboardSetupRoute
|
||||||
'/onboard/create-complex': typeof OnboardCreateComplexRoute
|
|
||||||
'/onboard/verify': typeof OnboardVerifyRoute
|
|
||||||
'/onboard/verify-email': typeof OnboardVerifyEmailRoute
|
|
||||||
'/onboard/': typeof OnboardIndexRoute
|
|
||||||
'/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute
|
'/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute
|
||||||
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
|
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
|
||||||
'/complex/$slug/edit': typeof AppAuthenticatedComplexSlugEditRoute
|
'/complex/$slug/edit': typeof AppAuthenticatedComplexSlugEditRoute
|
||||||
@@ -156,15 +128,12 @@ export interface FileRoutesByTo {
|
|||||||
'/auth-callback': typeof AuthCallbackRoute
|
'/auth-callback': typeof AuthCallbackRoute
|
||||||
'/invite': typeof InviteRoute
|
'/invite': typeof InviteRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
|
'/onboard': typeof OnboardRouteWithChildren
|
||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/select-complex': typeof SelectComplexRoute
|
'/select-complex': typeof SelectComplexRoute
|
||||||
'/about': typeof AppAboutRoute
|
'/signup': typeof SignupRoute
|
||||||
'/profile': typeof AppProfileRoute
|
'/profile': typeof AppProfileRoute
|
||||||
'/onboard/complete': typeof OnboardCompleteRoute
|
'/onboard/setup': typeof OnboardSetupRoute
|
||||||
'/onboard/create-complex': typeof OnboardCreateComplexRoute
|
|
||||||
'/onboard/verify': typeof OnboardVerifyRoute
|
|
||||||
'/onboard/verify-email': typeof OnboardVerifyEmailRoute
|
|
||||||
'/onboard': typeof OnboardIndexRoute
|
|
||||||
'/$complexSlug/booking': typeof ComplexSlugBookingIndexRoute
|
'/$complexSlug/booking': typeof ComplexSlugBookingIndexRoute
|
||||||
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
|
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
|
||||||
'/complex/$slug/edit': typeof AppAuthenticatedComplexSlugEditRoute
|
'/complex/$slug/edit': typeof AppAuthenticatedComplexSlugEditRoute
|
||||||
@@ -178,15 +147,11 @@ export interface FileRoutesById {
|
|||||||
'/onboard': typeof OnboardRouteWithChildren
|
'/onboard': typeof OnboardRouteWithChildren
|
||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/select-complex': typeof SelectComplexRoute
|
'/select-complex': typeof SelectComplexRoute
|
||||||
|
'/signup': typeof SignupRoute
|
||||||
'/_app/_authenticated': typeof AppAuthenticatedRouteRouteWithChildren
|
'/_app/_authenticated': typeof AppAuthenticatedRouteRouteWithChildren
|
||||||
'/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren
|
'/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren
|
||||||
'/_app/about': typeof AppAboutRoute
|
|
||||||
'/_app/profile': typeof AppProfileRoute
|
'/_app/profile': typeof AppProfileRoute
|
||||||
'/onboard/complete': typeof OnboardCompleteRoute
|
'/onboard/setup': typeof OnboardSetupRoute
|
||||||
'/onboard/create-complex': typeof OnboardCreateComplexRoute
|
|
||||||
'/onboard/verify': typeof OnboardVerifyRoute
|
|
||||||
'/onboard/verify-email': typeof OnboardVerifyEmailRoute
|
|
||||||
'/onboard/': typeof OnboardIndexRoute
|
|
||||||
'/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute
|
'/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute
|
||||||
'/_app/_authenticated/': typeof AppAuthenticatedIndexRoute
|
'/_app/_authenticated/': typeof AppAuthenticatedIndexRoute
|
||||||
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
|
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
|
||||||
@@ -202,14 +167,10 @@ export interface FileRouteTypes {
|
|||||||
| '/onboard'
|
| '/onboard'
|
||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/select-complex'
|
| '/select-complex'
|
||||||
|
| '/signup'
|
||||||
| '/$complexSlug/booking'
|
| '/$complexSlug/booking'
|
||||||
| '/about'
|
|
||||||
| '/profile'
|
| '/profile'
|
||||||
| '/onboard/complete'
|
| '/onboard/setup'
|
||||||
| '/onboard/create-complex'
|
|
||||||
| '/onboard/verify'
|
|
||||||
| '/onboard/verify-email'
|
|
||||||
| '/onboard/'
|
|
||||||
| '/$complexSlug/booking/'
|
| '/$complexSlug/booking/'
|
||||||
| '/$complexSlug/booking/confirmed/$bookingCode'
|
| '/$complexSlug/booking/confirmed/$bookingCode'
|
||||||
| '/complex/$slug/edit'
|
| '/complex/$slug/edit'
|
||||||
@@ -219,15 +180,12 @@ export interface FileRouteTypes {
|
|||||||
| '/auth-callback'
|
| '/auth-callback'
|
||||||
| '/invite'
|
| '/invite'
|
||||||
| '/login'
|
| '/login'
|
||||||
|
| '/onboard'
|
||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/select-complex'
|
| '/select-complex'
|
||||||
| '/about'
|
| '/signup'
|
||||||
| '/profile'
|
| '/profile'
|
||||||
| '/onboard/complete'
|
| '/onboard/setup'
|
||||||
| '/onboard/create-complex'
|
|
||||||
| '/onboard/verify'
|
|
||||||
| '/onboard/verify-email'
|
|
||||||
| '/onboard'
|
|
||||||
| '/$complexSlug/booking'
|
| '/$complexSlug/booking'
|
||||||
| '/$complexSlug/booking/confirmed/$bookingCode'
|
| '/$complexSlug/booking/confirmed/$bookingCode'
|
||||||
| '/complex/$slug/edit'
|
| '/complex/$slug/edit'
|
||||||
@@ -240,15 +198,11 @@ export interface FileRouteTypes {
|
|||||||
| '/onboard'
|
| '/onboard'
|
||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/select-complex'
|
| '/select-complex'
|
||||||
|
| '/signup'
|
||||||
| '/_app/_authenticated'
|
| '/_app/_authenticated'
|
||||||
| '/$complexSlug/booking'
|
| '/$complexSlug/booking'
|
||||||
| '/_app/about'
|
|
||||||
| '/_app/profile'
|
| '/_app/profile'
|
||||||
| '/onboard/complete'
|
| '/onboard/setup'
|
||||||
| '/onboard/create-complex'
|
|
||||||
| '/onboard/verify'
|
|
||||||
| '/onboard/verify-email'
|
|
||||||
| '/onboard/'
|
|
||||||
| '/$complexSlug/booking/'
|
| '/$complexSlug/booking/'
|
||||||
| '/_app/_authenticated/'
|
| '/_app/_authenticated/'
|
||||||
| '/$complexSlug/booking/confirmed/$bookingCode'
|
| '/$complexSlug/booking/confirmed/$bookingCode'
|
||||||
@@ -263,11 +217,19 @@ export interface RootRouteChildren {
|
|||||||
OnboardRoute: typeof OnboardRouteWithChildren
|
OnboardRoute: typeof OnboardRouteWithChildren
|
||||||
ResetPasswordRoute: typeof ResetPasswordRoute
|
ResetPasswordRoute: typeof ResetPasswordRoute
|
||||||
SelectComplexRoute: typeof SelectComplexRoute
|
SelectComplexRoute: typeof SelectComplexRoute
|
||||||
|
SignupRoute: typeof SignupRoute
|
||||||
ComplexSlugBookingRoute: typeof ComplexSlugBookingRouteWithChildren
|
ComplexSlugBookingRoute: typeof ComplexSlugBookingRouteWithChildren
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
|
'/signup': {
|
||||||
|
id: '/signup'
|
||||||
|
path: '/signup'
|
||||||
|
fullPath: '/signup'
|
||||||
|
preLoaderRoute: typeof SignupRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/select-complex': {
|
'/select-complex': {
|
||||||
id: '/select-complex'
|
id: '/select-complex'
|
||||||
path: '/select-complex'
|
path: '/select-complex'
|
||||||
@@ -317,39 +279,11 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AppRouteRouteImport
|
preLoaderRoute: typeof AppRouteRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/onboard/': {
|
'/onboard/setup': {
|
||||||
id: '/onboard/'
|
id: '/onboard/setup'
|
||||||
path: '/'
|
path: '/setup'
|
||||||
fullPath: '/onboard/'
|
fullPath: '/onboard/setup'
|
||||||
preLoaderRoute: typeof OnboardIndexRouteImport
|
preLoaderRoute: typeof OnboardSetupRouteImport
|
||||||
parentRoute: typeof OnboardRoute
|
|
||||||
}
|
|
||||||
'/onboard/verify-email': {
|
|
||||||
id: '/onboard/verify-email'
|
|
||||||
path: '/verify-email'
|
|
||||||
fullPath: '/onboard/verify-email'
|
|
||||||
preLoaderRoute: typeof OnboardVerifyEmailRouteImport
|
|
||||||
parentRoute: typeof OnboardRoute
|
|
||||||
}
|
|
||||||
'/onboard/verify': {
|
|
||||||
id: '/onboard/verify'
|
|
||||||
path: '/verify'
|
|
||||||
fullPath: '/onboard/verify'
|
|
||||||
preLoaderRoute: typeof OnboardVerifyRouteImport
|
|
||||||
parentRoute: typeof OnboardRoute
|
|
||||||
}
|
|
||||||
'/onboard/create-complex': {
|
|
||||||
id: '/onboard/create-complex'
|
|
||||||
path: '/create-complex'
|
|
||||||
fullPath: '/onboard/create-complex'
|
|
||||||
preLoaderRoute: typeof OnboardCreateComplexRouteImport
|
|
||||||
parentRoute: typeof OnboardRoute
|
|
||||||
}
|
|
||||||
'/onboard/complete': {
|
|
||||||
id: '/onboard/complete'
|
|
||||||
path: '/complete'
|
|
||||||
fullPath: '/onboard/complete'
|
|
||||||
preLoaderRoute: typeof OnboardCompleteRouteImport
|
|
||||||
parentRoute: typeof OnboardRoute
|
parentRoute: typeof OnboardRoute
|
||||||
}
|
}
|
||||||
'/_app/profile': {
|
'/_app/profile': {
|
||||||
@@ -359,13 +293,6 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AppProfileRouteImport
|
preLoaderRoute: typeof AppProfileRouteImport
|
||||||
parentRoute: typeof AppRouteRoute
|
parentRoute: typeof AppRouteRoute
|
||||||
}
|
}
|
||||||
'/_app/about': {
|
|
||||||
id: '/_app/about'
|
|
||||||
path: '/about'
|
|
||||||
fullPath: '/about'
|
|
||||||
preLoaderRoute: typeof AppAboutRouteImport
|
|
||||||
parentRoute: typeof AppRouteRoute
|
|
||||||
}
|
|
||||||
'/$complexSlug/booking': {
|
'/$complexSlug/booking': {
|
||||||
id: '/$complexSlug/booking'
|
id: '/$complexSlug/booking'
|
||||||
path: '/$complexSlug/booking'
|
path: '/$complexSlug/booking'
|
||||||
@@ -428,13 +355,11 @@ const AppAuthenticatedRouteRouteWithChildren =
|
|||||||
|
|
||||||
interface AppRouteRouteChildren {
|
interface AppRouteRouteChildren {
|
||||||
AppAuthenticatedRouteRoute: typeof AppAuthenticatedRouteRouteWithChildren
|
AppAuthenticatedRouteRoute: typeof AppAuthenticatedRouteRouteWithChildren
|
||||||
AppAboutRoute: typeof AppAboutRoute
|
|
||||||
AppProfileRoute: typeof AppProfileRoute
|
AppProfileRoute: typeof AppProfileRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppRouteRouteChildren: AppRouteRouteChildren = {
|
const AppRouteRouteChildren: AppRouteRouteChildren = {
|
||||||
AppAuthenticatedRouteRoute: AppAuthenticatedRouteRouteWithChildren,
|
AppAuthenticatedRouteRoute: AppAuthenticatedRouteRouteWithChildren,
|
||||||
AppAboutRoute: AppAboutRoute,
|
|
||||||
AppProfileRoute: AppProfileRoute,
|
AppProfileRoute: AppProfileRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,19 +368,11 @@ const AppRouteRouteWithChildren = AppRouteRoute._addFileChildren(
|
|||||||
)
|
)
|
||||||
|
|
||||||
interface OnboardRouteChildren {
|
interface OnboardRouteChildren {
|
||||||
OnboardCompleteRoute: typeof OnboardCompleteRoute
|
OnboardSetupRoute: typeof OnboardSetupRoute
|
||||||
OnboardCreateComplexRoute: typeof OnboardCreateComplexRoute
|
|
||||||
OnboardVerifyRoute: typeof OnboardVerifyRoute
|
|
||||||
OnboardVerifyEmailRoute: typeof OnboardVerifyEmailRoute
|
|
||||||
OnboardIndexRoute: typeof OnboardIndexRoute
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const OnboardRouteChildren: OnboardRouteChildren = {
|
const OnboardRouteChildren: OnboardRouteChildren = {
|
||||||
OnboardCompleteRoute: OnboardCompleteRoute,
|
OnboardSetupRoute: OnboardSetupRoute,
|
||||||
OnboardCreateComplexRoute: OnboardCreateComplexRoute,
|
|
||||||
OnboardVerifyRoute: OnboardVerifyRoute,
|
|
||||||
OnboardVerifyEmailRoute: OnboardVerifyEmailRoute,
|
|
||||||
OnboardIndexRoute: OnboardIndexRoute,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const OnboardRouteWithChildren =
|
const OnboardRouteWithChildren =
|
||||||
@@ -483,6 +400,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
OnboardRoute: OnboardRouteWithChildren,
|
OnboardRoute: OnboardRouteWithChildren,
|
||||||
ResetPasswordRoute: ResetPasswordRoute,
|
ResetPasswordRoute: ResetPasswordRoute,
|
||||||
SelectComplexRoute: SelectComplexRoute,
|
SelectComplexRoute: SelectComplexRoute,
|
||||||
|
SignupRoute: SignupRoute,
|
||||||
ComplexSlugBookingRoute: ComplexSlugBookingRouteWithChildren,
|
ComplexSlugBookingRoute: ComplexSlugBookingRouteWithChildren,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { PublicBookingConfirmationPage } from '@/features/public-booking/public-booking-confirmation-page';
|
import { createFileRoute, lazyRouteComponent } from '@tanstack/react-router';
|
||||||
import { createFileRoute } from '@tanstack/react-router';
|
|
||||||
|
const PublicBookingConfirmationPage = lazyRouteComponent(
|
||||||
|
() => import('@/features/public-booking/public-booking-confirmation-page'),
|
||||||
|
'PublicBookingConfirmationPage'
|
||||||
|
);
|
||||||
|
|
||||||
export const Route = createFileRoute('/$complexSlug/booking/confirmed/$bookingCode')({
|
export const Route = createFileRoute('/$complexSlug/booking/confirmed/$bookingCode')({
|
||||||
component: PublicBookingConfirmationRouteComponent,
|
component: RouteComponent,
|
||||||
});
|
});
|
||||||
|
|
||||||
function PublicBookingConfirmationRouteComponent() {
|
function RouteComponent() {
|
||||||
const { complexSlug, bookingCode } = Route.useParams();
|
const { complexSlug, bookingCode } = Route.useParams();
|
||||||
|
|
||||||
return <PublicBookingConfirmationPage complexSlug={complexSlug} bookingCode={bookingCode} />;
|
return <PublicBookingConfirmationPage complexSlug={complexSlug} bookingCode={bookingCode} />;
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { PublicBookingPage } from '@/features/public-booking/public-booking-page';
|
import { createFileRoute, lazyRouteComponent } from '@tanstack/react-router';
|
||||||
import { createFileRoute } from '@tanstack/react-router';
|
|
||||||
|
const PublicBookingPage = lazyRouteComponent(
|
||||||
|
() => import('@/features/public-booking/public-booking-page'),
|
||||||
|
'PublicBookingPage'
|
||||||
|
);
|
||||||
|
|
||||||
export const Route = createFileRoute('/$complexSlug/booking/')({
|
export const Route = createFileRoute('/$complexSlug/booking/')({
|
||||||
component: PublicBookingIndexRouteComponent,
|
component: RouteComponent,
|
||||||
});
|
});
|
||||||
|
|
||||||
function PublicBookingIndexRouteComponent() {
|
function RouteComponent() {
|
||||||
const { complexSlug } = Route.useParams();
|
const { complexSlug } = Route.useParams();
|
||||||
|
|
||||||
return <PublicBookingPage complexSlug={complexSlug} />;
|
return <PublicBookingPage complexSlug={complexSlug} />;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user