no message
This commit is contained in:
@@ -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 && (
|
||||
{/* 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)}
|
||||
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={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">
|
||||
|
||||
Reference in New Issue
Block a user