Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
245 changes: 245 additions & 0 deletions app/notifications/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Card
className={cn(
"transition-colors",
!notification.is_read && "border-l-2 border-l-primary bg-accent/30"
)}
>
<CardContent className="flex items-start gap-3 p-4">
<div className="mt-0.5 shrink-0">
{notification.is_read ? (
<MailOpen className="size-4 text-muted-foreground" />
) : (
<Mail className="size-4 text-primary" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{notification.title}</p>
<p className="text-sm text-muted-foreground mt-0.5">
{notification.message}
</p>
<p className="text-xs text-muted-foreground/70 mt-1">
{formatDistanceToNow(new Date(notification.created_at), { addSuffix: true })}
</p>
</div>
</CardContent>
</Card>
);
}

function SkeletonRow() {
return (
<Card>
<CardContent className="flex items-start gap-3 p-4">
<Skeleton className="size-4 rounded" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-20" />
</div>
</CardContent>
</Card>
);
}

export default function NotificationsPage() {
const [filter, setFilter] = useState<FilterTab>("all");

const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
isFetching,
} = useInfiniteQuery<NotificationsResponse>({
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<HTMLDivElement>(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<HTMLDivElement | null>).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 (
<main className="container mx-auto max-w-2xl px-4 py-8">
<h1 className="text-2xl font-bold mb-6">Notifications</h1>
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<SkeletonRow key={i} />
))}
</div>
</main>
);
}

return (
<main className="container mx-auto max-w-2xl px-4 py-8">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Bell className="size-6" />
<h1 className="text-2xl font-bold">Notifications</h1>
{unreadCount > 0 && (
<span className="rounded-full bg-primary px-2 py-0.5 text-xs font-medium text-primary-foreground">
{unreadCount}
</span>
)}
</div>
{unreadCount > 0 && (
<Button
variant="outline"
size="sm"
onClick={handleMarkAllAsRead}
disabled={markingAll}
>
{markingAll ? (
<Loader2 className="size-3 animate-spin" />
) : (
<CheckCheck className="size-3" />
)}
Mark all as read
</Button>
)}
</div>

<div className="flex gap-1 mb-6 border-b">
<button
onClick={() => setFilter("all")}
className={cn(
"px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px",
filter === "all"
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
All
</button>
<button
onClick={() => setFilter("unread")}
className={cn(
"px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px",
filter === "unread"
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
Unread
{unreadCount > 0 && (
<span className="ml-2 rounded-full bg-primary/10 px-1.5 py-0.5 text-xs text-primary">
{unreadCount}
</span>
)}
</button>
</div>

{isFetching && !isLoading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-4">
<Loader2 className="size-3 animate-spin" />
Refreshing...
</div>
)}

<div className="space-y-2">
{displayedNotifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Bell className="size-8 mb-2" />
<p className="text-sm">
{filter === "unread" ? "No unread notifications" : "No notifications yet"}
</p>
</div>
) : (
displayedNotifications.map((notification) => (
<NotificationRow key={notification.id} notification={notification} />
))
)}
{isFetchingNextPage &&
Array.from({ length: 3 }).map((_, i) => (
<SkeletonRow key={`skeleton-${i}`} />
))}
{hasNextPage && <div ref={sentinelRefCallback} className="h-4" />}
{!hasNextPage && displayedNotifications.length > 0 && (
<p className="text-center text-sm text-muted-foreground py-4">
All caught up
</p>
)}
</div>
</main>
);
}
14 changes: 13 additions & 1 deletion components/layout/Navbar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<header className="sticky top-0 z-50 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
Expand All @@ -21,6 +23,16 @@ export function Navbar() {
<Link href="/marketplace" className="text-sm text-muted-foreground hover:text-foreground">
Marketplace
</Link>
<Link href="/notifications" className="relative">
<Button variant="ghost" size="icon" className="cursor-default">
<Bell className="size-4" />
{unreadCount !== undefined && unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-medium text-destructive-foreground">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</Button>
</Link>
{isConnected ? (
<WalletChip
address={address!}
Expand Down
76 changes: 76 additions & 0 deletions hooks/useNotifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"use client";

import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
fetchNotifications,
markAllNotificationsAsRead,
type NotificationsResponse,
} from "@/lib/api";

export const NOTIFICATIONS_QUERY_KEY = ["notifications"] as const;

export function useNotifications() {
return useInfiniteQuery<NotificationsResponse>({
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"] });
},
});
}
34 changes: 34 additions & 0 deletions lib/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NotificationsResponse> {
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();
}