@base44/app-plugin-commerce 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) 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/storefront-catalog/entry.ts +1 -1
  11. package/base44/functions/commerce/storefront-checkout/entry.ts +1 -1
  12. package/base44/shared/commerce/card-payment.stripe.ts +29 -9
  13. package/base44/shared/commerce/card-payment.ts +1 -1
  14. package/base44/shared/commerce/payments.ts +2 -2
  15. package/base44/shared/commerce/scan.ts +1 -1
  16. package/base44/shared/commerce/sequence.ts +2 -2
  17. package/package.json +1 -1
  18. package/scripts/install.js +1 -1
  19. package/skills/commerce/SKILL.md +36 -26
  20. package/skills/commerce/docs/api-storefront.md +6 -6
  21. package/skills/commerce/install/01-install.md +2 -2
  22. package/skills/commerce/install/02-storefront.md +355 -99
  23. package/skills/commerce/install/03-data.md +5 -5
  24. package/skills/commerce/references/catalog-rendering.md +6 -6
  25. package/skills/commerce/references/online-payments.md +5 -6
  26. package/skills/commerce/references/reviews.md +5 -5
  27. package/src/commerce/admin/README.md +6 -3
  28. package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
  29. package/src/commerce/admin/pages/products/Reviews.jsx +1 -1
  30. package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -1
  31. package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +1 -1
  32. package/src/commerce/storefront/index.js +45 -33
  33. package/src/commerce/storefront/useCartLine.js +37 -0
  34. package/src/commerce/storefront/useCheckout.jsx +18 -6
  35. package/src/commerce/storefront/useOrderReturn.js +36 -10
  36. package/src/commerce/storefront/useProduct.js +68 -0
  37. package/src/commerce/utils/index.js +9 -6
  38. package/src/commerce/utils/shipping-promos.js +2 -2
  39. package/src/commerce/utils/specs.js +26 -0
  40. package/src/commerce/utils/variants.js +49 -2
  41. package/src/commerce/storefront/blocks/AddToCartBlock.jsx +0 -86
  42. package/src/commerce/storefront/blocks/AddressFieldsBlock.jsx +0 -96
  43. package/src/commerce/storefront/blocks/BreadcrumbsBlock.jsx +0 -52
  44. package/src/commerce/storefront/blocks/CartLinesBlock.jsx +0 -98
  45. package/src/commerce/storefront/blocks/CheckoutBlock.jsx +0 -247
  46. package/src/commerce/storefront/blocks/CouponFieldBlock.jsx +0 -84
  47. package/src/commerce/storefront/blocks/OrderReceivedBlock.jsx +0 -129
  48. package/src/commerce/storefront/blocks/ProductGalleryBlock.jsx +0 -66
  49. package/src/commerce/storefront/blocks/ProductSpecsBlock.jsx +0 -33
  50. package/src/commerce/storefront/blocks/ProductStripBlock.jsx +0 -55
  51. package/src/commerce/storefront/blocks/QuantityStepper.jsx +0 -62
  52. package/src/commerce/storefront/blocks/ReviewsBlock.jsx +0 -191
  53. package/src/commerce/storefront/blocks/TotalsBlock.jsx +0 -42
  54. package/src/commerce/storefront/blocks/VariantSelectorBlock.jsx +0 -81
  55. package/src/commerce/storefront/blocks/index.js +0 -44
@@ -5,19 +5,45 @@ 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."
11
12
  ---
12
13
 
13
14
  # 02 — Storefront
14
15
 
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`.
16
+ One split decides everything here: **the logic is premade, the UI never is.**
17
+
18
+ - **Logichooks, shipped.** Checkout repricing from the address, variant
19
+ resolution, cart state, coupon redemption, review policies, order-return
20
+ verification. Every store's version of these is functionally identical, and
21
+ hand-writing them is where storefront bugs cluster: **never re-implement what
22
+ a hook does.**
23
+ - **UI — yours, always.** Every element, class, layout and word of copy on
24
+ every page. Nothing in `@/commerce/storefront` renders markup or carries CSS,
25
+ and this file deliberately doesn't hand you page bodies either — the design
26
+ is the part of the storefront only you can do, and it should be designed,
27
+ not assembled.
28
+
29
+ Each hook returns a complete view-model — a `status` to branch on,
30
+ ready-to-map arrays, handlers, error objects — and its **doc comment (JSDoc) is
31
+ the API reference**: open the hook's file when you need exact shapes; don't
32
+ guess fields. This file gives you the routing, each surface's hook, and the
33
+ render rules that keep a store correct (marked ⚑ — these must survive whatever
34
+ design you build).
35
+
36
+ Where you get a **reference implementation** and where you get only the hook is
37
+ deliberate: **cart, checkout and order-received** have reference code below —
38
+ their wiring is dense enough that reading it is cheaper than deriving it, and
39
+ they are conventions (a form, a receipt) where familiarity beats invention.
40
+ The **identity surfaces** — home, collection, the card, the product page's
41
+ layout — get hooks only, on purpose: reference markup there would make every
42
+ store look the same, and their design is the work only you can do. Either way
43
+ the hooks are high-level enough that a page is a handful of calls plus your
44
+ markup — writing more code than the budgets at the bottom allow means you are
45
+ re-deriving logic a hook already owns. Everything imports from
46
+ `@/commerce/storefront`.
21
47
 
22
48
  ## Setup — once
23
49
 
@@ -39,9 +65,16 @@ import { base44 } from "@/api/base44Client";
39
65
  </BrowserRouter>
40
66
  ```
