import { useEffect, useMemo, useState } from "react"; import { useParams } from "react-router-dom"; import { useMutation, useQuery } from "@tanstack/react-query"; import { Sparkles, Clock, CalendarDays, User, Check, ChevronRight, ChevronLeft, Phone, MapPin, CalendarCheck, PartyPopper, AlertCircle, Mail, } 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 } 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 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 ?? []; 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] ); 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) => { setResult(res); 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); bookMut.reset(); }; if (!slug) { return ; } if (businessQuery.isLoading) { return (
Cargando reservas…
); } if (businessQuery.isError || !business || !data) { return ( ); } 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); } }; const pickEmployee = (id: number | null) => { setEmployeeId(id); setSelectedSlot(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; 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); }} min={todayStr()} max={maxBookableDate()} slots={slots} loading={slotsQuery.isLoading} isError={slotsQuery.isError} selectedSlot={selectedSlot} onPick={pickSlot} /> )} {currentOriginal === 4 && selectedService && selectedSlot && ( } title="Tus datos" subtitle="Necesitamos algunos datos para confirmar tu cita." > {!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, }: { employees: { id: number; name: string; role: string; color: string }[]; selectedId: number | null; onPick: (id: number | null) => void; }) { const anyActive = selectedId === null; return (
{employees.map((e) => { const active = e.id === selectedId; return ( ); })}
); } function DateTimePicker({ date, onDate, min, max, slots, loading, isError, selectedSlot, onPick, }: { date: string; onDate: (d: string) => void; min: string; max?: string; slots: PublicSlot[]; loading: boolean; isError: boolean; selectedSlot: PublicSlot | null; onPick: (s: PublicSlot) => void; }) { const { morning, afternoon } = useMemo(() => { const morning: PublicSlot[] = []; const afternoon: PublicSlot[] = []; for (const s of slots) { const m = /(\d{1,2}):(\d{2})/.exec(s.time); const h = m ? Number(m[1]) : 0; const pm = /PM/i.test(s.time); const hour24 = pm && h !== 12 ? h + 12 : !pm && h === 12 ? 0 : h; (hour24 < 12 ? morning : afternoon).push(s); } return { morning, afternoon }; }, [slots]); return (
{/* Columna izquierda: calendario */}
onDate(d)} />
Con cupo Hoy
{/* Columna derecha: horarios */}
{loading && (
Buscando horarios…
)} {!loading && isError && (

No pudimos cargar los horarios. Intenta 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, }: { title: string; slots: PublicSlot[]; selectedSlot: PublicSlot | null; onPick: (s: PublicSlot) => void; }) { 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, }: { client: { name: string; phone: string; email: string; notes: string }; setClient: (c: { name: string; phone: string; email: string; notes: string }) => void; summary: React.ReactNode; }) { return (
{summary}
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" />