Refactored the admin dashboard to use tabbed navigation for analytics and management. Enhanced AdminAnalytics with Recharts visualizations, added top vendors by revenue, and improved chart tooltips. Removed unused columns from vendor table. Updated layout and notification context to exclude admin pages from dashboard-specific UI and notifications. Minor debug logging added to SystemStatusCard.
343 lines
11 KiB
TypeScript
343 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import React, { createContext, useContext, useState, useEffect, useRef, ReactNode } from "react";
|
|
import { clientFetch } from "@/lib/api";
|
|
import { toast } from "sonner";
|
|
import { getCookie } from "@/lib/api";
|
|
import { cacheUtils } from '@/lib/api-client';
|
|
import { Package } from "lucide-react";
|
|
|
|
interface Order {
|
|
_id: string;
|
|
orderId: string;
|
|
status: string;
|
|
totalPrice: number;
|
|
orderDate: string;
|
|
underpaid?: boolean;
|
|
underpaymentAmount?: number;
|
|
}
|
|
|
|
interface UnreadCounts {
|
|
totalUnread: number;
|
|
chatCounts: Record<string, number>;
|
|
}
|
|
|
|
interface NotificationContextType {
|
|
// Chat notifications
|
|
unreadCounts: UnreadCounts;
|
|
chatMetadata: Record<string, { buyerId: string }>;
|
|
|
|
// Order notifications
|
|
newOrders: Order[];
|
|
clearOrderNotifications: () => void;
|
|
|
|
// Shared state
|
|
totalNotifications: number;
|
|
loading: boolean;
|
|
}
|
|
|
|
const NotificationContext = createContext<NotificationContextType | undefined>(undefined);
|
|
|
|
const STORAGE_KEYS = {
|
|
SEEN_ORDER_IDS: 'ember-notifications-seen-orders',
|
|
NEW_ORDERS: 'ember-notifications-new-orders',
|
|
LAST_CHAT_CHECK: 'ember-notifications-last-chat-check'
|
|
};
|
|
|
|
interface NotificationProviderProps {
|
|
children: ReactNode;
|
|
}
|
|
|
|
export function NotificationProvider({ children }: NotificationProviderProps) {
|
|
// Chat notification state
|
|
const [unreadCounts, setUnreadCounts] = useState<UnreadCounts>({ totalUnread: 0, chatCounts: {} });
|
|
const [previousUnreadTotal, setPreviousUnreadTotal] = useState<number>(0);
|
|
const [chatMetadata, setChatMetadata] = useState<Record<string, { buyerId: string }>>({});
|
|
|
|
// Order notification state
|
|
const [newOrders, setNewOrders] = useState<Order[]>([]);
|
|
const seenOrderIds = useRef<Set<string>>(new Set());
|
|
const isInitialOrdersFetch = useRef(true);
|
|
|
|
// Shared state
|
|
const [loading, setLoading] = useState(true);
|
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
const vendorIdRef = useRef<string | null>(null);
|
|
|
|
// Total notifications count
|
|
const totalNotifications = unreadCounts.totalUnread + newOrders.length;
|
|
|
|
// Initialize localStorage and audio
|
|
useEffect(() => {
|
|
// Load seen order IDs from localStorage
|
|
const savedSeenOrders = localStorage.getItem(STORAGE_KEYS.SEEN_ORDER_IDS);
|
|
if (savedSeenOrders) {
|
|
try {
|
|
const orderIds = JSON.parse(savedSeenOrders);
|
|
seenOrderIds.current = new Set(orderIds);
|
|
} catch (error) {
|
|
console.error('Error loading seen order IDs from localStorage:', error);
|
|
}
|
|
}
|
|
|
|
// Load new orders from localStorage
|
|
const savedNewOrders = localStorage.getItem(STORAGE_KEYS.NEW_ORDERS);
|
|
if (savedNewOrders) {
|
|
try {
|
|
const orders = JSON.parse(savedNewOrders);
|
|
setNewOrders(orders);
|
|
} catch (error) {
|
|
console.error('Error loading new orders from localStorage:', error);
|
|
}
|
|
}
|
|
|
|
// Initialize audio
|
|
audioRef.current = new Audio('/notification.mp3');
|
|
audioRef.current.addEventListener('error', () => {
|
|
audioRef.current = null;
|
|
});
|
|
|
|
return () => {
|
|
if (audioRef.current) {
|
|
audioRef.current = null;
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// Save seen order IDs to localStorage whenever it changes
|
|
const updateSeenOrderIds = (orderIds: Set<string>) => {
|
|
seenOrderIds.current = orderIds;
|
|
localStorage.setItem(STORAGE_KEYS.SEEN_ORDER_IDS, JSON.stringify(Array.from(orderIds)));
|
|
};
|
|
|
|
// Save new orders to localStorage whenever it changes
|
|
useEffect(() => {
|
|
localStorage.setItem(STORAGE_KEYS.NEW_ORDERS, JSON.stringify(newOrders));
|
|
}, [newOrders]);
|
|
|
|
// Get vendor ID from JWT token
|
|
const getVendorIdFromToken = () => {
|
|
if (vendorIdRef.current) {
|
|
return vendorIdRef.current;
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
vendorIdRef.current = vendorId;
|
|
return vendorId;
|
|
};
|
|
|
|
// Function to play notification sound
|
|
const playNotificationSound = () => {
|
|
if (audioRef.current) {
|
|
audioRef.current.currentTime = 0;
|
|
audioRef.current.play().catch(err => {
|
|
console.log('Error playing sound:', err);
|
|
// Fallback beep if audio file fails
|
|
try {
|
|
const context = new (window.AudioContext || (window as any).webkitAudioContext)();
|
|
const oscillator = context.createOscillator();
|
|
oscillator.type = 'sine';
|
|
oscillator.frequency.setValueAtTime(800, context.currentTime);
|
|
oscillator.connect(context.destination);
|
|
oscillator.start();
|
|
oscillator.stop(context.currentTime + 0.2);
|
|
} catch (e) {
|
|
console.error('Could not play fallback audio', e);
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
// Check for new paid orders
|
|
useEffect(() => {
|
|
// Only run this on dashboard pages, but not on admin pages
|
|
if (typeof window === 'undefined' || !window.location.pathname.includes("/dashboard") || window.location.pathname.includes("/dashboard/admin")) return;
|
|
|
|
const checkForNewOrders = async () => {
|
|
try {
|
|
// Get orders from the last 24 hours with a more efficient query
|
|
const yesterday = new Date();
|
|
yesterday.setDate(yesterday.getDate() - 1);
|
|
const timestamp = yesterday.toISOString();
|
|
|
|
const orderData = await clientFetch(`/orders?status=paid&limit=10&orderDate[gte]=${timestamp}`);
|
|
const orders: Order[] = orderData.orders || [];
|
|
|
|
// Filter out orders that are still showing as underpaid (cache issue)
|
|
const validPaidOrders = orders.filter(order => {
|
|
// Only include orders that are actually fully paid (not underpaid)
|
|
return order.status === 'paid' &&
|
|
(!order.underpaid || order.underpaymentAmount === 0);
|
|
});
|
|
|
|
// If this is the first fetch, just store the orders without notifications
|
|
if (isInitialOrdersFetch.current) {
|
|
const orderIds = new Set([...seenOrderIds.current, ...validPaidOrders.map(order => order._id)]);
|
|
updateSeenOrderIds(orderIds);
|
|
isInitialOrdersFetch.current = false;
|
|
return;
|
|
}
|
|
|
|
// Check for new paid orders that haven't been seen before
|
|
const latestNewOrders = validPaidOrders.filter(order => !seenOrderIds.current.has(order._id));
|
|
|
|
// Show notifications for new orders
|
|
if (latestNewOrders.length > 0) {
|
|
// Update the seen orders set
|
|
const updatedSeenOrders = new Set([...seenOrderIds.current, ...latestNewOrders.map(order => order._id)]);
|
|
updateSeenOrderIds(updatedSeenOrders);
|
|
|
|
// Show a toast notification for each new order
|
|
latestNewOrders.forEach(order => {
|
|
toast.success(
|
|
<div className="flex flex-col">
|
|
<p className="font-semibold">New Paid Order!</p>
|
|
<p className="text-sm">Order #{order.orderId}</p>
|
|
<p className="text-sm font-semibold">£{order.totalPrice.toFixed(2)}</p>
|
|
</div>,
|
|
{
|
|
duration: 8000,
|
|
icon: <Package className="h-5 w-5" />,
|
|
action: {
|
|
label: "View",
|
|
onClick: () => window.open(`/dashboard/orders/${order._id}`, "_blank")
|
|
}
|
|
}
|
|
);
|
|
});
|
|
|
|
// Play notification sound
|
|
playNotificationSound();
|
|
|
|
// Update the state with new orders for the dropdown
|
|
setNewOrders(prev => [...latestNewOrders, ...prev].slice(0, 10));
|
|
|
|
// Invalidate order cache to ensure all components refresh
|
|
cacheUtils.invalidateOrderData();
|
|
}
|
|
} catch (error) {
|
|
console.error("Error checking for new orders:", error);
|
|
}
|
|
};
|
|
|
|
// Check for new orders every minute
|
|
const orderInterval = setInterval(checkForNewOrders, 60000);
|
|
|
|
// Initial check for orders
|
|
checkForNewOrders();
|
|
|
|
return () => {
|
|
clearInterval(orderInterval);
|
|
};
|
|
}, []);
|
|
|
|
// Fetch unread chat counts
|
|
useEffect(() => {
|
|
// Only run this on dashboard pages, but not on admin pages
|
|
if (typeof window === 'undefined' || !window.location.pathname.includes("/dashboard") || window.location.pathname.includes("/dashboard/admin")) return;
|
|
|
|
const fetchUnreadCounts = async () => {
|
|
try {
|
|
// Get vendor ID from token
|
|
const vendorId = getVendorIdFromToken();
|
|
|
|
// Use clientFetch which will properly route through Next.js API rewrites
|
|
const response = await clientFetch(`/chats/vendor/${vendorId}/unread`);
|
|
|
|
// Check if there are new notifications and play sound if needed
|
|
if (!loading && response.totalUnread > previousUnreadTotal) {
|
|
playNotificationSound();
|
|
}
|
|
|
|
// Update chat state - note that clientFetch already parses the JSON response
|
|
setUnreadCounts(response);
|
|
setPreviousUnreadTotal(response.totalUnread);
|
|
|
|
if (response.totalUnread > 0) {
|
|
const chatIds = Object.keys(response.chatCounts);
|
|
|
|
if (chatIds.length > 0) {
|
|
// Create a simplified metadata object with just needed info
|
|
const metadata: Record<string, { buyerId: string }> = {};
|
|
|
|
// Fetch each chat to get buyer IDs
|
|
await Promise.all(
|
|
chatIds.map(async (chatId) => {
|
|
try {
|
|
// Use markAsRead=false to ensure we don't mark messages as read
|
|
const chatResponse = await clientFetch(`/chats/${chatId}?markAsRead=false`);
|
|
metadata[chatId] = {
|
|
buyerId: chatResponse.buyerId,
|
|
};
|
|
} catch (error) {
|
|
console.error(`Error fetching chat ${chatId}:`, error);
|
|
}
|
|
})
|
|
);
|
|
|
|
setChatMetadata(metadata);
|
|
}
|
|
}
|
|
|
|
setLoading(false);
|
|
} catch (error) {
|
|
console.error("Error fetching unread counts:", error);
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// Initial fetch
|
|
fetchUnreadCounts();
|
|
|
|
// Set polling interval (every 10 seconds for more responsive chat notifications)
|
|
const chatInterval = setInterval(fetchUnreadCounts, 10000);
|
|
|
|
return () => clearInterval(chatInterval);
|
|
}, [loading, previousUnreadTotal]);
|
|
|
|
// Clear notification handlers
|
|
const clearOrderNotifications = () => {
|
|
setNewOrders([]);
|
|
localStorage.removeItem(STORAGE_KEYS.NEW_ORDERS);
|
|
};
|
|
|
|
const contextValue: NotificationContextType = {
|
|
unreadCounts,
|
|
chatMetadata,
|
|
newOrders,
|
|
clearOrderNotifications,
|
|
totalNotifications,
|
|
loading,
|
|
};
|
|
|
|
return (
|
|
<NotificationContext.Provider value={contextValue}>
|
|
{children}
|
|
</NotificationContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useNotifications() {
|
|
const context = useContext(NotificationContext);
|
|
if (context === undefined) {
|
|
throw new Error('useNotifications must be used within a NotificationProvider');
|
|
}
|
|
return context;
|
|
}
|