Files
ERP_Distribution/distribution_fe/contexts/notification-context.tsx
T

136 lines
4.2 KiB
TypeScript

'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<NotificationSeverity, 'info' | 'success' | 'warning' | 'error'> = {
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<void>
markAllRead: () => Promise<void>
/** Re-fetches the recent list and the unread count. */
refresh: () => Promise<void>
}
const NotificationContext = createContext<NotificationContextValue | null>(null)
export function NotificationProvider({ children }: { children: ReactNode }) {
const router = useRouter()
const { user } = useAuth()
const [recent, setRecent] = useState<NotificationResponse[]>([])
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 (
<NotificationContext.Provider
value={{
recent: user ? recent : [],
unreadCount: user ? unreadCount : 0,
open,
markAllRead,
refresh,
}}
>
{children}
</NotificationContext.Provider>
)
}
export function useNotifications(): NotificationContextValue {
const ctx = useContext(NotificationContext)
if (!ctx) throw new Error('useNotifications must be used inside NotificationProvider')
return ctx
}