Files
ember-market-frontend/components/dashboard/ChatTable.tsx
g 211cdc71f9
All checks were successful
Build Frontend / build (push) Successful in 1m12s
Enhance dashboard UI and add order timeline
Refactored dashboard pages for improved layout and visual consistency using Card components, motion animations, and updated color schemes. Added an OrderTimeline component to the order details page to visualize order lifecycle. Improved customer management page with better sorting, searching, and a detailed customer dialog. Updated storefront settings page with a modernized layout and clearer sectioning.
2026-01-12 06:53:28 +00:00

477 lines
17 KiB
TypeScript

"use client"
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { motion, AnimatePresence } from "framer-motion";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
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,
MoreHorizontal
} 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('/notification.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-end">
<div>
<h2 className="text-2xl font-bold tracking-tight">Messages</h2>
<p className="text-muted-foreground">Manage your customer conversations</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={loading}
className="h-9"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<RefreshCw className="h-4 w-4 mr-2" />
)}
Refresh
</Button>
<Button onClick={handleCreateChat} size="sm" className="h-9">
<Plus className="h-4 w-4 mr-2" />
New Chat
</Button>
</div>
</div>
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
<CardContent className="p-0">
<Table>
<TableHeader className="bg-muted/50">
<TableRow className="hover:bg-transparent">
<TableHead className="w-[300px] pl-6">Customer</TableHead>
<TableHead>Last Activity</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right pr-6">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<AnimatePresence mode="popLayout">
{loading ? (
<TableRow>
<TableCell colSpan={4} className="h-32 text-center">
<div className="flex flex-col items-center justify-center gap-2">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="text-sm text-muted-foreground">Loading conversations...</span>
</div>
</TableCell>
</TableRow>
) : chats.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="h-32 text-center">
<div className="flex flex-col items-center justify-center">
<MessageCircle className="h-10 w-10 text-muted-foreground/50 mb-3" />
<p className="text-muted-foreground font-medium">No chats found</p>
<p className="text-xs text-muted-foreground mt-1">Start a new conversation to communicate with customers</p>
</div>
</TableCell>
</TableRow>
) : (
chats.map((chat, index) => (
<motion.tr
key={chat._id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, delay: index * 0.05 }}
className="group cursor-pointer hover:bg-muted/30 transition-colors border-b border-border/50 last:border-0"
onClick={() => handleChatClick(chat._id)}
style={{ display: 'table-row' }} // Essential for table layout
>
<TableCell className="pl-6 py-4">
<div className="flex items-center space-x-4">
<div className="relative">
<Avatar className="h-10 w-10 border-2 border-background shadow-sm group-hover:scale-105 transition-transform duration-200">
<AvatarFallback className={cn(
"font-medium text-xs",
unreadCounts.chatCounts[chat._id] > 0 ? "bg-primary/20 text-primary" : "bg-muted text-muted-foreground"
)}>
{chat.buyerId.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
{unreadCounts.chatCounts[chat._id] > 0 && (
<span className="absolute -top-1 -right-1 h-3 w-3 bg-primary rounded-full ring-2 ring-background animate-pulse" />
)}
</div>
<div>
<div className="font-semibold text-sm flex items-center gap-2">
{chat.telegramUsername ? (
<span className="text-blue-400">@{chat.telegramUsername}</span>
) : (
<span className="text-foreground">Customer {chat.buyerId.slice(0, 6)}...</span>
)}
</div>
<div className="text-xs text-muted-foreground mt-0.5 font-mono">
ID: {chat.buyerId}
</div>
{chat.orderId && (
<div className="text-[10px] text-muted-foreground mt-1 flex items-center gap-1 bg-muted/50 px-1.5 py-0.5 rounded w-fit">
<span className="w-1 h-1 rounded-full bg-zinc-400" />
Order #{chat.orderId}
</div>
)}
</div>
</div>
</TableCell>
<TableCell className="py-4">
<div className="flex flex-col">
<span className="text-sm font-medium">
{formatDistanceToNow(new Date(chat.lastUpdated), { addSuffix: true })}
</span>
<span className="text-xs text-muted-foreground">
{new Date(chat.lastUpdated).toLocaleDateString()}
</span>
</div>
</TableCell>
<TableCell className="py-4">
{unreadCounts.chatCounts[chat._id] > 0 ? (
<div className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-primary/10 text-primary text-xs font-medium border border-primary/20 shadow-[0_0_10px_rgba(var(--primary),0.1)]">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
</span>
{unreadCounts.chatCounts[chat._id]} new message{unreadCounts.chatCounts[chat._id] !== 1 ? 's' : ''}
</div>
) : (
<div className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-muted text-muted-foreground text-xs font-medium border border-border">
<CheckCheck className="h-3 w-3" />
All caught up
</div>
)}
</TableCell>
<TableCell className="text-right pr-6 py-4">
<div className="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<Button
variant="secondary"
size="sm"
className="h-8 w-8 p-0 rounded-full"
onClick={(e) => {
e.stopPropagation();
handleChatClick(chat._id);
}}
>
<ArrowRightCircle className="h-4 w-4" />
<span className="sr-only">View</span>
</Button>
</div>
</TableCell>
</motion.tr>
))
)}
</AnimatePresence>
</TableBody>
</Table>
</CardContent>
</Card>
{/* 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>
);
}