"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import Link from "next/link"; import { Bell } from "lucide-react"; interface NotificationItem { id: string; body: string; link: string | null; isRead: boolean; sentAt: string; poId: string | null; } interface Props { initialUnreadCount: number; initialNotifications: NotificationItem[]; } function timeAgo(dateStr: string): string { const diff = Date.now() - new Date(dateStr).getTime(); const mins = Math.floor(diff / 60_000); if (mins < 1) return "just now"; if (mins < 60) return `${mins}m ago`; const hrs = Math.floor(mins / 60); if (hrs < 24) return `${hrs}h ago`; return `${Math.floor(hrs / 24)}d ago`; } export function NotificationBell({ initialUnreadCount, initialNotifications }: Props) { const [open, setOpen] = useState(false); const [unreadCount, setUnreadCount] = useState(initialUnreadCount); const [notifications, setNotifications] = useState(initialNotifications); const [newArrival, setNewArrival] = useState(false); const prevCountRef = useRef(initialUnreadCount); const panelRef = useRef(null); // ── Poll for new notifications every 30 s ─────────────────────────────────── const fetchNotifications = useCallback(async () => { try { const res = await fetch("/api/notifications", { cache: "no-store" }); if (!res.ok) return; const data: { unreadCount: number; notifications: NotificationItem[] } = await res.json(); setNotifications(data.notifications); if (data.unreadCount > prevCountRef.current) { // New notification arrived — flash the bell setNewArrival(true); setTimeout(() => setNewArrival(false), 3000); } prevCountRef.current = data.unreadCount; setUnreadCount(data.unreadCount); } catch { // network error — silently ignore } }, []); useEffect(() => { const id = setInterval(fetchNotifications, 30_000); return () => clearInterval(id); }, [fetchNotifications]); // ── Close on outside click ────────────────────────────────────────────────── useEffect(() => { function handleClick(e: MouseEvent) { if (panelRef.current && !panelRef.current.contains(e.target as Node)) { setOpen(false); } } document.addEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick); }, []); // ── Mark all read when panel opens ───────────────────────────────────────── async function handleOpen() { const next = !open; setOpen(next); if (next && unreadCount > 0) { setUnreadCount(0); prevCountRef.current = 0; setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true }))); await fetch("/api/notifications/read", { method: "PATCH" }); } } return (
{/* Bell button */} {/* Dropdown panel */} {open && (
{/* Header */}

Notifications

{notifications.length > 0 && ( )}
{/* List */}
{notifications.length === 0 ? (
No notifications yet
) : ( notifications.map((n) => { const content = (
{/* Unread dot */}

{n.body}

{timeAgo(n.sentAt)}

); return n.link ? ( setOpen(false)} className="block" > {content} ) : (
{content}
); }) )}
{/* Footer */} {notifications.length === 20 && (
Showing 20 most recent
)}
)}
); }