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:
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
140
src/components/settings/notification-settings-form.tsx
Normal file
140
src/components/settings/notification-settings-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
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