@base44/app-plugin-commerce 0.1.20 → 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 (73) hide show
  1. package/README.md +25 -22
  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/entry.ts +34 -0
  11. package/base44/functions/commerce/seed-store/seed-catalog.ts +39 -5
  12. package/base44/functions/commerce/storefront-catalog/entry.ts +1 -1
  13. package/base44/functions/commerce/storefront-checkout/entry.ts +1 -1
  14. package/base44/shared/commerce/card-payment.stripe.ts +198 -0
  15. package/base44/shared/commerce/card-payment.ts +1 -1
  16. package/base44/shared/commerce/payments.ts +2 -2
  17. package/base44/shared/commerce/scan.ts +1 -1
  18. package/base44/shared/commerce/sequence.ts +2 -2
  19. package/package.json +1 -1
  20. package/scripts/install.js +24 -14
  21. package/skills/commerce/SKILL.md +117 -51
  22. package/skills/commerce/docs/api-admin.md +89 -28
  23. package/skills/commerce/docs/api-storefront.md +113 -126
  24. package/skills/commerce/docs/entities.md +137 -0
  25. package/skills/commerce/install/01-install.md +101 -0
  26. package/skills/commerce/install/02-storefront.md +444 -0
  27. package/skills/commerce/install/03-data.md +162 -0
  28. package/skills/commerce/references/admin-product-form.md +10 -0
  29. package/skills/commerce/references/catalog-rendering.md +110 -0
  30. package/skills/commerce/references/emails.md +49 -12
  31. package/skills/commerce/references/guest-access-security.md +18 -5
  32. package/skills/commerce/references/online-payments.md +49 -149
  33. package/skills/commerce/references/operations.md +52 -0
  34. package/skills/commerce/references/reviews.md +31 -16
  35. package/skills/commerce/references/shipping-and-tax.md +110 -0
  36. package/skills/commerce/references/store-admin-agent.md +21 -0
  37. package/skills/commerce/references/store-settings.md +49 -0
  38. package/src/commerce/admin/README.md +6 -3
  39. package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
  40. package/src/commerce/admin/pages/products/Reviews.jsx +1 -1
  41. package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -1
  42. package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +1 -1
  43. package/src/commerce/storefront/StorefrontProvider.jsx +106 -20
  44. package/src/commerce/storefront/index.js +74 -24
  45. package/src/commerce/storefront/internal/useAsyncData.js +86 -0
  46. package/src/commerce/storefront/useAddressForm.js +96 -0
  47. package/src/commerce/storefront/useCartLine.js +221 -0
  48. package/src/commerce/storefront/useCheckout.jsx +18 -6
  49. package/src/commerce/storefront/useOrderReturn.js +36 -10
  50. package/src/commerce/storefront/useProduct.js +295 -0
  51. package/src/commerce/storefront/useProductGallery.js +74 -0
  52. package/src/commerce/storefront/useProductList.js +153 -0
  53. package/src/commerce/storefront/useProductPrice.js +58 -0
  54. package/src/commerce/storefront/useProductReviews.js +242 -0
  55. package/src/commerce/storefront/useStorefrontSeo.js +204 -0
  56. package/src/commerce/storefront/useTotalsLines.js +109 -0
  57. package/src/commerce/utils/address-spec.js +89 -0
  58. package/src/commerce/utils/images.js +45 -0
  59. package/src/commerce/utils/index.js +22 -7
  60. package/src/commerce/utils/price.js +95 -0
  61. package/src/commerce/utils/shipping-promos.js +2 -2
  62. package/src/commerce/utils/specs.js +26 -0
  63. package/src/commerce/utils/storefront.js +47 -3
  64. package/src/commerce/utils/totals.js +110 -0
  65. package/src/commerce/utils/variants.js +58 -3
  66. package/skills/commerce/installation-guidelines.md +0 -93
  67. package/skills/commerce/post-installation.md +0 -496
  68. package/skills/commerce/references/limits-and-performance.md +0 -16
  69. package/skills/commerce/references/media-and-downloads.md +0 -4
  70. package/skills/commerce/references/product-render.md +0 -89
  71. package/skills/commerce/references/scheduled-work.md +0 -19
  72. package/skills/commerce/references/storefront-product-page.md +0 -83
  73. package/skills/commerce/references/webhooks.md +0 -10
