/**
* ChatWidget Component - Main Floating Chat Interface
*
* Features:
* - Floating bottom-right widget
* - Keyboard shortcut (Cmd/Ctrl + K)
* - Collapsed/Expanded states with animations
* - Integrates all chat components
* - Persists across navigation
*/
'use client';
import React from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, HelpCircle, ExternalLink, Maximize2, Minimize2, History, Plus, Download, Share2, Check } from 'lucide-react';
import { MapleLeafIcon } from '@canadagpt/design-system';
import { useChatOpen, useChatQuota, useChatExpanded, useChatHistory } from '@/lib/stores/chatStore';
import { useChatStore } from '@/lib/stores/chatStore';
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
import { useAuth } from '@/contexts/AuthContext';
import { useMobileDetect } from '@/hooks/useMobileDetect';
import { ChatHistory } from './ChatHistory';
import { ChatInput } from './ChatInput';
import { QuotaDisplay } from './QuotaDisplay';
import { ChatHelp } from './ChatHelp';
import { ChatError } from './ChatError';
import { ConversationList } from './ConversationList';
export function ChatWidget() {
const [isOpen, toggleOpen] = useChatOpen();
const [isExpanded, toggleExpanded] = useChatExpanded();
const [showHelp, setShowHelp] = React.useState(false);
const { checkQuota, refreshUsageStats} = useChatQuota();
const { showHistory, toggleHistory, startNewConversation } = useChatHistory();
const { preferences, updatePreferences } = useUserPreferences();
const { user } = useAuth();
const { addMessage, messages, conversation, createConversation } = useChatStore();
const [hasShownWelcome, setHasShownWelcome] = React.useState(false);
const [hasInitialized, setHasInitialized] = React.useState(false);
const [exportSuccess, setExportSuccess] = React.useState(false);
const [shareSuccess, setShareSuccess] = React.useState(false);
const { isMobile, isTablet } = useMobileDetect();
// Handle pop-out to separate window
const handlePopOut = () => {
const width = 600;
const height = 700;
const left = window.screen.width - width - 50;
const top = 50;
window.open(
'/chat/window',
'ChatWindow',
`width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=no`
);
// Close the widget when popping out
toggleOpen();
};
// Export conversation as Markdown
const handleExport = () => {
if (!messages.length) return;
const title = conversation?.title || 'CanadaGPT Conversation';
const date = new Date().toLocaleDateString('en-CA');
let markdown = `# ${title}\n\n`;
markdown += `*Exported from CanadaGPT on ${date}*\n\n---\n\n`;
messages.forEach((msg) => {
const role = msg.role === 'user' ? '**You**' : '**Gordie**';
const time = new Date(msg.created_at).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
});
markdown += `### ${role} (${time})\n\n${msg.content}\n\n---\n\n`;
});
markdown += `\n*Powered by [CanadaGPT](https://canadagpt.ca)*`;
// Create and download file
const blob = new Blob([markdown], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `canadagpt-${conversation?.id || 'chat'}-${date}.md`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setExportSuccess(true);
setTimeout(() => setExportSuccess(false), 2000);
};
// Share/Copy conversation to clipboard
const handleShare = async () => {
if (!messages.length) return;
const title = conversation?.title || 'CanadaGPT Conversation';
let text = `${title}\n\n`;
messages.forEach((msg) => {
const role = msg.role === 'user' ? 'You' : 'Gordie';
text += `${role}: ${msg.content}\n\n`;
});
text += `Shared from CanadaGPT - https://canadagpt.ca`;
// Try Web Share API first (mobile)
if (navigator.share) {
try {
await navigator.share({
title: title,
text: text,
});
setShareSuccess(true);
setTimeout(() => setShareSuccess(false), 2000);
return;
} catch (err) {
// User cancelled or share failed, fall through to clipboard
}
}
// Fallback to clipboard
await navigator.clipboard.writeText(text);
setShareSuccess(true);
setTimeout(() => setShareSuccess(false), 2000);
};
// Initialize quota check on mount
React.useEffect(() => {
if (!user) return; // Skip if not authenticated
checkQuota();
refreshUsageStats();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user]); // checkQuota and refreshUsageStats are stable Zustand store functions
// Welcome flow for first-time users - DISABLED (auto-open removed)
React.useEffect(() => {
const showWelcome = async () => {
// Only mark as seen if user is logged in and hasn't seen it
if (user && preferences && !preferences.has_seen_welcome && !hasShownWelcome) {
// Mark as shown immediately to prevent re-triggering
setHasShownWelcome(true);
// Mark as seen in preferences (without auto-opening)
await updatePreferences({ has_seen_welcome: true });
}
};
showWelcome();
}, [user, preferences, hasShownWelcome, updatePreferences]);
// Auto-open in sidebar mode on desktop (one-time initialization)
// Works for both authenticated and anonymous users
React.useEffect(() => {
if (hasInitialized) return;
// Detect desktop (screen width >= 1024px)
const isDesktop = typeof window !== 'undefined' && window.innerWidth >= 1024;
if (isDesktop) {
// Check if user has seen the chat before
const shouldAutoOpen = () => {
if (user) {
// Authenticated: use Supabase preference
return !preferences?.has_seen_welcome;
} else {
// Anonymous: use localStorage
const hasSeenChat = localStorage.getItem('canadagpt_has_seen_chat');
return !hasSeenChat;
}
};
// Get current store state
const currentState = useChatStore.getState();
// Only auto-open if not already open/expanded AND user hasn't seen it before
if (!currentState.isOpen && !currentState.isExpanded && shouldAutoOpen()) {
// Open chat in sidebar mode
if (!currentState.isOpen) {
toggleOpen();
}
if (!currentState.isExpanded) {
toggleExpanded();
}
// Mark as seen
if (!user) {
localStorage.setItem('canadagpt_has_seen_chat', 'true');
}
// Authenticated users are marked as seen via the welcome flow effect
}
}
setHasInitialized(true);
}, [user, preferences, hasInitialized, toggleOpen, toggleExpanded]);
// Keyboard shortcut: Cmd/Ctrl + K
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
toggleOpen();
}
// Escape to close
if (e.key === 'Escape' && isOpen) {
toggleOpen();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, toggleOpen]);
// Chat widget now supports both authenticated and anonymous users
// Anonymous users will see a welcome message with signup CTA
return (
<>
{/* Floating button (shows when closed) */}
<AnimatePresence>
{!isOpen && (
<motion.button
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ type: 'spring', stiffness: 260, damping: 20 }}
onClick={toggleOpen}
className="fixed bottom-6 right-6 z-50 w-14 h-14 bg-accent-red text-white rounded-full shadow-lg hover:bg-red-700 active:scale-95 transition-colors flex items-center justify-center group"
title="Open chat (⌘K)"
aria-label="Open chat assistant. Press Command K as keyboard shortcut."
aria-expanded={false}
>
<MapleLeafIcon className="w-[52px] h-[52px]" size={52} />
{/* Keyboard hint tooltip */}
<div className="absolute bottom-full right-0 mb-2 px-2 py-1 bg-gray-900 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">
Press ⌘K to open
</div>
</motion.button>
)}
</AnimatePresence>
{/* Chat widget (shows when open) */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ type: 'spring', stiffness: 260, damping: 20 }}
role="dialog"
aria-modal="true"
aria-label="Chat with Gordie, your Canadian Parliament assistant"
className="z-50 bg-gray-900 bg-opacity-30 backdrop-blur-md rounded-lg shadow-2xl border border-gray-700 border-opacity-30 overflow-hidden flex flex-col relative pointer-events-auto transition-all duration-300"
style={{
position: 'fixed',
...(isMobile || isTablet ? {
// Mobile/Tablet: Full-screen overlay
inset: 0,
width: '100%',
height: '100%',
maxWidth: '100%',
maxHeight: '100%',
borderRadius: 0,
} : {
// Desktop: Original behavior with safe fallback
bottom: isExpanded ? '0' : '24px',
right: isExpanded ? '0' : '24px',
top: isExpanded ? '0' : 'auto',
width: isExpanded ? '25vw' : 'min(600px, calc(100vw - 48px))',
height: isExpanded ? '100vh' : '575px', // Increased from 500px for more real estate
maxHeight: isExpanded ? '100vh' : 'calc(100vh - 100px)',
maxWidth: isExpanded ? '25vw' : 'calc(100vw - 48px)',
borderRadius: isExpanded ? '0' : undefined,
}),
}}
>
{/* Content */}
{(
<div className="flex h-full">
{/* Conversation history sidebar */}
<AnimatePresence>
{showHistory && user && (
<motion.div
initial={{ width: 0, opacity: 0 }}
animate={{ width: isMobile || isTablet ? '100%' : 280, opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
className="flex-shrink-0 overflow-hidden"
style={{
position: isMobile || isTablet ? 'absolute' : 'relative',
inset: isMobile || isTablet ? 0 : undefined,
zIndex: isMobile || isTablet ? 10 : undefined,
}}
>
<ConversationList onClose={toggleHistory} />
</motion.div>
)}
</AnimatePresence>
{/* Main chat area */}
<div className="flex flex-col flex-1 min-w-0">
{/* Error display (fixed position) */}
<ChatError />
{/* Quota display */}
<div className="flex-shrink-0">
<QuotaDisplay />
</div>
{/* Chat history - takes remaining space */}
<ChatHistory />
{/* Bottom section: input + buttons */}
<div className="flex-shrink-0">
{/* Input */}
<ChatInput />
{/* Control buttons */}
<div className="flex items-center justify-between gap-2 px-4 py-3 border-t border-gray-700 bg-gray-900">
{/* Left side buttons */}
<div className="flex items-center gap-2">
{/* History toggle button */}
{user && (
<button
onClick={(e) => {
e.stopPropagation();
toggleHistory();
}}
className={`p-2 rounded-lg transition-colors ${
showHistory
? 'bg-accent-red/20 text-accent-red'
: 'hover:bg-gray-700 text-gray-300 hover:text-white'
}`}
title={showHistory ? 'Hide history' : 'Show history'}
aria-label={showHistory ? 'Hide conversation history' : 'Show conversation history'}
aria-pressed={showHistory}
>
<History className="w-4 h-4" aria-hidden="true" />
</button>
)}
{/* New chat button */}
<button
onClick={(e) => {
e.stopPropagation();
startNewConversation();
}}
className="p-2 hover:bg-gray-700 text-gray-300 hover:text-white rounded-lg transition-colors"
title="New chat"
aria-label="Start a new conversation"
>
<Plus className="w-4 h-4" aria-hidden="true" />
</button>
{/* Help button - just question mark */}
<button
onClick={(e) => {
e.stopPropagation();
setShowHelp(true);
}}
className="p-2 hover:bg-gray-700 text-gray-300 hover:text-white rounded-lg transition-colors"
title="Show help"
aria-label="Show help and available commands"
>
<HelpCircle className="w-4 h-4" aria-hidden="true" />
</button>
{/* Export button (when messages exist) */}
{messages.length > 0 && (
<button
onClick={(e) => {
e.stopPropagation();
handleExport();
}}
className={`p-2 rounded-lg transition-colors ${
exportSuccess
? 'bg-green-600/20 text-green-500'
: 'hover:bg-gray-700 text-gray-300 hover:text-white'
}`}
title="Export as Markdown"
aria-label="Download conversation as Markdown file"
>
{exportSuccess ? (
<Check className="w-4 h-4" aria-hidden="true" />
) : (
<Download className="w-4 h-4" aria-hidden="true" />
)}
</button>
)}
{/* Share button (when messages exist) */}
{messages.length > 0 && (
<button
onClick={(e) => {
e.stopPropagation();
handleShare();
}}
className={`p-2 rounded-lg transition-colors ${
shareSuccess
? 'bg-green-600/20 text-green-500'
: 'hover:bg-gray-700 text-gray-300 hover:text-white'
}`}
title="Share conversation"
aria-label="Copy conversation to clipboard or share"
>
{shareSuccess ? (
<Check className="w-4 h-4" aria-hidden="true" />
) : (
<Share2 className="w-4 h-4" aria-hidden="true" />
)}
</button>
)}
{/* Pop-out and expand buttons - hidden on mobile/tablet */}
{!isMobile && !isTablet && (
<>
{/* Pop-out button */}
<button
onClick={(e) => {
e.stopPropagation();
handlePopOut();
}}
className="p-2 hover:bg-gray-700 text-gray-300 hover:text-white rounded-lg transition-colors"
title="Pop out to window"
aria-label="Open chat in a new window"
>
<ExternalLink className="w-4 h-4" aria-hidden="true" />
</button>
{/* Expand button */}
<button
onClick={(e) => {
e.stopPropagation();
toggleExpanded();
}}
className="p-2 hover:bg-gray-700 text-gray-300 hover:text-white rounded-lg transition-colors"
title={isExpanded ? 'Collapse' : 'Expand'}
aria-label={isExpanded ? 'Collapse chat to smaller window' : 'Expand chat to sidebar'}
aria-expanded={isExpanded}
>
{isExpanded ? (
<Minimize2 className="w-4 h-4" aria-hidden="true" />
) : (
<Maximize2 className="w-4 h-4" aria-hidden="true" />
)}
</button>
</>
)}
</div>
{/* Close button */}
<button
onClick={(e) => {
e.stopPropagation();
toggleOpen();
}}
className="p-2 hover:bg-gray-700 text-gray-300 hover:text-white rounded-lg transition-colors"
title="Close (Esc)"
aria-label="Close chat. Press Escape as keyboard shortcut."
>
<X className="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
</div>
</div>
)}
</motion.div>
)}
</AnimatePresence>
{/* Help modal */}
{showHelp && <ChatHelp onClose={() => setShowHelp(false)} />}
</>
);
}