diff --git a/src/components/common/NotificationBell.tsx b/src/components/common/NotificationBell.tsx new file mode 100644 index 0000000..3cbefd9 --- /dev/null +++ b/src/components/common/NotificationBell.tsx @@ -0,0 +1,160 @@ +import { Bell } from 'lucide-react'; +import { useNavigate } from 'react-router'; +import { cn } from '@/lib/utils'; +import { useNotifications } from '@/hooks/useNotifications'; +import { formatRelativeTime } from '@/utils/time.utils'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; + +interface NotificationBellProps { + /** The authenticated user's id used as the cache key. */ + userId: string; + className?: string; +} + +/** + * Notification bell for the nav bar (issue #720). + * + * Shows an unread-count badge when count > 0, opens a dropdown of the five + * most recent notifications, marks items read on click, and provides a + * "View all" link to /notifications. + */ +export function NotificationBell({ userId, className = '' }: NotificationBellProps) { + const navigate = useNavigate(); + const { recent, unreadCount, isLoading, markAsRead } = useNotifications(userId); + + const handleNotificationClick = (notificationId: string, href: string) => { + markAsRead(notificationId); + void navigate(href); + }; + + return ( + + + + + + + + Notifications + {unreadCount > 0 && ( + + {unreadCount} unread + + )} + + + + + {isLoading && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ )} + + {!isLoading && recent.length === 0 && ( +

+ No notifications yet +

+ )} + + {!isLoading && + recent.map(notification => ( + + handleNotificationClick( + notification.id, + notification.href + ) + } + > +
+ {/* Unread indicator dot */} + {!notification.read && ( + + )} + + {notification.message} + +
+ + {formatRelativeTime(notification.createdAt)} + +
+ ))} + + + + void navigate('/notifications')} + > + View all + + + + ); +} + +export default NotificationBell; diff --git a/src/components/common/__tests__/NotificationBell.test.tsx b/src/components/common/__tests__/NotificationBell.test.tsx new file mode 100644 index 0000000..2ffa440 --- /dev/null +++ b/src/components/common/__tests__/NotificationBell.test.tsx @@ -0,0 +1,325 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router'; +import NotificationBell from '@/components/common/NotificationBell'; +import { useNotifications } from '@/hooks/useNotifications'; +import type { Notification } from '@/services/notification.service'; + +vi.mock('@/hooks/useNotifications'); +vi.mock('react-router', async () => { + const actual = await vi.importActual('react-router'); + return { ...actual, useNavigate: vi.fn(() => vi.fn()) }; +}); + +const mockUseNotifications = vi.mocked(useNotifications); + +function makeNotification( + id: string, + overrides: Partial = {} +): Notification { + return { + id, + type: 'new_follower', + message: `Notification message ${id}`, + createdAt: new Date(Date.now() - 60_000).toISOString(), + read: false, + href: `/creator/${id}`, + ...overrides, + }; +} + +function renderBell(userId = 'user_abc') { + return render( + + + + ); +} + +describe('NotificationBell', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── Badge visibility ───────────────────────────────────────────────────── + + it('does not show the unread badge when unreadCount is 0', () => { + mockUseNotifications.mockReturnValue({ + recent: [], + unreadCount: 0, + isLoading: false, + isError: false, + markAsRead: vi.fn(), + }); + + renderBell(); + + expect( + screen.queryByTestId('notification-unread-badge') + ).not.toBeInTheDocument(); + }); + + it('shows the unread badge with the correct count when unreadCount > 0', () => { + mockUseNotifications.mockReturnValue({ + recent: [makeNotification('n1')], + unreadCount: 3, + isLoading: false, + isError: false, + markAsRead: vi.fn(), + }); + + renderBell(); + + const badge = screen.getByTestId('notification-unread-badge'); + expect(badge).toBeInTheDocument(); + expect(badge).toHaveTextContent('3'); + }); + + it('caps the badge display at 99+ when unreadCount > 99', () => { + mockUseNotifications.mockReturnValue({ + recent: [], + unreadCount: 150, + isLoading: false, + isError: false, + markAsRead: vi.fn(), + }); + + renderBell(); + + expect(screen.getByTestId('notification-unread-badge')).toHaveTextContent( + '99+' + ); + }); + + // ── Accessible label ───────────────────────────────────────────────────── + + it('gives the bell button an accessible label mentioning unread count', () => { + mockUseNotifications.mockReturnValue({ + recent: [], + unreadCount: 2, + isLoading: false, + isError: false, + markAsRead: vi.fn(), + }); + + renderBell(); + + expect( + screen.getByRole('button', { name: /notifications — 2 unread/i }) + ).toBeInTheDocument(); + }); + + it('uses a generic label when unreadCount is 0', () => { + mockUseNotifications.mockReturnValue({ + recent: [], + unreadCount: 0, + isLoading: false, + isError: false, + markAsRead: vi.fn(), + }); + + renderBell(); + + expect( + screen.getByRole('button', { name: /^notifications$/i }) + ).toBeInTheDocument(); + }); + + // ── Dropdown content ───────────────────────────────────────────────────── + + it('opens the dropdown when the bell is clicked', async () => { + const user = userEvent.setup(); + mockUseNotifications.mockReturnValue({ + recent: [], + unreadCount: 0, + isLoading: false, + isError: false, + markAsRead: vi.fn(), + }); + + renderBell(); + + await user.click(screen.getByRole('button', { name: /notifications/i })); + + expect( + screen.getByTestId('notification-dropdown') + ).toBeInTheDocument(); + }); + + it('shows the empty state when there are no notifications', async () => { + const user = userEvent.setup(); + mockUseNotifications.mockReturnValue({ + recent: [], + unreadCount: 0, + isLoading: false, + isError: false, + markAsRead: vi.fn(), + }); + + renderBell(); + await user.click(screen.getByRole('button', { name: /notifications/i })); + + expect( + screen.getByTestId('notification-empty-state') + ).toBeInTheDocument(); + expect( + screen.getByText('No notifications yet') + ).toBeInTheDocument(); + }); + + it('shows skeleton rows while loading', async () => { + const user = userEvent.setup(); + mockUseNotifications.mockReturnValue({ + recent: [], + unreadCount: 0, + isLoading: true, + isError: false, + markAsRead: vi.fn(), + }); + + renderBell(); + await user.click(screen.getByRole('button', { name: /notifications/i })); + + expect(screen.getByTestId('notification-loading')).toBeInTheDocument(); + expect( + screen.queryByTestId('notification-empty-state') + ).not.toBeInTheDocument(); + }); + + it('renders up to five notification items in the dropdown', async () => { + const user = userEvent.setup(); + const recent = [ + makeNotification('n1'), + makeNotification('n2'), + makeNotification('n3'), + makeNotification('n4'), + makeNotification('n5'), + ]; + mockUseNotifications.mockReturnValue({ + recent, + unreadCount: 5, + isLoading: false, + isError: false, + markAsRead: vi.fn(), + }); + + renderBell(); + await user.click(screen.getByRole('button', { name: /notifications/i })); + + for (const n of recent) { + expect( + screen.getByTestId(`notification-item-${n.id}`) + ).toBeInTheDocument(); + } + }); + + it('shows the notification message text for each item', async () => { + const user = userEvent.setup(); + const recent = [makeNotification('n1', { message: 'Alice followed you' })]; + mockUseNotifications.mockReturnValue({ + recent, + unreadCount: 1, + isLoading: false, + isError: false, + markAsRead: vi.fn(), + }); + + renderBell(); + await user.click(screen.getByRole('button', { name: /notifications/i })); + + expect(screen.getByText('Alice followed you')).toBeInTheDocument(); + }); + + // ── Marking read ───────────────────────────────────────────────────────── + + it('calls markAsRead with the notification id when an item is clicked', async () => { + const user = userEvent.setup(); + const markAsRead = vi.fn(); + const recent = [makeNotification('n1')]; + mockUseNotifications.mockReturnValue({ + recent, + unreadCount: 1, + isLoading: false, + isError: false, + markAsRead, + }); + + renderBell(); + await user.click(screen.getByRole('button', { name: /notifications/i })); + await user.click(screen.getByTestId('notification-item-n1')); + + expect(markAsRead).toHaveBeenCalledWith('n1'); + }); + + // ── View all link ──────────────────────────────────────────────────────── + + it('renders a "View all" link in the dropdown', async () => { + const user = userEvent.setup(); + mockUseNotifications.mockReturnValue({ + recent: [], + unreadCount: 0, + isLoading: false, + isError: false, + markAsRead: vi.fn(), + }); + + renderBell(); + await user.click(screen.getByRole('button', { name: /notifications/i })); + + expect( + screen.getByTestId('notification-view-all') + ).toBeInTheDocument(); + expect( + screen.getByTestId('notification-view-all') + ).toHaveTextContent('View all'); + }); + + // ── Badge decrement ────────────────────────────────────────────────────── + + it('badge count decrements after a notification is marked read', async () => { + const markAsRead = vi.fn(); + + // Start with count = 2 + mockUseNotifications.mockReturnValue({ + recent: [ + makeNotification('n1', { read: false }), + makeNotification('n2', { read: false }), + ], + unreadCount: 2, + isLoading: false, + isError: false, + markAsRead, + }); + + const { rerender } = renderBell(); + + expect(screen.getByTestId('notification-unread-badge')).toHaveTextContent( + '2' + ); + + // Simulate the hook returning updated state after markAsRead is called + mockUseNotifications.mockReturnValue({ + recent: [ + makeNotification('n1', { read: true }), + makeNotification('n2', { read: false }), + ], + unreadCount: 1, + isLoading: false, + isError: false, + markAsRead, + }); + + rerender( + + + + ); + + await waitFor(() => { + expect( + screen.getByTestId('notification-unread-badge') + ).toHaveTextContent('1'); + }); + }); +}); diff --git a/src/components/home/Header.tsx b/src/components/home/Header.tsx index 8441f7c..f88f65e 100644 --- a/src/components/home/Header.tsx +++ b/src/components/home/Header.tsx @@ -1,5 +1,7 @@ import { useEffect, useState } from 'react'; import WalletStatusChip from '@/components/common/WalletStatusChip'; +import NotificationBell from '@/components/common/NotificationBell'; +import { useProfileStore } from '@/hooks/useProfileStore'; import { Link } from 'react-router'; const navLinks = [ @@ -10,6 +12,7 @@ const navLinks = [ export default function Header() { const [scrolled, setScrolled] = useState(false); + const profile = useProfileStore(state => state.profile); useEffect(() => { const onScroll = () => { @@ -66,10 +69,21 @@ export default function Header() { )} - {/* CTA — #686: a persistent wallet status chip replaces the bare - Connect link. WalletStatusChip renders the same link itself when - no wallet is connected, so the slot is never empty. */} - + {/* Right-side actions: notification bell (#720) + wallet status chip (#686). + NotificationBell is only rendered when a user profile is available. */} +
+ {profile && ( + + )} + +
); diff --git a/src/hooks/__tests__/useNotifications.test.ts b/src/hooks/__tests__/useNotifications.test.ts new file mode 100644 index 0000000..8493d3b --- /dev/null +++ b/src/hooks/__tests__/useNotifications.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; +import { useNotifications } from '@/hooks/useNotifications'; +import type { NotificationsResponse } from '@/services/notification.service'; + +const USER_ID = 'user_abc123'; + +function makeResponse( + overrides: Partial = {} +): NotificationsResponse { + return { + notifications: [], + unreadCount: 0, + ...overrides, + }; +} + +function makeWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + return ({ children }: { children: React.ReactNode }) => + React.createElement( + QueryClientProvider, + { client: queryClient }, + children + ); +} + +describe('useNotifications', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns isLoading=true before the query resolves', () => { + const fetchFn = vi.fn(() => new Promise(() => {})); + const { result } = renderHook( + () => useNotifications(USER_ID, fetchFn), + { wrapper: makeWrapper() } + ); + expect(result.current.isLoading).toBe(true); + }); + + it('returns empty recent and zero unreadCount while loading', () => { + const fetchFn = vi.fn(() => new Promise(() => {})); + const { result } = renderHook( + () => useNotifications(USER_ID, fetchFn), + { wrapper: makeWrapper() } + ); + expect(result.current.recent).toEqual([]); + expect(result.current.unreadCount).toBe(0); + }); + + it('returns resolved notifications and unreadCount', async () => { + const notifications = [ + { + id: 'n1', + type: 'new_follower' as const, + message: 'Alice started following you', + createdAt: new Date().toISOString(), + read: false, + href: '/creator/alice', + }, + ]; + const fetchFn = vi.fn().mockResolvedValue( + makeResponse({ notifications, unreadCount: 1 }) + ); + + const { result } = renderHook( + () => useNotifications(USER_ID, fetchFn), + { wrapper: makeWrapper() } + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.recent).toHaveLength(1); + expect(result.current.recent[0].id).toBe('n1'); + expect(result.current.unreadCount).toBe(1); + }); + + it('caps recent at five notifications even when the API returns more', async () => { + const notifications = Array.from({ length: 8 }, (_, i) => ({ + id: `n${i}`, + type: 'new_follower' as const, + message: `Notification ${i}`, + createdAt: new Date().toISOString(), + read: false, + href: `/creator/${i}`, + })); + const fetchFn = vi.fn().mockResolvedValue( + makeResponse({ notifications, unreadCount: 8 }) + ); + + const { result } = renderHook( + () => useNotifications(USER_ID, fetchFn), + { wrapper: makeWrapper() } + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.recent).toHaveLength(5); + }); + + it('does not fetch when userId is empty', () => { + const fetchFn = vi.fn(); + renderHook(() => useNotifications('', fetchFn), { + wrapper: makeWrapper(), + }); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('applies an optimistic read update when markAsRead is called', async () => { + const notifications = [ + { + id: 'n1', + type: 'new_follower' as const, + message: 'Alice started following you', + createdAt: new Date().toISOString(), + read: false, + href: '/creator/alice', + }, + { + id: 'n2', + type: 'key_purchase' as const, + message: 'Bob bought your key', + createdAt: new Date().toISOString(), + read: false, + href: '/creator/bob', + }, + ]; + + // markAsRead hits the real notificationService — stub the module-level + // singleton so we can make it resolve without a real server. + vi.mock('@/services/notification.service', () => ({ + notificationService: { + getNotifications: vi.fn(), + markAsRead: vi.fn().mockResolvedValue(undefined), + }, + NotificationService: vi.fn(), + })); + + const fetchFn = vi.fn().mockResolvedValue( + makeResponse({ notifications, unreadCount: 2 }) + ); + + const { result } = renderHook( + () => useNotifications(USER_ID, fetchFn), + { wrapper: makeWrapper() } + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.unreadCount).toBe(2); + + act(() => { + result.current.markAsRead('n1'); + }); + + // After the optimistic update the count should drop by 1. + await waitFor(() => expect(result.current.unreadCount).toBe(1)); + + // The mutated notification should now appear as read. + const n1 = result.current.recent.find(n => n.id === 'n1'); + expect(n1?.read).toBe(true); + + // The other notification remains unread. + const n2 = result.current.recent.find(n => n.id === 'n2'); + expect(n2?.read).toBe(false); + }); +}); diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts new file mode 100644 index 0000000..2b1883e --- /dev/null +++ b/src/hooks/useNotifications.ts @@ -0,0 +1,91 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import { + notificationService, + type Notification, + type NotificationsResponse, +} from '@/services/notification.service'; + +const MAX_DROPDOWN_NOTIFICATIONS = 5; + +export interface UseNotificationsResult { + /** Up to five most recent notifications for the dropdown. */ + recent: Notification[]; + /** Total number of unread notifications. */ + unreadCount: number; + isLoading: boolean; + isError: boolean; + /** Mark a single notification as read by its id. */ + markAsRead: (notificationId: string) => void; +} + +/** + * Fetches the current user's notifications and exposes a helper to mark + * individual items as read with an optimistic update. + * + * The queryFn is injected so tests can supply a stub without module-level + * patching (matches the pattern used in `useCreatorActivityFeed`). + */ +export function useNotifications( + userId: string, + fetchNotifications: ( + id: string + ) => Promise = id => + notificationService.getNotifications(id) +): UseNotificationsResult { + const queryClient = useQueryClient(); + const queryKey = queryKeys.notifications.list(userId); + + const { data, isLoading, isError } = useQuery({ + queryKey, + queryFn: () => fetchNotifications(userId), + enabled: !!userId, + }); + + const { mutate: markAsRead } = useMutation({ + mutationFn: (notificationId: string) => + notificationService.markAsRead(notificationId), + // Optimistic update: flip the `read` flag and decrement the count + // so the badge responds instantly without waiting for the server. + onMutate: async (notificationId: string) => { + await queryClient.cancelQueries({ queryKey }); + + const previous = + queryClient.getQueryData(queryKey); + + if (previous) { + queryClient.setQueryData(queryKey, { + notifications: previous.notifications.map(n => + n.id === notificationId ? { ...n, read: true } : n + ), + unreadCount: Math.max(0, previous.unreadCount - 1), + }); + } + + return { previous }; + }, + onError: (_err, _id, context) => { + // Roll back the optimistic update on failure. + if (context?.previous) { + queryClient.setQueryData( + queryKey, + context.previous + ); + } + }, + onSettled: () => { + void queryClient.invalidateQueries({ queryKey }); + }, + }); + + const allNotifications = data?.notifications ?? []; + const recent = allNotifications.slice(0, MAX_DROPDOWN_NOTIFICATIONS); + + return { + recent, + unreadCount: data?.unreadCount ?? 0, + isLoading, + isError, + markAsRead, + }; +} diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index 43aaae9..567985e 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -30,4 +30,8 @@ export const queryKeys = { holdings: (address: string) => ['wallet', address, 'holdings'] as const, activity: (address: string) => ['wallet', address, 'activity'] as const, }, + notifications: { + all: () => ['notifications'] as const, + list: (userId: string) => ['notifications', userId, 'list'] as const, + }, } as const; diff --git a/src/pages/NotificationsPage.tsx b/src/pages/NotificationsPage.tsx new file mode 100644 index 0000000..71a003d --- /dev/null +++ b/src/pages/NotificationsPage.tsx @@ -0,0 +1,125 @@ +import { Bell } from 'lucide-react'; +import { useNavigate } from 'react-router'; +import { cn } from '@/lib/utils'; +import { useNotifications } from '@/hooks/useNotifications'; +import { useProfileStore } from '@/hooks/useProfileStore'; +import { formatRelativeTime } from '@/utils/time.utils'; +import { useNavigationTiming } from '@/hooks/useNavigationTiming'; + +export default function NotificationsPage() { + useNavigationTiming('notifications'); + + const navigate = useNavigate(); + const profile = useProfileStore(state => state.profile); + const userId = profile?.id ?? ''; + + const { recent, unreadCount, isLoading, markAsRead } = + useNotifications(userId); + + const handleNotificationClick = ( + notificationId: string, + href: string + ) => { + markAsRead(notificationId); + void navigate(href); + }; + + return ( +
+
+
+ + {isLoading && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ )} + + {!isLoading && recent.length === 0 && ( +
+
+
+

+ No notifications yet +

+
+ )} + + {!isLoading && recent.length > 0 && ( +
    + {recent.map(notification => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/routes.tsx b/src/routes.tsx index 64c5e97..b4add51 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -1,6 +1,7 @@ import HomePage from './pages/HomePage'; import NotFoundPage from './pages/NotFoundPage'; import CreatorDetailPage from './pages/CreatorDetailPage'; +import NotificationsPage from './pages/NotificationsPage'; export const routes = [ { @@ -19,6 +20,10 @@ export const routes = [ path: '/creators/:id', element: , }, + { + path: '/notifications', + element: , + }, { path: '*', element: , diff --git a/src/services/notification.service.ts b/src/services/notification.service.ts new file mode 100644 index 0000000..c3f8398 --- /dev/null +++ b/src/services/notification.service.ts @@ -0,0 +1,48 @@ +// src/services/notification.service.ts +import { BaseApiService, type APIResponse } from './api.service'; + +/** The kind of event that generated a notification. */ +export type NotificationType = 'new_follower' | 'key_purchase' | 'price_milestone'; + +/** A single notification entry returned from the API. */ +export interface Notification { + id: string; + type: NotificationType; + message: string; + /** ISO 8601 timestamp when the notification was created. */ + createdAt: string; + read: boolean; + /** Client-side path to navigate to when this notification is clicked. */ + href: string; +} + +/** Shape of the API response for a notifications list request. */ +export interface NotificationsResponse { + notifications: Notification[]; + unreadCount: number; +} + +class NotificationService extends BaseApiService { + /** Fetch the current user's notifications — GET /notifications */ + async getNotifications(userId: string): Promise { + try { + const response = await this.api.get< + APIResponse + >(`/notifications`, { params: { userId } }); + return response.data.data; + } catch (error) { + throw this.handleError(error); + } + } + + /** Mark a single notification as read — PATCH /notifications/:id/read */ + async markAsRead(notificationId: string): Promise { + try { + await this.api.patch(`/notifications/${notificationId}/read`); + } catch (error) { + throw this.handleError(error); + } + } +} + +export const notificationService = new NotificationService();