diff --git a/src/app/c/[slug]/error.test.tsx b/src/app/c/[...slug]/error.test.tsx similarity index 100% rename from src/app/c/[slug]/error.test.tsx rename to src/app/c/[...slug]/error.test.tsx diff --git a/src/app/c/[slug]/error.tsx b/src/app/c/[...slug]/error.tsx similarity index 100% rename from src/app/c/[slug]/error.tsx rename to src/app/c/[...slug]/error.tsx diff --git a/src/app/c/[slug]/layout.tsx b/src/app/c/[...slug]/layout.tsx similarity index 100% rename from src/app/c/[slug]/layout.tsx rename to src/app/c/[...slug]/layout.tsx diff --git a/src/app/c/[slug]/page.test.tsx b/src/app/c/[...slug]/page.test.tsx similarity index 81% rename from src/app/c/[slug]/page.test.tsx rename to src/app/c/[...slug]/page.test.tsx index b51a349..fe64099 100644 --- a/src/app/c/[slug]/page.test.tsx +++ b/src/app/c/[...slug]/page.test.tsx @@ -78,7 +78,7 @@ beforeEach(() => { vi.mocked(getPublicSettings).mockResolvedValue(mockPublicSettings) }) -const params = Promise.resolve({ slug: 'general' }) +const params = Promise.resolve({ slug: ['general'] }) describe('CategoryPage', () => { it('renders category name as heading', async () => { @@ -124,4 +124,23 @@ describe('CategoryPage', () => { }) expect(breadcrumbScript).toBeTruthy() }) + + it('renders subcategory with parent breadcrumb', async () => { + const subcategoryData = { + ...mockCategories[2]!.children[1]!, // bug-reports under feedback + topicCount: 3, + } + mockGetCategoryBySlug.mockResolvedValue(subcategoryData) + mockGetTopics.mockResolvedValue({ topics: [], cursor: null }) + + const subcategoryParams = Promise.resolve({ slug: ['feedback', 'bug-reports'] }) + const page = await CategoryPage({ params: subcategoryParams }) + render(page) + + expect(screen.getByRole('heading', { level: 1, name: 'Bug Reports' })).toBeInTheDocument() + + // Parent category should appear in breadcrumbs + const breadcrumbNav = screen.getByRole('navigation', { name: /breadcrumb/i }) + expect(breadcrumbNav).toHaveTextContent('Feedback & Ideas') + }) }) diff --git a/src/app/c/[slug]/page.tsx b/src/app/c/[...slug]/page.tsx similarity index 69% rename from src/app/c/[slug]/page.tsx rename to src/app/c/[...slug]/page.tsx index 11a7060..d3500dd 100644 --- a/src/app/c/[slug]/page.tsx +++ b/src/app/c/[...slug]/page.tsx @@ -1,6 +1,6 @@ /** * Category page - Shows topics for a specific category. - * URL: /c/{slug} + * URL: /c/{slug} or /c/{parentSlug}/{slug} for subcategories. * Server-side rendered with SEO metadata and JSON-LD. * Maturity-aware: Adult categories are noindex'd, Mature get rating meta. * @see specs/prd-web.md Section 3.1 @@ -24,17 +24,34 @@ import { CategoryNav } from '@/components/category-nav' import { Breadcrumbs } from '@/components/breadcrumbs' import { Pagination } from '@/components/pagination' import { NewTopicButton } from '@/components/new-topic-button' +import type { CategoryTreeNode } from '@/lib/api/types' interface CategoryPageProps { - params: Promise<{ slug: string }> + params: Promise<{ slug: string[] }> searchParams?: Promise<{ page?: string }> } +/** Find a category in the tree by slug. */ +function findCategoryInTree( + categories: CategoryTreeNode[], + slug: string +): CategoryTreeNode | undefined { + for (const cat of categories) { + if (cat.slug === slug) return cat + const found = findCategoryInTree(cat.children, slug) + if (found) return found + } + return undefined +} + export async function generateMetadata({ params }: CategoryPageProps): Promise { - const { slug } = await params + const { slug: slugSegments } = await params + const categorySlug = slugSegments.at(-1)! + const canonicalPath = `/c/${slugSegments.join('/')}` + try { const [category, publicSettings] = await Promise.all([ - getCategoryBySlug(slug), + getCategoryBySlug(categorySlug), getPublicSettings().catch(() => null), ]) @@ -47,7 +64,7 @@ export async function generateMetadata({ params }: CategoryPageProps): Promise null), @@ -93,15 +112,24 @@ export default async function CategoryPage({ params, searchParams }: CategoryPag const totalPages = Math.max(1, Math.ceil(category.topicCount / TOPICS_PER_PAGE)) - const breadcrumbItems = [ - { label: 'Home', href: '/' }, - { label: category.name, href: `/c/${slug}` }, - ] + // Build breadcrumbs: Home > [Parent] > Category + const breadcrumbItems = [{ label: 'Home', href: '/' }] + + if (slugSegments.length > 1) { + const parentSlug = slugSegments[0]! + const parentCategory = findCategoryInTree(categoriesResult.categories, parentSlug) + breadcrumbItems.push({ + label: parentCategory?.name ?? parentSlug, + href: `/c/${parentSlug}`, + }) + } + + breadcrumbItems.push({ label: category.name, href: canonicalPath }) return ( } + sidebar={} > {/* Breadcrumbs (includes JSON-LD BreadcrumbList) */} @@ -119,7 +147,11 @@ export default async function CategoryPage({ params, searchParams }: CategoryPag {/* New topic button */}
- +
{/* Topic list */} @@ -128,7 +160,7 @@ export default async function CategoryPage({ params, searchParams }: CategoryPag {/* Pagination */} {totalPages > 1 && (
- +
)}
diff --git a/src/app/sitemap.test.ts b/src/app/sitemap.test.ts index 5cbe757..8a5374b 100644 --- a/src/app/sitemap.test.ts +++ b/src/app/sitemap.test.ts @@ -118,7 +118,7 @@ describe('sitemap', () => { const result = await sitemap() const urls = result.map((entry) => entry.url) expect(urls).toContain('https://barazo.forum/c/general') - expect(urls).toContain('https://barazo.forum/c/introductions') + expect(urls).toContain('https://barazo.forum/c/general/introductions') }) it('includes topic pages with author handle and rkey', async () => { @@ -163,7 +163,7 @@ describe('sitemap', () => { const urls = result.map((entry) => entry.url) // Both parent and child categories should be included expect(urls).toContain('https://barazo.forum/c/general') - expect(urls).toContain('https://barazo.forum/c/introductions') + expect(urls).toContain('https://barazo.forum/c/general/introductions') }) it('handles API errors gracefully', async () => { diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index ca44962..e03686a 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -12,12 +12,17 @@ import type { CategoryTreeNode } from '@/lib/api/types' const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://barazo.forum' -function flattenCategories(nodes: CategoryTreeNode[]): CategoryTreeNode[] { - const result: CategoryTreeNode[] = [] +interface FlatCategory extends CategoryTreeNode { + path: string +} + +function flattenCategories(nodes: CategoryTreeNode[], parentPath = ''): FlatCategory[] { + const result: FlatCategory[] = [] for (const node of nodes) { - result.push(node) + const path = parentPath ? `${parentPath}/${node.slug}` : node.slug + result.push({ ...node, path }) if (node.children.length > 0) { - result.push(...flattenCategories(node.children)) + result.push(...flattenCategories(node.children, path)) } } return result @@ -45,7 +50,7 @@ export default async function sitemap(): Promise { for (const category of allCategories) { if (category.maturityRating === 'adult') continue entries.push({ - url: `${SITE_URL}/c/${category.slug}`, + url: `${SITE_URL}/c/${category.path}`, lastModified: new Date(category.updatedAt), changeFrequency: 'daily', priority: 0.8,