fe integration for notification system with context, components, and API integration
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
'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
|
||||
}
|
||||
Reference in New Issue
Block a user