@base44/app-plugin-commerce 0.2.7 → 0.3.2

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.
Files changed (39) hide show
  1. package/README.md +4 -4
  2. package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
  3. package/base44/functions/commerce/seed-store/entry.ts +16 -2
  4. package/package.json +2 -2
  5. package/scripts/install.js +15 -0
  6. package/skills/commerce/SKILL.md +34 -17
  7. package/skills/commerce/docs/api-admin.md +1 -1
  8. package/skills/commerce/docs/api-storefront.md +12 -12
  9. package/skills/commerce/install/01-install.md +6 -8
  10. package/skills/commerce/install/02-storefront.md +180 -278
  11. package/skills/commerce/install/03-data.md +13 -11
  12. package/skills/commerce/references/catalog-rendering.md +37 -43
  13. package/skills/commerce/references/online-payments.md +10 -0
  14. package/skills/commerce/references/reviews.md +21 -14
  15. package/skills/commerce/references/store-settings.md +1 -1
  16. package/skills/commerce/references/storefront-verification.md +21 -15
  17. package/src/commerce/storefront/StorefrontProvider.jsx +65 -128
  18. package/src/commerce/storefront/cartUI.jsx +11 -30
  19. package/src/commerce/storefront/index.js +61 -98
  20. package/src/commerce/storefront/pickers.jsx +50 -64
  21. package/src/commerce/storefront/useCartLine.js +23 -130
  22. package/src/commerce/storefront/useCheckout.jsx +50 -43
  23. package/src/commerce/storefront/useOrderReturn.js +17 -7
  24. package/src/commerce/storefront/useProduct.js +41 -97
  25. package/src/commerce/storefront/useProductList.js +14 -22
  26. package/src/commerce/utils/address-spec.js +1 -1
  27. package/src/commerce/utils/images.js +1 -1
  28. package/src/commerce/utils/index.js +9 -9
  29. package/src/commerce/utils/price.js +2 -1
  30. package/src/commerce/utils/specs.js +41 -91
  31. package/src/commerce/utils/totals.js +7 -4
  32. package/src/commerce/storefront/useAddressForm.js +0 -166
  33. package/src/commerce/storefront/usePlaceOrder.js +0 -63
  34. package/src/commerce/storefront/useProductGallery.js +0 -78
  35. package/src/commerce/storefront/useProductPrice.js +0 -58
  36. package/src/commerce/storefront/useProductReviews.js +0 -242
  37. package/src/commerce/storefront/useStorefrontSeo.js +0 -204
  38. package/src/commerce/storefront/useTotalsLines.js +0 -109
  39. package/src/commerce/storefront/useUpsell.js +0 -90
@@ -5,25 +5,24 @@ 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 have a coupon field (useCoupon) in the cart or the checkout, or its codes can never be redeemed."
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
9
  - "/order-received is mandatory and renders useOrderReturn's states, including paymentInstructions — how a normal (offline) customer learns how to pay."
10
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
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
- - "Hooks return plain states and handlers (label, disabled, value, set, addToCart) you write every element and attribute from them; render the state-driven text (buy.label, f.error, hint.message) and never re-derive what a hook already resolves."
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, full-screen states go through the shared StatusScreen, and every page is measured (wc -c) against its char budget."
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."
15
15
  ---
16
16
 
17
17
  # 02 — Storefront
18
18
 
