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 { ApiClientError, apiClient } from '@/lib/api-client'; import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation, useQuery } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; import { useEffect, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; const DAYS_STEP = 5; 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, 'Ingresa un telefono valido.') .max(30, 'El telefono no puede superar los 30 caracteres.'), }); type BookingFormValues = z.infer; type PublicBookingPageProps = { complexSlug: string; }; type SelectedSlot = { courtId: string; courtName: string; sportId: string; sportName: string; startTime: string; endTime: 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 formatDayLabel(date: Date) { const weekday = new Intl.DateTimeFormat('es-AR', { weekday: 'short' }).format(date); const dayMonth = new Intl.DateTimeFormat('es-AR', { day: '2-digit', month: '2-digit', }).format(date); return { weekday: weekday.replace('.', ''), dayMonth, }; } function extractMessage(error: unknown, fallback: string) { if (error instanceof ApiClientError) { return error.message || fallback; } return fallback; } export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) { const navigate = useNavigate(); const today = useMemo(() => new Date(), []); const todayIsoDate = useMemo(() => toIsoDateLocal(today), [today]); const [windowStartOffset, setWindowStartOffset] = useState(0); const [selectedSportId, setSelectedSportId] = useState(); const [selectedSlot, setSelectedSlot] = useState(null); const [navigationError, setNavigationError] = useState(null); const [hasAutoAdjustedInitialDate, setHasAutoAdjustedInitialDate] = useState(false); const [autoAdjustedDateNotice, setAutoAdjustedDateNotice] = useState(null); const dayOptions = useMemo(() => { return Array.from({ length: DAYS_STEP }).map((_, index) => { const current = addDays(today, windowStartOffset + index); return { value: toIsoDateLocal(current), date: current, ...formatDayLabel(current), }; }); }, [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), 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 { register, handleSubmit, reset, formState: { errors, isSubmitting, isValid }, } = useForm({ resolver: zodResolver(bookingFormSchema), mode: 'onChange', defaultValues: { customerName: '', customerPhone: '', }, }); const createBookingMutation = useMutation({ mutationFn: (payload: BookingFormValues) => { if (!selectedSlot) { throw new Error('Debes seleccionar un horario.'); } return apiClient.publicBookings.create(complexSlug, { date: selectedDate, sportId: selectedSportId, 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 codigo de confirmacion de la reserva.'); return; } 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 (hasAutoAdjustedInitialDate) return; if (selectedDate !== todayIsoDate) { setHasAutoAdjustedInitialDate(true); return; } if (availabilityQuery.isLoading || availabilityQuery.isError || !availabilityQuery.data) { return; } if (availabilityQuery.data.sportSelectionRequired && !selectedSportId) { 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 habia turnos disponibles para hoy. Te mostramos el proximo dia 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 goToPreviousFiveDays = () => { if (!canGoBack) return; setWindowStartOffset((previous) => { const next = Math.max(0, previous - DAYS_STEP); const nextStartDate = toIsoDateLocal(addDays(today, next)); setSelectedDate(nextStartDate); setSelectedSlot(null); setAutoAdjustedDateNotice(null); return next; }); }; const goToNextFiveDays = () => { setWindowStartOffset((previous) => { const next = previous + DAYS_STEP; const nextStartDate = toIsoDateLocal(addDays(today, next)); setSelectedDate(nextStartDate); setSelectedSlot(null); setAutoAdjustedDateNotice(null); return next; }); }; return (

{availabilityQuery.data?.complexName}

{availabilityQuery.data?.complexAddress && (

{availabilityQuery.data.complexAddress}

)}

Elige un dia, revisa horarios disponibles y confirma con tu nombre y telefono.

Dia

Mostramos 5 dias por vez.

{autoAdjustedDateNotice && (

{autoAdjustedDateNotice}

)}
{canGoBack ? ( ) : (
)} {dayOptions.map((option) => ( ))}

Disponibilidad

Selecciona cancha y horario.

{availabilityQuery.data?.sportSelectionRequired && (
Deporte
)} {availabilityQuery.isLoading && (

Buscando disponibilidad...

)} {availabilityQuery.isError && (

{extractMessage(availabilityQuery.error, 'No pudimos cargar la disponibilidad.')}

)} {!availabilityQuery.isLoading && !availabilityQuery.isError && availabilityQuery.data?.sportSelectionRequired && !selectedSportId && (

Selecciona un deporte para ver los horarios disponibles.

)} {!availabilityQuery.isLoading && !availabilityQuery.isError && availabilityQuery.data && (!availabilityQuery.data.sportSelectionRequired || selectedSportId) && visibleCourts.length === 0 && (

No hay horarios disponibles para la fecha elegida.

)}
{visibleCourts.map((court) => (

{court.courtName}

{court.sport.name} · Turnos de {court.slotDurationMinutes} min

{court.availableSlots.map((slot) => { const isSelected = selectedSlot?.courtId === court.courtId && selectedSlot.startTime === slot.startTime; return ( ); })}
))}
{selectedSlot && (

Confirmar turno

{selectedSlot.courtName} · {selectedSlot.sportName} · {selectedSlot.startTime} -{' '} {selectedSlot.endTime}

Nombre Telefono {createBookingMutation.isError && (

{extractMessage(createBookingMutation.error, 'No pudimos confirmar el turno.')}

)} {navigationError &&

{navigationError}

}
)}
); }