MOPC-App/src/components/shared/notification-bell.tsx

448 lines
12 KiB
TypeScript

'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { usePathname } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { cn, formatRelativeTime } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Bell,
CheckCheck,
Settings,
AlertTriangle,
FileText,
Files,
Upload,
ClipboardList,
PlayCircle,
Clock,
AlertCircle,
Lock,
Users,
TrendingUp,
Trophy,
CheckCircle,
Star,
GraduationCap,
Vote,
Brain,
Download,
AlertOctagon,
RefreshCw,
CalendarPlus,
Heart,
BarChart,
Award,
UserPlus,
UserCheck,
UserMinus,
FileCheck,
Eye,
MessageSquare,
MessageCircle,
Info,
Calendar,
Newspaper,
UserX,
Lightbulb,
BookOpen,
XCircle,
Edit,
FileUp,
} from 'lucide-react'
// Icon mapping for notification types
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
Brain,
AlertTriangle,
FileText,
Files,
Upload,
ClipboardList,
PlayCircle,
Clock,
AlertCircle,
Lock,
Users,
TrendingUp,
Trophy,
CheckCircle,
Star,
GraduationCap,
Vote,
Download,
AlertOctagon,
RefreshCw,
CalendarPlus,
Heart,
BarChart,
Award,
UserPlus,
UserCheck,
UserMinus,
FileCheck,
Eye,
MessageSquare,
MessageCircle,
Info,
Calendar,
Newspaper,
UserX,
Lightbulb,
BookOpen,
XCircle,
Edit,
FileUp,
Bell,
}
// Priority styles
const PRIORITY_STYLES = {
low: {
iconBg: 'bg-slate-100 dark:bg-slate-800',
iconColor: 'text-slate-500',
},
normal: {
iconBg: 'bg-blue-100 dark:bg-blue-900/30',
iconColor: 'text-blue-600 dark:text-blue-400',
},
high: {
iconBg: 'bg-amber-100 dark:bg-amber-900/30',
iconColor: 'text-amber-600 dark:text-amber-400',
},
urgent: {
iconBg: 'bg-red-100 dark:bg-red-900/30',
iconColor: 'text-red-600 dark:text-red-400',
},
}
type Notification = {
id: string
type: string
priority: string
icon: string | null
title: string
message: string
linkUrl: string | null
linkLabel: string | null
isRead: boolean
createdAt: Date
}
function NotificationItem({
notification,
onRead,
observeRef,
}: {
notification: Notification
onRead: () => void
observeRef?: (el: HTMLDivElement | null) => void
}) {
const IconComponent = ICON_MAP[notification.icon || 'Bell'] || Bell
const priorityStyle =
PRIORITY_STYLES[notification.priority as keyof typeof PRIORITY_STYLES] ||
PRIORITY_STYLES.normal
const content = (
<div
ref={observeRef}
data-notification-id={notification.id}
className={cn(
'flex gap-3 p-3 hover:bg-muted/50 transition-colors cursor-pointer',
!notification.isRead && 'bg-blue-50/50 dark:bg-blue-950/20'
)}
onClick={onRead}
>
{/* Icon with colored background */}
<div
className={cn(
'shrink-0 w-10 h-10 rounded-full flex items-center justify-center',
priorityStyle.iconBg
)}
>
<IconComponent className={cn('h-5 w-5', priorityStyle.iconColor)} />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<p className={cn('text-sm', !notification.isRead && 'font-medium')}>
{notification.title}
</p>
<p className="text-sm text-muted-foreground line-clamp-2">
{notification.message}
</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-muted-foreground">
{formatRelativeTime(notification.createdAt)}
</span>
{notification.linkLabel && (
<span className="text-xs text-primary font-medium">
{notification.linkLabel} &rarr;
</span>
)}
</div>
</div>
{/* Unread dot */}
{!notification.isRead && (
<div className="shrink-0 w-2 h-2 rounded-full bg-primary mt-2" />
)}
</div>
)
if (notification.linkUrl) {
return (
<Link href={notification.linkUrl as Route} className="block">
{content}
</Link>
)
}
return content
}
export function NotificationBell() {
const [filter, setFilter] = useState<'all' | 'unread'>('all')
const [open, setOpen] = useState(false)
const pathname = usePathname()
// Derive the role-based path prefix from the current route
const pathPrefix = pathname.startsWith('/admin')
? '/admin'
: pathname.startsWith('/jury')
? '/jury'
: pathname.startsWith('/mentor')
? '/mentor'
: pathname.startsWith('/observer')
? '/observer'
: ''
const { data: countData } = trpc.notification.getUnreadCount.useQuery(
undefined,
{
refetchInterval: 30000, // Refetch every 30 seconds
}
)
const { data: hasUrgent } = trpc.notification.hasUrgent.useQuery(undefined, {
refetchInterval: 30000,
})
const { data: notificationData, refetch } = trpc.notification.list.useQuery(
{
unreadOnly: filter === 'unread',
limit: 20,
},
{
enabled: open, // Only fetch when popover is open
}
)
const markAsReadMutation = trpc.notification.markAsRead.useMutation({
onSuccess: () => refetch(),
})
const markAllAsReadMutation = trpc.notification.markAllAsRead.useMutation({
onSuccess: () => refetch(),
})
const markBatchAsReadMutation = trpc.notification.markBatchAsRead.useMutation({
onSuccess: () => refetch(),
})
const unreadCount = countData ?? 0
const notifications = notificationData?.notifications ?? []
// Track unread notification IDs that have become visible
const pendingReadIds = useRef<Set<string>>(new Set())
const flushTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const observerRef = useRef<IntersectionObserver | null>(null)
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map())
// Flush pending read IDs in a batch
const flushPendingReads = useCallback(() => {
if (pendingReadIds.current.size === 0) return
const ids = Array.from(pendingReadIds.current)
pendingReadIds.current.clear()
markBatchAsReadMutation.mutate({ ids })
}, [markBatchAsReadMutation])
// Set up IntersectionObserver when popover opens
useEffect(() => {
if (!open) {
// Flush any remaining on close
if (flushTimer.current) clearTimeout(flushTimer.current)
flushPendingReads()
observerRef.current?.disconnect()
observerRef.current = null
return
}
observerRef.current = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const id = (entry.target as HTMLElement).dataset.notificationId
if (id) {
// Check if this notification is unread
const notif = notifications.find((n) => n.id === id)
if (notif && !notif.isRead) {
pendingReadIds.current.add(id)
}
}
}
}
// Debounce the batch call
if (pendingReadIds.current.size > 0) {
if (flushTimer.current) clearTimeout(flushTimer.current)
flushTimer.current = setTimeout(flushPendingReads, 500)
}
},
{ threshold: 0.5 }
)
// Observe all currently tracked items
itemRefs.current.forEach((el) => observerRef.current?.observe(el))
return () => {
observerRef.current?.disconnect()
if (flushTimer.current) clearTimeout(flushTimer.current)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, notifications])
// Ref callback for each notification item
const getItemRef = useCallback(
(id: string) => (el: HTMLDivElement | null) => {
if (el) {
itemRefs.current.set(id, el)
observerRef.current?.observe(el)
} else {
const prev = itemRefs.current.get(id)
if (prev) observerRef.current?.unobserve(prev)
itemRefs.current.delete(id)
}
},
[]
)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell
className={cn('h-5 w-5', hasUrgent && 'animate-pulse text-red-500')}
/>
{unreadCount > 0 && (
<span
className={cn(
'absolute -top-1 -right-1 min-w-5 h-5 px-1 rounded-full text-[10px] font-bold text-white flex items-center justify-center',
hasUrgent ? 'bg-red-500' : 'bg-primary'
)}
>
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
<span className="sr-only">Notifications</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-96 p-0" align="end">
{/* Header */}
<div className="flex items-center justify-between p-3 border-b">
<h3 className="font-semibold">Notifications</h3>
<div className="flex gap-1">
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => markAllAsReadMutation.mutate()}
disabled={markAllAsReadMutation.isPending}
>
<CheckCheck className="h-4 w-4 mr-1" />
Mark all read
</Button>
)}
<Button variant="ghost" size="icon" asChild>
<Link href={`${pathPrefix}/settings` as Route}>
<Settings className="h-4 w-4" />
<span className="sr-only">Notification settings</span>
</Link>
</Button>
</div>
</div>
{/* Filter tabs */}
<div className="flex border-b">
<button
className={cn(
'flex-1 py-2 text-sm transition-colors',
filter === 'all'
? 'border-b-2 border-primary font-medium'
: 'text-muted-foreground hover:text-foreground'
)}
onClick={() => setFilter('all')}
>
All
</button>
<button
className={cn(
'flex-1 py-2 text-sm transition-colors',
filter === 'unread'
? 'border-b-2 border-primary font-medium'
: 'text-muted-foreground hover:text-foreground'
)}
onClick={() => setFilter('unread')}
>
Unread ({unreadCount})
</button>
</div>
{/* Notification list */}
<ScrollArea className="h-[400px]">
<div className="divide-y">
{notifications.map((notification) => (
<NotificationItem
key={notification.id}
notification={notification}
observeRef={!notification.isRead ? getItemRef(notification.id) : undefined}
onRead={() => {
if (!notification.isRead) {
markAsReadMutation.mutate({ id: notification.id })
}
}}
/>
))}
{notifications.length === 0 && (
<div className="p-8 text-center">
<Bell className="h-10 w-10 mx-auto text-muted-foreground/30" />
<p className="mt-2 text-muted-foreground">
{filter === 'unread'
? 'No unread notifications'
: 'No notifications yet'}
</p>
</div>
)}
</div>
</ScrollArea>
{/* Footer */}
{notifications.length > 0 && (
<div className="p-2 border-t bg-muted/30">
<Button variant="ghost" className="w-full" asChild>
<Link href={`${pathPrefix}/notifications` as Route}>View all notifications</Link>
</Button>
</div>
)}
</PopoverContent>
</Popover>
)
}