41
67
 
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.
68
+ The provider owns the shared client, the store-info cache and **one** shared
69
+ cart, so a header badge, a drawer and the checkout render the same state. Never
70
+ mount a second provider, and never touch the `cart_token` — the provider owns
71
+ its whole lifecycle.
43
72
 
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:
73
+ > ⚠ **`<Routes>` accepts only `<Route>` children.** Nesting the provider inside
74
+ > it — the natural reading of "wrap the storefront routes" — throws at render:
75
+ > `Error: [StorefrontProvider] is not a <Route> component. All component children of <Routes> must be a <Route> or <React.Fragment>`.
76
+ > To scope the provider to storefront routes only, use a pathless **layout
77
+ > route**, the one place a wrapper is legal:
45
78
  > ```jsx
46
79
  > <Route element={<StorefrontProvider base44={base44}><Outlet /></StorefrontProvider>}>
47
80
  > <Route path="/" element={<Home />} />
@@ -50,124 +83,343 @@ The provider owns the shared client, the store-info cache and **one** shared car
50
83
  > <Route path="/store-admin/*" element={<AdminApp />} /> {/* outside the provider */}
51
84
  > ```
52
85
 
53
- ## Product list / collection — identity tier
54
-
55
- ```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).
71
-
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
86
+ ## Product list / collection
87
+
88
+ `useProductList(params)` → `{ status, products, hasNext, next, refreshing,
89
+ setParams, reload }`. `status` is `"loading" | "ready" | "empty" | "error"`
90
+ branch on it, so a failed request renders as a failure instead of an empty
91
+ grid. `setParams({ category_id, search, on_sale, min_price, in_stock_only })`
92
+ resets to page 1 and keeps the current rows on screen (`refreshing`) while the
93
+ page loads. `useCategories()` / `useRibbons()` `{ items }` (arrays, children
94
+ nested).
95
+
96
+ Your card renders `name`, `images[0]?.src` (⚑ **images are `{src,name,alt}`
97
+ objects and the array may be empty — render a placeholder, never a broken
98
+ `<img>`**), `useProductPrice(row).label` (already "From €19.99" when the
99
+ product sells variants — there is no product `type` flag), `on_sale`,
100
+ `short_description`, `stock_status`, `average_rating`/`rating_count`,
101
+ `ribbons`. Full field matrix:
102
+ [`../references/catalog-rendering.md`](../references/catalog-rendering.md).
103
+
104
+ **Rails** (featured row, "new in") are the same hook with a filter
105
+ (`{ featured: true, per_page: 4 }`). Any filter may legitimately match
106
+ nothing — render *nothing* then, never a heading over an empty row. Upsells
107
+ beside a product need no query: `p.upsells` / `p.crossSells` from `useProduct`.
108
+
109
+ ## Product page
110
+
111
+ `useProduct(slug)` (or `{ id }`) owns fetch + variant selection + quantity +
112
+ price, race-safe, selection mirrored to the URL. ⚑ `status: "not_found"` is a
113
+ 404 page, not a spinner. Destructure `{ product, view, price, categories }`
114
+ and build your layout from:
115
+
116
+ - **Price** — `price.label`, plus `price.compareAtLabel` (struck through) when
117
+ on sale. Never read `product.price` directly — the parent's price is a
118
+ rolled-up from-price.
119
+ - **Gallery** — `useProductGallery(product, view)` → `{ hasImages, images,
120
+ active, activeIndex, setActiveIndex, next, prev }`. The active image already
121
+ follows the variant selection; `hasImages: false` means render your
122
+ placeholder.
123
+ - **Variant selector** — `variantAxes(view, p.pick)` → one entry per axis:
124
+ `{ key, name, selectedOption, options: [{ value, selected, disabled,
125
+ outOfStock, pick }] }`. Map it to any control — buttons, swatches, a dropdown.
126
+ ⚑ **One control per axis, never a list of variations** (`Red / S`, `Red / M`,
127
+ … is n × m noise), and ⚑ **an unbuyable option renders `disabled`, never
128
+ hidden** (`outOfStock` stays visible, just marked) — a customer who can't see
129
+ that a size exists assumes the store doesn't carry it. `view.missingAxes`
130
+ names what's still unpicked. The shape of the map (the one interaction agents
131
+ reliably get wrong — the control itself is yours):
132
+
133
+ ```jsx
134
+ {variantAxes(view, p.pick).map((axis) => (
135
+ <fieldset key={axis.key}>{/* label from axis.name / axis.selectedOption */}
136
+ {axis.options.map((o) => (
137
+ <button key={o.value} disabled={o.disabled} aria-pressed={o.selected} onClick={o.pick}>
138
+ {o.value}{/* o.outOfStock → mark visibly, keep clickable-looking off */}
139
+ </button>
140
+ ))}
141
+ </fieldset>
142
+ ))}
143
+ ```
144
+ - **Buy box** — `useAddToCartButton(p, { onAdded })` → `{ add, adding, error,
145
+ disabled, soldOut, needsSelection, quantity, increase, decrease, canIncrease,
146
+ canDecrease, showQuantity }`. It gates on purchasability, recovers from every
147
+ add failure and clamps quantity to stock and `sold_individually`. ⚑ Render
148
+ `error.message` inline; ⚑ `showQuantity: false` means no stepper (only 1 can
149
+ be bought); the button label should reflect `adding`/`soldOut`/
150
+ `needsSelection` — the words are yours.
151
+ - **Description** — `product.description` is HTML; render as rich text
152
+ (`dangerouslySetInnerHTML`), `short_description` above it.
153
+ - **Specs** — `productSpecs(product)` → `[{ key, label, value }]` from
154
+ `meta_data` (Material, Care). `[]` means no section at all.
155
+ - **Breadcrumbs** — build from `categories`
156
+ (`/collection?category_id=${c.id}`); skip on a flat catalog. Ribbons are
157
+ labels, not breadcrumbs.
158
+
159
+ All optional — include what this store's products actually have.
160
+
161
+ ### Reviews — optional
162
+
163
+ **Build reviews only if the store wants them** — because the brief asks, or the
164
+ products are the kind customers rate. No review UI means no reviews, and that
165
+ is a complete outcome. (If you skip them, don't put star ratings on cards
166
+ either — an average of nothing is `0`.)
167
+
168
+ `useProductReviews(product, { policy, user })` is the whole surface: `items`,
169
+ paging (`hasNext`/`loadMore`), `averageRating`/`ratingCount`, and the submit
170
+ form — `form`/`setField`/`fieldErrors` (matching the server's error codes),
171
+ `valid`, `submit`, `requiresEmail` (false for a signed-in visitor),
172
+ `reviewBlockedReason` (`"login_required"` / `"not_a_buyer"` under the stricter
173
+ policies). ⚑ The confirmation copy is `message`, **taken from the server's
174
+ response** — a store with auto-approval on says "published", not "awaiting
175
+ approval", so render `message`, never your own text. `policy` is
176
+ `"open" | "login" | "verified_buyers"`. Details beyond this:
177
+ [`../references/reviews.md`](../references/reviews.md).
178
+
179
+ ## Cart / bag
180
+
181
+ A cart *page* is optional: a store selling one made-to-order piece reads better
182
+ as buy-now straight to checkout. The surface is four hooks: `useCart()`
183
+ (`status`, `lines`, `notices`), `CartLine` (headless render-prop binding
184
+ `useCartLine` per row — quantity stepping that clamps, coalesces and recovers),
185
+ `useTotalsLines()`, `useCoupon()`.
186
+
187
+ ⚑ Rules: branch on `status`, never on emptiness while loading. Render
188
+ `notices` — they say what auto-dropped from the cart and why. Render every
189
+ non-`hidden` totals line rather than hardcoding subtotal/total — a hand-written
190
+ summary omits discount and tax, then stops adding up the day a coupon or a tax
191
+ rate exists. **A store with any coupons must have a coupon field** (here or in
192
+ the checkout): coupons are admin-only data, redeemable only through a field the
193
+ customer types into — if no field exists anywhere, don't seed coupons and don't
194
+ write "use WELCOME10" in the copy.
195
+
196
+ **Reference implementation** — read once for the wiring, then write your own
197
+ page: the structure below is correct, the presentation is deliberately absent.
198
+ Restyle, rearrange, split into your own components; the ⚑ rules are the part
199
+ that must survive.
82
200
 
83
201
  ```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
