@base44/app-plugin-commerce 0.9.4 → 0.10.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.
@@ -11,12 +11,12 @@ carry_forward:
11
11
  - "Interleave: start image generation first → mount admin + build the storefront → seed with the image_url the generate_image result already returned (never poll, never write a function to fetch URLs) → payments last."
12
12
  - "Entities are dotted + bracket-syntax only (`base44.entities[\"commerce.X\"]`); the map is ../docs/entities.md — never scan base44/entities/."
13
13
  - "Payment gateways, currency and countries come from useStoreInfo() only — never off a cart (cart.payment_gateways is always undefined)."
14
- - "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."
15
- - "/order-received is mandatory and renders useOrderReturn's states, including paymentInstructions how a normal (offline) customer learns how to pay."
16
- - "Branch cart/list/product UI on `status`, never on `isEmpty`/nullable data — `isEmpty` is false while loading by design."
14
+ - "Cart page, drawer, checkout and order-received are SHIPPED components (@/commerce/storefront-ui): mount them, set the .sfui theme block in pairs + brand props, and build or edit nothing inside — day-2 changes go through ../references/storefront-ui.md, never file edits (kit updates re-copy the directory)."
15
+ - "<MiniCart /> mounts once in the layout, never on a route; the shipped coupon field appears by itself exactly when the store has coupons (sections.coupon \"auto\") never wire your own."
16
+ - "Branch list/product UI on `status`, never on `isEmpty`/nullable data — `isEmpty` is false while loading by design."
17
17
  - "Variant options that aren't buyable render disabled, never hidden; one control per axis, never a list of variations."
18
18
  - "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\"."
19
- - "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."
19
+ - "On the pages you build the kit ships no copy: hooks hand you state codes (buy.state) and you write every word in the store's voice; the shipped surfaces carry their own localized copy, reworded only via brand props."
20
20
  - "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."
21
21
  - "The product page is the richest surface and budgeted for it (~8K chars): productSpecs rows carry a type — branch on it, never .map() the list into one grey label/value table."
22
22
  - "Product slugs come from the seed response's catalog.products[] — link pages by slug, never by a client-side map."
@@ -47,26 +47,31 @@ lines and treat that stage as done — but keep reading this file, not re-fetchi
47
47
 
48
48
  **Dependencies.** `sonner`, `recharts`, `react-markdown` — all three ship with the default Base44 template, so check `package.json` and `npm i` only what is actually absent (the normal outcome is installing nothing).
49
49
 
50
- ## The whole build is THREE messages from here
50
+ ## Work order interleave, don't queue
51
51
 
52
- Image generation is the slowest step and nothing depends on it until seed time; the storefront doesn't wait on live data either. So batch hard: every extra assistant message restarts your reasoning and buys another sandbox validate — on real builds, fragmenting this plan roughly doubles LLM time while producing the same files.
52
+ Image generation is the slowest step and nothing depends on it until seed time; the storefront doesn't wait on live data either.
53
53
 
