no message

This commit is contained in:
2026-05-08 18:40:40 +01:00
parent eea5790233
commit f9a8d83e5e

View File

@@ -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<string, number> = {
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<Message[]>([]);
// Historial independiente por propiedad: al cambiar de casa cada conversación se conserva.
const [messagesByProperty, setMessagesByProperty] = useState<Record<Property, Message[]>>({
los_dragos: [],
la_esquinita: [],
});
const [input, setInput] = useState('');
const [selectedProperty, setSelectedProperty] = useState<Property>('los_dragos');
const [reservationsMap, setReservationsMap] = useState<Record<Property, Reservation[]>>({
@@ -344,6 +699,17 @@ export function ChatbotContainer() {
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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() {
<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>
)}
{/* Quick replies — siempre visibles para acceso rápido */}
<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)}
disabled={isTyping}
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 disabled:opacity-50`}
>
{qr.label}
</button>
))}
</div>
{/* Input */}
<div className="px-4 pb-4 pt-2 flex gap-2 flex-shrink-0 border-t border-white/5">