@base44/app-plugin-commerce 0.3.3 → 0.4.0

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.
@@ -5,24 +5,21 @@ skip_when: "The storefront pages already render against live data and pass the c
5
5
  forget_when: "The checklist at the bottom passes — every page renders against the seeded catalog and an offline order completes."
6
6
  carry_forward:
7
7
  - "Payment gateways, currency and countries come from useStoreInfo() only — never off a cart (cart.payment_gateways is always undefined)."
8
- - "A store with any coupons must have a coupon field (useCart().applyCoupon) 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."
8
+ - "A store with any coupons must place a CouponField part once (cart or checkout), or its codes can never be redeemed."
9
+ - "The receipt route is mandatory: OrderReceived.Root with all five gates, PaymentInstructions on the unpaid branch."
10
+ - "Branch custom hook-built UI on `status`, never on `isEmpty`/nullable data; every hook goes above its status guards."
11
11
  - "Variant options that aren't buyable render disabled, never hidden; one control per axis, never a list of variations."
12
- - "Every hook on a page goes above its status guards a hook after an early return crashes with \"Rendered more hooks than during the previous render\"."
13
- - "The kit ships no copy: hooks hand you state codes (buy.state, hint.code, blockers) and you write every word including the reason a disabled button is disabled."
14
- - "Identity is encoded once — design classes in index.css plus one or two signature moments per page; a utility run that appears twice becomes a class."
12
+ - "No copy ships: every word the parts render comes from the store's copy file (labels); a ⟨copy: …⟩ placeholder on screen is unfinished work."
13
+ - "Identity is encoded once design classes in index.css (parts styled via [data-part] selectors) plus one or two signature moments per page."
15
14
  ---
16
15
 
17
16
  # 02 — Storefront
18
17
 
