@base44/app-plugin-commerce 0.2.4 → 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,165 +10,104 @@ 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. Everything imports from
58
- `@/commerce/storefront`.
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.)
59
26
 
60
27
  ## Setup — once
61
28
 
29
+ Nearly every store has shared chrome, so **start from a pathless layout route** — it also keeps the admin outside the storefront's provider:
30
+
62
31
  ```jsx
63
- import { StorefrontProvider } from "@/commerce/storefront";
32
+ import { Routes, Route, Outlet } from "react-router-dom";
33
+ import { StorefrontProvider, CartUIProvider } from "@/commerce/storefront";
64
34
  import { base44 } from "@/api/base44Client";
35
+ import AdminApp from "@/commerce/admin";
65
36
 
66
37
  <BrowserRouter>
67
- <StorefrontProvider base44={base44}> {/* wraps <Routes> — never a child of it */}
68
- <Routes> {/* ONE <Routes> — merge new pages into the app's */}
38
+ <Routes> {/* ONE <Routes> — merge new pages into the app's */}
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
+ }>
69
46
  <Route path="/" element={<Home />} />
70
47
  <Route path="/product/:slug" element={<ProductPage />} />
71
48
  <Route path="/bag" element={<Bag />} />
72
49
  <Route path="/checkout" element={<Checkout />} />
73
50
  <Route path="/order-received" element={<OrderReceived />} />
74
- <Route path="/store-admin/*" element={<AdminApp />} />
75
- </Routes>
76
- </StorefrontProvider>
51
+ </Route>
52
+ <Route path="/store-admin/*" element={<AdminApp />} /> {/* own chrome, outside the provider */}
53
+ </Routes>
77
54
  </BrowserRouter>
78
55
  ```
79
56
 
