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/shared/MarkdownRenderer.test.ts b/src/components/shared/MarkdownRenderer.test.ts index 7c1e02af..9f677175 100644 --- a/src/components/shared/MarkdownRenderer.test.ts +++ b/src/components/shared/MarkdownRenderer.test.ts @@ -144,4 +144,54 @@ describe('markdownToHtml', () => { // Verify we at least produce an img tag (format check) expect(html).toContain(' { + expect(markdownToHtml('This is ~~deleted~~ text')).toContain('deleted'); + }); + + // ── GFM: Task lists ─────────────────────────────────────────────────────── + + it('renders unchecked task list item', () => { + const html = markdownToHtml('- [ ] Buy milk'); + expect(html).toContain('
    '); + expect(html).toContain('type="checkbox"'); + expect(html).toContain('Buy milk'); + expect(html).not.toContain('checked'); + }); + + it('renders checked task list item', () => { + const html = markdownToHtml('- [x] Done task'); + expect(html).toContain('checked'); + expect(html).toContain('Done task'); + }); + + it('renders mixed task list', () => { + const md = '- [x] First\n- [ ] Second'; + const html = markdownToHtml(md); + expect(html).toContain('
      '); + expect(html).toContain('First'); + expect(html).toContain('Second'); + }); + + // ── GFM: Tables ─────────────────────────────────────────────────────────── + + it('renders a simple GFM table', () => { + const md = '| Name | Age |\n| --- | --- |\n| Alice | 30 |\n| Bob | 25 |'; + const html = markdownToHtml(md); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + }); + + it('renders table with leading/trailing pipes', () => { + const md = '| A | B |\n|---|---|\n| 1 | 2 |'; + const html = markdownToHtml(md); + expect(html).toContain(''); + expect(html).toContain(''); + }); }); diff --git a/src/components/shared/MarkdownRenderer.tsx b/src/components/shared/MarkdownRenderer.tsx index 52b0354a..8d99e557 100644 --- a/src/components/shared/MarkdownRenderer.tsx +++ b/src/components/shared/MarkdownRenderer.tsx @@ -10,11 +10,14 @@ import { useMemo } from 'react'; * - Headings: `# H1`, `## H2`, `### H3` * - Bold: `**text**` or `__text__` * - Italic: `*text*` or `_text_` + * - Strikethrough: `~~text~~` (GFM) * - Inline code: `` `code` `` * - Fenced code blocks: ` ```lang\n...\n``` ` * - Blockquotes: `> text` * - Unordered lists: `- item` or `* item` + * - Task lists: `- [ ] todo` / `- [x] done` (GFM) * - Ordered lists: `1. item` + * - Tables: GFM pipe tables * - Links: `[label](url)` * - Images: `![alt](url)` * - Horizontal rules: `---` @@ -33,6 +36,46 @@ export function markdownToHtml(markdown: string): string { return `
      ${escapeHtml(code.trimEnd())}
      `; }); + // GFM tables — must run before other block rules + html = html.replace(/((?:^[^\n]*\|[^\n]*(?:\n|$))+)/gm, (block) => { + const lines = block.trim().split('\n'); + if (lines.length < 2) return block; + const isSeparator = (l: string) => /^[\s|:-]+$/.test(l); + const sepIdx = lines.findIndex(isSeparator); + if (sepIdx < 1) return block; + + const parseRow = (line: string) => + line + .replace(/^\||\|$/g, '') + .split('|') + .map((cell) => cell.trim()); + + const headers = parseRow(lines[0]); + const thead = `${headers.map((h) => ``).join('')}`; + const bodyRows = lines + .slice(sepIdx + 1) + .filter((l) => l.trim() && !isSeparator(l)) + .map( + (l) => + `${parseRow(l) + .map((c) => ``) + .join('')}`, + ) + .join(''); + return `
      NameAgeAliceBobA1
      ${h}
      ${c}
      ${thead}${bodyRows}
      `; + }); + + // Task lists — must run before unordered list rule + html = html.replace(/^[*-] \[( |x)\] (.+)$/gm, (_m, checked, label) => { + const attrs = checked === 'x' ? ' checked disabled' : ' disabled'; + return `
    • ${label}
    • `; + }); + + // Wrap consecutive task-list
    • items in
        + html = html.replace(/((?:
      • .*<\/li>\n?)+)/g, (block) => { + return `
          ${block}
        `; + }); + // Headings html = html.replace(/^### (.+)$/gm, '

        $1

        '); html = html.replace(/^## (.+)$/gm, '

        $1

        '); @@ -72,6 +115,9 @@ export function markdownToHtml(markdown: string): string { html = html.replace(/(?$1'); html = html.replace(/(?$1'); + // Strikethrough (GFM) + html = html.replace(/~~(.+?)~~/g, '$1'); + // Inline code html = html.replace(/`([^`]+)`/g, '$1'); @@ -133,12 +179,35 @@ export function MarkdownRenderer({ content, className = '' }: MarkdownRendererPr if (typeof window === 'undefined') return raw; return DOMPurify.sanitize(raw, { ALLOWED_TAGS: [ - 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', - 'p', 'br', 'strong', 'em', 'code', 'pre', - 'ul', 'ol', 'li', 'blockquote', 'hr', - 'a', 'img', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'p', + 'br', + 'strong', + 'em', + 'del', + 'code', + 'pre', + 'ul', + 'ol', + 'li', + 'blockquote', + 'hr', + 'a', + 'img', + 'table', + 'thead', + 'tbody', + 'tr', + 'th', + 'td', + 'input', ], - ALLOWED_ATTR: ['href', 'src', 'alt', 'class', 'target', 'rel'], + ALLOWED_ATTR: ['href', 'src', 'alt', 'class', 'target', 'rel', 'type', 'checked', 'disabled'], }); }, [content]); 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 }; +}