@base44/app-plugin-commerce 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +6 -6
  2. package/base44/agents/commerce/StoreAdmin.jsonc +1 -1
  3. package/base44/entities/commerce.OrderRefund.jsonc +1 -1
  4. package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
  5. package/base44/entities/commerce.Webhook.jsonc +1 -1
  6. package/base44/functions/commerce/admin-products/entry.ts +1 -1
  7. package/base44/functions/commerce/admin-reports/entry.ts +1 -1
  8. package/base44/functions/commerce/payments/entry.ts +2 -2
  9. package/base44/functions/commerce/seed-store/defaults.ts +1 -1
  10. package/base44/functions/commerce/seed-store/seed-catalog.ts +64 -6
  11. package/base44/functions/commerce/storefront-catalog/entry.ts +1 -1
  12. package/base44/functions/commerce/storefront-checkout/entry.ts +1 -1
  13. package/base44/shared/commerce/card-payment.stripe.ts +29 -9
  14. package/base44/shared/commerce/card-payment.ts +1 -1
  15. package/base44/shared/commerce/payments.ts +2 -2
  16. package/base44/shared/commerce/scan.ts +1 -1
  17. package/base44/shared/commerce/sequence.ts +2 -2
  18. package/package.json +1 -1
  19. package/scripts/install.js +1 -1
  20. package/skills/commerce/SKILL.md +36 -26
  21. package/skills/commerce/docs/api-storefront.md +6 -6
  22. package/skills/commerce/install/01-install.md +2 -2
  23. package/skills/commerce/install/02-storefront.md +381 -94
  24. package/skills/commerce/install/03-data.md +62 -19
  25. package/skills/commerce/references/catalog-rendering.md +6 -6
  26. package/skills/commerce/references/online-payments.md +5 -6
  27. package/skills/commerce/references/reviews.md +5 -5
  28. package/skills/commerce/references/shipping-and-tax.md +2 -0
  29. package/src/commerce/admin/README.md +6 -3
  30. package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
  31. package/src/commerce/admin/pages/products/Reviews.jsx +1 -1
  32. package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -1
  33. package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +1 -1
  34. package/src/commerce/storefront/index.js +45 -33
  35. package/src/commerce/storefront/useAddressForm.js +41 -8
  36. package/src/commerce/storefront/useCartLine.js +37 -0
  37. package/src/commerce/storefront/useCheckout.jsx +18 -6
  38. package/src/commerce/storefront/useOrderReturn.js +36 -10
  39. package/src/commerce/storefront/useProduct.js +72 -0
  40. package/src/commerce/storefront/useProductGallery.js +4 -0
  41. package/src/commerce/utils/index.js +9 -6
  42. package/src/commerce/utils/shipping-promos.js +2 -2
  43. package/src/commerce/utils/specs.js +26 -0
  44. package/src/commerce/utils/variants.js +49 -2
  45. package/src/commerce/storefront/blocks/AddToCartBlock.jsx +0 -86
  46. package/src/commerce/storefront/blocks/AddressFieldsBlock.jsx +0 -96
  47. package/src/commerce/storefront/blocks/BreadcrumbsBlock.jsx +0 -52
  48. package/src/commerce/storefront/blocks/CartLinesBlock.jsx +0 -98
  49. package/src/commerce/storefront/blocks/CheckoutBlock.jsx +0 -247
  50. package/src/commerce/storefront/blocks/CouponFieldBlock.jsx +0 -84
  51. package/src/commerce/storefront/blocks/OrderReceivedBlock.jsx +0 -129
  52. package/src/commerce/storefront/blocks/ProductGalleryBlock.jsx +0 -66
  53. package/src/commerce/storefront/blocks/ProductSpecsBlock.jsx +0 -33
  54. package/src/commerce/storefront/blocks/ProductStripBlock.jsx +0 -55
  55. package/src/commerce/storefront/blocks/QuantityStepper.jsx +0 -62
  56. package/src/commerce/storefront/blocks/ReviewsBlock.jsx +0 -191
  57. package/src/commerce/storefront/blocks/TotalsBlock.jsx +0 -42
  58. package/src/commerce/storefront/blocks/VariantSelectorBlock.jsx +0 -81
  59. package/src/commerce/storefront/blocks/index.js +0 -44
@@ -21,7 +21,7 @@ What lands in the app, how the admin gets mounted, and the order to do the rest
21
21
  | `base44/entities/commerce.*.jsonc` | 20 entity schemas, all admin-only RLS |
22
22
  | `base44/functions/commerce/` + `shared/` + `agents/` | 16 functions, the engine, the StoreAdmin copilot |
23
23
  | `src/commerce/admin/` | the finished admin app — **don't validate it, it ships tested** |
24
- | `src/commerce/storefront/` + `utils/` | the hooks and blocks you build against ([`./02-storefront.md`](./02-storefront.md)) |
24
+ | `src/commerce/storefront/` + `utils/` | the headless hooks you build the storefront UI on ([`./02-storefront.md`](./02-storefront.md)) |
25
25
  | `.agents/skills/commerce/` | these docs |
