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.
237 lines
7.7 KiB
TypeScript
237 lines
7.7 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useRef } from "react";
|
|
import { clientFetch } from "@/lib/api";
|
|
import { toast } from "sonner";
|
|
import { Package, Bell } from "lucide-react";
|
|
import { useRouter } from "next/navigation";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
|
|
interface Order {
|
|
_id: string;
|
|
orderId: string;
|
|
status: string;
|
|
totalPrice: number;
|
|
orderDate: string;
|
|
items?: Array<{ name: string; quantity: number }>;
|
|
customerName?: string;
|
|
}
|
|
|
|
export default function OrderNotifications() {
|
|
const router = useRouter();
|
|
const [newOrders, setNewOrders] = useState<Order[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const seenOrderIds = useRef<Set<string>>(new Set());
|
|
const isInitialFetch = useRef(true);
|
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
|
|
useEffect(() => {
|
|
audioRef.current = new Audio('/hohoho.mp3');
|
|
|
|
// Fallback if hohoho.mp3 doesn't exist
|
|
audioRef.current.addEventListener('error', () => {
|
|
audioRef.current = null;
|
|
});
|
|
|
|
return () => {
|
|
if (audioRef.current) {
|
|
audioRef.current = null;
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// 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 to simple 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);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
// Only run this on dashboard pages
|
|
if (typeof window === 'undefined' || !window.location.pathname.includes("/dashboard")) 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();
|
|
|
|
// Use orderDate parameter instead of 'after' to avoid backend casting errors
|
|
// The error logs show that the 'after' parameter is being interpreted as 'orderId' incorrectly
|
|
const orderData = await clientFetch(`/orders?status=paid&limit=10&orderDate[gte]=${timestamp}`);
|
|
const orders: Order[] = orderData.orders || [];
|
|
|
|
// If this is the first fetch, just store the orders without notifications
|
|
if (isInitialFetch.current) {
|
|
orders.forEach(order => seenOrderIds.current.add(order._id));
|
|
isInitialFetch.current = false;
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
// Check for new paid orders that haven't been seen before
|
|
const latestNewOrders = orders.filter(order => !seenOrderIds.current.has(order._id));
|
|
|
|
// Show notifications for new orders
|
|
if (latestNewOrders.length > 0) {
|
|
// Update the seen orders set
|
|
latestNewOrders.forEach(order => seenOrderIds.current.add(order._id));
|
|
|
|
// Show a 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));
|
|
}
|
|
|
|
setLoading(false);
|
|
} catch (error) {
|
|
console.error("Error checking for new orders:", error);
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// Check immediately on component mount (with a small delay)
|
|
const initialTimeout = setTimeout(() => {
|
|
checkForNewOrders();
|
|
}, 2000);
|
|
|
|
// Set up polling interval (every 60 seconds)
|
|
const interval = setInterval(checkForNewOrders, 60000);
|
|
|
|
// Clean up
|
|
return () => {
|
|
clearTimeout(initialTimeout);
|
|
clearInterval(interval);
|
|
};
|
|
}, []);
|
|
|
|
const handleOrderClick = (orderId: string) => {
|
|
router.push(`/dashboard/orders/${orderId}`);
|
|
};
|
|
|
|
const clearNotifications = () => {
|
|
setNewOrders([]);
|
|
};
|
|
|
|
// Format the price as currency
|
|
const formatPrice = (price: number) => {
|
|
return `£${price.toFixed(2)}`;
|
|
};
|
|
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="relative" disabled={loading}>
|
|
<Package className="h-5 w-5" />
|
|
{newOrders.length > 0 && (
|
|
<Badge
|
|
variant="destructive"
|
|
className="absolute -top-1 -right-1 px-1.5 py-0.5 text-xs"
|
|
>
|
|
{newOrders.length}
|
|
</Badge>
|
|
)}
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-72">
|
|
<div className="p-2 border-b flex justify-between items-center">
|
|
<h3 className="font-medium">New Paid Orders</h3>
|
|
{newOrders.length > 0 && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={clearNotifications}
|
|
className="h-7 text-xs"
|
|
>
|
|
Clear
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{newOrders.length === 0 ? (
|
|
<div className="p-4 flex items-center justify-center">
|
|
<p className="text-sm text-muted-foreground">No new paid orders</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="max-h-80 overflow-y-auto">
|
|
{newOrders.map((order) => (
|
|
<DropdownMenuItem
|
|
key={order._id}
|
|
className="p-3 cursor-pointer"
|
|
onClick={() => handleOrderClick(order._id)}
|
|
>
|
|
<div className="flex items-center justify-between w-full">
|
|
<div>
|
|
<p className="font-medium">Order #{order.orderId}</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{formatPrice(order.totalPrice)}
|
|
</p>
|
|
</div>
|
|
<Badge className="bg-green-500 hover:bg-green-600">Paid</Badge>
|
|
</div>
|
|
</DropdownMenuItem>
|
|
))}
|
|
</div>
|
|
<div className="p-2 border-t">
|
|
<Button
|
|
variant="outline"
|
|
className="w-full"
|
|
onClick={() => router.push('/dashboard/orders')}
|
|
>
|
|
View All Orders
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|