Bankbot / frontend /src /components /notifications /NotificationPanel.tsx
mohsin-devs's picture
feat: improved light mode + language sync to html lang attribute
7782325
Raw
History Blame Contribute Delete
12.9 kB
"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>
);
}