Barazo default frontend barazo.forum
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(web): notifications bell, page, and header integration (M10) (#11)

Add NotificationBell with unread count badge and ARIA live region,
notifications page with mark-all-read, mock data, and MSW handlers.

authored by

Guido X Jansen and committed by
GitHub
1509b13c c8ea09ee

+586 -1
+172
src/app/notifications/page.test.tsx
··· 1 + /** 2 + * Tests for notifications page. 3 + */ 4 + 5 + import { describe, it, expect, vi, beforeEach } from 'vitest' 6 + import { render, screen, act, cleanup } from '@testing-library/react' 7 + import userEvent from '@testing-library/user-event' 8 + import { axe } from 'vitest-axe' 9 + import NotificationsPage from './page' 10 + 11 + // Mock next/navigation 12 + vi.mock('next/navigation', () => ({ 13 + useRouter: () => ({ push: vi.fn() }), 14 + })) 15 + 16 + // Mock next-themes 17 + vi.mock('next-themes', () => ({ 18 + useTheme: () => ({ theme: 'dark', setTheme: vi.fn() }), 19 + ThemeProvider: ({ children }: { children: React.ReactNode }) => children, 20 + })) 21 + 22 + // Mock next/image 23 + vi.mock('next/image', () => ({ 24 + default: (props: Record<string, unknown>) => { 25 + // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text 26 + return <img {...props} /> 27 + }, 28 + })) 29 + 30 + // Mock next/link 31 + vi.mock('next/link', () => ({ 32 + default: ({ 33 + children, 34 + href, 35 + ...props 36 + }: { children: React.ReactNode; href: string } & Record<string, unknown>) => ( 37 + <a href={href} {...props}> 38 + {children} 39 + </a> 40 + ), 41 + })) 42 + 43 + // Mock API client 44 + vi.mock('@/lib/api/client', () => ({ 45 + getNotifications: vi.fn(), 46 + markNotificationsRead: vi.fn(), 47 + })) 48 + 49 + import { getNotifications, markNotificationsRead } from '@/lib/api/client' 50 + 51 + const mockGetNotifications = vi.mocked(getNotifications) 52 + const mockMarkRead = vi.mocked(markNotificationsRead) 53 + 54 + const mockNotifications = [ 55 + { 56 + id: 'notif-1', 57 + type: 'reply' as const, 58 + userDid: 'did:plc:user', 59 + actorDid: 'did:plc:bob', 60 + actorHandle: 'bob.bsky.social', 61 + subjectUri: 'at://did:plc:user/forum.barazo.topic.post/abc', 62 + subjectTitle: 'My Topic', 63 + message: 'bob.bsky.social replied to your topic', 64 + read: false, 65 + createdAt: '2026-02-14T12:00:00Z', 66 + }, 67 + { 68 + id: 'notif-2', 69 + type: 'reaction' as const, 70 + userDid: 'did:plc:user', 71 + actorDid: 'did:plc:carol', 72 + actorHandle: 'carol.example.com', 73 + subjectUri: 'at://did:plc:user/forum.barazo.topic.post/abc', 74 + subjectTitle: 'My Topic', 75 + message: 'carol.example.com reacted to your topic', 76 + read: true, 77 + createdAt: '2026-02-13T12:00:00Z', 78 + }, 79 + ] 80 + 81 + describe('NotificationsPage', () => { 82 + beforeEach(() => { 83 + vi.clearAllMocks() 84 + cleanup() 85 + mockGetNotifications.mockResolvedValue({ 86 + notifications: mockNotifications, 87 + cursor: null, 88 + unreadCount: 1, 89 + }) 90 + mockMarkRead.mockResolvedValue(undefined) 91 + }) 92 + 93 + it('renders page heading', async () => { 94 + render(<NotificationsPage />) 95 + expect(screen.getByRole('heading', { level: 1, name: /notification/i })).toBeInTheDocument() 96 + }) 97 + 98 + it('displays notifications from API', async () => { 99 + render(<NotificationsPage />) 100 + 101 + await act(async () => { 102 + await new Promise((r) => setTimeout(r, 100)) 103 + }) 104 + 105 + expect(await screen.findByText(/bob\.bsky\.social replied/)).toBeInTheDocument() 106 + expect(screen.getByText(/carol\.example\.com reacted/)).toBeInTheDocument() 107 + }) 108 + 109 + it('shows unread indicator on unread notifications', async () => { 110 + render(<NotificationsPage />) 111 + 112 + await act(async () => { 113 + await new Promise((r) => setTimeout(r, 100)) 114 + }) 115 + 116 + const items = screen.getAllByRole('article') 117 + // First notification is unread 118 + expect(items[0]).toHaveClass('border-l-primary') 119 + }) 120 + 121 + it('renders mark all read button', async () => { 122 + render(<NotificationsPage />) 123 + 124 + await act(async () => { 125 + await new Promise((r) => setTimeout(r, 100)) 126 + }) 127 + 128 + expect(screen.getByRole('button', { name: /mark all read/i })).toBeInTheDocument() 129 + }) 130 + 131 + it('calls markNotificationsRead on mark all', async () => { 132 + const user = userEvent.setup() 133 + render(<NotificationsPage />) 134 + 135 + await act(async () => { 136 + await new Promise((r) => setTimeout(r, 100)) 137 + }) 138 + 139 + const button = screen.getByRole('button', { name: /mark all read/i }) 140 + await user.click(button) 141 + 142 + expect(mockMarkRead).toHaveBeenCalled() 143 + }) 144 + 145 + it('shows empty state when no notifications', async () => { 146 + mockGetNotifications.mockResolvedValue({ 147 + notifications: [], 148 + cursor: null, 149 + unreadCount: 0, 150 + }) 151 + 152 + render(<NotificationsPage />) 153 + 154 + await act(async () => { 155 + await new Promise((r) => setTimeout(r, 100)) 156 + }) 157 + 158 + expect(await screen.findByText(/no notifications/i)).toBeInTheDocument() 159 + }) 160 + 161 + it('renders breadcrumbs', () => { 162 + render(<NotificationsPage />) 163 + const nav = screen.getByRole('navigation', { name: /breadcrumb/i }) 164 + expect(nav).toBeInTheDocument() 165 + }) 166 + 167 + it('passes axe accessibility check', async () => { 168 + const { container } = render(<NotificationsPage />) 169 + const results = await axe(container) 170 + expect(results).toHaveNoViolations() 171 + }) 172 + })
+150
src/app/notifications/page.tsx
··· 1 + /** 2 + * Notifications page. 3 + * URL: /notifications 4 + * Lists user notifications with mark-read functionality. 5 + * @see specs/prd-web.md Section M10 6 + */ 7 + 8 + 'use client' 9 + 10 + import { useState, useEffect, useCallback } from 'react' 11 + import Link from 'next/link' 12 + import { ChatCircle, Heart, At, ShieldCheck, CheckCircle } from '@phosphor-icons/react' 13 + import { ForumLayout } from '@/components/layout/forum-layout' 14 + import { Breadcrumbs } from '@/components/breadcrumbs' 15 + import { getNotifications, markNotificationsRead } from '@/lib/api/client' 16 + import { cn } from '@/lib/utils' 17 + import type { Notification, NotificationType } from '@/lib/api/types' 18 + 19 + // TODO: Replace with actual auth token from session 20 + const MOCK_TOKEN = 'mock-access-token' 21 + 22 + const NOTIFICATION_ICONS: Record<NotificationType, typeof ChatCircle> = { 23 + reply: ChatCircle, 24 + reaction: Heart, 25 + mention: At, 26 + moderation: ShieldCheck, 27 + } 28 + 29 + export default function NotificationsPage() { 30 + const [notifications, setNotifications] = useState<Notification[]>([]) 31 + const [loading, setLoading] = useState(true) 32 + 33 + const fetchNotifications = useCallback(async () => { 34 + try { 35 + const response = await getNotifications(MOCK_TOKEN) 36 + setNotifications(response.notifications) 37 + } catch { 38 + // Silently handle - notifications are non-critical 39 + } finally { 40 + setLoading(false) 41 + } 42 + }, []) 43 + 44 + useEffect(() => { 45 + void fetchNotifications() 46 + }, [fetchNotifications]) 47 + 48 + const handleMarkAllRead = useCallback(async () => { 49 + const unreadIds = notifications.filter((n) => !n.read).map((n) => n.id) 50 + if (unreadIds.length === 0) return 51 + 52 + try { 53 + await markNotificationsRead(MOCK_TOKEN, unreadIds) 54 + setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))) 55 + } catch { 56 + // Silently handle 57 + } 58 + }, [notifications]) 59 + 60 + const formatDate = (dateStr: string) => { 61 + return new Date(dateStr).toLocaleDateString('en-US', { 62 + month: 'short', 63 + day: 'numeric', 64 + hour: 'numeric', 65 + minute: '2-digit', 66 + }) 67 + } 68 + 69 + const hasUnread = notifications.some((n) => !n.read) 70 + 71 + return ( 72 + <ForumLayout> 73 + <div className="space-y-6"> 74 + <Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Notifications' }]} /> 75 + 76 + <div className="flex items-center justify-between"> 77 + <h1 className="text-2xl font-bold text-foreground">Notifications</h1> 78 + {hasUnread && ( 79 + <button 80 + type="button" 81 + onClick={() => void handleMarkAllRead()} 82 + className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring" 83 + > 84 + <CheckCircle size={16} aria-hidden="true" /> 85 + Mark all read 86 + </button> 87 + )} 88 + </div> 89 + 90 + {loading && ( 91 + <div className="animate-pulse space-y-3"> 92 + <div className="h-16 rounded bg-muted" /> 93 + <div className="h-16 rounded bg-muted" /> 94 + <div className="h-16 rounded bg-muted" /> 95 + </div> 96 + )} 97 + 98 + {!loading && notifications.length === 0 && ( 99 + <p className="py-8 text-center text-muted-foreground"> 100 + No notifications yet. You&rsquo;ll be notified when someone replies, reacts, or mentions 101 + you. 102 + </p> 103 + )} 104 + 105 + {!loading && notifications.length > 0 && ( 106 + <div className="space-y-2"> 107 + {notifications.map((notification) => { 108 + const Icon = NOTIFICATION_ICONS[notification.type] 109 + return ( 110 + <article 111 + key={notification.id} 112 + className={cn( 113 + 'rounded-lg border border-border bg-card p-4 transition-colors hover:bg-card-hover', 114 + !notification.read && 'border-l-2 border-l-primary' 115 + )} 116 + > 117 + <div className="flex items-start gap-3"> 118 + <div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted"> 119 + <Icon size={16} className="text-muted-foreground" aria-hidden="true" /> 120 + </div> 121 + <div className="min-w-0 flex-1"> 122 + <p className="text-sm text-foreground">{notification.message}</p> 123 + {notification.subjectTitle && ( 124 + <Link 125 + href={`/t/-/${notification.subjectUri.split('/').pop()}`} 126 + className="mt-1 block text-xs text-primary hover:underline" 127 + > 128 + {notification.subjectTitle} 129 + </Link> 130 + )} 131 + <p className="mt-1 text-xs text-muted-foreground"> 132 + {formatDate(notification.createdAt)} 133 + </p> 134 + </div> 135 + {!notification.read && ( 136 + <span 137 + className="mt-1 h-2 w-2 shrink-0 rounded-full bg-primary" 138 + aria-label="Unread" 139 + /> 140 + )} 141 + </div> 142 + </article> 143 + ) 144 + })} 145 + </div> 146 + )} 147 + </div> 148 + </ForumLayout> 149 + ) 150 + }
+3 -1
src/app/settings/page.test.tsx
··· 42 42 43 43 it('renders notification preferences section', () => { 44 44 render(<SettingsPage />) 45 - expect(screen.getByText(/notifications/i)).toBeInTheDocument() 45 + // Find the fieldset legend specifically (not the header notification bell's ARIA text) 46 + const legends = screen.getAllByText(/notifications/i) 47 + expect(legends.some((el) => el.tagName === 'LEGEND')).toBe(true) 46 48 }) 47 49 48 50 it('renders save button', () => {
+9
src/components/layout/forum-layout.test.tsx
··· 91 91 expect(screen.getByText('Skip to main content')).toBeInTheDocument() 92 92 }) 93 93 94 + it('renders notification bell in header', () => { 95 + render( 96 + <ForumLayout> 97 + <p>Content</p> 98 + </ForumLayout> 99 + ) 100 + expect(screen.getByRole('link', { name: /notification/i })).toBeInTheDocument() 101 + }) 102 + 94 103 it('renders search input in header', () => { 95 104 render( 96 105 <ForumLayout>
+2
src/components/layout/forum-layout.tsx
··· 10 10 import { SkipLinks } from '@/components/skip-links' 11 11 import { ThemeToggle } from '@/components/theme-toggle' 12 12 import { SearchInput } from '@/components/search-input' 13 + import { NotificationBell } from '@/components/notification-bell' 13 14 import { MagnifyingGlass } from '@phosphor-icons/react/dist/ssr' 14 15 15 16 interface ForumLayoutProps { ··· 60 61 > 61 62 <MagnifyingGlass className="h-5 w-5" weight="regular" aria-hidden="true" /> 62 63 </Link> 64 + <NotificationBell unreadCount={0} /> 63 65 <ThemeToggle /> 64 66 </div> 65 67 </div>
+62
src/components/notification-bell.test.tsx
··· 1 + /** 2 + * Tests for NotificationBell component. 3 + */ 4 + 5 + import { describe, it, expect, vi } from 'vitest' 6 + import { render, screen } from '@testing-library/react' 7 + import { axe } from 'vitest-axe' 8 + import { NotificationBell } from './notification-bell' 9 + 10 + // Mock next/link 11 + vi.mock('next/link', () => ({ 12 + default: ({ 13 + children, 14 + href, 15 + ...props 16 + }: { children: React.ReactNode; href: string } & Record<string, unknown>) => ( 17 + <a href={href} {...props}> 18 + {children} 19 + </a> 20 + ), 21 + })) 22 + 23 + describe('NotificationBell', () => { 24 + it('renders as link to notifications page', () => { 25 + render(<NotificationBell unreadCount={0} />) 26 + const link = screen.getByRole('link', { name: /notification/i }) 27 + expect(link).toHaveAttribute('href', '/notifications') 28 + }) 29 + 30 + it('shows count badge when unread > 0', () => { 31 + render(<NotificationBell unreadCount={3} />) 32 + expect(screen.getByText('3')).toBeInTheDocument() 33 + }) 34 + 35 + it('hides count badge when unread is 0', () => { 36 + render(<NotificationBell unreadCount={0} />) 37 + expect(screen.queryByText('0')).not.toBeInTheDocument() 38 + }) 39 + 40 + it('shows 99+ for large counts', () => { 41 + render(<NotificationBell unreadCount={150} />) 42 + expect(screen.getByText('99+')).toBeInTheDocument() 43 + }) 44 + 45 + it('announces count via ARIA live region', () => { 46 + render(<NotificationBell unreadCount={5} />) 47 + const status = screen.getByRole('status') 48 + expect(status).toHaveTextContent('5 unread notifications') 49 + }) 50 + 51 + it('announces no unread via ARIA live region', () => { 52 + render(<NotificationBell unreadCount={0} />) 53 + const status = screen.getByRole('status') 54 + expect(status).toHaveTextContent('No unread notifications') 55 + }) 56 + 57 + it('passes axe accessibility check', async () => { 58 + const { container } = render(<NotificationBell unreadCount={3} />) 59 + const results = await axe(container) 60 + expect(results).toHaveNoViolations() 61 + }) 62 + })
+41
src/components/notification-bell.tsx
··· 1 + /** 2 + * NotificationBell - Header notification icon with unread count badge. 3 + * ARIA live region announces count changes for screen readers. 4 + * @see specs/prd-web.md Section M10 (Notifications) 5 + */ 6 + 7 + import Link from 'next/link' 8 + import { Bell } from '@phosphor-icons/react/dist/ssr' 9 + import { cn } from '@/lib/utils' 10 + 11 + interface NotificationBellProps { 12 + unreadCount: number 13 + className?: string 14 + } 15 + 16 + export function NotificationBell({ unreadCount, className }: NotificationBellProps) { 17 + const displayCount = unreadCount > 99 ? '99+' : String(unreadCount) 18 + const hasUnread = unreadCount > 0 19 + 20 + return ( 21 + <div className={cn('relative', className)}> 22 + <Link 23 + href="/notifications" 24 + aria-label={hasUnread ? `Notifications (${unreadCount} unread)` : 'Notifications'} 25 + className="inline-flex h-10 w-10 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-card-hover hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" 26 + > 27 + <Bell size={20} weight={hasUnread ? 'fill' : 'regular'} aria-hidden="true" /> 28 + {hasUnread && ( 29 + <span className="absolute right-0.5 top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-destructive-foreground"> 30 + {displayCount} 31 + </span> 32 + )} 33 + </Link> 34 + <div role="status" aria-live="polite" className="sr-only"> 35 + {hasUnread 36 + ? `${unreadCount} unread notification${unreadCount !== 1 ? 's' : ''}` 37 + : 'No unread notifications'} 38 + </div> 39 + </div> 40 + ) 41 + }
+37
src/lib/api/client.ts
··· 15 15 UpdateTopicInput, 16 16 RepliesResponse, 17 17 SearchResponse, 18 + NotificationsResponse, 18 19 PaginationParams, 19 20 } from './types' 20 21 ··· 168 169 cursor: params.cursor, 169 170 }) 170 171 return apiFetch<SearchResponse>(`/api/search${query}`, options) 172 + } 173 + 174 + // --- Notification endpoints --- 175 + 176 + export function getNotifications( 177 + accessToken: string, 178 + params: PaginationParams = {}, 179 + options?: FetchOptions 180 + ): Promise<NotificationsResponse> { 181 + const query = buildQuery({ 182 + limit: params.limit, 183 + cursor: params.cursor, 184 + }) 185 + return apiFetch<NotificationsResponse>(`/api/notifications${query}`, { 186 + ...options, 187 + headers: { 188 + ...options?.headers, 189 + Authorization: `Bearer ${accessToken}`, 190 + }, 191 + }) 192 + } 193 + 194 + export function markNotificationsRead( 195 + accessToken: string, 196 + ids: string[], 197 + options?: FetchOptions 198 + ): Promise<void> { 199 + return apiFetch<void>('/api/notifications/read', { 200 + ...options, 201 + method: 'PUT', 202 + headers: { 203 + ...options?.headers, 204 + Authorization: `Bearer ${accessToken}`, 205 + }, 206 + body: { ids }, 207 + }) 171 208 } 172 209 173 210 // --- Community endpoints ---
+23
src/lib/api/types.ts
··· 185 185 handle: string 186 186 } 187 187 188 + // --- Notifications --- 189 + 190 + export type NotificationType = 'reply' | 'reaction' | 'mention' | 'moderation' 191 + 192 + export interface Notification { 193 + id: string 194 + type: NotificationType 195 + userDid: string 196 + actorDid: string 197 + actorHandle: string 198 + subjectUri: string 199 + subjectTitle: string | null 200 + message: string 201 + read: boolean 202 + createdAt: string 203 + } 204 + 205 + export interface NotificationsResponse { 206 + notifications: Notification[] 207 + cursor: string | null 208 + unreadCount: number 209 + } 210 + 188 211 // --- Shared --- 189 212 190 213 export type MaturityRating = 'safe' | 'mature' | 'adult'
+54
src/mocks/data.ts
··· 8 8 CategoryWithTopicCount, 9 9 Topic, 10 10 Reply, 11 + Notification, 11 12 SearchResult, 12 13 } from '@/lib/api/types' 13 14 ··· 260 261 rank: 0.71, 261 262 rootUri: mockTopics[0]!.uri, 262 263 rootTitle: 'Welcome to Barazo Forums', 264 + }, 265 + ] 266 + 267 + // --- Notifications --- 268 + 269 + export const mockNotifications: Notification[] = [ 270 + { 271 + id: 'notif-1', 272 + type: 'reply', 273 + userDid: mockUsers[0]!.did, 274 + actorDid: mockUsers[1]!.did, 275 + actorHandle: mockUsers[1]!.handle, 276 + subjectUri: mockTopics[0]!.uri, 277 + subjectTitle: 'Welcome to Barazo Forums', 278 + message: 'bob.bsky.social replied to your topic', 279 + read: false, 280 + createdAt: NOW, 281 + }, 282 + { 283 + id: 'notif-2', 284 + type: 'reaction', 285 + userDid: mockUsers[0]!.did, 286 + actorDid: mockUsers[2]!.did, 287 + actorHandle: mockUsers[2]!.handle, 288 + subjectUri: mockTopics[0]!.uri, 289 + subjectTitle: 'Welcome to Barazo Forums', 290 + message: 'carol.example.com reacted to your topic', 291 + read: false, 292 + createdAt: YESTERDAY, 293 + }, 294 + { 295 + id: 'notif-3', 296 + type: 'mention', 297 + userDid: mockUsers[0]!.did, 298 + actorDid: mockUsers[3]!.did, 299 + actorHandle: mockUsers[3]!.handle, 300 + subjectUri: `at://${mockUsers[3]!.did}/forum.barazo.reply.post/3kf6ddd`, 301 + subjectTitle: null, 302 + message: 'dave.bsky.social mentioned you in a reply', 303 + read: true, 304 + createdAt: YESTERDAY, 305 + }, 306 + { 307 + id: 'notif-4', 308 + type: 'moderation', 309 + userDid: mockUsers[0]!.did, 310 + actorDid: mockUsers[4]!.did, 311 + actorHandle: mockUsers[4]!.handle, 312 + subjectUri: mockTopics[0]!.uri, 313 + subjectTitle: 'Welcome to Barazo Forums', 314 + message: 'Your topic was pinned by a moderator', 315 + read: true, 316 + createdAt: TWO_DAYS_AGO, 263 317 }, 264 318 ] 265 319
+33
src/mocks/handlers.ts
··· 11 11 mockTopics, 12 12 mockReplies, 13 13 mockSearchResults, 14 + mockNotifications, 14 15 } from './data' 15 16 16 17 const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000' 17 18 18 19 export const handlers = [ 20 + // GET /api/notifications 21 + http.get(`${API_URL}/api/notifications`, ({ request }) => { 22 + const auth = request.headers.get('Authorization') 23 + if (!auth?.startsWith('Bearer ')) { 24 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 25 + } 26 + 27 + const url = new URL(request.url) 28 + const limitParam = url.searchParams.get('limit') 29 + const limit = limitParam ? parseInt(limitParam, 10) : 20 30 + 31 + const limited = mockNotifications.slice(0, limit) 32 + const hasMore = mockNotifications.length > limit 33 + const unreadCount = mockNotifications.filter((n) => !n.read).length 34 + 35 + return HttpResponse.json({ 36 + notifications: limited, 37 + cursor: hasMore ? 'mock-cursor-next' : null, 38 + unreadCount, 39 + }) 40 + }), 41 + 42 + // PUT /api/notifications/read 43 + http.put(`${API_URL}/api/notifications/read`, async ({ request }) => { 44 + const auth = request.headers.get('Authorization') 45 + if (!auth?.startsWith('Bearer ')) { 46 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 47 + } 48 + 49 + return new HttpResponse(null, { status: 204 }) 50 + }), 51 + 19 52 // GET /api/categories 20 53 http.get(`${API_URL}/api/categories`, () => { 21 54 return HttpResponse.json({ categories: mockCategories })