From f9a8d83e5e07fc3cf6ada49b83f3eb50c4deeb6e Mon Sep 17 00:00:00 2001 From: Kilian Date: Fri, 8 May 2026 18:40:40 +0100 Subject: [PATCH] no message --- apps/web/src/components/ChatbotContainer.tsx | 496 ++++++++++++++++--- 1 file changed, 429 insertions(+), 67 deletions(-) diff --git a/apps/web/src/components/ChatbotContainer.tsx b/apps/web/src/components/ChatbotContainer.tsx index e117080..daa78b7 100644 --- a/apps/web/src/components/ChatbotContainer.tsx +++ b/apps/web/src/components/ChatbotContainer.tsx @@ -11,7 +11,11 @@ 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 { + format, parseISO, isAfter, isBefore, differenceInDays, + startOfMonth, endOfMonth, addMonths, startOfWeek, endOfWeek, addWeeks, + isWithinInterval, max as maxDate, min as minDate, +} from 'date-fns'; import { es } from 'date-fns/locale/es'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -216,13 +220,226 @@ function allReservationsInfo(reservations: Reservation[]): string { return lines.join('\n'); } +// ─── Filter parsing ────────────────────────────────────────────────────────── + +function normalize(s: string): string { + return s.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, ''); +} + +const MONTHS: Record = { + enero: 0, ene: 0, + febrero: 1, feb: 1, + marzo: 2, mar: 2, + abril: 3, abr: 3, + mayo: 4, may: 4, + junio: 5, jun: 5, + julio: 6, jul: 6, + agosto: 7, ago: 7, agost: 7, + septiembre: 8, sep: 8, sept: 8, setiembre: 8, + octubre: 9, oct: 9, + noviembre: 10, nov: 10, + diciembre: 11, dic: 11, +}; + +interface Filter { + rangeStart?: Date; + rangeEnd?: Date; + origin?: 'Teneriffa2000' | 'Naturcalabacera'; + onlyEvents?: boolean; + onlyStays?: boolean; + poolHeating?: boolean; + withoutRegistration?: boolean; + label?: string; // human-readable name of the period +} + +function parseFilter(input: string, now: Date): Filter { + const t = normalize(input); + const filter: Filter = {}; + + // Year (YYYY) + const yearMatch = t.match(/\b(20\d{2})\b/); + const explicitYear = yearMatch ? parseInt(yearMatch[1], 10) : undefined; + + // Specific date: "el 15 de mayo", "15/05", "15-05-2026", "15 de mayo" + const dmMatch = t.match(/\b(\d{1,2})(?:\s*(?:de|\/|-)\s*)(\d{1,2}|enero|febrero|marzo|abril|mayo|junio|julio|agosto|septiembre|setiembre|octubre|noviembre|diciembre|ene|feb|mar|abr|may|jun|jul|ago|sep|sept|oct|nov|dic)(?:\s*(?:de|\/|-)\s*(20\d{2}))?\b/); + if (dmMatch) { + const day = parseInt(dmMatch[1], 10); + const monthRaw = dmMatch[2]; + const month = /^\d+$/.test(monthRaw) ? parseInt(monthRaw, 10) - 1 : MONTHS[monthRaw]; + const year = dmMatch[3] ? parseInt(dmMatch[3], 10) : (explicitYear ?? now.getFullYear()); + if (month !== undefined && day >= 1 && day <= 31) { + const d = new Date(year, month, day); + filter.rangeStart = d; + filter.rangeEnd = d; + filter.label = format(d, "d 'de' MMMM yyyy", { locale: es }); + return applyOriginAndKindFilter(filter, t); + } + } + + // Month name only: "en mayo", "mayo", "reservas de mayo" + for (const [name, idx] of Object.entries(MONTHS)) { + const re = new RegExp(`\\b${name}\\b`); + if (re.test(t)) { + const year = explicitYear ?? now.getFullYear(); + const start = new Date(year, idx, 1); + filter.rangeStart = startOfMonth(start); + filter.rangeEnd = endOfMonth(start); + filter.label = format(start, "MMMM yyyy", { locale: es }); + return applyOriginAndKindFilter(filter, t); + } + } + + // Relative periods + if (/\b(este mes|el mes|mes actual)\b/.test(t)) { + filter.rangeStart = startOfMonth(now); + filter.rangeEnd = endOfMonth(now); + filter.label = `este mes (${format(now, 'MMMM', { locale: es })})`; + } else if (/\b(proximo mes|mes que viene|siguiente mes)\b/.test(t)) { + const next = addMonths(now, 1); + filter.rangeStart = startOfMonth(next); + filter.rangeEnd = endOfMonth(next); + filter.label = `el próximo mes (${format(next, 'MMMM', { locale: es })})`; + } else if (/\b(esta semana|semana actual)\b/.test(t)) { + filter.rangeStart = startOfWeek(now, { weekStartsOn: 1 }); + filter.rangeEnd = endOfWeek(now, { weekStartsOn: 1 }); + filter.label = 'esta semana'; + } else if (/\b(proxima semana|semana que viene)\b/.test(t)) { + const next = addWeeks(now, 1); + filter.rangeStart = startOfWeek(next, { weekStartsOn: 1 }); + filter.rangeEnd = endOfWeek(next, { weekStartsOn: 1 }); + filter.label = 'la próxima semana'; + } else if (/\b(verano)\b/.test(t)) { + const y = explicitYear ?? now.getFullYear(); + filter.rangeStart = new Date(y, 5, 21); + filter.rangeEnd = new Date(y, 8, 22); + filter.label = `verano ${y}`; + } else if (/\b(invierno)\b/.test(t)) { + const y = explicitYear ?? now.getFullYear(); + filter.rangeStart = new Date(y, 11, 21); + filter.rangeEnd = new Date(y + 1, 2, 19); + filter.label = `invierno ${y}-${y + 1}`; + } else if (/\b(navidad|navidades)\b/.test(t)) { + const y = explicitYear ?? now.getFullYear(); + filter.rangeStart = new Date(y, 11, 20); + filter.rangeEnd = new Date(y + 1, 0, 7); + filter.label = `navidades ${y}`; + } else if (explicitYear) { + filter.rangeStart = new Date(explicitYear, 0, 1); + filter.rangeEnd = new Date(explicitYear, 11, 31); + filter.label = String(explicitYear); + } else if (/\b(este ano|ano actual)\b/.test(t)) { + filter.rangeStart = new Date(now.getFullYear(), 0, 1); + filter.rangeEnd = new Date(now.getFullYear(), 11, 31); + filter.label = `este año (${now.getFullYear()})`; + } + + return applyOriginAndKindFilter(filter, t); +} + +function applyOriginAndKindFilter(filter: Filter, t: string): Filter { + if (/\bteneriffa\b/.test(t)) filter.origin = 'Teneriffa2000'; + else if (/\b(natur|naturcalabacera)\b/.test(t)) filter.origin = 'Naturcalabacera'; + + if (/\b(boda|bodas|comuni|cumpleano|cumpleanos|cumple|bautizo|evento|eventos|fiesta)\b/.test(t)) { + filter.onlyEvents = true; + } else if (/\b(estancia|estancias|alquiler|alquileres)\b/.test(t)) { + filter.onlyStays = true; + } + + if (/\b(calefaccion|piscina caliente|piscina climatizada)\b/.test(t)) { + filter.poolHeating = true; + } + + if (/\b(sin registro|sin contrato|falta registro|sin viajero|pendiente registro)\b/.test(t)) { + filter.withoutRegistration = true; + } + + return filter; +} + +function applyFilter(reservations: Reservation[], f: Filter): Reservation[] { + let out = reservations; + if (f.rangeStart && f.rangeEnd) { + const interval = { start: f.rangeStart, end: f.rangeEnd }; + out = out.filter(r => { + const s = parseISO(r.start_date); + const e = parseISO(r.end_date); + // Reserva intersecta el rango + return !(isBefore(e, interval.start) || isAfter(s, interval.end)); + }); + } + if (f.origin) out = out.filter(r => r.origin === f.origin); + if (f.onlyEvents) out = out.filter(r => r.is_event); + if (f.onlyStays) out = out.filter(r => !r.is_event); + if (f.poolHeating) out = out.filter(r => r.has_pool_heating); + if (f.withoutRegistration) out = out.filter(r => !r.government_registration && !r.is_event); + return out; +} + +function listReservations(reservations: Reservation[], title: string, now: Date): string { + if (reservations.length === 0) return `${title}\n\nNo hay reservas que coincidan.`; + const sorted = [...reservations].sort((a, b) => a.start_date.localeCompare(b.start_date)); + const lines = [`${title} **(${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 = r.is_event ? '🎉' : isPast ? '⬜' : isActive ? '🟢' : '🔵'; + const nights = nightsBetween(r.start_date, r.end_date); + const dates = r.is_event && nights <= 1 + ? fmtShort(r.start_date) + : `${fmtShort(r.start_date)} → ${fmtShort(r.end_date)}`; + lines.push(`${icon} **${r.client_name}** — ${dates}${r.is_event ? '' : ` · ${nights}n`}`); + }); + return lines.join('\n'); +} + +/** + * Calcula los huecos libres dentro del rango [start, end] dado, descontando las + * reservas que lo intersectan. + */ +function freeGapsInRange( + reservations: Reservation[], + start: Date, + end: Date, +): Array<{ from: Date; to: Date }> { + // Reservas ordenadas que tocan el rango. + const overlapping = reservations + .map(r => ({ s: parseISO(r.start_date), e: parseISO(r.end_date) })) + .filter(({ s, e }) => !(isBefore(e, start) || isAfter(s, end))) + .sort((a, b) => a.s.getTime() - b.s.getTime()); + + const gaps: Array<{ from: Date; to: Date }> = []; + let cursor = start; + for (const { s, e } of overlapping) { + if (isAfter(s, cursor)) { + gaps.push({ from: cursor, to: s }); + } + if (isAfter(e, cursor)) cursor = e; + } + if (isBefore(cursor, end) || cursor.getTime() === end.getTime()) { + if (!isAfter(cursor, end)) gaps.push({ from: cursor, to: end }); + } + return gaps.filter(g => differenceInDays(g.to, g.from) >= 0); +} + +function isDateAvailable(reservations: Reservation[], date: Date): { free: boolean; conflict?: Reservation } { + for (const r of reservations) { + const s = parseISO(r.start_date); + const e = parseISO(r.end_date); + if (isWithinInterval(date, { start: s, end: e })) { + return { free: false, conflict: r }; + } + } + return { free: true }; +} + function processMessage(input: string, reservations: Reservation[], property: Property): string { - const lower = input.toLowerCase().trim(); + const t = normalize(input.trim()); const now = new Date(); const label = propertyLabel(property); // Greeting - if (/^(hola|buenos|buenas|hey|hi|ey|qué tal|que tal|buen)/.test(lower)) { + if (/^(hola|buenos|buenas|hey|hi|ey|que tal|buen)/.test(t)) { const total = reservations.length; const upcoming = reservations.filter(r => isAfter(parseISO(r.start_date), now)).length; const active = reservations.filter(r => @@ -240,61 +457,186 @@ function processMessage(input: string, reservations: Reservation[], property: Pr ].join('\n'); } - // Stats / summary - if (/estadist|resumen|total|cuántas|cuantas|dato|cifra|número|numeros|summary/.test(lower)) { + const filter = parseFilter(input, now); + const filtered = applyFilter(reservations, filter); + const periodSuffix = filter.label ? ` (${filter.label})` : ''; + + // ── Specific date queries: "está libre el X", "quién entra el X" ───────────── + if (filter.rangeStart && filter.rangeEnd && filter.rangeStart.getTime() === filter.rangeEnd.getTime()) { + const date = filter.rangeStart; + if (/\b(libre|disponible|ocupad|vacante|hueco)\b/.test(t)) { + const { free, conflict } = isDateAvailable(reservations, date); + if (free) return `✅ El ${format(date, "d 'de' MMMM yyyy", { locale: es })} está **libre** en ${label}.`; + return `🔴 El ${format(date, "d 'de' MMMM yyyy", { locale: es })} está **ocupado** por **${conflict!.client_name}** (${fmtShort(conflict!.start_date)} → ${fmtShort(conflict!.end_date)}).`; + } + if (/\b(entra|llega|huesped|reserva|hay|quien)\b/.test(t)) { + if (filtered.length === 0) return `✅ No hay reservas que toquen el ${format(date, "d 'de' MMMM yyyy", { locale: es })} en ${label}.`; + return filtered.map(guestDetail).join('\n\n---\n\n'); + } + } + + // ── Counters: "cuántas", "cuántos", "número de" ───────────────────────────── + if (/\bcuant[oa]s?\b|\bnumero de\b|\bnum de\b|\bhow many\b/.test(t)) { + if (filter.onlyEvents) { + return `🎉 Hay **${filtered.length}** evento${filtered.length !== 1 ? 's' : ''}${periodSuffix} en ${label}.`; + } + if (/\b(adulto)/.test(t)) { + const adults = filtered.reduce((s, r) => s + (r.adults_count ?? 0), 0); + return `👤 Total **${adults}** adulto${adults !== 1 ? 's' : ''}${periodSuffix} en ${label}.`; + } + if (/\b(nino|nin)/.test(t)) { + const kids = filtered.reduce((s, r) => s + (r.children_count ?? 0), 0); + return `🧒 Total **${kids}** niño${kids !== 1 ? 's' : ''}${periodSuffix} en ${label}.`; + } + if (/\b(noche)/.test(t)) { + const nights = filtered.reduce((s, r) => s + nightsBetween(r.start_date, r.end_date), 0); + return `🌙 Total **${nights}** noche${nights !== 1 ? 's' : ''}${periodSuffix} en ${label}.`; + } + if (/\b(persona|huespe|pax)/.test(t)) { + const pax = filtered.reduce((s, r) => s + (r.adults_count ?? 0) + (r.children_count ?? 0), 0); + return `👥 Total **${pax}** persona${pax !== 1 ? 's' : ''}${periodSuffix} en ${label}.`; + } + return `📋 Hay **${filtered.length}** reserva${filtered.length !== 1 ? 's' : ''}${periodSuffix} en ${label}.`; + } + + // ── Free-gap query within a multi-day range: "días libres en junio" ───────── + const hasPeriod = !!(filter.rangeStart && filter.rangeEnd); + const isMultiDay = hasPeriod && filter.rangeStart!.getTime() !== filter.rangeEnd!.getTime(); + const asksAvailability = /\b(libre|libres|disponible|disponibles|hueco|huecos|vacante|vacantes)\b/.test(t); + + if (isMultiDay && asksAvailability) { + const gaps = freeGapsInRange(reservations, filter.rangeStart!, filter.rangeEnd!); + const periodTitle = filter.label ? `🟢 **Días libres · ${filter.label}**` : '🟢 **Días libres**'; + if (gaps.length === 0) { + return `${periodTitle}\n\n🔴 No hay días libres en ese periodo. Todo el rango está ocupado.`; + } + const totalDays = gaps.reduce((s, g) => s + Math.max(0, differenceInDays(g.to, g.from)), 0); + const lines = [`${periodTitle}\n`]; + gaps.forEach(g => { + const days = Math.max(0, differenceInDays(g.to, g.from)); + if (days <= 0) return; + const from = format(g.from, 'd MMM', { locale: es }); + const to = format(g.to, 'd MMM', { locale: es }); + lines.push(`✅ ${from} → ${to} · ${days} día${days !== 1 ? 's' : ''}`); + }); + if (totalDays > 0) lines.push(`\n📊 Total libre: **${totalDays}** día${totalDays !== 1 ? 's' : ''}.`); + return lines.join('\n'); + } + + // ── Filtered list when a period was detected ──────────────────────────────── + const hasOriginOrKindFilter = filter.origin || filter.onlyEvents || filter.onlyStays || filter.poolHeating || filter.withoutRegistration; + + if (hasPeriod || hasOriginOrKindFilter) { + let title = '📋 Reservas'; + if (filter.onlyEvents) title = '🎉 Eventos'; + if (filter.onlyStays) title = '🏠 Estancias'; + if (filter.poolHeating) title = '♨️ Reservas con calefacción de piscina'; + if (filter.withoutRegistration) title = '⚠️ Sin registro gubernamental'; + if (filter.origin === 'Teneriffa2000') title += ' · Teneriffa'; + if (filter.origin === 'Naturcalabacera') title += ' · Naturcalabacera'; + if (filter.label) title += ` · ${filter.label}`; + return listReservations(filtered, title, now); + } + + // ── Next check-in / check-out ──────────────────────────────────────────────── + if (/\b(proxim|siguient|que sigue).*(entrad|llegad|check.?in)/.test(t) || /\b(quien entra|cuando entra el siguiente)/.test(t)) { + const nextIn = [...reservations] + .filter(r => !isBefore(parseISO(r.start_date), now)) + .sort((a, b) => a.start_date.localeCompare(b.start_date))[0]; + if (!nextIn) return 'No hay próximas entradas registradas.'; + const days = differenceInDays(parseISO(nextIn.start_date), now); + return `🔵 Próxima entrada: **${nextIn.client_name}** en ${days} día${days !== 1 ? 's' : ''} (${fmtDate(nextIn.start_date)}).\n\n${guestDetail(nextIn)}`; + } + if (/\b(proxim|siguient).*(salid|check.?out)/.test(t)) { + const nextOut = [...reservations] + .filter(r => !isBefore(parseISO(r.end_date), now)) + .sort((a, b) => a.end_date.localeCompare(b.end_date))[0]; + if (!nextOut) return 'No hay próximas salidas.'; + const days = differenceInDays(parseISO(nextOut.end_date), now); + return `🔴 Próxima salida: **${nextOut.client_name}** en ${days} día${days !== 1 ? 's' : ''} (${fmtDate(nextOut.end_date)}).`; + } + + // ── Stats / summary ───────────────────────────────────────────────────────── + if (/\b(estadist|resumen|total|datos|cifras|summary)\b/.test(t)) { return statsInfo(reservations, property); } - // Contracts / government registration - if (/contrat|registro|viajero|govern|código|codigos|rvtca|factura|invoice|número de registro/.test(lower)) { + // ── Contracts / government registration ───────────────────────────────────── + if (/\b(contrat|registro|viajero|govern|codigo|rvtca|factura|invoice)\b/.test(t)) { return contractsInfo(reservations); } - // Upcoming - if (/próxim|siguiente|futuras|upcoming|pronto|esta semana|este mes|entran|llegan/.test(lower)) { + // ── Upcoming list ─────────────────────────────────────────────────────────── + if (/\b(proxim|siguiente|futuras|upcoming|pronto|entran|llegan)\b/.test(t)) { return upcomingInfo(reservations); } - // Availability - if (/disponib|libre|ocup|vac|hueco|cuando|free|gap|abierto/.test(lower)) { + // ── Availability ──────────────────────────────────────────────────────────── + if (/\b(disponib|libre|ocup|vac|hueco|cuando|gap|abierto|huecos)\b/.test(t)) { return availabilityInfo(reservations); } - // List all - if (/lista|todas|todos|ver todo|todas las|show all|all reserv/.test(lower)) { + // ── List all ──────────────────────────────────────────────────────────────── + if (/\b(lista|todas|todos|all)\b/.test(t)) { 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); + // ── Search by guest name (accent-insensitive, fuzzy) ──────────────────────── + const stopwords = new Set([ + 'cuando','viene','llega','entra','sale','reserva','reservas','hay','quien','que', + 'el','la','los','las','de','del','en','un','una','para','por','con','sin','y','o', + 'me','dame','dime','muestra','muestrame','busca','buscar','informacion','info', + 'sobre','acerca','huesped','cliente','este','esta','estos','estas','tiene', + 'ano','mes','dia','semana','cumpleano','boda','evento','calefaccion','piscina', + ]); + const words = t.split(/\s+/).filter(w => w.length > 2 && !stopwords.has(w)); for (const word of words) { - const found = reservations.find(r => - r.client_name.toLowerCase().includes(word) - ); - if (found) return guestDetail(found); + const found = reservations.filter(r => normalize(r.client_name).includes(word)); + if (found.length === 1) return guestDetail(found[0]); + if (found.length > 1) { + const lines = [`Encontré **${found.length}** coincidencias para "${word}":\n`]; + found.forEach(r => lines.push(`• **${r.client_name}** — ${fmtShort(r.start_date)} → ${fmtShort(r.end_date)}`)); + lines.push(`\nEscribe el nombre completo para ver el detalle.`); + return lines.join('\n'); + } } - // Help / default + // ── Help / default ────────────────────────────────────────────────────────── return [ - `Puedo ayudarte con información de **${label}**. Pregúntame sobre:`, + `Puedo ayudarte con información de **${label}**. Algunos ejemplos:`, ``, - `• 📋 **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"`, + `📋 **Listas y filtros**`, + `• "reservas de mayo" · "eventos en agosto" · "bodas 2026"`, + `• "reservas de Teneriffa" · "calefacción de piscina"`, + ``, + `📅 **Fechas concretas**`, + `• "¿está libre el 15 de junio?"`, + `• "¿quién entra el 8 de mayo?"`, + ``, + `🔢 **Conteos**`, + `• "¿cuántas reservas en julio?" · "¿cuántos adultos en agosto?"`, + `• "¿cuántas noches este mes?"`, + ``, + `📊 **Otros**`, + `• "próxima entrada" · "próxima salida"`, + `• "sin registro" · "registros gubernamentales"`, + `• "resumen" · "disponibilidad"`, + `• Escribe el **nombre del huésped** para ver su ficha`, ].join('\n'); } +// Suprime warning de "imports no usados" cuando no hay rama que los toque (date-fns). +void maxDate; void minDate; + // ─── Quick-reply suggestions ────────────────────────────────────────────────── const QUICK_REPLIES = [ - { label: 'Próximas reservas', text: 'próximas reservas' }, + { label: 'Próxima entrada', text: 'próxima entrada' }, + { label: 'Reservas este mes', text: 'reservas este mes' }, { 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' }, + { label: 'Eventos 2026', text: 'eventos 2026' }, + { label: 'Sin registro', text: 'sin registro' }, + { label: 'Resumen', text: 'dame un resumen' }, ]; // ─── Message renderer (markdown-lite) ──────────────────────────────────────── @@ -330,9 +672,22 @@ function RenderMessage({ content }: { content: string }) { // ─── Main component ─────────────────────────────────────────────────────────── +function buildGreeting(): Message { + return { + 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(), + }; +} + export function ChatbotContainer() { const [isOpen, setIsOpen] = useState(false); - const [messages, setMessages] = useState([]); + // Historial independiente por propiedad: al cambiar de casa cada conversación se conserva. + const [messagesByProperty, setMessagesByProperty] = useState>({ + los_dragos: [], + la_esquinita: [], + }); const [input, setInput] = useState(''); const [selectedProperty, setSelectedProperty] = useState('los_dragos'); const [reservationsMap, setReservationsMap] = useState>({ @@ -344,6 +699,17 @@ export function ChatbotContainer() { const messagesEndRef = useRef(null); const inputRef = useRef(null); + const messages = messagesByProperty[selectedProperty]; + const setMessagesForCurrent = useCallback( + (updater: (prev: Message[]) => Message[]) => { + setMessagesByProperty(prev => ({ + ...prev, + [selectedProperty]: updater(prev[selectedProperty]), + })); + }, + [selectedProperty], + ); + // Fetch all reservations for both properties const fetchAll = useCallback(async () => { setLoadingData(true); @@ -363,19 +729,21 @@ export function ChatbotContainer() { } }, []); + // Carga inicial de datos al abrir. 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]); + if (isOpen) fetchAll(); + }, [isOpen, fetchAll]); + + // Inicializa el historial vacío con un saludo la primera vez que se entra a una propiedad. + useEffect(() => { + if (!isOpen) return; + if (messagesByProperty[selectedProperty].length === 0) { + setMessagesByProperty(prev => ({ + ...prev, + [selectedProperty]: [buildGreeting()], + })); } - }, [isOpen, messages.length, fetchAll]); + }, [isOpen, selectedProperty, messagesByProperty]); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); @@ -391,7 +759,7 @@ export function ChatbotContainer() { content: userText, timestamp: new Date(), }; - setMessages(prev => [...prev, userMsg]); + setMessagesForCurrent(prev => [...prev, userMsg]); setInput(''); setIsTyping(true); @@ -408,8 +776,8 @@ export function ChatbotContainer() { timestamp: new Date(), }; setIsTyping(false); - setMessages(prev => [...prev, botMsg]); - }, [input, reservationsMap, selectedProperty]); + setMessagesForCurrent(prev => [...prev, botMsg]); + }, [input, reservationsMap, selectedProperty, setMessagesForCurrent]); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -418,15 +786,10 @@ export function ChatbotContainer() { } }; + // Cambiar de casa preserva el historial de cada una. La primera vez que se entra + // a una propiedad se inicializa con saludo (vía useEffect arriba). 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'; @@ -547,20 +910,19 @@ export function ChatbotContainer() {
- {/* Quick replies */} - {messages.length <= 2 && ( -
- {QUICK_REPLIES.map(qr => ( - - ))} -
- )} + {/* Quick replies — siempre visibles para acceso rápido */} +
+ {QUICK_REPLIES.map(qr => ( + + ))} +
{/* Input */}