From 2742b882f1184ca062bd64b9ad0980d68da5ac7a Mon Sep 17 00:00:00 2001 From: kris1027 Date: Fri, 16 Oct 2026 19:52:32 +0200 Subject: [PATCH 01/21] feat(catalog): category pages, home listing, search, filters and sort Co-Authored-By: Claude Opus 5.5 --- app/(store)/categories/[slug]/page.tsx | 71 +++ app/(store)/layout.tsx | 12 +- app/(store)/page.tsx | 25 +- app/(store)/search/page.tsx | 52 +++ src/components/layout/store-nav-menu.tsx | 7 +- src/components/layout/store-shell.tsx | 2 +- .../catalog/actions/create-product.ts | 4 +- .../actions/update-product-categories.ts | 8 +- .../catalog/components/arrange-products.tsx | 6 +- .../catalog/components/product-grid.tsx | 48 -- .../listing/components/active-filters.tsx | 96 ++++ .../listing/components/filter-panel.tsx | 226 ++++++++++ .../listing/components/filter-sheet.tsx | 61 +++ .../listing/components/listing-controls.tsx | 110 +++++ .../listing/components/listing-empty.tsx | 133 ++++++ .../listing/components/listing-nav.tsx | 63 +++ .../listing/components/listing-skeleton.tsx | 32 ++ .../listing/components/listing-view.tsx | 76 ++++ .../catalog/listing/components/pagination.tsx | 80 ++++ .../catalog/listing/components/search-box.tsx | 93 ++++ .../listing/components/sort-select.tsx | 54 +++ src/features/catalog/listing/index-query.ts | 52 +++ src/features/catalog/listing/index-rows.ts | 89 ++++ src/features/catalog/listing/listing.test.ts | 416 ++++++++++++++++++ src/features/catalog/listing/listing.ts | 388 ++++++++++++++++ .../catalog/listing/pagination.test.ts | 23 + src/features/catalog/listing/pagination.ts | 15 + src/features/catalog/listing/params.test.ts | 198 +++++++++ src/features/catalog/listing/params.ts | 229 ++++++++++ src/features/catalog/paths.ts | 4 + src/features/catalog/queries.ts | 52 +-- .../categories/actions/create-category.ts | 7 +- .../categories/actions/delete-category.ts | 6 + .../categories/actions/reorder-categories.ts | 4 + .../actions/set-category-products.ts | 4 + .../categories/actions/update-category.ts | 5 + .../categories/components/category-form.tsx | 2 +- .../categories/components/category-header.tsx | 33 ++ src/features/categories/paths.ts | 5 + src/features/categories/queries.ts | 64 +++ src/lib/cache-tags.ts | 4 + src/lib/search-text.test.ts | 64 +++ src/lib/search-text.ts | 44 ++ tests/db/catalog-actions.db.test.ts | 2 + tests/db/catalog-arrange-delete.db.test.ts | 4 +- tests/db/catalog-listing.db.test.ts | 310 +++++++++++++ tests/db/catalog-queries.db.test.ts | 113 +---- 47 files changed, 3174 insertions(+), 222 deletions(-) create mode 100644 app/(store)/categories/[slug]/page.tsx create mode 100644 app/(store)/search/page.tsx delete mode 100644 src/features/catalog/components/product-grid.tsx create mode 100644 src/features/catalog/listing/components/active-filters.tsx create mode 100644 src/features/catalog/listing/components/filter-panel.tsx create mode 100644 src/features/catalog/listing/components/filter-sheet.tsx create mode 100644 src/features/catalog/listing/components/listing-controls.tsx create mode 100644 src/features/catalog/listing/components/listing-empty.tsx create mode 100644 src/features/catalog/listing/components/listing-nav.tsx create mode 100644 src/features/catalog/listing/components/listing-skeleton.tsx create mode 100644 src/features/catalog/listing/components/listing-view.tsx create mode 100644 src/features/catalog/listing/components/pagination.tsx create mode 100644 src/features/catalog/listing/components/search-box.tsx create mode 100644 src/features/catalog/listing/components/sort-select.tsx create mode 100644 src/features/catalog/listing/index-query.ts create mode 100644 src/features/catalog/listing/index-rows.ts create mode 100644 src/features/catalog/listing/listing.test.ts create mode 100644 src/features/catalog/listing/listing.ts create mode 100644 src/features/catalog/listing/pagination.test.ts create mode 100644 src/features/catalog/listing/pagination.ts create mode 100644 src/features/catalog/listing/params.test.ts create mode 100644 src/features/catalog/listing/params.ts create mode 100644 src/features/categories/components/category-header.tsx create mode 100644 src/features/categories/queries.ts create mode 100644 src/lib/search-text.test.ts create mode 100644 src/lib/search-text.ts create mode 100644 tests/db/catalog-listing.db.test.ts diff --git a/app/(store)/categories/[slug]/page.tsx b/app/(store)/categories/[slug]/page.tsx new file mode 100644 index 0000000..6c2464b --- /dev/null +++ b/app/(store)/categories/[slug]/page.tsx @@ -0,0 +1,71 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; + +import { storeContainer } from "@/components/layout/container"; +import { ListingSkeleton } from "@/features/catalog/listing/components/listing-skeleton"; +import { ListingView } from "@/features/catalog/listing/components/listing-view"; +import { + CategoryHeader, + CategoryHeaderSkeleton, +} from "@/features/categories/components/category-header"; +import { categoryPath } from "@/features/categories/paths"; +import { getCategoryBySlug, getPrerenderedCategorySlugs } from "@/features/categories/queries"; +import { env } from "@/lib/env"; +import { cn } from "@/lib/utils"; + +// Cannot match the slug pattern, so it renders the 404 page. Cache Components refuses an empty +// list, and a store with no visible category has no slugs to give. +const NO_CATEGORY_SLUG = "__none__"; + +export async function generateStaticParams() { + const slugs = await getPrerenderedCategorySlugs(); + return slugs.length > 0 ? slugs.map((slug) => ({ slug })) : [{ slug: NO_CATEGORY_SLUG }]; +} + +// spec 0013, AC-24: never reads the search params, so filters never reach the metadata. +export async function generateMetadata({ + params, +}: PageProps<"/categories/[slug]">): Promise { + const category = await getCategoryBySlug((await params).slug); + if (!category) return { title: "Page not found" }; + return { + title: category.metaTitle ?? category.name, + description: category.metaDescription ?? category.description ?? undefined, + alternates: { + canonical: new URL(categoryPath(category.slug), env.NEXT_PUBLIC_SITE_URL).toString(), + }, + }; +} + +// The page joins the two features: the category from categories, the listing from catalog. +async function CategoryListing({ + params, + searchParams, +}: Pick, "params" | "searchParams">) { + const category = await getCategoryBySlug((await params).slug); + // The header shows the not found page. + if (!category) return null; + return ( + + ); +} + +// spec 0013, AC-1 to AC-3. The header reads only the path (static for prerendered slugs); the +// results read the search params, so they render per request. +export default function CategoryPage({ params, searchParams }: PageProps<"/categories/[slug]">) { + return ( +
+ }> + + + }> + + +
+ ); +} diff --git a/app/(store)/layout.tsx b/app/(store)/layout.tsx index e1e2560..3c10c80 100644 --- a/app/(store)/layout.tsx +++ b/app/(store)/layout.tsx @@ -3,12 +3,22 @@ import { Suspense } from "react"; import { StoreShell } from "@/components/layout/store-shell"; import { AccountLink } from "@/features/account/components/account-link"; import { CartLink, CartLinkView } from "@/features/cart/components/cart-link"; +import { HeaderSearch } from "@/features/catalog/listing/components/search-box"; +import { categoryPath } from "@/features/categories/paths"; +import { getNavCategories } from "@/features/categories/queries"; -export default function StoreLayout({ children }: LayoutProps<"/">) { +// The nav is a cached read tagged `categories`, so the shell stays static (spec 0013, AC-3). +export default async function StoreLayout({ children }: LayoutProps<"/">) { + const categories = await getNavCategories(); return ( ({ + href: categoryPath(category.slug), + label: category.name, + }))} actions={ <> + }> diff --git a/app/(store)/page.tsx b/app/(store)/page.tsx index c7397ef..d139c7b 100644 --- a/app/(store)/page.tsx +++ b/app/(store)/page.tsx @@ -1,13 +1,23 @@ +import type { Metadata } from "next"; import { Suspense } from "react"; import { storeContainer } from "@/components/layout/container"; import { AccountDeletedNotice } from "@/features/account/components/account-deleted-notice"; -import { ProductGrid } from "@/features/catalog/components/product-grid"; +import { ListingSkeleton } from "@/features/catalog/listing/components/listing-skeleton"; +import { ListingView } from "@/features/catalog/listing/components/listing-view"; +import { homePath } from "@/features/catalog/paths"; import { brand } from "@/lib/brand/brand"; +import { env } from "@/lib/env"; import { cn } from "@/lib/utils"; -// Served from the cache: the grid's read is tagged `catalog` (spec 0005, AC-6). -export default function StoreHomePage() { +// spec 0013, AC-24: the home title stays; paged and filtered views point at the plain path. +export const metadata: Metadata = { + alternates: { canonical: new URL(homePath, env.NEXT_PUBLIC_SITE_URL).toString() }, +}; + +// spec 0013, AC-4: every listable product, with filters, sorts and paging. The heading is the +// static shell; the results read the search params, so they render behind Suspense. +export default function StoreHomePage({ searchParams }: PageProps<"/">) { return (
@@ -19,7 +29,14 @@ export default function StoreHomePage() { Everything we sell, in one place.

- + }> + +
); } diff --git a/app/(store)/search/page.tsx b/app/(store)/search/page.tsx new file mode 100644 index 0000000..069ac9c --- /dev/null +++ b/app/(store)/search/page.tsx @@ -0,0 +1,52 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; + +import { storeContainer } from "@/components/layout/container"; +import { ListingSkeleton } from "@/features/catalog/listing/components/listing-skeleton"; +import { ListingView } from "@/features/catalog/listing/components/listing-view"; +import { SearchForm } from "@/features/catalog/listing/components/search-box"; +import { cleanQuery } from "@/features/catalog/listing/params"; +import { searchPath } from "@/features/catalog/paths"; +import { cn } from "@/lib/utils"; + +// spec 0013, AC-24: the query never reaches the title or any metadata; results are not indexed. +export const metadata: Metadata = { + title: "Search", + robots: { index: false, follow: true }, +}; + +async function SearchResults({ searchParams }: Pick, "searchParams">) { + const { q } = await searchParams; + const query = cleanQuery(typeof q === "string" ? q : q?.[0]); + return ( +
+ {/* Keyed by the query, so a new search shows its own text in the box. */} + + +
+ ); +} + +// spec 0013, AC-6 to AC-9: the box and the results both read the query, so both sit behind +// Suspense; the heading is the static shell. +export default function SearchPage({ searchParams }: PageProps<"/search">) { + return ( +
+

Search

+ }> + + +
+ ); +} diff --git a/src/components/layout/store-nav-menu.tsx b/src/components/layout/store-nav-menu.tsx index 7913afb..c44fd54 100644 --- a/src/components/layout/store-nav-menu.tsx +++ b/src/components/layout/store-nav-menu.tsx @@ -19,7 +19,10 @@ export function StoreDesktopNav({ items }: StoreNavProps) { if (items.length === 0) return null; return ( -