fase 2.3: portal publico de reservas en linea (5 endpoints anonimos con rate limit y token HMAC, wizard movil, gestion/cancelacion por enlace, distintivo en agenda, editor y enlace publico en Configuracion); apagado por defecto

This commit is contained in:
2026-10-11 08:28:33 +00:00
parent 358b85afcd
commit add9b28188
14 changed files with 1859 additions and 4 deletions

View File

@@ -0,0 +1,130 @@
import { useEffect, useState } from 'react';
import { odooApi, type BookingConfig } from '../services/odoo';
// Utilidades y hook compartidos del portal público de reservas en línea.
// El portal es anónimo: solo usa los endpoints /public/booking/*, que
// responden 404 cuando el módulo está desactivado.
/**
* Config del portal público. Devuelve `config=null` cuando está desactivado
* (404) o el backend no responde: en ese caso el portal se muestra como "no
* disponible" y no se ofrece ningún enlace.
*/
export const useBookingConfig = () => {
const [config, setConfig] = useState<BookingConfig | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let alive = true;
odooApi.getBookingConfig()
.then((res) => { if (alive && res && res.activo) setConfig(res); })
.catch(() => { if (alive) setConfig(null); })
.finally(() => { if (alive) setLoading(false); });
return () => { alive = false; };
}, []);
return { config, loading, active: !!config };
};
const DIAS = ['Domingo', 'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado'];
const DIAS_ABREV = ['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'];
const MESES = ['enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio', 'julio',
'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre'];
/** 'YYYY-MM-DD' local de un Date (sin cruzar a UTC). */
export const fechaISO = (d: Date): string => {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
};
/** Próximos `max` días desde hoy (inclusive), para el selector de fecha. */
export const proximosDias = (max: number, tope = 30): Date[] => {
const n = Math.max(1, Math.min(max || 1, tope));
const hoy = new Date();
hoy.setHours(0, 0, 0, 0);
return Array.from({ length: n }, (_, i) => new Date(hoy.getFullYear(), hoy.getMonth(), hoy.getDate() + i));
};
export const etiquetaDiaCorto = (d: Date): { abrev: string; dia: string } => ({
abrev: DIAS_ABREV[d.getDay()],
dia: String(d.getDate()),
});
export const fechaLarga = (d: Date): string =>
`${DIAS[d.getDay()]} ${d.getDate()} de ${MESES[d.getMonth()]}`;
export const fechaLargaISO = (iso: string): string => {
const [y, m, day] = iso.split('-').map(Number);
return fechaLarga(new Date(y, (m || 1) - 1, day || 1));
};
/** Evento .ics (hora local flotante) para "agregar al calendario". */
export const construirIcs = (opts: {
folio: string;
fecha: string; // YYYY-MM-DD
hora: string; // HH:MM
duracionMin: number;
titulo: string;
descripcion?: string;
lugar?: string;
}): string => {
const [y, m, d] = opts.fecha.split('-').map(Number);
const [hh, mm] = opts.hora.split(':').map(Number);
const fmt = (dt: Date) =>
`${dt.getFullYear()}${String(dt.getMonth() + 1).padStart(2, '0')}${String(dt.getDate()).padStart(2, '0')}` +
`T${String(dt.getHours()).padStart(2, '0')}${String(dt.getMinutes()).padStart(2, '0')}00`;
const inicio = new Date(y, (m || 1) - 1, d || 1, hh || 0, mm || 0);
const fin = new Date(inicio.getTime() + Math.max(opts.duracionMin || 30, 5) * 60000);
const esc = (s: string) => String(s || '').replace(/([,;\\])/g, '\\$1').replace(/\n/g, '\\n');
return [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//SKEEN//Reservas en linea//ES',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'BEGIN:VEVENT',
`UID:${esc(opts.folio)}@skeen`,
`DTSTAMP:${fmt(new Date())}`,
`DTSTART:${fmt(inicio)}`,
`DTEND:${fmt(fin)}`,
`SUMMARY:${esc(opts.titulo)}`,
opts.lugar ? `LOCATION:${esc(opts.lugar)}` : '',
opts.descripcion ? `DESCRIPTION:${esc(opts.descripcion)}` : '',
'END:VEVENT',
'END:VCALENDAR',
].filter(Boolean).join('\r\n');
};
export const descargarIcs = (nombreArchivo: string, contenido: string) => {
const blob = new Blob([contenido], { type: 'text/calendar;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = nombreArchivo.endsWith('.ics') ? nombreArchivo : `${nombreArchivo}.ics`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
};
/** URL base pública de la clínica (para el enlace /reservar compartible). */
export const urlBasePublica = (urlPublica?: string): string => {
const base = (urlPublica || '').trim();
if (base) return base.replace(/\/+$/, '');
if (typeof window !== 'undefined') return window.location.origin;
return '';
};
export const enlaceReserva = (urlPublica?: string): string => `${urlBasePublica(urlPublica)}/reservar`;
/** Formatea el teléfono a solo dígitos (con + inicial si venía). */
export const normalizarTelefono = (valor: string): string => {
const raw = (valor || '').trim();
const digitos = raw.replace(/\D/g, '');
return raw.startsWith('+') && digitos ? `+${digitos}` : digitos;
};
export const telefonoValido = (valor: string): boolean => {
const digitos = normalizarTelefono(valor).replace(/\D/g, '');
return digitos.length >= 10 && digitos.length <= 15;
};