Updated audio playback and preloading logic to check for and handle returned promises from play() and load() methods. This prevents uncaught promise rejections in browsers where these methods may return undefined, improving reliability and error handling for notification sounds.
423 lines
13 KiB
TypeScript
423 lines
13 KiB
TypeScript
"use client"
|
|
|
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
import { formatDistanceToNow } from "date-fns";
|
|
import {
|
|
Plus,
|
|
MessageCircle,
|
|
Loader2,
|
|
RefreshCw,
|
|
Eye,
|
|
User,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
MessageSquare,
|
|
ArrowRightCircle,
|
|
X,
|
|
Clock,
|
|
CheckCheck,
|
|
Search,
|
|
Volume2,
|
|
VolumeX
|
|
} from "lucide-react";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { toast } from "sonner";
|
|
import { clientFetch, getCookie } from "@/lib/api";
|
|
import { formatDistance } from 'date-fns';
|
|
import Link from 'next/link';
|
|
import { cn } from '@/lib/utils/general';
|
|
|
|
interface Chat {
|
|
_id: string;
|
|
buyerId: string;
|
|
vendorId: string;
|
|
storeId: string;
|
|
lastUpdated: string;
|
|
orderId?: string;
|
|
telegramUsername?: string | null;
|
|
}
|
|
|
|
interface UnreadCounts {
|
|
totalUnread: number;
|
|
chatCounts: Record<string, number>;
|
|
}
|
|
|
|
interface ChatResponse {
|
|
chats: Chat[];
|
|
page: number;
|
|
totalPages: number;
|
|
totalChats: number;
|
|
}
|
|
|
|
export default function ChatTable() {
|
|
const router = useRouter();
|
|
const [chats, setChats] = useState<Chat[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [unreadCounts, setUnreadCounts] = useState<UnreadCounts>({ totalUnread: 0, chatCounts: {} });
|
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
const [totalChats, setTotalChats] = useState(0);
|
|
const [itemsPerPage, setItemsPerPage] = useState<number>(10);
|
|
const isManualRefresh = useRef(false);
|
|
|
|
// Initialize audio element for notifications
|
|
useEffect(() => {
|
|
audioRef.current = new Audio('/hohoho.mp3');
|
|
return () => {
|
|
if (audioRef.current) {
|
|
audioRef.current = null;
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// Play notification sound
|
|
const playNotificationSound = () => {
|
|
if (audioRef.current) {
|
|
const playPromise = audioRef.current.play();
|
|
if (playPromise !== undefined) {
|
|
playPromise.catch(e => {
|
|
console.log("Failed to play notification sound:", e);
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
// Get vendor ID from JWT token
|
|
const getVendorIdFromToken = () => {
|
|
const authToken = getCookie("Authorization") || "";
|
|
|
|
if (!authToken) {
|
|
throw new Error("No auth token found");
|
|
}
|
|
|
|
const tokenParts = authToken.split(".");
|
|
if (tokenParts.length !== 3) {
|
|
throw new Error("Invalid token format");
|
|
}
|
|
|
|
const payload = JSON.parse(atob(tokenParts[1]));
|
|
const vendorId = payload.id;
|
|
|
|
if (!vendorId) {
|
|
throw new Error("Vendor ID not found in token");
|
|
}
|
|
|
|
return { vendorId, authToken };
|
|
};
|
|
|
|
// Fetch chats when component mounts or page/limit changes
|
|
useEffect(() => {
|
|
// Skip fetch if this effect was triggered by a manual refresh
|
|
// since we'll call fetchChats directly in that case
|
|
if (!isManualRefresh.current) {
|
|
fetchChats();
|
|
}
|
|
|
|
isManualRefresh.current = false;
|
|
|
|
// Set up polling for unread messages
|
|
const interval = setInterval(() => {
|
|
fetchUnreadCounts();
|
|
}, 30000); // Check every 30 seconds
|
|
|
|
return () => clearInterval(interval);
|
|
}, [currentPage, itemsPerPage]);
|
|
|
|
// Handle refresh button click
|
|
const handleRefresh = () => {
|
|
isManualRefresh.current = true;
|
|
setCurrentPage(1);
|
|
fetchChats();
|
|
};
|
|
|
|
// Fetch unread counts
|
|
const fetchUnreadCounts = async () => {
|
|
try {
|
|
// Get the vendor ID from the auth token
|
|
const { vendorId } = getVendorIdFromToken();
|
|
|
|
// Fetch unread counts for this vendor using clientFetch
|
|
const response = await clientFetch(`/chats/vendor/${vendorId}/unread`);
|
|
|
|
const newUnreadCounts = response;
|
|
|
|
// Play sound if there are new messages
|
|
if (newUnreadCounts.totalUnread > unreadCounts.totalUnread) {
|
|
//playNotificationSound();
|
|
}
|
|
|
|
setUnreadCounts(newUnreadCounts);
|
|
} catch (error) {
|
|
console.error("Failed to fetch unread counts:", error);
|
|
}
|
|
};
|
|
|
|
// Fetch chats with pagination
|
|
const fetchChats = async (page = currentPage, limit = itemsPerPage) => {
|
|
setLoading(true);
|
|
|
|
try {
|
|
// Get the vendor ID from the auth token
|
|
const { vendorId } = getVendorIdFromToken();
|
|
|
|
// Use the optimized batch endpoint that fetches chats and unread counts together
|
|
const batchResponse = await clientFetch(`/chats/vendor/${vendorId}/batch?page=${page}&limit=${limit}`);
|
|
|
|
// Handle batch response (contains both chats and unread counts)
|
|
if (Array.isArray(batchResponse)) {
|
|
// Fallback to old API response format (backward compatibility)
|
|
setChats(batchResponse);
|
|
setTotalPages(1);
|
|
setTotalChats(batchResponse.length);
|
|
// Try to fetch unread counts separately if using old endpoint
|
|
try {
|
|
const unreadResponse = await clientFetch(`/chats/vendor/${vendorId}/unread`);
|
|
setUnreadCounts(unreadResponse);
|
|
} catch (error) {
|
|
console.warn("Failed to fetch unread counts:", error);
|
|
}
|
|
} else {
|
|
// Handle new batch response format
|
|
setChats(batchResponse.chats || []);
|
|
setTotalPages(batchResponse.totalPages || 1);
|
|
setCurrentPage(batchResponse.page || 1);
|
|
setTotalChats(batchResponse.totalChats || 0);
|
|
|
|
// Handle unread counts from batch response
|
|
const newUnreadCounts = batchResponse.unreadCounts || { totalUnread: 0, chatCounts: {} };
|
|
|
|
// Play sound if there are new messages
|
|
if (newUnreadCounts.totalUnread > unreadCounts.totalUnread) {
|
|
//playNotificationSound();
|
|
}
|
|
|
|
setUnreadCounts(newUnreadCounts);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to fetch chats:", error);
|
|
toast.error("Failed to load chat conversations");
|
|
setChats([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// Navigate to chat detail page
|
|
const handleChatClick = (chatId: string) => {
|
|
router.push(`/dashboard/chats/${chatId}`);
|
|
};
|
|
|
|
// Create new chat
|
|
const handleCreateChat = () => {
|
|
router.push("/dashboard/chats/new");
|
|
};
|
|
|
|
// Handle pagination
|
|
const goToNextPage = () => {
|
|
if (currentPage < totalPages) {
|
|
isManualRefresh.current = true;
|
|
const nextPage = currentPage + 1;
|
|
setCurrentPage(nextPage);
|
|
fetchChats(nextPage);
|
|
}
|
|
};
|
|
|
|
const goToPrevPage = () => {
|
|
if (currentPage > 1) {
|
|
isManualRefresh.current = true;
|
|
const prevPage = currentPage - 1;
|
|
setCurrentPage(prevPage);
|
|
fetchChats(prevPage);
|
|
}
|
|
};
|
|
|
|
// Handle items per page change
|
|
const handleItemsPerPageChange = (value: string) => {
|
|
const newLimit = parseInt(value);
|
|
isManualRefresh.current = true;
|
|
setItemsPerPage(newLimit);
|
|
setCurrentPage(1); // Reset to first page when changing limit
|
|
fetchChats(1, newLimit);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex justify-between items-center">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleRefresh}
|
|
disabled={loading}
|
|
>
|
|
{loading ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<RefreshCw className="h-4 w-4" />
|
|
)}
|
|
<span className="ml-2">Refresh</span>
|
|
</Button>
|
|
|
|
<Button onClick={handleCreateChat} size="sm">
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
New Chat
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="rounded-md border">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-[200px]">Customer</TableHead>
|
|
<TableHead>Last Activity</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead className="text-right">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{loading ? (
|
|
<TableRow>
|
|
<TableCell colSpan={4} className="h-24 text-center">
|
|
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
|
|
</TableCell>
|
|
</TableRow>
|
|
) : chats.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={4} className="h-24 text-center">
|
|
<div className="flex flex-col items-center justify-center">
|
|
<MessageCircle className="h-8 w-8 text-muted-foreground mb-2" />
|
|
<p className="text-muted-foreground">No chats found</p>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
chats.map((chat) => (
|
|
<TableRow
|
|
key={chat._id}
|
|
className="cursor-pointer hover:bg-muted/50"
|
|
onClick={() => handleChatClick(chat._id)}
|
|
>
|
|
<TableCell>
|
|
<div className="flex items-center space-x-3">
|
|
<Avatar>
|
|
<AvatarFallback>
|
|
<User className="h-4t w-4" />
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div>
|
|
<div className="font-medium">
|
|
{chat.telegramUsername ? `@${chat.telegramUsername}` : 'Customer'}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
ID: {chat.buyerId}
|
|
</div>
|
|
{chat.orderId && (
|
|
<div className="text-xs text-muted-foreground">
|
|
Order #{chat.orderId}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
{formatDistanceToNow(new Date(chat.lastUpdated), { addSuffix: true })}
|
|
</TableCell>
|
|
<TableCell>
|
|
{unreadCounts.chatCounts[chat._id] > 0 ? (
|
|
<Badge variant="destructive" className="ml-1">
|
|
{unreadCounts.chatCounts[chat._id]} new
|
|
</Badge>
|
|
) : (
|
|
<Badge variant="outline">Read</Badge>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<div className="flex justify-end space-x-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleChatClick(chat._id);
|
|
}}
|
|
>
|
|
<Eye className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
{/* Pagination controls */}
|
|
{!loading && chats.length > 0 && (
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-sm text-muted-foreground">
|
|
Showing {chats.length} of {totalChats} chats
|
|
</div>
|
|
<div className="flex items-center space-x-4">
|
|
<div className="flex items-center space-x-2">
|
|
<span className="text-sm text-muted-foreground">Rows per page:</span>
|
|
<Select
|
|
value={itemsPerPage.toString()}
|
|
onValueChange={handleItemsPerPageChange}
|
|
>
|
|
<SelectTrigger className="h-8 w-[70px]">
|
|
<SelectValue placeholder={itemsPerPage.toString()} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="5">5</SelectItem>
|
|
<SelectItem value="10">10</SelectItem>
|
|
<SelectItem value="20">20</SelectItem>
|
|
<SelectItem value="50">50</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={goToPrevPage}
|
|
disabled={currentPage <= 1 || loading}
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</Button>
|
|
<div className="text-sm">
|
|
Page {currentPage} of {totalPages}
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={goToNextPage}
|
|
disabled={currentPage >= totalPages || loading}
|
|
>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|