feat: integrate persistent floating chatbot widget into the global app layout
This commit is contained in:
@@ -0,0 +1,427 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ChatTeardropTextIcon,
|
||||
XIcon,
|
||||
PlusCircleIcon,
|
||||
ArrowSquareOutIcon,
|
||||
PaperPlaneRightIcon,
|
||||
ArrowsClockwiseIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import { SparkleIcon } from 'lucide-react';
|
||||
import { ChatMessageItem } from './ChatMessageItem';
|
||||
import { CustomAlertBanner, Skeleton } from '../../../components/custom';
|
||||
import {
|
||||
getConversations,
|
||||
createConversation,
|
||||
getConversationMessages,
|
||||
sendMessage as sendChatMessage,
|
||||
} from '../services/chatApi';
|
||||
import type { Conversation, ChatMessage } from '../types/ChatTypes';
|
||||
|
||||
const SUGGESTIONS = [
|
||||
{
|
||||
title: 'What is IROPS?',
|
||||
desc: 'Understand Irregular Operations & flight disruption policies.',
|
||||
},
|
||||
{
|
||||
title: 'Policy Engine Overview',
|
||||
desc: 'How business rules, compensation, and gates evaluate.',
|
||||
},
|
||||
{
|
||||
title: 'Cohort Targeting Rules',
|
||||
desc: 'Passenger segmentation by tier, cabin class, & route.',
|
||||
},
|
||||
];
|
||||
|
||||
export function FloatingChatWidget() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [activeConversationId, setActiveConversationId] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [loadingConversations, setLoadingConversations] = useState(false);
|
||||
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [hasUnread, setHasUnread] = useState(false);
|
||||
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// If we're on the dedicated /chatbot page, hide the floating widget to avoid duplication
|
||||
const isChatbotPage = location.pathname === '/chatbot';
|
||||
|
||||
// Load conversations
|
||||
const loadConversations = useCallback(async (autoSelectFirst = true) => {
|
||||
setLoadingConversations(true);
|
||||
try {
|
||||
const list = await getConversations();
|
||||
setConversations(list);
|
||||
if (autoSelectFirst && list.length > 0 && !activeConversationId) {
|
||||
setActiveConversationId(list[0].id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load conversations in widget:', err);
|
||||
} finally {
|
||||
setLoadingConversations(false);
|
||||
}
|
||||
}, [activeConversationId]);
|
||||
|
||||
// When widget opens for the first time, load conversations
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setHasUnread(false);
|
||||
loadConversations();
|
||||
}
|
||||
}, [isOpen, loadConversations]);
|
||||
|
||||
// Fetch messages when active conversation changes
|
||||
useEffect(() => {
|
||||
if (!isOpen || !activeConversationId) {
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingMessages(true);
|
||||
getConversationMessages(activeConversationId)
|
||||
.then((msgs) => setMessages(msgs))
|
||||
.catch((err) => {
|
||||
console.error('Failed to load messages in widget:', err);
|
||||
setError('Failed to retrieve message history.');
|
||||
})
|
||||
.finally(() => setLoadingMessages(false));
|
||||
}, [isOpen, activeConversationId]);
|
||||
|
||||
// Auto-scroll to bottom on new messages or sending
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [messages, sending, isOpen]);
|
||||
|
||||
// Handle "+ New Conversation"
|
||||
const handleNewConversation = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const newConv = await createConversation('New Conversation');
|
||||
setConversations((prev) => [newConv, ...prev]);
|
||||
setActiveConversationId(newConv.id);
|
||||
setMessages([]);
|
||||
} catch (err) {
|
||||
console.error('Failed to create new conversation:', err);
|
||||
setError('Failed to create new conversation.');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle sending message
|
||||
const handleSendMessage = async (text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || sending) return;
|
||||
|
||||
setError(null);
|
||||
let convId = activeConversationId;
|
||||
|
||||
// Auto-create conversation if none selected
|
||||
if (!convId) {
|
||||
try {
|
||||
const title = trimmed.length > 30 ? trimmed.substring(0, 30) + '...' : trimmed;
|
||||
const newConv = await createConversation(title);
|
||||
setConversations((prev) => [newConv, ...prev]);
|
||||
convId = newConv.id;
|
||||
setActiveConversationId(convId);
|
||||
} catch (err) {
|
||||
console.error('Failed to create conversation:', err);
|
||||
setError('Failed to start conversation.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Optimistically add user message
|
||||
const tempUserMsg: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
conversationId: convId,
|
||||
role: 'user',
|
||||
content: trimmed,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, tempUserMsg]);
|
||||
setInputText('');
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
}
|
||||
setSending(true);
|
||||
|
||||
try {
|
||||
const response = await sendChatMessage({
|
||||
conversationId: convId,
|
||||
message: trimmed,
|
||||
});
|
||||
|
||||
const assistantMsg: ChatMessage = {
|
||||
id: response.messageId,
|
||||
conversationId: response.conversationId,
|
||||
role: response.role,
|
||||
content: response.content,
|
||||
createdAt: response.createdAt,
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, assistantMsg]);
|
||||
loadConversations(false);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to send message in widget:', err);
|
||||
setError(
|
||||
err.response?.data?.message ||
|
||||
'Unable to connect to the chatbot service. Please try again.',
|
||||
);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage(inputText);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTextChange = (val: string) => {
|
||||
setInputText(val);
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px`;
|
||||
}
|
||||
};
|
||||
|
||||
const activeConversation = conversations.find((c) => c.id === activeConversationId);
|
||||
|
||||
if (isChatbotPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Floating Chat Modal Window */}
|
||||
{isOpen && (
|
||||
<div className="fixed bottom-24 right-6 z-50 w-[420px] max-w-[calc(100vw-32px)] h-[620px] max-h-[calc(100vh-120px)] bg-white rounded-[24px] shadow-[0_20px_60px_-15px_rgba(0,0,0,0.25)] border border-slate-200/90 flex flex-col overflow-hidden animate-in fade-in zoom-in-95 duration-200 font-sans">
|
||||
{/* Header */}
|
||||
<div className="px-5 py-3.5 bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 text-white flex items-center justify-between shadow-sm shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-tr from-[#1B9869] to-[#14704E] text-white flex items-center justify-center shadow-sm shrink-0">
|
||||
<SparkleIcon size={18} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-[14px] font-bold text-white leading-tight">
|
||||
Aero Resolve Assistant
|
||||
</h3>
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">
|
||||
AI
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||
<span className="text-[11px] text-slate-300 font-medium">
|
||||
Online & Ready
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Header Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewConversation}
|
||||
title="New Chat"
|
||||
className="p-2 text-slate-300 hover:text-white hover:bg-white/10 rounded-xl transition-colors"
|
||||
>
|
||||
<PlusCircleIcon size={18} weight="bold" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
navigate('/chatbot');
|
||||
}}
|
||||
title="Open in Full Page"
|
||||
className="p-2 text-slate-300 hover:text-white hover:bg-white/10 rounded-xl transition-colors"
|
||||
>
|
||||
<ArrowSquareOutIcon size={18} weight="bold" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
title="Close Window"
|
||||
className="p-2 text-slate-300 hover:text-white hover:bg-white/10 rounded-xl transition-colors"
|
||||
>
|
||||
<XIcon size={18} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conversation Bar (if multiple conversations exist) */}
|
||||
{conversations.length > 1 && (
|
||||
<div className="px-4 py-2 bg-slate-50 border-b border-slate-100 flex items-center justify-between text-xs text-slate-600">
|
||||
<span className="truncate font-medium max-w-[220px]">
|
||||
{activeConversation?.title || 'Active Conversation'}
|
||||
</span>
|
||||
<select
|
||||
value={activeConversationId || ''}
|
||||
onChange={(e) => setActiveConversationId(e.target.value)}
|
||||
className="text-[11px] bg-white border border-slate-200 rounded-lg px-2 py-1 text-slate-700 outline-none focus:border-[#1B9869]"
|
||||
>
|
||||
{conversations.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.title.length > 25 ? c.title.substring(0, 25) + '...' : c.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Banner */}
|
||||
{error && (
|
||||
<div className="px-4 pt-3 pb-1 shrink-0">
|
||||
<CustomAlertBanner
|
||||
message={error}
|
||||
type="error"
|
||||
onClose={() => setError(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages Stream Container */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-2 bg-[#FAFBFB] scrollbar-thin">
|
||||
{loadingMessages ? (
|
||||
<div className="space-y-3 max-w-sm py-4">
|
||||
<Skeleton height={50} className="rounded-2xl" />
|
||||
<Skeleton height={65} className="rounded-2xl w-3/4 ml-auto" />
|
||||
<Skeleton height={55} className="rounded-2xl" />
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
/* Empty State Suggestions */
|
||||
<div className="h-full flex flex-col items-center justify-center text-center px-2 py-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-emerald-50 text-[#1B9869] flex items-center justify-center mb-3 border border-emerald-100 shadow-sm">
|
||||
<SparkleIcon size={24} />
|
||||
</div>
|
||||
<h4 className="text-[15px] font-bold text-slate-800 mb-1">
|
||||
How can I assist you?
|
||||
</h4>
|
||||
<p className="text-xs text-slate-500 mb-4 max-w-[280px]">
|
||||
Ask questions about IROPS, flight policies, compensation rules, or cohorts.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{SUGGESTIONS.map((s, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => handleSendMessage(s.title)}
|
||||
className="text-left p-2.5 rounded-xl border border-slate-200/80 bg-white hover:border-[#1B9869] hover:bg-emerald-50/30 transition-all group shadow-2xs"
|
||||
>
|
||||
<div className="text-xs font-bold text-slate-800 group-hover:text-[#1B9869]">
|
||||
{s.title}
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-500 leading-tight mt-0.5">
|
||||
{s.desc}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Message List */
|
||||
messages.map((m) => <ChatMessageItem key={m.id} message={m} />)
|
||||
)}
|
||||
|
||||
{/* Typing Indicator */}
|
||||
{sending && (
|
||||
<div className="flex gap-2.5 my-3 items-start">
|
||||
<div className="w-8 h-8 rounded-xl bg-emerald-50 text-[#14704E] border border-emerald-200/60 flex items-center justify-center shrink-0">
|
||||
<SparkleIcon size={15} />
|
||||
</div>
|
||||
<div className="bg-white text-slate-500 px-3.5 py-2.5 rounded-2xl rounded-tl-none border border-slate-100 flex items-center gap-1.5 shadow-2xs">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-slate-400 animate-bounce" />
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-slate-400 animate-bounce [animation-delay:0.2s]" />
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-slate-400 animate-bounce [animation-delay:0.4s]" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
{/* Footer Input Area */}
|
||||
<div className="p-3 bg-white border-t border-slate-100 shrink-0">
|
||||
<div className="bg-slate-50 border border-slate-200/80 rounded-2xl p-1.5 transition-all focus-within:border-[#1B9869] focus-within:bg-white focus-within:ring-2 focus-within:ring-[#1B9869]/10 shadow-2xs">
|
||||
<div className="flex items-end gap-1.5">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={inputText}
|
||||
disabled={sending}
|
||||
onChange={(e) => handleTextChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask Aero AI anything..."
|
||||
rows={1}
|
||||
className="flex-1 resize-none bg-transparent px-2.5 py-1.5 text-[13px] text-slate-800 placeholder-slate-400 focus:outline-none max-h-[120px] scrollbar-thin"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSendMessage(inputText)}
|
||||
disabled={sending || !inputText.trim()}
|
||||
className="bg-[#1B9869] hover:bg-[#14704E] text-white rounded-xl w-9 h-9 flex items-center justify-center shrink-0 disabled:opacity-30 disabled:hover:bg-[#1B9869] transition-all shadow-sm active:scale-95"
|
||||
title="Send message"
|
||||
>
|
||||
<PaperPlaneRightIcon size={16} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between px-2 pt-1 text-[10px] text-slate-400">
|
||||
<span>Press <kbd className="font-semibold text-slate-500">Enter</kbd> to send</span>
|
||||
<span>Aero Resolve AI</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Floating Action Button (FAB) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
aria-label={isOpen ? 'Close chat assistant' : 'Open chat assistant'}
|
||||
className={`fixed bottom-6 right-6 z-50 group flex items-center justify-center w-14 h-14 rounded-full bg-gradient-to-tr from-[#1B9869] to-[#14704E] text-white shadow-[0_8px_25px_rgba(27,152,105,0.45)] hover:shadow-[0_12px_30px_rgba(27,152,105,0.6)] hover:scale-105 active:scale-95 transition-all duration-300 focus:outline-none focus:ring-4 focus:ring-[#1B9869]/30 ${
|
||||
isOpen ? 'rotate-90' : ''
|
||||
}`}
|
||||
>
|
||||
{isOpen ? (
|
||||
<XIcon size={24} weight="bold" className="transition-transform duration-200" />
|
||||
) : (
|
||||
<div className="relative flex items-center justify-center">
|
||||
<ChatTeardropTextIcon size={26} weight="fill" />
|
||||
{/* Sparkle badge */}
|
||||
<span className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-amber-400 text-slate-900 rounded-full flex items-center justify-center shadow-sm">
|
||||
<SparkleIcon size={10} className="fill-slate-900" />
|
||||
</span>
|
||||
{/* Active pulse dot */}
|
||||
<span className="absolute -bottom-1 -left-1 w-2.5 h-2.5 rounded-full bg-emerald-300 border-2 border-[#14704E] animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover Tooltip (when closed) */}
|
||||
{!isOpen && (
|
||||
<span className="absolute right-16 px-3 py-1.5 bg-slate-900 text-white text-xs font-semibold rounded-xl shadow-lg opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity duration-200 whitespace-nowrap">
|
||||
Ask Aero AI
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default FloatingChatWidget;
|
||||
@@ -1,3 +1,5 @@
|
||||
import ChatbotPage from './ChatbotPage';
|
||||
export { FloatingChatWidget } from './components/FloatingChatWidget';
|
||||
|
||||
export default ChatbotPage;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import AppSidebar from './AppSidebar';
|
||||
import AppHeader from './AppHeader';
|
||||
import { FloatingChatWidget } from '../app/chatbot';
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode;
|
||||
@@ -21,6 +22,9 @@ function Layout({ children }: LayoutProps) {
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Global Floating Chatbot FAB & Modal Window */}
|
||||
<FloatingChatWidget />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user