@base44/app-plugin-commerce 0.2.5 → 0.2.6

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.
@@ -10,78 +10,39 @@ carry_forward:
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
+ - "Spread the hook's prop set (inputProps, radioProps, buttonProps, panelProps, moreProps) and add className — never assemble those attributes by hand."
13
14
  ---
14
15
 
15
16
  # 02 — Storefront
16
17
 
17
- One split decides everything here: **the logic is premade, the UI never is.**
18
-
19
- - **Logic hooks, shipped.** Checkout repricing from the address, variant
20
- resolution, cart state, coupon redemption, review policies, order-return
21
- verification. Every store's version of these is functionally identical, and
22
- hand-writing them is where storefront bugs cluster: **never re-implement what
23
- a hook does.**
24
- - **UIyours, always.** Every element, class, layout and word of copy on
25
- every page. Nothing in `@/commerce/storefront` renders markup or carries CSS,
26
- so there is no component to drop in and nothing to restyle — the design is
27
- the part of the storefront only you can do, and it should be designed, not
28
- assembled.
29
-
30
- ## Before you begin
31
-
32
- **Decide how the store looks as if this kit did not exist** — identity, type,
33
- palette, spacing, the shape of a card, how a checkout is laid out — from the
34
- brief and your own judgement. Then use this file for **how to wire it**:
35
- everything below is implementation reference and **none of it is design input**.
36
- The bare tags, flat structure and placeholder copy show where the data goes in
37
- the fewest characters; they are not a look to adopt, keep or tweak. The finished
38
- store should look like what you would have built with no kit at all — the kit's
39
- only job is to make it cost far less code.
40
-
41
- Each hook returns a complete view-model — a `status` to branch on,
42
- ready-to-map arrays, handlers, error objects — and its **doc comment (JSDoc) is
43
- the API reference**: open the hook's file when you need exact shapes; don't
44
- guess fields. This file gives you the routing, each surface's hook, and the
45
- render rules that keep a store correct (marked ⚑ — these must survive whatever
46
- design you build).
47
-
48
- Where you get a **reference implementation** and where you get only the hook is
49
- deliberate: **cart, checkout and order-received** have reference code below —
50
- their wiring is dense enough that reading it is cheaper than deriving it, and
51
- they are conventions (a form, a receipt) where familiarity beats invention.
52
- The **identity surfaces** — home, collection, the card, the product page's
53
- layout — get hooks only, on purpose: reference markup there would make every
54
- store look the same, and their design is the work only you can do. Either way
55
- the hooks are high-level enough that a page is a handful of calls plus your
56
- markup — writing more code than the budgets at the bottom allow means you are
57
- re-deriving logic a hook already owns.
58
-
59
- **One import path: `@/commerce/storefront`.** Each section below opens with its
60
- page's exact import line — **copy it verbatim** instead of assembling one from
61
- memory, then delete any name you don't end up using. `variantAxes` and
62
- `productSpecs` live in `@/commerce/utils` but are re-exported here, so a React
63
- page never imports from `@/commerce/utils` directly; `useStoreInfo` is the name
64
- most often left out, and it is the only source of store name and currency.
18
+ 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 use this file for how to wire it — the snippets below are implementation reference, not design input.
19
+
20
+ Two mechanics to hold everywhere:
21
+
22
+ - **Prop sets, not hand-assembled attributes.** Hooks return ready-to-spread objects — `f.inputProps`, `m.radioProps`, `buy.buttonProps`, `ui.panelProps`, `list.moreProps` carrying the handlers, ids, aria wiring and disabled logic. Spread first, put your `className` after; writing `value`/`onChange`/`autoComplete` yourself means re-deriving what a prop set already holds.
23
+ - **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.
24
+
25
+ **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.)
65
26
 
66
27
  ## Setup — once
67
28
 
68
- Nearly every store has shared chrome (a nav with a cart badge, a footer), so
69
- **start from the layout route** — a pathless `<Route>` whose element is your
70
- layout, rendering `<Outlet/>` where the page goes. It also keeps the admin
71
- outside the storefront's provider and chrome:
29
+ Nearly every store has shared chrome, so **start from a pathless layout route** it also keeps the admin outside the storefront's provider:
72
30
 
73
31
  ```jsx
74
32
  import { Routes, Route, Outlet } from "react-router-dom";
75
- import { StorefrontProvider } from "@/commerce/storefront";
33
+ import { StorefrontProvider, CartUIProvider } from "@/commerce/storefront";
76
34
  import { base44 } from "@/api/base44Client";
77
35
  import AdminApp from "@/commerce/admin";
78
36
 
79
- // StoreLayout is YOURS: <Nav/> (its cart badge calls useCart) + <Outlet/> + <Footer/>.
80
- function StoreLayout() { return <><Nav /><Outlet /><Footer /></>; }
81
-
82
37
  <BrowserRouter>
83
38
  <Routes> {/* ONE <Routes> — merge new pages into the app's */}
84
- <Route element={<StorefrontProvider base44={base44}><StoreLayout /></StorefrontProvider>}>
39
+ <Route element={
40
+ <StorefrontProvider base44={base44}>
41
+ <CartUIProvider> {/* only if the cart is a drawer — see Cart below */}
42
+ <StoreLayout /> {/* YOURS: header + <Outlet/> + footer + drawer */}
43
+ </CartUIProvider>
44
+ </StorefrontProvider>
45
+ }>
85
46
  <Route path="/" element={<Home />} />
86
47
  <Route path="/product/:slug" element={<ProductPage />} />
87
48
  <Route path="/bag" element={<Bag />} />
@@ -93,30 +54,7 @@ function StoreLayout() { return <><Nav /><Outlet /><Footer /></>; }
93
54
  </BrowserRouter>
94
55
  ```
95
56
 
