447 lines
20 KiB
TypeScript
447 lines
20 KiB
TypeScript
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<number | null>(null);
|
|
const [sucursal, setSucursal] = useState<string | null>(null);
|
|
const [medicoId, setMedicoId] = useState<number | null>(null);
|
|
const [fecha, setFecha] = useState<string>(() => fechaISO(new Date()));
|
|
const [hora, setHora] = useState<string | null>(null);
|
|
const [slots, setSlots] = useState<string[]>([]);
|
|
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<BookingCreateResult | null>(null);
|
|
const [error, setError] = useState<string | null>(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<GrupoServicios[]>(() => {
|
|
if (!config) return [];
|
|
const out: GrupoServicios[] = [];
|
|
const usados = new Set<string>();
|
|
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 (
|
|
<Centro>
|
|
<Loader2 className="h-6 w-6 animate-spin text-theme-muted" />
|
|
</Centro>
|
|
);
|
|
}
|
|
|
|
if (!config) {
|
|
return (
|
|
<Centro>
|
|
<div className="w-full max-w-sm rounded-3xl border border-theme-border bg-theme-surface p-8 text-center shadow-soft">
|
|
<Clock className="mx-auto mb-3 h-8 w-8 text-theme-muted" />
|
|
<h1 className="font-heading text-lg text-theme-heading">Reservas en línea no disponibles</h1>
|
|
<p className="mt-2 text-sm text-theme-muted">
|
|
Por el momento no puedes agendar desde aquí. Comunícate directamente con la clínica.
|
|
</p>
|
|
</div>
|
|
</Centro>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Centro>
|
|
<div className="w-full max-w-lg space-y-4">
|
|
<div className="rounded-3xl border border-theme-border bg-theme-surface p-6 text-center shadow-soft">
|
|
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-emerald-100">
|
|
<Check className="h-6 w-6 text-emerald-600" />
|
|
</div>
|
|
<h1 className="font-heading text-xl text-theme-heading">
|
|
{resultado.requiere_confirmacion ? 'Solicitud recibida' : '¡Reserva confirmada!'}
|
|
</h1>
|
|
<p className="mt-1 text-sm text-theme-muted">Folio <span className="font-semibold text-theme-heading">{resultado.folio}</span></p>
|
|
<div className="mt-5 space-y-2 rounded-2xl bg-theme-bg p-4 text-left text-sm">
|
|
<Linea icon={<CalendarPlus className="h-4 w-4" />} texto={`${fechaLargaISO(resultado.fecha)} · ${resultado.hora}`} />
|
|
<Linea icon={<Check className="h-4 w-4" />} texto={resultado.servicio} />
|
|
{resultado.medico && <Linea icon={<User className="h-4 w-4" />} texto={resultado.medico} />}
|
|
{resultado.sucursal && <Linea icon={<MapPin className="h-4 w-4" />} texto={resultado.sucursal} />}
|
|
</div>
|
|
{resultado.mensaje_confirmacion && (
|
|
<p className="mt-4 text-sm text-theme-muted">{resultado.mensaje_confirmacion}</p>
|
|
)}
|
|
{resultado.requiere_confirmacion && !resultado.mensaje_confirmacion && (
|
|
<p className="mt-4 text-sm text-theme-muted">
|
|
Tu reserva quedó registrada y la clínica la confirmará en breve.
|
|
</p>
|
|
)}
|
|
<div className="mt-6 flex flex-col gap-2">
|
|
<Button onClick={() => descargarIcs(`cita-${resultado.folio}`, icalUrl)}>
|
|
<CalendarPlus size={16} className="mr-2" /> Agregar al calendario
|
|
</Button>
|
|
<Button variant="ghost" onClick={() => navigate(`/reservar/cita/${resultado.token_de_gestion}`)}>
|
|
Ver o cancelar mi reserva
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-2xl border border-theme-border bg-theme-surface p-4">
|
|
<p className="text-xs font-medium text-theme-muted">Guarda este enlace para gestionar tu reserva</p>
|
|
<div className="mt-2 flex items-center gap-2">
|
|
<input
|
|
readOnly
|
|
value={enlaceGestion}
|
|
className="min-w-0 flex-1 rounded-xl border border-theme-border bg-theme-bg px-3 py-2 text-xs text-theme-heading"
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
onClick={async () => {
|
|
try { await navigator.clipboard.writeText(enlaceGestion); setCopiado(true); } catch { /* ignore */ }
|
|
}}
|
|
>
|
|
<Copy size={14} className="mr-1" /> {copiado ? 'Copiado' : 'Copiar'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Centro>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Centro>
|
|
<div className="w-full max-w-lg">
|
|
<header className="mb-5 text-center">
|
|
{config.brand.logo_oscuro && (
|
|
<img src={config.brand.logo_oscuro} alt={config.brand.nombre} className="mx-auto mb-4 h-9 w-auto object-contain" />
|
|
)}
|
|
<h1 className="font-heading text-xl text-theme-heading">{config.config.titulo}</h1>
|
|
{config.config.descripcion && (
|
|
<p className="mt-1 text-sm text-theme-muted">{config.config.descripcion}</p>
|
|
)}
|
|
</header>
|
|
|
|
<div className="rounded-3xl border border-theme-border bg-theme-surface p-5 shadow-soft">
|
|
<div className="mb-4 flex items-center gap-2">
|
|
{paso > 0 && (
|
|
<button type="button" onClick={atras} aria-label="Atrás"
|
|
className="flex h-8 w-8 items-center justify-center rounded-full text-theme-muted hover:bg-theme-bg">
|
|
<ArrowLeft size={16} />
|
|
</button>
|
|
)}
|
|
<h2 className="font-heading text-base text-theme-heading">{tituloPaso()}</h2>
|
|
</div>
|
|
|
|
{/* Paso 1: servicio agrupado por categoría */}
|
|
{pasoActual === 'servicio' && (
|
|
<div className="space-y-5">
|
|
{grupos.map((g) => (
|
|
<div key={g.code}>
|
|
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-theme-muted">{g.nombre}</p>
|
|
<div className="space-y-2">
|
|
{g.servicios.map((s) => (
|
|
<button
|
|
key={s.id}
|
|
type="button"
|
|
onClick={() => { setServicioId(s.id); setHora(null); siguiente(); }}
|
|
className={`flex w-full items-center justify-between rounded-2xl border px-4 py-3 text-left transition ${
|
|
servicioId === s.id
|
|
? 'border-theme-accent bg-theme-accent-bg'
|
|
: 'border-theme-border hover:border-theme-border-strong'
|
|
}`}
|
|
>
|
|
<span>
|
|
<span className="block text-sm font-medium text-theme-heading">{s.nombre}</span>
|
|
<span className="mt-0.5 block text-xs text-theme-muted">
|
|
{s.duracion_min} min{s.precio > 0 ? ` · $${s.precio.toFixed(0)}` : ''}
|
|
</span>
|
|
</span>
|
|
<ChevronRight size={18} className="shrink-0 text-theme-muted" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Paso 2: sucursal */}
|
|
{pasoActual === 'sucursal' && (
|
|
<div className="space-y-2">
|
|
{config.sucursales.map((s) => (
|
|
<button
|
|
key={s.code}
|
|
type="button"
|
|
onClick={() => { setSucursal(s.code); siguiente(); }}
|
|
className={`flex w-full items-center justify-between rounded-2xl border px-4 py-3 text-left transition ${
|
|
sucursal === s.code ? 'border-theme-accent bg-theme-accent-bg' : 'border-theme-border hover:border-theme-border-strong'
|
|
}`}
|
|
>
|
|
<span>
|
|
<span className="block text-sm font-medium text-theme-heading">{s.name}</span>
|
|
{s.address && <span className="mt-0.5 block text-xs text-theme-muted">{s.address}</span>}
|
|
</span>
|
|
<ChevronRight size={18} className="shrink-0 text-theme-muted" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Paso 3: especialista */}
|
|
{pasoActual === 'especialista' && (
|
|
<div className="space-y-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => { setMedicoId(null); setHora(null); siguiente(); }}
|
|
className={`flex w-full items-center justify-between rounded-2xl border px-4 py-3 text-left transition ${
|
|
medicoId === null ? 'border-theme-accent bg-theme-accent-bg' : 'border-theme-border hover:border-theme-border-strong'
|
|
}`}
|
|
>
|
|
<span className="text-sm font-medium text-theme-heading">Cualquiera disponible</span>
|
|
<ChevronRight size={18} className="shrink-0 text-theme-muted" />
|
|
</button>
|
|
{config.medicos.map((m) => (
|
|
<button
|
|
key={m.id}
|
|
type="button"
|
|
onClick={() => { setMedicoId(m.id); setHora(null); siguiente(); }}
|
|
className={`flex w-full items-center justify-between rounded-2xl border px-4 py-3 text-left transition ${
|
|
medicoId === m.id ? 'border-theme-accent bg-theme-accent-bg' : 'border-theme-border hover:border-theme-border-strong'
|
|
}`}
|
|
>
|
|
<span className="text-sm font-medium text-theme-heading">{m.nombre}</span>
|
|
<ChevronRight size={18} className="shrink-0 text-theme-muted" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Paso 4: fecha y hora */}
|
|
{pasoActual === 'fecha' && (
|
|
<div className="space-y-5">
|
|
<div className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1">
|
|
{proximosDias(config.config.max_dias_futuro).map((d) => {
|
|
const iso = fechaISO(d);
|
|
const et = etiquetaDiaCorto(d);
|
|
const activo = fecha === iso;
|
|
return (
|
|
<button
|
|
key={iso}
|
|
type="button"
|
|
onClick={() => { setFecha(iso); setHora(null); }}
|
|
className={`flex min-w-[3.25rem] flex-col items-center rounded-2xl border px-2 py-2 text-xs transition ${
|
|
activo ? 'border-theme-accent bg-theme-accent text-theme-inverse' : 'border-theme-border text-theme-muted hover:border-theme-border-strong'
|
|
}`}
|
|
>
|
|
<span>{et.abrev}</span>
|
|
<span className="text-base font-semibold">{et.dia}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{slotsLoading ? (
|
|
<div className="flex justify-center py-6"><Loader2 className="h-5 w-5 animate-spin text-theme-muted" /></div>
|
|
) : slots.length === 0 ? (
|
|
<p className="py-4 text-center text-sm text-theme-muted">
|
|
No hay horarios disponibles ese día. Prueba con otra fecha.
|
|
</p>
|
|
) : (
|
|
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
|
|
{slots.map((s) => (
|
|
<button
|
|
key={s}
|
|
type="button"
|
|
onClick={() => setHora(s)}
|
|
className={`rounded-xl border px-2 py-2.5 text-sm font-medium transition ${
|
|
hora === s ? 'border-theme-accent bg-theme-accent text-theme-inverse' : 'border-theme-border text-theme-heading hover:border-theme-border-strong'
|
|
}`}
|
|
>
|
|
{s}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<Button className="w-full" disabled={!hora} onClick={siguiente}>Continuar</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Paso 5: datos del cliente */}
|
|
{pasoActual === 'datos' && (
|
|
<div className="space-y-4">
|
|
<div className="rounded-2xl bg-theme-bg p-3 text-xs text-theme-muted">
|
|
{servicioSel?.nombre} · {fechaLargaISO(fecha)} · {hora}
|
|
{medicoSel ? ` · ${medicoSel.nombre}` : ''}
|
|
</div>
|
|
<Input label="Nombre completo" value={nombre} onChange={(e) => setNombre(e.target.value)} autoComplete="name" />
|
|
<Input label="Teléfono (WhatsApp)" value={telefono} onChange={(e) => setTelefono(e.target.value)} inputMode="tel" autoComplete="tel" />
|
|
<Input label="Email (opcional)" value={email} onChange={(e) => setEmail(e.target.value)} inputMode="email" autoComplete="email" />
|
|
<TextArea label="Notas (opcional)" value={notas} onChange={(e) => setNotas(e.target.value)} rows={3} />
|
|
{/* Honeypot: invisible para personas; los bots suelen rellenarlo. */}
|
|
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', width: 1, height: 1, overflow: 'hidden' }}>
|
|
<label>No llenar este campo
|
|
<input
|
|
type="text"
|
|
name="empresa"
|
|
tabIndex={-1}
|
|
autoComplete="off"
|
|
value={hp}
|
|
onChange={(e) => setHp(e.target.value)}
|
|
/>
|
|
</label>
|
|
</div>
|
|
{error && <p className="text-sm text-rose-600">{error}</p>}
|
|
<Button className="w-full" loading={enviando} onClick={enviar}>
|
|
{config.config.requiere_confirmacion ? 'Solicitar reserva' : 'Confirmar reserva'}
|
|
</Button>
|
|
{config.config.telefono_contacto && (
|
|
<p className="flex items-center justify-center gap-1 text-center text-xs text-theme-muted">
|
|
<MessageCircle size={12} /> ¿Dudas? {config.config.telefono_contacto}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{!puedeSeguir && error && <p className="mt-3 text-sm text-rose-600">{error}</p>}
|
|
</div>
|
|
|
|
<p className="mt-6 text-center text-xs text-theme-muted">{config.brand.nombre}</p>
|
|
</div>
|
|
</Centro>
|
|
);
|
|
};
|
|
|
|
const Centro: FC<{ children: ReactNode }> = ({ children }) => (
|
|
<div className="flex min-h-screen items-start justify-center bg-theme-bg px-4 py-8 sm:items-center">
|
|
{children}
|
|
</div>
|
|
);
|
|
|
|
const Linea: FC<{ icon: ReactNode; texto: string }> = ({ icon, texto }) => (
|
|
<div className="flex items-center gap-2 text-theme-heading">
|
|
<span className="text-theme-muted">{icon}</span>
|
|
<span>{texto}</span>
|
|
</div>
|
|
);
|
|
|
|
export default Reservar;
|