87
- 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 }} /> {/* HTMLrender as rich text */}
96
- <ProductSpecsBlock product={product} /> {/* product.meta_data → spec table */}
97
- <ReviewsBlock product={product} />
202
+ function Bag() {
203
+ const { status, lines, notices } = useCart();
204
+ const totals = useTotalsLines();
205
+ const formatMoney = useFormatMoney();
206
+ if (status === "loading") return /* your loading state */;
207
+ if (status === "empty") return /* your empty-bag state, linking back to the catalog */;
208
+ return (
209
+ <>
210
+ {notices.map((n, i) => <p key={i} role="status">{n.message}</p>)}
211
+ {lines.map((line) => (
212
+ <CartLine key={line.item_key} line={line}>
213
+ {(l) => ( /* line: name, attributesLabel, image, totall: the controls */
214
+ <li>
215
+ {line.name} {line.attributesLabel}
216
+ <button onClick={l.decrease} disabled={!l.canDecrease || l.pending}>−</button>
217
+ {l.quantity}
218
+ <button onClick={l.increase} disabled={!l.canIncrease || l.pending}>+</button>
219
+ <button onClick={l.remove}>Remove</button>
220
+ {formatMoney(line.total)}
221
+ {l.error && <p role="alert">{l.error.message}</p>}
222
+ </li>
223
+ )}
224
+ </CartLine>
225
+ ))}
226
+ <CouponField /> {/* useCoupon: code/setCode, apply, applying, error, applied[] + remove */}
227
+ {totals.filter((l) => !l.hidden).map((l) => (
228
+ <div key={l.key}>{l.label} {l.formatted}</div> /* l.emphasis → the total row */
229
+ ))}
230
+ <Link to="/checkout">Checkout</Link>
231
+ </>
232
+ );
233
+ }
98
234
  ```
99
235
 
100
- `view` is the resolved selection: `view.axes` (one control each), `view.availability` (unbuyable options render *disabled*, not hidden the 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 / bag — commodity 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:
236
+ No shipping estimator herecheckout reprices shipping and tax from the
237
+ address.
238
+
239
+ ## Checkout
240
+
241
+ The state machine is `useCheckout`, shared across the page's regions by
242
+ `CheckoutProvider` + `useCheckoutContext()`. It reprices shipping/tax from the
243
+ address automatically (debounced, never on a half-typed address), derives the
244
+ shipping and payment choices, gates the button (`canPlaceOrder` +
245
+ `useCheckoutBlockers()` in words), and `placeOrder()` handles **both**
246
+ navigations — online gateway → provider redirect, everything else →
247
+ `/order-received`. The address form comes from `useAddressForm(which)` as a
248
+ field spec (`state` collected, country options never null); the two
249
+ store-data choices come through the headless `ShippingMethodPicker` /
250
+ `PaymentMethodPicker`, whose render props enumerate every branch.
251
+
252
+ ⚑ Rules: handle every picker branch (they exist because every one occurs in a
253
+ normal store); a single shipping or payment option still *shows* what it is —
254
+ never a picker of one, never "nothing selected"; zero gateways → say checkout
255
+ is unavailable instead of a dead button; keep each field's `autoComplete` (the
256
+ spec provides it) and render `f.error` — "we don't ship there" arrives on the
257
+ country field; show `orderError.message` and the blockers so the gate explains
258
+ itself. ⚑ Payment methods, currency and countries come from `useStoreInfo()`
259
+ only — `cart.payment_gateways` is always `undefined`, and a default store
260
+ offers `offline` only ([`./03-data.md`](./03-data.md)).
261
+
262
+ **Reference implementation** — the densest wiring in the storefront; read it,
263
+ then build yours around it. Structure correct, presentation absent.
107
264
 
108
265
  ```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>