96
- ⚑ **The nesting is provider → layout → `<Outlet/>`, never the reverse.** A
97
- layout that renders the provider *inside* itself leaves the nav above (or
98
- outside) it, so the header badge and the cart page read different carts — and a
99
- `useCart` in the nav throws outright. The provider goes on the layout route's
100
- element, wrapping your layout component.
101
-
102
- **No shared chrome** (each page draws its own header, or there is one page)?
103
- Then wrap `<Routes>` directly and skip the layout route:
104
-
105
- ```jsx
106
- <StorefrontProvider base44={base44}> {/* wraps <Routes> — never a child of it */}
107
- <Routes>…</Routes>
108
- </StorefrontProvider>
109
- ```
110
-
111
- > ⚠ **`<Routes>` accepts only `<Route>` children.** Putting the provider inside
112
- > it — the natural reading of "wrap the storefront routes" — throws at render:
113
- > `Error: [StorefrontProvider] is not a <Route> component. All component children of <Routes> must be a <Route> or <React.Fragment>`.
114
- > A pathless layout route (above) is the one place a wrapper is legal.
115
-
116
- The provider owns the shared client, the store-info cache and **one** shared
117
- cart, so a header badge, a drawer and the checkout render the same state. Never
118
- mount a second provider, and never touch the `cart_token` — the provider owns
119
- its whole lifecycle.
57
+ ⚑ **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`.
120
58
 
121
59
  ## Product list / collection
122
60
 
@@ -124,9 +62,7 @@ its whole lifecycle.
124
62
  import { useProductList, useCategories, useProductPrice, useStoreInfo, useStorefrontSeo, collectionSeo, productSpecs } from "@/commerce/storefront";
125
63
  ```
126
64
 
127
- (Drop `useCategories` with no filter bar, `productSpecs` if the card shows no
128
- modifiers. Add `useRibbons` for a ribbon filter.) The top of the component,
129
- before any markup — every hook above the guards, and `products` derived once:
65
+ (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:
130
66
 
131
67
  ```jsx
132
68
  const list = useProductList({ per_page: 24 });
@@ -135,66 +71,30 @@ useStorefrontSeo(collectionSeo({ title: "…", products: list.products, storeNam
135
71
 
136
72
  if (list.status === "loading") return /* your loading state */;
137
73
  if (list.status === "error") return /* your failure state, with a retry calling list.reload() */;
138
- const products = list.products; // always an array — never null, so no defensive `?? []`
74
+ const products = list.products; // always an array — never null
139
75
  ```
140
76
 
141
- The same opening carries a home page's rails and a search results page — only
142
- the params and your markup change.
143
-
144
- `useProductList(params)` → `{ status, products, hasNext, next, refreshing,
145
- setParams, reload }`. ⚑ `status` is `"loading" | "ready" | "empty" | "error"`
146
- — branch on it, so a failed request renders as a failure instead of an empty
147
- grid. `setParams({ category_id, search, on_sale, min_price, in_stock_only })`
148
- resets to page 1 and keeps the current rows on screen (`refreshing`) while the
149
- page loads. `useCategories()` / `useRibbons()` → `{ items }` (arrays, children
150
- nested).
151
-
152
- Your card can render `name`, `images[0]?.src` (⚑ **images are `{src,name,alt}`
153
- objects and the array may be empty — render a placeholder, never a broken
154
- `<img>`**), `useProductPrice(row).label` (already "From €19.99" when the
155
- product sells variants — there is no product `type` flag), `on_sale`,
156
- `short_description`, `stock_status`, `average_rating`/`rating_count`,
157
- `ribbons` — and a row carries the whole product record, so `weight`,
158
- `dimensions`, `attributes[]` and `meta_data` are there too. Full field matrix:
159
- [`../references/catalog-rendering.md`](../references/catalog-rendering.md).
160
-
161
- That is an inventory of what you *can* show, not a card design and not a list
162
- to render in order. An even grid of identical cards, each with the same
163
- name/price/stars trio, is where a generated store lands by default and almost
164
- never where this catalog belongs: give the grid a rhythm (a hero piece spanning
165
- two columns, an editorial break between rows, a denser tile for a large
166
- catalog), and lead each card with the one or two fields *these* products are
167
- judged on — carat weight, focal length, edition size, ABV — read off
168
- `meta_data` via `productSpecs(row)`, not the fields every store shows.
169
-
170
- **Rails** (featured row, "new in") are the same hook with a filter
171
- (`{ featured: true, per_page: 4 }`). ⚑ Any filter may legitimately match
172
- nothing — render *nothing* then, never a heading over an empty row. Upsells
173
- beside a product need no query: `p.upsells` / `p.crossSells` from `useProduct`.
77
+ `useProductList(params)` `{ status, products, hasNext, moreProps, 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 `<button {...list.moreProps}>Load more</button>`** (or "Next") it hides itself on the last page; 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).
78
+
79
+ 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: give the grid a rhythm and lead each card with the one or two fields *these* products are judged on (via `productSpecs`), not the name/price/stars trio every generated store ships.
80
+
81
+ **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).
174
82
 
175
83
  ## Product page
176
84
 
177
85
  ```jsx
178
- import { useProduct, useProductGallery, useAddToCartButton, useStoreInfo, useStorefrontSeo, productSeo, variantAxes, productSpecs } from "@/commerce/storefront";
86
+ import { useProduct, useProductGallery, useAddToCartButton, useProductSpecs, useStoreInfo, useStorefrontSeo, productSeo, variantAxes } from "@/commerce/storefront";
179
87
  ```
180
88
 
181
- (Add `useProductReviews` only if the store has reviews; drop `productSpecs` if
182
- these products carry no modifiers.)
89
+ (Add `useProductReviews` only if the store has reviews; drop `useProductSpecs` if these products carry no modifiers.)
183
90
 
184
- `useProduct(slug)` (or `{ id }`) owns fetch + variant selection + quantity +
185
- price, race-safe, selection mirrored to the URL. ⚑ `status: "not_found"` is a
186
- 404 page, not a spinner.
187
-
188
- ⚑ **Call every hook above the status guards.** This page needs more than one,
189
- and a hook placed after an early `return` runs on some renders but not others —
190
- React then throws *"Rendered more hooks than during the previous render"* the
191
- moment the product resolves. All of these tolerate a null/loading product
192
- precisely so they can sit at the top:
91
+ `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.
193
92
 
194
93
  ```jsx
195
94
  const p = useProduct(slug);
196
95
  const g = useProductGallery(p.product, p.view);
197
- const buy = useAddToCartButton(p, { onAdded: () => navigate("/bag") });
96
+ const buy = useAddToCartButton(p, { labels: { ready: "Add to bag" } });
97
+ const specs = useProductSpecs(p.product, { pick: ["material", "care"] });
198
98
  const { settings } = useStoreInfo(); // the ONLY source of store name + currency
199
99
  useStorefrontSeo(productSeo(p.product, p.view, { storeName: settings?.store_name, currency: settings?.currency }));
200
100
 
@@ -203,24 +103,11 @@ if (p.status === "not_found") return /* your 404 */;
203
103
  const { product, view, price, categories } = p;
204
104
  ```
205
105
 
206
- (`variantAxes` and `productSpecs` are plain functions, not hooks they can go
207
- anywhere.) Build your layout from:
208
-
209
- - **Price** — `price.label`, plus `price.compareAtLabel` (struck through) when
210
- on sale. Never read `product.price` directly — the parent's price is a
211
- rolled-up from-price.
212
- - **Gallery** — `g` from above → `{ hasImages, images, active, activeIndex,
213
- setActiveIndex, next, prev }`. The active image already follows the variant
214
- selection; `hasImages: false` means render your placeholder.
215
- - **Variant selector** — `variantAxes(view, p.pick)` → one entry per axis:
216
- `{ key, name, selectedOption, options: [{ value, selected, disabled,
217
- outOfStock, pick }] }`. Map it to any control — buttons, swatches, a dropdown.
218
- ⚑ **One control per axis, never a list of variations** (`Red / S`, `Red / M`,
219
- … is n × m noise), and ⚑ **an unbuyable option renders `disabled`, never
220
- hidden** (`outOfStock` stays visible, just marked) — a customer who can't see
221
- that a size exists assumes the store doesn't carry it. `view.missingAxes`
222
- names what's still unpicked. The shape of the map (the one interaction agents
223
- reliably get wrong — the control itself is yours):
106
+ Build your layout from all optional, each one hook, **not one component style**:
107
+
108
+ - **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.
109
+ - **Gallery** — `g` `{ hasImages, images, active, activeIndex, setActiveIndex, next, prev }`. The active image follows the variant selection; `hasImages: false` means render your placeholder.
110
+ - **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. The shape of the map (the control itself is yours swatches for a colour axis, chips with a size guide for a size axis; every axis as the identical chip row is a generated-page tell):
224
111
 