@@ -0,0 +1,444 @@
1
+ ---
2
+ stage: install/02
3
+ read_when: "You are about to build storefront pages (product list/page, cart, checkout, order-received)."
4
+ skip_when: "The storefront pages already render against live data and pass the checklist at the bottom of this file."
5
+ forget_when: "The checklist at the bottom passes — every page renders against the seeded catalog and an offline order completes."
6
+ carry_forward:
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 (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
+ - "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
+ ---
13
+
14
+ # 02 — Storefront
15
+
16
+ One split decides everything here: **the logic is premade, the UI never is.**
17
+
18
+ - **Logic — hooks, 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`.
47
+
48
+ ## Setup — once
49
+
50
+ ```jsx
51
+ import { StorefrontProvider } from "@/commerce/storefront";
52
+ import { base44 } from "@/api/base44Client";
53
+
54
+ <BrowserRouter>
55
+ <StorefrontProvider base44={base44}> {/* wraps <Routes> — never a child of it */}
56
+ <Routes> {/* ONE <Routes> — merge new pages into the app's */}
57
+ <Route path="/" element={<Home />} />
58
+ <Route path="/product/:slug" element={<ProductPage />} />
59
+ <Route path="/bag" element={<Bag />} />
60
+ <Route path="/checkout" element={<Checkout />} />
61
+ <Route path="/order-received" element={<OrderReceived />} />
62
+ <Route path="/store-admin/*" element={<AdminApp />} />
63
+ </Routes>
64
+ </StorefrontProvider>
65
+ </BrowserRouter>
66
+ ```
67
+
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.
72
+
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:
78
+ > ```jsx
79
+ > <Route element={<StorefrontProvider base44={base44}><Outlet /></StorefrontProvider>}>
80
+ > <Route path="/" element={<Home />} />
81
+ > <Route path="/checkout" element={<Checkout />} />
82
+ > </Route>
83
+ > <Route path="/store-admin/*" element={<AdminApp />} /> {/* outside the provider */}
84
+ > ```
85
+
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.
200
+
201
+ ```jsx
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, total — l: 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
+ }
234
+ ```
235
+
236
+ No shipping estimator here — checkout 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.
264
+
265
+ ```jsx
266
+ function Checkout() { // hooks read the context BELOW the provider
267
+ return <CheckoutProvider><CheckoutForm /></CheckoutProvider>;
268
+ }
269
+
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
+ }
328
+
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
+ ));
346
+ }
347
+ ```
348
+
349
+ ## Order received
350
+
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.
355
+
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)`).
362
+
363
+ **Reference implementation** — a receipt is a convention, not an identity
364
+ surface: take this structure, restyle it to the store, keep every branch.
365
+
366
+ ```jsx
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
+ }
394
+ ```
395
+
396
+ ## SEO — one line per page type
397
+
398
+ ```jsx
399
+ useStorefrontSeo(productSeo(p.product, p.view, { storeName, currency })); // product page
400
+ useStorefrontSeo(collectionSeo({ title, products: list.products })); // collection / home
401
+ // order-received is already noindex via useOrderReturn
402
+ ```
403
+
404
+ It is a hook — call it above the page's early returns (the builders tolerate a
405
+ null product).
406
+
407
+ ## Per-page output budgets
408
+
409
+ | Page | budget (chars) | rationale |
410
+ |---|---|---|
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 |
415
+ | Collection | ≤ 3K | `useProductList` + custom card + pagination controls |
416
+ | Home | ≤ 5K | pure identity — hero/editorial earn their chars |
417
+ | Any single component file | ≤ 4K, hard ceiling 8K | Base1 evidence: decode is 34% of wall; a 12K file is a 45s write batch |
418
+
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.
423
+
424
+ ## Done — forget this file
425
+
426
+ - [ ] Catalog UI exists in whatever form fits the store (list, product pages, or both), plus a checkout, plus `/order-received`.
427
+ - [ ] **One** `<StorefrontProvider>` above every storefront route, wrapping `<Routes>` (or a layout route's `<Outlet/>`); one client, no hand-rolled `cart_token`.
428
+ - [ ] Pages branch on `status`; no page maps a possibly-null list or shows an empty state while loading.
429
+ - [ ] Gateways/currency/countries read from `useStoreInfo()` only.
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.
435
+ - [ ] Every page is within its budget above.
436
+ - [ ] A real purchase completes in the preview — pick a variant, add it, check out, place an offline order, land on `/order-received`.
437
+
438
+ Record these lines in your working notes; do not re-read this file.
439
+
440
+ - Payment gateways, currency and countries come from `useStoreInfo()` only — never off a cart (`cart.payment_gateways` is always undefined).
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.
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.
@@ -0,0 +1,162 @@
1
+ ---
2
+ stage: install/03
3
+ read_when: "You are about to build the seed payload — the store's catalog, shipping, coupons, currency — or deciding about payments."
4
+ skip_when: "commerce/seed-store has already returned success for this store and payments are decided."
5
+ forget_when: "The seed response is received and recorded (slugs + warnings), and the checklist at the bottom passes."
6
+ carry_forward:
7
+ - "Product slugs come from the seed response's catalog.products[] — link pages by slug, never by a client-side map."
8
+ - "Payments: report at handover where they landed (default = offline on, card off) — the owner must never learn it from a customer."
9
+ - "Turning card payments on or off later is one more seed call: { payment_methods: [\"offline\", \"card\"] }."
10
+ - "Seed-time `locations` is THE shipping path; patching commerce.ShippingTaxLocation is the day-2 route."
11
+ ---
12
+
13
+ # 03 — Store data
14
+
15
+ A fresh install has **no settings and no catalog**. One admin-only, idempotent call to `commerce/seed-store` creates both: the six settings groups (`general`, `products`, `inventory`, `tax`, `shipping`, `emails`), the two gateway rows (`offline` enabled, `card` disabled), and — from the payload — the catalog, coupons and shipping locations. Nothing in [`./02-storefront.md`](./02-storefront.md) waits on it: fire it when the image URLs are back and pick the response up when you need the slugs.
16
+
17
+ | Mode | Body | Products created |
18
+ |---|---|---|
19
+ | **Real catalog** | `{ store_name, products: [...] }` (+ optional `coupons`, `locations`) | yours — categories, ribbons, attributes, variants, all in this one call |
20
+ | **Demo data** | `{ store_name, with_sample_data: true }` | 10 generic demo products (skipped if any product exists) |
21
+ | **No products** | `{ store_name }` | none — defaults only |
22
+
23
+ `with_sample_data` cannot be combined with `products` (**400** `invalid_payload`). Never calling the function leaves the operator the admin's first-run "Set up your store" screen.
24
+
25
+ **`store_name` is required on a first seed** (**400** `store_name_required`) — the app's name as the platform shows it (`base44/config.jsonc` → `name` can be stale; ask if unsure). It becomes the email sender name and the public shop name. **`currency`** is an ISO code (`"EUR"`); prices are *formatted* per the viewer's locale, so there is nothing else to set. Explicit values always win, first seed and re-runs alike.
26
+
27
+ Full field contract — every payload key, every response field, all error codes: [`../docs/api-admin.md`](../docs/api-admin.md#commerceseed-store). Here is the working call.
28
+
29
+ ```js
30
+ try {
31
+ const res = await base44.functions.invoke("commerce/seed-store", {
32
+ store_name: "Aurora Threads",
33
+ currency: "EUR",
34
+ products: [
35
+ { name: "Classic T-Shirt",
36
+ sku: "TEE-CLASSIC", // optional; makes re-runs idempotent
37
+ regular_price: 19.99,
38
+ stock_quantity: 50, // implies manage_stock
39
+ categories: ["Clothing"], // get-or-created by display name
40
+ ribbons: ["Best Seller"],
41
+ images: ["https://…/tee.jpg"], // URLs or { src, alt } — see Images below
42
+ short_description: "A soft, breathable everyday tee.",
43
+ description: "<p>Cut from combed cotton…</p>", // HTML, rendered as rich text
44
+ },
45
+ { name: "Runner Sneaker",
46
+ sku: "SNK-RUN",
47
+ regular_price: 89, // inherited by variations that don't override
48
+ attributes: [ // the axes → one selector each in the storefront
49
+ { name: "Size", options: ["41", "42"] },
50
+ { name: "Color", options: ["Black", "White"] },
51
+ ],
52
+ default_options: { Size: "42", Color: "Black" },
53
+ variations: [ // omit entirely → all 4 combos auto-generated
54
+ { options: { Size: "41", Color: "Black" }, stock_quantity: 4 },
55
+ { options: { Size: "42", Color: "White" }, regular_price: 94, stock_quantity: 3,
56
+ image: "https://…/sneaker-white.jpg" }, // per-variation image for a visual axis
57
+ ],
58
+ },
59
+ ],
60
+ coupons: [{ code: "WELCOME10", discount_type: "percent", amount: 10 }],
61
+ // ONLY with a coupon field in the cart or checkout (see ./02-storefront.md)
62
+ // locations: [ … ], // shipping — the next section; passing any makes them the store's ONLY ones
63
+ });
64
+ return res.data; // ← the { success, data } envelope: plain JSON
65
+ } catch (e) {
66
+ return { success: false, status: e.response?.status, ...(e.response?.data ?? { error: e.message }) };
67
+ }
68
+ ```
69
+
70
+ **Running this through a code-execution tool? Return `res.data`, never the raw response.** `invoke` resolves to the raw HTTP response, which carries circular request/response objects — `return res` (or stringifying a thrown error whole) fails with `Converting circular structure to JSON` *even when the seed succeeded*, and a thrown error needs `e.response?.data` for the same reason.
71
+
72
+ Reference taxonomy by **display name** (categories, ribbons, attributes, options) — existing records are matched case-insensitively and reused. The seeder derives slugs, checks SKU uniqueness, prices variations, and **rolls the parent's `price`/`regular_price`/`on_sale` up from the cheapest publishable variant** — never set a variant parent's price yourself. Unknown keys are rejected, so typos surface instead of vanishing.
73
+
74
+ **Idempotency:** a product whose `sku` (or derived slug) exists is skipped and reported — safe to retry after a timeout, or to seed into a store that already has products. **Limits:** ≤100 products, ≤500 variations per call, ≤50 per product, ≤50 locations. Bad payloads fail **400** `invalid_payload` with `errors: [{ path, error }]`, a modified schema **422** `schema_incompatible` — both before anything is written.
75
+
76
+ The response reports everything; these matter downstream:
77
+
78
+ ```jsonc
79
+ { "catalog": { "products_created": 2, "variations_created": 3,
80
+ "products": [{ "name": "Runner Sneaker", "id": "…", "slug": "runner-sneaker", "variation_count": 3 }] },
81
+ "store_name": { "value": "Aurora Threads", "action": "created" },
82
+ "payment_methods": null, // null = the default (offline on, card off)
83
+ "warnings": [] } // always present; read it — see the shipping section
84
+ ```
85
+
86
+ Link pages by the **`slug`** from `catalog.products[]`; never mirror the seed in client-side constants (the database is the source of truth and a mirror diverges the first time the merchant edits a product).
87
+
88
+ ## Shipping — declare it in the seed call
89
+
90
+ **Seed-time `locations` is THE way to configure shipping.** Each location is a scope plus its rates and taxes, locations match in `order` ascending, and `order` defaults to the payload position — so the array reads as the priority. "€20 in Europe, €100 everywhere else" is two locations:
91
+
92
+ ```js
93
+ locations: [
94
+ { name: "Europe", continents: ["EU"], shipping_rates: [{ name: "Standard", cost: 20 }] },
95
+ { name: "Worldwide", rest_of_world: true, shipping_rates: [{ name: "International", cost: 100 }] },
96
+ ]
97
+ ```
98
+
99
+ - **`continents: ["EU"]`** spares you a 51-code country list. The seven codes are `AF` `AN` `AS` `EU` `NA` `OC` `SA`, and `EU` is the *continent* Europe, not the European Union. An unknown code fails `400 invalid_payload` with the known list.
100
+ - **`rest_of_world: true`** is the catch-all — the location that matches every address no other location claims. It cannot also carry `countries`/`continents`/`regions`.
101
+ - Other scopes: `countries: ["IL", "DE"]`, or explicit `regions: [{ type: "state", code: "US:CA" }]`. Matching is **country + state only** — no postcode or city rules exist.
102
+ - One matched location supplies **both** the shipping rates and the tax groups: `shipping_rates: [{ name, cost, free_over? }]`, `tax_groups: [{ name, rates: [{ name, rate }] }]`, `shipping_tax: { type: "percent"|"fixed", value }`.
103
+
104
+ **The catch-all trap.** Passing any `locations` suppresses the seeded "Rest of the world" fallback, so scoped locations with nothing behind them mean every other address gets `shipping_not_available` at checkout. That is plausibly intended, so it is not an error — it comes back in the response as `warnings: ["no_catchall_location: …"]`. Read `warnings` on every seed and either add a `rest_of_world` location or state the restriction to the user.
105
+
106
+ Patching `commerce.ShippingTaxLocation` records afterwards is the **harder, day-2 route** — you must mint stable rate `id`s yourself, and there is no admin function for it. Do shipping in the seed payload. For continent codes in full, state-level regions, tax-group binding, free-over thresholds, VAT-on-shipping and day-2 edits: [`../references/shipping-and-tax.md`](../references/shipping-and-tax.md).
107
+
108
+ **Tiered rates are worth one assertion**, because a wrong zone looks exactly like a right one until a customer in the wrong country pays. The cart prices from an address, so ask it directly — no UI needed:
109
+
110
+ ```js
111
+ // one item in the cart, then price it for each country the brief names
112
+ for (const country of ["DE", "AU"]) {
113
+ const { data } = await base44.functions.invoke("commerce/storefront-cart", {
114
+ action: "set-shipping-address", cart_token, address: { country, city: "x" },
115
+ });
116
+ console.log(country, data.data.available_shipping_methods.map((m) => m.cost));
117
+ }
118
+ // DE → [20], AU → [100] for the payload above. A [] means no location matched
119
+ // that address — the catch-all is missing.
120
+ ```
121
+
122
+ ## Images
123
+
124
+ Every product needs at least one, and **the URL you seed is the URL the store serves** — there are no placeholders to swap later. Resolve each to its **final, permanent URL before seeding**: the app's image generation, `base44.integrations.Core.UploadFile({ file })` → public URL, or stable public stock URLs (`seed-store/sample-data.ts` shows a working pattern). Match the image to the product.
125
+
126
+ Both failure modes are silent at seed time and visible only in the store, so check now: a **temporary or signed** URL (expiry params in the query string are the tell) must be re-hosted via `UploadFile`, and spot-check that one or two URLs resolve (200, image content type). Seeding without an image and setting it later through the admin API is allowed, but it is an open debt — track every imageless product and close it before handover.
127
+
128
+ ## Payments — the decision
129
+
130
+ **The rule, in full.** Online card payments are **off by default**: `commerce/seed-store` enables the manual `offline` method and leaves the `card` gateway disabled. Offline-only is a complete, payable store — the order goes on-hold with the gateway's description rendered as payment instructions on `/order-received`, which needs no code and no credentials. **Enable `card` only if a payment provider is wired, or will be in the same stretch of work:** enabled means offered, and an enabled card option with nothing behind it answers **`503 no_card_payment_provider`** the moment a customer picks it. Enabling and wiring are two halves of one step; neither half is useful alone.
131
+
132
+ **`payment_methods` is the on/off switch.** Pass gateway slugs and the listed gateways are enabled while **every other row is disabled** — `["offline"]`, `["card"]` (card-only) or both, with no `commerce.PaymentGateway` reads or writes of your own. Omit it and the store stays offline-only. It is idempotent and needs no catalog, so `{ payment_methods: ["offline", "card"] }` alone is the later on/off switch. Unknown slugs fail `400 invalid_payload` with the known list.
133
+
134
+ | What the request says | Decision |
135
+ |---|---|
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
+ | Paid another way (transfer, COD, invoice, pickup, quotes) | nothing to do — the default already is exactly this |
139
+ | Payments not mentioned at all | leave the default and **mention it at handover** |
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 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
+
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
+
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
+
147
+ ## Done — forget this file
148
+
149
+ - [ ] `commerce/seed-store` returned success and reported the catalog — real products, final permanent image URLs.
150
+ - [ ] `warnings` in the response is empty, or every warning is deliberate and stated to the user.
151
+ - [ ] Shipping is expressed in `locations` (with a catch-all if the store ships worldwide), not patched into entities afterwards.
152
+ - [ ] `coupons` seeded only if a coupon field exists ([`./02-storefront.md`](./02-storefront.md)).
153
+ - [ ] Cards are either off, or on with the provider file copied whole and the `card` gateway enabled.
154
+ - [ ] Product slugs from `catalog.products[]` recorded, and the storefront links by them.
155
+ - [ ] If the brief named tiered rates, each named region prices to its rate (the `set-shipping-address` check above).
156
+
157
+ Record these lines in your working notes; do not re-read this file.
158
+
159
+ - Product slugs come from the seed response's `catalog.products[]` — link pages by slug, never by a client-side map.
160
+ - Payments: report at handover where they landed (default = offline on, card off) — the owner must never learn it from a customer.
161
+ - Turning card payments on or off later is one more seed call: `{ payment_methods: ["offline", "card"] }`.
162
+ - Seed-time `locations` is THE shipping path; patching `commerce.ShippingTaxLocation` is the day-2 route.
@@ -1,3 +1,13 @@
1
+ ---
2
+ stage: reference
3
+ read_when: "You are changing the admin's product editing screens — sections, price/inventory rows, attribute or variant behaviour."
4
+ skip_when: "You are not editing the admin product form; the shipped form already covers every product shape."
5
+ forget_when: "The form change saves correctly for both a simple product and one with attributes."
6
+ carry_forward:
7
+ - "A product sells variants because it carries attributes — the attributes ARE the variant control; there is no product type and no generate-variants button."
8
+ - "A variant parent's price is derived from the cheapest publishable variant on save — never add an input for it."
9
+ ---
10
+
1
11
  # The admin product form
2
12
 
3
13
  Where to change what, in `src/commerce/admin/pages/products/`. The shape follows one