Files
playzer/apps/frontend/src/features/public-booking/public-booking-page.tsx

1599 lines
53 KiB
TypeScript

import PlayzerIcon from '@/assets/playzer-favicon-512-transparent.png';
import { Button } from '@/components/ui/button';
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Stepper,
StepperIndicator,
StepperItem,
StepperList,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from '@/components/ui/stepper';
import { useIsMobile } from '@/hooks/use-mobile';
import { ApiClientError, apiClient } from '@/lib/api-client';
import { useTheme } from '@/lib/theme';
import { zodResolver } from '@hookform/resolvers/zod';
import type { PublicAvailabilityCourt, PublicBookingSport } from '@repo/api-contract';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router';
import {
ArrowRight,
Building2,
CalendarDays,
Camera,
Check,
ChevronLeft,
ChevronRight,
Clock3,
Dumbbell,
Lock,
MapPin,
MessageCircle,
Moon,
Share2,
ShieldCheck,
Shirt,
Sparkles,
Sun,
Trophy,
Users,
Wrench,
Zap,
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const DAYS_STEP = 5;
const DESKTOP_DAYS_STEP = 7;
const BUSINESS_START_MINUTES = 8 * 60;
const BUSINESS_END_MINUTES = 22 * 60;
const bookingFormSchema = z.object({
customerName: z
.string()
.trim()
.min(2, 'Ingresa tu nombre.')
.max(120, 'El nombre no puede superar los 120 caracteres.'),
customerPhone: z
.string()
.trim()
.min(6, 'Ingresá un teléfono válido.')
.max(30, 'El teléfono no puede superar los 30 caracteres.'),
});
type BookingFormValues = z.infer<typeof bookingFormSchema>;
type PublicBookingPageProps = {
complexSlug: string;
};
type SelectedSlot = {
courtId: string;
courtName: string;
sportId: string;
sportName: string;
startTime: string;
endTime: string;
};
type BookingShellProps = {
complexName?: string;
complexAddress?: string;
sports: PublicBookingSport[];
courts: PublicAvailabilityCourt[];
selectedDate: string;
selectedSportId?: string;
selectedCourtId?: string;
selectedSlot: SelectedSlot | null;
dayOptions: DayOption[];
canGoBack: boolean;
isLoading: boolean;
isError: boolean;
sportSelectionRequired?: boolean;
errorMessage?: string;
autoAdjustedDateNotice: string | null;
form: ReturnType<typeof useForm<BookingFormValues>>;
isCreating: boolean;
createError?: string;
navigationError: string | null;
onSelectDate: (date: string) => void;
onPreviousDays: () => void;
onNextDays: () => void;
onPreviousDay: () => void;
onNextDay: () => void;
onSelectSport: (sportId: string) => void;
onSelectCourt: (courtId: string) => void;
onSelectSlot: (slot: SelectedSlot) => void;
onSubmit: (values: BookingFormValues) => Promise<void>;
};
type DayOption = {
value: string;
date: Date;
weekday: string;
dayMonth: string;
shortDay: string;
monthLabel: string;
longLabel: string;
};
function addDays(baseDate: Date, amount: number) {
const next = new Date(baseDate);
next.setDate(next.getDate() + amount);
return next;
}
function toIsoDateLocal(date: Date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function toMinutes(value: string): number {
const [hours, minutes] = value.split(':').map((part) => Number(part));
return hours * 60 + minutes;
}
function toTimeLabel(minutes: number) {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
}
function formatDayLabel(date: Date): DayOption {
const weekday = new Intl.DateTimeFormat('es-AR', { weekday: 'short' })
.format(date)
.replace('.', '');
const monthLabel = new Intl.DateTimeFormat('es-AR', { month: 'short' })
.format(date)
.replace('.', '');
const longLabel = new Intl.DateTimeFormat('es-AR', {
weekday: 'long',
day: 'numeric',
month: 'long',
}).format(date);
return {
value: toIsoDateLocal(date),
date,
weekday,
dayMonth: new Intl.DateTimeFormat('es-AR', {
day: '2-digit',
month: '2-digit',
}).format(date),
shortDay: new Intl.DateTimeFormat('es-AR', { day: '2-digit' }).format(date),
monthLabel,
longLabel: longLabel.charAt(0).toUpperCase() + longLabel.slice(1),
};
}
function extractMessage(error: unknown, fallback: string) {
if (error instanceof ApiClientError) {
return error.message || fallback;
}
return fallback;
}
function getStep(selectedSlot: SelectedSlot | null, isValid: boolean) {
if (!selectedSlot) return 1;
if (!isValid) return 3;
return 4;
}
function getCompletedSteps(selectedSlot: SelectedSlot | null, isValid: boolean) {
return {
court: Boolean(selectedSlot),
time: Boolean(selectedSlot),
details: Boolean(selectedSlot && isValid),
confirm: false,
};
}
function getSelectedCourt(
courts: PublicAvailabilityCourt[],
selectedCourtId: string | undefined,
selectedSlot: SelectedSlot | null
) {
if (selectedSlot) {
return courts.find((court) => court.courtId === selectedSlot.courtId) ?? courts[0];
}
return courts.find((court) => court.courtId === selectedCourtId) ?? courts[0];
}
function getTimelineRange(court?: PublicAvailabilityCourt) {
if (!court || court.availableSlots.length === 0) {
return {
start: BUSINESS_START_MINUTES,
end: BUSINESS_END_MINUTES,
};
}
const starts = court.availableSlots.map((slot) => toMinutes(slot.startTime));
const ends = court.availableSlots.map((slot) => toMinutes(slot.endTime));
const firstStart = Math.min(...starts);
const lastEnd = Math.max(...ends);
return {
start: Math.min(BUSINESS_START_MINUTES, Math.floor(firstStart / 60) * 60),
end: Math.max(BUSINESS_END_MINUTES, Math.ceil(lastEnd / 60) * 60),
};
}
function getHourTicks(start: number, end: number, compact = false) {
const step = compact ? 120 : 60;
const ticks = [];
for (let minutes = start; minutes <= end; minutes += step) {
ticks.push(minutes);
}
return ticks;
}
function isSlotSelected(
slot: { startTime: string },
courtId: string,
selectedSlot: SelectedSlot | null
) {
return selectedSlot?.courtId === courtId && selectedSlot.startTime === slot.startTime;
}
function getSportIcon(sportName: string) {
const lower = sportName.toLowerCase();
if (lower.includes('tenis')) return Zap;
if (lower.includes('futbol') || lower.includes('fútbol')) return Dumbbell;
if (lower.includes('paddle') || lower.includes('pádel')) return Trophy;
if (lower.includes('basquet') || lower.includes('básquet')) return Trophy;
if (lower.includes('volei') || lower.includes('vóley')) return Trophy;
return Trophy;
}
function PublicBookingThemeToggle() {
const { resolvedTheme, toggleTheme } = useTheme();
const Icon = resolvedTheme === 'dark' ? Sun : Moon;
return (
<button
type="button"
onClick={toggleTheme}
className="flex size-10 items-center justify-center rounded-lg border border-white/10 bg-white/[0.06] text-white transition hover:bg-white/[0.1]"
aria-label="Cambiar tema"
>
<Icon className="size-5" />
</button>
);
}
function PlayzerBrand({ compact = false }: { compact?: boolean }) {
return (
<div className="flex items-center gap-2">
<img src={PlayzerIcon} alt="Playzer" className={compact ? 'size-8' : 'size-9'} />
<span
className={
compact
? 'text-lg font-bold tracking-normal text-cyan-300'
: 'text-2xl font-bold tracking-normal text-white'
}
>
Playzer
</span>
</div>
);
}
function ProgressSteps({
currentStep,
completedSteps,
mobile = false,
}: {
currentStep: number;
completedSteps: Record<string, boolean>;
mobile?: boolean;
}) {
const steps = [
{ value: 'court', label: 'Elegí cancha' },
{ value: 'time', label: 'Seleccioná horario' },
{ value: 'details', label: 'Tus datos' },
{ value: 'confirm', label: 'Confirmación' },
];
const activeStep = steps[Math.max(0, Math.min(currentStep - 1, steps.length - 1))]?.value;
return (
<Stepper
value={activeStep}
nonInteractive
className={mobile ? 'mx-auto w-full max-w-[240px]' : 'max-w-[760px]'}
>
<StepperList className="items-center gap-0">
{steps.map((step, index) => {
const isCompleted = completedSteps[step.value];
const isActive = step.value === activeStep;
return (
<StepperItem
key={step.value}
value={step.value}
completed={isCompleted}
className="flex min-w-0 flex-1 items-center last:flex-none"
>
<StepperTrigger
className={`gap-2 rounded-full p-0 ${
isCompleted || isActive ? 'text-white' : 'text-white/48'
}`}
>
<StepperIndicator
className={`size-7 text-xs font-bold shadow-none ${
isCompleted
? 'border-0 bg-emerald-500 text-white'
: isActive
? 'border border-emerald-400 bg-white/10 text-white shadow-[0_0_0_4px_rgba(16,185,129,0.12)]'
: 'border-0 bg-white/10 text-white/62'
}`}
>
{index + 1}
</StepperIndicator>
{!mobile && (
<StepperTitle className="whitespace-nowrap text-xs font-semibold text-inherit">
{step.label}
</StepperTitle>
)}
</StepperTrigger>
<StepperSeparator
className={`mx-3 h-px min-w-6 flex-1 ${
isCompleted ? 'bg-emerald-500/80' : 'bg-white/10'
}`}
/>
</StepperItem>
);
})}
</StepperList>
</Stepper>
);
}
function Legend() {
return (
<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>
);
}
function LegendDot({ color, label }: { color: string; label: string }) {
return (
<span className="inline-flex items-center gap-2">
<span className={`size-2.5 rounded-full ${color}`} />
{label}
</span>
);
}
function CourtIcon({ className = 'size-5' }: { className?: string }) {
return <Trophy className={className} />;
}
function PublicBookingPageChrome({
children,
currentStep,
completedSteps,
complexName,
mobile = false,
}: {
children: React.ReactNode;
currentStep: number;
completedSteps: Record<string, boolean>;
complexName?: string;
mobile?: boolean;
}) {
const { resolvedTheme } = useTheme();
const themeClass =
resolvedTheme === 'dark'
? 'public-booking-root public-booking-dark'
: 'public-booking-root public-booking-light';
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">
<header className="flex items-center justify-between">
<PlayzerBrand compact />
<PublicBookingThemeToggle />
</header>
<section className="mt-6 text-center">
<h1 className="text-balance text-2xl font-bold leading-tight tracking-normal">
Reservá <span className="text-emerald-400">tu cancha</span>
<br />
de forma rápida y sencilla
</h1>
<p className="mx-auto mt-2 max-w-[260px] text-sm leading-5 text-white/66">
Elegí tu cancha, seleccioná el horario y completá tu reserva.
</p>
{complexName && (
<p className="mt-3 text-xs font-semibold uppercase tracking-[0.16em] text-emerald-300/80">
{complexName}
</p>
)}
</section>
<div className="mt-5">
<ProgressSteps currentStep={currentStep} completedSteps={completedSteps} mobile />
</div>
{children}
<MobileBottomNav />
</div>
</main>
);
}
return (
<main className={`${themeClass} min-h-screen bg-[#02070b] text-white`}>
<div className="public-booking-frame min-h-screen overflow-hidden bg-[radial-gradient(circle_at_18%_12%,rgba(7,172,132,0.16),transparent_28%),radial-gradient(circle_at_82%_20%,rgba(46,116,255,0.12),transparent_30%),linear-gradient(180deg,#07131b_0%,#071019_48%,#050b11_100%)]">
<header className="border-b border-white/10">
<div className="mx-auto flex h-20 max-w-[1320px] items-center gap-8 px-10">
<PlayzerBrand />
{complexName && (
<div className="ml-auto hidden min-w-0 items-center gap-3 px-4 py-2 md:flex">
<div className="min-w-0 text-right">
<p className="truncate text-sm font-semibold text-white">{complexName}</p>
</div>
</div>
)}
<PublicBookingThemeToggle />
</div>
</header>
<section className="mx-auto max-w-[1320px] px-10 py-8">
<h1 className="max-w-[560px] text-balance text-4xl font-bold leading-tight tracking-normal">
Reservá <span className="text-emerald-400">tu cancha</span>
<br />
de forma rápida y sencilla
</h1>
<p className="mt-3 text-sm text-white/68">
Elegí tu cancha, seleccioná el horario y completá tu reserva.
</p>
<div className="mt-7">
<ProgressSteps currentStep={currentStep} completedSteps={completedSteps} />
</div>
</section>
{children}
<footer className="mt-8 border-t border-white/10">
<div className="mx-auto flex max-w-[1320px] items-center gap-6 px-10 py-6">
<PlayzerBrand />
<p className="text-xs text-white/45">© 2026 Playzer. Todos los derechos reservados.</p>
<div className="ml-auto flex gap-3 text-white/55">
<span className="flex size-10 items-center justify-center rounded-full border border-white/10">
<Camera className="size-4" />
</span>
<span className="flex size-10 items-center justify-center rounded-full border border-white/10">
<Share2 className="size-4" />
</span>
<span className="flex size-10 items-center justify-center rounded-full border border-white/10">
<MessageCircle className="size-4" />
</span>
</div>
</div>
</footer>
</div>
</main>
);
}
function ComplexInfoPanel({
complexName,
complexAddress,
}: {
complexName?: string;
complexAddress?: string;
}) {
return (
<Panel className="p-5">
<div className="flex items-start gap-4">
<div className="flex size-12 shrink-0 items-center justify-center rounded-full border border-emerald-500/40 bg-emerald-500/12">
<Building2 className="size-6 text-emerald-400" />
</div>
<div className="min-w-0">
<h2 className="text-lg font-semibold text-white">{complexName ?? 'Complex'}</h2>
{complexAddress && (
<p className="mt-1 flex items-start gap-2 text-sm text-white/62">
<MapPin className="mt-0.5 size-4 shrink-0 text-emerald-400" />
<span className="leading-relaxed">{complexAddress}</span>
</p>
)}
</div>
</div>
</Panel>
);
}
function PublicBookingDesktop(props: BookingShellProps) {
const {
courts,
sports,
selectedSportId,
selectedCourtId,
selectedDate,
selectedSlot,
dayOptions,
canGoBack,
isLoading,
isError,
sportSelectionRequired,
errorMessage,
autoAdjustedDateNotice,
onSelectSport,
onSelectCourt,
onSelectSlot,
} = props;
const selectedCourt = getSelectedCourt(courts, selectedCourtId, selectedSlot);
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;
return (
<PublicBookingPageChrome
currentStep={currentStep}
completedSteps={completedSteps}
complexName={props.complexName}
>
<div className="mx-auto grid max-w-[1320px] grid-cols-[330px_minmax(0,1fr)] gap-5 px-10">
<aside className="space-y-4">
<ComplexInfoPanel complexName={props.complexName} complexAddress={props.complexAddress} />
<Panel className="p-5">
<h2 className="text-lg font-semibold">Seleccioná tu cancha</h2>
<div className="mt-5 space-y-4">
{sportSelectionRequired && (
<SportSelector
label="Deporte"
value={selectedSportId ?? ''}
onValueChange={onSelectSport}
options={sports.map((sport) => ({ value: sport.id, label: sport.name }))}
/>
)}
<div>
<p className="mb-2 text-sm text-white/76">Canchas disponibles</p>
<div id="canchas" className="space-y-2">
{courts.map((court) => (
<button
key={court.courtId}
type="button"
onClick={() => onSelectCourt(court.courtId)}
className={`flex w-full items-center gap-3 rounded-md border p-3 text-left transition ${
selectedCourt?.courtId === court.courtId
? 'border-emerald-500 bg-emerald-500/18 shadow-[inset_0_0_20px_rgba(16,185,129,0.14)]'
: 'border-white/10 bg-white/[0.035] hover:border-white/20 hover:bg-white/[0.055]'
}`}
>
<CourtIcon className="size-5 text-white" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold">
{court.courtName}
</span>
<span className="block text-xs text-white/54">{court.sport.name}</span>
</span>
{selectedCourt?.courtId === court.courtId && (
<span className="flex size-5 items-center justify-center rounded-full bg-white text-emerald-600">
<Check className="size-3.5" />
</span>
)}
</button>
))}
{!isLoading && courts.length === 0 && (
<p className="rounded-md border border-white/10 bg-white/[0.035] p-3 text-sm text-white/62">
No hay canchas disponibles para esta fecha.
</p>
)}
</div>
</div>
</div>
</Panel>
<Panel className="p-5">
<div id="como-funciona" className="space-y-4">
<Feature
icon={<Sparkles className="size-6" />}
title="Reserva en pocos pasos"
text="Completá tu reserva en menos de 2 minutos"
/>
<Feature
icon={<ShieldCheck className="size-6" />}
title="Cancelación flexible"
text="Cancelá gratis hasta 2 horas antes"
/>
<Feature
icon={<Users className="size-6" />}
title="Atención personalizada"
text="Estamos disponibles para ayudarte"
/>
</div>
</Panel>
</aside>
<section className="space-y-4">
{autoAdjustedDateNotice && (
<p className="rounded-md border border-amber-300/30 bg-amber-400/10 px-4 py-3 text-sm text-amber-100">
{autoAdjustedDateNotice}
</p>
)}
{isError && <StatusPanel>{errorMessage}</StatusPanel>}
{isLoading && <StatusPanel>Buscando disponibilidad...</StatusPanel>}
{selectedCourt && (
<>
<Panel className="overflow-hidden">
<div className="flex items-center border-b border-white/10 px-6 py-5">
<CourtHeader court={selectedCourt} complexName={props.complexName} />
<div className="ml-auto flex items-center overflow-hidden rounded-md border border-white/10 bg-white/[0.035] text-sm">
<button
type="button"
className="flex size-11 items-center justify-center border-r border-white/10 transition hover:bg-white/[0.06]"
onClick={props.onPreviousDay}
disabled={!canGoBackButton}
aria-label="Día anterior"
>
<ChevronLeft className="size-5" />
</button>
<div className="flex h-11 min-w-[250px] items-center justify-center gap-2 px-4">
<CalendarDays className="size-4 text-white/76" />
<span>{selectedDay?.longLabel ?? selectedDate}</span>
</div>
<button
type="button"
className="flex size-11 items-center justify-center border-l border-white/10 transition hover:bg-white/[0.06]"
onClick={props.onNextDay}
aria-label="Día siguiente"
>
<ChevronRight className="size-5" />
</button>
</div>
</div>
<div className="p-6">
<Legend />
<BookingTimeline
court={selectedCourt}
selectedSlot={selectedSlot}
onSelectSlot={onSelectSlot}
/>
</div>
</Panel>
<div className="grid grid-cols-[minmax(0,1fr)_minmax(340px,0.95fr)] gap-4">
<CourtDetails court={selectedCourt} />
<SelectedSlotCard {...props} />
</div>
</>
)}
</section>
</div>
</PublicBookingPageChrome>
);
}
function PublicBookingMobile(props: BookingShellProps) {
const selectedCourt = getSelectedCourt(props.courts, props.selectedCourtId, props.selectedSlot);
const currentStep = getStep(props.selectedSlot, props.form.formState.isValid);
const completedSteps = getCompletedSteps(props.selectedSlot, props.form.formState.isValid);
const selectedDay = props.dayOptions.find((option) => option.value === props.selectedDate);
return (
<PublicBookingPageChrome
currentStep={currentStep}
completedSteps={completedSteps}
complexName={props.complexName}
mobile
>
<div className="mt-5 space-y-3">
<Panel className="p-3">
<h2 className="text-base font-semibold">Seleccioná tu cancha</h2>
<div className="mt-3 space-y-0">
{props.sportSelectionRequired && (
<MobileSportSelector
label="Deporte"
value={props.selectedSportId ?? ''}
onValueChange={props.onSelectSport}
options={props.sports.map((sport) => ({ value: sport.id, label: sport.name }))}
/>
)}
{props.courts.length > 0 ? (
<MobileSelect
label="Cancha"
value={selectedCourt?.courtId ?? ''}
placeholder="Seleccioná"
onValueChange={props.onSelectCourt}
options={props.courts.map((court) => ({
value: court.courtId,
label: court.courtName,
}))}
/>
) : (
!props.isLoading && (
<p className="rounded-md border border-white/10 bg-white/[0.035] p-3 text-sm text-white/62">
No hay canchas disponibles para esta fecha.
</p>
)
)}
</div>
</Panel>
<Panel className="flex items-center gap-2 p-2">
<span className="px-2 text-sm text-white/76">Fecha</span>
<button
type="button"
onClick={props.onPreviousDay}
disabled={!props.canGoBack}
className="flex size-9 items-center justify-center rounded-md border border-white/10 bg-white/[0.035] disabled:opacity-35"
aria-label="Día anterior"
>
<ChevronLeft className="size-4" />
</button>
<div className="flex h-9 min-w-0 flex-1 items-center justify-center gap-2 rounded-md border border-white/10 bg-white/[0.035] px-2 text-xs">
<CalendarDays className="size-4" />
<span className="truncate">{selectedDay?.longLabel ?? props.selectedDate}</span>
</div>
<button
type="button"
onClick={props.onNextDay}
className="flex size-9 items-center justify-center rounded-md border border-white/10 bg-white/[0.035]"
aria-label="Día siguiente"
>
<ChevronRight className="size-4" />
</button>
</Panel>
{props.autoAdjustedDateNotice && (
<p className="rounded-md border border-amber-300/30 bg-amber-400/10 px-3 py-2 text-xs text-amber-100">
{props.autoAdjustedDateNotice}
</p>
)}
{props.isLoading && <StatusPanel>Buscando disponibilidad...</StatusPanel>}
{props.isError && <StatusPanel>{props.errorMessage}</StatusPanel>}
{selectedCourt && (
<Panel className="overflow-hidden">
<div className="p-3">
<CourtHeader court={selectedCourt} compact />
<div className="mt-3">
<Legend />
</div>
</div>
<div className="border-t border-white/10 px-0 py-3">
<BookingTimeline
court={selectedCourt}
selectedSlot={props.selectedSlot}
onSelectSlot={props.onSelectSlot}
compact
/>
</div>
<div className="px-3 pb-3">
<SelectedSlotCard {...props} compact />
</div>
</Panel>
)}
</div>
</PublicBookingPageChrome>
);
}
function Panel({ children, className = '' }: { children: React.ReactNode; className?: string }) {
return (
<section
className={`public-booking-panel rounded-md border border-white/10 bg-[#0d1820]/78 shadow-[inset_0_1px_0_rgba(255,255,255,0.04),0_20px_60px_rgba(0,0,0,0.18)] backdrop-blur ${className}`}
>
{children}
</section>
);
}
function StatusPanel({ children }: { children?: React.ReactNode }) {
return (
<Panel className="p-4">
<p className="text-sm text-white/68">{children}</p>
</Panel>
);
}
function SportSelector({
label,
value,
options,
onValueChange,
}: {
label: string;
value: string;
options: { value: string; label: string }[];
onValueChange: (value: string) => void;
}) {
return (
<Field>
<FieldLabel className="text-sm text-white/76">{label}</FieldLabel>
<div className="flex flex-wrap gap-2">
{options.map((option) => {
const Icon = getSportIcon(option.label);
const isSelected = value === option.value;
return (
<button
key={option.value}
type="button"
onClick={() => onValueChange(option.value)}
className={`inline-flex items-center gap-2 rounded-full px-4 py-2 transition-all ${
isSelected
? 'border border-emerald-500 bg-emerald-500/18 shadow-[inset_0_0_20px_rgba(16,185,129,0.14)] text-white'
: 'border border-white/15 bg-white/[0.05] text-white/76 hover:border-white/30 hover:bg-white/[0.08] hover:text-white'
}`}
>
<Icon className="size-4" />
<span className="text-sm font-medium">{option.label}</span>
</button>
);
})}
</div>
</Field>
);
}
function MobileSportSelector({
label,
value,
options,
onValueChange,
}: {
label: string;
value: string;
options: { value: string; label: string }[];
onValueChange: (value: string) => void;
}) {
return (
<div className="border-b border-white/10 bg-white/[0.025] px-3 py-3 last:border-b-0">
<span className="mb-2 block text-xs text-white/66">{label}</span>
<div className="flex flex-wrap gap-2">
{options.map((option) => {
const Icon = getSportIcon(option.label);
const isSelected = value === option.value;
return (
<button
key={option.value}
type="button"
onClick={() => onValueChange(option.value)}
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
isSelected
? 'border border-emerald-500 bg-emerald-500/18 shadow-[inset_0_0_15px_rgba(16,185,129,0.1)] text-white'
: 'border border-white/15 bg-white/[0.05] text-white/66 hover:border-white/25 hover:bg-white/[0.08] hover:text-white/80'
}`}
>
<Icon className="size-3.5" />
<span>{option.label}</span>
</button>
);
})}
</div>
</div>
);
}
function MobileSelect({
label,
value,
placeholder,
options,
onValueChange,
}: {
label: string;
value: string;
placeholder: string;
options: { value: string; label: string }[];
onValueChange: (value: string) => void;
}) {
return (
<div className="grid grid-cols-[88px_minmax(0,1fr)] items-center border-b border-white/10 bg-white/[0.025] px-3 py-2 last:border-b-0">
<span className="text-xs text-white/66">{label}</span>
<Select value={value} onValueChange={onValueChange}>
<SelectTrigger className="h-8 border-0 bg-transparent px-0 text-xs text-white shadow-none">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
function Feature({ icon, title, text }: { icon: React.ReactNode; title: string; text: string }) {
return (
<div className="flex gap-4">
<div className="mt-1 text-emerald-400">{icon}</div>
<div>
<p className="text-sm font-medium">{title}</p>
<p className="mt-1 text-xs text-white/52">{text}</p>
</div>
</div>
);
}
function CourtHeader({
court,
complexName,
compact = false,
}: {
court?: PublicAvailabilityCourt;
complexName?: string;
compact?: boolean;
}) {
return (
<div className="flex min-w-0 items-center gap-4">
{!compact && (
<div className="flex size-10 items-center justify-center rounded-full border border-white/70">
<CourtIcon className="size-6 text-white" />
</div>
)}
<div className="min-w-0">
<h2
className={compact ? 'truncate text-lg font-semibold' : 'truncate text-xl font-semibold'}
>
{court?.courtName ?? 'Cancha'}
</h2>
<p className="mt-1 truncate text-sm text-white/62">
{court?.sport.name ?? 'Deporte'} {complexName ? ` · ${complexName}` : ''}
</p>
</div>
</div>
);
}
function BookingTimeline({
court,
selectedSlot,
onSelectSlot,
compact = false,
}: {
court?: PublicAvailabilityCourt;
selectedSlot: SelectedSlot | null;
onSelectSlot: (slot: SelectedSlot) => void;
compact?: boolean;
}) {
const range = getTimelineRange(court);
const ticks = getHourTicks(range.start, range.end, compact);
const total = range.end - range.start;
const slots = court?.availableSlots ?? [];
return (
<div
className={
compact ? 'overflow-x-auto booking-scrollbar px-0' : 'overflow-x-auto booking-scrollbar'
}
>
<div className={compact ? 'min-w-[540px] px-3' : 'min-w-[780px]'}>
<div className="relative mt-2 h-6">
{ticks.map((tick) => (
<div
key={tick}
className="absolute top-0 text-xs text-white"
style={{ left: `${((tick - range.start) / total) * 100}%` }}
>
{toTimeLabel(tick)}
</div>
))}
</div>
<div className={compact ? 'relative h-20' : 'relative h-24'}>
{ticks.map((tick) => (
<span
key={tick}
className="absolute top-0 h-full w-px bg-white/[0.035]"
style={{ left: `${((tick - range.start) / total) * 100}%` }}
/>
))}
{slots.length > 0 ? (
slots.map((slot) => {
const left = ((toMinutes(slot.startTime) - range.start) / total) * 100;
const width = ((toMinutes(slot.endTime) - toMinutes(slot.startTime)) / total) * 100;
const selected = court ? isSlotSelected(slot, court.courtId, selectedSlot) : false;
return (
<button
key={`${court?.courtId}-${slot.startTime}`}
type="button"
onClick={() => {
if (!court) return;
onSelectSlot({
courtId: court.courtId,
courtName: court.courtName,
sportId: court.sport.id,
sportName: court.sport.name,
startTime: slot.startTime,
endTime: slot.endTime,
});
}}
className={`absolute top-7 flex h-12 min-w-[50px] items-center justify-center rounded border text-xs transition ${
selected
? 'border-emerald-300 bg-emerald-500/45 text-white shadow-lg shadow-emerald-500/20'
: 'border-emerald-500 bg-emerald-500/18 text-white hover:bg-emerald-500/28'
}`}
style={{ left: `${left}%`, width: `${Math.max(width - 0.6, 5)}%` }}
>
<span className="font-semibold">{slot.startTime}</span>
</button>
);
})
) : (
<div className="absolute inset-x-0 top-12 rounded border border-blue-500/60 bg-blue-500/18 px-4 py-5 text-sm text-white/68">
Sin horarios disponibles
</div>
)}
{slots.slice(0, compact ? 2 : 3).map((slot, index) => {
const end = toMinutes(slot.endTime);
const nextStart = slots[index + 1] ? toMinutes(slots[index + 1].startTime) : end + 60;
if (nextStart - end < 45 || nextStart > range.end) return null;
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'
}`}
style={{ left: `${left}%`, width: `${Math.max(width - 0.8, 4.5)}%` }}
>
{maintenance ? <Wrench className="size-4" /> : <Lock className="size-4" />}
</div>
);
})}
{selectedSlot && court?.courtId === selectedSlot.courtId && (
<div
className="absolute top-0 flex -translate-x-1/2 flex-col items-center"
style={{
left: `${((toMinutes(selectedSlot.startTime) - range.start) / total) * 100}%`,
}}
>
<span className="rounded-md bg-emerald-500 px-2 py-1 text-xs font-bold text-white shadow-lg shadow-emerald-500/25">
{selectedSlot.startTime}
</span>
<span className="h-14 w-px border-l border-dashed border-emerald-400" />
</div>
)}
</div>
</div>
</div>
);
}
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,
form,
isCreating,
createError,
navigationError,
onSubmit,
compact = false,
} = props;
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isValid },
} = form;
const customerNameInputRef = useRef<HTMLInputElement | null>(null);
const customerNameRegistration = register('customerName');
useEffect(() => {
if (!selectedSlot) return;
window.requestAnimationFrame(() => {
customerNameInputRef.current?.focus();
});
}, [selectedSlot]);
return (
<Panel className={compact ? 'border-emerald-500/70 p-3' : 'p-6'}>
<div className={compact ? 'flex gap-3' : 'grid grid-cols-[56px_minmax(0,1fr)] gap-5'}>
<div className="flex size-12 shrink-0 items-center justify-center rounded-full border border-emerald-500 text-emerald-400">
<Check className="size-7" />
</div>
<div className="min-w-0">
<h3 className="text-lg font-bold">
{selectedSlot ? '¡Hora libre!' : 'Elegí un horario'}
</h3>
<p className="mt-1 text-sm text-white/66">
{selectedSlot
? `Este horario está disponible para reservar. ${selectedSlot.startTime} - ${selectedSlot.endTime}`
: 'Seleccioná una cancha y un horario disponible.'}
</p>
</div>
</div>
{selectedSlot && (
<form
className={compact ? 'mt-4 space-y-3' : 'mt-5 grid gap-3'}
onSubmit={handleSubmit(onSubmit)}
>
<div className={compact ? 'space-y-3' : 'grid grid-cols-2 gap-3'}>
<Field data-invalid={Boolean(errors.customerName)}>
<FieldLabel htmlFor="customerName" className="text-white/76">
Nombre
</FieldLabel>
<Input
id="customerName"
placeholder="Ej: Juan Perez"
aria-invalid={Boolean(errors.customerName)}
className="border-white/10 bg-white/[0.045] text-white placeholder:text-white/32"
{...customerNameRegistration}
ref={(element) => {
customerNameRegistration.ref(element);
customerNameInputRef.current = element;
}}
/>
<FieldError errors={[errors.customerName]} />
</Field>
<Field data-invalid={Boolean(errors.customerPhone)}>
<FieldLabel htmlFor="customerPhone" className="text-white/76">
Telefono
</FieldLabel>
<Input
id="customerPhone"
type="tel"
placeholder="Ej: 3875551234"
aria-invalid={Boolean(errors.customerPhone)}
className="border-white/10 bg-white/[0.045] text-white placeholder:text-white/32"
{...register('customerPhone')}
/>
<FieldError errors={[errors.customerPhone]} />
</Field>
</div>
{createError && <p className="text-sm text-red-300">{createError}</p>}
{navigationError && <p className="text-sm text-red-300">{navigationError}</p>}
<Button
type="submit"
className="h-12 w-full rounded-md bg-emerald-500 font-bold text-white shadow-lg shadow-emerald-500/20 hover:bg-emerald-400"
disabled={!isValid || isSubmitting || isCreating}
>
{isCreating ? 'Confirmando...' : 'Reservar este horario'}
<ArrowRight className="ml-2 size-5" />
</Button>
</form>
)}
</Panel>
);
}
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();
const today = useMemo(() => new Date(), []);
const todayIsoDate = useMemo(() => toIsoDateLocal(today), [today]);
const [windowStartOffset, setWindowStartOffset] = useState(0);
const [selectedSportId, setSelectedSportId] = useState<string | undefined>();
const [selectedCourtId, setSelectedCourtId] = useState<string | undefined>();
const [selectedSlot, setSelectedSlot] = useState<SelectedSlot | null>(null);
const [navigationError, setNavigationError] = useState<string | null>(null);
const [hasAutoAdjustedInitialDate, setHasAutoAdjustedInitialDate] = useState(false);
const [autoAdjustedDateNotice, setAutoAdjustedDateNotice] = useState<string | null>(null);
const daysToShow = isMobile ? DAYS_STEP : DESKTOP_DAYS_STEP;
const dayOptions = useMemo(() => {
return Array.from({ length: daysToShow }).map((_, index) => {
const current = addDays(today, windowStartOffset + index);
return formatDayLabel(current);
});
}, [daysToShow, today, windowStartOffset]);
const [selectedDate, setSelectedDate] = useState(dayOptions[0]?.value ?? '');
useEffect(() => {
if (dayOptions.some((option) => option.value === selectedDate)) {
return;
}
setSelectedDate(dayOptions[0]?.value ?? '');
}, [dayOptions, selectedDate]);
const availabilityQuery = useQuery({
queryKey: ['public-booking-availability', complexSlug, selectedDate, selectedSportId],
enabled: Boolean(selectedDate),
placeholderData: (previousData) => previousData,
queryFn: () =>
apiClient.publicBookings.getAvailability(complexSlug, {
date: selectedDate,
...(selectedSportId ? { sportId: selectedSportId } : {}),
}),
});
useEffect(() => {
const availability = availabilityQuery.data;
if (!availability) return;
if (!availability.sportSelectionRequired) {
if (!selectedSportId && availability.sports[0]) {
setSelectedSportId(availability.sports[0].id);
}
return;
}
if (selectedSportId && !availability.sports.some((sport) => sport.id === selectedSportId)) {
setSelectedSportId(undefined);
}
}, [availabilityQuery.data, selectedSportId]);
const form = useForm<BookingFormValues>({
resolver: zodResolver(bookingFormSchema),
mode: 'onChange',
defaultValues: {
customerName: '',
customerPhone: '',
},
});
const createBookingMutation = useMutation({
mutationFn: (payload: BookingFormValues) => {
if (!selectedSlot) {
throw new Error('Debés seleccionar un horario.');
}
return apiClient.publicBookings.create(complexSlug, {
date: selectedDate,
sportId: selectedSportId ?? selectedSlot.sportId,
courtId: selectedSlot.courtId,
startTime: selectedSlot.startTime,
customerName: payload.customerName,
customerPhone: payload.customerPhone,
});
},
});
const onSubmit = async (values: BookingFormValues) => {
setNavigationError(null);
const booking = await createBookingMutation.mutateAsync(values);
if (!booking.bookingCode) {
setNavigationError('No se pudo obtener el código de confirmación de la reserva.');
return;
}
form.reset();
setSelectedSlot(null);
await availabilityQuery.refetch();
await navigate({
to: '/$complexSlug/booking/confirmed/$bookingCode',
params: {
complexSlug,
bookingCode: booking.bookingCode,
},
});
};
const visibleCourts = useMemo(() => {
const courts = availabilityQuery.data?.courts ?? [];
if (selectedDate !== todayIsoDate) {
return courts;
}
const now = new Date();
const nowMinutes = now.getHours() * 60 + now.getMinutes();
return courts
.map((court) => ({
...court,
availableSlots: court.availableSlots.filter(
(slot) => toMinutes(slot.startTime) >= nowMinutes
),
}))
.filter((court) => court.availableSlots.length > 0);
}, [availabilityQuery.data?.courts, selectedDate, todayIsoDate]);
useEffect(() => {
if (!selectedSlot) return;
const stillAvailable = visibleCourts.some(
(court) =>
court.courtId === selectedSlot.courtId &&
court.availableSlots.some((slot) => slot.startTime === selectedSlot.startTime)
);
if (!stillAvailable) {
setSelectedSlot(null);
}
}, [selectedSlot, visibleCourts]);
useEffect(() => {
if (visibleCourts.length === 0) {
setSelectedCourtId(undefined);
return;
}
if (selectedCourtId && visibleCourts.some((court) => court.courtId === selectedCourtId)) {
return;
}
setSelectedCourtId(visibleCourts[0]?.courtId);
}, [selectedCourtId, visibleCourts]);
useEffect(() => {
if (hasAutoAdjustedInitialDate) return;
if (selectedDate !== todayIsoDate) {
setHasAutoAdjustedInitialDate(true);
return;
}
if (availabilityQuery.isLoading || availabilityQuery.isError || !availabilityQuery.data) {
return;
}
if (visibleCourts.length > 0) {
setHasAutoAdjustedInitialDate(true);
return;
}
let cancelled = false;
const findNextAvailableDate = async () => {
for (let dayOffset = 1; dayOffset <= 30; dayOffset += 1) {
const candidateDate = toIsoDateLocal(addDays(today, dayOffset));
try {
const availability = await apiClient.publicBookings.getAvailability(complexSlug, {
date: candidateDate,
...(selectedSportId ? { sportId: selectedSportId } : {}),
});
if (availability.courts.length === 0) {
continue;
}
if (cancelled) return;
setWindowStartOffset(dayOffset);
setSelectedDate(candidateDate);
setSelectedSlot(null);
setAutoAdjustedDateNotice(
'No había turnos disponibles para hoy. Te mostramos el próximo día con turnos.'
);
setHasAutoAdjustedInitialDate(true);
return;
} catch {
break;
}
}
if (!cancelled) {
setHasAutoAdjustedInitialDate(true);
}
};
void findNextAvailableDate();
return () => {
cancelled = true;
};
}, [
availabilityQuery.data,
availabilityQuery.isError,
availabilityQuery.isLoading,
complexSlug,
hasAutoAdjustedInitialDate,
selectedDate,
selectedSportId,
today,
todayIsoDate,
visibleCourts.length,
]);
const canGoBack = windowStartOffset > 0;
const selectDate = (date: string) => {
setSelectedDate(date);
setSelectedSlot(null);
setAutoAdjustedDateNotice(null);
};
const goToPreviousDays = () => {
if (!canGoBack) return;
setWindowStartOffset((previous) => {
const next = Math.max(0, previous - daysToShow);
const nextStartDate = toIsoDateLocal(addDays(today, next));
setSelectedDate(nextStartDate);
setSelectedSlot(null);
setAutoAdjustedDateNotice(null);
return next;
});
};
const goToNextDays = () => {
setWindowStartOffset((previous) => {
const next = previous + daysToShow;
const nextStartDate = toIsoDateLocal(addDays(today, next));
setSelectedDate(nextStartDate);
setSelectedSlot(null);
setAutoAdjustedDateNotice(null);
return next;
});
};
const goToPreviousDay = () => {
const currentIndex = dayOptions.findIndex((o) => o.value === selectedDate);
if (currentIndex > 0) {
const prevDate = dayOptions[currentIndex - 1].value;
setSelectedDate(prevDate);
setSelectedSlot(null);
setAutoAdjustedDateNotice(null);
return;
}
if (windowStartOffset > 0) {
const newOffset = windowStartOffset - 1;
const newStartDate = toIsoDateLocal(addDays(today, newOffset));
setWindowStartOffset(newOffset);
setSelectedDate(newStartDate);
setSelectedSlot(null);
setAutoAdjustedDateNotice(null);
}
};
const goToNextDay = () => {
const currentIndex = dayOptions.findIndex((o) => o.value === selectedDate);
if (currentIndex < dayOptions.length - 1) {
const nextDate = dayOptions[currentIndex + 1].value;
setSelectedDate(nextDate);
setSelectedSlot(null);
setAutoAdjustedDateNotice(null);
return;
}
const newOffset = windowStartOffset + 1;
const nextDate = toIsoDateLocal(addDays(today, newOffset));
setWindowStartOffset(newOffset);
setSelectedDate(nextDate);
setSelectedSlot(null);
setAutoAdjustedDateNotice(null);
};
const shellProps: BookingShellProps = {
complexName: availabilityQuery.data?.complexName,
complexAddress: availabilityQuery.data?.complexAddress,
sports: availabilityQuery.data?.sports ?? [],
courts: visibleCourts,
selectedDate,
selectedSportId,
selectedCourtId,
selectedSlot,
dayOptions,
canGoBack,
isLoading: availabilityQuery.isLoading,
isError: availabilityQuery.isError,
sportSelectionRequired: availabilityQuery.data?.sportSelectionRequired,
errorMessage: availabilityQuery.isError
? extractMessage(availabilityQuery.error, 'No pudimos cargar la disponibilidad.')
: undefined,
autoAdjustedDateNotice,
form,
isCreating: createBookingMutation.isPending,
createError: createBookingMutation.isError
? extractMessage(createBookingMutation.error, 'No pudimos confirmar el turno.')
: undefined,
navigationError,
onSelectDate: selectDate,
onPreviousDays: goToPreviousDays,
onNextDays: goToNextDays,
onPreviousDay: goToPreviousDay,
onNextDay: goToNextDay,
onSelectSport: (sportId) => {
setSelectedSportId(sportId);
setSelectedCourtId(undefined);
setSelectedSlot(null);
setAutoAdjustedDateNotice(null);
},
onSelectCourt: (courtId) => {
setSelectedCourtId(courtId);
setSelectedSlot(null);
},
onSelectSlot: (slot) => {
setSelectedCourtId(slot.courtId);
setSelectedSlot(slot);
},
onSubmit,
};
return isMobile ? (
<PublicBookingMobile {...shellProps} />
) : (
<PublicBookingDesktop {...shellProps} />
);
}