'use client' import { createContext, useContext, useState, useEffect, useCallback, useRef, type ReactNode } from 'react' import { useRouter } from 'next/navigation' import { HubConnectionBuilder, LogLevel } from '@microsoft/signalr' import { useAuth } from '@/contexts/auth-context' import { notifications, NOTIFICATION_HUB_URL } from '@/lib/api/notifications' import { toast } from '@/components/ui/toast' import type { NotificationResponse, NotificationSeverity } from '@/types/notifications' const RECENT_COUNT = 20 const TOAST_TYPE: Record = { Info: 'info', Success: 'success', Warning: 'warning', Critical: 'error', } interface NotificationContextValue { /** The most recent notifications, newest first (what the bell popover shows). */ recent: NotificationResponse[] unreadCount: number /** Marks the notification read (if needed) and navigates to its link. */ open: (n: NotificationResponse) => Promise markAllRead: () => Promise /** Re-fetches the recent list and the unread count. */ refresh: () => Promise } const NotificationContext = createContext(null) export function NotificationProvider({ children }: { children: ReactNode }) { const router = useRouter() const { user } = useAuth() const [recent, setRecent] = useState([]) const [unreadCount, setUnreadCount] = useState(0) const refresh = useCallback(async () => { try { const [page, count] = await Promise.all([ notifications.list({ page: 1, pageSize: RECENT_COUNT }), notifications.unreadCount(), ]) setRecent(page.items) setUnreadCount(count) } catch { // The bell is a convenience; never break the page if it can't load. } }, []) const markRead = useCallback(async (n: NotificationResponse) => { if (n.isRead) return setRecent(prev => prev.map(x => (x.id === n.id ? { ...x, isRead: true } : x))) setUnreadCount(c => Math.max(0, c - 1)) try { await notifications.markRead(n.id) } catch { await refresh() } }, [refresh]) const open = useCallback(async (n: NotificationResponse) => { await markRead(n) if (n.link) router.push(n.link) }, [markRead, router]) const markAllRead = useCallback(async () => { setRecent(prev => prev.map(x => ({ ...x, isRead: true }))) setUnreadCount(0) try { await notifications.markAllRead() } catch { await refresh() } }, [refresh]) // Keep the latest `open` for the long-lived socket handler without reconnecting. const openRef = useRef(open) useEffect(() => { openRef.current = open }, [open]) useEffect(() => { if (!user) return void refresh() const connection = new HubConnectionBuilder() .withUrl(NOTIFICATION_HUB_URL, { withCredentials: true }) .withAutomaticReconnect() .configureLogging(LogLevel.Warning) .build() connection.on('notification', (n: NotificationResponse) => { setRecent(prev => [n, ...prev.filter(x => x.id !== n.id)].slice(0, RECENT_COUNT)) setUnreadCount(c => c + 1) toast.add({ title: n.title, description: n.message, type: TOAST_TYPE[n.severity] ?? 'info', actionProps: n.link ? { children: 'Open', onClick: () => { void openRef.current(n) } } : undefined, }) }) // Anything pushed while the socket was down is only in the inbox — catch up. connection.onreconnected(() => { void refresh() }) connection.start().catch(() => { // Initial connect failed (offline / API down); the inbox endpoints still work. }) return () => { void connection.stop() } }, [user, refresh]) return ( {children} ) } export function useNotifications(): NotificationContextValue { const ctx = useContext(NotificationContext) if (!ctx) throw new Error('useNotifications must be used inside NotificationProvider') return ctx }