1599 lines
53 KiB
TypeScript
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} />
|
|
);
|
|
}
|