571 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}
|