117
- ```
118
-
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.
120
-
121
- No shipping estimator here — `useCheckout` reprices shipping and tax from the address. Custom rows: `useCartLine(line)` (optimistic, clamped, coalesced) and `useCoupon()`.
266
+ function Checkout() { // hooks read the context BELOW the provider
267
+ return <CheckoutProvider><CheckoutForm /></CheckoutProvider>;
268
+ }
122
269
 
123
- ## Checkout — one block
270
+ function CheckoutForm() {
271
+ const { status } = useCart();
272
+ const checkout = useCheckoutContext();
273
+ const blockers = useCheckoutBlockers();
274
+ const formatMoney = useFormatMoney();
275
+ if (status === "loading") return /* loading */;
276
+ if (status === "empty") return /* "your bag is empty" — a checkout with nothing says so */;
277
+ return (
278
+ <>
279
+ <AddressFields which="billing" />
280
+ <label>
281
+ <input type="checkbox" checked={checkout.shipToDifferent}
282
+ onChange={(e) => checkout.setShipToDifferent(e.target.checked)} />
283
+ Deliver to a different address
284
+ </label>
285
+ {checkout.shipToDifferent && <AddressFields which="shipping" />}
286
+
287
+ <ShippingMethodPicker>
288
+ {({ status, methods, chosen, choose, mustChoose, syncing }) => (
289
+ <fieldset>{/* syncing → subtle busy state; renders null for a virtual cart */}
290
+ {status === "missing_address" && <p>Delivery options appear once your address is entered.</p>}
291
+ {status === "none_available" && <p role="alert">We don't deliver to that address yet.</p>}
292
+ {mustChoose && methods.map((m) => (
293
+ <label key={m.id}>
294
+ <input type="radio" checked={chosen?.id === m.id} onChange={() => choose(m.id)} />
295
+ {m.title} {formatMoney(m.cost)}
296
+ </label>
297
+ ))}
298
+ {!mustChoose && chosen && <p>{chosen.title} {formatMoney(chosen.cost)}</p>}
299
+ </fieldset>
300
+ )}
301
+ </ShippingMethodPicker>
302
+
303
+ <PaymentMethodPicker>
304
+ {({ gateways, value, select, selected, single }) => (
305
+ <fieldset>
306
+ {gateways.length === 0 && <p role="alert">No payment method is available right now.</p>}
307
+ {!single && gateways.map((g) => (
308
+ <label key={g.slug}>
309
+ <input type="radio" checked={value === g.slug} onChange={() => select(g.slug)} />
310
+ {g.title} {g.description}
311
+ </label>
312
+ ))}
313
+ {single && selected && <p>{selected.title}</p>}
314
+ </fieldset>
315
+ )}
316
+ </PaymentMethodPicker>
317
+
318
+ {/* summary: coupon field (if not in the cart) + useTotalsLines(), as in the cart page */}
319
+
320
+ <button disabled={!checkout.canPlaceOrder || checkout.placing} onClick={() => checkout.placeOrder()}>
321
+ {checkout.placing ? "Placing your order…" : "Place order"}
322
+ </button>
323
+ {checkout.orderError && <p role="alert">{checkout.orderError.message}</p>}
324
+ {!checkout.canPlaceOrder && blockers.map((b) => <p key={b.code}>{b.message}</p>)}
325
+ </>
326
+ );
327
+ }
124
328
 
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>;
329
+ function AddressFields({ which }) {
330
+ const { fields, set, countriesLoading } = useAddressForm(which);
331
+ return fields.map((f) => (
332
+ <label key={f.key}>
333
+ {f.label}{f.required && " *"}
334
+ {f.type === "select" ? (
335
+ <select value={f.value} onChange={(e) => set(f.key, e.target.value)} autoComplete={f.autoComplete}>
336
+ <option value="">{f.key === "country" && countriesLoading ? "Loading…" : `Select ${f.label}`}</option>
337
+ {f.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
338
+ </select>
339
+ ) : (
340
+ <input type={f.type} value={f.value} required={f.required}
341
+ onChange={(e) => set(f.key, e.target.value)} autoComplete={f.autoComplete} />
342
+ )}
343
+ {f.error && <span role="alert">{f.error}</span>}
344
+ </label>
345
+ ));
131
346
  }
132
347
  ```