80
- The provider owns the shared client, the store-info cache and **one** shared
81
- cart, so a header badge, a drawer and the checkout render the same state. Never
82
- mount a second provider, and never touch the `cart_token` — the provider owns
83
- its whole lifecycle.
84
-
85
- > ⚠ **`<Routes>` accepts only `<Route>` children.** Nesting the provider inside
86
- > it — the natural reading of "wrap the storefront routes" — throws at render:
87
- > `Error: [StorefrontProvider] is not a <Route> component. All component children of <Routes> must be a <Route> or <React.Fragment>`.
88
- > To scope the provider to storefront routes only, use a pathless **layout
89
- > route**, the one place a wrapper is legal:
90
- > ```jsx
91
- > <Route element={<StorefrontProvider base44={base44}><Outlet /></StorefrontProvider>}>
92
- > <Route path="/" element={<Home />} />
93
- > <Route path="/checkout" element={<Checkout />} />
94
- > </Route>
95
- > <Route path="/store-admin/*" element={<AdminApp />} /> {/* outside the provider */}
96
- > ```
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`.
97
58
 
98
59
  ## Product list / collection
99
60
 
100
- `useProductList(params)` → `{ status, products, hasNext, next, refreshing,
101
- setParams, reload }`. `status` is `"loading" | "ready" | "empty" | "error"`
102
- — branch on it, so a failed request renders as a failure instead of an empty
103
- grid. `setParams({ category_id, search, on_sale, min_price, in_stock_only })`
104
- resets to page 1 and keeps the current rows on screen (`refreshing`) while the
105
- page loads. `useCategories()` / `useRibbons()` → `{ items }` (arrays, children
106
- nested).
107
-
108
- Your card can render `name`, `images[0]?.src` (⚑ **images are `{src,name,alt}`
109
- objects and the array may be empty — render a placeholder, never a broken
110
- `<img>`**), `useProductPrice(row).label` (already "From €19.99" when the
111
- product sells variants there is no product `type` flag), `on_sale`,
112
- `short_description`, `stock_status`, `average_rating`/`rating_count`,
113
- `ribbons` and a row carries the whole product record, so `weight`,
114
- `dimensions`, `attributes[]` and `meta_data` are there too. Full field matrix:
115
- [`../references/catalog-rendering.md`](../references/catalog-rendering.md).
116
-
117
- That is an inventory of what you *can* show, not a card design and not a list
118
- to render in order. An even grid of identical cards, each with the same
119
- name/price/stars trio, is where a generated store lands by default and almost
120
- never where this catalog belongs: give the grid a rhythm (a hero piece spanning
121
- two columns, an editorial break between rows, a denser tile for a large
122
- catalog), and lead each card with the one or two fields *these* products are
123
- judged on — carat weight, focal length, edition size, ABV — read off
124
- `meta_data` via `productSpecs(row)`, not the fields every store shows.
125
-
126
- **Rails** (featured row, "new in") are the same hook with a filter
127
- (`{ featured: true, per_page: 4 }`). ⚑ Any filter may legitimately match
128
- nothing — render *nothing* then, never a heading over an empty row. Upsells
129
- beside a product need no query: `p.upsells` / `p.crossSells` from `useProduct`.
61
+ ```jsx
62
+ import { useProductList, useCategories, useProductPrice, useStoreInfo, useStorefrontSeo, collectionSeo, productSpecs } from "@/commerce/storefront";
63
+ ```
64
+
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:
66
+
67
+ ```jsx
68
+ const list = useProductList({ per_page: 24 });
69
+ const { settings } = useStoreInfo();
70
+ useStorefrontSeo(collectionSeo({ title: "…", products: list.products, storeName: settings?.store_name }));
71
+
72
+ if (list.status === "loading") return /* your loading state */;
73
+ if (list.status === "error") return /* your failure state, with a retry calling list.reload() */;
74
+ const products = list.products; // always an array never null
75
+ ```
76
+
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).
130
82
 
131
83
  ## Product page
132
84
 
133
- `useProduct(slug)` (or `{ id }`) owns fetch + variant selection + quantity +
134
- price, race-safe, selection mirrored to the URL. `status: "not_found"` is a
135
- 404 page, not a spinner.
85
+ ```jsx
86
+ import { useProduct, useProductGallery, useAddToCartButton, useProductSpecs, useStoreInfo, useStorefrontSeo, productSeo, variantAxes } from "@/commerce/storefront";
87
+ ```
88
+
89
+ (Add `useProductReviews` only if the store has reviews; drop `useProductSpecs` if these products carry no modifiers.)
136
90
 
137
- ⚑ **Call every hook above the status guards.** This page needs more than one,
138
- and a hook placed after an early `return` runs on some renders but not others —
139
- React then throws *"Rendered more hooks than during the previous render"* the
140
- moment the product resolves. All of these tolerate a null/loading product
141
- 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.
142
92
 
143
93
  ```jsx
144
94
  const p = useProduct(slug);
145
95
  const g = useProductGallery(p.product, p.view);
146
- const buy = useAddToCartButton(p, { onAdded: () => navigate("/bag") });
147
- useStorefrontSeo(productSeo(p.product, p.view, { storeName, currency }));
96
+ const buy = useAddToCartButton(p, { labels: { ready: "Add to bag" } });
97
+ const specs = useProductSpecs(p.product, { pick: ["material", "care"] });
98
+ const { settings } = useStoreInfo(); // the ONLY source of store name + currency
99
+ useStorefrontSeo(productSeo(p.product, p.view, { storeName: settings?.store_name, currency: settings?.currency }));
148
100
 
149
101
  if (p.status === "loading") return /* your loading state */;
150
102
  if (p.status === "not_found") return /* your 404 */;
151
103
  const { product, view, price, categories } = p;
152
104
  ```
153
105
 
154
- (`variantAxes` and `productSpecs` are plain functions, not hooks they can go
155
- anywhere.) Build your layout from:
156
-
157
- - **Price** — `price.label`, plus `price.compareAtLabel` (struck through) when
158
- on sale. Never read `product.price` directly — the parent's price is a
159
- rolled-up from-price.
160
- - **Gallery** — `g` from above → `{ hasImages, images, active, activeIndex,
161
- setActiveIndex, next, prev }`. The active image already follows the variant
162
- selection; `hasImages: false` means render your placeholder.
163
- - **Variant selector** — `variantAxes(view, p.pick)` → one entry per axis:
164
- `{ key, name, selectedOption, options: [{ value, selected, disabled,
165
- outOfStock, pick }] }`. Map it to any control — buttons, swatches, a dropdown.
166
- ⚑ **One control per axis, never a list of variations** (`Red / S`, `Red / M`,
167
- … is n × m noise), and ⚑ **an unbuyable option renders `disabled`, never
168
- hidden** (`outOfStock` stays visible, just marked) — a customer who can't see
169
- that a size exists assumes the store doesn't carry it. `view.missingAxes`
170
- names what's still unpicked. The shape of the map (the one interaction agents
171
- 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):
172
111
 
173
112
  ```jsx