225
112
  ```jsx
226
113
  {variantAxes(view, p.pick).map((axis) => (
@@ -233,121 +120,34 @@ anywhere.) Build your layout from:
233
120
  </fieldset>
234
121
  ))}
235
122
  ```
236
- - **Buy box** — `buy` from above → `{ add, adding, error, disabled, soldOut,
237
- needsSelection, quantity, increase, decrease, canIncrease, canDecrease,
238
- showQuantity }`. It gates on purchasability, recovers from every add failure
239
- and clamps quantity to stock and `sold_individually`. ⚑ Render `error.message`
240
- inline; ⚑ `showQuantity: false` means no stepper (only 1 can be bought); the
241
- button label should reflect `adding`/`soldOut`/`needsSelection` — the words
242
- are yours.
243
- - **Description** — `product.description` is HTML; render as rich text
244
- (`dangerouslySetInnerHTML`), `short_description` above it.
245
- - **Specs** — `productSpecs(product)` → `[{ key, label, value, type, number,
246
- unit, items }]` from `meta_data` (Material, Care, Provenance, Weight). `[]`
247
- means no section at all. ⚑ **Don't `.map()` it into one uniform label/value
248
- table** — that is the most reliable tell of a generated product page. Every
249
- row's `type` is inferred for you so the branch point is already there:
250
- `"numeric"` (with `number` and `unit` split out), `"duration"`, `"location"`,
251
- `"list"` (with `items`), `"text"`.
252
-
253
- ```jsx
254
- // ❌ what a generated store ships: one grey table, every store the same
255
- <dl>{specs.map((s) => <div key={s.key}><dt>{s.label}</dt><dd>{s.value}</dd></div>)}</dl>
256
-
257
- // ✅ the figures read as figures, the rest falls through to a plain row
258
- {productSpecs(product).map((s) =>
259
- s.type === "numeric" ? <Figure key={s.key} label={s.label} n={s.number} unit={s.unit} /> // 0.75 ct, set large
260
- : s.type === "list" ? <Bars key={s.key} parts={s.items} /> // 70% wool / 30% cashmere
261
- : s.type === "location" ? <Sourced key={s.key} place={s.value} /> // a located line, a pin
262
- : <Row key={s.key} label={s.label} value={s.value} />)}
263
- ```
264
123
 
265
- Design the two or three that carry *this* product's meaning; branch on `s.key`
266
- instead when one particular modifier deserves its own treatment. And they need
267
- not sit in one blocka spec can go under the gallery, beside the price, or
268
- inside the description.
269
- - **Breadcrumbs** — build from `categories`
270
- (`/collection?category_id=${c.id}`); skip on a flat catalog. Ribbons are
271
- labels, not breadcrumbs.
272
-
273
- All optional — include what this store's products actually have — and each is
274
- one hook, **not one component style**. The same rule the specs bullet states
275
- applies to the axes: `variantAxes` gives you `axis.key` / `axis.name` so a
276
- colour axis can be swatches in the real colours, a size axis chips with a size
277
- guide beside them, a material axis a small sample image — every axis rendered as
278
- the identical chip row is the other half of the generated-page tell. The ⚑ rules
279
- above (one control per axis, unbuyable options disabled) constrain the
280
- *behaviour* of a selector, never its form.
124
+ - **Buy box** — `<button {...buy.buttonProps}>{buy.label}</button>` is the whole button: gate, busy state and the ready/adding/sold-out/needs-selection precedence are inside (`buy.state`; override copy via `labels`). ⚑ 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.
125
+ - **Description** `product.description` is HTML; render as rich text, `short_description` above it.
126
+ - **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. ⚑ **Don't `.map()` everything into one uniform label/value table** — branch on `type` (or `key`) for the two or three specs that carry *this* product's meaning; `[]` means no section at all.
127
+ - **Breadcrumbs** — build from `categories` (`/collection?category_id=${c.id}`); skip on a flat catalog. Ribbons are labels, not breadcrumbs.
281
128
 
