Files
ember-market-frontend/components/dashboard/ChatTable.tsx
g 0176f89cb7 Add CSV export for orders and update UI symbols
Introduces an exportOrdersToCSV function in lib/api-client.ts to allow exporting orders by status as a CSV file. Updates various UI components to use the '•' (bullet) symbol instead of '·' (middle dot) and replaces some emoji/unicode characters for improved consistency and compatibility. Also normalizes the 'use client' directive to include a BOM in many files.
2025-12-15 17:57:18 +00:00

424 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>
);
}