diff --git a/src/app/notifications/page.test.tsx b/src/app/notifications/page.test.tsx new file mode 100644 index 0000000..c0ad7bd --- /dev/null +++ b/src/app/notifications/page.test.tsx @@ -0,0 +1,172 @@ +/** + * Tests for notifications page. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, act, cleanup } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { axe } from 'vitest-axe' +import NotificationsPage from './page' + +// Mock next/navigation +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), +})) + +// Mock next-themes +vi.mock('next-themes', () => ({ + useTheme: () => ({ theme: 'dark', setTheme: vi.fn() }), + ThemeProvider: ({ children }: { children: React.ReactNode }) => children, +})) + +// Mock next/image +vi.mock('next/image', () => ({ + default: (props: Record) => { + // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text + return + }, +})) + +// Mock next/link +vi.mock('next/link', () => ({ + default: ({ + children, + href, + ...props + }: { children: React.ReactNode; href: string } & Record) => ( + + {children} + + ), +})) + +// Mock API client +vi.mock('@/lib/api/client', () => ({ + getNotifications: vi.fn(), + markNotificationsRead: vi.fn(), +})) + +import { getNotifications, markNotificationsRead } from '@/lib/api/client' + +const mockGetNotifications = vi.mocked(getNotifications) +const mockMarkRead = vi.mocked(markNotificationsRead) + +const mockNotifications = [ + { + id: 'notif-1', + type: 'reply' as const, + userDid: 'did:plc:user', + actorDid: 'did:plc:bob', + actorHandle: 'bob.bsky.social', + subjectUri: 'at://did:plc:user/forum.barazo.topic.post/abc', + subjectTitle: 'My Topic', + message: 'bob.bsky.social replied to your topic', + read: false, + createdAt: '2026-02-14T12:00:00Z', + }, + { + id: 'notif-2', + type: 'reaction' as const, + userDid: 'did:plc:user', + actorDid: 'did:plc:carol', + actorHandle: 'carol.example.com', + subjectUri: 'at://did:plc:user/forum.barazo.topic.post/abc', + subjectTitle: 'My Topic', + message: 'carol.example.com reacted to your topic', + read: true, + createdAt: '2026-02-13T12:00:00Z', + }, +] + +describe('NotificationsPage', () => { + beforeEach(() => { + vi.clearAllMocks() + cleanup() + mockGetNotifications.mockResolvedValue({ + notifications: mockNotifications, + cursor: null, + unreadCount: 1, + }) + mockMarkRead.mockResolvedValue(undefined) + }) + + it('renders page heading', async () => { + render() + expect(screen.getByRole('heading', { level: 1, name: /notification/i })).toBeInTheDocument() + }) + + it('displays notifications from API', async () => { + render() + + await act(async () => { + await new Promise((r) => setTimeout(r, 100)) + }) + + expect(await screen.findByText(/bob\.bsky\.social replied/)).toBeInTheDocument() + expect(screen.getByText(/carol\.example\.com reacted/)).toBeInTheDocument() + }) + + it('shows unread indicator on unread notifications', async () => { + render() + + await act(async () => { + await new Promise((r) => setTimeout(r, 100)) + }) + + const items = screen.getAllByRole('article') + // First notification is unread + expect(items[0]).toHaveClass('border-l-primary') + }) + + it('renders mark all read button', async () => { + render() + + await act(async () => { + await new Promise((r) => setTimeout(r, 100)) + }) + + expect(screen.getByRole('button', { name: /mark all read/i })).toBeInTheDocument() + }) + + it('calls markNotificationsRead on mark all', async () => { + const user = userEvent.setup() + render() + + await act(async () => { + await new Promise((r) => setTimeout(r, 100)) + }) + + const button = screen.getByRole('button', { name: /mark all read/i }) + await user.click(button) + + expect(mockMarkRead).toHaveBeenCalled() + }) + + it('shows empty state when no notifications', async () => { + mockGetNotifications.mockResolvedValue({ + notifications: [], + cursor: null, + unreadCount: 0, + }) + + render() + + await act(async () => { + await new Promise((r) => setTimeout(r, 100)) + }) + + expect(await screen.findByText(/no notifications/i)).toBeInTheDocument() + }) + + it('renders breadcrumbs', () => { + render() + const nav = screen.getByRole('navigation', { name: /breadcrumb/i }) + expect(nav).toBeInTheDocument() + }) + + it('passes axe accessibility check', async () => { + const { container } = render() + const results = await axe(container) + expect(results).toHaveNoViolations() + }) +}) diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx new file mode 100644 index 0000000..4464673 --- /dev/null +++ b/src/app/notifications/page.tsx @@ -0,0 +1,150 @@ +/** + * Notifications page. + * URL: /notifications + * Lists user notifications with mark-read functionality. + * @see specs/prd-web.md Section M10 + */ + +'use client' + +import { useState, useEffect, useCallback } from 'react' +import Link from 'next/link' +import { ChatCircle, Heart, At, ShieldCheck, CheckCircle } from '@phosphor-icons/react' +import { ForumLayout } from '@/components/layout/forum-layout' +import { Breadcrumbs } from '@/components/breadcrumbs' +import { getNotifications, markNotificationsRead } from '@/lib/api/client' +import { cn } from '@/lib/utils' +import type { Notification, NotificationType } from '@/lib/api/types' + +// TODO: Replace with actual auth token from session +const MOCK_TOKEN = 'mock-access-token' + +const NOTIFICATION_ICONS: Record = { + reply: ChatCircle, + reaction: Heart, + mention: At, + moderation: ShieldCheck, +} + +export default function NotificationsPage() { + const [notifications, setNotifications] = useState([]) + const [loading, setLoading] = useState(true) + + const fetchNotifications = useCallback(async () => { + try { + const response = await getNotifications(MOCK_TOKEN) + setNotifications(response.notifications) + } catch { + // Silently handle - notifications are non-critical + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + void fetchNotifications() + }, [fetchNotifications]) + + const handleMarkAllRead = useCallback(async () => { + const unreadIds = notifications.filter((n) => !n.read).map((n) => n.id) + if (unreadIds.length === 0) return + + try { + await markNotificationsRead(MOCK_TOKEN, unreadIds) + setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))) + } catch { + // Silently handle + } + }, [notifications]) + + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }) + } + + const hasUnread = notifications.some((n) => !n.read) + + return ( + +
+ + +
+

