All checks were successful
Build Frontend / build (push) Successful in 1m11s
Removed all Christmas-specific theming and logic from the home page and navbar, standardizing colors to indigo. Updated QuickActions to open a modal for adding products instead of navigating to a new page, including logic for product creation and category fetching. Simplified ChatTable row animations and fixed minor layout issues. Updated button styles and mobile menu links for consistency.
477 lines
17 KiB
TypeScript
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>
|
|
{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 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
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>
|
|
);
|
|
}
|