# Product Detail Experience

## Scope

Builds the product detail page (PDP) on top of `07`'s design system and `09`'s discovery system, using Apple's PDP as a storytelling/configuration reference (a sticky purchase panel beside a scrolling story, not a form) — not cloned, and not assuming every merchant sells configurable electronics the way Apple does.

## One page, four product shapes

Catalog has no explicit `product_type` column — adding one would mean a migration and a merchant-facing type picker neither asked for yet. Instead, `storefront/src/pages/productType.ts` **infers** a shape from data that already exists:

- **Grouped** — `bundleComponents.length > 0`. The product itself represents a curated set of other products at one price (already modeled: `CatalogService::getBundleComponents`/`setBundleComponents`).
- **Configurable** — more than one variant, and at least two variants disagree on the same `attributes` key (e.g. every variant has a `Color`, and more than one distinct color exists). Apple's step-by-step picker (color swatches, then storage pills) only makes sense once there's more than one independent choice to make.
- **Variable** — more than one variant, but not "configurable" by the rule above — a flat list of options (e.g. differently-priced pre-built configs, or variants with no `attributes` at all, which is every seeded product today).
- **Simple** — exactly one variant. No picker at all; just price, quantity, buy.

This is why `attributes` (below) matters: it's the one signal that separates "configurable" from "variable," and until a merchant actually sets it, every multi-variant product reads as "variable" — an honest default, not a broken configurator.

## What closes a real gap vs. what's still honestly empty

`catalog_product_variant.attributes` (free-form JSON) has been in the schema and the *admin* create/update-variant API since day one — nothing ever read it back. This phase adds it to `CatalogService::listVariantsForProduct()`'s return, so it now flows all the way to the storefront. Two sections depend on it:

- **Specifications** (`components/Specifications.tsx`) — renders the selected variant's `attributes` as a key/value table. Renders nothing if `attributes` is empty — no fabricated spec sheet.
- **Variant configurator** (`components/VariantSelector.tsx`) — groups variants by shared attribute keys into Apple-style per-attribute pickers when attributes exist; falls back to today's flat `<select>` of variant names when they don't. Every seeded product today falls back — that's expected, not a bug, until a merchant (or a future admin UI field — none exists yet either) actually sets attributes on a variant.

**Downloads** (`components/Downloads.tsx`) and true **delivery estimates** are the two requested capabilities with no backing data source at all: there's no product-attachment model for spec sheets/manuals, and no `estimated_days` anywhere in Shipping. Both get the same honest treatment as `09`'s Brand page: `Downloads` is a real component that accepts a `downloads?: {label, url}[]` prop and renders nothing when absent (ready the moment a merchant CMS exists to attach files); the delivery line under the price states the true, already-correct thing — pricing depends on the address entered at checkout (`../ux/05-storefront-flows.md` step 2) — rather than inventing a "3–5 business days" figure nothing computes.

## Frequently bought together vs. Bundles vs. Related products

Three different, now clearly distinguished modules that were previously conflated:

- **Bundles** (`components/BundleContents.tsx`) — what a *grouped* product's own price includes. Real, curated by the merchant via the existing bundle-components API.
- **Frequently bought together** (`components/FrequentlyBoughtTogether.tsx`) — an Amazon-style checkbox row (this product + N complementary items, a running total, one "Add selected to cart" action). CommerceDex has no purchase-pattern analytics (that needs order-history ML this app doesn't have), so this is honestly sourced from the same category-based relation `getRelatedProducts` already provides — a legitimate merchandising choice, just not claiming to be "customers who bought this also bought," a claim nothing backs yet.
- **Related products / Recommendations** (`RelatedProducts`/`ProductRail`, unchanged from `07`) — the plain "You might also like" rail.
- **Recently viewed** (`RecentlyViewedRail`, from `09`) — now also wired onto the PDP itself, excluding the current product.

## Sticky purchase panel

The right-hand column (name, price, configurator, quantity, Add to Cart/Buy Now, wishlist, trust row) becomes `position: sticky` on desktop, so it stays reachable while the gallery/specs/reviews/related content scrolls beneath it — Apple's actual PDP behavior. Below the `1024px` breakpoint the two-column layout already collapses to one column (existing responsive rule); sticky is disabled there since a single-column page has no "beside the scrolling content" to stick against.

## Trust indicators and merchant-defined promotional modules

The page's hand-rolled inline-SVG trust icons are replaced with `components/TrustBadges.tsx` — the same lucide-icon convention as every other component built in `07`–`09`. The PDP originally *also* kept a "Why shop with us" section carried over from before this phase, which — on inspection — restated the same three facts (secure checkout, fast shipping, quality) `TrustBadges` already states, just longer. That's not two features, it's one feature said twice; the section has been removed rather than kept alongside the thing it duplicated.

**Merchant-defined promotional modules** is its own capability, not a restatement of trust indicators, and is now built as one: `ProductDetail` reads an optional `product.promoModule` field and renders it through the *same* `PromoModule` component the Homepage uses (`08`). No admin authoring UI writes to this field yet — no per-product CMS binding exists — so it's honestly absent on every product today, the same "real, ready-to-wire, currently empty" treatment as `Downloads`. The difference from the old section: this one is actually shaped like something a merchant could set (a headline/subhead/image/CTA, scoped to one product), not evergreen static copy mislabeled as merchant content.