19
- One split decides everything here: **the logic is premade, the UI never is.** The hooks own checkout repricing, variant resolution, cart state, coupon redemption, review policies, 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 of copy 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 that look **once** as design classes (["Design language"](#design-language--once-before-any-page) below) and build every page in them — the snippets in this file are implementation reference, not design input.
19
+ 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, carries CSS, or contains a sentence a customer will read. **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
20
 
21
- Two mechanics to hold everywhere:
21
+ **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
22
 
23
- - **States and handlers, not magic.** Hooks return plain values strings, booleans, arrays, callbacks (`buy.label`, `buy.disabled`, `buy.addToCart`, `f.value`, `f.set`) — never ready-made prop objects to spread. You write every element and every attribute; the hook guarantees the values are right. The flip side: **render what the hook resolves** the button's text is `{buy.label}`, its gate is `disabled={buy.disabled}` and never re-derive a state the hook already owns (a ternary chain over `adding`/`purchasable` re-implements `buy.state` wrong).
24
- - **Each hook's JSDoc is the API reference.** Open the hook's file when you need exact shapes; don't guess fields. Rules marked ⚑ must survive whatever design you build.
23
+ **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
24
 
26
- **One import path: `@/commerce/storefront`.** Each section opens with its page's exact import line — copy it verbatim, then delete unused names. (`variantAxes` and `productSpecs` are re-exported there; a React page never imports `@/commerce/utils` directly. `useStoreInfo` is the name most often left out — it is the only source of store name and currency.)
25
+ **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 — it is the only source of store name and currency.
27
26
 
28
27
  ## Setup — once
29
28
 
@@ -46,255 +45,221 @@ import AdminApp from "@/commerce/admin";
46
45
  }>
47
46
  <Route path="/" element={<Home />} />
48
47
  <Route path="/product/:slug" element={<ProductPage />} />
49
- <Route path="/bag" element={<Bag />} />
50
- <Route path="/checkout" element={<Checkout />} />
51
- <Route path="/order-received" element={<OrderReceived />} />
48
+ {/* /bag, /checkout, and /order-received — which is mandatory */}
52
49
  </Route>
53
50
  <Route path="/store-admin/*" element={<AdminApp />} /> {/* own chrome, outside the provider */}
54
51
  </Routes>
55
52
  </BrowserRouter>
53
+
54
+ // …and the layout that route renders. Yours to design; the shape is the point:
55
+ function StoreLayout() {
56
+ const { itemCount } = useCart(); // one cart, shared with every page
57
+ return (
58
+ <>
59
+ <header>{/* nav + your cart trigger, showing itemCount */}</header>
60
+ <Outlet /> {/* the routed page lands here */}
61
+ <footer>…</footer>{/* + the drawer, if the cart is one — see Cart below */}
62
+ </>
63
+ );
64
+ }
56
65
  ```
57
66
 
58
67
  ⚑ **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`.
59
68
 
60
69
  ## Design language — once, before any page
61
70
 
62
- The measured cost driver of a generated storefront is not wiring — it is decoration repeated inline. In audited builds ~35% of every hand-written file was className strings, the same 150-char utility run pasted onto every panel, button and heading. Encode the store's identity **once**, then spend it everywhere:
63
-
64
- - In `index.css`, set the palette/typography variables, then define the store's recurring surfaces as **10–15 composable classes** in Tailwind's components layer, named in *this* store's language:
65
-
66
- ```css
67
- @layer components {
68
- .panel { @apply border border-border/40 bg-card p-6; }
69
- .btn-cta { @apply w-full py-4 bg-primary text-primary-foreground text-xs uppercase tracking-[0.2em] hover:bg-primary/90 transition-colors disabled:opacity-40 disabled:cursor-not-allowed; }
70
- .label-mono { @apply text-xs uppercase tracking-[0.2em] text-muted-foreground; }
71
- .field { @apply w-full border border-border bg-transparent px-4 py-3 text-sm outline-none focus:border-primary; }
72
- .choice-row { @apply flex items-center gap-4 p-4 border border-border/40 cursor-pointer transition-colors hover:border-primary/50; }
73
- /* …heading scale, link style, price, alert — whatever THIS store repeats */
74
- }
75
- ```
76
-
77
- Pages then carry short names plus at most a couple of layout utilities. ⚑ **A utility run that appears twice becomes a class** — pasting the same string onto a second element means it belongs in `index.css`.
71
+ 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.**
78
72
 
79
- - **One `<StatusScreen>` component for every full-screen state.** Loading, empty, error, not-found and order-submitted screens are one layout with different words. It is stateless every hook already hands the page a plain `status` enum to branch on so the whole component is this, restyled:
73
+ 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: "Add to bag" in every store built from this plugin is how they end up reading alike.
80
74
 
81
- ```jsx
82
- // src/components/store/StatusScreen.jsx — the store's full-screen states, styled once
83
- export default function StatusScreen({ title, message, action, busy }) {
84
- return (
85
- <div role="status" aria-busy={busy || undefined} className="…"> {/* centered, full-height */}
86
- {busy && <span className="…" aria-hidden="true" />} {/* your spinner/pulse */}
87
- <h1 className="…">{title}</h1>
88
- {message && <p className="…">{message}</p>}
89
- {action}
90
- </div>
91
- );
92
- }
93
- ```
75
+ **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. Keep components small (~2–4K chars) — faster to emit, review and fix than one long page file.
94
76
 
95
- Every page's guards then become one-liners:
77
+ ## What each hook resolves to
96
78
 
97
- ```jsx
98
- if (p.status === "loading") return <StatusScreen busy title="Loading…" />;
99
- if (p.status === "not_found") return <StatusScreen title="Not found" action={<Link to="/">Back to the shop</Link>} />;
100
- ```
79
+ Everything below is already unwrapped — no `.data`, no envelope. `formatMoney` throughout is `useFormatMoney()`.
101
80
 
102
- Once `StatusScreen` exists, no page hand-draws a full-screen status branch — and don't wrap it in a status-mapping hook either; the copy per state is the page's, in words.
103
-
104
- **Concentrate identity; don't diffuse it.** The design classes already carry the store's look on every surface. 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 store's classes. The signature budget concentrates where the products are: **the product page stays the storefront's richest surface** — its section's rules to render axes and specs *by what they are* hold with full force; the richness there is semantic (what the controls and rows show), which costs words, not chrome. One navigation affordance per control (a gallery gets thumbnails *or* arrows, never both plus dots); checkout, bag and order-received are convention surfaces the classes style them, no bespoke widgets. Identity is legible in the tokens and the signature moments; decorating every element produces bulk, not character.
81
+ | Call | Resolves to |
82
+ |---|---|
83
+ | `useStoreInfo()` | `{ info, settings, paymentGateways, countries, currencies, loading, error }``settings.store_name`, `settings.currency`. The ONLY source of gateways/currency/countries. |
84
+ | `useCountries()` | `{ countries, options, loading, error }` — `options` is `[{value, label}]` and **never null** (mapping `useStoreInfo().countries` raw white-screens a cold load). |
85
+ | `useFormatMoney()` | `(amount) => "€19.99"` in the store's currency. |
86
+ | `useProductList(params)` | `{ status, products, hasNext, next, loadMore, busy, refreshing, isEmpty, setParams, reload }` — `status`: `"loading" \| "ready" \| "empty" \| "error"`. |
87
+ | `useCategories()` / `useRibbons()` | `{ items, loading, error }` — `items` always an array. |
88
+ | `useProduct(slug)` | `{ status, product, view, price, selection, pick, quantity, setQuantity, incQuantity, decQuantity, maxQuantity, canIncrease, categories, ribbons, upsells, crossSells, reviews, reload }` — `status`: `"loading" \| "ready" \| "not_found" \| "error"`; `reviews` is `{ items, page, per_page, has_next }`. |
89
+ | `useAddToCart(p)` | `{ state, disabled, addToCart, adding, error, soldOut, needsSelection, quantity, increase, decrease, canIncrease, canDecrease, showQuantity, reset }` — `state`: `"ready" \| "adding" \| "sold_out" \| "needs_selection"`. |
90
+ | `variantAxes(view, pick)` | `[{ key, name, selectedOption, options: [{ value, selected, disabled, outOfStock, pick }] }]`. |
91
+ | `productPrice(rowOrView, { formatMoney })` | `{ label, compareAtLabel, onSale, isFrom, isRange, min, max }` — `label` is what to render. |
92
+ | `productImages(product)` | `[{ src, name, alt }]`, de-duplicated. `[]` is legitimate → render your placeholder. |
93
+ | `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. |
94
+ | `useCart()` | `{ status, cart, itemCount, isEmpty, loading, error, mutationError, refresh, addItem, updateItem, removeItem, applyCoupon, removeCoupon }` — `status`: `"loading" \| "ready" \| "empty"`. |
95
+ | `cart.items[n]` | `{ item_key, product_id, variation_id, name, 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. |
96
+ | `attributesLabel(item.attributes)` | `"Size: 42 · Color: Ivory"` (`""` when the product has no attributes). |
97
+ | `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. |
98
+ | `useCartLine(item)` | `{ quantity, setQuantity, increase, decrease, remove, pending, error, canIncrease, canDecrease, maxQuantity, atMax, atMin }`. |
99
+ | `useCartUI()` | `{ open, openCart, closeCart, toggleCart }`. |
100
+ | `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. |
101
+ | `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`. |
105
102
 
106
103
  ## Product list / collection
107
104
 
108
105
  ```jsx
109
- import { useProductList, useCategories, useProductPrice, useStoreInfo, useStorefrontSeo, collectionSeo, productSpecs } from "@/commerce/storefront";
106
+ import { useProductList, useCategories, useStoreInfo, useFormatMoney, productPrice, productImages } from "@/commerce/storefront";
110
107
  ```
111
108
 
112
- (Drop `useCategories` with no filter bar, `productSpecs` if the card shows no modifiers, add `useRibbons` for a ribbon filter.) The top of the component, before any markup:
109
+ (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`.
113
110
 
114
- ```jsx
115
- const list = useProductList({ per_page: 24 });
116
- const { settings } = useStoreInfo();
117
- useStorefrontSeo(collectionSeo({ title: "…", products: list.products, storeName: settings?.store_name }));
111
+ ⚑ **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.
118
112
 
119
- if (list.status === "loading") return <StatusScreen busy title="…" />;
120
- if (list.status === "error") return <StatusScreen title="…" action={<button type="button" onClick={list.reload}>Try again</button>} />;
121
- const products = list.products; // always an array — never null
122
- ```
113
+ 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` on its own is a rolled-up from-price), `on_sale`, `short_description`, `stock_status`, `average_rating`/`rating_count`, `ribbons`, `productSpecs(row)`. ⚑ **Images are objects and the array may be empty** — render your placeholder, never a broken `<img>`. Full 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.
123
114
 
124
- `useProductList(params)` `{ status, products, hasNext, next, loadMore, busy, refreshing, setParams, reload }`. `status` is `"loading" | "ready" | "empty" | "error"` — branch on it, so a failed request renders as a failure instead of an empty grid. `setParams({ category_id, search, on_sale, in_stock_only, … })` resets to page 1 and keeps current rows on screen (`refreshing`). **Render paging whenever `hasNext` is true** — `{list.hasNext && <button type="button" onClick={list.next} disabled={list.busy}>Next</button>}` (append mode: `onClick={list.loadMore}` for "Load more"); a page that renders nothing for paging ships a catalog silently capped at `per_page`. `useCategories()` / `useRibbons()` → `{ items }` — drive filters from that data, never from hardcoded names (a renamed ribbon must not strand a dead button).
115
+ **Ribbons belong in both views** grid and product page. They are the merchant's own merchandising ("Limited", "Last pieces"), the most-skipped part of catalog rendering, and each links to its filtered listing (`/collection?ribbon_id=<id>`). Never render a bare "Ribbons:" label with nothing after it.
125
116
 
126
- Your card can render `name`, `productImages(row)[0]` (⚑ **images are `{src, alt}` objects and the array may be empty render a placeholder, never a broken `<img>`**), `useProductPrice(row).label` (already "From €19.99" when the product sells variants there is no product `type` flag), `on_sale`, `short_description`, `stock_status`, `average_rating`/`rating_count`, `ribbons`, and `meta_data` via `productSpecs(row)`. Full field matrix: [`../references/catalog-rendering.md`](../references/catalog-rendering.md). That is an inventory, not a card design: lead each card with the one or two fields *these* products are judged on (via `productSpecs`) rather than the default name/price/stars trio — but the card is your design classes plus those fields, not a bespoke widget.
127
-
128
- **Rails** (featured row, "new in") are the same hook with a filter (`{ featured: true, per_page: 4 }`). ⚑ Any filter may legitimately match nothing — render *nothing* then, never a heading over an empty row. Upsells beside a product need no query: `p.upsells` / `p.crossSells` from `useProduct` (render via `useUpsell` — see Cart).
117
+ **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.
129
118
 
130
119
  ## Product page
131
120
 
132
121
  ```jsx
133
- import { useProduct, useProductGallery, useAddToCart, useProductSpecs, useStoreInfo, useStorefrontSeo, productSeo, variantAxes } from "@/commerce/storefront";
122
+ import { useProduct, useAddToCart, useStoreInfo, useFormatMoney, useStorefront, variantAxes, productImages, imageIndex, productSpecs, findSpec, storefrontErrorCode } from "@/commerce/storefront";
134
123
  ```
135
124
 
136
- (Add `useProductReviews` only if the store has reviews; drop `useProductSpecs` if these products carry no modifiers.)
137
-
138
- `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 the moment the product resolves.
125
+ `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.
139
126
 
140
127
  ```jsx
141
128
  const p = useProduct(slug);
142
- const g = useProductGallery(p.product, p.view);
143
- const buy = useAddToCart(p, { labels: { ready: "Add to bag" } });
144
- const specs = useProductSpecs(p.product, { pick: ["material", "care"] });
129
+ const buy = useAddToCart(p);
145
130
  const { settings } = useStoreInfo(); // the ONLY source of store name + currency
146
- useStorefrontSeo(productSeo(p.product, p.view, { storeName: settings?.store_name, currency: settings?.currency }));
147
-
148
- if (p.status === "loading") return <StatusScreen busy title="…" />;
149
- if (p.status === "not_found") return <StatusScreen title="…" action={/* link back to the catalog */} />;
131
+ const [picked, setPicked] = useState(null); // gallery: which thumbnail was clicked
132
+ if (p.status === "loading") return /* your loading screen */;
133
+ if (p.status === "not_found") return /* your 404, linking back to the catalog */;
150
134
  const { product, view, price, categories } = p;
135
+ const images = productImages(product);
151
136
  ```
152
137
 
153
- Build your layout from — all optional, each one hook, **not one component style**:
138
+ Build your layout from — all optional, **not one component style**:
154
139
 
155
- - **Price** — `price.label`, plus `price.compareAtLabel` (struck through) when on sale. Never read `product.price` directly the parent's price is a rolled-up from-price.
156
- - **Gallery** — `g` `{ hasImages, images, active, activeIndex, setActiveIndex, next, prev }`. The active image follows the variant selection; `hasImages: false` means render your placeholder.
157
- - **Variant selector** — `variantAxes(view, p.pick)` → one entry per axis: `{ key, name, selectedOption, options: [{ value, selected, disabled, outOfStock, pick }] }`. ⚑ **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. The differentiation is semantic — what the control *shows* — built from your classes, not extra chrome around each row. The shape of the map:
140
+ - **Price** — `price.label`, plus `price.compareAtLabel` (struck through) on sale. Never read `product.price` directly: the parent's is a rolled-up from-price, and an incomplete selection has a *range* — `price.label` already renders both correctly.
141
+ - **Gallery** — `productImages(product)` is the thumbnail strip; the main frame comes from the **selection**, and the two are not the same list:
142
+
143
+ ```jsx
144
+ const active = picked != null ? images[picked] : (view?.display?.image ?? images[0] ?? null);
145
+ const highlight = picked ?? imageIndex(images, view?.display?.image); // -1 = nothing to mark
146
+ useEffect(() => setPicked(null), [view?.variation?.id]); // a new variant re-takes the lead
147
+ ```
148
+
149
+ ⚑ **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.
150
+ - **Variant selector** — `variantAxes(view, p.pick)`, one entry per axis:
158
151
 
159
152
  ```jsx
160
153
  {variantAxes(view, p.pick).map((axis) => (
161
154
  <fieldset key={axis.key}>{/* label from axis.name / axis.selectedOption */}
162
155
  {axis.options.map((o) => (
163
156
  <button key={o.value} disabled={o.disabled} aria-pressed={o.selected} onClick={o.pick}>
164
- {o.value}{/* o.outOfStock → mark visibly, keep clickable-looking off */}
157
+ {o.value}{/* o.outOfStock → mark visibly */}
165
158
  </button>
166
159
  ))}
167
160
  </fieldset>
168
161
  ))}
169
162
  ```
170
163
 
171
- - **Buy box** `<button type="button" onClick={buy.addToCart} disabled={buy.disabled}>{buy.label}</button>` is the whole button. ⚑ **Both the text and the gate come from the hook** `{buy.label}` follows the ready/adding/sold-out/needs-selection precedence (`buy.state`; override copy via `labels`), so never hardcode the button text or re-derive `disabled` from your own ternary chain. Render `buy.error.message` inline; `buy.showQuantity: false` means no stepper (`increase`/`decrease`/`canIncrease` drive one when true). With `<CartUIProvider>` mounted, a successful add opens the drawer by itself.
172
- - **Description** — `product.description` is HTML; render as rich text, `short_description` above it.
173
- - **Specs** — `useProductSpecs(product, { pick: [...] })`: `picked` are the rows to feature (matched case/`_`-insensitively — ⚑ **never match rows by `label` equality**, it silently misses), `rest` is the remainder, safe to render as plain rows. Each row carries `titleLabel` (display-cased) and a `type` with `number`/`unit`/`items` split out, so a weight can be a figure and a composition bars. **This is the product page's one signature-moment candidate**: branch on `type` (or `key`) for the two or three `picked` specs that carry *this* product's meaning, and render `rest` as plain rows in your classes — not everything into one uniform table, and not a bespoke widget per spec either. `[]` means no section at all.
174
- - **Breadcrumbs** — build from `categories` (`/collection?category_id=${c.id}`); skip on a flat catalog. Ribbons are labels, not breadcrumbs.
164
+ **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.
165
+ - **Buy box** — one button, and **you supply its four words**:
175
166
 
176
- ### Reviews — optional
167
+ ```jsx
168
+ const BUY = { ready: "…", adding: "…", sold_out: "…", needs_selection: "…" }; // this store's voice
169
+ <button type="button" onClick={buy.addToCart} disabled={buy.disabled}>{BUY[buy.state]}</button>
170
+ {buy.error?.message && <p role="alert">{buy.error.message}</p>}
171
+ ```
177
172
 
178
- **Build 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`). `useProductReviews(product, { policy, user })` is the whole surface: `items`, paging, `averageRating`, and the submit form (`form`/`setField`/`fieldErrors`, `valid`, `submit`, `requiresEmail`, `reviewBlockedReason`). ⚑ The confirmation copy is `message`, **taken from the server's response** a store with auto-approval says "published", not "awaiting approval". `policy` is `"open" | "login" | "verified_buyers"`; details: [`../references/reviews.md`](../references/reviews.md).
173
+ **Text for every state, and the gate from the hook.** `buy.state` resolves the precedencenever 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.
174
+ - **Description** — `product.description` is HTML; render as rich text, `short_description` above it.
175
+ - **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 typed by whoever set the product up (`care`, `Care`, `Care Instructions`), so `rows.find(s => s.label === "Care")` silently never matches and the feature renders its 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 everything into one uniform grey table; not a bespoke widget per row. `[]` means no section at all.
176
+ - **Breadcrumbs** — from `categories` (`/collection?category_id=${c.id}`); skip on a flat catalog. Ribbons are labels, not breadcrumbs.
177
+ - **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).
178
+ - **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 either — if the store wants rich results, emit your own `Product`/`Offer` JSON-LD from `product` and `view.display` (price, currency, availability).
179
179
 
180
180
  ## Cart / bag
181
181
 
182
182
  ```jsx
183
- import { useCart, CartLine, useTotalsLines, useCoupon, useCartUI, useUpsell } from "@/commerce/storefront";
183
+ import { useCart, useCartLine, CartLine, useCartUI, useFormatMoney, attributesLabel, cartTotalsLines } from "@/commerce/storefront";
184
184
  ```
185
185
 
186
- A cart *page* is optional (buy-now straight to checkout reads better for a single-piece store). The surface: `useCart()` (`status`, `lines`, `notices`), `CartLine` (headless per-row binding — quantity stepping that clamps, coalesces and recovers), `useTotalsLines()`, `useCoupon()`.
186
+ 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).
187
187
 
188
- ⚑ Rules: branch on `status`, never on emptiness while loading. Render `notices` — they say what auto-dropped from the cart and why. Render every non-`hidden` totals line 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) — coupons are admin-only data, redeemable only through a field the customer types into; if none exists, don't seed coupons and don't write "use WELCOME10" in the copy.
188
+ ⚑ 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.
189
189
 
190
190
  ⚑ **`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.
191
191
 
192
- **Reference wiring** — structure correct, presentation deliberately absent; restyle and rearrange, keep the rules:
192
+ **Reference wiring** for one row the rest of the page (notices, coupon field, totals, checkout link) is ordinary markup around it:
193
193
 
194
194
  ```jsx
195
- function Bag() {
196
- const { status, lines, notices } = useCart();
197
- const totals = useTotalsLines();
198
- if (status === "loading") return <StatusScreen busy title="…" />;
199
- if (status === "empty") return <StatusScreen title="…" action={/* link back to the catalog */} />;
200
- return (
201
- <>
202
- {notices.map((n, i) => <p key={i} role="status">{n.message}</p>)}
203
- {lines.map((line) => (
204
- <CartLine key={line.item_key} line={line}>
205
- {(l) => ( /* line: name, attributesLabel, image, totalLabel — l: the controls */
206
- <li aria-busy={l.pending}> {/* busy scope is THIS row, never the cart */}
207
- {line.name} {line.attributesLabel}
208
- <button onClick={l.decrease} disabled={!l.canDecrease || l.pending}
209
- aria-label={`Decrease quantity of ${line.name}`}>−</button>
210
- {l.quantity}
211
- <button onClick={l.increase} disabled={!l.canIncrease || l.pending}
212
- aria-label={`Increase quantity of ${line.name}`}>+</button>
213
- <button onClick={l.remove} disabled={l.pending}
214
- aria-label={`Remove ${line.name}`}>Remove</button>
215
- {l.totalLabel}
216
- {l.error && <p role="alert">{l.error.message}</p>}
217
- </li>
218
- )}
219
- </CartLine>
220
- ))}
221
- <CouponField /> {/* useCoupon: code/setCode, apply, applying, error, applied[] + remove */}
222
- {totals.filter((t) => !t.hidden).map((t) => (
223
- <div key={t.key}>{t.label} {t.formatted}</div> /* t.emphasis → the total row */
224
- ))}
225
- <Link to="/checkout">Checkout</Link>
226
- </>
227
- );
228
- }
195
+ const { status, cart } = useCart();
196
+ const formatMoney = useFormatMoney();
197
+ // guards on status first, then:
198
+ {cart.items.map((item) => (
199
+ <CartLine key={item.item_key} line={item}>
200
+ {(l) => (
201
+ <li aria-busy={l.pending}> {/* busy scope is THIS row, never the cart */}
202
+ {item.name} {attributesLabel(item.attributes)} {formatMoney(item.total)}
203
+ <button onClick={l.decrease} disabled={!l.canDecrease || l.pending}
204
+ aria-label={`Decrease quantity of ${item.name}`}>−</button>
205
+ {l.quantity}
206
+ <button onClick={l.increase} disabled={!l.canIncrease || l.pending}
207
+ aria-label={`Increase quantity of ${item.name}`}>+</button>
208
+ <button onClick={l.remove} disabled={l.pending}
209
+ aria-label={`Remove ${item.name}`}>Remove</button>
210
+ {l.error && <p role="alert">{l.error.message}</p>}
211
+ </li>
212
+ )}
213
+ </CartLine>
214
+ ))}
215
+ {cartTotalsLines(cart, { formatMoney }).filter((t) => !t.hidden).map((t) => (
216
+ <div key={t.key}>{t.label} {t.formatted}</div> /* t.emphasis → the total row */
217
+ ))}
229
218
  ```
230
219
 
231
- No shipping estimator here — checkout reprices shipping and tax from the address. An upsell beside the lines is one hook: `const kit = useUpsell(slugOrRow)` `{ show, product, price, add, adding }`; branch on `show` (it is false while loading, out of stock, or already in the cart matched by id).
220
+ 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 rename must not break the match). ⚑ **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.
232
221
 
233
222
  ### If the cart is a drawer
234
223
 
235
- Same hooks, same rows. The drawer's *state* — open/close, Esc, close-on-route-change, open-on-add — is `useCartUI` (`{ open, openCart, closeCart, toggleCart }`); the markup is yours:
236
-
237
- ```jsx
238
- function StoreLayout() { // inside <CartUIProvider> (see Setup)
239
- const ui = useCartUI();
240
- const { itemCount } = useCart();
241
- return (<>
242
- <header>… <button type="button" onClick={ui.toggleCart} aria-expanded={ui.open}
243
- className="…">Bag ({itemCount})</button></header>
244
- <Outlet />
245
- {ui.open && (<>
246
- <div onClick={ui.closeCart} aria-hidden="true" className="…" />
247
- <aside role="dialog" aria-modal="true" aria-label="Cart" className="…">
248
- <button type="button" onClick={ui.closeCart} aria-label="Close cart" className="…">×</button>
249
- {/* your rows: useCart + CartLine, as above */}
250
- </aside>
251
- </>)}
252
- </>);
253
- }
254
- ```
224
+ 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).
255
225
 
