Skip to content

Commit 0ac15b4

Browse files
committed
[Dashboard] Add notifications system with unread tracking
1 parent b3e42f7 commit 0ac15b4

File tree

16 files changed

+840
-475
lines changed

16 files changed

+840
-475
lines changed
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
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+
}
158+
159+
// --------------------
160+
// Changelog API (Notification-like)
161+
// --------------------
162+
163+
export type ProductUpdateItem = {
164+
published_at: string;
165+
title: string;
166+
id: string;
167+
url: string;
168+
};
169+
170+
export async function fetchProductUpdates() {
171+
const res = await fetch(
172+
"https://thirdweb.ghost.io/ghost/api/content/posts/?key=49c62b5137df1c17ab6b9e46e3&fields=id,title,url,published_at&filter=tag:update&visibility:public&limit=20",
173+
{
174+
next: {
175+
revalidate: 60 * 15, // 15min
176+
},
177+
},
178+
);
179+
if (!res.ok) {
180+
return {
181+
status: "error",
182+
reason: "unknown",
183+
body: await res.text(),
184+
} as const;
185+
}
186+
const json = await res.json();
187+
return {
188+
status: "success",
189+
data: json.posts as ProductUpdateItem[],
190+
} as const;
191+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"use client";
2+
3+
import { Button } from "@/components/ui/button";
4+
import {
5+
Popover,
6+
PopoverContent,
7+
PopoverTrigger,
8+
} from "@/components/ui/popover";
9+
10+
import { BellIcon } from "lucide-react";
11+
import { NotificationList } from "./notification-list";
12+
import { useNotifications } from "./state/manager";
13+
14+
export function NotificationsButton(props: { accountId: string }) {
15+
const manager = useNotifications(props.accountId);
16+
17+
return (
18+
<Popover>
19+
<PopoverTrigger asChild>
20+
<Button variant="outline" size="icon" className="relative rounded-full">
21+
<BellIcon className="h-4 w-4" />
22+
{/* kinda jank but it works: always take the last page and check the unread count of IT */}
23+
{(manager.totalUnreadCount || 0) > 0 && (
24+
<span className="absolute top-0 right-0 flex h-2 w-2">
25+
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75" />
26+
<span className="relative inline-flex h-2 w-2 rounded-full bg-primary" />
27+
</span>
28+
)}
29+
</Button>
30+
</PopoverTrigger>
31+
<PopoverContent className="w-[448px] max-w-md p-0" align="end">
32+
<NotificationList {...manager} />
33+
</PopoverContent>
34+
</Popover>
35+
);
36+
}
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 { Trash2Icon } 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+
<Trash2Icon className="h-4 w-4" />
77+
</Button>
78+
)}
79+
</div>
80+
</div>
81+
);
82+
}

0 commit comments

Comments
 (0)