19 Commits

Author SHA1 Message Date
Jose Selesan
c278c78e3d feat(booking): replace filter toggle with dedicated list view for today's bookings 2026-06-08 23:21:35 -03:00
Jose Selesan
d767ac3ac7 fix(booking): prevent past time slots from being selectable for today 2026-06-08 22:50:56 -03:00
Jose Selesan
8c3ca0e7e1 Fix booking cancelation 2026-06-08 16:53:28 -03:00
Jose Selesan
72f273354e fix(public-booking): enhance focus behavior for customer name input on slot selection 2026-06-08 16:47:13 -03:00
Jose Selesan
ef2ded9d64 Fix input focus on time select 2026-06-08 16:41:24 -03:00
Jose Selesan
4c7b825129 refactor(public-booking): remove MobileBottomNav component and adjust layout styles 2026-06-08 16:34:36 -03:00
339e6a70d7 Merge pull request 'refactor(public-booking): remove CourtDetails component and simplify canGoBack logic' (#15) from feat/remove-attributes into development
Reviewed-on: #15
2026-06-08 19:16:04 +00:00
Jose Selesan
c70541b4f5 refactor(public-booking): remove CourtDetails component and simplify canGoBack logic 2026-06-08 16:14:27 -03:00
89673c3844 Merge pull request 'feat/court-maintenace' (#14) from feat/court-maintenace into development
Reviewed-on: #14
2026-06-08 18:47:02 +00:00
Jose Selesan
630dedb507 feat(booking): add validation to prevent past bookings and implement slot validation logic 2026-06-08 15:46:17 -03:00
Jose Selesan
49d2a13672 feat(booking): allow empty customer email in booking forms and schemas 2026-06-08 15:34:53 -03:00
Jose Selesan
b2f9a14b87 refactor(booking): remove maintenance-related metrics and UI components 2026-06-08 15:28:49 -03:00
Jose Selesan
b48dd4f15d feat(public-booking): update logo and improve layout in confirmation page 2026-06-05 17:06:24 -03:00
Jose Selesan
fee0be7e1f feat(booking-ui): add date navigation buttons in MobileFilters component 2026-06-05 15:52:56 -03:00
Jose Selesan
721ebaa775 fix(booking-ui): adjust height units in booking dialog styling 2026-06-05 15:24:02 -03:00
2ccff7dcaa Merge pull request 'feat(booking-ui): enhance mobile booking dialog styling and layout' (#11) from codex/fix-mobile-booking-dlg into development
Reviewed-on: #11
2026-06-05 17:37:52 +00:00
Jose Selesan
4b1b06f00f feat(booking-ui): enhance mobile booking dialog styling and layout 2026-06-05 14:37:27 -03:00
Jose Selesan
af687fe2d8 refactor(tests): simplify prisma mock imports and ensure sport mock is included 2026-06-05 11:36:21 -03:00
9875f22d5a Merge pull request 'feat/improve-booking-ui' (#10) from feat/improve-booking-ui into development
Reviewed-on: #10
2026-06-05 14:25:06 +00:00
22 changed files with 498 additions and 237 deletions

View File

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

View File

@@ -1,6 +1,7 @@
import { randomInt } from 'node:crypto';
import { CourtBookingStatus } from '@/generated/prisma/enums';
import { db } from '@/lib/prisma';
import { isSlotInPast } from '@/lib/slot-validator';
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service';
import type { DayOfWeek } from '@repo/api-contract';
import type {
@@ -361,6 +362,10 @@ export async function createAdminBooking(
);
}
if (isSlotInPast(bookingDate, input.startTime, court.slotDurationMinutes)) {
throw new AdminBookingServiceError('No se pueden crear reservas en el pasado.', 400);
}
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
const booking = await db.$transaction(async (tx) => {
@@ -424,7 +429,7 @@ export async function createAdminBooking(
endTime: selectedSlot.endTime,
customerName: input.customerName.trim(),
customerPhone: input.customerPhone.trim(),
customerEmail: input.customerEmail.trim(),
customerEmail: input.customerEmail?.trim() ?? '',
status: 'CONFIRMED',
},
include: {

View File

@@ -1,5 +1,6 @@
import { randomInt } from 'node:crypto';
import { db } from '@/lib/prisma';
import { isSlotInPast } from '@/lib/slot-validator';
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service';
import type {
CancelPublicBookingInput,
@@ -574,6 +575,10 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
);
}
if (isSlotInPast(bookingDate, input.startTime, selectedCourt.slotDurationMinutes)) {
throw new PublicBookingServiceError('No se pueden crear reservas en el pasado.', 400);
}
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
const booking = await db.$transaction(async (tx) => {
@@ -765,7 +770,7 @@ export async function cancelPublicBooking(complexSlug: string, input: CancelPubl
}
if (booking.customerPhone !== input.customerPhone.trim()) {
throw new PublicBookingServiceError('Los datos ingresados no coinciden con la reserva.', 403);
throw new PublicBookingServiceError('Los datos ingresados no coinciden con la reserva.', 400);
}
const date = formatIsoDate(booking.bookingDate);

View File

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

View File

@@ -1,20 +1,6 @@
import { beforeEach, expect, mock, test } from 'bun:test';
import { beforeEach, expect, 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');
import { prismaMock, uuidV7Mock } from '../support/prisma.mock';
const { createSport, getSportById, listSports, updateSport } = await import(
'@/modules/sport/services/sport.service'

View File

@@ -21,6 +21,7 @@ export const dbMock = {
complex: prismaMock.complex,
user: prismaMock.user,
complexInvitation: prismaMock.complexInvitation,
sport: prismaMock.sport,
$transaction: transactionMock,
};

View File

@@ -35,7 +35,7 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"flex w-full items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}

View File

@@ -147,6 +147,9 @@ function buildSegmentsForCourt(
const segments: BookingTimelineSegment[] = [];
const slotDuration = court.slotDurationMinutes;
const nowMinutes = timeToMinutes(getNowTime());
const isToday = isTodayIso(selectedDate);
const availabilityRanges = court.availability
.filter((range) => range.dayOfWeek === dayOfWeek)
.map((range) => ({
@@ -181,6 +184,9 @@ function buildSegmentsForCourt(
slotStart += slotDuration
) {
const slotEnd = slotStart + slotDuration;
if (isToday && slotEnd <= nowMinutes) continue;
const isBooked = courtBookings.some((booking) =>
overlapsBooking(slotStart, slotEnd, booking)
);
@@ -210,20 +216,14 @@ function buildSegmentsForCourt(
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;
const totalSegments = freeSlots + reservedSlots || 1;
return {
totalReservations: reservedSlots + maintenanceSlots + freeSlots,
totalReservations: freeSlots + reservedSlots,
freeSlots,
reservedSlots,
maintenanceSlots,
freePercent: Math.round((freeSlots / totalSegments) * 100),
reservedPercent: Math.round((reservedSlots / totalSegments) * 100),
maintenancePercent: Math.round((maintenanceSlots / totalSegments) * 100),
};
}
@@ -275,7 +275,6 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
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 };

View File

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

View File

@@ -1,8 +1,8 @@
import type { AdminBooking, Court } from '@repo/api-contract';
export type BookingViewMode = 'panel' | 'status';
export type BookingStatusFilter = 'all' | 'free' | 'reserved' | 'maintenance';
export type BookingSegmentStatus = 'free' | 'reserved' | 'maintenance';
export type BookingViewMode = 'panel' | 'status' | 'list';
export type BookingStatusFilter = 'all' | 'free' | 'reserved';
export type BookingSegmentStatus = 'free' | 'reserved';
export interface BookingTimeRange {
start: string;
@@ -13,16 +13,13 @@ 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 {

View File

@@ -26,6 +26,8 @@ import { useBooking } from '../booking-provider';
import {
fromIsoDateLocal,
getDayOfWeek,
getNowTime,
isTodayIso,
minutesToTime,
timeToMinutes,
toIsoDateLocal,
@@ -42,7 +44,7 @@ const bookingFormSchema = z.object({
.trim()
.min(6, 'Ingresa un telefono válido.')
.max(30, 'El telefono no puede superar los 30 caracteres.'),
customerEmail: z.string().email('Ingresá un email válido.'),
customerEmail: z.string().email('Ingresá un email válido.').or(z.literal('')),
});
type BookingForm = z.infer<typeof bookingFormSchema>;
@@ -128,6 +130,9 @@ export function BookingCreateDialog() {
minute += selectedCourt.slotDurationMinutes
) {
const slotEnd = minute + selectedCourt.slotDurationMinutes;
if (isTodayIso(date) && minute <= timeToMinutes(getNowTime())) continue;
const isBooked = courtBookings.some((booking) => {
const bookingStart = timeToMinutes(booking.startTime);
const bookingEnd = timeToMinutes(booking.endTime);
@@ -166,17 +171,21 @@ export function BookingCreateDialog() {
onOpenChange={(open) => !open && closeCreateBooking()}
>
<ResponsiveDialogContent
className="data-[variant=dialog]:max-w-lg"
className="group/create-booking data-[variant=dialog]:max-w-lg data-[variant=drawer]:!h-[92svh] data-[variant=drawer]:!max-h-[92svh] data-[variant=drawer]:overflow-hidden data-[variant=drawer]:px-0 data-[variant=drawer]:pb-0"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<ResponsiveDialogHeader>
<ResponsiveDialogHeader className="group-data-[variant=drawer]/create-booking:shrink-0 group-data-[variant=drawer]/create-booking:px-4 group-data-[variant=drawer]/create-booking:pb-3">
<ResponsiveDialogTitle>Nueva reserva</ResponsiveDialogTitle>
<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)}>
<form
id="booking-create-form"
className="grid gap-4 group-data-[variant=drawer]/create-booking:min-h-0 group-data-[variant=drawer]/create-booking:flex-1 group-data-[variant=drawer]/create-booking:overflow-y-auto group-data-[variant=drawer]/create-booking:px-4 group-data-[variant=drawer]/create-booking:pb-4"
onSubmit={handleSubmit(onSubmit)}
>
<div className="grid gap-3 md:grid-cols-2">
<Field>
<FieldLabel>Fecha</FieldLabel>
@@ -294,7 +303,7 @@ export function BookingCreateDialog() {
{createBookingError && <p className="text-sm text-destructive">{createBookingError}</p>}
</form>
<ResponsiveDialogFooter>
<ResponsiveDialogFooter className="group-data-[variant=drawer]/create-booking:shrink-0 group-data-[variant=drawer]/create-booking:border-t group-data-[variant=drawer]/create-booking:border-border/70 group-data-[variant=drawer]/create-booking:bg-popover/95 group-data-[variant=drawer]/create-booking:px-4 group-data-[variant=drawer]/create-booking:pt-3 group-data-[variant=drawer]/create-booking:pb-[calc(12px+env(safe-area-inset-bottom))]">
<ResponsiveDialogClose asChild>
<Button variant="outline">Cancelar</Button>
</ResponsiveDialogClose>

View File

@@ -1,4 +1,4 @@
import { CalendarDays, Download, Drill, ShieldCheck, UsersRound } from 'lucide-react';
import { CalendarDays, Download, ShieldCheck, UsersRound } from 'lucide-react';
import { useBooking } from '../booking-provider';
import { formatBookingDate, toIsoDateLocal } from '../lib/booking-time';
@@ -14,7 +14,7 @@ export function BookingDaySummary() {
</p>
</div>
<div className="grid gap-3 md:grid-cols-4">
<div className="grid gap-3 md:grid-cols-3">
<SummaryCard
label="Total de bloques"
value={summary.totalReservations}
@@ -35,13 +35,6 @@ export function BookingDaySummary() {
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>
);
@@ -73,11 +66,8 @@ function SummaryCard({ label, value, detail, icon: Icon, className }: SummaryCar
}
export function BookingQuickActions() {
const { selectedDate, selectedStatus, setSelectedDate, setSelectedStatus, exportDayReport } =
useBooking();
const { setSelectedDate, setViewMode, 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">
@@ -87,22 +77,12 @@ export function BookingQuickActions() {
type="button"
onClick={() => {
setSelectedDate(todayIso);
setSelectedStatus(isViewingTodayReservations ? 'all' : 'reserved');
setViewMode('list');
}}
aria-pressed={isViewingTodayReservations}
className="flex h-10 items-center gap-3 rounded-md border bg-background/45 px-3 text-left text-sm transition-colors hover:bg-muted aria-pressed:border-reserved/50 aria-pressed:bg-reserved/10"
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"
>
<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'}
Ver reservas de hoy
</button>
<button
type="button"

View File

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

View File

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

View File

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

View File

@@ -215,10 +215,6 @@ function BookingCourtInfo({ schedule }: { schedule: BookingCourtSchedule }) {
<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>
@@ -247,8 +243,7 @@ function BookingSlotBlock({ segment, schedule, rangeStart, totalMinutes }: Booki
'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' &&
@@ -300,7 +295,7 @@ function BookingSlotBlock({ segment, schedule, rangeStart, totalMinutes }: Booki
</div>
<span className="mt-1 flex items-center gap-1 truncate opacity-90">
<Users className="size-3" />
{segment.booking?.customerName ?? 'Mantenimiento'}
{segment.booking?.customerName ?? 'Sin información'}
</span>
</>
)}

View File

@@ -8,7 +8,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ChevronLeft, ChevronRight, Clock, SlidersHorizontal } from 'lucide-react';
import { ChevronLeft, ChevronRight, Clock } from 'lucide-react';
import { useBooking } from '../booking-provider';
import type { BookingStatusFilter } from '../booking.types';
import { fromIsoDateLocal, toIsoDateLocal } from '../lib/booking-time';
@@ -24,7 +24,6 @@ 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() {
@@ -46,7 +45,7 @@ export function BookingToolbar() {
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">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-[1fr_1fr_1.6fr_1.1fr] xl:items-end">
<Field>
<FieldLabel>Deporte</FieldLabel>
<Select value={selectedSportId} onValueChange={setSelectedSportId}>
@@ -133,11 +132,6 @@ export function BookingToolbar() {
</SelectContent>
</Select>
</Field>
<Button variant="outline" className="xl:self-end">
<SlidersHorizontal className="size-4" />
Filtros avanzados
</Button>
</div>
</section>
);

View File

@@ -59,13 +59,14 @@ export function ShareWhatsappButton({ confirmation }: ShareWhatsappButtonProps)
<Button
type="button"
variant="outline"
className="h-12 w-full rounded-xl border-slate-200 bg-white text-sm font-semibold text-slate-900 hover:bg-slate-50 sm:text-base"
className="h-12 w-full overflow-hidden rounded-xl border-slate-200 bg-white px-0 text-sm font-semibold text-slate-900 hover:bg-slate-50 sm:px-4 sm:text-base"
asChild
>
<a
href={whatsappUrl}
target="_blank"
rel="noopener noreferrer"
aria-label="Compartir por WhatsApp"
title="Compartir por WhatsApp"
>
<svg
@@ -76,7 +77,7 @@ export function ShareWhatsappButton({ confirmation }: ShareWhatsappButtonProps)
>
<path d={siWhatsapp.path} fill="currentColor" />
</svg>
<span className="sm:hidden">Compartir por WhatsApp</span>
<span className="hidden sm:inline">Compartir por WhatsApp</span>
</a>
</Button>
);

View File

@@ -1,4 +1,4 @@
import PlayzerIcon from '@/assets/playzer-favicon-512-transparent.png';
import PlayzerLogo from '@/assets/playzer-logo-transparent.svg';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
@@ -162,9 +162,8 @@ export function PublicBookingConfirmationPage({
{displayData.complexName}
</h1>
</div>
<div className="flex items-center gap-2 text-slate-600">
<img src={PlayzerIcon} alt="Playzer" className="size-8" />
<span className="text-lg font-bold tracking-tight">Playzer</span>
<div className="flex items-center">
<img src={PlayzerLogo} alt="Playzer" className="h-9 w-auto sm:h-10" />
</div>
</div>
@@ -221,7 +220,7 @@ export function PublicBookingConfirmationPage({
cancelación.
</div>
) : (
<div className="grid grid-cols-3 gap-2 sm:gap-3">
<div className="grid grid-cols-[48px_minmax(0,1fr)_minmax(0,1fr)] gap-2 sm:grid-cols-3 sm:gap-3">
<ShareWhatsappButton confirmation={confirmationQuery.data!} />
<Button
type="button"

View File

@@ -26,7 +26,6 @@ import {
Check,
ChevronLeft,
ChevronRight,
Clock3,
Dumbbell,
Lock,
MapPin,
@@ -34,12 +33,10 @@ import {
Moon,
Share2,
ShieldCheck,
Shirt,
Sparkles,
Sun,
Trophy,
Users,
Wrench,
Zap,
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
@@ -376,7 +373,6 @@ function Legend() {
<div className="flex flex-wrap gap-4 text-xs text-white/70">
<LegendDot color="bg-emerald-500" label="Libre" />
<LegendDot color="bg-blue-500" label="Reservado" />
<LegendDot color="bg-red-500" label="Mantenimiento" />
</div>
);
}
@@ -416,7 +412,7 @@ function PublicBookingPageChrome({
if (mobile) {
return (
<main className={`${themeClass} min-h-screen bg-[#050d14] text-white`}>
<div className="public-booking-frame mx-auto min-h-screen max-w-[430px] bg-[radial-gradient(circle_at_50%_0%,rgba(17,185,129,0.16),transparent_34%),linear-gradient(180deg,#07131d_0%,#071018_47%,#050b11_100%)] px-4 pb-24 pt-7 shadow-2xl shadow-black">
<div className="public-booking-frame mx-auto min-h-screen max-w-[430px] bg-[radial-gradient(circle_at_50%_0%,rgba(17,185,129,0.16),transparent_34%),linear-gradient(180deg,#07131d_0%,#071018_47%,#050b11_100%)] px-4 pb-0 pt-7 shadow-2xl shadow-black">
<header className="flex items-center justify-between">
<PlayzerBrand compact />
<PublicBookingThemeToggle />
@@ -443,8 +439,6 @@ function PublicBookingPageChrome({
</div>
{children}
<MobileBottomNav />
</div>
</main>
);
@@ -555,8 +549,7 @@ function PublicBookingDesktop(props: BookingShellProps) {
const currentStep = getStep(selectedSlot, props.form.formState.isValid);
const completedSteps = getCompletedSteps(selectedSlot, props.form.formState.isValid);
const selectedDay = dayOptions.find((option) => option.value === selectedDate);
const currentDayIndex = dayOptions.findIndex((option) => option.value === selectedDate);
const canGoBackButton = canGoBack || currentDayIndex > 0;
const canGoBackButton = canGoBack;
return (
<PublicBookingPageChrome
@@ -685,10 +678,7 @@ function PublicBookingDesktop(props: BookingShellProps) {
</div>
</Panel>
<div className="grid grid-cols-[minmax(0,1fr)_minmax(340px,0.95fr)] gap-4">
<CourtDetails court={selectedCourt} />
<SelectedSlotCard {...props} />
</div>
<SelectedSlotCard {...props} />
</>
)}
</section>
@@ -1134,19 +1124,13 @@ function BookingTimeline({
const left = ((end - range.start) / total) * 100;
const width = ((Math.min(nextStart, range.end) - end) / total) * 100;
const maintenance = index % 2 === 1;
return (
<div
key={`${court?.courtId}-blocked-${slot.startTime}`}
className={`absolute top-7 flex h-12 min-w-[44px] items-center justify-center rounded border ${
maintenance
? 'border-red-500/80 bg-red-500/28 text-red-100'
: 'border-blue-500/70 bg-blue-500/24 text-blue-100'
}`}
className="absolute top-7 flex h-12 min-w-[44px] items-center justify-center rounded border border-blue-500/70 bg-blue-500/24 text-blue-100"
style={{ left: `${left}%`, width: `${Math.max(width - 0.8, 4.5)}%` }}
>
{maintenance ? <Wrench className="size-4" /> : <Lock className="size-4" />}
<Lock className="size-4" />
</div>
);
})}
@@ -1169,41 +1153,6 @@ function BookingTimeline({
);
}
function CourtDetails({ court }: { court?: PublicAvailabilityCourt }) {
return (
<Panel className="p-6">
<div className="flex items-center gap-6">
<div className="hidden size-24 items-center justify-center rounded border border-white/16 text-white/45 md:flex">
<MapPin className="size-14" />
</div>
<div>
<h3 className="text-lg font-semibold">
{court?.courtName ? `${court.courtName} de ${court.sport.name}` : 'Cancha'}
</h3>
<div className="mt-4 space-y-2 text-sm text-white/62">
<p className="flex items-center gap-2">
<Users className="size-4 text-emerald-400" />
Superficie: Césped sintético
</p>
<p className="flex items-center gap-2">
<Dumbbell className="size-4 text-emerald-400" />
Turnos de {court?.slotDurationMinutes ?? 60} minutos
</p>
<p className="flex items-center gap-2">
<Sparkles className="size-4 text-emerald-400" />
Iluminación LED
</p>
<p className="flex items-center gap-2">
<Shirt className="size-4 text-emerald-400" />
Vestuarios disponibles
</p>
</div>
</div>
</div>
</Panel>
);
}
function SelectedSlotCard(props: BookingShellProps & { compact?: boolean }) {
const {
selectedSlot,
@@ -1225,9 +1174,11 @@ function SelectedSlotCard(props: BookingShellProps & { compact?: boolean }) {
useEffect(() => {
if (!selectedSlot) return;
window.requestAnimationFrame(() => {
customerNameInputRef.current?.focus();
});
const input = customerNameInputRef.current;
if (!input) return;
input.focus();
input.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, [selectedSlot]);
return (
@@ -1330,40 +1281,6 @@ function SelectedSlotCard(props: BookingShellProps & { compact?: boolean }) {
);
}
function MobileBottomNav() {
const items = [
{ label: 'Reservar', icon: CalendarDays, active: true },
{ label: 'Canchas', icon: Building2 },
{ label: 'Cómo funciona', icon: Clock3 },
{ label: 'Contacto', icon: Users },
];
return (
<nav className="fixed inset-x-0 bottom-0 z-40 mx-auto max-w-[430px] border-t border-emerald-500/30 bg-[#061019]/94 px-4 py-3 backdrop-blur">
<div className="grid grid-cols-4 gap-1">
{items.map((item) => {
const Icon = item.icon;
return (
<a
key={item.label}
href={
item.label === 'Reservar' ? '#' : `#${item.label.toLowerCase().replace(' ', '-')}`
}
className={`flex flex-col items-center gap-1 text-[11px] ${
item.active ? 'text-emerald-400' : 'text-white/58'
}`}
>
<Icon className="size-5" />
{item.label}
</a>
);
})}
</div>
</nav>
);
}
export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
const navigate = useNavigate();
const isMobile = useIsMobile();
@@ -1587,7 +1504,8 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
visibleCourts.length,
]);
const canGoBack = windowStartOffset > 0;
const currentDayIndex = dayOptions.findIndex((option) => option.value === selectedDate);
const canGoBack = windowStartOffset > 0 || currentDayIndex > 0;
const selectDate = (date: string) => {
setSelectedDate(date);

View File

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

View File

@@ -56,7 +56,8 @@ export const createAdminBookingSchema = z.object({
.max(30, 'El telefono no puede superar los 30 caracteres.'),
customerEmail: z
.string()
.email('El email ingresado no es valido.'),
.email('El email ingresado no es valido.')
.or(z.literal('')),
})
export const updateAdminBookingStatusSchema = z.object({