'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 '@/lib/utils/authErrors' import { AlertTriangleIcon, CheckCircleIcon } from '@ciphera-net/ui' // * Bell icon (simple SVG, no extra deps) function BellIcon({ className }: { className?: string }) { return ( ) } function formatTimeAgo(dateStr: string): string { const d = new Date(dateStr) const now = new Date() const diffMs = now.getTime() - d.getTime() const diffMins = Math.floor(diffMs / 60000) const diffHours = Math.floor(diffMs / 3600000) const diffDays = Math.floor(diffMs / 86400000) if (diffMins < 1) return 'Just now' if (diffMins < 60) return `${diffMins}m ago` if (diffHours < 24) return `${diffHours}h ago` if (diffDays < 7) return `${diffDays}d ago` return d.toLocaleDateString() } function getTypeIcon(type: string) { if (type.includes('down') || type.includes('degraded')) { return } return } export default function NotificationCenter() { const [open, setOpen] = useState(false) const [notifications, setNotifications] = useState([]) const [unreadCount, setUnreadCount] = useState(0) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const dropdownRef = useRef(null) const fetchNotifications = async () => { setLoading(true) setError(null) try { const res = await listNotifications() setNotifications(res.notifications) setUnreadCount(res.unread_count) } catch (err) { setError(getAuthErrorMessage(err as Error) || 'Failed to load notifications') setNotifications([]) setUnreadCount(0) } finally { setLoading(false) } } useEffect(() => { if (open) { fetchNotifications() } }, [open]) // * 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 ( 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'} > {unreadCount > 0 && ( )} {open && ( Notifications {unreadCount > 0 && ( Mark all read )} {loading && ( Loading… )} {error && ( {error} )} {!loading && !error && notifications.length === 0 && ( No notifications yet )} {!loading && !error && notifications.length > 0 && ( {notifications.map((n) => ( {n.link_url ? ( 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' : ''}`} > {getTypeIcon(n.type)} {n.title} {n.body && ( {n.body} )} {formatTimeAgo(n.created_at)} ) : ( 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' : ''}`} > {getTypeIcon(n.type)} {n.title} {n.body && ( {n.body} )} {formatTimeAgo(n.created_at)} )} ))} )} )} ) }
{n.title}
{n.body}
{formatTimeAgo(n.created_at)}