737 lines
31 KiB
TypeScript
737 lines
31 KiB
TypeScript
import React, { useState, useEffect, useRef } from 'react';
|
|
import { MessageCircle, X, Send, Minimize2, Maximize2, Clock } from 'lucide-react';
|
|
import chatService, { type Chat, type ChatMessage } from '../services/chatService';
|
|
import contactService, { type ContactFormData } from '../../content/services/contactService';
|
|
import useAuthStore from '../../../store/useAuthStore';
|
|
import { useCompanySettings } from '../../../shared/contexts/CompanySettingsContext';
|
|
import { toast } from 'react-toastify';
|
|
import ConfirmationDialog from '../../../shared/components/ConfirmationDialog';
|
|
import { formatWorkingHours } from '../../../shared/utils/format';
|
|
|
|
interface ChatWidgetProps {
|
|
onClose?: () => void;
|
|
}
|
|
|
|
const ChatWidget: React.FC<ChatWidgetProps> = ({ onClose }) => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [isMinimized, setIsMinimized] = useState(false);
|
|
const [chat, setChat] = useState<Chat | null>(null);
|
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
const [newMessage, setNewMessage] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
const [ws, setWs] = useState<WebSocket | null>(null);
|
|
const [visitorInfo, setVisitorInfo] = useState({
|
|
name: '',
|
|
email: '',
|
|
phone: ''
|
|
});
|
|
const [showVisitorForm, setShowVisitorForm] = useState(false);
|
|
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
|
const [showEndChatDialog, setShowEndChatDialog] = useState(false);
|
|
const [inquiry, setInquiry] = useState('');
|
|
const [inquiryEmail, setInquiryEmail] = useState('');
|
|
const [submittingInquiry, setSubmittingInquiry] = useState(false);
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
const { isAuthenticated, userInfo } = useAuthStore();
|
|
const { settings } = useCompanySettings();
|
|
|
|
// Check if current time is within business hours from settings
|
|
const isBusinessHours = () => {
|
|
const now = new Date();
|
|
const hour = now.getHours();
|
|
const startHour = settings.chat_working_hours_start || 9;
|
|
const endHour = settings.chat_working_hours_end || 17;
|
|
return hour >= startHour && hour < endHour;
|
|
};
|
|
|
|
const [isWithinBusinessHours, setIsWithinBusinessHours] = useState(isBusinessHours());
|
|
|
|
// Update business hours check periodically
|
|
useEffect(() => {
|
|
const checkBusinessHours = () => {
|
|
setIsWithinBusinessHours(isBusinessHours());
|
|
};
|
|
checkBusinessHours();
|
|
const interval = setInterval(checkBusinessHours, 60000); // Check every minute
|
|
return () => clearInterval(interval);
|
|
}, [settings.chat_working_hours_start, settings.chat_working_hours_end]);
|
|
|
|
const scrollToBottom = () => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
};
|
|
|
|
useEffect(() => {
|
|
scrollToBottom();
|
|
}, [messages]);
|
|
|
|
const validateVisitorForm = (): boolean => {
|
|
const errors: Record<string, string> = {};
|
|
|
|
if (!visitorInfo.name.trim()) {
|
|
errors.name = 'Name is required';
|
|
}
|
|
|
|
if (!visitorInfo.email.trim()) {
|
|
errors.email = 'Email is required';
|
|
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(visitorInfo.email)) {
|
|
errors.email = 'Please enter a valid email address';
|
|
}
|
|
|
|
if (!visitorInfo.phone.trim()) {
|
|
errors.phone = 'Phone is required';
|
|
}
|
|
|
|
setFormErrors(errors);
|
|
return Object.keys(errors).length === 0;
|
|
};
|
|
|
|
const createChat = async () => {
|
|
if (!isAuthenticated) {
|
|
if (!validateVisitorForm()) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
setLoading(true);
|
|
const response = await chatService.createChat(
|
|
isAuthenticated
|
|
? undefined
|
|
: {
|
|
visitor_name: visitorInfo.name,
|
|
visitor_email: visitorInfo.email,
|
|
visitor_phone: visitorInfo.phone
|
|
}
|
|
);
|
|
setChat(response.data);
|
|
setShowVisitorForm(false);
|
|
|
|
const messagesResponse = await chatService.getMessages(response.data.id);
|
|
setMessages(messagesResponse.data);
|
|
|
|
connectWebSocket(response.data.id);
|
|
|
|
// Show success message - chat is ready to use
|
|
toast.success('Chat started! You can now send messages.');
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.detail || 'Failed to start chat');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const connectWebSocket = (chatId: number) => {
|
|
if (ws) {
|
|
ws.close();
|
|
}
|
|
|
|
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
|
const normalizedBase = baseUrl.replace(/\/$/, '');
|
|
const wsProtocol = normalizedBase.startsWith('https') ? 'wss' : 'ws';
|
|
const wsBase = normalizedBase.replace(/^https?/, wsProtocol);
|
|
const wsUrl = `${wsBase}/api/chat/ws/${chatId}?user_type=visitor`;
|
|
|
|
const websocket = new WebSocket(wsUrl);
|
|
|
|
websocket.onopen = () => {
|
|
console.log('WebSocket connected for chat', chatId);
|
|
};
|
|
|
|
websocket.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
|
|
if (data.type === 'new_message') {
|
|
setMessages(prev => {
|
|
const exists = prev.find(m => m.id === data.data.id);
|
|
if (exists) return prev;
|
|
return [...prev, data.data];
|
|
});
|
|
} else if (data.type === 'chat_accepted') {
|
|
if (chat) {
|
|
const updatedChat = {
|
|
...chat,
|
|
status: 'active' as const,
|
|
staff_name: data.data.staff_name,
|
|
staff_id: data.data.staff_id
|
|
};
|
|
setChat(updatedChat);
|
|
toast.success(`Chat accepted by ${data.data.staff_name}`);
|
|
}
|
|
} else if (data.type === 'chat_closed') {
|
|
toast.info('Chat has been closed');
|
|
if (chat) {
|
|
setChat({ ...chat, status: 'closed' });
|
|
// Optionally reset after a delay to allow user to see the closed state
|
|
// The user can click "Start New Chat" button to start fresh
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error parsing WebSocket message:', error);
|
|
}
|
|
};
|
|
|
|
websocket.onerror = (error) => {
|
|
console.error('WebSocket error:', error);
|
|
};
|
|
|
|
websocket.onclose = (event) => {
|
|
console.log('WebSocket disconnected', event.code, event.reason);
|
|
if (event.code !== 1000 && chat) {
|
|
console.log('Attempting to reconnect WebSocket...');
|
|
setTimeout(() => {
|
|
if (chat) {
|
|
connectWebSocket(chat.id);
|
|
}
|
|
}, 3000);
|
|
}
|
|
};
|
|
|
|
setWs(websocket);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!chat || !isOpen || isMinimized) return;
|
|
|
|
const pollInterval = setInterval(async () => {
|
|
try {
|
|
const messagesResponse = await chatService.getMessages(chat.id);
|
|
if (messagesResponse.success) {
|
|
setMessages(messagesResponse.data);
|
|
}
|
|
|
|
if (chat.status === 'pending') {
|
|
try {
|
|
const chatResponse = await chatService.getChat(chat.id);
|
|
if (chatResponse.success) {
|
|
const updatedChat = chatResponse.data;
|
|
if (updatedChat.status !== chat.status ||
|
|
updatedChat.staff_name !== chat.staff_name ||
|
|
updatedChat.staff_id !== chat.staff_id) {
|
|
setChat(updatedChat);
|
|
if (updatedChat.status === 'active' && updatedChat.staff_name && chat.status === 'pending') {
|
|
toast.success(`Chat accepted by ${updatedChat.staff_name}`);
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.debug('Could not poll chat status:', error);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error polling chat updates:', error);
|
|
}
|
|
}, 5000);
|
|
|
|
return () => clearInterval(pollInterval);
|
|
}, [chat, isOpen, isMinimized]);
|
|
|
|
const handleOpen = () => {
|
|
setIsOpen(true);
|
|
setIsMinimized(false);
|
|
|
|
// If outside business hours, don't show chat options
|
|
if (!isWithinBusinessHours) {
|
|
return;
|
|
}
|
|
|
|
// If there's a closed chat, reset chat state but keep visitor info
|
|
if (chat && chat.status === 'closed') {
|
|
setChat(null);
|
|
setMessages([]);
|
|
setNewMessage('');
|
|
setShowVisitorForm(false);
|
|
// Don't auto-start, let user click the button
|
|
} else if (isAuthenticated && !chat && !loading) {
|
|
createChat();
|
|
} else if (!isAuthenticated && !chat) {
|
|
// If visitor info exists, show the "Start New Chat" button instead of form
|
|
if (visitorInfo.name && visitorInfo.email && visitorInfo.phone) {
|
|
setShowVisitorForm(false);
|
|
} else {
|
|
setShowVisitorForm(true);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleSubmitInquiry = async () => {
|
|
if (!inquiryEmail.trim() || !inquiry.trim()) {
|
|
toast.error('Please provide your email and inquiry');
|
|
return;
|
|
}
|
|
|
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inquiryEmail)) {
|
|
toast.error('Please enter a valid email address');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setSubmittingInquiry(true);
|
|
const contactData: ContactFormData = {
|
|
name: visitorInfo.name || 'Chat Visitor',
|
|
email: inquiryEmail,
|
|
phone: visitorInfo.phone || '',
|
|
subject: 'Chat Inquiry - Outside Business Hours',
|
|
message: inquiry
|
|
};
|
|
|
|
await contactService.submitContactForm(contactData);
|
|
toast.success('Your inquiry has been sent! We will get back to you as soon as possible.');
|
|
setInquiry('');
|
|
setInquiryEmail('');
|
|
setIsOpen(false);
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.detail || 'Failed to send inquiry. Please try again.');
|
|
} finally {
|
|
setSubmittingInquiry(false);
|
|
}
|
|
};
|
|
|
|
const handleClose = () => {
|
|
if (ws) {
|
|
ws.close();
|
|
setWs(null);
|
|
}
|
|
setIsOpen(false);
|
|
if (onClose) onClose();
|
|
};
|
|
|
|
const handleEndChat = () => {
|
|
if (!chat) return;
|
|
setShowEndChatDialog(true);
|
|
};
|
|
|
|
const confirmEndChat = async () => {
|
|
if (!chat) return;
|
|
|
|
try {
|
|
setShowEndChatDialog(false);
|
|
await chatService.closeChat(chat.id);
|
|
toast.success('Chat ended');
|
|
if (ws) {
|
|
ws.close();
|
|
setWs(null);
|
|
}
|
|
// Reset chat state but preserve visitor info for new chat
|
|
setChat(null);
|
|
setMessages([]);
|
|
setNewMessage('');
|
|
setShowVisitorForm(false);
|
|
// Keep visitor info so they can start a new chat with same info
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.detail || 'Failed to end chat');
|
|
}
|
|
};
|
|
|
|
const handleSend = async () => {
|
|
if (!newMessage.trim() || !chat) return;
|
|
|
|
const messageText = newMessage.trim();
|
|
setNewMessage('');
|
|
|
|
const tempMessage: ChatMessage = {
|
|
id: Date.now(),
|
|
chat_id: chat.id,
|
|
sender_type: 'visitor',
|
|
sender_name: isAuthenticated ? userInfo?.name : (visitorInfo.name || 'Guest'),
|
|
message: messageText,
|
|
is_read: false,
|
|
created_at: new Date().toISOString()
|
|
};
|
|
setMessages(prev => [...prev, tempMessage]);
|
|
|
|
try {
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
|
|
ws.send(JSON.stringify({
|
|
type: 'message',
|
|
message: messageText
|
|
}));
|
|
} else {
|
|
await chatService.sendMessage(chat.id, messageText);
|
|
}
|
|
} catch (error: any) {
|
|
toast.error('Failed to send message');
|
|
setMessages(prev => prev.filter(m => m.id !== tempMessage.id));
|
|
}
|
|
};
|
|
|
|
const handleKeyPress = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSend();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="fixed bottom-6 right-6 z-50" style={{ willChange: 'transform' }}>
|
|
{!isOpen ? (
|
|
<button
|
|
onClick={handleOpen}
|
|
className="bg-gradient-to-r from-[var(--luxury-gold)] to-[var(--luxury-gold-dark)] text-slate-900 p-5 rounded-full shadow-2xl shadow-[var(--luxury-gold)]/40 hover:from-[var(--luxury-gold-dark)] hover:to-[var(--luxury-gold)] transition-all duration-300 hover:scale-110 flex items-center justify-center group relative"
|
|
aria-label="Open chat"
|
|
>
|
|
<div className="absolute inset-0 bg-gradient-to-r from-[var(--luxury-gold)] to-[var(--luxury-gold-dark)] rounded-full blur-xl opacity-50 group-hover:opacity-70 transition-opacity"></div>
|
|
<MessageCircle className="w-6 h-6 group-hover:scale-110 transition-transform relative z-10" strokeWidth={2.5} />
|
|
{chat && chat.status === 'pending' && (
|
|
<span className="absolute -top-1 -right-1 w-4 h-4 bg-red-500 rounded-full animate-pulse border-2 border-white shadow-lg z-10"></span>
|
|
)}
|
|
</button>
|
|
) : (
|
|
<div
|
|
className={`luxury-glass rounded-2xl shadow-2xl border border-[var(--luxury-gold)]/30 flex flex-col transition-all duration-300 ${
|
|
isMinimized ? 'w-80 h-16' : 'w-96 h-[600px]'
|
|
}`}
|
|
style={{ transformOrigin: 'bottom right' }}
|
|
>
|
|
{}
|
|
<div className="bg-gradient-to-r from-[var(--luxury-gold)] to-[var(--luxury-gold-dark)] text-slate-900 p-4 rounded-t-2xl flex items-center justify-between relative overflow-hidden">
|
|
<div className="absolute inset-0 bg-gradient-to-r from-[var(--luxury-gold)]/20 to-[var(--luxury-gold-dark)]/20"></div>
|
|
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-transparent via-slate-900/20 to-transparent"></div>
|
|
<div className="flex items-center gap-3 relative z-10">
|
|
<div className="bg-slate-900/10 p-2 rounded-lg backdrop-blur-sm">
|
|
<MessageCircle className="w-5 h-5 text-slate-900" strokeWidth={2.5} />
|
|
</div>
|
|
<div>
|
|
<h3 className="font-serif font-semibold text-slate-900 tracking-wide">
|
|
{!isWithinBusinessHours
|
|
? 'Leave Your Inquiry'
|
|
: chat?.status === 'active' && chat?.staff_name
|
|
? `Chat with ${chat.staff_name}`
|
|
: chat?.status === 'closed'
|
|
? 'Chat Ended'
|
|
: 'Support Chat'}
|
|
</h3>
|
|
{!isWithinBusinessHours ? (
|
|
<p className="text-xs text-slate-700/80 font-light flex items-center gap-1">
|
|
<Clock className="w-3 h-3" />
|
|
Chat available {settings.chat_working_hours_start !== undefined && settings.chat_working_hours_end !== undefined
|
|
? formatWorkingHours(settings.chat_working_hours_start, settings.chat_working_hours_end)
|
|
: '9:00 AM - 5:00 PM'}
|
|
</p>
|
|
) : chat?.status === 'pending' ? (
|
|
<p className="text-xs text-slate-700/80 font-light">Waiting for staff...</p>
|
|
) : chat?.status === 'active' ? (
|
|
<p className="text-xs text-slate-700/80 font-light">Online</p>
|
|
) : chat?.status === 'closed' ? (
|
|
<p className="text-xs text-slate-700/80 font-light">This chat has ended</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 relative z-10">
|
|
{chat && chat.status !== 'closed' && (
|
|
<button
|
|
onClick={handleEndChat}
|
|
className="px-3 py-1.5 text-xs bg-red-500/20 hover:bg-red-500/30 rounded-lg transition-all duration-200 border border-red-400/30 text-red-700 font-semibold hover:shadow-md"
|
|
title="End chat"
|
|
>
|
|
End Chat
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => setIsMinimized(!isMinimized)}
|
|
className="p-2 hover:bg-slate-900/10 rounded-lg transition-all duration-200 backdrop-blur-sm"
|
|
aria-label={isMinimized ? 'Maximize' : 'Minimize'}
|
|
>
|
|
{isMinimized ? (
|
|
<Maximize2 className="w-4 h-4 text-slate-900" />
|
|
) : (
|
|
<Minimize2 className="w-4 h-4 text-slate-900" />
|
|
)}
|
|
</button>
|
|
<button
|
|
onClick={handleClose}
|
|
className="p-2 hover:bg-slate-900/10 rounded-lg transition-all duration-200 backdrop-blur-sm"
|
|
aria-label="Close chat widget"
|
|
>
|
|
<X className="w-4 h-4 text-slate-900" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{!isMinimized && (
|
|
<>
|
|
{}
|
|
{!isWithinBusinessHours ? (
|
|
<div className="flex-1 overflow-y-auto p-4 bg-gradient-to-b from-slate-50/50 to-white">
|
|
<div className="max-w-md mx-auto">
|
|
<div className="mb-6 p-4 bg-gradient-to-r from-[var(--luxury-gold)]/10 to-[var(--luxury-gold-dark)]/10 rounded-xl border border-[var(--luxury-gold)]/20">
|
|
<div className="flex items-start gap-3">
|
|
<Clock className="w-5 h-5 text-[var(--luxury-gold)] mt-0.5 flex-shrink-0" />
|
|
<div>
|
|
<p className="text-sm font-semibold text-slate-900 mb-1">Chat Hours</p>
|
|
<p className="text-xs text-slate-600 font-light">
|
|
Our chat support is available Monday to Friday, {settings.chat_working_hours_start !== undefined && settings.chat_working_hours_end !== undefined
|
|
? formatWorkingHours(settings.chat_working_hours_start, settings.chat_working_hours_end)
|
|
: '9:00 AM - 5:00 PM'}.
|
|
Please leave your inquiry below and we'll get back to you as soon as possible.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
|
Your Email <span className="text-red-500">*</span>
|
|
</label>
|
|
<input
|
|
type="email"
|
|
value={inquiryEmail}
|
|
onChange={(e) => setInquiryEmail(e.target.value)}
|
|
className="w-full px-4 py-2.5 border-2 border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-[var(--luxury-gold)]/30 focus:border-[var(--luxury-gold)] transition-all duration-200 font-light"
|
|
placeholder="your.email@example.com"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
|
Your Inquiry <span className="text-red-500">*</span>
|
|
</label>
|
|
<textarea
|
|
value={inquiry}
|
|
onChange={(e) => setInquiry(e.target.value)}
|
|
rows={6}
|
|
className="w-full px-4 py-2.5 border-2 border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-[var(--luxury-gold)]/30 focus:border-[var(--luxury-gold)] transition-all duration-200 font-light resize-none"
|
|
placeholder="Please describe your inquiry or question..."
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={handleSubmitInquiry}
|
|
disabled={submittingInquiry || !inquiryEmail.trim() || !inquiry.trim()}
|
|
className="w-full bg-gradient-to-r from-[var(--luxury-gold)] to-[var(--luxury-gold-dark)] text-slate-900 px-4 py-3 rounded-xl hover:from-[var(--luxury-gold-dark)] hover:to-[var(--luxury-gold)] disabled:bg-gray-300 disabled:text-gray-500 disabled:cursor-not-allowed transition-all duration-300 font-semibold shadow-lg shadow-[var(--luxury-gold)]/30 hover:shadow-xl disabled:shadow-none"
|
|
>
|
|
{submittingInquiry ? 'Sending...' : 'Send Inquiry'}
|
|
</button>
|
|
<p className="text-xs text-slate-500 text-center font-light">
|
|
We'll respond to your inquiry as soon as possible.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : showVisitorForm && !chat && (
|
|
<div className="flex-1 overflow-y-auto p-4 bg-gradient-to-b from-slate-50/50 to-white">
|
|
<div className="max-w-md mx-auto">
|
|
<h3 className="text-lg font-serif font-semibold text-slate-900 mb-4 tracking-wide">
|
|
Please provide your information to start chatting
|
|
</h3>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
|
Name <span className="text-red-500">*</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={visitorInfo.name}
|
|
onChange={(e) => {
|
|
setVisitorInfo({ ...visitorInfo, name: e.target.value });
|
|
if (formErrors.name) setFormErrors({ ...formErrors, name: '' });
|
|
}}
|
|
className={`w-full px-4 py-2.5 border-2 rounded-xl focus:outline-none focus:ring-2 focus:ring-[var(--luxury-gold)]/30 transition-all duration-200 font-light ${
|
|
formErrors.name ? 'border-red-500 focus:border-red-500' : 'border-slate-200 focus:border-[var(--luxury-gold)]'
|
|
}`}
|
|
placeholder="Your full name"
|
|
/>
|
|
{formErrors.name && (
|
|
<p className="text-xs text-red-500 mt-1">{formErrors.name}</p>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
|
Email <span className="text-red-500">*</span>
|
|
</label>
|
|
<input
|
|
type="email"
|
|
value={visitorInfo.email}
|
|
onChange={(e) => {
|
|
setVisitorInfo({ ...visitorInfo, email: e.target.value });
|
|
if (formErrors.email) setFormErrors({ ...formErrors, email: '' });
|
|
}}
|
|
className={`w-full px-4 py-2.5 border-2 rounded-xl focus:outline-none focus:ring-2 focus:ring-[var(--luxury-gold)]/30 transition-all duration-200 font-light ${
|
|
formErrors.email ? 'border-red-500 focus:border-red-500' : 'border-slate-200 focus:border-[var(--luxury-gold)]'
|
|
}`}
|
|
placeholder="your.email@example.com"
|
|
/>
|
|
{formErrors.email && (
|
|
<p className="text-xs text-red-500 mt-1">{formErrors.email}</p>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
|
Phone <span className="text-red-500">*</span>
|
|
</label>
|
|
<input
|
|
type="tel"
|
|
value={visitorInfo.phone}
|
|
onChange={(e) => {
|
|
setVisitorInfo({ ...visitorInfo, phone: e.target.value });
|
|
if (formErrors.phone) setFormErrors({ ...formErrors, phone: '' });
|
|
}}
|
|
className={`w-full px-4 py-2.5 border-2 rounded-xl focus:outline-none focus:ring-2 focus:ring-[var(--luxury-gold)]/30 transition-all duration-200 font-light ${
|
|
formErrors.phone ? 'border-red-500 focus:border-red-500' : 'border-slate-200 focus:border-[var(--luxury-gold)]'
|
|
}`}
|
|
placeholder="+1 (555) 123-4567"
|
|
/>
|
|
{formErrors.phone && (
|
|
<p className="text-xs text-red-500 mt-1">{formErrors.phone}</p>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={createChat}
|
|
disabled={loading}
|
|
className="w-full bg-gradient-to-r from-[var(--luxury-gold)] to-[var(--luxury-gold-dark)] text-slate-900 px-4 py-3 rounded-xl hover:from-[var(--luxury-gold-dark)] hover:to-[var(--luxury-gold)] disabled:bg-gray-300 disabled:text-gray-500 disabled:cursor-not-allowed transition-all duration-300 font-semibold shadow-lg shadow-[var(--luxury-gold)]/30 hover:shadow-xl disabled:shadow-none"
|
|
>
|
|
{loading ? 'Starting chat...' : 'Start Chat'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{}
|
|
{!showVisitorForm && isWithinBusinessHours && (
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-gradient-to-b from-slate-50/50 to-white">
|
|
{/* Working Hours Info Banner */}
|
|
{settings.chat_working_hours_start !== undefined && settings.chat_working_hours_end !== undefined && (
|
|
<div className="mb-4 p-3 bg-gradient-to-r from-[var(--luxury-gold)]/5 to-[var(--luxury-gold-dark)]/5 rounded-lg border border-[var(--luxury-gold)]/20">
|
|
<div className="flex items-center gap-2">
|
|
<Clock className="w-4 h-4 text-[var(--luxury-gold)] flex-shrink-0" />
|
|
<p className="text-xs text-slate-600 font-light">
|
|
<span className="font-medium text-slate-700">Chat Support Hours:</span>{' '}
|
|
{formatWorkingHours(settings.chat_working_hours_start, settings.chat_working_hours_end)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{loading && !chat ? (
|
|
<div className="text-center text-slate-500 py-8 font-light">
|
|
Starting chat...
|
|
</div>
|
|
) : (chat && chat.status === 'closed') || (!chat && !isAuthenticated && visitorInfo.name && visitorInfo.email && visitorInfo.phone) ? (
|
|
<div className="flex flex-col items-center justify-center py-12 px-4">
|
|
<div className="text-center mb-6">
|
|
<p className="text-sm text-slate-600 mb-2 font-light">
|
|
{chat && chat.status === 'closed' ? 'This chat has ended.' : 'Ready to start a new chat.'}
|
|
</p>
|
|
{!isAuthenticated && visitorInfo.name && visitorInfo.email && visitorInfo.phone && (
|
|
<p className="text-xs text-slate-500 mb-4 font-light">
|
|
Start a new chat with {visitorInfo.name}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
setChat(null);
|
|
setMessages([]);
|
|
setNewMessage('');
|
|
// If visitor info exists, start chat directly, otherwise show form
|
|
if (!isAuthenticated && visitorInfo.name && visitorInfo.email && visitorInfo.phone) {
|
|
setShowVisitorForm(false);
|
|
createChat();
|
|
} else if (!isAuthenticated) {
|
|
setShowVisitorForm(true);
|
|
} else {
|
|
createChat();
|
|
}
|
|
}}
|
|
disabled={loading}
|
|
className="bg-gradient-to-r from-[var(--luxury-gold)] to-[var(--luxury-gold-dark)] text-slate-900 px-8 py-3 rounded-xl hover:from-[var(--luxury-gold-dark)] hover:to-[var(--luxury-gold)] transition-all duration-300 font-semibold shadow-lg shadow-[var(--luxury-gold)]/30 hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{loading ? 'Starting...' : 'Start New Chat'}
|
|
</button>
|
|
</div>
|
|
) : messages.length === 0 ? (
|
|
<div className="text-center text-slate-500 py-8 font-light">
|
|
No messages yet. Start the conversation!
|
|
</div>
|
|
) : (
|
|
messages.map((message) => (
|
|
<div
|
|
key={message.id}
|
|
className={`flex ${
|
|
message.sender_type === 'visitor' ? 'justify-end' : 'justify-start'
|
|
}`}
|
|
>
|
|
<div
|
|
className={`max-w-[80%] rounded-xl p-4 shadow-lg ${
|
|
message.sender_type === 'visitor'
|
|
? 'bg-gradient-to-br from-[var(--luxury-gold)] to-[var(--luxury-gold-dark)] text-slate-900 border border-[var(--luxury-gold)]/30'
|
|
: 'bg-white text-slate-800 border border-slate-200/60 shadow-sm'
|
|
}`}
|
|
>
|
|
{message.sender_type === 'staff' && (
|
|
<div className="text-xs font-semibold mb-1.5 text-[var(--luxury-gold)] tracking-wide">
|
|
{message.sender_name || 'Staff'}
|
|
</div>
|
|
)}
|
|
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">
|
|
{message.message}
|
|
</p>
|
|
<p
|
|
className={`text-xs mt-2 ${
|
|
message.sender_type === 'visitor'
|
|
? 'text-slate-700/70'
|
|
: 'text-slate-500'
|
|
}`}
|
|
>
|
|
{new Date(message.created_at).toLocaleTimeString([], {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
})}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
)}
|
|
|
|
{}
|
|
{!showVisitorForm && chat && chat.status !== 'closed' && (
|
|
<div className="p-4 border-t border-[var(--luxury-gold)]/20 bg-white/90 backdrop-blur-sm rounded-b-2xl">
|
|
<div className="flex gap-3">
|
|
<input
|
|
type="text"
|
|
value={newMessage}
|
|
onChange={(e) => setNewMessage(e.target.value)}
|
|
onKeyPress={handleKeyPress}
|
|
placeholder="Type your message..."
|
|
className="flex-1 px-4 py-3 border-2 border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-[var(--luxury-gold)]/30 focus:border-[var(--luxury-gold)] transition-all duration-200 font-light"
|
|
/>
|
|
<button
|
|
onClick={handleSend}
|
|
disabled={!newMessage.trim()}
|
|
className="bg-gradient-to-r from-[var(--luxury-gold)] to-[var(--luxury-gold-dark)] text-slate-900 px-5 py-3 rounded-xl hover:from-[var(--luxury-gold-dark)] hover:to-[var(--luxury-gold)] disabled:bg-gray-300 disabled:text-gray-500 disabled:cursor-not-allowed transition-all duration-300 flex items-center gap-2 font-semibold shadow-lg shadow-[var(--luxury-gold)]/30 hover:shadow-xl disabled:shadow-none"
|
|
>
|
|
<Send className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
{chat?.status === 'pending' && (
|
|
<p className="text-xs text-slate-500 mt-2 font-light">
|
|
Your message will be delivered. A staff member will respond soon.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<ConfirmationDialog
|
|
isOpen={showEndChatDialog}
|
|
onClose={() => setShowEndChatDialog(false)}
|
|
onConfirm={confirmEndChat}
|
|
title="End Chat"
|
|
message="Are you sure you want to end this chat? You can start a new chat anytime."
|
|
confirmText="End Chat"
|
|
cancelText="Cancel"
|
|
variant="warning"
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default ChatWidget;
|
|
|