From 0d10077be8f0d5d312466f2d5cd10195ab69ef97 Mon Sep 17 00:00:00 2001 From: Kaybee973 Date: Wed, 29 Jul 2026 23:25:42 -0700 Subject: [PATCH] feat: add full notifications page with filtering, infinite scroll, and mark-all-as-read --- app/notifications/page.tsx | 245 +++++++++++++++++++++++++++++++++++ components/layout/Navbar.tsx | 14 +- hooks/useNotifications.ts | 76 +++++++++++ lib/api/index.ts | 34 +++++ 4 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 app/notifications/page.tsx create mode 100644 hooks/useNotifications.ts diff --git a/app/notifications/page.tsx b/app/notifications/page.tsx new file mode 100644 index 0000000..0c15d28 --- /dev/null +++ b/app/notifications/page.tsx @@ -0,0 +1,245 @@ +"use client"; + +import { useCallback, useMemo, useRef, useState } from "react"; +import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; +import { formatDistanceToNow } from "date-fns"; +import { Bell, CheckCheck, Loader2, Mail, MailOpen } from "lucide-react"; +import { fetchNotifications, markAllNotificationsAsRead, type NotificationItem, type NotificationsResponse } from "@/lib/api"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +type FilterTab = "all" | "unread"; + +function NotificationRow({ notification }: { notification: NotificationItem }) { + return ( + + +
+ {notification.is_read ? ( + + ) : ( + + )} +
+
+

{notification.title}

+

+ {notification.message} +

+

+ {formatDistanceToNow(new Date(notification.created_at), { addSuffix: true })} +

+
+
+
+ ); +} + +function SkeletonRow() { + return ( + + + +
+ + + +
+
+
+ ); +} + +export default function NotificationsPage() { + const [filter, setFilter] = useState("all"); + + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isFetching, + } = useInfiniteQuery({ + queryKey: ["notifications"], + queryFn: ({ pageParam }) => fetchNotifications(pageParam as string | undefined), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => + lastPage.has_more ? lastPage.next_cursor ?? undefined : undefined, + staleTime: 60 * 1000, + }); + + const queryClient = useQueryClient(); + + const [markingAll, setMarkingAll] = useState(false); + + const handleMarkAllAsRead = useCallback(async () => { + setMarkingAll(true); + try { + await markAllNotificationsAsRead(); + queryClient.invalidateQueries({ queryKey: ["notifications"] }); + } catch { + // error handled by sonner toast in the mutation + } finally { + setMarkingAll(false); + } + }, [queryClient]); + + const sentinelRef = useRef(null); + + const handleIntersect = useCallback( + (entries: IntersectionObserverEntry[]) => { + if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + [fetchNextPage, hasNextPage, isFetchingNextPage] + ); + + const observer = useMemo(() => { + if (typeof window === "undefined") return null; + return new IntersectionObserver(handleIntersect, { rootMargin: "200px" }); + }, [handleIntersect]); + + const sentinelRefCallback = useCallback( + (node: HTMLDivElement | null) => { + if (observer) { + if (sentinelRef.current) observer.unobserve(sentinelRef.current); + if (node) observer.observe(node); + } + (sentinelRef as React.MutableRefObject).current = node; + }, + [observer] + ); + + const allNotifications = useMemo( + () => data?.pages.flatMap((p) => p.notifications) ?? [], + [data] + ); + + const displayedNotifications = useMemo( + () => + filter === "all" + ? allNotifications + : allNotifications.filter((n) => !n.is_read), + [allNotifications, filter] + ); + + const unreadCount = useMemo( + () => allNotifications.filter((n) => !n.is_read).length, + [allNotifications] + ); + + if (isLoading) { + return ( +
+

Notifications

+
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ ); + } + + return ( +
+
+
+ +

Notifications

+ {unreadCount > 0 && ( + + {unreadCount} + + )} +
+ {unreadCount > 0 && ( + + )} +
+ +
+ + +
+ + {isFetching && !isLoading && ( +
+ + Refreshing... +
+ )} + +
+ {displayedNotifications.length === 0 ? ( +
+ +

+ {filter === "unread" ? "No unread notifications" : "No notifications yet"} +

+
+ ) : ( + displayedNotifications.map((notification) => ( + + )) + )} + {isFetchingNextPage && + Array.from({ length: 3 }).map((_, i) => ( + + ))} + {hasNextPage &&
} + {!hasNextPage && displayedNotifications.length > 0 && ( +

+ All caught up +

+ )} +
+
+ ); +} diff --git a/components/layout/Navbar.tsx b/components/layout/Navbar.tsx index 2fa04ac..98c758c 100644 --- a/components/layout/Navbar.tsx +++ b/components/layout/Navbar.tsx @@ -1,14 +1,16 @@ "use client"; import Link from "next/link"; -import { Wallet } from "lucide-react"; +import { Bell, Wallet } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useStellarWallet } from "@/hooks/useStellarWallet"; import { WalletChip } from "@/components/wallet/WalletChip"; +import { useUnreadCount } from "@/hooks/useNotifications"; export function Navbar() { const { address, network, isConnected, isConnecting, connect, disconnect, refreshNetwork } = useStellarWallet(); + const { data: unreadCount } = useUnreadCount(); return (
@@ -21,6 +23,16 @@ export function Navbar() { Marketplace + + + {isConnected ? ( ({ + queryKey: NOTIFICATIONS_QUERY_KEY, + queryFn: ({ pageParam }) => fetchNotifications(pageParam as string | undefined), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => + lastPage.has_more ? lastPage.next_cursor ?? undefined : undefined, + staleTime: 60 * 1000, + }); +} + +export function useUnreadCount() { + return useQuery({ + queryKey: ["notifications", "unread-count"], + queryFn: () => fetchNotifications(), + select: (data) => data.unread_count, + staleTime: 30 * 1000, + refetchInterval: 60 * 1000, + }); +} + +export function useMarkAllAsRead() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: markAllNotificationsAsRead, + + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: NOTIFICATIONS_QUERY_KEY }); + await queryClient.cancelQueries({ queryKey: ["notifications", "unread-count"] }); + + const previousPages = queryClient.getQueryData<{ + pages: NotificationsResponse[]; + pageParams: unknown[]; + }>(NOTIFICATIONS_QUERY_KEY); + + if (previousPages) { + queryClient.setQueryData(NOTIFICATIONS_QUERY_KEY, { + ...previousPages, + pages: previousPages.pages.map((page) => ({ + ...page, + notifications: page.notifications.map((n) => ({ ...n, is_read: true })), + unread_count: 0, + })), + }); + } + + queryClient.setQueryData(["notifications", "unread-count"], 0); + + return { previousPages }; + }, + + onError: (_err, _vars, context) => { + if (context?.previousPages) { + queryClient.setQueryData(NOTIFICATIONS_QUERY_KEY, context.previousPages); + } + toast.error("Failed to mark notifications as read."); + }, + + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["notifications"] }); + }, + }); +} diff --git a/lib/api/index.ts b/lib/api/index.ts index 7516655..c4e5ddc 100644 --- a/lib/api/index.ts +++ b/lib/api/index.ts @@ -89,3 +89,37 @@ export async function updateNotificationPreference( if (!res.ok) throw new Error("Failed to update notification preference"); return res.json(); } + +export interface NotificationItem { + id: string; + type: string; + title: string; + message: string; + is_read: boolean; + created_at: string; +} + +export interface NotificationsResponse { + notifications: NotificationItem[]; + has_more: boolean; + next_cursor: string | null; + unread_count: number; +} + +export async function fetchNotifications( + cursor?: string +): Promise { + const params = new URLSearchParams(); + if (cursor) params.set("cursor", cursor); + const res = await fetch(`${API_BASE}/notifications?${params}`); + if (!res.ok) throw new Error("Failed to fetch notifications"); + return res.json(); +} + +export async function markAllNotificationsAsRead(): Promise<{ success: boolean }> { + const res = await fetch(`${API_BASE}/notifications/read-all`, { + method: "POST", + }); + if (!res.ok) throw new Error("Failed to mark all notifications as read"); + return res.json(); +}