"use client" import React, { useState, useEffect, useRef } from "react"; import { useRouter } from "next/navigation"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { cn } from "@/lib/utils"; import { formatDistanceToNow } from "date-fns"; import axios from "axios"; import { toast } from "sonner"; import { ArrowLeft, Send, RefreshCw, File, FileText, Image as ImageIcon, Download } from "lucide-react"; import { getCookie, clientFetch } from "@/lib/client-utils"; import { ImageViewerModal } from "@/components/modals/image-viewer-modal"; import BuyerOrderInfo from "./BuyerOrderInfo"; interface Message { _id: string; sender: "buyer" | "vendor"; content: string; attachments: string[]; read: boolean; createdAt: string; buyerId: string; vendorId: string; } interface Chat { _id: string; buyerId: string; vendorId: string; storeId: string; messages: Message[]; lastUpdated: string; orderId?: string; telegramUsername?: string | null; } // Helper function to extract filename from URL const getFileNameFromUrl = (url: string): string => { // Try to extract filename from the URL path const pathParts = url.split('/'); const lastPart = pathParts[pathParts.length - 1]; // Remove query parameters if any const fileNameParts = lastPart.split('?'); let fileName = fileNameParts[0]; // If filename is too long or not found, create a generic name if (!fileName || fileName.length > 30) { return 'attachment'; } // URL decode the filename (handle spaces and special characters) try { fileName = decodeURIComponent(fileName); } catch (e) { // If decoding fails, use the original } return fileName; }; // Helper function to get file icon based on extension or URL pattern const getFileIcon = (url: string): React.ReactNode => { const fileName = url.toLowerCase(); // Image files if (/\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff)($|\?)/i.test(fileName) || url.includes('/photos/') || url.includes('/photo/')) { return ; } // Document files if (/\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|rtf|csv)($|\?)/i.test(fileName)) { return ; } // Default file icon return ; }; export default function ChatDetail({ chatId }: { chatId: string }) { const router = useRouter(); const [chat, setChat] = useState(null); const [loading, setLoading] = useState(true); const [message, setMessage] = useState(""); const [sending, setSending] = useState(false); const [messages, setMessages] = useState([]); const [chatData, setChatData] = useState(null); const messagesEndRef = useRef(null); const [previousMessageCount, setPreviousMessageCount] = useState(0); const audioRef = useRef(null); const markReadTimeoutRef = useRef(null); const isPollingRef = useRef(false); const [selectedImage, setSelectedImage] = useState(null); const [selectedMessageIndex, setSelectedMessageIndex] = useState(null); const [selectedAttachmentIndex, setSelectedAttachmentIndex] = useState(null); // Scroll to bottom utility functions const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }; const isNearBottom = () => { if (!messagesEndRef.current) return true; const container = messagesEndRef.current.parentElement; if (!container) return true; const { scrollTop, scrollHeight, clientHeight } = container; // Consider "near bottom" if within 100px of the bottom return scrollHeight - (scrollTop + clientHeight) < 100; }; // Initialize audio element useEffect(() => { // Create audio element for notification sound audioRef.current = new Audio('/notification.mp3'); // Fallback if notification.mp3 doesn't exist - use browser API for a simple beep audioRef.current.addEventListener('error', () => { audioRef.current = null; }); return () => { if (audioRef.current) { audioRef.current = null; } // Clear any pending timeouts when component unmounts if (markReadTimeoutRef.current) { clearTimeout(markReadTimeoutRef.current); } }; }, []); // Function to play notification sound const playNotificationSound = () => { if (audioRef.current) { audioRef.current.currentTime = 0; audioRef.current.play().catch(err => { console.log('Error playing sound:', err); // Fallback to simple beep if audio file fails try { const context = new (window.AudioContext || (window as any).webkitAudioContext)(); const oscillator = context.createOscillator(); oscillator.type = 'sine'; oscillator.frequency.setValueAtTime(800, context.currentTime); oscillator.connect(context.destination); oscillator.start(); oscillator.stop(context.currentTime + 0.2); } catch (e) { console.error('Could not play fallback audio', e); } }); } else { // Fallback to simple beep if audio element is not available try { const context = new (window.AudioContext || (window as any).webkitAudioContext)(); const oscillator = context.createOscillator(); oscillator.type = 'sine'; oscillator.frequency.setValueAtTime(800, context.currentTime); oscillator.connect(context.destination); oscillator.start(); oscillator.stop(context.currentTime + 0.2); } catch (e) { console.error('Could not play fallback audio', e); } } }; // Function to mark messages as read const markMessagesAsRead = async () => { try { const authToken = getCookie("Authorization"); if (!authToken) return; const authAxios = axios.create({ baseURL: process.env.NEXT_PUBLIC_API_URL, headers: { Authorization: `Bearer ${authToken}` } }); // Use dedicated endpoint to mark messages as read await authAxios.post(`/chats/${chatId}/mark-read`); console.log("Marked messages as read"); } catch (error) { console.error("Error marking messages as read:", error); } }; // Loading effect useEffect(() => { if (chatId) { fetchChatData(); } }, [chatId]); // Get a fresh auth axios instance const getAuthAxios = () => { const authToken = getCookie("Authorization"); if (!authToken) return null; return axios.create({ baseURL: process.env.NEXT_PUBLIC_API_URL, headers: { Authorization: `Bearer ${authToken}`, "Content-Type": "application/json", } }); }; // Fetch chat information and messages const fetchChatData = async () => { if (!chatId) return; try { setLoading(true); // Use clientFetch instead of direct axios calls const response = await clientFetch(`/chats/${chatId}`); setChatData(response); setChat(response); // Set chat data to maintain compatibility setMessages(Array.isArray(response.messages) ? response.messages : []); // Scroll to bottom on initial load setTimeout(() => { scrollToBottom(); }, 100); } catch (error) { console.error("Error fetching chat data:", error); toast.error("Failed to load chat"); } finally { setLoading(false); } }; // Fetch new messages periodically useEffect(() => { if (!chatId || !chatData) return; const checkForNewMessages = async () => { if (isPollingRef.current) return; isPollingRef.current = true; try { // Use clientFetch instead of direct axios calls const response = await clientFetch(`/chats/${chatId}?markAsRead=true`); if ( response && Array.isArray(response.messages) && response.messages.length !== messages.length ) { setMessages(response.messages); // Only auto-scroll if we're already near the bottom if (isNearBottom()) { setTimeout(() => { scrollToBottom(); }, 100); } } } catch (error) { console.error("Error checking for new messages:", error); } finally { isPollingRef.current = false; } }; // Check for new messages every 3 seconds const intervalId = setInterval(checkForNewMessages, 3000); return () => { clearInterval(intervalId); }; }, [chatId, chatData, messages.length]); // Send a message const sendMessage = async () => { if (!chatId || !message.trim()) return; // Optimistically add message to UI const tempId = `temp-${Date.now()}`; const tempMessage = { _id: tempId, chatId, sender: "vendor" as const, content: message.trim(), attachments: [], read: false, createdAt: new Date().toISOString(), buyerId: chatData?.buyerId || "", vendorId: chatData?.vendorId || "", }; setMessages(prev => [...prev, tempMessage]); scrollToBottom(); // Clear input setMessage(""); try { // Use clientFetch instead of direct axios calls const response = await clientFetch(`/chats/${chatId}/message`, { method: 'POST', body: JSON.stringify({ message: message.trim() }), }); // Replace temp message with real one from server setMessages(prev => prev.filter(m => m._id !== tempId) .concat(response) ); } catch (error) { console.error("Error sending message:", error); toast.error("Failed to send message"); // Remove temp message on error setMessages(prev => prev.filter(m => m._id !== tempId)); } }; // Handle form submit for sending messages const handleSendMessage = (e: React.FormEvent) => { e.preventDefault(); sendMessage(); }; const handleBackClick = () => { router.push("/dashboard/chats"); }; // Add function to handle image navigation const handleImageNavigation = (direction: 'prev' | 'next') => { if (!chat || selectedMessageIndex === null || selectedAttachmentIndex === null) return; // Get all images from all messages const allImages: { messageIndex: number; attachmentIndex: number; url: string }[] = []; chat.messages.forEach((msg, msgIndex) => { msg.attachments.forEach((att, attIndex) => { if (/\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff)($|\?)/i.test(att) || att.includes('/photos/') || att.includes('/photo/')) { allImages.push({ messageIndex: msgIndex, attachmentIndex: attIndex, url: att }); } }); }); if (allImages.length === 0) return; // Find current image index const currentIndex = allImages.findIndex(img => img.messageIndex === selectedMessageIndex && img.attachmentIndex === selectedAttachmentIndex ); if (currentIndex === -1) return; // Calculate new index let newIndex; if (direction === 'next') { newIndex = (currentIndex + 1) % allImages.length; } else { newIndex = currentIndex === 0 ? allImages.length - 1 : currentIndex - 1; } // Update state with new image const newImage = allImages[newIndex]; setSelectedMessageIndex(newImage.messageIndex); setSelectedAttachmentIndex(newImage.attachmentIndex); setSelectedImage(newImage.url); }; // Update the image click handler const handleImageClick = (imageUrl: string, messageIndex: number, attachmentIndex: number) => { setSelectedImage(imageUrl); setSelectedMessageIndex(messageIndex); setSelectedAttachmentIndex(attachmentIndex); }; if (loading) { return (
Loading conversation...
); } if (!chat) { return (
Chat not found

This conversation doesn't exist or you don't have access to it.

); } return (

Chat with Customer {chat.buyerId.slice(-4)}

{chat.telegramUsername && ( @{chat.telegramUsername} )}
{chat.messages.length === 0 ? (

No messages yet. Send one to start the conversation.

) : ( chat.messages.map((msg, messageIndex) => (
{msg.sender === "buyer" && ( {chat.buyerId.slice(0, 2).toUpperCase()} )} {formatDistanceToNow(new Date(msg.createdAt), { addSuffix: true })}

{msg.content}

{/* Show attachments if any */} {msg.attachments && msg.attachments.length > 0 && (
{msg.attachments.map((attachment, attachmentIndex) => { const isImage = /\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff)($|\?)/i.test(attachment) || attachment.includes('/photos/') || attachment.includes('/photo/'); const fileName = getFileNameFromUrl(attachment); return isImage ? (
handleImageClick(attachment, messageIndex, attachmentIndex)} > {fileName} { (e.target as HTMLImageElement).src = "/placeholder-image.svg"; }} />
) : ( // Render file attachment
{getFileIcon(attachment)}
{fileName}
); })}
)}
)) )}
setMessage(e.target.value)} placeholder="Type your message..." disabled={sending} className="flex-1" />
{/* Update the image viewer modal */} { setSelectedImage(null); setSelectedMessageIndex(null); setSelectedAttachmentIndex(null); }} imageUrl={selectedImage || ""} onNavigate={handleImageNavigation} />
); }