Initial commit: monorepo Naturcalabacera reservas (apps/api + apps/web + packages/shared)
This commit is contained in:
591
apps/web/src/components/ChatbotContainer.tsx
Normal file
591
apps/web/src/components/ChatbotContainer.tsx
Normal file
@@ -0,0 +1,591 @@
|
||||
/**
|
||||
* 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user