203 lines
6.8 KiB
TypeScript
203 lines
6.8 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import * as React from "react";
|
||
|
|
import Link from "next/link";
|
||
|
|
import {
|
||
|
|
Bell,
|
||
|
|
CheckCircle2,
|
||
|
|
AlertCircle,
|
||
|
|
RefreshCw,
|
||
|
|
Mail,
|
||
|
|
Monitor,
|
||
|
|
LayoutGrid,
|
||
|
|
Globe,
|
||
|
|
Clock,
|
||
|
|
CheckCheck,
|
||
|
|
} from "lucide-react";
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { Badge } from "@/components/ui/badge";
|
||
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
|
||
|
|
type Notification = {
|
||
|
|
id: string;
|
||
|
|
createdAt: string;
|
||
|
|
read: boolean;
|
||
|
|
type: string;
|
||
|
|
title: string;
|
||
|
|
message: string;
|
||
|
|
cardId: string | null;
|
||
|
|
actionUrl: string | null;
|
||
|
|
};
|
||
|
|
|
||
|
|
export function NotificationCenter() {
|
||
|
|
const [open, setOpen] = React.useState(false);
|
||
|
|
const [notifications, setNotifications] = React.useState<Notification[]>([]);
|
||
|
|
const [unreadCount, setUnreadCount] = React.useState(0);
|
||
|
|
|
||
|
|
const fetchUnreadCount = React.useCallback(async () => {
|
||
|
|
try {
|
||
|
|
const res = await fetch("/api/notifications?unreadOnly=true&limit=1");
|
||
|
|
if (res.ok) {
|
||
|
|
const data = await res.json();
|
||
|
|
setUnreadCount(data.unreadCount ?? 0);
|
||
|
|
}
|
||
|
|
} catch { /* ignore */ }
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
React.useEffect(() => {
|
||
|
|
fetchUnreadCount();
|
||
|
|
const interval = setInterval(fetchUnreadCount, 30_000);
|
||
|
|
return () => clearInterval(interval);
|
||
|
|
}, [fetchUnreadCount]);
|
||
|
|
|
||
|
|
const fetchNotifications = React.useCallback(async () => {
|
||
|
|
try {
|
||
|
|
const res = await fetch("/api/notifications?limit=30");
|
||
|
|
if (res.ok) {
|
||
|
|
const data = await res.json();
|
||
|
|
setNotifications(data.notifications ?? []);
|
||
|
|
setUnreadCount(data.unreadCount ?? 0);
|
||
|
|
}
|
||
|
|
} catch { /* ignore */ }
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
React.useEffect(() => {
|
||
|
|
if (open) fetchNotifications();
|
||
|
|
}, [open, fetchNotifications]);
|
||
|
|
|
||
|
|
const markAllRead = async () => {
|
||
|
|
try {
|
||
|
|
await fetch("/api/notifications", {
|
||
|
|
method: "PUT",
|
||
|
|
headers: { "Content-Type": "application/json" },
|
||
|
|
body: JSON.stringify({ action: "mark_all_read" }),
|
||
|
|
});
|
||
|
|
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
||
|
|
setUnreadCount(0);
|
||
|
|
} catch { /* ignore */ }
|
||
|
|
};
|
||
|
|
|
||
|
|
const markRead = async (id: string) => {
|
||
|
|
try {
|
||
|
|
await fetch("/api/notifications", {
|
||
|
|
method: "PUT",
|
||
|
|
headers: { "Content-Type": "application/json" },
|
||
|
|
body: JSON.stringify({ action: "mark_read", id }),
|
||
|
|
});
|
||
|
|
setNotifications((prev) => prev.map((n) => n.id === id ? { ...n, read: true } : n));
|
||
|
|
setUnreadCount((c) => Math.max(0, c - 1));
|
||
|
|
} catch { /* ignore */ }
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Popover open={open} onOpenChange={setOpen}>
|
||
|
|
<PopoverTrigger
|
||
|
|
render={
|
||
|
|
<Button variant="ghost" size="icon" className="relative size-9 rounded-xl" />
|
||
|
|
}
|
||
|
|
>
|
||
|
|
<Bell className="size-4" />
|
||
|
|
{unreadCount > 0 && (
|
||
|
|
<span className="absolute -right-0.5 -top-0.5 flex size-4.5 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white">
|
||
|
|
{unreadCount > 9 ? "9+" : unreadCount}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
<span className="sr-only">Notifications</span>
|
||
|
|
</PopoverTrigger>
|
||
|
|
<PopoverContent align="end" sideOffset={8} className="w-80 p-0">
|
||
|
|
<div className="flex items-center justify-between border-b border-border/50 px-3 py-2.5">
|
||
|
|
<span className="text-sm font-medium">Notifications</span>
|
||
|
|
{unreadCount > 0 && (
|
||
|
|
<Button variant="ghost" size="sm" className="h-7 text-xs px-2" onClick={markAllRead}>
|
||
|
|
<CheckCheck className="mr-1 size-3" /> Mark all read
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<div className="max-h-80 overflow-y-auto">
|
||
|
|
{notifications.length === 0 ? (
|
||
|
|
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
|
||
|
|
<Bell className="size-8 mb-2 opacity-20" />
|
||
|
|
<p className="text-sm">No notifications</p>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
notifications.map((n) => (
|
||
|
|
<NotificationItem key={n.id} notification={n} onRead={markRead} onClose={() => setOpen(false)} />
|
||
|
|
))
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</PopoverContent>
|
||
|
|
</Popover>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function NotificationItem({
|
||
|
|
notification: n,
|
||
|
|
onRead,
|
||
|
|
onClose,
|
||
|
|
}: {
|
||
|
|
notification: Notification;
|
||
|
|
onRead: (id: string) => void;
|
||
|
|
onClose: () => void;
|
||
|
|
}) {
|
||
|
|
const iconMap: Record<string, { icon: React.ReactNode; color: string }> = {
|
||
|
|
ocr_complete: { icon: <CheckCircle2 className="size-3.5" />, color: "text-emerald-600 bg-emerald-500/10" },
|
||
|
|
ocr_error: { icon: <AlertCircle className="size-3.5" />, color: "text-red-600 bg-red-500/10" },
|
||
|
|
card_needs_review: { icon: <RefreshCw className="size-3.5" />, color: "text-amber-600 bg-amber-500/10" },
|
||
|
|
monday_sync: { icon: <LayoutGrid className="size-3.5" />, color: "text-blue-600 bg-blue-500/10" },
|
||
|
|
monday_error: { icon: <LayoutGrid className="size-3.5" />, color: "text-red-600 bg-red-500/10" },
|
||
|
|
webhook_error: { icon: <Globe className="size-3.5" />, color: "text-red-600 bg-red-500/10" },
|
||
|
|
email_watcher: { icon: <Mail className="size-3.5" />, color: "text-purple-600 bg-purple-500/10" },
|
||
|
|
system: { icon: <Monitor className="size-3.5" />, color: "text-muted-foreground bg-muted" },
|
||
|
|
};
|
||
|
|
|
||
|
|
const { icon, color } = iconMap[n.type] ?? iconMap.system;
|
||
|
|
|
||
|
|
const handleClick = () => {
|
||
|
|
if (!n.read) onRead(n.id);
|
||
|
|
if (n.actionUrl) onClose();
|
||
|
|
};
|
||
|
|
|
||
|
|
const content = (
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
"flex gap-3 px-3 py-2.5 border-b border-border/30 transition-colors hover:bg-muted/50 cursor-pointer",
|
||
|
|
!n.read && "bg-primary/[0.03]"
|
||
|
|
)}
|
||
|
|
onClick={handleClick}
|
||
|
|
>
|
||
|
|
<div className={cn("flex size-7 shrink-0 items-center justify-center rounded-full mt-0.5", color)}>
|
||
|
|
{icon}
|
||
|
|
</div>
|
||
|
|
<div className="min-w-0 flex-1">
|
||
|
|
<div className="flex items-center gap-1.5">
|
||
|
|
<span className={cn("text-xs font-medium truncate", !n.read && "text-foreground")}>{n.title}</span>
|
||
|
|
{!n.read && <span className="size-1.5 shrink-0 rounded-full bg-primary" />}
|
||
|
|
</div>
|
||
|
|
<p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">{n.message}</p>
|
||
|
|
<span className="text-[10px] text-muted-foreground/70 flex items-center gap-1 mt-1">
|
||
|
|
<Clock className="size-2.5" />
|
||
|
|
{formatTimeAgo(n.createdAt)}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
|
||
|
|
if (n.actionUrl) {
|
||
|
|
return <Link href={n.actionUrl}>{content}</Link>;
|
||
|
|
}
|
||
|
|
return content;
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatTimeAgo(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 hours = Math.floor(mins / 60);
|
||
|
|
if (hours < 24) return `${hours}h ago`;
|
||
|
|
const days = Math.floor(hours / 24);
|
||
|
|
if (days < 30) return `${days}d ago`;
|
||
|
|
return new Date(dateStr).toLocaleDateString();
|
||
|
|
}
|