diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json
index 52841eaa8ee..c93afc6a3f5 100644
--- a/apps/dashboard/package.json
+++ b/apps/dashboard/package.json
@@ -66,7 +66,6 @@
"flat": "^6.0.1",
"framer-motion": "12.9.2",
"fuse.js": "7.1.0",
- "idb-keyval": "^6.2.1",
"input-otp": "^1.4.1",
"ioredis": "^5.6.1",
"ipaddr.js": "^2.2.0",
@@ -105,6 +104,7 @@
"thirdweb": "workspace:*",
"tiny-invariant": "^1.3.3",
"use-debounce": "^10.0.4",
+ "vaul": "^1.1.2",
"zod": "3.25.24"
},
"devDependencies": {
diff --git a/apps/dashboard/src/@/api/notifications.ts b/apps/dashboard/src/@/api/notifications.ts
new file mode 100644
index 00000000000..1c62cb9046c
--- /dev/null
+++ b/apps/dashboard/src/@/api/notifications.ts
@@ -0,0 +1,157 @@
+"use server";
+
+import "server-only";
+import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
+import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "../constants/public-envs";
+
+export type Notification = {
+ id: string;
+ createdAt: string;
+ accountId: string;
+ teamId: string | null;
+ description: string;
+ readAt: string | null;
+ ctaText: string;
+ ctaUrl: string;
+};
+
+export type NotificationsApiResponse = {
+ result: Notification[];
+ nextCursor?: string;
+};
+
+export async function getUnreadNotifications(cursor?: string) {
+ const authToken = await getAuthToken();
+ if (!authToken) {
+ throw new Error("No auth token found");
+ }
+ const url = new URL(
+ "/v1/dashboard-notifications/unread",
+ NEXT_PUBLIC_THIRDWEB_API_HOST,
+ );
+ if (cursor) {
+ url.searchParams.set("cursor", cursor);
+ }
+
+ const response = await fetch(url, {
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ },
+ });
+ if (!response.ok) {
+ const body = await response.text();
+ return {
+ status: "error",
+ reason: "unknown",
+ body,
+ } as const;
+ }
+
+ const data = (await response.json()) as NotificationsApiResponse;
+
+ return {
+ status: "success",
+ data,
+ } as const;
+}
+
+export async function getArchivedNotifications(cursor?: string) {
+ const authToken = await getAuthToken();
+ if (!authToken) {
+ throw new Error("No auth token found");
+ }
+
+ const url = new URL(
+ "/v1/dashboard-notifications/archived",
+ NEXT_PUBLIC_THIRDWEB_API_HOST,
+ );
+ if (cursor) {
+ url.searchParams.set("cursor", cursor);
+ }
+
+ const response = await fetch(url, {
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ },
+ });
+ if (!response.ok) {
+ const body = await response.text();
+ return {
+ status: "error",
+ reason: "unknown",
+ body,
+ } as const;
+ }
+
+ const data = (await response.json()) as NotificationsApiResponse;
+
+ return {
+ status: "success",
+ data,
+ } as const;
+}
+
+export async function getUnreadNotificationsCount() {
+ const authToken = await getAuthToken();
+ if (!authToken) {
+ throw new Error("No auth token found");
+ }
+
+ const url = new URL(
+ "/v1/dashboard-notifications/unread-count",
+ NEXT_PUBLIC_THIRDWEB_API_HOST,
+ );
+ const response = await fetch(url, {
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ },
+ });
+ if (!response.ok) {
+ const body = await response.text();
+ return {
+ status: "error",
+ reason: "unknown",
+ body,
+ } as const;
+ }
+ const data = (await response.json()) as {
+ result: {
+ unreadCount: number;
+ };
+ };
+ return {
+ status: "success",
+ data,
+ } as const;
+}
+
+export async function markNotificationAsRead(notificationId?: string) {
+ const authToken = await getAuthToken();
+ if (!authToken) {
+ throw new Error("No auth token found");
+ }
+ const url = new URL(
+ "/v1/dashboard-notifications/mark-as-read",
+ NEXT_PUBLIC_THIRDWEB_API_HOST,
+ );
+ const response = await fetch(url, {
+ method: "PUT",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ // if notificationId is provided, mark it as read, otherwise mark all as read
+ body: JSON.stringify(notificationId ? { notificationId } : {}),
+ });
+ if (!response.ok) {
+ const body = await response.text();
+ return {
+ status: "error",
+ reason: "unknown",
+ body,
+ } as const;
+ }
+ return {
+ status: "success",
+ } as const;
+}
diff --git a/apps/dashboard/src/@/components/blocks/notifications/notification-button.tsx b/apps/dashboard/src/@/components/blocks/notifications/notification-button.tsx
new file mode 100644
index 00000000000..f7ab81f24fc
--- /dev/null
+++ b/apps/dashboard/src/@/components/blocks/notifications/notification-button.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ Drawer,
+ DrawerContent,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { useIsMobile } from "@/hooks/use-mobile";
+import { BellIcon } from "lucide-react";
+import { useMemo, useState } from "react";
+import { NotificationList } from "./notification-list";
+import { useNotifications } from "./state/manager";
+
+export function NotificationsButton(props: { accountId: string }) {
+ const manager = useNotifications(props.accountId);
+ const [open, setOpen] = useState(false);
+
+ const isMobile = useIsMobile();
+
+ const trigger = useMemo(
+ () => (
+
+ ),
+ [manager.unreadNotificationsCount],
+ );
+
+ if (isMobile) {
+ return (
+
+ {trigger}
+
+ Notifications
+
+
+
+ );
+ }
+
+ return (
+
+ {trigger}
+
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/@/components/blocks/notifications/notification-entry.tsx b/apps/dashboard/src/@/components/blocks/notifications/notification-entry.tsx
new file mode 100644
index 00000000000..d13bbc730d5
--- /dev/null
+++ b/apps/dashboard/src/@/components/blocks/notifications/notification-entry.tsx
@@ -0,0 +1,82 @@
+"use client";
+
+import type { Notification } from "@/api/notifications";
+import { Button } from "@/components/ui/button";
+import {
+ format,
+ formatDistanceToNow,
+ isBefore,
+ parseISO,
+ subDays,
+} from "date-fns";
+import { ArchiveIcon } from "lucide-react";
+import { useMemo } from "react";
+
+interface NotificationEntryProps {
+ notification: Notification;
+ onMarkAsRead?: (id: string) => void;
+}
+
+export function NotificationEntry({
+ notification,
+ onMarkAsRead,
+}: NotificationEntryProps) {
+ const timeAgo = useMemo(() => {
+ try {
+ const now = new Date();
+ const date = parseISO(notification.createdAt);
+ // if the date is older than 1 day, show the date
+ // otherwise, show the time ago
+
+ if (isBefore(date, subDays(now, 1))) {
+ return format(date, "MMM d, yyyy");
+ }
+
+ return formatDistanceToNow(date, {
+ addSuffix: true,
+ });
+ } catch (error) {
+ console.error("Failed to parse date", error);
+ return null;
+ }
+ }, [notification.createdAt]);
+
+ return (
+
+ {onMarkAsRead && (
+
+ )}
+
+
+
+
{notification.description}
+ {timeAgo && (
+
{timeAgo}
+ )}
+
+
+
+ {onMarkAsRead && (
+
+ )}
+
+
+ );
+}
diff --git a/apps/dashboard/src/@/components/blocks/notifications/notification-list.tsx b/apps/dashboard/src/@/components/blocks/notifications/notification-list.tsx
new file mode 100644
index 00000000000..476c985f172
--- /dev/null
+++ b/apps/dashboard/src/@/components/blocks/notifications/notification-list.tsx
@@ -0,0 +1,202 @@
+"use client";
+import { TabButtons } from "@/components/ui/tabs";
+import { ArchiveIcon, BellIcon, Loader2Icon } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+import { Badge } from "../../ui/badge";
+import { NotificationEntry } from "./notification-entry";
+import type { useNotifications } from "./state/manager";
+
+export function NotificationList(props: ReturnType) {
+ // default to inbox if there are unread notifications, otherwise default to archive
+ const [activeTab, setActiveTab] = useState(
+ props.unreadNotificationsCount > 0
+ ? "inbox"
+ : // if we have archived notifications, default to archive
+ props.archivedNotifications.length > 0
+ ? "archive"
+ : // otherwise defualt to inbox (if there are no archived notifications either)
+ "inbox",
+ );
+
+ const scrollContainerRef = useRef(null);
+
+ return (
+
+
+ Inbox
+ {props.unreadNotificationsCount > 0 && (
+
+ {props.unreadNotificationsCount}
+
+ )}
+
+ ),
+ onClick: () => setActiveTab("inbox"),
+ isActive: activeTab === "inbox",
+ },
+ {
+ name: "Archive",
+ onClick: () => setActiveTab("archive"),
+ isActive: activeTab === "archive",
+ },
+ ]}
+ />
+
+
+ {activeTab === "inbox" ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+function InboxTab(
+ props: Pick<
+ ReturnType,
+ | "unreadNotifications"
+ | "isLoadingUnread"
+ | "hasMoreUnread"
+ | "isFetchingMoreUnread"
+ | "loadMoreUnread"
+ | "markAsRead"
+ | "markAllAsRead"
+ > & {
+ scrollContainerRef: React.RefObject;
+ },
+) {
+ return props.unreadNotifications.length === 0 ? (
+
+
+
+
+
No new notifications
+
+ ) : (
+ <>
+ {props.unreadNotifications.map((notification) => (
+
+ ))}
+
+ >
+ );
+}
+
+function ArchiveTab(
+ props: Pick<
+ ReturnType,
+ | "archivedNotifications"
+ | "isLoadingArchived"
+ | "hasMoreArchived"
+ | "isFetchingMoreArchived"
+ | "loadMoreArchived"
+ > & {
+ scrollContainerRef: React.RefObject;
+ },
+) {
+ return props.archivedNotifications.length === 0 ? (
+
+
+
No archived notifications
+
+ ) : (
+ <>
+ {props.archivedNotifications.map((notification) => (
+
+ ))}
+
+ >
+ );
+}
+
+function AutoLoadMore(props: {
+ hasMore: boolean;
+ isLoading: boolean;
+ loadMore: () => void;
+ scrollContainerRef: React.RefObject;
+}) {
+ const ref = useRef(null);
+
+ // if the element is scrolled into view, load more
+ // eslint-disable-next-line no-restricted-syntax
+ useEffect(() => {
+ // only run if the ref and scroll container ref are defined
+ if (ref.current && props.scrollContainerRef.current) {
+ const observer = new IntersectionObserver(
+ (entries) => {
+ for (const entry of entries) {
+ if (entry.isIntersecting && !props.isLoading && props.hasMore) {
+ props.loadMore();
+ observer.unobserve(entry.target); // prevent duplicate fires
+ }
+ }
+ },
+ { root: props.scrollContainerRef.current, threshold: 0.1 },
+ );
+
+ observer.observe(ref.current);
+
+ return () => observer.disconnect();
+ }
+ }, [
+ props.hasMore,
+ props.isLoading,
+ props.loadMore,
+ props.scrollContainerRef,
+ ]);
+
+ if (!props.hasMore) return null;
+
+ if (props.isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return ;
+}
diff --git a/apps/dashboard/src/@/components/blocks/notifications/state/manager.ts b/apps/dashboard/src/@/components/blocks/notifications/state/manager.ts
new file mode 100644
index 00000000000..0def2459717
--- /dev/null
+++ b/apps/dashboard/src/@/components/blocks/notifications/state/manager.ts
@@ -0,0 +1,223 @@
+"use client";
+
+import {
+ type Notification,
+ type NotificationsApiResponse,
+ getArchivedNotifications,
+ getUnreadNotifications,
+ getUnreadNotificationsCount,
+ markNotificationAsRead,
+} from "@/api/notifications";
+import {
+ type InfiniteData,
+ useInfiniteQuery,
+ useMutation,
+ useQuery,
+ useQueryClient,
+} from "@tanstack/react-query";
+
+import { useMemo } from "react";
+import { toast } from "sonner";
+
+/**
+ * Internal helper to safely flatten pages coming from useInfiniteQuery.
+ */
+function flattenPages(
+ data?: InfiniteData,
+): Notification[] {
+ return data?.pages.flatMap((p) => p.result) ?? [];
+}
+
+/**
+ * React hook that provides notifications state with optimistic archiving.
+ *
+ * Example:
+ * ```tsx
+ * const {
+ * unreadNotifications,
+ * archivedNotifications,
+ * unreadCount,
+ * markAsRead,
+ * markAllAsRead,
+ * loadMoreUnread,
+ * loadMoreArchived,
+ * hasMoreUnread,
+ * hasMoreArchived,
+ * } = useNotifications();
+ * ```
+ */
+
+export function useNotifications(accountId: string) {
+ const queryClient = useQueryClient();
+
+ // --------------------
+ // Query definitions
+ // --------------------
+
+ const unreadQueryKey = useMemo(
+ () => ["notifications", "unread", { accountId }],
+ [accountId],
+ );
+ const archivedQueryKey = useMemo(
+ () => ["notifications", "archived", { accountId }],
+ [accountId],
+ );
+ const unreadCountKey = useMemo(
+ () => ["notifications", "unread-count", { accountId }],
+ [accountId],
+ );
+
+ const unreadQuery = useInfiniteQuery({
+ queryKey: unreadQueryKey,
+ queryFn: async ({ pageParam }) => {
+ const cursor = (pageParam ?? undefined) as string | undefined;
+ const res = await getUnreadNotifications(cursor);
+ if (res.status === "error") {
+ throw new Error(res.reason ?? "unknown");
+ }
+ return res.data;
+ },
+ initialPageParam: undefined as string | undefined,
+ getNextPageParam: (lastPage) => lastPage?.nextCursor ?? undefined,
+ enabled: !!accountId,
+ refetchInterval: 60_000, // 1min
+ });
+
+ const archivedQuery = useInfiniteQuery({
+ queryKey: archivedQueryKey,
+ queryFn: async ({ pageParam }) => {
+ const cursor = (pageParam ?? undefined) as string | undefined;
+ const res = await getArchivedNotifications(cursor);
+ if (res.status === "error") {
+ throw new Error(res.reason ?? "unknown");
+ }
+ return res.data;
+ },
+ initialPageParam: undefined as string | undefined,
+ getNextPageParam: (lastPage) => lastPage?.nextCursor ?? undefined,
+ enabled: !!accountId,
+ refetchInterval: 60_000, // 1min
+ });
+
+ const unreadCountQuery = useQuery({
+ queryKey: unreadCountKey,
+ queryFn: async () => {
+ const res = await getUnreadNotificationsCount();
+ if (res.status === "error") {
+ throw new Error(res.reason ?? "unknown");
+ }
+ return res.data.result.unreadCount;
+ },
+ refetchInterval: 60_000, // 1min
+ enabled: !!accountId,
+ });
+
+ // --------------------
+ // Mutation (archive)
+ // --------------------
+ const archiveMutation = useMutation({
+ mutationFn: async (notificationId?: string) => {
+ const res = await markNotificationAsRead(notificationId);
+ if (res.status === "error") {
+ toast.error("Failed to mark notification as read");
+ throw new Error(res.reason ?? "unknown");
+ }
+ return { notificationId } as const;
+ },
+ // Optimistic update
+ onMutate: async (notificationId) => {
+ await Promise.all([
+ queryClient.cancelQueries({ queryKey: unreadQueryKey }),
+ queryClient.cancelQueries({ queryKey: archivedQueryKey }),
+ ]);
+
+ const previousUnread =
+ queryClient.getQueryData>(
+ unreadQueryKey,
+ );
+
+ const previousCount = queryClient.getQueryData(unreadCountKey);
+
+ let optimisticUnread: InfiniteData | undefined;
+ let optimisticCount: number | undefined;
+
+ if (previousUnread) {
+ if (notificationId) {
+ // Remove a single notification
+ optimisticUnread = {
+ ...previousUnread,
+ pages: previousUnread.pages.map((page) => ({
+ ...page,
+ result: page.result.filter((n) => n.id !== notificationId),
+ })),
+ };
+ if (typeof previousCount === "number") {
+ optimisticCount = Math.max(previousCount - 1, 0);
+ }
+ } else {
+ // Clear all unread notifications
+ optimisticUnread = {
+ ...previousUnread,
+ pages: previousUnread.pages.map((page) => ({
+ ...page,
+ result: [],
+ })),
+ };
+ optimisticCount = 0;
+ }
+ queryClient.setQueryData(unreadQueryKey, optimisticUnread);
+ }
+
+ if (typeof optimisticCount === "number") {
+ queryClient.setQueryData(unreadCountKey, optimisticCount);
+ }
+
+ return { previousUnread, previousCount } as const;
+ },
+ // Rollback on error
+ onError: (_err, _vars, context) => {
+ if (context?.previousUnread) {
+ queryClient.setQueryData(unreadQueryKey, context.previousUnread);
+ }
+ if (typeof context?.previousCount === "number") {
+ queryClient.setQueryData(unreadCountKey, context.previousCount);
+ }
+ },
+ // Always refetch to ensure consistency
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: unreadQueryKey });
+ queryClient.invalidateQueries({ queryKey: archivedQueryKey });
+ queryClient.invalidateQueries({ queryKey: unreadCountKey });
+ },
+ });
+
+ // --------------------
+ // Derived helpers
+ // --------------------
+ const unreadNotifications = flattenPages(unreadQuery.data);
+ const archivedNotifications = flattenPages(archivedQuery.data);
+ const unreadNotificationsCount = unreadCountQuery.data ?? 0; // this is the total unread count
+
+ return {
+ // data
+ unreadNotifications,
+ archivedNotifications,
+ unreadNotificationsCount,
+
+ // booleans
+ isLoadingUnread: unreadQuery.isLoading,
+ isLoadingArchived: archivedQuery.isLoading,
+ hasMoreUnread: unreadQuery.hasNextPage ?? false,
+ hasMoreArchived: archivedQuery.hasNextPage ?? false,
+ isFetchingMoreUnread: unreadQuery.isFetchingNextPage,
+ isFetchingMoreArchived: archivedQuery.isFetchingNextPage,
+
+ // pagination helpers
+ loadMoreUnread: () => unreadQuery.fetchNextPage(),
+ loadMoreArchived: () => archivedQuery.fetchNextPage(),
+
+ // mutations
+ markAsRead: (id: string) => archiveMutation.mutate(id),
+ markAllAsRead: () => archiveMutation.mutate(undefined),
+ } as const;
+}
diff --git a/apps/dashboard/src/@/components/ui/drawer.tsx b/apps/dashboard/src/@/components/ui/drawer.tsx
new file mode 100644
index 00000000000..ad6c090e7d1
--- /dev/null
+++ b/apps/dashboard/src/@/components/ui/drawer.tsx
@@ -0,0 +1,118 @@
+"use client";
+
+import * as React from "react";
+import { Drawer as DrawerPrimitive } from "vaul";
+
+import { cn } from "@/lib/utils";
+
+const Drawer = ({
+ shouldScaleBackground = true,
+ ...props
+}: React.ComponentProps) => (
+
+);
+Drawer.displayName = "Drawer";
+
+const DrawerTrigger = DrawerPrimitive.Trigger;
+
+const DrawerPortal = DrawerPrimitive.Portal;
+
+const DrawerClose = DrawerPrimitive.Close;
+
+const DrawerOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
+
+const DrawerContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+ {children}
+
+
+));
+DrawerContent.displayName = "DrawerContent";
+
+const DrawerHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+DrawerHeader.displayName = "DrawerHeader";
+
+const DrawerFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+DrawerFooter.displayName = "DrawerFooter";
+
+const DrawerTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
+
+const DrawerDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
+
+export {
+ Drawer,
+ DrawerPortal,
+ DrawerOverlay,
+ DrawerTrigger,
+ DrawerClose,
+ DrawerContent,
+ DrawerHeader,
+ DrawerFooter,
+ DrawerTitle,
+ DrawerDescription,
+};
diff --git a/apps/dashboard/src/app/(app)/account/components/AccountHeader.tsx b/apps/dashboard/src/app/(app)/account/components/AccountHeader.tsx
index 79f115622f1..a9b0465f5e7 100644
--- a/apps/dashboard/src/app/(app)/account/components/AccountHeader.tsx
+++ b/apps/dashboard/src/app/(app)/account/components/AccountHeader.tsx
@@ -10,10 +10,6 @@ import { useCallback, useState } from "react";
import type { ThirdwebClient } from "thirdweb";
import { useActiveWallet, useDisconnect } from "thirdweb/react";
import { doLogout } from "../../login/auth-actions";
-import {
- getInboxNotifications,
- markNotificationAsRead,
-} from "../../team/components/NotificationButton/fetch-notifications";
import {
type AccountHeaderCompProps,
AccountHeaderDesktopUI,
@@ -60,8 +56,6 @@ export function AccountHeader(props: {
account: props.account,
client: props.client,
accountAddress: props.accountAddress,
- getInboxNotifications: getInboxNotifications,
- markNotificationAsRead: markNotificationAsRead,
};
return (
diff --git a/apps/dashboard/src/app/(app)/account/components/AccountHeaderUI.stories.tsx b/apps/dashboard/src/app/(app)/account/components/AccountHeaderUI.stories.tsx
index 78d8cd00106..3e6bea2daf4 100644
--- a/apps/dashboard/src/app/(app)/account/components/AccountHeaderUI.stories.tsx
+++ b/apps/dashboard/src/app/(app)/account/components/AccountHeaderUI.stories.tsx
@@ -64,8 +64,6 @@ function Variants(props: {
email: "foo@example.com",
}}
client={storybookThirdwebClient}
- getInboxNotifications={() => Promise.resolve([])}
- markNotificationAsRead={() => Promise.resolve()}
/>
diff --git a/apps/dashboard/src/app/(app)/account/components/AccountHeaderUI.tsx b/apps/dashboard/src/app/(app)/account/components/AccountHeaderUI.tsx
index 57dd97a3bf1..9ab986ead91 100644
--- a/apps/dashboard/src/app/(app)/account/components/AccountHeaderUI.tsx
+++ b/apps/dashboard/src/app/(app)/account/components/AccountHeaderUI.tsx
@@ -5,13 +5,10 @@ import { cn } from "@/lib/utils";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import Link from "next/link";
import type { ThirdwebClient } from "thirdweb";
+import { NotificationsButton } from "../../../../@/components/blocks/notifications/notification-button";
import { SecondaryNav } from "../../components/Header/SecondaryNav/SecondaryNav";
import { MobileBurgerMenuButton } from "../../components/MobileBurgerMenuButton";
import { ThirdwebMiniLogo } from "../../components/ThirdwebMiniLogo";
-import {
- NotificationButtonUI,
- type NotificationMetadata,
-} from "../../team/components/NotificationButton/NotificationButton";
import { TeamAndProjectSelectorPopoverButton } from "../../team/components/TeamHeader/TeamAndProjectSelectorPopoverButton";
import { TeamSelectorMobileMenuButton } from "../../team/components/TeamHeader/TeamSelectorMobileMenuButton";
@@ -24,8 +21,6 @@ export type AccountHeaderCompProps = {
account: Pick;
client: ThirdwebClient;
accountAddress: string;
- getInboxNotifications: () => Promise;
- markNotificationAsRead: (id: string) => Promise;
};
export function AccountHeaderDesktopUI(props: AccountHeaderCompProps) {
@@ -77,8 +72,6 @@ export function AccountHeaderDesktopUI(props: AccountHeaderCompProps) {
connectButton={props.connectButton}
client={props.client}
accountAddress={props.accountAddress}
- getInboxNotifications={props.getInboxNotifications}
- markNotificationAsRead={props.markNotificationAsRead}
/>
);
@@ -123,10 +116,7 @@ export function AccountHeaderMobileUI(props: AccountHeaderCompProps) {
-
+
Promise;
- markNotificationAsRead: (id: string) => Promise;
}) {
return (
-
+
(
-
-
-
- ),
- ],
-} satisfies Meta;
-
-export default meta;
-type Story = StoryObj;
-
-const titlesToPickFrom = [
- "Insight API Supports Block Queries",
- "Insight Adds Search Capability",
- "RPC Edge: Faster, Leaner, and Smarter",
- ".NET/Unity - Insight Indexer and Engine Wallet Integration",
- "Invite and Manage Team Members on Dashboard",
- "Insight API ENS Support",
- "Insight Token Queries Just Got 10X Faster",
- "Insight indexer major version upgrade",
- "Introducing Universal Bridge",
- "Unreal Engine Plugin v2",
- "Cross-chain deterministic contract deployments",
- "Organize Contracts into Projects on Dashboard",
- ".NET/Unity - Auth token login, timeout improvements, rotating secret keys.",
- "Mint fees for contract deployments update",
- "Engine v2.1.32: Circle Wallet and Secure Credential Management",
- "Nebula Update v0.0.7: Support for swapping and bridging, Offchain",
- "Insight - Automatic Event & Transaction Resolution",
- "Nebula Update v0.0.6: Upgraded Model, Speed & Accuracy Improvements",
- "Enhancing RPC Edge Support with Support IDs",
- "Blocks data retrieval with Insight [v0.2.2-beta]",
- "Insight reorg handling improvement",
- "Resilient Infrastructure & Smarter Insights",
- "Nebula Update v0.0.5: Advanced configurations, new endpoints, and simplified context filters",
- "Universal Smart Wallet Addresses",
- ".NET/Unity - Nebula Integration for native apps and games!",
-];
-
-function generateRandomNotification(isRead: boolean): NotificationMetadata {
- const index = Math.floor(Math.random() * titlesToPickFrom.length);
- const safeIndex = Math.min(index, titlesToPickFrom.length - 1);
- const title = titlesToPickFrom[safeIndex] || "";
- const randomTimeAgo = Math.floor(Math.random() * 7 * 24 * 60 * 60 * 1000); // Random time within last 7 days
-
- return {
- title,
- href: "https://blog.thirdweb.com/changelog",
- isRead,
- createdAt: new Date(Date.now() - randomTimeAgo).toISOString(),
- id: crypto.randomUUID(),
- };
-}
-function randomNotifications(count: number): NotificationMetadata[] {
- return Array.from({ length: count }, () => generateRandomNotification(false));
-}
-
-export const AllUnread: Story = {
- args: {
- notifications: randomNotifications(10),
- isPending: false,
- markNotificationAsRead: () => Promise.resolve(),
- },
-};
-
-export const Loading: Story = {
- args: {
- notifications: [],
- isPending: true,
- markNotificationAsRead: () => Promise.resolve(),
- },
-};
-
-export const NoNotifications: Story = {
- args: {
- notifications: [],
- isPending: false,
- markNotificationAsRead: () => Promise.resolve(),
- },
-};
-
-export const AllRead: Story = {
- args: {
- notifications: randomNotifications(30).map((x) => ({
- ...x,
- isRead: true,
- })),
- isPending: false,
- markNotificationAsRead: () => Promise.resolve(),
- },
-};
-
-export const MixedNotifications: Story = {
- args: {
- notifications: randomNotifications(10).map((x) => ({
- ...x,
- isRead: Math.random() > 0.5,
- })),
- isPending: false,
- markNotificationAsRead: () => Promise.resolve(),
- },
-};
diff --git a/apps/dashboard/src/app/(app)/team/components/NotificationButton/NotificationButton.tsx b/apps/dashboard/src/app/(app)/team/components/NotificationButton/NotificationButton.tsx
deleted file mode 100644
index 10616bf02da..00000000000
--- a/apps/dashboard/src/app/(app)/team/components/NotificationButton/NotificationButton.tsx
+++ /dev/null
@@ -1,224 +0,0 @@
-"use client";
-
-import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow";
-import { Spinner } from "@/components/ui/Spinner/Spinner";
-import { Button } from "@/components/ui/button";
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover";
-import { cn } from "@/lib/utils";
-import { useQuery } from "@tanstack/react-query";
-import { formatDistance } from "date-fns";
-import { BellIcon, InboxIcon } from "lucide-react";
-import Link from "next/link";
-import { useCallback, useMemo, useState } from "react";
-import { cleanupReadNotifications } from "./fetch-notifications";
-
-export type NotificationMetadata = {
- title: string;
- id: string;
- href: string;
- isRead: boolean;
- createdAt: string;
-};
-
-export function NotificationButtonUI(props: {
- getInboxNotifications: () => Promise;
- markNotificationAsRead: (id: string) => Promise;
-}) {
- const inboxNotificationsQuery = useQuery({
- queryKey: ["inbox-notifications"],
- queryFn: props.getInboxNotifications,
- });
-
- const [readNotifications, setReadNotifications] = useState>(
- new Set(),
- );
-
- const inboxNotifications = useMemo(() => {
- return (inboxNotificationsQuery.data || []).map((notification) => ({
- ...notification,
- isRead: notification.isRead || readNotifications.has(notification.id),
- }));
- }, [inboxNotificationsQuery.data, readNotifications]);
-
- const markAsRead = useCallback(
- async (id: string) => {
- setReadNotifications((prev) => new Set([...prev, id]));
- await props.markNotificationAsRead(id);
- await cleanupReadNotifications(inboxNotifications);
- },
- [props.markNotificationAsRead, inboxNotifications],
- );
-
- return (
-
- );
-}
-
-export function NotificationButtonInner(props: {
- notifications: NotificationMetadata[];
- isPending: boolean;
- markNotificationAsRead: (id: string) => Promise;
-}) {
- const unreadCount = props.notifications.filter((x) => !x.isRead).length;
-
- return (
-
-
-
-
-
-
- Notifications
-
-
-
-
-
- );
-}
-
-function BadgeTab(props: {
- unreadCount: number;
-}) {
- return (
-
- {props.unreadCount > 0 && (
-
- {props.unreadCount}
-
- )}
-
- );
-}
-
-function NotificationContent(props: {
- notifications: NotificationMetadata[];
- isPending: boolean;
- icon: React.FC<{ className?: string }>;
- markNotificationAsRead: (id: string) => Promise;
-}) {
- return (
-
-
- {props.notifications?.map((notification) => (
-
- ))}
-
- {props.isPending && (
-
-
-
- )}
-
- {!props.isPending && props.notifications.length === 0 && (
-
-
-
-
- No Notifications
-
-
-
- )}
-
-
- );
-}
-
-function NotificationLink(props: {
- notification: NotificationMetadata;
- icon: React.FC<{ className?: string }>;
- markNotificationAsRead: (id: string) => Promise;
-}) {
- const { notification } = props;
-
- const link = new URL(notification.href);
- link.searchParams.append("utm_source", "notification");
-
- const handleClick = useCallback(async () => {
- if (!notification.isRead) {
- await props.markNotificationAsRead(notification.id);
- }
- }, [notification.id, notification.isRead, props.markNotificationAsRead]);
-
- return (
-
- {/* Icon */}
-
-
- {/* Content */}
-
-
- {notification.title}
-
-
- {formatDistance(new Date(notification.createdAt), new Date(), {
- addSuffix: true,
- })}
-
-
-
-
- {!notification.isRead && (
-
-
-
-
- )}
-
-
- );
-}
diff --git a/apps/dashboard/src/app/(app)/team/components/NotificationButton/fetch-notifications.ts b/apps/dashboard/src/app/(app)/team/components/NotificationButton/fetch-notifications.ts
deleted file mode 100644
index e3a8c25f896..00000000000
--- a/apps/dashboard/src/app/(app)/team/components/NotificationButton/fetch-notifications.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import { get, set } from "idb-keyval";
-import type { NotificationMetadata } from "./NotificationButton";
-
-type ChangelogItem = {
- published_at: string;
- title: string;
- id: string;
- url: string;
-};
-
-// Note: This saving read notifications in IndexedDB is temporary implementation - This will soon be replaced by a proper API setup
-
-const READ_NOTIFICATIONS_KEY = "thirdweb:read-notifications";
-
-// get set of notification ids marked as read
-async function getReadNotificationIds(): Promise> {
- const readIds = (await get(READ_NOTIFICATIONS_KEY)) || [];
- return new Set(readIds);
-}
-
-// fetches last 20 posts with tag "changelog" notifications
-async function fetchGhostPosts(tag: string) {
- const res = await fetch(
- `https://thirdweb.ghost.io/ghost/api/content/posts/?key=49c62b5137df1c17ab6b9e46e3&fields=id,title,url,published_at&filter=tag:${tag}&visibility:public&limit=20`,
- );
- const json = await res.json();
- return json.posts as ChangelogItem[];
-}
-
-// Clean up the indexedDB storage of read notification IDs
-// This is to prevent the storage from growing indefinitely
-// Remove notification IDs from storage that are no longer shown to the user
-export async function cleanupReadNotifications(
- notifications: NotificationMetadata[],
-) {
- const readIds = await getReadNotificationIds();
-
- // Get all current notification IDs
- const currentIds = new Set([...notifications.map((item) => item.id)]);
-
- // remove ids that are no longer being displayed
- const usedIds = new Set(
- Array.from(readIds).filter((id) => currentIds.has(id)),
- );
-
- await set(READ_NOTIFICATIONS_KEY, Array.from(usedIds));
-}
-
-export async function markNotificationAsRead(id: string) {
- const readIds = await getReadNotificationIds();
- readIds.add(id);
-
- // Clean up old notification IDs periodically (every 10th notification marked as read)
- await set(READ_NOTIFICATIONS_KEY, Array.from(readIds));
-}
-
-export async function getInboxNotifications(): Promise {
- const [changelogs, readIds] = await Promise.all([
- fetchGhostPosts("update"),
- getReadNotificationIds(),
- ]);
-
- return changelogs.map((item) => ({
- id: item.id,
- title: item.title,
- href: item.url,
- isRead: readIds.has(item.id),
- createdAt: item.published_at,
- }));
-}
diff --git a/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamHeaderUI.stories.tsx b/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamHeaderUI.stories.tsx
index 3caa39746e1..255d94a3879 100644
--- a/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamHeaderUI.stories.tsx
+++ b/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamHeaderUI.stories.tsx
@@ -138,9 +138,6 @@ function Variant(props: {
const Comp =
props.type === "mobile" ? TeamHeaderMobileUI : TeamHeaderDesktopUI;
- const getInboxNotificationsStub = () => Promise.resolve([]);
- const markNotificationAsReadStub = () => Promise.resolve();
-
return (
}
createProject={() => {}}
client={storybookThirdwebClient}
- getInboxNotifications={getInboxNotificationsStub}
- markNotificationAsRead={markNotificationAsReadStub}
/>
);
diff --git a/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamHeaderUI.tsx b/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamHeaderUI.tsx
index f46a562b834..66e0fa4a0fd 100644
--- a/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamHeaderUI.tsx
+++ b/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamHeaderUI.tsx
@@ -6,14 +6,11 @@ import { cn } from "@/lib/utils";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import Link from "next/link";
import type { ThirdwebClient } from "thirdweb";
+import { NotificationsButton } from "../../../../../@/components/blocks/notifications/notification-button";
import { SecondaryNav } from "../../../components/Header/SecondaryNav/SecondaryNav";
import { MobileBurgerMenuButton } from "../../../components/MobileBurgerMenuButton";
import { TeamPlanBadge } from "../../../components/TeamPlanBadge";
import { ThirdwebMiniLogo } from "../../../components/ThirdwebMiniLogo";
-import {
- NotificationButtonUI,
- type NotificationMetadata,
-} from "../NotificationButton/NotificationButton";
import { ProjectSelectorMobileMenuButton } from "./ProjectSelectorMobileMenuButton";
import { TeamAndProjectSelectorPopoverButton } from "./TeamAndProjectSelectorPopoverButton";
import { TeamSelectorMobileMenuButton } from "./TeamSelectorMobileMenuButton";
@@ -31,8 +28,6 @@ export type TeamHeaderCompProps = {
createProject: (team: Team) => void;
client: ThirdwebClient;
accountAddress: string;
- getInboxNotifications: () => Promise;
- markNotificationAsRead: (id: string) => Promise;
};
export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) {
@@ -119,8 +114,6 @@ export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) {
connectButton={props.connectButton}
client={props.client}
accountAddress={props.accountAddress}
- getInboxNotifications={props.getInboxNotifications}
- markNotificationAsRead={props.markNotificationAsRead}
/>
);
@@ -202,10 +195,7 @@ export function TeamHeaderMobileUI(props: TeamHeaderCompProps) {
-
+
= 0.8'}
+ vaul@1.1.2:
+ resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==}
+ peerDependencies:
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
+
vfile-location@5.0.3:
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
@@ -25940,7 +25945,7 @@ snapshots:
'@vue/compiler-sfc@3.5.13':
dependencies:
- '@babel/parser': 7.27.2
+ '@babel/parser': 7.27.3
'@vue/compiler-core': 3.5.13
'@vue/compiler-dom': 3.5.13
'@vue/compiler-ssr': 3.5.13
@@ -27403,7 +27408,7 @@ snapshots:
babel-plugin-jest-hoist@29.6.3:
dependencies:
'@babel/template': 7.27.2
- '@babel/types': 7.27.1
+ '@babel/types': 7.27.3
'@types/babel__core': 7.20.5
'@types/babel__traverse': 7.20.7
@@ -31172,7 +31177,7 @@ snapshots:
istanbul-lib-instrument@5.2.1:
dependencies:
'@babel/core': 7.27.1
- '@babel/parser': 7.27.2
+ '@babel/parser': 7.27.3
'@istanbuljs/schema': 0.1.3
istanbul-lib-coverage: 3.2.2
semver: 7.7.2
@@ -36446,6 +36451,15 @@ snapshots:
vary@1.1.2: {}
+ vaul@1.1.2(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
+ dependencies:
+ '@radix-ui/react-dialog': 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ react: 19.1.0
+ react-dom: 19.1.0(react@19.1.0)
+ transitivePeerDependencies:
+ - '@types/react'
+ - '@types/react-dom'
+
vfile-location@5.0.3:
dependencies:
'@types/unist': 3.0.3