import { useEffect, useMemo, useState } from "react"; import { useParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Sparkles, Clock, CalendarDays, User, Check, ChevronRight, ChevronLeft, Phone, MapPin, CalendarCheck, PartyPopper, AlertCircle, Mail, RefreshCw, Info, } from "lucide-react"; import { getBusiness, getSlots, book, type PublicService, type PublicSlot, type BookResponse, } from "../../lib/publicApi"; import { Spinner } from "../../components/ui"; import { MonthCalendar } from "../../components/MonthCalendar"; import { cn, formatCurrency, formatDate, formatTime, hourInTz } from "../../lib/format"; function todayStr() { const d = new Date(); const pad = (n: number) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; } function maxBookableDate() { const d = new Date(); d.setMonth(d.getMonth() + 3); const pad = (n: number) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; } const FULL_STEPS = [ { n: 1, label: "Servicio" }, { n: 2, label: "Especialista" }, { n: 3, label: "Horario" }, { n: 4, label: "Datos" }, ] as const; function stepsFor(autoAssign: boolean) { return autoAssign ? FULL_STEPS.filter((s) => s.label !== "Especialista").map((s, i) => ({ n: i + 1, label: s.label, original: s.n })) : FULL_STEPS.map((s, i) => ({ n: i + 1, label: s.label, original: s.n })); } export default function BookingPage() { const { slug } = useParams<{ slug: string }>(); const [step, setStep] = useState(1); const [serviceId, setServiceId] = useState(null); const [employeeId, setEmployeeId] = useState(null); const [selectedDate, setSelectedDate] = useState(todayStr()); const [selectedSlot, setSelectedSlot] = useState(null); const [client, setClient] = useState({ name: "", phone: "", email: "", notes: "" }); const [result, setResult] = useState(null); const [bookError, setBookError] = useState(null); const queryClient = useQueryClient(); const businessQuery = useQuery({ queryKey: ["public", "business", slug], queryFn: () => getBusiness(slug!), enabled: !!slug, retry: 1, }); const data = businessQuery.data; const business = data?.business; const services = data?.services ?? []; const employees = data?.employees ?? []; // Toda hora que ve el cliente se rinde en la zona del NEGOCIO. Si se cae a la del // navegador, alguien que reserva desde otro huso ve una hora que no es la de su cita. const tz = business?.timezone || "America/Mexico_City"; const autoAssign = !!business?.auto_assign_specialist; const steps = useMemo(() => stepsFor(autoAssign), [autoAssign]); const stepCount = steps.length; const currentOriginal = steps.find((s) => s.n === step)?.original; const selectedService = useMemo( () => services.find((s) => s.id === serviceId), [services, serviceId] ); // Un especialista que no ofrece el servicio hace que /slots responda 400. Antes se // listaban todos, así que el cliente podía elegir a alguien imposible y solo se // enteraba un paso después, con un mensaje que le pedía cambiar la fecha. const eligibleEmployees = useMemo( () => (serviceId == null ? [] : employees.filter((e) => e.service_ids.includes(serviceId))), [employees, serviceId] ); const slotsQuery = useQuery({ queryKey: ["public", "slots", slug, serviceId, selectedDate, employeeId], queryFn: () => getSlots(slug!, serviceId!, selectedDate, employeeId ?? undefined), enabled: currentOriginal === 3 && !!serviceId && !!selectedDate && !result, }); const slots = slotsQuery.data?.slots ?? []; const bookMut = useMutation({ mutationFn: (payload: Parameters[1]) => book(slug!, payload), onSuccess: (res) => { setBookError(null); setResult(res); window.scrollTo({ top: 0, behavior: "smooth" }); }, onError: (err: Error) => { setBookError(err?.message || "No pudimos completar tu reserva. Inténtalo de nuevo."); setResult(null); queryClient.invalidateQueries({ queryKey: ["public", "slots", slug] }); window.scrollTo({ top: 0, behavior: "smooth" }); }, }); useEffect(() => { window.scrollTo({ top: 0, behavior: "smooth" }); }, [step]); const reset = () => { setStep(1); setServiceId(null); setEmployeeId(null); setSelectedDate(todayStr()); setSelectedSlot(null); setClient({ name: "", phone: "", email: "", notes: "" }); setResult(null); setBookError(null); bookMut.reset(); }; if (!slug) { return ; } if (businessQuery.isLoading) { return (
Cargando reservas…
); } if (businessQuery.isError || !business || !data) { return ( ); } const hasPolicy = (business.cancel_penalty_pct ?? 0) > 0 || !!business.require_deposit; const policyNote = ( ); if (result) { return ( ); } if (!business.booking_enabled) { return (

Reservas desactivadas

{business.name} no está recibiendo reservas en línea en este momento.

{business.phone && ( Llamar al {business.phone} )}
); } const goNext = () => setStep((s) => Math.min(stepCount, s + 1)); const goBack = () => setStep((s) => Math.max(1, s - 1)); const pickService = (id: number) => { if (serviceId !== id) { setServiceId(id); setSelectedSlot(null); setBookError(null); // El especialista elegido puede no ofrecer el servicio nuevo: soltarlo en vez // de arrastrar una combinación que el servidor rechazará. const stillValid = employees.some((e) => e.id === employeeId && e.service_ids.includes(id)); if (!stillValid) setEmployeeId(null); } }; const pickEmployee = (id: number | null) => { setEmployeeId(id); setSelectedSlot(null); setBookError(null); }; const pickSlot = (slot: PublicSlot) => { setSelectedSlot(slot); setStep((s) => Math.min(stepCount, s + 1)); }; const handleBook = () => { if (!selectedService || !selectedSlot) return; if (!client.name.trim()) return; setBookError(null); bookMut.mutate({ service_id: selectedService.id, employee_id: autoAssign ? undefined : selectedSlot.employee_id, start_at: selectedSlot.iso, client: { name: client.name.trim(), email: client.email.trim() || undefined, phone: client.phone.trim() || undefined, notes: client.notes.trim() || undefined, }, }); }; const canContinue = (currentOriginal === 1 && !!serviceId) || currentOriginal === 2 || (currentOriginal === 3 && !!selectedSlot) || (currentOriginal === 4 && client.name.trim().length > 0); const currency = business.currency_symbol || "$"; const primaryLabel = currentOriginal === 4 ? "Confirmar reserva" : "Continuar"; const onPrimary = currentOriginal === 4 ? handleBook : goNext; return (
{currentOriginal === 1 && }
{currentOriginal === 1 && ( } title="Elige un servicio" subtitle="Selecciona el servicio que deseas reservar." > )} {currentOriginal === 2 && ( } title="Elige un especialista" subtitle="¿Tienes una preferencia? O déjalo en cualquiera." > )} {currentOriginal === 3 && selectedService && ( } title="Elige fecha y hora" subtitle={`${selectedService.name} · ${selectedService.duration_min} min · ${formatCurrency(selectedService.price, currency)}`} > { setSelectedDate(d); setSelectedSlot(null); setBookError(null); }} min={todayStr()} max={maxBookableDate()} slots={slots} loading={slotsQuery.isLoading} isError={slotsQuery.isError} errorMessage={slotsQuery.error instanceof Error ? slotsQuery.error.message : undefined} selectedSlot={selectedSlot} onPick={pickSlot} tz={tz} /> )} {currentOriginal === 4 && selectedService && selectedSlot && ( } title="Tus datos" subtitle="Necesitamos algunos datos para confirmar tu cita." > {bookError && (

{bookError}

)} {!autoAssign && } } />
)}
); } function TopBar({ name, industry, step, stepCount, steps }: { name: string; industry: string; step: number; stepCount: number; steps: { n: number; label: string }[]; }) { const current = steps.find((s) => s.n === step); return (
{name}
{industry}
{step > 0 && (
Paso {step} de {stepCount}
{steps.map((s) => (
{s.n < step ? : {s.n}} {s.label}
))}
{step} / {stepCount} · {current?.label}
)}
); } function Hero({ business }: { business: { name: string; industry: string; phone: string | null; address: string | null } }) { return (
Reservas en línea

{business.name}

Reserva tu cita en {business.industry.toLowerCase()} en pocos segundos. Sin llamadas, sin esperas.

{(business.address || business.phone) && (
{business.address && ( {business.address} )} {business.phone && ( {business.phone} )}
)}
); } function StepShell({ icon, title, subtitle, children, }: { icon: React.ReactNode; title: string; subtitle?: string; children: React.ReactNode; }) { return (
{icon}

{title}

{subtitle &&

{subtitle}

}
{children}
); } function ServiceGrid({ services, currency, selectedId, onPick, }: { services: PublicService[]; currency: string; selectedId: number | null; onPick: (id: number) => void; }) { if (services.length === 0) { return (
Este negocio aún no tiene servicios disponibles para reservar.
); } return (
{services.map((s) => { const active = s.id === selectedId; return ( ); })}
); } function EmployeeList({ employees, selectedId, onPick, serviceName, }: { employees: { id: number; name: string; role: string; color: string }[]; selectedId: number | null; onPick: (id: number | null) => void; serviceName?: string; }) { const anyActive = selectedId === null; return (
{employees.length === 0 && (

{serviceName ? `Para ${serviceName} asignamos al especialista disponible.` : "Asignamos al especialista disponible."}

)} {employees.map((e) => { const active = e.id === selectedId; return ( ); })}
); } function DateTimePicker({ date, onDate, min, max, slots, loading, isError, errorMessage, selectedSlot, onPick, tz, }: { date: string; onDate: (d: string) => void; min: string; max?: string; slots: PublicSlot[]; loading: boolean; isError: boolean; errorMessage?: string; selectedSlot: PublicSlot | null; onPick: (s: PublicSlot) => void; tz: string; }) { // La hora sale del instante `iso` resuelto en la tz del negocio. Antes se parseaba // `s.time`, ya localizado: es-MX rinde "05:00 p.m." y `/PM/i` no casa con "p.m.", // así que TODOS los slots de la tarde se agrupaban bajo "Mañana". const { morning, afternoon } = useMemo(() => { const morning: PublicSlot[] = []; const afternoon: PublicSlot[] = []; for (const s of slots) { (hourInTz(s.iso, tz) < 12 ? morning : afternoon).push(s); } return { morning, afternoon }; }, [slots, tz]); return (
{/* Columna izquierda: calendario */}
onDate(d)} />
Con cupo Hoy
{/* Columna derecha: horarios */}
{loading && (
Buscando horarios…
)} {!loading && isError && (
{/* Se muestra el motivo real del servidor. El texto fijo anterior ("intenta otra fecha") mandaba al cliente a probar días al azar cuando el problema era el especialista, y ninguna fecha lo resolvía. */}

{errorMessage || "No pudimos cargar los horarios."}

Prueba con otro especialista u otra fecha.

)} {!loading && !isError && slots.length === 0 && (

Sin horarios disponibles

Prueba con otra fecha o especialista.

)} {!loading && !isError && slots.length > 0 && (
)}
); } function SlotGroup({ title, slots, selectedSlot, onPick, tz, }: { title: string; slots: PublicSlot[]; selectedSlot: PublicSlot | null; onPick: (s: PublicSlot) => void; tz: string; }) { if (slots.length === 0) return null; return (
{title} {slots.length} opciones
{slots.map((slot) => { const active = selectedSlot?.iso === slot.iso && selectedSlot?.employee_id === slot.employee_id; return ( ); })}
); } function DetailsForm({ client, setClient, summary, policy, }: { client: { name: string; phone: string; email: string; notes: string }; setClient: (c: { name: string; phone: string; email: string; notes: string }) => void; summary: React.ReactNode; policy?: React.ReactNode; }) { return (
{summary}
{policy}
setClient({ ...client, name: e.target.value })} placeholder="Tu nombre" autoFocus />
setClient({ ...client, phone: e.target.value })} placeholder="+52 …" />
setClient({ ...client, email: e.target.value })} placeholder="tucorreo@email.com" />