133
348
 
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`.
349
+ ## Order received
135
350
 
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()`.
351
+ **Mandatory route** every payment link returns here, and confirming is what
352
+ marks a card order paid. `useOrderReturn()` is the whole page's logic: it reads
353
+ `order_id`/`order_key` from the URL, verifies with the provider (idempotent),
354
+ and marks the page noindex itself.
137
355
 
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)).
356
+ Rules: render all five states never a blank page while `"loading"`, and a
357
+ retry via `reload()` on `"error"`. ⚑ **Never drop `paymentInstructions`**: a
358
+ manual/offline order settles outside the store, so these ARE how the store's
359
+ default customer learns how to pay — render them whenever present, on any
360
+ state. ⚑ An order's totals are **flat** — `order.total`; there is no
361
+ `order.totals` (use `useTotalsLines(order)`).
139
362
 
140
- ## Order received
363
+ **Reference implementation** — a receipt is a convention, not an identity
364
+ surface: take this structure, restyle it to the store, keep every branch.
141
365
 
142
366
  ```jsx
143
- <main className="mx-auto max-w-2xl px-6 py-20"><OrderReceivedBlock /></main>
367
+ function OrderReceived() {
368
+ const { status, order, lines, paymentLink, paymentInstructions, error, reload } = useOrderReturn();
369
+ const totals = useTotalsLines(order);
370
+ const formatMoney = useFormatMoney();
371
+ if (status === "loading") return /* confirming copy */;
372
+ if (status === "error") return <><p role="alert">{error.message}</p>
373
+ <button onClick={() => reload()}>Try again</button></>;
374
+ return (
375
+ <>
376
+ {status === "paid" && /* thank-you heading */}
377
+ {status === "unpaid" && <>{/* awaiting-payment heading */}
378
+ {paymentLink?.url && <a href={paymentLink.url}>Pay now</a>}</>}
379
+ {status === "cancelled" && <>{/* payment-cancelled heading */}
380
+ {paymentLink?.url && <a href={paymentLink.url}>Try payment again</a>}</>}
381
+ {order?.order_number && <p>Order {order.order_number}</p>}
382
+ {paymentInstructions && (
383
+ <section>{/* "How to pay" — the offline customer's next step */}
384
+ {paymentInstructions.description && <p>{paymentInstructions.description}</p>}
385
+ {paymentInstructions.account_details && Object.entries(paymentInstructions.account_details)
386
+ .map(([k, v]) => <p key={k}>{k.replace(/_/g, " ")}: {String(v)}</p>)}
387
+ </section>
388
+ )}
389
+ {lines.map((l, i) => <p key={i}>{l.name} {l.attributesLabel} × {l.quantity} — {formatMoney(l.total)}</p>)}
390
+ {totals.filter((t) => !t.hidden).map((t) => <p key={t.key}>{t.label} {t.formatted}</p>)}
391
+ </>
392
+ );
393
+ }
144
394
  ```