19
- One split decides everything here: **the logic is premade, the UI never is.** The hooks own checkout repricing, variant resolution, cart state, order-return verification hand-writing any of it is where storefront bugs cluster, so **never re-implement what a hook does**. Every element, class, layout and **word** is yours; nothing in `@/commerce/storefront` renders markup or carries CSS. **Decide how the store looks as if this kit did not exist**, then encode it **once** as design classes ([below](#design-language--once-before-any-page)) the snippets here are wiring reference, never design input.
18
+ Two tiers decide everything here. **The catalog surfaces — product list and product page — are fully yours**: the logic ships as hooks, every element, class and word is the store's own, and this is where the design effort goes. **The commodity surfaces — cart, drawer, checkout, order-receivedare placed, not written**: `Cart.*` / `CartDrawer.*` / `Checkout.*` / `OrderReceived.*` parts render each section's correct markup with **zero CSS, zero copy and zero navigation**; your page is the layout around them, your copy file their words, your `index.css` their look. Nothing in the kit ships a look or a sentence **decide how the store looks and sounds as if the kit did not exist**, then encode it once (design classes + the copy file); the snippets here are wiring reference, never design input. Where a state needs words you get the *state* (`buy.state`, a blocker code) and write the words; never re-derive a state you were handed, never leave one unworded.
20
19
 
21
- **States and codes, not copy.** Where a state needs words you get the *state* (`buy.state`, `hint.code`, `blockers`) and write the words. So: never re-derive a state you were handed (a ternary chain over `adding`/`purchasable` re-implements `buy.state`, wrong), and never leave one unworded (a button with no text for `sold_out` renders empty).
20
+ **This file is the whole job.** Every shape and contract you need is in the tables here **don't open the hook or part files while building**. The parts are the app's own source (`src/commerce/storefront/parts/`), so they *can* be edited only when a user requirement genuinely exceeds their props, after the cheaper rungs (`classes`/CSS labels `…Render` overrides → the hooks, [`../references/storefront-custom.md`](../references/storefront-custom.md)); an edited part is the store's code to maintain. Rules marked must survive whatever design you build.
22
21
 
23
- **This file is the whole job.** Every shape you need is in ["What each hook resolves to"](#what-each-hook-resolves-to) don't open the hook files while building; that is the most expensive way to answer a question this page already answers. Rules marked ⚑ must survive whatever design you build.
24
-
25
- **One import path: `@/commerce/storefront`.** Each section opens with its page's exact import line — copy it verbatim, then delete unused names. Everything a page needs is re-exported there, so a React page never imports `@/commerce/utils` directly. `useStoreInfo` is the name most often left out.
22
+ **One import path: `@/commerce/storefront`.** Each section opens with its import linecopy it, delete unused names.
26
23
 
27
24
  ## Setup — once
28
25
 
@@ -30,53 +27,107 @@ Nearly every store has shared chrome, so **start from a pathless layout route**
30
27
 
31
28
  ```jsx
32
29
  import { Routes, Route, Outlet } from "react-router-dom";
33
- import { StorefrontProvider, CartUIProvider } from "@/commerce/storefront";
30
+ import { StorefrontProvider, CartUIProvider, useCart } from "@/commerce/storefront";
31
+ import { STORE_COPY } from "@/copy"; // the copy file, below
34
32
  import { base44 } from "@/api/base44Client";
35
33
  import AdminApp from "@/commerce/admin";
36
34
 
37
35
  <BrowserRouter>
38
36
  <Routes> {/* ONE <Routes> — merge new pages into the app's */}
39
37
  <Route element={
40
- <StorefrontProvider base44={base44}>
41
- <CartUIProvider> {/* only if the cart is a drawer — see Cart below */}
38
+ <StorefrontProvider base44={base44} labels={STORE_COPY}>
39
+ <CartUIProvider> {/* only if the cart is a drawer */}
42
40
  <StoreLayout /> {/* YOURS: header + <Outlet/> + footer + drawer */}
43
41
  </CartUIProvider>
44
42
  </StorefrontProvider>
45
43
  }>
46
44
  <Route path="/" element={<Home />} />
47
45
  <Route path="/product/:slug" element={<ProductPage />} />
48
- {/* /bag, /checkout, and /order-received — which is mandatory */}
46
+ {/* /bag, /checkout, /order-received (mandatory) */}
49
47
  </Route>
50
48
  <Route path="/store-admin/*" element={<AdminApp />} /> {/* own chrome, outside the provider */}
51
49
  </Routes>
52
50
  </BrowserRouter>
53
-
54
- // …and the layout that route renders. Yours to design; the shape is the point:
55
- function StoreLayout() {
56
- const { itemCount } = useCart(); // one cart, shared with every page
57
- return (
58
- <>
59
- <header>{/* nav + your cart trigger, showing itemCount */}</header>
60
- <Outlet /> {/* the routed page lands here */}
61
- <footer>…</footer>{/* + the drawer, if the cart is one — see Cart below */}
62
- </>
63
- );
64
- }
65
51
  ```
66
52
 
67
- ⚑ **The nesting is provider → layout → `<Outlet/>`, never the reverse** — a layout that renders the provider inside itself leaves the nav's cart badge on a different cart (or throws). With no shared chrome, wrap `<Routes>` in the provider instead; a provider *inside* `<Routes>` throws ("is not a `<Route>` component"). The provider owns the shared client, store info and **one** shared cart — never mount a second one, never touch `cart_token`.
53
+ ⚑ **The nesting is provider → layout → `<Outlet/>`, never the reverse** — a layout that renders the provider inside itself leaves the nav's cart badge on a different cart. With no shared chrome, wrap `<Routes>` in the provider instead; a provider *inside* `<Routes>` throws ("is not a `<Route>` component"). The provider owns the shared client, store info and **one** shared cart — never mount a second, never touch `cart_token`.
68
54
 
69
55
  ## Design language — once, before any page
70
56
 
71
- The cost driver of a generated storefront is not wiring — it is decoration repeated inline. Encode identity **once**: in `index.css`, set the palette and type scale, then define the store's recurring surfaces as **10–15 composable classes** in Tailwind's components layer, named in *this* store's language (`.panel`, `.btn-cta`, `.label-mono`, `.field`, `.choice-row`, a heading scale, a price style — whatever *this* store repeats). Pages then carry short class names plus a couple of layout utilities. ⚑ **A utility run that appears twice becomes a class.**
57
+ The cost driver of a generated storefront is not wiring — it is decoration repeated inline. Encode identity **once**: in `index.css`, set the palette and type scale, then define the store's recurring surfaces as **10–15 composable classes** in Tailwind's components layer, named in *this* store's language (`.panel`, `.btn-cta`, `.label-mono`, `.field`, `.choice-row` — whatever *this* store repeats). Pages then carry short class names plus a couple of layout utilities. ⚑ **A utility run that appears twice becomes a class.** The store's words work the same way: one copy file in the store's voice (below), written once.
58
+
59
+ **Concentrate identity; don't diffuse it.** The classes carry the look everywhere; on top of them, spend bespoke markup on **one or two signature moments per page**. **The product page stays the storefront's richest surface**, and that richness is semantic: what the controls and rows *show*. Checkout, bag and order-received are convention surfaces — the parts render their structure, your classes carry their look. Keep components small (~2–4K chars).
60
+
61
+ ## The parts — shared contract
62
+
63
+ A part renders one section's correct semantic markup — every branch, guard and aria attribute — and nothing else. Your markup arranges the parts (grid, order, sticky aside, accordion steps — a hidden part loses no state; the place-order gate still counts every blocker). Three inputs carry the identity:
64
+
65
+ - **Styling.** Every element carries `data-part` + state attributes. Style in `index.css` — `[data-part="option"][data-state="selected"]`, `[data-part="row"][data-pending]` — and/or pass `className` (part root) and `classes` (keyed by the **same inner `data-part` names**: `classes={{ row: "bag-row", "line-total": "price" }}`). No part ships a class: browser-default means unfinished.
66
+ - **Words.** The copy file below, passed once on `StorefrontProvider labels`; override per Root or part with the same shape. A missing key renders a visible `⟨copy: path⟩` and warns — never silent, never English.
67
+ - **Navigation — never rendered, never assumed.** A gate renders *your* element when the state calls for it (function children hand you the data); `OrderReceived.PaymentLink` hands `{ url }` to your element; `Checkout.Root` requires `orderReceivedPath`. Nothing assumes a route, or even an anchor.
68
+
69
+ A section the props can't express drops to its hook — same shared state, mixing is safe: [`../references/storefront-custom.md`](../references/storefront-custom.md).
70
+
71
+ ### The copy file
72
+
73
+ Copy this map into `src/copy.js`, then ⚑ **rewrite every value in this store's voice** — the English here is *reference*; shipped verbatim it is how storefronts end up sounding alike (`aria.*` name controls for screen readers; templates take the item's name):
74
+
75
+ ```js
76
+ export const STORE_COPY = {
77
+ placeOrder: { label: "Place order", placing: "Placing your order…" },
78
+ blockers: {
79
+ empty_cart: "Your bag is empty.", billing_incomplete: "Fill in your details above.",
80
+ shipping_address_incomplete: "Finish the delivery address.",
81
+ shipping_address_required: "Enter your address to see delivery options.",
82
+ shipping_method_required: "Choose a delivery option.",
83
+ shipping_not_available: "We can't deliver to that address.",
84
+ payment_method_required: "Choose how you'd like to pay.",
85
+ cart_loading: "One moment — loading your bag.", shipping_recalculating: "Updating delivery costs…",
86
+ },
87
+ shipping: {
88
+ missing_address: "Delivery options appear once your address is entered.",
89
+ none_available: "We don't deliver to that address yet.",
90
+ syncing: "Updating delivery options…",
91
+ },
92
+ payment: { none_available: "Checkout is unavailable right now — please try again later." },
93
+ coupon: { placeholder: "Gift or promo code", apply: "Apply", remove: "Remove" },
94
+ fields: {
95
+ first_name: "First name", last_name: "Last name", email: "Email", company: "Company",
96
+ address_1: "Address", address_2: "Apartment, suite (optional)", country: "Country",
97
+ city: "City", state: "State / Province", postcode: "Postal code",
98
+ phone: "Phone (optional)", select_placeholder: "Select…",
99
+ },
100
+ totals: { subtotal: "Subtotal", discount: "Discount", shipping: "Shipping", tax: "Tax", total: "Total" },
101
+ bank: { account_name: "Account name", account_number: "Account number", bank_name: "Bank",
102
+ sort_code: "Sort code", iban: "IBAN", bic: "BIC" },
103
+ aria: {
104
+ trigger: "Open your bag", drawer: "Your bag", close: "Close",
105
+ increase: (name) => `Add one more ${name}`, decrease: (name) => `Remove one ${name}`,
106
+ remove: (name) => `Remove ${name} from your bag`,
107
+ shipping_group: "Delivery options", payment_group: "Payment methods",
108
+ },
109
+ };
110
+ ```
111
+
112
+ Blocker lines are what a disabled place-order button says — one per code, naming the thing the customer can fix (`cart_loading`/`shipping_recalculating` are quiet transients, not errors); `shipping.*`/`payment.*` are the pickers' dead-ends, with the server's own message rendered instead where one exists. The key tree is frozen in code as `REQUIRED_LABEL_KEYS`.
113
+
114
+ ### `data-part` inventory
72
115
 
73
- The store's words work the same way: the states these hooks hand you recur across pages (an empty bag, an unbuyable product, an undeliverable address), so write that copy once in the store's voice a small map per surface, as the sections below show. It is the half of a store's identity a kit cannot ship.
116
+ | Part root | Inner `data-part`s | State attributes |
117
+ |---|---|---|
118
+ | `address-fields`; `ship-to-different` | field · label · required · control · error | `data-which`, `data-key`, `data-span`, `data-invalid` |
119
+ | `shipping-methods` `payment-methods` | hint · option · option-input · option-label · option-cost / option-description · chosen | `data-state="selected"`, `data-syncing`, `data-severity` |
120
+ | `lines` `items`; `notices` | row · media · name · attributes · quantity · line-total · error; notice; `lines` adds stepper · increase · decrease · remove | `data-pending`, `data-empty`, `data-code` |
121
+ | `totals`; `payment-instructions` | row · label · value; description · account | `data-key`, `data-emphasis` |
122
+ | `coupon-field` | input · apply · error · applied · code · remove | `data-busy` |
123
+ | `place-order` · `order-error` · `blockers` | blocker | `data-state="placing"`, `data-code` |
124
+ | `trigger` · `drawer` | overlay · panel; `close` | `data-state="open\|closed"` |
74
125
 
75
- **Concentrate identity; don't diffuse it.** The classes carry the look everywhere; on top of them, spend bespoke markup on **one or two signature moments per page** — the hero, the one product-page module that shows what these products are judged on — and render everything else as conventions in the classes. **The product page stays the storefront's richest surface**, and that richness is semantic: what the controls and rows *show*, which costs words rather than chrome. One navigation affordance per control (thumbnails *or* arrows, never both plus dots); checkout, bag and order-received are convention surfaces. Keep components small (~2–4K chars) faster to emit, review and fix than one long page file.
126
+ Tailwind-first styling reaches inner parts with arbitrary variants (`[&_[data-part=option]]:flex …`) or `@apply` inside the design classes.
76
127
 
77
128
  ## What each hook resolves to
78
129
 
79
- Everything below is already unwrapped — no `.data`, no envelope. `formatMoney` throughout is `useFormatMoney()`.
130
+ Everything below is already unwrapped — no `.data`, no envelope; `formatMoney` is `useFormatMoney()`. This table serves the catalog surfaces and row/option overrides; the custom-section shapes (`useCartLine`, `useCartUI`, `useCheckoutContext`, `useOrderReturn`, `cartTotalsLines`/`orderTotalsLines`) live in [`../references/storefront-custom.md`](../references/storefront-custom.md).
80
131
 
81
132
  | Call | Resolves to |
82
133
  |---|---|
@@ -93,13 +144,7 @@ Everything below is already unwrapped — no `.data`, no envelope. `formatMoney`
93
144
  | `productRibbons(product)` | `[{ id, name }]` — **objects**, and the field can be absent; takes a listing row or `useProduct().product`. |
94
145
  | `productSpecs(product)` | `[{ key, label, titleLabel, value }]` from `meta_data`; `findSpec(rows, key)` looks one up ignoring case/spaces/`_`/`-`. Never match on `label` — meta keys are free text. |
95
146
  | `useCart()` | `{ status, cart, itemCount, isEmpty, loading, error, mutationError, refresh, addItem, updateItem, removeItem, applyCoupon, removeCoupon }` — `status`: `"loading" \| "ready" \| "empty"`. |
96
- | `cart.items[n]` | `{ item_key, product_id, variation_id, name, quantity, price, subtotal, total, image, attributes, sold_individually, purchasable }` — `attributes` is an **array** of `{name, option}`; `purchasable` is a **result object** `{ok, code, error}`, not a boolean. |
97
- | `attributesLabel(item.attributes)` | `"Size: 42 · Color: Ivory"` (`""` when the product has no attributes). |
98
- | `cartTotalsLines(cart, { formatMoney })` | `[{ key, label, amount, formatted, hidden, emphasis }]` — every line the store has, incl. discount and tax. `orderTotalsLines(order, …)` is the same shape for a receipt. Pass `labels: {…}` to rename a row. |
99
- | `useCartLine(item)` | `{ quantity, setQuantity, increase, decrease, remove, pending, error, canIncrease, canDecrease, maxQuantity, atMax, atMin }`. |
100
- | `useCartUI()` | `{ open, openCart, closeCart, toggleCart }`. |
101
- | `useCheckoutContext()` | the address (`billing`, `updateBilling`, `shipping`, `updateShipping`, `shipToDifferent`, `setShipToDifferent`, `missingBillingFields`, `addressError`), the shipping and payment state (the pickers read those for you), and the gate: `blockers`, `canPlaceOrder`, `placing`, `stage`, `orderError`, `placeOrder` — plus `cart`. The Checkout section wires all of it. |
102
- | `useOrderReturn()` | `{ status, order, lines, paymentLink, paymentInstructions, error, reload }` — `status`: `"loading" \| "paid" \| "unpaid" \| "cancelled" \| "error"`. An order's totals are **flat** (`order.total`); there is no `order.totals`. |
147
+ | `cart.items[n]` | `{ item_key, product_id, variation_id, name, slug, quantity, price, subtotal, total, image, attributes, sold_individually, purchasable }` — `attributes` is an **array** of `{name, option}` (`attributesLabel(item.attributes)` → `"Size: 42 · Color: Ivory"`); `purchasable` is a **result object** `{ok, code, error}`, not a boolean; `slug` is the line's product-page link. |
103
148
 
104
149
  ## Product list / collection
105
150
 
@@ -123,7 +168,7 @@ A card can render `name`, `productImages(row)[0]`, `productPrice(row, { formatMo
123
168
  import { useProduct, useAddToCart, useStoreInfo, useFormatMoney, useStorefront, variantAxes, productImages, imageIndex, productRibbons, productSpecs, findSpec, storefrontErrorCode } from "@/commerce/storefront";
124
169
  ```
125
170
 
126
- `useProduct(slug)` (or `{ id }`) owns fetch + variant selection + quantity + price, race-safe, selection mirrored to the URL. ⚑ `status: "not_found"` is a 404 page, not a spinner. ⚑ **Call every hook above the status guards** — they all tolerate a null/loading product precisely so they can sit at the top; a hook after an early `return` crashes React with "Rendered more hooks than during the previous render" the moment the product resolves.
171
+ `useProduct(slug)` (or `{ id }`) owns fetch + variant selection + quantity + price, race-safe, selection mirrored to the URL. ⚑ `status: "not_found"` is a 404 page, not a spinner. ⚑ **Call every hook above the status guards** — they all tolerate a null/loading product; a hook after an early `return` crashes React with "Rendered more hooks than during the previous render" the moment the product resolves.
127
172
 
128
173
  ```jsx
129
174
  const p = useProduct(slug);
@@ -175,183 +220,137 @@ Build your layout from — all optional, **not one component style**:
175
220
  - **Description** — `product.description` is HTML; render as rich text, `short_description` above it.
176
221
  - **Specs** — `productSpecs(product)` rows from the admin's *Modifiers*. ⚑ **Look a particular spec up with `findSpec(rows, "care")`**, which ignores case, spaces, `_` and `-`. Meta keys are free text (`care`, `Care`, `Care Instructions`), so `rows.find(s => s.label === "Care")` silently never matches and renders the fallback forever. **This is the product page's signature-moment candidate**: pick the two or three keys that carry *this* catalog's meaning and render each as what it is (a weight as a figure, a composition as bars, a provenance beside its place), then let the rest fall through to plain rows in your classes. Not one uniform grey table; not a bespoke widget per row. `[]` means no section at all.
177
222
  - **Breadcrumbs** — from `categories` (`/collection?category_id=${c.id}`); skip on a flat catalog. Ribbons (`productRibbons(product)`) are labels, not breadcrumbs.
178
- - **Reviews, only if the store wants them** — no review UI is a complete outcome (then no star ratings on cards either: an average of nothing is `0`). `p.reviews` arrives with the product as `{ items, page, per_page, has_next }`; submitting is `submitReview` off `useStorefront()`, open to guests. ⚑ Derive the confirmation from the response's `status` (`"approved"` vs `"hold"`) — a hardcoded "awaiting approval" lies to every store that auto-approves — and refresh the list after, or the review doesn't appear. Field codes, policies and moderation: [`../references/reviews.md`](../references/reviews.md).
179
- - **Title** — give each page type its own `<title>` and description; a store whose every page shares one static title is invisible to search. Nothing here emits structured data eitherif the store wants rich results, emit your own `Product`/`Offer` JSON-LD from `product` and `view.display` (price, currency, availability).
223
+ - **Reviews, only if the store wants them** — no review UI is a complete outcome (then no star ratings on cards: an average of nothing is `0`). `p.reviews` arrives with the product; submitting is `submitReview` off `useStorefront()`, open to guests. ⚑ Derive the confirmation from the response's `status` (`"approved"` vs `"hold"`) — a hardcoded "awaiting approval" lies to auto-approving stores — and refresh the list after. Policies and moderation: [`../references/reviews.md`](../references/reviews.md).
224
+ - **Title** — give each page type its own `<title>` and description; one static title everywhere is invisible to search. No structured data shipsfor rich results emit your own `Product`/`Offer` JSON-LD from `product` and `view.display`.
180
225
 
181
226
  ## Cart / bag
182
227
 
183
228
  ```jsx
184
- import { useCart, useCartLine, CartLine, useCartUI, useFormatMoney, attributesLabel, cartTotalsLines } from "@/commerce/storefront";
229
+ import { Cart, CartDrawer } from "@/commerce/storefront";
185
230
  ```
186
231
 
187
- A cart *page* is optional — decide from what the store sells (buy-now straight to checkout reads better for a single-piece store; a grocery basket needs a page).
188
-
189
- ⚑ Rules: branch on `status`, never on emptiness while loading. Render `cart.coupon_notices` (`[{ code, error, error_code }]` — a coupon that stopped validating) and `cart.removed_items` (`[{ item_key, product_id, reason, code }]` — a product that vanished or was unpublished): render `error`/`reason`, the server's own words, or a line disappears from the bag with no explanation. Render every non-`hidden` line from `cartTotalsLines` rather than hardcoding subtotal/total — a hand-written summary omits discount and tax, then stops adding up the day a coupon or tax rate exists. **A store with any coupons must have a coupon field** (here or in the checkout) — they are admin-only data, redeemable only through a field the customer types into. `applyCoupon(code)` resolves `{ok: false, message}` for a bad code rather than throwing, so render that inline. No field means no coupons: don't seed them, don't name a code in the copy.
190
-
191
- ⚑ **`pending` is one row's flag**: it goes true when that row's debounced request leaves and false only after the new cart view lands — so disable and mark only that row (`disabled={l.pending}`, `aria-busy` on the row), never the whole cart. `status` never returns to `"loading"` for a mutation; there is deliberately no cart-wide busy flag. ⚑ **Repeated controls need unique accessible names** — three "Remove" buttons name nothing; put the line in the label.
192
-
193
- **Reference wiring** for one row — the rest of the page (notices, coupon field, totals, checkout link) is ordinary markup around it:
232
+ A cart *page* is optional — decide from what the store sells (buy-now-to-checkout suits a single-piece store; a grocery basket needs a page). Page or drawer, the same parts in your layout — here as a drawer (`<CartUIProvider>`, mounted in Setup, owns open/close, Esc, open-on-add, close-on-navigate; a page skips the `CartDrawer.*` wrapper and provider):
194
233
 
195
234
  ```jsx
196
- const { status, cart } = useCart();
197
- const formatMoney = useFormatMoney();
198
- // guards on status first, then:
199
- {cart.items.map((item) => (
200
- <CartLine key={item.item_key} line={item}>
201
- {(l) => (
202
- <li aria-busy={l.pending}> {/* busy scope is THIS row, never the cart */}
203
- {item.name} {attributesLabel(item.attributes)} {formatMoney(item.total)}
204
- <button onClick={l.decrease} disabled={!l.canDecrease || l.pending}
205
- aria-label={`Decrease quantity of ${item.name}`}>−</button>
206
- {l.quantity}
207
- <button onClick={l.increase} disabled={!l.canIncrease || l.pending}
208
- aria-label={`Increase quantity of ${item.name}`}>+</button>
209
- <button onClick={l.remove} disabled={l.pending}
210
- aria-label={`Remove ${item.name}`}>Remove</button>
211
- {l.error && <p role="alert">{l.error.message}</p>}
212
- </li>
213
- )}
214
- </CartLine>
215
- ))}
216
- {cartTotalsLines(cart, { formatMoney }).filter((t) => !t.hidden).map((t) => (
217
- <div key={t.key}>{t.label} {t.formatted}</div> /* t.emphasis → the total row */
218
- ))}
235
+ <header>{/* your nav */}
236
+ <CartDrawer.Trigger className="nav-icon">
237
+ {({ count }) => (<><YourBagGlyph />{count > 0 && <span className="badge">{count}</span>}</>)}
238
+ </CartDrawer.Trigger>
239
+ </header>
240
+
241
+ <CartDrawer.Panel className="drawer">
242
+ <header>{/* your title */}<CartDrawer.Close><YourXGlyph /></CartDrawer.Close></header>
243
+ <Cart.Empty>{/* your empty state + your own link back into the shop */}</Cart.Empty>
244
+ <Cart.Ready>
245
+ <Cart.Notices />
246
+ <Cart.Lines classes={{ row: "bag-row", media: "bag-thumb" }} />
247
+ <Cart.CouponField />
248
+ <Cart.Totals pick={["subtotal"]} />
249
+ {/* your checkout affordance — your element, your route */}
250
+ </Cart.Ready>
251
+ </CartDrawer.Panel>
219
252
  ```
220
253
 
221
- No shipping estimator here checkout reprices shipping and tax from the address. An upsell beside the lines needs no query: `p.upsells` / `p.crossSells` from `useProduct` are rows you can render and add with `addItem`, matching "already in the bag" by product id, never by display name. ⚑ **A one-click Add only works on a product with no attributes**: one that sells variants answers `400 variation_required`, so link those tiles to the product page instead.
222
-
223
- ### If the cart is a drawer
254
+ | Part | Renders | Props |
255
+ |---|---|---|
256
+ | `Cart.Loading` / `Empty` / `Ready` | gates — children only in that cart status, so emptiness-while-loading can't render; function children get `{ cart, itemCount }` | — |
257
+ | `Cart.Lines` | the rows: media, name, attributes, stepper (optimistic, coalesces clicks, rolls back — busy scoped to **that row**, never the cart), remove, line total, per-row errors in the server's words | `lineRender(item, l)` replaces a row — `item.slug` for your product link |
258
+ | `Cart.Notices` | expired coupons + vanished products, the server's words — a line never disappears unexplained | — |
259
+ | `Cart.CouponField` | input + apply + applied codes with remove; an invalid code renders inline | — |
260
+ | `Cart.Totals` | every non-hidden summary line, discount and tax included, `data-emphasis` on the total | `pick={["subtotal"]}` |
261
+ | `CartDrawer.Trigger/Panel/Close` | the wired header button (render prop gets `{ count, open }`); the dialog done right — click-away overlay (never the close control), focus in/restored, unmounted while closed (`keepMounted` to animate → the closed panel goes `inert`); the named close button | children; `keepMounted` |
224
262
 
225
- Same hooks, same rows. The drawer's *state* open/close, Esc, close-on-route-change, open-on-addis `useCartUI()`; the markup is yours: a trigger in the header (`onClick={ui.toggleCart}`, `aria-expanded={ui.open}`), then `{ui.open && …}` rendering a click-away overlay plus your panel (`role="dialog" aria-modal="true"`, a named close button inside it).
226
-
227
- ⚑ **Render the drawer conditionally — `{ui.open && …}`.** The classic drawer bug is a panel translated off-screen but still mounted: its buttons stay clickable, tab-able and visible to screen readers. If you keep it mounted to animate the slide, set the `inert` attribute while closed. The overlay is a click-away surface, not the close control.
263
+ **A store with any coupons places `CouponField` once** (cart or checkout)codes are redeemable only through a field; no field don't seed codes. **Upsells**: `useProduct().upsells` rows added via `useCart().addItem`, matched by product id — ⚑ one-click add only for a product with no attributes (others answer `400 variation_required`; link those tiles to the product page). Drawer position, width and motion are your CSS on `[data-part="panel"]` / `[data-state]`.
228
264
 
229
265
  ## Checkout
230
266
 
231
267
  ```jsx
232
- import { CheckoutProvider, useCheckoutContext, ShippingMethodPicker, PaymentMethodPicker, useCart, useCountries, useFormatMoney, cartTotalsLines, addressFieldSpec, REQUIRED_BILLING_FIELDS } from "@/commerce/storefront";
268
+ import { Checkout } from "@/commerce/storefront";
233
269
  ```
234
270
 
235
- `useCheckout` reprices shipping/tax from the address automatically (debounced, never on a half-typed address), derives the shipping and payment choices, gates the button, and `placeOrder()` handles **both** navigations — online gateway → provider redirect, everything else `/order-received` as **full page loads** (`<CheckoutProvider options={{ orderReceivedPath: null }}>` for a router transition instead). `CheckoutProvider` shares it across the page's regions.
236
-
237
- ⚑ Rules: render each picker's `hint` and every branch; a single shipping or payment option still *shows* what it is — never a picker of one, never "nothing selected". Render `addressError` on the address fields. ⚑ Payment methods, currency and countries come from `useStoreInfo()`/`useCountries()` only — `cart.payment_gateways` is always `undefined`, and a default store offers `offline` only, so never hardcode a card option.
238
-
239
- ⚑ **A disabled place-order button must say why** — the silent disabled button is the most common checkout dead end. `blockers` is an array of codes; write one line per code, in the store's voice, anchored near the field that fixes it: `empty_cart` (bag is empty) · `billing_incomplete` (required address fields — `missingBillingFields` names them) · `shipping_address_incomplete` (the separate delivery address) · `shipping_address_required` (no address to price yet) · `shipping_method_required` (choose a delivery option) · `shipping_not_available` (this address can't be delivered to) · `payment_method_required` (choose how to pay) · `cart_loading` / `shipping_recalculating` (transient — a quiet "one moment", not an error).
240
-
241
- The pickers' `hint.code` works the same way (`missing_address`, `none_available`, `syncing` for shipping; `none_available` for payment): write those words once, and prefer `hint.serverMessage` when it is set — the backend's explanation is more specific than anything you can write.
271
+ `Checkout.Root` mounts the guided state address edits reprice shipping/tax automatically (debounced, never on a half-typed address), choices derive from store data and resolves the page's phase: `Submitted` renders above everything (placing an order clears the bag; this ordering kills the empty-bag flash over a just-placed order), then `Loading` / `Empty` / `Form`.
242
272
 
243
- **Reference wiring** — the densest part of the storefront. `Checkout()` is just
244
- `<CheckoutProvider><CheckoutForm /></CheckoutProvider>`; the hooks read the context **below** the provider. `SHIPPING_HINTS`, `PAYMENT_HINTS`, `BLOCKERS` and the two button labels below are *your* copy maps, written once (see [Design language](#design-language--once-before-any-page)):
273
+ **`orderReceivedPath` is required** — pass the route *your* order-received page is mounted at; the kit assumes none. The landing is a **full page load** by design (the provider hop leaves the app; the receipt boots from the URL alone — a browser script must navigate fresh to it, not wait for a transition). `null` + `onPlaced(result)` hands the step to you.
245
274
 
246
275
  ```jsx
247
- function CheckoutForm() {
248
- const { status } = useCart();
249
- const c = useCheckoutContext();
250
- if (c.stage === "submitted") return /* "taking you to your receipt" screen */;
251
- if (status === "loading") return /* your loading screen */;
252
- if (status === "empty") return /* your empty-bag screen */;
253
- return (
254
- <>
255
- <AddressFields which="billing" />
256
- {/* a checkbox on c.shipToDifferent / c.setShipToDifferent, your wording */}
257
- {c.shipToDifferent && <AddressFields which="shipping" />}
258
-
259
- <ShippingMethodPicker>
260
- {({ hint, mustChoose, methods, chosen }) => (
261
- <fieldset>{/* renders null for a virtual cart */}
262
- {hint && <p role={hint.severity === "error" ? "alert" : "status"}>
263
- {hint.serverMessage ?? SHIPPING_HINTS[hint.code]}</p>}
264
- {mustChoose && methods.map((m) => (
265
- <label key={m.id}>
266
- <input type="radio" name="shipping-method" checked={m.selected} onChange={m.select} />
267
- {m.title} {m.costLabel}
268
- </label>
269
- ))}
270
- {!mustChoose && chosen && <p>{chosen.title} {chosen.costLabel}</p>}
271
- </fieldset>
272
- )}
273
- </ShippingMethodPicker>
274
-
275
- {/* PaymentMethodPicker: same shape — hint, then gateways.map radios unless
276
- `single`, then `selected.title`. Titles/descriptions are the admin's copy. */}
277
-
278
- {/* summary: coupon field (if not in the cart) + cartTotalsLines(c.cart, { formatMoney }) */}
279
-
280
- <button type="button" onClick={c.placeOrder} disabled={!c.canPlaceOrder || c.placing}>
281
- {c.placing ? PLACING_LABEL : PLACE_ORDER_LABEL}
282
- </button>
283
- {c.orderError && <p role="alert">{c.orderError.message}</p>}
284
- {!c.canPlaceOrder && c.blockers.map((code) => <p key={code}>{BLOCKERS[code]}</p>)}
285
- </>
286
- );
287
- }
288
-
289
- function AddressFields({ which }) {
290
- const c = useCheckoutContext();
291
- const { countries } = useCountries(); // [] until store info lands — never null
292
- const isBilling = which === "billing";
293
- const values = isBilling ? c.billing : c.shipping;
294
- const set = isBilling ? c.updateBilling : c.updateShipping;
295
- const fields = addressFieldSpec({
296
- countries,
297
- country: values.country,
298
- required: isBilling ? REQUIRED_BILLING_FIELDS : ["country", "city"],
299
- includeEmail: isBilling, // one email per order, on billing
300
- });
301
- return fields.map((f) => (
302
- <div key={f.key}>
303
- <label htmlFor={`${which}-${f.key}`}>{f.label}{f.required && " *"}</label>
304
- {f.type === "select" ? (
305
- <select id={`${which}-${f.key}`} value={values[f.key] ?? ""} autoComplete={f.autoComplete}
306
- onChange={(e) => set({ [f.key]: e.target.value })}>
307
- <option value="">{/* your placeholder */}</option>
308
- {f.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
309
- </select>
310
- ) : (
311
- <input id={`${which}-${f.key}`} type={f.type} value={values[f.key] ?? ""}
312
- autoComplete={f.autoComplete} onChange={(e) => set({ [f.key]: e.target.value })} />
313
- )}
314
- {/* "we don't ship there" is an address-level error — it belongs on country */}
315
- {f.key === "country" && c.addressError && <span role="alert">{c.addressError.message}</span>}
276
+ <Checkout.Root orderReceivedPath="/order-received">
277
+ <Checkout.Submitted>{/* your "taking you to your receipt…" screen */}</Checkout.Submitted>
278
+ <Checkout.Loading>{/* your loader */}</Checkout.Loading>
279
+ <Checkout.Empty>{/* your empty state + your own link back */}</Checkout.Empty>
280
+ <Checkout.Form>
281
+ <div className="checkout-grid">{/* YOUR layout: order, columns, headings, steps */}
282
+ <section>
283
+ <Checkout.AddressFields which="billing" />
284
+ <Checkout.ShipToDifferent>{/* your label */}</Checkout.ShipToDifferent>
285
+ <Checkout.AddressFields which="shipping" />
286
+ <Checkout.ShippingMethods />
287
+ <Checkout.PaymentMethods />
288
+ </section>
289
+ <aside>
290
+ <Checkout.Items />
291
+ <Checkout.CouponField />
292
+ <Checkout.Totals />
293
+ <Checkout.PlaceOrder className="btn-cta" />
294
+ </aside>
316
295
  </div>
317
- ));
318
- }
296
+ </Checkout.Form>
297
+ </Checkout.Root>
319
298
  ```
320
299
 
321
- **Passing `country` is what makes the state/province field appear**, with the right options for the US, Canada and Australia — and shipping rates and taxes match on country *plus* state, so a form without that field mis-prices those orders with no error anywhere. ⚑ Keep each field's `autoComplete` token; it is what makes browser autofill work. Labels are plain conventions — rename or restyle freely. `c.missingBillingFields` is the live list of what is still missing, if you want per-field marks; arm them on first edit, not on load.
322
-
323
- **The `stage === "submitted"` guard goes above the empty-cart branch** — placing an order clears the cart before the browser navigates, and without the guard the page flashes an empty bag over a just-placed order.
300
+ | Part | Renders | Props |
301
+ |---|---|---|
302
+ | `Checkout.Root` | the provider, the phase, the labels scope | `orderReceivedPath` (**required**; or `null` + `onPlaced`), `labels`, `options`, `checkout` |
303
+ | `Submitted` / `Loading` / `Empty` / `Form` | gates; function children get `{ checkout, cart }` | — |
304
+ | `AddressFields` | the spec-driven fields: state/province appears once a country is picked (rates and taxes match on country *plus* state — its absence silently mis-prices US/CA/AU orders); `autoComplete` on; required marks arm on blur; "we don't ship there" lands on country; `which="shipping"` renders only when toggled | `which`, `include={["company"]}`, `omit`, `inputRender`, `fieldRender` |
305
+ | `ShipToDifferent` | the wired toggle; children are its label | children |
306
+ | `ShippingMethods` | every branch: the hint line (server's message preferred over yours), a radio list while there is a real choice, the single/auto-selected option *displayed* — never a picker of one, never "nothing selected" — null for a virtual cart | `optionRender(m)` — option content; the radio stays wired |
307
+ | `PaymentMethods` | every **enabled** gateway from store info, titles/descriptions in the admin's words (a default store offers `offline` only — never hardcode a card option); one renders as the selection it is; none renders your unavailable line | `optionRender(g)` |
308
+ | `Items` | the read-only mini summary | `itemRender(item)` |
309
+ | `CouponField` / `Totals` | same parts as the cart's | — / `pick` |
310
+ | `PlaceOrder` | the button (your two labels), the server's order error, and the blocker lines — ⚑ a disabled button always says why | `showBlockers` (default true) |
311
+ | `Blockers` | the reasons alone, for custom placement (then `showBlockers={false}`) | — |
312
+
313
+ `inputRender` is the middle tier when the fields need your own control — more authored code, more styled: `({ field, id, value, onChange, onBlur, invalid, autoComplete, options }) => <YourField …/>`; `fieldRender` replaces the whole labeled block. **Multi-step checkout is your markup**: wrap parts in your step containers — a hidden step loses nothing, the gate still counts every blocker.
324
314
 
325
315
  ## Order received
326
316
 
327
317
  ```jsx
328
- import { useOrderReturn, useFormatMoney, orderTotalsLines } from "@/commerce/storefront";
318
+ import { OrderReceived } from "@/commerce/storefront";
329
319
  ```
330
320
 
331
- **Mandatory route** — every payment link returns here, and confirming is what marks a card order paid. `useOrderReturn()` reads `order_id`/`order_key` from the URL, verifies with the provider (idempotent on every visit), and marks the page noindex itself.
332
-
333
- ⚑ Rules: render all five states — never a blank page while `"loading"`, a retry via `reload()` on `"error"`, and `paymentLink.url` on `"unpaid"`/`"cancelled"` when present. ⚑ **Never drop `paymentInstructions`** (`{ description, account_details }`) — a manual/offline order settles outside the store, so these ARE how the store's default customer learns how to pay. ⚑ An order's totals are flat (`order.total`, no `order.totals`) — use `orderTotalsLines(order, { formatMoney })`. `lines` are the order's items already normalized (`name`, `attributesLabel`, `quantity`, `image` as `{src, alt}|null`, `totalLabel`), because a receipt reusing cart-row markup otherwise paints a broken image.
321
+ **Mandatory route** — every payment link returns here; visiting verifies with the provider (idempotent) and the page marks itself noindex. Render **all five** gates a blank frame while loading or a wordless error strands a real customer:
334
322
 
335
- A receipt is a convention surface: your classes, every branch present, no bespoke widgets.
323
+ ```jsx
324
+ <OrderReceived.Root>
325
+ <OrderReceived.Loading>{/* confirming copy — never a blank page */}</OrderReceived.Loading>
326
+ <OrderReceived.Paid>
327
+ {({ order }) => (<>{/* your thank-you, order #{order.order_number} */}
328
+ <OrderReceived.Items /><OrderReceived.Totals /></>)}
329
+ </OrderReceived.Paid>
330
+ <OrderReceived.Unpaid>{/* your wording for "not settled yet" */}
331
+ <OrderReceived.PaymentInstructions /> {/* ⚑ see below */}
332
+ <OrderReceived.PaymentLink>{({ url }) => (/* your pay-now element on url */)}</OrderReceived.PaymentLink>
333
+ <OrderReceived.Items /><OrderReceived.Totals />
334
+ </OrderReceived.Unpaid>
335
+ <OrderReceived.Cancelled>{/* your wording; PaymentLink works here too */}</OrderReceived.Cancelled>
336
+ <OrderReceived.Error>{({ error, reload }) => (/* error.message + your retry calling reload() */)}</OrderReceived.Error>
337
+ </OrderReceived.Root>
338
+ ```
336
339
 
337
- ## Driving the storefront from a browser script?
340
+ **`PaymentInstructions` goes on the unpaid branch.** A default-seeded store settles manually (bank transfer, cash on delivery), so the instructions ARE how its customers pay — a receipt without them strands every real order. It renders the admin's description plus account details (your `bank.*` labels); null for card orders. `PaymentLink` renders your element only when a live link exists; the gates' function children get `{ order, paymentLink, paymentInstructions, error, reload }` for anything custom. `Items` rows arrive normalized; `Totals` owns the flat-totals trap (`order.total`, never `order.totals`).
338
341
 
339
- The cart is optimistic and debounced, so a script that acts faster than it settles sees a working store as broken. Read [`../references/storefront-verification.md`](../references/storefront-verification.md) **before** writing the script not after it fails.
342
+ **Driving the storefront from a browser script?** The cart is optimistic and debounced, so a script that acts faster than it settles sees a working store as broken read [`../references/storefront-verification.md`](../references/storefront-verification.md) **before** writing the script, not after it fails.
340
343
 
341
344
  ## Done — forget this file
342
345
 
343
- - [ ] Catalog UI in whatever form fits the store, plus a checkout, plus `/order-received` rendering `useOrderReturn`'s states **including `paymentInstructions`**.
344
- - [ ] `index.css` defines the store's design classes; pages carry class names, not repeated utility runs.
345
- - [ ] **One** `<StorefrontProvider>` above every storefront route (layout-route pattern); one client, no hand-rolled `cart_token`.
346
- - [ ] Every page's imports came from its section's import line; no unused names.
347
- - [ ] Pages branch on `status`; gateways/currency/countries read from `useStoreInfo()`/`useCountries()` only.
348
- - [ ] Every state the hooks expose has words: the buy button reads for all four `buy.state` values, picker `hint` codes and place-order `blockers` each have a line, and no state renders empty.
349
- - [ ] No re-implemented hook logic (button state precedence, quantity clamps, totals math, drawer state).
350
- - [ ] Coupon field present if the store has coupons; a paging control rendered whenever `hasNext` is true; ribbons rendered in both the grid and the product page.
346
+ - [ ] Catalog UI in whatever form fits the store; cart (and/or drawer), checkout and `/order-received` built from the parts — all five receipt gates present, `PaymentInstructions` placed on unpaid.
347
+ - [ ] One copy file passed via `<StorefrontProvider labels={…}>`, **every value rewritten in the store's voice** no `⟨copy: …⟩` on any page, no reference-example sentence shipped verbatim; the buy button reads for all four `buy.state` values.
348
+ - [ ] `index.css` defines the design classes; parts styled via `[data-part]`/`classes` — nothing left browser-default; pages carry class names, not repeated utility runs.
349
+ - [ ] **One** `<StorefrontProvider>` above every storefront route (layout-route pattern); no hand-rolled `cart_token`; imports from each section's import line.
350
+ - [ ] Custom hook-built sections branch on `status` and never re-implement part/hook logic; gateways/currency/countries from `useStoreInfo()`/`useCountries()` only.
351
+ - [ ] Coupon field placed if the store has coupons; a paging control whenever `hasNext`; ribbons in both the grid and the product page.
351
352
  - [ ] Variant options: one control per axis, unbuyable options disabled, not hidden.
352
- - [ ] Address form includes the state/province field and every `autoComplete` token.
353
- - [ ] Cart rows scope busy state to the row; repeated controls have unique accessible names; a drawer uses `useCartUI` and is rendered conditionally on `ui.open`.
354
- - [ ] Checkout guards `stage === "submitted"` above its empty-cart branch.
355
- - [ ] The storefront carries the design you settled on before reading this file — design classes plus one or two signature moments per page; axes and specs rendered by what they are (not one uniform table, not one identical chip row); convention surfaces carry the classes and nothing bespoke.
353
+ - [ ] Navigation affordances are yours: the checkout link in `Cart.Ready`, `orderReceivedPath` on `Checkout.Root`, `PaymentLink`'s child — the kit rendered none of them.
354
+ - [ ] The storefront carries the design you settled on before reading this file design classes plus one or two signature moments per page; the product page stays the richest surface.
356
355
 
357
- Then copy this file's `carry_forward` lines (in its front matter) into your working notes, and do not re-read this file.
356
+ Then copy this file's `carry_forward` lines into your working notes, and do not re-read this file.
@@ -22,7 +22,7 @@ A fresh install has **no settings and no catalog**. One admin-only, idempotent c
22
22
 
23
23
  **`store_name` is required on a first seed** — the app's name as the platform shows it (`base44/config.jsonc` → `name` can be stale; ask if unsure). **`currency`** is an ISO code (`"EUR"`); formatting follows the viewer's locale, nothing else to set. Explicit values always win, first seed and re-runs alike.
24
24
 
25
- The working call — `name` is the only required product key; give each product the keys its own catalog entry actually has and leave the rest out. The **full key list** (sale windows, downloads, tax, backorders, dimensions…) lives in `api-admin.md` — open it only if the catalog needs one:
25
+ The working call — `name` is the only required product key; give each product the keys its own catalog entry actually has and leave the rest out. The **full key list** (sale windows, downloads, tax, backorders, upsells…) lives in `api-admin.md` — open it only if the catalog needs one:
26
26
 
27
27
  ```js
28
28
  try {
@@ -45,10 +45,14 @@ try {
45
45
  categories: ["Shoes"], // get-or-created by display name
46
46
  ribbons: ["Best Seller"], // flat labels, not a hierarchy
47
47
 
48
+ weight: 0.248, // real fields, in the store's units —
49
+ dimensions: { length: 31, width: 12, height: 11 }, // never a meta_data row
50
+
48
51
  // Descriptive spec rows (productSpecs). NOT axes, NOT ribbons; `_` hides.
52
+ // Qualities only — a weight or a size here is a string nothing can read.
49
53
  meta_data: [
50
54
  { key: "Material", value: "Recycled knit upper" },
51
- { key: "Weight", value: "248 g" },
55
+ { key: "Care", value: "Machine wash cold" },
52
56
  ],
53
57
 
54
58
  attributes: [ // the axes → one selector each
@@ -57,8 +61,9 @@ try {
57
61
  ],
58
62
  default_options: { Size: "42", Color: "Black" },
59
63
  variations: [ // omit entirely → all combos auto-generated
60
- { options: { Size: "41", Color: "Black" }, stock_quantity: 4 },
64
+ { options: { Size: "41", Color: "Black" }, stock_quantity: 4, weight: 0.242 },
61
65
  { options: { Size: "42", Color: "White" }, regular_price: 94, stock_quantity: 3,
66
+ weight: 0.254, // per variant, never inherited from the parent
62
67
  image: "https://…/sneaker-white.jpg" }, // per-variation image for a visual axis
63
68
  ],
64
69
  },
@@ -136,9 +141,4 @@ Online card payments are **off by default**: the seeded store takes the manual `
136
141
  - [ ] `coupons` seeded only if a coupon field exists ([`./02-storefront.md`](./02-storefront.md)).
137
142
  - [ ] Cards off, or on with the provider file copied whole.
138
143
 
139
- Record these lines in your working notes; do not re-read this file.
140
-
141
- - Product slugs come from the seed response's `catalog.products[]` — link pages by slug, never by a client-side map.
142
- - Payments: report at handover where they landed (default = offline on, card off) — the owner must never learn it from a customer.
143
- - Turning card payments on or off later is one more seed call: `{ payment_methods: ["offline", "card"] }`.
144
- - Seed-time `locations` is THE shipping path; patching `commerce.ShippingTaxLocation` is the day-2 route.
144
+ Then copy this file's `carry_forward` lines (in its front matter) into your working notes, and do not re-read this file.
@@ -55,6 +55,8 @@ A kit update re-copies `shared/commerce/` and restores the stub — re-run the c
55
55
 
56
56
  Checkout (`place-order`, `card` gateway) creates the order `pending`, runs `createCardPayment`, stores its `reference` on the order and redirects to `url`; return URLs carry `order_id`/`order_key`/`payment=`. Confirmation is **two idempotent paths** (the second a no-op): the customer return (`/order-received` → `commerce/payments` `complete-return` → `checkCardPaymentPaid` → `processing` with all side-effects; `useOrderReturn` is that page in one hook) and the webhook (covers the closed tab). `create-link` mints a fresh page for any unpaid order the same way; `admin-refunds` `refund_payment: true` calls `refundCardPayment` **before** writing locally (unimplemented → `501`; record without the flag instead). Gateway enabled with no provider behind it → `503 no_card_payment_provider`; every other payment option is **manual** (on-hold, the option's description as payment instructions) and needs no code.
57
57
 
58
+ ⚑ **Card payment cannot complete in the preview pane.** Providers serve their payment page `frame-ancestors 'none'`, so the redirect dead-ends in the frame. `useCheckout`'s `placeOrder` refuses up front — `{ ok: false, error: { code: "card_payment_in_preview" } }`, nothing created — because `place-order` takes the order and its stock hold *before* minting the payment page, and an attempt that dies at the redirect would strand an unpayable order. Manual gateways are unaffected. **Test card checkout in the published store**; a card flow that appears to do nothing in preview and works published is this, not broken provider wiring.
59
+
58
60
  ## Implementation rules
59
61
 
60
62
  For a **custom** provider (the shipped files already obey all of these):