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:
130
frontend/src/lib/publicBooking.ts
Normal file
130
frontend/src/lib/publicBooking.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user