145
395
 
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
396
  ## SEO — one line per page type
149
397
 
150
398
  ```jsx
151
399
  useStorefrontSeo(productSeo(p.product, p.view, { storeName, currency })); // product page
152
400
  useStorefrontSeo(collectionSeo({ title, products: list.products })); // collection / home
153
- useStorefrontSeo(orderSeo(order)); // order-received & checkout noindex
401
+ // order-received is already noindex via useOrderReturn
154
402
  ```
155
403
 
156
- It is a hook — call it above the page's early returns (the builders tolerate a null product).
404
+ It is a hook — call it above the page's early returns (the builders tolerate a
405
+ null product).
157
406
 
158
407
  ## Per-page output budgets
159
408
 
160
409
  | Page | budget (chars) | rationale |
161
410
  |---|---|---|
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 |
411
+ | Checkout | ≤ 5K | your markup over the reference above the logic is all hook calls |
412
+ | Cart / bag | ≤ 3K | `useCart` + `CartLine` rows + totals + coupon + empty state |
413
+ | Order-received | ≤ 2.5K | five states + payment instructions + summary |
414
+ | Product page | ≤ 5K | your layout and type around `useProduct`, `variantAxes`, `useAddToCartButton`, the gallery |
166
415
  | Collection | ≤ 3K | `useProductList` + custom card + pagination controls |
167
- | Home | ≤ 5K | pure identity tier — hero/editorial earn their chars |
416
+ | Home | ≤ 5K | pure identity — hero/editorial earn their chars |
168
417
  | Any single component file | ≤ 4K, hard ceiling 8K | Base1 evidence: decode is 34% of wall; a 12K file is a 45s write batch |
169
418
 
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.**
419
+ These budgets assume the hooks carry the logic and your markup carries only the
420
+ design. Over budget ⇒ you are re-implementing something a hook does — an
421
+ address spec, a quantity clamp, totals math, variant resolution, add-to-cart
422
+ error recovery. Go back to the hook and delete your version.
171
423
 
172
424
  ## Done — forget this file
173
425
 
@@ -175,14 +427,18 @@ Over budget ⇒ extract components, or adopt the block you are re-implementing.
175
427
  - [ ] **One** `<StorefrontProvider>` above every storefront route, wrapping `<Routes>` (or a layout route's `<Outlet/>`); one client, no hand-rolled `cart_token`.
176
428
  - [ ] Pages branch on `status`; no page maps a possibly-null list or shows an empty state while loading.
177
429
  - [ ] 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).
430
+ - [ ] If the store has coupons, a coupon field (`useCoupon`) exists in the cart or the checkout.
431
+ - [ ] `/order-received` renders `useOrderReturn`'s states **including `paymentInstructions`**.
432
+ - [ ] Variant options render one control per axis; unbuyable options are disabled, not hidden.
433
+ - [ ] No hook logic was re-implemented by hand (address fields, quantity clamps, totals rows, shipping/payment branching).
434
+ - [ ] The storefront looks designed — the reference implementations above were adapted into this store's design, not shipped bare.
180
435
  - [ ] Every page is within its budget above.
181
436
  - [ ] A real purchase completes in the preview — pick a variant, add it, check out, place an offline order, land on `/order-received`.
182
437
 
183
438
  Record these lines in your working notes; do not re-read this file.
184
439
 
185
440
  - 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.
441
+ - A store with any coupons must have a coupon field (`useCoupon`) in the cart or the checkout, or its codes can never be redeemed.
442
+ - `/order-received` is mandatory and renders `useOrderReturn`'s states, including `paymentInstructions` — how a normal (offline) customer learns how to pay.
188
443
  - Branch cart/list/product UI on `status`, never on `isEmpty`/nullable data — `isEmpty` is false while loading by design.
444
+ - Variant options that aren't buyable render disabled, never hidden; one control per axis, never a list of variations.
@@ -133,16 +133,16 @@ Both failure modes are silent at seed time and visible only in the store, so che
133
133
 
134
134
  | What the request says | Decision |
135
135
  |---|---|
