249 lines
9.2 KiB
TypeScript
249 lines
9.2 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* @file Notification center: bell icon with dropdown of recent notifications.
|
|
*/
|
|
|
|
import { useEffect, useState, useRef } from 'react'
|
|
import Link from 'next/link'
|
|
import { listNotifications, markNotificationRead, markAllNotificationsRead, type Notification } from '@/lib/api/notifications'
|
|
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
|
import { formatTimeAgo, getTypeIcon } from '@/lib/utils/notifications'
|
|
import { SettingsIcon } from '@ciphera-net/ui'
|
|
|
|
// * Bell icon (simple SVG, no extra deps)
|
|
function BellIcon({ className }: { className?: string }) {
|
|
return (
|
|
<svg
|
|
className={className}
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="20"
|
|
height="20"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
|
|
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
const LOADING_DELAY_MS = 250
|
|
const POLL_INTERVAL_MS = 90_000
|
|
|
|
export default function NotificationCenter() {
|
|
const [open, setOpen] = useState(false)
|
|
const [notifications, setNotifications] = useState<Notification[]>([])
|
|
const [unreadCount, setUnreadCount] = useState(0)
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
|
|
|
const fetchUnreadCount = async () => {
|
|
try {
|
|
const res = await listNotifications({ limit: 1 })
|
|
setUnreadCount(typeof res?.unread_count === 'number' ? res.unread_count : 0)
|
|
} catch {
|
|
// Ignore polling errors
|
|
}
|
|
}
|
|
|
|
const fetchNotifications = async () => {
|
|
setError(null)
|
|
const loadingTimer = setTimeout(() => setLoading(true), LOADING_DELAY_MS)
|
|
try {
|
|
const res = await listNotifications({})
|
|
setNotifications(Array.isArray(res?.notifications) ? res.notifications : [])
|
|
setUnreadCount(typeof res?.unread_count === 'number' ? res.unread_count : 0)
|
|
} catch (err) {
|
|
setError(getAuthErrorMessage(err as Error) || 'Failed to load notifications')
|
|
setNotifications([])
|
|
setUnreadCount(0)
|
|
} finally {
|
|
clearTimeout(loadingTimer)
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
fetchNotifications()
|
|
}
|
|
}, [open])
|
|
|
|
// * Poll unread count in background (when authenticated)
|
|
useEffect(() => {
|
|
fetchUnreadCount()
|
|
const id = setInterval(fetchUnreadCount, POLL_INTERVAL_MS)
|
|
return () => clearInterval(id)
|
|
}, [])
|
|
|
|
// * Close dropdown when clicking outside
|
|
useEffect(() => {
|
|
function handleClickOutside(e: MouseEvent) {
|
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
|
setOpen(false)
|
|
}
|
|
}
|
|
if (open) {
|
|
document.addEventListener('mousedown', handleClickOutside)
|
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
}
|
|
}, [open])
|
|
|
|
const handleMarkRead = async (id: string) => {
|
|
try {
|
|
await markNotificationRead(id)
|
|
setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, read: true } : n)))
|
|
setUnreadCount((c) => Math.max(0, c - 1))
|
|
} catch {
|
|
// Ignore; user can retry
|
|
}
|
|
}
|
|
|
|
const handleMarkAllRead = async () => {
|
|
try {
|
|
await markAllNotificationsRead()
|
|
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })))
|
|
setUnreadCount(0)
|
|
} catch {
|
|
// Ignore
|
|
}
|
|
}
|
|
|
|
const handleNotificationClick = (n: Notification) => {
|
|
if (!n.read) {
|
|
handleMarkRead(n.id)
|
|
}
|
|
setOpen(false)
|
|
}
|
|
|
|
return (
|
|
<div className="relative" ref={dropdownRef}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(!open)}
|
|
className="relative p-2 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white rounded-lg hover:bg-neutral-100/50 dark:hover:bg-neutral-800/50 transition-colors"
|
|
aria-label={unreadCount > 0 ? `${unreadCount} unread notifications` : 'Notifications'}
|
|
>
|
|
<BellIcon />
|
|
{unreadCount > 0 && (
|
|
<span className="absolute top-1 right-1 w-2 h-2 bg-brand-orange rounded-full" />
|
|
)}
|
|
</button>
|
|
|
|
{open && (
|
|
<div className="absolute right-0 top-full mt-2 w-80 sm:w-96 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-xl overflow-hidden z-[100]">
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-neutral-200 dark:border-neutral-700">
|
|
<h3 className="font-semibold text-neutral-900 dark:text-white">Notifications</h3>
|
|
{unreadCount > 0 && (
|
|
<button
|
|
type="button"
|
|
onClick={handleMarkAllRead}
|
|
className="text-sm text-brand-orange hover:underline"
|
|
>
|
|
Mark all read
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="max-h-80 overflow-y-auto">
|
|
{loading && (
|
|
<div className="p-6 text-center text-neutral-500 dark:text-neutral-400 text-sm">
|
|
Loading…
|
|
</div>
|
|
)}
|
|
{error && (
|
|
<div className="p-6 text-center text-red-500 text-sm">{error}</div>
|
|
)}
|
|
{!loading && !error && (notifications?.length ?? 0) === 0 && (
|
|
<div className="p-6 text-center text-neutral-500 dark:text-neutral-400 text-sm">
|
|
No notifications yet
|
|
</div>
|
|
)}
|
|
{!loading && !error && (notifications?.length ?? 0) > 0 && (
|
|
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
|
|
{(notifications ?? []).map((n) => (
|
|
<li key={n.id}>
|
|
{n.link_url ? (
|
|
<Link
|
|
href={n.link_url}
|
|
onClick={() => handleNotificationClick(n)}
|
|
className={`block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors ${!n.read ? 'bg-brand-orange/5 dark:bg-brand-orange/10' : ''}`}
|
|
>
|
|
<div className="flex gap-3">
|
|
{getTypeIcon(n.type)}
|
|
<div className="min-w-0 flex-1">
|
|
<p className={`text-sm ${!n.read ? 'font-medium' : ''} text-neutral-900 dark:text-white`}>
|
|
{n.title}
|
|
</p>
|
|
{n.body && (
|
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5 line-clamp-2">
|
|
{n.body}
|
|
</p>
|
|
)}
|
|
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
|
|
{formatTimeAgo(n.created_at)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
) : (
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => handleNotificationClick(n)}
|
|
onKeyDown={(e) => e.key === 'Enter' && handleNotificationClick(n)}
|
|
className={`block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 cursor-pointer ${!n.read ? 'bg-brand-orange/5 dark:bg-brand-orange/10' : ''}`}
|
|
>
|
|
<div className="flex gap-3">
|
|
{getTypeIcon(n.type)}
|
|
<div className="min-w-0 flex-1">
|
|
<p className={`text-sm ${!n.read ? 'font-medium' : ''} text-neutral-900 dark:text-white`}>
|
|
{n.title}
|
|
</p>
|
|
{n.body && (
|
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5 line-clamp-2">
|
|
{n.body}
|
|
</p>
|
|
)}
|
|
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
|
|
{formatTimeAgo(n.created_at)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
|
|
<div className="border-t border-neutral-200 dark:border-neutral-700 px-4 py-3 flex items-center justify-between gap-2">
|
|
<Link
|
|
href="/notifications"
|
|
onClick={() => setOpen(false)}
|
|
className="text-sm text-brand-orange hover:underline"
|
|
>
|
|
View all
|
|
</Link>
|
|
<Link
|
|
href="/org-settings?tab=notifications"
|
|
onClick={() => setOpen(false)}
|
|
className="flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors"
|
|
>
|
|
<SettingsIcon className="w-4 h-4" />
|
|
Manage settings
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|