/** * 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 (
{seg.slice(1, -1)}
);
}
return seg;
});
});
if (line === '') return ;
return {parts}
; })}Asistente de Reservas
{propertyLabel(selectedProperty)}
{format(msg.timestamp, 'HH:mm')}