Compare commits
9 Commits
93fea8ecad
...
ui-redesig
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
000277b312 | ||
|
|
7a3452c2d1 | ||
|
|
d6e32f3e84 | ||
|
|
82dbc8ad99 | ||
|
|
fb19458ddf | ||
|
|
c63f0f2109 | ||
|
|
f1a7e6c24a | ||
|
|
c63b5a0a07 | ||
|
|
503c29613b |
211
.interface-design/system.md
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
# Design System - Playzer
|
||||||
|
|
||||||
|
## Direction
|
||||||
|
|
||||||
|
**Feel:** Technical but warm. Like a well-organized sports facility — functional, clean, but with life. Not cold like a trading terminal, not playful like a toy app.
|
||||||
|
|
||||||
|
**Users:** Owners and employees managing sports complex bookings. They're juggling schedules, phone calls, and walk-ins. The interface should stay out of the way while they're working.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Domain
|
||||||
|
|
||||||
|
**Concepts:**
|
||||||
|
- Turno (booking slot) — A scheduled time block
|
||||||
|
- Cancha (court) — Sports court/pitch
|
||||||
|
- Horario — Time slot
|
||||||
|
- Complex — The sports facility
|
||||||
|
- Estado — Booking status (confirmed, cancelled, etc.)
|
||||||
|
|
||||||
|
**Color world:**
|
||||||
|
- Court green (grass/tennis)
|
||||||
|
- Clay tan (paddle court)
|
||||||
|
- Pool blue (swimming)
|
||||||
|
- Wood grain (locker rooms)
|
||||||
|
- Concrete gray (tech floor)
|
||||||
|
|
||||||
|
**Signature:**
|
||||||
|
- Status pills use subtle borders + background tint, not bold badges
|
||||||
|
- Status communicates without demanding attention — whisper-level hierarchy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Depth
|
||||||
|
**Approach:** Borders-only with surface elevation shifts
|
||||||
|
- No drop shadows
|
||||||
|
- Higher elevation = slightly lighter surface (in dark: lighter = higher)
|
||||||
|
- Subtle rgba borders define edges quietly
|
||||||
|
|
||||||
|
### Spacing
|
||||||
|
**Base unit:** 4px
|
||||||
|
- Micro: 2-4px (icon gaps)
|
||||||
|
- Component: 8-12px (inside buttons, badges)
|
||||||
|
- Section: 16-24px (between groups)
|
||||||
|
- Major: 32-40px (between distinct areas)
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
**Font:** Geist Variable
|
||||||
|
**Scale:**
|
||||||
|
- Display: 28px / semibold / -0.02em tracking
|
||||||
|
- Heading: 20px / semibold / -0.01em tracking
|
||||||
|
- Body: 14px / normal / 0 tracking
|
||||||
|
- Label: 12px / medium / 0 tracking
|
||||||
|
- Caption: 11px / normal / 0.02em tracking
|
||||||
|
|
||||||
|
### Border Radius
|
||||||
|
**Scale:**
|
||||||
|
- `sm`: 4px (inputs, small badges)
|
||||||
|
- `md`: 6px (buttons, cards)
|
||||||
|
- `lg`: 10px (dialogs, large cards)
|
||||||
|
- `xl`: 14px (modals)
|
||||||
|
|
||||||
|
### Borders
|
||||||
|
**Progression:**
|
||||||
|
- Subtle: `oklch(1 0 0 / 8%)` (background edges)
|
||||||
|
- Default: `oklch(1 0 0 / 12%)` (card borders)
|
||||||
|
- Emphasis: `oklch(1 0 0 / 20%)` (active states)
|
||||||
|
|
||||||
|
### Status Semantic Colors
|
||||||
|
**Light mode:**
|
||||||
|
- Confirmed: `bg-emerald-50 border-emerald-200 text-emerald-700`
|
||||||
|
- Pending: `bg-amber-50 border-amber-200 text-amber-700`
|
||||||
|
- Cancelled: `bg-rose-50 border-rose-200 text-rose-700`
|
||||||
|
- No-show: `bg-orange-50 border-orange-200 text-orange-700`
|
||||||
|
- Completed: `bg-sky-50 border-sky-200 text-sky-700`
|
||||||
|
|
||||||
|
**Dark mode:**
|
||||||
|
- Slight desaturation, lower opacity backgrounds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Patterns
|
||||||
|
|
||||||
|
### Card
|
||||||
|
- Background: `bg-card`
|
||||||
|
- Border: `border` token
|
||||||
|
- Radius: `md`
|
||||||
|
- Padding: `p-4` (compact) / `p-5` (default)
|
||||||
|
|
||||||
|
### Status Badge
|
||||||
|
```tsx
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium">
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Input
|
||||||
|
- Background: `bg-input` (darker than surface)
|
||||||
|
- No heavy borders by default
|
||||||
|
- Focus ring via `ring-3 ring-ring/50`
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
- Sidebar: Same background as main, subtle border separation
|
||||||
|
- Content: Max-width 6xl, centered
|
||||||
|
- Padding: `px-4 sm:px-6`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
|
||||||
|
- [x] Status tokens in CSS variables
|
||||||
|
- [x] Refactor status badges to use semantic classes
|
||||||
|
- [x] StatusBadge component created
|
||||||
|
- [x] Home panel redesign with metrics + timeline
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Home Page Patterns (v2)
|
||||||
|
|
||||||
|
### Structure
|
||||||
|
- Header + URL Card: 2-column grid (lg:)
|
||||||
|
- Main: Timeline de reservas
|
||||||
|
- Sidebar: Stats grid consolidado (sticky)
|
||||||
|
|
||||||
|
### URL Card (destacada)
|
||||||
|
```tsx
|
||||||
|
<div className="group relative overflow-hidden rounded-2xl border border-blue-200/60 bg-blue-50/50 p-5">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-blue-50/80 to-blue-100/30" />
|
||||||
|
<div className="relative flex items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-blue-600/70">
|
||||||
|
Reserva online
|
||||||
|
</p>
|
||||||
|
<a href={url} className="mt-1 block truncate text-sm font-medium text-blue-700 underline-offset-2 hover:underline">
|
||||||
|
{url}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<button className="relative flex shrink-0 items-center gap-2 rounded-lg bg-blue-500/10 px-3 py-2 text-sm font-medium text-blue-700 transition-colors hover:bg-blue-500/20">
|
||||||
|
<Copy className="size-4" />
|
||||||
|
<span>Copiar</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stats Pills (header)
|
||||||
|
```tsx
|
||||||
|
<span className="rounded-full border border-emerald-200/60 bg-emerald-50/60 px-3 py-1 text-xs font-medium text-emerald-700">
|
||||||
|
{count} hoy
|
||||||
|
</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stats Sidebar (grid 2x2)
|
||||||
|
```tsx
|
||||||
|
<section className="space-y-3 rounded-2xl border border-border/70 bg-card p-4">
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-[0.28em] text-muted-foreground">
|
||||||
|
Resumen
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="rounded-xl border border-border/50 bg-background/80 p-3">
|
||||||
|
<p className="text-xs text-muted-foreground">Total</p>
|
||||||
|
<p className="mt-1 text-xl font-semibold tracking-tight">{value}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-emerald-200/40 bg-emerald-50/40 p-3">
|
||||||
|
<p className="text-xs text-emerald-700/80">Hoy</p>
|
||||||
|
<p className="mt-1 text-xl font-semibold tracking-tight text-emerald-700">{value}</p>
|
||||||
|
</div>
|
||||||
|
{/* ... confirmed (blue), completed (sky) */}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Timeline Item (v2 - mejorado)
|
||||||
|
```tsx
|
||||||
|
<div className={[
|
||||||
|
'group flex items-center justify-between gap-3 rounded-xl border border-border/70 bg-card/60 p-3.5 transition-all hover:bg-muted/50',
|
||||||
|
isPast && 'opacity-50',
|
||||||
|
isNow && 'border-l-2 border-l-emerald-500 bg-emerald-50/30',
|
||||||
|
].join(' ')}>
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
|
<div className="flex w-14 flex-col items-center">
|
||||||
|
<span className="text-sm font-medium tabular-nums">{startTime}</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground">{endTime}</span>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium">{courtName}</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">{customer}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<StatusBadge status={...} />
|
||||||
|
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<Button size="icon-xs" variant="ghost" title="Marcar cumplida"><Check className="size-3.5" /></Button>
|
||||||
|
<Button size="icon-xs" variant="ghost" title="Cancelar">×</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Date Group Header
|
||||||
|
```tsx
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 className="text-sm font-semibold capitalize">{dateLabel}</h2>
|
||||||
|
<div className="h-px flex-1 border-t border-dashed border-border/40" />
|
||||||
|
<span className="shrink-0 rounded-full border border-border/70 bg-background px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
18
Dockerfile
@@ -32,19 +32,25 @@ COPY packages/api-contract/package.json packages/api-contract/
|
|||||||
RUN bun install
|
RUN bun install
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Stage 4: Build Backend (prisma generate + tsc)
|
# Stage 4: Prisma Generate
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
FROM deps-backend AS build-backend
|
FROM deps-backend AS prisma
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ARG DATABASE_URL
|
ARG DATABASE_URL
|
||||||
ENV DATABASE_URL=${DATABASE_URL:-postgresql://dummy:dummy@localhost:5432/dummy}
|
ENV DATABASE_URL=${DATABASE_URL}
|
||||||
COPY apps/backend ./apps/backend
|
COPY apps/backend ./apps/backend
|
||||||
COPY packages ./packages
|
|
||||||
RUN bun --cwd apps/backend prisma:generate
|
RUN bun --cwd apps/backend prisma:generate
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Stage 5: Build Backend
|
||||||
|
# =============================================================================
|
||||||
|
FROM prisma AS build-backend
|
||||||
|
WORKDIR /app
|
||||||
|
COPY packages ./packages
|
||||||
RUN bun --cwd apps/backend build
|
RUN bun --cwd apps/backend build
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Stage 5: Runner - Production image
|
# Stage 6: Runner - Production image
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
FROM oven/bun:1.3.11 AS runner
|
FROM oven/bun:1.3.11 AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -54,7 +60,7 @@ COPY --from=build-backend /app/node_modules ./node_modules
|
|||||||
COPY --from=build-frontend /app/apps/frontend/dist ./apps/backend/public
|
COPY --from=build-frontend /app/apps/frontend/dist ./apps/backend/public
|
||||||
COPY --from=build-backend /app/apps/backend ./apps/backend
|
COPY --from=build-backend /app/apps/backend ./apps/backend
|
||||||
COPY --from=build-backend /app/packages ./packages
|
COPY --from=build-backend /app/packages ./packages
|
||||||
COPY package.json bun.lock ./
|
COPY package.json ./
|
||||||
COPY entrypoint.sh /app/entrypoint.sh
|
COPY entrypoint.sh /app/entrypoint.sh
|
||||||
|
|
||||||
RUN chmod +x /app/entrypoint.sh
|
RUN chmod +x /app/entrypoint.sh
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
[test]
|
[test]
|
||||||
preload = ["./test/support/prisma.mock.ts"]
|
preload = ["./test/support/prisma.mock.ts"]
|
||||||
coverageThreshold = 0.8
|
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
"dev": "bun run --hot src/server.ts",
|
"dev": "bun run --hot src/server.ts",
|
||||||
"start": "bun src/server.ts",
|
"start": "bun src/server.ts",
|
||||||
"build": "tsc -b",
|
"build": "tsc -b",
|
||||||
"test": "bun test --preload ./test/support/prisma.mock.ts ./test",
|
"test": "bun test",
|
||||||
"lint": "biome check .",
|
"lint": "biome check .",
|
||||||
"lint:fix": "biome check --write .",
|
"lint:fix": "biome check --write .",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ model User {
|
|||||||
email String
|
email String
|
||||||
emailVerified Boolean @default(false)
|
emailVerified Boolean @default(false)
|
||||||
image String?
|
image String?
|
||||||
phone String?
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ enum CourtBookingStatus {
|
|||||||
CONFIRMED
|
CONFIRMED
|
||||||
CANCELLED
|
CANCELLED
|
||||||
COMPLETED
|
COMPLETED
|
||||||
NOSHOW
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model Sport {
|
model Sport {
|
||||||
@@ -83,9 +82,9 @@ model CourtBooking {
|
|||||||
bookingDate DateTime @map("booking_date") @db.Date
|
bookingDate DateTime @map("booking_date") @db.Date
|
||||||
startTime String @map("start_time") @db.VarChar(5)
|
startTime String @map("start_time") @db.VarChar(5)
|
||||||
endTime String @map("end_time") @db.VarChar(5)
|
endTime String @map("end_time") @db.VarChar(5)
|
||||||
|
price Decimal? @map("price") @db.Decimal(19, 2)
|
||||||
customerName String @map("customer_name") @db.VarChar(120)
|
customerName String @map("customer_name") @db.VarChar(120)
|
||||||
customerPhone String @map("customer_phone") @db.VarChar(30)
|
customerPhone String @map("customer_phone") @db.VarChar(30)
|
||||||
customerEmail String? @map("customer_email") @db.VarChar(254)
|
|
||||||
status CourtBookingStatus @default(CONFIRMED)
|
status CourtBookingStatus @default(CONFIRMED)
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
@@ -96,21 +95,3 @@ model CourtBooking {
|
|||||||
@@index([bookingDate])
|
@@index([bookingDate])
|
||||||
@@map("court_bookings")
|
@@map("court_bookings")
|
||||||
}
|
}
|
||||||
|
|
||||||
model CourtBookingLog {
|
|
||||||
id String @id @db.Uuid
|
|
||||||
bookingCode String @map("booking_code") @db.VarChar(8)
|
|
||||||
courtId String @map("court_id") @db.Uuid
|
|
||||||
bookingDate DateTime @map("booking_date") @db.Date
|
|
||||||
startTime String @map("start_time") @db.VarChar(5)
|
|
||||||
endTime String @map("end_time") @db.VarChar(5)
|
|
||||||
previousStatus CourtBookingStatus @map("previous_status")
|
|
||||||
newStatus CourtBookingStatus @map("new_status")
|
|
||||||
customerName String @map("customer_name") @db.VarChar(120)
|
|
||||||
customerPhone String @map("customer_phone") @db.VarChar(30)
|
|
||||||
customerEmail String? @map("customer_email") @db.VarChar(254)
|
|
||||||
changedAt DateTime @default(now()) @map("changed_at")
|
|
||||||
|
|
||||||
@@map("court_booking_logs")
|
|
||||||
@@index([courtId, newStatus])
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "court_bookings" ADD COLUMN "price" DECIMAL(19,2);
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
-- CreateTable
|
|
||||||
CREATE TABLE "court_booking_logs" (
|
|
||||||
"id" UUID NOT NULL,
|
|
||||||
"booking_code" UUID NOT NULL,
|
|
||||||
"court_id" UUID NOT NULL,
|
|
||||||
"booking_date" DATE NOT NULL,
|
|
||||||
"start_time" VARCHAR(5) NOT NULL,
|
|
||||||
"end_time" VARCHAR(5) NOT NULL,
|
|
||||||
"previous_status" "CourtBookingStatus" NOT NULL,
|
|
||||||
"new_status" "CourtBookingStatus" NOT NULL,
|
|
||||||
"changed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "court_booking_logs_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "court_booking_logs_court_id_new_status_idx" ON "court_booking_logs"("court_id", "new_status");
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
/*
|
|
||||||
Warnings:
|
|
||||||
|
|
||||||
- Added the required column `customer_name` to the `court_booking_logs` table without a default value. This is not possible if the table is not empty.
|
|
||||||
- Added the required column `customer_phone` to the `court_booking_logs` table without a default value. This is not possible if the table is not empty.
|
|
||||||
|
|
||||||
*/
|
|
||||||
-- AlterEnum
|
|
||||||
ALTER TYPE "CourtBookingStatus" ADD VALUE 'NOSHOW';
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "court_booking_logs" ADD COLUMN "customer_name" VARCHAR(120) NOT NULL,
|
|
||||||
ADD COLUMN "customer_phone" VARCHAR(30) NOT NULL;
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
/*
|
|
||||||
Warnings:
|
|
||||||
|
|
||||||
- Changed the type of `booking_code` on the `court_booking_logs` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
|
|
||||||
|
|
||||||
*/
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "court_booking_logs" DROP COLUMN "booking_code",
|
|
||||||
ADD COLUMN "booking_code" VARCHAR(8) NOT NULL;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE "users" ADD COLUMN "phone" TEXT;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
/*
|
|
||||||
Warnings:
|
|
||||||
|
|
||||||
- You are about to drop the `onboarding_requests` table. If the table is not empty, all the data it contains will be lost.
|
|
||||||
|
|
||||||
*/
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "court_booking_logs" ADD COLUMN "customer_email" VARCHAR(254);
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "court_bookings" ADD COLUMN "customer_email" VARCHAR(254);
|
|
||||||
|
|
||||||
-- DropTable
|
|
||||||
DROP TABLE "onboarding_requests";
|
|
||||||
18
apps/backend/prisma/onboarding.prisma
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
model OnboardingRequest {
|
||||||
|
id String @id @db.Uuid
|
||||||
|
fullName String @map("full_name")
|
||||||
|
email String
|
||||||
|
otpHash String @map("otp_hash")
|
||||||
|
otpExpiresAt DateTime @map("otp_expires_at")
|
||||||
|
otpAttempts Int @default(0) @map("otp_attempts")
|
||||||
|
otpLastSentAt DateTime @map("otp_last_sent_at")
|
||||||
|
otpResendCount Int @default(0) @map("otp_resend_count")
|
||||||
|
emailVerifiedAt DateTime? @map("email_verified_at")
|
||||||
|
completedAt DateTime? @map("completed_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@index([email])
|
||||||
|
@@index([otpExpiresAt])
|
||||||
|
@@map("onboarding_requests")
|
||||||
|
}
|
||||||
@@ -3,15 +3,10 @@ import type { AppEnv } from '@/types/hono';
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { cors } from 'hono/cors';
|
import { cors } from 'hono/cors';
|
||||||
import { requestId } from 'hono/request-id';
|
import { requestId } from 'hono/request-id';
|
||||||
import { errorHandler } from './lib/http/error-handler';
|
|
||||||
import { requestIdMiddleware } from './lib/http/request-id';
|
|
||||||
|
|
||||||
export function createApp() {
|
export function createApp() {
|
||||||
const app = new Hono<AppEnv>();
|
const app = new Hono<AppEnv>();
|
||||||
|
|
||||||
app.use('*', requestIdMiddleware);
|
|
||||||
app.use('*', errorHandler);
|
|
||||||
|
|
||||||
const allowedOrigins = (Bun.env.CORS_ORIGIN ?? 'http://localhost:5173,http://127.0.0.1:5173')
|
const allowedOrigins = (Bun.env.CORS_ORIGIN ?? 'http://localhost:5173,http://127.0.0.1:5173')
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((value) => value.trim())
|
.map((value) => value.trim())
|
||||||
|
|||||||
@@ -1,385 +0,0 @@
|
|||||||
type BookingEmailData = {
|
|
||||||
bookingCode: 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;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
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;min-height:100vh;">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="padding:32px 16px;">
|
|
||||||
<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:32px 32px 24px;">
|
|
||||||
<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 32px 24px;">
|
|
||||||
<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 32px 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;">
|
|
||||||
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>
|
|
||||||
|
|
||||||
<tr>
|
|
||||||
<td style="padding:0 32px 32px;">
|
|
||||||
<p style="margin:0;font-size:13px;color:#6b7280;text-align:center;line-height:1.5;">
|
|
||||||
Presentá el código de reserva al llegar al complejo.
|
|
||||||
<br />
|
|
||||||
Si necesitás cancelar o modificar, contactate directamente con el complejo.
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>`;
|
|
||||||
|
|
||||||
return wrapLayout(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function bookingCancelledHtml(data: BookingEmailData): string {
|
|
||||||
const friendlyDate = formatFriendlyDate(data.date);
|
|
||||||
|
|
||||||
const content = `
|
|
||||||
<tr>
|
|
||||||
<td style="padding:32px 32px 24px;">
|
|
||||||
<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 32px 24px;">
|
|
||||||
<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 32px 24px;">
|
|
||||||
<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 32px 32px;">
|
|
||||||
<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:32px 32px 24px;">
|
|
||||||
<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 32px 24px;">
|
|
||||||
<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 32px 24px;">
|
|
||||||
<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 32px 32px;">
|
|
||||||
<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);
|
|
||||||
}
|
|
||||||
@@ -78,10 +78,10 @@ export type CourtPriceRule = Prisma.CourtPriceRuleModel
|
|||||||
*/
|
*/
|
||||||
export type CourtBooking = Prisma.CourtBookingModel
|
export type CourtBooking = Prisma.CourtBookingModel
|
||||||
/**
|
/**
|
||||||
* Model CourtBookingLog
|
* Model OnboardingRequest
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export type CourtBookingLog = Prisma.CourtBookingLogModel
|
export type OnboardingRequest = Prisma.OnboardingRequestModel
|
||||||
/**
|
/**
|
||||||
* Model PasswordResetRequest
|
* Model PasswordResetRequest
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -102,10 +102,10 @@ export type CourtPriceRule = Prisma.CourtPriceRuleModel
|
|||||||
*/
|
*/
|
||||||
export type CourtBooking = Prisma.CourtBookingModel
|
export type CourtBooking = Prisma.CourtBookingModel
|
||||||
/**
|
/**
|
||||||
* Model CourtBookingLog
|
* Model OnboardingRequest
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export type CourtBookingLog = Prisma.CourtBookingLogModel
|
export type OnboardingRequest = Prisma.OnboardingRequestModel
|
||||||
/**
|
/**
|
||||||
* Model PasswordResetRequest
|
* Model PasswordResetRequest
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -280,6 +280,17 @@ export type EnumDayOfWeekNullableWithAggregatesFilter<$PrismaModel = never> = {
|
|||||||
_max?: Prisma.NestedEnumDayOfWeekNullableFilter<$PrismaModel>
|
_max?: Prisma.NestedEnumDayOfWeekNullableFilter<$PrismaModel>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DecimalNullableFilter<$PrismaModel = never> = {
|
||||||
|
equals?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel> | null
|
||||||
|
in?: runtime.Decimal[] | runtime.DecimalJsLike[] | number[] | string[] | Prisma.ListDecimalFieldRefInput<$PrismaModel> | null
|
||||||
|
notIn?: runtime.Decimal[] | runtime.DecimalJsLike[] | number[] | string[] | Prisma.ListDecimalFieldRefInput<$PrismaModel> | null
|
||||||
|
lt?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
|
||||||
|
lte?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
|
||||||
|
gt?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
|
||||||
|
gte?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedDecimalNullableFilter<$PrismaModel> | runtime.Decimal | runtime.DecimalJsLike | number | string | null
|
||||||
|
}
|
||||||
|
|
||||||
export type EnumCourtBookingStatusFilter<$PrismaModel = never> = {
|
export type EnumCourtBookingStatusFilter<$PrismaModel = never> = {
|
||||||
equals?: $Enums.CourtBookingStatus | Prisma.EnumCourtBookingStatusFieldRefInput<$PrismaModel>
|
equals?: $Enums.CourtBookingStatus | Prisma.EnumCourtBookingStatusFieldRefInput<$PrismaModel>
|
||||||
in?: $Enums.CourtBookingStatus[] | Prisma.ListEnumCourtBookingStatusFieldRefInput<$PrismaModel>
|
in?: $Enums.CourtBookingStatus[] | Prisma.ListEnumCourtBookingStatusFieldRefInput<$PrismaModel>
|
||||||
@@ -287,6 +298,22 @@ export type EnumCourtBookingStatusFilter<$PrismaModel = never> = {
|
|||||||
not?: Prisma.NestedEnumCourtBookingStatusFilter<$PrismaModel> | $Enums.CourtBookingStatus
|
not?: Prisma.NestedEnumCourtBookingStatusFilter<$PrismaModel> | $Enums.CourtBookingStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DecimalNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel> | null
|
||||||
|
in?: runtime.Decimal[] | runtime.DecimalJsLike[] | number[] | string[] | Prisma.ListDecimalFieldRefInput<$PrismaModel> | null
|
||||||
|
notIn?: runtime.Decimal[] | runtime.DecimalJsLike[] | number[] | string[] | Prisma.ListDecimalFieldRefInput<$PrismaModel> | null
|
||||||
|
lt?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
|
||||||
|
lte?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
|
||||||
|
gt?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
|
||||||
|
gte?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedDecimalNullableWithAggregatesFilter<$PrismaModel> | runtime.Decimal | runtime.DecimalJsLike | number | string | null
|
||||||
|
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||||
|
_avg?: Prisma.NestedDecimalNullableFilter<$PrismaModel>
|
||||||
|
_sum?: Prisma.NestedDecimalNullableFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedDecimalNullableFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedDecimalNullableFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
export type EnumCourtBookingStatusWithAggregatesFilter<$PrismaModel = never> = {
|
export type EnumCourtBookingStatusWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
equals?: $Enums.CourtBookingStatus | Prisma.EnumCourtBookingStatusFieldRefInput<$PrismaModel>
|
equals?: $Enums.CourtBookingStatus | Prisma.EnumCourtBookingStatusFieldRefInput<$PrismaModel>
|
||||||
in?: $Enums.CourtBookingStatus[] | Prisma.ListEnumCourtBookingStatusFieldRefInput<$PrismaModel>
|
in?: $Enums.CourtBookingStatus[] | Prisma.ListEnumCourtBookingStatusFieldRefInput<$PrismaModel>
|
||||||
@@ -625,6 +652,17 @@ export type NestedEnumDayOfWeekNullableWithAggregatesFilter<$PrismaModel = never
|
|||||||
_max?: Prisma.NestedEnumDayOfWeekNullableFilter<$PrismaModel>
|
_max?: Prisma.NestedEnumDayOfWeekNullableFilter<$PrismaModel>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type NestedDecimalNullableFilter<$PrismaModel = never> = {
|
||||||
|
equals?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel> | null
|
||||||
|
in?: runtime.Decimal[] | runtime.DecimalJsLike[] | number[] | string[] | Prisma.ListDecimalFieldRefInput<$PrismaModel> | null
|
||||||
|
notIn?: runtime.Decimal[] | runtime.DecimalJsLike[] | number[] | string[] | Prisma.ListDecimalFieldRefInput<$PrismaModel> | null
|
||||||
|
lt?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
|
||||||
|
lte?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
|
||||||
|
gt?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
|
||||||
|
gte?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedDecimalNullableFilter<$PrismaModel> | runtime.Decimal | runtime.DecimalJsLike | number | string | null
|
||||||
|
}
|
||||||
|
|
||||||
export type NestedEnumCourtBookingStatusFilter<$PrismaModel = never> = {
|
export type NestedEnumCourtBookingStatusFilter<$PrismaModel = never> = {
|
||||||
equals?: $Enums.CourtBookingStatus | Prisma.EnumCourtBookingStatusFieldRefInput<$PrismaModel>
|
equals?: $Enums.CourtBookingStatus | Prisma.EnumCourtBookingStatusFieldRefInput<$PrismaModel>
|
||||||
in?: $Enums.CourtBookingStatus[] | Prisma.ListEnumCourtBookingStatusFieldRefInput<$PrismaModel>
|
in?: $Enums.CourtBookingStatus[] | Prisma.ListEnumCourtBookingStatusFieldRefInput<$PrismaModel>
|
||||||
@@ -632,6 +670,22 @@ export type NestedEnumCourtBookingStatusFilter<$PrismaModel = never> = {
|
|||||||
not?: Prisma.NestedEnumCourtBookingStatusFilter<$PrismaModel> | $Enums.CourtBookingStatus
|
not?: Prisma.NestedEnumCourtBookingStatusFilter<$PrismaModel> | $Enums.CourtBookingStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type NestedDecimalNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel> | null
|
||||||
|
in?: runtime.Decimal[] | runtime.DecimalJsLike[] | number[] | string[] | Prisma.ListDecimalFieldRefInput<$PrismaModel> | null
|
||||||
|
notIn?: runtime.Decimal[] | runtime.DecimalJsLike[] | number[] | string[] | Prisma.ListDecimalFieldRefInput<$PrismaModel> | null
|
||||||
|
lt?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
|
||||||
|
lte?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
|
||||||
|
gt?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
|
||||||
|
gte?: runtime.Decimal | runtime.DecimalJsLike | number | string | Prisma.DecimalFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedDecimalNullableWithAggregatesFilter<$PrismaModel> | runtime.Decimal | runtime.DecimalJsLike | number | string | null
|
||||||
|
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||||
|
_avg?: Prisma.NestedDecimalNullableFilter<$PrismaModel>
|
||||||
|
_sum?: Prisma.NestedDecimalNullableFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedDecimalNullableFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedDecimalNullableFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
export type NestedEnumCourtBookingStatusWithAggregatesFilter<$PrismaModel = never> = {
|
export type NestedEnumCourtBookingStatusWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
equals?: $Enums.CourtBookingStatus | Prisma.EnumCourtBookingStatusFieldRefInput<$PrismaModel>
|
equals?: $Enums.CourtBookingStatus | Prisma.EnumCourtBookingStatusFieldRefInput<$PrismaModel>
|
||||||
in?: $Enums.CourtBookingStatus[] | Prisma.ListEnumCourtBookingStatusFieldRefInput<$PrismaModel>
|
in?: $Enums.CourtBookingStatus[] | Prisma.ListEnumCourtBookingStatusFieldRefInput<$PrismaModel>
|
||||||
|
|||||||
@@ -33,8 +33,7 @@ export type DayOfWeek = (typeof DayOfWeek)[keyof typeof DayOfWeek]
|
|||||||
export const CourtBookingStatus = {
|
export const CourtBookingStatus = {
|
||||||
CONFIRMED: 'CONFIRMED',
|
CONFIRMED: 'CONFIRMED',
|
||||||
CANCELLED: 'CANCELLED',
|
CANCELLED: 'CANCELLED',
|
||||||
COMPLETED: 'COMPLETED',
|
COMPLETED: 'COMPLETED'
|
||||||
NOSHOW: 'NOSHOW'
|
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type CourtBookingStatus = (typeof CourtBookingStatus)[keyof typeof CourtBookingStatus]
|
export type CourtBookingStatus = (typeof CourtBookingStatus)[keyof typeof CourtBookingStatus]
|
||||||
|
|||||||
@@ -396,7 +396,7 @@ export const ModelName = {
|
|||||||
CourtAvailability: 'CourtAvailability',
|
CourtAvailability: 'CourtAvailability',
|
||||||
CourtPriceRule: 'CourtPriceRule',
|
CourtPriceRule: 'CourtPriceRule',
|
||||||
CourtBooking: 'CourtBooking',
|
CourtBooking: 'CourtBooking',
|
||||||
CourtBookingLog: 'CourtBookingLog',
|
OnboardingRequest: 'OnboardingRequest',
|
||||||
PasswordResetRequest: 'PasswordResetRequest',
|
PasswordResetRequest: 'PasswordResetRequest',
|
||||||
Plan: 'Plan'
|
Plan: 'Plan'
|
||||||
} as const
|
} as const
|
||||||
@@ -414,7 +414,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
|||||||
omit: GlobalOmitOptions
|
omit: GlobalOmitOptions
|
||||||
}
|
}
|
||||||
meta: {
|
meta: {
|
||||||
modelProps: "user" | "session" | "account" | "verification" | "complex" | "complexUser" | "complexInvitation" | "sport" | "court" | "courtAvailability" | "courtPriceRule" | "courtBooking" | "courtBookingLog" | "passwordResetRequest" | "plan"
|
modelProps: "user" | "session" | "account" | "verification" | "complex" | "complexUser" | "complexInvitation" | "sport" | "court" | "courtAvailability" | "courtPriceRule" | "courtBooking" | "onboardingRequest" | "passwordResetRequest" | "plan"
|
||||||
txIsolationLevel: TransactionIsolationLevel
|
txIsolationLevel: TransactionIsolationLevel
|
||||||
}
|
}
|
||||||
model: {
|
model: {
|
||||||
@@ -1306,77 +1306,77 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CourtBookingLog: {
|
OnboardingRequest: {
|
||||||
payload: Prisma.$CourtBookingLogPayload<ExtArgs>
|
payload: Prisma.$OnboardingRequestPayload<ExtArgs>
|
||||||
fields: Prisma.CourtBookingLogFieldRefs
|
fields: Prisma.OnboardingRequestFieldRefs
|
||||||
operations: {
|
operations: {
|
||||||
findUnique: {
|
findUnique: {
|
||||||
args: Prisma.CourtBookingLogFindUniqueArgs<ExtArgs>
|
args: Prisma.OnboardingRequestFindUniqueArgs<ExtArgs>
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload> | null
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload> | null
|
||||||
}
|
}
|
||||||
findUniqueOrThrow: {
|
findUniqueOrThrow: {
|
||||||
args: Prisma.CourtBookingLogFindUniqueOrThrowArgs<ExtArgs>
|
args: Prisma.OnboardingRequestFindUniqueOrThrowArgs<ExtArgs>
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload>
|
||||||
}
|
}
|
||||||
findFirst: {
|
findFirst: {
|
||||||
args: Prisma.CourtBookingLogFindFirstArgs<ExtArgs>
|
args: Prisma.OnboardingRequestFindFirstArgs<ExtArgs>
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload> | null
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload> | null
|
||||||
}
|
}
|
||||||
findFirstOrThrow: {
|
findFirstOrThrow: {
|
||||||
args: Prisma.CourtBookingLogFindFirstOrThrowArgs<ExtArgs>
|
args: Prisma.OnboardingRequestFindFirstOrThrowArgs<ExtArgs>
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload>
|
||||||
}
|
}
|
||||||
findMany: {
|
findMany: {
|
||||||
args: Prisma.CourtBookingLogFindManyArgs<ExtArgs>
|
args: Prisma.OnboardingRequestFindManyArgs<ExtArgs>
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>[]
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload>[]
|
||||||
}
|
}
|
||||||
create: {
|
create: {
|
||||||
args: Prisma.CourtBookingLogCreateArgs<ExtArgs>
|
args: Prisma.OnboardingRequestCreateArgs<ExtArgs>
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload>
|
||||||
}
|
}
|
||||||
createMany: {
|
createMany: {
|
||||||
args: Prisma.CourtBookingLogCreateManyArgs<ExtArgs>
|
args: Prisma.OnboardingRequestCreateManyArgs<ExtArgs>
|
||||||
result: BatchPayload
|
result: BatchPayload
|
||||||
}
|
}
|
||||||
createManyAndReturn: {
|
createManyAndReturn: {
|
||||||
args: Prisma.CourtBookingLogCreateManyAndReturnArgs<ExtArgs>
|
args: Prisma.OnboardingRequestCreateManyAndReturnArgs<ExtArgs>
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>[]
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload>[]
|
||||||
}
|
}
|
||||||
delete: {
|
delete: {
|
||||||
args: Prisma.CourtBookingLogDeleteArgs<ExtArgs>
|
args: Prisma.OnboardingRequestDeleteArgs<ExtArgs>
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload>
|
||||||
}
|
}
|
||||||
update: {
|
update: {
|
||||||
args: Prisma.CourtBookingLogUpdateArgs<ExtArgs>
|
args: Prisma.OnboardingRequestUpdateArgs<ExtArgs>
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload>
|
||||||
}
|
}
|
||||||
deleteMany: {
|
deleteMany: {
|
||||||
args: Prisma.CourtBookingLogDeleteManyArgs<ExtArgs>
|
args: Prisma.OnboardingRequestDeleteManyArgs<ExtArgs>
|
||||||
result: BatchPayload
|
result: BatchPayload
|
||||||
}
|
}
|
||||||
updateMany: {
|
updateMany: {
|
||||||
args: Prisma.CourtBookingLogUpdateManyArgs<ExtArgs>
|
args: Prisma.OnboardingRequestUpdateManyArgs<ExtArgs>
|
||||||
result: BatchPayload
|
result: BatchPayload
|
||||||
}
|
}
|
||||||
updateManyAndReturn: {
|
updateManyAndReturn: {
|
||||||
args: Prisma.CourtBookingLogUpdateManyAndReturnArgs<ExtArgs>
|
args: Prisma.OnboardingRequestUpdateManyAndReturnArgs<ExtArgs>
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>[]
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload>[]
|
||||||
}
|
}
|
||||||
upsert: {
|
upsert: {
|
||||||
args: Prisma.CourtBookingLogUpsertArgs<ExtArgs>
|
args: Prisma.OnboardingRequestUpsertArgs<ExtArgs>
|
||||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$OnboardingRequestPayload>
|
||||||
}
|
}
|
||||||
aggregate: {
|
aggregate: {
|
||||||
args: Prisma.CourtBookingLogAggregateArgs<ExtArgs>
|
args: Prisma.OnboardingRequestAggregateArgs<ExtArgs>
|
||||||
result: runtime.Types.Utils.Optional<Prisma.AggregateCourtBookingLog>
|
result: runtime.Types.Utils.Optional<Prisma.AggregateOnboardingRequest>
|
||||||
}
|
}
|
||||||
groupBy: {
|
groupBy: {
|
||||||
args: Prisma.CourtBookingLogGroupByArgs<ExtArgs>
|
args: Prisma.OnboardingRequestGroupByArgs<ExtArgs>
|
||||||
result: runtime.Types.Utils.Optional<Prisma.CourtBookingLogGroupByOutputType>[]
|
result: runtime.Types.Utils.Optional<Prisma.OnboardingRequestGroupByOutputType>[]
|
||||||
}
|
}
|
||||||
count: {
|
count: {
|
||||||
args: Prisma.CourtBookingLogCountArgs<ExtArgs>
|
args: Prisma.OnboardingRequestCountArgs<ExtArgs>
|
||||||
result: runtime.Types.Utils.Optional<Prisma.CourtBookingLogCountAggregateOutputType> | number
|
result: runtime.Types.Utils.Optional<Prisma.OnboardingRequestCountAggregateOutputType> | number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1573,7 +1573,6 @@ export const UserScalarFieldEnum = {
|
|||||||
email: 'email',
|
email: 'email',
|
||||||
emailVerified: 'emailVerified',
|
emailVerified: 'emailVerified',
|
||||||
image: 'image',
|
image: 'image',
|
||||||
phone: 'phone',
|
|
||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
updatedAt: 'updatedAt',
|
updatedAt: 'updatedAt',
|
||||||
role: 'role'
|
role: 'role'
|
||||||
@@ -1729,9 +1728,9 @@ export const CourtBookingScalarFieldEnum = {
|
|||||||
bookingDate: 'bookingDate',
|
bookingDate: 'bookingDate',
|
||||||
startTime: 'startTime',
|
startTime: 'startTime',
|
||||||
endTime: 'endTime',
|
endTime: 'endTime',
|
||||||
|
price: 'price',
|
||||||
customerName: 'customerName',
|
customerName: 'customerName',
|
||||||
customerPhone: 'customerPhone',
|
customerPhone: 'customerPhone',
|
||||||
customerEmail: 'customerEmail',
|
|
||||||
status: 'status',
|
status: 'status',
|
||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
updatedAt: 'updatedAt'
|
updatedAt: 'updatedAt'
|
||||||
@@ -1740,22 +1739,22 @@ export const CourtBookingScalarFieldEnum = {
|
|||||||
export type CourtBookingScalarFieldEnum = (typeof CourtBookingScalarFieldEnum)[keyof typeof CourtBookingScalarFieldEnum]
|
export type CourtBookingScalarFieldEnum = (typeof CourtBookingScalarFieldEnum)[keyof typeof CourtBookingScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
export const CourtBookingLogScalarFieldEnum = {
|
export const OnboardingRequestScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
bookingCode: 'bookingCode',
|
fullName: 'fullName',
|
||||||
courtId: 'courtId',
|
email: 'email',
|
||||||
bookingDate: 'bookingDate',
|
otpHash: 'otpHash',
|
||||||
startTime: 'startTime',
|
otpExpiresAt: 'otpExpiresAt',
|
||||||
endTime: 'endTime',
|
otpAttempts: 'otpAttempts',
|
||||||
previousStatus: 'previousStatus',
|
otpLastSentAt: 'otpLastSentAt',
|
||||||
newStatus: 'newStatus',
|
otpResendCount: 'otpResendCount',
|
||||||
customerName: 'customerName',
|
emailVerifiedAt: 'emailVerifiedAt',
|
||||||
customerPhone: 'customerPhone',
|
completedAt: 'completedAt',
|
||||||
customerEmail: 'customerEmail',
|
createdAt: 'createdAt',
|
||||||
changedAt: 'changedAt'
|
updatedAt: 'updatedAt'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type CourtBookingLogScalarFieldEnum = (typeof CourtBookingLogScalarFieldEnum)[keyof typeof CourtBookingLogScalarFieldEnum]
|
export type OnboardingRequestScalarFieldEnum = (typeof OnboardingRequestScalarFieldEnum)[keyof typeof OnboardingRequestScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
export const PasswordResetRequestScalarFieldEnum = {
|
export const PasswordResetRequestScalarFieldEnum = {
|
||||||
@@ -2070,7 +2069,7 @@ export type GlobalOmitConfig = {
|
|||||||
courtAvailability?: Prisma.CourtAvailabilityOmit
|
courtAvailability?: Prisma.CourtAvailabilityOmit
|
||||||
courtPriceRule?: Prisma.CourtPriceRuleOmit
|
courtPriceRule?: Prisma.CourtPriceRuleOmit
|
||||||
courtBooking?: Prisma.CourtBookingOmit
|
courtBooking?: Prisma.CourtBookingOmit
|
||||||
courtBookingLog?: Prisma.CourtBookingLogOmit
|
onboardingRequest?: Prisma.OnboardingRequestOmit
|
||||||
passwordResetRequest?: Prisma.PasswordResetRequestOmit
|
passwordResetRequest?: Prisma.PasswordResetRequestOmit
|
||||||
plan?: Prisma.PlanOmit
|
plan?: Prisma.PlanOmit
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export const ModelName = {
|
|||||||
CourtAvailability: 'CourtAvailability',
|
CourtAvailability: 'CourtAvailability',
|
||||||
CourtPriceRule: 'CourtPriceRule',
|
CourtPriceRule: 'CourtPriceRule',
|
||||||
CourtBooking: 'CourtBooking',
|
CourtBooking: 'CourtBooking',
|
||||||
CourtBookingLog: 'CourtBookingLog',
|
OnboardingRequest: 'OnboardingRequest',
|
||||||
PasswordResetRequest: 'PasswordResetRequest',
|
PasswordResetRequest: 'PasswordResetRequest',
|
||||||
Plan: 'Plan'
|
Plan: 'Plan'
|
||||||
} as const
|
} as const
|
||||||
@@ -90,7 +90,6 @@ export const UserScalarFieldEnum = {
|
|||||||
email: 'email',
|
email: 'email',
|
||||||
emailVerified: 'emailVerified',
|
emailVerified: 'emailVerified',
|
||||||
image: 'image',
|
image: 'image',
|
||||||
phone: 'phone',
|
|
||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
updatedAt: 'updatedAt',
|
updatedAt: 'updatedAt',
|
||||||
role: 'role'
|
role: 'role'
|
||||||
@@ -246,9 +245,9 @@ export const CourtBookingScalarFieldEnum = {
|
|||||||
bookingDate: 'bookingDate',
|
bookingDate: 'bookingDate',
|
||||||
startTime: 'startTime',
|
startTime: 'startTime',
|
||||||
endTime: 'endTime',
|
endTime: 'endTime',
|
||||||
|
price: 'price',
|
||||||
customerName: 'customerName',
|
customerName: 'customerName',
|
||||||
customerPhone: 'customerPhone',
|
customerPhone: 'customerPhone',
|
||||||
customerEmail: 'customerEmail',
|
|
||||||
status: 'status',
|
status: 'status',
|
||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
updatedAt: 'updatedAt'
|
updatedAt: 'updatedAt'
|
||||||
@@ -257,22 +256,22 @@ export const CourtBookingScalarFieldEnum = {
|
|||||||
export type CourtBookingScalarFieldEnum = (typeof CourtBookingScalarFieldEnum)[keyof typeof CourtBookingScalarFieldEnum]
|
export type CourtBookingScalarFieldEnum = (typeof CourtBookingScalarFieldEnum)[keyof typeof CourtBookingScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
export const CourtBookingLogScalarFieldEnum = {
|
export const OnboardingRequestScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
bookingCode: 'bookingCode',
|
fullName: 'fullName',
|
||||||
courtId: 'courtId',
|
email: 'email',
|
||||||
bookingDate: 'bookingDate',
|
otpHash: 'otpHash',
|
||||||
startTime: 'startTime',
|
otpExpiresAt: 'otpExpiresAt',
|
||||||
endTime: 'endTime',
|
otpAttempts: 'otpAttempts',
|
||||||
previousStatus: 'previousStatus',
|
otpLastSentAt: 'otpLastSentAt',
|
||||||
newStatus: 'newStatus',
|
otpResendCount: 'otpResendCount',
|
||||||
customerName: 'customerName',
|
emailVerifiedAt: 'emailVerifiedAt',
|
||||||
customerPhone: 'customerPhone',
|
completedAt: 'completedAt',
|
||||||
customerEmail: 'customerEmail',
|
createdAt: 'createdAt',
|
||||||
changedAt: 'changedAt'
|
updatedAt: 'updatedAt'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type CourtBookingLogScalarFieldEnum = (typeof CourtBookingLogScalarFieldEnum)[keyof typeof CourtBookingLogScalarFieldEnum]
|
export type OnboardingRequestScalarFieldEnum = (typeof OnboardingRequestScalarFieldEnum)[keyof typeof OnboardingRequestScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
export const PasswordResetRequestScalarFieldEnum = {
|
export const PasswordResetRequestScalarFieldEnum = {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export type * from './models/Court'
|
|||||||
export type * from './models/CourtAvailability'
|
export type * from './models/CourtAvailability'
|
||||||
export type * from './models/CourtPriceRule'
|
export type * from './models/CourtPriceRule'
|
||||||
export type * from './models/CourtBooking'
|
export type * from './models/CourtBooking'
|
||||||
export type * from './models/CourtBookingLog'
|
export type * from './models/OnboardingRequest'
|
||||||
export type * from './models/PasswordResetRequest'
|
export type * from './models/PasswordResetRequest'
|
||||||
export type * from './models/Plan'
|
export type * from './models/Plan'
|
||||||
export type * from './commonInputTypes'
|
export type * from './commonInputTypes'
|
||||||
@@ -20,10 +20,20 @@ export type CourtBookingModel = runtime.Types.Result.DefaultSelection<Prisma.$Co
|
|||||||
|
|
||||||
export type AggregateCourtBooking = {
|
export type AggregateCourtBooking = {
|
||||||
_count: CourtBookingCountAggregateOutputType | null
|
_count: CourtBookingCountAggregateOutputType | null
|
||||||
|
_avg: CourtBookingAvgAggregateOutputType | null
|
||||||
|
_sum: CourtBookingSumAggregateOutputType | null
|
||||||
_min: CourtBookingMinAggregateOutputType | null
|
_min: CourtBookingMinAggregateOutputType | null
|
||||||
_max: CourtBookingMaxAggregateOutputType | null
|
_max: CourtBookingMaxAggregateOutputType | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CourtBookingAvgAggregateOutputType = {
|
||||||
|
price: runtime.Decimal | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CourtBookingSumAggregateOutputType = {
|
||||||
|
price: runtime.Decimal | null
|
||||||
|
}
|
||||||
|
|
||||||
export type CourtBookingMinAggregateOutputType = {
|
export type CourtBookingMinAggregateOutputType = {
|
||||||
id: string | null
|
id: string | null
|
||||||
bookingCode: string | null
|
bookingCode: string | null
|
||||||
@@ -31,9 +41,9 @@ export type CourtBookingMinAggregateOutputType = {
|
|||||||
bookingDate: Date | null
|
bookingDate: Date | null
|
||||||
startTime: string | null
|
startTime: string | null
|
||||||
endTime: string | null
|
endTime: string | null
|
||||||
|
price: runtime.Decimal | null
|
||||||
customerName: string | null
|
customerName: string | null
|
||||||
customerPhone: string | null
|
customerPhone: string | null
|
||||||
customerEmail: string | null
|
|
||||||
status: $Enums.CourtBookingStatus | null
|
status: $Enums.CourtBookingStatus | null
|
||||||
createdAt: Date | null
|
createdAt: Date | null
|
||||||
updatedAt: Date | null
|
updatedAt: Date | null
|
||||||
@@ -46,9 +56,9 @@ export type CourtBookingMaxAggregateOutputType = {
|
|||||||
bookingDate: Date | null
|
bookingDate: Date | null
|
||||||
startTime: string | null
|
startTime: string | null
|
||||||
endTime: string | null
|
endTime: string | null
|
||||||
|
price: runtime.Decimal | null
|
||||||
customerName: string | null
|
customerName: string | null
|
||||||
customerPhone: string | null
|
customerPhone: string | null
|
||||||
customerEmail: string | null
|
|
||||||
status: $Enums.CourtBookingStatus | null
|
status: $Enums.CourtBookingStatus | null
|
||||||
createdAt: Date | null
|
createdAt: Date | null
|
||||||
updatedAt: Date | null
|
updatedAt: Date | null
|
||||||
@@ -61,9 +71,9 @@ export type CourtBookingCountAggregateOutputType = {
|
|||||||
bookingDate: number
|
bookingDate: number
|
||||||
startTime: number
|
startTime: number
|
||||||
endTime: number
|
endTime: number
|
||||||
|
price: number
|
||||||
customerName: number
|
customerName: number
|
||||||
customerPhone: number
|
customerPhone: number
|
||||||
customerEmail: number
|
|
||||||
status: number
|
status: number
|
||||||
createdAt: number
|
createdAt: number
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
@@ -71,6 +81,14 @@ export type CourtBookingCountAggregateOutputType = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export type CourtBookingAvgAggregateInputType = {
|
||||||
|
price?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CourtBookingSumAggregateInputType = {
|
||||||
|
price?: true
|
||||||
|
}
|
||||||
|
|
||||||
export type CourtBookingMinAggregateInputType = {
|
export type CourtBookingMinAggregateInputType = {
|
||||||
id?: true
|
id?: true
|
||||||
bookingCode?: true
|
bookingCode?: true
|
||||||
@@ -78,9 +96,9 @@ export type CourtBookingMinAggregateInputType = {
|
|||||||
bookingDate?: true
|
bookingDate?: true
|
||||||
startTime?: true
|
startTime?: true
|
||||||
endTime?: true
|
endTime?: true
|
||||||
|
price?: true
|
||||||
customerName?: true
|
customerName?: true
|
||||||
customerPhone?: true
|
customerPhone?: true
|
||||||
customerEmail?: true
|
|
||||||
status?: true
|
status?: true
|
||||||
createdAt?: true
|
createdAt?: true
|
||||||
updatedAt?: true
|
updatedAt?: true
|
||||||
@@ -93,9 +111,9 @@ export type CourtBookingMaxAggregateInputType = {
|
|||||||
bookingDate?: true
|
bookingDate?: true
|
||||||
startTime?: true
|
startTime?: true
|
||||||
endTime?: true
|
endTime?: true
|
||||||
|
price?: true
|
||||||
customerName?: true
|
customerName?: true
|
||||||
customerPhone?: true
|
customerPhone?: true
|
||||||
customerEmail?: true
|
|
||||||
status?: true
|
status?: true
|
||||||
createdAt?: true
|
createdAt?: true
|
||||||
updatedAt?: true
|
updatedAt?: true
|
||||||
@@ -108,9 +126,9 @@ export type CourtBookingCountAggregateInputType = {
|
|||||||
bookingDate?: true
|
bookingDate?: true
|
||||||
startTime?: true
|
startTime?: true
|
||||||
endTime?: true
|
endTime?: true
|
||||||
|
price?: true
|
||||||
customerName?: true
|
customerName?: true
|
||||||
customerPhone?: true
|
customerPhone?: true
|
||||||
customerEmail?: true
|
|
||||||
status?: true
|
status?: true
|
||||||
createdAt?: true
|
createdAt?: true
|
||||||
updatedAt?: true
|
updatedAt?: true
|
||||||
@@ -152,6 +170,18 @@ export type CourtBookingAggregateArgs<ExtArgs extends runtime.Types.Extensions.I
|
|||||||
* Count returned CourtBookings
|
* Count returned CourtBookings
|
||||||
**/
|
**/
|
||||||
_count?: true | CourtBookingCountAggregateInputType
|
_count?: true | CourtBookingCountAggregateInputType
|
||||||
|
/**
|
||||||
|
* {@link https://www.prisma.io/docs/concepts/components/prisma-client/aggregations Aggregation Docs}
|
||||||
|
*
|
||||||
|
* Select which fields to average
|
||||||
|
**/
|
||||||
|
_avg?: CourtBookingAvgAggregateInputType
|
||||||
|
/**
|
||||||
|
* {@link https://www.prisma.io/docs/concepts/components/prisma-client/aggregations Aggregation Docs}
|
||||||
|
*
|
||||||
|
* Select which fields to sum
|
||||||
|
**/
|
||||||
|
_sum?: CourtBookingSumAggregateInputType
|
||||||
/**
|
/**
|
||||||
* {@link https://www.prisma.io/docs/concepts/components/prisma-client/aggregations Aggregation Docs}
|
* {@link https://www.prisma.io/docs/concepts/components/prisma-client/aggregations Aggregation Docs}
|
||||||
*
|
*
|
||||||
@@ -185,6 +215,8 @@ export type CourtBookingGroupByArgs<ExtArgs extends runtime.Types.Extensions.Int
|
|||||||
take?: number
|
take?: number
|
||||||
skip?: number
|
skip?: number
|
||||||
_count?: CourtBookingCountAggregateInputType | true
|
_count?: CourtBookingCountAggregateInputType | true
|
||||||
|
_avg?: CourtBookingAvgAggregateInputType
|
||||||
|
_sum?: CourtBookingSumAggregateInputType
|
||||||
_min?: CourtBookingMinAggregateInputType
|
_min?: CourtBookingMinAggregateInputType
|
||||||
_max?: CourtBookingMaxAggregateInputType
|
_max?: CourtBookingMaxAggregateInputType
|
||||||
}
|
}
|
||||||
@@ -196,13 +228,15 @@ export type CourtBookingGroupByOutputType = {
|
|||||||
bookingDate: Date
|
bookingDate: Date
|
||||||
startTime: string
|
startTime: string
|
||||||
endTime: string
|
endTime: string
|
||||||
|
price: runtime.Decimal | null
|
||||||
customerName: string
|
customerName: string
|
||||||
customerPhone: string
|
customerPhone: string
|
||||||
customerEmail: string | null
|
|
||||||
status: $Enums.CourtBookingStatus
|
status: $Enums.CourtBookingStatus
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
_count: CourtBookingCountAggregateOutputType | null
|
_count: CourtBookingCountAggregateOutputType | null
|
||||||
|
_avg: CourtBookingAvgAggregateOutputType | null
|
||||||
|
_sum: CourtBookingSumAggregateOutputType | null
|
||||||
_min: CourtBookingMinAggregateOutputType | null
|
_min: CourtBookingMinAggregateOutputType | null
|
||||||
_max: CourtBookingMaxAggregateOutputType | null
|
_max: CourtBookingMaxAggregateOutputType | null
|
||||||
}
|
}
|
||||||
@@ -232,9 +266,9 @@ export type CourtBookingWhereInput = {
|
|||||||
bookingDate?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
bookingDate?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
||||||
startTime?: Prisma.StringFilter<"CourtBooking"> | string
|
startTime?: Prisma.StringFilter<"CourtBooking"> | string
|
||||||
endTime?: Prisma.StringFilter<"CourtBooking"> | string
|
endTime?: Prisma.StringFilter<"CourtBooking"> | string
|
||||||
|
price?: Prisma.DecimalNullableFilter<"CourtBooking"> | runtime.Decimal | runtime.DecimalJsLike | number | string | null
|
||||||
customerName?: Prisma.StringFilter<"CourtBooking"> | string
|
customerName?: Prisma.StringFilter<"CourtBooking"> | string
|
||||||
customerPhone?: Prisma.StringFilter<"CourtBooking"> | string
|
customerPhone?: Prisma.StringFilter<"CourtBooking"> | string
|
||||||
customerEmail?: Prisma.StringNullableFilter<"CourtBooking"> | string | null
|
|
||||||
status?: Prisma.EnumCourtBookingStatusFilter<"CourtBooking"> | $Enums.CourtBookingStatus
|
status?: Prisma.EnumCourtBookingStatusFilter<"CourtBooking"> | $Enums.CourtBookingStatus
|
||||||
createdAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
createdAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
updatedAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
||||||
@@ -248,9 +282,9 @@ export type CourtBookingOrderByWithRelationInput = {
|
|||||||
bookingDate?: Prisma.SortOrder
|
bookingDate?: Prisma.SortOrder
|
||||||
startTime?: Prisma.SortOrder
|
startTime?: Prisma.SortOrder
|
||||||
endTime?: Prisma.SortOrder
|
endTime?: Prisma.SortOrder
|
||||||
|
price?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
customerName?: Prisma.SortOrder
|
customerName?: Prisma.SortOrder
|
||||||
customerPhone?: Prisma.SortOrder
|
customerPhone?: Prisma.SortOrder
|
||||||
customerEmail?: Prisma.SortOrderInput | Prisma.SortOrder
|
|
||||||
status?: Prisma.SortOrder
|
status?: Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
@@ -268,9 +302,9 @@ export type CourtBookingWhereUniqueInput = Prisma.AtLeast<{
|
|||||||
bookingDate?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
bookingDate?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
||||||
startTime?: Prisma.StringFilter<"CourtBooking"> | string
|
startTime?: Prisma.StringFilter<"CourtBooking"> | string
|
||||||
endTime?: Prisma.StringFilter<"CourtBooking"> | string
|
endTime?: Prisma.StringFilter<"CourtBooking"> | string
|
||||||
|
price?: Prisma.DecimalNullableFilter<"CourtBooking"> | runtime.Decimal | runtime.DecimalJsLike | number | string | null
|
||||||
customerName?: Prisma.StringFilter<"CourtBooking"> | string
|
customerName?: Prisma.StringFilter<"CourtBooking"> | string
|
||||||
customerPhone?: Prisma.StringFilter<"CourtBooking"> | string
|
customerPhone?: Prisma.StringFilter<"CourtBooking"> | string
|
||||||
customerEmail?: Prisma.StringNullableFilter<"CourtBooking"> | string | null
|
|
||||||
status?: Prisma.EnumCourtBookingStatusFilter<"CourtBooking"> | $Enums.CourtBookingStatus
|
status?: Prisma.EnumCourtBookingStatusFilter<"CourtBooking"> | $Enums.CourtBookingStatus
|
||||||
createdAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
createdAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
updatedAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
||||||
@@ -284,15 +318,17 @@ export type CourtBookingOrderByWithAggregationInput = {
|
|||||||
bookingDate?: Prisma.SortOrder
|
bookingDate?: Prisma.SortOrder
|
||||||
startTime?: Prisma.SortOrder
|
startTime?: Prisma.SortOrder
|
||||||
endTime?: Prisma.SortOrder
|
endTime?: Prisma.SortOrder
|
||||||
|
price?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
customerName?: Prisma.SortOrder
|
customerName?: Prisma.SortOrder
|
||||||
customerPhone?: Prisma.SortOrder
|
customerPhone?: Prisma.SortOrder
|
||||||
customerEmail?: Prisma.SortOrderInput | Prisma.SortOrder
|
|
||||||
status?: Prisma.SortOrder
|
status?: Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
_count?: Prisma.CourtBookingCountOrderByAggregateInput
|
_count?: Prisma.CourtBookingCountOrderByAggregateInput
|
||||||
|
_avg?: Prisma.CourtBookingAvgOrderByAggregateInput
|
||||||
_max?: Prisma.CourtBookingMaxOrderByAggregateInput
|
_max?: Prisma.CourtBookingMaxOrderByAggregateInput
|
||||||
_min?: Prisma.CourtBookingMinOrderByAggregateInput
|
_min?: Prisma.CourtBookingMinOrderByAggregateInput
|
||||||
|
_sum?: Prisma.CourtBookingSumOrderByAggregateInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CourtBookingScalarWhereWithAggregatesInput = {
|
export type CourtBookingScalarWhereWithAggregatesInput = {
|
||||||
@@ -305,9 +341,9 @@ export type CourtBookingScalarWhereWithAggregatesInput = {
|
|||||||
bookingDate?: Prisma.DateTimeWithAggregatesFilter<"CourtBooking"> | Date | string
|
bookingDate?: Prisma.DateTimeWithAggregatesFilter<"CourtBooking"> | Date | string
|
||||||
startTime?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
|
startTime?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
|
||||||
endTime?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
|
endTime?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
|
||||||
|
price?: Prisma.DecimalNullableWithAggregatesFilter<"CourtBooking"> | runtime.Decimal | runtime.DecimalJsLike | number | string | null
|
||||||
customerName?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
|
customerName?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
|
||||||
customerPhone?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
|
customerPhone?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
|
||||||
customerEmail?: Prisma.StringNullableWithAggregatesFilter<"CourtBooking"> | string | null
|
|
||||||
status?: Prisma.EnumCourtBookingStatusWithAggregatesFilter<"CourtBooking"> | $Enums.CourtBookingStatus
|
status?: Prisma.EnumCourtBookingStatusWithAggregatesFilter<"CourtBooking"> | $Enums.CourtBookingStatus
|
||||||
createdAt?: Prisma.DateTimeWithAggregatesFilter<"CourtBooking"> | Date | string
|
createdAt?: Prisma.DateTimeWithAggregatesFilter<"CourtBooking"> | Date | string
|
||||||
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"CourtBooking"> | Date | string
|
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"CourtBooking"> | Date | string
|
||||||
@@ -319,9 +355,9 @@ export type CourtBookingCreateInput = {
|
|||||||
bookingDate: Date | string
|
bookingDate: Date | string
|
||||||
startTime: string
|
startTime: string
|
||||||
endTime: string
|
endTime: string
|
||||||
|
price?: runtime.Decimal | runtime.DecimalJsLike | number | string | null
|
||||||
customerName: string
|
customerName: string
|
||||||
customerPhone: string
|
customerPhone: string
|
||||||
customerEmail?: string | null
|
|
||||||
status?: $Enums.CourtBookingStatus
|
status?: $Enums.CourtBookingStatus
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
@@ -335,9 +371,9 @@ export type CourtBookingUncheckedCreateInput = {
|
|||||||
bookingDate: Date | string
|
bookingDate: Date | string
|
||||||
startTime: string
|
startTime: string
|
||||||
endTime: string
|
endTime: string
|
||||||
|
price?: runtime.Decimal | runtime.DecimalJsLike | number | string | null
|
||||||
customerName: string
|
customerName: string
|
||||||
customerPhone: string
|
customerPhone: string
|
||||||
customerEmail?: string | null
|
|
||||||
status?: $Enums.CourtBookingStatus
|
status?: $Enums.CourtBookingStatus
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
@@ -349,9 +385,9 @@ export type CourtBookingUpdateInput = {
|
|||||||
bookingDate?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
bookingDate?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
startTime?: Prisma.StringFieldUpdateOperationsInput | string
|
startTime?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
price?: Prisma.NullableDecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string | null
|
||||||
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerEmail?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
|
||||||
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -365,9 +401,9 @@ export type CourtBookingUncheckedUpdateInput = {
|
|||||||
bookingDate?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
bookingDate?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
startTime?: Prisma.StringFieldUpdateOperationsInput | string
|
startTime?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
price?: Prisma.NullableDecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string | null
|
||||||
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerEmail?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
|
||||||
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -380,9 +416,9 @@ export type CourtBookingCreateManyInput = {
|
|||||||
bookingDate: Date | string
|
bookingDate: Date | string
|
||||||
startTime: string
|
startTime: string
|
||||||
endTime: string
|
endTime: string
|
||||||
|
price?: runtime.Decimal | runtime.DecimalJsLike | number | string | null
|
||||||
customerName: string
|
customerName: string
|
||||||
customerPhone: string
|
customerPhone: string
|
||||||
customerEmail?: string | null
|
|
||||||
status?: $Enums.CourtBookingStatus
|
status?: $Enums.CourtBookingStatus
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
@@ -394,9 +430,9 @@ export type CourtBookingUpdateManyMutationInput = {
|
|||||||
bookingDate?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
bookingDate?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
startTime?: Prisma.StringFieldUpdateOperationsInput | string
|
startTime?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
price?: Prisma.NullableDecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string | null
|
||||||
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerEmail?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
|
||||||
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -409,9 +445,9 @@ export type CourtBookingUncheckedUpdateManyInput = {
|
|||||||
bookingDate?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
bookingDate?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
startTime?: Prisma.StringFieldUpdateOperationsInput | string
|
startTime?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
price?: Prisma.NullableDecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string | null
|
||||||
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerEmail?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
|
||||||
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -440,14 +476,18 @@ export type CourtBookingCountOrderByAggregateInput = {
|
|||||||
bookingDate?: Prisma.SortOrder
|
bookingDate?: Prisma.SortOrder
|
||||||
startTime?: Prisma.SortOrder
|
startTime?: Prisma.SortOrder
|
||||||
endTime?: Prisma.SortOrder
|
endTime?: Prisma.SortOrder
|
||||||
|
price?: Prisma.SortOrder
|
||||||
customerName?: Prisma.SortOrder
|
customerName?: Prisma.SortOrder
|
||||||
customerPhone?: Prisma.SortOrder
|
customerPhone?: Prisma.SortOrder
|
||||||
customerEmail?: Prisma.SortOrder
|
|
||||||
status?: Prisma.SortOrder
|
status?: Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CourtBookingAvgOrderByAggregateInput = {
|
||||||
|
price?: Prisma.SortOrder
|
||||||
|
}
|
||||||
|
|
||||||
export type CourtBookingMaxOrderByAggregateInput = {
|
export type CourtBookingMaxOrderByAggregateInput = {
|
||||||
id?: Prisma.SortOrder
|
id?: Prisma.SortOrder
|
||||||
bookingCode?: Prisma.SortOrder
|
bookingCode?: Prisma.SortOrder
|
||||||
@@ -455,9 +495,9 @@ export type CourtBookingMaxOrderByAggregateInput = {
|
|||||||
bookingDate?: Prisma.SortOrder
|
bookingDate?: Prisma.SortOrder
|
||||||
startTime?: Prisma.SortOrder
|
startTime?: Prisma.SortOrder
|
||||||
endTime?: Prisma.SortOrder
|
endTime?: Prisma.SortOrder
|
||||||
|
price?: Prisma.SortOrder
|
||||||
customerName?: Prisma.SortOrder
|
customerName?: Prisma.SortOrder
|
||||||
customerPhone?: Prisma.SortOrder
|
customerPhone?: Prisma.SortOrder
|
||||||
customerEmail?: Prisma.SortOrder
|
|
||||||
status?: Prisma.SortOrder
|
status?: Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
@@ -470,14 +510,18 @@ export type CourtBookingMinOrderByAggregateInput = {
|
|||||||
bookingDate?: Prisma.SortOrder
|
bookingDate?: Prisma.SortOrder
|
||||||
startTime?: Prisma.SortOrder
|
startTime?: Prisma.SortOrder
|
||||||
endTime?: Prisma.SortOrder
|
endTime?: Prisma.SortOrder
|
||||||
|
price?: Prisma.SortOrder
|
||||||
customerName?: Prisma.SortOrder
|
customerName?: Prisma.SortOrder
|
||||||
customerPhone?: Prisma.SortOrder
|
customerPhone?: Prisma.SortOrder
|
||||||
customerEmail?: Prisma.SortOrder
|
|
||||||
status?: Prisma.SortOrder
|
status?: Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CourtBookingSumOrderByAggregateInput = {
|
||||||
|
price?: Prisma.SortOrder
|
||||||
|
}
|
||||||
|
|
||||||
export type CourtBookingCreateNestedManyWithoutCourtInput = {
|
export type CourtBookingCreateNestedManyWithoutCourtInput = {
|
||||||
create?: Prisma.XOR<Prisma.CourtBookingCreateWithoutCourtInput, Prisma.CourtBookingUncheckedCreateWithoutCourtInput> | Prisma.CourtBookingCreateWithoutCourtInput[] | Prisma.CourtBookingUncheckedCreateWithoutCourtInput[]
|
create?: Prisma.XOR<Prisma.CourtBookingCreateWithoutCourtInput, Prisma.CourtBookingUncheckedCreateWithoutCourtInput> | Prisma.CourtBookingCreateWithoutCourtInput[] | Prisma.CourtBookingUncheckedCreateWithoutCourtInput[]
|
||||||
connectOrCreate?: Prisma.CourtBookingCreateOrConnectWithoutCourtInput | Prisma.CourtBookingCreateOrConnectWithoutCourtInput[]
|
connectOrCreate?: Prisma.CourtBookingCreateOrConnectWithoutCourtInput | Prisma.CourtBookingCreateOrConnectWithoutCourtInput[]
|
||||||
@@ -520,6 +564,14 @@ export type CourtBookingUncheckedUpdateManyWithoutCourtNestedInput = {
|
|||||||
deleteMany?: Prisma.CourtBookingScalarWhereInput | Prisma.CourtBookingScalarWhereInput[]
|
deleteMany?: Prisma.CourtBookingScalarWhereInput | Prisma.CourtBookingScalarWhereInput[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type NullableDecimalFieldUpdateOperationsInput = {
|
||||||
|
set?: runtime.Decimal | runtime.DecimalJsLike | number | string | null
|
||||||
|
increment?: runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||||
|
decrement?: runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||||
|
multiply?: runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||||
|
divide?: runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||||
|
}
|
||||||
|
|
||||||
export type EnumCourtBookingStatusFieldUpdateOperationsInput = {
|
export type EnumCourtBookingStatusFieldUpdateOperationsInput = {
|
||||||
set?: $Enums.CourtBookingStatus
|
set?: $Enums.CourtBookingStatus
|
||||||
}
|
}
|
||||||
@@ -530,9 +582,9 @@ export type CourtBookingCreateWithoutCourtInput = {
|
|||||||
bookingDate: Date | string
|
bookingDate: Date | string
|
||||||
startTime: string
|
startTime: string
|
||||||
endTime: string
|
endTime: string
|
||||||
|
price?: runtime.Decimal | runtime.DecimalJsLike | number | string | null
|
||||||
customerName: string
|
customerName: string
|
||||||
customerPhone: string
|
customerPhone: string
|
||||||
customerEmail?: string | null
|
|
||||||
status?: $Enums.CourtBookingStatus
|
status?: $Enums.CourtBookingStatus
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
@@ -544,9 +596,9 @@ export type CourtBookingUncheckedCreateWithoutCourtInput = {
|
|||||||
bookingDate: Date | string
|
bookingDate: Date | string
|
||||||
startTime: string
|
startTime: string
|
||||||
endTime: string
|
endTime: string
|
||||||
|
price?: runtime.Decimal | runtime.DecimalJsLike | number | string | null
|
||||||
customerName: string
|
customerName: string
|
||||||
customerPhone: string
|
customerPhone: string
|
||||||
customerEmail?: string | null
|
|
||||||
status?: $Enums.CourtBookingStatus
|
status?: $Enums.CourtBookingStatus
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
@@ -588,9 +640,9 @@ export type CourtBookingScalarWhereInput = {
|
|||||||
bookingDate?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
bookingDate?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
||||||
startTime?: Prisma.StringFilter<"CourtBooking"> | string
|
startTime?: Prisma.StringFilter<"CourtBooking"> | string
|
||||||
endTime?: Prisma.StringFilter<"CourtBooking"> | string
|
endTime?: Prisma.StringFilter<"CourtBooking"> | string
|
||||||
|
price?: Prisma.DecimalNullableFilter<"CourtBooking"> | runtime.Decimal | runtime.DecimalJsLike | number | string | null
|
||||||
customerName?: Prisma.StringFilter<"CourtBooking"> | string
|
customerName?: Prisma.StringFilter<"CourtBooking"> | string
|
||||||
customerPhone?: Prisma.StringFilter<"CourtBooking"> | string
|
customerPhone?: Prisma.StringFilter<"CourtBooking"> | string
|
||||||
customerEmail?: Prisma.StringNullableFilter<"CourtBooking"> | string | null
|
|
||||||
status?: Prisma.EnumCourtBookingStatusFilter<"CourtBooking"> | $Enums.CourtBookingStatus
|
status?: Prisma.EnumCourtBookingStatusFilter<"CourtBooking"> | $Enums.CourtBookingStatus
|
||||||
createdAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
createdAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
updatedAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
||||||
@@ -602,9 +654,9 @@ export type CourtBookingCreateManyCourtInput = {
|
|||||||
bookingDate: Date | string
|
bookingDate: Date | string
|
||||||
startTime: string
|
startTime: string
|
||||||
endTime: string
|
endTime: string
|
||||||
|
price?: runtime.Decimal | runtime.DecimalJsLike | number | string | null
|
||||||
customerName: string
|
customerName: string
|
||||||
customerPhone: string
|
customerPhone: string
|
||||||
customerEmail?: string | null
|
|
||||||
status?: $Enums.CourtBookingStatus
|
status?: $Enums.CourtBookingStatus
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
@@ -616,9 +668,9 @@ export type CourtBookingUpdateWithoutCourtInput = {
|
|||||||
bookingDate?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
bookingDate?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
startTime?: Prisma.StringFieldUpdateOperationsInput | string
|
startTime?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
price?: Prisma.NullableDecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string | null
|
||||||
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerEmail?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
|
||||||
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -630,9 +682,9 @@ export type CourtBookingUncheckedUpdateWithoutCourtInput = {
|
|||||||
bookingDate?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
bookingDate?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
startTime?: Prisma.StringFieldUpdateOperationsInput | string
|
startTime?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
price?: Prisma.NullableDecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string | null
|
||||||
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerEmail?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
|
||||||
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -644,9 +696,9 @@ export type CourtBookingUncheckedUpdateManyWithoutCourtInput = {
|
|||||||
bookingDate?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
bookingDate?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
startTime?: Prisma.StringFieldUpdateOperationsInput | string
|
startTime?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
price?: Prisma.NullableDecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string | null
|
||||||
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerEmail?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
|
||||||
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -661,9 +713,9 @@ export type CourtBookingSelect<ExtArgs extends runtime.Types.Extensions.Internal
|
|||||||
bookingDate?: boolean
|
bookingDate?: boolean
|
||||||
startTime?: boolean
|
startTime?: boolean
|
||||||
endTime?: boolean
|
endTime?: boolean
|
||||||
|
price?: boolean
|
||||||
customerName?: boolean
|
customerName?: boolean
|
||||||
customerPhone?: boolean
|
customerPhone?: boolean
|
||||||
customerEmail?: boolean
|
|
||||||
status?: boolean
|
status?: boolean
|
||||||
createdAt?: boolean
|
createdAt?: boolean
|
||||||
updatedAt?: boolean
|
updatedAt?: boolean
|
||||||
@@ -677,9 +729,9 @@ export type CourtBookingSelectCreateManyAndReturn<ExtArgs extends runtime.Types.
|
|||||||
bookingDate?: boolean
|
bookingDate?: boolean
|
||||||
startTime?: boolean
|
startTime?: boolean
|
||||||
endTime?: boolean
|
endTime?: boolean
|
||||||
|
price?: boolean
|
||||||
customerName?: boolean
|
customerName?: boolean
|
||||||
customerPhone?: boolean
|
customerPhone?: boolean
|
||||||
customerEmail?: boolean
|
|
||||||
status?: boolean
|
status?: boolean
|
||||||
createdAt?: boolean
|
createdAt?: boolean
|
||||||
updatedAt?: boolean
|
updatedAt?: boolean
|
||||||
@@ -693,9 +745,9 @@ export type CourtBookingSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.
|
|||||||
bookingDate?: boolean
|
bookingDate?: boolean
|
||||||
startTime?: boolean
|
startTime?: boolean
|
||||||
endTime?: boolean
|
endTime?: boolean
|
||||||
|
price?: boolean
|
||||||
customerName?: boolean
|
customerName?: boolean
|
||||||
customerPhone?: boolean
|
customerPhone?: boolean
|
||||||
customerEmail?: boolean
|
|
||||||
status?: boolean
|
status?: boolean
|
||||||
createdAt?: boolean
|
createdAt?: boolean
|
||||||
updatedAt?: boolean
|
updatedAt?: boolean
|
||||||
@@ -709,15 +761,15 @@ export type CourtBookingSelectScalar = {
|
|||||||
bookingDate?: boolean
|
bookingDate?: boolean
|
||||||
startTime?: boolean
|
startTime?: boolean
|
||||||
endTime?: boolean
|
endTime?: boolean
|
||||||
|
price?: boolean
|
||||||
customerName?: boolean
|
customerName?: boolean
|
||||||
customerPhone?: boolean
|
customerPhone?: boolean
|
||||||
customerEmail?: boolean
|
|
||||||
status?: boolean
|
status?: boolean
|
||||||
createdAt?: boolean
|
createdAt?: boolean
|
||||||
updatedAt?: boolean
|
updatedAt?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CourtBookingOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "bookingCode" | "courtId" | "bookingDate" | "startTime" | "endTime" | "customerName" | "customerPhone" | "customerEmail" | "status" | "createdAt" | "updatedAt", ExtArgs["result"]["courtBooking"]>
|
export type CourtBookingOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "bookingCode" | "courtId" | "bookingDate" | "startTime" | "endTime" | "price" | "customerName" | "customerPhone" | "status" | "createdAt" | "updatedAt", ExtArgs["result"]["courtBooking"]>
|
||||||
export type CourtBookingInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
export type CourtBookingInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
court?: boolean | Prisma.CourtDefaultArgs<ExtArgs>
|
court?: boolean | Prisma.CourtDefaultArgs<ExtArgs>
|
||||||
}
|
}
|
||||||
@@ -740,9 +792,9 @@ export type $CourtBookingPayload<ExtArgs extends runtime.Types.Extensions.Intern
|
|||||||
bookingDate: Date
|
bookingDate: Date
|
||||||
startTime: string
|
startTime: string
|
||||||
endTime: string
|
endTime: string
|
||||||
|
price: runtime.Decimal | null
|
||||||
customerName: string
|
customerName: string
|
||||||
customerPhone: string
|
customerPhone: string
|
||||||
customerEmail: string | null
|
|
||||||
status: $Enums.CourtBookingStatus
|
status: $Enums.CourtBookingStatus
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
@@ -1176,9 +1228,9 @@ export interface CourtBookingFieldRefs {
|
|||||||
readonly bookingDate: Prisma.FieldRef<"CourtBooking", 'DateTime'>
|
readonly bookingDate: Prisma.FieldRef<"CourtBooking", 'DateTime'>
|
||||||
readonly startTime: Prisma.FieldRef<"CourtBooking", 'String'>
|
readonly startTime: Prisma.FieldRef<"CourtBooking", 'String'>
|
||||||
readonly endTime: Prisma.FieldRef<"CourtBooking", 'String'>
|
readonly endTime: Prisma.FieldRef<"CourtBooking", 'String'>
|
||||||
|
readonly price: Prisma.FieldRef<"CourtBooking", 'Decimal'>
|
||||||
readonly customerName: Prisma.FieldRef<"CourtBooking", 'String'>
|
readonly customerName: Prisma.FieldRef<"CourtBooking", 'String'>
|
||||||
readonly customerPhone: Prisma.FieldRef<"CourtBooking", 'String'>
|
readonly customerPhone: Prisma.FieldRef<"CourtBooking", 'String'>
|
||||||
readonly customerEmail: Prisma.FieldRef<"CourtBooking", 'String'>
|
|
||||||
readonly status: Prisma.FieldRef<"CourtBooking", 'CourtBookingStatus'>
|
readonly status: Prisma.FieldRef<"CourtBooking", 'CourtBookingStatus'>
|
||||||
readonly createdAt: Prisma.FieldRef<"CourtBooking", 'DateTime'>
|
readonly createdAt: Prisma.FieldRef<"CourtBooking", 'DateTime'>
|
||||||
readonly updatedAt: Prisma.FieldRef<"CourtBooking", 'DateTime'>
|
readonly updatedAt: Prisma.FieldRef<"CourtBooking", 'DateTime'>
|
||||||
|
|||||||
1395
apps/backend/src/generated/prisma/models/OnboardingRequest.ts
Normal file
@@ -30,7 +30,6 @@ export type UserMinAggregateOutputType = {
|
|||||||
email: string | null
|
email: string | null
|
||||||
emailVerified: boolean | null
|
emailVerified: boolean | null
|
||||||
image: string | null
|
image: string | null
|
||||||
phone: string | null
|
|
||||||
createdAt: Date | null
|
createdAt: Date | null
|
||||||
updatedAt: Date | null
|
updatedAt: Date | null
|
||||||
role: string | null
|
role: string | null
|
||||||
@@ -42,7 +41,6 @@ export type UserMaxAggregateOutputType = {
|
|||||||
email: string | null
|
email: string | null
|
||||||
emailVerified: boolean | null
|
emailVerified: boolean | null
|
||||||
image: string | null
|
image: string | null
|
||||||
phone: string | null
|
|
||||||
createdAt: Date | null
|
createdAt: Date | null
|
||||||
updatedAt: Date | null
|
updatedAt: Date | null
|
||||||
role: string | null
|
role: string | null
|
||||||
@@ -54,7 +52,6 @@ export type UserCountAggregateOutputType = {
|
|||||||
email: number
|
email: number
|
||||||
emailVerified: number
|
emailVerified: number
|
||||||
image: number
|
image: number
|
||||||
phone: number
|
|
||||||
createdAt: number
|
createdAt: number
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
role: number
|
role: number
|
||||||
@@ -68,7 +65,6 @@ export type UserMinAggregateInputType = {
|
|||||||
email?: true
|
email?: true
|
||||||
emailVerified?: true
|
emailVerified?: true
|
||||||
image?: true
|
image?: true
|
||||||
phone?: true
|
|
||||||
createdAt?: true
|
createdAt?: true
|
||||||
updatedAt?: true
|
updatedAt?: true
|
||||||
role?: true
|
role?: true
|
||||||
@@ -80,7 +76,6 @@ export type UserMaxAggregateInputType = {
|
|||||||
email?: true
|
email?: true
|
||||||
emailVerified?: true
|
emailVerified?: true
|
||||||
image?: true
|
image?: true
|
||||||
phone?: true
|
|
||||||
createdAt?: true
|
createdAt?: true
|
||||||
updatedAt?: true
|
updatedAt?: true
|
||||||
role?: true
|
role?: true
|
||||||
@@ -92,7 +87,6 @@ export type UserCountAggregateInputType = {
|
|||||||
email?: true
|
email?: true
|
||||||
emailVerified?: true
|
emailVerified?: true
|
||||||
image?: true
|
image?: true
|
||||||
phone?: true
|
|
||||||
createdAt?: true
|
createdAt?: true
|
||||||
updatedAt?: true
|
updatedAt?: true
|
||||||
role?: true
|
role?: true
|
||||||
@@ -177,7 +171,6 @@ export type UserGroupByOutputType = {
|
|||||||
email: string
|
email: string
|
||||||
emailVerified: boolean
|
emailVerified: boolean
|
||||||
image: string | null
|
image: string | null
|
||||||
phone: string | null
|
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
role: string
|
role: string
|
||||||
@@ -210,7 +203,6 @@ export type UserWhereInput = {
|
|||||||
email?: Prisma.StringFilter<"User"> | string
|
email?: Prisma.StringFilter<"User"> | string
|
||||||
emailVerified?: Prisma.BoolFilter<"User"> | boolean
|
emailVerified?: Prisma.BoolFilter<"User"> | boolean
|
||||||
image?: Prisma.StringNullableFilter<"User"> | string | null
|
image?: Prisma.StringNullableFilter<"User"> | string | null
|
||||||
phone?: Prisma.StringNullableFilter<"User"> | string | null
|
|
||||||
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||||
role?: Prisma.StringFilter<"User"> | string
|
role?: Prisma.StringFilter<"User"> | string
|
||||||
@@ -225,7 +217,6 @@ export type UserOrderByWithRelationInput = {
|
|||||||
email?: Prisma.SortOrder
|
email?: Prisma.SortOrder
|
||||||
emailVerified?: Prisma.SortOrder
|
emailVerified?: Prisma.SortOrder
|
||||||
image?: Prisma.SortOrderInput | Prisma.SortOrder
|
image?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
phone?: Prisma.SortOrderInput | Prisma.SortOrder
|
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
role?: Prisma.SortOrder
|
role?: Prisma.SortOrder
|
||||||
@@ -243,7 +234,6 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{
|
|||||||
name?: Prisma.StringFilter<"User"> | string
|
name?: Prisma.StringFilter<"User"> | string
|
||||||
emailVerified?: Prisma.BoolFilter<"User"> | boolean
|
emailVerified?: Prisma.BoolFilter<"User"> | boolean
|
||||||
image?: Prisma.StringNullableFilter<"User"> | string | null
|
image?: Prisma.StringNullableFilter<"User"> | string | null
|
||||||
phone?: Prisma.StringNullableFilter<"User"> | string | null
|
|
||||||
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||||
role?: Prisma.StringFilter<"User"> | string
|
role?: Prisma.StringFilter<"User"> | string
|
||||||
@@ -258,7 +248,6 @@ export type UserOrderByWithAggregationInput = {
|
|||||||
email?: Prisma.SortOrder
|
email?: Prisma.SortOrder
|
||||||
emailVerified?: Prisma.SortOrder
|
emailVerified?: Prisma.SortOrder
|
||||||
image?: Prisma.SortOrderInput | Prisma.SortOrder
|
image?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
phone?: Prisma.SortOrderInput | Prisma.SortOrder
|
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
role?: Prisma.SortOrder
|
role?: Prisma.SortOrder
|
||||||
@@ -276,7 +265,6 @@ export type UserScalarWhereWithAggregatesInput = {
|
|||||||
email?: Prisma.StringWithAggregatesFilter<"User"> | string
|
email?: Prisma.StringWithAggregatesFilter<"User"> | string
|
||||||
emailVerified?: Prisma.BoolWithAggregatesFilter<"User"> | boolean
|
emailVerified?: Prisma.BoolWithAggregatesFilter<"User"> | boolean
|
||||||
image?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null
|
image?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null
|
||||||
phone?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null
|
|
||||||
createdAt?: Prisma.DateTimeWithAggregatesFilter<"User"> | Date | string
|
createdAt?: Prisma.DateTimeWithAggregatesFilter<"User"> | Date | string
|
||||||
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"User"> | Date | string
|
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"User"> | Date | string
|
||||||
role?: Prisma.StringWithAggregatesFilter<"User"> | string
|
role?: Prisma.StringWithAggregatesFilter<"User"> | string
|
||||||
@@ -288,7 +276,6 @@ export type UserCreateInput = {
|
|||||||
email: string
|
email: string
|
||||||
emailVerified?: boolean
|
emailVerified?: boolean
|
||||||
image?: string | null
|
image?: string | null
|
||||||
phone?: string | null
|
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
role?: string
|
role?: string
|
||||||
@@ -303,7 +290,6 @@ export type UserUncheckedCreateInput = {
|
|||||||
email: string
|
email: string
|
||||||
emailVerified?: boolean
|
emailVerified?: boolean
|
||||||
image?: string | null
|
image?: string | null
|
||||||
phone?: string | null
|
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
role?: string
|
role?: string
|
||||||
@@ -318,7 +304,6 @@ export type UserUpdateInput = {
|
|||||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
role?: Prisma.StringFieldUpdateOperationsInput | string
|
role?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
@@ -333,7 +318,6 @@ export type UserUncheckedUpdateInput = {
|
|||||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
role?: Prisma.StringFieldUpdateOperationsInput | string
|
role?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
@@ -348,7 +332,6 @@ export type UserCreateManyInput = {
|
|||||||
email: string
|
email: string
|
||||||
emailVerified?: boolean
|
emailVerified?: boolean
|
||||||
image?: string | null
|
image?: string | null
|
||||||
phone?: string | null
|
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
role?: string
|
role?: string
|
||||||
@@ -360,7 +343,6 @@ export type UserUpdateManyMutationInput = {
|
|||||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
role?: Prisma.StringFieldUpdateOperationsInput | string
|
role?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
@@ -372,7 +354,6 @@ export type UserUncheckedUpdateManyInput = {
|
|||||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
role?: Prisma.StringFieldUpdateOperationsInput | string
|
role?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
@@ -384,7 +365,6 @@ export type UserCountOrderByAggregateInput = {
|
|||||||
email?: Prisma.SortOrder
|
email?: Prisma.SortOrder
|
||||||
emailVerified?: Prisma.SortOrder
|
emailVerified?: Prisma.SortOrder
|
||||||
image?: Prisma.SortOrder
|
image?: Prisma.SortOrder
|
||||||
phone?: Prisma.SortOrder
|
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
role?: Prisma.SortOrder
|
role?: Prisma.SortOrder
|
||||||
@@ -396,7 +376,6 @@ export type UserMaxOrderByAggregateInput = {
|
|||||||
email?: Prisma.SortOrder
|
email?: Prisma.SortOrder
|
||||||
emailVerified?: Prisma.SortOrder
|
emailVerified?: Prisma.SortOrder
|
||||||
image?: Prisma.SortOrder
|
image?: Prisma.SortOrder
|
||||||
phone?: Prisma.SortOrder
|
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
role?: Prisma.SortOrder
|
role?: Prisma.SortOrder
|
||||||
@@ -408,7 +387,6 @@ export type UserMinOrderByAggregateInput = {
|
|||||||
email?: Prisma.SortOrder
|
email?: Prisma.SortOrder
|
||||||
emailVerified?: Prisma.SortOrder
|
emailVerified?: Prisma.SortOrder
|
||||||
image?: Prisma.SortOrder
|
image?: Prisma.SortOrder
|
||||||
phone?: Prisma.SortOrder
|
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
role?: Prisma.SortOrder
|
role?: Prisma.SortOrder
|
||||||
@@ -483,7 +461,6 @@ export type UserCreateWithoutSessionsInput = {
|
|||||||
email: string
|
email: string
|
||||||
emailVerified?: boolean
|
emailVerified?: boolean
|
||||||
image?: string | null
|
image?: string | null
|
||||||
phone?: string | null
|
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
role?: string
|
role?: string
|
||||||
@@ -497,7 +474,6 @@ export type UserUncheckedCreateWithoutSessionsInput = {
|
|||||||
email: string
|
email: string
|
||||||
emailVerified?: boolean
|
emailVerified?: boolean
|
||||||
image?: string | null
|
image?: string | null
|
||||||
phone?: string | null
|
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
role?: string
|
role?: string
|
||||||
@@ -527,7 +503,6 @@ export type UserUpdateWithoutSessionsInput = {
|
|||||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
role?: Prisma.StringFieldUpdateOperationsInput | string
|
role?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
@@ -541,7 +516,6 @@ export type UserUncheckedUpdateWithoutSessionsInput = {
|
|||||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
role?: Prisma.StringFieldUpdateOperationsInput | string
|
role?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
@@ -555,7 +529,6 @@ export type UserCreateWithoutAccountsInput = {
|
|||||||
email: string
|
email: string
|
||||||
emailVerified?: boolean
|
emailVerified?: boolean
|
||||||
image?: string | null
|
image?: string | null
|
||||||
phone?: string | null
|
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
role?: string
|
role?: string
|
||||||
@@ -569,7 +542,6 @@ export type UserUncheckedCreateWithoutAccountsInput = {
|
|||||||
email: string
|
email: string
|
||||||
emailVerified?: boolean
|
emailVerified?: boolean
|
||||||
image?: string | null
|
image?: string | null
|
||||||
phone?: string | null
|
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
role?: string
|
role?: string
|
||||||
@@ -599,7 +571,6 @@ export type UserUpdateWithoutAccountsInput = {
|
|||||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
role?: Prisma.StringFieldUpdateOperationsInput | string
|
role?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
@@ -613,7 +584,6 @@ export type UserUncheckedUpdateWithoutAccountsInput = {
|
|||||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
role?: Prisma.StringFieldUpdateOperationsInput | string
|
role?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
@@ -627,7 +597,6 @@ export type UserCreateWithoutComplexesInput = {
|
|||||||
email: string
|
email: string
|
||||||
emailVerified?: boolean
|
emailVerified?: boolean
|
||||||
image?: string | null
|
image?: string | null
|
||||||
phone?: string | null
|
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
role?: string
|
role?: string
|
||||||
@@ -641,7 +610,6 @@ export type UserUncheckedCreateWithoutComplexesInput = {
|
|||||||
email: string
|
email: string
|
||||||
emailVerified?: boolean
|
emailVerified?: boolean
|
||||||
image?: string | null
|
image?: string | null
|
||||||
phone?: string | null
|
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
role?: string
|
role?: string
|
||||||
@@ -671,7 +639,6 @@ export type UserUpdateWithoutComplexesInput = {
|
|||||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
role?: Prisma.StringFieldUpdateOperationsInput | string
|
role?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
@@ -685,7 +652,6 @@ export type UserUncheckedUpdateWithoutComplexesInput = {
|
|||||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
role?: Prisma.StringFieldUpdateOperationsInput | string
|
role?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
@@ -748,7 +714,6 @@ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
|
|||||||
email?: boolean
|
email?: boolean
|
||||||
emailVerified?: boolean
|
emailVerified?: boolean
|
||||||
image?: boolean
|
image?: boolean
|
||||||
phone?: boolean
|
|
||||||
createdAt?: boolean
|
createdAt?: boolean
|
||||||
updatedAt?: boolean
|
updatedAt?: boolean
|
||||||
role?: boolean
|
role?: boolean
|
||||||
@@ -764,7 +729,6 @@ export type UserSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensio
|
|||||||
email?: boolean
|
email?: boolean
|
||||||
emailVerified?: boolean
|
emailVerified?: boolean
|
||||||
image?: boolean
|
image?: boolean
|
||||||
phone?: boolean
|
|
||||||
createdAt?: boolean
|
createdAt?: boolean
|
||||||
updatedAt?: boolean
|
updatedAt?: boolean
|
||||||
role?: boolean
|
role?: boolean
|
||||||
@@ -776,7 +740,6 @@ export type UserSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensio
|
|||||||
email?: boolean
|
email?: boolean
|
||||||
emailVerified?: boolean
|
emailVerified?: boolean
|
||||||
image?: boolean
|
image?: boolean
|
||||||
phone?: boolean
|
|
||||||
createdAt?: boolean
|
createdAt?: boolean
|
||||||
updatedAt?: boolean
|
updatedAt?: boolean
|
||||||
role?: boolean
|
role?: boolean
|
||||||
@@ -788,13 +751,12 @@ export type UserSelectScalar = {
|
|||||||
email?: boolean
|
email?: boolean
|
||||||
emailVerified?: boolean
|
emailVerified?: boolean
|
||||||
image?: boolean
|
image?: boolean
|
||||||
phone?: boolean
|
|
||||||
createdAt?: boolean
|
createdAt?: boolean
|
||||||
updatedAt?: boolean
|
updatedAt?: boolean
|
||||||
role?: boolean
|
role?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "name" | "email" | "emailVerified" | "image" | "phone" | "createdAt" | "updatedAt" | "role", ExtArgs["result"]["user"]>
|
export type UserOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "name" | "email" | "emailVerified" | "image" | "createdAt" | "updatedAt" | "role", ExtArgs["result"]["user"]>
|
||||||
export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
sessions?: boolean | Prisma.User$sessionsArgs<ExtArgs>
|
sessions?: boolean | Prisma.User$sessionsArgs<ExtArgs>
|
||||||
accounts?: boolean | Prisma.User$accountsArgs<ExtArgs>
|
accounts?: boolean | Prisma.User$accountsArgs<ExtArgs>
|
||||||
@@ -817,7 +779,6 @@ export type $UserPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
|
|||||||
email: string
|
email: string
|
||||||
emailVerified: boolean
|
emailVerified: boolean
|
||||||
image: string | null
|
image: string | null
|
||||||
phone: string | null
|
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
role: string
|
role: string
|
||||||
@@ -1252,7 +1213,6 @@ export interface UserFieldRefs {
|
|||||||
readonly email: Prisma.FieldRef<"User", 'String'>
|
readonly email: Prisma.FieldRef<"User", 'String'>
|
||||||
readonly emailVerified: Prisma.FieldRef<"User", 'Boolean'>
|
readonly emailVerified: Prisma.FieldRef<"User", 'Boolean'>
|
||||||
readonly image: Prisma.FieldRef<"User", 'String'>
|
readonly image: Prisma.FieldRef<"User", 'String'>
|
||||||
readonly phone: Prisma.FieldRef<"User", 'String'>
|
|
||||||
readonly createdAt: Prisma.FieldRef<"User", 'DateTime'>
|
readonly createdAt: Prisma.FieldRef<"User", 'DateTime'>
|
||||||
readonly updatedAt: Prisma.FieldRef<"User", 'DateTime'>
|
readonly updatedAt: Prisma.FieldRef<"User", 'DateTime'>
|
||||||
readonly role: Prisma.FieldRef<"User", 'String'>
|
readonly role: Prisma.FieldRef<"User", 'String'>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ 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({
|
||||||
@@ -19,29 +18,6 @@ export const auth = betterAuth({
|
|||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
user: {
|
|
||||||
additionalFields: {
|
|
||||||
phone: {
|
|
||||||
type: 'string',
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emailVerification: {
|
|
||||||
sendOnSignUp: true,
|
|
||||||
autoSignInAfterVerification: true,
|
|
||||||
sendVerificationEmail: async ({ user, url }) => {
|
|
||||||
const verificationUrl = new URL(url);
|
|
||||||
const appUrl = process.env.APP_BASE_URL ?? 'http://localhost:5173';
|
|
||||||
verificationUrl.searchParams.set('callbackURL', appUrl);
|
|
||||||
await sendMail({
|
|
||||||
to: user.email,
|
|
||||||
subject: 'Verificá tu email en Playzer',
|
|
||||||
html: `Hacé click para verificar tu email: <a href="${verificationUrl.toString()}">${verificationUrl.toString()}</a>`,
|
|
||||||
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!,
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
// lib/errors.ts
|
|
||||||
export type ValidationIssue = {
|
|
||||||
path: string;
|
|
||||||
message: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AppError =
|
|
||||||
| {
|
|
||||||
type: 'validation';
|
|
||||||
message: string;
|
|
||||||
issues?: ValidationIssue[];
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'not_found';
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'conflict';
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'unauthorized';
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'forbidden';
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'unexpected';
|
|
||||||
message: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Errors = {
|
|
||||||
validation(message: string, issues?: ValidationIssue[]): AppError {
|
|
||||||
return { type: 'validation', message, issues };
|
|
||||||
},
|
|
||||||
|
|
||||||
notFound(message = 'Resource not found'): AppError {
|
|
||||||
return { type: 'not_found', message };
|
|
||||||
},
|
|
||||||
|
|
||||||
conflict(message: string): AppError {
|
|
||||||
return { type: 'conflict', message };
|
|
||||||
},
|
|
||||||
|
|
||||||
unauthorized(message = 'Unauthorized'): AppError {
|
|
||||||
return { type: 'unauthorized', message };
|
|
||||||
},
|
|
||||||
|
|
||||||
forbidden(message = 'Forbidden'): AppError {
|
|
||||||
return { type: 'forbidden', message };
|
|
||||||
},
|
|
||||||
|
|
||||||
unexpected(message = 'Unexpected error'): AppError {
|
|
||||||
return { type: 'unexpected', message };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
// http/error-handler.ts
|
|
||||||
import type { MiddlewareHandler } from 'hono';
|
|
||||||
import { logger } from '../logger';
|
|
||||||
import { unexpectedProblem } from './problem-builders';
|
|
||||||
|
|
||||||
export const errorHandler: MiddlewareHandler = async (c, next) => {
|
|
||||||
try {
|
|
||||||
await next();
|
|
||||||
} catch (error) {
|
|
||||||
const requestId = c.get('requestId') as string | undefined;
|
|
||||||
const instance = requestId ? `/requests/${requestId}` : undefined;
|
|
||||||
|
|
||||||
logger.error({
|
|
||||||
requestId,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
|
|
||||||
return c.json(unexpectedProblem({ instance }), 500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import type { Result } from '@/lib/result';
|
|
||||||
import type { Context } from 'hono';
|
|
||||||
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
|
||||||
import { mapAppErrorToProblem } from './problem-mapper';
|
|
||||||
|
|
||||||
export function handleResult<T>(
|
|
||||||
c: Context,
|
|
||||||
result: Result<T>,
|
|
||||||
successStatus: ContentfulStatusCode = 200
|
|
||||||
) {
|
|
||||||
if (!result.ok) {
|
|
||||||
const requestId = c.get('requestId') as string | undefined;
|
|
||||||
const instance = requestId ? `/requests/${requestId}` : undefined;
|
|
||||||
const problem = mapAppErrorToProblem(result.error, instance);
|
|
||||||
|
|
||||||
return c.json(problem, problem.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json(result.value, successStatus);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
// http/problem-builders.ts
|
|
||||||
import type { ProblemDetails } from './problem-details';
|
|
||||||
|
|
||||||
export function validationProblem(params: {
|
|
||||||
detail?: string;
|
|
||||||
instance?: string;
|
|
||||||
errors?: Record<string, string[]>;
|
|
||||||
}): ProblemDetails {
|
|
||||||
return {
|
|
||||||
type: 'https://api.myapp.dev/problems/validation',
|
|
||||||
title: 'Bad Request',
|
|
||||||
status: 400,
|
|
||||||
detail: params.detail ?? 'Invalid request data',
|
|
||||||
instance: params.instance,
|
|
||||||
errors: params.errors,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function unexpectedProblem(params?: {
|
|
||||||
instance?: string;
|
|
||||||
}): ProblemDetails {
|
|
||||||
return {
|
|
||||||
type: 'https://api.myapp.dev/problems/unexpected',
|
|
||||||
title: 'Internal Server Error',
|
|
||||||
status: 500,
|
|
||||||
detail: 'Internal server error',
|
|
||||||
instance: params?.instance,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
|
||||||
|
|
||||||
export type ProblemDetails = {
|
|
||||||
type: string;
|
|
||||||
title: string;
|
|
||||||
status: ContentfulStatusCode;
|
|
||||||
detail?: string;
|
|
||||||
instance?: string;
|
|
||||||
errors?: Record<string, string[]>;
|
|
||||||
};
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import type { AppError } from '@/lib/errors';
|
|
||||||
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
|
||||||
import type { ProblemDetails } from './problem-details';
|
|
||||||
|
|
||||||
function statusFromError(error: AppError): ContentfulStatusCode {
|
|
||||||
switch (error.type) {
|
|
||||||
case 'validation':
|
|
||||||
return 400;
|
|
||||||
case 'unauthorized':
|
|
||||||
return 401;
|
|
||||||
case 'forbidden':
|
|
||||||
return 403;
|
|
||||||
case 'not_found':
|
|
||||||
return 404;
|
|
||||||
case 'conflict':
|
|
||||||
return 409;
|
|
||||||
case 'unexpected':
|
|
||||||
return 500;
|
|
||||||
default:
|
|
||||||
return 500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function titleFromStatus(status: ContentfulStatusCode): string {
|
|
||||||
switch (status) {
|
|
||||||
case 400:
|
|
||||||
return 'Bad Request';
|
|
||||||
case 401:
|
|
||||||
return 'Unauthorized';
|
|
||||||
case 403:
|
|
||||||
return 'Forbidden';
|
|
||||||
case 404:
|
|
||||||
return 'Not Found';
|
|
||||||
case 409:
|
|
||||||
return 'Conflict';
|
|
||||||
default:
|
|
||||||
return 'Internal Server Error';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapAppErrorToProblem(error: AppError, instance?: string): ProblemDetails {
|
|
||||||
const status = statusFromError(error);
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: `https://api.myapp.dev/problems/${error.type}`,
|
|
||||||
title: titleFromStatus(status),
|
|
||||||
status,
|
|
||||||
detail: status === 500 ? 'Internal server error' : error.message,
|
|
||||||
instance,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import type { MiddlewareHandler } from 'hono';
|
|
||||||
|
|
||||||
type Env = {
|
|
||||||
Variables: {
|
|
||||||
requestId: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const requestIdMiddleware: MiddlewareHandler<Env> = async (c, next) => {
|
|
||||||
const requestId = crypto.randomUUID();
|
|
||||||
|
|
||||||
c.set('requestId', requestId);
|
|
||||||
c.header('X-Request-Id', requestId);
|
|
||||||
|
|
||||||
await next();
|
|
||||||
};
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { zValidator } from '@hono/zod-validator';
|
|
||||||
import type { ZodSchema } from 'zod';
|
|
||||||
import { validationProblem } from './problem-builders';
|
|
||||||
import { zodIssuesToRecord } from './zod-issues';
|
|
||||||
|
|
||||||
type ValidationTarget = 'json' | 'query' | 'param' | 'header' | 'form';
|
|
||||||
|
|
||||||
function makeValidator<TSchema extends ZodSchema>(target: ValidationTarget, schema: TSchema) {
|
|
||||||
return zValidator(target, schema, (result, c) => {
|
|
||||||
if (result.success) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestId = c.get('requestId') as string | undefined;
|
|
||||||
const instance = requestId ? `/requests/${requestId}` : undefined;
|
|
||||||
|
|
||||||
return c.json(
|
|
||||||
validationProblem({
|
|
||||||
detail: `Invalid ${target} data`,
|
|
||||||
instance,
|
|
||||||
errors: zodIssuesToRecord(result.error.issues),
|
|
||||||
}),
|
|
||||||
400
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export const validate = {
|
|
||||||
json<TSchema extends ZodSchema>(schema: TSchema) {
|
|
||||||
return makeValidator('json', schema);
|
|
||||||
},
|
|
||||||
|
|
||||||
query<TSchema extends ZodSchema>(schema: TSchema) {
|
|
||||||
return makeValidator('query', schema);
|
|
||||||
},
|
|
||||||
|
|
||||||
param<TSchema extends ZodSchema>(schema: TSchema) {
|
|
||||||
return makeValidator('param', schema);
|
|
||||||
},
|
|
||||||
|
|
||||||
header<TSchema extends ZodSchema>(schema: TSchema) {
|
|
||||||
return makeValidator('header', schema);
|
|
||||||
},
|
|
||||||
|
|
||||||
form<TSchema extends ZodSchema>(schema: TSchema) {
|
|
||||||
return makeValidator('form', schema);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
type IssueLike = {
|
|
||||||
path: PropertyKey[];
|
|
||||||
message: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function zodIssuesToRecord(issues: IssueLike[]): Record<string, string[]> {
|
|
||||||
const out: Record<string, string[]> = {};
|
|
||||||
|
|
||||||
for (const issue of issues) {
|
|
||||||
const key = issue.path.length ? issue.path.join('.') : 'root';
|
|
||||||
out[key] ??= [];
|
|
||||||
out[key].push(issue.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
// lib/result.ts
|
|
||||||
import type { AppError } from '@/lib/errors';
|
|
||||||
|
|
||||||
export type Result<T, E = AppError> = { ok: true; value: T } | { ok: false; error: E };
|
|
||||||
|
|
||||||
export function ok<T>(value: T): Result<T> {
|
|
||||||
return { ok: true, value };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function err<E>(error: E): Result<never, E> {
|
|
||||||
return { ok: false, error };
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ 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';
|
||||||
|
|
||||||
@@ -15,20 +14,6 @@ export async function createAdminBookingHandler(c: AppContext) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const booking = await createAdminBooking(user.id, complexId, payload);
|
const booking = await createAdminBooking(user.id, complexId, payload);
|
||||||
|
|
||||||
void sendBookingConfirmation({
|
|
||||||
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,
|
|
||||||
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,7 +2,6 @@ import {
|
|||||||
AdminBookingServiceError,
|
AdminBookingServiceError,
|
||||||
updateAdminBookingStatus,
|
updateAdminBookingStatus,
|
||||||
} from '@/modules/admin-booking/services/admin-booking.service';
|
} from '@/modules/admin-booking/services/admin-booking.service';
|
||||||
import { sendBookingCancelled, sendBookingNoShow } from '@/services/booking-email.service';
|
|
||||||
import type { AppContext } from '@/types/hono';
|
import type { AppContext } from '@/types/hono';
|
||||||
import type { UpdateAdminBookingStatusInput } from '@repo/api-contract';
|
import type { UpdateAdminBookingStatusInput } from '@repo/api-contract';
|
||||||
|
|
||||||
@@ -15,33 +14,6 @@ export async function updateAdminBookingStatusHandler(c: AppContext) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const booking = await updateAdminBookingStatus(user.id, id, payload);
|
const booking = await updateAdminBookingStatus(user.id, id, payload);
|
||||||
|
|
||||||
if (payload.status === 'CANCELLED') {
|
|
||||||
void sendBookingCancelled({
|
|
||||||
bookingCode: booking.bookingCode,
|
|
||||||
complexName: booking.complexName,
|
|
||||||
date: booking.date,
|
|
||||||
startTime: booking.startTime,
|
|
||||||
endTime: booking.endTime,
|
|
||||||
courtName: booking.courtName,
|
|
||||||
sportName: booking.sport.name,
|
|
||||||
customerName: booking.customerName,
|
|
||||||
customerEmail: booking.customerEmail,
|
|
||||||
});
|
|
||||||
} else if (payload.status === 'NOSHOW') {
|
|
||||||
void sendBookingNoShow({
|
|
||||||
bookingCode: booking.bookingCode,
|
|
||||||
complexName: booking.complexName,
|
|
||||||
date: booking.date,
|
|
||||||
startTime: booking.startTime,
|
|
||||||
endTime: booking.endTime,
|
|
||||||
courtName: booking.courtName,
|
|
||||||
sportName: booking.sport.name,
|
|
||||||
customerName: booking.customerName,
|
|
||||||
customerEmail: booking.customerEmail,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json(booking);
|
return c.json(booking);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AdminBookingServiceError) {
|
if (error instanceof AdminBookingServiceError) {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { randomInt } from 'node:crypto';
|
|||||||
import { CourtBookingStatus } from '@/generated/prisma/enums';
|
import { CourtBookingStatus } from '@/generated/prisma/enums';
|
||||||
import { db } from '@/lib/prisma';
|
import { db } from '@/lib/prisma';
|
||||||
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service';
|
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service';
|
||||||
import type { DayOfWeek } from '@repo/api-contract';
|
|
||||||
import type {
|
import type {
|
||||||
AdminBooking,
|
AdminBooking,
|
||||||
CreateAdminBookingInput,
|
CreateAdminBookingInput,
|
||||||
@@ -157,40 +156,6 @@ async function ensureComplexAccess(complexId: string, userId: string) {
|
|||||||
return complexUser.complex;
|
return complexUser.complex;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolvePrice(
|
|
||||||
court: {
|
|
||||||
basePrice: unknown;
|
|
||||||
priceRules: Array<{
|
|
||||||
dayOfWeek: string | null;
|
|
||||||
startTime: string | null;
|
|
||||||
endTime: string | null;
|
|
||||||
price: unknown;
|
|
||||||
}>;
|
|
||||||
},
|
|
||||||
dayOfWeek: string,
|
|
||||||
startTime: string,
|
|
||||||
endTime: string
|
|
||||||
): number {
|
|
||||||
const slotStart = toMinutes(startTime);
|
|
||||||
const slotEnd = toMinutes(endTime);
|
|
||||||
|
|
||||||
const matchingRules = court.priceRules
|
|
||||||
.filter((rule) => {
|
|
||||||
if (rule.dayOfWeek && rule.dayOfWeek !== dayOfWeek) return false;
|
|
||||||
if (!rule.startTime || !rule.endTime) return true;
|
|
||||||
return slotStart >= toMinutes(rule.startTime) && slotEnd <= toMinutes(rule.endTime);
|
|
||||||
})
|
|
||||||
.sort((first, second) => {
|
|
||||||
const firstSpecificity =
|
|
||||||
(first.dayOfWeek ? 2 : 0) + (first.startTime && first.endTime ? 1 : 0);
|
|
||||||
const secondSpecificity =
|
|
||||||
(second.dayOfWeek ? 2 : 0) + (second.startTime && second.endTime ? 1 : 0);
|
|
||||||
return secondSpecificity - firstSpecificity;
|
|
||||||
});
|
|
||||||
|
|
||||||
return Number(matchingRules[0]?.price ?? court.basePrice);
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapBookingResponse(booking: {
|
function mapBookingResponse(booking: {
|
||||||
id: string;
|
id: string;
|
||||||
bookingCode: string;
|
bookingCode: string;
|
||||||
@@ -199,8 +164,7 @@ function mapBookingResponse(booking: {
|
|||||||
endTime: string;
|
endTime: string;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
customerPhone: string;
|
customerPhone: string;
|
||||||
customerEmail: string | null;
|
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED';
|
||||||
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NOSHOW';
|
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
court: {
|
court: {
|
||||||
@@ -216,7 +180,6 @@ function mapBookingResponse(booking: {
|
|||||||
slug: string;
|
slug: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
price?: number;
|
|
||||||
}): AdminBooking {
|
}): AdminBooking {
|
||||||
return {
|
return {
|
||||||
id: booking.id,
|
id: booking.id,
|
||||||
@@ -235,8 +198,6 @@ function mapBookingResponse(booking: {
|
|||||||
endTime: booking.endTime,
|
endTime: booking.endTime,
|
||||||
customerName: booking.customerName,
|
customerName: booking.customerName,
|
||||||
customerPhone: booking.customerPhone,
|
customerPhone: booking.customerPhone,
|
||||||
customerEmail: booking.customerEmail ?? undefined,
|
|
||||||
price: booking.price ?? 0,
|
|
||||||
status: booking.status,
|
status: booking.status,
|
||||||
createdAt: booking.createdAt.toISOString(),
|
createdAt: booking.createdAt.toISOString(),
|
||||||
updatedAt: booking.updatedAt.toISOString(),
|
updatedAt: booking.updatedAt.toISOString(),
|
||||||
@@ -297,16 +258,6 @@ export async function createAdminBooking(
|
|||||||
input: CreateAdminBookingInput
|
input: CreateAdminBookingInput
|
||||||
) {
|
) {
|
||||||
const complex = await ensureComplexAccess(complexId, userId);
|
const complex = await ensureComplexAccess(complexId, userId);
|
||||||
|
|
||||||
const adminUser = await db.user.findUnique({
|
|
||||||
where: { id: userId },
|
|
||||||
select: { emailVerified: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!adminUser?.emailVerified) {
|
|
||||||
throw new AdminBookingServiceError('Debés verificar tu email para poder crear reservas.', 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
const bookingDate = parseIsoDate(input.date);
|
const bookingDate = parseIsoDate(input.date);
|
||||||
const dayOfWeek = getDayOfWeek(bookingDate);
|
const dayOfWeek = getDayOfWeek(bookingDate);
|
||||||
|
|
||||||
@@ -331,10 +282,6 @@ export async function createAdminBooking(
|
|||||||
slug: true,
|
slug: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
priceRules: {
|
|
||||||
where: { isActive: true },
|
|
||||||
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -424,7 +371,6 @@ export async function createAdminBooking(
|
|||||||
endTime: selectedSlot.endTime,
|
endTime: selectedSlot.endTime,
|
||||||
customerName: input.customerName.trim(),
|
customerName: input.customerName.trim(),
|
||||||
customerPhone: input.customerPhone.trim(),
|
customerPhone: input.customerPhone.trim(),
|
||||||
customerEmail: input.customerEmail?.trim() || null,
|
|
||||||
status: 'CONFIRMED',
|
status: 'CONFIRMED',
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
@@ -451,9 +397,7 @@ export async function createAdminBooking(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const price = resolvePrice(court, dayOfWeek, selectedSlot.startTime, selectedSlot.endTime);
|
return mapBookingResponse(booking);
|
||||||
|
|
||||||
return mapBookingResponse({ ...booking, price });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AdminBookingServiceError) {
|
if (error instanceof AdminBookingServiceError) {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -550,43 +494,34 @@ export async function updateAdminBookingStatus(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.status === 'NOSHOW' && booking.status !== 'CONFIRMED') {
|
const updated = await db.courtBooking.update({
|
||||||
throw new AdminBookingServiceError(
|
where: { id: booking.id },
|
||||||
'Solo se pueden marcar como no show las reservas confirmadas.',
|
|
||||||
409
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.courtBookingLog.create({
|
|
||||||
data: {
|
data: {
|
||||||
id: uuidv7(),
|
status:
|
||||||
bookingCode: booking.bookingCode,
|
input.status === 'COMPLETED' ? CourtBookingStatus.COMPLETED : CourtBookingStatus.CANCELLED,
|
||||||
courtId: booking.court.id,
|
},
|
||||||
bookingDate: booking.bookingDate,
|
include: {
|
||||||
startTime: booking.startTime,
|
court: {
|
||||||
endTime: booking.endTime,
|
select: {
|
||||||
customerName: booking.customerName,
|
id: true,
|
||||||
customerPhone: booking.customerPhone,
|
name: true,
|
||||||
customerEmail: booking.customerEmail,
|
sport: {
|
||||||
previousStatus: booking.status,
|
select: {
|
||||||
newStatus: input.status,
|
id: true,
|
||||||
changedAt: new Date(),
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
complex: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
complexName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (input.status === 'COMPLETED' || input.status === 'NOSHOW') {
|
return mapBookingResponse(updated);
|
||||||
await db.courtBooking.update({
|
|
||||||
where: { id: booking.id },
|
|
||||||
data: {
|
|
||||||
status: input.status,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// if the booking is cancelled we delete it to free up the slot, but we keep a log of it with the cancelled status
|
|
||||||
await db.courtBooking.delete({
|
|
||||||
where: { id: booking.id },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return mapBookingResponse({ ...booking, status: input.status });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,11 +40,6 @@ export async function createComplexHandler(c: AppContext) {
|
|||||||
city: payload.city,
|
city: payload.city,
|
||||||
state: payload.state,
|
state: payload.state,
|
||||||
country: payload.country,
|
country: payload.country,
|
||||||
setupCourts: payload.setupCourts,
|
|
||||||
courtSportId: payload.courtSportId,
|
|
||||||
courtStartTime: payload.courtStartTime,
|
|
||||||
courtEndTime: payload.courtEndTime,
|
|
||||||
courtDaysOfWeek: payload.courtDaysOfWeek,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return c.json(complex, 201);
|
return c.json(complex, 201);
|
||||||
|
|||||||
@@ -11,47 +11,8 @@ export type CreateComplexInput = {
|
|||||||
city?: string;
|
city?: string;
|
||||||
state?: string;
|
state?: string;
|
||||||
country?: string;
|
country?: string;
|
||||||
setupCourts?: boolean;
|
|
||||||
courtSportId?: string;
|
|
||||||
courtStartTime?: string;
|
|
||||||
courtEndTime?: string;
|
|
||||||
courtDaysOfWeek?: string[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type DayOfWeek = 'MONDAY' | 'TUESDAY' | 'WEDNESDAY' | 'THURSDAY' | 'FRIDAY' | 'SATURDAY' | 'SUNDAY';
|
|
||||||
|
|
||||||
function toMinutes(value: string): number {
|
|
||||||
const [hours, minutes] = value.split(':').map((part) => Number(part));
|
|
||||||
return hours * 60 + minutes;
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertAvailabilityRanges(
|
|
||||||
availability: Array<{
|
|
||||||
dayOfWeek: DayOfWeek;
|
|
||||||
startTime: string;
|
|
||||||
endTime: string;
|
|
||||||
}>
|
|
||||||
) {
|
|
||||||
for (const range of availability) {
|
|
||||||
const start = toMinutes(range.startTime);
|
|
||||||
const end = toMinutes(range.endTime);
|
|
||||||
if (start >= end) {
|
|
||||||
throw new Error(`El rango ${range.startTime}-${range.endTime} es inválido.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureActiveSport(sportId: string) {
|
|
||||||
const sport = await db.sport.findFirst({
|
|
||||||
where: { id: sportId, isActive: true },
|
|
||||||
select: { id: true, name: true },
|
|
||||||
});
|
|
||||||
if (!sport) {
|
|
||||||
throw new Error('El deporte seleccionado no existe o está inactivo.');
|
|
||||||
}
|
|
||||||
return sport;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UpdateComplexInput = {
|
export type UpdateComplexInput = {
|
||||||
complexName?: string;
|
complexName?: string;
|
||||||
physicalAddress?: string | null;
|
physicalAddress?: string | null;
|
||||||
@@ -133,38 +94,6 @@ export async function createComplex(input: CreateComplexInput) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.setupCourts && input.courtSportId) {
|
|
||||||
const sport = await ensureActiveSport(input.courtSportId);
|
|
||||||
const availability = (input.courtDaysOfWeek ?? []).map((day) => ({
|
|
||||||
dayOfWeek: day as DayOfWeek,
|
|
||||||
startTime: input.courtStartTime ?? '08:00',
|
|
||||||
endTime: input.courtEndTime ?? '22:00',
|
|
||||||
}));
|
|
||||||
|
|
||||||
assertAvailabilityRanges(availability);
|
|
||||||
|
|
||||||
const court = await tx.court.create({
|
|
||||||
data: {
|
|
||||||
id: uuidv7(),
|
|
||||||
complexId: complex.id,
|
|
||||||
sportId: input.courtSportId,
|
|
||||||
name: `${sport.name} 1`,
|
|
||||||
slotDurationMinutes: 60,
|
|
||||||
basePrice: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.courtAvailability.createMany({
|
|
||||||
data: availability.map((avail) => ({
|
|
||||||
id: uuidv7(),
|
|
||||||
courtId: court.id,
|
|
||||||
dayOfWeek: avail.dayOfWeek,
|
|
||||||
startTime: avail.startTime,
|
|
||||||
endTime: avail.endTime,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return complex;
|
return complex;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import {
|
||||||
|
OnboardingError,
|
||||||
|
completeOnboarding,
|
||||||
|
} from '@/modules/onboarding/services/onboarding.service';
|
||||||
|
import type { AppContext } from '@/types/hono';
|
||||||
|
import type { OnboardingCompleteInput } from '@repo/api-contract';
|
||||||
|
|
||||||
|
export async function completeOnboardingHandler(c: AppContext) {
|
||||||
|
const payload = c.req.valid('json' as never) as OnboardingCompleteInput;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await completeOnboarding(payload);
|
||||||
|
return c.json({
|
||||||
|
message: 'Onboarding completado correctamente.',
|
||||||
|
...result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof OnboardingError) {
|
||||||
|
return c.json({ message: error.message }, error.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return c.json({ message: error.message }, 400);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import {
|
||||||
|
OnboardingError,
|
||||||
|
resendOnboardingOtp,
|
||||||
|
} from '@/modules/onboarding/services/onboarding.service';
|
||||||
|
import type { AppContext } from '@/types/hono';
|
||||||
|
import type { OnboardingResendOtpInput } from '@repo/api-contract';
|
||||||
|
|
||||||
|
export async function resendOtpHandler(c: AppContext) {
|
||||||
|
const payload = c.req.valid('json' as never) as OnboardingResendOtpInput;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await resendOnboardingOtp(payload);
|
||||||
|
return c.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof OnboardingError) {
|
||||||
|
return c.json({ message: error.message }, error.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return c.json({ message: error.message }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { startOnboarding } from '@/modules/onboarding/services/onboarding.service';
|
||||||
|
import type { AppContext } from '@/types/hono';
|
||||||
|
import type { OnboardingStartInput } from '@repo/api-contract';
|
||||||
|
|
||||||
|
export async function startOnboardingHandler(c: AppContext) {
|
||||||
|
const payload = c.req.valid('json' as never) as OnboardingStartInput;
|
||||||
|
|
||||||
|
const result = await startOnboarding(payload);
|
||||||
|
return c.json(result, 202);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { verifyOnboardingOtp } from '@/modules/onboarding/services/onboarding.service';
|
||||||
|
import type { AppContext } from '@/types/hono';
|
||||||
|
import type { OnboardingVerifyOtpInput } from '@repo/api-contract';
|
||||||
|
|
||||||
|
export async function verifyOtpHandler(c: AppContext) {
|
||||||
|
const payload = c.req.valid('json' as never) as OnboardingVerifyOtpInput;
|
||||||
|
const result = await verifyOnboardingOtp(payload);
|
||||||
|
|
||||||
|
return c.json(result);
|
||||||
|
}
|
||||||
35
apps/backend/src/modules/onboarding/onboarding.routes.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { completeOnboardingHandler } from '@/modules/onboarding/handlers/complete-onboarding.handler';
|
||||||
|
import { resendOtpHandler } from '@/modules/onboarding/handlers/resend-otp.handler';
|
||||||
|
import { startOnboardingHandler } from '@/modules/onboarding/handlers/start-onboarding.handler';
|
||||||
|
import { verifyOtpHandler } from '@/modules/onboarding/handlers/verify-otp.handler';
|
||||||
|
import type { AppEnv } from '@/types/hono';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import {
|
||||||
|
onboardingCompleteSchema,
|
||||||
|
onboardingResendOtpSchema,
|
||||||
|
onboardingStartSchema,
|
||||||
|
onboardingVerifyOtpSchema,
|
||||||
|
} from '@repo/api-contract';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
|
||||||
|
export const onboardingRoutes = new Hono<AppEnv>();
|
||||||
|
|
||||||
|
onboardingRoutes.post('/start', zValidator('json', onboardingStartSchema), startOnboardingHandler);
|
||||||
|
|
||||||
|
onboardingRoutes.post(
|
||||||
|
'/verify-otp',
|
||||||
|
zValidator('json', onboardingVerifyOtpSchema),
|
||||||
|
verifyOtpHandler
|
||||||
|
);
|
||||||
|
|
||||||
|
onboardingRoutes.post(
|
||||||
|
'/resend-otp',
|
||||||
|
zValidator('json', onboardingResendOtpSchema),
|
||||||
|
resendOtpHandler
|
||||||
|
);
|
||||||
|
|
||||||
|
onboardingRoutes.post(
|
||||||
|
'/complete',
|
||||||
|
zValidator('json', onboardingCompleteSchema),
|
||||||
|
completeOnboardingHandler
|
||||||
|
);
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
import { createHash, randomInt } from 'node:crypto';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { sendMail } from '@/lib/mailer';
|
||||||
|
import { db } from '@/lib/prisma';
|
||||||
|
import type {
|
||||||
|
OnboardingCompleteInput,
|
||||||
|
OnboardingResendOtpInput,
|
||||||
|
OnboardingStartInput,
|
||||||
|
OnboardingVerifyOtpInput,
|
||||||
|
} from '@repo/api-contract';
|
||||||
|
import { v7 as uuidv7 } from 'uuid';
|
||||||
|
|
||||||
|
const OTP_LENGTH = 6;
|
||||||
|
const OTP_TTL_MINUTES = Number(Bun.env.ONBOARDING_OTP_TTL_MINUTES ?? 10);
|
||||||
|
const OTP_MAX_ATTEMPTS = Number(Bun.env.ONBOARDING_OTP_MAX_ATTEMPTS ?? 5);
|
||||||
|
const OTP_RESEND_COOLDOWN_SECONDS = Number(Bun.env.ONBOARDING_OTP_RESEND_COOLDOWN_SECONDS ?? 30);
|
||||||
|
|
||||||
|
type VerifyOtpResult = {
|
||||||
|
message: string;
|
||||||
|
verified: boolean;
|
||||||
|
requestId: string;
|
||||||
|
email: string | null;
|
||||||
|
expiresAt: string | null;
|
||||||
|
remainingAttempts: number;
|
||||||
|
cooldownSeconds: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResendOtpResult = {
|
||||||
|
message: string;
|
||||||
|
requestId: string;
|
||||||
|
email: string;
|
||||||
|
expiresAt: string;
|
||||||
|
cooldownSeconds: number;
|
||||||
|
remainingAttempts: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StartOnboardingResult = {
|
||||||
|
message: string;
|
||||||
|
requestId: string;
|
||||||
|
email: string;
|
||||||
|
expiresAt: string;
|
||||||
|
cooldownSeconds: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class OnboardingError extends Error {
|
||||||
|
status: 400 | 404 | 409 | 429;
|
||||||
|
|
||||||
|
constructor(message: string, status: 400 | 404 | 409 | 429 = 400) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'OnboardingError';
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowPlusMinutes(minutes: number): Date {
|
||||||
|
const date = new Date();
|
||||||
|
date.setMinutes(date.getMinutes() + minutes);
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEmail(email: string): string {
|
||||||
|
return email.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOtpCode(): string {
|
||||||
|
return randomInt(0, 10 ** OTP_LENGTH)
|
||||||
|
.toString()
|
||||||
|
.padStart(OTP_LENGTH, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashValue(value: string): string {
|
||||||
|
return createHash('sha256').update(value).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRemainingAttempts(otpAttempts: number): number {
|
||||||
|
return Math.max(0, OTP_MAX_ATTEMPTS - otpAttempts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCooldownSeconds(otpLastSentAt: Date): number {
|
||||||
|
const elapsedMs = Date.now() - otpLastSentAt.getTime();
|
||||||
|
const remainingMs = OTP_RESEND_COOLDOWN_SECONDS * 1000 - elapsedMs;
|
||||||
|
return Math.max(0, Math.ceil(remainingMs / 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(value: string): string {
|
||||||
|
return value
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildUniqueComplexSlug(complexName: string): Promise<string> {
|
||||||
|
const base = slugify(complexName);
|
||||||
|
const fallback = base.length > 0 ? base : `complex-${uuidv7().slice(0, 8)}`;
|
||||||
|
|
||||||
|
let candidate = fallback;
|
||||||
|
let index = 1;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const existing = await db.complex.findFirst({
|
||||||
|
where: { complexSlug: candidate },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
index += 1;
|
||||||
|
candidate = `${fallback}-${index}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendOtpEmail(email: string, otpCode: string) {
|
||||||
|
await sendMail({
|
||||||
|
to: email,
|
||||||
|
subject: 'Codigo OTP para validar tu email',
|
||||||
|
text: `Tu codigo OTP es ${otpCode}. Vence en ${OTP_TTL_MINUTES} minutos.`,
|
||||||
|
html: `<p>Tu codigo OTP es <strong>${otpCode}</strong>.</p><p>Vence en ${OTP_TTL_MINUTES} minutos.</p>`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startOnboarding(input: OnboardingStartInput): Promise<StartOnboardingResult> {
|
||||||
|
const email = normalizeEmail(input.email);
|
||||||
|
const otpCode = createOtpCode();
|
||||||
|
const expiresAt = nowPlusMinutes(OTP_TTL_MINUTES);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const request = await db.onboardingRequest.create({
|
||||||
|
data: {
|
||||||
|
id: uuidv7(),
|
||||||
|
fullName: input.fullName.trim(),
|
||||||
|
email,
|
||||||
|
otpHash: hashValue(otpCode),
|
||||||
|
otpExpiresAt: expiresAt,
|
||||||
|
otpAttempts: 0,
|
||||||
|
otpLastSentAt: now,
|
||||||
|
otpResendCount: 0,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
otpExpiresAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendOtpEmail(email, otpCode);
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Te enviamos un codigo OTP para validar tu direccion.',
|
||||||
|
requestId: request.id,
|
||||||
|
email: request.email,
|
||||||
|
expiresAt: request.otpExpiresAt.toISOString(),
|
||||||
|
cooldownSeconds: OTP_RESEND_COOLDOWN_SECONDS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyOnboardingOtp(
|
||||||
|
input: OnboardingVerifyOtpInput
|
||||||
|
): Promise<VerifyOtpResult> {
|
||||||
|
const request = await db.onboardingRequest.findUnique({
|
||||||
|
where: { id: input.requestId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!request) {
|
||||||
|
return {
|
||||||
|
message: 'Solicitud de onboarding invalida o expirada.',
|
||||||
|
verified: false,
|
||||||
|
requestId: input.requestId,
|
||||||
|
email: null,
|
||||||
|
expiresAt: null,
|
||||||
|
remainingAttempts: 0,
|
||||||
|
cooldownSeconds: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.completedAt) {
|
||||||
|
return {
|
||||||
|
message: 'Este onboarding ya fue completado.',
|
||||||
|
verified: true,
|
||||||
|
requestId: request.id,
|
||||||
|
email: request.email,
|
||||||
|
expiresAt: request.otpExpiresAt.toISOString(),
|
||||||
|
remainingAttempts: getRemainingAttempts(request.otpAttempts),
|
||||||
|
cooldownSeconds: getCooldownSeconds(request.otpLastSentAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.otpExpiresAt < new Date()) {
|
||||||
|
return {
|
||||||
|
message: 'El codigo OTP vencio. Solicita un nuevo codigo.',
|
||||||
|
verified: false,
|
||||||
|
requestId: request.id,
|
||||||
|
email: request.email,
|
||||||
|
expiresAt: request.otpExpiresAt.toISOString(),
|
||||||
|
remainingAttempts: getRemainingAttempts(request.otpAttempts),
|
||||||
|
cooldownSeconds: getCooldownSeconds(request.otpLastSentAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.otpAttempts >= OTP_MAX_ATTEMPTS) {
|
||||||
|
return {
|
||||||
|
message: 'Se agotaron los intentos de OTP. Solicita un nuevo codigo.',
|
||||||
|
verified: false,
|
||||||
|
requestId: request.id,
|
||||||
|
email: request.email,
|
||||||
|
expiresAt: request.otpExpiresAt.toISOString(),
|
||||||
|
remainingAttempts: 0,
|
||||||
|
cooldownSeconds: getCooldownSeconds(request.otpLastSentAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const otpMatches = hashValue(input.otp) === request.otpHash;
|
||||||
|
|
||||||
|
if (!otpMatches) {
|
||||||
|
const updated = await db.onboardingRequest.update({
|
||||||
|
where: { id: request.id },
|
||||||
|
data: {
|
||||||
|
otpAttempts: {
|
||||||
|
increment: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
otpAttempts: true,
|
||||||
|
otpExpiresAt: true,
|
||||||
|
otpLastSentAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const remainingAttempts = getRemainingAttempts(updated.otpAttempts);
|
||||||
|
|
||||||
|
return {
|
||||||
|
message:
|
||||||
|
remainingAttempts > 0
|
||||||
|
? 'Codigo OTP invalido.'
|
||||||
|
: 'Se agotaron los intentos de OTP. Solicita un nuevo codigo.',
|
||||||
|
verified: false,
|
||||||
|
requestId: updated.id,
|
||||||
|
email: updated.email,
|
||||||
|
expiresAt: updated.otpExpiresAt.toISOString(),
|
||||||
|
remainingAttempts,
|
||||||
|
cooldownSeconds: getCooldownSeconds(updated.otpLastSentAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request.emailVerifiedAt) {
|
||||||
|
await db.onboardingRequest.update({
|
||||||
|
where: { id: request.id },
|
||||||
|
data: { emailVerifiedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Email verificado correctamente.',
|
||||||
|
verified: true,
|
||||||
|
requestId: request.id,
|
||||||
|
email: request.email,
|
||||||
|
expiresAt: request.otpExpiresAt.toISOString(),
|
||||||
|
remainingAttempts: getRemainingAttempts(request.otpAttempts),
|
||||||
|
cooldownSeconds: getCooldownSeconds(request.otpLastSentAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resendOnboardingOtp(
|
||||||
|
input: OnboardingResendOtpInput
|
||||||
|
): Promise<ResendOtpResult> {
|
||||||
|
const request = await db.onboardingRequest.findUnique({
|
||||||
|
where: { id: input.requestId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!request) {
|
||||||
|
throw new OnboardingError('Solicitud de onboarding invalida o expirada.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.completedAt) {
|
||||||
|
throw new OnboardingError('Este onboarding ya fue completado.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.emailVerifiedAt) {
|
||||||
|
throw new OnboardingError('El email ya fue verificado.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cooldownSeconds = getCooldownSeconds(request.otpLastSentAt);
|
||||||
|
if (cooldownSeconds > 0) {
|
||||||
|
throw new OnboardingError(`Debes esperar ${cooldownSeconds}s antes de reenviar el OTP.`, 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
const otpCode = createOtpCode();
|
||||||
|
const expiresAt = nowPlusMinutes(OTP_TTL_MINUTES);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const updated = await db.onboardingRequest.update({
|
||||||
|
where: { id: request.id },
|
||||||
|
data: {
|
||||||
|
otpHash: hashValue(otpCode),
|
||||||
|
otpExpiresAt: expiresAt,
|
||||||
|
otpAttempts: 0,
|
||||||
|
otpLastSentAt: now,
|
||||||
|
otpResendCount: {
|
||||||
|
increment: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
otpExpiresAt: true,
|
||||||
|
otpAttempts: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendOtpEmail(updated.email, otpCode);
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Te enviamos un nuevo codigo OTP.',
|
||||||
|
requestId: updated.id,
|
||||||
|
email: updated.email,
|
||||||
|
expiresAt: updated.otpExpiresAt.toISOString(),
|
||||||
|
cooldownSeconds: OTP_RESEND_COOLDOWN_SECONDS,
|
||||||
|
remainingAttempts: getRemainingAttempts(updated.otpAttempts),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeOnboarding(input: OnboardingCompleteInput) {
|
||||||
|
const onboardingRequest = await db.onboardingRequest.findUnique({
|
||||||
|
where: { id: input.onboardingRequestId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!onboardingRequest) {
|
||||||
|
throw new OnboardingError('Solicitud de onboarding inválida o expirada.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onboardingRequest.otpExpiresAt < new Date()) {
|
||||||
|
throw new OnboardingError('La sesión de onboarding expiró. Solicita un nuevo OTP.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!onboardingRequest.emailVerifiedAt) {
|
||||||
|
throw new OnboardingError('Debes verificar el email antes de continuar.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onboardingRequest.completedAt) {
|
||||||
|
throw new OnboardingError('Este onboarding ya fue completado.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = await db.plan.findUnique({
|
||||||
|
where: { code: input.planCode },
|
||||||
|
select: { code: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!plan) {
|
||||||
|
throw new OnboardingError('El plan seleccionado no existe.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = await auth.api.signUpEmail({
|
||||||
|
asResponse: false,
|
||||||
|
body: {
|
||||||
|
email: onboardingRequest.email,
|
||||||
|
password: input.password,
|
||||||
|
name: onboardingRequest.fullName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const complexSlug = await buildUniqueComplexSlug(input.complexName);
|
||||||
|
|
||||||
|
const result = await db.$transaction(async (tx) => {
|
||||||
|
await tx.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { emailVerified: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const complex = await tx.complex.create({
|
||||||
|
data: {
|
||||||
|
id: uuidv7(),
|
||||||
|
complexName: input.complexName.trim(),
|
||||||
|
physicalAddress: input.physicalAddress.trim(),
|
||||||
|
city: input.city?.trim() || null,
|
||||||
|
state: input.state?.trim() || null,
|
||||||
|
country: input.country?.trim() || null,
|
||||||
|
complexSlug,
|
||||||
|
adminEmail: onboardingRequest.email,
|
||||||
|
planCode: input.planCode,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.complexUser.create({
|
||||||
|
data: {
|
||||||
|
complexId: complex.id,
|
||||||
|
userId: user.id,
|
||||||
|
role: 'ADMIN',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.onboardingRequest.update({
|
||||||
|
where: { id: onboardingRequest.id },
|
||||||
|
data: {
|
||||||
|
completedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: user.id,
|
||||||
|
complexId: complex.id,
|
||||||
|
complexSlug: complex.complexSlug,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,24 +1,23 @@
|
|||||||
import { db } from '@/lib/prisma';
|
import { db } from '@/lib/prisma';
|
||||||
import { parsePlanRules } from '@/modules/plan/services/plan-rules.service';
|
|
||||||
import type { AppContext } from '@/types/hono';
|
import type { AppContext } from '@/types/hono';
|
||||||
|
|
||||||
export async function listPlansHandler(c: AppContext) {
|
export async function listPlansHandler(c: AppContext) {
|
||||||
const plans = await db.plan.findMany({
|
const plans = await db.plan.findMany({
|
||||||
|
select: {
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
price: true,
|
||||||
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
price: 'asc',
|
price: 'asc',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return c.json(
|
return c.json(
|
||||||
plans.map((plan) => {
|
plans.map((plan) => ({
|
||||||
const rules = parsePlanRules(plan.rules);
|
|
||||||
return {
|
|
||||||
code: plan.code,
|
code: plan.code,
|
||||||
name: plan.name,
|
name: plan.name,
|
||||||
price: Number(plan.price),
|
price: Number(plan.price),
|
||||||
features: rules.features,
|
}))
|
||||||
limits: rules.limits,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
PublicBookingServiceError,
|
PublicBookingServiceError,
|
||||||
createPublicBooking,
|
createPublicBooking,
|
||||||
} from '@/modules/public-booking/services/public-booking.service';
|
} from '@/modules/public-booking/services/public-booking.service';
|
||||||
import { sendBookingConfirmation } from '@/services/booking-email.service';
|
|
||||||
import type { AppContext } from '@/types/hono';
|
import type { AppContext } from '@/types/hono';
|
||||||
import type { CreatePublicBookingInput } from '@repo/api-contract';
|
import type { CreatePublicBookingInput } from '@repo/api-contract';
|
||||||
|
|
||||||
@@ -31,19 +30,6 @@ export async function createPublicBookingHandler(c: AppContext) {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
void sendBookingConfirmation({
|
|
||||||
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,
|
|
||||||
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) {
|
||||||
|
|||||||
@@ -13,16 +13,6 @@ type Slot = {
|
|||||||
endTime: string;
|
endTime: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PriceableCourt = {
|
|
||||||
basePrice: unknown;
|
|
||||||
priceRules: Array<{
|
|
||||||
dayOfWeek: DayOfWeek | null;
|
|
||||||
startTime: string | null;
|
|
||||||
endTime: string | null;
|
|
||||||
price: unknown;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ComplexWithPublicBookingData = Awaited<ReturnType<typeof getComplexWithBookingData>>;
|
type ComplexWithPublicBookingData = Awaited<ReturnType<typeof getComplexWithBookingData>>;
|
||||||
|
|
||||||
const DAY_OF_WEEK_BY_INDEX: DayOfWeek[] = [
|
const DAY_OF_WEEK_BY_INDEX: DayOfWeek[] = [
|
||||||
@@ -240,33 +230,6 @@ async function getComplexWithBookingData(complexSlug: string) {
|
|||||||
return complex;
|
return complex;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveSlotPrice(court: PriceableCourt, dayOfWeek: DayOfWeek, slot: Slot): number {
|
|
||||||
const slotStart = toMinutes(slot.startTime);
|
|
||||||
const slotEnd = toMinutes(slot.endTime);
|
|
||||||
const matchingRules = court.priceRules
|
|
||||||
.filter((rule) => {
|
|
||||||
if (rule.dayOfWeek && rule.dayOfWeek !== dayOfWeek) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!rule.startTime || !rule.endTime) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return slotStart >= toMinutes(rule.startTime) && slotEnd <= toMinutes(rule.endTime);
|
|
||||||
})
|
|
||||||
.sort((first, second) => {
|
|
||||||
const firstSpecificity =
|
|
||||||
(first.dayOfWeek ? 2 : 0) + (first.startTime && first.endTime ? 1 : 0);
|
|
||||||
const secondSpecificity =
|
|
||||||
(second.dayOfWeek ? 2 : 0) + (second.startTime && second.endTime ? 1 : 0);
|
|
||||||
|
|
||||||
return secondSpecificity - firstSpecificity;
|
|
||||||
});
|
|
||||||
|
|
||||||
return Number(matchingRules[0]?.price ?? court.basePrice);
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapBookingResponse(input: {
|
function mapBookingResponse(input: {
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
bookingCode: string;
|
bookingCode: string;
|
||||||
@@ -274,11 +237,10 @@ function mapBookingResponse(input: {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
endTime: string;
|
endTime: string;
|
||||||
|
price: number;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
customerPhone: string;
|
customerPhone: string;
|
||||||
customerEmail: string | null;
|
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED';
|
||||||
price: number;
|
|
||||||
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NOSHOW';
|
|
||||||
court: {
|
court: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -304,11 +266,10 @@ function mapBookingResponse(input: {
|
|||||||
date: formatIsoDate(input.bookingDate),
|
date: formatIsoDate(input.bookingDate),
|
||||||
startTime: input.startTime,
|
startTime: input.startTime,
|
||||||
endTime: input.endTime,
|
endTime: input.endTime,
|
||||||
|
price: input.price,
|
||||||
customerName: input.customerName,
|
customerName: input.customerName,
|
||||||
customerPhone: input.customerPhone,
|
customerPhone: input.customerPhone,
|
||||||
customerEmail: input.customerEmail ?? undefined,
|
|
||||||
status: input.status,
|
status: input.status,
|
||||||
price: input.price,
|
|
||||||
createdAt: input.createdAt.toISOString(),
|
createdAt: input.createdAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -329,17 +290,12 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC
|
|||||||
bookingDate: true,
|
bookingDate: true,
|
||||||
startTime: true,
|
startTime: true,
|
||||||
endTime: true,
|
endTime: true,
|
||||||
customerEmail: true,
|
price: true,
|
||||||
status: true,
|
status: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
court: {
|
court: {
|
||||||
select: {
|
select: {
|
||||||
name: true,
|
name: true,
|
||||||
basePrice: true,
|
|
||||||
priceRules: {
|
|
||||||
where: { isActive: true },
|
|
||||||
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
|
|
||||||
},
|
|
||||||
sport: {
|
sport: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -350,7 +306,6 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC
|
|||||||
complex: {
|
complex: {
|
||||||
select: {
|
select: {
|
||||||
complexName: true,
|
complexName: true,
|
||||||
physicalAddress: true,
|
|
||||||
complexSlug: true,
|
complexSlug: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -363,22 +318,13 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC
|
|||||||
throw new PublicBookingServiceError('Reserva no encontrada.', 404);
|
throw new PublicBookingServiceError('Reserva no encontrada.', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const date = formatIsoDate(booking.bookingDate);
|
|
||||||
const { dayOfWeek } = parseIsoDate(date);
|
|
||||||
const price = resolveSlotPrice(booking.court, dayOfWeek, {
|
|
||||||
startTime: booking.startTime,
|
|
||||||
endTime: booking.endTime,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bookingCode: booking.bookingCode,
|
bookingCode: booking.bookingCode,
|
||||||
complexName: booking.court.complex.complexName,
|
complexName: booking.court.complex.complexName,
|
||||||
complexAddress: booking.court.complex.physicalAddress ?? undefined,
|
|
||||||
complexSlug: booking.court.complex.complexSlug,
|
complexSlug: booking.court.complex.complexSlug,
|
||||||
date,
|
date: formatIsoDate(booking.bookingDate),
|
||||||
startTime: booking.startTime,
|
startTime: booking.startTime,
|
||||||
endTime: booking.endTime,
|
endTime: booking.endTime,
|
||||||
price,
|
|
||||||
courtName: booking.court.name,
|
courtName: booking.court.name,
|
||||||
sport: {
|
sport: {
|
||||||
id: booking.court.sport.id,
|
id: booking.court.sport.id,
|
||||||
@@ -386,39 +332,14 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC
|
|||||||
slug: booking.court.sport.slug,
|
slug: booking.court.sport.slug,
|
||||||
},
|
},
|
||||||
status: booking.status,
|
status: booking.status,
|
||||||
customerEmail: booking.customerEmail ?? undefined,
|
price: Number(booking.price),
|
||||||
createdAt: booking.createdAt.toISOString(),
|
createdAt: booking.createdAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hasVerifiedAdmin(complexId: string): Promise<boolean> {
|
|
||||||
const verifiedAdmin = await db.complexUser.findFirst({
|
|
||||||
where: {
|
|
||||||
complexId,
|
|
||||||
role: 'ADMIN',
|
|
||||||
user: { emailVerified: true },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return verifiedAdmin !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listPublicAvailability(complexSlug: string, query: PublicAvailabilityQuery) {
|
export async function listPublicAvailability(complexSlug: string, query: PublicAvailabilityQuery) {
|
||||||
const { bookingDate, dayOfWeek } = parseIsoDate(query.date);
|
const { bookingDate, dayOfWeek } = parseIsoDate(query.date);
|
||||||
const complex = await getComplexWithBookingData(complexSlug);
|
const complex = await getComplexWithBookingData(complexSlug);
|
||||||
|
|
||||||
if (!(await hasVerifiedAdmin(complex.id))) {
|
|
||||||
return {
|
|
||||||
complexId: complex.id,
|
|
||||||
complexName: complex.complexName,
|
|
||||||
complexAddress: complex.physicalAddress ?? null,
|
|
||||||
complexSlug: complex.complexSlug,
|
|
||||||
date: query.date,
|
|
||||||
sportSelectionRequired: false,
|
|
||||||
sports: [],
|
|
||||||
courts: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const sports = resolveSports(complex);
|
const sports = resolveSports(complex);
|
||||||
const sportSelectionRequired = sports.length > 1;
|
const sportSelectionRequired = sports.length > 1;
|
||||||
|
|
||||||
@@ -484,6 +405,50 @@ export async function listPublicAvailability(complexSlug: string, query: PublicA
|
|||||||
(slot) => !occupiedSlots.some((occupied) => hasOverlap(slot, occupied))
|
(slot) => !occupiedSlots.some((occupied) => hasOverlap(slot, occupied))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const basePrice = Number(court.basePrice);
|
||||||
|
const priceRuleMap = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const rule of court.priceRules) {
|
||||||
|
const ruleDay = rule.dayOfWeek;
|
||||||
|
const ruleStart = rule.startTime;
|
||||||
|
const ruleEnd = rule.endTime;
|
||||||
|
const price = Number(rule.price);
|
||||||
|
|
||||||
|
if (ruleStart && ruleEnd && ruleDay) {
|
||||||
|
const startMin = toMinutes(ruleStart);
|
||||||
|
const endMin = toMinutes(ruleEnd);
|
||||||
|
for (let mins = startMin; mins < endMin; mins += 30) {
|
||||||
|
priceRuleMap.set(`${ruleDay}-${minutesToTime(mins)}`, price);
|
||||||
|
}
|
||||||
|
} else if (ruleDay && !ruleStart && !ruleEnd) {
|
||||||
|
priceRuleMap.set(`day-${ruleDay}`, price);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const calcPrice = (startTime: string) => {
|
||||||
|
if (!basePrice || basePrice <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const timeKey = `${dayOfWeek}-${startTime}`;
|
||||||
|
const dayKey = `day-${dayOfWeek}`;
|
||||||
|
return priceRuleMap.get(timeKey) ?? priceRuleMap.get(dayKey) ?? basePrice;
|
||||||
|
};
|
||||||
|
|
||||||
|
const price =
|
||||||
|
availableSlots.length > 0
|
||||||
|
? (() => {
|
||||||
|
const prices = availableSlots.map((slot) => calcPrice(slot.startTime));
|
||||||
|
const validPrices = prices.filter((p) => p > 0);
|
||||||
|
if (validPrices.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
min: Math.min(...validPrices),
|
||||||
|
max: Math.max(...validPrices),
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
courtId: court.id,
|
courtId: court.id,
|
||||||
courtName: court.name,
|
courtName: court.name,
|
||||||
@@ -494,10 +459,8 @@ export async function listPublicAvailability(complexSlug: string, query: PublicA
|
|||||||
},
|
},
|
||||||
slotDurationMinutes: court.slotDurationMinutes,
|
slotDurationMinutes: court.slotDurationMinutes,
|
||||||
availabilityDay: dayOfWeek,
|
availabilityDay: dayOfWeek,
|
||||||
availableSlots: availableSlots.map((slot) => ({
|
availableSlots,
|
||||||
...slot,
|
price,
|
||||||
price: resolveSlotPrice(court, dayOfWeek, slot),
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((court) => court.availableSlots.length > 0);
|
.filter((court) => court.availableSlots.length > 0);
|
||||||
@@ -517,14 +480,6 @@ export async function listPublicAvailability(complexSlug: string, query: PublicA
|
|||||||
export async function createPublicBooking(complexSlug: string, input: CreatePublicBookingInput) {
|
export async function createPublicBooking(complexSlug: string, input: CreatePublicBookingInput) {
|
||||||
const { bookingDate, dayOfWeek } = parseIsoDate(input.date);
|
const { bookingDate, dayOfWeek } = parseIsoDate(input.date);
|
||||||
const complex = await getComplexWithBookingData(complexSlug);
|
const complex = await getComplexWithBookingData(complexSlug);
|
||||||
|
|
||||||
if (!(await hasVerifiedAdmin(complex.id))) {
|
|
||||||
throw new PublicBookingServiceError(
|
|
||||||
'El complejo no puede recibir reservas hasta que un administrador verifique su email.',
|
|
||||||
403
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sports = resolveSports(complex);
|
const sports = resolveSports(complex);
|
||||||
const { sportSelectionRequired } = validateSportSelection(sports, input.sportId);
|
const { sportSelectionRequired } = validateSportSelection(sports, input.sportId);
|
||||||
|
|
||||||
@@ -574,6 +529,60 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function calcBookingPrice(
|
||||||
|
court: {
|
||||||
|
basePrice: number | null;
|
||||||
|
priceRules: Array<{
|
||||||
|
dayOfWeek: string | null;
|
||||||
|
startTime: string | null;
|
||||||
|
endTime: string | null;
|
||||||
|
price: number;
|
||||||
|
}>;
|
||||||
|
},
|
||||||
|
dayOfWeek: string,
|
||||||
|
startTime: string
|
||||||
|
): number {
|
||||||
|
if (!court.basePrice) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slotMinutes = toMinutes(startTime);
|
||||||
|
|
||||||
|
const exactMatch = court.priceRules.find((rule) => {
|
||||||
|
if (!rule.dayOfWeek || rule.dayOfWeek !== dayOfWeek) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!rule.startTime || !rule.endTime) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const ruleStart = toMinutes(rule.startTime);
|
||||||
|
const ruleEnd = toMinutes(rule.endTime);
|
||||||
|
return slotMinutes >= ruleStart && slotMinutes < ruleEnd;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (exactMatch) {
|
||||||
|
return Number(exactMatch.price);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dayMatch = court.priceRules.find((rule) => {
|
||||||
|
if (!rule.dayOfWeek || rule.dayOfWeek !== dayOfWeek) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (rule.startTime || rule.endTime) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dayMatch) {
|
||||||
|
return Number(dayMatch.price);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number(court.basePrice);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookingPrice = calcBookingPrice(selectedCourt, dayOfWeek, selectedSlot.startTime);
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
const booking = await db.$transaction(async (tx) => {
|
const booking = await db.$transaction(async (tx) => {
|
||||||
@@ -629,9 +638,9 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
|
|||||||
bookingDate,
|
bookingDate,
|
||||||
startTime: selectedSlot.startTime,
|
startTime: selectedSlot.startTime,
|
||||||
endTime: selectedSlot.endTime,
|
endTime: selectedSlot.endTime,
|
||||||
|
price: bookingPrice,
|
||||||
customerName: input.customerName.trim(),
|
customerName: input.customerName.trim(),
|
||||||
customerPhone: input.customerPhone.trim(),
|
customerPhone: input.customerPhone.trim(),
|
||||||
customerEmail: input.customerEmail?.trim() || null,
|
|
||||||
status: 'CONFIRMED',
|
status: 'CONFIRMED',
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
@@ -641,16 +650,14 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
|
|||||||
createdAt: true,
|
createdAt: true,
|
||||||
startTime: true,
|
startTime: true,
|
||||||
endTime: true,
|
endTime: true,
|
||||||
|
price: true,
|
||||||
customerName: true,
|
customerName: true,
|
||||||
customerPhone: true,
|
customerPhone: true,
|
||||||
customerEmail: true,
|
|
||||||
status: true,
|
status: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const price = resolveSlotPrice(selectedCourt, dayOfWeek, selectedSlot);
|
|
||||||
|
|
||||||
return mapBookingResponse({
|
return mapBookingResponse({
|
||||||
bookingId: booking.id,
|
bookingId: booking.id,
|
||||||
bookingCode: booking.bookingCode,
|
bookingCode: booking.bookingCode,
|
||||||
@@ -658,10 +665,9 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
|
|||||||
createdAt: booking.createdAt,
|
createdAt: booking.createdAt,
|
||||||
startTime: booking.startTime,
|
startTime: booking.startTime,
|
||||||
endTime: booking.endTime,
|
endTime: booking.endTime,
|
||||||
|
price: Number(booking.price),
|
||||||
customerName: booking.customerName,
|
customerName: booking.customerName,
|
||||||
customerPhone: booking.customerPhone,
|
customerPhone: booking.customerPhone,
|
||||||
customerEmail: booking.customerEmail,
|
|
||||||
price,
|
|
||||||
status: booking.status,
|
status: booking.status,
|
||||||
court: {
|
court: {
|
||||||
id: selectedCourt.id,
|
id: selectedCourt.id,
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import { Prisma } from '@/generated/prisma/client';
|
import { Prisma } from '@/generated/prisma/client';
|
||||||
import { handleResult } from '@/lib/http/handle-result';
|
|
||||||
import { createSport } from '@/modules/sport/services/sport.service';
|
import { createSport } from '@/modules/sport/services/sport.service';
|
||||||
import type { AppContext } from '@/types/hono';
|
import type { AppContext } from '@/types/hono';
|
||||||
import type { CreateSportInput } from '@repo/api-contract';
|
import type { CreateSportInput } from '@repo/api-contract';
|
||||||
|
|
||||||
export async function createSportHandler(c: AppContext) {
|
export async function createSportHandler(c: AppContext) {
|
||||||
const payload = c.req.valid('json' as never) as CreateSportInput;
|
const payload = c.req.valid('json' as never) as CreateSportInput;
|
||||||
return handleResult(c, await createSport(payload), 201);
|
|
||||||
|
try {
|
||||||
|
const sport = await createSport(payload);
|
||||||
|
return c.json(sport, 201);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
return c.json({ message: 'No se pudo crear el deporte.' }, 409);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { handleResult } from '@/lib/http/handle-result';
|
|
||||||
import { listSports } from '@/modules/sport/services/sport.service';
|
import { listSports } from '@/modules/sport/services/sport.service';
|
||||||
import type { AppContext } from '@/types/hono';
|
import type { AppContext } from '@/types/hono';
|
||||||
|
|
||||||
export async function listSportsHandler(c: AppContext) {
|
export async function listSportsHandler(c: AppContext) {
|
||||||
const sports = await listSports();
|
const sports = await listSports();
|
||||||
return handleResult(c, sports);
|
return c.json(sports);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { Prisma, Sport } from '@/generated/prisma/client';
|
import { Prisma } from '@/generated/prisma/client';
|
||||||
import { Errors } from '@/lib/errors';
|
|
||||||
import { db } from '@/lib/prisma';
|
import { db } from '@/lib/prisma';
|
||||||
import { Result, err, ok } from '@/lib/result';
|
|
||||||
import { v7 as uuidv7 } from 'uuid';
|
import { v7 as uuidv7 } from 'uuid';
|
||||||
|
|
||||||
export type CreateSportInput = {
|
export type CreateSportInput = {
|
||||||
@@ -53,25 +51,16 @@ async function buildUniqueSlug(source: string, excludeSportId?: string): Promise
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listSports(): Promise<Result<Sport[]>> {
|
export async function listSports() {
|
||||||
const sports = await db.sport.findMany({
|
return db.sport.findMany({
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
});
|
});
|
||||||
return ok(sports);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSport(input: CreateSportInput) {
|
export async function createSport(input: CreateSportInput) {
|
||||||
const slug = await buildUniqueSlug(input.name);
|
const slug = await buildUniqueSlug(input.name);
|
||||||
|
|
||||||
const existing = await db.sport.count({
|
return db.sport.create({
|
||||||
where: { slug },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existing > 0) {
|
|
||||||
return err(Errors.conflict('Ya existe un deporte con ese nombre.'));
|
|
||||||
}
|
|
||||||
|
|
||||||
const created = await db.sport.create({
|
|
||||||
data: {
|
data: {
|
||||||
id: uuidv7(),
|
id: uuidv7(),
|
||||||
name: input.name.trim(),
|
name: input.name.trim(),
|
||||||
@@ -79,8 +68,6 @@ export async function createSport(input: CreateSportInput) {
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return ok(created);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSportById(id: string) {
|
export async function getSportById(id: string) {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { validate } from '@/lib/http/validate';
|
|
||||||
import { requireAuth } from '@/middlewares/require-auth.middleware';
|
import { requireAuth } from '@/middlewares/require-auth.middleware';
|
||||||
import { requireSuperAdmin } from '@/middlewares/require-super-admin.middleware';
|
import { requireSuperAdmin } from '@/middlewares/require-super-admin.middleware';
|
||||||
import { createSportHandler } from '@/modules/sport/handlers/create-sport.handler';
|
import { createSportHandler } from '@/modules/sport/handlers/create-sport.handler';
|
||||||
@@ -9,17 +8,18 @@ import { zValidator } from '@hono/zod-validator';
|
|||||||
import { createSportSchema, updateSportSchema } from '@repo/api-contract';
|
import { createSportSchema, updateSportSchema } from '@repo/api-contract';
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const sportRoutes = new Hono<AppEnv>();
|
export const sportRoutes = new Hono<AppEnv>();
|
||||||
const sportIdParamsSchema = z.object({ id: z.uuid() });
|
const sportIdParamsSchema = z.object({ id: z.uuid() });
|
||||||
|
|
||||||
sportRoutes.use('*', requireAuth);
|
sportRoutes.use('*', requireAuth);
|
||||||
|
|
||||||
sportRoutes.get('/', listSportsHandler);
|
sportRoutes.get('/', listSportsHandler);
|
||||||
sportRoutes.post('/', requireSuperAdmin, validate.json(createSportSchema), createSportHandler);
|
sportRoutes.post('/', requireSuperAdmin, zValidator('json', createSportSchema), createSportHandler);
|
||||||
sportRoutes.patch(
|
sportRoutes.patch(
|
||||||
'/:id',
|
'/:id',
|
||||||
requireSuperAdmin,
|
requireSuperAdmin,
|
||||||
validate.param(sportIdParamsSchema),
|
zValidator('param', sportIdParamsSchema),
|
||||||
validate.json(updateSportSchema),
|
zValidator('json', updateSportSchema),
|
||||||
updateSportHandler
|
updateSportHandler
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { adminBookingRoutes } from '@/modules/admin-booking/admin-booking.routes
|
|||||||
import { authRoutes } from '@/modules/auth/auth.routes';
|
import { authRoutes } from '@/modules/auth/auth.routes';
|
||||||
import { complexRoutes } from '@/modules/complex/complex.routes';
|
import { complexRoutes } from '@/modules/complex/complex.routes';
|
||||||
import { courtRoutes } from '@/modules/court/court.routes';
|
import { courtRoutes } from '@/modules/court/court.routes';
|
||||||
|
import { onboardingRoutes } from '@/modules/onboarding/onboarding.routes';
|
||||||
import { passwordResetRoutes } from '@/modules/password-reset/password-reset.routes';
|
import { passwordResetRoutes } from '@/modules/password-reset/password-reset.routes';
|
||||||
import { planRoutes } from '@/modules/plan/plan.routes';
|
import { planRoutes } from '@/modules/plan/plan.routes';
|
||||||
import { publicBookingRoutes } from '@/modules/public-booking/public-booking.routes';
|
import { publicBookingRoutes } from '@/modules/public-booking/public-booking.routes';
|
||||||
@@ -19,6 +20,7 @@ export function registerRoutes(app: Hono<AppEnv>) {
|
|||||||
.route('', authRoutes)
|
.route('', authRoutes)
|
||||||
.route('/api/user', userRoutes)
|
.route('/api/user', userRoutes)
|
||||||
.route('/api/plans', planRoutes)
|
.route('/api/plans', planRoutes)
|
||||||
|
.route('/api/onboarding', onboardingRoutes)
|
||||||
.route('/api/password-reset', passwordResetRoutes)
|
.route('/api/password-reset', passwordResetRoutes)
|
||||||
.route('/api/complexes', complexRoutes)
|
.route('/api/complexes', complexRoutes)
|
||||||
.route('/api/sports', sportRoutes)
|
.route('/api/sports', sportRoutes)
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
import {
|
|
||||||
bookingCancelledHtml,
|
|
||||||
bookingConfirmationHtml,
|
|
||||||
bookingNoShowHtml,
|
|
||||||
} from '@/emails/booking-confirmation';
|
|
||||||
import { sendMail } from '@/lib/mailer';
|
|
||||||
|
|
||||||
type BookingEmailInput = {
|
|
||||||
bookingCode: 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,
|
|
||||||
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,5 +1,6 @@
|
|||||||
import { beforeEach, expect, mock, test } from 'bun:test';
|
import { beforeEach, expect, mock, test } from 'bun:test';
|
||||||
|
|
||||||
|
import { createInviteComplexUserHandler } from '@/modules/complex/handlers/invite-complex-user.handler';
|
||||||
import type { InviteComplexUserResponse } from '@repo/api-contract';
|
import type { InviteComplexUserResponse } from '@repo/api-contract';
|
||||||
|
|
||||||
const inviteComplexUserMock = mock(async () => undefined as unknown as InviteComplexUserResponse);
|
const inviteComplexUserMock = mock(async () => undefined as unknown as InviteComplexUserResponse);
|
||||||
@@ -14,10 +15,6 @@ class MockComplexMembersError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { createInviteComplexUserHandler } = await import(
|
|
||||||
'@/modules/complex/handlers/invite-complex-user.handler'
|
|
||||||
);
|
|
||||||
|
|
||||||
const inviteComplexUserHandler = createInviteComplexUserHandler({
|
const inviteComplexUserHandler = createInviteComplexUserHandler({
|
||||||
inviteComplexUser: inviteComplexUserMock,
|
inviteComplexUser: inviteComplexUserMock,
|
||||||
ComplexMembersError: MockComplexMembersError,
|
ComplexMembersError: MockComplexMembersError,
|
||||||
@@ -62,6 +59,7 @@ function createContext(input: {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
inviteComplexUserMock.mockReset();
|
inviteComplexUserMock.mockReset();
|
||||||
|
mock.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('returns 201 with the service result', async () => {
|
test('returns 201 with the service result', async () => {
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { beforeEach, expect, test } from 'bun:test';
|
import { beforeEach, expect, mock, test } from 'bun:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ComplexMembersError,
|
||||||
|
inviteComplexUser,
|
||||||
|
} from '@/modules/complex/services/complex-members.service';
|
||||||
import { prismaMock, sendMailMock, transactionMock } from '../support/prisma.mock';
|
import { prismaMock, sendMailMock, transactionMock } from '../support/prisma.mock';
|
||||||
|
|
||||||
const { ComplexMembersError, inviteComplexUser } = await import(
|
|
||||||
'@/modules/complex/services/complex-members.service'
|
|
||||||
);
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
prismaMock._reset();
|
prismaMock._reset();
|
||||||
sendMailMock.mockClear();
|
mock.clearAllMocks();
|
||||||
transactionMock.mockClear();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('creates a pending invitation and sends the email', async () => {
|
test('creates a pending invitation and sends the email', async () => {
|
||||||
@@ -22,10 +21,10 @@ test('creates a pending invitation and sends the email', async () => {
|
|||||||
complexId: 'complex-1',
|
complexId: 'complex-1',
|
||||||
email: 'new.member@example.com',
|
email: 'new.member@example.com',
|
||||||
tokenHash: 'token-hash',
|
tokenHash: 'token-hash',
|
||||||
expiresAt: new Date('2027-01-01T00:00:00.000Z'),
|
expiresAt: new Date('2026-04-29T00:00:00.000Z'),
|
||||||
acceptedAt: null,
|
acceptedAt: null,
|
||||||
revokedAt: null,
|
revokedAt: null,
|
||||||
createdAt: new Date('2026-12-01T00:00:00.000Z'),
|
createdAt: new Date('2026-04-22T00:00:00.000Z'),
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
const result = await inviteComplexUser('admin-1', 'complex-1', {
|
const result = await inviteComplexUser('admin-1', 'complex-1', {
|
||||||
@@ -80,7 +79,7 @@ test('rejects when the invite target already belongs to the complex', async () =
|
|||||||
}
|
}
|
||||||
|
|
||||||
expect(caught).toBeInstanceOf(ComplexMembersError);
|
expect(caught).toBeInstanceOf(ComplexMembersError);
|
||||||
expect((caught as InstanceType<typeof ComplexMembersError>).status).toBe(409);
|
expect((caught as ComplexMembersError).status).toBe(409);
|
||||||
expect(transactionMock).not.toHaveBeenCalled();
|
expect(transactionMock).not.toHaveBeenCalled();
|
||||||
expect(sendMailMock).not.toHaveBeenCalled();
|
expect(sendMailMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
import { beforeEach, expect, mock, test } from 'bun:test';
|
|
||||||
|
|
||||||
import { Errors } from '@/lib/errors';
|
|
||||||
import { err, ok } from '@/lib/result';
|
|
||||||
import type { CreateSportInput } from '@repo/api-contract';
|
|
||||||
|
|
||||||
const createSportMock = mock(async (_input: CreateSportInput) =>
|
|
||||||
ok({
|
|
||||||
id: 'sport-1',
|
|
||||||
name: 'Tenis',
|
|
||||||
slug: 'tenis',
|
|
||||||
isActive: true,
|
|
||||||
createdAt: '2026-04-23T00:00:00.000Z',
|
|
||||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
mock.module('@/modules/sport/services/sport.service', () => ({
|
|
||||||
__esModule: true,
|
|
||||||
createSport: createSportMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { createSportHandler } = await import('@/modules/sport/handlers/create-sport.handler');
|
|
||||||
|
|
||||||
type HandlerContext = {
|
|
||||||
get: (key: 'requestId') => string | undefined;
|
|
||||||
req: {
|
|
||||||
valid: () => unknown;
|
|
||||||
};
|
|
||||||
json: ReturnType<typeof mock>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function createContext(body: unknown) {
|
|
||||||
const json = mock((payload: unknown, init?: unknown) => ({
|
|
||||||
payload,
|
|
||||||
init,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const c = {
|
|
||||||
get: () => undefined,
|
|
||||||
req: {
|
|
||||||
valid: () => body,
|
|
||||||
},
|
|
||||||
json,
|
|
||||||
} as unknown as HandlerContext;
|
|
||||||
|
|
||||||
return { c, json };
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
createSportMock.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns 201 with the created sport', async () => {
|
|
||||||
const createdSport = {
|
|
||||||
id: 'sport-1',
|
|
||||||
name: 'Tenis',
|
|
||||||
slug: 'tenis',
|
|
||||||
isActive: true,
|
|
||||||
createdAt: '2026-04-23T00:00:00.000Z',
|
|
||||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
createSportMock.mockResolvedValue(ok(createdSport));
|
|
||||||
|
|
||||||
const payload: CreateSportInput = { name: ' Tenis ' };
|
|
||||||
const { c, json } = createContext(payload);
|
|
||||||
|
|
||||||
const response = await createSportHandler(c as never);
|
|
||||||
|
|
||||||
expect(createSportMock).toHaveBeenCalledWith(payload);
|
|
||||||
expect(json.mock.calls[0]?.[0]).toEqual(createdSport);
|
|
||||||
expect(json.mock.calls[0]?.[1]).toBe(201);
|
|
||||||
expect(response).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns a conflict problem when the service rejects with a conflict error', async () => {
|
|
||||||
createSportMock.mockResolvedValue(err(Errors.conflict('Ya existe un deporte con ese nombre.')));
|
|
||||||
|
|
||||||
const payload: CreateSportInput = { name: 'Tenis' };
|
|
||||||
const { c, json } = createContext(payload);
|
|
||||||
|
|
||||||
const response = await createSportHandler(c as never);
|
|
||||||
|
|
||||||
expect(createSportMock).toHaveBeenCalledWith(payload);
|
|
||||||
expect(json.mock.calls[0]?.[0]).toEqual({
|
|
||||||
type: 'https://api.myapp.dev/problems/conflict',
|
|
||||||
title: 'Conflict',
|
|
||||||
status: 409,
|
|
||||||
detail: 'Ya existe un deporte con ese nombre.',
|
|
||||||
});
|
|
||||||
expect(json.mock.calls[0]?.[1]).toBe(409);
|
|
||||||
expect(response).toBeDefined();
|
|
||||||
});
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
import { beforeEach, expect, mock, test } from 'bun:test';
|
|
||||||
|
|
||||||
import { createPrismaMock } from 'bun-mock-prisma';
|
|
||||||
|
|
||||||
import type { PrismaClient } from '@/generated/prisma/client';
|
|
||||||
import type { PrismaClientMock } from 'bun-mock-prisma';
|
|
||||||
|
|
||||||
const prismaMock = createPrismaMock<PrismaClient>() as PrismaClientMock<PrismaClient>;
|
|
||||||
|
|
||||||
mock.module('@/lib/prisma', () => ({
|
|
||||||
__esModule: true,
|
|
||||||
db: {
|
|
||||||
sport: prismaMock.sport,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { uuidV7Mock } = await import('../support/prisma.mock');
|
|
||||||
|
|
||||||
const { createSport, getSportById, listSports, updateSport } = await import(
|
|
||||||
'@/modules/sport/services/sport.service'
|
|
||||||
);
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
prismaMock._reset();
|
|
||||||
uuidV7Mock.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('listSports returns sports ordered by name', async () => {
|
|
||||||
const sports = [
|
|
||||||
{
|
|
||||||
id: 'sport-2',
|
|
||||||
name: 'Tenis',
|
|
||||||
slug: 'tenis',
|
|
||||||
isActive: true,
|
|
||||||
createdAt: new Date('2026-04-22T00:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-04-22T00:00:00.000Z'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'sport-1',
|
|
||||||
name: 'Básquet',
|
|
||||||
slug: 'basquet',
|
|
||||||
isActive: true,
|
|
||||||
createdAt: new Date('2026-04-21T00:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-04-21T00:00:00.000Z'),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
prismaMock.sport.findMany.mockResolvedValue(sports as never);
|
|
||||||
|
|
||||||
const result = await listSports();
|
|
||||||
|
|
||||||
expect(prismaMock.sport.findMany).toHaveBeenCalledWith({
|
|
||||||
orderBy: { name: 'asc' },
|
|
||||||
});
|
|
||||||
expect(result).toEqual({
|
|
||||||
ok: true,
|
|
||||||
value: sports,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getSportById returns the requested sport', async () => {
|
|
||||||
const sport = {
|
|
||||||
id: 'sport-1',
|
|
||||||
name: 'Tenis',
|
|
||||||
slug: 'tenis',
|
|
||||||
isActive: true,
|
|
||||||
createdAt: new Date('2026-04-22T00:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-04-22T00:00:00.000Z'),
|
|
||||||
};
|
|
||||||
|
|
||||||
prismaMock.sport.findUnique.mockResolvedValue(sport as never);
|
|
||||||
|
|
||||||
const result = await getSportById('sport-1');
|
|
||||||
|
|
||||||
expect(prismaMock.sport.findUnique).toHaveBeenCalledWith({
|
|
||||||
where: { id: 'sport-1' },
|
|
||||||
});
|
|
||||||
expect(result).toEqual(sport);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('createSport trims the name and creates a unique slug', async () => {
|
|
||||||
const createdSport = {
|
|
||||||
id: '00000000-0000-4000-8000-000000000001',
|
|
||||||
name: 'Fútbol 7!!',
|
|
||||||
slug: 'futbol-7',
|
|
||||||
isActive: true,
|
|
||||||
createdAt: new Date('2026-04-23T00:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-04-23T00:00:00.000Z'),
|
|
||||||
};
|
|
||||||
|
|
||||||
prismaMock.sport.findFirst.mockResolvedValue(null as never);
|
|
||||||
prismaMock.sport.count.mockResolvedValue(0 as never);
|
|
||||||
prismaMock.sport.create.mockResolvedValue(createdSport as never);
|
|
||||||
|
|
||||||
const result = await createSport({ name: ' Fútbol 7!! ' });
|
|
||||||
|
|
||||||
expect(uuidV7Mock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(prismaMock.sport.findFirst).toHaveBeenCalledWith({
|
|
||||||
where: { slug: 'futbol-7' },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
expect(prismaMock.sport.count).toHaveBeenCalledWith({
|
|
||||||
where: { slug: 'futbol-7' },
|
|
||||||
});
|
|
||||||
expect(prismaMock.sport.create).toHaveBeenCalledWith({
|
|
||||||
data: {
|
|
||||||
id: '00000000-0000-4000-8000-000000000001',
|
|
||||||
name: 'Fútbol 7!!',
|
|
||||||
slug: 'futbol-7',
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(result).toEqual({
|
|
||||||
ok: true,
|
|
||||||
value: createdSport,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('createSport retries with a numeric suffix when the slug already exists', async () => {
|
|
||||||
const createdSport = {
|
|
||||||
id: '00000000-0000-4000-8000-000000000001',
|
|
||||||
name: 'Tenis',
|
|
||||||
slug: 'tenis-2',
|
|
||||||
isActive: true,
|
|
||||||
createdAt: new Date('2026-04-23T00:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-04-23T00:00:00.000Z'),
|
|
||||||
};
|
|
||||||
|
|
||||||
prismaMock.sport.findFirst
|
|
||||||
.mockResolvedValueOnce({ id: 'existing-sport' } as never)
|
|
||||||
.mockResolvedValueOnce(null as never);
|
|
||||||
prismaMock.sport.count.mockResolvedValue(0 as never);
|
|
||||||
prismaMock.sport.create.mockResolvedValue(createdSport as never);
|
|
||||||
|
|
||||||
const result = await createSport({ name: 'Tenis' });
|
|
||||||
|
|
||||||
expect(prismaMock.sport.findFirst).toHaveBeenNthCalledWith(1, {
|
|
||||||
where: { slug: 'tenis' },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
expect(prismaMock.sport.findFirst).toHaveBeenNthCalledWith(2, {
|
|
||||||
where: { slug: 'tenis-2' },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
expect(prismaMock.sport.create).toHaveBeenCalledWith({
|
|
||||||
data: {
|
|
||||||
id: '00000000-0000-4000-8000-000000000001',
|
|
||||||
name: 'Tenis',
|
|
||||||
slug: 'tenis-2',
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(result).toEqual({
|
|
||||||
ok: true,
|
|
||||||
value: createdSport,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('updateSport updates the name, slug and active flag', async () => {
|
|
||||||
const updatedSport = {
|
|
||||||
id: 'sport-1',
|
|
||||||
name: 'Básquet 3x3',
|
|
||||||
slug: 'basquet-3x3',
|
|
||||||
isActive: false,
|
|
||||||
createdAt: new Date('2026-04-21T00:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-04-23T00:00:00.000Z'),
|
|
||||||
};
|
|
||||||
|
|
||||||
prismaMock.sport.findFirst.mockResolvedValue(null as never);
|
|
||||||
prismaMock.sport.update.mockResolvedValue(updatedSport as never);
|
|
||||||
|
|
||||||
const result = await updateSport('sport-1', {
|
|
||||||
name: 'Básquet 3x3',
|
|
||||||
isActive: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(prismaMock.sport.findFirst).toHaveBeenCalledWith({
|
|
||||||
where: {
|
|
||||||
slug: 'basquet-3x3',
|
|
||||||
id: {
|
|
||||||
not: 'sport-1',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
expect(prismaMock.sport.update).toHaveBeenCalledWith({
|
|
||||||
where: { id: 'sport-1' },
|
|
||||||
data: {
|
|
||||||
name: 'Básquet 3x3',
|
|
||||||
slug: 'basquet-3x3',
|
|
||||||
isActive: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(result).toEqual(updatedSport);
|
|
||||||
});
|
|
||||||
@@ -33,10 +33,3 @@ mock.module('@/lib/mailer', () => ({
|
|||||||
__esModule: true,
|
__esModule: true,
|
||||||
sendMail: sendMailMock,
|
sendMail: sendMailMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const uuidV7Mock = mock(() => '00000000-0000-4000-8000-000000000001');
|
|
||||||
|
|
||||||
mock.module('uuid', () => ({
|
|
||||||
__esModule: true,
|
|
||||||
v7: uuidV7Mock,
|
|
||||||
}));
|
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="iimage/x-icon" href="/src/assets/playzer-favicon-transparent.ico" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>PlayZer</title>
|
<title>PlayZer</title>
|
||||||
</head>
|
</head>
|
||||||
<body lass="bg-background text-foreground antialiased">
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -15,10 +15,12 @@
|
|||||||
"@better-auth/infra": "^0.2.4",
|
"@better-auth/infra": "^0.2.4",
|
||||||
"@fontsource-variable/geist": "^5.2.8",
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-popover": "^1.1.2",
|
"@radix-ui/react-popover": "^1.1.2",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@repo/api-contract": "workspace:*",
|
"@repo/api-contract": "workspace:*",
|
||||||
|
"@tabler/icons-react": "^3.41.1",
|
||||||
"@tanstack/react-query": "^5.96.1",
|
"@tanstack/react-query": "^5.96.1",
|
||||||
"@tanstack/react-query-devtools": "^5.96.1",
|
"@tanstack/react-query-devtools": "^5.96.1",
|
||||||
"@tanstack/react-router": "^1.168.10",
|
"@tanstack/react-router": "^1.168.10",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 151 KiB |
@@ -0,0 +1,182 @@
|
|||||||
|
.counter {
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-bg);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
}
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.base,
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
inset-inline: 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base {
|
||||||
|
width: 170px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework {
|
||||||
|
z-index: 1;
|
||||||
|
top: 34px;
|
||||||
|
height: 28px;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) scale(1.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vite {
|
||||||
|
z-index: 0;
|
||||||
|
top: 107px;
|
||||||
|
height: 26px;
|
||||||
|
width: auto;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 25px;
|
||||||
|
place-content: center;
|
||||||
|
place-items: center;
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 32px 20px 24px;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps {
|
||||||
|
display: flex;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
flex: 1 1 0;
|
||||||
|
padding: 32px;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#docs {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 32px 0 0;
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--text-h);
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--social-bg);
|
||||||
|
display: flex;
|
||||||
|
padding: 6px 12px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: box-shadow 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.button-icon {
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
margin-top: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
li {
|
||||||
|
flex: 1 1 calc(50% - 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#spacer {
|
||||||
|
height: 88px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticks {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -4.5px;
|
||||||
|
border: 5px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
left: 0;
|
||||||
|
border-left-color: var(--border);
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
right: 0;
|
||||||
|
border-right-color: var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
34
apps/frontend/src/assets/icons/football.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { type LucideProps } from 'lucide-react';
|
||||||
|
import { forwardRef } from 'react';
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const SoccerBallIcon = forwardRef<any, LucideProps>(({ className, ...props }, ref) => (
|
||||||
|
<svg
|
||||||
|
ref={ref}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
className={className}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M12 2c.8 2.2 2.2 4.2 4 5.8" />
|
||||||
|
<path d="M12 2c-.8 2.2-2.2 4.2-4 5.8" />
|
||||||
|
<path d="M2 12c2.2.8 4.2 2.2 5.8 4" />
|
||||||
|
<path d="M2 12c2.2-.8 4.2-2.2 5.8-4" />
|
||||||
|
<path d="M22 12c-2.2.8-4.2 2.2-5.8 4" />
|
||||||
|
<path d="M22 12c-2.2-.8-4.2-2.2-5.8-4" />
|
||||||
|
<path d="M12 22c.8-2.2 2.2-4.2 4-5.8" />
|
||||||
|
<path d="M12 22c-.8-2.2-2.2-4.2-4-5.8" />
|
||||||
|
</svg>
|
||||||
|
));
|
||||||
|
|
||||||
|
export { SoccerBallIcon as FootballIcon };
|
||||||
|
Before Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 372 KiB |
|
Before Width: | Height: | Size: 607 KiB |
|
Before Width: | Height: | Size: 810 KiB |
|
Before Width: | Height: | Size: 1.2 MiB |
@@ -1,15 +0,0 @@
|
|||||||
export function Background() {
|
|
||||||
return (
|
|
||||||
<div className="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-[#040b11] to-[#09131c]" />
|
|
||||||
|
|
||||||
<div className="absolute left-0 top-0 h-[420px] w-[420px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/20 blur-3xl" />
|
|
||||||
<div className="absolute right-0 top-24 h-[360px] w-[360px] translate-x-1/3 rounded-full bg-reserved/15 blur-3xl" />
|
|
||||||
<div className="absolute bottom-0 left-1/2 h-[300px] w-[300px] -translate-x-1/2 translate-y-1/3 rounded-full bg-accent/10 blur-3xl" />
|
|
||||||
|
|
||||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,oklch(1_0_0/0.055)_1px,transparent_1px),linear-gradient(to_bottom,oklch(1_0_0/0.055)_1px,transparent_1px)] bg-[size:64px_64px]" />
|
|
||||||
|
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,#09131c_78%)]" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,431 +0,0 @@
|
|||||||
import {
|
|
||||||
CalendarDays,
|
|
||||||
Check,
|
|
||||||
Laptop,
|
|
||||||
LogOut,
|
|
||||||
Menu,
|
|
||||||
Moon,
|
|
||||||
Settings,
|
|
||||||
Sun,
|
|
||||||
User,
|
|
||||||
X,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
|
|
||||||
import PlayzerIcon from '@/assets/playzer-favicon-512-transparent.png';
|
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { Link, useNavigate } from '@tanstack/react-router';
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuRadioGroup,
|
|
||||||
DropdownMenuRadioItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu';
|
|
||||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
|
||||||
import { UserAvatar } from '@/components/user-avatar';
|
|
||||||
import { apiClient } from '@/lib/api-client';
|
|
||||||
import { useAuth } from '@/lib/auth';
|
|
||||||
import { acceptStoredPendingInvite } from '@/lib/invitations';
|
|
||||||
import { useCurrentComplexStore } from '@/lib/stores/current-complex-store';
|
|
||||||
import { useTheme } from '@/lib/theme';
|
|
||||||
|
|
||||||
export function Header() {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { user, isAuthenticated, displayName, avatarUrl, signOut } = useAuth();
|
|
||||||
const { theme, setTheme } = useTheme();
|
|
||||||
const { currentComplexSlug, setCurrentComplex } = useCurrentComplexStore();
|
|
||||||
const processedInviteForUser = useRef<string | null>(null);
|
|
||||||
const myComplexesQuery = useQuery({
|
|
||||||
queryKey: ['my-complexes'],
|
|
||||||
enabled: isAuthenticated,
|
|
||||||
queryFn: () => apiClient.complexes.listMine(),
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
processedInviteForUser.current = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userKey = user?.email ?? displayName ?? 'authenticated-user';
|
|
||||||
|
|
||||||
if (processedInviteForUser.current === userKey) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
processedInviteForUser.current = userKey;
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
await acceptStoredPendingInvite(apiClient.complexes.acceptPendingInvitations);
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ['my-complexes'] });
|
|
||||||
})();
|
|
||||||
}, [displayName, isAuthenticated, queryClient, user?.email]);
|
|
||||||
|
|
||||||
const complexSlug = currentComplexSlug || myComplexesQuery.data?.[0]?.complexSlug;
|
|
||||||
const currentComplexName = useMemo(() => {
|
|
||||||
const complexes = myComplexesQuery.data ?? [];
|
|
||||||
if (complexes.length === 0) return 'Mi complejo';
|
|
||||||
|
|
||||||
const selected = complexes.find((complex) => complex.complexSlug === complexSlug);
|
|
||||||
|
|
||||||
return selected?.complexName ?? complexes[0]?.complexName ?? 'Mi complejo';
|
|
||||||
}, [complexSlug, myComplexesQuery.data]);
|
|
||||||
|
|
||||||
const handleSelectComplex = async (complexId: string, nextSlug: string) => {
|
|
||||||
await apiClient.complexes.select({ complexId });
|
|
||||||
setCurrentComplex(nextSlug);
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ['current-complex'] });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSignOut = async () => {
|
|
||||||
await signOut();
|
|
||||||
await navigate({ to: '/login' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenBookingCreate = async () => {
|
|
||||||
if (location.pathname !== '/') {
|
|
||||||
await navigate({ to: '/' });
|
|
||||||
}
|
|
||||||
|
|
||||||
window.setTimeout(() => {
|
|
||||||
window.dispatchEvent(new Event('playzer:open-booking-create'));
|
|
||||||
}, 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header className="sticky top-0 z-50 w-full border-b border-border/60 bg-background/60 dark:border-primary/20 dark:bg-[#030a10]/90 backdrop-blur-xl">
|
|
||||||
<div className="absolute inset-0 -z-10 bg-gradient-to-b from-background/85 to-background/45 dark:from-[#030a10]/95 dark:to-[#030a10]/70" />
|
|
||||||
|
|
||||||
<div className="mx-auto flex h-16 w-full max-w-7xl items-center gap-4 px-3 sm:px-6 lg:px-8">
|
|
||||||
<Link to="/" className="group flex items-center gap-3">
|
|
||||||
<div className="flex size-10 items-center justify-center shadow-primary/10 ">
|
|
||||||
<img src={PlayzerIcon} alt="Playzer" className="size-7" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span className="bg-gradient-to-r from-primary via-primary to-reserved bg-clip-text text-xl font-bold tracking-tight text-transparent">
|
|
||||||
Playzer
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="flex-1" />
|
|
||||||
|
|
||||||
<div className="hidden items-center gap-2 md:flex">
|
|
||||||
<Button
|
|
||||||
className="rounded-2xl px-5 shadow-lg shadow-primary/20"
|
|
||||||
onClick={() => {
|
|
||||||
void handleOpenBookingCreate();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CalendarDays className="mr-2 size-4" />
|
|
||||||
Reservar ahora
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isAuthenticated ? (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="hidden size-10 rounded-full ring-2 ring-transparent transition-all duration-200 hover:ring-primary/30 focus:outline-none md:flex"
|
|
||||||
>
|
|
||||||
<UserAvatar
|
|
||||||
src={avatarUrl}
|
|
||||||
alt={displayName}
|
|
||||||
fallbackText={displayName}
|
|
||||||
className="size-10 border border-border/60 shadow-lg shadow-black/10"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
|
|
||||||
<DropdownMenuContent
|
|
||||||
align="end"
|
|
||||||
className="w-64 rounded-2xl border-border/60 bg-popover/95 p-2 shadow-xl shadow-black/20 backdrop-blur-xl"
|
|
||||||
>
|
|
||||||
<DropdownMenuLabel className="px-2 py-2">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<UserAvatar
|
|
||||||
src={avatarUrl}
|
|
||||||
alt={displayName}
|
|
||||||
fallbackText={displayName}
|
|
||||||
className="size-9"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-col">
|
|
||||||
<span className="truncate text-sm font-medium">{displayName}</span>
|
|
||||||
<span className="truncate text-xs text-muted-foreground">{user?.email}</span>
|
|
||||||
<span className="truncate text-xs text-muted-foreground">
|
|
||||||
{currentComplexName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
|
|
||||||
{myComplexesQuery.data && myComplexesQuery.data.length > 1 && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuLabel className="px-2 text-xs font-normal text-muted-foreground">
|
|
||||||
Cambiar complejo
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
{myComplexesQuery.data.map((complex) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={complex.id}
|
|
||||||
className="cursor-pointer rounded-xl"
|
|
||||||
onSelect={() => {
|
|
||||||
void handleSelectComplex(complex.id, complex.complexSlug);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="mr-2 flex size-4 items-center justify-center">
|
|
||||||
{complex.complexSlug === complexSlug && <Check className="size-4" />}
|
|
||||||
</span>
|
|
||||||
{complex.complexName}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DropdownMenuLabel className="px-2 text-xs font-normal text-muted-foreground">
|
|
||||||
Apariencia
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuRadioGroup
|
|
||||||
value={theme}
|
|
||||||
onValueChange={(value) => setTheme(value as 'light' | 'dark' | 'system')}
|
|
||||||
>
|
|
||||||
<DropdownMenuRadioItem value="light" className="cursor-pointer rounded-xl">
|
|
||||||
<Sun className="mr-2 size-4" />
|
|
||||||
Light
|
|
||||||
</DropdownMenuRadioItem>
|
|
||||||
<DropdownMenuRadioItem value="dark" className="cursor-pointer rounded-xl">
|
|
||||||
<Moon className="mr-2 size-4" />
|
|
||||||
Dark
|
|
||||||
</DropdownMenuRadioItem>
|
|
||||||
<DropdownMenuRadioItem value="system" className="cursor-pointer rounded-xl">
|
|
||||||
<Laptop className="mr-2 size-4" />
|
|
||||||
System
|
|
||||||
</DropdownMenuRadioItem>
|
|
||||||
</DropdownMenuRadioGroup>
|
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="cursor-pointer rounded-xl"
|
|
||||||
onSelect={() => {
|
|
||||||
void navigate({ to: '/profile' });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<User className="mr-2 size-4" />
|
|
||||||
Mi Perfil
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
{currentComplexSlug && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="cursor-pointer rounded-xl"
|
|
||||||
onSelect={() => {
|
|
||||||
void navigate({
|
|
||||||
to: '/complex/$slug/edit',
|
|
||||||
params: { slug: currentComplexSlug },
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Settings className="mr-2 size-4" />
|
|
||||||
Configuración
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="cursor-pointer rounded-xl text-destructive focus:bg-destructive/10 focus:text-destructive"
|
|
||||||
onSelect={() => {
|
|
||||||
void handleSignOut();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LogOut className="mr-2 size-4" />
|
|
||||||
Cerrar sesión
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
) : (
|
|
||||||
<Button asChild size="sm" variant="outline" className="hidden rounded-2xl md:inline-flex">
|
|
||||||
<Link to="/login">Login</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Sheet open={open} onOpenChange={setOpen}>
|
|
||||||
<SheetTrigger asChild>
|
|
||||||
<Button variant="outline" size="icon" className="rounded-2xl md:hidden">
|
|
||||||
{open ? <X className="size-5" /> : <Menu className="size-5" />}
|
|
||||||
</Button>
|
|
||||||
</SheetTrigger>
|
|
||||||
|
|
||||||
<SheetContent
|
|
||||||
side="right"
|
|
||||||
className="w-[88vw] border-border/60 bg-background/95 p-5 backdrop-blur-xl sm:max-w-sm"
|
|
||||||
>
|
|
||||||
<div className="mt-6 flex flex-col gap-6">
|
|
||||||
<div className="rounded-3xl border border-border/60 bg-card/70 p-4 shadow-xl shadow-black/10">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<UserAvatar
|
|
||||||
src={avatarUrl}
|
|
||||||
alt={displayName}
|
|
||||||
fallbackText={displayName}
|
|
||||||
className="size-11"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate text-sm font-medium">{displayName}</p>
|
|
||||||
<p className="truncate text-xs text-muted-foreground">{user?.email}</p>
|
|
||||||
{isAuthenticated && (
|
|
||||||
<p className="truncate text-xs text-muted-foreground">{currentComplexName}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
className="rounded-2xl shadow-lg shadow-primary/20"
|
|
||||||
onClick={() => {
|
|
||||||
setOpen(false);
|
|
||||||
void handleOpenBookingCreate();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CalendarDays className="mr-2 size-4" />
|
|
||||||
Reservar ahora
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="border-t border-border/60 pt-4">
|
|
||||||
<div className="mb-2 flex items-center justify-between px-2">
|
|
||||||
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
|
||||||
Cuenta
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className="flex flex-col gap-1">
|
|
||||||
{!isAuthenticated && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="justify-start rounded-2xl"
|
|
||||||
onClick={() => {
|
|
||||||
setOpen(false);
|
|
||||||
void navigate({ to: '/login' });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Login
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="py-1">
|
|
||||||
<p className="px-3 py-1 text-xs text-muted-foreground">Apariencia</p>
|
|
||||||
{[
|
|
||||||
{ value: 'light', label: 'Light', icon: Sun },
|
|
||||||
{ value: 'dark', label: 'Dark', icon: Moon },
|
|
||||||
{ value: 'system', label: 'System', icon: Laptop },
|
|
||||||
].map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={item.value}
|
|
||||||
variant="ghost"
|
|
||||||
className="w-full justify-start rounded-2xl"
|
|
||||||
onClick={() => setTheme(item.value as 'light' | 'dark' | 'system')}
|
|
||||||
>
|
|
||||||
<span className="mr-2 flex size-4 items-center justify-center">
|
|
||||||
{theme === item.value ? (
|
|
||||||
<Check className="size-4" />
|
|
||||||
) : (
|
|
||||||
<Icon className="size-4" />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{item.label}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{myComplexesQuery.data && myComplexesQuery.data.length > 1 && (
|
|
||||||
<div className="py-1">
|
|
||||||
<p className="px-3 py-1 text-xs text-muted-foreground">Cambiar complejo</p>
|
|
||||||
{myComplexesQuery.data.map((complex) => (
|
|
||||||
<Button
|
|
||||||
key={complex.id}
|
|
||||||
variant="ghost"
|
|
||||||
className="w-full justify-start rounded-2xl"
|
|
||||||
onClick={() => {
|
|
||||||
setOpen(false);
|
|
||||||
void handleSelectComplex(complex.id, complex.complexSlug);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="mr-2 flex size-4 items-center justify-center">
|
|
||||||
{complex.complexSlug === complexSlug && <Check className="size-4" />}
|
|
||||||
</span>
|
|
||||||
{complex.complexName}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isAuthenticated && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="justify-start rounded-2xl"
|
|
||||||
onClick={() => {
|
|
||||||
setOpen(false);
|
|
||||||
void navigate({ to: '/profile' });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<User className="mr-2 size-4" />
|
|
||||||
Mi Perfil
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentComplexSlug && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="justify-start rounded-2xl"
|
|
||||||
onClick={() => {
|
|
||||||
setOpen(false);
|
|
||||||
void navigate({
|
|
||||||
to: '/complex/$slug/edit',
|
|
||||||
params: { slug: currentComplexSlug },
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Settings className="mr-2 size-4" />
|
|
||||||
Configuración
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isAuthenticated && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="justify-start rounded-2xl text-destructive hover:bg-destructive/10 hover:text-destructive"
|
|
||||||
onClick={() => {
|
|
||||||
setOpen(false);
|
|
||||||
void handleSignOut();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LogOut className="mr-2 size-4" />
|
|
||||||
Cerrar sesión
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-px bg-gradient-to-r from-transparent via-primary/40 to-transparent" />
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { Background } from '@/components/background';
|
|
||||||
import { useIsMobile } from '@/hooks/use-mobile';
|
|
||||||
import { useLocation } from '@tanstack/react-router';
|
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
import { Header } from './header';
|
|
||||||
|
|
||||||
interface LayoutProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Layout({ children }: LayoutProps) {
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
const location = useLocation();
|
|
||||||
const isMobileHome = isMobile && location.pathname === '/';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative min-h-dvh overflow-x-hidden bg-background text-foreground">
|
|
||||||
<Background />
|
|
||||||
{!isMobileHome && <Header />}
|
|
||||||
<main
|
|
||||||
className={
|
|
||||||
isMobileHome
|
|
||||||
? 'relative z-10 mx-auto w-full max-w-7xl'
|
|
||||||
: 'relative z-10 mx-auto w-full max-w-7xl px-3 py-6 sm:px-6 lg:px-8'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
113
apps/frontend/src/components/ui/availability-heatmap.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
// Types for the component
|
||||||
|
type Slot = {
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Court = {
|
||||||
|
courtId: string;
|
||||||
|
courtName: string;
|
||||||
|
sport: { id: string; name: string; slug: string };
|
||||||
|
slotDurationMinutes: number;
|
||||||
|
availableSlots: Slot[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type SelectedSlot = {
|
||||||
|
courtId: string;
|
||||||
|
startTime: string;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
type AvailabilityHeatMapProps = {
|
||||||
|
courts: Court[];
|
||||||
|
selectedSlot: SelectedSlot;
|
||||||
|
onSelectSlot: (payload: {
|
||||||
|
courtId: string;
|
||||||
|
courtName: string;
|
||||||
|
sportId: string;
|
||||||
|
sportName: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
price?: { min: number; max: number };
|
||||||
|
}) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AvailabilityHeatMap
|
||||||
|
*
|
||||||
|
* A visual heat‑map style component that displays each court as a card and colours the
|
||||||
|
* slot buttons according to how many slots are available for that court. The colour scale
|
||||||
|
* goes from green (few slots) to red (many slots), providing an immediate visual cue of
|
||||||
|
* density – the signature element requested for the bookings panel.
|
||||||
|
*/
|
||||||
|
export default function AvailabilityHeatMap({
|
||||||
|
courts,
|
||||||
|
selectedSlot,
|
||||||
|
onSelectSlot,
|
||||||
|
}: AvailabilityHeatMapProps) {
|
||||||
|
// Determine the maximum number of slots among all courts to normalise the heat scale.
|
||||||
|
const maxSlots = Math.max(1, ...courts.map((c) => c.availableSlots.length));
|
||||||
|
|
||||||
|
// Convert a slot count into an HSL colour ranging from green (low) to red (high).
|
||||||
|
const slotCountToHue = (count: number) => {
|
||||||
|
const ratio = count / maxSlots; // 0 => green, 1 => red
|
||||||
|
const hue = Math.round(120 - 120 * ratio); // 120° (green) to 0° (red)
|
||||||
|
return `hsl(${hue}, 70%, 55%)`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{courts.map((court) => {
|
||||||
|
const bg = slotCountToHue(court.availableSlots.length);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={court.courtId}
|
||||||
|
className="rounded-2xl border border-border/70 bg-background p-4 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<p className="font-semibold text-foreground">{court.courtName}</p>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{court.availableSlots.length} horarios
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{court.availableSlots.map((slot) => {
|
||||||
|
const isSelected =
|
||||||
|
selectedSlot?.courtId === court.courtId &&
|
||||||
|
selectedSlot?.startTime === slot.startTime;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={slot.startTime}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
onSelectSlot({
|
||||||
|
courtId: court.courtId,
|
||||||
|
courtName: court.courtName,
|
||||||
|
sportId: court.sport.id,
|
||||||
|
sportName: court.sport.name,
|
||||||
|
startTime: slot.startTime,
|
||||||
|
endTime: slot.endTime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={`h-10 rounded-xl border text-sm font-medium transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'border-primary bg-primary text-primary-foreground shadow-sm'
|
||||||
|
: 'border-border/70 hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-sm'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: isSelected ? undefined : bg,
|
||||||
|
opacity: isSelected ? 1 : 0.8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{slot.startTime}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,13 +5,13 @@ import * as React from 'react';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
|
default: 'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90',
|
||||||
outline:
|
outline:
|
||||||
'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
|
'border-border bg-card shadow-sm hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/20 dark:hover:bg-input/30',
|
||||||
secondary:
|
secondary:
|
||||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
|
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
|
||||||
ghost:
|
ghost:
|
||||||
@@ -22,16 +22,15 @@ const buttonVariants = cva(
|
|||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default:
|
default:
|
||||||
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
'h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5',
|
||||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
xs: "h-6 gap-1 rounded-lg px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
sm: "h-8 gap-1.5 rounded-lg px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
lg: 'h-10 gap-1.5 rounded-xl px-3.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3',
|
||||||
icon: 'size-8',
|
icon: 'size-9 rounded-xl',
|
||||||
'icon-xs':
|
'icon-xs':
|
||||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
"size-6 rounded-lg in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
'icon-sm':
|
'icon-sm': 'size-8 rounded-lg in-data-[slot=button-group]:rounded-lg',
|
||||||
'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
|
'icon-lg': 'size-10 rounded-xl',
|
||||||
'icon-lg': 'size-9',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|||||||
@@ -4,14 +4,12 @@ import { DayPicker } from 'react-day-picker';
|
|||||||
|
|
||||||
import { buttonVariants } from '@/components/ui/button';
|
import { buttonVariants } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { es } from 'date-fns/locale';
|
|
||||||
|
|
||||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
|
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
|
||||||
|
|
||||||
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
|
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
|
||||||
return (
|
return (
|
||||||
<DayPicker
|
<DayPicker
|
||||||
locale={es}
|
|
||||||
showOutsideDays={showOutsideDays}
|
showOutsideDays={showOutsideDays}
|
||||||
className={cn('p-3', className)}
|
className={cn('p-3', className)}
|
||||||
classNames={{
|
classNames={{
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ function DialogFooter({
|
|||||||
<div
|
<div
|
||||||
data-slot="dialog-footer"
|
data-slot="dialog-footer"
|
||||||
className={cn(
|
className={cn(
|
||||||
'-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-green-50/70 p-4 sm:flex-row sm:justify-end dark:bg-muted/50',
|
'-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
|||||||
type={type}
|
type={type}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
|
'h-10 w-full min-w-0 rounded-xl border border-input bg-card px-3 py-2 text-base shadow-[0_1px_0_rgba(15,23,42,0.02)] transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/20 dark:disabled:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ function SelectTrigger({
|
|||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
"flex w-fit items-center justify-between gap-1.5 rounded-xl border border-input bg-card py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-10 data-[size=sm]:h-9 data-[size=sm]:rounded-lg *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/20 dark:hover:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -61,7 +62,7 @@ function SelectContent({
|
|||||||
data-slot="select-content"
|
data-slot="select-content"
|
||||||
data-align-trigger={position === 'item-aligned'}
|
data-align-trigger={position === 'item-aligned'}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
|
'relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-xl bg-popover text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
|
||||||
position === 'popper' &&
|
position === 'popper' &&
|
||||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||||
className
|
className
|
||||||
@@ -105,7 +106,7 @@ function SelectItem({
|
|||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
data-slot="select-item"
|
data-slot="select-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
"relative flex w-full cursor-default items-center gap-1.5 rounded-lg py-2 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
import { Dialog as SheetPrimitive } from 'radix-ui';
|
|
||||||
import * as React from 'react';
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { XIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
|
||||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
|
||||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
|
||||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
|
||||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SheetOverlay({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
|
||||||
return (
|
|
||||||
<SheetPrimitive.Overlay
|
|
||||||
data-slot="sheet-overlay"
|
|
||||||
className={cn(
|
|
||||||
'fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SheetContent({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
side = 'right',
|
|
||||||
showCloseButton = true,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
|
||||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
|
||||||
showCloseButton?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<SheetPortal>
|
|
||||||
<SheetOverlay />
|
|
||||||
<SheetPrimitive.Content
|
|
||||||
data-slot="sheet-content"
|
|
||||||
data-side={side}
|
|
||||||
className={cn(
|
|
||||||
'fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
{showCloseButton && (
|
|
||||||
<SheetPrimitive.Close data-slot="sheet-close" asChild>
|
|
||||||
<Button variant="ghost" className="absolute top-3 right-3" size="icon-sm">
|
|
||||||
<XIcon />
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</Button>
|
|
||||||
</SheetPrimitive.Close>
|
|
||||||
)}
|
|
||||||
</SheetPrimitive.Content>
|
|
||||||
</SheetPortal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="sheet-header"
|
|
||||||
className={cn('flex flex-col gap-0.5 p-4', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="sheet-footer"
|
|
||||||
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
|
||||||
return (
|
|
||||||
<SheetPrimitive.Title
|
|
||||||
data-slot="sheet-title"
|
|
||||||
className={cn('font-heading text-base font-medium text-foreground', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SheetDescription({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
|
||||||
return (
|
|
||||||
<SheetPrimitive.Description
|
|
||||||
data-slot="sheet-description"
|
|
||||||
className={cn('text-sm text-muted-foreground', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Sheet,
|
|
||||||
SheetTrigger,
|
|
||||||
SheetClose,
|
|
||||||
SheetContent,
|
|
||||||
SheetHeader,
|
|
||||||
SheetFooter,
|
|
||||||
SheetTitle,
|
|
||||||
SheetDescription,
|
|
||||||
};
|
|
||||||
49
apps/frontend/src/components/ui/status-badge.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { type LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
type StatusType = 'CONFIRMED' | 'PENDING' | 'CANCELLED' | 'NO_SHOW' | 'COMPLETED';
|
||||||
|
|
||||||
|
type StatusBadgeProps = {
|
||||||
|
status: StatusType;
|
||||||
|
label: string;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusStyles: Record<StatusType, string> = {
|
||||||
|
CONFIRMED: 'bg-status-confirmed-bg border-status-confirmed-border text-status-confirmed-text',
|
||||||
|
PENDING: 'bg-status-pending-bg border-status-pending-border text-status-pending-text',
|
||||||
|
CANCELLED: 'bg-status-cancelled-bg border-status-cancelled-border text-status-cancelled-text',
|
||||||
|
NO_SHOW: 'bg-status-noshow-bg border-status-noshow-border text-status-noshow-text',
|
||||||
|
COMPLETED: 'bg-status-completed-bg border-status-completed-border text-status-completed-text',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusBadge({ status, label, icon: Icon, className }: StatusBadgeProps) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium',
|
||||||
|
statusStyles[status],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{Icon && <Icon className="size-3" />}
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function effectiveStatusClassName(
|
||||||
|
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW'
|
||||||
|
): string {
|
||||||
|
return statusStyles[status];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function effectiveStatusLabel(
|
||||||
|
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW'
|
||||||
|
): string {
|
||||||
|
if (status === 'NO_SHOW') return 'No show';
|
||||||
|
if (status === 'COMPLETED') return 'Cumplida';
|
||||||
|
if (status === 'CANCELLED') return 'Cancelada';
|
||||||
|
return 'Confirmada';
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
type UserAvatarProps = {
|
type UserAvatarProps = {
|
||||||
src?: string | null;
|
src?: string | null;
|
||||||
@@ -21,9 +22,25 @@ export function UserAvatar({
|
|||||||
size = 'default',
|
size = 'default',
|
||||||
className,
|
className,
|
||||||
}: UserAvatarProps) {
|
}: UserAvatarProps) {
|
||||||
|
const [imageError, setImageError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setImageError(false);
|
||||||
|
}, [src]);
|
||||||
|
|
||||||
|
const canShowImage = Boolean(src) && !imageError;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Avatar size={size} className={className}>
|
<Avatar size={size} className={className}>
|
||||||
<AvatarImage src={src ?? undefined} alt={alt} />
|
{canShowImage && (
|
||||||
|
<AvatarImage
|
||||||
|
src={src ?? undefined}
|
||||||
|
alt={alt}
|
||||||
|
onError={() => {
|
||||||
|
setImageError(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<AvatarFallback>{getInitials(fallbackText)}</AvatarFallback>
|
<AvatarFallback>{getInitials(fallbackText)}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
);
|
);
|
||||||
|
|||||||
12
apps/frontend/src/features/about/about-page.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export function AboutPage() {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto flex min-h-[60vh] w-full max-w-6xl items-center justify-center px-6">
|
||||||
|
<section className="w-full max-w-3xl rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
||||||
|
<h2 className="text-2xl font-semibold">About</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Ruta de ejemplo funcionando con TanStack Router.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,508 +0,0 @@
|
|||||||
import { ApiClientError, apiClient } from '@/lib/api-client';
|
|
||||||
import type { AdminBooking, ComplexWithRole, Court } from '@repo/api-contract';
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import {
|
|
||||||
type ReactNode,
|
|
||||||
createContext,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
import type {
|
|
||||||
BookingCourtSchedule,
|
|
||||||
BookingSlotDraft,
|
|
||||||
BookingStatusFilter,
|
|
||||||
BookingSummary,
|
|
||||||
BookingTimeRange,
|
|
||||||
BookingTimelineSegment,
|
|
||||||
BookingViewMode,
|
|
||||||
} from './booking.types';
|
|
||||||
import {
|
|
||||||
addDaysIso,
|
|
||||||
formatBookingDate,
|
|
||||||
getDayOfWeek,
|
|
||||||
getNowTime,
|
|
||||||
isTodayIso,
|
|
||||||
minutesToTime,
|
|
||||||
timeToMinutes,
|
|
||||||
toIsoDateLocal,
|
|
||||||
} from './lib/booking-time';
|
|
||||||
|
|
||||||
interface BookingProviderProps {
|
|
||||||
children: ReactNode;
|
|
||||||
complex: ComplexWithRole;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CreateManualBookingPayload {
|
|
||||||
courtId: string;
|
|
||||||
date: string;
|
|
||||||
startTime: string;
|
|
||||||
customerName: string;
|
|
||||||
customerPhone: string;
|
|
||||||
customerEmail?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BookingContextValue {
|
|
||||||
complex: ComplexWithRole;
|
|
||||||
courts: Court[];
|
|
||||||
bookings: AdminBooking[];
|
|
||||||
schedules: BookingCourtSchedule[];
|
|
||||||
selectedDate: string;
|
|
||||||
selectedSportId: string;
|
|
||||||
selectedStatus: BookingStatusFilter;
|
|
||||||
viewMode: BookingViewMode;
|
|
||||||
visibleTimeRange: BookingTimeRange;
|
|
||||||
timelineHours: string[];
|
|
||||||
summary: BookingSummary;
|
|
||||||
currentTime: string | null;
|
|
||||||
selectedSlot: BookingSlotDraft | null;
|
|
||||||
selectedSegment: BookingTimelineSegment | null;
|
|
||||||
isLoading: boolean;
|
|
||||||
isError: boolean;
|
|
||||||
errorMessage: string | null;
|
|
||||||
isCreateBookingOpen: boolean;
|
|
||||||
createBookingError: string | null;
|
|
||||||
isCreatingBooking: boolean;
|
|
||||||
bookingToolsOpen: boolean;
|
|
||||||
setSelectedDate: (date: string) => void;
|
|
||||||
moveSelectedDate: (amount: number) => void;
|
|
||||||
setSelectedSportId: (sportId: string) => void;
|
|
||||||
setSelectedStatus: (status: BookingStatusFilter) => void;
|
|
||||||
setViewMode: (mode: BookingViewMode) => void;
|
|
||||||
setVisibleTimeRange: (range: BookingTimeRange) => void;
|
|
||||||
openCreateBooking: (draft?: Partial<BookingSlotDraft>) => void;
|
|
||||||
closeCreateBooking: () => void;
|
|
||||||
createBooking: (payload: CreateManualBookingPayload) => Promise<void>;
|
|
||||||
updateBookingStatus: (bookingId: string, status: 'CANCELLED' | 'COMPLETED' | 'NOSHOW') => void;
|
|
||||||
exportDayReport: () => void;
|
|
||||||
openBookingTools: (segment?: BookingTimelineSegment) => void;
|
|
||||||
closeBookingTools: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BookingContext = createContext<BookingContextValue | null>(null);
|
|
||||||
|
|
||||||
const DEFAULT_TIME_RANGE: BookingTimeRange = {
|
|
||||||
start: '08:00',
|
|
||||||
end: '22:00',
|
|
||||||
};
|
|
||||||
|
|
||||||
function extractMessage(error: unknown, fallback: string) {
|
|
||||||
if (error instanceof ApiClientError) {
|
|
||||||
return error.message || fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof Error) {
|
|
||||||
return error.message || fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeTimelineHours(range: BookingTimeRange) {
|
|
||||||
const start = timeToMinutes(range.start);
|
|
||||||
const end = timeToMinutes(range.end);
|
|
||||||
const hours: string[] = [];
|
|
||||||
|
|
||||||
for (let minute = start; minute <= end; minute += 60) {
|
|
||||||
hours.push(minutesToTime(minute));
|
|
||||||
}
|
|
||||||
|
|
||||||
return hours;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isActiveBooking(booking: AdminBooking) {
|
|
||||||
return (
|
|
||||||
booking.status === 'CONFIRMED' || booking.status === 'COMPLETED' || booking.status === 'NOSHOW'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCourtBookings(court: Court, bookings: AdminBooking[], selectedDate: string) {
|
|
||||||
return bookings
|
|
||||||
.filter(
|
|
||||||
(booking) =>
|
|
||||||
booking.courtId === court.id && booking.date === selectedDate && isActiveBooking(booking)
|
|
||||||
)
|
|
||||||
.sort((a, b) => timeToMinutes(a.startTime) - timeToMinutes(b.startTime));
|
|
||||||
}
|
|
||||||
|
|
||||||
function overlapsBooking(start: number, end: number, booking: AdminBooking) {
|
|
||||||
const bookingStart = timeToMinutes(booking.startTime);
|
|
||||||
const bookingEnd = timeToMinutes(booking.endTime);
|
|
||||||
|
|
||||||
return start < bookingEnd && end > bookingStart;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSegmentsForCourt(
|
|
||||||
court: Court,
|
|
||||||
bookings: AdminBooking[],
|
|
||||||
selectedDate: string,
|
|
||||||
visibleRange: BookingTimeRange
|
|
||||||
) {
|
|
||||||
const dayOfWeek = getDayOfWeek(selectedDate);
|
|
||||||
const rangeStart = timeToMinutes(visibleRange.start);
|
|
||||||
const rangeEnd = timeToMinutes(visibleRange.end);
|
|
||||||
const courtBookings = getCourtBookings(court, bookings, selectedDate);
|
|
||||||
const segments: BookingTimelineSegment[] = [];
|
|
||||||
const slotDuration = court.slotDurationMinutes;
|
|
||||||
|
|
||||||
const availabilityRanges = court.availability
|
|
||||||
.filter((range) => range.dayOfWeek === dayOfWeek)
|
|
||||||
.map((range) => ({
|
|
||||||
start: Math.max(timeToMinutes(range.startTime), rangeStart),
|
|
||||||
end: Math.min(timeToMinutes(range.endTime), rangeEnd),
|
|
||||||
}))
|
|
||||||
.filter((range) => range.start < range.end)
|
|
||||||
.sort((a, b) => a.start - b.start);
|
|
||||||
|
|
||||||
for (const booking of courtBookings) {
|
|
||||||
const bookingStart = timeToMinutes(booking.startTime);
|
|
||||||
const bookingEnd = timeToMinutes(booking.endTime);
|
|
||||||
|
|
||||||
if (bookingEnd > rangeStart && bookingStart < rangeEnd) {
|
|
||||||
segments.push({
|
|
||||||
id: booking.id,
|
|
||||||
courtId: court.id,
|
|
||||||
status: 'reserved',
|
|
||||||
startTime: booking.startTime,
|
|
||||||
endTime: booking.endTime,
|
|
||||||
startMinutes: Math.max(bookingStart, rangeStart),
|
|
||||||
endMinutes: Math.min(bookingEnd, rangeEnd),
|
|
||||||
booking,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const availability of availabilityRanges) {
|
|
||||||
for (
|
|
||||||
let slotStart = availability.start;
|
|
||||||
slotStart + slotDuration <= availability.end;
|
|
||||||
slotStart += slotDuration
|
|
||||||
) {
|
|
||||||
const slotEnd = slotStart + slotDuration;
|
|
||||||
const isBooked = courtBookings.some((booking) =>
|
|
||||||
overlapsBooking(slotStart, slotEnd, booking)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isBooked) continue;
|
|
||||||
|
|
||||||
const slotStartTime = minutesToTime(slotStart);
|
|
||||||
const slotEndTime = minutesToTime(slotEnd);
|
|
||||||
|
|
||||||
segments.push({
|
|
||||||
id: `${court.id}-free-${slotStartTime}-${slotEndTime}`,
|
|
||||||
courtId: court.id,
|
|
||||||
status: 'free',
|
|
||||||
startTime: slotStartTime,
|
|
||||||
endTime: slotEndTime,
|
|
||||||
startMinutes: slotStart,
|
|
||||||
endMinutes: slotEnd,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
segments.sort((a, b) => a.startMinutes - b.startMinutes);
|
|
||||||
|
|
||||||
return segments;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSummary(schedules: BookingCourtSchedule[]): BookingSummary {
|
|
||||||
const freeSlots = schedules.reduce((total, schedule) => total + schedule.metrics.free, 0);
|
|
||||||
const reservedSlots = schedules.reduce((total, schedule) => total + schedule.metrics.reserved, 0);
|
|
||||||
const maintenanceSlots = schedules.reduce(
|
|
||||||
(total, schedule) => total + schedule.metrics.maintenance,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
const totalSegments = freeSlots + reservedSlots + maintenanceSlots || 1;
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalReservations: reservedSlots + maintenanceSlots + freeSlots,
|
|
||||||
freeSlots,
|
|
||||||
reservedSlots,
|
|
||||||
maintenanceSlots,
|
|
||||||
freePercent: Math.round((freeSlots / totalSegments) * 100),
|
|
||||||
reservedPercent: Math.round((reservedSlots / totalSegments) * 100),
|
|
||||||
maintenancePercent: Math.round((maintenanceSlots / totalSegments) * 100),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BookingProvider({ children, complex }: BookingProviderProps) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const todayIso = useMemo(() => toIsoDateLocal(new Date()), []);
|
|
||||||
const [selectedDate, setSelectedDate] = useState(todayIso);
|
|
||||||
const [selectedSportId, setSelectedSportId] = useState('all');
|
|
||||||
const [selectedStatus, setSelectedStatus] = useState<BookingStatusFilter>('all');
|
|
||||||
const [viewMode, setViewMode] = useState<BookingViewMode>('panel');
|
|
||||||
const [visibleTimeRange, setVisibleTimeRange] = useState(DEFAULT_TIME_RANGE);
|
|
||||||
const [selectedSlot, setSelectedSlot] = useState<BookingSlotDraft | null>(null);
|
|
||||||
const [isCreateBookingOpen, setIsCreateBookingOpen] = useState(false);
|
|
||||||
const [selectedSegment, setSelectedSegment] = useState<BookingTimelineSegment | null>(null);
|
|
||||||
const [bookingToolsOpen, setBookingToolsOpen] = useState(false);
|
|
||||||
|
|
||||||
const courtsQuery = useQuery({
|
|
||||||
queryKey: ['courts', complex.id],
|
|
||||||
queryFn: () => apiClient.courts.listByComplex(complex.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
const bookingsQuery = useQuery({
|
|
||||||
queryKey: ['admin-bookings', complex.id, selectedDate],
|
|
||||||
queryFn: () =>
|
|
||||||
apiClient.adminBookings.listByComplex(complex.id, {
|
|
||||||
fromDate: selectedDate,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const bookings = useMemo(
|
|
||||||
() => (bookingsQuery.data?.bookings ?? []).filter((booking) => booking.date === selectedDate),
|
|
||||||
[bookingsQuery.data?.bookings, selectedDate]
|
|
||||||
);
|
|
||||||
|
|
||||||
const courts = useMemo(
|
|
||||||
() => courtsQuery.data?.sort((a, b) => a.name.localeCompare(b.name)) ?? [],
|
|
||||||
[courtsQuery.data]
|
|
||||||
);
|
|
||||||
|
|
||||||
const filteredCourts = useMemo(() => {
|
|
||||||
if (selectedSportId === 'all') return courts;
|
|
||||||
return courts.filter((court) => court.sportId === selectedSportId);
|
|
||||||
}, [courts, selectedSportId]);
|
|
||||||
|
|
||||||
const schedules = useMemo<BookingCourtSchedule[]>(() => {
|
|
||||||
return filteredCourts
|
|
||||||
.map((court) => {
|
|
||||||
const segments = buildSegmentsForCourt(court, bookings, selectedDate, visibleTimeRange);
|
|
||||||
const metrics = {
|
|
||||||
free: segments.filter((segment) => segment.status === 'free').length,
|
|
||||||
reserved: segments.filter((segment) => segment.status === 'reserved').length,
|
|
||||||
maintenance: segments.filter((segment) => segment.status === 'maintenance').length,
|
|
||||||
};
|
|
||||||
|
|
||||||
return { court, segments, metrics };
|
|
||||||
})
|
|
||||||
.filter((schedule) => {
|
|
||||||
if (selectedStatus === 'all') return true;
|
|
||||||
return schedule.segments.some((segment) => segment.status === selectedStatus);
|
|
||||||
});
|
|
||||||
}, [bookings, filteredCourts, selectedDate, selectedStatus, visibleTimeRange]);
|
|
||||||
|
|
||||||
const summary = useMemo(() => getSummary(schedules), [schedules]);
|
|
||||||
const timelineHours = useMemo(() => makeTimelineHours(visibleTimeRange), [visibleTimeRange]);
|
|
||||||
|
|
||||||
const [now, setNow] = useState(() => getNowTime());
|
|
||||||
useEffect(() => {
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
setNow(getNowTime());
|
|
||||||
}, 60 * 1000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const currentTime = useMemo(() => {
|
|
||||||
if (!isTodayIso(selectedDate)) return null;
|
|
||||||
const nowMinutes = timeToMinutes(now);
|
|
||||||
const start = timeToMinutes(visibleTimeRange.start);
|
|
||||||
const end = timeToMinutes(visibleTimeRange.end);
|
|
||||||
|
|
||||||
if (nowMinutes < start || nowMinutes > end) return null;
|
|
||||||
return now;
|
|
||||||
}, [selectedDate, visibleTimeRange, now]);
|
|
||||||
|
|
||||||
const createBookingMutation = useMutation({
|
|
||||||
mutationFn: (payload: CreateManualBookingPayload) =>
|
|
||||||
apiClient.adminBookings.create(complex.id, {
|
|
||||||
date: payload.date,
|
|
||||||
courtId: payload.courtId,
|
|
||||||
startTime: payload.startTime,
|
|
||||||
customerName: payload.customerName,
|
|
||||||
customerPhone: payload.customerPhone,
|
|
||||||
customerEmail: payload.customerEmail || undefined,
|
|
||||||
}),
|
|
||||||
onSuccess: async () => {
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ['admin-bookings', complex.id] });
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ['manual-booking-availability'] });
|
|
||||||
setIsCreateBookingOpen(false);
|
|
||||||
setSelectedSlot(null);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateStatusMutation = useMutation({
|
|
||||||
mutationFn: (payload: { bookingId: string; status: 'CANCELLED' | 'COMPLETED' | 'NOSHOW' }) =>
|
|
||||||
apiClient.adminBookings.updateStatus(payload.bookingId, { status: payload.status }),
|
|
||||||
onSuccess: async () => {
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ['admin-bookings', complex.id] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const openCreateBooking = useCallback(
|
|
||||||
(draft?: Partial<BookingSlotDraft>) => {
|
|
||||||
setSelectedSlot({
|
|
||||||
date: draft?.date ?? selectedDate,
|
|
||||||
courtId: draft?.courtId,
|
|
||||||
startTime: draft?.startTime,
|
|
||||||
sportId: draft?.sportId,
|
|
||||||
});
|
|
||||||
setIsCreateBookingOpen(true);
|
|
||||||
},
|
|
||||||
[selectedDate]
|
|
||||||
);
|
|
||||||
|
|
||||||
const openBookingTools = useCallback(
|
|
||||||
(segment?: BookingTimelineSegment) => {
|
|
||||||
setSelectedSegment(segment ?? null);
|
|
||||||
setBookingToolsOpen(true);
|
|
||||||
console.log(JSON.stringify(segment, null, 2));
|
|
||||||
},
|
|
||||||
[selectedDate]
|
|
||||||
);
|
|
||||||
|
|
||||||
const closeBookingTools = () => setBookingToolsOpen(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleOpenCreateBooking = () => {
|
|
||||||
openCreateBooking();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('playzer:open-booking-create', handleOpenCreateBooking);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('playzer:open-booking-create', handleOpenCreateBooking);
|
|
||||||
};
|
|
||||||
}, [openCreateBooking]);
|
|
||||||
|
|
||||||
const closeCreateBooking = useCallback(() => {
|
|
||||||
setIsCreateBookingOpen(false);
|
|
||||||
setSelectedSlot(null);
|
|
||||||
createBookingMutation.reset();
|
|
||||||
}, [createBookingMutation]);
|
|
||||||
|
|
||||||
const createBooking = useCallback(
|
|
||||||
async (payload: CreateManualBookingPayload) => {
|
|
||||||
await createBookingMutation.mutateAsync(payload);
|
|
||||||
},
|
|
||||||
[createBookingMutation]
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateBookingStatus = useCallback(
|
|
||||||
(bookingId: string, status: 'CANCELLED' | 'COMPLETED' | 'NOSHOW') => {
|
|
||||||
updateStatusMutation.mutate({ bookingId, status });
|
|
||||||
},
|
|
||||||
[updateStatusMutation]
|
|
||||||
);
|
|
||||||
|
|
||||||
const exportDayReport = useCallback(() => {
|
|
||||||
const rows = [
|
|
||||||
['Fecha', 'Cancha', 'Deporte', 'Inicio', 'Fin', 'Cliente', 'Telefono', 'Estado'],
|
|
||||||
...bookings.map((booking) => [
|
|
||||||
booking.date,
|
|
||||||
booking.courtName,
|
|
||||||
booking.sport.name,
|
|
||||||
booking.startTime,
|
|
||||||
booking.endTime,
|
|
||||||
booking.customerName,
|
|
||||||
booking.customerPhone,
|
|
||||||
booking.status,
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
const csv = rows.map((row) => row.map((cell) => `"${cell}"`).join(',')).join('\n');
|
|
||||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = `reservas-${complex.complexSlug}-${selectedDate}.csv`;
|
|
||||||
link.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}, [bookings, complex.complexSlug, selectedDate]);
|
|
||||||
|
|
||||||
const value = useMemo<BookingContextValue>(
|
|
||||||
() => ({
|
|
||||||
complex,
|
|
||||||
courts,
|
|
||||||
bookings,
|
|
||||||
schedules,
|
|
||||||
selectedDate,
|
|
||||||
selectedSportId,
|
|
||||||
selectedStatus,
|
|
||||||
viewMode,
|
|
||||||
visibleTimeRange,
|
|
||||||
timelineHours,
|
|
||||||
summary,
|
|
||||||
currentTime,
|
|
||||||
selectedSlot,
|
|
||||||
isLoading: courtsQuery.isLoading || bookingsQuery.isLoading,
|
|
||||||
isError: courtsQuery.isError || bookingsQuery.isError,
|
|
||||||
errorMessage:
|
|
||||||
courtsQuery.isError || bookingsQuery.isError
|
|
||||||
? extractMessage(
|
|
||||||
courtsQuery.error ?? bookingsQuery.error,
|
|
||||||
'No pudimos cargar el panel de reservas.'
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
isCreateBookingOpen,
|
|
||||||
createBookingError: createBookingMutation.isError
|
|
||||||
? extractMessage(createBookingMutation.error, 'No pudimos crear la reserva.')
|
|
||||||
: null,
|
|
||||||
isCreatingBooking: createBookingMutation.isPending,
|
|
||||||
setSelectedDate,
|
|
||||||
moveSelectedDate: (amount) => setSelectedDate((date) => addDaysIso(date, amount)),
|
|
||||||
setSelectedSportId,
|
|
||||||
setSelectedStatus,
|
|
||||||
setViewMode,
|
|
||||||
setVisibleTimeRange,
|
|
||||||
openCreateBooking,
|
|
||||||
closeCreateBooking,
|
|
||||||
createBooking,
|
|
||||||
updateBookingStatus,
|
|
||||||
exportDayReport,
|
|
||||||
bookingToolsOpen,
|
|
||||||
openBookingTools,
|
|
||||||
closeBookingTools,
|
|
||||||
selectedSegment,
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
bookings,
|
|
||||||
bookingsQuery.error,
|
|
||||||
bookingsQuery.isError,
|
|
||||||
bookingsQuery.isLoading,
|
|
||||||
closeCreateBooking,
|
|
||||||
complex,
|
|
||||||
courts,
|
|
||||||
courtsQuery.error,
|
|
||||||
courtsQuery.isError,
|
|
||||||
courtsQuery.isLoading,
|
|
||||||
createBooking,
|
|
||||||
createBookingMutation.error,
|
|
||||||
createBookingMutation.isError,
|
|
||||||
createBookingMutation.isPending,
|
|
||||||
currentTime,
|
|
||||||
exportDayReport,
|
|
||||||
isCreateBookingOpen,
|
|
||||||
openCreateBooking,
|
|
||||||
schedules,
|
|
||||||
selectedDate,
|
|
||||||
selectedSlot,
|
|
||||||
selectedSportId,
|
|
||||||
selectedStatus,
|
|
||||||
summary,
|
|
||||||
timelineHours,
|
|
||||||
updateBookingStatus,
|
|
||||||
viewMode,
|
|
||||||
visibleTimeRange,
|
|
||||||
bookingToolsOpen,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
return <BookingContext.Provider value={value}>{children}</BookingContext.Provider>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useBooking() {
|
|
||||||
const context = useContext(BookingContext);
|
|
||||||
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useBooking must be used within BookingProvider');
|
|
||||||
}
|
|
||||||
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
export { formatBookingDate };
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { useIsMobile } from '@/hooks/use-mobile';
|
|
||||||
import { authClient } from '@/lib/api-client';
|
|
||||||
import { useAuth } from '@/lib/auth';
|
|
||||||
import type { ComplexWithRole } from '@repo/api-contract';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { BookingProvider } from './booking-provider';
|
|
||||||
import { BookingCreateDialog } from './components/booking-create-dialog';
|
|
||||||
import { BookingDaySummary, BookingQuickActions } from './components/booking-day-summary';
|
|
||||||
import { BookingHeader } from './components/booking-header';
|
|
||||||
import { BookingMobile } from './components/booking-mobile';
|
|
||||||
import { BookingTimeline } from './components/booking-timeline';
|
|
||||||
import { BookingToolbar } from './components/booking-toolbar';
|
|
||||||
import { BookingToolsDialog } from './components/booking-tools-dialog';
|
|
||||||
|
|
||||||
interface BookingProps {
|
|
||||||
complex: ComplexWithRole;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Booking({ complex }: BookingProps) {
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
const { user } = useAuth();
|
|
||||||
const [sending, setSending] = useState(false);
|
|
||||||
const [emailSent, setEmailSent] = useState(false);
|
|
||||||
|
|
||||||
const emailNotVerified = user && !user.emailVerified;
|
|
||||||
|
|
||||||
const handleResendVerification = async () => {
|
|
||||||
if (!user?.email || sending) return;
|
|
||||||
setSending(true);
|
|
||||||
try {
|
|
||||||
await authClient.sendVerificationEmail({ email: user.email });
|
|
||||||
setEmailSent(true);
|
|
||||||
setTimeout(() => setEmailSent(false), 6000);
|
|
||||||
} catch {
|
|
||||||
// Silently fail — the banner already shows the email to contact
|
|
||||||
} finally {
|
|
||||||
setSending(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BookingProvider complex={complex}>
|
|
||||||
{emailNotVerified && (
|
|
||||||
<div className="mb-6 flex items-center justify-between gap-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-800/30 dark:bg-amber-950/30 dark:text-amber-200">
|
|
||||||
<span>
|
|
||||||
No verificaste tu email. Para crear reservas necesitás verificar tu dirección de correo
|
|
||||||
electrónico.
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleResendVerification}
|
|
||||||
disabled={sending}
|
|
||||||
className="shrink-0 rounded-lg bg-amber-200 px-3 py-1.5 text-xs font-medium text-amber-900 transition-colors hover:bg-amber-300 disabled:opacity-50 dark:bg-amber-800 dark:text-amber-50 dark:hover:bg-amber-700"
|
|
||||||
>
|
|
||||||
{sending ? 'Enviando...' : emailSent ? '¡Email enviado!' : 'Reenviar email'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isMobile ? (
|
|
||||||
<BookingMobile />
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-5">
|
|
||||||
<BookingHeader />
|
|
||||||
<BookingToolbar />
|
|
||||||
<BookingTimeline />
|
|
||||||
<div className="grid gap-5 xl:grid-cols-4">
|
|
||||||
<BookingDaySummary />
|
|
||||||
<BookingQuickActions />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<BookingCreateDialog />
|
|
||||||
<BookingToolsDialog />
|
|
||||||
</BookingProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import type { AdminBooking, Court } from '@repo/api-contract';
|
|
||||||
|
|
||||||
export type BookingViewMode = 'panel' | 'status';
|
|
||||||
export type BookingStatusFilter = 'all' | 'free' | 'reserved' | 'maintenance';
|
|
||||||
export type BookingSegmentStatus = 'free' | 'reserved' | 'maintenance';
|
|
||||||
|
|
||||||
export interface BookingTimeRange {
|
|
||||||
start: string;
|
|
||||||
end: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BookingSummary {
|
|
||||||
totalReservations: number;
|
|
||||||
freeSlots: number;
|
|
||||||
reservedSlots: number;
|
|
||||||
maintenanceSlots: number;
|
|
||||||
freePercent: number;
|
|
||||||
reservedPercent: number;
|
|
||||||
maintenancePercent: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BookingCourtMetrics {
|
|
||||||
free: number;
|
|
||||||
reserved: number;
|
|
||||||
maintenance: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BookingTimelineSegment {
|
|
||||||
id: string;
|
|
||||||
courtId: string;
|
|
||||||
status: BookingSegmentStatus;
|
|
||||||
startTime: string;
|
|
||||||
endTime: string;
|
|
||||||
startMinutes: number;
|
|
||||||
endMinutes: number;
|
|
||||||
booking?: AdminBooking;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BookingCourtSchedule {
|
|
||||||
court: Court;
|
|
||||||
segments: BookingTimelineSegment[];
|
|
||||||
metrics: BookingCourtMetrics;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BookingSlotDraft {
|
|
||||||
courtId?: string;
|
|
||||||
date: string;
|
|
||||||
startTime?: string;
|
|
||||||
sportId?: string;
|
|
||||||
}
|
|
||||||
@@ -1,312 +0,0 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { DatePicker } from '@/components/ui/date-picker';
|
|
||||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import {
|
|
||||||
ResponsiveDialog,
|
|
||||||
ResponsiveDialogClose,
|
|
||||||
ResponsiveDialogContent,
|
|
||||||
ResponsiveDialogDescription,
|
|
||||||
ResponsiveDialogFooter,
|
|
||||||
ResponsiveDialogHeader,
|
|
||||||
ResponsiveDialogTitle,
|
|
||||||
} from '@/components/ui/responsive-dialog';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { useBooking } from '../booking-provider';
|
|
||||||
import {
|
|
||||||
fromIsoDateLocal,
|
|
||||||
getDayOfWeek,
|
|
||||||
minutesToTime,
|
|
||||||
timeToMinutes,
|
|
||||||
toIsoDateLocal,
|
|
||||||
} from '../lib/booking-time';
|
|
||||||
|
|
||||||
const bookingFormSchema = z.object({
|
|
||||||
customerName: z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.min(2, 'Ingresa un nombre válido.')
|
|
||||||
.max(120, 'El nombre no puede superar los 120 caracteres.'),
|
|
||||||
customerPhone: z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.min(6, 'Ingresa un telefono válido.')
|
|
||||||
.max(30, 'El telefono no puede superar los 30 caracteres.'),
|
|
||||||
customerEmail: z.string().email('Ingresá un email válido.').optional().or(z.literal('')),
|
|
||||||
});
|
|
||||||
|
|
||||||
type BookingForm = z.infer<typeof bookingFormSchema>;
|
|
||||||
|
|
||||||
export function BookingCreateDialog() {
|
|
||||||
const {
|
|
||||||
courts,
|
|
||||||
bookings,
|
|
||||||
selectedDate,
|
|
||||||
selectedSlot,
|
|
||||||
isCreateBookingOpen,
|
|
||||||
createBookingError,
|
|
||||||
isCreatingBooking,
|
|
||||||
closeCreateBooking,
|
|
||||||
createBooking,
|
|
||||||
} = useBooking();
|
|
||||||
|
|
||||||
const [date, setDate] = useState(selectedSlot?.date ?? selectedDate);
|
|
||||||
const [sportId, setSportId] = useState(selectedSlot?.sportId ?? 'all');
|
|
||||||
const [courtId, setCourtId] = useState(selectedSlot?.courtId ?? '');
|
|
||||||
const [startTime, setStartTime] = useState(selectedSlot?.startTime ?? '');
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
reset,
|
|
||||||
setFocus,
|
|
||||||
formState: { errors, isValid },
|
|
||||||
} = useForm<BookingForm>({
|
|
||||||
resolver: zodResolver(bookingFormSchema),
|
|
||||||
mode: 'onChange',
|
|
||||||
defaultValues: {
|
|
||||||
customerName: '',
|
|
||||||
customerPhone: '',
|
|
||||||
customerEmail: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isCreateBookingOpen) return;
|
|
||||||
|
|
||||||
setDate(selectedSlot?.date ?? selectedDate);
|
|
||||||
setSportId(selectedSlot?.sportId ?? 'all');
|
|
||||||
setCourtId(selectedSlot?.courtId ?? '');
|
|
||||||
setStartTime(selectedSlot?.startTime ?? '');
|
|
||||||
reset();
|
|
||||||
|
|
||||||
if (selectedSlot?.courtId && selectedSlot?.startTime) {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
setFocus('customerName');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [isCreateBookingOpen, reset, selectedDate, selectedSlot, setFocus]);
|
|
||||||
|
|
||||||
const sports = [...new Map(courts.map((court) => [court.sport.id, court.sport])).values()];
|
|
||||||
const filteredCourts = useMemo(() => {
|
|
||||||
if (sportId === 'all') return courts;
|
|
||||||
return courts.filter((court) => court.sportId === sportId);
|
|
||||||
}, [courts, sportId]);
|
|
||||||
const selectedCourt = courts.find((court) => court.id === courtId);
|
|
||||||
const availableStartTimes = useMemo(() => {
|
|
||||||
if (!selectedCourt) return [];
|
|
||||||
|
|
||||||
const times: string[] = [];
|
|
||||||
const dayOfWeek = getDayOfWeek(date);
|
|
||||||
const dayAvailability = selectedCourt.availability
|
|
||||||
.filter((range) => range.dayOfWeek === dayOfWeek)
|
|
||||||
.sort((a, b) => timeToMinutes(a.startTime) - timeToMinutes(b.startTime));
|
|
||||||
const courtBookings = bookings.filter(
|
|
||||||
(booking) =>
|
|
||||||
booking.date === date &&
|
|
||||||
booking.courtId === selectedCourt.id &&
|
|
||||||
booking.status !== 'CANCELLED'
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const range of dayAvailability) {
|
|
||||||
const start = timeToMinutes(range.startTime);
|
|
||||||
const end = timeToMinutes(range.endTime);
|
|
||||||
|
|
||||||
for (
|
|
||||||
let minute = start;
|
|
||||||
minute + selectedCourt.slotDurationMinutes <= end;
|
|
||||||
minute += selectedCourt.slotDurationMinutes
|
|
||||||
) {
|
|
||||||
const slotEnd = minute + selectedCourt.slotDurationMinutes;
|
|
||||||
const isBooked = courtBookings.some((booking) => {
|
|
||||||
const bookingStart = timeToMinutes(booking.startTime);
|
|
||||||
const bookingEnd = timeToMinutes(booking.endTime);
|
|
||||||
return minute < bookingEnd && slotEnd > bookingStart;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!isBooked) {
|
|
||||||
times.push(minutesToTime(minute));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return times;
|
|
||||||
}, [bookings, date, selectedCourt]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (startTime && !availableStartTimes.includes(startTime)) {
|
|
||||||
setStartTime('');
|
|
||||||
}
|
|
||||||
}, [availableStartTimes, startTime]);
|
|
||||||
|
|
||||||
const onSubmit = async (values: BookingForm) => {
|
|
||||||
await createBooking({
|
|
||||||
courtId,
|
|
||||||
date,
|
|
||||||
startTime,
|
|
||||||
customerName: values.customerName,
|
|
||||||
customerPhone: values.customerPhone,
|
|
||||||
customerEmail: values.customerEmail || undefined,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ResponsiveDialog
|
|
||||||
open={isCreateBookingOpen}
|
|
||||||
onOpenChange={(open) => !open && closeCreateBooking()}
|
|
||||||
>
|
|
||||||
<ResponsiveDialogContent
|
|
||||||
className="data-[variant=dialog]:max-w-lg"
|
|
||||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
|
||||||
>
|
|
||||||
<ResponsiveDialogHeader>
|
|
||||||
<ResponsiveDialogTitle>Nueva reserva</ResponsiveDialogTitle>
|
|
||||||
<ResponsiveDialogDescription>
|
|
||||||
Crea un turno para atención telefónica o mostrador.
|
|
||||||
</ResponsiveDialogDescription>
|
|
||||||
</ResponsiveDialogHeader>
|
|
||||||
|
|
||||||
<form id="booking-create-form" className="grid gap-4" onSubmit={handleSubmit(onSubmit)}>
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
|
||||||
<Field>
|
|
||||||
<FieldLabel>Fecha</FieldLabel>
|
|
||||||
<DatePicker
|
|
||||||
value={fromIsoDateLocal(date)}
|
|
||||||
onChange={(nextDate) => {
|
|
||||||
setDate(nextDate ? toIsoDateLocal(nextDate) : selectedDate);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field>
|
|
||||||
<FieldLabel>Deporte</FieldLabel>
|
|
||||||
<Select
|
|
||||||
value={sportId}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
setSportId(value);
|
|
||||||
setCourtId('');
|
|
||||||
setStartTime('');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">Todos</SelectItem>
|
|
||||||
{sports.map((sport) => (
|
|
||||||
<SelectItem key={sport.id} value={sport.id}>
|
|
||||||
{sport.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field>
|
|
||||||
<FieldLabel>Cancha</FieldLabel>
|
|
||||||
<Select
|
|
||||||
value={courtId}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
setCourtId(value);
|
|
||||||
setStartTime('');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Selecciona una cancha" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{filteredCourts.map((court) => (
|
|
||||||
<SelectItem key={court.id} value={court.id}>
|
|
||||||
{court.name} · {court.sport.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field>
|
|
||||||
<FieldLabel>Horario</FieldLabel>
|
|
||||||
<Select value={startTime} onValueChange={setStartTime}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Selecciona un horario" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{availableStartTimes.map((time) => (
|
|
||||||
<SelectItem key={time} value={time}>
|
|
||||||
{time}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Field data-invalid={Boolean(errors.customerName)}>
|
|
||||||
<FieldLabel htmlFor="customerName">Nombre</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="customerName"
|
|
||||||
placeholder="Ej: Juan Perez"
|
|
||||||
aria-invalid={Boolean(errors.customerName)}
|
|
||||||
{...register('customerName')}
|
|
||||||
/>
|
|
||||||
<FieldError errors={[errors.customerName]} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field data-invalid={Boolean(errors.customerPhone)}>
|
|
||||||
<FieldLabel htmlFor="customerPhone">Teléfono</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="customerPhone"
|
|
||||||
type="tel"
|
|
||||||
placeholder="Ej: 3875551234"
|
|
||||||
aria-invalid={Boolean(errors.customerPhone)}
|
|
||||||
{...register('customerPhone')}
|
|
||||||
/>
|
|
||||||
<FieldError errors={[errors.customerPhone]} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field data-invalid={Boolean(errors.customerEmail)}>
|
|
||||||
<FieldLabel htmlFor="customerEmail">
|
|
||||||
Email <span className="text-muted-foreground font-normal">(opcional)</span>
|
|
||||||
</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="customerEmail"
|
|
||||||
type="email"
|
|
||||||
placeholder="Ej: juan@ejemplo.com"
|
|
||||||
aria-invalid={Boolean(errors.customerEmail)}
|
|
||||||
{...register('customerEmail')}
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
Se enviará la confirmación de la reserva por email.
|
|
||||||
</p>
|
|
||||||
<FieldError errors={[errors.customerEmail]} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
{createBookingError && <p className="text-sm text-destructive">{createBookingError}</p>}
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<ResponsiveDialogFooter>
|
|
||||||
<ResponsiveDialogClose asChild>
|
|
||||||
<Button variant="outline">Cancelar</Button>
|
|
||||||
</ResponsiveDialogClose>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
form="booking-create-form"
|
|
||||||
disabled={!isValid || isCreatingBooking || !courtId || !startTime}
|
|
||||||
>
|
|
||||||
{isCreatingBooking ? 'Guardando...' : 'Crear reserva'}
|
|
||||||
</Button>
|
|
||||||
</ResponsiveDialogFooter>
|
|
||||||
</ResponsiveDialogContent>
|
|
||||||
</ResponsiveDialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import { CalendarDays, Download, Drill, ShieldCheck, UsersRound } from 'lucide-react';
|
|
||||||
import { useBooking } from '../booking-provider';
|
|
||||||
import { formatBookingDate, toIsoDateLocal } from '../lib/booking-time';
|
|
||||||
|
|
||||||
export function BookingDaySummary() {
|
|
||||||
const { selectedDate, summary } = useBooking();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="rounded-lg border bg-card/85 p-5 shadow-sm xl:col-span-3">
|
|
||||||
<div className="mb-4">
|
|
||||||
<h2 className="text-base font-semibold">Resumen del día</h2>
|
|
||||||
<p className="mt-1 text-sm capitalize text-muted-foreground">
|
|
||||||
{formatBookingDate(selectedDate, { year: 'numeric' })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-4">
|
|
||||||
<SummaryCard
|
|
||||||
label="Total de bloques"
|
|
||||||
value={summary.totalReservations}
|
|
||||||
icon={CalendarDays}
|
|
||||||
className="border-border bg-background/45"
|
|
||||||
/>
|
|
||||||
<SummaryCard
|
|
||||||
label="Libres"
|
|
||||||
value={summary.freeSlots}
|
|
||||||
detail={`${summary.freePercent}% del total`}
|
|
||||||
icon={UsersRound}
|
|
||||||
className="border-primary/30 bg-primary/10 text-primary"
|
|
||||||
/>
|
|
||||||
<SummaryCard
|
|
||||||
label="Reservados"
|
|
||||||
value={summary.reservedSlots}
|
|
||||||
detail={`${summary.reservedPercent}% del total`}
|
|
||||||
icon={ShieldCheck}
|
|
||||||
className="border-reserved/35 bg-reserved/10 text-reserved"
|
|
||||||
/>
|
|
||||||
<SummaryCard
|
|
||||||
label="Mantenimiento"
|
|
||||||
value={summary.maintenanceSlots}
|
|
||||||
detail={`${summary.maintenancePercent}% del total`}
|
|
||||||
icon={Drill}
|
|
||||||
className="border-maintenance/35 bg-maintenance/10 text-maintenance"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SummaryCardProps {
|
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
detail?: string;
|
|
||||||
icon: typeof CalendarDays;
|
|
||||||
className: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SummaryCard({ label, value, detail, icon: Icon, className }: SummaryCardProps) {
|
|
||||||
return (
|
|
||||||
<article className={`rounded-lg border p-4 ${className}`}>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-md bg-transparent">
|
|
||||||
<Icon className="size-5" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate text-sm text-muted-foreground">{label}</p>
|
|
||||||
<p className="text-2xl font-semibold leading-tight">{value}</p>
|
|
||||||
{detail && <p className="mt-1 truncate text-xs text-muted-foreground">{detail}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BookingQuickActions() {
|
|
||||||
const { selectedDate, selectedStatus, setSelectedDate, setSelectedStatus, exportDayReport } =
|
|
||||||
useBooking();
|
|
||||||
const todayIso = toIsoDateLocal(new Date());
|
|
||||||
const isViewingTodayReservations = selectedDate === todayIso && selectedStatus === 'reserved';
|
|
||||||
const isViewingMaintenance = selectedStatus === 'maintenance';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="rounded-lg border bg-card/85 p-5 shadow-sm">
|
|
||||||
<h2 className="text-base font-semibold">Acciones rápidas</h2>
|
|
||||||
<div className="mt-3 grid gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedDate(todayIso);
|
|
||||||
setSelectedStatus(isViewingTodayReservations ? 'all' : 'reserved');
|
|
||||||
}}
|
|
||||||
aria-pressed={isViewingTodayReservations}
|
|
||||||
className="flex h-10 items-center gap-3 rounded-md border bg-background/45 px-3 text-left text-sm transition-colors hover:bg-muted aria-pressed:border-reserved/50 aria-pressed:bg-reserved/10"
|
|
||||||
>
|
|
||||||
<CalendarDays className="size-4 text-muted-foreground" />
|
|
||||||
{isViewingTodayReservations ? 'Ver todos los estados' : 'Ver reservas de hoy'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelectedStatus(isViewingMaintenance ? 'all' : 'maintenance')}
|
|
||||||
aria-pressed={isViewingMaintenance}
|
|
||||||
className="flex h-10 items-center gap-3 rounded-md border bg-background/45 px-3 text-left text-sm transition-colors hover:bg-muted aria-pressed:border-maintenance/50 aria-pressed:bg-maintenance/10"
|
|
||||||
>
|
|
||||||
<Drill className="size-4 text-maintenance" />
|
|
||||||
{isViewingMaintenance ? 'Ver todos los estados' : 'Ver mantenimiento'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={exportDayReport}
|
|
||||||
className="flex h-10 items-center gap-3 rounded-md border bg-background/45 px-3 text-left text-sm transition-colors hover:bg-muted"
|
|
||||||
>
|
|
||||||
<Download className="size-4 text-primary" />
|
|
||||||
Exportar reporte
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { useBooking } from '../booking-provider';
|
|
||||||
|
|
||||||
export function BookingHeader() {
|
|
||||||
const { complex } = useBooking();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-semibold tracking-normal text-foreground">
|
|
||||||
Panel de Reservas
|
|
||||||
</h1>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
Gestiona las canchas de {complex.complexName}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
const items = [
|
|
||||||
{ label: 'Libre', className: 'bg-primary' },
|
|
||||||
{ label: 'Reservado', className: 'bg-reserved' },
|
|
||||||
{ label: 'Mantenimiento', className: 'bg-maintenance' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function BookingStatusLegend() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap items-center gap-5 text-sm text-muted-foreground">
|
|
||||||
{items.map((item) => (
|
|
||||||
<span key={item.label} className="inline-flex items-center gap-2">
|
|
||||||
<span className={`size-3 rounded-full ${item.className}`} />
|
|
||||||
{item.label}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,336 +0,0 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { ChevronLeft, ChevronRight, Dumbbell, Plus, UserX, Users } from 'lucide-react';
|
|
||||||
import { useEffect, useRef } from 'react';
|
|
||||||
import { useBooking } from '../booking-provider';
|
|
||||||
import type { BookingCourtSchedule, BookingTimelineSegment } from '../booking.types';
|
|
||||||
import { timeToMinutes } from '../lib/booking-time';
|
|
||||||
import { BookingStatusLegend } from './booking-status-legend';
|
|
||||||
|
|
||||||
const courtInfoWidth = 220;
|
|
||||||
const hourWidth = 104;
|
|
||||||
|
|
||||||
export function BookingTimeline() {
|
|
||||||
const {
|
|
||||||
schedules,
|
|
||||||
timelineHours,
|
|
||||||
visibleTimeRange,
|
|
||||||
currentTime,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
errorMessage,
|
|
||||||
} = useBooking();
|
|
||||||
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
const prevCurrentTimeRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
const rangeStart = timeToMinutes(visibleTimeRange.start);
|
|
||||||
const rangeEnd = timeToMinutes(visibleTimeRange.end);
|
|
||||||
const totalMinutes = rangeEnd - rangeStart;
|
|
||||||
const gridWidth = Math.max((timelineHours.length - 1) * hourWidth, 760);
|
|
||||||
const timelineMarks = timelineHours.map((hour) => ({
|
|
||||||
hour,
|
|
||||||
left: ((timeToMinutes(hour) - rangeStart) / totalMinutes) * 100,
|
|
||||||
}));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!currentTime || !scrollRef.current) return;
|
|
||||||
if (currentTime === prevCurrentTimeRef.current) return;
|
|
||||||
prevCurrentTimeRef.current = currentTime;
|
|
||||||
|
|
||||||
const container = scrollRef.current;
|
|
||||||
const indicatorLeft =
|
|
||||||
courtInfoWidth + ((timeToMinutes(currentTime) - rangeStart) / totalMinutes) * gridWidth;
|
|
||||||
const targetScroll = indicatorLeft - container.clientWidth / 2;
|
|
||||||
const clampedScroll = Math.max(
|
|
||||||
0,
|
|
||||||
Math.min(targetScroll, container.scrollWidth - container.clientWidth)
|
|
||||||
);
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
container.scrollTo({ left: clampedScroll, behavior: 'smooth' });
|
|
||||||
});
|
|
||||||
}, [currentTime, rangeStart, totalMinutes, gridWidth]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="overflow-hidden rounded-lg border bg-card/85 shadow-sm">
|
|
||||||
<div className="flex items-center justify-between gap-4 border-b px-4 py-4">
|
|
||||||
<BookingStatusLegend />
|
|
||||||
<div className="hidden items-center gap-2 md:flex">
|
|
||||||
<Button type="button" size="icon-sm" variant="ghost">
|
|
||||||
<ChevronLeft className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button type="button" size="icon-sm" variant="ghost">
|
|
||||||
<ChevronRight className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading && (
|
|
||||||
<div className="px-4 py-10 text-sm text-muted-foreground">Cargando reservas...</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isError && !isLoading && (
|
|
||||||
<div className="px-4 py-10 text-sm text-destructive">{errorMessage}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isLoading && !isError && schedules.length === 0 && (
|
|
||||||
<div className="px-4 py-10 text-sm text-muted-foreground">
|
|
||||||
No hay canchas para los filtros seleccionados.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isLoading && !isError && schedules.length > 0 && (
|
|
||||||
<div ref={scrollRef} className="booking-scrollbar overflow-x-auto overflow-y-hidden">
|
|
||||||
<div className="relative min-w-full" style={{ width: courtInfoWidth + gridWidth }}>
|
|
||||||
<TimelineHeader
|
|
||||||
timelineMarks={timelineMarks}
|
|
||||||
courtInfoWidth={courtInfoWidth}
|
|
||||||
gridWidth={gridWidth}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="divide-y">
|
|
||||||
{schedules.map((schedule) => (
|
|
||||||
<BookingCourtRow
|
|
||||||
key={schedule.court.id}
|
|
||||||
schedule={schedule}
|
|
||||||
courtInfoWidth={courtInfoWidth}
|
|
||||||
gridWidth={gridWidth}
|
|
||||||
rangeStart={rangeStart}
|
|
||||||
totalMinutes={totalMinutes}
|
|
||||||
timelineMarks={timelineMarks}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{currentTime && (
|
|
||||||
<CurrentTimeIndicator
|
|
||||||
time={currentTime}
|
|
||||||
courtInfoWidth={courtInfoWidth}
|
|
||||||
gridWidth={gridWidth}
|
|
||||||
rangeStart={rangeStart}
|
|
||||||
totalMinutes={totalMinutes}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TimelineHeaderProps {
|
|
||||||
timelineMarks: Array<{ hour: string; left: number }>;
|
|
||||||
courtInfoWidth: number;
|
|
||||||
gridWidth: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TimelineHeader({ timelineMarks, courtInfoWidth, gridWidth }: TimelineHeaderProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="sticky top-0 z-20 grid border-b bg-card/95 z-auto"
|
|
||||||
style={{ gridTemplateColumns: `${courtInfoWidth}px ${gridWidth}px` }}
|
|
||||||
>
|
|
||||||
<div className="sticky left-0 z-30 border-r bg-card/95 px-4 py-3 shadow-[8px_0_18px_-18px_oklch(0_0_0/0.45)]" />
|
|
||||||
<div className="relative h-12">
|
|
||||||
{timelineMarks.map((mark) => (
|
|
||||||
<div
|
|
||||||
key={mark.hour}
|
|
||||||
className="absolute top-0 flex h-full items-center border-l px-3 text-sm text-muted-foreground first:border-l-0"
|
|
||||||
style={{ left: `${mark.left}%` }}
|
|
||||||
>
|
|
||||||
{mark.hour}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BookingCourtRowProps {
|
|
||||||
schedule: BookingCourtSchedule;
|
|
||||||
courtInfoWidth: number;
|
|
||||||
gridWidth: number;
|
|
||||||
rangeStart: number;
|
|
||||||
totalMinutes: number;
|
|
||||||
timelineMarks: Array<{ hour: string; left: number }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function BookingCourtRow({
|
|
||||||
schedule,
|
|
||||||
courtInfoWidth,
|
|
||||||
gridWidth,
|
|
||||||
rangeStart,
|
|
||||||
totalMinutes,
|
|
||||||
timelineMarks,
|
|
||||||
}: BookingCourtRowProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="grid min-h-[96px]"
|
|
||||||
style={{ gridTemplateColumns: `${courtInfoWidth}px ${gridWidth}px` }}
|
|
||||||
>
|
|
||||||
<BookingCourtInfo schedule={schedule} />
|
|
||||||
<div className="relative bg-background/20 py-4">
|
|
||||||
<div className="absolute inset-y-0 left-0 right-0 opacity-70">
|
|
||||||
{timelineMarks.map((mark) => (
|
|
||||||
<span
|
|
||||||
key={mark.hour}
|
|
||||||
className="absolute top-0 h-full border-l first:border-l-0"
|
|
||||||
style={{ left: `${mark.left}%` }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative h-16">
|
|
||||||
{schedule.segments.map((segment) => (
|
|
||||||
<BookingSlotBlock
|
|
||||||
key={segment.id}
|
|
||||||
segment={segment}
|
|
||||||
schedule={schedule}
|
|
||||||
rangeStart={rangeStart}
|
|
||||||
totalMinutes={totalMinutes}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BookingCourtInfo({ schedule }: { schedule: BookingCourtSchedule }) {
|
|
||||||
return (
|
|
||||||
<div className="sticky left-0 z-10 flex items-center gap-3 border-r bg-card/95 px-4 py-4 shadow-[8px_0_18px_-18px_oklch(0_0_0/0.45)]">
|
|
||||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-lg border border-primary/40 bg-primary/15 text-primary">
|
|
||||||
<Dumbbell className="size-6" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<h2 className="truncate text-base font-semibold">{schedule.court.name}</h2>
|
|
||||||
<p className="truncate text-sm text-muted-foreground">{schedule.court.sport.name}</p>
|
|
||||||
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<span className="size-2 rounded-full bg-primary" />
|
|
||||||
{schedule.metrics.free}
|
|
||||||
</span>
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<span className="size-2 rounded-full bg-reserved" />
|
|
||||||
{schedule.metrics.reserved}
|
|
||||||
</span>
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<span className="size-2 rounded-full bg-maintenance" />
|
|
||||||
{schedule.metrics.maintenance}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BookingSlotBlockProps {
|
|
||||||
segment: BookingTimelineSegment;
|
|
||||||
schedule: BookingCourtSchedule;
|
|
||||||
rangeStart: number;
|
|
||||||
totalMinutes: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function BookingSlotBlock({ segment, schedule, rangeStart, totalMinutes }: BookingSlotBlockProps) {
|
|
||||||
const { openCreateBooking, updateBookingStatus, openBookingTools } = useBooking();
|
|
||||||
const left = ((segment.startMinutes - rangeStart) / totalMinutes) * 100;
|
|
||||||
const width = ((segment.endMinutes - segment.startMinutes) / totalMinutes) * 100;
|
|
||||||
const isFree = segment.status === 'free';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
'absolute top-0 flex h-16 min-w-0 flex-col justify-center overflow-hidden rounded-md border px-2 text-left text-xs shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md focus-visible:ring-2 focus-visible:ring-ring',
|
|
||||||
segment.status === 'free' &&
|
|
||||||
'border-primary/70 bg-primary/20 text-primary hover:bg-primary/25',
|
|
||||||
segment.status === 'reserved' &&
|
|
||||||
'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' &&
|
|
||||||
'border-reserved/70 bg-reserved/25 dark:text-reserved-foreground text-gray-600 line-through',
|
|
||||||
segment.booking?.status === 'NOSHOW' &&
|
|
||||||
'border-amber-500/60 bg-amber-500/50 text-amber-950 dark:text-amber-200'
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
left: `calc(${left}% + 4px)`,
|
|
||||||
width: `calc(${width}% - 8px)`,
|
|
||||||
}}
|
|
||||||
onClick={(event) => {
|
|
||||||
if (isFree) {
|
|
||||||
openCreateBooking({
|
|
||||||
courtId: schedule.court.id,
|
|
||||||
startTime: segment.startTime,
|
|
||||||
sportId: schedule.court.sportId,
|
|
||||||
});
|
|
||||||
} else if (segment.booking?.status === 'NOSHOW') {
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
if (event.shiftKey && segment.booking?.status === 'CONFIRMED') {
|
|
||||||
updateBookingStatus(segment.booking.id, 'COMPLETED');
|
|
||||||
} else {
|
|
||||||
openBookingTools(segment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
title={
|
|
||||||
segment.booking
|
|
||||||
? `${segment.booking.customerName} · ${segment.startTime} - ${segment.endTime}`
|
|
||||||
: `${segment.startTime} - ${segment.endTime}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isFree ? (
|
|
||||||
<span className="flex items-center justify-center gap-1 text-sm font-medium">
|
|
||||||
<Plus className="size-4" />
|
|
||||||
<span className="hidden sm:inline">Reservar</span>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="truncate text-sm font-medium">
|
|
||||||
{segment.startTime} - {segment.endTime}
|
|
||||||
</span>
|
|
||||||
{segment.booking?.status === 'NOSHOW' && (
|
|
||||||
<span title="No Show - El cliente no se presentó">
|
|
||||||
<UserX className="size-3 shrink-0" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="mt-1 flex items-center gap-1 truncate opacity-90">
|
|
||||||
<Users className="size-3" />
|
|
||||||
{segment.booking?.customerName ?? 'Mantenimiento'}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CurrentTimeIndicatorProps {
|
|
||||||
time: string;
|
|
||||||
courtInfoWidth: number;
|
|
||||||
gridWidth: number;
|
|
||||||
rangeStart: number;
|
|
||||||
totalMinutes: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CurrentTimeIndicator({
|
|
||||||
time,
|
|
||||||
courtInfoWidth,
|
|
||||||
gridWidth,
|
|
||||||
rangeStart,
|
|
||||||
totalMinutes,
|
|
||||||
}: CurrentTimeIndicatorProps) {
|
|
||||||
const left = courtInfoWidth + ((timeToMinutes(time) - rangeStart) / totalMinutes) * gridWidth;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="pointer-events-none absolute bottom-0 top-12 z-[5]" style={{ left }}>
|
|
||||||
<div className="absolute left-0 top-0 -translate-x-1/2 -translate-y-1/2 rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground shadow-sm">
|
|
||||||
{time}
|
|
||||||
</div>
|
|
||||||
<div className="absolute bottom-0 left-0 top-0 w-px bg-primary" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { DatePicker } from '@/components/ui/date-picker';
|
|
||||||
import { Field, FieldLabel } from '@/components/ui/field';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { ChevronLeft, ChevronRight, Clock, SlidersHorizontal } from 'lucide-react';
|
|
||||||
import { useBooking } from '../booking-provider';
|
|
||||||
import type { BookingStatusFilter } from '../booking.types';
|
|
||||||
import { fromIsoDateLocal, toIsoDateLocal } from '../lib/booking-time';
|
|
||||||
|
|
||||||
const timeRangeOptions = [
|
|
||||||
{ label: '08:00 - 22:00', start: '08:00', end: '22:00' },
|
|
||||||
{ label: '06:00 - 00:00', start: '06:00', end: '23:59' },
|
|
||||||
{ label: '08:00 - 14:00', start: '08:00', end: '14:00' },
|
|
||||||
{ label: '14:00 - 22:00', start: '14:00', end: '22:00' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const statusOptions: Array<{ value: BookingStatusFilter; label: string }> = [
|
|
||||||
{ value: 'all', label: 'Todos' },
|
|
||||||
{ value: 'free', label: 'Libre' },
|
|
||||||
{ value: 'reserved', label: 'Reservado' },
|
|
||||||
{ value: 'maintenance', label: 'Mantenimiento' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function BookingToolbar() {
|
|
||||||
const {
|
|
||||||
courts,
|
|
||||||
selectedDate,
|
|
||||||
selectedSportId,
|
|
||||||
selectedStatus,
|
|
||||||
visibleTimeRange,
|
|
||||||
setSelectedDate,
|
|
||||||
moveSelectedDate,
|
|
||||||
setSelectedSportId,
|
|
||||||
setSelectedStatus,
|
|
||||||
setVisibleTimeRange,
|
|
||||||
} = useBooking();
|
|
||||||
|
|
||||||
const sports = [...new Map(courts.map((court) => [court.sport.id, court.sport])).values()];
|
|
||||||
const activeRangeValue = `${visibleTimeRange.start}-${visibleTimeRange.end}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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">
|
|
||||||
<Field>
|
|
||||||
<FieldLabel>Deporte</FieldLabel>
|
|
||||||
<Select value={selectedSportId} onValueChange={setSelectedSportId}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">Todos</SelectItem>
|
|
||||||
{sports.map((sport) => (
|
|
||||||
<SelectItem key={sport.id} value={sport.id}>
|
|
||||||
{sport.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field>
|
|
||||||
<FieldLabel>Estado</FieldLabel>
|
|
||||||
<Select
|
|
||||||
value={selectedStatus}
|
|
||||||
onValueChange={(value) => setSelectedStatus(value as BookingStatusFilter)}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{statusOptions.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field>
|
|
||||||
<FieldLabel>Fecha</FieldLabel>
|
|
||||||
<div className="grid grid-cols-[auto_1fr_auto] gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => moveSelectedDate(-1)}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<DatePicker
|
|
||||||
value={fromIsoDateLocal(selectedDate)}
|
|
||||||
onChange={(date) =>
|
|
||||||
setSelectedDate(date ? toIsoDateLocal(date) : toIsoDateLocal(new Date()))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Button type="button" variant="outline" size="icon" onClick={() => moveSelectedDate(1)}>
|
|
||||||
<ChevronRight className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field>
|
|
||||||
<FieldLabel>Hora</FieldLabel>
|
|
||||||
<Select
|
|
||||||
value={activeRangeValue}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
const option = timeRangeOptions.find((item) => `${item.start}-${item.end}` === value);
|
|
||||||
if (option) {
|
|
||||||
setVisibleTimeRange({ start: option.start, end: option.end });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<Clock className="mr-2 size-4 text-muted-foreground" />
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{timeRangeOptions.map((option) => (
|
|
||||||
<SelectItem
|
|
||||||
key={`${option.start}-${option.end}`}
|
|
||||||
value={`${option.start}-${option.end}`}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Button variant="outline" className="xl:self-end">
|
|
||||||
<SlidersHorizontal className="size-4" />
|
|
||||||
Filtros avanzados
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||