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:
@@ -0,0 +1,11 @@
|
||||
import { jsonResponse, memberApiFetch } from '../../../_lib/api';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const qs = url.searchParams.toString();
|
||||
const res = await memberApiFetch(`/my/knowledge${qs ? `?${qs}` : ''}`);
|
||||
const body = await res.json();
|
||||
return jsonResponse(body, res.status);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user