Skip to content

Commit e742929

Browse files
committed
[Dashboard] Add notifications system with unread tracking (#7302)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced a new notifications system with a bell icon button displaying unread indicators. - Added a notifications panel with Inbox and Archive tabs supporting infinite scrolling and marking notifications as read. - Implemented a centralized notification state manager with optimized fetching, caching, and mutation handling. - Added a UI drawer component for mobile notification display with smooth open/close interactions. - **Refactor** - Replaced legacy notification button and fetching logic with the new unified Notifications button using only account ID. - Removed legacy notification callbacks and components from header and navigation for simplified notification handling. - **Chores** - Removed deprecated notification files and Storybook stories. - Updated dependencies by replacing "idb-keyval" with "vaul" for UI drawer functionality. <!-- end of auto-generated comment: release notes by coderabbit.ai --> <!-- start pr-codex --> --- ## PR-Codex overview This PR focuses on removing the `NotificationButton` component and its related files, replacing it with a new `NotificationsButton` component that integrates notifications more effectively within the application. ### Detailed summary - Deleted `NotificationButton.tsx`, `fetch-notifications.ts`, and `NotificationButton.stories.tsx`. - Added `NotificationsButton` component with enhanced functionality for displaying notifications. - Updated various components (`AccountHeaderUI`, `SecondaryNav`, `TeamHeaderUI`) to use `NotificationsButton`. - Removed references to `getInboxNotifications` and `markNotificationAsRead` from multiple components. - Introduced new notification management logic and UI components in `notification-list.tsx` and `notification-entry.tsx`. - Updated dependencies in `package.json` to include the new `vaul` library for UI elements. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` <!-- end pr-codex -->
1 parent dacfd94 commit e742929

File tree

18 files changed

+876
-467
lines changed

18 files changed

+876
-467
lines changed

apps/dashboard/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,6 @@
6666
"flat": "^6.0.1",
6767
"framer-motion": "12.9.2",
6868
"fuse.js": "7.1.0",
69-
"idb-keyval": "^6.2.1",
7069
"input-otp": "^1.4.1",
7170
"ioredis": "^5.6.1",
7271
"ipaddr.js": "^2.2.0",
@@ -105,6 +104,7 @@
105104
"thirdweb": "workspace:*",
106105
"tiny-invariant": "^1.3.3",
107106
"use-debounce": "^10.0.4",
107+
"vaul": "^1.1.2",
108108
"zod": "3.25.24"
109109
},
110110
"devDependencies": {
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"use server";
2+
3+
import "server-only";
4+
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
5+
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "../constants/public-envs";
6+
7+
export type Notification = {
8+
id: string;
9+
createdAt: string;
10+
accountId: string;
11+
teamId: string | null;
12+
description: string;
13+
readAt: string | null;
14+
ctaText: string;
15+
ctaUrl: string;
16+
};
17+
18+
export type NotificationsApiResponse = {
19+
result: Notification[];
20+
nextCursor?: string;
21+
};
22+
23+
export async function getUnreadNotifications(cursor?: string) {
24+
const authToken = await getAuthToken();
25+
if (!authToken) {
26+
throw new Error("No auth token found");
27+
}
28+
const url = new URL(
29+
"/v1/dashboard-notifications/unread",
30+
NEXT_PUBLIC_THIRDWEB_API_HOST,
31+
);
32+
if (cursor) {
33+
url.searchParams.set("cursor", cursor);
34+
}
35+
36+
const response = await fetch(url, {
37+
headers: {
38+
Authorization: `Bearer ${authToken}`,
39+
},
40+
});
41+
if (!response.ok) {
42+
const body = await response.text();
43+
return {
44+
status: "error",
45+
reason: "unknown",
46+
body,
47+
} as const;
48+
}
49+
50+
const data = (await response.json()) as NotificationsApiResponse;
51+
52+
return {
53+
status: "success",
54+
data,
55+
} as const;
56+
}
57+
58+
export async function getArchivedNotifications(cursor?: string) {
59+
const authToken = await getAuthToken();
60+
if (!authToken) {
61+
throw new Error("No auth token found");
62+
}
63+
64+
const url = new URL(
65+
"/v1/dashboard-notifications/archived",
66+
NEXT_PUBLIC_THIRDWEB_API_HOST,
67+
);
68+
if (cursor) {
69+
url.searchParams.set("cursor", cursor);
70+
}
71+
72+
const response = await fetch(url, {
73+
headers: {
74+
Authorization: `Bearer ${authToken}`,
75+
},
76+
});
77+
if (!response.ok) {
78+
const body = await response.text();
79+
return {
80+
status: "error",
81+
reason: "unknown",
82+
body,
83+
} as const;
84+
}
85+
86+
const data = (await response.json()) as NotificationsApiResponse;
87+
88+
return {
89+
status: "success",
90+
data,
91+
} as const;
92+
}
93+
94+
export async function getUnreadNotificationsCount() {
95+
const authToken = await getAuthToken();
96+
if (!authToken) {
97+
throw new Error("No auth token found");
98+
}
99+
100+
const url = new URL(
101+
"/v1/dashboard-notifications/unread-count",
102+
NEXT_PUBLIC_THIRDWEB_API_HOST,
103+
);
104+
const response = await fetch(url, {
105+
headers: {
106+
Authorization: `Bearer ${authToken}`,
107+
},
108+
});
109+
if (!response.ok) {
110+
const body = await response.text();
111+
return {
112+
status: "error",
113+
reason: "unknown",
114+
body,
115+
} as const;
116+
}
117+
const data = (await response.json()) as {
118+
result: {
119+
unreadCount: number;
120+
};
121+
};
122+
return {
123+
status: "success",
124+
data,
125+
} as const;
126+
}
127+
128+
export async function markNotificationAsRead(notificationId?: string) {
129+
const authToken = await getAuthToken();
130+
if (!authToken) {
131+
throw new Error("No auth token found");
132+
}
133+
const url = new URL(
134+
"/v1/dashboard-notifications/mark-as-read",
135+
NEXT_PUBLIC_THIRDWEB_API_HOST,
136+
);
137+
const response = await fetch(url, {
138+
method: "PUT",
139+
headers: {
140+
Authorization: `Bearer ${authToken}`,
141+
"Content-Type": "application/json",
142+
},
143+
// if notificationId is provided, mark it as read, otherwise mark all as read
144+
body: JSON.stringify(notificationId ? { notificationId } : {}),
145+
});
146+
if (!response.ok) {
147+
const body = await response.text();
148+
return {
149+
status: "error",
150+
reason: "unknown",
151+
body,
152+
} as const;
153+
}
154+
return {
155+
status: "success",
156+
} as const;
157+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"use client";
2+
3+
import { Button } from "@/components/ui/button";
4+
import {
5+
Drawer,
6+
DrawerContent,
7+
DrawerTitle,
8+
DrawerTrigger,
9+
} from "@/components/ui/drawer";
10+
import {
11+
Popover,
12+
PopoverContent,
13+
PopoverTrigger,
14+
} from "@/components/ui/popover";
15+
import { useIsMobile } from "@/hooks/use-mobile";
16+
import { BellIcon } from "lucide-react";
17+
import { useMemo, useState } from "react";
18+
import { NotificationList } from "./notification-list";
19+
import { useNotifications } from "./state/manager";
20+
21+
export function NotificationsButton(props: { accountId: string }) {
22+
const manager = useNotifications(props.accountId);
23+
const [open, setOpen] = useState(false);
24+
25+
const isMobile = useIsMobile();
26+
27+
const trigger = useMemo(
28+
() => (
29+
<Button variant="outline" size="icon" className="relative rounded-full">
30+
<BellIcon className="h-4 w-4" />
31+
{(manager.unreadNotificationsCount || 0) > 0 && (
32+
<span className="absolute top-0 right-0 flex h-2 w-2">
33+
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75" />
34+
<span className="relative inline-flex h-2 w-2 rounded-full bg-primary" />
35+
</span>
36+
)}
37+
</Button>
38+
),
39+
[manager.unreadNotificationsCount],
40+
);
41+
42+
if (isMobile) {
43+
return (
44+
<Drawer open={open} onOpenChange={setOpen}>
45+
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
46+
<DrawerContent className="max-h-[90vh] min-h-[66vh]">
47+
<DrawerTitle className="sr-only">Notifications</DrawerTitle>
48+
<NotificationList {...manager} />
49+
</DrawerContent>
50+
</Drawer>
51+
);
52+
}
53+
54+
return (
55+
<Popover open={open} onOpenChange={setOpen}>
56+
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
57+
<PopoverContent
58+
className="max-h-[90vh] min-h-[500px] w-[400px] max-w-md p-0"
59+
align="end"
60+
>
61+
<NotificationList {...manager} />
62+
</PopoverContent>
63+
</Popover>
64+
);
65+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"use client";
2+
3+
import type { Notification } from "@/api/notifications";
4+
import { Button } from "@/components/ui/button";
5+
import {
6+
format,
7+
formatDistanceToNow,
8+
isBefore,
9+
parseISO,
10+
subDays,
11+
} from "date-fns";
12+
import { ArchiveIcon } from "lucide-react";
13+
import { useMemo } from "react";
14+
15+
interface NotificationEntryProps {
16+
notification: Notification;
17+
onMarkAsRead?: (id: string) => void;
18+
}
19+
20+
export function NotificationEntry({
21+
notification,
22+
onMarkAsRead,
23+
}: NotificationEntryProps) {
24+
const timeAgo = useMemo(() => {
25+
try {
26+
const now = new Date();
27+
const date = parseISO(notification.createdAt);
28+
// if the date is older than 1 day, show the date
29+
// otherwise, show the time ago
30+
31+
if (isBefore(date, subDays(now, 1))) {
32+
return format(date, "MMM d, yyyy");
33+
}
34+
35+
return formatDistanceToNow(date, {
36+
addSuffix: true,
37+
});
38+
} catch (error) {
39+
console.error("Failed to parse date", error);
40+
return null;
41+
}
42+
}, [notification.createdAt]);
43+
44+
return (
45+
<div className="flex flex-row py-1.5">
46+
{onMarkAsRead && (
47+
<div className="min-h-full w-1 shrink-0 rounded-r-lg bg-primary" />
48+
)}
49+
<div className="flex w-full flex-row justify-between gap-2 border-b px-4 py-2 transition-colors last:border-b-0">
50+
<div className="flex items-start gap-3">
51+
<div className="flex-1 space-y-1">
52+
<p className="text-sm">{notification.description}</p>
53+
{timeAgo && (
54+
<p className="text-muted-foreground text-xs">{timeAgo}</p>
55+
)}
56+
<div className="flex flex-row justify-between gap-2 pt-1">
57+
<Button asChild variant="link" size="sm" className="px-0">
58+
<a
59+
href={notification.ctaUrl}
60+
target="_blank"
61+
rel="noopener noreferrer"
62+
>
63+
{notification.ctaText}
64+
</a>
65+
</Button>
66+
</div>
67+
</div>
68+
</div>
69+
{onMarkAsRead && (
70+
<Button
71+
variant="ghost"
72+
size="icon"
73+
onClick={() => onMarkAsRead(notification.id)}
74+
className="text-muted-foreground hover:text-foreground"
75+
>
76+
<ArchiveIcon className="h-4 w-4" />
77+
</Button>
78+
)}
79+
</div>
80+
</div>
81+
);
82+
}

0 commit comments

Comments
 (0)