Files
Gesti-n-Reservas-Naturcalab…/apps/web/src/components/ChatbotContainer.tsx

592 lines
24 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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>
)}
</>
);
}