Files
ember-market-frontend/lib/notification-context.tsx
g adb01009eb Handle play() and load() promises for audio elements
Updated audio playback and preloading logic to check for and handle returned promises from play() and load() methods. This prevents uncaught promise rejections in browsers where these methods may return undefined, improving reliability and error handling for notification sounds.
2025-12-09 22:17:20 +00:00

346 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('/hohoho.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;
const playPromise = audioRef.current.play();
if (playPromise !== undefined) {
playPromise.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;
}