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:
2026-02-03 21:30:25 +01:00
parent e1968d45df
commit 0277768ed7
18 changed files with 2344 additions and 13 deletions

View File

@@ -38,6 +38,7 @@ import { getInitials } from '@/lib/utils'
import { Logo } from '@/components/shared/logo'
import { EditionSelector } from '@/components/shared/edition-selector'
import { UserAvatar } from '@/components/shared/user-avatar'
import { NotificationBell } from '@/components/shared/notification-bell'
import { trpc } from '@/lib/trpc/client'
interface AdminSidebarProps {
@@ -137,18 +138,21 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
{/* Mobile menu button */}
<div className="fixed top-0 left-0 right-0 z-40 flex h-16 items-center justify-between border-b bg-card px-4 lg:hidden">
<Logo showText textSuffix="Admin" />
<Button
variant="ghost"
size="icon"
aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'}
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
>
{isMobileMenuOpen ? (
<X className="h-5 w-5" />
) : (
<Menu className="h-5 w-5" />
)}
</Button>
<div className="flex items-center gap-2">
<NotificationBell />
<Button
variant="ghost"
size="icon"
aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'}
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
>
{isMobileMenuOpen ? (
<X className="h-5 w-5" />
) : (
<Menu className="h-5 w-5" />
)}
</Button>
</div>
</div>
{/* Mobile menu overlay */}
@@ -241,6 +245,10 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
{/* User Profile Section */}
<div className="border-t p-3">
{/* Notification Bell - Desktop */}
<div className="hidden lg:flex justify-end mb-2">
<NotificationBell />
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="group flex w-full items-center gap-3 rounded-xl p-2.5 text-left transition-all duration-200 hover:bg-slate-100 dark:hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">

View File

@@ -18,6 +18,7 @@ import {
import type { Route } from 'next'
import { BookOpen, ClipboardList, Home, LogOut, Menu, Settings, User, X } from 'lucide-react'
import { Logo } from '@/components/shared/logo'
import { NotificationBell } from '@/components/shared/notification-bell'
interface JuryNavProps {
user: {
@@ -84,6 +85,7 @@ export function JuryNav({ user }: JuryNavProps) {
{/* User menu & mobile toggle */}
<div className="flex items-center gap-2">
<NotificationBell />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button

View File

@@ -18,6 +18,7 @@ import {
} from '@/components/ui/dropdown-menu'
import { BookOpen, Home, LogOut, Menu, Settings, User, Users, X } from 'lucide-react'
import { Logo } from '@/components/shared/logo'
import { NotificationBell } from '@/components/shared/notification-bell'
interface MentorNavProps {
user: {
@@ -84,6 +85,7 @@ export function MentorNav({ user }: MentorNavProps) {
{/* User menu & mobile toggle */}
<div className="flex items-center gap-2">
<NotificationBell />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button

View File

@@ -18,6 +18,7 @@ import {
import type { Route } from 'next'
import { Home, BarChart3, Menu, X, LogOut, Eye, Settings } from 'lucide-react'
import { Logo } from '@/components/shared/logo'
import { NotificationBell } from '@/components/shared/notification-bell'
interface ObserverNavProps {
user: {
@@ -76,6 +77,7 @@ export function ObserverNav({ user }: ObserverNavProps) {
{/* User Menu */}
<div className="flex items-center gap-2">
<NotificationBell />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="gap-2">

View File

@@ -0,0 +1,140 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { toast } from 'sonner'
import { Users, Scale, GraduationCap, Eye, Shield } from 'lucide-react'
// Category icons and labels
const CATEGORIES = {
team: { label: 'Team / Applicant', icon: Users },
jury: { label: 'Jury Members', icon: Scale },
mentor: { label: 'Mentors', icon: GraduationCap },
observer: { label: 'Observers', icon: Eye },
admin: { label: 'Administrators', icon: Shield },
}
type NotificationSetting = {
id: string
notificationType: string
category: string
label: string
description: string | null
sendEmail: boolean
}
export function NotificationSettingsForm() {
const { data: settings, isLoading, refetch } = trpc.notification.getEmailSettings.useQuery()
const updateMutation = trpc.notification.updateEmailSetting.useMutation({
onSuccess: () => {
toast.success('Notification setting updated')
refetch()
},
onError: (error) => {
toast.error(`Failed to update: ${error.message}`)
},
})
const handleToggle = (notificationType: string, sendEmail: boolean) => {
updateMutation.mutate({ notificationType, sendEmail })
}
if (isLoading) {
return (
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
)
}
// Group settings by category
const groupedSettings = (settings || []).reduce(
(acc, setting) => {
const category = setting.category || 'other'
if (!acc[category]) acc[category] = []
acc[category].push(setting)
return acc
},
{} as Record<string, NotificationSetting[]>
)
if (Object.keys(groupedSettings).length === 0) {
return (
<div className="text-center py-8">
<p className="text-muted-foreground">
No notification types configured yet. Notification settings will appear here once the system is seeded.
</p>
<Button
variant="outline"
className="mt-4"
onClick={() => {
toast.info('Run the seed script to populate notification types')
}}
>
Learn More
</Button>
</div>
)
}
return (
<div className="space-y-6">
<p className="text-sm text-muted-foreground">
Toggle which notifications should also send email notifications to users.
Users can still disable email notifications in their personal preferences.
</p>
{Object.entries(CATEGORIES).map(([categoryKey, { label, icon: Icon }]) => {
const categorySettings = groupedSettings[categoryKey]
if (!categorySettings || categorySettings.length === 0) return null
return (
<Card key={categoryKey}>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-3 text-base">
<Icon className="h-5 w-5 text-muted-foreground" />
{label}
<span className="ml-auto text-xs font-normal text-muted-foreground">
{categorySettings.filter(s => s.sendEmail).length}/{categorySettings.length} enabled
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{categorySettings.map((setting) => (
<div
key={setting.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<div className="space-y-0.5">
<Label className="text-sm font-medium">
{setting.label}
</Label>
{setting.description && (
<p className="text-xs text-muted-foreground">
{setting.description}
</p>
)}
</div>
<Switch
checked={setting.sendEmail}
onCheckedChange={(checked) =>
handleToggle(setting.notificationType, checked)
}
disabled={updateMutation.isPending}
/>
</div>
))}
</CardContent>
</Card>
)
})}
</div>
)
}

View File

@@ -17,6 +17,7 @@ import {
HardDrive,
Shield,
Settings as SettingsIcon,
Bell,
} from 'lucide-react'
import { AISettingsForm } from './ai-settings-form'
import { AIUsageCard } from './ai-usage-card'
@@ -25,6 +26,7 @@ import { EmailSettingsForm } from './email-settings-form'
import { StorageSettingsForm } from './storage-settings-form'
import { SecuritySettingsForm } from './security-settings-form'
import { DefaultsSettingsForm } from './defaults-settings-form'
import { NotificationSettingsForm } from './notification-settings-form'
function SettingsSkeleton() {
return (
@@ -108,7 +110,7 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
return (
<Tabs defaultValue="ai" className="space-y-6">
<TabsList className="grid w-full grid-cols-2 lg:grid-cols-6">
<TabsList className="grid w-full grid-cols-3 lg:grid-cols-7">
<TabsTrigger value="ai" className="gap-2">
<Bot className="h-4 w-4" />
<span className="hidden sm:inline">AI</span>
@@ -121,6 +123,10 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
<Mail className="h-4 w-4" />
<span className="hidden sm:inline">Email</span>
</TabsTrigger>
<TabsTrigger value="notifications" className="gap-2">
<Bell className="h-4 w-4" />
<span className="hidden sm:inline">Notifications</span>
</TabsTrigger>
<TabsTrigger value="storage" className="gap-2">
<HardDrive className="h-4 w-4" />
<span className="hidden sm:inline">Storage</span>
@@ -178,6 +184,20 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
</Card>
</TabsContent>
<TabsContent value="notifications">
<Card>
<CardHeader>
<CardTitle>Notification Email Settings</CardTitle>
<CardDescription>
Configure which notification types should also send email notifications
</CardDescription>
</CardHeader>
<CardContent>
<NotificationSettingsForm />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="storage">
<Card>
<CardHeader>

View 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} &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 { 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>
)
}