import { useMemo, useState } from "react"; import { useMutation, useQuery } from "@tanstack/react-query"; import { motion, AnimatePresence } from "framer-motion"; import { Bell, Check, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { apiRequest, queryClient } from "@/lib/queryClient"; import { useToast } from "@/hooks/use-toast"; import { Notification } from "@repo/db/types"; import { formatDateToHumanReadable} from "@/utils/dateUtils"; const PAGE_SIZE = 5; export function NotificationsBell() { const { toast } = useToast(); // dialog / pagination state (client-side over fetched 20) const [open, setOpen] = useState(false); const [pageIndex, setPageIndex] = useState(0); // 0..N (each page size 5) // ------- Single load (no polling): fetch up to 20 latest notifications ------- const listQuery = useQuery({ queryKey: ["/notifications"], queryFn: async (): Promise => { const res = await apiRequest("GET", "/api/notifications"); if (!res.ok) throw new Error("Failed to fetch notifications"); return res.json(); }, refetchOnWindowFocus: false, staleTime: Infinity, gcTime: Infinity, }); const all = listQuery.data ?? []; const unread = useMemo(() => all.filter((n) => !n.read), [all]); const unreadCount = unread.length; // latest unread for spotlight const latestUnread = unread[0] ?? null; // client-side dialog pagination over the fetched 20 const totalPages = Math.max(1, Math.ceil(all.length / PAGE_SIZE)); const currentPageItems = useMemo(() => { const start = pageIndex * PAGE_SIZE; return all.slice(start, start + PAGE_SIZE); }, [all, pageIndex]); // ------- mutations ------- const markRead = useMutation({ mutationFn: async (id: number) => { const res = await apiRequest("POST", `/api/notifications/${id}/read`); if (!res.ok) throw new Error("Failed to mark as read"); }, onMutate: async (id) => { // optimistic update in cache await queryClient.cancelQueries({ queryKey: ["/notifications"] }); const prev = queryClient.getQueryData(["/notifications"]); if (prev) { queryClient.setQueryData( ["/notifications"], prev.map((n) => (n.id === id ? { ...n, read: true } : n)) ); } return { prev }; }, onError: (_e, _id, ctx) => { if (ctx?.prev) queryClient.setQueryData(["/notifications"], ctx.prev); toast({ title: "Error", description: "Failed to update notification", variant: "destructive", }); }, }); const markAllRead = useMutation({ mutationFn: async () => { const res = await apiRequest("POST", "/api/notifications/read-all"); if (!res.ok) throw new Error("Failed to mark all as read"); }, onMutate: async () => { await queryClient.cancelQueries({ queryKey: ["/notifications"] }); const prev = queryClient.getQueryData(["/notifications"]); if (prev) { queryClient.setQueryData( ["/notifications"], prev.map((n) => ({ ...n, read: true })) ); } return { prev }; }, onError: (_e, _id, ctx) => { if (ctx?.prev) queryClient.setQueryData(["/notifications"], ctx.prev); toast({ title: "Error", description: "Failed to mark all as read", variant: "destructive", }); }, }); // when opening dialog, reset to first page const onOpenChange = async (v: boolean) => { setOpen(v); if (v) { setPageIndex(0); await listQuery.refetch(); } }; return (
{/* Bell + unread badge */} {/* Dialog (client-side pagination over the 20 we already fetched) */} Notifications {listQuery.isLoading ? (
) : all.length === 0 ? (

No notifications yet.

) : ( <>
{currentPageItems.map((n) => (

{n.message}

{formatDateToHumanReadable(n.createdAt)}

{!n.read ? ( ) : ( Read )}
))}
Page {pageIndex + 1} / {totalPages}
)}
{/* Spotlight: ONE latest unread (animates in; collapses when marked read) */} {latestUnread && (
{/* animated halo */}
{/* ping dot */}

{latestUnread.message}

{formatDateToHumanReadable(latestUnread.createdAt)}

)}
); }