282
129
  ### Reviews — optional
283
130
 
284
- **Build reviews only if the store wants them** — because the brief asks, or the
285
- products are the kind customers rate. No review UI means no reviews, and that
286
- is a complete outcome. (If you skip them, don't put star ratings on cards
287
- either — an average of nothing is `0`.)
288
-
289
- `useProductReviews(product, { policy, user })` is the whole surface: `items`,
290
- paging (`hasNext`/`loadMore`), `averageRating`/`ratingCount`, and the submit
291
- form — `form`/`setField`/`fieldErrors` (matching the server's error codes),
292
- `valid`, `submit`, `requiresEmail` (false for a signed-in visitor),
293
- `reviewBlockedReason` (`"login_required"` / `"not_a_buyer"` under the stricter
294
- policies). ⚑ The confirmation copy is `message`, **taken from the server's
295
- response** — a store with auto-approval on says "published", not "awaiting
296
- approval", so render `message`, never your own text. `policy` is
297
- `"open" | "login" | "verified_buyers"`. Details beyond this:
298
- [`../references/reviews.md`](../references/reviews.md).
131
+ **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).
299
132
 
300
133
  ## Cart / bag
301
134
 
302
135
  ```jsx
303
- import { useCart, CartLine, useTotalsLines, useCoupon, useFormatMoney } from "@/commerce/storefront";
136
+ import { useCart, CartLine, useTotalsLines, useCoupon, useCartUI, useUpsell } from "@/commerce/storefront";
304
137
  ```
305
138
 
306
- A cart *page* is optional: a store selling one made-to-order piece reads better
307
- as buy-now straight to checkout. The surface is four hooks: `useCart()`
308
- (`status`, `lines`, `notices`), `CartLine` (headless render-prop binding
309
- `useCartLine` per row — quantity stepping that clamps, coalesces and recovers),
310
- `useTotalsLines()`, `useCoupon()`.
311
-
312
- Rules: branch on `status`, never on emptiness while loading. Render
313
- `notices` — they say what auto-dropped from the cart and why. Render every
314
- non-`hidden` totals line rather than hardcoding subtotal/total — a hand-written
315
- summary omits discount and tax, then stops adding up the day a coupon or a tax
316
- rate exists. **A store with any coupons must have a coupon field** (here or in
317
- the checkout): coupons are admin-only data, redeemable only through a field the
318
- customer types into — if no field exists anywhere, don't seed coupons and don't
319
- write "use WELCOME10" in the copy.
320
-
321
- ⚑ **`pending` is one row's flag, and it stays up until that update finishes.**
322
- The window is not the click: `increase`/`decrease` set the optimistic number and
323
- start a 250ms debounce, `pending` goes true when the request leaves, and returns
324
- to false **only after the new cart view has landed** — so `pending === false`
325
- with no `error` means that row's quantity and the totals are settled, not just
326
- that a request returned. (`remove()` skips the debounce, `pending` immediately.)
327
- So **disable and mark only that row** — `disabled={l.pending}` on its own
328
- controls, `aria-busy` on the row — since `pending` says nothing about the other
329
- lines, and stalling the whole cart over one 250ms stepper reads as a broken page.
330
- And note **`status` never returns to `"loading"` for a mutation**: it settles
331
- once, on first load, then only moves between `"empty"` and `"ready"`. There is
332
- deliberately no cart-wide busy flag — `status` is the page's shape, `pending` is
333
- "did that change land".
334
-
335
- ⚑ **Repeated controls need unique accessible names.** A three-line cart renders
336
- three buttons named "Remove", and "+" or "×" alone names nothing at all — put the
337
- line in the label (``aria-label={`Remove ${line.name}`}``, likewise ±, and a
338
- drawer's close button). Identical or empty names are ambiguous to a screen reader
339
- and to anything driving the page by name.
340
-
341
- **Reference implementation** — read once for the wiring, then write your own
342
- page: the structure below is correct, the presentation is deliberately absent.
343
- Restyle, rearrange, split into your own components; the ⚑ rules are the part
344
- that must survive.
139
+ 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()`.
140
+
141
+ ⚑ 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.
142
+
143
+ ⚑ **`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.
144
+
145
+ **Reference wiring** structure correct, presentation deliberately absent; restyle and rearrange, keep the ⚑ rules:
345
146
 
346
147
  ```jsx
347
148
  function Bag() {
348
149
  const { status, lines, notices } = useCart();
349
150
  const totals = useTotalsLines();
350
- const formatMoney = useFormatMoney();
351
151
  if (status === "loading") return /* your loading state */;
352
152
  if (status === "empty") return /* your empty-bag state, linking back to the catalog */;
353
153
  return (
@@ -355,7 +155,7 @@ function Bag() {
355
155
  {notices.map((n, i) => <p key={i} role="status">{n.message}</p>)}
356
156
  {lines.map((line) => (
357
157
  <CartLine key={line.item_key} line={line}>
358
- {(l) => ( /* line: name, attributesLabel, image, total — l: the controls */
158
+ {(l) => ( /* line: name, attributesLabel, image, totalLabel — l: the controls */
359
159
  <li aria-busy={l.pending}> {/* busy scope is THIS row, never the cart */}
360
160
  {line.name} {line.attributesLabel}
361
161
  <button onClick={l.decrease} disabled={!l.canDecrease || l.pending}
@@ -365,15 +165,15 @@ function Bag() {
365
165
  aria-label={`Increase quantity of ${line.name}`}>+</button>
366
166
  <button onClick={l.remove} disabled={l.pending}
367
167
  aria-label={`Remove ${line.name}`}>Remove</button>
368
- {formatMoney(line.total)}
168
+ {l.totalLabel}
369
169
  {l.error && <p role="alert">{l.error.message}</p>}
370
170
  </li>
371
171
  )}
372
172
  </CartLine>
373
173
  ))}
374
174
  <CouponField /> {/* useCoupon: code/setCode, apply, applying, error, applied[] + remove */}
375
- {totals.filter((l) => !l.hidden).map((l) => (
376
- <div key={l.key}>{l.label} {l.formatted}</div> /* l.emphasis → the total row */
175
+ {totals.filter((t) => !t.hidden).map((t) => (
176
+ <div key={t.key}>{t.label} {t.formatted}</div> /* t.emphasis → the total row */
377
177
  ))}
378
178
  <Link to="/checkout">Checkout</Link>
379
179
  </>
@@ -381,69 +181,43 @@ function Bag() {
381
181
  }
382
182
  ```
383
183
 
384
- No shipping estimator here — checkout reprices shipping and tax from the
385
- address.
184
+ 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).
386
185
 
