feat: member portal Sprint 10 — Knowledge Base

- KnowledgeBoard + KnowledgeItem models + migration (DRAFT/PUBLISHED/ARCHIVED status, slug per tenant)
- KnowledgeModule: admin CRUD for boards + items (auto-slug, status workflow)
- Member endpoint GET /my/knowledge?q= — published items grouped by board, optional text search over question/answer
- /my/knowledge page: search box + collapsible boards with accordion FAQ items
- KnowledgeItem accordion + KnowledgeSearch (debounced URL-driven) client components
- Knowledge nav item; register KnowledgeModule

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 17:06:26 +05:30
parent f61c070428
commit 5801cbf85b
13 changed files with 479 additions and 1 deletions
@@ -0,0 +1,38 @@
'use client';
import { useState } from 'react';
interface Props {
question: string;
answer: string;
tags: string[];
}
export function KnowledgeItem({ question, answer, tags }: Props) {
const [open, setOpen] = useState(false);
return (
<div className="border-b border-gray-100 last:border-0">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="w-full flex items-center justify-between gap-3 py-3.5 text-left"
>
<span className="text-sm font-medium text-gray-900">{question}</span>
<span className={`text-gray-400 transition-transform shrink-0 ${open ? 'rotate-180' : ''}`}></span>
</button>
{open && (
<div className="pb-4">
<p className="text-sm text-gray-600 whitespace-pre-line leading-relaxed">{answer}</p>
{tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{tags.map((t) => (
<span key={t} className="text-[10px] bg-gray-100 text-gray-500 rounded-full px-2 py-0.5">{t}</span>
))}
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,30 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
export function KnowledgeSearch() {
const router = useRouter();
const searchParams = useSearchParams();
const [q, setQ] = useState(searchParams.get('q') ?? '');
useEffect(() => {
const handle = setTimeout(() => {
const params = new URLSearchParams();
if (q.trim()) params.set('q', q.trim());
router.replace(`/my/knowledge${params.toString() ? `?${params.toString()}` : ''}`);
}, 300);
return () => clearTimeout(handle);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q]);
return (
<input
type="text"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search the knowledge base…"
className="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { getMemberToken, getApiBaseUrl } from '../../_lib/api';
import { KnowledgeItem } from './KnowledgeItem';
import { KnowledgeSearch } from './KnowledgeSearch';
import Link from 'next/link';
interface Board {
id: string;
name: string;
description: string | null;
items: Array<{ id: string; question: string; answer: string; tags: string[] }>;
}
async function fetchKnowledge(token: string, q?: string): Promise<Board[]> {
const params = new URLSearchParams();
if (q) params.set('q', q);
const res = await fetch(`${getApiBaseUrl()}/my/knowledge${params.toString() ? `?${params.toString()}` : ''}`, {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
}).catch(() => null);
if (!res || !res.ok) return [];
return (await res.json()) as Board[];
}
export default async function KnowledgePage({
searchParams,
}: {
searchParams: Promise<{ q?: string }>;
}) {
const token = await getMemberToken();
if (!token) return null;
const { q } = await searchParams;
const boards = await fetchKnowledge(token, q);
return (
<div className="max-w-2xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-gray-900">Knowledge base</h1>
<Link href="/my" className="text-sm text-indigo-600 hover:underline"> Dashboard</Link>
</div>
<KnowledgeSearch />
{boards.length === 0 ? (
<div className="text-center py-16 text-gray-400">
<p className="text-sm">{q ? 'No matching articles.' : 'No knowledge articles yet.'}</p>
{!q && <p className="text-xs mt-1">Your chapter admin will publish FAQs and guides here.</p>}
</div>
) : (
<div className="space-y-5">
{boards.map((b) => (
<section key={b.id} className="bg-white rounded-xl border border-gray-200 p-5">
<h2 className="text-sm font-semibold text-gray-900">{b.name}</h2>
{b.description && <p className="text-xs text-gray-400 mt-0.5 mb-1">{b.description}</p>}
<div className="mt-1">
{b.items.map((item) => (
<KnowledgeItem key={item.id} question={item.question} answer={item.answer} tags={item.tags} />
))}
</div>
</section>
))}
</div>
)}
</div>
);
}
+1
View File
@@ -5,6 +5,7 @@ import { redirect } from 'next/navigation';
const NAV = [
{ href: '/my', label: 'Dashboard' },
{ href: '/my/ask', label: 'Ask AI' },
{ href: '/my/knowledge', label: 'Knowledge' },
{ href: '/my/digest', label: 'Digest' },
{ href: '/my/events', label: 'Events' },
{ href: '/my/seva', label: 'Seva & Points' },