9 Commits

8 changed files with 304 additions and 108 deletions

View File

@@ -770,7 +770,7 @@ export async function cancelPublicBooking(complexSlug: string, input: CancelPubl
} }
if (booking.customerPhone !== input.customerPhone.trim()) { 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); const date = formatIsoDate(booking.bookingDate);

View File

@@ -147,6 +147,9 @@ function buildSegmentsForCourt(
const segments: BookingTimelineSegment[] = []; const segments: BookingTimelineSegment[] = [];
const slotDuration = court.slotDurationMinutes; const slotDuration = court.slotDurationMinutes;
const nowMinutes = timeToMinutes(getNowTime());
const isToday = isTodayIso(selectedDate);
const availabilityRanges = court.availability const availabilityRanges = court.availability
.filter((range) => range.dayOfWeek === dayOfWeek) .filter((range) => range.dayOfWeek === dayOfWeek)
.map((range) => ({ .map((range) => ({
@@ -181,6 +184,9 @@ function buildSegmentsForCourt(
slotStart += slotDuration slotStart += slotDuration
) { ) {
const slotEnd = slotStart + slotDuration; const slotEnd = slotStart + slotDuration;
if (isToday && slotEnd <= nowMinutes) continue;
const isBooked = courtBookings.some((booking) => const isBooked = courtBookings.some((booking) =>
overlapsBooking(slotStart, slotEnd, booking) overlapsBooking(slotStart, slotEnd, booking)
); );

View File

@@ -3,10 +3,11 @@ import { authClient } from '@/lib/api-client';
import { useAuth } from '@/lib/auth'; import { useAuth } from '@/lib/auth';
import type { ComplexWithRole } from '@repo/api-contract'; import type { ComplexWithRole } from '@repo/api-contract';
import { useState } from 'react'; import { useState } from 'react';
import { BookingProvider } from './booking-provider'; import { BookingProvider, useBooking } from './booking-provider';
import { BookingCreateDialog } from './components/booking-create-dialog'; import { BookingCreateDialog } from './components/booking-create-dialog';
import { BookingDaySummary, BookingQuickActions } from './components/booking-day-summary'; import { BookingDaySummary, BookingQuickActions } from './components/booking-day-summary';
import { BookingHeader } from './components/booking-header'; import { BookingHeader } from './components/booking-header';
import { BookingListView } from './components/booking-list-view';
import { BookingMobile } from './components/booking-mobile'; import { BookingMobile } from './components/booking-mobile';
import { BookingTimeline } from './components/booking-timeline'; import { BookingTimeline } from './components/booking-timeline';
import { BookingToolbar } from './components/booking-toolbar'; import { BookingToolbar } from './components/booking-toolbar';
@@ -17,7 +18,6 @@ interface BookingProps {
} }
export function Booking({ complex }: BookingProps) { export function Booking({ complex }: BookingProps) {
const isMobile = useIsMobile();
const { user } = useAuth(); const { user } = useAuth();
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [emailSent, setEmailSent] = useState(false); const [emailSent, setEmailSent] = useState(false);
@@ -56,21 +56,34 @@ export function Booking({ complex }: BookingProps) {
</button> </button>
</div> </div>
)} )}
{isMobile ? ( <BookingInner />
<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 /> <BookingCreateDialog />
<BookingToolsDialog /> <BookingToolsDialog />
</BookingProvider> </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,6 +1,6 @@
import type { AdminBooking, Court } from '@repo/api-contract'; import type { AdminBooking, Court } from '@repo/api-contract';
export type BookingViewMode = 'panel' | 'status'; export type BookingViewMode = 'panel' | 'status' | 'list';
export type BookingStatusFilter = 'all' | 'free' | 'reserved'; export type BookingStatusFilter = 'all' | 'free' | 'reserved';
export type BookingSegmentStatus = 'free' | 'reserved'; export type BookingSegmentStatus = 'free' | 'reserved';

View File

@@ -26,6 +26,8 @@ import { useBooking } from '../booking-provider';
import { import {
fromIsoDateLocal, fromIsoDateLocal,
getDayOfWeek, getDayOfWeek,
getNowTime,
isTodayIso,
minutesToTime, minutesToTime,
timeToMinutes, timeToMinutes,
toIsoDateLocal, toIsoDateLocal,
@@ -128,6 +130,9 @@ export function BookingCreateDialog() {
minute += selectedCourt.slotDurationMinutes minute += selectedCourt.slotDurationMinutes
) { ) {
const slotEnd = minute + selectedCourt.slotDurationMinutes; const slotEnd = minute + selectedCourt.slotDurationMinutes;
if (isTodayIso(date) && minute <= timeToMinutes(getNowTime())) continue;
const isBooked = courtBookings.some((booking) => { const isBooked = courtBookings.some((booking) => {
const bookingStart = timeToMinutes(booking.startTime); const bookingStart = timeToMinutes(booking.startTime);
const bookingEnd = timeToMinutes(booking.endTime); const bookingEnd = timeToMinutes(booking.endTime);

View File

@@ -66,10 +66,8 @@ function SummaryCard({ label, value, detail, icon: Icon, className }: SummaryCar
} }
export function BookingQuickActions() { export function BookingQuickActions() {
const { selectedDate, selectedStatus, setSelectedDate, setSelectedStatus, exportDayReport } = const { setSelectedDate, setViewMode, exportDayReport } = useBooking();
useBooking();
const todayIso = toIsoDateLocal(new Date()); const todayIso = toIsoDateLocal(new Date());
const isViewingTodayReservations = selectedDate === todayIso && selectedStatus === 'reserved';
return ( return (
<section className="rounded-lg border bg-card/85 p-5 shadow-sm"> <section className="rounded-lg border bg-card/85 p-5 shadow-sm">
@@ -79,13 +77,12 @@ export function BookingQuickActions() {
type="button" type="button"
onClick={() => { onClick={() => {
setSelectedDate(todayIso); 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"
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" /> <CalendarDays className="size-4 text-muted-foreground" />
{isViewingTodayReservations ? 'Ver todos los estados' : 'Ver reservas de hoy'} Ver reservas de hoy
</button> </button>
<button <button
type="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

@@ -26,7 +26,6 @@ import {
Check, Check,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Clock3,
Dumbbell, Dumbbell,
Lock, Lock,
MapPin, MapPin,
@@ -34,7 +33,6 @@ import {
Moon, Moon,
Share2, Share2,
ShieldCheck, ShieldCheck,
Shirt,
Sparkles, Sparkles,
Sun, Sun,
Trophy, Trophy,
@@ -414,7 +412,7 @@ function PublicBookingPageChrome({
if (mobile) { if (mobile) {
return ( return (
<main className={`${themeClass} min-h-screen bg-[#050d14] text-white`}> <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"> <header className="flex items-center justify-between">
<PlayzerBrand compact /> <PlayzerBrand compact />
<PublicBookingThemeToggle /> <PublicBookingThemeToggle />
@@ -441,8 +439,6 @@ function PublicBookingPageChrome({
</div> </div>
{children} {children}
<MobileBottomNav />
</div> </div>
</main> </main>
); );
@@ -553,8 +549,7 @@ function PublicBookingDesktop(props: BookingShellProps) {
const currentStep = getStep(selectedSlot, props.form.formState.isValid); const currentStep = getStep(selectedSlot, props.form.formState.isValid);
const completedSteps = getCompletedSteps(selectedSlot, props.form.formState.isValid); const completedSteps = getCompletedSteps(selectedSlot, props.form.formState.isValid);
const selectedDay = dayOptions.find((option) => option.value === selectedDate); const selectedDay = dayOptions.find((option) => option.value === selectedDate);
const currentDayIndex = dayOptions.findIndex((option) => option.value === selectedDate); const canGoBackButton = canGoBack;
const canGoBackButton = canGoBack || currentDayIndex > 0;
return ( return (
<PublicBookingPageChrome <PublicBookingPageChrome
@@ -683,10 +678,7 @@ function PublicBookingDesktop(props: BookingShellProps) {
</div> </div>
</Panel> </Panel>
<div className="grid grid-cols-[minmax(0,1fr)_minmax(340px,0.95fr)] gap-4"> <SelectedSlotCard {...props} />
<CourtDetails court={selectedCourt} />
<SelectedSlotCard {...props} />
</div>
</> </>
)} )}
</section> </section>
@@ -1161,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 }) { function SelectedSlotCard(props: BookingShellProps & { compact?: boolean }) {
const { const {
selectedSlot, selectedSlot,
@@ -1217,9 +1174,11 @@ function SelectedSlotCard(props: BookingShellProps & { compact?: boolean }) {
useEffect(() => { useEffect(() => {
if (!selectedSlot) return; if (!selectedSlot) return;
window.requestAnimationFrame(() => { const input = customerNameInputRef.current;
customerNameInputRef.current?.focus(); if (!input) return;
});
input.focus();
input.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, [selectedSlot]); }, [selectedSlot]);
return ( return (
@@ -1322,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) { export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@@ -1579,7 +1504,8 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
visibleCourts.length, visibleCourts.length,
]); ]);
const canGoBack = windowStartOffset > 0; const currentDayIndex = dayOptions.findIndex((option) => option.value === selectedDate);
const canGoBack = windowStartOffset > 0 || currentDayIndex > 0;
const selectDate = (date: string) => { const selectDate = (date: string) => {
setSelectedDate(date); setSelectedDate(date);