Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions src/app/api/help/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, HelpArticle> = {
'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<HelpArticle>[] }
*/
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<HelpArticle>[] = 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 });
}
121 changes: 121 additions & 0 deletions src/components/ui/HelpDocumentation.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="border border-gray-200 rounded-lg dark:border-gray-700">
<button
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
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"
>
<span className="flex items-center gap-2">
<HelpCircle size={16} aria-hidden="true" className="shrink-0 text-blue-500" />
{article.title}
</span>
{open ? (
<ChevronUp size={16} aria-hidden="true" className="shrink-0 text-gray-400" />
) : (
<ChevronDown size={16} aria-hidden="true" className="shrink-0 text-gray-400" />
)}
</button>

{open && (
<div className="border-t border-gray-200 px-4 py-3 dark:border-gray-700">
<p className="text-sm text-gray-700 dark:text-gray-300">{article.content}</p>
{article.tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1" aria-label="Tags">
{article.tags.map((tag) => (
<span
key={tag}
className="rounded-full bg-blue-100 px-2 py-0.5 text-xs text-blue-700 dark:bg-blue-900 dark:text-blue-200"
>
{tag}
</span>
))}
</div>
)}
</div>
)}
</div>
);
}

/**
* 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 (
<section
aria-label={title}
className={`rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900 ${className}`}
>
<h2 className="mb-4 flex items-center gap-2 text-base font-semibold text-gray-900 dark:text-gray-50">
<HelpCircle size={18} aria-hidden="true" className="text-blue-500" />
{title}
</h2>

{loading && (
<div role="status" aria-live="polite" className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-10 animate-pulse rounded-lg bg-gray-100 dark:bg-gray-800" />
))}
<span className="sr-only">Loading help articles…</span>
</div>
)}

{error && !loading && (
<div
role="alert"
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"
>
<AlertCircle size={16} aria-hidden="true" className="shrink-0" />
<span>{error}</span>
<button
onClick={() => fetchArticles(articleIds)}
className="ml-auto underline hover:no-underline focus:outline-none focus:ring-2 focus:ring-red-500"
>
Retry
</button>
</div>
)}

{!loading && !error && articles.length === 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400">No help articles found.</p>
)}

{!loading && articles.length > 0 && (
<ul className="space-y-2" aria-label="Help articles">
{articles.map((article) => (
<li key={article.id}>
<ArticleItem article={article} />
</li>
))}
</ul>
)}
</section>
);
}
95 changes: 95 additions & 0 deletions src/hooks/useHelpDocumentation.ts
Original file line number Diff line number Diff line change
@@ -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<HelpArticle>({
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<HelpArticle[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 };
}
Loading
Loading