592 lines
24 KiB
TypeScript
592 lines
24 KiB
TypeScript
/**
|
||
* 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>
|
||
)}
|
||
</>
|
||
);
|
||
}
|