# Product Discovery System

## Scope

`07-storefront-design-system.md` set the tokens/components, `08-homepage-module-system.md` built the Homepage on top of them. This document covers everything a customer uses to get from "I don't know exactly what I want" to a product detail page: category/collection/brand listings, search, filtering, sorting, pagination, and the cross-cutting engagement features (recommendations, recently viewed, continue shopping, comparison, wishlist).

## One template, four entry points

Nike's reference (the "Trainers for Men (618)" screenshot) shows the shape every discovery page needs regardless of *how* a customer got there: a heading + result count, a filter sidebar, a sort control, a product grid, and infinite scroll. CommerceDex builds that shape once — `storefront/src/discovery/ProductListPage.tsx` — and feeds it a `ProductListScope` (`category`, `collection`, `brand`, or `search`). Four thin route components (`CategoryPage`, `CollectionPage`, `BrandPage`, `SearchResultsPage`) each just parse their URL and hand the template a scope; `useProductListScope.ts` resolves whichever scope into products, a heading, and pagination state, mirroring the Homepage's `useContentSource` resolver pattern from `08`.

## What's real vs. honestly unavailable

Building this page surfaced (and closed) a real gap: `catalog_collection_product` — the pivot table linking products to collections — existed in the schema from day one but had no read path and no way to populate it. This phase adds both:

- `CatalogService::listProducts()` gained `sort` (`newest`/`price-asc`/`price-desc`), `minPrice`/`maxPrice`, and `collectionId` filters — all backed by real variant pricing, joined once via a min-price subquery rather than N+1 queries per product.
- `CatalogService::listCollections()` / `getCollectionBySlug()` — public, active-products-only, mirroring the existing category/product honesty rule (a customer never sees an empty or draft collection).
- `CatalogService::setCollectionProducts()` — a merchant-facing write path now exists too, though **no admin UI calls it yet** — the admin dashboard has no "add products to a collection" screen. Collections are fully real on the storefront read side; curating one today requires calling the service directly (e.g. via `tinker`), not clicking through the admin.
- Every storefront product row now carries `defaultVariantId` — the first variant ever created for that product, computed the same batched way as `imageUrl`/`categoryName`. It exists specifically so a listing card can wishlist without a full variant picker (see "Wishlist" below).

**Category** and **collection** pages are fully real. **Brand** pages are not: Catalog has no brand entity at all (confirmed against the schema — only `category_id` on `catalog_product`). `BrandPage` is still a real route/component, not omitted — it always resolves to `ProductListPage`'s honest `EmptyState` ("Brands aren't available yet"), the same "modeled but not yet resolvable" treatment `08`'s `EditableRegion` gives unbacked homepage content sources. **Search** reuses `listProducts({ q })` (already public) plus the same sort/price filters, applied client-side to the ranked result set in `ProductSearchController::storefrontIndex` since relevance ranking doesn't paginate the way a plain listing does.

**Deliberately not built**: color/size faceted filtering. `catalog_product_variant.attributes` is a schema-ready JSON column for exactly this, but every seeded variant has `attributes: null` — there's no real data to facet on, so `FilterSidebar` only offers price range (and sort, and an active-scope chip), not fabricated swatches.

## Recommendation modules

Already established by `07`/`08`: `ProductRail` (used for "You might also like" on PDP and "New arrivals" on the Homepage) is the one recommendation-rail implementation every surface reuses.

## Recently viewed

`storefront/src/recentlyViewed.ts` — a `localStorage`-backed list of product slugs, capped at 12, most-recent-first, updated whenever `ProductDetailPage` mounts. Fully real, no backend needed: it's client state describing this browser's own session, not merchandising data a merchant configures. Rendered via `RecentlyViewedRail` (a thin `ProductRail` wrapper that fetches each slug's current data, so a since-changed price/image is never stale) on the Homepage and the Cart page.

## Continue shopping

Nike/Apple convention: an empty cart isn't a dead end. `CartPage`'s empty state now uses the shared `EmptyState` component with a "Continue shopping" `Button` back to `/`, and — when the cart does have items — shows the Recently Viewed rail underneath the order summary, so finishing a purchase or topping up an order both stay one click away from more browsing.

## Product comparison

New, fully client-side: `storefront/src/compare/CompareContext.tsx` holds up to 4 product slugs in `localStorage` (a `CartContext`-shaped provider, same persistence convention). `ProductCard` gained an opt-in compare checkbox (only rendered where a caller passes `onToggleCompare`, same opt-in pattern as its existing wishlist heart). A sticky `CompareBar` appears once 2+ products are selected; `/compare` renders a side-by-side table (image, name, price, description) built from real `getProduct` data for each selected slug — no separate comparison backend, since everything being compared is already public product detail.

## Wishlist

The wishlist backend already existed in full (guest-by-email add, authenticated list/remove) — what was missing was reaching it from anywhere *except* the product detail page. `storefront/src/wishlist/useWishlist.ts` centralizes the authenticated add/remove/is-wishlisted logic PDP already had, so `ProductCard` instances on category/collection/search pages and the Homepage's rails can offer the same one-click heart toggle using each product's `defaultVariantId`. Guests clicking the heart from a listing card are sent to `/login` rather than shown an inline email-capture form per card — PDP keeps that richer guest flow, since a single-product page has room for it and a dense grid doesn't.
