![]() Server : Apache/2 System : Linux server-15-235-50-60 5.15.0-164-generic #174-Ubuntu SMP Fri Nov 14 20:25:16 UTC 2025 x86_64 User : gositeme ( 1004) PHP Version : 8.2.29 Disable Function : exec,system,passthru,shell_exec,proc_close,proc_open,dl,popen,show_source,posix_kill,posix_mkfifo,posix_getpwuid,posix_setpgid,posix_setsid,posix_setuid,posix_setgid,posix_seteuid,posix_setegid,posix_uname Directory : /home/gositeme/domains/lavocat.quebec/private_html/src/components/ |
'use client';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useSession } from 'next-auth/react';
import { Send, Paperclip, FileText, Users, Clock, AlertCircle, CheckCircle, MessageSquare } from 'lucide-react';
import { format } from 'date-fns';
import { useWebSocket } from '@/context/StableWebSocketContext';
import { useToast } from '@/components/ui/use-toast';
interface CaseChatProps {
caseId: string;
caseTitle?: string;
onClose?: () => void;
}
interface CaseMessage {
id: string;
content: string;
type: 'TEXT' | 'FILE' | 'SYSTEM' | 'STATUS_UPDATE';
fileUrl?: string;
fileName?: string;
fileSize?: number;
mimeType?: string;
createdAt: string;
senderId: string;
sender: {
id: string;
name: string;
email: string;
role: string;
avatar?: string;
};
caseId: string;
isSystem?: boolean;
statusUpdate?: {
oldStatus: string;
newStatus: string;
field: string;
};
}
interface CaseTeamMember {
id: string;
name: string;
email: string;
role: string;
avatar?: string;
isOnline: boolean;
lastSeen?: string;
}
const CaseChat: React.FC<CaseChatProps> = ({ caseId, caseTitle = 'Case Chat', onClose }) => {
const { data: session } = useSession();
const { ws, connected, sendTyping } = useWebSocket();
const { toast } = useToast();
const [messages, setMessages] = useState<CaseMessage[]>([]);
const [newMessage, setNewMessage] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [teamMembers, setTeamMembers] = useState<CaseTeamMember[]>([]);
const [typingUsers, setTypingUsers] = useState<Set<string>>(new Set());
const [isTyping, setIsTyping] = useState(false);
const [showTeamPanel, setShowTeamPanel] = useState(true);
const messagesEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const typingTimeoutRef = useRef<NodeJS.Timeout>();
// Auto-scroll to bottom
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
// Load case messages and team members
useEffect(() => {
if (!caseId || !session?.user) return;
const loadCaseData = async () => {
try {
setIsLoading(true);
// Load messages
const messagesResponse = await fetch(`/api/cases/${caseId}/messages`, {
credentials: 'same-origin',
});
if (messagesResponse.ok) {
const messagesData = await messagesResponse.json();
setMessages(messagesData.messages || []);
}
// Load team members
const teamResponse = await fetch(`/api/cases/${caseId}/team`, {
credentials: 'same-origin',
});
if (teamResponse.ok) {
const teamData = await teamResponse.json();
setTeamMembers(teamData.members || []);
}
// Join case chat room via WebSocket
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'JOIN_CASE_CHAT',
data: { caseId }
}));
}
} catch (error) {
toast({
title: "Error",
description: "Failed to load case chat data",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
loadCaseData();
// Cleanup: Leave case chat room
return () => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'LEAVE_CASE_CHAT',
data: { caseId }
}));
}
};
}, [caseId, session?.user, ws, toast]);
// WebSocket message handling
useEffect(() => {
if (!ws) return;
const handleMessage = (event: MessageEvent) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'CASE_MESSAGE' && data.data.caseId === caseId) {
const message = data.data.message;
setMessages(prev => [...prev, message]);
} else if (data.type === 'CASE_TYPING' && data.data.caseId === caseId) {
const { userId, isTyping: userTyping } = data.data;
setTypingUsers(prev => {
const newSet = new Set(prev);
if (userTyping) {
newSet.add(userId);
} else {
newSet.delete(userId);
}
return newSet;
});
} else if (data.type === 'CASE_STATUS_UPDATE' && data.data.caseId === caseId) {
const systemMessage: CaseMessage = {
id: `system-${Date.now()}
content: `Case status updated: ${data.data.oldStatus} → ${data.data.newStatus}
type: 'STATUS_UPDATE',
createdAt: new Date().toISOString(),
senderId: 'system',
sender: {
id: 'system',
name: 'System',
email: '',
role: 'SYSTEM'
},
caseId,
isSystem: true,
statusUpdate: {
oldStatus: data.data.oldStatus,
newStatus: data.data.newStatus,
field: data.data.field
}
};
setMessages(prev => [...prev, systemMessage]);
}
} catch (error) {
}
};
ws.addEventListener('message', handleMessage);
return () => ws.removeEventListener('message', handleMessage);
}, [ws, caseId]);
const sendMessage = async () => {
if (!newMessage.trim() || !session?.user) return;
try {
const messageData = {
content: newMessage,
type: 'TEXT' as const,
caseId,
senderId: session.user.id,
sender: {
id: session.user.id,
name: session.user.name || 'Unknown User',
email: session.user.email || '',
role: session.user.role || 'USER',
avatar: session.user.image
}
};
const response = await fetch(`/api/cases/${caseId}/messages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify(messageData),
});
if (response.ok) {
const savedMessage = await response.json();
setMessages(prev => [...prev, savedMessage]);
setNewMessage('');
// Send typing stop
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'CASE_TYPING',
data: { caseId, userId: session.user.id, isTyping: false }
}));
}
} else {
throw new Error('Failed to send message');
}
} catch (error) {
toast({
title: "Error",
description: "Failed to send message",
variant: "destructive",
});
}
};
const handleFileUpload = async (file: File) => {
if (!session?.user) return;
try {
const formData = new FormData();
formData.append('file', file);
formData.append('caseId', caseId);
const response = await fetch(`/api/cases/${caseId}/upload
method: 'POST',
credentials: 'same-origin',
body: formData,
});
if (response.ok) {
const fileData = await response.json();
const messageData = {
content: `File uploaded: ${file.name}
type: 'FILE' as const,
caseId,
fileUrl: fileData.url,
fileName: file.name,
fileSize: file.size,
mimeType: file.type,
senderId: session.user.id,
sender: {
id: session.user.id,
name: session.user.name || 'Unknown User',
email: session.user.email || '',
role: session.user.role || 'USER',
avatar: session.user.image
}
};
const messageResponse = await fetch(`/api/cases/${caseId}/messages
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify(messageData),
});
if (messageResponse.ok) {
const savedMessage = await messageResponse.json();
setMessages(prev => [...prev, savedMessage]);
}
} else {
throw new Error('Failed to upload file');
}
} catch (error) {
toast({
title: "Error",
description: "Failed to upload file",
variant: "destructive",
});
}
};
const renderMessage = (message: CaseMessage, index: number) => {
const isSender = message.senderId === session?.user?.id;
const isSystem = message.isSystem || message.type === 'SYSTEM' || message.type === 'STATUS_UPDATE';
const prevMessage = index > 0 ? messages[index - 1] : null;
const showAvatar = !isSender && !isSystem && (!prevMessage || prevMessage.senderId !== message.senderId);
return (
<div
key={message.id}
className={`flex gap-3 mb-4 ${isSender ? 'justify-end' : 'justify-start'}`}
>
{showAvatar && (
<div className="w-8 h-8 rounded-full bg-gray-300 flex items-center justify-center text-sm font-medium">
{message.sender.name.charAt(0).toUpperCase()}
</div>
)}
<div className={`max-w-xs lg:max-w-md ${isSender ? 'order-first' : ''}`}>
{!isSender && showAvatar && (
<div className="text-xs text-gray-500 mb-1">{message.sender.name}</div>
)}
<div className={
isSystem
? 'bg-yellow-50 border border-yellow-200 text-yellow-800'
: isSender
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-900'
}>
{message.type === 'FILE' && (
<div className="flex items-center gap-2 mb-2">
<FileText className="h-4 w-4" />
<span className="text-xs">
{message.fileName}
</span>
</div>
)}
<div className="text-sm">{message.content}</div>
{message.type === 'STATUS_UPDATE' && (
<div className="text-xs mt-1 opacity-75">
Status Update
</div>
)}
</div>
<div className="text-xs text-gray-500 mt-1">
{format(new Date(message.createdAt), 'MMM d, h:mm a')}
</div>
</div>
</div>
);
};
if (isLoading) {
return (
<div className="w-full h-[600px] bg-white rounded-lg shadow-sm border">
<div className="p-4 border-b border-gray-200">
<h3 className="text-lg font-semibold flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
{caseTitle}
</h3>
</div>
<div className="flex items-center justify-center h-96">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-gray-500">Loading chat...</p>
</div>
</div>
</div>
);
}
return (
<div className="w-full h-[600px] bg-white rounded-lg shadow-sm border">
<div className="p-4 border-b border-gray-200">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
{caseTitle}
</h3>
<div className="flex items-center gap-2">
<button
className="inline-flex items-center px-3 py-2 border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors"
onClick={() => setShowTeamPanel(!showTeamPanel)}
>
<Users className="h-4 w-4 mr-1" />
Team
</button>
{onClose && (
<button
className="inline-flex items-center px-3 py-2 border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors"
onClick={onClose}
>
Close
</button>
)}
</div>
</div>
</div>
<div className="flex h-full">
{/* Messages Area */}
<div className="flex-1 flex flex-col">
{/* Messages */}
<div className="flex-1 p-4 overflow-y-auto">
{messages.length === 0 ? (
<div className="text-center py-8">
<MessageSquare className="h-12 w-12 text-gray-400 mx-auto mb-3" />
<p className="text-gray-500">No messages yet. Start the conversation!</p>
</div>
) : (
<div className="space-y-4">
{messages.map((message, index) => renderMessage(message, index))}
{typingUsers.size > 0 && (
<div className="flex gap-3">
<div className="w-8 h-8 rounded-full bg-gray-300 flex items-center justify-center text-sm font-medium">
?
</div>
<div className="bg-gray-100 rounded-lg p-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>
)}
</div>
{/* Input Area */}
<div className="p-4 border-t border-gray-200">
<div className="flex gap-2">
<input
type="text"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
placeholder="Type your message..."
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<button
className="inline-flex items-center px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
onClick={() => fileInputRef.current?.click()}
title="Attach file"
>
<Paperclip className="h-4 w-4" />
</button>
<button
className="inline-flex items-center px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onClick={sendMessage}
disabled={!newMessage.trim()}
>
<Send className="h-4 w-4" />
</button>
</div>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
handleFileUpload(file);
e.target.value = '';
}
}}
/>
</div>
</div>
{/* Team Panel */}
{showTeamPanel && (
<div className="w-64 border-l border-gray-200 p-4">
<h4 className="font-semibold mb-3">Team Members</h4>
<div className="space-y-2">
{teamMembers.map((member) => (
<div key={member.id} className="flex items-center gap-2 p-2 rounded-lg hover:bg-gray-50">
<div className="w-8 h-8 rounded-full bg-gray-300 flex items-center justify-center text-sm font-medium">
{member.name.charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{member.name}</div>
<div className="text-xs text-gray-500 truncate">{member.role}</div>
</div>
<div className={`w-2 h-2 rounded-full ${member.isOnline ? 'bg-green-500' : 'bg-gray-300'}`}></div>
))}
</div>
</div>
)}
</div>
</div>
);
};
export default CaseChat;