diff --git a/AGENTS.md b/AGENTS.md index e7aa5a8..4846407 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,8 +96,8 @@ MCP servers: Supabase (connected), Stripe (connected), Vercel (connected), Playw ## Context files - [emails/AGENTS.md](emails/AGENTS.md): React Email templates sent through Resend -- [src/features/catalog/AGENTS.md](src/features/catalog/AGENTS.md): products, variants, images, stock history, the admin create, edit and arrange pages, cached storefront reads -- [src/features/categories/AGENTS.md](src/features/categories/AGENTS.md): admin category pages, product links, category reordering +- [src/features/catalog/AGENTS.md](src/features/catalog/AGENTS.md): products, variants, images, stock history, the admin create, edit and arrange pages, cached storefront reads, the home, search and category listings with filters +- [src/features/categories/AGENTS.md](src/features/categories/AGENTS.md): admin category pages, product links, category reordering, arranging products inside a category, the storefront nav and category pages - [src/features/cart/AGENTS.md](src/features/cart/AGENTS.md): the signed cart cookie, cart actions and caps, the expired cart cron - [src/features/checkout/AGENTS.md](src/features/checkout/AGENTS.md): the checkout form, `startCheckout` and the Stripe session, the confirmation page - [src/features/orders/AGENTS.md](src/features/orders/AGENTS.md): the Stripe webhook, order transitions, the reconcile cron, the expired order PII purge cron, admin orders 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..294ac07 --- /dev/null +++ b/app/(store)/search/page.tsx @@ -0,0 +1,51 @@ +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 { searchQuery } 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 query = searchQuery(await searchParams); + 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/docs/scope/scope.md b/docs/scope/scope.md index ba6a43d..b38f490 100644 --- a/docs/scope/scope.md +++ b/docs/scope/scope.md @@ -26,7 +26,7 @@ _These are recommendations to keep your build orderly, not requirements. Skip an | 10 | Admin order management | Slice 4 | done | | 11 | Order emails | Slice 5 | done | | 12 | Customer accounts | Slice 6 | done | -| 13 | Categories, search & filters | Slice 7 | planned | +| 13 | Categories, search & filters | Slice 7 | done | | 14 | Discounts & promo codes | Slice 8 | planned | | 15 | Admin sales dashboard | Slice 9 | planned | | 16 | Legal pages & cookie consent | Launch | planned | @@ -248,10 +248,19 @@ spec [0012](../specs/0012-customer-accounts/index.md) · code in [src/features/a ## Slice 7: Discovery -### 13. Categories, search & filters · needs a decision +### 13. Categories, search & filters · done Help customers find products: category pages, text search, filters by price and variant options, sorting. **Done when:** a customer can browse by category, search by name, filter and sort results, and the URL reflects the filters so results can be shared; empty and no results states render. -- [ ] Design it (spec): `/architect categories, search & filters` +spec [0013](../specs/0013-categories-search-filters/index.md) · code in [src/features/catalog/listing/](../../src/features/catalog/listing/), [src/features/categories/](../../src/features/categories/) +- [x] Design it (spec): `/architect categories, search & filters` +- [x] Build it: `/develop categories, search & filters` + - [x] Thin thread: `categories` cache tag and its expiry, the cached listing index, header nav, category pages with paging, not found and empty states (AC-1 to AC-3, AC-5, AC-22) + - [x] Home and search: home becomes the full listing, text folding, the header search box, `/search` with Best match and the short query prompt (AC-4, AC-6 to AC-10) + - [x] Filters and sort: option groups and counts, per variant matching, price, stock and sale, URL repair and serialization, sort, the panel, Sheet, chips and no results states (AC-11 to AC-21) + - [x] Admin arrange inside a category (AC-23) + - [x] Safety net: category metadata and canonicals, e2e with axe and keyboard on home, category and search (AC-1 to AC-25) +- [x] Verify it: `/check verify categories, search & filters` +- [x] Test it: `/test categories, search & filters` ## Slice 8: Discounts @@ -302,6 +311,7 @@ Out of scope for the current build pass, kept so the plan stays honest. - **Wishlist**: save products for later · needs a decision - **Dark mode**: a dark token set with its own contrast pass and a theme switch; the semantic tokens rule keeps it a token change · needs a decision · from spec 0003 - **Sweep abandoned product image uploads**: delete files in `product-images` that no image row and no order line references, older than a day, if storage cost grows · from spec 0009 +- **Storefront listing in SQL**: move search, filters and counts from the cached in memory index to SQL per request (`pg_trgm`, `unaccent`) once the catalog passes about 2,000 active products or the index nears the cache entry limit · needs a decision · from spec 0013 - **Trigram search indexes for admin orders**: `pg_trgm` indexes on the searched order columns, once the list is measured as slow · from spec 0010 - **Redact customer data at Stripe**: remove the name, email and address Stripe keeps on expired Checkout Sessions, if its redaction tooling fits · needs a decision · from spec 0008 - **Resend bounce and complaint webhooks**: record bounced and complained on `email_sends` and show "Bounced" on the order page, once live · from spec 0011 diff --git a/docs/specs/0013-categories-search-filters/index.md b/docs/specs/0013-categories-search-filters/index.md new file mode 100644 index 0000000..95c6818 --- /dev/null +++ b/docs/specs/0013-categories-search-filters/index.md @@ -0,0 +1,268 @@ +# 0013. Categories, search and filters + +**Date**: 2026-10-16 (amended the same day after a cross check: AC-3, AC-5, AC-6, AC-8, AC-9, AC-11, AC-13 to AC-16, AC-18, AC-20, AC-22 to AC-24, the index shape; amended 2026-10-17 after code review: AC-3, AC-15, AC-18, AC-20, AC-21) +**Status**: Accepted + +## Summary + +Customers can now find products three ways: a page per visible category (linked from the header), a text search on product names (a box in the header), and the home page, which becomes the full catalog with paging. Every listing gets the same filters (price, option values like size and colour, in stock, on sale) and sort orders, and the URL holds the whole state, so a link shows the same results. To keep it simple at a few hundred products, the whole sellable catalog is read once into one cached list and filtered in plain code on each request, with no schema change and no database query per search. Admins also get to arrange the products inside each category, which sets the category page's default order. + +## Requirements + +**User stories**: +- As a shopper, I want to open a category from the header so that I see only the kind of product I came for. +- As a shopper, I want to search by product name, without worrying about accents or capitals, so that I find a product I already know. +- As a shopper, I want to filter by price, size, colour, stock and sale, and sort the results, so that I only see what I could buy. +- As a shopper, I want to share or bookmark a filtered list so that whoever opens it sees the same results. +- As an admin, I want to arrange the products inside a category so that the best ones show first. + +**Acceptance criteria** (the contract): + +*Listings and categories* +- **AC-1**: `/categories/` for a visible category shows its name, its plain text description, and its listable products as cards in Featured order: `product_categories.position`, then `products.position`, `created_at` newest first, `id` descending. +- **AC-2**: An unknown slug, a malformed slug, or a hidden category renders the store's not found page (status 200 under Cache Components, as the product page already accepts). Only listable products appear in any listing: status `active` with at least one non archived variant. Draft and archived products never appear. +- **AC-3**: The header nav lists every visible category in admin order (`categories.position`), on desktop (scrolling sideways when the names overflow) and in the existing mobile menu. With no visible categories there is no nav and no mobile menu button (`StoreShell` already hides both for an empty list). A visible category with no listable products still renders its name and description (nothing when the description is empty), then "Nothing here yet" with a link to all products, and no filter panel. +- **AC-4**: The home page `/` lists every listable product in Featured order (`products.position`, `created_at` newest first, `id` descending) with the same filters, sorts and paging as a category page. The 48 product cap is gone. +- **AC-5**: Listings show 24 products per page with numbered page links (previous, next, first, last, the current page ±1, gaps as an ellipsis) inside `