Knowledge Base Frontend Implementation Guide¶
This guide provides step-by-step instructions to complete the knowledge base frontend implementation for the Producer Portal.
✅ What's Already Complete¶
- TypeScript Types -
src/types/knowledge.ts - All interfaces for Category, Tag, Article, Feedback
-
Properly exported in
src/types/index.ts -
API Client Functions -
src/lib/api.ts - fetchKnowledgeCategories()
- fetchKnowledgeArticle(slug)
- searchKnowledgeArticles(query)
- submitArticleFeedback(slug, feedback)
- Plus 12 more helper functions
🚀 Next Steps: Pages & Components¶
Directory Structure to Create¶
apps/producer/src/
├── app/dashboard/knowledge/
│ ├── page.tsx # ⬅️ HOME PAGE
│ ├── category/
│ │ └── [slug]/
│ │ └── page.tsx # ⬅️ CATEGORY PAGE
│ ├── article/
│ │ └── [slug]/
│ │ └── page.tsx # ⬅️ ARTICLE DETAIL PAGE
│ └── search/
│ └── page.tsx # ⬅️ SEARCH RESULTS PAGE
│
└── components/knowledge/
├── ArticleCard.tsx # ⬅️ ARTICLE PREVIEW CARD
├── CategoryCard.tsx # ⬅️ CATEGORY CARD WITH ICON
├── FeedbackWidget.tsx # ⬅️ HELPFUL/NOT HELPFUL WIDGET
├── SearchBar.tsx # ⬅️ SEARCH INPUT
└── RelatedArticles.tsx # ⬅️ RELATED ARTICLES SIDEBAR
📄 Page 1: Knowledge Base Home¶
File: apps/producer/src/app/dashboard/knowledge/page.tsx
import { Suspense } from 'react';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { redirect } from 'next/navigation';
import {
fetchKnowledgeCategories,
fetchFeaturedArticles,
fetchRecentArticles
} from '@/lib/api';
import type { ExtendedSession } from '@/lib/api';
import CategoryCard from '@/components/knowledge/CategoryCard';
import ArticleCard from '@/components/knowledge/ArticleCard';
import SearchBar from '@/components/knowledge/SearchBar';
export const metadata = {
title: 'Knowledge Base | PiqueTickets Producer Portal',
description: 'Find answers and learn how to use the Producer Portal',
};
export default async function KnowledgeBasePage() {
const session = (await getServerSession(authOptions)) as ExtendedSession;
if (!session) {
redirect('/login');
}
// Fetch data in parallel
const [categoriesRes, featuredRes, recentRes] = await Promise.all([
fetchKnowledgeCategories(session),
fetchFeaturedArticles(session),
fetchRecentArticles(session),
]);
const categories = categoriesRes.data || [];
const featured = featuredRes.data || [];
const recent = recentRes.data || [];
return (
<div className="container mx-auto px-4 py-8 max-w-7xl">
{/* Header */}
<div className="mb-8">
<h1 className="text-4xl font-bold mb-2">Knowledge Base</h1>
<p className="text-muted-foreground text-lg">
Everything you need to know about using PiqueTickets
</p>
</div>
{/* Search Bar */}
<div className="mb-12">
<SearchBar />
</div>
{/* Categories Grid */}
<section className="mb-12">
<h2 className="text-2xl font-semibold mb-6">Browse by Category</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{categories.map((category) => (
<CategoryCard key={category.id} category={category} />
))}
</div>
</section>
{/* Featured Articles */}
{featured.length > 0 && (
<section className="mb-12">
<h2 className="text-2xl font-semibold mb-6">Featured Articles</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{featured.slice(0, 3).map((article) => (
<ArticleCard key={article.id} article={article} />
))}
</div>
</section>
)}
{/* Recent Articles */}
<section>
<h2 className="text-2xl font-semibold mb-6">Recent Articles</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{recent.slice(0, 6).map((article) => (
<ArticleCard key={article.id} article={article} />
))}
</div>
</section>
</div>
);
}
📄 Page 2: Category View¶
File: apps/producer/src/app/dashboard/knowledge/category/[slug]/page.tsx
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { redirect } from 'next/navigation';
import { fetchKnowledgeCategoryBySlug, fetchCategoryArticles } from '@/lib/api';
import type { ExtendedSession } from '@/lib/api';
import ArticleCard from '@/components/knowledge/ArticleCard';
import { ArrowLeft } from 'lucide-react';
import Link from 'next/link';
export async function generateMetadata({ params }: { params: { slug: string } }) {
return {
title: `${params.slug} | Knowledge Base`,
};
}
export default async function CategoryPage({ params }: { params: { slug: string } }) {
const session = (await getServerSession(authOptions)) as ExtendedSession;
if (!session) {
redirect('/login');
}
const [categoryRes, articlesRes] = await Promise.all([
fetchKnowledgeCategoryBySlug(params.slug, session),
fetchCategoryArticles(params.slug, session),
]);
if (categoryRes.error || !categoryRes.data) {
redirect('/dashboard/knowledge');
}
const category = categoryRes.data;
const articles = articlesRes.data || [];
return (
<div className="container mx-auto px-4 py-8 max-w-7xl">
{/* Breadcrumb */}
<nav className="mb-6">
<Link
href="/dashboard/knowledge"
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Knowledge Base
</Link>
</nav>
{/* Category Header */}
<div className="mb-8">
<div className="flex items-center gap-4 mb-4">
{category.icon && (
<span className="material-icons text-4xl">{category.icon}</span>
)}
<h1 className="text-4xl font-bold">{category.name}</h1>
</div>
{category.description && (
<p className="text-muted-foreground text-lg">{category.description}</p>
)}
<p className="text-sm text-muted-foreground mt-2">
{category.article_count} {category.article_count === 1 ? 'article' : 'articles'}
</p>
</div>
{/* Subcategories */}
{category.subcategories && category.subcategories.length > 0 && (
<section className="mb-8">
<h2 className="text-xl font-semibold mb-4">Subcategories</h2>
<div className="flex flex-wrap gap-2">
{category.subcategories.map((subcat) => (
<Link
key={subcat.id}
href={`/dashboard/knowledge/category/${subcat.slug}`}
className="px-4 py-2 bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/80 transition-colors"
>
{subcat.name}
</Link>
))}
</div>
</section>
)}
{/* Articles */}
<section>
{articles.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{articles.map((article) => (
<ArticleCard key={article.id} article={article} />
))}
</div>
) : (
<div className="text-center py-12">
<p className="text-muted-foreground">No articles in this category yet.</p>
</div>
)}
</section>
</div>
);
}
📄 Page 3: Article Detail¶
File: apps/producer/src/app/dashboard/knowledge/article/[slug]/page.tsx
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { redirect } from 'next/navigation';
import { fetchKnowledgeArticle, fetchRelatedArticles } from '@/lib/api';
import type { ExtendedSession } from '@/lib/api';
import FeedbackWidget from '@/components/knowledge/FeedbackWidget';
import RelatedArticles from '@/components/knowledge/RelatedArticles';
import { Clock, Calendar, Tag } from 'lucide-react';
import Link from 'next/link';
import Image from 'next/image';
export async function generateMetadata({ params }: { params: { slug: string } }) {
return {
title: `${params.slug} | Knowledge Base`,
};
}
export default async function ArticlePage({ params }: { params: { slug: string } }) {
const session = (await getServerSession(authOptions)) as ExtendedSession;
if (!session) {
redirect('/login');
}
const [articleRes, relatedRes] = await Promise.all([
fetchKnowledgeArticle(params.slug, session),
fetchRelatedArticles(params.slug, session),
]);
if (articleRes.error || !articleRes.data) {
redirect('/dashboard/knowledge');
}
const article = articleRes.data;
const related = relatedRes.data || [];
return (
<div className="container mx-auto px-4 py-8 max-w-7xl">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Main Content */}
<div className="lg:col-span-2">
{/* Breadcrumb */}
<nav className="mb-6 text-sm">
<Link href="/dashboard/knowledge" className="text-muted-foreground hover:text-foreground">
Knowledge Base
</Link>
<span className="mx-2">/</span>
<Link
href={`/dashboard/knowledge/category/${article.category.slug}`}
className="text-muted-foreground hover:text-foreground"
>
{article.category.name}
</Link>
</nav>
{/* Article Header */}
<article>
<h1 className="text-4xl font-bold mb-4">{article.title}</h1>
{/* Meta Info */}
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground mb-6">
<div className="flex items-center gap-1">
<Clock className="h-4 w-4" />
{article.estimated_read_time} min read
</div>
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{new Date(article.published_at || '').toLocaleDateString()}
</div>
<div className="flex items-center gap-1">
👁️ {article.view_count} views
</div>
</div>
{/* Featured Image */}
{article.featured_image && (
<div className="mb-8 rounded-lg overflow-hidden">
<Image
src={article.featured_image}
alt={article.featured_image_alt_text || article.title}
width={1200}
height={600}
className="w-full h-auto"
/>
</div>
)}
{/* Article Content */}
<div
className="prose prose-lg max-w-none mb-8"
dangerouslySetInnerHTML={{ __html: article.content }}
/>
{/* Tags */}
{article.tags && article.tags.length > 0 && (
<div className="mb-8">
<div className="flex items-center gap-2 mb-3">
<Tag className="h-4 w-4" />
<span className="font-semibold">Tags:</span>
</div>
<div className="flex flex-wrap gap-2">
{article.tags.map((tag) => (
<Link
key={tag.id}
href={`/dashboard/knowledge?tag=${tag.slug}`}
className="px-3 py-1 bg-secondary text-secondary-foreground rounded-full text-sm hover:bg-secondary/80"
>
{tag.name}
</Link>
))}
</div>
</div>
)}
{/* Feedback Widget */}
<FeedbackWidget article={article} />
</article>
</div>
{/* Sidebar */}
<aside className="lg:col-span-1">
<RelatedArticles articles={related} />
</aside>
</div>
</div>
);
}
📄 Page 4: Search Results¶
File: apps/producer/src/app/dashboard/knowledge/search/page.tsx
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { redirect } from 'next/navigation';
import { searchKnowledgeArticles } from '@/lib/api';
import type { ExtendedSession } from '@/lib/api';
import ArticleCard from '@/components/knowledge/ArticleCard';
import SearchBar from '@/components/knowledge/SearchBar';
import Link from 'next/link';
export default async function SearchPage({
searchParams,
}: {
searchParams: { q?: string; category?: string; tag?: string };
}) {
const session = (await getServerSession(authOptions)) as ExtendedSession;
if (!session) {
redirect('/login');
}
const query = searchParams.q || '';
if (!query) {
redirect('/dashboard/knowledge');
}
const searchRes = await searchKnowledgeArticles(
query,
session,
searchParams.category,
searchParams.tag
);
const results = searchRes.data?.results || [];
const count = searchRes.data?.count || 0;
return (
<div className="container mx-auto px-4 py-8 max-w-7xl">
{/* Header */}
<div className="mb-8">
<h1 className="text-4xl font-bold mb-2">Search Results</h1>
<p className="text-muted-foreground">
Found {count} {count === 1 ? 'result' : 'results'} for "{query}"
</p>
</div>
{/* Search Bar */}
<div className="mb-8">
<SearchBar initialQuery={query} />
</div>
{/* Results */}
{results.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{results.map((article) => (
<ArticleCard key={article.id} article={article} highlightQuery={query} />
))}
</div>
) : (
<div className="text-center py-12">
<p className="text-muted-foreground text-lg mb-4">
No articles found matching your search.
</p>
<Link
href="/dashboard/knowledge"
className="text-primary hover:underline"
>
Browse all categories
</Link>
</div>
)}
</div>
);
}
🧩 Component 1: ArticleCard¶
File: apps/producer/src/components/knowledge/ArticleCard.tsx
'use client';
import Link from 'next/link';
import Image from 'next/image';
import { Clock, ThumbsUp } from 'lucide-react';
import type { ArticleListItem } from '@/types/knowledge';
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
interface ArticleCardProps {
article: ArticleListItem;
highlightQuery?: string;
}
export default function ArticleCard({ article, highlightQuery }: ArticleCardProps) {
return (
<Link href={`/dashboard/knowledge/article/${article.slug}`}>
<Card className="h-full hover:shadow-lg transition-shadow cursor-pointer">
{article.featured_image && (
<div className="relative h-48 w-full overflow-hidden rounded-t-lg">
<Image
src={article.featured_image}
alt={article.featured_image_alt_text || article.title}
fill
className="object-cover"
/>
</div>
)}
<CardHeader>
<div className="flex items-start justify-between gap-2 mb-2">
<Badge variant="outline">{article.category_name}</Badge>
{article.featured && <Badge>Featured</Badge>}
</div>
<h3 className="font-semibold text-lg line-clamp-2">{article.title}</h3>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm line-clamp-3">
{article.excerpt}
</p>
</CardContent>
<CardFooter className="flex items-center justify-between text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{article.estimated_read_time} min
</div>
{article.helpfulness_percentage !== null && (
<div className="flex items-center gap-1">
<ThumbsUp className="h-3 w-3" />
{article.helpfulness_percentage}%
</div>
)}
</CardFooter>
</Card>
</Link>
);
}
🧩 Component 2: CategoryCard¶
File: apps/producer/src/components/knowledge/CategoryCard.tsx
'use client';
import Link from 'next/link';
import type { Category } from '@/types/knowledge';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
interface CategoryCardProps {
category: Category;
}
export default function CategoryCard({ category }: CategoryCardProps) {
return (
<Link href={`/dashboard/knowledge/category/${category.slug}`}>
<Card className="h-full hover:shadow-lg transition-shadow cursor-pointer">
<CardHeader>
<div className="flex items-center gap-3">
{category.icon && (
<span className="material-icons text-3xl text-primary">
{category.icon}
</span>
)}
<div>
<h3 className="font-semibold text-lg">{category.name}</h3>
<p className="text-sm text-muted-foreground">
{category.article_count} {category.article_count === 1 ? 'article' : 'articles'}
</p>
</div>
</div>
</CardHeader>
{category.description && (
<CardContent>
<p className="text-sm text-muted-foreground line-clamp-2">
{category.description}
</p>
</CardContent>
)}
</Card>
</Link>
);
}
🧩 Component 3: FeedbackWidget¶
File: apps/producer/src/components/knowledge/FeedbackWidget.tsx
'use client';
import { useState } from 'react';
import { ThumbsUp, ThumbsDown } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { submitArticleFeedback } from '@/lib/api';
import { useSession } from 'next-auth/react';
import type { Article } from '@/types/knowledge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
interface FeedbackWidgetProps {
article: Article;
}
export default function FeedbackWidget({ article }: FeedbackWidgetProps) {
const { data: session } = useSession();
const [submitted, setSubmitted] = useState(!!article.user_feedback);
const [isHelpful, setIsHelpful] = useState<boolean | null>(
article.user_feedback?.is_helpful ?? null
);
const [comment, setComment] = useState('');
const [showComment, setShowComment] = useState(false);
const [loading, setLoading] = useState(false);
const handleFeedback = async (helpful: boolean) => {
if (submitted || !session) return;
setLoading(true);
setIsHelpful(helpful);
if (!helpful) {
setShowComment(true);
setLoading(false);
return;
}
const result = await submitArticleFeedback(
article.slug,
{ is_helpful: helpful },
session as any
);
setLoading(false);
if (result.data) {
setSubmitted(true);
}
};
const handleSubmitWithComment = async () => {
if (!session) return;
setLoading(true);
const result = await submitArticleFeedback(
article.slug,
{ is_helpful: false, comment },
session as any
);
setLoading(false);
if (result.data) {
setSubmitted(true);
setShowComment(false);
}
};
if (submitted) {
return (
<Card className="bg-green-50 border-green-200">
<CardContent className="pt-6">
<p className="text-green-800 text-center">
Thank you for your feedback! 🎉
</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">Was this article helpful?</CardTitle>
</CardHeader>
<CardContent>
{!showComment ? (
<div className="flex gap-4">
<Button
onClick={() => handleFeedback(true)}
disabled={loading}
variant="outline"
className="flex-1"
>
<ThumbsUp className="mr-2 h-4 w-4" />
Yes, helpful
</Button>
<Button
onClick={() => handleFeedback(false)}
disabled={loading}
variant="outline"
className="flex-1"
>
<ThumbsDown className="mr-2 h-4 w-4" />
No, not helpful
</Button>
</div>
) : (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
We're sorry this wasn't helpful. Can you tell us why?
</p>
<Textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Your feedback helps us improve..."
rows={3}
/>
<div className="flex gap-2">
<Button onClick={handleSubmitWithComment} disabled={loading}>
Submit Feedback
</Button>
<Button
variant="ghost"
onClick={() => {
setShowComment(false);
setIsHelpful(null);
}}
>
Cancel
</Button>
</div>
</div>
)}
</CardContent>
</Card>
);
}
🧩 Component 4: SearchBar¶
File: apps/producer/src/components/knowledge/SearchBar.tsx
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Search } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
interface SearchBarProps {
initialQuery?: string;
}
export default function SearchBar({ initialQuery = '' }: SearchBarProps) {
const router = useRouter();
const [query, setQuery] = useState(initialQuery);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (query.trim()) {
router.push(`/dashboard/knowledge/search?q=${encodeURIComponent(query)}`);
}
};
return (
<form onSubmit={handleSubmit} className="flex gap-2 max-w-2xl">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Search articles..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-10"
/>
</div>
<Button type="submit">Search</Button>
</form>
);
}
🧩 Component 5: RelatedArticles¶
File: apps/producer/src/components/knowledge/RelatedArticles.tsx
import Link from 'next/link';
import type { ArticleListItem } from '@/types/knowledge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Clock } from 'lucide-react';
interface RelatedArticlesProps {
articles: ArticleListItem[];
}
export default function RelatedArticles({ articles }: RelatedArticlesProps) {
if (articles.length === 0) {
return null;
}
return (
<Card>
<CardHeader>
<CardTitle>Related Articles</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{articles.map((article) => (
<Link
key={article.id}
href={`/dashboard/knowledge/article/${article.slug}`}
className="block group"
>
<h4 className="font-medium group-hover:text-primary transition-colors mb-1">
{article.title}
</h4>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{article.category_name}</span>
<span>•</span>
<div className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{article.estimated_read_time} min
</div>
</div>
</Link>
))}
</div>
</CardContent>
</Card>
);
}
🔧 Final Step: Update Navigation¶
Update the sidebar to include the Knowledge Base link.
Find the sidebar navigation file (likely apps/producer/src/components/layout/Sidebar.tsx or similar) and add:
{
title: 'Knowledge Base',
url: '/dashboard/knowledge',
icon: 'BookOpen', // or whatever icon set you're using
description: 'Help & Documentation',
}
🚦 Feature Flag Integration¶
The Knowledge Base is protected by the knowledgeBaseEnabled producer-level feature flag. This integration was completed as part of PIQUE-506.
How It Works¶
- Navigation Display
- The Knowledge Base navigation item only appears when
knowledgeBaseEnabledis true - Configured in
apps/producer/src/constants/data.tswithfeatureFlag: 'knowledgeBaseEnabled' -
The sidebar automatically filters feature-flagged items using
useFeatureFlags()hook -
Route Protection
- All routes under
/dashboard/knowledge/*are protected by a layout component - Located at:
apps/producer/src/app/dashboard/knowledge/layout.tsx - Performs server-side check of feature flag before rendering any pages
-
Redirects to
/dashboard/overviewif feature is disabled -
Implementation Details
Enabling/Disabling the Feature¶
Via Django Admin:
1. Navigate to /admin/
2. Go to "Producer Profiles"
3. Find the producer profile
4. Check/uncheck "Knowledge Base Enabled"
5. Save changes
Effect: - When enabled: Nav item appears, all routes accessible - When disabled: Nav item hidden, direct URL access redirected - Changes take effect after page refresh or cache expiration (5 minutes)
Testing Feature Flags¶
Test Scenarios:
1. ✅ Enable flag → Nav item appears
2. ✅ Enable flag → Can access /dashboard/knowledge
3. ✅ Disable flag → Nav item disappears
4. ✅ Disable flag → Direct URL access redirects to dashboard
5. ✅ Disable flag → All sub-routes redirect (category, article, search)
Related Files:
- apps/producer/src/constants/data.ts - Navigation configuration
- apps/producer/src/app/dashboard/knowledge/layout.tsx - Route protection
- apps/producer/src/hooks/use-feature-flags.ts - Feature flag hook
- apps/producer/src/components/layout/app-sidebar.tsx - Sidebar filtering
- apps/producer/src/types/index.ts - FeatureFlags interface
🎨 Styling Notes¶
The components use Shadcn UI components (Card, Button, Input, etc.) that should already be installed in your project. If any are missing, install them with:
✅ Testing Checklist¶
Once implemented, test:
- Home Page - Categories, featured articles, search bar all display
- Category Page - Articles filter correctly, breadcrumbs work
- Article Detail - Content renders, images display, feedback works
- Search - Returns relevant results, filters work
- Navigation - All links work correctly
- Mobile - Responsive on all screen sizes
🚀 Next Steps After Implementation¶
- Run migrations on the backend (when Docker is available)
- Create sample categories and articles via Django admin
- Test the complete flow from frontend to backend
- Add Material Icons if using icon-based categories
- Consider adding pagination for long article lists
Questions or Issues?
- Check the backend README:
apps/api/knowledge/README.md - Review API responses in browser DevTools
- Ensure JWT auth is working correctly
Good luck with the implementation! 🎉