54
- - **M1 kick off + foundation.** Every `generate_image` call (all products, one message), and in the same message: fonts/meta in `index.html`, the complete design system in `src/index.css` (written ONCE later tweaks are `find_replace`, never a rewrite), `base44/ui.jsonc`, and the admin mount + routes in `src/App.jsx` (below).
55
- ⚑ **The result already carries the real `image_url`** alongside a `placeholder_url`
56
- — use `image_url` and move on. Nothing is pending, there is nothing to poll, and a
57
- backend function calling `Core.GenerateImage` to "fetch the real URLs" is pure waste:
58
- you already have them. If a `/__generating__/…` value does reach a file or a seed
59
- payload, the platform swaps it for the real URL after the turn (in files *and* entity
60
- records) — so a placeholder that renders broken in a mid-build preview is expected and
61
- must not be "fixed".
62
- - **M2 the whole storefront + the seed, one message.** Every component and page, written from the shapes in [stage 02 below](#02--storefront) not from live data — with the `commerce/seed-store` call ([stage 03 below](#03--store-data)) in the SAME message: M1's image URLs are already real. Never write pages one or two at a time.
63
- - **M3 converge.** Fix only what a tool result reported broken, run the checklists by recall, hand over. Payments last, if at all — cards are off by default; [stage 03 below](#03--store-data) decides it.
54
+ 1. **Start image generation first**every product image, before anything else.
55
+ ⚑ **The result already carries the real `image_url`** alongside a `placeholder_url`
56
+ — use `image_url` and move on. Nothing is pending, there is nothing to poll, and a
57
+ backend function calling `Core.GenerateImage` to "fetch the real URLs" is pure waste:
58
+ you already have them. If a `/__generating__/…` value does reach a file or a seed
59
+ payload, the platform swaps it for the real URL after the turn (in files *and* entity
60
+ records) — so a placeholder that renders broken in a mid-build preview is expected and
61
+ must not be "fixed".
62
+ 2. **Mount the admin (below) and build the storefront** while images render. Every
63
+ request and response shape the pages build against is written out in
64
+ [stage 02 below](#02--storefront), so they are written from the docs,
65
+ not from live data.
66
+ 3. **Seed the moment the image URLs are back** — one `commerce/seed-store` call ([stage 03 below](#03--store-data)). Its writes run in parallel, so the call usually takes a few seconds — but don't idle on it either.
67
+ 4. **Converge**: open the finished pages against the live catalog.
68
+ 5. **Payments last, if at all** — cards are off by default; [stage 03 below](#03--store-data) decides it.
64
69
 
65
- Custom scope beyond the kit (an extra entity, a backend function) rides M1/M2 — it buys no extra messages. The only dependency edges are *image URLs → seed payload* and *seed done → real products on the pages*.
70
+ The only dependency edges are *image URLs → seed payload* and *seed done → real products on the pages*.
66
71
 
67
72
  ## Pre-verified — take this file on faith
68
73
 
69
- Everything this file states about the kit is exact for the version just installed: the admin import lines and route JSX below, every hook's return shape in stage 02's table, the picker render-prop arguments, `<AddressFields>`, the barrel's export list. Never open kit source to confirm it, and never probe your own just-written usage against it — the checklists are recall checks, not an audit: fix what a tool result reported or what you know deviates, nothing else.
74
+ Everything this file states about the kit is exact for the version just installed: the admin import lines and route JSX below, every hook's return shape in stage 02's table, the shipped storefront-ui components with their props and theme tokens, the barrels' export lists. Never open kit source to confirm it, and never probe your own just-written usage against it — the checklists are recall checks, not an audit: fix what a tool result reported or what you know deviates, nothing else.
70
75
 
71
76
  ## Mount the admin
72
77
 
@@ -88,7 +93,7 @@ import Dashboard from "@/commerce/admin/pages/Dashboard"; // …and orders/Ord
88
93
  <Route path="*" element={<AdminRoutes />} /> {/* editors, settings, webhooks */}
89
94
  </Route>
90
95
  <Route path="/" element={<Navigate to="/store-admin" replace />} /> {/* until a storefront exists */}
91
- <Route path="/order-received" element={<OrderReceived />} /> {/* mandatory — see below */}
96
+ {/* /order-received is mandatory — mounted inside the storefront layout route, stage 02's snippet */}
92
97
  ```
93
98
 
94
99
  - **Those seven lines, as they are.** The splat is what keeps the app's listed pages to six instead of 26: `path="*"` is skipped, so the editors, settings tabs and webhook screens stay navigable without appearing there, and `<AdminRoutes />` still serves the admin's own 404.
@@ -97,7 +102,7 @@ import Dashboard from "@/commerce/admin/pages/Dashboard"; // …and orders/Ord
97
102
  - **Name the group** in `base44/ui.jsonc` — app-owned, so edit it in place, keep any other keys, never recreate a deleted one: `{ "version": 1, "sections": [{ "path": "/store-admin/*", "name": "Store Management" }] }`
98
103
  - **Give `/` something** — a blank app has no `/` route, and "page not found" at the app's own URL reads like a broken install.
99
104
  - **Link the admin from the storefront header** — otherwise the merchant has no way in but typing the URL. Resolve the signed-in user once (`base44.auth.me()`, rejection/no session = not an admin, never blocking the page) and render a plainly visible "Store manager" link to `/store-admin` in the header when `role === "admin"` — and nothing at all for everyone else.
100
- - **`/order-received` is mandatory**, even offline-only: every payment link returns there, and confirming is what marks an order paid — without it a paying customer hits a 404 and the order stays unpaid. The page is one hook, `useOrderReturn()` ([stage 02 below](#02--storefront)). A different path must be set in Settings → General (`general.order_received_path`).
105
+ - **`/order-received` is mandatory**, even offline-only: every payment link returns there, and confirming is what marks an order paid — without it a paying customer hits a 404 and the order stays unpaid. The page ships finished mount `<OrderReceivedPage />` **inside the storefront's provider** (the layout route in [stage 02 below](#02--storefront), never up here beside the admin). A different path must be set in Settings → General (`general.order_received_path`).
101
106
 
102
107
  ## Admin-role enforcement — do not weaken
103
108
 
@@ -124,13 +129,13 @@ Record this file's `carry_forward` lines (front matter) in your working notes, t
124
129
 
125
130
  # 02 — Storefront
126
131
 
127
- 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.
132
+ One split decides everything here **who owns which surface**. The catalog — the list/collection and the product page — is yours end to end: every element, class, layout and **word**; its logic is premade as headless hooks (`@/commerce/storefront`) that render nothing. The **conversion surfaces cart page, cart drawer, checkout, order received ship finished** (`@/commerce/storefront-ui`, [below](#cart-drawer-checkout-order-received--shipped)): you mount them, set a theme block and brand wording, and build nothing inside. **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.
128
133
 
129
- **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).
134
+ **On the pages you build, states and codes not copy.** Where a state needs words you get the *state* (`buy.state`) 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). The hooks own variant resolution, cart state, pricing — **never re-implement what a hook does**; that is where storefront bugs cluster.
130
135
 
131
136
  **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.
132
137
 
133
- **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.
138
+ **One import path per surface.** Pages you build import from `@/commerce/storefront` each section opens with its page's exact import line; copy it verbatim, then delete unused names (a React page never imports `@/commerce/utils` directly; `useStoreInfo` is the name most often left out). The shipped surfaces import from `@/commerce/storefront-ui`.
134
139
 
135
140
  ⚑ **Only some of what lives there is importable.** Hooks and helpers (`useProduct`, `useCart`, `productImages`, …) are named exports; a few operations exist **only on the client** `useStorefront()` returns — `submitReview`, `getProductReviews`, `listProducts`, `getProduct`, `applyCoupon`, `chooseShippingMethod`, `completeReturn`. Importing one by name throws `does not provide an export named …` and blanks the whole app: `const c = useStorefront(); await c.submitReview(…)`.
136
141
 
@@ -141,6 +146,7 @@ Nearly every store has shared chrome, so **start from a pathless layout route**
141
146
  ```jsx
142
147
  import { Routes, Route, Outlet } from "react-router-dom";
143
148
  import { StorefrontProvider, CartUIProvider } from "@/commerce/storefront";
149
+ import { CartPage, MiniCart, CartButton, CheckoutPage, OrderReceivedPage } from "@/commerce/storefront-ui";
144
150
  import { base44 } from "@/api/base44Client";
145
151
  import AdminApp from "@/commerce/admin";
146
152
 
@@ -148,14 +154,16 @@ import AdminApp from "@/commerce/admin";
148
154
  <Routes> {/* ONE <Routes> — merge new pages into the app's */}
149
155
  <Route element={
150
156
  <StorefrontProvider base44={base44}>
151
- <CartUIProvider> {/* only if the cart is a drawer see Cart below */}
152
- <StoreLayout /> {/* YOURS: header + <Outlet/> + footer + drawer */}
157
+ <CartUIProvider> {/* the drawer's statekeep it mounted */}
158
+ <StoreLayout /> {/* YOURS: header + <Outlet/> + footer + <MiniCart/> */}
153
159
  </CartUIProvider>
154
160
  </StorefrontProvider>
155
161
  }>
156
162
  <Route path="/" element={<Home />} />
157
163
  <Route path="/product/:slug" element={<ProductPage />} />
158
- {/* /bag, /checkout, and /order-received — which is mandatory */}
164
+ <Route path="/cart" element={<CartPage />} />
165
+ <Route path="/checkout" element={<CheckoutPage />} />
166
+ <Route path="/order-received" element={<OrderReceivedPage />} /> {/* mandatory */}
159
167
  </Route>
160
168
  <Route path="/store-admin" element={<AdminApp />}>…</Route> {/* own chrome, outside the provider */}
161
169
  </Routes>
@@ -163,12 +171,12 @@ import AdminApp from "@/commerce/admin";
163
171
 
164
172
  // …and the layout that route renders. Yours to design; the shape is the point:
165
173
  function StoreLayout() {
166
- const { itemCount } = useCart(); // one cart, shared with every page
167
174
  return (
168
175
  <>
169
- <header>{/* nav + your cart trigger, showing itemCount */}</header>
176
+ <header>{/* nav + <CartButton /> (or your own trigger on useCartUI/useCart) */}</header>
170
177
  <Outlet /> {/* the routed page lands here */}
171
- <footer>…</footer>{/* + the drawer, if the cart is one — see Cart below */}
178
+ <footer>…</footer>
179
+ <MiniCart cartHref="/cart" checkoutHref="/checkout" /> {/* once, here — never on a route */}
172
180
  </>
173
181
  );
174
182
  }
@@ -184,9 +192,9 @@ The cost driver of a generated storefront is not wiring — it is decoration rep
184
192
 
185
193
  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.
186
194
 
187
- **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.
195
+ **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).
188
196
 
189
- ⚑ **Budget by surface, and spend the product page's.** Convention surfaces are ~2–4K chars each; **the product page gets ~8K and the collection ~5K**, because rendering axes and specs *by what they are* is exactly what those chars buy — a product page that came in at 3K is the generic one. Over budget means re-implemented hook logic (a quantity clamp, totals math, variant resolution), never too much design: find your version, delete it, call the hook.
197
+ ⚑ **Budget by surface, and spend the product page's.** **The product page gets ~8K chars and the collection ~5K**, because rendering axes and specs *by what they are* is exactly what those chars buy — a product page that came in at 3K is the generic one. Cart, drawer, checkout and order-received cost **zero** — they ship ([below](#cart-drawer-checkout-order-received--shipped)); authored code there is the budget's biggest leak. Over budget elsewhere means re-implemented hook logic (a quantity clamp, totals math, variant resolution), never too much design: find your version, delete it, call the hook.
190
198
 
191
199
  ## What each hook resolves to
192
200
 
@@ -206,14 +214,8 @@ Everything below is already unwrapped — no `.data`, no envelope. `formatMoney`
206
214
  | `productImages(product)` | `[{ src, name, alt }]`, de-duplicated. `[]` is legitimate → render your placeholder. |
207
215
  | `productRibbons(product)` | `[{ id, name }]` — **objects**, and the field can be absent; takes a listing row or `useProduct().product`. |
208
216
  | `productSpecs(product)` | `[{ key, label, titleLabel, value, type, number, unit, items }]` from `meta_data` — `type` is `"numeric" \| "duration" \| "location" \| "list" \| "text"`, inferred, with `number`/`unit` split out for the first two and `items` for a list. `findSpec(rows, key)` looks one up ignoring case/spaces/`_`/`-`. Never match on `label` — meta keys are free text. |
209
- | `useCart()` | `{ status, cart, itemCount, isEmpty, loading, error, mutationError, refresh, addItem, updateItem, removeItem, applyCoupon, removeCoupon }` — `status`: `"loading" \| "ready" \| "empty"`. |
210
- | `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}`; `purchasable` is a **result object** `{ok, code, error}`, not a boolean; `slug` is the line's product-page link. |
211
- | `attributesLabel(item.attributes)` | `"Size: 42 · Color: Ivory"` (`""` when the product has no attributes). |
212
- | `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. |
213
- | `useCartLine(item)` | `{ quantity, setQuantity, increase, decrease, remove, pending, error, canIncrease, canDecrease, maxQuantity, atMax, atMin }`. |
214
- | `useCartUI()` | `{ open, openCart, closeCart, toggleCart }`. |
215
- | `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. |
216
- | `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`. |
217
+ | `useCart()` | `{ status, cart, itemCount, isEmpty, loading, error, mutationError, refresh, addItem, updateItem, removeItem, applyCoupon, removeCoupon }` — `status`: `"loading" \| "ready" \| "empty"`. Needed on *your* pages only for `itemCount` and upsell `addItem` — the shipped surfaces carry their own cart logic. |
218
+ | `useCartUI()` | `{ open, openCart, closeCart, toggleCart }` — for your own header trigger; `<CartButton />` is this pre-wired. |
217
219
 
218
220
  ## Product list / collection
219
221
 
@@ -229,7 +231,7 @@ A card can render `name`, `productImages(row)[0]`, `productPrice(row, { formatMo
229
231
 
230
232
  ⚑ **Ribbons belong in both views** — grid and product page. They are the merchant's own merchandising ("Limited", "Last pieces"), and each links to its filtered listing (`/collection?ribbon_id=<id>`). `productRibbons(row)` hands you `{id, name}` **objects** — render `r.name`, key the link on `r.id`; the entry itself in JSX is React's "Objects are not valid as a React child". Never render a bare "Ribbons:" label with nothing after it. ⚑ **A ribbon link inside a card that is itself a link nests `<a>` in `<a>`** — invalid, React warns. In the grid use plain labels, or link the image and title rather than the whole card; keep ribbon links on the product page.
231
233
 
232
- **Rails** (featured row, "new in") are the same hook with a filter (`{ featured: true, per_page: 4 }`) — `featured` is the merchant's own flag, so the rail stays curated store data instead of hardcoded slugs. ⚑ Any filter may legitimately match nothing — render *nothing* then, never a heading over an empty row.
234
+ **Rails** (featured row, "new in") are the same hook with a filter (`{ featured: true, per_page: 4 }`) — `featured` is the merchant's own flag, so the rail stays curated store data instead of hardcoded slugs. On a product page, `p.upsells` / `p.crossSells` are ready rows for the same markup — add with `useCart().addItem`, but **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. ⚑ Any filter may legitimately match nothing — render *nothing* then, never a heading over an empty row.
233
235
 
234
236
  ## Product page
235
237
 
@@ -315,132 +317,44 @@ Build your layout from — all optional, **not one component style**:
315
317
  Text or stars — either alone submits. ⚑ **The form renders for every visitor by default** (guests supply an email; hide it when signed in) — login-gate it only when the store asks. ⚑ Derive the confirmation from the response's `status` (`"approved"` vs `"hold"`) — a hardcoded "awaiting approval" lies to every auto-approving store — and refresh the list after, or the review doesn't appear. Codes, policies, moderation: [`../references/reviews.md`](../references/reviews.md).
316
318
  - **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 either — if the store wants rich results, emit your own `Product`/`Offer` JSON-LD from `product` and `view.display` (price, currency, availability).
317
319
 
318
- ## Cart / bag
320
+ ## Cart, drawer, checkout, order received — shipped
319
321
 
320
- ```jsx
321
- import { useCart, useCartLine, CartLine, useCartUI, useFormatMoney, attributesLabel, cartTotalsLines } from "@/commerce/storefront";
322
- ```
323
-
324
- 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).
325
-
326
- ⚑ 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.
322
+ These four surfaces are **finished components** in `@/commerce/storefront-ui` — the one part of the storefront you do not design or build. They render complete on their own: the cart page (line rows with quantity editing, full totals, coupons, a designed empty state), the drawer (`<MiniCart />` — a real dialog: portal, backdrop, focus trap, Esc, scroll-lock, opens by itself on add-to-cart under `<CartUIProvider>`), the checkout (contact + addresses, deliver-elsewhere, shipping and payment choice, blockers worded, order notes, coupon, place-order with the offline-instructions flow and the card redirect), and the order-received page (paid / unpaid with payment instructions / cancelled / error). Every internal state is handled and worded; functional labels are localized (en/de/es/fr/ja/pt ship).
327
323
 
328
- **`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.
329
-
330
- **Reference wiring** for one row — the rest of the page (notices, coupon field, totals, checkout link) is ordinary markup around it:
331
-
332
- ```jsx
333
- const { status, cart } = useCart();
334
- const formatMoney = useFormatMoney();
335
- // guards on status first, then:
336
- {cart.items.map((item) => (
337
- <CartLine key={item.item_key} line={item}>
338
- {(l) => (
339
- <li aria-busy={l.pending}> {/* busy scope is THIS row, never the cart */}
340
- {item.name} {attributesLabel(item.attributes)} {formatMoney(item.total)}
341
- <button onClick={l.decrease} disabled={!l.canDecrease || l.pending}
342
- aria-label={`Decrease quantity of ${item.name}`}>−</button>
343
- {l.quantity}
344
- <button onClick={l.increase} disabled={!l.canIncrease || l.pending}
345
- aria-label={`Increase quantity of ${item.name}`}>+</button>
346
- <button onClick={l.remove} disabled={l.pending}
347
- aria-label={`Remove ${item.name}`}>Remove</button>
348
- {l.error && <p role="alert">{l.error.message}</p>}
349
- </li>
350
- )}
351
- </CartLine>
352
- ))}
353
- {cartTotalsLines(cart, { formatMoney }).filter((t) => !t.hidden).map((t) => (
354
- <div key={t.key}>{t.label} {t.formatted}</div> /* t.emphasis → the total row */
355
- ))}
356
- ```
324
+ **They ship tested treat them like the admin, with one difference in tone**: mount them (the [Setup](#setup--once) snippet is the whole wiring), set the theme block and brand props below, and glance at the preview as you would any page. Don't open their source, probe their internals, or rebuild any part by hand; if something needs to change beyond tokens/props, the user will ask [`../references/storefront-ui.md`](../references/storefront-ui.md) covers day-2 recipes (slots, hiding sections, extra locales, restyling beyond tokens). Direct edits to `src/commerce/storefront-ui/` are unsupported: kit updates re-copy the directory.
357
325
 
358
- 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.
359
-
360
- ### If the cart is a drawer
361
-
362
- Same hooks, same rows. The drawer's *state* — open/close, Esc, close-on-route-change, open-on-add — is `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).
363
-
364
- ⚑ **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.
365
-
366
- ## Checkout
367
-
368
- ```jsx
369
- import { CheckoutProvider, useCheckoutContext, AddressFields, ShippingMethodPicker, PaymentMethodPicker, useCart, useFormatMoney, cartTotalsLines } from "@/commerce/storefront";
370
- ```
326
+ ### Themeone block, pairs only
371
327
 
372
- `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.
328
+ The components style themselves; with no block at all they follow the app's shadcn variables and stay correct. To align them to *this* store's palette, set **`--sfui-*` tokens in `index.css`, always in pairs** a surface never changes without its `on-` pair, which is what makes invisible text impossible:
373
329
 
374
- ⚑ 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". ⚑ Picks are instant: both pickers reflect a click immediately (shipping optimistically), and `mustChoose` stays true after a choice — the radios keep rendering, still changeable; never disable options while `syncing`/`choosing` (the hint covers it). 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.
375
-
376
- ⚑ **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` · `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` (can't deliver there) · `payment_method_required` · `cart_loading` / `shipping_recalculating` (transient — a quiet "one moment", not an error).
377
-
378
- 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.
379
-
380
- **Reference wiring** — the densest part of the storefront. `Checkout()` is just
381
- `<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)):
382
-
383
- ```jsx
384
- function CheckoutForm() {
385
- const { status } = useCart();
386
- const c = useCheckoutContext();
387
- if (c.stage === "submitted") return /* "taking you to your receipt" screen */;
388
- if (status === "loading") return /* your loading screen */;
389
- if (status === "empty") return /* your empty-bag screen */;
390
- return (
391
- <>
392
- <AddressFields which="billing" />
393
- {/* a checkbox on c.shipToDifferent / c.setShipToDifferent, your wording */}
394
- <AddressFields which="shipping" /> {/* renders null until shipToDifferent */}
395
-
396
- <ShippingMethodPicker>
397
- {({ hint, mustChoose, methods, chosen }) => (
398
- <fieldset>{/* renders null for a virtual cart */}
399
- {hint && <p role={hint.severity === "error" ? "alert" : "status"}>
400
- {hint.serverMessage ?? SHIPPING_HINTS[hint.code]}</p>}
401
- {mustChoose && methods.map((m) => (
402
- <label key={m.id}>
403
- <input type="radio" name="shipping-method" checked={m.selected} onChange={m.select} />
404
- {m.title} {m.costLabel}
405
- </label>
406
- ))}
407
- {!mustChoose && chosen && <p>{chosen.title} {chosen.costLabel}</p>}{/* the lone option */}
408
- </fieldset>
409
- )}
410
- </ShippingMethodPicker>
411
-
412
- {/* PaymentMethodPicker: same shape — hint, then gateways.map radios unless
413
- `single`, then `selected.title`. Titles/descriptions are the admin's copy. */}
414
-
415
- {/* summary: coupon field (if not in the cart) + cartTotalsLines(c.cart, { formatMoney }) */}
416
-
417
- <button type="button" onClick={c.placeOrder} disabled={!c.canPlaceOrder || c.placing}>
418
- {c.placing ? PLACING_LABEL : PLACE_ORDER_LABEL}
419
- </button>
420
- {c.orderError && <p role="alert">{c.orderError.message}</p>}
421
- {!c.canPlaceOrder && c.blockers.map((code) => <p key={code}>{BLOCKERS[code]}</p>)}
422
- </>
423
- );
330
+ ```css
331
+ .sfui {
332
+ --sfui-surface: #fff; --sfui-on-surface: #101014; /* page + text */
333
+ --sfui-subtle: #f4f1ea; --sfui-on-subtle: #6b6459; /* panels + secondary text */
334
+ --sfui-accent: #1b3a2f; --sfui-on-accent: #fff; /* CTAs */
335
+ --sfui-danger: #a52a2a; --sfui-on-danger: #fff; /* errors */
336
+ --sfui-border: #e5e0d5;
337
+ --sfui-radius: 0.5rem; /* panels; controls derive from it */
338
+ --sfui-font-heading: "Fraunces", serif; /* body text inherits the site's font by itself */
424
339
  }
425
340
  ```
426
341
 
427
- **`<AddressFields>` is the one shipped component use it, never hand-roll the address form.** It owns what hand-rolled forms get wrong: the state/province field appears with the right options once a country is picked (shipping rates and taxes match on country *plus* state, so a form without it mis-prices US/CA/AU orders with no error anywhere), every field keeps its `autoComplete` token (what makes browser autofill work), required marks arm on first blur, and the server's "we don't ship there" lands on the country field. `which="shipping"` renders null until `shipToDifferent` is on — the deliver-elsewhere checkbox itself is yours, wired to `c.shipToDifferent` / `c.setShipToDifferent`.
342
+ That is the entire vocabularynothing else is a token. If a pair lands under 3:1 contrast the components fall back to the default pair for it and `console.warn` which one; the worst case is the default look, never an unreadable page.
428
343
 
429
- **Pass `classes.control`**an unstyled `<input>` still reads as deliberate; a `<select>` does not, so skipping this one prop leaves the checkout looking broken on exactly one field. It ships **no CSS** bar a `max-width:100%` cap on the selects (an unstyled checkout must not scroll sideways): every element carries `data-part` (`address-fields`, `field`, `label`, `control`, `required`, `error`) plus `data-key` (the field) and `data-span` (1 or 2 — the field's natural width in a two-column grid), so style it in your `index.css` via `[data-part]` selectors or pass `className`/`classes={{ field, label, control, error }}`. ⚑ **`data-part` sits on the element, not a wrapper** — `select[data-part="control"]`, never `[data-part="control"] input`: the descendant form matches nothing and ships the form unstyled. Props: `includeCompany` (false), `includePhone` (true), `omit={["…"]}`, `labels={{ postcode: "ZIP code" }}`, `selectPlaceholder`, and two escape hatches — `inputRender` swaps the control only (spread the handed `dom` props onto your input), `fieldRender` replaces the whole labeled block. `c.missingBillingFields` stays the live list of what is missing, for your own per-field marks.
344
+ ### Brand wordingprops, in the store's voice
430
345
 
431
- **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.
346
+ Headings, CTAs and empty-state copy come in per component as `brand` (each overrides its label; precedence brand locale en). Everything not listed here is a functional label and already localized don't reword those:
432
347
 
433
- ## Order received
434
-
435
- ```jsx
436
- import { useOrderReturn, useFormatMoney, orderTotalsLines } from "@/commerce/storefront";
437
- ```
438
-
439
- **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.
348
+ | component | brand keys |
349
+ |---|---|
350
+ | `<CartPage />` | `title`, `summaryTitle`, `checkoutCta`, `continueShopping`, `emptyTitle`, `emptyBody`, `emptyCta`, `note` |
351
+ | `<MiniCart />` | `title`, `checkoutLabel`, `viewCartLabel`, `emptyTitle`, `emptyCta` |
352
+ | `<CheckoutPage />` | `title`, `contactTitle`, `shippingTitle`, `paymentTitle`, `summaryTitle`, `submitLabel`, `termsLabel`, `emptyTitle`, `emptyCta` |
353
+ | `<OrderReceivedPage />` | `paidTitle`, `paidBody`, `unpaidTitle`, `unpaidBody`, `backToStore` |
440
354
 
441
- 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.
355
+ Behavior options, all optional (defaults are the professional store): `CartPage`/`CheckoutPage` take `sections` `coupon: "auto"` (field appears exactly when the store has coupons never wire your own), `notes: false`, and on checkout `phone: "optional"|"required"|"hidden"`, `shipToDifferent: true`, `termsCheckbox: false`, `layout: "two-column"|"single"`. `hrefs` (`checkoutHref`, `cartHref`, `continueHref`, `homeHref`) default to `/checkout`, `/cart`, `/`; pass `productHref={(item) => …}` so line names link to your product route. Full props, slots and recipes: [`../references/storefront-ui.md`](../references/storefront-ui.md).
442
356
 
443
- A receipt is a convention surface: your classes, every branch present, no bespoke widgets.
357
+ **A store language outside the six**: copy `src/commerce/storefront-ui/i18n/locales/en.js` to `<lang>.js`, translate the values (~2–3K chars), repoint the one `import active` line in `src/commerce/storefront-ui/i18n/index.js`. Same-language brand props alone don't need this.
444
358
 
445
359
  ## Driving the storefront from a browser script?
446
360
 
@@ -448,19 +362,17 @@ The cart is optimistic and debounced, so a script that acts faster than it settl
448
362
 
449
363
  ## Done — stage 02 complete
450
364
 
451
- - [ ] Catalog UI in whatever form fits the store, plus a checkout, plus `/order-received` rendering `useOrderReturn`'s states **including `paymentInstructions`**.
452
- - [ ] `index.css` defines the store's design classes; pages carry class names, not repeated utility runs.
453
- - [ ] **One** `<StorefrontProvider>` above every storefront route (layout-route pattern); one client, no hand-rolled `cart_token`.
365
+ - [ ] Catalog UI (collection + product page) in whatever form fits the store; `/cart`, `/checkout` and `/order-received` are the **shipped components** mounted on routes, `<MiniCart />` mounted once in the layout — no hand-built version of any of the four, no edits under `src/commerce/storefront-ui/`.
366
+ - [ ] The `.sfui` theme block is set in `index.css` in **pairs** (or deliberately left to the defaults), and `brand` props carry the store's voice on all four surfaces.
367
+ - [ ] `index.css` defines the store's design classes; your pages carry class names, not repeated utility runs.
368
+ - [ ] **One** `<StorefrontProvider>` (+ `<CartUIProvider>`) above every storefront route (layout-route pattern); one client, no hand-rolled `cart_token`.
454
369
  - [ ] Every page's imports came from its section's import line; no unused names.
455
- - [ ] Pages branch on `status`; gateways/currency/countries read from `useStoreInfo()`/`useCountries()` only.
456
- - [ ] 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.
370
+ - [ ] Your pages branch on `status`; gateways/currency/countries read from `useStoreInfo()`/`useCountries()` only.
371
+ - [ ] Every state your pages expose has words: the buy button reads for all four `buy.state` values, and no state renders empty.
457
372
  - [ ] No re-implemented hook logic (button state precedence, quantity clamps, totals math, drawer state).
458
- - [ ] 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.
373
+ - [ ] A paging control rendered whenever `hasNext` is true; ribbons rendered in both the grid and the product page.
459
374
  - [ ] Variant options: one control per axis, unbuyable options disabled, not hidden.
460
- - [ ] The checkout's addresses render through `<AddressFields>` (both `which` values), styled in the store's classes not a hand-rolled field list.
461
- - [ ] 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`.
462
- - [ ] Checkout guards `stage === "submitted"` above its empty-cart branch.
463
- - [ ] The storefront carries the design you settled on before reading this file — design classes plus one or two signature moments per page; convention surfaces carry the classes and nothing bespoke.
375
+ - [ ] The storefront carries the design you settled on before reading this file design classes plus one or two signature moments per page.
464
376
  - [ ] **Specs and axes render by what they are**: the product page branches on `productSpecs` `type`/`key` for the rows that carry this catalog's meaning, and the grid has a rhythm — no page ships one uniform grey label/value table or one identical chip row per axis.
465
377
 
466
378
  Record this file's `carry_forward` lines (front matter) in your working notes, then move on to stage 03 below.
@@ -525,7 +437,7 @@ try {
525
437
  },
526
438
  ],
527
439
  coupons: [{ code: "WELCOME10", discount_type: "percent", amount: 10 }],
528
- // ONLY with a coupon field in the cart or checkout (see stage 02 below)
440
+ // the shipped cart/checkout surface the coupon field by themselves once any exist
529
441
  // locations: [ … ], // shipping — next section; passing any makes them the store's ONLY ones
530
442
  });
531
443
  return res.data; // ← the { success, data } envelope: plain JSON
@@ -594,7 +506,6 @@ Online card payments are **off by default**: the seeded store takes the manual `
594
506
  - [ ] Seed returned success — real products, final permanent image URLs; slugs recorded and pages link by them.
595
507
  - [ ] `warnings` is empty, or every warning is deliberate and stated to the user.
596
508
  - [ ] Shipping expressed in `locations` (catch-all present if the store ships worldwide); named tiers asserted.
597
- - [ ] `coupons` seeded only if a coupon field exists ([stage 02 below](#02--storefront)).
598
509
  - [ ] Cards off, or on with the provider file copied whole.
599
510
 
600
511
  Record this file's `carry_forward` lines (front matter) in your working notes. The install is finished — treat this file as spent and do not re-read it.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  stage: reference
3
3
  read_when: "The admin must run in a language OUTSIDE the six that ship (en/de/es/fr/ja/pt), or you are adding new admin UI text that has to localize."
4
- skip_when: "The admin stays in English (nothing to do) — or it runs in German, Spanish, French, Japanese or Portuguese, which is one import line in src/commerce/admin/i18n/index.js and needs nothing from this file. Also skip for storefront copy (the storefront ships none write it directly in the store's language) and for transactional emails (backend templates — references/emails.md)."
4
+ skip_when: "The admin stays in English (nothing to do) — or it runs in German, Spanish, French, Japanese or Portuguese, which is one import line in src/commerce/admin/i18n/index.js and needs nothing from this file. Also skip for copy on storefront pages you build (write it directly in the store's language), for the shipped storefront-ui surfaces (same six-locale pattern, own files — references/storefront-ui.md), and for transactional emails (backend templates — references/emails.md)."
5
5
  forget_when: "The language is switched or the locale file is written, and the admin renders in it."
6
6
  carry_forward:
7
7
  - "Admin language = one import line in src/commerce/admin/i18n/index.js; no runtime picker, no npm package."
@@ -32,9 +32,11 @@ locale automatically; `lib/geo-data.js` keeps English state/province names
32
32
  - **Covers:** every admin screen — navigation, tables, editors, settings,
33
33
  dialogs, toasts, the first-run setup screen, enum labels
34
34
  (`lib/constants.js` reads its labels from the catalog).
35
- - **Does not cover:** the storefront (headless it ships no copy; write your
36
- storefront's copy directly in the store's language) and transactional
37
- emails (backend templates `references/emails.md`). The StoreAdmin bot's
35
+ - **Does not cover:** the storefront pages you build (write their copy directly
36
+ in the store's language), the shipped storefront-ui surfaces (their own
37
+ locale files under `src/commerce/storefront-ui/i18n/` follow this same
38
+ pattern — `references/storefront-ui.md`), and transactional emails (backend
39
+ templates — `references/emails.md`). The StoreAdmin bot's
38
40
  UI chrome is localized; the bot's *answers* come from the agent and follow
39
41
  the language the admin types in.
40
42