import type { FC, ReactNode } from 'react'; import { useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { ArrowLeft, CalendarPlus, Check, ChevronRight, Clock, Copy, Loader2, MapPin, MessageCircle, User, } from 'lucide-react'; import { Button, Input, TextArea } from '../components/ui'; import { odooApi, type BookingConfig, type BookingCreateResult } from '../services/odoo'; import { useBookingConfig, proximosDias, fechaISO, etiquetaDiaCorto, fechaLargaISO, construirIcs, descargarIcs, normalizarTelefono, telefonoValido, } from '../lib/publicBooking'; // Portal público de reservas en línea (sin sesión). Mobile-first: la mayoría // de las clientas reservan del celular. Respeta brand y glosario del backend. type ServicioPortal = BookingConfig['servicios'][number]; type GrupoServicios = { code: string; nombre: string; servicios: ServicioPortal[] }; const Reservar: FC = () => { const navigate = useNavigate(); const { config, loading } = useBookingConfig(); const [paso, setPaso] = useState(0); const [servicioId, setServicioId] = useState(null); const [sucursal, setSucursal] = useState(null); const [medicoId, setMedicoId] = useState(null); const [fecha, setFecha] = useState(() => fechaISO(new Date())); const [hora, setHora] = useState(null); const [slots, setSlots] = useState([]); const [slotsLoading, setSlotsLoading] = useState(false); const [nombre, setNombre] = useState(''); const [telefono, setTelefono] = useState(''); const [email, setEmail] = useState(''); const [notas, setNotas] = useState(''); const [hp, setHp] = useState(''); // honeypot anti-bot const [enviando, setEnviando] = useState(false); const [resultado, setResultado] = useState(null); const [error, setError] = useState(null); const [copiado, setCopiado] = useState(false); const pasos = useMemo(() => { const p: string[] = ['servicio']; if (config?.config.permite_sucursal && config.sucursales.length > 1) p.push('sucursal'); if (config?.config.permite_eleccion_doctor) p.push('especialista'); p.push('fecha', 'datos'); return p; }, [config]); const pasoActual = pasos[Math.min(paso, pasos.length - 1)]; const servicioSel = config?.servicios.find((s) => s.id === servicioId) || null; const medicoSel = config?.medicos.find((m) => m.id === medicoId) || null; const grupos = useMemo(() => { if (!config) return []; const out: GrupoServicios[] = []; const usados = new Set(); for (const c of config.categorias) { const svcs = config.servicios.filter((s) => s.categoria === c.code); if (svcs.length) { out.push({ code: c.code, nombre: c.name, servicios: svcs }); usados.add(c.code); } } const resto = config.servicios.filter((s) => !usados.has(s.categoria)); if (resto.length) out.push({ code: 'otros', nombre: 'Otros', servicios: resto }); return out; }, [config]); useEffect(() => { if (!config || pasoActual !== 'fecha' || !servicioId) return; let alive = true; setSlotsLoading(true); setSlots([]); odooApi.getBookingSlots({ servicio_id: servicioId, fecha, medico_id: medicoId }) .then((res) => { if (alive && res.status === 'success') setSlots(res.slots || []); }) .catch(() => { if (alive) setSlots([]); }) .finally(() => { if (alive) setSlotsLoading(false); }); return () => { alive = false; }; }, [config, pasoActual, servicioId, fecha, medicoId]); const siguiente = () => setPaso((p) => Math.min(p + 1, pasos.length)); const atras = () => setPaso((p) => Math.max(p - 1, 0)); const enviar = async () => { if (!servicioId || !hora) return; if (nombre.trim().length < 2) { setError('Escribe tu nombre'); return; } if (!telefonoValido(telefono)) { setError('Escribe un teléfono válido (10 dígitos)'); return; } setEnviando(true); setError(null); try { const res = await odooApi.createBooking({ servicio_id: servicioId, fecha, hora, medico_id: medicoId, sucursal, nombre: nombre.trim(), telefono: normalizarTelefono(telefono), email: email.trim(), notas: notas.trim(), hp, }); setResultado(res); window.scrollTo({ top: 0, behavior: 'smooth' }); } catch (err) { const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; setError(msg || 'No se pudo completar la reserva. Intenta de nuevo.'); } finally { setEnviando(false); } }; const term = (k: string, fallback: string) => config?.terms?.[k as keyof typeof config.terms] || fallback; if (loading) { return ( ); } if (!config) { return (

Reservas en línea no disponibles

Por el momento no puedes agendar desde aquí. Comunícate directamente con la clínica.

); } const icalUrl = resultado ? construirIcs({ folio: resultado.folio, fecha: resultado.fecha, hora: resultado.hora, duracionMin: servicioSel?.duracion_min || 30, titulo: `${resultado.servicio} · ${config.brand.nombre}`, descripcion: `Folio ${resultado.folio}`, lugar: resultado.sucursal, }) : ''; const enlaceGestion = resultado ? `${window.location.origin}/reservar/cita/${resultado.token_de_gestion}` : ''; // ---- Pantalla final: folio + calendario + enlace de gestión ---- if (resultado) { return (

