import { useState, useEffect, useRef, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { ChatMessage } from './ChatMessage'; import { TypingIndicator } from './TypingIndicator'; import { MessageInput } from './MessageInput'; import { SuggestedQuestions } from './SuggestedQuestions'; import { ChatBubblePreview } from './ChatBubblePreview'; import { useChatWebSocket } from '../hooks/useChatWebSocket'; import { usePersonalization } from '../hooks/usePersonalization'; import { usePageTracker } from '../hooks/usePageTracker'; import { useAnalytics } from '../hooks/useAnalytics'; // Storage key for tracking if user has seen greeting (return visitor detection) const GREETING_SEEN_KEY = 'kanji_greeting_seen'; // Raiko avatar URL const RAIKO_AVATAR_URL = 'https://kanji.fra1.cdn.digitaloceanspaces.com/raiko-avatar-64-static.webp'; /** * Convert animated avatar URL to static version * e.g., "mete-avatar-64.webp" -> "mete-avatar-64-static.webp" */ function toStaticAvatar(url: string | undefined): string | undefined { if (!url) return url; // Already static if (url.includes('-static.')) return url; // Convert .webp to -static.webp return url.replace(/\.webp$/, '-static.webp'); } /** * Chat Widget Component * * A floating chat button that expands into a chat panel. * Allows users to interact with Raiko AI assistant via WebSocket. * * Features: * - Floating action button (bottom-right corner) * - Expandable chat panel with smooth animations * - Real-time chat via WebSocket * - Mobile-responsive design * - Dark theme consistent with landing page * - Accessibility support with ARIA labels * - Notification badge for unread messages * - Auto-scroll to latest message * - Human takeover indicator * * Design principles: * - Non-intrusive when collapsed * - Easy to discover and access * - Smooth transitions * - Maintains visual hierarchy */ interface ChatWidgetProps { /** * Controlled open state * @default false */ open?: boolean; /** * Optional callback when the chat opens/closes */ onOpenChange?: (open: boolean) => void; /** * Number of unread messages to show badge * @default 0 */ unreadCount?: number; /** * Initial message to send when chat opens (used for "Ask Raiko" integration) */ initialMessage?: string; /** * Callback to clear the initial message after it's been sent */ onInitialMessageSent?: () => void; } export function ChatWidget({ open: controlledOpen, onOpenChange, unreadCount = 0, initialMessage, onInitialMessageSent, }: ChatWidgetProps) { // Support both controlled and uncontrolled usage const [internalOpen, setInternalOpen] = useState(false); const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen; // Bubble preview state const [bubbleMessage, setBubbleMessage] = useState(null); const [showBubble, setShowBubble] = useState(false); const [bubbleDismissed, setBubbleDismissed] = useState(false); const [bubbleSender, setBubbleSender] = useState<{ name: string; avatar: string; isHuman: boolean; }>({ name: 'Raiko', avatar: RAIKO_AVATAR_URL, isHuman: false }); // Track if this is a returning visitor const hasSeenGreeting = useRef(false); // Check localStorage on mount for returning visitor detection useEffect(() => { try { hasSeenGreeting.current = localStorage.getItem(GREETING_SEEN_KEY) === 'true'; } catch { // localStorage might be unavailable hasSeenGreeting.current = false; } }, []); // Personalization const { content: personalizedContent } = usePersonalization(); // WebSocket connection const { messages, sendMessage, sendTypingIndicator, sendPageUpdate, isConnected, isLoading, isTyping, takenOverBy, error, clearError, } = useChatWebSocket({ autoConnect: true, // Connect immediately on mount }); // Track current page for admin dashboard usePageTracker({ sendPageUpdate, isConnected }); // Analytics tracking const { trackEvent, startHeartbeat } = useAnalytics(); const heartbeatStopRef = useRef<(() => void) | null>(null); // Ref for auto-scrolling to bottom const messagesEndRef = useRef(null); const isFirstRender = useRef(true); // Track chat open/close and manage heartbeat useEffect(() => { if (isOpen) { trackEvent('chat_opened'); // Start heartbeat for duration tracking heartbeatStopRef.current = startHeartbeat('chat'); } else { // Only track close if it was previously open if (heartbeatStopRef.current) { trackEvent('chat_closed'); heartbeatStopRef.current(); heartbeatStopRef.current = null; } } return () => { // Cleanup heartbeat on unmount if (heartbeatStopRef.current) { heartbeatStopRef.current(); heartbeatStopRef.current = null; } }; }, [isOpen, trackEvent, startHeartbeat]); const handleToggle = () => { const newState = !isOpen; if (controlledOpen === undefined) { setInternalOpen(newState); } onOpenChange?.(newState); }; const handleClose = (e?: React.MouseEvent) => { e?.preventDefault(); e?.stopPropagation(); if (controlledOpen === undefined) { setInternalOpen(false); } onOpenChange?.(false); }; const handleSendMessage = (message: string) => { sendMessage(message); }; const handleQuestionClick = (question: string) => { sendMessage(question); }; // Auto-scroll to bottom when new messages arrive useEffect(() => { if (isOpen && messagesEndRef.current) { // Use instant scroll on first render, smooth scroll for new messages const behavior = isFirstRender.current ? 'instant' : 'smooth'; messagesEndRef.current.scrollIntoView({ behavior }); isFirstRender.current = false; } }, [messages, isOpen]); // Send initial message when chat opens with one (e.g., from "Ask Raiko" button) useEffect(() => { if (isOpen && initialMessage && isConnected) { sendMessage(initialMessage); onInitialMessageSent?.(); } // eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally omit sendMessage and onInitialMessageSent to avoid re-triggering }, [isOpen, initialMessage, isConnected]); // Track previous message count to detect new messages const prevMessageCountRef = useRef(messages.length); // Show bubble preview when a NEW message arrives and chat is closed useEffect(() => { const prevCount = prevMessageCountRef.current; const currentCount = messages.length; // Update ref for next comparison prevMessageCountRef.current = currentCount; // Only react to NEW messages (not on initial load or when user closes) if (currentCount > prevCount && currentCount > 0 && !isOpen) { const lastMessage = messages[currentCount - 1]; // Only show bubble for non-user messages if (lastMessage.role !== 'user') { const isHumanMessage = !!lastMessage.humanName; // For automated greetings (Raiko/AI), skip if user already seen greeting before // Always show bubble for human messages (founder takeover) if (!isHumanMessage && hasSeenGreeting.current) { // Return visitor - don't show automated greeting bubble return; } // Show the bubble - always show for human messages, respect bubbleDismissed for AI // Human takeover messages are important and should always show a notification if (!bubbleDismissed || isHumanMessage) { setBubbleMessage(lastMessage.content); setBubbleSender({ name: isHumanMessage ? (lastMessage.humanName || 'Team Member') : 'Raiko', avatar: isHumanMessage ? toStaticAvatar(lastMessage.humanAvatar) || RAIKO_AVATAR_URL : RAIKO_AVATAR_URL, isHuman: isHumanMessage, }); setShowBubble(true); // Reset bubbleDismissed for human messages so future human messages also show if (isHumanMessage && bubbleDismissed) { setBubbleDismissed(false); } // Mark greeting as seen for non-human messages (so return visitors don't see it again) if (!isHumanMessage) { try { localStorage.setItem(GREETING_SEEN_KEY, 'true'); hasSeenGreeting.current = true; } catch { // localStorage unavailable } } } } } // eslint-disable-next-line react-hooks/exhaustive-deps -- Use messages.length to detect new messages, access messages array inside effect }, [messages.length, isOpen, bubbleDismissed]); // Hide bubble when chat opens useEffect(() => { if (isOpen) { setShowBubble(false); } }, [isOpen]); // Bubble dismiss handler const handleBubbleDismiss = useCallback(() => { setShowBubble(false); setBubbleDismissed(true); }, []); // Bubble click handler (opens chat) const handleBubbleClick = useCallback(() => { setShowBubble(false); if (controlledOpen === undefined) { setInternalOpen(true); } onOpenChange?.(true); }, [controlledOpen, onOpenChange]); // Determine who is responding (Raiko or human) const respondentName = takenOverBy.active ? takenOverBy.name || 'Team Member' : 'Raiko'; const respondentRole = takenOverBy.active ? takenOverBy.role || 'Support' : 'AI Assistant'; const content = ( <> {/* Chat Panel - slides in from bottom-right */}
{/* Chat Header - Compact */}
{/* Avatar */}
{takenOverBy.active && takenOverBy.avatar ? ( {respondentName} ) : ( Raiko )}
{/* Status indicator */}
{/* Header Info - Stacked & Centered */}

{respondentName}

{respondentRole}

{/* Close Button */}
{/* Chat Messages Area */}
{/* Loading state */} {isLoading && messages.length === 0 && (

Connecting...

)} {/* Error state */} {error && (

{error}

)} {/* Welcome message (shown when no messages yet) */} {!isLoading && messages.length === 0 && !error && ( <>
Raiko

Welcome to Kanji

Hi! I'm Raiko, your guide to the Kanji network. Ask me anything about wallets, invites, or the upcoming $KANJI presale.

{/* Suggested Questions - Personalized based on traffic source */} )} {/* Chat messages */} {messages.map((message) => ( ))} {/* Typing indicator */} {isTyping && ( )} {/* Auto-scroll anchor */}
{/* Chat Input Area - Compact */}
{/* Bubble Preview - shows when message arrives and chat is closed */} {showBubble && !isOpen && bubbleMessage && ( )} {/* Breathing glow behind button */} {!isOpen && ( )} {/* Floating Action Button */} ); // Render via portal directly to body - this ensures chat widget // is always rendered after Radix Dialog portals in DOM order if (typeof document !== 'undefined') { return createPortal(content, document.body); } return content; }