mirror of
https://github.com/infiniflow/ragflow.git
synced 2025-12-26 00:46:52 +08:00
Chat Widget (#10187)
### What problem does this PR solve? Add a chat widget. I'll probably need some assistance to get this ready for merge! ### Type of change - [x] New Feature (non-breaking change which adds functionality) Co-authored-by: Mohamed Mathari <nocodeventure@Mac-mini-van-Mohamed.fritz.box>
This commit is contained in:
666
web/src/components/floating-chat-widget.tsx
Normal file
666
web/src/components/floating-chat-widget.tsx
Normal file
@ -0,0 +1,666 @@
|
||||
import { MessageType, SharedFrom } from '@/constants/chat';
|
||||
import { useFetchNextConversationSSE } from '@/hooks/chat-hooks';
|
||||
import { useFetchFlowSSE } from '@/hooks/flow-hooks';
|
||||
import { useFetchExternalChatInfo } from '@/hooks/use-chat-request';
|
||||
import i18n from '@/locales/config';
|
||||
import { MessageCircle, Minimize2, Send, X } from 'lucide-react';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
useGetSharedChatSearchParams,
|
||||
useSendSharedMessage,
|
||||
} from '../pages/next-chats/hooks/use-send-shared-message';
|
||||
|
||||
const FloatingChatWidget = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [lastResponseId, setLastResponseId] = useState<string | null>(null);
|
||||
const [displayMessages, setDisplayMessages] = useState<any[]>([]);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
sharedId: conversationId,
|
||||
from,
|
||||
locale,
|
||||
visibleAvatar,
|
||||
} = useGetSharedChatSearchParams();
|
||||
|
||||
// Check if we're in button-only mode or window-only mode
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const mode = urlParams.get('mode') || 'full'; // 'button', 'window', or 'full'
|
||||
const enableStreaming = urlParams.get('streaming') === 'true'; // Only enable if explicitly set to true
|
||||
|
||||
const {
|
||||
handlePressEnter,
|
||||
handleInputChange,
|
||||
value: hookValue,
|
||||
sendLoading,
|
||||
derivedMessages,
|
||||
hasError,
|
||||
} = useSendSharedMessage();
|
||||
|
||||
// Sync our local input with the hook's value when needed
|
||||
useEffect(() => {
|
||||
if (hookValue && hookValue !== inputValue) {
|
||||
setInputValue(hookValue);
|
||||
}
|
||||
}, [hookValue, inputValue]);
|
||||
|
||||
const { data: chatInfo } = useFetchExternalChatInfo();
|
||||
|
||||
const useFetchAvatar = useMemo(() => {
|
||||
return from === SharedFrom.Agent
|
||||
? useFetchFlowSSE
|
||||
: useFetchNextConversationSSE;
|
||||
}, [from]);
|
||||
|
||||
const { data: avatarData } = useFetchAvatar();
|
||||
|
||||
// Play sound when opening
|
||||
const playNotificationSound = useCallback(() => {
|
||||
try {
|
||||
const audioContext = new (window.AudioContext ||
|
||||
(window as any).webkitAudioContext)();
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gainNode = audioContext.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
|
||||
oscillator.frequency.value = 800;
|
||||
oscillator.type = 'sine';
|
||||
|
||||
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(
|
||||
0.01,
|
||||
audioContext.currentTime + 0.3,
|
||||
);
|
||||
|
||||
oscillator.start(audioContext.currentTime);
|
||||
oscillator.stop(audioContext.currentTime + 0.3);
|
||||
} catch (error) {
|
||||
// Silent fail if audio not supported
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Play sound for AI responses (Intercom-style)
|
||||
const playResponseSound = useCallback(() => {
|
||||
try {
|
||||
const audioContext = new (window.AudioContext ||
|
||||
(window as any).webkitAudioContext)();
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gainNode = audioContext.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
|
||||
oscillator.frequency.value = 600;
|
||||
oscillator.type = 'sine';
|
||||
|
||||
gainNode.gain.setValueAtTime(0.2, audioContext.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(
|
||||
0.01,
|
||||
audioContext.currentTime + 0.2,
|
||||
);
|
||||
|
||||
oscillator.start(audioContext.currentTime);
|
||||
oscillator.stop(audioContext.currentTime + 0.2);
|
||||
} catch (error) {
|
||||
// Silent fail if audio not supported
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Set loaded state and locale
|
||||
useEffect(() => {
|
||||
// Set component as loaded after a brief moment to prevent flash
|
||||
const timer = setTimeout(() => {
|
||||
setIsLoaded(true);
|
||||
// Tell parent window that we're ready to be shown
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'WIDGET_READY',
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}, 50);
|
||||
|
||||
if (locale && i18n.language !== locale) {
|
||||
i18n.changeLanguage(locale);
|
||||
}
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [locale]);
|
||||
|
||||
// Handle message display based on streaming preference
|
||||
useEffect(() => {
|
||||
if (!derivedMessages) {
|
||||
setDisplayMessages([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (enableStreaming) {
|
||||
// Show messages as they stream
|
||||
setDisplayMessages(derivedMessages);
|
||||
} else {
|
||||
// Only show complete messages (non-streaming mode)
|
||||
const completeMessages = derivedMessages.filter((msg, index) => {
|
||||
// Always show user messages immediately
|
||||
if (msg.role === MessageType.User) return true;
|
||||
|
||||
// For AI messages, only show when response is complete (not loading)
|
||||
if (msg.role === MessageType.Assistant) {
|
||||
return !sendLoading || index < derivedMessages.length - 1;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
setDisplayMessages(completeMessages);
|
||||
}
|
||||
}, [derivedMessages, enableStreaming, sendLoading]);
|
||||
|
||||
// Auto-scroll to bottom when display messages change
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [displayMessages]);
|
||||
|
||||
// Play sound only when AI response is complete (not streaming chunks)
|
||||
useEffect(() => {
|
||||
if (derivedMessages && derivedMessages.length > 0 && !sendLoading) {
|
||||
const lastMessage = derivedMessages[derivedMessages.length - 1];
|
||||
if (
|
||||
lastMessage.role === MessageType.Assistant &&
|
||||
lastMessage.id !== lastResponseId &&
|
||||
derivedMessages.length > 1
|
||||
) {
|
||||
setLastResponseId(lastMessage.id || '');
|
||||
playResponseSound();
|
||||
}
|
||||
}
|
||||
}, [derivedMessages, sendLoading, lastResponseId, playResponseSound]);
|
||||
|
||||
const toggleChat = useCallback(() => {
|
||||
if (mode === 'button') {
|
||||
// In button mode, communicate with parent window to show/hide chat window
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'TOGGLE_CHAT',
|
||||
isOpen: !isOpen,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
setIsOpen(!isOpen);
|
||||
if (!isOpen) {
|
||||
playNotificationSound();
|
||||
}
|
||||
} else {
|
||||
// In full mode, handle locally
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
setIsMinimized(false);
|
||||
playNotificationSound();
|
||||
} else {
|
||||
setIsOpen(false);
|
||||
setIsMinimized(false);
|
||||
}
|
||||
}
|
||||
}, [isOpen, mode, playNotificationSound]);
|
||||
|
||||
const minimizeChat = useCallback(() => {
|
||||
setIsMinimized(true);
|
||||
}, []);
|
||||
|
||||
const handleSendMessage = useCallback(() => {
|
||||
if (!inputValue.trim() || sendLoading) return;
|
||||
|
||||
// Update the hook's internal state first
|
||||
const syntheticEvent = {
|
||||
target: { value: inputValue },
|
||||
currentTarget: { value: inputValue },
|
||||
preventDefault: () => {},
|
||||
} as any;
|
||||
|
||||
handleInputChange(syntheticEvent);
|
||||
|
||||
// Wait for state to update, then send
|
||||
setTimeout(() => {
|
||||
handlePressEnter([]);
|
||||
// Clear our local input after sending
|
||||
setInputValue('');
|
||||
}, 50);
|
||||
}, [inputValue, sendLoading, handleInputChange, handlePressEnter]);
|
||||
|
||||
const handleKeyPress = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
},
|
||||
[handleSendMessage],
|
||||
);
|
||||
|
||||
if (!conversationId) {
|
||||
return (
|
||||
<div className="fixed bottom-5 right-5 z-50">
|
||||
<div className="bg-red-500 text-white p-4 rounded-lg shadow-lg">
|
||||
Error: No conversation ID provided
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Remove the blocking return - we'll handle visibility with CSS instead
|
||||
|
||||
const messageCount = displayMessages?.length || 0;
|
||||
|
||||
// Render different content based on mode
|
||||
if (mode === 'master') {
|
||||
// Master mode - handles everything and creates second iframe dynamically
|
||||
useEffect(() => {
|
||||
// Create the chat window iframe dynamically when needed
|
||||
const createChatWindow = () => {
|
||||
// Check if iframe already exists in parent document
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'CREATE_CHAT_WINDOW',
|
||||
src: window.location.href.replace('mode=master', 'mode=window'),
|
||||
},
|
||||
'*',
|
||||
);
|
||||
};
|
||||
|
||||
createChatWindow();
|
||||
|
||||
// Listen for our own toggle events to show/hide the dynamic iframe
|
||||
const handleToggle = (e: MessageEvent) => {
|
||||
if (e.source === window) return; // Ignore our own messages
|
||||
|
||||
const chatWindow = document.getElementById(
|
||||
'dynamic-chat-window',
|
||||
) as HTMLIFrameElement;
|
||||
if (chatWindow && e.data.type === 'TOGGLE_CHAT') {
|
||||
chatWindow.style.display = e.data.isOpen ? 'block' : 'none';
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleToggle);
|
||||
return () => window.removeEventListener('message', handleToggle);
|
||||
}, []);
|
||||
|
||||
// Show just the button in master mode
|
||||
return (
|
||||
<div
|
||||
className={`fixed bottom-6 right-6 z-50 transition-opacity duration-300 ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newIsOpen = !isOpen;
|
||||
setIsOpen(newIsOpen);
|
||||
if (newIsOpen) playNotificationSound();
|
||||
|
||||
// Tell the parent to show/hide the dynamic iframe
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'TOGGLE_CHAT',
|
||||
isOpen: newIsOpen,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}}
|
||||
className={`w-14 h-14 bg-blue-600 hover:bg-blue-700 text-white rounded-full transition-all duration-300 flex items-center justify-center group ${
|
||||
isOpen ? 'scale-95' : 'scale-100 hover:scale-105'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`transition-transform duration-300 ${isOpen ? 'rotate-45' : 'rotate-0'}`}
|
||||
>
|
||||
{isOpen ? <X size={24} /> : <MessageCircle size={24} />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Unread Badge */}
|
||||
{!isOpen && messageCount > 0 && (
|
||||
<div className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white text-xs font-bold rounded-full flex items-center justify-center animate-pulse">
|
||||
{messageCount > 9 ? '9+' : messageCount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === 'button') {
|
||||
// Only render the floating button
|
||||
return (
|
||||
<div
|
||||
className={`fixed bottom-6 right-6 z-50 transition-opacity duration-300 ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
>
|
||||
<button
|
||||
onClick={toggleChat}
|
||||
className={`w-14 h-14 bg-blue-600 hover:bg-blue-700 text-white rounded-full transition-all duration-300 flex items-center justify-center group ${
|
||||
isOpen ? 'scale-95' : 'scale-100 hover:scale-105'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`transition-transform duration-300 ${isOpen ? 'rotate-45' : 'rotate-0'}`}
|
||||
>
|
||||
{isOpen ? <X size={24} /> : <MessageCircle size={24} />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Unread Badge */}
|
||||
{!isOpen && messageCount > 0 && (
|
||||
<div className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white text-xs font-bold rounded-full flex items-center justify-center animate-pulse">
|
||||
{messageCount > 9 ? '9+' : messageCount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === 'window') {
|
||||
// Only render the chat window (always open)
|
||||
return (
|
||||
<div
|
||||
className={`fixed top-0 left-0 z-50 bg-blue-600 rounded-2xl transition-all duration-300 ease-out h-[500px] w-[380px] overflow-hidden ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-t-2xl">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-white bg-opacity-20 rounded-full flex items-center justify-center">
|
||||
<MessageCircle size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">
|
||||
{chatInfo?.title || 'Chat Support'}
|
||||
</h3>
|
||||
<p className="text-xs text-blue-100">
|
||||
We typically reply instantly
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages and Input */}
|
||||
<div
|
||||
className="flex flex-col h-[436px] bg-white"
|
||||
style={{ borderRadius: '0 0 16px 16px' }}
|
||||
>
|
||||
<div
|
||||
className="flex-1 overflow-y-auto p-4 space-y-4"
|
||||
onWheel={(e) => {
|
||||
const element = e.currentTarget;
|
||||
const isAtTop = element.scrollTop === 0;
|
||||
const isAtBottom =
|
||||
element.scrollTop + element.clientHeight >=
|
||||
element.scrollHeight - 1;
|
||||
|
||||
// Allow scroll to pass through to parent when at boundaries
|
||||
if ((isAtTop && e.deltaY < 0) || (isAtBottom && e.deltaY > 0)) {
|
||||
e.preventDefault();
|
||||
// Let the parent handle the scroll
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'SCROLL_PASSTHROUGH',
|
||||
deltaY: e.deltaY,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{displayMessages?.map((message, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex ${message.role === MessageType.User ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[280px] px-4 py-2 rounded-2xl ${
|
||||
message.role === MessageType.User
|
||||
? 'bg-blue-600 text-white rounded-br-md'
|
||||
: 'bg-gray-100 text-gray-800 rounded-bl-md'
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{message.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Clean Typing Indicator */}
|
||||
{sendLoading && !enableStreaming && (
|
||||
<div className="flex justify-start pl-4">
|
||||
<div className="flex space-x-1">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce"></div>
|
||||
<div
|
||||
className="w-2 h-2 bg-blue-500 rounded-full animate-bounce"
|
||||
style={{ animationDelay: '0.1s' }}
|
||||
></div>
|
||||
<div
|
||||
className="w-2 h-2 bg-blue-500 rounded-full animate-bounce"
|
||||
style={{ animationDelay: '0.2s' }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="border-t border-gray-200 p-4">
|
||||
<div className="flex items-end space-x-3">
|
||||
<div className="flex-1">
|
||||
<textarea
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setInputValue(newValue);
|
||||
handleInputChange(e);
|
||||
}}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Type your message..."
|
||||
rows={1}
|
||||
className="w-full resize-none border border-gray-300 rounded-2xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
style={{ minHeight: '44px', maxHeight: '120px' }}
|
||||
disabled={hasError || sendLoading}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!inputValue.trim() || sendLoading}
|
||||
className="p-3 bg-blue-600 text-white rounded-full hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Send size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Full mode - render everything together (original behavior)
|
||||
return (
|
||||
<div
|
||||
className={`transition-opacity duration-300 ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
>
|
||||
{/* Chat Widget Container */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className={`fixed bottom-24 right-6 z-50 bg-blue-600 rounded-2xl transition-all duration-300 ease-out ${
|
||||
isMinimized ? 'h-16' : 'h-[500px]'
|
||||
} w-[380px] overflow-hidden`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-t-2xl">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-white bg-opacity-20 rounded-full flex items-center justify-center">
|
||||
<MessageCircle size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">
|
||||
{chatInfo?.title || 'Chat Support'}
|
||||
</h3>
|
||||
<p className="text-xs text-blue-100">
|
||||
We typically reply instantly
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<button
|
||||
onClick={minimizeChat}
|
||||
className="p-1.5 hover:bg-white hover:bg-opacity-20 rounded-full transition-colors"
|
||||
>
|
||||
<Minimize2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleChat}
|
||||
className="p-1.5 hover:bg-white hover:bg-opacity-20 rounded-full transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages Container */}
|
||||
{!isMinimized && (
|
||||
<div
|
||||
className="flex flex-col h-[436px] bg-white"
|
||||
style={{ borderRadius: '0 0 16px 16px' }}
|
||||
>
|
||||
<div
|
||||
className="flex-1 overflow-y-auto p-4 space-y-4"
|
||||
onWheel={(e) => {
|
||||
const element = e.currentTarget;
|
||||
const isAtTop = element.scrollTop === 0;
|
||||
const isAtBottom =
|
||||
element.scrollTop + element.clientHeight >=
|
||||
element.scrollHeight - 1;
|
||||
|
||||
// Allow scroll to pass through to parent when at boundaries
|
||||
if (
|
||||
(isAtTop && e.deltaY < 0) ||
|
||||
(isAtBottom && e.deltaY > 0)
|
||||
) {
|
||||
e.preventDefault();
|
||||
// Let the parent handle the scroll
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'SCROLL_PASSTHROUGH',
|
||||
deltaY: e.deltaY,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{displayMessages?.map((message, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex ${message.role === MessageType.User ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[280px] px-4 py-2 rounded-2xl ${
|
||||
message.role === MessageType.User
|
||||
? 'bg-blue-600 text-white rounded-br-md'
|
||||
: 'bg-gray-100 text-gray-800 rounded-bl-md'
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{message.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Typing Indicator */}
|
||||
{sendLoading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-gray-100 rounded-2xl rounded-bl-md px-4 py-3">
|
||||
<div className="flex space-x-1">
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
|
||||
<div
|
||||
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
|
||||
style={{ animationDelay: '0.1s' }}
|
||||
></div>
|
||||
<div
|
||||
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
|
||||
style={{ animationDelay: '0.2s' }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="border-t border-gray-200 p-4">
|
||||
<div className="flex items-end space-x-3">
|
||||
<div className="flex-1">
|
||||
<textarea
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setInputValue(newValue);
|
||||
// Also update the hook's state
|
||||
handleInputChange(e);
|
||||
}}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Type your message..."
|
||||
rows={1}
|
||||
className="w-full resize-none border border-gray-300 rounded-2xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
style={{ minHeight: '44px', maxHeight: '120px' }}
|
||||
disabled={hasError || sendLoading}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!inputValue.trim() || sendLoading}
|
||||
className="p-3 bg-blue-600 text-white rounded-full hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Send size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Floating Button */}
|
||||
<div className="fixed bottom-6 right-6 z-50">
|
||||
<button
|
||||
onClick={toggleChat}
|
||||
className={`w-14 h-14 bg-blue-600 hover:bg-blue-700 text-white rounded-full transition-all duration-300 flex items-center justify-center group ${
|
||||
isOpen ? 'scale-95' : 'scale-100 hover:scale-105'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`transition-transform duration-300 ${isOpen ? 'rotate-45' : 'rotate-0'}`}
|
||||
>
|
||||
{isOpen ? <X size={24} /> : <MessageCircle size={24} />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Unread Badge */}
|
||||
{!isOpen && messageCount > 0 && (
|
||||
<div className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white text-xs font-bold rounded-full flex items-center justify-center animate-pulse">
|
||||
{messageCount > 9 ? '9+' : messageCount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FloatingChatWidget;
|
||||
Reference in New Issue
Block a user