174
113
  {variantAxes(view, p.pick).map((axis) => (
@@ -181,80 +120,34 @@ anywhere.) Build your layout from:
181
120
  </fieldset>
182
121
  ))}
183
122
  ```
184
- - **Buy box** — `buy` from above → `{ add, adding, error, disabled, soldOut,
185
- needsSelection, quantity, increase, decrease, canIncrease, canDecrease,
186
- showQuantity }`. It gates on purchasability, recovers from every add failure
187
- and clamps quantity to stock and `sold_individually`.Render `error.message`
188
- inline; `showQuantity: false` means no stepper (only 1 can be bought); the
189
- button label should reflect `adding`/`soldOut`/`needsSelection` — the words
190
- are yours.
191
- - **Description** — `product.description` is HTML; render as rich text
192
- (`dangerouslySetInnerHTML`), `short_description` above it.
193
- - **Specs** — `productSpecs(product)` → `[{ key, label, value }]` from
194
- `meta_data` (Material, Care, Provenance). `[]` means no section at all.
195
- - **Breadcrumbs** — build from `categories`
196
- (`/collection?category_id=${c.id}`); skip on a flat catalog. Ribbons are
197
- labels, not breadcrumbs.
198
-
199
- All optional — include what this store's products actually have — and each is
200
- one hook, **not one component style**. The tell of a generated product page is
201
- that every axis is the same chip row and every modifier the same grey
202
- label/value line. Branch on what they are: `variantAxes` gives you `axis.key` /
203
- `axis.name`, `productSpecs` gives you `key` / `label`, so a colour axis can be
204
- swatches in the real colours, a size axis chips with a size guide beside them,
205
- a material axis a small sample image; a "Composition" modifier can be bars, a
206
- "Provenance" a map pin, a "Certification" a seal, a "Weight" a figure set in
207
- the display face. Design the two or three that carry this product's meaning,
208
- let the rest fall back to a plain row, and don't feel obliged to keep them in
209
- one block — a spec can sit under the gallery, beside the price, or inside the
210
- description. The ⚑ rules above (one control per axis, unbuyable options
211
- disabled) constrain the *behaviour* of a selector, never its form.
123
+
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.
212
128
 
213
129
  ### Reviews — optional
214
130
 
215
- **Build reviews only if the store wants them** — because the brief asks, or the
216
- products are the kind customers rate. No review UI means no reviews, and that
217
- is a complete outcome. (If you skip them, don't put star ratings on cards
218
- either — an average of nothing is `0`.)
219
-
220
- `useProductReviews(product, { policy, user })` is the whole surface: `items`,
221
- paging (`hasNext`/`loadMore`), `averageRating`/`ratingCount`, and the submit
222
- form — `form`/`setField`/`fieldErrors` (matching the server's error codes),
223
- `valid`, `submit`, `requiresEmail` (false for a signed-in visitor),
224
- `reviewBlockedReason` (`"login_required"` / `"not_a_buyer"` under the stricter
225
- policies). ⚑ The confirmation copy is `message`, **taken from the server's
226
- response** — a store with auto-approval on says "published", not "awaiting
227
- approval", so render `message`, never your own text. `policy` is
228
- `"open" | "login" | "verified_buyers"`. Details beyond this:
229
- [`../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).
230
132
 
231
133
  ## Cart / bag
232
134
 
