@base44/app-plugin-commerce 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,7 +12,7 @@ It provides a full-featured **commerce data model and behavior** (variant-driven
12
12
  - **Shared commerce engine** (`base44/shared/commerce/`) — totals, tax, shipping, coupons, stock, order lifecycle, webhook dispatch (HMAC-signed), emails, card-payment plumbing, plus static country/currency/continent data.
13
13
  - **Admin UI** (`src/commerce/admin/`) — a React/Tailwind/shadcn admin with a familiar store back-office information architecture: dashboard, orders, products, coupons, customers, reports, and full settings including webhooks. Admin-role gated.
14
14
  - **Storefront helpers** (`src/commerce/utils/`) — framework-free, dependency-free modules for the shopfront you build: `storefront.js` is the API client (`createStorefront(base44)` — cart-token lifecycle, cached store-info, catalog/cart/checkout/reviews/return-page calls); `variants.js` maps an attribute selection (Size, Color) onto a `ProductVariation` and back, plus per-option availability and price ranges; `price.js` encodes the from-price and price-range rules; `totals.js` projects a cart *or* an order into one summary shape; `address-spec.js` is the checkout address form as data; `images.js` and `ribbons.js` normalize the two catalog fields that are arrays of objects (`{src, name, alt}` images, `{id, name}` ribbons) rather than strings; `types.js` writes the catalog shapes down as JSDoc typedefs (`StorefrontProduct` and the rest), so what a field holds is answerable from the frontend; `shipping-promos.js` reads the store's real free-shipping configuration so "Free shipping over €150" states a configured rule rather than an invented number.
15
- - **Storefront React layer** (`src/commerce/storefront/`) — two tiers, and neither ships a look or a sentence. **The catalog tier is headless hooks** — `StorefrontProvider` (+ `useStorefront`/`useStoreInfo`/`useFormatMoney`/`useCountries`), `useProductList`/`useCategories`/`useRibbons`, `useProduct`/`useAddToCart`, `useCart`/`useCartLine`/`CartLine`, `useCheckout`/`CheckoutProvider`, `useOrderReturn`, the `ShippingMethodPicker`/`PaymentMethodPicker` render-prop components, and the framework-free view-model helpers re-exported so one import line covers a page (`variantAxes`, `productPrice`, `productImages`, `productRibbons`, `productSpecs`, `attributesLabel`, `cartTotalsLines`/`orderTotalsLines`, `addressFieldSpec`) — the product list and product page are built from these, entirely in the store's own markup, classes and words. **The commodity tier is parts** — `Cart.*`, `CartDrawer.*`, `Checkout.*`, `OrderReceived.*` compound components that render each section of the cart, drawer, checkout and receipt as correct semantic markup with **zero CSS, zero copy and zero navigation**: the store's page is the layout around them; styling comes via `data-part`/`data-state` selectors or `className`/`classes` props; every word (blocker lines, field labels, button text, aria names) comes from one agent-written copy file passed as `labels` (a missing key renders a visible `⟨copy: …⟩` placeholder — the code has no English fallback); and every navigation affordance (the cart's checkout link, `Checkout.Root`'s required `orderReceivedPath`, `OrderReceived.PaymentLink`'s render-prop child) is the store's own element and route. Parts compose the hooks, so replacing one section with a hook-built custom region is a normal move — and the parts are copied source, editable when a requirement outgrows their props. Needs React and nothing else.
15
+ - **Storefront React layer** (`src/commerce/storefront/`) — two tiers, and neither ships a look or a sentence. **The catalog tier is headless hooks** — `StorefrontProvider` (+ `useStorefront`/`useStoreInfo`/`useFormatMoney`/`useCountries`), `useProductList`/`useCategories`/`useRibbons`, `useProduct`/`useAddToCart`, `useCart`/`useCartLine`/`CartLine`, `useCheckout`/`CheckoutProvider`, `useOrderReturn`, the `ShippingMethodPicker`/`PaymentMethodPicker` render-prop components, and the framework-free view-model helpers re-exported so one import line covers a page (`variantAxes`, `productPrice`, `productImages`, `productRibbons`, `productSpecs`, `attributesLabel`, `cartTotalsLines`/`orderTotalsLines`, `addressFieldSpec`) — the product list and product page are built from these, entirely in the store's own markup, classes and words. **The commodity tier is parts** — `Cart.*`, `Checkout.*`, `OrderReceived.*` compound components that render each section of the cart contents, checkout and receipt as correct semantic markup carrying **layout geometry and nothing else** (field grids, labels above full-width controls, square thumbnails, label-left/value-right totals — enforced by a release gate to contain no color, type or border), with **zero copy and zero navigation**: the store's page is the layout around them; styling comes via `data-part`/`data-state` selectors or `className`/`classes` props; every word (blocker lines, field labels, button text, aria names) comes from one agent-written copy file passed as `labels` (a missing key renders a visible `⟨copy: …⟩` placeholder — the code has no English fallback); and every navigation affordance (the cart's checkout link, `Checkout.Root`'s required `orderReceivedPath`, `OrderReceived.PaymentLink`'s render-prop child) is the store's own element and route. The **cart drawer stays the store's own surface** — which side it enters from, its width, padding and animation — while `useCartUI()` keeps the state and the fiddly behavior (Esc, close-on-navigate, open-on-add, focus in and back out, inert while closed) and `CartDrawer.Trigger`/`Close` are the two wired buttons. Parts compose the hooks, so replacing one section with a hook-built custom region is a normal move (`useInCheckout()` even lets one shared cart-contents component drop its upsell rail inside the checkout) — and the parts are copied source, editable when a requirement outgrows their props. Needs React and nothing else.
16
16
  - **StoreAdmin agent + bot** — an AI copilot (`base44/agents/commerce/StoreAdmin.jsonc`, registered as `commerce/StoreAdmin`) with the `commerce/*` functions attached directly as tools (calls run as the chatting user → `requireAdmin()` still applies), variant-aware order editing, plus a chat panel in the admin sidebar with GFM markdown-table rendering.
17
17
  - **Docs** — this README plus the commerce skill folder [`skills/commerce/`](./skills/commerce/): [`SKILL.md`](./skills/commerce/SKILL.md) is the map every agent starts from (and the only path the platform needs to know); [`install/`](./skills/commerce/install/) holds the three stage files that are the whole install (`01-install` → `02-storefront` → `03-data`, each read at the moment its work starts and dropped when its checklist passes); [`references/`](./skills/commerce/references/) holds per-topic guides opened only on demand; [`docs/`](./skills/commerce/docs/) holds the data-model map ([`entities.md`](./skills/commerce/docs/entities.md)) and the two API references. The whole folder is installed into the app at `.agents/skills/commerce/` so agents pick it up natively.
18
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@base44/app-plugin-commerce",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "Base44 Commerce plugin — entities, backend functions, shared commerce engine, admin UI and the commerce skill, shipped as copyable source",
5
5
  "keywords": [
6
6
  "base44",
@@ -54,15 +54,17 @@ feel like <site>" is your work, done as with no kit; encode that identity
54
54
  method. **Never re-implement what a hook does** — that is where storefront
55
55
  bugs cluster.
56
56
 
57
- **The commodity surfaces — cart, cart drawer, checkout, order-received — are
58
- placed, not written**: `Cart.*` / `CartDrawer.*` / `Checkout.*` /
59
- `OrderReceived.*` parts render each section's correct markup with **zero CSS,
60
- zero copy and zero navigation**. Your layout arranges the sections, your
61
- `index.css` styles them (`[data-part]` selectors, `className`/`classes`
62
- props), and every word comes from **one copy file** passed as `labels` — a
63
- missing key renders a visible `⟨copy: …⟩`. The kit ships no look and no
64
- sentence anywhere: a kit sentence rendered into every store is how storefronts
65
- end up alike.
57
+ **The commodity surfaces — cart contents, checkout, order-received — are
58
+ placed, not written**: `Cart.*` / `Checkout.*` / `OrderReceived.*` parts render
59
+ each section's correct markup, carrying **layout geometry and nothing else** —
60
+ no color, type or border, no copy, no navigation. Your layout arranges the
61
+ sections, your `index.css` gives them their look (`[data-part]` selectors,
62
+ `className`/`classes` props), and every word comes from **one copy file**
63
+ passed as `labels` — a missing key renders a visible `⟨copy: …⟩`. The kit ships
64
+ no look and no sentence anywhere: a kit sentence rendered into every store is
65
+ how storefronts end up alike. **The cart drawer stays the store's own surface**
66
+ (side, width, padding, animation); `useCartUI()` keeps its state and behavior,
67
+ and `CartDrawer.Trigger`/`Close` are its two wired buttons.
66
68
 
67
69
  ```jsx
68
70
  import { Cart, CartDrawer, Checkout, OrderReceived, useProduct } from "@/commerce/storefront";
@@ -106,13 +108,13 @@ Open a file when its work starts — not while planning.
106
108
  | Topic | Open when | Size |
107
109
  |---|---|---|
108
110
  | [`install/01-install.md`](./install/01-install.md) | installing — routes you to 02 and 03 | 5K |
109
- | [`install/02-storefront.md`](./install/02-storefront.md) | building storefront pages | 33K |
111
+ | [`install/02-storefront.md`](./install/02-storefront.md) | building storefront pages | 34K |
110
112
  | [`install/03-data.md`](./install/03-data.md) | seeding catalog, shipping rates/zones, payments; re-callable per slice | 11K |
111
113
  | [`docs/entities.md`](./docs/entities.md) | any direct entity read/write ("which entity holds X") | 11K |
112
114
  | [`references/catalog-rendering.md`](./references/catalog-rendering.md) | field shapes each catalog call returns, variant edge cases | 16K |
113
115
  | [`references/shipping-and-tax.md`](./references/shipping-and-tax.md) | zones beyond 03's recipe, taxes, day-2 edits | 8K |
114
116
  | [`references/online-payments.md`](./references/online-payments.md) | enabling card payments, or wiring the provider — at install or any time later | 8K |
115
- | [`references/storefront-styling.md`](./references/storefront-styling.md) | writing the CSS for the cart/checkout parts (selectors, geometry vars) | 5K |
117
+ | [`references/storefront-parts.md`](./references/storefront-parts.md) | the parts' copy map, CSS selectors and control overrides | 10K |
116
118
  | [`references/storefront-custom.md`](./references/storefront-custom.md) | a custom cart row / checkout section on the raw hooks | 8K |
117
119
  | [`references/storefront-verification.md`](./references/storefront-verification.md) | driving the storefront from a browser script | 3K |
118
120
  | [`references/reviews.md`](./references/reviews.md) | review policies (login-gated, verified buyers), moderation | 5K |
@@ -11,15 +11,16 @@ carry_forward:
11
11
  - "Variant options that aren't buyable render disabled, never hidden; one control per axis, never a list of variations."
12
12
  - "No copy ships: every word the parts render comes from the store's copy file (labels); a ⟨copy: …⟩ placeholder on screen is unfinished work."
13
13
  - "Identity is encoded once — design classes in index.css (parts styled via [data-part] selectors, controls always among them) plus one or two signature moments per page."
14
+ - "The product page is the richest surface and budgeted for it (~8K chars): productSpecs rows carry a type — branch on it, never .map() the list into one grey label/value table."
14
15
  ---
15
16
 
16
17
  # 02 — Storefront
17
18
 
18
- Two tiers decide everything here. **The catalog surfaces — product list and product page — are fully yours**: the logic ships as hooks, every element, class and word is the store's own, and this is where the design effort goes. **The commodity surfaces — cart, drawer, checkout, order-received — are placed, not written**: `Cart.*` / `CartDrawer.*` / `Checkout.*` / `OrderReceived.*` parts render each section's correct markup with **zero CSS, zero copy and zero navigation**; your page is the layout around them, your copy file their words, your `index.css` their look. Nothing in the kit ships a look or a sentence — **decide how the store looks and sounds as if the kit did not exist**, then encode it once (design classes + the copy file); the snippets here are wiring reference, never design input. Where a state needs words you get the *state* (`buy.state`, a blocker code) and write the words; never re-derive a state you were handed, never leave one unworded.
19
+ Two tiers decide everything here. **The catalog surfaces — product list and product page — are fully yours**: the logic ships as hooks, every element, class and word is the store's own, and this is where the design effort goes. **The commodity surfaces — cart, drawer, checkout, order-received — are placed, not written**: `Cart.*` / `CartDrawer.*` / `Checkout.*` / `OrderReceived.*` parts render each section's correct markup with **no look, no copy and no navigation**; your page is the layout around them, your copy file their words, your `index.css` their look. Nothing in the kit ships a look or a sentence — **decide how the store looks and sounds as if the kit did not exist**, then encode it once (design classes + the copy file); the snippets here are wiring reference, never design input. Where a state needs words you get the *state* (`buy.state`, a blocker code) and write the words; never re-derive a state you were handed, never leave one unworded.
19
20
 
20
- **This file is the whole job.** Every shape and contract you need is in the tables here — **don't open the hook or part files while building**. The parts are the app's own source (`src/commerce/storefront/parts/`), so they *can* be edited — only when a user requirement genuinely exceeds their props, after the cheaper rungs (`classes`/CSS → labels → `…Render` overrides → the hooks, [`../references/storefront-custom.md`](../references/storefront-custom.md)); an edited part is the store's code to maintain. Rules marked ⚑ must survive whatever design you build.
21
+ **This file is the job.** Every shape and contract is in the tables here — **don't open the hook or part files while building**. The parts are the app's own source (`src/commerce/storefront/parts/`), so they *can* be edited — only when a user requirement exceeds their props, after the cheaper rungs (`classes`/CSS → labels → `…Render` overrides → the hooks, [`../references/storefront-custom.md`](../references/storefront-custom.md)); an edited part is the store's code to maintain. Rules marked ⚑ must survive whatever design you build.
21
22
 
22
- **One import path: `@/commerce/storefront`.** Each section opens with its import line — copy it, delete unused names.
23
+ **One import path: `@/commerce/storefront`.** Each section opens with its import line — copy it, drop unused names.
23
24
 
24
25
  ## Setup — once
25
26
 
@@ -28,7 +29,7 @@ Nearly every store has shared chrome, so **start from a pathless layout route**
28
29
  ```jsx
29
30
  import { Routes, Route, Outlet } from "react-router-dom";
30
31
  import { StorefrontProvider, CartUIProvider, useCart } from "@/commerce/storefront";
31
- import { STORE_COPY } from "@/copy"; // the copy file, below
32
+ import { STORE_COPY } from "@/copy"; // your copy file — see The copy file
32
33
  import { base44 } from "@/api/base44Client";
33
34
  import AdminApp from "@/commerce/admin";
34
35
 
@@ -54,66 +55,31 @@ import AdminApp from "@/commerce/admin";
54
55
 
55
56
  ## Design language — once, before any page
56
57
 
57
- The cost driver of a generated storefront is not wiring — it is decoration repeated inline. Encode identity **once**: in `index.css`, set the palette and type scale, then define the store's recurring surfaces as **10–15 composable classes** in Tailwind's components layer, named in *this* store's language (`.panel`, `.btn-cta`, `.label-mono`, `.field`, `.choice-row` — whatever *this* store repeats). Pages then carry short class names plus a couple of layout utilities. ⚑ **A utility run that appears twice becomes a class.** The store's words work the same way: one copy file in the store's voice (below), written once.
58
+ The cost driver of a generated storefront is not wiring — it is decoration repeated inline. Encode identity **once**: in `index.css`, set the palette and type scale, then define the store's recurring surfaces as **10–15 composable classes** named in *this* store's language (`.panel`, `.btn-cta`, `.label-mono`, `.field`, `.choice-row`). Pages then carry short class names plus a little layout. ⚑ **A utility run that appears twice becomes a class.** The words work the same way: one copy file, written once.
58
59
 
59
- **Concentrate identity; don't diffuse it.** The classes carry the look everywhere; on top of them, spend bespoke markup on **one or two signature moments per page**. **The product page stays the storefront's richest surface**, and that richness is semantic: what the controls and rows *show*. Checkout, bag and order-received are convention surfaces — the parts render their structure, your classes carry their look. Keep components small (~2–4K chars).
60
+ **Concentrate identity; don't diffuse it.** The classes carry the look; on top of them spend bespoke markup on **one or two signature moments per page**. **The product page stays the richest surface**, and that richness is semantic: what the controls and rows *show*. Checkout, bag and receipt are convention surfaces — parts give the structure, your classes the look.
61
+
62
+ ⚑ **Budget by surface, and spend the product page's.** Convention surfaces are ~2–4K chars each; **the product page gets ~8K and the collection ~5K**, because rendering axes and specs *by what they are* is exactly what those chars buy — a product page that came in at 3K is the generic one. Over budget means re-implemented hook logic (a quantity clamp, totals math, variant resolution), never too much design: find your version, delete it, call the hook.
60
63
 
61
64
  ## The parts — shared contract
62
65
 
63
- A part renders one section's correct semantic markup — every branch, guard and aria attribute — and nothing else. Your markup arranges the parts (grid, order, sticky aside, accordion steps — a hidden part loses no state; the place-order gate still counts every blocker). Three inputs carry the identity:
66
+ A part renders one section's correct semantic markup — every branch, guard and aria attribute — and nothing else. Your markup arranges the parts (grid, order, sticky aside, accordion steps — a hidden part loses no state; the gate still counts every blocker). What you pass carries the identity:
64
67
 
65
- - **Styling.** The parts ship **layout geometry only** field grids, labels above full-width controls, square thumbnails, label-left/value-right totals, the drawer as a right-hand panel — and **no** color, border, radius, shadow or font, so an unstyled store reads as unfinished, never broken. The look is yours: every element carries `data-part` + state attributes (`[data-part="option"][data-state="selected"]`, `[data-part="row"][data-pending]`), each shipped rule sits at **specificity 0** (`:where()`), so a plain selector of yours always wins, and `className`/`classes` take the same names (`classes={{ row: "bag-row", "line-total": "price" }}`). ⚑ **Style the controls at minimum** — `[data-part="control"]`/`[data-part="input"]` need `border`, `background`, `color: inherit`, `font: inherit`, `padding` and a focus ring, or the store ships browser-default white boxes. Space *between* sections is your containers' `gap`; the parts add no outer margins. Selector inventory, the geometry's custom properties (`--commerce-gap`, `--commerce-media-size`, `--commerce-field-columns`, …) and what else repays styling: [`../references/storefront-styling.md`](../references/storefront-styling.md).
66
- - **Words.** The copy file below, passed once on `StorefrontProvider labels`; override per Root or part with the same shape. A missing key renders a visible `⟨copy: path⟩` and warns — never silent, never English.
67
- - **Navigation — never rendered, never assumed.** A gate renders *your* element when the state calls for it (function children hand you the data); `OrderReceived.PaymentLink` hands `{ url }` to your element; `Checkout.Root` requires `orderReceivedPath`. Nothing assumes a route, or even an anchor.
68
+ - **Styling.** Parts ship **layout geometry only** (field grids, labels above full-width controls, square thumbnails, label-left/value-right totals) and **no** color, border, radius, shadow or font unstyled reads unfinished, never broken. The look is yours: elements carry `data-part` + state attributes, every shipped rule sits at **specificity 0** (`:where()`) so your plain selector wins, and `className`/`classes` take the same names. ⚑ **Style the controls at minimum** — `[data-part="control"]`/`[data-part="input"]` need `border`, `background`, `color: inherit`, `font: inherit`, `padding` and a focus ring, or the store ships browser-default white boxes. Arranging *sections* stays yours (columns, gaps, the drawer): parts add no outer margins. Selectors, geometry vars and what else repays styling: [`../references/storefront-parts.md`](../references/storefront-parts.md).
69
+ - **Words.** Your copy file, passed once on `StorefrontProvider labels`; override per Root or part with the same shape. A missing key renders a visible `⟨copy: path⟩` and warns — never silent, never English.
70
+ - **Navigation — never rendered, never assumed.** A gate renders *your* element when the state calls for it (function children hand you the data); `OrderReceived.PaymentLink` hands `{ url }` to yours; `Checkout.Root` requires `orderReceivedPath`. Nothing assumes a route, or even an anchor.
71
+ - **Display settings — `show`, never CSS.** Which sub-elements a part renders is a prop: `show={{ media: false }}` (overrides) or `show={["subtotal", "total"]}` (exactly these). Keys are the same `data-part` names, per part below. ⚑ **Turn an element off with `show`, not `display: none`** — the prop renders nothing, while hiding leaves an empty box in the layout and the accessibility tree.
72
+ - **Overrides — your element, the part's wiring.** Where the *element type* is the design decision, one prop swaps it and the part keeps the state and aria: `inputRender`/`fieldRender` (address fields), `controlRender` (ship-to-different), `optionRender` (a shipping/payment option — **the whole element**, radio included: call `option.select()`, show `option.selected`), `stepperRender` (cart quantity), `inputRender` (coupon), `lineRender`/`itemRender` (a whole row). ⚑ A replaced control still owes keyboard operability, a readable state and a name. Signatures: [`../references/storefront-parts.md`](../references/storefront-parts.md).
68
73
 
69
74
  A section the props can't express drops to its hook — same shared state, mixing is safe: [`../references/storefront-custom.md`](../references/storefront-custom.md).
70
75
 
71
76
  ### The copy file
72
77
 
73
- Copy this map into `src/copy.js`, then **rewrite every value in this store's voice** the English here is *reference*; shipped verbatim it is how storefronts end up sounding alike (`aria.*` name controls for screen readers; templates take the item's name):
74
-
75
- ```js
76
- export const STORE_COPY = {
77
- placeOrder: { label: "Place order", placing: "Placing your order…" },
78
- blockers: {
79
- empty_cart: "Your bag is empty.", billing_incomplete: "Fill in your details above.",
80
- shipping_address_incomplete: "Finish the delivery address.",
81
- shipping_address_required: "Enter your address to see delivery options.",
82
- shipping_method_required: "Choose a delivery option.",
83
- shipping_not_available: "We can't deliver to that address.",
84
- payment_method_required: "Choose how you'd like to pay.",
85
- cart_loading: "One moment — loading your bag.", shipping_recalculating: "Updating delivery costs…",
86
- },
87
- shipping: {
88
- missing_address: "Delivery options appear once your address is entered.",
89
- none_available: "We don't deliver to that address yet.",
90
- syncing: "Updating delivery options…",
91
- },
92
- payment: { none_available: "Checkout is unavailable right now — please try again later." },
93
- coupon: { placeholder: "Gift or promo code", apply: "Apply", remove: "Remove" },
94
- fields: {
95
- first_name: "First name", last_name: "Last name", email: "Email", company: "Company",
96
- address_1: "Address", address_2: "Apartment, suite (optional)", country: "Country",
97
- city: "City", state: "State / Province", postcode: "Postal code",
98
- phone: "Phone (optional)", select_placeholder: "Select…",
99
- },
100
- totals: { subtotal: "Subtotal", discount: "Discount", shipping: "Shipping", tax: "Tax", total: "Total" },
101
- bank: { account_name: "Account name", account_number: "Account number", bank_name: "Bank",
102
- sort_code: "Sort code", iban: "IBAN", bic: "BIC" },
103
- aria: {
104
- trigger: "Open your bag", drawer: "Your bag", close: "Close",
105
- increase: (name) => `Add one more ${name}`, decrease: (name) => `Remove one ${name}`,
106
- remove: (name) => `Remove ${name} from your bag`,
107
- shipping_group: "Delivery options", payment_group: "Payment methods",
108
- },
109
- };
110
- ```
111
-
112
- Blocker lines are what a disabled place-order button says — one per code, naming the thing the customer can fix (`cart_loading`/`shipping_recalculating` are quiet transients, not errors); `shipping.*`/`payment.*` are the pickers' dead-ends, with the server's own message rendered instead where one exists. The key tree is frozen in code as `REQUIRED_LABEL_KEYS`.
78
+ The parts render **no kit-authored word**: every string — visible copy, field labels, totals row names, aria names — comes from one map you write in the store's voice and pass once (`<StorefrontProvider labels={STORE_COPY}>`, Setup above); a part or page `Root` takes a `labels` slice to override one surface. Groups: `placeOrder`, `blockers` (one line per code, each naming what the customer can fix), `shipping`/`payment` (picker dead-ends), `coupon`, `fields`, `totals`, `bank`, `aria`. **A key you don't provide renders a visible `⟨copy: …⟩` placeholder** and warns — never English, never silent, and never something to ship. **Copy the reference map from [`../references/storefront-parts.md`](../references/storefront-parts.md) and rewrite every value** — the same file carries the styling contract, so open it once for both.
113
79
 
114
80
  ## What each hook resolves to
115
81
 
116
- Everything below is already unwrapped — no `.data`, no envelope; `formatMoney` is `useFormatMoney()`. This table serves the catalog surfaces and row/option overrides; the custom-section shapes (`useCartLine`, `useCartUI`, `useCheckoutContext`, `useOrderReturn`, `cartTotalsLines`/`orderTotalsLines`) live in [`../references/storefront-custom.md`](../references/storefront-custom.md).
82
+ Already unwrapped — no `.data`, no envelope; `formatMoney` is `useFormatMoney()`. This serves the catalog surfaces and row/option overrides; custom-section shapes (`useCartLine`, `useCartUI`, `useCheckoutContext`, `useOrderReturn`, `cartTotalsLines`/`orderTotalsLines`) are in [`../references/storefront-custom.md`](../references/storefront-custom.md).
117
83
 
118
84
  | Call | Resolves to |
119
85
  |---|---|
@@ -128,7 +94,7 @@ Everything below is already unwrapped — no `.data`, no envelope; `formatMoney`
128
94
  | `productPrice(rowOrView, { formatMoney })` | `{ label, compareAtLabel, onSale, isFrom, isRange, min, max }` — `label` is what to render. |
129
95
  | `productImages(product)` | `[{ src, name, alt }]`, de-duplicated. `[]` is legitimate → render your placeholder. |
130
96
  | `productRibbons(product)` | `[{ id, name }]` — **objects**, and the field can be absent; takes a listing row or `useProduct().product`. |
131
- | `productSpecs(product)` | `[{ key, label, titleLabel, value }]` from `meta_data`; `findSpec(rows, key)` looks one up ignoring case/spaces/`_`/`-`. Never match on `label` — meta keys are free text. |
97
+ | `productSpecs(product)` | `[{ key, label, titleLabel, value, type, number, unit, items }]` from `meta_data` `type` is `"numeric" \| "duration" \| "location" \| "list" \| "text"`, inferred, with `number`/`unit` split out for the first two and `items` for a list. `findSpec(rows, key)` looks one up ignoring case/spaces/`_`/`-`. Never match on `label` — meta keys are free text. |
132
98
  | `useCart()` | `{ status, cart, itemCount, isEmpty, loading, error, mutationError, refresh, addItem, updateItem, removeItem, applyCoupon, removeCoupon }` — `status`: `"loading" \| "ready" \| "empty"`. |
133
99
  | `cart.items[n]` | `{ item_key, product_id, variation_id, name, slug, quantity, price, subtotal, total, image, attributes, sold_individually, purchasable }` — `attributes` is an **array** of `{name, option}` (`attributesLabel(item.attributes)` → `"Size: 42 · Color: Ivory"`); `purchasable` is a **result object** `{ok, code, error}`, not a boolean; `slug` is the line's product-page link. |
134
100
 
@@ -138,15 +104,15 @@ Everything below is already unwrapped — no `.data`, no envelope; `formatMoney`
138
104
  import { useProductList, useCategories, useStoreInfo, useFormatMoney, productPrice, productImages, productRibbons } from "@/commerce/storefront";
139
105
  ```
140
106
 
141
- (Drop `useCategories` with no filter bar; add `useRibbons` for a ribbon filter.) `const list = useProductList({ per_page: 24 })`, then guards on `list.status` before any markup — ⚑ branch on `status`, so a failed request renders as a failure instead of an empty grid, with a retry calling `list.reload`.
107
+ (Drop `useCategories` with no filter bar; add `useRibbons` for a ribbon filter.) `const list = useProductList({ per_page: 24 })`, then guard on `list.status` before any markup — ⚑ branch on `status`, so a failed request renders as a failure, not an empty grid, with a retry calling `list.reload`.
142
108
 
143
109
  ⚑ **Render paging whenever `hasNext` is true** — `{list.hasNext && <button type="button" onClick={list.next} disabled={list.busy}>…</button>}` (append mode: `list.loadMore`); a page that renders nothing for paging ships a catalog silently capped at `per_page`. Drive filters from `useCategories()`/`useRibbons()` data via `setParams`, never from hardcoded names — a renamed ribbon must not strand a dead button.
144
110
 
145
- A card can render `name`, `productImages(row)[0]`, `productPrice(row, { formatMoney }).label` (already "From €19.99" when the product sells variants — there is no product `type` flag, and `product.price` alone is a rolled-up from-price), `on_sale`, `short_description`, `stock_status`, `average_rating`/`rating_count`, `productRibbons(row)`, `productSpecs(row)`. ⚑ **Images and ribbons are objects, either may be empty** — render your placeholder, never a broken `<img>` or a raw object. Field matrix: [`../references/catalog-rendering.md`](../references/catalog-rendering.md). That list is an inventory, not a card design: lead with the one or two fields *these* products are judged on rather than the default name/price/stars trio.
111
+ A card can render `name`, `productImages(row)[0]`, `productPrice(row, { formatMoney }).label` (already "From €19.99" when the product sells variants — there is no `type` flag, and `product.price` is a rolled-up from-price), `on_sale`, `short_description`, `stock_status`, `average_rating`/`rating_count`, `productRibbons(row)`, `productSpecs(row)`. ⚑ **Images and ribbons are objects, either may be empty** — render your placeholder, never a broken `<img>` or a raw object. Field matrix: [`../references/catalog-rendering.md`](../references/catalog-rendering.md). That is an inventory, not a card design and not an order to render in. An even grid of identical cards, each carrying the same name/price/stars trio, is where a generated store lands by default and almost never where this catalog belongs: give the grid a rhythm (a hero piece spanning two columns, an editorial break between rows, a denser tile for a large catalog), and lead each card with the one or two fields *these* products are judged on carat weight, focal length, edition size, ABV — read off `productSpecs(row)`, not the fields every store shows.
146
112
 
147
- ⚑ **Ribbons belong in both views** — grid and product page. They are the merchant's own merchandising ("Limited", "Last pieces"), and each links to its filtered listing (`/collection?ribbon_id=<id>`). `productRibbons(row)` hands you `{id, name}` **objects** — render `r.name`, key the link on `r.id`; the entry itself in JSX is React's "Objects are not valid as a React child". Never render a bare "Ribbons:" label with nothing after it.
113
+ ⚑ **Ribbons belong in both views** — grid and product page. They are the merchant's own merchandising ("Limited", "Last pieces"), each linking to its filtered listing (`/collection?ribbon_id=<id>`). `productRibbons(row)` hands you `{id, name}` **objects** — render `r.name`, key on `r.id`; the object in JSX is React's "Objects are not valid as a React child". Never render a bare "Ribbons:" label with nothing after it.
148
114
 
149
- **Rails** (featured row, "new in") are the same hook with a filter (`{ featured: true, per_page: 4 }`) — `featured` is the merchant's own flag, so the rail stays curated store data instead of hardcoded slugs. ⚑ Any filter may legitimately match nothing — render *nothing* then, never a heading over an empty row.
115
+ **Rails** (featured row, "new in") are the same hook with a filter (`{ featured: true, per_page: 4 }`) — `featured` is the merchant's flag, so the rail stays curated store data, not hardcoded slugs. ⚑ Any filter may match nothing — render *nothing* then, never a heading over an empty row.
150
116
 
151
117
  ## Product page
152
118
 
@@ -154,7 +120,7 @@ A card can render `name`, `productImages(row)[0]`, `productPrice(row, { formatMo
154
120
  import { useProduct, useAddToCart, useStoreInfo, useFormatMoney, useStorefront, variantAxes, productImages, imageIndex, productRibbons, productSpecs, findSpec, storefrontErrorCode } from "@/commerce/storefront";
155
121
  ```
156
122
 
157
- `useProduct(slug)` (or `{ id }`) owns fetch + variant selection + quantity + price, race-safe, selection mirrored to the URL. ⚑ `status: "not_found"` is a 404 page, not a spinner. ⚑ **Call every hook above the status guards** — they all tolerate a null/loading product; a hook after an early `return` crashes React with "Rendered more hooks than during the previous render" the moment the product resolves.
123
+ `useProduct(slug)` (or `{ id }`) owns fetch + variant selection + quantity + price, race-safe, selection mirrored to the URL. ⚑ `status: "not_found"` is a 404 page, not a spinner. ⚑ **Every hook goes above the status guards** — they tolerate a null/loading product; a hook after an early `return` crashes React with "Rendered more hooks than during the previous render" once the product resolves.
158
124
 
159
125
  ```jsx
160
126
  const p = useProduct(slug);
@@ -178,7 +144,7 @@ Build your layout from — all optional, **not one component style**:
178
144
  useEffect(() => setPicked(null), [view?.variation?.id]); // a new variant re-takes the lead
179
145
  ```
180
146
 
181
- ⚑ **The main frame comes from `view.display.image`, never from an index into the strip.** A variation's image lives on the variation and is usually *not* in `product.images`, so indexing the strip shows the wrong colour as soon as a variant is picked. `display.image` is variation-first with a parent fallback, in the same `{src, name, alt}` shape as a strip entry — which is why `imageIndex` locates it when it is there and returns `-1` when it isn't. `active === null` only when the product has no images at all.
147
+ ⚑ **The main frame comes from `view.display.image`, never an index into the strip.** A variation's image lives on the variation and usually is *not* in `product.images`, so indexing the strip shows the wrong colour as soon as a variant is picked. `display.image` is variation-first with a parent fallback, in the same `{src, name, alt}` shape as a strip entry — which is why `imageIndex` finds it when present and returns `-1` when not. `active === null` only when the product has no images.
182
148
  - **Variant selector** — `variantAxes(view, p.pick)`, one entry per axis:
183
149
 
184
150
  ```jsx
@@ -193,7 +159,7 @@ Build your layout from — all optional, **not one component style**:
193
159
  ))}
194
160
  ```
195
161
 
196
- ⚑ **One control per axis, never a list of variations**, and ⚑ **an unbuyable option renders `disabled`, never hidden** (`outOfStock` stays visible, just marked). `view.missingAxes` names what's unpicked. **Render each axis by what it is** — swatches for a colour axis, chips with a size guide beside a size axis; every axis as the identical chip row is a generated-page tell. That differentiation is semantic what the control *shows* — built from your classes, not extra chrome around each row.
162
+ ⚑ **One control per axis, never a list of variations**, and ⚑ **an unbuyable option renders `disabled`, never hidden** (`outOfStock` stays visible, just marked). `view.missingAxes` names what's unpicked. **Render each axis by what it is** — swatches for a colour axis, chips plus a size guide for a size axis; every axis as the identical chip row is a generated-page tell, and the differentiation is semantic (what the control *shows*), not chrome.
197
163
  - **Buy box** — one button, and **you supply its four words**:
198
164
 
199
165
  ```jsx
@@ -202,12 +168,22 @@ Build your layout from — all optional, **not one component style**:
202
168
  {buy.error?.message && <p role="alert">{buy.error.message}</p>}
203
169
  ```
204
170
 
205
- ⚑ **Text for every state, and the gate from the hook.** `buy.state` resolves the precedence — never re-derive `disabled` from your own ternary chain, and never leave a state unworded (the button renders empty). ⚑ `buy.showQuantity: false` means no stepper. With `<CartUIProvider>` mounted, a successful add opens the drawer by itself.
206
- - **Description** — `product.description` is HTML; render as rich text, `short_description` above it.
207
- - **Specs** — `productSpecs(product)` rows from the admin's *Modifiers*. **Look a particular spec up with `findSpec(rows, "care")`**, which ignores case, spaces, `_` and `-`. Meta keys are free text (`care`, `Care`, `Care Instructions`), so `rows.find(s => s.label === "Care")` silently never matches and renders the fallback forever. **This is the product page's signature-moment candidate**: pick the two or three keys that carry *this* catalog's meaning and render each as what it is (a weight as a figure, a composition as bars, a provenance beside its place), then let the rest fall through to plain rows in your classes. Not one uniform grey table; not a bespoke widget per row. `[]` means no section at all.
208
- - **Breadcrumbs** — from `categories` (`/collection?category_id=${c.id}`); skip on a flat catalog. Ribbons (`productRibbons(product)`) are labels, not breadcrumbs.
209
- - **Reviews, only if the store wants them** — no review UI is a complete outcome (then no star ratings on cards: an average of nothing is `0`). `p.reviews` arrives with the product; submitting is `submitReview` off `useStorefront()`, open to guests. ⚑ Derive the confirmation from the response's `status` (`"approved"` vs `"hold"`) — a hardcoded "awaiting approval" lies to auto-approving stores — and refresh the list after. Policies and moderation: [`../references/reviews.md`](../references/reviews.md).
210
- - **Title** — give each page type its own `<title>` and description; one static title everywhere is invisible to search. No structured data ships — for rich results emit your own `Product`/`Offer` JSON-LD from `product` and `view.display`.
171
+ ⚑ **Text for every state, and the gate from the hook.** `buy.state` resolves the precedence — never re-derive `disabled` from a ternary chain, never leave a state unworded (the button renders empty). ⚑ `buy.showQuantity: false` means no stepper. With `<CartUIProvider>` mounted, a successful add opens the drawer.
172
+ - **Description** — `product.description` is HTML; render as rich text, `short_description` above.
173
+ - **Specs** — `productSpecs(product)` rows from the admin's *Modifiers*, **the product page's signature-moment candidate**. Every row arrives pre-classified, so the branch point is already there and one uniform table is a choice, not a default:
174
+
175
+ ```jsx
176
+ {productSpecs(product).map((s) =>
177
+ s.type === "numeric" ? <Figure key={s.key} label={s.label} n={s.number} unit={s.unit} />
178
+ : s.type === "list" ? <Bars key={s.key} parts={s.items} /> // composition, materials
179
+ : s.type === "location" ? <Sourced key={s.key} place={s.value} /> // origin, provenance
180
+ : <Row key={s.key} label={s.titleLabel} value={s.value} />)}
181
+ ```
182
+
183
+ ⚑ **Never `.map()` the whole list into one grey label/value table** — that is the single most reliable tell of a generated product page. Design the two or three rows that carry *this* catalog's meaning as what they are (a weight set in the display face, a composition as bars, a provenance beside its place); let the rest fall through to the plain row, and don't feel obliged to keep them in one block — a spec can sit under the gallery, beside the price, or inside the description. Branch on `s.key` too where one particular modifier deserves its own treatment regardless of type. ⚑ **Look a spec up with `findSpec(rows, "care")`** (ignores case, spaces, `_`, `-`): meta keys are free text (`care`, `Care`, `Care Instructions`), so `rows.find(s => s.label === "Care")` silently never matches and renders the fallback forever. `[]` means no section.
184
+ - **Breadcrumbs** — from `categories` (`/collection?category_id=${c.id}`); skip on a flat catalog. Ribbons are labels, not breadcrumbs.
185
+ - **Reviews, only if the store wants them** — no review UI is a complete outcome (then no star ratings on cards: an average of nothing is `0`). `p.reviews` arrives with the product; submitting is `submitReview` off `useStorefront()`, open to guests. ⚑ Derive the confirmation from the response's `status` (`"approved"` vs `"hold"`) — a hardcoded "awaiting approval" lies to auto-approving stores — and refresh after. Policies: [`../references/reviews.md`](../references/reviews.md).
186
+ - **Title** — a `<title>` and description per page type; one static title everywhere is invisible to search. No structured data ships — for rich results emit your own `Product`/`Offer` JSON-LD from `product` and `view.display`.
211
187
 
212
188
  ## Cart / bag
213
189
 
@@ -215,38 +191,50 @@ Build your layout from — all optional, **not one component style**:
215
191
  import { Cart, CartDrawer } from "@/commerce/storefront";
216
192
  ```
217
193
 
218
- A cart *page* is optional — decide from what the store sells (buy-now-to-checkout suits a single-piece store; a grocery basket needs a page). Page or drawer, the same parts in your layout — here as a drawer (`<CartUIProvider>`, mounted in Setup, owns open/close, Esc, open-on-add, close-on-navigate; a page skips the `CartDrawer.*` wrapper and provider):
194
+ A cart *page* is optional (buy-now-to-checkout suits a single-piece store; a grocery basket needs a page). Page or drawer, the same parts. Below as a drawer: `<CartUIProvider>` (Setup) owns state and behavior — open/close, Esc, open-on-add, close-on-navigate, focus in and back out, `inert` while closed — while **the drawer's surface is entirely yours**: side, width, padding, animation. A cart page skips the provider and the `ui.open` wrapper.
219
195
 
220
196
  ```jsx
197
+ const ui = useCartUI(); // open + handlers + panelRef/panelId
198
+
221
199
  <header>{/* your nav */}
222
200
  <CartDrawer.Trigger className="nav-icon">
223
201
  {({ count }) => (<><YourBagGlyph />{count > 0 && <span className="badge">{count}</span>}</>)}
224
202
  </CartDrawer.Trigger>
225
203
  </header>
226
204
 
227
- <CartDrawer.Panel className="drawer">
228
- <header>{/* your title */}<CartDrawer.Close><YourXGlyph /></CartDrawer.Close></header>
229
- <Cart.Empty>{/* your empty state + your own link back into the shop */}</Cart.Empty>
230
- <Cart.Ready>
231
- <Cart.Notices />
232
- <Cart.Lines classes={{ row: "bag-row", media: "bag-thumb" }} />
233
- <Cart.CouponField />
234
- <Cart.Totals pick={["subtotal"]} />
235
- {/* your checkout affordance — your element, your route */}
236
- </Cart.Ready>
237
- </CartDrawer.Panel>
205
+ {ui.open && ( /* the drawer's SURFACE is yours: */
206
+ <div className="drawer-layer"> /* fixed layer — side, z-index, transition */
207
+ <div className="drawer-scrim" aria-hidden="true" onClick={ui.closeCart} />
208
+ <aside id={ui.panelId} ref={ui.panelRef} tabIndex={-1}
209
+ role="dialog" aria-modal="true" aria-label={/* your words */}
210
+ className="drawer-panel"> /* width, padding, layout, animation */
211
+ <header>{/* your title */}<CartDrawer.Close><YourXGlyph /></CartDrawer.Close></header>
212
+ <Cart.Empty>{/* your empty state + your own link back into the shop */}</Cart.Empty>
213
+ <Cart.Ready>
214
+ <Cart.Notices />
215
+ <Cart.Lines classes={{ row: "bag-row", media: "bag-thumb" }} />
216
+ <Cart.CouponField />
217
+ <Cart.Totals show={["subtotal"]} />
218
+ {/* your checkout affordance — your element, your route */}
219
+ </Cart.Ready>
220
+ </aside>
221
+ </div>
222
+ )}
238
223
  ```
239
224
 
240
225
  | Part | Renders | Props |
241
226
  |---|---|---|
242
227
  | `Cart.Loading` / `Empty` / `Ready` | gates — children only in that cart status, so emptiness-while-loading can't render; function children get `{ cart, itemCount }` | — |
243
- | `Cart.Lines` | the rows: media, name, attributes, stepper (optimistic, coalesces clicks, rolls back — busy scoped to **that row**, never the cart), remove, line total, per-row errors in the server's words | `lineRender(item, l)` replaces a row `item.slug` for your product link |
228
+ | `Cart.Lines` | the rows: thumbnail, name, attributes, stepper (optimistic, coalesces clicks, rolls back — busy scoped to **that row**, never the cart), remove, line total, per-row errors in the server's words | `show`: `media` `attributes` `stepper` `remove` `lineTotal`; `stepperRender`, `lineRender` (`item.slug` for your product link) |
244
229
  | `Cart.Notices` | expired coupons + vanished products, the server's words — a line never disappears unexplained | — |
245
- | `Cart.CouponField` | input + apply + applied codes with remove; an invalid code renders inline | |
246
- | `Cart.Totals` | every non-hidden summary line, discount and tax included, `data-emphasis` on the total | `pick={["subtotal"]}` |
247
- | `CartDrawer.Trigger/Panel/Close` | the wired header button (render prop gets `{ count, open }`); the dialog done right click-away overlay (never the close control), focus in/restored, unmounted while closed (`keepMounted` to animate the closed panel goes `inert`); the named close button | children; `keepMounted` |
230
+ | `Cart.CouponField` | input + apply + applied codes with remove; an invalid code renders inline | `inputRender` |
231
+ | `Cart.Totals` | every non-hidden summary line, discount and tax included, `data-emphasis` on the total | `show`: row keys (`["subtotal"]` for a drawer footer) |
232
+ | `CartDrawer.Trigger` / `Close` | the two wired buttons the trigger toggles and reports `aria-expanded`/`aria-controls` (render prop gets `{ count, open }`); `Close` is the named way out. **No panel part ships**: the overlay, the panel, their side, width, padding and animation are yours | children |
233
+ | `useCartUI()` | `{ open, openCart, closeCart, toggleCart, panelRef, panelId }` — plus Esc, close-on-navigate and open-on-add. Attach `panelRef` and focus moves into your panel on open and back out on close, and a panel you keep mounted to animate is made `inert` while closed | — |
234
+
235
+ ⚑ Drawer rules, now that the surface is yours: render it conditionally (`{ui.open && …}`) or keep it mounted to animate and let `panelRef` handle `inert`; the panel takes `role="dialog" aria-modal="true"`, a name, `tabIndex={-1}` and `id={ui.panelId}`; the scrim is click-away only (`aria-hidden`, no tab stop) — `<CartDrawer.Close>` is the close control.
248
236
 
249
- ⚑ **A store with any coupons places `CouponField` once** (cart or checkout) — codes are redeemable only through a field; no field → don't seed codes. **Upsells**: `useProduct().upsells` rows added via `useCart().addItem`, matched by product id — ⚑ one-click add only for a product with no attributes (others answer `400 variation_required`; link those tiles to the product page). Drawer position, width and motion are your CSS on `[data-part="panel"]` / `[data-state]`.
237
+ ⚑ **A store with any coupons places `CouponField` once** (cart or checkout) — codes are redeemable only through a field; no field → don't seed codes. **Upsells**: `useProduct().upsells` rows added via `useCart().addItem`, matched by product id — ⚑ one-click add only for a product with no attributes (others answer `400 variation_required`; link those tiles to the product page). A cart-contents component reused in the drawer, the cart page *and* the checkout summary drops what a checkout shouldn't carry via `useInCheckout()` a merchandising rail is a way out of the funnel there.
250
238
 
251
239
  ## Checkout
252
240
 
@@ -254,9 +242,9 @@ A cart *page* is optional — decide from what the store sells (buy-now-to-check
254
242
  import { Checkout } from "@/commerce/storefront";
255
243
  ```
256
244
 
257
- `Checkout.Root` mounts the guided state — address edits reprice shipping/tax automatically (debounced, never on a half-typed address), choices derive from store data — and resolves the page's phase: `Submitted` renders above everything (placing an order clears the bag; this ordering kills the empty-bag flash over a just-placed order), then `Loading` / `Empty` / `Form`.
245
+ `Checkout.Root` mounts the guided state — address edits reprice shipping/tax automatically (debounced, never on a half-typed address), choices derive from store data — and resolves the phase: `Submitted` renders above everything (placing an order clears the bag, and this ordering kills the empty-bag flash over it), then `Loading` / `Empty` / `Form`.
258
246
 
259
- ⚑ **`orderReceivedPath` is required** — pass the route *your* order-received page is mounted at; the kit assumes none. The landing is a **full page load** by design (the provider hop leaves the app; the receipt boots from the URL alone — a browser script must navigate fresh to it, not wait for a transition). `null` + `onPlaced(result)` hands the step to you.
247
+ ⚑ **`orderReceivedPath` is required** — the route *your* receipt page is mounted at; the kit assumes none. The landing is a **full page load** by design (the provider hop leaves the app; the receipt boots from the URL alone — a browser script must navigate fresh to it, not await a transition). `null` + `onPlaced(result)` hands the step to you.
260
248
 
261
249
  ```jsx
262
250
  <Checkout.Root orderReceivedPath="/order-received">
@@ -287,16 +275,16 @@ import { Checkout } from "@/commerce/storefront";
287
275
  |---|---|---|
288
276
  | `Checkout.Root` | the provider, the phase, the labels scope | `orderReceivedPath` (**required**; or `null` + `onPlaced`), `labels`, `options`, `checkout` |
289
277
  | `Submitted` / `Loading` / `Empty` / `Form` | gates; function children get `{ checkout, cart }` | — |
290
- | `AddressFields` | the spec-driven fields: state/province appears once a country is picked (rates and taxes match on country *plus* state — its absence silently mis-prices US/CA/AU orders); `autoComplete` on; required marks arm on blur; "we don't ship there" lands on country; `which="shipping"` renders only when toggled | `which`, `include={["company"]}`, `omit`, `inputRender`, `fieldRender` |
291
- | `ShipToDifferent` | the wired toggle; children are its label | children |
292
- | `ShippingMethods` | every branch: the hint line (server's message preferred over yours), a radio list while there is a real choice, the single/auto-selected option *displayed* — never a picker of one, never "nothing selected" — null for a virtual cart | `optionRender(m)` — option content; the radio stays wired |
278
+ | `AddressFields` | the spec-driven fields: state/province appears once a country is picked (rates and taxes match on country *plus* state — its absence silently mis-prices US/CA/AU orders); `autoComplete` on; required marks arm on blur; "we don't ship there" lands on country; `which="shipping"` renders only when toggled | `which`; `show` keyed by field (`company` off by default); `inputRender`, `fieldRender` |
279
+ | `ShipToDifferent` | the wired toggle; children are its label | children, `controlRender` |
280
+ | `ShippingMethods` | every branch: the hint line (server's message preferred), a radio list while there is a real choice, the single/auto-selected option *displayed* — never a picker of one, never "nothing selected" — null for a virtual cart | `optionRender(m)` — the whole option |
293
281
  | `PaymentMethods` | every **enabled** gateway from store info, titles/descriptions in the admin's words (a default store offers `offline` only — never hardcode a card option); one renders as the selection it is; none renders your unavailable line | `optionRender(g)` |
294
- | `Items` | the read-only mini summary | `itemRender(item)` |
295
- | `CouponField` / `Totals` | same parts as the cart's | / `pick` |
296
- | `PlaceOrder` | the button (your two labels), the server's order error, and the blocker lines — ⚑ a disabled button always says why | `showBlockers` (default true) |
297
- | `Blockers` | the reasons alone, for custom placement (then `showBlockers={false}`) | — |
282
+ | `Items` | the read-only mini summary | `show`: `media` `attributes` `quantity` `lineTotal`; `itemRender` |
283
+ | `CouponField` / `Totals` | same parts as the cart's | `inputRender` / `show` |
284
+ | `PlaceOrder` | the button (your two labels), the server's order error, and the blocker lines — ⚑ a disabled button always says why | `show`: `blockers` `orderError` |
285
+ | `Blockers` | the reasons alone, for custom placement the button's own copy stands down by itself | — |
298
286
 
299
- `inputRender` is the middle tier when the fields need your own control — more authored code, more styled: `({ field, id, value, onChange, onBlur, invalid, autoComplete, options }) => <YourField …/>`; `fieldRender` replaces the whole labeled block. **Multi-step checkout is your markup**: wrap parts in your step containers — a hidden step loses nothing, the gate still counts every blocker.
287
+ **Multi-step checkout is your markup**: wrap parts in your step containers — a hidden step loses nothing, the gate still counts every blocker.
300
288
 
301
289
  ## Order received
302
290
 
@@ -313,8 +301,8 @@ import { OrderReceived } from "@/commerce/storefront";
313
301
  {({ order }) => (<>{/* your thank-you, order #{order.order_number} */}
314
302
  <OrderReceived.Items /><OrderReceived.Totals /></>)}
315
303
  </OrderReceived.Paid>
316
- <OrderReceived.Unpaid>{/* your wording for "not settled yet" */}
317
- <OrderReceived.PaymentInstructions /> {/* ⚑ see below */}
304
+ <OrderReceived.Unpaid>{/* your "not settled yet" wording */}
305
+ <OrderReceived.PaymentInstructions /> {/* ⚑ below */}
318
306
  <OrderReceived.PaymentLink>{({ url }) => (/* your pay-now element on url */)}</OrderReceived.PaymentLink>
319
307
  <OrderReceived.Items /><OrderReceived.Totals />
320
308
  </OrderReceived.Unpaid>
@@ -323,20 +311,22 @@ import { OrderReceived } from "@/commerce/storefront";
323
311
  </OrderReceived.Root>
324
312
  ```
325
313
 
326
- ⚑ **`PaymentInstructions` goes on the unpaid branch.** A default-seeded store settles manually (bank transfer, cash on delivery), so the instructions ARE how its customers pay — a receipt without them strands every real order. It renders the admin's description plus account details (your `bank.*` labels); null for card orders. `PaymentLink` renders your element only when a live link exists; the gates' function children get `{ order, paymentLink, paymentInstructions, error, reload }` for anything custom. `Items` rows arrive normalized; `Totals` owns the flat-totals trap (`order.total`, never `order.totals`).
314
+ ⚑ **`PaymentInstructions` goes on the unpaid branch.** A default-seeded store settles manually (bank transfer, cash on delivery), so the instructions ARE how its customers pay — a receipt without them strands every real order. It renders the admin's description plus account details (your `bank.*` labels); null for card orders. `PaymentLink` renders your element only when a live link exists; the gates' function children get `{ order, paymentLink, paymentInstructions, error, reload }`. `Items` takes the same `show`/`itemRender` as the cart; `Totals` owns the flat-totals trap (`order.total`, never `order.totals`).
327
315
 
328
- **Driving the storefront from a browser script?** The cart is optimistic and debounced, so a script that acts faster than it settles sees a working store as broken — read [`../references/storefront-verification.md`](../references/storefront-verification.md) **before** writing the script, not after it fails.
316
+ **Driving the storefront from a browser script?** The cart is optimistic and debounced, so a script acting faster than it settles sees a working store as broken — read [`../references/storefront-verification.md`](../references/storefront-verification.md) **before** writing it.
329
317
 
330
318
  ## Done — forget this file
331
319
 
332
320
  - [ ] Catalog UI in whatever form fits the store; cart (and/or drawer), checkout and `/order-received` built from the parts — all five receipt gates present, `PaymentInstructions` placed on unpaid.
321
+ - [ ] A drawer, if any, is the store's own surface (side, width, padding, animation) over `useCartUI()`: `panelRef` + `panelId` on the panel, `role="dialog" aria-modal` + a name, the scrim not doubling as close.
333
322
  - [ ] One copy file passed via `<StorefrontProvider labels={…}>`, **every value rewritten in the store's voice** — no `⟨copy: …⟩` on any page, no reference-example sentence shipped verbatim; the buy button reads for all four `buy.state` values.
334
- - [ ] `index.css` defines the design classes; parts styled via `[data-part]`/`classes` — **controls carry border/background/font/padding + a focus ring**, option rows show their selected state, the drawer panel has a surface, nothing left browser-default; pages carry class names, not repeated utility runs.
323
+ - [ ] `index.css` defines the design classes; parts styled via `[data-part]`/`classes` — **controls carry border/background/font/padding + a focus ring**, option rows show their selected state, nothing left browser-default; pages carry class names, not repeated utility runs.
335
324
  - [ ] **One** `<StorefrontProvider>` above every storefront route (layout-route pattern); no hand-rolled `cart_token`; imports from each section's import line.
336
- - [ ] Custom hook-built sections branch on `status` and never re-implement part/hook logic; gateways/currency/countries from `useStoreInfo()`/`useCountries()` only.
325
+ - [ ] Custom sections branch on `status` and never re-implement part/hook logic; gateways/currency/countries from `useStoreInfo()`/`useCountries()` only.
337
326
  - [ ] Coupon field placed if the store has coupons; a paging control whenever `hasNext`; ribbons in both the grid and the product page.
338
327
  - [ ] Variant options: one control per axis, unbuyable options disabled, not hidden.
339
328
  - [ ] Navigation affordances are yours: the checkout link in `Cart.Ready`, `orderReceivedPath` on `Checkout.Root`, `PaymentLink`'s child — the kit rendered none of them.
340
- - [ ] The storefront carries the design you settled on before reading this file — design classes plus one or two signature moments per page; the product page stays the richest surface.
329
+ - [ ] The storefront carries the design you settled on before reading this file — design classes plus one or two signature moments per page; the product page stays richest.
330
+ - [ ] **Specs and axes render by what they are**: the product page branches on `productSpecs` `type`/`key` for the rows that carry this catalog's meaning, and the grid has a rhythm — no page ships one uniform grey label/value table or one identical chip row per axis.
341
331
 
342
332
  Then copy this file's `carry_forward` lines into your working notes, and do not re-read this file.
@@ -51,7 +51,7 @@ once. The whole list, so none of it has to be read out of the backend:
51
51
  |---|---|---|
52
52
  | `images[]` | `{src, name, alt}` — **objects**; `[]` is legitimate | `productImages(product)` |
53
53
  | `ribbons[]` | `{id, name}` — **objects**; the field is **absent**, not `[]`, when no row on the page carries one | `productRibbons(product)` |
54
- | `meta_data[]` | `{key, value}` — keys are free text | `productSpecs(product)` + `findSpec` |
54
+ | `meta_data[]` | `{key, value}` — keys are free text | `productSpecs(product)` (adds a render `type`) + `findSpec` |
55
55
  | `attributes[]` | `{attribute_id, name, position, options: string[]}` — one entry per **axis** | `variantAxes(view, pick)` |
56
56
  | `default_attributes[]` | `{attribute_id, name, option}` — the merchant's pre-selection | `defaultSelection` (automatic in `useProduct`) |
57
57
  | `dimensions` | `{length, width, height}` in the store's unit | — |
@@ -95,7 +95,7 @@ Both lists are inventories of what the data supports — **not a layout and not
95
95
 
96
96
  **Product page:** gallery, name, price, variant selectors, stock, `short_description` then `description`, SKU, categories breadcrumb, ribbons, reviews, upsells/cross-sells. All but the markup is handed to you: `productImages(product)` + your own active index, `variantAxes(view, pick)`, `useAddToCart`, `productSpecs(product)`, `p.reviews` (+ `submitReview` off `useStorefront()`), and `p.upsells`/`p.crossSells` — added with `useCart().addItem`, matching "already in cart" by product id.
97
97
 
98
- **Attributes and modifiers are individually designable.** `productSpecs(product)` rows are `{key, label, titleLabel, value}` and nothing more. ⚑ **Look a row up with `findSpec(rows, "care")`**, which ignores case, spaces, `_` and `-`: meta keys are free text typed per product (`care`, `Care`, `Care Instructions`), so an equality test on `label` — or on one spelling of `key` — silently never fires and the feature renders its fallback forever; `titleLabel` is the display-cased form, for a heading. What a row *looks* like is a per-catalog design decision — not one uniform chip row per axis and one grey label/value table ([`../install/02-storefront.md`](../install/02-storefront.md)); §5's rules govern selector *behaviour*, never its form.
98
+ **Attributes and modifiers are individually designable.** `productSpecs(product)` rows are `{key, label, titleLabel, value}` plus an inferred `type` — `"numeric"` / `"duration"` (with `number` and `unit` split out), `"location"`, `"list"` (with `items`), `"text"` — so the rendering branch is already made for you: a weight can be a figure, a composition bars, a provenance a place. ⚑ **Never `.map()` the whole list into one grey label/value table**; design the two or three rows this catalog is judged on and let the rest fall through to a plain row. ⚑ **Look a row up with `findSpec(rows, "care")`**, which ignores case, spaces, `_` and `-`: meta keys are free text typed per product (`care`, `Care`, `Care Instructions`), so an equality test on `label` — or on one spelling of `key` — silently never fires and the feature renders its fallback forever; `titleLabel` is the display-cased form, for a heading. What a row *looks* like is a per-catalog design decision — not one uniform chip row per axis and one grey table ([`../install/02-storefront.md`](../install/02-storefront.md)); §5's rules govern selector *behaviour*, never its form.
99
99
 
100
100
  ## 4. Ribbons — in **both** views
101
101
 
@@ -28,7 +28,7 @@ a disabled button that says why.
28
28
  | Call | Resolves to |
29
29
  |---|---|
30
30
  | `useCartLine(item)` | `{ quantity, setQuantity, increase, decrease, remove, pending, error, canIncrease, canDecrease, maxQuantity, atMax, atMin }` — optimistic quantity, clicks coalesced (~250ms), rollback on rejection. `pending` is **this row's** flag: true while its request is out, false only after the new cart view lands — the only mutation-settled signal (`useCart().status` never returns to `"loading"`). |
31
- | `useCartUI()` | `{ open, openCart, closeCart, toggleCart }` — the drawer state on `<CartUIProvider>` (Esc, open-on-add, close-on-navigate included). |
31
+ | `useCartUI()` | `{ open, openCart, closeCart, toggleCart, panelRef, panelId }` — drawer state and behavior on `<CartUIProvider>` (Esc, open-on-add, close-on-navigate; `panelRef` moves focus in/out and makes a closed-but-mounted panel `inert`). The drawer's overlay and panel are your markup. |
32
32
  | `useCheckoutContext()` | the address (`billing`, `updateBilling`, `shipping`, `updateShipping`, `shipToDifferent`, `setShipToDifferent`, `missingBillingFields`, `addressError`), the choices (`shippingStatus`, `shippingMethods`, `chosenShippingMethod`, `chooseShippingMethod`, `paymentMethods`, `paymentMethod`, `setPaymentMethod`, `selectedGateway`), and the gate (`blockers`, `canPlaceOrder`, `placing`, `stage`, `orderError`, `placeOrder`) — plus `cart`. Works under `<Checkout.Root>` (or your own `<CheckoutProvider>`). |
33
33
  | `useOrderReturn()` | `{ status, order, lines, paymentLink, paymentInstructions, error, reload }` — `status`: `"loading" \| "paid" \| "unpaid" \| "cancelled" \| "error"`; `lines` normalized (`image` is `{src, alt}\|null`, `totalLabel` pre-formatted); an order's totals are **flat** (`order.total`, no `order.totals`). |
34
34
  | `cartTotalsLines(cart, { formatMoney, labels })` / `orderTotalsLines(order, …)` | `[{ key, label, amount, formatted, hidden, emphasis }]` — keys `subtotal` `discount` `shipping` `tax` `total`; discount/tax are produced and flagged `hidden` when zero, so a mapping renderer can't drop them. |