387
186
  ### If the cart is a drawer
388
187
 
389
- Same hooks, same rows — a drawer only adds what a cart *page* gets for free:
390
-
391
- **Close it on navigation.** A product link or "Checkout" inside the drawer
392
- changes the route with the drawer still open, leaving it hanging over the new
393
- page. One effect covers every link inside, so no link has to remember:
188
+ Same hooks, same rows. The drawer's *machinery* close-on-route-change, inert-when-closed, dialog semantics, Esc, focus, open-on-add — is `useCartUI`, and hand-writing any of it is how drawers break (invisible-but-clickable controls, a drawer hanging over the checkout):
394
189
 
395
190
  ```jsx
396
- const { pathname } = useLocation();
397
- useEffect(() => { setOpen(false); }, [pathname]);
191
+ function StoreLayout() { // inside <CartUIProvider> (see Setup)
192
+ const ui = useCartUI();
193
+ const { itemCount } = useCart();
194
+ return (<>
195
+ <header>… <button {...ui.triggerProps} className="…">Bag ({itemCount})</button></header>
196
+ <Outlet />
197
+ <div {...ui.overlayProps} className={ui.open ? "…" : "hidden"} />
198
+ <aside {...ui.panelProps} className={ui.open ? "… translate-x-0" : "… translate-x-full"}>
199
+ <button {...ui.closeButtonProps} className="…">×</button>
200
+ {/* your rows: useCart + CartLine, as above */}
201
+ </aside>
202
+ </>);
203
+ }
398
204
  ```
399
205
 
400
- **A closed drawer must be inert, not just invisible.** Hidden with `opacity` or
401
- `translate`, its buttons stay focusable and clickable — Tab walks into an
402
- invisible cart, and a click lands on a "Remove" nobody can see. Either don't
403
- render it (`{open && <Drawer/>}`) or, if it stays mounted for the transition, use
404
- `hidden` / `visibility: hidden` / `inert`. ⚑ Never `aria-hidden` alone: that
405
- hides it from a screen reader while leaving it fully clickable.
406
-
407
- **The backdrop is not the close button.** Both close the drawer; only one is a
408
- control. Name the visible button (`aria-label="Close cart"`) and leave the
409
- backdrop an unnamed overlay — `onClick={close}`, `aria-hidden="true"`, no tab
410
- stop. Two elements named "Close cart" are ambiguous to a screen reader and to
411
- anything driving the page; the keyboard's way out is Esc and the button.
206
+ Keep the panel mounted and animate with classes — `panelProps` makes it inert while closed, which is what an off-screen drawer needs and `aria-hidden` alone does not provide. The overlay is not a second close control; the named close button is `closeButtonProps`.
412
207
 
413
208
  ## Checkout
414
209
 
415
210
  ```jsx
416
- import { CheckoutProvider, useCheckoutContext, useCheckoutBlockers, useAddressForm, ShippingMethodPicker, PaymentMethodPicker, useCart, useTotalsLines, useCoupon, useFormatMoney } from "@/commerce/storefront";
211
+ import { CheckoutProvider, useCheckoutContext, usePlaceOrder, useAddressForm, ShippingMethodPicker, PaymentMethodPicker, useCart, useTotalsLines, useCoupon } from "@/commerce/storefront";
417
212
  ```
418
213
 
419
214
  (`useCoupon` only if the coupon field lives here rather than in the cart.)
420
215
 
421
- The state machine is `useCheckout`, shared across the page's regions by
422
- `CheckoutProvider` + `useCheckoutContext()`. It reprices shipping/tax from the
423
- address automatically (debounced, never on a half-typed address), derives the
424
- shipping and payment choices, gates the button (`canPlaceOrder` +
425
- `useCheckoutBlockers()` in words), and `placeOrder()` handles **both**
426
- navigations — online gateway → provider redirect, everything else →
427
- `/order-received`. Both are **full page loads** (`window.location.assign`),
428
- which is why the order-received page boots from the URL alone; pass
429
- `orderReceivedPath: null` and `navigate(orderReceivedUrl(result))` if you want
430
- a router transition instead. The address form comes from `useAddressForm(which)` as a
431
- field spec (`state` collected, country options never null); the two
432
- store-data choices come through the headless `ShippingMethodPicker` /
433
- `PaymentMethodPicker`, whose render props enumerate every branch.
434
-
435
- ⚑ Rules: handle every picker branch (they exist because every one occurs in a
436
- normal store); a single shipping or payment option still *shows* what it is —
437
- never a picker of one, never "nothing selected"; zero gateways → say checkout
438
- is unavailable instead of a dead button; keep each field's `autoComplete` (the
439
- spec provides it) and render `f.error` — "we don't ship there" arrives on the
440
- country field; show `orderError.message` and the blockers so the gate explains
441
- itself. ⚑ Payment methods, currency and countries come from `useStoreInfo()`
442
- only — `cart.payment_gateways` is always `undefined`, and a default store
443
- offers `offline` only ([`./03-data.md`](./03-data.md)).
444
-
445
- **Reference implementation** — the densest wiring in the storefront; read it,
446
- then build yours around it. Structure correct, presentation absent.
216
+ 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).
217
+
218
+ 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"; keep each field's spread props intact (they carry `autoComplete` and the error wiring — "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.
219
+
220
+ **Reference implementation** — the densest wiring in the storefront; read it, then build yours around it:
447
221
 
