Skip to content

Commit 284153f

Browse files
Merge pull request #628 from omonxooo-commits/feat/help-documentation-request-batching
feat: Help Documentation with Request Batching (#496)
2 parents ff1c8ae + 37b1e95 commit 284153f

5 files changed

Lines changed: 667 additions & 0 deletions

File tree

src/app/api/help/route.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { NextResponse } from 'next/server';
2+
import type { BatchRequest, BatchResponse } from '@/lib/api/batch';
3+
4+
export const runtime = 'edge';
5+
6+
export interface HelpArticle {
7+
id: string;
8+
title: string;
9+
content: string;
10+
category: string;
11+
tags: string[];
12+
}
13+
14+
/** Static help content keyed by article id */
15+
const HELP_ARTICLES: Record<string, HelpArticle> = {
16+
'getting-started': {
17+
id: 'getting-started',
18+
title: 'Getting Started with TeachLink',
19+
content:
20+
'Welcome to TeachLink! Connect your Starknet wallet to begin exploring courses, earning reputation, and tipping creators.',
21+
category: 'Onboarding',
22+
tags: ['wallet', 'starknet', 'beginner'],
23+
},
24+
'wallet-connect': {
25+
id: 'wallet-connect',
26+
title: 'Connecting Your Wallet',
27+
content:
28+
'TeachLink supports Argent X and Braavos wallets. Click the "Connect Wallet" button in the top navigation to get started.',
29+
category: 'Web3',
30+
tags: ['wallet', 'argent', 'braavos'],
31+
},
32+
tipping: {
33+
id: 'tipping',
34+
title: 'How Tipping Works',
35+
content:
36+
'Send on-chain tips to course creators using STRK tokens. Tips are processed via smart contracts on Starknet.',
37+
category: 'Web3',
38+
tags: ['tips', 'strk', 'creators'],
39+
},
40+
courses: {
41+
id: 'courses',
42+
title: 'Browsing and Enrolling in Courses',
43+
content:
44+
'Browse courses by topic, filter by skill level, and enroll with a single click. Progress is tracked on-chain.',
45+
category: 'Learning',
46+
tags: ['courses', 'enroll', 'progress'],
47+
},
48+
reputation: {
49+
id: 'reputation',
50+
title: 'Building Your Reputation',
51+
content:
52+
'Earn reputation points by completing courses, contributing to discussions, and receiving tips from peers.',
53+
category: 'Gamification',
54+
tags: ['reputation', 'points', 'achievements'],
55+
},
56+
};
57+
58+
/**
59+
* POST /api/help
60+
*
61+
* Accepts a batch of help article requests and returns all matching articles
62+
* in a single response, reducing round-trips for the HelpDocumentation component.
63+
*
64+
* Body: { requests: BatchRequest[] }
65+
* Response: { responses: BatchResponse<HelpArticle>[] }
66+
*/
67+
export async function POST(request: Request) {
68+
let body: { requests: BatchRequest[] };
69+
70+
try {
71+
body = await request.json();
72+
} catch {
73+
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
74+
}
75+
76+
if (!Array.isArray(body?.requests)) {
77+
return NextResponse.json({ error: 'requests must be an array' }, { status: 400 });
78+
}
79+
80+
const responses: BatchResponse<HelpArticle>[] = body.requests.map((req) => {
81+
const article = HELP_ARTICLES[req.path];
82+
if (!article) {
83+
return { id: req.id, error: `Article not found: ${req.path}` };
84+
}
85+
return { id: req.id, data: article };
86+
});
87+
88+
return NextResponse.json({ responses });
89+
}
90+
91+
/**
92+
* GET /api/help?ids=id1,id2
93+
*
94+
* Convenience endpoint for fetching multiple articles by comma-separated ids.
95+
*/
96+
export async function GET(request: Request) {
97+
const { searchParams } = new URL(request.url);
98+
const ids = searchParams.get('ids')?.split(',').filter(Boolean) ?? [];
99+
100+
if (ids.length === 0) {
101+
const all = Object.values(HELP_ARTICLES);
102+
return NextResponse.json({ articles: all });
103+
}
104+
105+
const articles = ids.map((id) => HELP_ARTICLES[id.trim()]).filter(Boolean);
106+
return NextResponse.json({ articles });
107+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import { HelpCircle, ChevronDown, ChevronUp, AlertCircle } from 'lucide-react';
5+
import { useHelpDocumentation } from '@/hooks/useHelpDocumentation';
6+
import type { HelpArticle } from '@/hooks/useHelpDocumentation';
7+
8+
export interface HelpDocumentationProps {
9+
/** Article ids to load on mount */
10+
articleIds?: string[];
11+
/** Optional heading shown above the article list */
12+
title?: string;
13+
className?: string;
14+
}
15+
16+
function ArticleItem({ article }: { article: HelpArticle }) {
17+
const [open, setOpen] = useState(false);
18+
19+
return (
20+
<div className="border border-gray-200 rounded-lg dark:border-gray-700">
21+
<button
22+
onClick={() => setOpen((v) => !v)}
23+
aria-expanded={open}
24+
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm font-medium text-gray-900 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500 dark:text-gray-100 dark:hover:bg-gray-800"
25+
>
26+
<span className="flex items-center gap-2">
27+
<HelpCircle size={16} aria-hidden="true" className="shrink-0 text-blue-500" />
28+
{article.title}
29+
</span>
30+
{open ? (
31+
<ChevronUp size={16} aria-hidden="true" className="shrink-0 text-gray-400" />
32+
) : (
33+
<ChevronDown size={16} aria-hidden="true" className="shrink-0 text-gray-400" />
34+
)}
35+
</button>
36+
37+
{open && (
38+
<div className="border-t border-gray-200 px-4 py-3 dark:border-gray-700">
39+
<p className="text-sm text-gray-700 dark:text-gray-300">{article.content}</p>
40+
{article.tags.length > 0 && (
41+
<div className="mt-2 flex flex-wrap gap-1" aria-label="Tags">
42+
{article.tags.map((tag) => (
43+
<span
44+
key={tag}
45+
className="rounded-full bg-blue-100 px-2 py-0.5 text-xs text-blue-700 dark:bg-blue-900 dark:text-blue-200"
46+
>
47+
{tag}
48+
</span>
49+
))}
50+
</div>
51+
)}
52+
</div>
53+
)}
54+
</div>
55+
);
56+
}
57+
58+
/**
59+
* HelpDocumentation
60+
*
61+
* Renders a list of collapsible help articles. Uses `useHelpDocumentation`
62+
* which batches concurrent article requests into a single API call.
63+
*/
64+
export function HelpDocumentation({
65+
articleIds = ['getting-started', 'wallet-connect', 'tipping', 'courses', 'reputation'],
66+
title = 'Help & Documentation',
67+
className = '',
68+
}: HelpDocumentationProps) {
69+
const { articles, loading, error, fetchArticles } = useHelpDocumentation(articleIds);
70+
71+
return (
72+
<section
73+
aria-label={title}
74+
className={`rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900 ${className}`}
75+
>
76+
<h2 className="mb-4 flex items-center gap-2 text-base font-semibold text-gray-900 dark:text-gray-50">
77+
<HelpCircle size={18} aria-hidden="true" className="text-blue-500" />
78+
{title}
79+
</h2>
80+
81+
{loading && (
82+
<div role="status" aria-live="polite" className="space-y-2">
83+
{[1, 2, 3].map((i) => (
84+
<div key={i} className="h-10 animate-pulse rounded-lg bg-gray-100 dark:bg-gray-800" />
85+
))}
86+
<span className="sr-only">Loading help articles…</span>
87+
</div>
88+
)}
89+
90+
{error && !loading && (
91+
<div
92+
role="alert"
93+
className="flex items-center gap-2 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300"
94+
>
95+
<AlertCircle size={16} aria-hidden="true" className="shrink-0" />
96+
<span>{error}</span>
97+
<button
98+
onClick={() => fetchArticles(articleIds)}
99+
className="ml-auto underline hover:no-underline focus:outline-none focus:ring-2 focus:ring-red-500"
100+
>
101+
Retry
102+
</button>
103+
</div>
104+
)}
105+
106+
{!loading && !error && articles.length === 0 && (
107+
<p className="text-sm text-gray-500 dark:text-gray-400">No help articles found.</p>
108+
)}
109+
110+
{!loading && articles.length > 0 && (
111+
<ul className="space-y-2" aria-label="Help articles">
112+
{articles.map((article) => (
113+
<li key={article.id}>
114+
<ArticleItem article={article} />
115+
</li>
116+
))}
117+
</ul>
118+
)}
119+
</section>
120+
);
121+
}

src/hooks/useHelpDocumentation.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
'use client';
2+
3+
import { useState, useEffect, useRef, useCallback } from 'react';
4+
import { createBatcher } from '@/lib/api/batch';
5+
import type { HelpArticle } from '@/app/api/help/route';
6+
import type { BatchRequest, BatchResponse } from '@/lib/api/batch';
7+
8+
export type { HelpArticle };
9+
10+
export interface UseHelpDocumentationResult {
11+
articles: HelpArticle[];
12+
loading: boolean;
13+
error: string | null;
14+
/** Fetch additional articles by id on demand */
15+
fetchArticles: (ids: string[]) => void;
16+
}
17+
18+
/** Shared batcher instance – created once per module load */
19+
const helpBatcher = createBatcher<HelpArticle>({
20+
debounceMs: 10,
21+
maxBatchSize: 20,
22+
executor: async (requests: BatchRequest[]) => {
23+
const res = await fetch('/api/help', {
24+
method: 'POST',
25+
headers: { 'Content-Type': 'application/json' },
26+
body: JSON.stringify({ requests }),
27+
});
28+
if (!res.ok) throw new Error(`Help API error: ${res.status}`);
29+
const json = await res.json();
30+
return json.responses as BatchResponse[];
31+
},
32+
});
33+
34+
/**
35+
* useHelpDocumentation
36+
*
37+
* Fetches one or more help articles via the shared request batcher so that
38+
* multiple components mounting simultaneously share a single network call.
39+
*
40+
* @param articleIds - Article ids to load on mount (optional)
41+
*/
42+
export function useHelpDocumentation(articleIds: string[] = []): UseHelpDocumentationResult {
43+
const [articles, setArticles] = useState<HelpArticle[]>([]);
44+
const [loading, setLoading] = useState(false);
45+
const [error, setError] = useState<string | null>(null);
46+
const mountedRef = useRef(true);
47+
48+
useEffect(() => {
49+
mountedRef.current = true;
50+
return () => {
51+
mountedRef.current = false;
52+
};
53+
}, []);
54+
55+
const fetchArticles = useCallback((ids: string[]) => {
56+
if (ids.length === 0) return;
57+
setLoading(true);
58+
setError(null);
59+
60+
const promises = ids.map((id) => helpBatcher.queue({ id, path: id }));
61+
62+
Promise.allSettled(promises).then((results) => {
63+
if (!mountedRef.current) return;
64+
65+
const fetched: HelpArticle[] = [];
66+
let firstError: string | null = null;
67+
68+
for (const result of results) {
69+
if (result.status === 'fulfilled' && result.value) {
70+
fetched.push(result.value);
71+
} else if (result.status === 'rejected' && !firstError) {
72+
firstError =
73+
result.reason instanceof Error ? result.reason.message : String(result.reason);
74+
}
75+
}
76+
77+
setArticles((prev) => {
78+
const existingIds = new Set(prev.map((a) => a.id));
79+
const newOnes = fetched.filter((a) => !existingIds.has(a.id));
80+
return newOnes.length > 0 ? [...prev, ...newOnes] : prev;
81+
});
82+
if (firstError) setError(firstError);
83+
setLoading(false);
84+
});
85+
}, []);
86+
87+
useEffect(() => {
88+
if (articleIds.length > 0) {
89+
fetchArticles(articleIds);
90+
}
91+
// eslint-disable-next-line react-hooks/exhaustive-deps
92+
}, [articleIds.join(',')]);
93+
94+
return { articles, loading, error, fetchArticles };
95+
}

0 commit comments

Comments
 (0)