Initial commit
This commit is contained in:
@@ -0,0 +1,564 @@
|
||||
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'
|
||||
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'
|
||||
|
||||
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-4 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">
|
||||
<h1 className="text-xl font-semibold sm:text-2xl">Reserva tu turno</h1>
|
||||
<p className="mt-2 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user