Spaces:
Sleeping
Sleeping
| "use client"; | |
| import { useState, useEffect, useCallback } from "react"; | |
| import { motion, AnimatePresence } from "framer-motion"; | |
| import { | |
| Shield, TrendingUp, AlertTriangle, Sparkles, | |
| DollarSign, X, CheckCheck, Bell, RefreshCw | |
| } from "lucide-react"; | |
| import { notificationsApi, NotificationItem } from "@/lib/api"; | |
| import { useThemeStore } from "@/lib/stores/themeStore"; | |
| // βββ Type config ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const typeConfig: Record<string, { | |
| icon: React.ElementType; | |
| color: string; | |
| darkBg: string; | |
| lightBg: string; | |
| }> = { | |
| alert: { icon: Shield, color: "text-red-500", darkBg: "bg-red-500/10 border-red-500/20", lightBg: "bg-red-50 border-red-200/70" }, | |
| insight: { icon: Sparkles, color: "text-blue-500", darkBg: "bg-blue-500/10 border-blue-500/20", lightBg: "bg-blue-50 border-blue-200/70" }, | |
| warning: { icon: AlertTriangle, color: "text-amber-500", darkBg: "bg-amber-500/10 border-amber-500/20", lightBg: "bg-amber-50 border-amber-200/70" }, | |
| success: { icon: TrendingUp, color: "text-emerald-500", darkBg: "bg-emerald-500/10 border-emerald-500/20", lightBg: "bg-emerald-50 border-emerald-200/70" }, | |
| default: { icon: DollarSign, color: "text-slate-500", darkBg: "bg-zinc-500/10 border-zinc-500/20", lightBg: "bg-slate-50 border-slate-200/70" }, | |
| }; | |
| function getTypeConfig(type: string) { | |
| return typeConfig[type] || typeConfig.default; | |
| } | |
| function timeAgo(dateStr: string): string { | |
| const diff = Date.now() - new Date(dateStr).getTime(); | |
| const mins = Math.floor(diff / 60000); | |
| if (mins < 1) return "Just now"; | |
| if (mins < 60) return `${mins}m ago`; | |
| const hrs = Math.floor(mins / 60); | |
| if (hrs < 24) return `${hrs}h ago`; | |
| return `${Math.floor(hrs / 24)}d ago`; | |
| } | |
| // βββ Notification Item ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function NotifItem({ | |
| notification, | |
| onDismiss, | |
| onRead, | |
| isLight, | |
| }: { | |
| notification: NotificationItem; | |
| onDismiss: (id: string) => void; | |
| onRead: (id: string) => void; | |
| isLight: boolean; | |
| }) { | |
| const config = getTypeConfig(notification.type); | |
| const Icon = config.icon; | |
| const bgClass = isLight ? config.lightBg : config.darkBg; | |
| return ( | |
| <motion.div | |
| layout | |
| initial={{ opacity: 0, x: 20 }} | |
| animate={{ opacity: 1, x: 0 }} | |
| exit={{ opacity: 0, x: 20, height: 0, marginBottom: 0 }} | |
| transition={{ duration: 0.25 }} | |
| onClick={() => onRead(notification.id)} | |
| className={`relative flex gap-3 rounded-xl border p-3 cursor-pointer transition-all group ${bgClass} ${ | |
| !notification.read ? "ring-1 ring-inset" : "opacity-60" | |
| }`} | |
| > | |
| {!notification.read && ( | |
| <div className="absolute top-3 right-3 h-1.5 w-1.5 rounded-full bg-blue-500" /> | |
| )} | |
| <div className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg border ${bgClass}`}> | |
| <Icon className={`h-4 w-4 ${config.color}`} /> | |
| </div> | |
| <div className="flex-1 min-w-0 pr-4"> | |
| <p className="text-xs font-semibold leading-tight" style={{ color: "var(--fg)" }}> | |
| {notification.title} | |
| </p> | |
| <p className="text-xs mt-0.5 leading-relaxed line-clamp-2" style={{ color: "var(--fg-muted)" }}> | |
| {notification.message} | |
| </p> | |
| <p className="text-xs mt-1" style={{ color: "var(--fg-subtle)" }}> | |
| {timeAgo(notification.created_at)} | |
| </p> | |
| </div> | |
| <button | |
| onClick={(e) => { e.stopPropagation(); onDismiss(notification.id); }} | |
| className={`absolute top-2 right-2 p-0.5 rounded transition-colors opacity-0 group-hover:opacity-100 ${ | |
| isLight ? "text-slate-400 hover:text-slate-600" : "text-zinc-600 hover:text-zinc-400" | |
| }`} | |
| > | |
| <X className="h-3 w-3" /> | |
| </button> | |
| </motion.div> | |
| ); | |
| } | |
| // βββ Notification Panel βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export function NotificationPanel({ | |
| isOpen, | |
| onClose, | |
| }: { | |
| isOpen: boolean; | |
| onClose: () => void; | |
| }) { | |
| const { theme } = useThemeStore(); | |
| const isLight = theme === "light"; | |
| const [notifications, setNotifications] = useState<NotificationItem[]>([]); | |
| const [unreadCount, setUnreadCount] = useState(0); | |
| const [filter, setFilter] = useState<"all" | "unread">("all"); | |
| const [loading, setLoading] = useState(false); | |
| const loadNotifications = useCallback(async () => { | |
| setLoading(true); | |
| try { | |
| const res = await notificationsApi.list(); | |
| setNotifications(res.notifications); | |
| setUnreadCount(res.unread_count); | |
| } catch { | |
| // backend offline β keep empty state | |
| } finally { | |
| setLoading(false); | |
| } | |
| }, []); | |
| useEffect(() => { | |
| if (isOpen) loadNotifications(); | |
| }, [isOpen, loadNotifications]); | |
| const dismiss = async (id: string) => { | |
| setNotifications((prev) => prev.filter((n) => n.id !== id)); | |
| try { await notificationsApi.dismiss(id); } catch { /* ignore */ } | |
| }; | |
| const markRead = async (id: string) => { | |
| setNotifications((prev) => prev.map((n) => n.id === id ? { ...n, read: true } : n)); | |
| setUnreadCount((c) => Math.max(0, c - 1)); | |
| try { await notificationsApi.markRead(id); } catch { /* ignore */ } | |
| }; | |
| const markAllRead = async () => { | |
| setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))); | |
| setUnreadCount(0); | |
| try { await notificationsApi.markAllRead(); } catch { /* ignore */ } | |
| }; | |
| const displayed = filter === "unread" ? notifications.filter((n) => !n.read) : notifications; | |
| return ( | |
| <AnimatePresence> | |
| {isOpen && ( | |
| <> | |
| <motion.div | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| exit={{ opacity: 0 }} | |
| onClick={onClose} | |
| className="fixed inset-0 z-40" | |
| /> | |
| <motion.div | |
| initial={{ opacity: 0, y: -10, scale: 0.97 }} | |
| animate={{ opacity: 1, y: 0, scale: 1 }} | |
| exit={{ opacity: 0, y: -10, scale: 0.97 }} | |
| transition={{ duration: 0.2, ease: "easeOut" }} | |
| className="absolute right-0 top-full mt-2 z-50 w-80 rounded-2xl border backdrop-blur-2xl overflow-hidden" | |
| style={{ | |
| background: isLight ? "rgba(255,255,255,0.97)" : "rgba(9,9,11,0.97)", | |
| borderColor: "var(--border-strong)", | |
| boxShadow: isLight | |
| ? "0 8px 40px rgba(15,23,42,0.14), 0 2px 8px rgba(15,23,42,0.08)" | |
| : "0 8px 40px rgba(0,0,0,0.6)", | |
| }} | |
| > | |
| {/* Header */} | |
| <div | |
| className="flex items-center justify-between border-b px-4 py-3" | |
| style={{ borderColor: "var(--border)" }} | |
| > | |
| <div className="flex items-center gap-2"> | |
| <Bell className="h-4 w-4" style={{ color: "var(--fg-muted)" }} /> | |
| <span className="text-sm font-semibold" style={{ color: "var(--fg)" }}> | |
| Notifications | |
| </span> | |
| {unreadCount > 0 && ( | |
| <span className="flex h-4 w-4 items-center justify-center rounded-full bg-blue-500 text-[10px] font-bold text-white"> | |
| {unreadCount} | |
| </span> | |
| )} | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <button | |
| onClick={loadNotifications} | |
| className="transition-colors hover:text-emerald-500" | |
| style={{ color: "var(--fg-subtle)" }} | |
| > | |
| <RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} /> | |
| </button> | |
| {unreadCount > 0 && ( | |
| <button | |
| onClick={markAllRead} | |
| className="flex items-center gap-1 text-xs transition-colors hover:text-emerald-500" | |
| style={{ color: "var(--fg-subtle)" }} | |
| > | |
| <CheckCheck className="h-3 w-3" /> | |
| All read | |
| </button> | |
| )} | |
| <button | |
| onClick={onClose} | |
| className="transition-colors hover:text-red-500" | |
| style={{ color: "var(--fg-subtle)" }} | |
| > | |
| <X className="h-4 w-4" /> | |
| </button> | |
| </div> | |
| </div> | |
| {/* Filter tabs */} | |
| <div | |
| className="flex gap-1 border-b px-4 py-2" | |
| style={{ borderColor: "var(--border)" }} | |
| > | |
| {(["all", "unread"] as const).map((f) => ( | |
| <button | |
| key={f} | |
| onClick={() => setFilter(f)} | |
| className={`rounded-lg px-3 py-1 text-xs font-medium capitalize transition-all ${ | |
| filter === f | |
| ? isLight | |
| ? "bg-slate-100 text-slate-800" | |
| : "bg-white/10 text-white" | |
| : "" | |
| }`} | |
| style={filter !== f ? { color: "var(--fg-subtle)" } : undefined} | |
| > | |
| {f} {f === "unread" && unreadCount > 0 && `(${unreadCount})`} | |
| </button> | |
| ))} | |
| </div> | |
| {/* List */} | |
| <div className="max-h-96 overflow-y-auto p-3 space-y-2"> | |
| <AnimatePresence mode="popLayout"> | |
| {loading ? ( | |
| <div className="flex items-center justify-center py-8"> | |
| <RefreshCw className="h-5 w-5 animate-spin" style={{ color: "var(--fg-subtle)" }} /> | |
| </div> | |
| ) : displayed.length === 0 ? ( | |
| <motion.div | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| className="flex flex-col items-center gap-2 py-8 text-center" | |
| > | |
| <Bell className="h-8 w-8" style={{ color: "var(--fg-subtle)" }} /> | |
| <p className="text-sm" style={{ color: "var(--fg-muted)" }}>No notifications</p> | |
| </motion.div> | |
| ) : ( | |
| displayed.map((n) => ( | |
| <NotifItem | |
| key={n.id} | |
| notification={n} | |
| onDismiss={dismiss} | |
| onRead={markRead} | |
| isLight={isLight} | |
| /> | |
| )) | |
| )} | |
| </AnimatePresence> | |
| </div> | |
| <div | |
| className="border-t px-4 py-2.5" | |
| style={{ borderColor: "var(--border)" }} | |
| > | |
| <p className="text-center text-xs" style={{ color: "var(--fg-subtle)" }}> | |
| Real-time Β· AI-powered Β· Encrypted | |
| </p> | |
| </div> | |
| </motion.div> | |
| </> | |
| )} | |
| </AnimatePresence> | |
| ); | |
| } | |
| // βββ Notification Bell ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export function NotificationBell() { | |
| const { theme } = useThemeStore(); | |
| const isLight = theme === "light"; | |
| const [isOpen, setIsOpen] = useState(false); | |
| const [unreadCount, setUnreadCount] = useState(0); | |
| useEffect(() => { | |
| notificationsApi.list().then((res) => setUnreadCount(res.unread_count)).catch(() => {}); | |
| }, []); | |
| return ( | |
| <div className="relative"> | |
| <motion.button | |
| whileHover={{ scale: 1.05 }} | |
| whileTap={{ scale: 0.95 }} | |
| onClick={() => setIsOpen((v) => !v)} | |
| className={`relative rounded-xl p-2.5 transition-all duration-200 focus:outline-none ${ | |
| isLight | |
| ? "bg-slate-100 text-slate-500 hover:bg-slate-200 hover:text-slate-800" | |
| : "bg-white/5 text-zinc-400 hover:text-white hover:bg-white/10" | |
| }`} | |
| > | |
| <Bell className="h-5 w-5" aria-hidden="true" /> | |
| {unreadCount > 0 && ( | |
| <motion.span | |
| initial={{ scale: 0 }} | |
| animate={{ scale: 1 }} | |
| className="absolute -top-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[9px] font-bold text-white" | |
| > | |
| {unreadCount > 9 ? "9+" : unreadCount} | |
| </motion.span> | |
| )} | |
| </motion.button> | |
| <NotificationPanel isOpen={isOpen} onClose={() => setIsOpen(false)} /> | |
| </div> | |
| ); | |
| } | |