{resultado.requiere_confirmacion ? 'Solicitud recibida' : '¡Reserva confirmada!'}

Folio {resultado.folio}

} texto={`${fechaLargaISO(resultado.fecha)} · ${resultado.hora}`} /> } texto={resultado.servicio} /> {resultado.medico && } texto={resultado.medico} />} {resultado.sucursal && } texto={resultado.sucursal} />}
{resultado.mensaje_confirmacion && (

{resultado.mensaje_confirmacion}

)} {resultado.requiere_confirmacion && !resultado.mensaje_confirmacion && (

Tu reserva quedó registrada y la clínica la confirmará en breve.

)}

Guarda este enlace para gestionar tu reserva

); } const tituloPaso = (): string => { switch (pasoActual) { case 'servicio': return `Elige tu ${term('service_singular', 'servicio').toLowerCase()}`; case 'sucursal': return `Elige ${term('branch_singular', 'sucursal').toLowerCase()}`; case 'especialista': return `Elige ${term('doctor_singular', 'especialista').toLowerCase()}`; case 'fecha': return 'Elige fecha y hora'; case 'datos': return 'Tus datos'; default: return ''; } }; const puedeSeguir = ( (pasoActual === 'servicio' && servicioId) || (pasoActual === 'sucursal' && !!sucursal) || (pasoActual === 'especialista') || (pasoActual === 'fecha' && !!hora) || pasoActual === 'datos' ); return (
{config.brand.logo_oscuro && ( {config.brand.nombre} )}

{config.config.titulo}

{config.config.descripcion && (

{config.config.descripcion}

)}
{paso > 0 && ( )}

{tituloPaso()}

{/* Paso 1: servicio agrupado por categoría */} {pasoActual === 'servicio' && (
{grupos.map((g) => (

{g.nombre}

{g.servicios.map((s) => ( ))}
))}
)} {/* Paso 2: sucursal */} {pasoActual === 'sucursal' && (
{config.sucursales.map((s) => ( ))}
)} {/* Paso 3: especialista */} {pasoActual === 'especialista' && (
{config.medicos.map((m) => ( ))}
)} {/* Paso 4: fecha y hora */} {pasoActual === 'fecha' && (
{proximosDias(config.config.max_dias_futuro).map((d) => { const iso = fechaISO(d); const et = etiquetaDiaCorto(d); const activo = fecha === iso; return ( ); })}
{slotsLoading ? (
) : slots.length === 0 ? (

No hay horarios disponibles ese día. Prueba con otra fecha.

) : (
{slots.map((s) => ( ))}
)}
)} {/* Paso 5: datos del cliente */} {pasoActual === 'datos' && (
{servicioSel?.nombre} · {fechaLargaISO(fecha)} · {hora} {medicoSel ? ` · ${medicoSel.nombre}` : ''}
setNombre(e.target.value)} autoComplete="name" /> setTelefono(e.target.value)} inputMode="tel" autoComplete="tel" /> setEmail(e.target.value)} inputMode="email" autoComplete="email" />