233
- A cart *page* is optional: a store selling one made-to-order piece reads better
234
- as buy-now straight to checkout. The surface is four hooks: `useCart()`
235
- (`status`, `lines`, `notices`), `CartLine` (headless render-prop binding
236
- `useCartLine` per row — quantity stepping that clamps, coalesces and recovers),
237
- `useTotalsLines()`, `useCoupon()`.
238
-
239
- ⚑ Rules: branch on `status`, never on emptiness while loading. Render
240
- `notices` — they say what auto-dropped from the cart and why. Render every
241
- non-`hidden` totals line rather than hardcoding subtotal/total — a hand-written
242
- summary omits discount and tax, then stops adding up the day a coupon or a tax
243
- rate exists. **A store with any coupons must have a coupon field** (here or in
244
- the checkout): coupons are admin-only data, redeemable only through a field the
245
- customer types into — if no field exists anywhere, don't seed coupons and don't
246
- write "use WELCOME10" in the copy.
247
-
248
- **Reference implementation** — read once for the wiring, then write your own
249
- page: the structure below is correct, the presentation is deliberately absent.
250
- Restyle, rearrange, split into your own components; the ⚑ rules are the part
251
- that must survive.
135
+ ```jsx
136
+ import { useCart, CartLine, useTotalsLines, useCoupon, useCartUI, useUpsell } from "@/commerce/storefront";
137
+ ```
138
+
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:
252
146
 
253
147
  ```jsx
254
148
  function Bag() {
255
149
  const { status, lines, notices } = useCart();
256
150
  const totals = useTotalsLines();
257
- const formatMoney = useFormatMoney();
258
151
  if (status === "loading") return /* your loading state */;
259
152
  if (status === "empty") return /* your empty-bag state, linking back to the catalog */;
260
153
  return (
@@ -262,22 +155,25 @@ function Bag() {
262
155
  {notices.map((n, i) => <p key={i} role="status">{n.message}</p>)}
263
156
  {lines.map((line) => (
264
157
  <CartLine key={line.item_key} line={line}>
265
- {(l) => ( /* line: name, attributesLabel, image, total — l: the controls */
266
- <li>
158
+ {(l) => ( /* line: name, attributesLabel, image, totalLabel — l: the controls */
159
+ <li aria-busy={l.pending}> {/* busy scope is THIS row, never the cart */}
267
160
  {line.name} {line.attributesLabel}
268
- <button onClick={l.decrease} disabled={!l.canDecrease || l.pending}>−</button>
161
+ <button onClick={l.decrease} disabled={!l.canDecrease || l.pending}
162
+ aria-label={`Decrease quantity of ${line.name}`}>−</button>
269
163
  {l.quantity}
270
- <button onClick={l.increase} disabled={!l.canIncrease || l.pending}>+</button>
271
- <button onClick={l.remove}>Remove</button>
272
- {formatMoney(line.total)}
164
+ <button onClick={l.increase} disabled={!l.canIncrease || l.pending}
165
+ aria-label={`Increase quantity of ${line.name}`}>+</button>
166
+ <button onClick={l.remove} disabled={l.pending}
167
+ aria-label={`Remove ${line.name}`}>Remove</button>
168
+ {l.totalLabel}
273
169
  {l.error && <p role="alert">{l.error.message}</p>}
274
170
  </li>
275
171
  )}
276
172
  </CartLine>
277
173
  ))}
278
174
  <CouponField /> {/* useCoupon: code/setCode, apply, applying, error, applied[] + remove */}
279
- {totals.filter((l) => !l.hidden).map((l) => (
280
- <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 */
281
177
  ))}
282
178
  <Link to="/checkout">Checkout</Link>
283
179
  </>
@@ -285,37 +181,43 @@ function Bag() {
285
181
  }
286
182
  ```
287
183
 
