From c241941e41016a5075a46ec5a2ea91c03a4881ca Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Sat, 11 Jul 2026 16:21:28 -0400 Subject: [PATCH] feat(notifications): header notification bell --- .../layout/header/controls/index.tsx | 2 + .../header/controls/notification-bell.tsx | 149 ++++++++++++++++++ .../component/notification-bell.test.tsx | 135 ++++++++++++++++ 3 files changed, 286 insertions(+) create mode 100644 apps/next/src/components/layout/header/controls/notification-bell.tsx create mode 100644 apps/next/tests/component/notification-bell.test.tsx diff --git a/apps/next/src/components/layout/header/controls/index.tsx b/apps/next/src/components/layout/header/controls/index.tsx index 015fe22..de9c7d2 100644 --- a/apps/next/src/components/layout/header/controls/index.tsx +++ b/apps/next/src/components/layout/header/controls/index.tsx @@ -4,6 +4,7 @@ import type { ThemeToggleProps } from '@spoon/ui'; import { ThemeToggle } from '@spoon/ui'; import { AvatarDropdown } from './AvatarDropdown'; +import { NotificationBell } from './notification-bell'; export const Controls = (themeToggleProps?: ThemeToggleProps) => { return ( @@ -16,6 +17,7 @@ export const Controls = (themeToggleProps?: ThemeToggleProps) => { ...themeToggleProps?.buttonProps, }} /> + ); diff --git a/apps/next/src/components/layout/header/controls/notification-bell.tsx b/apps/next/src/components/layout/header/controls/notification-bell.tsx new file mode 100644 index 0000000..3627441 --- /dev/null +++ b/apps/next/src/components/layout/header/controls/notification-bell.tsx @@ -0,0 +1,149 @@ +'use client'; + +import Link from 'next/link'; +import { Bell } from 'lucide-react'; +import { toast } from 'sonner'; +import { useConvexAuth, useMutation, useQuery } from 'convex/react'; + +import { api } from '@spoon/backend/convex/_generated/api.js'; +import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@spoon/ui'; + +const RELATIVE_UNITS: [Intl.RelativeTimeFormatUnit, number][] = [ + ['year', 365 * 24 * 60 * 60 * 1000], + ['month', 30 * 24 * 60 * 60 * 1000], + ['day', 24 * 60 * 60 * 1000], + ['hour', 60 * 60 * 1000], + ['minute', 60 * 1000], +]; + +const relativeFormatter = new Intl.RelativeTimeFormat(undefined, { + numeric: 'auto', +}); + +const formatRelativeTime = (timestamp: number): string => { + const delta = timestamp - Date.now(); + const magnitude = Math.abs(delta); + for (const [unit, ms] of RELATIVE_UNITS) { + if (magnitude >= ms) { + return relativeFormatter.format(Math.round(delta / ms), unit); + } + } + return 'just now'; +}; + +export const NotificationBell = () => { + const { isAuthenticated } = useConvexAuth(); + const unreadCount = useQuery( + api.notifications.unreadCount, + isAuthenticated ? {} : 'skip', + ); + const notifications = useQuery( + api.notifications.listMine, + isAuthenticated ? { limit: 10 } : 'skip', + ); + const markRead = useMutation(api.notifications.markRead); + const markAllRead = useMutation(api.notifications.markAllRead); + + if (!isAuthenticated) { + return null; + } + + const count = unreadCount ?? 0; + const badgeLabel = count > 9 ? '9+' : String(count); + const items = notifications ?? []; + + // Fire-and-forget: never block navigation on the read write, but surface a + // toast if it fails so we don't silently assume success. + const handleMarkRead = async (notificationId: Id<'notifications'>) => { + try { + await markRead({ notificationId }); + } catch { + toast.error('Could not mark the notification as read.'); + } + }; + + const handleMarkAllRead = async () => { + try { + await markAllRead({}); + } catch { + toast.error('Could not mark notifications as read.'); + } + }; + + return ( + + + + + +
+ Notifications + {count > 0 && ( + + )} +
+ + {items.length === 0 ? ( +

+ No notifications yet. +

+ ) : ( + items.map((notification) => ( + + void handleMarkRead(notification._id)} + className='flex w-full cursor-pointer flex-col items-start gap-0.5' + > + + + {notification.title} + + {notification.readAt === undefined && ( + + )} + + + {notification.body} + + + {formatRelativeTime(notification.createdAt)} + + + + )) + )} +
+
+ ); +}; diff --git a/apps/next/tests/component/notification-bell.test.tsx b/apps/next/tests/component/notification-bell.test.tsx new file mode 100644 index 0000000..37cb96b --- /dev/null +++ b/apps/next/tests/component/notification-bell.test.tsx @@ -0,0 +1,135 @@ +import type { ComponentProps, ReactNode } from 'react'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { api } from '@spoon/backend/convex/_generated/api.js'; + +import { NotificationBell } from '../../src/components/layout/header/controls/notification-bell'; + +// Stable sentinels: the real Convex `api` proxy hands back a fresh reference on +// every access, so `===` comparisons in the mocks below would never match. +vi.mock('@spoon/backend/convex/_generated/api.js', () => ({ + api: { + notifications: { + unreadCount: 'notifications:unreadCount', + listMine: 'notifications:listMine', + markRead: 'notifications:markRead', + markAllRead: 'notifications:markAllRead', + }, + }, +})); + +const { mockMarkAllRead, mockMarkRead, mockUseConvexAuth, mockUseQuery } = + vi.hoisted(() => ({ + mockMarkAllRead: vi.fn(), + mockMarkRead: vi.fn(), + mockUseConvexAuth: vi.fn(), + mockUseQuery: vi.fn(), + })); + +vi.mock('convex/react', () => ({ + useConvexAuth: mockUseConvexAuth, + useMutation: (reference: unknown) => + reference === api.notifications.markAllRead ? mockMarkAllRead : mockMarkRead, + useQuery: mockUseQuery, +})); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); + +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), success: vi.fn() }, +})); + +// Render the dropdown primitives as pass-through wrappers so the menu content +// is deterministically present under jsdom (Radix pointer behavior is flaky +// here). We are exercising NotificationBell's logic, not Radix. +vi.mock('@spoon/ui', () => ({ + Button: (props: ComponentProps<'button'>) =>