@base44/app-plugin-commerce 0.1.15 → 0.1.16

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.
@@ -1,39 +1,39 @@
1
1
  # Post-installation
2
2
 
3
- What to do right after the static installation ([`installation-guidelines.md`](./installation-guidelines.md)): embed the admin pages into the app, seed the store's data, build the storefront from the quick start below, and — **if and when the store needs online payments, a call you make deliberately (§2.2)** — wire a payment provider. Installed into the app at `.agents/skills/commerce/post-installation.md`.
3
+ What to do right after the static installation ([`installation-guidelines.md`](./installation-guidelines.md)): embed the admin pages into the app, build the storefront from the quick start below, seed the store's data, and — **if and when the store needs online payments, a call you make deliberately (§4)** — wire a payment provider. Installed into the app at `.agents/skills/commerce/post-installation.md`.
4
4
 
5
- > **This file is intentionally the whole job.** Unless the user has a special requirement (a non-Stripe payment provider, signature-verified webhooks, filters/reviews/accounts beyond the happy path), do **not** open the `references/` guides or the `docs/` API references — everything needed to ship a working store (admin mount, seeding, product list, product page, cart, checkout, order-received, and card payments for the stores that need them) is on this page, ending with the [Done when](#4-done-when) checklist. Reading more first just burns time.
5
+ > **This file is intentionally the whole job.** Unless the user has a special requirement (a non-Stripe payment provider, signature-verified webhooks, filters/reviews/accounts beyond the happy path), do **not** open the `references/` guides or the `docs/` API references — everything needed to ship a working store (admin mount, the storefront catalog pages, optional cart, checkout, order-received — seeding, and card payments for the stores that need them) is on this page, ending with the [Done when](#5-done-when) checklist. Reading more first just burns time.
6
6
 
7
7
  ## 0. Sequencing — build the UI while the slow work runs
8
8
 
9
- The sections below are ordered for **reading**they are not a schedule. Executed strictly top-to-bottom, the install spends most of its wall-clock waiting on things that never needed to block: image generation (the slowest step of the whole install), the `commerce/seed-store` call (~20 seconds), and — for stores that take card payments the user round-trip for the provider secret key. **None of these block writing the storefront**: every request/response shape §3 builds against is documented on this page, so the components are written from the docs, not from live data. Live data is only needed at the very end, to verify.
9
+ The sections below are in work orderadmin mount, storefront, then the data and payment calls — but the order is not a queue to wait in. The install's wall-clock is dominated by things that never needed to block: image generation (the slowest step of the whole install) and, for stores that take card payments, the user round-trip for the provider secret key. **Nothing about the storefront waits on live data**: every request/response shape §2 builds against is documented on this page, so the components are written from the docs, and live data only matters once at the end.
10
10
 
11
11
  So interleave — whenever a slow activity is in flight, write UI instead of waiting:
12
12
 
13
- 1. **Start image generation first** — kick off every product image before anything else (§2.1 "Images"), because it takes the longest and nothing depends on it until seed time.
14
- 2. **Mount the admin router (§1)** — minutes of work — and start writing storefront components 3) while the images render.
15
- 3. **The moment the image URLs are back, fire `commerce/seed-store` (§2) and keep writing UI while it runs.** Don't idle on the call; pick up its response (catalog report, slugs) when you next need it. If your tooling runs calls in the background, use that; if not, order the work so the call sits between two chunks of component-writing, never between you and an empty wait.
16
- 4. **Payment wiring (§2.2), if the store takes card payments, runs in parallel too** — the file paste and webhook registration touch nothing the storefront depends on, and the ask for the secret key can be pending while you build.
17
- 5. **Converge at the end**: with the seed done and pages written, verify the storefront against the live catalog and walk the [Done when](#4-done-when) checklist.
13
+ 1. **Start image generation first** — kick off every product image before anything else (§3.1 "Images"), because it takes the longest and nothing depends on it until seed time.
14
+ 2. **Mount the admin router (§1)** — minutes of work — and build the storefront (§2) while the images render.
15
+ 3. **The moment the image URLs are back, fire `commerce/seed-store` (§3).** Its writes run in parallel, so the call usually takes a few seconds — but don't idle on it either; pick up its response (catalog report, slugs) when you next need it.
16
+ 4. **Payment wiring (§4), if the store takes card payments, runs in parallel too** — the file paste and webhook registration touch nothing the storefront depends on, and the ask for the secret key can be pending while you build.
17
+ 5. **Converge at the end**: with the seed done and pages written, open the storefront against the live catalog and walk the [Done when](#5-done-when) checklist.
18
18
 
19
- The only real dependency edges are: image URLs → seed payload, and seed done → final verification. Everything else overlaps.
19
+ The only real dependency edges are: image URLs → seed payload, and seed done → seeing real products on the finished pages. Everything else overlaps.
20
20
 
21
21
  ---
22
22
 
23
23
  ## 1. Embedding the admin pages
24
24
 
25
- The admin UI is a self-contained React app under `src/commerce/admin/`. Its only external touchpoints are `@/components/ui/*` (shadcn) and `@/api/base44Client` (your app's SDK client). `src/commerce/utils/` sits alongside it framework-free storefront helpers (variant selection, free-shipping rules) with no dependencies, used by the customer-facing UI you build, not by the admin.
25
+ The admin UI is a self-contained React app under `src/commerce/admin/`. Its only external touchpoints are `@/components/ui/*` (shadcn) and `@/api/base44Client` (your app's SDK client). Two storefront-logic folders sit alongside it, used by the customer-facing UI you build, not by the admin: `src/commerce/utils/` (framework-free helpersAPI client, variant selection, free-shipping rules; no dependencies) and `src/commerce/storefront/` (the React layer — shared-cart provider, guided-checkout hook, headless shipping/payment pickers, order-received hook; needs React and nothing else).
26
26
 
27
27
  **Steps:**
28
28
 
29
- 1. Copy `src/commerce/admin/` → `src/commerce/admin/` and `src/commerce/utils/` → `src/commerce/utils/` (already done if you ran `scripts/install.js`).
29
+ 1. Copy `src/commerce/admin/` → `src/commerce/admin/`, `src/commerce/utils/` `src/commerce/utils/` and `src/commerce/storefront/` → `src/commerce/storefront/` (already done if you ran `scripts/install.js`).
30
30
  2. Check the app's `package.json` for `sonner`, `recharts` and `react-markdown` (the StoreAdmin bot uses the last one), and run `npm i` **only** for the ones actually absent — all three ship with the default Base44 template, so the normal outcome is no install at all. Do not re-install a package that is already a dependency. The template needs **no other dependency**. Verify the shadcn primitives listed in `src/commerce/admin/README.md` are present (`npx shadcn@latest add <name>` for any missing).
31
31
  3. Mount the router:
32
32
  ```jsx
33
33
  import AdminApp from "@/commerce/admin";
34
34
  <Route path="/store-admin/*" element={<AdminApp />} />
35
35
  ```
36
- **You must also build a payment return page** (`/order-received` by default) — this is **mandatory for payment links to work at all**. If your route differs, set it in Settings → General → *Payment return path*, or payment links will send customers to a 404. Every link (checkout, the admin's payment link, emails) returns there; without the route a paying customer hits a 404, and since confirming is what marks an order paid, orders would stay unpaid. The page is a thin wrapper over one backend call — step 4 of the [storefront quick start](#3-storefront-quick-start--logic-only) below covers it completely.
36
+ **You must also build a payment return page** (`/order-received` by default) — this is **mandatory for payment links to work at all**. If your route differs, set it in Settings → General → *Payment return path*, or payment links will send customers to a 404. Every link (checkout, the admin's payment link, emails) returns there; without the route a paying customer hits a 404, and since confirming is what marks an order paid, orders would stay unpaid. The page is one shipped hook `useOrderReturn` from `@/commerce/storefront`plus your markup; step 4 of the [storefront quick start](#2-storefront-quick-start--logic-only) below covers it completely.
37
37
 
38
38
  **Give the app root something too.** A blank Base44 app has no `/` route, so after mounting only `/store-admin/*` the app's own URL still renders its "page not found" screen — which reads exactly like a broken install. Until a storefront exists, redirect: `<Route path="/" element={<Navigate to="/store-admin" replace />} />`.
39
39
 
@@ -58,9 +58,217 @@ Even if the client guard were bypassed, layers 2 and 3 keep the store data safe.
58
58
 
59
59
  ---
60
60
 
61
- ## 2. Store dataseeding
61
+ ## 2. Storefront quick start logic only
62
62
 
63
- A fresh install has **no settings and no catalog**. One call to `commerce/seed-store` (admin-only, idempotent) initializes both. The call takes **~20 seconds** never sit through it: fire it and write storefront components while it runs (§0); nothing in §3 needs its response, only the final verification does. It always creates the business defaults the six settings groups (`general`, `products`, `inventory`, `tax`, `shipping`, `emails`; USD, kg/cm, taxes off prices) and the `offline` and `card` payment gateways and, depending on the payload, the catalog. A fallback "Rest of the world" **Shipping & Tax Location** (one free shipping rate, no tax) is seeded **only when the payload carries no `locations`** locations you pass are the store's only shipping data, with no seeded fallback beside them. Pass **`currency`** (an ISO code, e.g. `"EUR"`) and/or **`weight_unit`**/**`dimension_unit`** to set the store's currency and measurement units instead of the defaults explicit values always win, on a first seed and a re-run alike. (Prices are *formatted* with `Intl.NumberFormat` per the viewer's locale the currency is a value; there are no format settings.)
63
+ No visitor UI ships and no visual component ships either: **every pixel of the shopfront stays yours to design**. What ships is the logic: the storefront **API**, the framework-free helpers in `@/commerce/utils`, and the React layer in `@/commerce/storefront` hooks and headless pickers owning the contracts every store must get right. The catalog views (§2.1–2.2) are deliberately the thinnest, because that is where storefronts differ most; the cart and checkout (§2.3–2.4) are more guided, because shipping recalculation, payment methods and the place-order gate work the same in every store. **None of it waits on anything**: every shape you build against is documented right here, so seeding (§3) and payment wiring (§4) run in parallel with building these pages (§0 has the schedule) kick off image generation, write the storefront while it renders, seed when the URLs are back, and pick up payments whenever the secret key arrives. Live data is only needed once, to see real products on the finished pages. The four chunks below are the whole happy path; open [`docs/api-storefront.md`](./docs/api-storefront.md) only for what's beyond them (attribute/price filters, reviews, customer accounts, refunds), and [`references/product-render.md`](./references/product-render.md) for which fields belong in which view.
64
+
65
+ **Set up once** — mount the provider above every storefront route. It owns the shared API client, the store-info cache and ONE shared cart, so a header badge, a cart drawer and the checkout all render the same state:
66
+
67
+ ```jsx
68
+ import { StorefrontProvider } from "@/commerce/storefront";
69
+ import { base44 } from "@/api/base44Client";
70
+
71
+ <StorefrontProvider base44={base44}>
72
+ {/* storefront routes */}
73
+ </StorefrontProvider>
74
+ ```
75
+
76
+ The provider owns the two things hand-rolled storefronts keep getting wrong, so **don't reimplement either — and never mount a second provider or create a second client**: the **`cart_token` lifecycle** (sent with every cart/checkout call, re-persisted from every response — a stale token silently starts a fresh cart; rolling 48 h expiry; cleared when checkout consumes the cart) and the **store-info cache** (`payment_gateways`, currency, countries live **only** on `get-store-info` — the cart view never carries them). Inside the tree, `useStorefront()` returns the shared client for catalog calls, and `store.inv(fn, payload)` on it is the raw escape hatch (it unwraps the `{ success, data }` envelope). If non-React code also needs the client, create it once in `src/lib/storefront.js` (`createStorefront(base44)` from `@/commerce/utils`) and pass that same instance via `<StorefrontProvider store={store}>`.
77
+
78
+ **What each hook / client method resolves to** — every envelope is already unwrapped, so take these shapes at face value (no `.data`, and no `.categories` on a list result):
79
+
80
+ | Source | Resolves to |
81
+ |---|---|
82
+ | `useStoreInfo()` | `{ info, settings, paymentGateways, countries, currencies, loading, error }` — cached; the **only** source of payment gateways |
83
+ | `useFormatMoney()` | `(amount) => "€19.99"` — the store's currency, the viewer's locale |
84
+ | `useCart()` | `{ cart, loading, itemCount, isEmpty, addItem, updateItem, removeItem, applyCoupon, removeCoupon, refresh }` — ONE cart shared by every consumer; `cart.items[n].attributes` is an **array** of `{ name, option }`, never a map |
85
+ | `useCheckout()` / `CheckoutProvider` | the guided checkout — §2.4 |
86
+ | `useOrderReturn()` | the `/order-received` page — §2.4 |
87
+ | client `listProducts(params)` | `{ products, page, per_page, has_next }` — a page object; the array is `products` |
88
+ | client `getProduct(slug \| { id })` | `{ product, variations, categories, ribbons, reviews }` |
89
+ | client `listCategories()` | an **array** of root categories, subcategories nested under `children` |
90
+ | client `listRibbons()` | an **array** of `{ id, name, count }` |
91
+
92
+ Keep the UI in **small focused components** (~2–4K characters each — product card, gallery, cart panel, address step, payment step…), not monolithic page files: smaller files are faster to emit, review and fix.
93
+
94
+ ### 2.1 Product list
95
+
96
+ The catalog is direct client calls plus your design — no premade structure to fight:
97
+
98
+ ```js
99
+ const store = useStorefront(); // the shared client
100
+ const formatMoney = useFormatMoney(); // store currency, viewer's locale — use for every price
101
+ // useStoreInfo() → { settings, paymentGateways, countries, … } wherever store data is needed;
102
+ // it is the ONLY source of payment gateways (they are never on the cart view)
103
+
104
+ const { products, page, per_page, has_next } = await store.listProducts({
105
+ page: 1, per_page: 12, // optional: search, category_id, ribbon_id, featured, on_sale,
106
+ sort: "-created_date", // min_price, max_price, in_stock_only
107
+ }); // sort: -created_date | name | price | -price | popularity | rating
108
+ ```
109
+
110
+ Each row is a full product record — for a card use `name`, `images[0]?.src` (**may be empty — render a placeholder, never a broken `<img>`**), `price`, `regular_price`, `on_sale` (sale badge), `short_description`, `stock_status`, `average_rating`/`rating_count` (stars cost no extra call) and `ribbons` (`[{ id, name }]`, may be absent — labels like "Best Seller" for the card corner). **There is no product type flag**: `product.attributes?.length > 0` means the product sells variants and its `price` is a *from*-price rolled up from the cheapest variant — render it as "From …". Categories for the nav come from `store.listCategories()` — an **array** of root categories with subcategories nested under `children` (map over it directly; there is no `{ categories }` wrapper on the result). That is the whole card — no other call or reference needed for the list view.
111
+
112
+ **Carry forward:** each card links to the product page by **`slug`**.
113
+
114
+ ### 2.2 Product page — variant selection included
115
+
116
+ ```js
117
+ const { product, variations, categories, ribbons, reviews } =
118
+ await store.getProduct(slug); // or store.getProduct({ id })
119
+
120
+ // One selector PER product.attributes[] entry — never a flat list of variations.
121
+ import { defaultSelection, selectOption, resolveSelection } from "@/commerce/utils";
122
+
123
+ let selection = defaultSelection(product, variations); // merchant defaults + single-option axes
124
+ // on user pick: selection = selectOption(product, variations, selection, axisKey, option);
125
+
126
+ const view = resolveSelection(product, variations, selection);
127
+ // view.axes → [{ key, name, options }] — render one control each
128
+ // view.availability → { [axisKey]: { [option]: "available" | "out_of_stock" | "unavailable" } }
129
+ // view.display → { price, regular_price, on_sale, sku, stock_status, image, … } for the selection
130
+ // ⚠ display.image is an OBJECT — { src, name, alt } | null. Render
131
+ // <img src={view.display.image?.src} alt={view.display.image?.alt}>.
132
+ // Passing the object itself as src fails the load and your fallback
133
+ // shows a placeholder for every product — with the real image sitting
134
+ // one `.src` away. Same shape everywhere: product.images[n].src too.
135
+ // view.purchasable → gate the Add-to-cart button on this
136
+ // view.addToCart → { product_id, variation_id } — null until the selection resolves
137
+ ```
138
+
139
+ Add to cart — through the shared cart, so every badge and drawer in the tree updates at once (the cart bootstraps itself; a missing, stale or expired token starts a fresh one, and the provider does all the token bookkeeping):
140
+
141
+ ```js
142
+ const { addItem } = useCart();
143
+ await addItem(view.addToCart); // quantity 1 — addItem(view.addToCart, 3) for more
144
+ ```
145
+
146
+ A product with attributes is **rejected without a `variation_id`** (`400 variation_required`) — that is why `view.addToCart` and not a bare `product_id` goes into the call.
147
+
148
+ **What the page renders — all from this one `get-product` call, no extra reads:** a gallery from `product.images` (`view.display.image` is the variant-selected one; placeholder **only** when the product truly has no images — every image is an `{ src, alt }` object, so render `img.src`/`img.alt`, never the object itself), name, price from `view.display` (`price`/`regular_price`/`on_sale` → sale badge), one selector per axis, stock state, `short_description` then `description` (**both HTML — render as rich text, don't escape or truncate away the markup**), SKU, `categories` as a breadcrumb, `ribbons` as light labels near the metadata, the `reviews` block (`{ items, has_next, average_rating, rating_count }`), and the `upsells`/`cross_sells` summaries. Descriptive properties (Material, Care…) live in `product.meta_data` — render them as a spec table; they are not attributes and not ribbons. That is the complete product page — [`references/storefront-product-page.md`](./references/storefront-product-page.md) and [`references/product-render.md`](./references/product-render.md) are only for edge cases and for adding fields to the *listing* call.
149
+
150
+ **Carry forward:** nothing — the client keeps the `cart_token`.
151
+
152
+ ### 2.3 Cart
153
+
154
+ **A cart page is optional — decide from what the store sells.** Under the hood a cart always exists (checkout consumes it, and `addItem` is still how anything gets bought), but the *UI step* — a cart page or drawer where customers review and edit line items — is a choice, not a requirement. A store selling one service, made-to-order pieces, or typically-single-item purchases reads better as **buy-now**: `addItem(view.addToCart)` and navigate straight to checkout, no cart view anywhere. Multi-item catalogs where customers accumulate a basket want the full cart step. Either way the checkout (§2.4) is unchanged — it reads the same shared cart whether the customer curated it on a cart page or a buy-now button filled it a second ago.
155
+
156
+ When the store does want one, the cart is **shared state** — `useCart()` anywhere in the tree reads and mutates the same view, every action re-renders every consumer, and calls are serialized so rapid quantity clicks can never apply out of order:
157
+
158
+ ```jsx
159
+ const { cart, loading, itemCount, isEmpty,
160
+ updateItem, removeItem, applyCoupon, removeCoupon } = useCart();
161
+ // cart === null → no cart yet (an expired token self-clears) — render an empty state
162
+ // cart.items → [{ item_key, name, image, quantity, price, subtotal, total, attributes, purchasable }]
163
+ // item.attributes is an ARRAY of { name, option } — render "Size: 42" by
164
+ // mapping it (attributes.map(a => `${a.name}: ${a.option}`)); it is never a map
165
+ // cart.totals → { subtotal, discount_total, shipping_total, cart_tax, total_tax, total, … }
166
+ // cart.coupon_notices / cart.removed_items → tell the customer what auto-dropped and why
167
+ //
168
+ // NOT in the cart view: payment_gateways (useStoreInfo() ONLY — cart.payment_gateways
169
+ // is always undefined), the product catalog (listProducts/getProduct), countries/currencies
170
+ // (useStoreInfo). Never dot into the cart for any of those.
171
+
172
+ await updateItem(item_key, quantity); // ≤0 removes
173
+ await removeItem(item_key);
174
+ const res = await applyCoupon(code); // { ok: true, cart } or { ok: false, code, message }
175
+ if (!res.ok) setCouponError(res.message); // an invalid code is expected flow — render it inline
176
+ ```
177
+
178
+ Shipping cost on the cart page: a store with exactly **one shipping location** shows its options (and, auto-selected, the cost) even before an address is known — render `cart.available_shipping_methods` and the totals as they come. Several locations report `shipping_status: "missing_address"` until an address resolves one; that address is collected in checkout, where `useCheckout` recalculates shipping and tax automatically the moment it is complete (§2.4) — **don't build a separate estimator or call `set-shipping-address` by hand on the cart page.**
179
+
180
+ **Carry forward:** nothing — the provider keeps the `cart_token`, and the address and method choice live on the shared cart.
181
+
182
+ ### 2.4 Checkout & order-received
183
+
184
+ `useCheckout()` is the guided checkout — it owns everything that is the same in every store, and your page is markup around it:
185
+
186
+ - **address form state** (`billing`/`updateBilling`, an optional separate `shipping`/`updateShipping` behind `setShipToDifferent`), with `missingBillingFields` tracking what `place-order` would reject;
187
+ - **automatic shipping/tax recalculation**: the moment the address is complete enough to price (default: country + city), the hook debounces and calls `set-shipping-address`, repricing every shipping option, its cost and the taxes — half-typed addresses are never sent, an unchanged address is never re-sent, and an address the store doesn't ship to surfaces as `addressError` to show **on the address fields**;
188
+ - **the shipping choice** (`shippingStatus`, `shippingMethods`, `chosenShippingMethod`, `chooseShippingMethod`) and **the payment choice** (`paymentMethods` from store info — their only source; a store with exactly one enabled gateway gets it pre-selected);
189
+ - **the gate**: `canPlaceOrder` + named `blockers`, and `placeOrder` with the online-payment redirect handled.
190
+
191
+ Share one instance across the page's components with `CheckoutProvider` and build each step as your own markup:
192
+
193
+ ```jsx
194
+ import { CheckoutProvider, useCheckoutContext, useCart, useStoreInfo,
195
+ ShippingMethodPicker, PaymentMethodPicker } from "@/commerce/storefront";
196
+
197
+ <CheckoutProvider> {/* one useCheckout shared by the whole page */}
198
+ <AddressFields /> {/* your components, your layout, your look */}
199
+ <ShippingStep />
200
+ <PaymentStep />
201
+ <OrderSummary /> {/* useCart().cart.totals re-renders as the address edits reprice it */}
202
+ <PlaceOrderButton />
203
+ </CheckoutProvider>
204
+ ```
205
+
206
+ The address form binds inputs to the hook — editing is all it takes to trigger the recalculation (`useStoreInfo().countries` is the country table for the selector):
207
+
208
+ ```jsx
209
+ const { billing, updateBilling, missingBillingFields,
210
+ addressError, shippingSyncing } = useCheckoutContext();
211
+ <input value={billing.city} onChange={(e) => updateBilling({ city: e.target.value })} />
212
+ {addressError && <p role="alert">{addressError.message}</p>} // "we don't ship there" lives HERE
213
+ ```
214
+
215
+ The two store-data choices — **never hardcode either** — come pre-branched through the headless pickers (they render nothing themselves; the render prop is the whole UI):
216
+
217
+ ```jsx
218
+ <ShippingMethodPicker>{({ status, methods, chosen, choose, mustChoose, syncing }) => (
219
+ // renders null for you on virtual carts (status "not_needed")
220
+ // status "missing_address" → say options appear once the address is entered
221
+ // mustChoose → render methods [{ id, title, cost }] as a picker → choose(m.id)
222
+ // otherwise → display chosen.title + chosen.cost (never a raw id); dim while syncing
223
+ )}</ShippingMethodPicker>
224
+
225
+ <PaymentMethodPicker>{({ gateways, value, select, selected, single }) => (
226
+ // several → picker labeled with the admin's title/description → select(g.slug)
227
+ // single → pre-selected; skip the picker but still show selected.title
228
+ // none → checkout cannot complete — say so instead of rendering a dead button
229
+ )}</PaymentMethodPicker>
230
+ ```
231
+
232
+ While the store has no card provider implemented (§4 — one file, Stripe paste-in), picking the card gateway fails `place-order` with `503 no_card_payment_provider` — tell the customer card payment is temporarily unavailable and offer the other methods. `online: true` marks the card/redirect gateway; every other gateway is manual reconciliation.
233
+
234
+ Placing the order — drive the button off the gate; the hook redirects to the provider's payment page when the gateway is online:
235
+
236
+ ```jsx
237
+ const { canPlaceOrder, blockers, placing, placeOrder, orderError } = useCheckoutContext();
238
+
239
+ <button disabled={!canPlaceOrder || placing} onClick={async () => {
240
+ const res = await placeOrder({ customer_note });
241
+ if (res.ok && !res.result.payment) showConfirmation(res.result);
242
+ // manual gateway → res.result.payment_instructions: { description, account_details } — render them
243
+ // online gateway → the hook already redirected to res.result.payment.checkout_url
244
+ // res.ok === false → res.error rendered below; the hook re-read the cart if it changed underneath
245
+ }}>Place order</button>
246
+ {orderError && <p role="alert">{orderError.message}</p>}
247
+ ```
248
+
249
+ `blockers` names exactly what still stands in the way — drive inline hints from it instead of re-deriving: `empty_cart`, `billing_incomplete` (pair with `missingBillingFields`), `shipping_address_incomplete`, `shipping_recalculating`, `shipping_address_required`, `shipping_method_required`, `shipping_not_available`, `payment_method_required`.
250
+
251
+ Every payment link returns to **`/order-received`** — the page from §1 step 3. One hook, idempotent, safe on every visit (it reads the URL params itself; `?payment=` is only a hint — the server verifies with the provider):
252
+
253
+ ```jsx
254
+ const { status, order, paymentLink, paymentInstructions, error, reload } = useOrderReturn();
255
+ // "loading" → spinner
256
+ // "paid" → thank-you + order summary (the order is now marked paid)
257
+ // "unpaid" → card order: offer paymentLink.url to pay now;
258
+ // manual order: render paymentInstructions ({ description, account_details })
259
+ // "cancelled" → payment was cancelled — offer paymentLink.url or support
260
+ // "error" → render error.message with a retry via reload()
261
+ ```
262
+
263
+ Two shapes to get right when rendering: **`order` carries flat totals** — `order.total`, `order.shipping_total`, `order.total_tax` — there is **no `order.totals` object** (that nested shape belongs to the cart view and the place-order result's top-level `totals`); and `paymentLink` is `{ url, reference } | null` (card orders only).
264
+
265
+ **Carry forward:** `order_id` + `order_key` are the guest's proof of ownership — `commerce/storefront-account` `get-order` with both returns the order for a tracking page (signed-in customers get `my-orders` with no key).
266
+
267
+ ---
268
+
269
+ ## 3. Store data — seeding
270
+
271
+ A fresh install has **no settings and no catalog**. One call to `commerce/seed-store` (admin-only, idempotent) initializes both. Its writes run in parallel, so the call usually takes a few seconds (large catalogs longer) — and nothing in §2 needs its response anyway, so fire it and keep building (§0). It always creates the business defaults — the six settings groups (`general`, `products`, `inventory`, `tax`, `shipping`, `emails`; USD, kg/cm, taxes off prices) and the `offline` and `card` payment gateways — and, depending on the payload, the catalog. A fallback "Rest of the world" **Shipping & Tax Location** (one free shipping rate, no tax) is seeded **only when the payload carries no `locations`** — locations you pass are the store's only shipping data, with no seeded fallback beside them. Pass **`currency`** (an ISO code, e.g. `"EUR"`) and/or **`weight_unit`**/**`dimension_unit`** to set the store's currency and measurement units instead of the defaults — explicit values always win, on a first seed and a re-run alike. (Prices are *formatted* with `Intl.NumberFormat` per the viewer's locale — the currency is a value; there are no format settings.)
64
272
 
65
273
  Pass **`payment_methods`** (gateway slugs, e.g. `["card"]`) when the user restricts how they get paid: the listed gateways are enabled and **every other gateway row is disabled** — "card-only" or "offline-only" is part of the same seed call, with **no `commerce.PaymentGateway` reads or writes of your own**. Explicit values win on re-runs too. Unknown slugs fail as `400 invalid_payload` (the error lists the known ones). Should you ever need direct entity access, names are dotted — bracket syntax only: `base44.entities["commerce.PaymentGateway"]` (`commerce__PaymentGateway` / `PaymentGateway` don't exist).
66
274
 
@@ -76,57 +284,65 @@ Pass **`payment_methods`** (gateway slugs, e.g. `["card"]`) when the user restri
76
284
 
77
285
  The call runs a canary schema check first — on **422** `schema_incompatible` fix the reported entities before continuing. Bad catalog payloads fail as **400** `invalid_payload` with an `errors: [{ path, error }]` list before anything is written.
78
286
 
79
- ### 2.1 The `products` payload
287
+ ### 3.1 The `products` payload
80
288
 
81
289
  **This is the store's real data, not template data.** The call writes the actual `commerce.*` records the admin and the storefront API read from that moment on — nothing is post-processed, substituted or "filled in by the platform" later. Whatever you pass is exactly what `list-products`/`get-product` return, so pass final values (real copy, real prices, permanently-resolvable image URLs) and build the storefront on what the API returns — never on client-side constants mirroring the seed (e.g. a slug→image map): the database is the single source of truth, and such a mirror silently diverges the first time a product is edited in the admin.
82
290
 
83
291
  Reference everything by **display name** — categories, ribbons, attributes and their options are get-or-created (slugs and codes derived, existing records matched case-insensitively and reused). Variants come from `attributes`: list each axis with the options the product comes in, and either pass explicit `variations` (only the combinations you stock, with per-variation overrides) or omit them to auto-generate **every combination**. Prices and the sale window are inherited from the product spec unless a variation overrides them; a variation with its own `stock_quantity` tracks it, one without draws on the parent's pooled `stock_quantity`.
84
292
 
293
+ **Running this through a code-execution tool? Return only plain JSON.** The SDK's `invoke` resolves to the raw HTTP response, which carries circular request/response objects — `return result` (or stringifying a thrown error whole) fails with `Converting circular structure to JSON` *even when the seed itself succeeded*. Return `res.data` (the `{ success, data }` envelope) and, on error, `e.response?.data`:
294
+
85
295
  ```js
86
- await base44.functions.invoke("commerce/seed-store", {
87
- store_name: "Aurora Threads",
88
- currency: "EUR", // optional — defaults to USD
89
- payment_methods: ["card"], // optional — enables ONLY these; omit to keep offline + card
90
- products: [
91
- { // simple product
92
- name: "Classic T-Shirt",
93
- sku: "TEE-CLASSIC", // optional, but makes re-runs idempotent
94
- regular_price: 19.99,
95
- stock_quantity: 50, // implies manage_stock: true
96
- categories: ["Clothing"], // get-or-create by name
97
- ribbons: ["Best Seller"],
98
- images: ["https://…/tee.jpg"], // URLs or { src, alt }
99
- short_description: "A soft, breathable everyday tee.",
100
- description: "<p>Cut from combed cotton…</p><ul><li>100% combed cotton</li><li>Pre-shrunk</li></ul>",
101
- },
102
- { // variant product — attributes declare the axes, variations the stocked combos
103
- name: "Runner Sneaker",
104
- sku: "SNK-RUN",
105
- regular_price: 89, // inherited by variations that don't override
106
- categories: ["Shoes"],
107
- images: [{ src: "https://…/sneaker.jpg", alt: "Runner Sneaker, side view" }],
108
- attributes: [
109
- { name: "Size", options: ["41", "42", "43"] },
110
- { name: "Color", options: ["Black", "White"] },
111
- ],
112
- default_options: { Size: "42", Color: "Black" }, // pre-selected combination
113
- variations: [ // omit entirely all 6 combos auto-generated
114
- { options: { Size: "41", Color: "Black" }, stock_quantity: 4 },
115
- { options: { Size: "42", Color: "Black" }, stock_quantity: 6 },
116
- { options: { Size: "43", Color: "Black" }, stock_quantity: 2 },
117
- { options: { Size: "42", Color: "White" }, regular_price: 94, stock_quantity: 3,
118
- image: "https://…/sneaker-white.jpg" }, // give a visual axis per-variation images
119
- ],
120
- },
121
- ],
122
- coupons: [{ code: "WELCOME10", discount_type: "percent", amount: 10 }], // optional
123
- locations: [{ // optional passing any makes these the store's ONLY locations (the free-shipping fallback is not seeded)
124
- name: "Israel", countries: ["IL"],
125
- shipping_rates: [{ name: "Standard", cost: 20, free_over: 150 }],
126
- tax_groups: [{ name: "Products", rates: [{ name: "VAT", rate: 18 }] }],
127
- shipping_tax: { type: "percent", value: 18 }, // or { type: "fixed", value: 5 }
128
- }],
129
- });
296
+ try {
297
+ const res = await base44.functions.invoke("commerce/seed-store", {
298
+ store_name: "Aurora Threads",
299
+ currency: "EUR", // optional — defaults to USD
300
+ payment_methods: ["card"], // optional — enables ONLY these; omit to keep offline + card
301
+ products: [
302
+ { // simple product
303
+ name: "Classic T-Shirt",
304
+ sku: "TEE-CLASSIC", // optional, but makes re-runs idempotent
305
+ regular_price: 19.99,
306
+ stock_quantity: 50, // implies manage_stock: true
307
+ categories: ["Clothing"], // get-or-create by name
308
+ ribbons: ["Best Seller"],
309
+ images: ["https://…/tee.jpg"], // URLs or { src, alt }
310
+ short_description: "A soft, breathable everyday tee.",
311
+ description: "<p>Cut from combed cotton…</p><ul><li>100% combed cotton</li><li>Pre-shrunk</li></ul>",
312
+ },
313
+ { // variant product — attributes declare the axes, variations the stocked combos
314
+ name: "Runner Sneaker",
315
+ sku: "SNK-RUN",
316
+ regular_price: 89, // inherited by variations that don't override
317
+ categories: ["Shoes"],
318
+ images: [{ src: "https://…/sneaker.jpg", alt: "Runner Sneaker, side view" }],
319
+ attributes: [
320
+ { name: "Size", options: ["41", "42", "43"] },
321
+ { name: "Color", options: ["Black", "White"] },
322
+ ],
323
+ default_options: { Size: "42", Color: "Black" }, // pre-selected combination
324
+ variations: [ // omit entirely all 6 combos auto-generated
325
+ { options: { Size: "41", Color: "Black" }, stock_quantity: 4 },
326
+ { options: { Size: "42", Color: "Black" }, stock_quantity: 6 },
327
+ { options: { Size: "43", Color: "Black" }, stock_quantity: 2 },
328
+ { options: { Size: "42", Color: "White" }, regular_price: 94, stock_quantity: 3,
329
+ image: "https://…/sneaker-white.jpg" }, // give a visual axis per-variation images
330
+ ],
331
+ },
332
+ ],
333
+ coupons: [{ code: "WELCOME10", discount_type: "percent", amount: 10 }], // optional
334
+ locations: [{ // optional — passing any makes these the store's ONLY locations (the free-shipping fallback is not seeded)
335
+ name: "Israel", countries: ["IL"],
336
+ shipping_rates: [{ name: "Standard", cost: 20, free_over: 150 }],
337
+ tax_groups: [{ name: "Products", rates: [{ name: "VAT", rate: 18 }] }],
338
+ shipping_tax: { type: "percent", value: 18 }, // or { type: "fixed", value: 5 }
339
+ }],
340
+ });
341
+ return res.data; // the { success, data } envelope — plain JSON
342
+ } catch (e) {
343
+ // e itself is circular (it wraps the HTTP request) — return only its payload
344
+ return { success: false, status: e.response?.status, ...(e.response?.data ?? { error: e.message }) };
345
+ }
130
346
  ```
131
347
 
132
348
  What the seeder does per product: derives a unique slug, checks SKU uniqueness, prices variations (`sale_price` + optional `date_on_sale_from/to` supported at both levels), rolls the parent's `price`/`regular_price`/`on_sale` up from the cheapest publishable variant (never set a variant parent's price yourself — it's derived), sets `stock_status`, and maintains category/ribbon counts. Products default to `status: "publish"`; pass `"draft"` to review first. Other `commerce.Product` fields (`weight`, `dimensions`, `virtual`, `downloadable`, `downloads`, `meta_data`, …) pass through; unknown keys are rejected so typos surface instead of vanishing.
@@ -151,21 +367,23 @@ The response reports everything:
151
367
  "payment_methods": { "enabled": ["card"], "disabled": ["offline"] } } // null when not passed
152
368
  ```
153
369
 
154
- **Images**: every product needs at least one, and the URL you seed is the URL the store serves — there are no placeholders to swap later. So resolve each image to its **final URL before seeding**: use whatever image generation the app has available and store the returned URL, upload real assets with `base44.integrations.Core.UploadFile({ file })` → public URL, or fall back to stable public stock URLs (`base44/functions/commerce/seed-store/sample-data.ts` shows a working Unsplash pattern). Match the image to the product. **Final means permanent and resolving**, and both fail silently later rather than at seed time, so check them now: an image tool that returns a *temporary or signed* URL (expiry params in the query string are the tell) must be re-hosted — download and `UploadFile` it for a stable public URL — and before seeding, spot-check that the URLs actually resolve (fetch one or two: HTTP 200, image content type). A dead or expiring URL seeds fine and then renders as a broken image or an eternal placeholder in the store. Image generation is usually the **slowest step of the whole install** — this is dependency edge #1 of §0: kick all product images off first, do the rest (router mount, payment file, storefront pages) while they render, and seed the moment the URLs are back — then keep writing UI through the seed call too. If an image isn't ready at seed time you *may* seed without it and set it afterwards through the admin API (never seed a dead path and compensate in the frontend) — but that is **an open debt, not a resolution**: track every product seeded imageless and close it before handover. The [Done when](#4-done-when) checklist fails while any product shows a placeholder for lack of a real image.
370
+ **Images**: every product needs at least one, and the URL you seed is the URL the store serves — there are no placeholders to swap later. So resolve each image to its **final URL before seeding**: use whatever image generation the app has available and store the returned URL, upload real assets with `base44.integrations.Core.UploadFile({ file })` → public URL, or fall back to stable public stock URLs (`base44/functions/commerce/seed-store/sample-data.ts` shows a working Unsplash pattern). Match the image to the product. **Final means permanent and resolving**, and both fail silently later rather than at seed time, so check them now: an image tool that returns a *temporary or signed* URL (expiry params in the query string are the tell) must be re-hosted — download and `UploadFile` it for a stable public URL — and before seeding, spot-check that the URLs actually resolve (fetch one or two: HTTP 200, image content type). A dead or expiring URL seeds fine and then renders as a broken image or an eternal placeholder in the store. Image generation is usually the **slowest step of the whole install** — this is dependency edge #1 of §0: kick all product images off first, do the rest (router mount, payment file, storefront pages) while they render, and seed the moment the URLs are back. If an image isn't ready at seed time you *may* seed without it and set it afterwards through the admin API (never seed a dead path and compensate in the frontend) — but that is **an open debt, not a resolution**: track every product seeded imageless and close it before handover.
155
371
 
156
372
  A successful response means the data is in — the catalog and settings are live exactly as reported. Write any remaining store-specific settings into `commerce.StoreSettings` (direct CRUD, one record per `group_id` — weight/dimension units are the usual ones; patch `values`, don't replace groups you weren't asked about).
157
373
 
158
- ### 2.2 Payments — one file, any provider (Stripe as the reference)
374
+ ---
375
+
376
+ ## 4. Payments — one file, any provider (Stripe as the reference)
159
377
 
160
378
  The order side of payments is **already implemented** (checkout routing, confirmation, payment links, refund records). The `offline` gateway — and any option the admin adds in Settings → Payments — works with nothing to configure: the order goes on-hold with the option's description as instructions.
161
379
 
162
- **Whether the store needs online payments — and when in the install to wire them — is your decision, not a fixed step.** Nothing else on this page depends on this section: the admin, the seed and the entire storefront work with no provider wired (until then the card option answers `503 no_card_payment_provider` at checkout, and §3.4 shows the graceful fallback). Decide from what the user is actually building:
380
+ **Whether the store needs online payments — and when in the install to wire them — is your decision, not a fixed step.** Nothing else on this page depends on this section: the admin, the seed and the entire storefront work with no provider wired (until then the card option answers `503 no_card_payment_provider` at checkout, and §2.4 shows the graceful fallback). Decide from what the user is actually building:
163
381
 
164
382
  - **The store doesn't take online payments** (cash on delivery, bank transfer, invoicing, pickup, quote-based…) → seed `payment_methods` without `"card"` and skip the rest of this section — the manual gateways are complete as shipped.
165
383
  - **The store does take online payments** → this section is the how-to (Stripe below is the reference), but *you* pick the point in the flow. The wiring is self-contained — the file paste and webhook registration touch nothing the storefront depends on, so it **runs in parallel with the storefront build** (§0) rather than before or after it. The one part that involves the user is the provider secret key: ask when it makes sense in the conversation, and keep building while the answer is pending — never stall the install on it. What's non-negotiable is only the end state: don't hand over a finished store with the card gateway enabled but no provider behind it (wire one, or disable the gateway).
166
384
  - **Unclear from the request** → build everything else first and raise payments with the user at the end, or ask.
167
385
 
168
- Which methods the store offers is **seed data**: pass `payment_methods` to `commerce/seed-store` (§2) — e.g. `["card"]` for a card-only store — instead of ever editing `commerce.PaymentGateway` records yourself.
386
+ Which methods the store offers is **seed data**: pass `payment_methods` to `commerce/seed-store` (§3) — e.g. `["card"]` for a card-only store — instead of ever editing `commerce.PaymentGateway` records yourself.
169
387
 
170
388
  The **Credit card** option needs a payment provider — **any** provider works (Stripe, PayPal, Adyen, a local PSP…), and whichever one it is, wiring it touches exactly **one file**: `base44/shared/commerce/card-payment.ts` — four functions, each backing a premade flow. `commerce/payment-webhook` (the function) is **premade — do not edit it**: it calls this file's `parseWebhook` to validate each event and **never trusts an event body on its own** — an unverified event only *names* an order, and whether money arrived is asked of the provider itself through `checkCardPaymentPaid`, so a forged webhook call can never mark an order paid and **no signing secret is needed**. Until the file is implemented the card option answers `503 no_card_payment_provider` at checkout (or switch it off in Settings → Payments).
171
389
 
@@ -292,209 +510,15 @@ That's it — checkout redirect, `/order-received` confirmation, the webhook, th
292
510
 
293
511
  ---
294
512
 
295
- ## 3. Storefront quick start — logic only
296
-
297
- No visitor UI ships; the storefront **API** is complete. The four chunks below are the whole happy path — product list → product page → cart → checkout — showing what to call, what comes back, and what to carry into the next step. **None of it waits on the seed**: every shape you build against is documented right here, so write these components while `seed-store` and image generation are still running (§0) — live data is only needed to verify the finished pages. Open [`docs/api-storefront.md`](./docs/api-storefront.md) only for what's beyond them (attribute/price filters, reviews, customer accounts, refunds), and [`references/product-render.md`](./references/product-render.md) for which fields belong in which view.
298
-
299
- Build on the **shipped API client** — create it once and import that instance everywhere (wrapping it in a React context is fine; never a second copy):
513
+ ## 5. Done when
300
514
 
301
- ```js
302
- // src/lib/storefront.js
303
- import { createStorefront } from "@/commerce/utils";
304
- import { base44 } from "@/api/base44Client";
305
- export const store = createStorefront(base44);
306
- ```
307
-
308
- The client owns the two things hand-rolled storefronts keep getting wrong, so **don't reimplement either**: the **`cart_token` lifecycle** (sent with every cart/checkout call, re-persisted from every response — a stale token silently starts a fresh cart; rolling 48 h expiry; cleared when checkout consumes the cart) and the **store-info cache** (`payment_gateways`, currency, countries live **only** on `get-store-info` — the cart view never carries them). For anything beyond its methods, `store.inv(fn, payload)` unwraps the `{ success, data }` envelope (`res.data.data`).
309
-
310
- **What each method resolves to** — the client already unwraps every envelope, so take these shapes at face value (no `.data`, and no `.categories` on a list result):
311
-
312
- | Method | Resolves to |
313
- |---|---|
314
- | `store.getStoreInfo()` | `{ settings, payment_gateways, countries, currencies }` — cached; the **only** source of `payment_gateways` |
315
- | `store.listProducts(params)` | `{ products, page, per_page, has_next }` — a page object; the array is `products` |
316
- | `store.getProduct(slug \| { id })` | `{ product, variations, categories, ribbons, reviews }` |
317
- | `store.listCategories()` | an **array** of root categories, subcategories nested under `children` |
318
- | `store.listRibbons()` | an **array** of `{ id, name, count }` |
319
- | `store.getCart()` and every cart mutation | the full cart view (`getCart()` alone may resolve to `null` — no cart yet); `cart.items[n].attributes` is an **array** of `{ name, option }`, never a map |
320
- | `store.placeOrder(details)` | `{ order_id, order_number, order_key, status, totals, order, payment_instructions, payment }` |
321
- | `store.completeReturn()` | `{ state, order, payment_link, payment_instructions }` |
322
-
323
- Keep the UI in **small focused components** (~2–4K characters each — product card, gallery, cart panel, address step, payment step…), not monolithic page files: smaller files are faster to emit, review and fix.
324
-
325
- ### 3.1 Product list
326
-
327
- ```js
328
- const info = await store.getStoreInfo(); // cached — call it wherever needed
329
- // info.settings → { store_name, currency, weight_unit, … } — format money with
330
- // Intl.NumberFormat(undefined, { style: "currency", currency: info.settings.currency })
331
- // info.payment_gateways → [{ slug, title, description, online }] — you'll need this at checkout;
332
- // this call is its ONLY source (it is never on the cart view)
333
- // info.countries / info.currencies → static tables for address forms and money display
334
-
335
- const { products, page, per_page, has_next } = await store.listProducts({
336
- page: 1, per_page: 12, // optional: search, category_id, ribbon_id, featured, on_sale,
337
- sort: "-created_date", // min_price, max_price, in_stock_only
338
- }); // sort: -created_date | name | price | -price | popularity | rating
339
- ```
340
-
341
- Each row is a full product record — for a card use `name`, `images[0]?.src` (**may be empty — render a placeholder, never a broken `<img>`**), `price`, `regular_price`, `on_sale` (sale badge), `short_description`, `stock_status`, `average_rating`/`rating_count` (stars cost no extra call) and `ribbons` (`[{ id, name }]`, may be absent — labels like "Best Seller" for the card corner). **There is no product type flag**: `product.attributes?.length > 0` means the product sells variants and its `price` is a *from*-price rolled up from the cheapest variant — render it as "From …". Categories for the nav come from `store.listCategories()` — an **array** of root categories with subcategories nested under `children` (map over it directly; there is no `{ categories }` wrapper on the result). That is the whole card — no other call or reference needed for the list view.
342
-
343
- **Carry forward:** each card links to the product page by **`slug`**.
344
-
345
- ### 3.2 Product page — variant selection included
346
-
347
- ```js
348
- const { product, variations, categories, ribbons, reviews } =
349
- await store.getProduct(slug); // or store.getProduct({ id })
350
-
351
- // One selector PER product.attributes[] entry — never a flat list of variations.
352
- import { defaultSelection, selectOption, resolveSelection } from "@/commerce/utils";
353
-
354
- let selection = defaultSelection(product, variations); // merchant defaults + single-option axes
355
- // on user pick: selection = selectOption(product, variations, selection, axisKey, option);
356
-
357
- const view = resolveSelection(product, variations, selection);
358
- // view.axes → [{ key, name, options }] — render one control each
359
- // view.availability → { [axisKey]: { [option]: "available" | "out_of_stock" | "unavailable" } }
360
- // view.display → { price, regular_price, on_sale, sku, stock_status, image, … } for the selection
361
- // ⚠ display.image is an OBJECT — { src, name, alt } | null. Render
362
- // <img src={view.display.image?.src} alt={view.display.image?.alt}>.
363
- // Passing the object itself as src fails the load and your fallback
364
- // shows a placeholder for every product — with the real image sitting
365
- // one `.src` away. Same shape everywhere: product.images[n].src too.
366
- // view.purchasable → gate the Add-to-cart button on this
367
- // view.addToCart → { product_id, variation_id } — null until the selection resolves
368
- ```
369
-
370
- Add to cart — the cart bootstraps itself (a missing, stale or expired token starts a fresh one) and the client does all the token bookkeeping:
371
-
372
- ```js
373
- const cart = await store.addItem({ ...view.addToCart, quantity: 1 });
374
- ```
375
-
376
- A product with attributes is **rejected without a `variation_id`** (`400 variation_required`) — that is why `view.addToCart` and not a bare `product_id` goes into the call.
377
-
378
- **What the page renders — all from this one `get-product` call, no extra reads:** a gallery from `product.images` (`view.display.image` is the variant-selected one; placeholder **only** when the product truly has no images — every image is an `{ src, alt }` object, so render `img.src`/`img.alt`, never the object itself), name, price from `view.display` (`price`/`regular_price`/`on_sale` → sale badge), one selector per axis, stock state, `short_description` then `description` (**both HTML — render as rich text, don't escape or truncate away the markup**), SKU, `categories` as a breadcrumb, `ribbons` as light labels near the metadata, the `reviews` block (`{ items, has_next, average_rating, rating_count }`), and the `upsells`/`cross_sells` summaries. Descriptive properties (Material, Care…) live in `product.meta_data` — render them as a spec table; they are not attributes and not ribbons. That is the complete product page — [`references/storefront-product-page.md`](./references/storefront-product-page.md) and [`references/product-render.md`](./references/product-render.md) are only for edge cases and for adding fields to the *listing* call.
379
-
380
- **Carry forward:** nothing — the client keeps the `cart_token`.
381
-
382
- ### 3.3 Cart
383
-
384
- **Every cart action returns the same full priced view**, so re-render from whatever the last call returned — no separate refresh:
385
-
386
- ```js
387
- let cart = await store.getCart(); // null → no cart yet (an expired token self-clears)
388
- // cart.items → [{ item_key, name, image, quantity, price, subtotal, total, attributes, purchasable }]
389
- // item.attributes is an ARRAY of { name, option } — render "Size: 42" by
390
- // mapping it (attributes.map(a => `${a.name}: ${a.option}`)); it is never a map
391
- // cart.totals → { subtotal, discount_total, shipping_total, cart_tax, total_tax, total, … }
392
- // cart.coupon_notices / cart.removed_items → tell the customer what auto-dropped and why
393
- //
394
- // NOT in the cart view: payment_gateways (store.getStoreInfo() ONLY — cart.payment_gateways
395
- // is always undefined), the product catalog (listProducts/getProduct), countries/currencies
396
- // (getStoreInfo). Never dot into the cart for any of those.
397
-
398
- cart = await store.updateItem(item_key, quantity); // ≤0 removes
399
- cart = await store.removeItem(item_key);
400
- cart = await store.applyCoupon(code);
401
- ```
402
-
403
- Shipping is chosen **on the cart, before place-order** — this is the step storefronts most often skip, and `place-order` refuses without it (`400 shipping_method_required`). **Call `set-shipping-address` the moment the customer provides an address** — every shipping option and cost is recalculated by that call (never reuse a list fetched earlier), and an address the store doesn't ship to **fails right there** with `400 shipping_not_available`, so the address form is where you surface it:
404
-
405
- ```js
406
- // as soon as the address is entered — this is what (re)calculates shipping options + cost
407
- cart = await store.setShippingAddress({ country, state, postcode, city });
408
- // 400 shipping_not_available → show it on the address form
409
- // cart.chosen_shipping_method is the rate's ID (a string) — to display it, look
410
- // it up: cart.available_shipping_methods.find(m => m.id === cart.chosen_shipping_method)
411
- // and render that entry's title + cost. Never render the id itself.
412
- switch (cart.shipping_status) {
413
- case "auto_selected": break; // only ONE option existed — the backend already applied it;
414
- // no picker needed, just display the looked-up title + cost
415
- case "chosen": break; // customer's earlier choice still valid
416
- case "choice_required": // several options — MUST render cart.available_shipping_methods
417
- // [{ id, title, cost }] as a picker, then send the customer's pick:
418
- cart = await store.chooseShippingMethod(picked.id); // the entry's id, not its method_id type
419
- break;
420
- case "missing_address": break; // several zones, no address yet — shipping cost is NOT
421
- // calculated; collect the address and call setShippingAddress
422
- case "not_needed": break; // fully virtual cart
423
- }
424
- ```
425
-
426
- A store with exactly **one shipping zone** shows its options (and, auto-selected, the cost) on the cart even before an address is set — the options are the same everywhere. Several zones report `missing_address` until the address resolves one.
427
-
428
- **Carry forward:** the **`cart_token`** (address and method choice live on the cart).
429
-
430
- ### 3.4 Checkout & order-received
431
-
432
- The checkout page renders **two sets of options that are store data, never hardcoded**: the shipping methods (already resolved on the cart in step 3 — `place-order` refuses with `400 shipping_method_required` until `shipping_status` is `chosen`/`auto_selected`/`not_needed`) and the payment methods — every gateway the admin has **enabled**:
433
-
434
- ```js
435
- // card payments = ONE file to implement — §2.2 above has the complete
436
- // Stripe implementation to paste; no other reading needed
437
- const { payment_gateways: gateways } = await store.getStoreInfo();
438
- // ⚠ get-store-info is the ONLY source of payment_gateways — the cart view does
439
- // NOT include them (cart.payment_gateways is always undefined, which reads as
440
- // "no methods" and dead-disables the place-order button). If a context caches
441
- // store-info, read it from there — never from the cart object.
442
- // gateways → [{ slug, title, description, online }] — admin-owned data
443
- // several → render a picker using the admin's title/description as the labels
444
- // exactly ONE → no picker: use it directly, but still show its title so the customer knows how they'll pay
445
- // none → checkout cannot complete — say so instead of rendering a dead button
446
- const payment_method = gateways.length === 1 ? gateways[0].slug : picked.slug; // never a hardcoded "card"
447
- ```
448
-
449
- While the store has no card provider implemented, picking the card gateway fails `place-order` with `503 no_card_payment_provider` — tell the customer card payment is temporarily unavailable and offer the other methods.
450
-
451
- `online: true` marks the card/redirect gateway; every other gateway is manual reconciliation. Then one call places the order:
452
-
453
- ```js
454
- const res = await store.placeOrder({
455
- payment_method, // the slug chosen above
456
- billing: { first_name, last_name, address_1, city, country, email }, // the required set; phone, state, postcode optional
457
- // shipping: { … } if it differs from billing; customer_note?
458
- }); // token + return_url handled by the client; the cart is consumed
459
- // res → { order_id, order_number, order_key, status, totals, order,
460
- // payment_instructions, // manual gateways: { description, account_details } — render them
461
- // payment } // card: { status: "requires_payment", checkout_url, … } | null
462
-
463
- if (res.payment?.status === "requires_payment") window.location.href = res.payment.checkout_url;
464
- else showConfirmation(res); // manual order placed — show payment_instructions
465
- ```
466
-
467
- Every payment link returns to **`/order-received`** — the page from §1 step 3. It is one call, idempotent, safe on every visit:
468
-
469
- ```js
470
- // GET /order-received?order_id=…&order_key=…&payment=success|cancel
471
- const { state, order, payment_link, payment_instructions } = await store.completeReturn();
472
- // (reads the URL params itself; ?payment= is only a hint — the server verifies with the provider)
473
- // state === "paid" → thank-you + order summary (order is now marked paid)
474
- // state === "unpaid" → card order: offer payment_link.url to pay now;
475
- // manual order: render payment_instructions ({ description, account_details })
476
- // state === "cancelled" → payment was cancelled — offer payment_link.url or support
477
- ```
478
-
479
- Two shapes to get right when rendering: **`order` carries flat totals** — `order.total`, `order.shipping_total`, `order.total_tax` — there is **no `order.totals` object** (that nested shape belongs to the cart view and the place-order response's top-level `totals`); and `payment_link` is `{ url, reference } | null` (card orders only).
480
-
481
- **Carry forward:** `order_id` + `order_key` are the guest's proof of ownership — `commerce/storefront-account` `get-order` with both returns the order for a tracking page (signed-in customers get `my-orders` with no key).
482
-
483
- ---
484
-
485
- ## 4. Done when
486
-
487
- Post-installation is complete when every line below holds — check against this list instead of re-reading docs. **Do not validate the admin part at all**: everything under `/store-admin` ships finished and already tested — there is nothing there for you to verify.
515
+ Post-installation is complete when every line below holds. **Do not validate the admin part at all**: everything under `/store-admin` ships finished and already tested — there is nothing there for you to verify.
488
516
 
489
517
  - [ ] `/store-admin/*` mounted behind the shipped `AuthGuard`; `/` routes somewhere real (storefront or a redirect).
490
518
  - [ ] `commerce/seed-store` ran once and reported the catalog — real products, final image URLs; if the user restricted payment methods, `payment_methods` was passed in that same call.
491
- - [ ] Product list renders from `store.listProducts` (cards: image/placeholder, name, price or "From …", sale badge, stars, ribbons) and links by `slug`.
492
- - [ ] Product page renders from `store.getProduct` with one selector per attribute, resolving to `view.addToCart`.
493
- - [ ] **Real images actually render** — no placeholder anywhere except for a product genuinely without images: every image rendered via `.src` (`images[n].src`, `view.display.image?.src` — they are objects, not URL strings), every seeded URL permanent and resolving, and any product deliberately seeded imageless (§2.1) since given its image.
494
- - [ ] The storefront talks to the API through **one `createStorefront` instance** — no hand-rolled `cart_token` handling, and `payment_gateways` read from `getStoreInfo()` only, never off a cart.
495
- - [ ] `/order-received` calls `completeReturn` and renders `paid` / `unpaid` / `cancelled`.
496
- - [ ] Online payments **decided, not defaulted** (§2.2): if the store takes card payments, `card-payment.ts` implemented (Stripe: paste §2.2), `STRIPE_SECRET_KEY` secret set and webhook endpoint registered — wired at whatever point in the flow you judged right; if it doesn't (or not yet), the card gateway disabled (seed `payment_methods` without `"card"`). Either way, checkout never offers a card option with no provider behind it.
497
-
498
- ## 5. Next
519
+ - [ ] **Catalog pages and a checkout exist** — the catalog UI in whatever form fits the store (a product list, product pages, or both one can be enough), a cart step only if the store wants one (§2.3), and the checkout built on `useCheckout` with `/order-received` rendering `useOrderReturn` (§2.4).
520
+ - [ ] **One `<StorefrontProvider>`** above every storefront route — no second client, no hand-rolled `cart_token` handling, payment gateways read from `useStoreInfo()` only (never off a cart), and cart state everywhere through `useCart()`.
521
+
522
+ ## 6. Next
499
523
 
500
524
  Continue with the commerce skill — [`.agents/skills/commerce/SKILL.md`](./SKILL.md) — for day-2 work: UI changes, deeper storefront features ([`references/product-render.md`](./references/product-render.md) for what to render per view, [`references/storefront-product-page.md`](./references/storefront-product-page.md) for variant edge cases, [`references/reviews.md`](./references/reviews.md) for the ready-made reviews backend), payment provider wiring, scheduled maintenance, emails, webhooks, and operational limits.