@base44/app-plugin-commerce 0.5.1 → 0.6.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.
@@ -5,22 +5,25 @@ skip_when: "The storefront pages already render against live data and pass the c
5
5
  forget_when: "The checklist at the bottom passes — every page renders against the seeded catalog and an offline order completes."
6
6
  carry_forward:
7
7
  - "Payment gateways, currency and countries come from useStoreInfo() only — never off a cart (cart.payment_gateways is always undefined)."
8
- - "A store with any coupons must place a CouponField part once (cart or checkout), or its codes can never be redeemed."
9
- - "The receipt route is mandatory: OrderReceived.Root with all five gates, PaymentInstructions on the unpaid branch."
10
- - "Branch custom hook-built UI on `status`, never on `isEmpty`/nullable data; every hook goes above its status guards."
8
+ - "A store with any coupons must have a coupon field (useCart().applyCoupon) in the cart or the checkout, or its codes can never be redeemed."
9
+ - "/order-received is mandatory and renders useOrderReturn's states, including paymentInstructions how a normal (offline) customer learns how to pay."
10
+ - "Branch cart/list/product UI on `status`, never on `isEmpty`/nullable data `isEmpty` is false while loading by design."
11
11
  - "Variant options that aren't buyable render disabled, never hidden; one control per axis, never a list of variations."
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
- - "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."
12
+ - "Every hook on a page goes above its status guards a hook after an early return crashes with \"Rendered more hooks than during the previous render\"."
13
+ - "The kit ships no copy: hooks hand you state codes (buy.state, hint.code, blockers) and you write every word including the reason a disabled button is disabled."
14
+ - "Identity is encoded once — design classes in index.css plus one or two signature moments per page; a utility run that appears twice becomes a class."
14
15
  - "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."
15
16
  ---
16
17
 
17
18
  # 02 — Storefront
