1
0
Fork 0
polaris-task-force/src/components/frontend/notifications/NotificationsBell.tsx
Z8MB1E b8cbb85dc8 feat(notifications): add notification inbox page and preference-aware muting
- Add /notifications inbox with mark-read and mark-all-read actions
- Respect mutedAll/mutedTypes preferences when creating notifications
- Add View all link and Discord request label to the notifications bell
2026-08-11 23:25:16 -04:00

173 lines
5.9 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { BellIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { notificationLabel } from "@/lib/notifications";
import {
markAllNotificationsRead,
markNotificationsRead,
} from "@/app/(frontend)/notifications/actions";
interface NotificationItem {
id: number;
type?: string | null;
title: string;
message?: string | null;
link?: string | null;
read: boolean;
createdAt: string;
}
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
const POLL_MS = 30_000;
export function NotificationsBell() {
const router = useRouter();
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const unreadCount = notifications.filter((n) => !n.read).length;
const fetchNotifications = useCallback(async () => {
try {
const res = await fetch("/api/user-notifications?limit=12&sort=-createdAt", {
cache: "no-store",
});
if (!res.ok) throw new Error("Failed to fetch");
const data = (await res.json()) as { docs?: NotificationItem[] };
setNotifications(data.docs ?? []);
setError(false);
} catch {
setError(true);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void fetchNotifications();
intervalRef.current = setInterval(() => void fetchNotifications(), POLL_MS);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [fetchNotifications]);
async function handleItemClick(item: NotificationItem) {
if (!item.read) {
await markNotificationsRead([item.id]);
setNotifications((prev) => prev.map((n) => (n.id === item.id ? { ...n, read: true } : n)));
}
if (item.link) {
router.push(item.link);
setOpen(false);
}
}
async function handleMarkAll() {
if (unreadCount === 0) return;
await markAllNotificationsRead();
setNotifications((prev) => prev.map((n) => (n.read ? n : { ...n, read: true })));
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="relative" aria-label="Notifications">
<BellIcon className="size-5" />
{unreadCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-semibold text-white">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80 sm:w-96">
<div className="flex items-center justify-between pr-1">
<DropdownMenuLabel className="pb-1">Notifications</DropdownMenuLabel>
{unreadCount > 0 && (
<button
onClick={() => void handleMarkAll()}
className="text-xs text-muted-foreground hover:text-foreground"
>
Mark all read
</button>
)}
</div>
<DropdownMenuSeparator />
<div className="max-h-96 overflow-y-auto">
{loading ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">Loading...</p>
) : error ? (
<p className="px-3 py-6 text-center text-sm text-red-400">
Could not load notifications.
</p>
) : notifications.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
No notifications yet.
</p>
) : (
notifications.map((item) => (
<DropdownMenuItem
key={item.id}
className={cn(
"flex flex-col items-start gap-1 py-2.5",
!item.read && "bg-accent/50",
)}
onClick={() => void handleItemClick(item)}
>
<span className="flex w-full items-center justify-between gap-2">
<span className="text-sm font-medium">
{!item.read && (
<span className="mr-1.5 inline-block size-1.5 rounded-full bg-blue-500 align-middle" />
)}
{item.title}
</span>
<span className="shrink-0 text-[10px] text-muted-foreground">
{timeAgo(item.createdAt)}
</span>
</span>
{item.message && (
<span className="w-full text-xs text-muted-foreground">{item.message}</span>
)}
<span className="text-[10px] text-muted-foreground/70">
{notificationLabel(item.type)}
</span>
</DropdownMenuItem>
))
)}
</div>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/notifications" className="justify-center text-sm font-medium">
View all
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}