448
222
  ```jsx
449
223
  function Checkout() { // hooks read the context BELOW the provider
@@ -453,10 +227,10 @@ function Checkout() { // hooks read the context BELOW the provider
453
227
  function CheckoutForm() {
454
228
  const { status } = useCart();
455
229
  const checkout = useCheckoutContext();
456
- const blockers = useCheckoutBlockers();
457
- const formatMoney = useFormatMoney();
230
+ const order = usePlaceOrder();
231
+ if (order.stage === "submitted") return /* "order placed — taking you to your receipt…" */;
458
232
  if (status === "loading") return /* loading */;
459
- if (status === "empty") return /* "your bag is empty" — a checkout with nothing says so */;
233
+ if (status === "empty") return /* "your bag is empty" */;
460
234
  return (
461
235
  <>
462
236
  <AddressFields which="billing" />
@@ -468,30 +242,23 @@ function CheckoutForm() {
468
242
  {checkout.shipToDifferent && <AddressFields which="shipping" />}
469
243
 
470
244
  <ShippingMethodPicker>
471
- {({ status, methods, chosen, choose, mustChoose, syncing }) => (
472
- <fieldset>{/* syncing → subtle busy state; renders null for a virtual cart */}
473
- {status === "missing_address" && <p>Delivery options appear once your address is entered.</p>}
474
- {status === "none_available" && <p role="alert">We don't deliver to that address yet.</p>}
245
+ {({ hint, mustChoose, methods, chosen }) => (
246
+ <fieldset>{/* renders null for a virtual cart */}
247
+ {hint && <p role={hint.severity === "error" ? "alert" : "status"}>{hint.message}</p>}
475
248
  {mustChoose && methods.map((m) => (
476
- <label key={m.id}>
477
- <input type="radio" checked={chosen?.id === m.id} onChange={() => choose(m.id)} />
478
- {m.title} {formatMoney(m.cost)}
479
- </label>
249
+ <label key={m.id} {...m.labelProps}><input {...m.radioProps} /> {m.title} {m.costLabel}</label>
480
250
  ))}
481
- {!mustChoose && chosen && <p>{chosen.title} {formatMoney(chosen.cost)}</p>}
251
+ {!mustChoose && chosen && <p>{chosen.title} {chosen.costLabel}</p>}
482
252
  </fieldset>
483
253
  )}
484
254
  </ShippingMethodPicker>
485
255
 
486
256
  <PaymentMethodPicker>
487
- {({ gateways, value, select, selected, single }) => (
257
+ {({ hint, single, gateways, selected }) => (
488
258
  <fieldset>
489
- {gateways.length === 0 && <p role="alert">No payment method is available right now.</p>}
259
+ {hint && <p role="alert">{hint.message}</p>}
490
260
  {!single && gateways.map((g) => (
491
- <label key={g.slug}>
492
- <input type="radio" checked={value === g.slug} onChange={() => select(g.slug)} />
493
- {g.title} {g.description}
494
- </label>
261
+ <label key={g.slug} {...g.labelProps}><input {...g.radioProps} /> {g.title} {g.description}</label>
495
262
  ))}
496
263
  {single && selected && <p>{selected.title}</p>}
497
264
  </fieldset>
@@ -500,61 +267,48 @@ function CheckoutForm() {
500
267
 
501
268
  {/* summary: coupon field (if not in the cart) + useTotalsLines(), as in the cart page */}
502
269
 
503
- <button disabled={!checkout.canPlaceOrder || checkout.placing} onClick={() => checkout.placeOrder()}>
504
- {checkout.placing ? "Placing your order…" : "Place order"}
505
- </button>
506
- {checkout.orderError && <p role="alert">{checkout.orderError.message}</p>}
507
- {!checkout.canPlaceOrder && blockers.map((b) => <p key={b.code}>{b.message}</p>)}
270
+ <button {...order.buttonProps}>{order.label}</button>
271
+ {order.error && <p {...order.errorProps}>{order.error.message}</p>}
272
+ {!order.canPlaceOrder && order.blockers.map((b) => <p key={b.code}>{b.message}</p>)}
508
273
  </>
509
274
  );
510
275
  }
511
276
 
512
277
  function AddressFields({ which }) {
513
- const { fields, countriesLoading } = useAddressForm(which);
514
- return fields.map((f) => ( /* each field carries its own setter: f.set */
515
- <label key={f.key}>
516
- {f.label}{f.required && " *"}
517
- {f.type === "select" ? (
518
- <select value={f.value} onChange={(e) => f.set(e.target.value)} autoComplete={f.autoComplete}>
519
- <option value="">{f.key === "country" && countriesLoading ? "Loading…" : `Select ${f.label}`}</option>
278
+ const { fields } = useAddressForm(which);
279
+ return fields.map((f) => (
280
+ <div key={f.key}>
281
+ <label {...f.labelProps}>{f.label}{f.required && " *"}</label>
282
+ {f.isSelect ? (
283
+ <select {...f.selectProps}>
284
+ <option value="">{f.placeholder}</option>
520
285
  {f.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
521
286
  </select>
522
- ) : (
523
- <input type={f.type} value={f.value} required={f.required}
524
- onChange={(e) => f.set(e.target.value)} autoComplete={f.autoComplete} />
525
- )}
526
- {f.error && <span role="alert">{f.error}</span>}
527
- </label>
287
+ ) : <input {...f.inputProps} />}
288
+ {f.error && <span {...f.errorProps}>{f.error}</span>}
289
+ </div>
528
290
  ));
529
291
  }
530
292
  ```
