Initial commit: monorepo Naturcalabacera reservas (apps/api + apps/web + packages/shared)

This commit is contained in:
2026-04-30 10:09:44 +01:00
commit a0ccb8ca64
188 changed files with 16418 additions and 0 deletions

View File

@@ -0,0 +1,591 @@
/**
* ChatbotContainer — asistente conversacional con datos reales de Supabase.
*
* Arquitectura lista para IA real: la función processMessage() actualmente
* usa un motor de reglas. Para conectar un LLM, sustituye su cuerpo por
* una llamada a la API (Claude, GPT, etc.) pasando los datos de reservas
* como contexto del sistema.
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { MessageCircle, X, Send, Home, RefreshCw } from 'lucide-react';
import { supabase } from '../lib/supabase';
import type { Reservation, Property } from '../types';
import { format, parseISO, isAfter, isBefore, differenceInDays } from 'date-fns';
import { es } from 'date-fns/locale/es';
// ─── Types ────────────────────────────────────────────────────────────────────
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function fmtDate(d: string) {
return format(parseISO(d), "d MMM yyyy", { locale: es });
}
function fmtShort(d: string) {
return format(parseISO(d), "d MMM", { locale: es });
}
function propertyLabel(p: Property) {
return p === 'los_dragos' ? 'Los Dragos' : 'La Esquinita';
}
function nightsBetween(start: string, end: string) {
return differenceInDays(parseISO(end), parseISO(start));
}
// ─── Response engine ──────────────────────────────────────────────────────────
function guestDetail(r: Reservation): string {
const nights = nightsBetween(r.start_date, r.end_date);
const pax = r.adults_count + r.children_count;
const lines: string[] = [
`👤 **${r.client_name}**`,
`📅 ${fmtDate(r.start_date)}${fmtDate(r.end_date)} (${nights} noche${nights !== 1 ? 's' : ''})`,
`👥 ${pax} persona${pax !== 1 ? 's' : ''} (${r.adults_count} adultos, ${r.children_count} niños)`,
`🏷️ Origen: ${r.origin}`,
];
if (r.government_registration) {
lines.push(`🏛️ Reg. gubernamental: \`${r.government_registration}\``);
} else {
lines.push(`⚠️ Sin registro gubernamental`);
}
if (r.invoice_number) lines.push(`🧾 Factura: ${r.invoice_number}`);
if (r.has_cleaning) lines.push(`🧹 Servicio de limpieza incluido`);
if (r.has_pool_heating) lines.push(`♨️ Calefacción de piscina incluida`);
if (r.has_flies_products) lines.push(`🦟 Productos anti-mosquitos incluidos`);
if (r.is_event) {
lines.push(`🎉 Evento: ${r.event_type ?? ''}${r.event_type_other ? ` (${r.event_type_other})` : ''}`);
if (r.attendees_count) lines.push(` Asistentes: ${r.attendees_count}`);
}
if (r.pricing_snapshot) {
const p = r.pricing_snapshot;
lines.push(`💶 Total: ${p.total.toLocaleString('es-ES', { style: 'currency', currency: 'EUR' })}`);
}
if (r.observations) lines.push(`📝 Notas: ${r.observations}`);
return lines.join('\n');
}
function statsInfo(reservations: Reservation[], property: Property): string {
const now = new Date();
const label = propertyLabel(property);
const total = reservations.length;
const active = reservations.filter(r =>
!isAfter(parseISO(r.start_date), now) && !isBefore(parseISO(r.end_date), now)
);
const upcoming = reservations.filter(r => isAfter(parseISO(r.start_date), now));
const past = reservations.filter(r => isBefore(parseISO(r.end_date), now));
const teneriffa = reservations.filter(r => r.origin === 'Teneriffa2000');
const natur = reservations.filter(r => r.origin === 'Naturcalabacera');
const withReg = reservations.filter(r => r.government_registration);
const withoutReg = reservations.filter(r => !r.government_registration);
const totalNights = reservations.reduce((sum, r) => sum + nightsBetween(r.start_date, r.end_date), 0);
const lines = [
`📊 **Resumen — ${label}**\n`,
`• Total reservas: **${total}**`,
`• Activas ahora: ${active.length}`,
`• Próximas: ${upcoming.length}`,
`• Pasadas: ${past.length}`,
`• Noches totales reservadas: ${totalNights}`,
``,
`📋 **Por origen:**`,
`• Teneriffa2000: ${teneriffa.length}`,
`• Naturcalabacera: ${natur.length}`,
``,
`🏛️ **Registros gubernamentales:**`,
`• Con registro: ${withReg.length}`,
`• Sin registro: ${withoutReg.length}${withoutReg.length > 0 ? ' ⚠️' : ' ✅'}`,
];
return lines.join('\n');
}
function contractsInfo(reservations: Reservation[]): string {
const now = new Date();
const withReg = reservations.filter(r => r.government_registration);
const pendingReg = reservations.filter(
r => !r.government_registration && !isBefore(parseISO(r.end_date), now)
);
const lines: string[] = ['🏛️ **Registros y contratos**\n'];
if (withReg.length > 0) {
lines.push(`✅ **Con registro (${withReg.length}):**`);
withReg.forEach(r => {
lines.push(`${r.client_name}\`${r.government_registration}\``);
if (r.invoice_number) lines.push(` Factura: ${r.invoice_number}`);
lines.push(` ${fmtShort(r.start_date)}${fmtShort(r.end_date)}`);
});
}
if (pendingReg.length > 0) {
lines.push(`\n⚠ **Sin registro (activas/futuras) — ${pendingReg.length}:**`);
pendingReg.forEach(r => {
lines.push(`${r.client_name} (${fmtShort(r.start_date)}${fmtShort(r.end_date)})`);
});
}
if (withReg.length === 0 && pendingReg.length === 0) {
lines.push('No hay reservas con información de contratos disponible.');
}
return lines.join('\n');
}
function upcomingInfo(reservations: Reservation[]): string {
const now = new Date();
const upcoming = reservations
.filter(r => isAfter(parseISO(r.start_date), now))
.sort((a, b) => a.start_date.localeCompare(b.start_date))
.slice(0, 6);
if (upcoming.length === 0) return 'No hay reservas futuras registradas.';
const lines = [`📅 **Próximas reservas (${upcoming.length}):**\n`];
upcoming.forEach(r => {
const daysTo = differenceInDays(parseISO(r.start_date), now);
const nights = nightsBetween(r.start_date, r.end_date);
lines.push(
`**${r.client_name}** — en ${daysTo} día${daysTo !== 1 ? 's' : ''}\n` +
` ${fmtShort(r.start_date)}${fmtShort(r.end_date)} · ${nights} noches · ${r.adults_count + r.children_count} pax\n` +
(r.government_registration ? ` ✅ Reg: \`${r.government_registration}\`` : ` ⚠️ Sin registro`)
);
});
return lines.join('\n');
}
function availabilityInfo(reservations: Reservation[]): string {
const now = new Date();
const future = reservations
.filter(r => !isBefore(parseISO(r.end_date), now))
.sort((a, b) => a.start_date.localeCompare(b.start_date));
if (future.length === 0) {
return '✅ No hay reservas futuras. La propiedad está completamente disponible.';
}
const lines = ['🗓️ **Disponibilidad próxima:**\n'];
// Gap before first reservation
const firstStart = parseISO(future[0].start_date);
const daysToFirst = differenceInDays(firstStart, now);
if (daysToFirst > 0) {
lines.push(`✅ Libre ahora → ${fmtShort(future[0].start_date)} (${daysToFirst} días)`);
}
// Gaps between reservations
for (let i = 0; i < future.length; i++) {
lines.push(`🔴 Ocupado: ${fmtShort(future[i].start_date)}${fmtShort(future[i].end_date)} (${future[i].client_name})`);
if (i < future.length - 1) {
const gapStart = parseISO(future[i].end_date);
const gapEnd = parseISO(future[i + 1].start_date);
const gapDays = differenceInDays(gapEnd, gapStart);
if (gapDays > 0) {
lines.push(`✅ Libre: ${fmtShort(future[i].end_date)}${fmtShort(future[i + 1].start_date)} (${gapDays} días)`);
}
}
}
return lines.join('\n');
}
function allReservationsInfo(reservations: Reservation[]): string {
if (reservations.length === 0) return 'No hay reservas registradas.';
const sorted = [...reservations].sort((a, b) => a.start_date.localeCompare(b.start_date));
const now = new Date();
const lines = [`📋 **Todas las reservas (${sorted.length}):**\n`];
sorted.forEach(r => {
const isPast = isBefore(parseISO(r.end_date), now);
const isActive = !isAfter(parseISO(r.start_date), now) && !isBefore(parseISO(r.end_date), now);
const icon = isPast ? '⬜' : isActive ? '🟢' : '🔵';
lines.push(
`${icon} **${r.client_name}** — ${fmtShort(r.start_date)}${fmtShort(r.end_date)}` +
(r.government_registration ? `` : ` ⚠️`)
);
});
return lines.join('\n');
}
function processMessage(input: string, reservations: Reservation[], property: Property): string {
const lower = input.toLowerCase().trim();
const now = new Date();
const label = propertyLabel(property);
// Greeting
if (/^(hola|buenos|buenas|hey|hi|ey|qué tal|que tal|buen)/.test(lower)) {
const total = reservations.length;
const upcoming = reservations.filter(r => isAfter(parseISO(r.start_date), now)).length;
const active = reservations.filter(r =>
!isAfter(parseISO(r.start_date), now) && !isBefore(parseISO(r.end_date), now)
).length;
return [
`¡Hola! Soy tu asistente para **${label}** 🏡`,
``,
`Estado actual:`,
`${active > 0 ? `🟢 ${active} reserva${active !== 1 ? 's' : ''} activa${active !== 1 ? 's' : ''} ahora mismo` : '⬜ Sin reservas activas hoy'}`,
`• 🔵 ${upcoming} próxima${upcoming !== 1 ? 's' : ''}`,
`• 📋 ${total} reserva${total !== 1 ? 's' : ''} en total`,
``,
`¿Qué necesitas saber?`,
].join('\n');
}
// Stats / summary
if (/estadist|resumen|total|cuántas|cuantas|dato|cifra|número|numeros|summary/.test(lower)) {
return statsInfo(reservations, property);
}
// Contracts / government registration
if (/contrat|registro|viajero|govern|código|codigos|rvtca|factura|invoice|número de registro/.test(lower)) {
return contractsInfo(reservations);
}
// Upcoming
if (/próxim|siguiente|futuras|upcoming|pronto|esta semana|este mes|entran|llegan/.test(lower)) {
return upcomingInfo(reservations);
}
// Availability
if (/disponib|libre|ocup|vac|hueco|cuando|free|gap|abierto/.test(lower)) {
return availabilityInfo(reservations);
}
// List all
if (/lista|todas|todos|ver todo|todas las|show all|all reserv/.test(lower)) {
return allReservationsInfo(reservations);
}
// Search by guest name — scan all words in input against client names
const words = lower.split(/\s+/).filter(w => w.length > 2);
for (const word of words) {
const found = reservations.find(r =>
r.client_name.toLowerCase().includes(word)
);
if (found) return guestDetail(found);
}
// Help / default
return [
`Puedo ayudarte con información de **${label}**. Pregúntame sobre:`,
``,
`• 📋 **Reservas** — "lista todas", "reservas de mayo"`,
`• 📅 **Próximas** — "próximas reservas"`,
`• 🗓️ **Disponibilidad** — "¿cuándo está libre?"`,
`• 🏛️ **Contratos** — "registros gubernamentales", "contratos"`,
`• 👤 **Huésped** — escribe el nombre del cliente`,
`• 📊 **Estadísticas** — "dame un resumen"`,
].join('\n');
}
// ─── Quick-reply suggestions ──────────────────────────────────────────────────
const QUICK_REPLIES = [
{ label: 'Próximas reservas', text: 'próximas reservas' },
{ label: 'Disponibilidad', text: '¿cuándo está libre?' },
{ label: 'Contratos y registros', text: 'registros gubernamentales' },
{ label: 'Resumen estadístico', text: 'dame un resumen' },
{ label: 'Todas las reservas', text: 'lista todas las reservas' },
];
// ─── Message renderer (markdown-lite) ────────────────────────────────────────
function RenderMessage({ content }: { content: string }) {
const lines = content.split('\n');
return (
<div className="space-y-0.5">
{lines.map((line, i) => {
// Bold: **text**
const parts = line.split(/(\*\*[^*]+\*\*)/g).map((part, j) => {
if (part.startsWith('**') && part.endsWith('**')) {
return <strong key={j}>{part.slice(2, -2)}</strong>;
}
// Inline code: `text`
return part.split(/(`[^`]+`)/g).map((seg, k) => {
if (seg.startsWith('`') && seg.endsWith('`')) {
return (
<code key={k} className="bg-white/10 px-1 py-0.5 rounded text-xs font-mono">
{seg.slice(1, -1)}
</code>
);
}
return seg;
});
});
if (line === '') return <div key={i} className="h-1.5" />;
return <p key={i} className="leading-snug">{parts}</p>;
})}
</div>
);
}
// ─── Main component ───────────────────────────────────────────────────────────
export function ChatbotContainer() {
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [selectedProperty, setSelectedProperty] = useState<Property>('los_dragos');
const [reservationsMap, setReservationsMap] = useState<Record<Property, Reservation[]>>({
los_dragos: [],
la_esquinita: [],
});
const [loadingData, setLoadingData] = useState(false);
const [isTyping, setIsTyping] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Fetch all reservations for both properties
const fetchAll = useCallback(async () => {
setLoadingData(true);
try {
const { data } = await supabase
.from('reservations')
.select('*')
.order('start_date', { ascending: true });
if (data) {
setReservationsMap({
los_dragos: data.filter((r: Reservation) => r.property === 'los_dragos'),
la_esquinita: data.filter((r: Reservation) => r.property === 'la_esquinita'),
});
}
} finally {
setLoadingData(false);
}
}, []);
useEffect(() => {
if (isOpen && messages.length === 0) {
fetchAll();
// Initial greeting
const greeting: Message = {
id: crypto.randomUUID(),
role: 'assistant',
content: `¡Hola! 👋 Soy tu asistente de reservas.\n\nSelecciona una propiedad arriba y pregúntame lo que necesites: disponibilidad, contratos, huéspedes, estadísticas…`,
timestamp: new Date(),
};
setMessages([greeting]);
}
}, [isOpen, messages.length, fetchAll]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, isTyping]);
const handleSend = useCallback(async (text?: string) => {
const userText = (text ?? input).trim();
if (!userText) return;
const userMsg: Message = {
id: crypto.randomUUID(),
role: 'user',
content: userText,
timestamp: new Date(),
};
setMessages(prev => [...prev, userMsg]);
setInput('');
setIsTyping(true);
// Simulate processing delay for UX
await new Promise(resolve => setTimeout(resolve, 400));
const reservations = reservationsMap[selectedProperty];
const response = processMessage(userText, reservations, selectedProperty);
const botMsg: Message = {
id: crypto.randomUUID(),
role: 'assistant',
content: response,
timestamp: new Date(),
};
setIsTyping(false);
setMessages(prev => [...prev, botMsg]);
}, [input, reservationsMap, selectedProperty]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handlePropertySwitch = (p: Property) => {
setSelectedProperty(p);
const switchMsg: Message = {
id: crypto.randomUUID(),
role: 'assistant',
content: `Cambiado a **${propertyLabel(p)}**. ¿Qué necesitas saber sobre esta propiedad?`,
timestamp: new Date(),
};
setMessages(prev => [...prev, switchMsg]);
};
const isDragos = selectedProperty === 'los_dragos';
const accentGradient = isDragos
? 'from-emerald-600 to-teal-600'
: 'from-amber-600 to-orange-600';
const accentBorder = isDragos ? 'border-emerald-500/30' : 'border-amber-500/30';
const accentShadow = isDragos ? 'shadow-emerald-500/20' : 'shadow-amber-500/20';
const accentBg = isDragos ? 'bg-emerald-600/20' : 'bg-amber-600/20';
const accentText = isDragos ? 'text-emerald-300' : 'text-amber-300';
const activePropBg = isDragos
? 'bg-gradient-to-r from-emerald-600 to-teal-600 text-white shadow-lg shadow-emerald-500/30'
: 'bg-gradient-to-r from-amber-600 to-orange-600 text-white shadow-lg shadow-amber-500/30';
return (
<>
{/* Floating button */}
<button
onClick={() => setIsOpen(o => !o)}
className={`fixed bottom-20 right-4 md:bottom-6 md:right-6 z-50 w-14 h-14 bg-gradient-to-br ${accentGradient} rounded-full shadow-xl ${accentShadow} flex items-center justify-center text-white transition-all duration-300 hover:scale-110 border border-white/10`}
aria-label="Abrir asistente"
>
{isOpen ? <X className="w-6 h-6" /> : <MessageCircle className="w-6 h-6" />}
</button>
{/* Chat panel */}
{isOpen && (
<div
className={`fixed bottom-36 right-4 md:bottom-24 md:right-6 z-50 w-[calc(100vw-2rem)] md:w-96 h-[520px] md:h-[580px] bg-slate-900 rounded-3xl shadow-2xl ${accentShadow} border ${accentBorder} flex flex-col overflow-hidden transition-all duration-300`}
>
{/* Header */}
<div className={`bg-gradient-to-r ${accentGradient} px-5 py-4 flex items-center justify-between flex-shrink-0`}>
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-white/20 rounded-xl flex items-center justify-center">
<MessageCircle className="w-4 h-4 text-white" />
</div>
<div>
<p className="font-bold text-white text-sm">Asistente de Reservas</p>
<p className="text-white/70 text-xs">{propertyLabel(selectedProperty)}</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={fetchAll}
disabled={loadingData}
className="p-1.5 rounded-lg bg-white/10 hover:bg-white/20 transition-colors text-white"
title="Actualizar datos"
>
<RefreshCw className={`w-3.5 h-3.5 ${loadingData ? 'animate-spin' : ''}`} />
</button>
<button
onClick={() => setIsOpen(false)}
className="p-1.5 rounded-lg bg-white/10 hover:bg-white/20 transition-colors text-white"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Property selector */}
<div className="flex gap-2 px-4 py-3 bg-slate-800/80 border-b border-white/5 flex-shrink-0">
{(['los_dragos', 'la_esquinita'] as Property[]).map(p => (
<button
key={p}
onClick={() => handlePropertySwitch(p)}
className={`flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-xl text-xs font-semibold transition-all duration-200 ${
selectedProperty === p
? (p === 'los_dragos'
? 'bg-gradient-to-r from-emerald-600 to-teal-600 text-white shadow-lg shadow-emerald-500/30'
: 'bg-gradient-to-r from-amber-600 to-orange-600 text-white shadow-lg shadow-amber-500/30')
: 'bg-white/5 text-slate-400 hover:bg-white/10 hover:text-slate-200'
}`}
>
<Home className="w-3 h-3" />
{p === 'los_dragos' ? 'Los Dragos' : 'La Esquinita'}
</button>
))}
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
{messages.map(msg => (
<div
key={msg.id}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
{msg.role === 'assistant' && (
<div className={`w-7 h-7 rounded-xl bg-gradient-to-br ${accentGradient} flex-shrink-0 flex items-center justify-center mr-2 mt-0.5`}>
<MessageCircle className="w-3.5 h-3.5 text-white" />
</div>
)}
<div
className={`max-w-[78%] px-4 py-3 rounded-2xl text-xs leading-relaxed ${
msg.role === 'user'
? `${accentBg} ${accentText} border ${accentBorder} rounded-tr-sm`
: 'bg-slate-800 text-slate-100 rounded-tl-sm border border-white/5'
}`}
>
<RenderMessage content={msg.content} />
<p className="text-[10px] opacity-40 mt-1.5 text-right">
{format(msg.timestamp, 'HH:mm')}
</p>
</div>
</div>
))}
{/* Typing indicator */}
{isTyping && (
<div className="flex justify-start">
<div className={`w-7 h-7 rounded-xl bg-gradient-to-br ${accentGradient} flex-shrink-0 flex items-center justify-center mr-2`}>
<MessageCircle className="w-3.5 h-3.5 text-white" />
</div>
<div className="bg-slate-800 border border-white/5 px-4 py-3 rounded-2xl rounded-tl-sm flex items-center gap-1">
<span className="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Quick replies */}
{messages.length <= 2 && (
<div className="px-4 pb-2 flex gap-1.5 flex-wrap flex-shrink-0">
{QUICK_REPLIES.map(qr => (
<button
key={qr.text}
onClick={() => handleSend(qr.text)}
className={`px-3 py-1.5 rounded-xl text-[11px] font-medium bg-slate-800 text-slate-300 border border-white/5 hover:border-white/20 hover:text-white transition-all duration-150`}
>
{qr.label}
</button>
))}
</div>
)}
{/* Input */}
<div className="px-4 pb-4 pt-2 flex gap-2 flex-shrink-0 border-t border-white/5">
<input
ref={inputRef}
type="text"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Escribe tu pregunta…"
className="flex-1 bg-slate-800 border border-white/10 rounded-xl px-4 py-2.5 text-sm text-white placeholder-slate-500 outline-none focus:border-white/25 transition-colors"
/>
<button
onClick={() => handleSend()}
disabled={!input.trim() || isTyping}
className={`w-10 h-10 flex items-center justify-center rounded-xl bg-gradient-to-br ${accentGradient} text-white disabled:opacity-40 hover:opacity-90 transition-all duration-200 flex-shrink-0`}
>
<Send className="w-4 h-4" />
</button>
</div>
</div>
)}
</>
);
}