26
26
 
27
27
  <details>
@@ -61,7 +61,7 @@ import { Navigate } from "react-router-dom";
61
61
 
62
62
  - **The `/*` splat is required.** The admin renders nested routes; a bare `path="/store-admin"` matches only the dashboard and every deeper link 404s. Mounting elsewhere: `<AdminApp basePath="/backoffice" />` — the prefix *without* the splat.
63
63
  - **Give `/` something.** A blank Base44 app has no `/` route, so after mounting only the admin the app's own URL renders "page not found", which reads exactly like a broken install. Redirect until the storefront exists.
64
- - **`/order-received` is mandatory**, even for a store that only ever takes offline payments. Every payment link (checkout, the admin's payment link, emails) returns there, and confirming is what marks an order paid — without the route a paying customer hits a 404 and the order stays unpaid. The page body is one block, `<OrderReceivedBlock />` ([`./02-storefront.md`](./02-storefront.md)). A different path must be set in Settings → General → *Payment return path*.
64
+ - **`/order-received` is mandatory**, even for a store that only ever takes offline payments. Every payment link (checkout, the admin's payment link, emails) returns there, and confirming is what marks an order paid — without the route a paying customer hits a 404 and the order stays unpaid. The page is one hook, `useOrderReturn()`, plus your markup for its states ([`./02-storefront.md`](./02-storefront.md)). A different path must be set in Settings → General → *Payment return path*.
65
65
 
66
66
  ## Admin-role enforcement — do not weaken
67
67
 
@@ -5,19 +5,57 @@ skip_when: "The storefront pages already render against live data and pass the c
5
5
  forget_when: "The checklist at the bottom passes — every page renders against the seeded catalog and an offline order completes."
6
6
  carry_forward:
7
7
  - "Payment gateways, currency and countries come from useStoreInfo() only — never off a cart (cart.payment_gateways is always undefined)."
8
- - "A store with any coupons must have a coupon field. <CartLinesBlock/> and <CheckoutBlock/> both ship one by default (showCoupon) keep it unless the store has no codes."
9
- - "/order-received renders <OrderReceivedBlock/>, which shows paymentInstructions — how a normal (offline) customer learns how to pay."
8
+ - "A store with any coupons must have a coupon field (useCoupon) in the cart or the checkout, or its codes can never be redeemed."
9
+ - "/order-received is mandatory and renders useOrderReturn's states, including paymentInstructions — how a normal (offline) customer learns how to pay."
10
10
  - "Branch cart/list/product UI on `status`, never on `isEmpty`/nullable data — `isEmpty` is false while loading by design."
11
+ - "Variant options that aren't buyable render disabled, never hidden; one control per axis, never a list of variations."
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\"."
11
13
  ---
12
14
 
13
15
  # 02 — Storefront
14
16
 
15
- Two tiers, and the split decides how much you write.
16
-
17
- - **Identity tier yours, always.** Home, the collection grid, **the product card**, the product page's layout, typography, motion, theme. Where "make it look like X" lives; no markup ships for it.
18
- - **Commodity tier — ships as blocks.** Checkout, cart lines, totals, coupon field, reviews, order-received, and the product page's internals (variant selector, gallery, specs, breadcrumbs, strips). Every store's version is functionally identical: **restyle or replace them, never hand-roll their logic.**
19
-
20
- Blocks are thin compositions of the same package's hooks, styled by inheritance — semantic markup, your theme tokens, a `data-commerce="…"` attribute on every element. Each takes `className`, most take `slots` or a render prop, so outgrowing one means rewriting *one region* against hooks you already know. Everything imports from `@/commerce/storefront`.
17
+ One split decides everything here: **the logic is premade, the UI never is.**
18
+
19
+ - **Logichooks, 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
+ - **UI — yours, 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`.
21
59
 
22
60
  ## Setup — once
23
61
 
@@ -39,9 +77,16 @@ import { base44 } from "@/api/base44Client";
39
77
  </BrowserRouter>
40
78
  ```
41
79
 
42
- The provider owns the shared client, the store-info cache and **one** shared cart, so a header badge, a drawer and the checkout render the same state. Never mount a second provider, and never touch the `cart_token` — the provider owns its whole lifecycle.
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.
43
84
 
44
- > ⚠ **`<Routes>` accepts only `<Route>` children.** Nesting the provider inside it — the natural reading of "wrap the storefront routes" — throws at render: `Error: [StorefrontProvider] is not a <Route> component. All component children of <Routes> must be a <Route> or <React.Fragment>`. To scope the provider to storefront routes only, use a pathless **layout route**, the one place a wrapper is legal:
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:
45
90
  > ```jsx
46
91
  > <Route element={<StorefrontProvider base44={base44}><Outlet /></StorefrontProvider>}>
47
92
  > <Route path="/" element={<Home />} />
@@ -50,124 +95,361 @@ The provider owns the shared client, the store-info cache and **one** shared car
50
95
  > <Route path="/store-admin/*" element={<AdminApp />} /> {/* outside the provider */}
51
96
  > ```
52
97
 
53
- ## Product list / collection — identity tier
98
+ ## Product list / collection
99
+
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 renders `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`. Full field matrix:
114
+ [`../references/catalog-rendering.md`](../references/catalog-rendering.md).
115
+
116
+ **Rails** (featured row, "new in") are the same hook with a filter
117
+ (`{ featured: true, per_page: 4 }`). ⚑ Any filter may legitimately match
118
+ nothing — render *nothing* then, never a heading over an empty row. Upsells
119
+ beside a product need no query: `p.upsells` / `p.crossSells` from `useProduct`.
120
+
121
+ ## Product page
122
+
123
+ `useProduct(slug)` (or `{ id }`) owns fetch + variant selection + quantity +
124
+ price, race-safe, selection mirrored to the URL. ⚑ `status: "not_found"` is a
125
+ 404 page, not a spinner.
126
+
127
+ ⚑ **Call every hook above the status guards.** This page needs more than one,
128
+ and a hook placed after an early `return` runs on some renders but not others —
129
+ React then throws *"Rendered more hooks than during the previous render"* the
130
+ moment the product resolves. All of these tolerate a null/loading product
131
+ precisely so they can sit at the top:
54
132
 
55
133
  ```jsx
56
- const list = useProductList({ per_page: 12, sort: "-created_date" });
57
- const { items: categories } = useCategories(); // ARRAY, children nested
58
-
59
- if (list.status === "error") return <ErrorState onRetry={list.reload} />;
60
- if (list.status === "empty") return <EmptyState />;
61
-
62
- {list.products.map((p) => <MyCard key={p.id} product={p} />)} {/* your card */}
63
- {list.hasNext && <button onClick={list.next}>Next</button>}
64
- // list.setParams({ category_id, search, on_sale: true, min_price, in_stock_only })
65
- // → resets to page 1, keeps the current rows on screen (list.refreshing) while the page loads
66
- ```
67
-
68
- `status` is `"loading" | "ready" | "empty" | "error"` — branch on it, so a failed request renders as a failure instead of an empty grid. `useRibbons()` has the same shape as `useCategories()`.
69
-
70
- A card uses `name`, `images[0]?.src` (**images are `{src,name,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`. Full field matrix: [`../references/catalog-rendering.md`](../references/catalog-rendering.md).
134
+ const p = useProduct(slug);
135
+ const g = useProductGallery(p.product, p.view);
136
+ const buy = useAddToCartButton(p, { onAdded: () => navigate("/bag") });
137
+ useStorefrontSeo(productSeo(p.product, p.view, { storeName, currency }));
71
138
 
72
- **Rails** (featured row, "new in", upsells) are one block, and `renderCard` is required because the card is identity tier:
73
-
74
- ```jsx
75
- <ProductStripBlock params={{ featured: true, per_page: 4 }} title="Featured"
76
- renderCard={(p) => <MyCard key={p.id} product={p} />} />
77
- // or products={p.upsells} (from useProduct) for rows you already have. A filter may match nothing —
78
- // the block renders nothing at all rather than a heading over an empty row.
79
- ```
80
-
81
- ## Product page — custom layout, blocks inside
82
-
83
- ```jsx
84
- const p = useProduct(slug); // slug from the route; { id } also works
85
- if (p.status === "loading") return <Skeleton />;
86
- if (p.status === "not_found") return <NotFound />; // a 404 is a status, not a spinner
139
+ if (p.status === "loading") return /* your loading state */;
140
+ if (p.status === "not_found") return /* your 404 */;
87
141
  const { product, view, price, categories } = p;
88
-
89
- <BreadcrumbsBlock categories={categories} current={product.name} />
90
- <ProductGalleryBlock product={product} view={view} imageClassName="aspect-[3/4] object-cover" />
91
- <h1>{product.name}</h1>
92
- <p>{price.label}{price.compareAtLabel && <s>{price.compareAtLabel}</s>}</p>
93
- <VariantSelectorBlock view={view} onPick={p.pick} /> {/* one control per axis */}
94
- <AddToCartBlock product={p} onAdded={() => navigate("/bag")} /> {/* pass the whole hook result */}
95
- <div dangerouslySetInnerHTML={{ __html: product.description }} /> {/* HTML — render as rich text */}
96
- <ProductSpecsBlock product={product} /> {/* product.meta_data → spec table */}
97
- <ReviewsBlock product={product} />
98
142
  ```
99
143
 
100
- `view` is the resolved selection: `view.axes` (one control each), `view.availability` (unbuyable options render *disabled*, not hiddenthe block does this), `view.purchasable`, `view.addToCart` (`{product_id, variation_id}` — a product with attributes is rejected without it). `p.price` is the *current* selection's price, a range until it resolves; the selection is mirrored to the URL, so a variant is linkable. Custom buy box: `p.pick`, `p.quantity`/`p.incQuantity`/`p.maxQuantity`, and `useAddToCart()` whose `add()` never throws and resolves `{ ok, error: { code, message, shouldReload } }`.
101
-
102
- **Reviews** are the one line above. The custom path is `useProductReviews(product, { policy, user })` — list, paging, aggregate rating, and the submit form with field-level errors; `policy` is `"open" | "login" | "verified_buyers"`, and the confirmation copy comes from the server's response, so it is right whether or not the store auto-approves.
103
-
104
- ## Cart / bagcommodity tier
105
-
106
- A cart *page* is optional: a store selling one made-to-order piece reads better as buy-now straight to checkout. When you build one:
144
+ (`variantAxes` and `productSpecs` are plain functions, not hooksthey can go
145
+ anywhere.) Build your layout from:
146
+
147
+ - **Price** — `price.label`, plus `price.compareAtLabel` (struck through) when
148
+ on sale. Never read `product.price` directly the parent's price is a
149
+ rolled-up from-price.
150
+ - **Gallery** `g` from above `{ hasImages, images, active, activeIndex,
151
+ setActiveIndex, next, prev }`. The active image already follows the variant
152
+ selection; `hasImages: false` means render your placeholder.
153
+ - **Variant selector** — `variantAxes(view, p.pick)` → one entry per axis:
154
+ `{ key, name, selectedOption, options: [{ value, selected, disabled,
155
+ outOfStock, pick }] }`. Map it to any control — buttons, swatches, a dropdown.
156
+ ⚑ **One control per axis, never a list of variations** (`Red / S`, `Red / M`,
157
+ … is n × m noise), and ⚑ **an unbuyable option renders `disabled`, never
158
+ hidden** (`outOfStock` stays visible, just marked) — a customer who can't see
159
+ that a size exists assumes the store doesn't carry it. `view.missingAxes`
160
+ names what's still unpicked. The shape of the map (the one interaction agents
161
+ reliably get wrong — the control itself is yours):
162
+
163
+ ```jsx
164
+ {variantAxes(view, p.pick).map((axis) => (
165
+ <fieldset key={axis.key}>{/* label from axis.name / axis.selectedOption */}
166
+ {axis.options.map((o) => (
167
+ <button key={o.value} disabled={o.disabled} aria-pressed={o.selected} onClick={o.pick}>
168
+ {o.value}{/* o.outOfStock → mark visibly, keep clickable-looking off */}
169
+ </button>
170
+ ))}
171
+ </fieldset>
172
+ ))}
173
+ ```
174
+ - **Buy box** — `buy` from above → `{ add, adding, error, disabled, soldOut,
175
+ needsSelection, quantity, increase, decrease, canIncrease, canDecrease,
176
+ showQuantity }`. It gates on purchasability, recovers from every add failure
177
+ and clamps quantity to stock and `sold_individually`. ⚑ Render `error.message`
178
+ inline; ⚑ `showQuantity: false` means no stepper (only 1 can be bought); the
179
+ button label should reflect `adding`/`soldOut`/`needsSelection` — the words
180
+ are yours.
181
+ - **Description** — `product.description` is HTML; render as rich text
182
+ (`dangerouslySetInnerHTML`), `short_description` above it.
183
+ - **Specs** — `productSpecs(product)` → `[{ key, label, value }]` from
184
+ `meta_data` (Material, Care). `[]` means no section at all.
185
+ - **Breadcrumbs** — build from `categories`
186
+ (`/collection?category_id=${c.id}`); skip on a flat catalog. Ribbons are
187
+ labels, not breadcrumbs.
188
+
189
+ All optional — include what this store's products actually have.
190
+
191
+ ### Reviews — optional
192
+
193
+ **Build reviews only if the store wants them** — because the brief asks, or the
194
+ products are the kind customers rate. No review UI means no reviews, and that
195
+ is a complete outcome. (If you skip them, don't put star ratings on cards
196
+ either — an average of nothing is `0`.)
197
+
198
+ `useProductReviews(product, { policy, user })` is the whole surface: `items`,
199
+ paging (`hasNext`/`loadMore`), `averageRating`/`ratingCount`, and the submit
200
+ form — `form`/`setField`/`fieldErrors` (matching the server's error codes),
201
+ `valid`, `submit`, `requiresEmail` (false for a signed-in visitor),
202
+ `reviewBlockedReason` (`"login_required"` / `"not_a_buyer"` under the stricter
203
+ policies). ⚑ The confirmation copy is `message`, **taken from the server's
204
+ response** — a store with auto-approval on says "published", not "awaiting
205
+ approval", so render `message`, never your own text. `policy` is
206
+ `"open" | "login" | "verified_buyers"`. Details beyond this:
207
+ [`../references/reviews.md`](../references/reviews.md).
208
+
209
+ ## Cart / bag
210
+
211
+ A cart *page* is optional: a store selling one made-to-order piece reads better
212
+ as buy-now straight to checkout. The surface is four hooks: `useCart()`
213
+ (`status`, `lines`, `notices`), `CartLine` (headless render-prop binding
214
+ `useCartLine` per row — quantity stepping that clamps, coalesces and recovers),
215
+ `useTotalsLines()`, `useCoupon()`.
216
+
217
+ ⚑ Rules: branch on `status`, never on emptiness while loading. Render
218
+ `notices` — they say what auto-dropped from the cart and why. Render every
219
+ non-`hidden` totals line rather than hardcoding subtotal/total — a hand-written
220
+ summary omits discount and tax, then stops adding up the day a coupon or a tax
221
+ rate exists. **A store with any coupons must have a coupon field** (here or in
222
+ the checkout): coupons are admin-only data, redeemable only through a field the
223
+ customer types into — if no field exists anywhere, don't seed coupons and don't
224
+ write "use WELCOME10" in the copy.
225
+
226
+ **Reference implementation** — read once for the wiring, then write your own
227
+ page: the structure below is correct, the presentation is deliberately absent.
228
+ Restyle, rearrange, split into your own components; the ⚑ rules are the part
229
+ that must survive.
107
230
 
108
231
  ```jsx
109
- const { status } = useCart();
110
- if (status === "loading") return <Skeleton />; // never branch on isEmpty while loading
111
- if (status === "empty") return <EmptyBag />;
112
-
113
- <CartLinesBlock /> {/* lines, variant labels, steppers, and the notices saying what auto-dropped */}
114
- {/* <CartLinesBlock/> already renders the coupon field (showCoupon, default on) */}
115
- <TotalsBlock /> {/* subtotal · discount · shipping · tax · total, zero rows hidden */}
116
- <Link to="/checkout">Checkout</Link>
232
+ function Bag() {
233
+ const { status, lines, notices } = useCart();
234
+ const totals = useTotalsLines();
235
+ const formatMoney = useFormatMoney();
236
+ if (status === "loading") return /* your loading state */;
237
+ if (status === "empty") return /* your empty-bag state, linking back to the catalog */;
238
+ return (
239
+ <>
240
+ {notices.map((n, i) => <p key={i} role="status">{n.message}</p>)}
241
+ {lines.map((line) => (
242
+ <CartLine key={line.item_key} line={line}>
243
+ {(l) => ( /* line: name, attributesLabel, image, total — l: the controls */
244
+ <li>
245
+ {line.name} {line.attributesLabel}
246
+ <button onClick={l.decrease} disabled={!l.canDecrease || l.pending}>−</button>
247
+ {l.quantity}
248
+ <button onClick={l.increase} disabled={!l.canIncrease || l.pending}>+</button>
249
+ <button onClick={l.remove}>Remove</button>
250
+ {formatMoney(line.total)}
251
+ {l.error && <p role="alert">{l.error.message}</p>}
252
+ </li>
253
+ )}
254
+ </CartLine>
255
+ ))}
256
+ <CouponField /> {/* useCoupon: code/setCode, apply, applying, error, applied[] + remove */}
257
+ {totals.filter((l) => !l.hidden).map((l) => (
258
+ <div key={l.key}>{l.label} {l.formatted}</div> /* l.emphasis → the total row */
259
+ ))}
260
+ <Link to="/checkout">Checkout</Link>
261
+ </>
262
+ );
263
+ }
117
264
  ```
118
265
 
119
- **A store with any coupons must have a coupon field, or its codes can never be redeemed.** Coupons are admin-only data a storefront cannot list codes, so the only way in is a field the customer types into. Both `<CartLinesBlock/>` and `<CheckoutBlock/>` render one by default (`showCoupon`), so the safe outcome is the one you get for free; pass `showCoupon={false}` only for a store with no codes. Standalone, the field is `<CouponFieldBlock/>`. If no field exists anywhere, don't seed coupons and don't write "use WELCOME10" in the copy.
266
+ No shipping estimator herecheckout reprices shipping and tax from the
267
+ address.
268
+
269
+ ## Checkout
270
+
271
+ The state machine is `useCheckout`, shared across the page's regions by
272
+ `CheckoutProvider` + `useCheckoutContext()`. It reprices shipping/tax from the
273
+ address automatically (debounced, never on a half-typed address), derives the
274
+ shipping and payment choices, gates the button (`canPlaceOrder` +
275
+ `useCheckoutBlockers()` in words), and `placeOrder()` handles **both**
276
+ navigations — online gateway → provider redirect, everything else →
277
+ `/order-received`. The address form comes from `useAddressForm(which)` as a
278
+ field spec (`state` collected, country options never null); the two
279
+ store-data choices come through the headless `ShippingMethodPicker` /
280
+ `PaymentMethodPicker`, whose render props enumerate every branch.
281
+
282
+ ⚑ Rules: handle every picker branch (they exist because every one occurs in a
283
+ normal store); a single shipping or payment option still *shows* what it is —
284
+ never a picker of one, never "nothing selected"; zero gateways → say checkout
285
+ is unavailable instead of a dead button; keep each field's `autoComplete` (the
286
+ spec provides it) and render `f.error` — "we don't ship there" arrives on the
287
+ country field; show `orderError.message` and the blockers so the gate explains
288
+ itself. ⚑ Payment methods, currency and countries come from `useStoreInfo()`
289
+ only — `cart.payment_gateways` is always `undefined`, and a default store
290
+ offers `offline` only ([`./03-data.md`](./03-data.md)).
291
+
292
+ **Reference implementation** — the densest wiring in the storefront; read it,
293
+ then build yours around it. Structure correct, presentation absent.
120
294
 
121
- No shipping estimator here — `useCheckout` reprices shipping and tax from the address. Custom rows: `useCartLine(line)` (optimistic, clamped, coalesced) and `useCoupon()`.
295
+ ```jsx
296
+ function Checkout() { // hooks read the context BELOW the provider
297
+ return <CheckoutProvider><CheckoutForm /></CheckoutProvider>;
298
+ }
122
299
 
123
- ## Checkout — one block
300
+ function CheckoutForm() {
301
+ const { status } = useCart();
302
+ const checkout = useCheckoutContext();
303
+ const blockers = useCheckoutBlockers();
304
+ const formatMoney = useFormatMoney();
305
+ if (status === "loading") return /* loading */;
306
+ if (status === "empty") return /* "your bag is empty" — a checkout with nothing says so */;
307
+ return (
308
+ <>
309
+ <AddressFields which="billing" />
310
+ <label>
311
+ <input type="checkbox" checked={checkout.shipToDifferent}
312
+ onChange={(e) => checkout.setShipToDifferent(e.target.checked)} />
313
+ Deliver to a different address
314
+ </label>
315
+ {checkout.shipToDifferent && <AddressFields which="shipping" />}
316
+
317
+ <ShippingMethodPicker>
318
+ {({ status, methods, chosen, choose, mustChoose, syncing }) => (
319
+ <fieldset>{/* syncing → subtle busy state; renders null for a virtual cart */}
320
+ {status === "missing_address" && <p>Delivery options appear once your address is entered.</p>}
321
+ {status === "none_available" && <p role="alert">We don't deliver to that address yet.</p>}
322
+ {mustChoose && methods.map((m) => (
323
+ <label key={m.id}>
324
+ <input type="radio" checked={chosen?.id === m.id} onChange={() => choose(m.id)} />
325
+ {m.title} {formatMoney(m.cost)}
326
+ </label>
327
+ ))}
328
+ {!mustChoose && chosen && <p>{chosen.title} {formatMoney(chosen.cost)}</p>}
329
+ </fieldset>
330
+ )}
331
+ </ShippingMethodPicker>
332
+
333
+ <PaymentMethodPicker>
334
+ {({ gateways, value, select, selected, single }) => (
335
+ <fieldset>
336
+ {gateways.length === 0 && <p role="alert">No payment method is available right now.</p>}
337
+ {!single && gateways.map((g) => (
338
+ <label key={g.slug}>
339
+ <input type="radio" checked={value === g.slug} onChange={() => select(g.slug)} />
340
+ {g.title} {g.description}
341
+ </label>
342
+ ))}
343
+ {single && selected && <p>{selected.title}</p>}
344
+ </fieldset>
345
+ )}
346
+ </PaymentMethodPicker>
347
+
348
+ {/* summary: coupon field (if not in the cart) + useTotalsLines(), as in the cart page */}
349
+
350
+ <button disabled={!checkout.canPlaceOrder || checkout.placing} onClick={() => checkout.placeOrder()}>
351
+ {checkout.placing ? "Placing your order…" : "Place order"}
352
+ </button>
353
+ {checkout.orderError && <p role="alert">{checkout.orderError.message}</p>}
354
+ {!checkout.canPlaceOrder && blockers.map((b) => <p key={b.code}>{b.message}</p>)}
355
+ </>
356
+ );
357
+ }
124
358
 
125
- ```jsx
126
- export default function Checkout() {
127
- return <main className="mx-auto max-w-3xl px-6 py-16">
128
- <h1 className="font-heading text-5xl">Checkout</h1>
129
- <CheckoutBlock />
130
- </main>;
359
+ function AddressFields({ which }) {
360
+ const { fields, countriesLoading } = useAddressForm(which);
361
+ return fields.map((f) => ( /* each field carries its own setter: f.set */
362
+ <label key={f.key}>
363
+ {f.label}{f.required && " *"}
364
+ {f.type === "select" ? (
365
+ <select value={f.value} onChange={(e) => f.set(e.target.value)} autoComplete={f.autoComplete}>
366
+ <option value="">{f.key === "country" && countriesLoading ? "Loading…" : `Select ${f.label}`}</option>
367
+ {f.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
368
+ </select>
369
+ ) : (
370
+ <input type={f.type} value={f.value} required={f.required}
371
+ onChange={(e) => f.set(e.target.value)} autoComplete={f.autoComplete} />
372
+ )}
373
+ {f.error && <span role="alert">{f.error}</span>}
374
+ </label>
375
+ ));
131
376
  }
132
377
  ```
133
378
 
134
- That is the whole page: address → delivery → payment → coupon → summary → place order, with its own `<CheckoutProvider>`. Correct with zero props in a default-seeded store, and every branch a checkout must handle is wired: shipping repricing from the address, a single option reading as selected, a store with no enabled gateway saying so instead of rendering a dead button, the place-order gate explaining itself, the redirect for an online gateway. Restyle with `className`, replace a region with `slots={{ summary: <MySummary/> }}`, or set `onPlaced`/`orderReceivedPath`.
379
+ ## Order received
135
380
 
136
- Escalate only for different *structure*, and then to the hooks the block itself uses: `useCheckout` / `CheckoutProvider` / `useCheckoutContext`, `useAddressForm("billing")` (fields including `state`, country options never null), `<ShippingMethodPicker>` / `<PaymentMethodPicker>` (headless render props over the two choices that are store data), `useTotalsLines()`, `useCheckoutBlockers()`.
381
+ **Mandatory route** every payment link returns here, and confirming is what
382
+ marks a card order paid. `useOrderReturn()` is the whole page's logic: it reads
383
+ `order_id`/`order_key` from the URL, verifies with the provider (idempotent),
384
+ and marks the page noindex itself.
137
385
 
138
- **Payment methods, currency and countries come from `useStoreInfo()` only** `cart.payment_gateways` is always `undefined`, and a default store offers `offline` only ([`./03-data.md`](./03-data.md)).
386
+ Rules: render all five states never a blank page while `"loading"`, and a
387
+ retry via `reload()` on `"error"`. ⚑ **Never drop `paymentInstructions`**: a
388
+ manual/offline order settles outside the store, so these ARE how the store's
389
+ default customer learns how to pay — render them whenever present, on any
390
+ state. ⚑ An order's totals are **flat** — `order.total`; there is no
391
+ `order.totals` (use `useTotalsLines(order)`).
139
392
 
140
- ## Order received
393
+ **Reference implementation** — a receipt is a convention, not an identity
394
+ surface: take this structure, restyle it to the store, keep every branch.
141
395
 
142
396
  ```jsx
143
- <main className="mx-auto max-w-2xl px-6 py-20"><OrderReceivedBlock /></main>
397
+ function OrderReceived() {
398
+ const { status, order, lines, paymentLink, paymentInstructions, error, reload } = useOrderReturn();
399
+ const totals = useTotalsLines(order);
400
+ const formatMoney = useFormatMoney();
401
+ if (status === "loading") return /* confirming copy */;
402
+ if (status === "error") return <><p role="alert">{error.message}</p>
403
+ <button onClick={() => reload()}>Try again</button></>;
404
+ return (
405
+ <>
406
+ {status === "paid" && /* thank-you heading */}
407
+ {status === "unpaid" && <>{/* awaiting-payment heading */}
408
+ {paymentLink?.url && <a href={paymentLink.url}>Pay now</a>}</>}
409
+ {status === "cancelled" && <>{/* payment-cancelled heading */}
410
+ {paymentLink?.url && <a href={paymentLink.url}>Try payment again</a>}</>}
411
+ {order?.order_number && <p>Order {order.order_number}</p>}
412
+ {paymentInstructions && (
413
+ <section>{/* "How to pay" — the offline customer's next step */}
414
+ {paymentInstructions.description && <p>{paymentInstructions.description}</p>}
415
+ {paymentInstructions.account_details && Object.entries(paymentInstructions.account_details)
416
+ .map(([k, v]) => <p key={k}>{k.replace(/_/g, " ")}: {String(v)}</p>)}
417
+ </section>
418
+ )}
419
+ {lines.map((l, i) => <p key={i}>{l.name} {l.attributesLabel} × {l.quantity} — {formatMoney(l.total)}</p>)}
420
+ {totals.filter((t) => !t.hidden).map((t) => <p key={t.key}>{t.label} {t.formatted}</p>)}
421
+ </>
422
+ );
423
+ }
144
424
  ```
145
425
 
146
- Mandatory route. It renders all five states, including the two hand-written pages drop: **`paymentInstructions` for a manual/offline order** — the default gateway, so this is how the store's normal customer learns how to pay — and the pay-now link for an unpaid card order. It is `noindex`, as a receipt carrying an order key should be. Custom version: `useOrderReturn()` + `useTotalsLines(order)` (an order's totals are **flat** — `order.total`; there is no `order.totals`).
147
-
148
426
  ## SEO — one line per page type
149
427
 
150
428
  ```jsx
151
429
  useStorefrontSeo(productSeo(p.product, p.view, { storeName, currency })); // product page
152
430
  useStorefrontSeo(collectionSeo({ title, products: list.products })); // collection / home
153
- useStorefrontSeo(orderSeo(order)); // order-received & checkout noindex
431
+ // order-received is already noindex via useOrderReturn
154
432
  ```
155
433
 
156
- It is a hook call it above the page's early returns (the builders tolerate a null product).
434
+ The `*Seo` builders tolerate a null product, so this sits with the other hooks
435
+ above the status guards.
157
436
 
158
437
  ## Per-page output budgets
159
438
 
160
439
  | Page | budget (chars) | rationale |
161
440
  |---|---|---|
162
- | Checkout | ≤ 2K | `<CheckoutBlock/>` + theme overrides; hand-rolling any step means a block escape hatch was missed |
163
- | Cart / bag | ≤ 2K | `<CartLinesBlock/>` + `<TotalsBlock/>` + empty state |
164
- | Order-received | ≤ 1.5K | `<OrderReceivedBlock/>` + brand framing |
165
- | Product page | ≤ 4K | custom layout around `useProduct` + the five blocks above |
441
+ | Checkout | ≤ 5K | your markup over the reference above the logic is all hook calls |
442
+ | Cart / bag | ≤ 3K | `useCart` + `CartLine` rows + totals + coupon + empty state |
443
+ | Order-received | ≤ 2.5K | five states + payment instructions + summary |
444
+ | Product page | ≤ 5K | your layout and type around `useProduct`, `variantAxes`, `useAddToCartButton`, the gallery |
166
445
  | Collection | ≤ 3K | `useProductList` + custom card + pagination controls |
167
- | Home | ≤ 5K | pure identity tier — hero/editorial earn their chars |
446
+ | Home | ≤ 5K | pure identity — hero/editorial earn their chars |
168
447
  | Any single component file | ≤ 4K, hard ceiling 8K | Base1 evidence: decode is 34% of wall; a 12K file is a 45s write batch |
169
448
 
170
- Over budget extract components, or adopt the block you are re-implementing. **A page re-implementing something a block ships — an address step, a quantity stepper, a totals row, a variant control — has missed an escape hatch: take the block and restyle it.**
449
+ These budgets assume the hooks carry the logic and your markup carries only the
450
+ design. Over budget ⇒ you are re-implementing something a hook does — an
451
+ address spec, a quantity clamp, totals math, variant resolution, add-to-cart
452
+ error recovery. Go back to the hook and delete your version.
171
453
 
172
454
  ## Done — forget this file
173
455
 
@@ -175,14 +457,19 @@ Over budget ⇒ extract components, or adopt the block you are re-implementing.
175
457
  - [ ] **One** `<StorefrontProvider>` above every storefront route, wrapping `<Routes>` (or a layout route's `<Outlet/>`); one client, no hand-rolled `cart_token`.
176
458
  - [ ] Pages branch on `status`; no page maps a possibly-null list or shows an empty state while loading.
177
459
  - [ ] Gateways/currency/countries read from `useStoreInfo()` only.
178
- - [ ] If the store has coupons, a coupon field exists in the cart or the checkout.
179
- - [ ] `/order-received` renders `<OrderReceivedBlock/>` (payment instructions included).
460
+ - [ ] If the store has coupons, a coupon field (`useCoupon`) exists in the cart or the checkout.
461
+ - [ ] `/order-received` renders `useOrderReturn`'s states **including `paymentInstructions`**.
462
+ - [ ] Variant options render one control per axis; unbuyable options are disabled, not hidden.
463
+ - [ ] No hook logic was re-implemented by hand (address fields, quantity clamps, totals rows, shipping/payment branching).
464
+ - [ ] The storefront carries the design you settled on before reading this file — no page ships the reference snippets' bare structure or placeholder copy.
180
465
  - [ ] Every page is within its budget above.
181
466
  - [ ] A real purchase completes in the preview — pick a variant, add it, check out, place an offline order, land on `/order-received`.
182
467
 
183
468
  Record these lines in your working notes; do not re-read this file.
184
469
 
185
470
  - Payment gateways, currency and countries come from `useStoreInfo()` only — never off a cart (`cart.payment_gateways` is always undefined).
186
- - A store with any coupons must have a coupon field. `<CartLinesBlock/>` and `<CheckoutBlock/>` both ship one by default (`showCoupon`) keep it unless the store has no codes.
187
- - `/order-received` renders `<OrderReceivedBlock/>`, which shows `paymentInstructions` — how a normal (offline) customer learns how to pay.
471
+ - A store with any coupons must have a coupon field (`useCoupon`) in the cart or the checkout, or its codes can never be redeemed.
472
+ - `/order-received` is mandatory and renders `useOrderReturn`'s states, including `paymentInstructions` — how a normal (offline) customer learns how to pay.
188
473
  - Branch cart/list/product UI on `status`, never on `isEmpty`/nullable data — `isEmpty` is false while loading by design.
474
+ - Variant options that aren't buyable render disabled, never hidden; one control per axis, never a list of variations.
475
+ - 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".