@base44/app-plugin-commerce 0.2.6 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,7 +12,7 @@ It provides a full-featured **commerce data model and behavior** (variant-driven
12
12
  - **Shared commerce engine** (`base44/shared/commerce/`) — totals, tax, shipping, coupons, stock, order lifecycle, webhook dispatch (HMAC-signed), emails, card-payment plumbing, plus static country/currency/continent data.
13
13
  - **Admin UI** (`src/commerce/admin/`) — a React/Tailwind/shadcn admin with a familiar store back-office information architecture: dashboard, orders, products, coupons, customers, reports, and full settings including webhooks. Admin-role gated.
14
14
  - **Storefront helpers** (`src/commerce/utils/`) — framework-free, dependency-free modules for the shopfront you build: `storefront.js` is the API client (`createStorefront(base44)` — cart-token lifecycle, cached store-info, catalog/cart/checkout/reviews/return-page calls); `variants.js` maps an attribute selection (Size, Color) onto a `ProductVariation` and back, plus per-option availability and price ranges; `price.js` encodes the from-price and price-range rules; `totals.js` projects a cart *or* an order into one summary shape; `address-spec.js` is the checkout address form as data; `images.js` normalizes catalog images; `shipping-promos.js` reads the store's real free-shipping configuration so "Free shipping over €150" states a configured rule rather than an invented number.
15
- - **Storefront React layer** (`src/commerce/storefront/`) — **headless: the logic is premade, the UI never is.** Nothing in the layer renders markup or carries CSS; every element, class and word of copy in the storefront you build is yours, so a brief like "make it feel like <site>" applies to the whole store, checkout included. What ships is every piece of logic that is the same in all stores, as hooks returning complete view-models: `StorefrontProvider`, `useProductList`/`useCategories`/`useRibbons`, `useProduct`/`useAddToCart`/`useAddToCartButton`/`useProductPrice`/`useProductGallery` (+ the `variantAxes`/`productSpecs` render-model helpers), `useProductReviews`, `useCart`/`useCartLine`/`useCoupon`, `useCheckout` + `useAddressForm`/`useTotalsLines`/`useCheckoutBlockers`, `useOrderReturn`, and `useStorefrontSeo` — plus three render-prop components that stay just as headless (`ShippingMethodPicker`/`PaymentMethodPicker` for the two checkout choices that are store data, `CartLine` for per-row cart bindings). Each hook's doc comment states the render rules that keep a store correct (an unbuyable variant option renders disabled, not hidden; a receipt page must render `paymentInstructions`; …). Needs React and nothing else.
15
+ - **Storefront React layer** (`src/commerce/storefront/`) — **headless: the logic is premade, the UI never is.** Nothing in the layer renders markup or carries CSS; every element, class and word of copy in the storefront you build is yours, so a brief like "make it feel like <site>" applies to the whole store, checkout included. What ships is every piece of logic that is the same in all stores, as hooks returning complete view-models: `StorefrontProvider`, `useProductList`/`useCategories`/`useRibbons`, `useProduct`/`useAddToCart`/`useProductPrice`/`useProductGallery` (+ the `variantAxes`/`productSpecs` render-model helpers), `useProductReviews`, `useCart`/`useCartLine`/`useCoupon`, `useCheckout` + `useAddressForm`/`useTotalsLines`/`useCheckoutBlockers`, `useOrderReturn`, and `useStorefrontSeo` — plus three render-prop components that stay just as headless (`ShippingMethodPicker`/`PaymentMethodPicker` for the two checkout choices that are store data, `CartLine` for per-row cart bindings). Each hook's doc comment states the render rules that keep a store correct (an unbuyable variant option renders disabled, not hidden; a receipt page must render `paymentInstructions`; …). Needs React and nothing else.
16
16
  - **StoreAdmin agent + bot** — an AI copilot (`base44/agents/commerce/StoreAdmin.jsonc`, registered as `commerce/StoreAdmin`) with the `commerce/*` functions attached directly as tools (calls run as the chatting user → `requireAdmin()` still applies), variant-aware order editing, plus a chat panel in the admin sidebar with GFM markdown-table rendering.
17
17
  - **Docs** — this README plus the commerce skill folder [`skills/commerce/`](./skills/commerce/): [`SKILL.md`](./skills/commerce/SKILL.md) is the map every agent starts from (and the only path the platform needs to know); [`install/`](./skills/commerce/install/) holds the three stage files that are the whole install (`01-install` → `02-storefront` → `03-data`, each read at the moment its work starts and dropped when its checklist passes); [`references/`](./skills/commerce/references/) holds per-topic guides opened only on demand; [`docs/`](./skills/commerce/docs/) holds the data-model map ([`entities.md`](./skills/commerce/docs/entities.md)) and the two API references. The whole folder is installed into the app at `.agents/skills/commerce/` so agents pick it up natively.
18
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@base44/app-plugin-commerce",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "Base44 Commerce plugin — entities, backend functions, shared commerce engine, admin UI and the commerce skill, shipped as copyable source",
5
5
  "keywords": [
6
6
  "base44",
@@ -47,15 +47,19 @@ moments. Read nothing else up front.
47
47
 
48
48
  **The UI is yours, all of it** — every page, element, class and word of copy.
49
49
  The kit deliberately ships **no markup and no CSS anywhere**; a brief like
50
- "make it feel like <site>" is your work, done as you would with no kit.
50
+ "make it feel like <site>" is your work, done as you would with no kit. Encode
51
+ that identity **once** — design classes in `index.css`, spent across every
52
+ page — not as decoration re-typed on every element;
53
+ [`install/02-storefront.md`](./install/02-storefront.md) opens with the
54
+ method.
51
55
 
52
56
  **The logic ships as headless hooks** (`@/commerce/storefront`) — checkout
53
57
  repricing, variant resolution, cart state, coupons, reviews, order-return
54
- verification, cart-drawer mechanics. Each returns a complete view-model
55
- (`status` to branch on, ready-to-map arrays, handlers) **plus ready-to-spread
56
- prop sets** (`inputProps`, `radioProps`, `buttonProps`, `panelProps`) that
57
- carry the wiring, so your markup is elements and classes. **Never re-implement
58
- what a hook does** — that is where storefront bugs cluster.
58
+ verification, cart-drawer state. Each returns a complete view-model as **plain
59
+ states and handlers** (`status` to branch on, ready-to-map arrays, `label`s
60
+ and `disabled` flags, callbacks) — you write every element and attribute from
61
+ them. **Never re-implement what a hook does** that is where storefront bugs
62
+ cluster.
59
63
 
60
64
  ```jsx
61
65
  import { useCart, CartLine } from "@/commerce/storefront";
@@ -2,7 +2,7 @@
2
2
 
3
3
  The raw HTTP/SDK surface behind a customer-facing shopfront. **A React storefront should not call most of it directly** — `src/commerce/storefront/` ships the logic layer, and this file is the reference for what lies beyond it, for non-React and headless clients, and for the exact payloads and error codes.
4
4
 
5
- **Logic is premade, UI never is.** In a React app, every surface here has a headless hook in `@/commerce/storefront` — `useProductList`, `useProduct` (+ `variantAxes`, `useAddToCartButton`, `useProductSpecs`, `useUpsell`), `useCart`/`useCartLine`/`useCoupon`/`useCartUI` (+ `CartUIProvider`), `useCheckout`/`usePlaceOrder` (+ `useAddressForm`, the pickers and prop-getters), `useOrderReturn`, `useProductReviews`, `useStorefrontSeo`. The hooks own the API calls and the branching below and hand you a view-model; **all markup and styling are yours** — nothing in the kit renders UI. Never hand-roll a hook's logic. Framework-free helpers (API client, variant resolution, price and totals rules, free-shipping rules) live in `src/commerce/utils/`.
5
+ **Logic is premade, UI never is.** In a React app, every surface here has a headless hook in `@/commerce/storefront` — `useProductList`, `useProduct` (+ `variantAxes`, `useAddToCart`, `useProductSpecs`, `useUpsell`), `useCart`/`useCartLine`/`useCoupon`/`useCartUI` (+ `CartUIProvider`), `useCheckout`/`usePlaceOrder` (+ `useAddressForm` and the pickers), `useOrderReturn`, `useProductReviews`, `useStorefrontSeo`. The hooks own the API calls and the branching below and hand you a view-model; **all markup and styling are yours** — nothing in the kit renders UI. Never hand-roll a hook's logic. Framework-free helpers (API client, variant resolution, price and totals rules, free-shipping rules) live in `src/commerce/utils/`.
6
6
 
7
7
  Entities and direct CRUD → [`entities.md`](./entities.md) · admin surface → [`api-admin.md`](./api-admin.md)
8
8
 
@@ -94,7 +94,7 @@ await cat({ category_id, sort: "popularity", per_page: 4 }); // top in catego
94
94
  ```
95
95
  **Errors:** `404 not_found` (missing / not published / hidden).
96
96
 
97
- > **`variations[]` is not a list of choices to show** — variant rule 1 applies, and variant prices come from `variations[]`, never `product.price` (a rolled-up from-price). React: `useProduct` + `useAddToCartButton`. The non-React resolver sample (`resolveSelection` from `@/commerce/utils`) and the variant deep-dive: [`../references/catalog-rendering.md`](../references/catalog-rendering.md).
97
+ > **`variations[]` is not a list of choices to show** — variant rule 1 applies, and variant prices come from `variations[]`, never `product.price` (a rolled-up from-price). React: `useProduct` + `useAddToCart`. The non-React resolver sample (`resolveSelection` from `@/commerce/utils`) and the variant deep-dive: [`../references/catalog-rendering.md`](../references/catalog-rendering.md).
98
98
 
99
99
  ### `list-categories`
100
100
  No payload. Returns a nested tree: `{ "categories": [ { ...category, "children": [...] } ] }` sorted by `menu_order` then name.
@@ -10,16 +10,17 @@ 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
+ - "Hooks return plain states and handlers (label, disabled, value, set, addToCart) — you write every element and attribute from them; render the state-driven text (buy.label, f.error, hint.message) and never re-derive what a hook already resolves."
14
+ - "Identity is encoded once — design classes in index.css plus one or two signature moments per page; a utility run that appears twice becomes a class, full-screen states go through the shared StatusScreen, and every page is measured (wc -c) against its char budget."
14
15
  ---
15
16
 
16
17
  # 02 — Storefront
17
18
 
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
+ One split decides everything here: **the logic is premade, the UI never is.** The hooks own checkout repricing, variant resolution, cart state, coupon redemption, review policies, order-return verification — hand-writing any of it is where storefront bugs cluster, so **never re-implement what a hook does**. Every element, class, layout and word of copy is yours; nothing in `@/commerce/storefront` renders markup or carries CSS. **Decide how the store looks as if this kit did not exist**, then encode that look **once** as design classes (["Design language"](#design-language--once-before-any-page) below) and build every page in them — the snippets in this file are implementation reference, not design input.
19
20
 
20
21
  Two mechanics to hold everywhere:
21
22
 
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
+ - **States and handlers, not magic.** Hooks return plain valuesstrings, booleans, arrays, callbacks (`buy.label`, `buy.disabled`, `buy.addToCart`, `f.value`, `f.set`)never ready-made prop objects to spread. You write every element and every attribute; the hook guarantees the values are right. The flip side: **render what the hook resolves** — the button's text is `{buy.label}`, its gate is `disabled={buy.disabled}` and never re-derive a state the hook already owns (a ternary chain over `adding`/`purchasable` re-implements `buy.state` wrong).
23
24
  - **Each hook's JSDoc is the API reference.** Open the hook's file when you need exact shapes; don't guess fields. Rules marked ⚑ must survive whatever design you build.
24
25
 
25
26
  **One import path: `@/commerce/storefront`.** Each section opens with its page's exact import line — copy it verbatim, then delete unused names. (`variantAxes` and `productSpecs` are re-exported there; a React page never imports `@/commerce/utils` directly. `useStoreInfo` is the name most often left out — it is the only source of store name and currency.)
@@ -56,6 +57,52 @@ import AdminApp from "@/commerce/admin";
56
57
 
57
58
  ⚑ **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`.
58
59
 
60
+ ## Design language — once, before any page
61
+
62
+ The measured cost driver of a generated storefront is not wiring — it is decoration repeated inline. In audited builds ~35% of every hand-written file was className strings, the same 150-char utility run pasted onto every panel, button and heading. Encode the store's identity **once**, then spend it everywhere:
63
+
64
+ - In `index.css`, set the palette/typography variables, then define the store's recurring surfaces as **10–15 composable classes** in Tailwind's components layer, named in *this* store's language:
65
+
66
+ ```css
67
+ @layer components {
68
+ .panel { @apply border border-border/40 bg-card p-6; }
69
+ .btn-cta { @apply w-full py-4 bg-primary text-primary-foreground text-xs uppercase tracking-[0.2em] hover:bg-primary/90 transition-colors disabled:opacity-40 disabled:cursor-not-allowed; }
70
+ .label-mono { @apply text-xs uppercase tracking-[0.2em] text-muted-foreground; }
71
+ .field { @apply w-full border border-border bg-transparent px-4 py-3 text-sm outline-none focus:border-primary; }
72
+ .choice-row { @apply flex items-center gap-4 p-4 border border-border/40 cursor-pointer transition-colors hover:border-primary/50; }
73
+ /* …heading scale, link style, price, alert — whatever THIS store repeats */
74
+ }
75
+ ```
76
+
77
+ Pages then carry short names plus at most a couple of layout utilities. ⚑ **A utility run that appears twice becomes a class** — pasting the same string onto a second element means it belongs in `index.css`.
78
+
79
+ - **One `<StatusScreen>` component for every full-screen state.** Loading, empty, error, not-found and order-submitted screens are one layout with different words. It is stateless — every hook already hands the page a plain `status` enum to branch on — so the whole component is this, restyled:
80
+
81
+ ```jsx
82
+ // src/components/store/StatusScreen.jsx — the store's full-screen states, styled once
83
+ export default function StatusScreen({ title, message, action, busy }) {
84
+ return (
85
+ <div role="status" aria-busy={busy || undefined} className="…"> {/* centered, full-height */}
86
+ {busy && <span className="…" aria-hidden="true" />} {/* your spinner/pulse */}
87
+ <h1 className="…">{title}</h1>
88
+ {message && <p className="…">{message}</p>}
89
+ {action}
90
+ </div>
91
+ );
92
+ }
93
+ ```
94
+
95
+ Every page's guards then become one-liners:
96
+
97
+ ```jsx
98
+ if (p.status === "loading") return <StatusScreen busy title="Loading…" />;
99
+ if (p.status === "not_found") return <StatusScreen title="Not found" action={<Link to="/">Back to the shop</Link>} />;
100
+ ```
101
+
102
+ ⚑ Once `StatusScreen` exists, no page hand-draws a full-screen status branch — and don't wrap it in a status-mapping hook either; the copy per state is the page's, in words.
103
+
104
+ **Concentrate identity; don't diffuse it.** The design classes already carry the store's look on every surface. On top of them, spend bespoke markup on **one or two signature moments per page** — the hero, the one product-page module that shows what these products are judged on — and render everything else as conventions in the store's classes. The signature budget concentrates where the products are: **the product page stays the storefront's richest surface** — its section's rules to render axes and specs *by what they are* hold with full force; the richness there is semantic (what the controls and rows show), which costs words, not chrome. One navigation affordance per control (a gallery gets thumbnails *or* arrows, never both plus dots); checkout, bag and order-received are convention surfaces — the classes style them, no bespoke widgets. Identity is legible in the tokens and the signature moments; decorating every element produces bulk, not character.
105
+
59
106
  ## Product list / collection
60
107
 
61
108
  ```jsx
@@ -69,21 +116,21 @@ const list = useProductList({ per_page: 24 });
69
116
  const { settings } = useStoreInfo();
70
117
  useStorefrontSeo(collectionSeo({ title: "…", products: list.products, storeName: settings?.store_name }));
71
118
 
72
- if (list.status === "loading") return /* your loading state */;
73
- if (list.status === "error") return /* your failure state, with a retry calling list.reload() */;
119
+ if (list.status === "loading") return <StatusScreen busy title="…" />;
120
+ if (list.status === "error") return <StatusScreen title="…" action={<button type="button" onClick={list.reload}>Try again</button>} />;
74
121
  const products = list.products; // always an array — never null
75
122
  ```
76
123
 
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).
124
+ `useProductList(params)` → `{ status, products, hasNext, next, loadMore, busy, refreshing, setParams, reload }`. ⚑ `status` is `"loading" | "ready" | "empty" | "error"` — branch on it, so a failed request renders as a failure instead of an empty grid. `setParams({ category_id, search, on_sale, in_stock_only, … })` resets to page 1 and keeps current rows on screen (`refreshing`). ⚑ **Render paging whenever `hasNext` is true** — `{list.hasNext && <button type="button" onClick={list.next} disabled={list.busy}>Next</button>}` (append mode: `onClick={list.loadMore}` for "Load more"); a page that renders nothing for paging ships a catalog silently capped at `per_page`. `useCategories()` / `useRibbons()` → `{ items }` — drive filters from that data, never from hardcoded names (a renamed ribbon must not strand a dead button).
78
125
 
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.
126
+ Your card can render `name`, `productImages(row)[0]` (⚑ **images are `{src, alt}` objects and the array may be empty — render a placeholder, never a broken `<img>`**), `useProductPrice(row).label` (already "From €19.99" when the product sells variants — there is no product `type` flag), `on_sale`, `short_description`, `stock_status`, `average_rating`/`rating_count`, `ribbons`, and `meta_data` via `productSpecs(row)`. Full field matrix: [`../references/catalog-rendering.md`](../references/catalog-rendering.md). That is an inventory, not a card design: lead each card with the one or two fields *these* products are judged on (via `productSpecs`) rather than the default name/price/stars trio but the card is your design classes plus those fields, not a bespoke widget.
80
127
 
81
128
  **Rails** (featured row, "new in") are the same hook with a filter (`{ featured: true, per_page: 4 }`). ⚑ Any filter may legitimately match nothing — render *nothing* then, never a heading over an empty row. Upsells beside a product need no query: `p.upsells` / `p.crossSells` from `useProduct` (render via `useUpsell` — see Cart).
82
129
 
83
130
  ## Product page
84
131
 
85
132
  ```jsx
86
- import { useProduct, useProductGallery, useAddToCartButton, useProductSpecs, useStoreInfo, useStorefrontSeo, productSeo, variantAxes } from "@/commerce/storefront";
133
+ import { useProduct, useProductGallery, useAddToCart, useProductSpecs, useStoreInfo, useStorefrontSeo, productSeo, variantAxes } from "@/commerce/storefront";
87
134
  ```
88
135
 
89
136
  (Add `useProductReviews` only if the store has reviews; drop `useProductSpecs` if these products carry no modifiers.)
@@ -93,13 +140,13 @@ import { useProduct, useProductGallery, useAddToCartButton, useProductSpecs, use
93
140
  ```jsx
94
141
  const p = useProduct(slug);
95
142
  const g = useProductGallery(p.product, p.view);
96
- const buy = useAddToCartButton(p, { labels: { ready: "Add to bag" } });
143
+ const buy = useAddToCart(p, { labels: { ready: "Add to bag" } });
97
144
  const specs = useProductSpecs(p.product, { pick: ["material", "care"] });
98
145
  const { settings } = useStoreInfo(); // the ONLY source of store name + currency
99
146
  useStorefrontSeo(productSeo(p.product, p.view, { storeName: settings?.store_name, currency: settings?.currency }));
100
147
 
101
- if (p.status === "loading") return /* your loading state */;
102
- if (p.status === "not_found") return /* your 404 */;
148
+ if (p.status === "loading") return <StatusScreen busy title="…" />;
149
+ if (p.status === "not_found") return <StatusScreen title="…" action={/* link back to the catalog */} />;
103
150
  const { product, view, price, categories } = p;
104
151
  ```
105
152
 
@@ -107,7 +154,7 @@ Build your layout from — all optional, each one hook, **not one component styl
107
154
 
108
155
  - **Price** — `price.label`, plus `price.compareAtLabel` (struck through) when on sale. Never read `product.price` directly — the parent's price is a rolled-up from-price.
109
156
  - **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):
157
+ - **Variant selector** — `variantAxes(view, p.pick)` → one entry per axis: `{ key, name, selectedOption, options: [{ value, selected, disabled, outOfStock, pick }] }`. ⚑ **One control per axis, never a list of variations**, and ⚑ **an unbuyable option renders `disabled`, never hidden** (`outOfStock` stays visible, just marked). `view.missingAxes` names what's unpicked. **Render each axis by what it is** — swatches for a colour axis, chips with a size guide beside a size axis; every axis as the identical chip row is a generated-page tell. The differentiation is semantic — what the control *shows* — built from your classes, not extra chrome around each row. The shape of the map:
111
158
 
112
159
  ```jsx
113
160
  {variantAxes(view, p.pick).map((axis) => (
@@ -121,9 +168,9 @@ Build your layout from — all optional, each one hook, **not one component styl
121
168
  ))}
122
169
  ```
123
170
 
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.
171
+ - **Buy box** — `<button type="button" onClick={buy.addToCart} disabled={buy.disabled}>{buy.label}</button>` is the whole button. **Both the text and the gate come from the hook** — `{buy.label}` follows the ready/adding/sold-out/needs-selection precedence (`buy.state`; override copy via `labels`), so never hardcode the button text or re-derive `disabled` from your own ternary chain. ⚑ Render `buy.error.message` inline; ⚑ `buy.showQuantity: false` means no stepper (`increase`/`decrease`/`canIncrease` drive one when true). With `<CartUIProvider>` mounted, a successful add opens the drawer by itself.
125
172
  - **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.
173
+ - **Specs** — `useProductSpecs(product, { pick: [...] })`: `picked` are the rows to feature (matched case/`_`-insensitively — ⚑ **never match rows by `label` equality**, it silently misses), `rest` is the remainder, safe to render as plain rows. Each row carries `titleLabel` (display-cased) and a `type` with `number`/`unit`/`items` split out, so a weight can be a figure and a composition bars. **This is the product page's one signature-moment candidate**: branch on `type` (or `key`) for the two or three `picked` specs that carry *this* product's meaning, and render `rest` as plain rows in your classes — not everything into one uniform table, and not a bespoke widget per spec either. `[]` means no section at all.
127
174
  - **Breadcrumbs** — build from `categories` (`/collection?category_id=${c.id}`); skip on a flat catalog. Ribbons are labels, not breadcrumbs.
128
175
 
129
176
  ### Reviews — optional
@@ -148,8 +195,8 @@ A cart *page* is optional (buy-now straight to checkout reads better for a singl
148
195
  function Bag() {
149
196
  const { status, lines, notices } = useCart();
150
197
  const totals = useTotalsLines();
151
- if (status === "loading") return /* your loading state */;
152
- if (status === "empty") return /* your empty-bag state, linking back to the catalog */;
198
+ if (status === "loading") return <StatusScreen busy title="…" />;
199
+ if (status === "empty") return <StatusScreen title="…" action={/* link back to the catalog */} />;
153
200
  return (
154
201
  <>
155
202
  {notices.map((n, i) => <p key={i} role="status">{n.message}</p>)}
@@ -185,25 +232,28 @@ No shipping estimator here — checkout reprices shipping and tax from the addre
185
232
 
186
233
  ### If the cart is a drawer
187
234
 
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):
235
+ Same hooks, same rows. The drawer's *state* — open/close, Esc, close-on-route-change, open-on-add — is `useCartUI` (`{ open, openCart, closeCart, toggleCart }`); the markup is yours:
189
236
 
190
237
  ```jsx
191
238
  function StoreLayout() { // inside <CartUIProvider> (see Setup)
192
239
  const ui = useCartUI();
193
240
  const { itemCount } = useCart();
194
241
  return (<>
195
- <header>… <button {...ui.triggerProps} className="…">Bag ({itemCount})</button></header>
242
+ <header>… <button type="button" onClick={ui.toggleCart} aria-expanded={ui.open}
243
+ className="…">Bag ({itemCount})</button></header>
196
244
  <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>
245
+ {ui.open && (<>
246
+ <div onClick={ui.closeCart} aria-hidden="true" className="…" />
247
+ <aside role="dialog" aria-modal="true" aria-label="Cart" className="…">
248
+ <button type="button" onClick={ui.closeCart} aria-label="Close cart" className="…">×</button>
249
+ {/* your rows: useCart + CartLine, as above */}
250
+ </aside>
251
+ </>)}
202
252
  </>);
203
253
  }
204
254
  ```
205
255
 
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`.
256
+ **Render the drawer conditionally (`{ui.open && …}`), exactly as above.** The classic drawer bug is a panel translated off-screen but still mounted — its buttons stay clickable, tab-able and visible to screen readers. If you keep it mounted to animate the slide, you own that concern: set the `inert` attribute while closed. The overlay is a click-away surface, not the close control the named close button inside the panel is.
207
257
 
208
258
  ## Checkout
209
259
 
@@ -215,7 +265,7 @@ import { CheckoutProvider, useCheckoutContext, usePlaceOrder, useAddressForm, Sh
215
265
 
216
266
  The state machine is `useCheckout`, shared across the page's regions by `CheckoutProvider`. It reprices shipping/tax from the address automatically (debounced, never on a half-typed address), derives the shipping and payment choices, gates the button, and `placeOrder()` handles **both** navigations — online gateway → provider redirect, everything else → `/order-received` — as **full page loads** (pass `orderReceivedPath: null` for a router transition; see `useCheckout`'s JSDoc).
217
267
 
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.
268
+ ⚑ Rules: render each picker's `hint` and every branch; a single shipping or payment option still *shows* what it is — never a picker of one, never "nothing selected"; wire each address field from its own states `value={f.value}` `onChange={f.set}` `autoComplete={f.autoComplete}`, and render `f.error` ("we don't ship there" arrives on the country field); render the gate's `error` and `blockers` so a disabled button explains itself. ⚑ Payment methods, currency and countries come from `useStoreInfo()` only — `cart.payment_gateways` is always `undefined`, and a default store offers `offline` only.
219
269
 
220
270
  **Reference implementation** — the densest wiring in the storefront; read it, then build yours around it:
221
271
 
@@ -228,9 +278,9 @@ function CheckoutForm() {
228
278
  const { status } = useCart();
229
279
  const checkout = useCheckoutContext();
230
280
  const order = usePlaceOrder();
231
- if (order.stage === "submitted") return /* "order placed taking you to your receipt" */;
232
- if (status === "loading") return /* loading */;
233
- if (status === "empty") return /* "your bag is empty" */;
281
+ if (order.stage === "submitted") return <StatusScreen busy title="…" />; // "taking you to your receipt"
282
+ if (status === "loading") return <StatusScreen busy title="…" />;
283
+ if (status === "empty") return <StatusScreen title="…" action={/* link back to the catalog */} />;
234
284
  return (
235
285
  <>
236
286
  <AddressFields which="billing" />
@@ -246,7 +296,10 @@ function CheckoutForm() {
246
296
  <fieldset>{/* renders null for a virtual cart */}
247
297
  {hint && <p role={hint.severity === "error" ? "alert" : "status"}>{hint.message}</p>}
248
298
  {mustChoose && methods.map((m) => (
249
- <label key={m.id} {...m.labelProps}><input {...m.radioProps} /> {m.title} {m.costLabel}</label>
299
+ <label key={m.id}>
300
+ <input type="radio" name="shipping-method" checked={m.selected} onChange={m.select} />
301
+ {m.title} {m.costLabel}
302
+ </label>
250
303
  ))}
251
304
  {!mustChoose && chosen && <p>{chosen.title} {chosen.costLabel}</p>}
252
305
  </fieldset>
@@ -258,7 +311,10 @@ function CheckoutForm() {
258
311
  <fieldset>
259
312
  {hint && <p role="alert">{hint.message}</p>}
260
313
  {!single && gateways.map((g) => (
261
- <label key={g.slug} {...g.labelProps}><input {...g.radioProps} /> {g.title} {g.description}</label>
314
+ <label key={g.slug}>
315
+ <input type="radio" name="payment-method" checked={g.selected} onChange={g.select} />
316
+ {g.title} {g.description}
317
+ </label>
262
318
  ))}
263
319
  {single && selected && <p>{selected.title}</p>}
264
320
  </fieldset>
@@ -267,8 +323,8 @@ function CheckoutForm() {
267
323
 
268
324
  {/* summary: coupon field (if not in the cart) + useTotalsLines(), as in the cart page */}
269
325
 
270
- <button {...order.buttonProps}>{order.label}</button>
271
- {order.error && <p {...order.errorProps}>{order.error.message}</p>}
326
+ <button type="button" onClick={order.placeOrder} disabled={order.disabled}>{order.label}</button>
327
+ {order.error && <p role="alert">{order.error.message}</p>}
272
328
  {!order.canPlaceOrder && order.blockers.map((b) => <p key={b.code}>{b.message}</p>)}
273
329
  </>
274
330
  );
@@ -278,14 +334,14 @@ function AddressFields({ which }) {
278
334
  const { fields } = useAddressForm(which);
279
335
  return fields.map((f) => (
280
336
  <div key={f.key}>
281
- <label {...f.labelProps}>{f.label}{f.required && " *"}</label>
337
+ <label htmlFor={f.id}>{f.label}{f.required && " *"}</label>
282
338
  {f.isSelect ? (
283
- <select {...f.selectProps}>
339
+ <select id={f.id} value={f.value} onChange={f.set} autoComplete={f.autoComplete}>
284
340
  <option value="">{f.placeholder}</option>
285
341
  {f.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
286
342
  </select>
287
- ) : <input {...f.inputProps} />}
288
- {f.error && <span {...f.errorProps}>{f.error}</span>}
343
+ ) : <input id={f.id} type={f.type} value={f.value} onChange={f.set} autoComplete={f.autoComplete} />}
344
+ {f.error && <span role="alert">{f.error}</span>}
289
345
  </div>
290
346
  ));
291
347
  }
@@ -309,9 +365,9 @@ import { useOrderReturn, useTotalsLines } from "@/commerce/storefront";
309
365
  function OrderReceived() {
310
366
  const { status, order, lines, paymentLink, paymentInstructions, error, reload } = useOrderReturn();
311
367
  const totals = useTotalsLines(order);
312
- if (status === "loading") return /* confirming copy */;
313
- if (status === "error") return <><p role="alert">{error.message}</p>
314
- <button onClick={() => reload()}>Try again</button></>;
368
+ if (status === "loading") return <StatusScreen busy title="…" />; // confirming copy
369
+ if (status === "error") return <StatusScreen title="" message={error.message}
370
+ action={<button type="button" onClick={() => reload()}>Try again</button>} />;
315
371
  return (
316
372
  <>
317
373
  {status === "paid" && /* thank-you heading */}
@@ -344,7 +400,7 @@ useStorefrontSeo(collectionSeo({ title, products: list.products })); // col
344
400
 
345
401
  The `*Seo` builders tolerate a null product, so this sits with the other hooks above the status guards.
346
402
 
347
- ## Per-page output budgets
403
+ ## Per-page output budgets — measured, not aspirational
348
404
 
349
405
  | Page | budget (chars) |
350
406
  |---|---|
@@ -353,10 +409,19 @@ The `*Seo` builders tolerate a null product, so this sits with the other hooks a
353
409
  | Order-received | ≤ 2.5K |
354
410
  | Product page | ≤ 5K |
355
411
  | Collection | ≤ 3K |
356
- | Home | ≤ 5K — pure identity; hero/editorial earn their chars |
412
+ | Home | ≤ 5K — identity lives in the hero + one editorial moment, in your classes |
357
413
  | Any single component file | ≤ 4K, hard ceiling 8K |
358
414
 
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.
415
+ When the storefront builds clean, **measure** (adjust the paths to your component folder names):
416
+
417
+ ```bash
418
+ wc -c src/pages/*.jsx src/components/store*/*.jsx
419
+ ```
420
+
421
+ A page over budget has one of two problems — fix in this order, and never by dropping states, branches or ⚑ rules:
422
+
423
+ 1. **Inline decoration that belongs in `index.css`** — this is where the chars measurably are. Extract every repeated utility run into the design classes, fold hand-drawn status branches into `StatusScreen`, and cut any second navigation affordance or off-signature widget.
424
+ 2. **Re-implemented hook logic** (a buy button's state precedence, a quantity clamp, totals math, drawer state) — go back to the hook and delete your version.
360
425
 
361
426
  ## Driving the storefront from a browser script?
362
427
 
@@ -365,16 +430,17 @@ The hooks are optimistic and debounced, so a script that acts faster than the ca
365
430
  ## Done — forget this file
366
431
 
367
432
  - [ ] Catalog UI in whatever form fits the store, plus a checkout, plus `/order-received` rendering `useOrderReturn`'s states **including `paymentInstructions`**.
433
+ - [ ] `index.css` defines the store's design classes; pages carry class names, not repeated utility runs; every full-screen state goes through the shared `StatusScreen`.
368
434
  - [ ] **One** `<StorefrontProvider>` above every storefront route (layout-route pattern); one client, no hand-rolled `cart_token`.
369
435
  - [ ] Every page's imports came from its section's import line; nothing imported from `@/commerce/utils`; no unused names.
370
436
  - [ ] Pages branch on `status`; gateways/currency/countries read from `useStoreInfo()` only.
371
- - [ ] Prop sets spread wherever one existsno 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`.
437
+ - [ ] Every control renders the hook's statesbutton text is `buy.label`/`order.label`, gates are the hook's `disabled`, address fields wire `value={f.value}` `onChange={f.set}` `autoComplete={f.autoComplete}` no re-implemented hook logic.
438
+ - [ ] Coupon field present if the store has coupons; a paging control rendered whenever `hasNext` is true.
373
439
  - [ ] 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).
440
+ - [ ] Cart rows scope busy state to the row; repeated controls have unique accessible names; a drawer uses `useCartUI` and is rendered conditionally on `ui.open` (closes on route change by itself).
375
441
  - [ ] 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.
377
- - [ ] Every page is within its budget above.
442
+ - [ ] The storefront carries the design you settled on before reading this file — encoded in the design classes plus one or two signature moments per page; on the product page, axes and specs are rendered by what they are (not one uniform table, not one identical chip row); convention surfaces (checkout, bag, order-received) carry the classes and nothing bespoke.
443
+ - [ ] Measured with `wc -c` — every page within its budget above.
378
444
 
379
445
  Record these lines in your working notes; do not re-read this file.
380
446
 
@@ -384,4 +450,5 @@ Record these lines in your working notes; do not re-read this file.
384
450
  - Branch cart/list/product UI on `status`, never on `isEmpty`/nullable data — `isEmpty` is false while loading by design.
385
451
  - Variant options that aren't buyable render disabled, never hidden; one control per axis, never a list of variations.
386
452
  - Every hook on a page goes above its status guards — a hook after an early return crashes with "Rendered more hooks than during the previous render".
387
- - Spread the hook's prop set (`inputProps`, `radioProps`, `buttonProps`, `panelProps`, `moreProps`) and add `className` never assemble those attributes by hand.
453
+ - Hooks return plain states and handlers (`label`, `disabled`, `value`, `set`, `addToCart`) — you write every element and attribute from them; render the state-driven text (`buy.label`, `f.error`, `hint.message`) and never re-derive what a hook already resolves.
454
+ - Identity is encoded once — design classes in `index.css` plus one or two signature moments per page; a utility run that appears twice becomes a class, full-screen states go through the shared `StatusScreen`, and every page is measured (`wc -c`) against its char budget.
@@ -62,7 +62,7 @@ Both lists below are field inventories — what the data supports — **not a la
62
62
 
63
63
  **Card:** image, name, `price.label`, sale badge from `on_sale`, stars from `average_rating`/`rating_count`, out-of-stock state from `stock_status`, one or two ribbons — plus anything else on the row (`weight`, `dimensions`, `meta_data` via `productSpecs`). Link the whole card to the product page; the layout is yours.
64
64
 
65
- **Product page:** gallery, name, price, variant selectors, stock, `short_description` then `description`, SKU, categories breadcrumb, ribbons, reviews, then upsells/cross-sells. Everything except the markup has a hook or helper: `useProductGallery`, `variantAxes(view, pick)`, `useAddToCartButton`, `productSpecs(product)`, `useProductReviews`, `p.upsells`/`p.crossSells`.
65
+ **Product page:** gallery, name, price, variant selectors, stock, `short_description` then `description`, SKU, categories breadcrumb, ribbons, reviews, then upsells/cross-sells. Everything except the markup has a hook or helper: `useProductGallery`, `variantAxes(view, pick)`, `useAddToCart`, `productSpecs(product)`, `useProductReviews`, `p.upsells`/`p.crossSells`.
66
66
 
67
67
  **Attributes and modifiers are individually designable.** `productSpecs` rows carry `key`/`label`/`titleLabel` (display-cased) plus an inferred `type` (`numeric` with `number`/`unit` split out, `duration`, `location`, `list` with `items`, `text`); `useProductSpecs(product, { pick })` is the React wrapper, its get/pick case/underscore-insensitive. Branch on `type`/`key` instead of mapping everything into one uniform chip row per axis and one grey label/value table — the design guidance is [`../install/02-storefront.md`](../install/02-storefront.md)'s; §5's rules govern selector *behaviour*, never its form.
68
68
 
@@ -99,7 +99,7 @@ Three decisions the helpers can't make for you:
99
99
 
100
100
  **Attributes but no usable variations** — what attaching an attribute and stopping leaves behind — deliberately gets no parent fallback, because `add-item` would reject it: empty `axes[].options`, `purchasable: false`, `addToCart: null`. Render it as unavailable rather than painting empty selector groups, and guard the label (with no attributes at all `missingAxes[0]?.name` is `undefined` → "Select a undefined").
101
101
 
102
- **Add to cart** goes through `useAddToCart()`, which never throws and maps the codes: `variation_required` (a page bug — empty `variation_id` on a product with attributes), `out_of_stock` / `insufficient_stock`, and `variation_not_found` (the catalog changed under the page → `shouldReload: true`, refetch). `product.sold_individually` caps quantity at 1, already reflected in `useProduct().maxQuantity`. Two behaviors that matter only if you hand-roll the view model: non-publishable variations never leak an option into the UI, and an empty `option` on a variation axis means **"any"**.
102
+ **Add to cart** goes through `useAddToCart(product)`, whose `addToCart()` never throws and maps the codes: `variation_required` (a page bug — empty `variation_id` on a product with attributes), `out_of_stock` / `insufficient_stock`, and `variation_not_found` (the catalog changed under the page → the hook reloads the product itself). `product.sold_individually` caps quantity at 1, already reflected in `useProduct().maxQuantity`. Two behaviors that matter only if you hand-roll the view model: non-publishable variations never leak an option into the UI, and an empty `option` on a variation axis means **"any"**.
103
103
 
104
104
  ## 6. Adding a `get-product`-only field to the listing
105
105
 
@@ -21,9 +21,9 @@ DOM is briefly right about the *intent* and wrong about the *state*.
21
21
  before reading or clicking again.
22
22
  - **Scope actions to the visible drawer.** With a drawer, the page can hold two
23
23
  "Remove" buttons for one line — drawer and cart page behind it. A drawer built
24
- on `useCartUI` is inert while closed, so its copies can't be hit by mistake —
25
- but still query inside the open drawer's container, not the document. A click
26
- that seems to do nothing usually hit the hidden copy.
24
+ on `useCartUI` is unmounted while closed, so its copies can't be hit by
25
+ mistake — but still query inside the open drawer's container, not the
26
+ document. A click that seems to do nothing usually hit a hidden copy.
27
27
  - **Remove lines one at a time.** Clicking every "Remove" in one pass fails on
28
28
  its own terms: cart calls are serialized, each removal re-renders the list,
29
29
  and buttons collected up front are detached by the time the loop reaches