136
- | A provider is named or a key handed over ("connect Stripe") | wire it, then enable `card` — whenever convenient; it blocks nothing |
137
- | Selling online implied, no provider named | cards are a reasonable read of *what* to offer, never of *when*: ask for the provider + key **after** the store works |
136
+ | A provider is named ("use Stripe") | copy the provider file, enable `card` — whenever convenient; it blocks nothing |
137
+ | Selling online implied, no provider named | cards are a reasonable read of *what* to offer, never of *when*: raise the provider **after** the store works |
138
138
  | Paid another way (transfer, COD, invoice, pickup, quotes) | nothing to do — the default already is exactly this |
139
139
  | Payments not mentioned at all | leave the default and **mention it at handover** |
140
140
 
141
- **Timing: late, never the opening move.** Payments have no dependency edge into anything — the admin, the seed and the entire buy path down to `place-order` work with no provider — so asking a user to fetch a secret key before their store exists only stalls the build. Any instruction that treats a payment provider as a required install step is superseded by this section.
141
+ **Timing: late, never the opening move.** Payments have no dependency edge into anything — the admin, the seed and the entire buy path down to `place-order` work with no provider — so putting the provider connection before the store exists only stalls the build. Any instruction that treats a payment provider as a required install step is superseded by this section.
142
142
 
143
143
  **Say where payments landed** in your handover summary, including "offline only, cards off". A store's owner should never discover their payment configuration from a customer who couldn't pay.
144
144
 