256
- ⚑ **Render the drawer conditionally (`{ui.open && …}`), exactly as above.** 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, you own that concern: set the `inert` attribute while closed. The overlay is a click-away surface, not the close control — the named close button inside the panel is.
226
+ ⚑ **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, you own that concern set the `inert` attribute while closed. The overlay is a click-away surface, not the close control.
257
227
 
258
228
  ## Checkout
259
229
 
260
230
  ```jsx
261
- import { CheckoutProvider, useCheckoutContext, usePlaceOrder, useAddressForm, ShippingMethodPicker, PaymentMethodPicker, useCart, useTotalsLines, useCoupon } from "@/commerce/storefront";
231
+ import { CheckoutProvider, useCheckoutContext, ShippingMethodPicker, PaymentMethodPicker, useCart, useCountries, useFormatMoney, cartTotalsLines, addressFieldSpec, REQUIRED_BILLING_FIELDS } from "@/commerce/storefront";
262
232
  ```
263
233
 
264
- (`useCoupon` only if the coupon field lives here rather than in the cart.)
234
+ `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.
265
235
 
266
- The state machine is `useCheckout`, shared across the page's regions by `CheckoutProvider`. It 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** (pass `orderReceivedPath: null` for a router transition; see `useCheckout`'s JSDoc).
236
+ 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.
267
237
 
268
- 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"; wire each address field from its own states `value={f.value}` `onChange={f.set}` `autoComplete={f.autoComplete}`, and render `f.error` ("we don't ship there" arrives on the country field); render the gate's `error` and `blockers` so a disabled button explains itself. Payment methods, currency and countries come from `useStoreInfo()` only `cart.payment_gateways` is always `undefined`, and a default store offers `offline` only.
238
+ **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).
269
239
 
270
- **Reference implementation** the densest wiring in the storefront; read it, then build yours around it:
240
+ 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.
271
241
 
272
- ```jsx
273
- function Checkout() { // hooks read the context BELOW the provider
274
- return <CheckoutProvider><CheckoutForm /></CheckoutProvider>;
275
- }
242
+ **Reference wiring** — the densest part of the storefront. `Checkout()` is just
243
+ `<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)):
276
244
 
245
+ ```jsx
277
246
  function CheckoutForm() {
278
247
  const { status } = useCart();
279
- const checkout = useCheckoutContext();
280
- const order = usePlaceOrder();
281
- if (order.stage === "submitted") return <StatusScreen busy title="…" />; // "taking you to your receipt"
282
- if (status === "loading") return <StatusScreen busy title="…" />;
283
- if (status === "empty") return <StatusScreen title="…" action={/* link back to the catalog */} />;
248
+ const c = useCheckoutContext();
249
+ if (c.stage === "submitted") return /* "taking you to your receipt" screen */;
250
+ if (status === "loading") return /* your loading screen */;
251
+ if (status === "empty") return /* your empty-bag screen */;
284
252
  return (
285
253
  <>
286
254
  <AddressFields which="billing" />
287
- <label>
288
- <input type="checkbox" checked={checkout.shipToDifferent}
289
- onChange={(e) => checkout.setShipToDifferent(e.target.checked)} />
290
- Deliver to a different address
291
- </label>
292
- {checkout.shipToDifferent && <AddressFields which="shipping" />}
255
+ {/* a checkbox on c.shipToDifferent / c.setShipToDifferent, your wording */}
256
+ {c.shipToDifferent && <AddressFields which="shipping" />}
293
257
 
294
258
  <ShippingMethodPicker>
295
259
  {({ hint, mustChoose, methods, chosen }) => (
296
260
  <fieldset>{/* renders null for a virtual cart */}
297
- {hint && <p role={hint.severity === "error" ? "alert" : "status"}>{hint.message}</p>}
261
+ {hint && <p role={hint.severity === "error" ? "alert" : "status"}>
262
+ {hint.serverMessage ?? SHIPPING_HINTS[hint.code]}</p>}
298
263
  {mustChoose && methods.map((m) => (
299
264
  <label key={m.id}>
300
265
  <input type="radio" name="shipping-method" checked={m.selected} onChange={m.select} />
@@ -306,149 +271,86 @@ function CheckoutForm() {
306
271
  )}
307
272
  </ShippingMethodPicker>
308
273
 
309
- <PaymentMethodPicker>
310
- {({ hint, single, gateways, selected }) => (
311
- <fieldset>
312
- {hint && <p role="alert">{hint.message}</p>}
313
- {!single && gateways.map((g) => (
314
- <label key={g.slug}>
315
- <input type="radio" name="payment-method" checked={g.selected} onChange={g.select} />
316
- {g.title} {g.description}
317
- </label>
318
- ))}
319
- {single && selected && <p>{selected.title}</p>}
320
- </fieldset>
321
- )}
322
- </PaymentMethodPicker>
274
+ {/* PaymentMethodPicker: same shape — hint, then gateways.map radios unless
275
+ `single`, then `selected.title`. Titles/descriptions are the admin's copy. */}
323
276
 
324
- {/* summary: coupon field (if not in the cart) + useTotalsLines(), as in the cart page */}
277
+ {/* summary: coupon field (if not in the cart) + cartTotalsLines(c.cart, { formatMoney }) */}
325
278
 
326
- <button type="button" onClick={order.placeOrder} disabled={order.disabled}>{order.label}</button>
327
- {order.error && <p role="alert">{order.error.message}</p>}
328
- {!order.canPlaceOrder && order.blockers.map((b) => <p key={b.code}>{b.message}</p>)}
279
+ <button type="button" onClick={c.placeOrder} disabled={!c.canPlaceOrder || c.placing}>
280
+ {c.placing ? PLACING_LABEL : PLACE_ORDER_LABEL}
281
+ </button>
282
+ {c.orderError && <p role="alert">{c.orderError.message}</p>}
283
+ {!c.canPlaceOrder && c.blockers.map((code) => <p key={code}>{BLOCKERS[code]}</p>)}
329
284
  </>
330
285
  );
331
286
  }
332
287
 
333
288
  function AddressFields({ which }) {
334
- const { fields } = useAddressForm(which);
289
+ const c = useCheckoutContext();
290
+ const { countries } = useCountries(); // [] until store info lands — never null
291
+ const isBilling = which === "billing";
292
+ const values = isBilling ? c.billing : c.shipping;
293
+ const set = isBilling ? c.updateBilling : c.updateShipping;
294
+ const fields = addressFieldSpec({
295
+ countries,
296
+ country: values.country,
297
+ required: isBilling ? REQUIRED_BILLING_FIELDS : ["country", "city"],
298
+ includeEmail: isBilling, // one email per order, on billing
299
+ });
335
300
  return fields.map((f) => (
336
301
  <div key={f.key}>
337
- <label htmlFor={f.id}>{f.label}{f.required && " *"}</label>
338
- {f.isSelect ? (
339
- <select id={f.id} value={f.value} onChange={f.set} autoComplete={f.autoComplete}>
340
- <option value="">{f.placeholder}</option>
302
+ <label htmlFor={`${which}-${f.key}`}>{f.label}{f.required && " *"}</label>
303
+ {f.type === "select" ? (
304
+ <select id={`${which}-${f.key}`} value={values[f.key] ?? ""} autoComplete={f.autoComplete}
305
+ onChange={(e) => set({ [f.key]: e.target.value })}>
306
+ <option value="">{/* your placeholder */}</option>
341
307
  {f.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
342
308
  </select>
343
- ) : <input id={f.id} type={f.type} value={f.value} onChange={f.set} autoComplete={f.autoComplete} />}
344
- {f.error && <span role="alert">{f.error}</span>}
309
+ ) : (
310
+ <input id={`${which}-${f.key}`} type={f.type} value={values[f.key] ?? ""}
311
+ autoComplete={f.autoComplete} onChange={(e) => set({ [f.key]: e.target.value })} />
312
+ )}
313
+ {/* "we don't ship there" is an address-level error — it belongs on country */}
314
+ {f.key === "country" && c.addressError && <span role="alert">{c.addressError.message}</span>}
345
315
  </div>
346
316
  ));
347
317
  }
348
318
  ```
349
319
 
350
- ⚑ **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 "your bag is empty" over a just-placed order.
351
-
352
- ## Order received
353
-
354
- ```jsx
355
- import { useOrderReturn, useTotalsLines } from "@/commerce/storefront";
356
- ```
320
+ ⚑ **Passing `country` is what makes the state/province field appear**, with the right options for the US, Canada and Australia and shipping rates and taxes match on country *plus* state, so a form without that field mis-prices those orders with no error anywhere. ⚑ Keep each field's `autoComplete` token; it is what makes browser autofill work. Labels are plain conventions — rename or restyle freely. `c.missingBillingFields` is the live list of what is still missing, if you want per-field marks; arm them on first edit, not on load.
357
321
 
358
- **Mandatory route** every payment link returns here, and confirming is what marks a card order paid. `useOrderReturn()` is the whole page's logic: it reads `order_id`/`order_key` from the URL, verifies with the provider (idempotent), and marks the page noindex itself.
322
+ **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.
359
323
 
360
- Rules: render all five states — never a blank page while `"loading"`, a retry via `reload()` on `"error"`. ⚑ **Never drop `paymentInstructions`** — a manual/offline order settles outside the store, so these ARE how the store's default customer learns how to pay; render them whenever present. ⚑ An order's totals are flat (`order.total`, no `order.totals`) — use `useTotalsLines(order)`.
361
-
362
- **Reference implementation** — a receipt is a convention, not an identity surface: take this structure, restyle it, keep every branch.
363
-
364
- ```jsx
365
- function OrderReceived() {
366
- const { status, order, lines, paymentLink, paymentInstructions, error, reload } = useOrderReturn();
367
- const totals = useTotalsLines(order);
368
- if (status === "loading") return <StatusScreen busy title="…" />; // confirming copy
369
- if (status === "error") return <StatusScreen title="…" message={error.message}
370
- action={<button type="button" onClick={() => reload()}>Try again</button>} />;
371
- return (
372
- <>
373
- {status === "paid" && /* thank-you heading */}
374
- {status === "unpaid" && <>{/* awaiting-payment heading */}
375
- {paymentLink?.url && <a href={paymentLink.url}>Pay now</a>}</>}
376
- {status === "cancelled" && <>{/* payment-cancelled heading */}
377
- {paymentLink?.url && <a href={paymentLink.url}>Try payment again</a>}</>}
378
- {order?.order_number && <p>Order {order.order_number}</p>}
379
- {paymentInstructions && (
380
- <section>{/* "How to pay" — the offline customer's next step */}
381
- {paymentInstructions.description && <p>{paymentInstructions.description}</p>}
382
- {paymentInstructions.account_details && Object.entries(paymentInstructions.account_details)
383
- .map(([k, v]) => <p key={k}>{k.replace(/_/g, " ")}: {String(v)}</p>)}
384
- </section>
385
- )}
386
- {lines.map((l, i) => <p key={i}>{l.name} {l.attributesLabel} × {l.quantity} — {l.totalLabel}</p>)}
387
- {totals.filter((t) => !t.hidden).map((t) => <p key={t.key}>{t.label} {t.formatted}</p>)}
388
- </>
389
- );
390
- }
391
- ```
392
-
393
- ## SEO — one line per page type
324
+ ## Order received
394
325
 
395
326
  ```jsx
396
- useStorefrontSeo(productSeo(p.product, p.view, { storeName, currency })); // product page
397
- useStorefrontSeo(collectionSeo({ title, products: list.products })); // collection / home
398
- // order-received is already noindex via useOrderReturn
327
+ import { useOrderReturn, useFormatMoney, orderTotalsLines } from "@/commerce/storefront";
399
328
  ```
400
329
 
401
- The `*Seo` builders tolerate a null product, so this sits with the other hooks above the status guards.
402
-
403
- ## Per-page output budgets — measured, not aspirational
330
+ **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.
404
331
 
405
- | Page | budget (chars) |
406
- |---|---|
407
- | Checkout | ≤ 3.5K |
408
- | Cart / bag | ≤ 3K (a drawer is its own component with its own 3K) |
409
- | Order-received | ≤ 2.5K |
410
- | Product page | ≤ 5K |
411
- | Collection | ≤ 3K |
412
- | Home | ≤ 5K — identity lives in the hero + one editorial moment, in your classes |
413
- | Any single component file | ≤ 4K, hard ceiling 8K |
414
-
415
- When the storefront builds clean, **measure** (adjust the paths to your component folder names):
416
-
417
- ```bash
418
- wc -c src/pages/*.jsx src/components/store*/*.jsx
419
- ```
332
+ 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.
420
333
 
421
- A page over budget has one of two problems — fix in this order, and never by dropping states, branches or ⚑ rules:
422
-
423
- 1. **Inline decoration that belongs in `index.css`** — this is where the chars measurably are. Extract every repeated utility run into the design classes, fold hand-drawn status branches into `StatusScreen`, and cut any second navigation affordance or off-signature widget.
424
- 2. **Re-implemented hook logic** (a buy button's state precedence, a quantity clamp, totals math, drawer state) — go back to the hook and delete your version.
334
+ A receipt is a convention surface: your classes, every branch present, no bespoke widgets.
425
335
 
426
336
  ## Driving the storefront from a browser script?
427
337
 
428
- The hooks are optimistic and debounced, so a script that acts faster than the cart settles sees a working store as broken. Read [`../references/storefront-verification.md`](../references/storefront-verification.md) **before** writing the script — not after it fails.
338
+ 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.
429
339
 
430
340
  ## Done — forget this file
431
341
 
432
342
  - [ ] Catalog UI in whatever form fits the store, plus a checkout, plus `/order-received` rendering `useOrderReturn`'s states **including `paymentInstructions`**.
433
- - [ ] `index.css` defines the store's design classes; pages carry class names, not repeated utility runs; every full-screen state goes through the shared `StatusScreen`.
343
+ - [ ] `index.css` defines the store's design classes; pages carry class names, not repeated utility runs.
434
344
  - [ ] **One** `<StorefrontProvider>` above every storefront route (layout-route pattern); one client, no hand-rolled `cart_token`.
435
- - [ ] Every page's imports came from its section's import line; nothing imported from `@/commerce/utils`; no unused names.
436
- - [ ] Pages branch on `status`; gateways/currency/countries read from `useStoreInfo()` only.
437
- - [ ] Every control renders the hook's states button text is `buy.label`/`order.label`, gates are the hook's `disabled`, address fields wire `value={f.value}` `onChange={f.set}` `autoComplete={f.autoComplete}` no re-implemented hook logic.
438
- - [ ] Coupon field present if the store has coupons; a paging control rendered whenever `hasNext` is true.
345
+ - [ ] Every page's imports came from its section's import line; no unused names.
346
+ - [ ] Pages branch on `status`; gateways/currency/countries read from `useStoreInfo()`/`useCountries()` only.
347
+ - [ ] 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.
348
+ - [ ] No re-implemented hook logic (button state precedence, quantity clamps, totals math, drawer state).
349
+ - [ ] 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.
439
350
  - [ ] Variant options: one control per axis, unbuyable options disabled, not hidden.
440
- - [ ] 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` (closes on route change by itself).
351
+ - [ ] Address form includes the state/province field and every `autoComplete` token.
352
+ - [ ] 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`.
441
353
  - [ ] Checkout guards `stage === "submitted"` above its empty-cart branch.
442
- - [ ] The storefront carries the design you settled on before reading this file — encoded in the design classes plus one or two signature moments per page; on the product page, axes and specs are rendered by what they are (not one uniform table, not one identical chip row); convention surfaces (checkout, bag, order-received) carry the classes and nothing bespoke.
443
- - [ ] Measured with `wc -c` — every page within its budget above.
444
-
445
- Record these lines in your working notes; do not re-read this file.
446
-
447
- - Payment gateways, currency and countries come from `useStoreInfo()` only — never off a cart (`cart.payment_gateways` is always undefined).
448
- - A store with any coupons must have a coupon field (`useCoupon`) in the cart or the checkout, or its codes can never be redeemed.
449
- - `/order-received` is mandatory and renders `useOrderReturn`'s states, including `paymentInstructions` — how a normal (offline) customer learns how to pay.
450
- - Branch cart/list/product UI on `status`, never on `isEmpty`/nullable data — `isEmpty` is false while loading by design.
451
- - Variant options that aren't buyable render disabled, never hidden; one control per axis, never a list of variations.
452
- - 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".
453
- - Hooks return plain states and handlers (`label`, `disabled`, `value`, `set`, `addToCart`) — you write every element and attribute from them; render the state-driven text (`buy.label`, `f.error`, `hint.message`) and never re-derive what a hook already resolves.
454
- - 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, full-screen states go through the shared `StatusScreen`, and every page is measured (`wc -c`) against its char budget.
354
+ - [ ] The storefront carries the design you settled on before reading this file — design classes plus one or two signature moments per page; on the product page, axes and specs are rendered by what they are (not one uniform table, not one identical chip row); convention surfaces (checkout, bag, order-received) carry the classes and nothing bespoke.
355
+
356
+ Then copy this file's `carry_forward` lines (in its front matter) into your working notes, and do not re-read this file.