288
- No shipping estimator here — checkout reprices shipping and tax from the
289
- 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).
185
+
186
+ ### If the cart is a drawer
187
+
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):
189
+
190
+ ```jsx
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
+ }
204
+ ```
205
+
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`.
290
207
 
291
208
  ## Checkout
292
209
 
293
- The state machine is `useCheckout`, shared across the page's regions by
294
- `CheckoutProvider` + `useCheckoutContext()`. It reprices shipping/tax from the
295
- address automatically (debounced, never on a half-typed address), derives the
296
- shipping and payment choices, gates the button (`canPlaceOrder` +
297
- `useCheckoutBlockers()` in words), and `placeOrder()` handles **both**
298
- navigations — online gateway → provider redirect, everything else →
299
- `/order-received`. Both are **full page loads** (`window.location.assign`),
300
- which is why the order-received page boots from the URL alone; pass
301
- `orderReceivedPath: null` and `navigate(orderReceivedUrl(result))` if you want
302
- a router transition instead. The address form comes from `useAddressForm(which)` as a
303
- field spec (`state` collected, country options never null); the two
304
- store-data choices come through the headless `ShippingMethodPicker` /
305
- `PaymentMethodPicker`, whose render props enumerate every branch.
306
-
307
- ⚑ Rules: handle every picker branch (they exist because every one occurs in a
308
- normal store); a single shipping or payment option still *shows* what it is —
309
- never a picker of one, never "nothing selected"; zero gateways → say checkout
310
- is unavailable instead of a dead button; keep each field's `autoComplete` (the
311
- spec provides it) and render `f.error` — "we don't ship there" arrives on the
312
- country field; show `orderError.message` and the blockers so the gate explains
313
- itself. ⚑ Payment methods, currency and countries come from `useStoreInfo()`
314
- only — `cart.payment_gateways` is always `undefined`, and a default store
315
- offers `offline` only ([`./03-data.md`](./03-data.md)).
316
-
317
- **Reference implementation** — the densest wiring in the storefront; read it,
318
- then build yours around it. Structure correct, presentation absent.
210
+ ```jsx
211
+ import { CheckoutProvider, useCheckoutContext, usePlaceOrder, useAddressForm, ShippingMethodPicker, PaymentMethodPicker, useCart, useTotalsLines, useCoupon } from "@/commerce/storefront";
212
+ ```
213
+
214
+ (`useCoupon` only if the coupon field lives here rather than in the cart.)
215
+
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:
319
221
 
320
222
  ```jsx
