Initial commit

This commit is contained in:
Jose Selesan
2026-04-08 22:53:11 -03:00
commit 9ae270609d
179 changed files with 28096 additions and 0 deletions

View File

@@ -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>
)
}