Add notification bell system and MOPC onboarding form
Notification System: - Add InAppNotification and NotificationEmailSetting database models - Create notification service with 60+ notification types for all user roles - Add notification router with CRUD endpoints - Build NotificationBell UI component with dropdown and unread count - Integrate bell into admin, jury, mentor, and observer navs - Add notification email settings admin UI in Settings > Notifications - Add notification triggers to filtering router (complete/failed) - Add sendNotificationEmail function to email library - Add formatRelativeTime utility function MOPC Onboarding Form: - Create /apply landing page with auto-redirect for single form - Create seed script for MOPC 2026 application form (6 steps) - Create seed script for default notification email settings Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
352
src/components/shared/notification-bell.tsx
Normal file
352
src/components/shared/notification-bell.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
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,
|
||||
}: {
|
||||
notification: Notification
|
||||
onRead: () => 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
|
||||
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} →
|
||||
</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 { 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 unreadCount = countData ?? 0
|
||||
const notifications = notificationData?.notifications ?? []
|
||||
|
||||
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={'/admin/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}
|
||||
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={'/admin/notifications' as Route}>View all notifications</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user