145
- *Only for a store that opted into cards:* wiring is a **file copy, not code you write** — `base44/shared/commerce/card-payment.stripe.ts` is a complete implementation; copy it over `base44/shared/commerce/card-payment.ts` (`fs.copyFileSync`, whole file — never a partial edit, which leaves duplicate exports and breaks every commerce function's deploy), store the provider secret as an app secret, register `https://<app-domain>/functions/commerce/payment-webhook` with the provider, then enable the gateway. Steps, other providers and the webhook model: [`../references/online-payments.md`](../references/online-payments.md).
145
+ *Only for a store that opted into cards:* **if it is Stripe, the code is already written** — `base44/shared/commerce/card-payment.stripe.ts` is a complete implementation, used **as-is**. Copy it over `base44/shared/commerce/card-payment.ts` (`fs.copyFileSync`, whole file — never a partial edit, which leaves duplicate exports and breaks every commerce function's deploy) and enable the gateway with `payment_methods: ["offline", "card"]`. Nothing in it needs filling in and no key belongs in the code; it reads the credential the app's Stripe connection publishes. Any other provider means implementing four functions in that one file — [`../references/online-payments.md`](../references/online-payments.md).
146
146
 
147
147
  ## Done — forget this file
148
148
 
@@ -150,7 +150,7 @@ Both failure modes are silent at seed time and visible only in the store, so che
150
150
  - [ ] `warnings` in the response is empty, or every warning is deliberate and stated to the user.
151
151
  - [ ] Shipping is expressed in `locations` (with a catch-all if the store ships worldwide), not patched into entities afterwards.
152
152
  - [ ] `coupons` seeded only if a coupon field exists ([`./02-storefront.md`](./02-storefront.md)).
153
- - [ ] Cards are either off, or on with a provider wired (file copied whole, secret stored, webhook registered).
153
+ - [ ] Cards are either off, or on with the provider file copied whole and the `card` gateway enabled.
154
154
  - [ ] Product slugs from `catalog.products[]` recorded, and the storefront links by them.
155
155
  - [ ] If the brief named tiered rates, each named region prices to its rate (the `set-shipping-address` check above).
156
156
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  stage: reference
3
3
  read_when: "You are asking which fields a card or a product page can actually show, or hit a variant edge case (unavailable combinations, attributes with no variations, price ranges, linkable selections)."
4
- skip_when: "The listing and product page render correctly from useProductList / useProduct and the blocks — the quick start in ../install/02-storefront.md covers the happy path."
4
+ skip_when: "The listing and product page render correctly from useProductList / useProduct and the render-model helpers — the quick start in ../install/02-storefront.md covers the happy path."
5
5
  forget_when: "Cards and the product page render the fields you intended, variant selection resolves to a variation, and add-to-cart succeeds."
6
6
  carry_forward:
7
7
  - "There is no product `type` field: a non-empty `attributes[]` is what makes a product sell variants, and such a product is only sellable via a `variation_id`."
@@ -31,7 +31,7 @@ A listing **row** is the product record itself (minus paywalled fields) plus res
31
31
  | `images[]`, `featured`, `short_description`, `description` | ✅ | ✅ | Cards normally use `images[0]` + `short_description`; every entry is an **object** — §2 |
32
32
  | `sku`, `stock_status`, `stock_quantity`, `manage_stock`, `backorders` | ✅ | ✅ | |
33
33
  | `average_rating`, `rating_count`, `total_sales` | ✅ | ✅ | Enough for stars on a card; the reviews themselves are not in a row |
34
- | `downloadable`, `virtual`, `weight`, `dimensions`, `attributes[]`, `meta_data` | ✅ | ✅ | `attributes[]` is the variant axes; `meta_data` is the descriptive **modifiers** (`<ProductSpecsBlock/>` renders them) |
34
+ | `downloadable`, `virtual`, `weight`, `dimensions`, `attributes[]`, `meta_data` | ✅ | ✅ | `attributes[]` is the variant axes; `meta_data` is the descriptive **modifiers** (`productSpecs(product)` turns them into spec-table rows) |
35
35
  | **`ribbons`** (resolved), `ribbon_ids`, `category_ids` | ✅ | ✅ | Rows carry `{id, name}` ribbons; `get-product` returns the full records |
36
36
  | **`categories`** (resolved) | ❌ *ids only* | ✅ | §6 to add them to rows |
37
37
  | **`variations[]`** (per-variant price/stock/image/attributes) | ❌ | ✅ | Why a product with variants can't be fully priced from a row |
@@ -53,14 +53,14 @@ A product with attributes is **only** sellable through a variant: `add-item` wit
53
53
  Three rules used to be prose here and are now enforced by exports — use them and they can't drift between views:
54
54
 
55
55
  - **From-price.** `admin-products` (and the seeder) roll a parent's `regular_price`/`price`/`on_sale` up from the cheapest publishable variant on every save, so the parent price is real, sortable and filterable — but it is the **lowest** price, not *the* price. `productPrice(rowOrView, {formatMoney})` / `useProductPrice(rowOrView)` accept **either** a listing row or a `resolveSelection` view and return `{label, compareAtLabel, onSale, isFrom, isRange, min, max}`: "From €19.99" on a card, a range on an unresolved page, the exact price once resolved.
56
- - **Images are objects, and may be absent.** Every stored image is `{src, name, alt}`, never a URL string, and `images` can legitimately be empty. `productImages(product)` / `normalizeImage(entry)` return clean entries (non-empty `src`, defaulted `alt`), and an empty array is the *render your placeholder* signal — `useProductGallery` and `<ProductGalleryBlock/>` build on them. Passing the object itself to an `<img src>` fails the load and shows the placeholder for every product in the store.
56
+ - **Images are objects, and may be absent.** Every stored image is `{src, name, alt}`, never a URL string, and `images` can legitimately be empty. `productImages(product)` / `normalizeImage(entry)` return clean entries (non-empty `src`, defaulted `alt`), and an empty array is the *render your placeholder* signal — `useProductGallery` builds on them (`hasImages`). Passing the object itself to an `<img src>` fails the load and shows the placeholder for every product in the store.
57
57
  - **Modifiers are not attributes.** `meta_data` (Material, Care, GTIN) is a spec table — never a selector, never a ribbon.
58
58
 
59
59
  ## 3. What each view renders
60
60
 
61
- **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. Link the whole card to the product page. The card is deliberately the one thing no block ships — it is the most identity-defining component in a storefront.
61
+ **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. Link the whole card to the product page like every other piece of storefront UI, the card is entirely yours to design.
62
62
 
63
- **Product page:** gallery, name, price, variant selectors, stock, `short_description` then `description`, SKU, categories breadcrumb, ribbons, reviews, then upsells/cross-sells. Everything except layout and styling has a block: `<ProductGalleryBlock/>`, `<VariantSelectorBlock/>`, `<AddToCartBlock/>`, `<ProductSpecsBlock/>`, `<BreadcrumbsBlock/>`, `<ReviewsBlock/>`, `<ProductStripBlock/>`.
63
+ **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`.
64
64
 
65
65
  ## 4. Ribbons — in **both** views
66
66
 
@@ -73,7 +73,7 @@ Ribbons are flat, cross-cutting labels ("Best Seller", "New", "Gift"); categorie
73
73
 
74
74
  ## 5. Variant selection
75
75
 
76
- The one interaction agents reliably get wrong. `<VariantSelectorBlock view={view} onPick={pick} />` encodes the rule: **one control per axis, never a list of variations** (`Red / S`, `Red / M`, … is `n × m` noise that hides the product's structure), and an option that isn't buyable is rendered **disabled, not removed**. Take the block; drop to the hook only for different *structure* (`renderOption` covers swatches).
76
+ The one interaction agents reliably get wrong. `variantAxes(view, pick)` hands you the render-ready model that encodes the rule: **one control per axis, never a list of variations** (`Red / S`, `Red / M`, … is `n × m` noise that hides the product's structure), and an option that isn't buyable renders **disabled, not removed** (`o.disabled`; `o.outOfStock` stays visible, just marked). Map it to any control buttons, swatches, dropdowns — and call `o.pick()` on select.
77
77
 
78
78
  Underneath, `useProduct` composes the framework-free helpers in `src/commerce/utils/variants.js` — `defaultSelection` → `selectOption` on a click → `resolveSelection` for the view. Every `product.attributes[]` entry is an **axis** in `position` order; every `variations[]` record is one combination. Bind the UI to `view`, not to `product.*`, or a selection changes nothing:
79
79