531
293
 
294
+ ⚑ **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.
295
+
532
296
  ## Order received
533
297
 
534
298
  ```jsx
535
- import { useOrderReturn, useTotalsLines, useFormatMoney } from "@/commerce/storefront";
299
+ import { useOrderReturn, useTotalsLines } from "@/commerce/storefront";
536
300
  ```
537
301
 
538
- **Mandatory route** — every payment link returns here, and confirming is what
539
- marks a card order paid. `useOrderReturn()` is the whole page's logic: it reads
540
- `order_id`/`order_key` from the URL, verifies with the provider (idempotent),
541
- and marks the page noindex itself.
302
+ **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.
542
303
 
543
- ⚑ Rules: render all five states — never a blank page while `"loading"`, and a
544
- retry via `reload()` on `"error"`. ⚑ **Never drop `paymentInstructions`**: a
545
- manual/offline order settles outside the store, so these ARE how the store's
546
- default customer learns how to pay — render them whenever present, on any
547
- state. ⚑ An order's totals are **flat** — `order.total`; there is no
548
- `order.totals` (use `useTotalsLines(order)`).
304
+ ⚑ 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)`.
549
305
 
550
- **Reference implementation** — a receipt is a convention, not an identity
551
- surface: take this structure, restyle it to the store, keep every branch.
306
+ **Reference implementation** — a receipt is a convention, not an identity surface: take this structure, restyle it, keep every branch.
552
307
 
553
308
  ```jsx
554
309
  function OrderReceived() {
555
310
  const { status, order, lines, paymentLink, paymentInstructions, error, reload } = useOrderReturn();
556
311
  const totals = useTotalsLines(order);
557
- const formatMoney = useFormatMoney();
558
312
  if (status === "loading") return /* confirming copy */;
559
313
  if (status === "error") return <><p role="alert">{error.message}</p>
560
314
  <button onClick={() => reload()}>Try again</button></>;
@@ -573,7 +327,7 @@ function OrderReceived() {
573
327
  .map(([k, v]) => <p key={k}>{k.replace(/_/g, " ")}: {String(v)}</p>)}
574
328
  </section>
575
329
  )}
576
- {lines.map((l, i) => <p key={i}>{l.name} {l.attributesLabel} × {l.quantity} — {formatMoney(l.total)}</p>)}
330
+ {lines.map((l, i) => <p key={i}>{l.name} {l.attributesLabel} × {l.quantity} — {l.totalLabel}</p>)}
577
331
  {totals.filter((t) => !t.hidden).map((t) => <p key={t.key}>{t.label} {t.formatted}</p>)}
578
332
  </>
579
333
  );
@@ -588,87 +342,38 @@ useStorefrontSeo(collectionSeo({ title, products: list.products })); // col
588
342
  // order-received is already noindex via useOrderReturn
