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