"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 } from "@/lib/client-utils"; import { ImageViewerModal } from "@/components/modals/image-viewer-modal"; 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; } // 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 messagesEndRef = useRef(null); const [previousMessageCount, setPreviousMessageCount] = useState(0); const audioRef = useRef(null); const markReadTimeoutRef = useRef(null); const [selectedImage, setSelectedImage] = useState(null); const [selectedMessageIndex, setSelectedMessageIndex] = useState(null); const [selectedAttachmentIndex, setSelectedAttachmentIndex] = useState(null); // 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); } }; // Fetch chat data const fetchChat = async () => { try { // Get auth token from cookies const authToken = getCookie("Authorization"); if (!authToken) { toast.error("You need to be logged in"); router.push("/auth/login"); return; } // Set up axios with the auth token const authAxios = axios.create({ baseURL: process.env.NEXT_PUBLIC_API_URL, headers: { Authorization: `Bearer ${authToken}` } }); // Always fetch messages without marking as read const response = await authAxios.get(`/chats/${chatId}?markAsRead=false`); // Check if there are new messages const hasNewMessages = chat && response.data.messages.length > chat.messages.length; const unreadBuyerMessages = response.data.messages.some( (msg: Message) => msg.sender === 'buyer' && !msg.read ); if (hasNewMessages) { // Don't play sound for messages we sent (vendor) const lastMessage = response.data.messages[response.data.messages.length - 1]; if (lastMessage.sender === 'buyer') { playNotificationSound(); } } setChat(response.data); // Update the previous message count if (response.data.messages) { setPreviousMessageCount(response.data.messages.length); } setLoading(false); // Clear any existing timeout if (markReadTimeoutRef.current) { clearTimeout(markReadTimeoutRef.current); } // Only mark as read with a delay if there are unread buyer messages if (unreadBuyerMessages) { // Add a 3-second delay before marking messages as read to allow notification to appear markReadTimeoutRef.current = setTimeout(() => { markMessagesAsRead(); }, 3000); } } catch (error) { console.error("Error fetching chat:", error); toast.error("Failed to load conversation"); setLoading(false); } }; useEffect(() => { fetchChat(); // Poll for updates every 10 seconds const intervalId = setInterval(fetchChat, 10000); return () => clearInterval(intervalId); }, [chatId]); // Scroll to bottom when messages change useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [chat?.messages]); // Send a message const handleSendMessage = async (e: React.FormEvent) => { e.preventDefault(); if (!message.trim()) return; setSending(true); try { // Get auth token from cookies const authToken = getCookie("Authorization"); if (!authToken) { toast.error("You need to be logged in"); router.push("/auth/login"); return; } // Set up axios with the auth token const authAxios = axios.create({ baseURL: process.env.NEXT_PUBLIC_API_URL, headers: { Authorization: `Bearer ${authToken}` } }); await authAxios.post(`/chats/${chatId}/message`, { content: message }); setMessage(""); await fetchChat(); // Refresh chat after sending } catch (error) { console.error("Error sending message:", error); toast.error("Failed to send message"); } finally { setSending(false); } }; 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. Back to Chats ); } return ( Chat with Customer {chat.buyerId.slice(-4)} {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.stopPropagation()} > { (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" /> {sending ? : } {/* Update the image viewer modal */} { setSelectedImage(null); setSelectedMessageIndex(null); setSelectedAttachmentIndex(null); }} imageUrl={selectedImage || ""} onNavigate={handleImageNavigation} /> ); }
This conversation doesn't exist or you don't have access to it.
No messages yet. Send one to start the conversation.
{msg.content}