589
343
  ```
590
344
 
591
- The `*Seo` builders tolerate a null product, so this sits with the other hooks
592
- above the status guards.
345
+ The `*Seo` builders tolerate a null product, so this sits with the other hooks above the status guards.
593
346
 
594
347
  ## Per-page output budgets
595
348
 
596
- | Page | budget (chars) | rationale |
597
- |---|---|---|
598
- | Checkout | ≤ 5K | your markup over the reference above — the logic is all hook calls |
599
- | Cart / bag | ≤ 3K | `useCart` + `CartLine` rows + totals + coupon + empty state — a drawer is its own component with its own 3K, not an extension of this one |
600
- | Order-received | ≤ 2.5K | five states + payment instructions + summary |
601
- | Product page | ≤ 5K | your layout and type around `useProduct`, `variantAxes`, `useAddToCartButton`, the gallery |
602
- | Collection | ≤ 3K | `useProductList` + custom card + pagination controls |
603
- | Home | ≤ 5K | pure identity hero/editorial earn their chars |
604
- | Any single component file | ≤ 4K, hard ceiling 8K | Base1 evidence: decode is 34% of wall; a 12K file is a 45s write batch |
605
-
606
- These budgets assume the hooks carry the logic and your markup carries only the
607
- design. Over budget ⇒ you are re-implementing something a hook does — an
608
- address spec, a quantity clamp, totals math, variant resolution, add-to-cart
609
- error recovery. Go back to the hook and delete your version. Design detail is
610
- not what pushes a page over: giving a colour axis swatches or a composition
611
- modifier bars costs a few hundred characters, and that is what the budget is
612
- for.
613
-
614
- ## If you drive the storefront from a browser script
615
-
616
- Whatever you choose to check and however you check it, these are what make a
617
- working storefront look broken under a script. The common cause is acting
618
- faster than the cart settles: the hooks are optimistic and debounced, so the DOM
619
- is briefly right about the *intent* and wrong about the *state*.
620
-
621
- - **Wait for the cart, then for each row.** Two waits, neither optional. Before
622
- the first action, wait for the initial load to settle — `status` leaves
623
- `"loading"` exactly once, so the signal is the loaded UI (a row, or the empty
624
- state), never a fixed sleep. Then after every stepper click wait for **that
625
- row**: the click starts a 250ms debounce before the request even leaves, so
626
- reading the quantity or total straight after gives the optimistic number and
627
- stale totals, and two quick clicks send **one** request for the final number.
628
- Wait for the row's busy state to clear (`aria-busy`, re-enabled buttons) before
629
- reading or clicking again.
630
- - **Scope actions to the visible drawer.** With a drawer, the page can hold two
631
- "Remove" buttons for one line — drawer and cart page behind it — and a
632
- closed-but-mounted drawer keeps its copies clickable. Query inside the open
633
- drawer's container, not the document. A click that seems to do nothing usually
634
- hit the hidden copy.
635
- - **Remove lines one at a time.** Clicking every "Remove" in one pass fails on
636
- its own terms: cart calls are serialized, each removal re-renders the list, and
637
- buttons collected up front are detached by the time the loop reaches them.
638
- Remove one, wait for the row to disappear, then the next.
639
- - **Verify the checkout navigation before cleaning up.** Confirm you are on
640
- `/checkout` — URL plus a field of the form on screen — before emptying the cart
641
- or moving on. Tearing the cart down while still on the cart page, or
642
- mid-navigation, produces an empty checkout that reads as a routing bug.
643
- - **Filling the checkout.** Every field is a controlled React input, so writing
644
- `el.value` changes nothing React sees. Use the harness's own fill (it
645
- dispatches `input` + `change`) — never lift the native setter off
646
- `HTMLInputElement.prototype` and call `descriptor.set(v)`: detached from the
647
- element it throws `Illegal invocation`, and the workaround it is reaching for
648
- is what the fill helper already does.
649
- - **`placeOrder` ends the page.** It navigates with `window.location.assign`
650
- (above), so a script that placed an order loses its page context and can land
651
- back at `/` — while the order itself was created normally. That is the hard
652
- navigation, not a broken redirect. The confirmation is reachable at any time
653
- from a fresh navigation to `/order-received?order_id=…&order_key=…` (the ids
654
- come back in `placeOrder`'s result, and `commerce/admin-orders` `search` has
655
- the order either way).
349
+ | Page | budget (chars) |
350
+ |---|---|
351
+ | Checkout | ≤ 3.5K |
352
+ | Cart / bag | ≤ 3K (a drawer is its own component with its own 3K) |
353
+ | Order-received | ≤ 2.5K |
354
+ | Product page | ≤ 5K |
355
+ | Collection | ≤ 3K |
356
+ | Home | ≤ 5K pure identity; hero/editorial earn their chars |
357
+ | Any single component file | ≤ 4K, hard ceiling 8K |
358
+
359
+ These budgets assume the hooks carry the logic and your markup carries only the design. Over budget ⇒ you are re-implementing something a hook or a prop set already does (an address field's attributes, a quantity clamp, totals math, drawer state) — go back to the hook and delete your version. Design detail is not what pushes a page over.
360
+
361
+ ## Driving the storefront from a browser script?
362
+
363
+ 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.
656
364
 
657
365
  ## Done — forget this file
658
366
 
659
- - [ ] Catalog UI exists in whatever form fits the store (list, product pages, or both), plus a checkout, plus `/order-received`.
660
- - [ ] **One** `<StorefrontProvider>` above every storefront route — on the layout route's element, wrapping the layout that renders `<Outlet/>` (or wrapping `<Routes>` if the store has no shared chrome); one client, no hand-rolled `cart_token`.
661
- - [ ] Every page's imports came from its section's import line above: one path (`@/commerce/storefront`), nothing imported from `@/commerce/utils`, and no imported name — React's included — left unused.
662
- - [ ] Pages branch on `status`; no page maps a possibly-null list or shows an empty state while loading.
663
- - [ ] Gateways/currency/countries read from `useStoreInfo()` only.
664
- - [ ] If the store has coupons, a coupon field (`useCoupon`) exists in the cart or the checkout.
665
- - [ ] `/order-received` renders `useOrderReturn`'s states **including `paymentInstructions`**.
666
- - [ ] Variant options render one control per axis; unbuyable options are disabled, not hidden.
667
- - [ ] Cart rows scope their busy state to the row (`l.pending` + `aria-busy`), and every repeated control (remove, ±, a drawer's close) has a unique accessible name.
668
- - [ ] A cart drawer closes on route change, is **inert** when closed (not merely invisible), and its backdrop is not a second control named "Close cart".
669
- - [ ] No hook logic was re-implemented by hand (address fields, quantity clamps, totals rows, shipping/payment branching).
670
- - [ ] The storefront carries the design you settled on before reading this file — no page ships the reference snippets' bare structure or placeholder copy.
671
- - [ ] Specs and axes are rendered by what they are (`productSpecs`' `type`/`key`, `axis.key`) — not one uniform label/value table and one identical chip row.
367
+ - [ ] Catalog UI in whatever form fits the store, plus a checkout, plus `/order-received` rendering `useOrderReturn`'s states **including `paymentInstructions`**.
368
+ - [ ] **One** `<StorefrontProvider>` above every storefront route (layout-route pattern); one client, no hand-rolled `cart_token`.
369
+ - [ ] Every page's imports came from its section's import line; nothing imported from `@/commerce/utils`; no unused names.
370
+ - [ ] Pages branch on `status`; gateways/currency/countries read from `useStoreInfo()` only.
371
+ - [ ] Prop sets spread wherever one exists — no hand-assembled `value`/`onChange`/`autoComplete`/radio/drawer wiring, no re-implemented hook logic.
372
+ - [ ] Coupon field present if the store has coupons; paging rendered via `moreProps`.
373
+ - [ ] Variant options: one control per axis, unbuyable options disabled, not hidden.
374
+ - [ ] Cart rows scope busy state to the row; repeated controls have unique accessible names; a drawer uses `useCartUI` (inert when closed, closes on route change).
375
+ - [ ] Checkout guards `stage === "submitted"` above its empty-cart branch.
376
+ - [ ] The storefront carries the design you settled on before reading this file no page ships the reference snippets' bare structure; specs and axes are rendered by what they are, not one uniform table and one identical chip row.
672
377
  - [ ] Every page is within its budget above.
673
378
 
674
379
  Record these lines in your working notes; do not re-read this file.
@@ -679,3 +384,4 @@ Record these lines in your working notes; do not re-read this file.
679
384
  - Branch cart/list/product UI on `status`, never on `isEmpty`/nullable data — `isEmpty` is false while loading by design.
680
385
  - Variant options that aren't buyable render disabled, never hidden; one control per axis, never a list of variations.
681
386
  - 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".
387
+ - Spread the hook's prop set (`inputProps`, `radioProps`, `buttonProps`, `panelProps`, `moreProps`) and add `className` — never assemble those attributes by hand.