Files
playzer/apps/frontend/src/features/public-booking/public-booking-page.tsx
2026-05-08 11:02:03 -03:00

571 lines
19 KiB
TypeScript

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<typeof bookingFormSchema>;
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<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 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<BookingFormValues>({
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 (
<main className="min-h-screen bg-linear-to-b from-background to-muted/40">
<div className="mx-auto flex w-full max-w-5xl flex-col gap-4 px-3 py-4 sm:gap-6 sm:px-6 sm:py-8">
<section className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold text-primary sm:text-3xl">
{availabilityQuery.data?.complexName}
</h1>
{availabilityQuery.data?.complexAddress && (
<p className="text-sm text-muted-foreground">
{availabilityQuery.data.complexAddress}
</p>
)}
</div>
<p className="mt-4 text-sm text-muted-foreground">
Elige un dia, revisa horarios disponibles y confirma con tu nombre y telefono.
</p>
</section>
<section className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
<div className="space-y-3">
<div>
<h2 className="text-sm font-medium">Dia</h2>
<p className="text-xs text-muted-foreground">Mostramos 5 dias por vez.</p>
</div>
{autoAdjustedDateNotice && (
<p className="rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900">
{autoAdjustedDateNotice}
</p>
)}
<div className="grid grid-cols-[2.25rem_repeat(5,minmax(0,1fr))_2.25rem] gap-2">
{canGoBack ? (
<Button
type="button"
variant="outline"
size="icon"
className="h-full"
onClick={goToPreviousFiveDays}
aria-label="Mostrar 5 dias anteriores"
>
{'<'}
</Button>
) : (
<div />
)}
{dayOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
setSelectedDate(option.value);
setSelectedSlot(null);
setAutoAdjustedDateNotice(null);
}}
className={`rounded-xl border px-1 py-2 text-center transition-colors sm:px-2 ${
selectedDate === option.value
? 'border-primary bg-primary text-primary-foreground'
: 'border-border bg-background hover:bg-muted'
}`}
>
<p className="text-[11px] font-medium uppercase sm:text-xs">{option.weekday}</p>
<p className="text-xs sm:text-sm">{option.dayMonth}</p>
</button>
))}
<Button
type="button"
variant="outline"
size="icon"
className="h-full"
onClick={goToNextFiveDays}
aria-label="Mostrar proximos 5 dias"
>
{'>'}
</Button>
</div>
</div>
</section>
<section className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h2 className="text-sm font-medium">Disponibilidad</h2>
<p className="text-xs text-muted-foreground">Selecciona cancha y horario.</p>
</div>
</div>
{availabilityQuery.data?.sportSelectionRequired && (
<div className="mb-4">
<Field>
<FieldLabel>Deporte</FieldLabel>
<Select
value={selectedSportId ?? ''}
onValueChange={(value) => {
setSelectedSportId(value);
setSelectedSlot(null);
setAutoAdjustedDateNotice(null);
}}
>
<SelectTrigger className="h-10 w-full">
<SelectValue placeholder="Selecciona un deporte" />
</SelectTrigger>
<SelectContent>
{availabilityQuery.data.sports.map((sport) => (
<SelectItem key={sport.id} value={sport.id}>
{sport.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
)}
{availabilityQuery.isLoading && (
<p className="text-sm text-muted-foreground">Buscando disponibilidad...</p>
)}
{availabilityQuery.isError && (
<p className="text-sm text-destructive">
{extractMessage(availabilityQuery.error, 'No pudimos cargar la disponibilidad.')}
</p>
)}
{!availabilityQuery.isLoading &&
!availabilityQuery.isError &&
availabilityQuery.data?.sportSelectionRequired &&
!selectedSportId && (
<p className="text-sm text-muted-foreground">
Selecciona un deporte para ver los horarios disponibles.
</p>
)}
{!availabilityQuery.isLoading &&
!availabilityQuery.isError &&
availabilityQuery.data &&
(!availabilityQuery.data.sportSelectionRequired || selectedSportId) &&
visibleCourts.length === 0 && (
<p className="text-sm text-muted-foreground">
No hay horarios disponibles para la fecha elegida.
</p>
)}
<div className="space-y-3">
{visibleCourts.map((court) => (
<article key={court.courtId} className="rounded-xl border bg-background p-3 sm:p-4">
<div className="mb-3 flex flex-col gap-1">
<p className="text-sm font-medium sm:text-base">{court.courtName}</p>
<p className="text-xs text-muted-foreground">
{court.sport.name} · Turnos de {court.slotDurationMinutes} min
</p>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{court.availableSlots.map((slot) => {
const isSelected =
selectedSlot?.courtId === court.courtId &&
selectedSlot.startTime === slot.startTime;
return (
<button
key={`${court.courtId}-${slot.startTime}`}
type="button"
onClick={() => {
setSelectedSlot({
courtId: court.courtId,
courtName: court.courtName,
sportId: court.sport.id,
sportName: court.sport.name,
startTime: slot.startTime,
endTime: slot.endTime,
});
}}
className={`h-10 rounded-lg border text-sm transition-colors ${
isSelected
? 'border-primary bg-primary text-primary-foreground'
: 'border-border bg-card hover:bg-muted'
}`}
>
{slot.startTime}
</button>
);
})}
</div>
</article>
))}
</div>
</section>
{selectedSlot && (
<section className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
<h2 className="text-sm font-medium">Confirmar turno</h2>
<p className="mt-1 text-xs text-muted-foreground">
{selectedSlot.courtName} · {selectedSlot.sportName} · {selectedSlot.startTime} -{' '}
{selectedSlot.endTime}
</p>
<form className="mt-4 space-y-3" onSubmit={handleSubmit(onSubmit)}>
<Field data-invalid={Boolean(errors.customerName)}>
<FieldLabel htmlFor="customerName">Nombre</FieldLabel>
<Input
id="customerName"
placeholder="Ej: Juan Perez"
aria-invalid={Boolean(errors.customerName)}
{...register('customerName')}
/>
<FieldError errors={[errors.customerName]} />
</Field>
<Field data-invalid={Boolean(errors.customerPhone)}>
<FieldLabel htmlFor="customerPhone">Telefono</FieldLabel>
<Input
id="customerPhone"
type="tel"
placeholder="Ej: 3875551234"
aria-invalid={Boolean(errors.customerPhone)}
{...register('customerPhone')}
/>
<FieldError errors={[errors.customerPhone]} />
</Field>
{createBookingMutation.isError && (
<p className="text-sm text-destructive">
{extractMessage(createBookingMutation.error, 'No pudimos confirmar el turno.')}
</p>
)}
{navigationError && <p className="text-sm text-destructive">{navigationError}</p>}
<Button type="submit" className="w-full" disabled={!isValid || isSubmitting}>
{createBookingMutation.isPending ? 'Confirmando...' : 'Confirmar turno'}
</Button>
</form>
</section>
)}
</div>
</main>
);
}