diff --git a/client/components/notifications/NotificationBell.tsx b/client/components/notifications/NotificationBell.tsx new file mode 100644 index 00000000..5b1c9708 --- /dev/null +++ b/client/components/notifications/NotificationBell.tsx @@ -0,0 +1,265 @@ +import { useEffect, useMemo, useState } from "react"; +import { + AlertTriangle, + Bell, + CheckCircle2, + Info, + Loader2, + Sparkles, + XCircle, +} from "lucide-react"; +import { formatDistanceToNow } from "date-fns"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import { useAuth } from "@/contexts/AuthContext"; +import { + aethexNotificationService, + aethexRealtimeService, +} from "@/lib/aethex-database-adapter"; +import { aethexToast } from "@/lib/aethex-toast"; + +interface AethexNotification { + id: string; + title: string; + message: string | null; + type: string | null; + created_at: string; + read: boolean | null; +} + +const typeIconMap: Record> = { + success: CheckCircle2, + warning: AlertTriangle, + error: XCircle, + info: Info, + default: Sparkles, +}; + +const typeAccentMap: Record = { + success: "text-emerald-300", + warning: "text-amber-300", + error: "text-rose-300", + info: "text-sky-300", + default: "text-aethex-300", +}; + +export default function NotificationBell({ className }: { className?: string }) { + const { user } = useAuth(); + const [notifications, setNotifications] = useState([]); + const [loading, setLoading] = useState(false); + const [markingAll, setMarkingAll] = useState(false); + + useEffect(() => { + if (!user?.id) { + setNotifications([]); + return; + } + + let isActive = true; + setLoading(true); + + aethexNotificationService + .getUserNotifications(user.id) + .then((data) => { + if (!isActive) return; + setNotifications(Array.isArray(data) ? (data as AethexNotification[]) : []); + }) + .catch(() => { + if (!isActive) return; + setNotifications([]); + }) + .finally(() => { + if (!isActive) return; + setLoading(false); + }); + + const subscription = aethexRealtimeService.subscribeToUserNotifications( + user.id, + (payload: any) => { + const next = (payload?.new ?? payload) as AethexNotification | undefined; + if (!next?.id) return; + + setNotifications((prev) => { + if (prev.some((item) => item.id === next.id)) { + return prev; + } + return [next, ...prev].slice(0, 20); + }); + + aethexToast.aethex({ + title: next.title || "New notification", + description: next.message ?? undefined, + }); + }, + ); + + return () => { + isActive = false; + subscription?.unsubscribe?.(); + }; + }, [user?.id]); + + const unreadCount = useMemo( + () => notifications.filter((notification) => !notification.read).length, + [notifications], + ); + + const markAsRead = async (id: string) => { + setNotifications((prev) => + prev.map((notification) => + notification.id === id ? { ...notification, read: true } : notification, + ), + ); + try { + await aethexNotificationService.markAsRead(id); + } catch { + // Non-blocking; keep optimistic state. + } + }; + + const markAllAsRead = async () => { + if (!notifications.length) return; + setMarkingAll(true); + const ids = notifications.filter((n) => !n.read).map((n) => n.id); + if (!ids.length) { + setMarkingAll(false); + return; + } + setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))); + try { + await Promise.all(ids.map((id) => aethexNotificationService.markAsRead(id))); + } catch { + // Soft fail silently + } finally { + setMarkingAll(false); + } + }; + + const renderNotification = (notification: AethexNotification) => { + const typeKey = notification.type?.toLowerCase() ?? "default"; + const Icon = typeIconMap[typeKey] ?? typeIconMap.default; + const accent = typeAccentMap[typeKey] ?? typeAccentMap.default; + + return ( + { + event.preventDefault(); + void markAsRead(notification.id); + }} + className="focus:bg-background/80" + > +
+ + + +
+
+

+ {notification.title} +

+ {!notification.read && ( + + New + + )} +
+ {notification.message ? ( +

+ {notification.message} +

+ ) : null} +

+ {formatDistanceToNow(new Date(notification.created_at), { + addSuffix: true, + })} +

+
+
+
+ ); + }; + + if (!user?.id) { + return null; + } + + return ( + + + + + + + Notifications + {unreadCount > 0 ? ( + + {unreadCount} unread + + ) : null} + + +
+ +
+ + + {loading ? ( +
+ Loading notifications… +
+ ) : notifications.length ? ( +
+ {notifications.map((notification) => renderNotification(notification))} +
+ ) : ( +
+ No notifications yet. You ll see activity updates here. +
+ )} +
+
+
+ ); +}