18
19
 
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-receivedare 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.
20
+ One split decides everything here: **the logic is premade, the UI never is.** The hooks own checkout repricing, variant resolution, cart state, order-return verification hand-writing any of it is where storefront bugs cluster, so **never re-implement what a hook does**. Every element, class, layout and **word** is yours; nothing in `@/commerce/storefront` renders markup or carries CSS. **Decide how the store looks as if this kit did not exist**, then encode it **once** as design classes ([below](#design-language--once-before-any-page)) the snippets here are wiring reference, never design input.
20
21
 
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.
22
+ **States and codes, not copy.** Where a state needs words you get the *state* (`buy.state`, `hint.code`, `blockers`) and write the words. So: never re-derive a state you were handed (a ternary chain over `adding`/`purchasable` re-implements `buy.state`, wrong), and never leave one unworded (a button with no text for `sold_out` renders empty).
22
23
 
23
- **One import path: `@/commerce/storefront`.** Each section opens with its import linecopy it, drop unused names.
24
+ **This file is the whole job.** Every shape you need is in ["What each hook resolves to"](#what-each-hook-resolves-to) don't open the hook files while building; that is the most expensive way to answer a question this page already answers. Rules marked ⚑ must survive whatever design you build.
25
+
26
+ **One import path: `@/commerce/storefront`.** Each section opens with its page's exact import line — copy it verbatim, then delete unused names. Everything a page needs is re-exported there, so a React page never imports `@/commerce/utils` directly. `useStoreInfo` is the name most often left out.
24
27
 
25
28
  ## Setup — once
26
29
 
@@ -28,58 +31,55 @@ Nearly every store has shared chrome, so **start from a pathless layout route**
28
31
 
29
32
  ```jsx
30
33
  import { Routes, Route, Outlet } from "react-router-dom";
31
- import { StorefrontProvider, CartUIProvider, useCart } from "@/commerce/storefront";
32
- import { STORE_COPY } from "@/copy"; // your copy file — see The copy file
34
+ import { StorefrontProvider, CartUIProvider } from "@/commerce/storefront";
33
35
  import { base44 } from "@/api/base44Client";
34
36
  import AdminApp from "@/commerce/admin";
35
37
 
36
38
  <BrowserRouter>
37
39
  <Routes> {/* ONE <Routes> — merge new pages into the app's */}
38
40
  <Route element={
39
- <StorefrontProvider base44={base44} labels={STORE_COPY}>
40
- <CartUIProvider> {/* only if the cart is a drawer */}
41
+ <StorefrontProvider base44={base44}>
42
+ <CartUIProvider> {/* only if the cart is a drawer — see Cart below */}
41
43
  <StoreLayout /> {/* YOURS: header + <Outlet/> + footer + drawer */}
42
44
  </CartUIProvider>
43
45
  </StorefrontProvider>
44
46
  }>
45
47
  <Route path="/" element={<Home />} />
46
48
  <Route path="/product/:slug" element={<ProductPage />} />
47
- {/* /bag, /checkout, /order-received (mandatory) */}
49
+ {/* /bag, /checkout, and /order-received — which is mandatory */}
48
50
  </Route>
49
51
  <Route path="/store-admin/*" element={<AdminApp />} /> {/* own chrome, outside the provider */}
50
52
  </Routes>
51
53
  </BrowserRouter>
54
+
55
+ // …and the layout that route renders. Yours to design; the shape is the point:
56
+ function StoreLayout() {
57
+ const { itemCount } = useCart(); // one cart, shared with every page
58
+ return (
59
+ <>
60
+ <header>{/* nav + your cart trigger, showing itemCount */}</header>
61
+ <Outlet /> {/* the routed page lands here */}
62
+ <footer>…</footer>{/* + the drawer, if the cart is one — see Cart below */}
63
+ </>
64
+ );
65
+ }
52
66
  ```
53
67
 
54
- ⚑ **The nesting is provider → layout → `<Outlet/>`, never the reverse** — a layout that renders the provider inside itself leaves the nav's cart badge on a different cart. With no shared chrome, wrap `<Routes>` in the provider instead; a provider *inside* `<Routes>` throws ("is not a `<Route>` component"). The provider owns the shared client, store info and **one** shared cart — never mount a second, never touch `cart_token`.
68
+ ⚑ **The nesting is provider → layout → `<Outlet/>`, never the reverse** — a layout that renders the provider inside itself leaves the nav's cart badge on a different cart (or throws). With no shared chrome, wrap `<Routes>` in the provider instead; a provider *inside* `<Routes>` throws ("is not a `<Route>` component"). The provider owns the shared client, store info and **one** shared cart — never mount a second one, never touch `cart_token`.
55
69
 
56
70
  ## Design language — once, before any page
57
71
 
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.
59
-
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.
63
-
64
- ## The parts — shared contract
72
+ 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`, a heading scale, a price style — 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.**
65
73
 
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:
74
+ The store's words work the same way: the states these hooks hand you recur across pages (an empty bag, an unbuyable product, an undeliverable address), so write that copy once in the store's voice — a small map per surface, as the sections below show. It is the half of a store's identity a kit cannot ship.
67
75
 
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 fontunstyled 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).
76
+ **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 hero, the one product-page module that shows what these products are judged on and render everything else as conventions in the classes. **The product page stays the storefront's richest surface**, and that richness is semantic: what the controls and rows *show*, which costs words rather than chrome. One navigation affordance per control (thumbnails *or* arrows, never both plus dots); checkout, bag and order-received are convention surfaces.
73
77
 
74
- A section the props can't express drops to its hooksame shared state, mixing is safe: [`../references/storefront-custom.md`](../references/storefront-custom.md).
75
-
76
- ### The copy file
77
-
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.
78
+ **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.
79
79
 
80
80
  ## What each hook resolves to
81
81
 
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).
82
+ Everything below is already unwrapped — no `.data`, no envelope. `formatMoney` throughout is `useFormatMoney()`.
83
83
 
84
84
  | Call | Resolves to |
85
85
  |---|---|
@@ -96,7 +96,13 @@ Already unwrapped — no `.data`, no envelope; `formatMoney` is `useFormatMoney(
96
96
  | `productRibbons(product)` | `[{ id, name }]` — **objects**, and the field can be absent; takes a listing row or `useProduct().product`. |
97
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. |
98
98
  | `useCart()` | `{ status, cart, itemCount, isEmpty, loading, error, mutationError, refresh, addItem, updateItem, removeItem, applyCoupon, removeCoupon }` — `status`: `"loading" \| "ready" \| "empty"`. |
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. |
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}`; `purchasable` is a **result object** `{ok, code, error}`, not a boolean; `slug` is the line's product-page link. |
100
+ | `attributesLabel(item.attributes)` | `"Size: 42 · Color: Ivory"` (`""` when the product has no attributes). |
101
+ | `cartTotalsLines(cart, { formatMoney })` | `[{ key, label, amount, formatted, hidden, emphasis }]` — every line the store has, incl. discount and tax. `orderTotalsLines(order, …)` is the same shape for a receipt. Pass `labels: {…}` to rename a row. |
102
+ | `useCartLine(item)` | `{ quantity, setQuantity, increase, decrease, remove, pending, error, canIncrease, canDecrease, maxQuantity, atMax, atMin }`. |
103
+ | `useCartUI()` | `{ open, openCart, closeCart, toggleCart }`. |
104
+ | `useCheckoutContext()` | the address (`billing`, `updateBilling`, `shipping`, `updateShipping`, `shipToDifferent`, `setShipToDifferent`, `missingBillingFields`, `addressError`), the shipping and payment state (the pickers read those for you), and the gate: `blockers`, `canPlaceOrder`, `placing`, `stage`, `orderError`, `placeOrder` — plus `cart`. The Checkout section wires all of it. |
105
+ | `useOrderReturn()` | `{ status, order, lines, paymentLink, paymentInstructions, error, reload }` — `status`: `"loading" \| "paid" \| "unpaid" \| "cancelled" \| "error"`. An order's totals are **flat** (`order.total`); there is no `order.totals`. |
100
106
 
101
107
  ## Product list / collection
102
108
 
@@ -104,15 +110,15 @@ Already unwrapped — no `.data`, no envelope; `formatMoney` is `useFormatMoney(
104
110
  import { useProductList, useCategories, useStoreInfo, useFormatMoney, productPrice, productImages, productRibbons } from "@/commerce/storefront";
105
111
  ```
106
112
 
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`.
113
+ (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`.
108
114
 
109
115
  ⚑ **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.
110
116
 
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.
117
+ 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 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.
112
118
 
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.
119
+ ⚑ **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.
114
120
 
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.
121
+ **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.
116
122
 
117
123
  ## Product page
118
124
 
@@ -120,7 +126,7 @@ A card can render `name`, `productImages(row)[0]`, `productPrice(row, { formatMo
120
126
  import { useProduct, useAddToCart, useStoreInfo, useFormatMoney, useStorefront, variantAxes, productImages, imageIndex, productRibbons, productSpecs, findSpec, storefrontErrorCode } from "@/commerce/storefront";
121
127
  ```
122
128
 
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.
129
+ `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 precisely so they can sit at the top; a hook after an early `return` crashes React with "Rendered more hooks than during the previous render" the moment the product resolves.
124
130
 
125
131
  ```jsx
126
132
  const p = useProduct(slug);
@@ -144,7 +150,7 @@ Build your layout from — all optional, **not one component style**:
144
150
  useEffect(() => setPicked(null), [view?.variation?.id]); // a new variant re-takes the lead
145
151
  ```
146
152
 
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.
153
+ ⚑ **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.
148
154
  - **Variant selector** — `variantAxes(view, p.pick)`, one entry per axis:
149
155
 
150
156
  ```jsx
@@ -159,7 +165,7 @@ Build your layout from — all optional, **not one component style**:
159
165
  ))}
160
166
  ```
161
167
 
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.
168
+ ⚑ **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.
163
169
  - **Buy box** — one button, and **you supply its four words**:
164
170
 
165
171
  ```jsx
@@ -168,8 +174,8 @@ Build your layout from — all optional, **not one component style**:
168
174
  {buy.error?.message && <p role="alert">{buy.error.message}</p>}
169
175
  ```
170
176
 
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.
177
+ ⚑ **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.
178
+ - **Description** — `product.description` is HTML; render as rich text, `short_description` above it.
173
179
  - **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
180
 
175
181
  ```jsx
@@ -180,153 +186,157 @@ Build your layout from — all optional, **not one component style**:
180
186
  : <Row key={s.key} label={s.titleLabel} value={s.value} />)}
181
187
  ```
182
188
 
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 shipsfor rich results emit your own `Product`/`Offer` JSON-LD from `product` and `view.display`.
189
+ ⚑ **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 at all.
190
+ - **Breadcrumbs** — from `categories` (`/collection?category_id=${c.id}`); skip on a flat catalog. Ribbons (`productRibbons(product)`) are labels, not breadcrumbs.
191
+ - **Reviews, only if the store wants them** — no review UI is a complete outcome (then no star ratings on cards either: an average of nothing is `0`). `p.reviews` arrives with the product as `{ items, page, per_page, has_next }`; 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 every store that auto-approves — and refresh the list after, or the review doesn't appear. Field codes, policies and moderation: [`../references/reviews.md`](../references/reviews.md).
192
+ - **Title** — give each page type its own `<title>` and description; a store whose every page shares one static title is invisible to search. Nothing here emits structured data eitherif the store wants rich results, emit your own `Product`/`Offer` JSON-LD from `product` and `view.display` (price, currency, availability).
187
193
 
188
194
  ## Cart / bag
189
195
 
190
196
  ```jsx
191
- import { Cart, CartDrawer } from "@/commerce/storefront";
197
+ import { useCart, useCartLine, CartLine, useCartUI, useFormatMoney, attributesLabel, cartTotalsLines } from "@/commerce/storefront";
192
198
  ```
193
199
 
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.
200
+ A cart *page* is optional — decide from what the store sells (buy-now straight to checkout reads better for a single-piece store; a grocery basket needs a page).
201
+
202
+ ⚑ Rules: branch on `status`, never on emptiness while loading. Render `cart.coupon_notices` (`[{ code, error, error_code }]` — a coupon that stopped validating) and `cart.removed_items` (`[{ item_key, product_id, reason, code }]` — a product that vanished or was unpublished): render `error`/`reason`, the server's own words, or a line disappears from the bag with no explanation. Render every non-`hidden` line from `cartTotalsLines` rather than hardcoding subtotal/total — a hand-written summary omits discount and tax, then stops adding up the day a coupon or tax rate exists. **A store with any coupons must have a coupon field** (here or in the checkout) — they are admin-only data, redeemable only through a field the customer types into. `applyCoupon(code)` resolves `{ok: false, message}` for a bad code rather than throwing, so render that inline. No field means no coupons: don't seed them, don't name a code in the copy.
203
+
204
+ ⚑ **`pending` is one row's flag**: it goes true when that row's debounced request leaves and false only after the new cart view lands — so disable and mark only that row (`disabled={l.pending}`, `aria-busy` on the row), never the whole cart. `status` never returns to `"loading"` for a mutation; there is deliberately no cart-wide busy flag. ⚑ **Repeated controls need unique accessible names** — three "Remove" buttons name nothing; put the line in the label.
205
+
206
+ **Reference wiring** for one row — the rest of the page (notices, coupon field, totals, checkout link) is ordinary markup around it:
195
207
 
196
208
  ```jsx
197
- const ui = useCartUI(); // open + handlers + panelRef/panelId
198
-
199
- <header>{/* your nav */}
200
- <CartDrawer.Trigger className="nav-icon">
201
- {({ count }) => (<><YourBagGlyph />{count > 0 && <span className="badge">{count}</span>}</>)}
202
- </CartDrawer.Trigger>
203
- </header>
204
-
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
- )}
209
+ const { status, cart } = useCart();
210
+ const formatMoney = useFormatMoney();
211
+ // guards on status first, then:
212
+ {cart.items.map((item) => (
213
+ <CartLine key={item.item_key} line={item}>
214
+ {(l) => (
215
+ <li aria-busy={l.pending}> {/* busy scope is THIS row, never the cart */}
216
+ {item.name} {attributesLabel(item.attributes)} {formatMoney(item.total)}
217
+ <button onClick={l.decrease} disabled={!l.canDecrease || l.pending}
218
+ aria-label={`Decrease quantity of ${item.name}`}>−</button>
219
+ {l.quantity}
220
+ <button onClick={l.increase} disabled={!l.canIncrease || l.pending}
221
+ aria-label={`Increase quantity of ${item.name}`}>+</button>
222
+ <button onClick={l.remove} disabled={l.pending}
223
+ aria-label={`Remove ${item.name}`}>Remove</button>
224
+ {l.error && <p role="alert">{l.error.message}</p>}
225
+ </li>
226
+ )}
227
+ </CartLine>
228
+ ))}
229
+ {cartTotalsLines(cart, { formatMoney }).filter((t) => !t.hidden).map((t) => (
230
+ <div key={t.key}>{t.label} {t.formatted}</div> /* t.emphasis the total row */
231
+ ))}
223
232
  ```
224
233
 
225
- | Part | Renders | Props |
226
- |---|---|---|
227
- | `Cart.Loading` / `Empty` / `Ready` | gates — children only in that cart status, so emptiness-while-loading can't render; function children get `{ cart, itemCount }` | — |
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) |
229
- | `Cart.Notices` | expired coupons + vanished products, the server's words — a line never disappears unexplained | — |
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
+ No shipping estimator here checkout reprices shipping and tax from the address. An upsell beside the lines needs no query: `p.upsells` / `p.crossSells` from `useProduct` are rows you can render and add with `addItem`, matching "already in the bag" by product id, never by display name. ⚑ **A one-click Add only works on a product with no attributes**: one that sells variants answers `400 variation_required`, so link those tiles to the product page instead.
235
+
236
+ ### If the cart is a drawer
234
237
 
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.
238
+ Same hooks, same rows. The drawer's *state* open/close, Esc, close-on-route-change, open-on-add is `useCartUI()`; the markup is yours: a trigger in the header (`onClick={ui.toggleCart}`, `aria-expanded={ui.open}`), then `{ui.open && …}` rendering a click-away overlay plus your panel (`role="dialog" aria-modal="true"`, a named close button inside it).
236
239
 
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.
240
+ ⚑ **Render the drawer conditionally — `{ui.open && …}`.** The classic drawer bug is a panel translated off-screen but still mounted: its buttons stay clickable, tab-able and visible to screen readers. If you keep it mounted to animate the slide, set the `inert` attribute while closed. The overlay is a click-away surface, not the close control.
238
241
 
239
242
  ## Checkout
240
243
 
241
244
  ```jsx
242
- import { Checkout } from "@/commerce/storefront";
245
+ import { CheckoutProvider, useCheckoutContext, AddressFields, ShippingMethodPicker, PaymentMethodPicker, useCart, useFormatMoney, cartTotalsLines } from "@/commerce/storefront";
243
246
  ```
244
247
 
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`.
248
+ `useCheckout` reprices shipping/tax from the address automatically (debounced, never on a half-typed address), derives the shipping and payment choices, gates the button, and `placeOrder()` handles **both** navigations — online gateway → provider redirect, everything else `/order-received` as **full page loads** (`<CheckoutProvider options={{ orderReceivedPath: null }}>` for a router transition instead). `CheckoutProvider` shares it across the page's regions.
249
+
250
+ ⚑ Rules: render each picker's `hint` and every branch; a single shipping or payment option still *shows* what it is — never a picker of one, never "nothing selected". Render `addressError` on the address fields. ⚑ Payment methods, currency and countries come from `useStoreInfo()`/`useCountries()` only — `cart.payment_gateways` is always `undefined`, and a default store offers `offline` only, so never hardcode a card option.
251
+
252
+ ⚑ **A disabled place-order button must say why** — the silent disabled button is the most common checkout dead end. `blockers` is an array of codes; write one line per code, in the store's voice, anchored near the field that fixes it: `empty_cart` (bag is empty) · `billing_incomplete` (required address fields — `missingBillingFields` names them) · `shipping_address_incomplete` (the separate delivery address) · `shipping_address_required` (no address to price yet) · `shipping_method_required` (choose a delivery option) · `shipping_not_available` (this address can't be delivered to) · `payment_method_required` (choose how to pay) · `cart_loading` / `shipping_recalculating` (transient — a quiet "one moment", not an error).
253
+
254
+ The pickers' `hint.code` works the same way (`missing_address`, `none_available`, `syncing` for shipping; `none_available` for payment): write those words once, and prefer `hint.serverMessage` when it is set — the backend's explanation is more specific than anything you can write.
246
255
 
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.
256
+ **Reference wiring** — the densest part of the storefront. `Checkout()` is just
257
+ `<CheckoutProvider><CheckoutForm /></CheckoutProvider>`; the hooks read the context **below** the provider. `SHIPPING_HINTS`, `PAYMENT_HINTS`, `BLOCKERS` and the two button labels below are *your* copy maps, written once (see [Design language](#design-language--once-before-any-page)):
248
258
 
249
259
  ```jsx
250
- <Checkout.Root orderReceivedPath="/order-received">
251
- <Checkout.Submitted>{/* your "taking you to your receipt…" screen */}</Checkout.Submitted>
252
- <Checkout.Loading>{/* your loader */}</Checkout.Loading>
253
- <Checkout.Empty>{/* your empty state + your own link back */}</Checkout.Empty>
254
- <Checkout.Form>
255
- <div className="checkout-grid">{/* YOUR layout: order, columns, headings, steps */}
256
- <section>
257
- <Checkout.AddressFields which="billing" />
258
- <Checkout.ShipToDifferent>{/* your label */}</Checkout.ShipToDifferent>
259
- <Checkout.AddressFields which="shipping" />
260
- <Checkout.ShippingMethods />
261
- <Checkout.PaymentMethods />
262
- </section>
263
- <aside>
264
- <Checkout.Items />
265
- <Checkout.CouponField />
266
- <Checkout.Totals />
267
- <Checkout.PlaceOrder className="btn-cta" />
268
- </aside>
269
- </div>
270
- </Checkout.Form>
271
- </Checkout.Root>
260
+ function CheckoutForm() {
261
+ const { status } = useCart();
262
+ const c = useCheckoutContext();
263
+ if (c.stage === "submitted") return /* "taking you to your receipt" screen */;
264
+ if (status === "loading") return /* your loading screen */;
265
+ if (status === "empty") return /* your empty-bag screen */;
266
+ return (
267
+ <>
268
+ <AddressFields which="billing" />
269
+ {/* a checkbox on c.shipToDifferent / c.setShipToDifferent, your wording */}
270
+ <AddressFields which="shipping" /> {/* renders null until shipToDifferent */}
271
+
272
+ <ShippingMethodPicker>
273
+ {({ hint, mustChoose, methods, chosen }) => (
274
+ <fieldset>{/* renders null for a virtual cart */}
275
+ {hint && <p role={hint.severity === "error" ? "alert" : "status"}>
276
+ {hint.serverMessage ?? SHIPPING_HINTS[hint.code]}</p>}
277
+ {mustChoose && methods.map((m) => (
278
+ <label key={m.id}>
279
+ <input type="radio" name="shipping-method" checked={m.selected} onChange={m.select} />
280
+ {m.title} {m.costLabel}
281
+ </label>
282
+ ))}
283
+ {!mustChoose && chosen && <p>{chosen.title} {chosen.costLabel}</p>}
284
+ </fieldset>
285
+ )}
286
+ </ShippingMethodPicker>
287
+
288
+ {/* PaymentMethodPicker: same shape — hint, then gateways.map radios unless
289
+ `single`, then `selected.title`. Titles/descriptions are the admin's copy. */}
290
+
291
+ {/* summary: coupon field (if not in the cart) + cartTotalsLines(c.cart, { formatMoney }) */}
292
+
293
+ <button type="button" onClick={c.placeOrder} disabled={!c.canPlaceOrder || c.placing}>
294
+ {c.placing ? PLACING_LABEL : PLACE_ORDER_LABEL}
295
+ </button>
296
+ {c.orderError && <p role="alert">{c.orderError.message}</p>}
297
+ {!c.canPlaceOrder && c.blockers.map((code) => <p key={code}>{BLOCKERS[code]}</p>)}
298
+ </>
299
+ );
300
+ }
272
301
  ```
273
302
 
274
- | Part | Renders | Props |
275
- |---|---|---|
276
- | `Checkout.Root` | the provider, the phase, the labels scope | `orderReceivedPath` (**required**; or `null` + `onPlaced`), `labels`, `options`, `checkout` |
277
- | `Submitted` / `Loading` / `Empty` / `Form` | gates; function children get `{ checkout, cart }` | — |
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 |
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)` |
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 | — |
286
-
287
- **Multi-step checkout is your markup**: wrap parts in your step containers — a hidden step loses nothing, the gate still counts every blocker.
303
+ **`<AddressFields>` is the one shipped component — use it, never hand-roll the address form.** It owns what hand-rolled forms get wrong: the state/province field appears with the right options once a country is picked (shipping rates and taxes match on country *plus* state, so a form without it mis-prices US/CA/AU orders with no error anywhere), every field keeps its `autoComplete` token (what makes browser autofill work), required marks arm on first blur, and the server's "we don't ship there" lands on the country field. `which="shipping"` renders null until `shipToDifferent` is on — the deliver-elsewhere checkbox itself is yours, wired to `c.shipToDifferent` / `c.setShipToDifferent`.
304
+
305
+ It ships **no CSS**: every element carries `data-part` (`address-fields`, `field`, `label`, `control`, `required`, `error`) plus `data-key` (the field) and `data-span` (1 or 2 — the field's natural width in a two-column grid), so style it in your `index.css` via `[data-part]` selectors or pass `className`/`classes={{ field, label, control, error }}`. Props: `includeCompany` (default false), `includePhone` (default true), `omit={["…"]}`, `labels={{ postcode: "ZIP code" }}` (over `addressFieldSpec`'s plain-convention defaults), `selectPlaceholder`, and two escape hatches — `inputRender` swaps the control only (spread the handed `dom` props onto your input), `fieldRender` replaces the whole labeled block. `c.missingBillingFields` stays the live list of what is still missing, if you want your own per-field marks.
306
+
307
+ **The `stage === "submitted"` guard goes above the empty-cart branch**placing an order clears the cart before the browser navigates, and without the guard the page flashes an empty bag over a just-placed order.
288
308
 
289
309
  ## Order received
290
310
 
291
311
  ```jsx
292
- import { OrderReceived } from "@/commerce/storefront";
312
+ import { useOrderReturn, useFormatMoney, orderTotalsLines } from "@/commerce/storefront";
293
313
  ```
294
314
 
295
- **Mandatory route** — every payment link returns here; visiting verifies with the provider (idempotent) and the page marks itself noindex. Render **all five** gates a blank frame while loading or a wordless error strands a real customer:
315
+ **Mandatory route** — every payment link returns here, and confirming is what marks a card order paid. `useOrderReturn()` reads `order_id`/`order_key` from the URL, verifies with the provider (idempotent on every visit), and marks the page noindex itself.
296
316
 
297
- ```jsx
298
- <OrderReceived.Root>
299
- <OrderReceived.Loading>{/* confirming copy never a blank page */}</OrderReceived.Loading>
300
- <OrderReceived.Paid>
301
- {({ order }) => (<>{/* your thank-you, order #{order.order_number} */}
302
- <OrderReceived.Items /><OrderReceived.Totals /></>)}
303
- </OrderReceived.Paid>
304
- <OrderReceived.Unpaid>{/* your "not settled yet" wording */}
305
- <OrderReceived.PaymentInstructions /> {/* ⚑ below */}
306
- <OrderReceived.PaymentLink>{({ url }) => (/* your pay-now element on url */)}</OrderReceived.PaymentLink>
307
- <OrderReceived.Items /><OrderReceived.Totals />
308
- </OrderReceived.Unpaid>
309
- <OrderReceived.Cancelled>{/* your wording; PaymentLink works here too */}</OrderReceived.Cancelled>
310
- <OrderReceived.Error>{({ error, reload }) => (/* error.message + your retry calling reload() */)}</OrderReceived.Error>
311
- </OrderReceived.Root>
312
- ```
317
+ ⚑ Rules: render all five states — never a blank page while `"loading"`, a retry via `reload()` on `"error"`, and `paymentLink.url` on `"unpaid"`/`"cancelled"` when present. ⚑ **Never drop `paymentInstructions`** (`{ description, account_details }`) — a manual/offline order settles outside the store, so these ARE how the store's default customer learns how to pay. ⚑ An order's totals are flat (`order.total`, no `order.totals`) — use `orderTotalsLines(order, { formatMoney })`. `lines` are the order's items already normalized (`name`, `attributesLabel`, `quantity`, `image` as `{src, alt}|null`, `totalLabel`), because a receipt reusing cart-row markup otherwise paints a broken image.
318
+
319
+ A receipt is a convention surface: your classes, every branch present, no bespoke widgets.
313
320
 
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`).
321
+ ## Driving the storefront from a browser script?
315
322
 
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.
323
+ 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.
317
324
 
318
325
  ## Done — forget this file
319
326
 
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.
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.
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.
324
- - [ ] **One** `<StorefrontProvider>` above every storefront route (layout-route pattern); no hand-rolled `cart_token`; imports from each section's import line.
325
- - [ ] Custom sections branch on `status` and never re-implement part/hook logic; gateways/currency/countries from `useStoreInfo()`/`useCountries()` only.
326
- - [ ] Coupon field placed if the store has coupons; a paging control whenever `hasNext`; ribbons in both the grid and the product page.
327
+ - [ ] Catalog UI in whatever form fits the store, plus a checkout, plus `/order-received` rendering `useOrderReturn`'s states **including `paymentInstructions`**.
328
+ - [ ] `index.css` defines the store's design classes; pages carry class names, not repeated utility runs.
329
+ - [ ] **One** `<StorefrontProvider>` above every storefront route (layout-route pattern); one client, no hand-rolled `cart_token`.
330
+ - [ ] Every page's imports came from its section's import line; no unused names.
331
+ - [ ] Pages branch on `status`; gateways/currency/countries read from `useStoreInfo()`/`useCountries()` only.
332
+ - [ ] Every state the hooks expose has words: the buy button reads for all four `buy.state` values, picker `hint` codes and place-order `blockers` each have a line, and no state renders empty.
333
+ - [ ] No re-implemented hook logic (button state precedence, quantity clamps, totals math, drawer state).
334
+ - [ ] Coupon field present if the store has coupons; a paging control rendered whenever `hasNext` is true; ribbons rendered in both the grid and the product page.
327
335
  - [ ] Variant options: one control per axis, unbuyable options disabled, not hidden.
328
- - [ ] Navigation affordances are yours: the checkout link in `Cart.Ready`, `orderReceivedPath` on `Checkout.Root`, `PaymentLink`'s childthe kit rendered none of them.
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.
336
+ - [ ] The checkout's addresses render through `<AddressFields>` (both `which` values), styled in the store's classesnot a hand-rolled field list.
337
+ - [ ] Cart rows scope busy state to the row; repeated controls have unique accessible names; a drawer uses `useCartUI` and is rendered conditionally on `ui.open`.
338
+ - [ ] Checkout guards `stage === "submitted"` above its empty-cart branch.
339
+ - [ ] The storefront carries the design you settled on before reading this file — design classes plus one or two signature moments per page; convention surfaces carry the classes and nothing bespoke.
330
340
  - [ ] **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.
331
341
 
332
- Then copy this file's `carry_forward` lines into your working notes, and do not re-read this file.
342
+ Then copy this file's `carry_forward` lines (in its front matter) into your working notes, and do not re-read this file.
@@ -51,7 +51,3 @@ DOM is briefly right about the *intent* and wrong about the *state*.
51
51
  (`/order-received?order_id=…&order_key=…` — the ids come back in
52
52
  `placeOrder`'s result, and `commerce/admin-orders` `search` has the order
53
53
  either way).
54
- - **Sweep for unfinished copy.** A part whose label is missing renders a
55
- visible `⟨copy: some.path⟩` placeholder. `⟨copy:` must appear on **no**
56
- page the script visits — finding one means the store's copy file is
57
- incomplete, a build defect on par with a broken button.