Notifications

+ {hasUnread && ( + + )} +
+ + {loading && ( +
+
+
+
+
+ )} + + {!loading && notifications.length === 0 && ( +

+ No notifications yet. You’ll be notified when someone replies, reacts, or mentions + you. +

+ )} + + {!loading && notifications.length > 0 && ( +
+ {notifications.map((notification) => { + const Icon = NOTIFICATION_ICONS[notification.type] + return ( +
+
+
+
+
+

{notification.message}

+ {notification.subjectTitle && ( + + {notification.subjectTitle} + + )} +

+ {formatDate(notification.createdAt)} +

+
+ {!notification.read && ( + + )} +
+
+ ) + })} +
+ )} +
+ + ) +} diff --git a/src/app/settings/page.test.tsx b/src/app/settings/page.test.tsx index 50def7a..7b45827 100644 --- a/src/app/settings/page.test.tsx +++ b/src/app/settings/page.test.tsx @@ -42,7 +42,9 @@ describe('SettingsPage', () => { it('renders notification preferences section', () => { render() - expect(screen.getByText(/notifications/i)).toBeInTheDocument() + // Find the fieldset legend specifically (not the header notification bell's ARIA text) + const legends = screen.getAllByText(/notifications/i) + expect(legends.some((el) => el.tagName === 'LEGEND')).toBe(true) }) it('renders save button', () => { diff --git a/src/components/layout/forum-layout.test.tsx b/src/components/layout/forum-layout.test.tsx index 92ba129..978549a 100644 --- a/src/components/layout/forum-layout.test.tsx +++ b/src/components/layout/forum-layout.test.tsx @@ -91,6 +91,15 @@ describe('ForumLayout', () => { expect(screen.getByText('Skip to main content')).toBeInTheDocument() }) + it('renders notification bell in header', () => { + render( + +

Content

+
+ ) + expect(screen.getByRole('link', { name: /notification/i })).toBeInTheDocument() + }) + it('renders search input in header', () => { render( diff --git a/src/components/layout/forum-layout.tsx b/src/components/layout/forum-layout.tsx index 6112955..e0362c0 100644 --- a/src/components/layout/forum-layout.tsx +++ b/src/components/layout/forum-layout.tsx @@ -10,6 +10,7 @@ import Image from 'next/image' import { SkipLinks } from '@/components/skip-links' import { ThemeToggle } from '@/components/theme-toggle' import { SearchInput } from '@/components/search-input' +import { NotificationBell } from '@/components/notification-bell' import { MagnifyingGlass } from '@phosphor-icons/react/dist/ssr' interface ForumLayoutProps { @@ -60,6 +61,7 @@ export function ForumLayout({ children, sidebar }: ForumLayoutProps) { >
diff --git a/src/components/notification-bell.test.tsx b/src/components/notification-bell.test.tsx new file mode 100644 index 0000000..f58f459 --- /dev/null +++ b/src/components/notification-bell.test.tsx @@ -0,0 +1,62 @@ +/** + * Tests for NotificationBell component. + */ + +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import { axe } from 'vitest-axe' +import { NotificationBell } from './notification-bell' + +// Mock next/link +vi.mock('next/link', () => ({ + default: ({ + children, + href, + ...props + }: { children: React.ReactNode; href: string } & Record) => ( + + {children} + + ), +})) + +describe('NotificationBell', () => { + it('renders as link to notifications page', () => { + render() + const link = screen.getByRole('link', { name: /notification/i }) + expect(link).toHaveAttribute('href', '/notifications') + }) + + it('shows count badge when unread > 0', () => { + render() + expect(screen.getByText('3')).toBeInTheDocument() + }) + + it('hides count badge when unread is 0', () => { + render() + expect(screen.queryByText('0')).not.toBeInTheDocument() + }) + + it('shows 99+ for large counts', () => { + render() + expect(screen.getByText('99+')).toBeInTheDocument() + }) + + it('announces count via ARIA live region', () => { + render() + const status = screen.getByRole('status') + expect(status).toHaveTextContent('5 unread notifications') + }) + + it('announces no unread via ARIA live region', () => { + render() + const status = screen.getByRole('status') + expect(status).toHaveTextContent('No unread notifications') + }) + + it('passes axe accessibility check', async () => { + const { container } = render() + const results = await axe(container) + expect(results).toHaveNoViolations() + }) +}) diff --git a/src/components/notification-bell.tsx b/src/components/notification-bell.tsx new file mode 100644 index 0000000..5632aa8 --- /dev/null +++ b/src/components/notification-bell.tsx @@ -0,0 +1,41 @@ +/** + * NotificationBell - Header notification icon with unread count badge. + * ARIA live region announces count changes for screen readers. + * @see specs/prd-web.md Section M10 (Notifications) + */ + +import Link from 'next/link' +import { Bell } from '@phosphor-icons/react/dist/ssr' +import { cn } from '@/lib/utils' + +interface NotificationBellProps { + unreadCount: number + className?: string +} + +export function NotificationBell({ unreadCount, className }: NotificationBellProps) { + const displayCount = unreadCount > 99 ? '99+' : String(unreadCount) + const hasUnread = unreadCount > 0 + + return ( +
+ +
+ ) +} diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 5313fb8..a2104d5 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -15,6 +15,7 @@ import type { UpdateTopicInput, RepliesResponse, SearchResponse, + NotificationsResponse, PaginationParams, } from './types' @@ -170,6 +171,42 @@ export function searchContent( return apiFetch(`/api/search${query}`, options) } +// --- Notification endpoints --- + +export function getNotifications( + accessToken: string, + params: PaginationParams = {}, + options?: FetchOptions +): Promise { + const query = buildQuery({ + limit: params.limit, + cursor: params.cursor, + }) + return apiFetch(`/api/notifications${query}`, { + ...options, + headers: { + ...options?.headers, + Authorization: `Bearer ${accessToken}`, + }, + }) +} + +export function markNotificationsRead( + accessToken: string, + ids: string[], + options?: FetchOptions +): Promise { + return apiFetch('/api/notifications/read', { + ...options, + method: 'PUT', + headers: { + ...options?.headers, + Authorization: `Bearer ${accessToken}`, + }, + body: { ids }, + }) +} + // --- Community endpoints --- export function getCommunitySettings(options?: FetchOptions): Promise { diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index fa71446..383e47b 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -185,6 +185,29 @@ export interface AuthUser { handle: string } +// --- Notifications --- + +export type NotificationType = 'reply' | 'reaction' | 'mention' | 'moderation' + +export interface Notification { + id: string + type: NotificationType + userDid: string + actorDid: string + actorHandle: string + subjectUri: string + subjectTitle: string | null + message: string + read: boolean + createdAt: string +} + +export interface NotificationsResponse { + notifications: Notification[] + cursor: string | null + unreadCount: number +} + // --- Shared --- export type MaturityRating = 'safe' | 'mature' | 'adult' diff --git a/src/mocks/data.ts b/src/mocks/data.ts index 406eba1..80043fa 100644 --- a/src/mocks/data.ts +++ b/src/mocks/data.ts @@ -8,6 +8,7 @@ import type { CategoryWithTopicCount, Topic, Reply, + Notification, SearchResult, } from '@/lib/api/types' @@ -263,6 +264,59 @@ export const mockSearchResults: SearchResult[] = [ }, ] +// --- Notifications --- + +export const mockNotifications: Notification[] = [ + { + id: 'notif-1', + type: 'reply', + userDid: mockUsers[0]!.did, + actorDid: mockUsers[1]!.did, + actorHandle: mockUsers[1]!.handle, + subjectUri: mockTopics[0]!.uri, + subjectTitle: 'Welcome to Barazo Forums', + message: 'bob.bsky.social replied to your topic', + read: false, + createdAt: NOW, + }, + { + id: 'notif-2', + type: 'reaction', + userDid: mockUsers[0]!.did, + actorDid: mockUsers[2]!.did, + actorHandle: mockUsers[2]!.handle, + subjectUri: mockTopics[0]!.uri, + subjectTitle: 'Welcome to Barazo Forums', + message: 'carol.example.com reacted to your topic', + read: false, + createdAt: YESTERDAY, + }, + { + id: 'notif-3', + type: 'mention', + userDid: mockUsers[0]!.did, + actorDid: mockUsers[3]!.did, + actorHandle: mockUsers[3]!.handle, + subjectUri: `at://${mockUsers[3]!.did}/forum.barazo.reply.post/3kf6ddd`, + subjectTitle: null, + message: 'dave.bsky.social mentioned you in a reply', + read: true, + createdAt: YESTERDAY, + }, + { + id: 'notif-4', + type: 'moderation', + userDid: mockUsers[0]!.did, + actorDid: mockUsers[4]!.did, + actorHandle: mockUsers[4]!.handle, + subjectUri: mockTopics[0]!.uri, + subjectTitle: 'Welcome to Barazo Forums', + message: 'Your topic was pinned by a moderator', + read: true, + createdAt: TWO_DAYS_AGO, + }, +] + // --- Replies --- export const mockReplies: Reply[] = [ diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index 1bae039..44d7252 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -11,11 +11,44 @@ import { mockTopics, mockReplies, mockSearchResults, + mockNotifications, } from './data' const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000' export const handlers = [ + // GET /api/notifications + http.get(`${API_URL}/api/notifications`, ({ request }) => { + const auth = request.headers.get('Authorization') + if (!auth?.startsWith('Bearer ')) { + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const url = new URL(request.url) + const limitParam = url.searchParams.get('limit') + const limit = limitParam ? parseInt(limitParam, 10) : 20 + + const limited = mockNotifications.slice(0, limit) + const hasMore = mockNotifications.length > limit + const unreadCount = mockNotifications.filter((n) => !n.read).length + + return HttpResponse.json({ + notifications: limited, + cursor: hasMore ? 'mock-cursor-next' : null, + unreadCount, + }) + }), + + // PUT /api/notifications/read + http.put(`${API_URL}/api/notifications/read`, async ({ request }) => { + const auth = request.headers.get('Authorization') + if (!auth?.startsWith('Bearer ')) { + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + return new HttpResponse(null, { status: 204 }) + }), + // GET /api/categories http.get(`${API_URL}/api/categories`, () => { return HttpResponse.json({ categories: mockCategories })