Files
ember-market-frontend/components/dashboard/ChatTable.tsx
NotII 29ec1be68c Refactor API URLs and add environment config example
Replaces hardcoded production API URLs with localhost defaults for local development in both server and client code. Updates Dockerfile to require API URLs via deployment environment variables. Improves ChatTable to use a batch endpoint for chats and unread counts, with backward compatibility. Adds an env.example file to document required environment variables. Updates next.config.mjs to use environment variables for backend API rewrites and image domains.
2025-09-01 15:35:10 +01:00

420 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('/notification.mp3');
return () => {
if (audioRef.current) {
audioRef.current = null;
}
};
}, []);
// Play notification sound
const playNotificationSound = () => {
if (audioRef.current) {
audioRef.current.play().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>
);
}