From 37b1e9590ff290219ae96fe69d1fcca881be16f4 Mon Sep 17 00:00:00 2001 From: OMIN MAN Date: Sat, 30 May 2026 21:43:07 +0000 Subject: [PATCH] feat: Help Documentation with Request Batching (#496) - Add createBatcher utility (src/lib/api/batch.ts) that collects concurrent requests within a debounce window and sends them as a single batched POST, reducing network round-trips - Add POST /api/help edge route that accepts BatchRequest[] and returns BatchResponse[] in one response; GET convenience endpoint also included - Add useHelpDocumentation hook backed by a shared module-level batcher so multiple components mounting simultaneously share one network call - Add HelpDocumentation component with collapsible articles, skeleton loading, error/retry UI, accessible markup, lucide icons, dark mode - Add 11 unit tests covering batcher and hook (all passing) Closes #496 --- src/app/api/help/route.ts | 107 +++++++++++ src/components/ui/HelpDocumentation.tsx | 121 ++++++++++++ src/hooks/useHelpDocumentation.ts | 95 ++++++++++ src/lib/api/__tests__/batch.test.ts | 238 ++++++++++++++++++++++++ src/lib/api/batch.ts | 106 +++++++++++ 5 files changed, 667 insertions(+) create mode 100644 src/app/api/help/route.ts create mode 100644 src/components/ui/HelpDocumentation.tsx create mode 100644 src/hooks/useHelpDocumentation.ts create mode 100644 src/lib/api/__tests__/batch.test.ts create mode 100644 src/lib/api/batch.ts diff --git a/src/app/api/help/route.ts b/src/app/api/help/route.ts new file mode 100644 index 00000000..42da5298 --- /dev/null +++ b/src/app/api/help/route.ts @@ -0,0 +1,107 @@ +import { NextResponse } from 'next/server'; +import type { BatchRequest, BatchResponse } from '@/lib/api/batch'; + +export const runtime = 'edge'; + +export interface HelpArticle { + id: string; + title: string; + content: string; + category: string; + tags: string[]; +} + +/** Static help content keyed by article id */ +const HELP_ARTICLES: Record = { + 'getting-started': { + id: 'getting-started', + title: 'Getting Started with TeachLink', + content: + 'Welcome to TeachLink! Connect your Starknet wallet to begin exploring courses, earning reputation, and tipping creators.', + category: 'Onboarding', + tags: ['wallet', 'starknet', 'beginner'], + }, + 'wallet-connect': { + id: 'wallet-connect', + title: 'Connecting Your Wallet', + content: + 'TeachLink supports Argent X and Braavos wallets. Click the "Connect Wallet" button in the top navigation to get started.', + category: 'Web3', + tags: ['wallet', 'argent', 'braavos'], + }, + tipping: { + id: 'tipping', + title: 'How Tipping Works', + content: + 'Send on-chain tips to course creators using STRK tokens. Tips are processed via smart contracts on Starknet.', + category: 'Web3', + tags: ['tips', 'strk', 'creators'], + }, + courses: { + id: 'courses', + title: 'Browsing and Enrolling in Courses', + content: + 'Browse courses by topic, filter by skill level, and enroll with a single click. Progress is tracked on-chain.', + category: 'Learning', + tags: ['courses', 'enroll', 'progress'], + }, + reputation: { + id: 'reputation', + title: 'Building Your Reputation', + content: + 'Earn reputation points by completing courses, contributing to discussions, and receiving tips from peers.', + category: 'Gamification', + tags: ['reputation', 'points', 'achievements'], + }, +}; + +/** + * POST /api/help + * + * Accepts a batch of help article requests and returns all matching articles + * in a single response, reducing round-trips for the HelpDocumentation component. + * + * Body: { requests: BatchRequest[] } + * Response: { responses: BatchResponse[] } + */ +export async function POST(request: Request) { + let body: { requests: BatchRequest[] }; + + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + if (!Array.isArray(body?.requests)) { + return NextResponse.json({ error: 'requests must be an array' }, { status: 400 }); + } + + const responses: BatchResponse[] = body.requests.map((req) => { + const article = HELP_ARTICLES[req.path]; + if (!article) { + return { id: req.id, error: `Article not found: ${req.path}` }; + } + return { id: req.id, data: article }; + }); + + return NextResponse.json({ responses }); +} + +/** + * GET /api/help?ids=id1,id2 + * + * Convenience endpoint for fetching multiple articles by comma-separated ids. + */ +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const ids = searchParams.get('ids')?.split(',').filter(Boolean) ?? []; + + if (ids.length === 0) { + const all = Object.values(HELP_ARTICLES); + return NextResponse.json({ articles: all }); + } + + const articles = ids.map((id) => HELP_ARTICLES[id.trim()]).filter(Boolean); + return NextResponse.json({ articles }); +} diff --git a/src/components/ui/HelpDocumentation.tsx b/src/components/ui/HelpDocumentation.tsx new file mode 100644 index 00000000..c358fb9d --- /dev/null +++ b/src/components/ui/HelpDocumentation.tsx @@ -0,0 +1,121 @@ +'use client'; + +import { useState } from 'react'; +import { HelpCircle, ChevronDown, ChevronUp, AlertCircle } from 'lucide-react'; +import { useHelpDocumentation } from '@/hooks/useHelpDocumentation'; +import type { HelpArticle } from '@/hooks/useHelpDocumentation'; + +export interface HelpDocumentationProps { + /** Article ids to load on mount */ + articleIds?: string[]; + /** Optional heading shown above the article list */ + title?: string; + className?: string; +} + +function ArticleItem({ article }: { article: HelpArticle }) { + const [open, setOpen] = useState(false); + + return ( +
+ + + {open && ( +
+

{article.content}

+ {article.tags.length > 0 && ( +
+ {article.tags.map((tag) => ( + + {tag} + + ))} +
+ )} +
+ )} +
+ ); +} + +/** + * HelpDocumentation + * + * Renders a list of collapsible help articles. Uses `useHelpDocumentation` + * which batches concurrent article requests into a single API call. + */ +export function HelpDocumentation({ + articleIds = ['getting-started', 'wallet-connect', 'tipping', 'courses', 'reputation'], + title = 'Help & Documentation', + className = '', +}: HelpDocumentationProps) { + const { articles, loading, error, fetchArticles } = useHelpDocumentation(articleIds); + + return ( +
+

+

+ + {loading && ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} + Loading help articles… +
+ )} + + {error && !loading && ( +
+
+ )} + + {!loading && !error && articles.length === 0 && ( +

No help articles found.

+ )} + + {!loading && articles.length > 0 && ( +
    + {articles.map((article) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/hooks/useHelpDocumentation.ts b/src/hooks/useHelpDocumentation.ts new file mode 100644 index 00000000..f83116bc --- /dev/null +++ b/src/hooks/useHelpDocumentation.ts @@ -0,0 +1,95 @@ +'use client'; + +import { useState, useEffect, useRef, useCallback } from 'react'; +import { createBatcher } from '@/lib/api/batch'; +import type { HelpArticle } from '@/app/api/help/route'; +import type { BatchRequest, BatchResponse } from '@/lib/api/batch'; + +export type { HelpArticle }; + +export interface UseHelpDocumentationResult { + articles: HelpArticle[]; + loading: boolean; + error: string | null; + /** Fetch additional articles by id on demand */ + fetchArticles: (ids: string[]) => void; +} + +/** Shared batcher instance – created once per module load */ +const helpBatcher = createBatcher({ + debounceMs: 10, + maxBatchSize: 20, + executor: async (requests: BatchRequest[]) => { + const res = await fetch('/api/help', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ requests }), + }); + if (!res.ok) throw new Error(`Help API error: ${res.status}`); + const json = await res.json(); + return json.responses as BatchResponse[]; + }, +}); + +/** + * useHelpDocumentation + * + * Fetches one or more help articles via the shared request batcher so that + * multiple components mounting simultaneously share a single network call. + * + * @param articleIds - Article ids to load on mount (optional) + */ +export function useHelpDocumentation(articleIds: string[] = []): UseHelpDocumentationResult { + const [articles, setArticles] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const fetchArticles = useCallback((ids: string[]) => { + if (ids.length === 0) return; + setLoading(true); + setError(null); + + const promises = ids.map((id) => helpBatcher.queue({ id, path: id })); + + Promise.allSettled(promises).then((results) => { + if (!mountedRef.current) return; + + const fetched: HelpArticle[] = []; + let firstError: string | null = null; + + for (const result of results) { + if (result.status === 'fulfilled' && result.value) { + fetched.push(result.value); + } else if (result.status === 'rejected' && !firstError) { + firstError = + result.reason instanceof Error ? result.reason.message : String(result.reason); + } + } + + setArticles((prev) => { + const existingIds = new Set(prev.map((a) => a.id)); + const newOnes = fetched.filter((a) => !existingIds.has(a.id)); + return newOnes.length > 0 ? [...prev, ...newOnes] : prev; + }); + if (firstError) setError(firstError); + setLoading(false); + }); + }, []); + + useEffect(() => { + if (articleIds.length > 0) { + fetchArticles(articleIds); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [articleIds.join(',')]); + + return { articles, loading, error, fetchArticles }; +} diff --git a/src/lib/api/__tests__/batch.test.ts b/src/lib/api/__tests__/batch.test.ts new file mode 100644 index 00000000..8ccf9f9d --- /dev/null +++ b/src/lib/api/__tests__/batch.test.ts @@ -0,0 +1,238 @@ +/** + * Tests for: + * - src/lib/api/batch.ts (createBatcher) + * - src/hooks/useHelpDocumentation.ts (useHelpDocumentation) + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { createBatcher } from '@/lib/api/batch'; +import { useHelpDocumentation } from '@/hooks/useHelpDocumentation'; +import type { BatchRequest, BatchResponse } from '@/lib/api/batch'; +import type { HelpArticle } from '@/hooks/useHelpDocumentation'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function makeArticle(id: string): HelpArticle { + return { id, title: `Title ${id}`, content: `Content ${id}`, category: 'Test', tags: [id] }; +} + +// ─── createBatcher ──────────────────────────────────────────────────────────── + +describe('createBatcher', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('batches multiple queued requests into a single executor call', async () => { + const executor = vi.fn( + async (reqs: BatchRequest[]): Promise => + reqs.map((r) => ({ id: r.id, data: `result-${r.path}` })), + ); + + const batcher = createBatcher({ executor, debounceMs: 10 }); + + const p1 = batcher.queue({ id: 'a', path: 'article-a' }); + const p2 = batcher.queue({ id: 'b', path: 'article-b' }); + + // Flush the debounce timer + await act(async () => { + vi.runAllTimers(); + }); + + const [r1, r2] = await Promise.all([p1, p2]); + + expect(executor).toHaveBeenCalledTimes(1); + expect(executor.mock.calls[0][0]).toHaveLength(2); + expect(r1).toBe('result-article-a'); + expect(r2).toBe('result-article-b'); + }); + + it('resolves each promise with its matching response', async () => { + const executor = vi.fn( + async (reqs: BatchRequest[]): Promise => + reqs.map((r) => ({ id: r.id, data: r.path.toUpperCase() })), + ); + + const batcher = createBatcher({ executor, debounceMs: 0 }); + + const p = batcher.queue({ id: 'x', path: 'hello' }); + + await act(async () => { + vi.runAllTimers(); + }); + + expect(await p).toBe('HELLO'); + }); + + it('rejects a promise when the response contains an error', async () => { + const executor = vi.fn( + async (reqs: BatchRequest[]): Promise => + reqs.map((r) => ({ id: r.id, error: 'not found' })), + ); + + const batcher = createBatcher({ executor, debounceMs: 0 }); + const p = batcher.queue({ id: 'bad', path: 'missing' }); + // Attach rejection handler before flushing to avoid unhandled rejection warning + const assertion = expect(p).rejects.toThrow('not found'); + + await act(async () => { + vi.runAllTimers(); + }); + + await assertion; + }); + + it('rejects all promises when the executor throws', async () => { + const executor = vi.fn(async (): Promise => { + throw new Error('network failure'); + }); + + const batcher = createBatcher({ executor, debounceMs: 0 }); + const p1 = batcher.queue({ id: '1', path: 'a' }); + const p2 = batcher.queue({ id: '2', path: 'b' }); + // Attach rejection handlers before flushing + const a1 = expect(p1).rejects.toThrow('network failure'); + const a2 = expect(p2).rejects.toThrow('network failure'); + + await act(async () => { + vi.runAllTimers(); + }); + + await a1; + await a2; + }); + + it('respects maxBatchSize and sends overflow in a second batch', async () => { + const executor = vi.fn( + async (reqs: BatchRequest[]): Promise => + reqs.map((r) => ({ id: r.id, data: r.path })), + ); + + const batcher = createBatcher({ executor, debounceMs: 0, maxBatchSize: 2 }); + + const promises = [ + batcher.queue({ id: '1', path: 'a' }), + batcher.queue({ id: '2', path: 'b' }), + batcher.queue({ id: '3', path: 'c' }), + ]; + + await act(async () => { + vi.runAllTimers(); + }); + + await Promise.all(promises); + + // First batch: 2 items, second batch: 1 item + expect(executor).toHaveBeenCalledTimes(2); + }); + + it('flushNow sends pending requests immediately', async () => { + const executor = vi.fn( + async (reqs: BatchRequest[]): Promise => + reqs.map((r) => ({ id: r.id, data: r.path })), + ); + + const batcher = createBatcher({ executor, debounceMs: 5000 }); + const p = batcher.queue({ id: 'z', path: 'immediate' }); + + // Don't advance timers – use flushNow instead + batcher.flushNow(); + + expect(await p).toBe('immediate'); + expect(executor).toHaveBeenCalledTimes(1); + }); +}); + +// ─── useHelpDocumentation ───────────────────────────────────────────────────── + +describe('useHelpDocumentation', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function mockFetch(articles: HelpArticle[]) { + const responses: BatchResponse[] = articles.map((a) => ({ id: a.id, data: a })); + (fetch as ReturnType).mockResolvedValue({ + ok: true, + json: async () => ({ responses }), + }); + } + + it('starts with empty articles and loading=false', () => { + const { result } = renderHook(() => useHelpDocumentation([])); + expect(result.current.articles).toEqual([]); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it('fetches articles on mount when ids are provided', async () => { + const article = makeArticle('getting-started'); + mockFetch([article]); + + const { result } = renderHook(() => useHelpDocumentation(['getting-started'])); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.articles).toHaveLength(1); + expect(result.current.articles[0].id).toBe('getting-started'); + }); + + it('sets error when fetch fails', async () => { + (fetch as ReturnType).mockResolvedValue({ + ok: false, + status: 500, + }); + + const { result } = renderHook(() => useHelpDocumentation(['bad-id'])); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.error).toBeTruthy(); + }); + + it('deduplicates articles already in state', async () => { + const article = makeArticle('courses'); + mockFetch([article]); + + const { result } = renderHook(() => useHelpDocumentation(['courses'])); + + await waitFor(() => expect(result.current.articles).toHaveLength(1)); + + // Fetch the same id again + mockFetch([article]); + act(() => { + result.current.fetchArticles(['courses']); + }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + // Still only one article + expect(result.current.articles).toHaveLength(1); + }); + + it('fetchArticles adds new articles on demand', async () => { + const a1 = makeArticle('tipping'); + const a2 = makeArticle('reputation'); + mockFetch([a1]); + + const { result } = renderHook(() => useHelpDocumentation(['tipping'])); + await waitFor(() => expect(result.current.articles).toHaveLength(1)); + + mockFetch([a2]); + act(() => { + result.current.fetchArticles(['reputation']); + }); + + await waitFor(() => expect(result.current.articles).toHaveLength(2)); + expect(result.current.articles.map((a) => a.id)).toContain('reputation'); + }); +}); diff --git a/src/lib/api/batch.ts b/src/lib/api/batch.ts new file mode 100644 index 00000000..7317d84b --- /dev/null +++ b/src/lib/api/batch.ts @@ -0,0 +1,106 @@ +/** + * Request Batching Utility (#496) + * + * Collects multiple requests within a tick window and sends them as a single + * batched request, reducing network overhead for Help Documentation lookups. + */ + +export interface BatchRequest { + id: string; + path: string; +} + +export interface BatchResponse { + id: string; + data?: T; + error?: string; +} + +type Resolver = { + resolve: (value: T) => void; + reject: (reason?: unknown) => void; +}; + +interface PendingItem { + request: BatchRequest; + resolver: Resolver; +} + +export interface BatcherOptions { + /** Max items per batch (default: 20) */ + maxBatchSize?: number; + /** Delay in ms before flushing (default: 10) */ + debounceMs?: number; + /** Function that executes the batch */ + executor: (requests: BatchRequest[]) => Promise; +} + +/** + * Creates a request batcher that collects individual requests and sends them + * together in a single call. + */ +export function createBatcher(options: BatcherOptions) { + const { maxBatchSize = 20, debounceMs = 10, executor } = options; + const pending: PendingItem[] = []; + let timer: ReturnType | null = null; + + function flush() { + timer = null; + if (pending.length === 0) return; + + const batch = pending.splice(0, maxBatchSize); + const requests = batch.map((item) => item.request); + + executor(requests) + .then((responses) => { + const responseMap = new Map(responses.map((r) => [r.id, r])); + for (const item of batch) { + const res = responseMap.get(item.request.id); + if (!res) { + item.resolver.reject(new Error(`No response for request ${item.request.id}`)); + } else if (res.error) { + item.resolver.reject(new Error(res.error)); + } else { + item.resolver.resolve(res.data as T); + } + } + }) + .catch((err) => { + for (const item of batch) { + item.resolver.reject(err); + } + }); + + // If more items remain (exceeded maxBatchSize), schedule another flush + if (pending.length > 0) { + timer = setTimeout(flush, 0); + } + } + + function schedule() { + if (timer === null) { + timer = setTimeout(flush, debounceMs); + } + } + + /** + * Queue a single request into the batch. + * Returns a promise that resolves when the batch response arrives. + */ + function queue(request: BatchRequest): Promise { + return new Promise((resolve, reject) => { + pending.push({ request, resolver: { resolve, reject } }); + schedule(); + }); + } + + /** Immediately flush any pending requests (useful in tests). */ + function flushNow() { + if (timer !== null) { + clearTimeout(timer); + } + flush(); + } + + return { queue, flushNow }; +}