321
223
  function Checkout() { // hooks read the context BELOW the provider
@@ -325,10 +227,10 @@ function Checkout() { // hooks read the context BELOW the provider
325
227
  function CheckoutForm() {
326
228
  const { status } = useCart();
327
229
  const checkout = useCheckoutContext();
328
- const blockers = useCheckoutBlockers();
329
- const formatMoney = useFormatMoney();
230
+ const order = usePlaceOrder();
231
+ if (order.stage === "submitted") return /* "order placed — taking you to your receipt…" */;
330
232
  if (status === "loading") return /* loading */;
331
- if (status === "empty") return /* "your bag is empty" — a checkout with nothing says so */;
233
+ if (status === "empty") return /* "your bag is empty" */;
332
234
  return (
333
235
  <>
334
236
  <AddressFields which="billing" />
@@ -340,30 +242,23 @@ function CheckoutForm() {
340
242
  {checkout.shipToDifferent && <AddressFields which="shipping" />}
341
243
 
342
244
  <ShippingMethodPicker>
343
- {({ status, methods, chosen, choose, mustChoose, syncing }) => (
344
- <fieldset>{/* syncing → subtle busy state; renders null for a virtual cart */}
345
- {status === "missing_address" && <p>Delivery options appear once your address is entered.</p>}
346
- {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>}
347
248
  {mustChoose && methods.map((m) => (
348
- <label key={m.id}>
349
- <input type="radio" checked={chosen?.id === m.id} onChange={() => choose(m.id)} />
350
- {m.title} {formatMoney(m.cost)}
351
- </label>
249
+ <label key={m.id} {...m.labelProps}><input {...m.radioProps} /> {m.title} {m.costLabel}</label>
352
250
  ))}
353
- {!mustChoose && chosen && <p>{chosen.title} {formatMoney(chosen.cost)}</p>}
251
+ {!mustChoose && chosen && <p>{chosen.title} {chosen.costLabel}</p>}
354
252
  </fieldset>
355
253
  )}
356
254
  </ShippingMethodPicker>
357
255
 
358
256
  <PaymentMethodPicker>
359
- {({ gateways, value, select, selected, single }) => (
257
+ {({ hint, single, gateways, selected }) => (
360
258
  <fieldset>
361
- {gateways.length === 0 && <p role="alert">No payment method is available right now.</p>}
259
+ {hint && <p role="alert">{hint.message}</p>}
362
260
  {!single && gateways.map((g) => (
363
- <label key={g.slug}>
364
- <input type="radio" checked={value === g.slug} onChange={() => select(g.slug)} />
365
- {g.title} {g.description}
366
- </label>
261
+ <label key={g.slug} {...g.labelProps}><input {...g.radioProps} /> {g.title} {g.description}</label>
367
262
  ))}
368
263
  {single && selected && <p>{selected.title}</p>}
369
264
  </fieldset>
@@ -372,57 +267,48 @@ function CheckoutForm() {
372
267
 
373
268
  {/* summary: coupon field (if not in the cart) + useTotalsLines(), as in the cart page */}
374
269
 
375
- <button disabled={!checkout.canPlaceOrder || checkout.placing} onClick={() => checkout.placeOrder()}>
376
- {checkout.placing ? "Placing your order…" : "Place order"}
377
- </button>
378
- {checkout.orderError && <p role="alert">{checkout.orderError.message}</p>}
379
- {!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>)}
380
273
  </>
381
274
  );
382
275
  }
383
276
 
384
277
  function AddressFields({ which }) {
385
- const { fields, countriesLoading } = useAddressForm(which);
386
- return fields.map((f) => ( /* each field carries its own setter: f.set */
387
- <label key={f.key}>
388
- {f.label}{f.required && " *"}
389
- {f.type === "select" ? (
390
- <select value={f.value} onChange={(e) => f.set(e.target.value)} autoComplete={f.autoComplete}>
391
- <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>
392
285
  {f.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
393
286
  </select>
394
- ) : (
395
- <input type={f.type} value={f.value} required={f.required}
396
- onChange={(e) => f.set(e.target.value)} autoComplete={f.autoComplete} />
397
- )}
398
- {f.error && <span role="alert">{f.error}</span>}
399
- </label>
287
+ ) : <input {...f.inputProps} />}
288
+ {f.error && <span {...f.errorProps}>{f.error}</span>}
289
+ </div>
400
290
  ));
401
291
  }
402
292
  ```
403
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
+
404
296
  ## Order received
405
297
 
406
- **Mandatory route** — every payment link returns here, and confirming is what
407
- marks a card order paid. `useOrderReturn()` is the whole page's logic: it reads
408
- `order_id`/`order_key` from the URL, verifies with the provider (idempotent),
409
- and marks the page noindex itself.
298
+ ```jsx
299
+ import { useOrderReturn, useTotalsLines } from "@/commerce/storefront";
300
+ ```
301
+
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.
410
303
 
411
- ⚑ Rules: render all five states — never a blank page while `"loading"`, and a
412
- retry via `reload()` on `"error"`. ⚑ **Never drop `paymentInstructions`**: a
413
- manual/offline order settles outside the store, so these ARE how the store's
414
- default customer learns how to pay — render them whenever present, on any
415
- state. ⚑ An order's totals are **flat** — `order.total`; there is no
416
- `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)`.
417
305
 
418
- **Reference implementation** — a receipt is a convention, not an identity
419
- 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.
420
307
 
421
308
  ```jsx
422
309
  function OrderReceived() {
423
310
  const { status, order, lines, paymentLink, paymentInstructions, error, reload } = useOrderReturn();
424
311
  const totals = useTotalsLines(order);
425
- const formatMoney = useFormatMoney();
426
312
  if (status === "loading") return /* confirming copy */;
427
313
  if (status === "error") return <><p role="alert">{error.message}</p>
428
314
  <button onClick={() => reload()}>Try again</button></>;
@@ -441,7 +327,7 @@ function OrderReceived() {
441
327
  .map(([k, v]) => <p key={k}>{k.replace(/_/g, " ")}: {String(v)}</p>)}
442
328
  </section>
443
329
  )}
444
- {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>)}
445
331
  {totals.filter((t) => !t.hidden).map((t) => <p key={t.key}>{t.label} {t.formatted}</p>)}
446
332
  </>
447
333
  );
@@ -456,59 +342,38 @@ useStorefrontSeo(collectionSeo({ title, products: list.products })); // col
456
342
  // order-received is already noindex via useOrderReturn
457
343
  ```
458
344
 
459
- The `*Seo` builders tolerate a null product, so this sits with the other hooks
460
- above the status guards.
345
+ The `*Seo` builders tolerate a null product, so this sits with the other hooks above the status guards.
461
346
 
462
347
  ## Per-page output budgets
463
348
 
464
- | Page | budget (chars) | rationale |
465
- |---|---|---|
466
- | Checkout | ≤ 5K | your markup over the reference above — the logic is all hook calls |
467
- | Cart / bag | ≤ 3K | `useCart` + `CartLine` rows + totals + coupon + empty state |
468
- | Order-received | ≤ 2.5K | five states + payment instructions + summary |
469
- | Product page | ≤ 5K | your layout and type around `useProduct`, `variantAxes`, `useAddToCartButton`, the gallery |
470
- | Collection | ≤ 3K | `useProductList` + custom card + pagination controls |
471
- | Home | ≤ 5K | pure identity hero/editorial earn their chars |
472
- | Any single component file | ≤ 4K, hard ceiling 8K | Base1 evidence: decode is 34% of wall; a 12K file is a 45s write batch |
473
-
474
- These budgets assume the hooks carry the logic and your markup carries only the
475
- design. Over budget ⇒ you are re-implementing something a hook does — an
476
- address spec, a quantity clamp, totals math, variant resolution, add-to-cart
477
- error recovery. Go back to the hook and delete your version. Design detail is
478
- not what pushes a page over: giving a colour axis swatches or a composition
479
- modifier bars costs a few hundred characters, and that is what the budget is
480
- for.
481
-
482
- ## If you drive the storefront from a browser script
483
-
484
- Whatever you choose to check and however you check it, two things make a
485
- working storefront look broken under a script:
486
-
487
- - **Filling the checkout.** Every field is a controlled React input, so writing
488
- `el.value` changes nothing React sees. Use the harness's own fill (it
489
- dispatches `input` + `change`) — never lift the native setter off
490
- `HTMLInputElement.prototype` and call `descriptor.set(v)`: detached from the
491
- element it throws `Illegal invocation`, and the workaround it is reaching for
492
- is what the fill helper already does.
493
- - **`placeOrder` ends the page.** It navigates with `window.location.assign`
494
- (above), so a script that placed an order loses its page context and can land
495
- back at `/` — while the order itself was created normally. That is the hard
496
- navigation, not a broken redirect. The confirmation is reachable at any time
497
- from a fresh navigation to `/order-received?order_id=…&order_key=…` (the ids
498
- come back in `placeOrder`'s result, and `commerce/admin-orders` `search` has
499
- 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.
500
364
 
501
365
  ## Done — forget this file
502
366
 
503
- - [ ] Catalog UI exists in whatever form fits the store (list, product pages, or both), plus a checkout, plus `/order-received`.
504
- - [ ] **One** `<StorefrontProvider>` above every storefront route, wrapping `<Routes>` (or a layout route's `<Outlet/>`); one client, no hand-rolled `cart_token`.
505
- - [ ] Pages branch on `status`; no page maps a possibly-null list or shows an empty state while loading.
506
- - [ ] Gateways/currency/countries read from `useStoreInfo()` only.
507
- - [ ] If the store has coupons, a coupon field (`useCoupon`) exists in the cart or the checkout.
508
- - [ ] `/order-received` renders `useOrderReturn`'s states **including `paymentInstructions`**.
509
- - [ ] Variant options render one control per axis; unbuyable options are disabled, not hidden.
510
- - [ ] No hook logic was re-implemented by hand (address fields, quantity clamps, totals rows, shipping/payment branching).
511
- - [ ] The storefront carries the design you settled on before reading this file — no page ships the reference snippets' bare structure or placeholder copy, and the attributes and modifiers that matter to these products are designed rather than poured into one uniform block.
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.
512
377
  - [ ] Every page is within its budget above.
513
378
 
514
379
  Record these lines in your working notes; do not re-read this file.
@@ -519,3 +384,4 @@ Record these lines in your working notes; do not re-read this file.
519
384
  - Branch cart/list/product UI on `status`, never on `isEmpty`/nullable data — `isEmpty` is false while loading by design.
520
385
  - Variant options that aren't buyable render disabled, never hidden; one control per axis, never a list of variations.
521
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.