@base44/app-plugin-commerce 0.9.5 → 0.10.1

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."
@@ -71,7 +71,7 @@ The only dependency edges are *image URLs → seed payload* and *seed done → r
71
71
 
72
72
  ## Pre-verified — take this file on faith
73
73
 
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 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.
75
75
 
76
76
  ## Mount the admin
77
77
 
@@ -93,7 +93,7 @@ import Dashboard from "@/commerce/admin/pages/Dashboard"; // …and orders/Ord
93
93
  <Route path="*" element={<AdminRoutes />} /> {/* editors, settings, webhooks */}
94
94
  </Route>
95
95
  <Route path="/" element={<Navigate to="/store-admin" replace />} /> {/* until a storefront exists */}
96
- <Route path="/order-received" element={<OrderReceived />} /> {/* mandatory — see below */}
96
+ {/* /order-received is mandatory — mounted inside the storefront layout route, stage 02's snippet */}
97
97
  ```
98
98
 
99
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.
@@ -102,7 +102,7 @@ import Dashboard from "@/commerce/admin/pages/Dashboard"; // …and orders/Ord
102
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" }] }`
103
103
  - **Give `/` something** — a blank app has no `/` route, and "page not found" at the app's own URL reads like a broken install.
104
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.
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 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`).
106
106
 
107
107
  ## Admin-role enforcement — do not weaken
108
108
 
@@ -129,13 +129,13 @@ Record this file's `carry_forward` lines (front matter) in your working notes, t
129
129
 
130
130
  # 02 — Storefront
131
131
 
132
- 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.
133
133
 
134
- **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.
135
135
 
136
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.
137
137
 
138
- **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`.
139
139
 
140
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(…)`.
141
141
 
@@ -146,6 +146,7 @@ Nearly every store has shared chrome, so **start from a pathless layout route**
146
146
  ```jsx
147
147
  import { Routes, Route, Outlet } from "react-router-dom";
148
148
  import { StorefrontProvider, CartUIProvider } from "@/commerce/storefront";
149
+ import { CartPage, MiniCart, CartButton, CheckoutPage, OrderReceivedPage } from "@/commerce/storefront-ui";
149
150
  import { base44 } from "@/api/base44Client";
150
151
  import AdminApp from "@/commerce/admin";
151
152
 
@@ -153,14 +154,16 @@ import AdminApp from "@/commerce/admin";
153
154
  <Routes> {/* ONE <Routes> — merge new pages into the app's */}
154
155
  <Route element={
155
156
  <StorefrontProvider base44={base44}>
156
- <CartUIProvider> {/* only if the cart is a drawer see Cart below */}
157
- <StoreLayout /> {/* YOURS: header + <Outlet/> + footer + drawer */}
157
+ <CartUIProvider> {/* the drawer's statekeep it mounted */}
158
+ <StoreLayout /> {/* YOURS: header + <Outlet/> + footer + <MiniCart/> */}
158
159
  </CartUIProvider>
159
160
  </StorefrontProvider>
160
161
  }>
161
162
  <Route path="/" element={<Home />} />
162
163
  <Route path="/product/:slug" element={<ProductPage />} />
163
- {/* /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 */}
164
167
  </Route>
165
168
  <Route path="/store-admin" element={<AdminApp />}>…</Route> {/* own chrome, outside the provider */}
166
169
  </Routes>
@@ -168,12 +171,12 @@ import AdminApp from "@/commerce/admin";
168
171
 
169
172
  // …and the layout that route renders. Yours to design; the shape is the point:
170
173
  function StoreLayout() {
171
- const { itemCount } = useCart(); // one cart, shared with every page
172
174
  return (
173
175
  <>
174
- <header>{/* nav + your cart trigger, showing itemCount */}</header>
176
+ <header>{/* nav + <CartButton /> (or your own trigger on useCartUI/useCart) */}</header>
175
177
  <Outlet /> {/* the routed page lands here */}
176
- <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 */}
177
180
  </>
178
181
  );
179
182
  }
@@ -189,9 +192,9 @@ The cost driver of a generated storefront is not wiring — it is decoration rep
189
192
 
190
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.
191
194
 
192
- **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).
193
196
 
194
- ⚑ **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.
195
198
 
196
199
  ## What each hook resolves to
197
200
 
@@ -207,18 +210,12 @@ Everything below is already unwrapped — no `.data`, no envelope. `formatMoney`
207
210
  | `useProduct(slug)` | `{ status, product, view, price, selection, pick, quantity, setQuantity, incQuantity, decQuantity, maxQuantity, canIncrease, categories, ribbons, upsells, crossSells, reviews, reload }` — `status`: `"loading" \| "ready" \| "not_found" \| "error"`; `reviews` is `{ items, page, per_page, has_next }`. |
208
211
  | `useAddToCart(p)` | `{ state, disabled, addToCart, adding, error, soldOut, needsSelection, quantity, increase, decrease, canIncrease, canDecrease, showQuantity, reset }` — `state`: `"ready" \| "adding" \| "sold_out" \| "needs_selection"`. |
209
212
  | `variantAxes(view, pick)` | `[{ key, name, selectedOption, options: [{ value, selected, disabled, outOfStock, pick }] }]`. |
210
- | `productPrice(rowOrView, { formatMoney })` | `{ label, compareAtLabel, onSale, isFrom, isRange, min, max }` — `label` is what to render. |
213
+ | `productPrice(rowOrView, { formatMoney, fromLabel })` | `{ label, compareAtLabel, onSale, isFrom, isRange, min, max }` — `label` is what to render. ⚑ `fromLabel` defaults to English "From": a store in another language passes its own word once, or every variant card ships one English word. |
211
214
  | `productImages(product)` | `[{ src, name, alt }]`, de-duplicated. `[]` is legitimate → render your placeholder. |
212
215
  | `productRibbons(product)` | `[{ id, name }]` — **objects**, and the field can be absent; takes a listing row or `useProduct().product`. |
213
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. |
214
- | `useCart()` | `{ status, cart, itemCount, isEmpty, loading, error, mutationError, refresh, addItem, updateItem, removeItem, applyCoupon, removeCoupon }` — `status`: `"loading" \| "ready" \| "empty"`. |
215
- | `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. |
216
- | `attributesLabel(item.attributes)` | `"Size: 42 · Color: Ivory"` (`""` when the product has no attributes). |
217
- | `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. |
218
- | `useCartLine(item)` | `{ quantity, setQuantity, increase, decrease, remove, pending, error, canIncrease, canDecrease, maxQuantity, atMax, atMin }`. |
219
- | `useCartUI()` | `{ open, openCart, closeCart, toggleCart }`. |
220
- | `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. |
221
- | `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. |
222
219
 
223
220
  ## Product list / collection
224
221
 
@@ -230,11 +227,11 @@ import { useProductList, useCategories, useStoreInfo, useFormatMoney, productPri
230
227
 
231
228
  ⚑ **Render paging whenever `hasNext` is true** — `{list.hasNext && <button type="button" onClick={list.next} disabled={list.busy}>…</button>}` (append mode: `list.loadMore`); a page that renders nothing for paging ships a catalog silently capped at `per_page`. Drive filters from `useCategories()`/`useRibbons()` data via `setParams`, never from hardcoded names — a renamed ribbon must not strand a dead button.
232
229
 
233
- A card can render `name`, `productImages(row)[0]`, `productPrice(row, { formatMoney }).label` (already "From €19.99" when the product sells variants — there is no product `type` flag, and `product.price` alone is a rolled-up from-price), `on_sale`, `short_description`, `stock_status`, `average_rating`/`rating_count`, `productRibbons(row)`, `productSpecs(row)`. ⚑ **Images and ribbons are objects, either may be empty** — render your placeholder, never a broken `<img>` or a raw object. Field matrix: [`../references/catalog-rendering.md`](../references/catalog-rendering.md). That list is an inventory, not a card design and not an order to render in. An even grid of identical cards, each carrying the same name/price/stars trio, is where a generated store lands by default and almost never where this catalog belongs: give the grid a rhythm (a hero piece spanning two columns, an editorial break between rows, a denser tile for a large catalog), and lead each card with the one or two fields *these* products are judged on — carat weight, focal length, ABV — read off `productSpecs(row)`.
230
+ A card can render `name`, `productImages(row)[0]`, `productPrice(row, { formatMoney }).label` (already "From €19.99" when the product sells variants — there is no product `type` flag, and `product.price` alone is a rolled-up from-price; on a non-English store pass `fromLabel` with the store's word), `on_sale`, `short_description`, `stock_status`, `average_rating`/`rating_count`, `productRibbons(row)`, `productSpecs(row)`. ⚑ **Images and ribbons are objects, either may be empty** — render your placeholder, never a broken `<img>` or a raw object. Field matrix: [`../references/catalog-rendering.md`](../references/catalog-rendering.md). That list is an inventory, not a card design and not an order to render in. An even grid of identical cards, each carrying the same name/price/stars trio, is where a generated store lands by default and almost never where this catalog belongs: give the grid a rhythm (a hero piece spanning two columns, an editorial break between rows, a denser tile for a large catalog), and lead each card with the one or two fields *these* products are judged on — carat weight, focal length, ABV — read off `productSpecs(row)`.
234
231
 
235
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.
236
233
 
237
- **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.
238
235
 
239
236
  ## Product page
240
237
 
@@ -320,132 +317,45 @@ Build your layout from — all optional, **not one component style**:
320
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).
321
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).
322
319
 
323
- ## Cart / bag
320
+ ## Cart, drawer, checkout, order received — shipped
324
321
 
325
- ```jsx
326
- import { useCart, useCartLine, CartLine, useCartUI, useFormatMoney, attributesLabel, cartTotalsLines } from "@/commerce/storefront";
327
- ```
328
-
329
- 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).
330
-
331
- ⚑ 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).
332
323
 
333
- **`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.
334
-
335
- **Reference wiring** for one row — the rest of the page (notices, coupon field, totals, checkout link) is ordinary markup around it:
336
-
337
- ```jsx
338
- const { status, cart } = useCart();
339
- const formatMoney = useFormatMoney();
340
- // guards on status first, then:
341
- {cart.items.map((item) => (
342
- <CartLine key={item.item_key} line={item}>
343
- {(l) => (
344
- <li aria-busy={l.pending}> {/* busy scope is THIS row, never the cart */}
345
- {item.name} {attributesLabel(item.attributes)} {formatMoney(item.total)}
346
- <button onClick={l.decrease} disabled={!l.canDecrease || l.pending}
347
- aria-label={`Decrease quantity of ${item.name}`}>−</button>
348
- {l.quantity}
349
- <button onClick={l.increase} disabled={!l.canIncrease || l.pending}
350
- aria-label={`Increase quantity of ${item.name}`}>+</button>
351
- <button onClick={l.remove} disabled={l.pending}
352
- aria-label={`Remove ${item.name}`}>Remove</button>
353
- {l.error && <p role="alert">{l.error.message}</p>}
354
- </li>
355
- )}
356
- </CartLine>
357
- ))}
358
- {cartTotalsLines(cart, { formatMoney }).filter((t) => !t.hidden).map((t) => (
359
- <div key={t.key}>{t.label} {t.formatted}</div> /* t.emphasis → the total row */
360
- ))}
361
- ```
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.
362
325
 
363
- 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.
364
-
365
- ### If the cart is a drawer
366
-
367
- 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).
368
-
369
- ⚑ **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.
370
-
371
- ## Checkout
372
-
373
- ```jsx
374
- import { CheckoutProvider, useCheckoutContext, AddressFields, ShippingMethodPicker, PaymentMethodPicker, useCart, useFormatMoney, cartTotalsLines } from "@/commerce/storefront";
375
- ```
326
+ ### Themeone block, pairs only
376
327
 
377
- `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:
378
329
 
379
- ⚑ 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.
380
-
381
- ⚑ **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).
382
-
383
- 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.
384
-
385
- **Reference wiring** — the densest part of the storefront. `Checkout()` is just
386
- `<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)):
387
-
388
- ```jsx
389
- function CheckoutForm() {
390
- const { status } = useCart();
391
- const c = useCheckoutContext();
392
- if (c.stage === "submitted") return /* "taking you to your receipt" screen */;
393
- if (status === "loading") return /* your loading screen */;
394
- if (status === "empty") return /* your empty-bag screen */;
395
- return (
396
- <>
397
- <AddressFields which="billing" />
398
- {/* a checkbox on c.shipToDifferent / c.setShipToDifferent, your wording */}
399
- <AddressFields which="shipping" /> {/* renders null until shipToDifferent */}
400
-
401
- <ShippingMethodPicker>
402
- {({ hint, mustChoose, methods, chosen }) => (
403
- <fieldset>{/* renders null for a virtual cart */}
404
- {hint && <p role={hint.severity === "error" ? "alert" : "status"}>
405
- {hint.serverMessage ?? SHIPPING_HINTS[hint.code]}</p>}
406
- {mustChoose && methods.map((m) => (
407
- <label key={m.id}>
408
- <input type="radio" name="shipping-method" checked={m.selected} onChange={m.select} />
409
- {m.title} {m.costLabel}
410
- </label>
411
- ))}
412
- {!mustChoose && chosen && <p>{chosen.title} {chosen.costLabel}</p>}{/* the lone option */}
413
- </fieldset>
414
- )}
415
- </ShippingMethodPicker>
416
-
417
- {/* PaymentMethodPicker: same shape — hint, then gateways.map radios unless
418
- `single`, then `selected.title`. Titles/descriptions are the admin's copy. */}
419
-
420
- {/* summary: coupon field (if not in the cart) + cartTotalsLines(c.cart, { formatMoney }) */}
421
-
422
- <button type="button" onClick={c.placeOrder} disabled={!c.canPlaceOrder || c.placing}>
423
- {c.placing ? PLACING_LABEL : PLACE_ORDER_LABEL}
424
- </button>
425
- {c.orderError && <p role="alert">{c.orderError.message}</p>}
426
- {!c.canPlaceOrder && c.blockers.map((code) => <p key={code}>{BLOCKERS[code]}</p>)}
427
- </>
428
- );
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 */
339
+ /* optional: --sfui-font-label — only if the site's labels/microcopy use a second face */
429
340
  }
430
341
  ```
431
342
 
432
- **`<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`.
343
+ 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.
433
344
 
434
- **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.
345
+ ### Brand wordingprops, in the store's voice
435
346
 
436
- **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.
347
+ 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:
437
348
 
438
- ## Order received
439
-
440
- ```jsx
441
- import { useOrderReturn, useFormatMoney, orderTotalsLines } from "@/commerce/storefront";
442
- ```
443
-
444
- **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.
349
+ | component | brand keys |
350
+ |---|---|
351
+ | `<CartPage />` | `title`, `summaryTitle`, `checkoutCta`, `continueShopping`, `emptyTitle`, `emptyBody`, `emptyCta`, `note` |
352
+ | `<MiniCart />` | `title`, `checkoutLabel`, `viewCartLabel`, `emptyTitle`, `emptyCta` |
353
+ | `<CheckoutPage />` | `title`, `contactTitle`, `shippingTitle`, `paymentTitle`, `summaryTitle`, `submitLabel`, `termsLabel`, `emptyTitle`, `emptyCta` |
354
+ | `<OrderReceivedPage />` | `paidTitle`, `paidBody`, `unpaidTitle`, `unpaidBody`, `backToStore` |
445
355
 
446
- 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.
356
+ 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).
447
357
 
448
- A receipt is a convention surface: your classes, every branch present, no bespoke widgets.
358
+ **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.
449
359
 
450
360
  ## Driving the storefront from a browser script?
451
361
 
@@ -453,19 +363,17 @@ The cart is optimistic and debounced, so a script that acts faster than it settl
453
363
 
454
364
  ## Done — stage 02 complete
455
365
 
456
- - [ ] Catalog UI in whatever form fits the store, plus a checkout, plus `/order-received` rendering `useOrderReturn`'s states **including `paymentInstructions`**.
457
- - [ ] `index.css` defines the store's design classes; pages carry class names, not repeated utility runs.
458
- - [ ] **One** `<StorefrontProvider>` above every storefront route (layout-route pattern); one client, no hand-rolled `cart_token`.
366
+ - [ ] 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/`.
367
+ - [ ] 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.
368
+ - [ ] `index.css` defines the store's design classes; your pages carry class names, not repeated utility runs.
369
+ - [ ] **One** `<StorefrontProvider>` (+ `<CartUIProvider>`) above every storefront route (layout-route pattern); one client, no hand-rolled `cart_token`.
459
370
  - [ ] Every page's imports came from its section's import line; no unused names.
460
- - [ ] Pages branch on `status`; gateways/currency/countries read from `useStoreInfo()`/`useCountries()` only.
461
- - [ ] 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.
371
+ - [ ] Your pages branch on `status`; gateways/currency/countries read from `useStoreInfo()`/`useCountries()` only.
372
+ - [ ] Every state your pages expose has words: the buy button reads for all four `buy.state` values, and no state renders empty.
462
373
  - [ ] No re-implemented hook logic (button state precedence, quantity clamps, totals math, drawer state).
463
- - [ ] 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.
374
+ - [ ] A paging control rendered whenever `hasNext` is true; ribbons rendered in both the grid and the product page.
464
375
  - [ ] Variant options: one control per axis, unbuyable options disabled, not hidden.
465
- - [ ] The checkout's addresses render through `<AddressFields>` (both `which` values), styled in the store's classes not a hand-rolled field list.
466
- - [ ] 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`.
467
- - [ ] Checkout guards `stage === "submitted"` above its empty-cart branch.
468
- - [ ] 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.
376
+ - [ ] The storefront carries the design you settled on before reading this file design classes plus one or two signature moments per page.
469
377
  - [ ] **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.
470
378
 
471
379
  Record this file's `carry_forward` lines (front matter) in your working notes, then move on to stage 03 below.
@@ -530,7 +438,7 @@ try {
530
438
  },
531
439
  ],
532
440
  coupons: [{ code: "WELCOME10", discount_type: "percent", amount: 10 }],
533
- // ONLY with a coupon field in the cart or checkout (see stage 02 below)
441
+ // the shipped cart/checkout surface the coupon field by themselves once any exist
534
442
  // locations: [ … ], // shipping — next section; passing any makes them the store's ONLY ones
535
443
  });
536
444
  return res.data; // ← the { success, data } envelope: plain JSON
@@ -599,7 +507,6 @@ Online card payments are **off by default**: the seeded store takes the manual `
599
507
  - [ ] Seed returned success — real products, final permanent image URLs; slugs recorded and pages link by them.
600
508
  - [ ] `warnings` is empty, or every warning is deliberate and stated to the user.
601
509
  - [ ] Shipping expressed in `locations` (catch-all present if the store ships worldwide); named tiers asserted.
602
- - [ ] `coupons` seeded only if a coupon field exists ([stage 02 below](#02--storefront)).
603
510
  - [ ] Cards off, or on with the provider file copied whole.
604
511
 
605
512
  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
 
@@ -0,0 +1,155 @@
1
+ ---
2
+ read_when: "The user asks to change something on the shipped cart page, cart drawer, checkout, or order-received page — wording, layout, sections, styling beyond the theme block, an extra locale, or custom content inside them."
3
+ skip_when: "First install (stage 02's theme block + brand props are the whole job), or the change is on a page you built (list/product)."
4
+ ---
5
+
6
+ # storefront-ui — day-2 reference
7
+
8
+ `src/commerce/storefront-ui/` is **kit-owned**: a kit update re-copies the whole
9
+ directory, so direct edits to its files do not survive and are unsupported. Every
10
+ supported change goes through the surface below, in this order — most requests stop
11
+ at the first rung that fits:
12
+
13
+ 1. **Theme tokens** — colors, radius, heading font.
14
+ 2. **`brand` props** — any heading/CTA/empty-state wording.
15
+ 3. **`sections` / layout props** — show, hide, or require parts.
16
+ 4. **`slots`** — inject your own markup at the defined points.
17
+ 5. **CSS overrides in `index.css`** — restyle beyond tokens (below).
18
+ 6. **A new locale file** — the one in-directory edit that survives conceptually
19
+ (re-add it after a kit update; keep a copy in your notes).
20
+
21
+ If none of those can express the request, say so and ask the user before forking a
22
+ component: copy the file **out** of `storefront-ui/` into the app's own pages,
23
+ rename it, and own it from then on — never edit it in place.
24
+
25
+ ## Theme tokens — the full set
26
+
27
+ Set on `.sfui` in `index.css`. Colors are **pairs**; never override one side alone.
28
+ Unset tokens fall back to the app's shadcn variables (`--background`, `--primary`, …),
29
+ then to safe literals — an app with no block at all is already correct.
30
+
31
+ | pair / token | used for | default chain |
32
+ |---|---|---|
33
+ | `--sfui-surface` / `--sfui-on-surface` | page background context + primary text | `--background` / `--foreground` |
34
+ | `--sfui-subtle` / `--sfui-on-subtle` | panels, summary asides, secondary text | `--muted` / `--muted-foreground` |
35
+ | `--sfui-accent` / `--sfui-on-accent` | CTAs, selected choices, badge | `--primary` / `--primary-foreground` |
36
+ | `--sfui-danger` / `--sfui-on-danger` | errors, blockers | `--destructive` / `--destructive-foreground` |
37
+ | `--sfui-border` | hairlines, control borders | `--border` |
38
+ | `--sfui-radius` | panels/cards (controls derive ×0.6, or set `--sfui-radius-control`) | `--radius` → `0.75rem` |
39
+ | `--sfui-font-heading` | headings only — body text always inherits the site font | `inherit` |
40
+ | `--sfui-font-label` | section/field labels and microcopy — set only when the site gives labels their own face (e.g. a mono label idiom) | `inherit` |
41
+
42
+ **Contrast guard**: on mount each active pair is measured; a pair under 3:1 is reset
43
+ to its default (both sides) and a `console.warn` names it. So a "my colors aren't
44
+ applying" report usually means the pair failed contrast — fix the colors, don't
45
+ fight the guard.
46
+
47
+ ## Components & props
48
+
49
+ All props optional; the zero-prop render is complete.
50
+
51
+ ### `<CartPage />` — route it (usually `/cart`)
52
+
53
+ - `brand`: `title`, `summaryTitle`, `checkoutCta`, `continueShopping`,
54
+ `emptyTitle`, `emptyBody`, `emptyCta`, `note` (the small tax/shipping note).
55
+ - `sections`: `{ coupon: "auto"|true|false, notes: false, continueShopping: true, taxNote: true }`.
56
+ - `checkoutHref="/checkout"`, `continueHref="/"`,
57
+ `productHref={(item) => "/product/" + item.slug}` — line names link only when given.
58
+ - `slots`: `{ lineExtra({ item, line }), aboveSummary(), emptyState() }` —
59
+ `lineExtra` renders inside every row (gift-wrap toggle, availability note);
60
+ `aboveSummary` sits above the totals (trust badges, upsell rail); `emptyState`
61
+ replaces the designed empty state entirely.
62
+
63
+ ### `<MiniCart />` — mount ONCE in the layout, never on a route
64
+
65
+ - `brand`: `title`, `checkoutLabel`, `viewCartLabel`, `emptyTitle`, `emptyCta`.
66
+ - `side: "right"|"left"` (`"left"` reads better for RTL stores),
67
+ `checkoutHref="/checkout"`, `cartHref` (the "view cart" link renders only when
68
+ given — a store without a cart page just omits it), `productHref`.
69
+ - Open/close state is `<CartUIProvider>`'s: Esc, close-on-navigate and
70
+ open-on-add already work. Programmatic control is `useCartUI()`
71
+ (`openCart`/`closeCart`/`toggleCart`) — e.g. suppress open-on-add by rendering
72
+ the provider with its own options, not by patching MiniCart.
73
+
74
+ ### `<CartButton />` — optional header trigger
75
+
76
+ `className`, `label` (aria-label). It is only `useCartUI().toggleCart` +
77
+ `useCart().itemCount` around a bag icon — replacing it with the store's own button
78
+ on those two hooks is normal and supported.
79
+
80
+ ### `<CheckoutPage />` — route it (usually `/checkout`)
81
+
82
+ - `brand`: `title`, `contactTitle`, `shippingTitle`, `paymentTitle`,
83
+ `summaryTitle`, `submitLabel`, `termsLabel`, `emptyTitle`, `emptyCta`.
84
+ - `layout: "two-column"|"single"` — `"single"` stacks the summary under the form
85
+ (long forms, narrow brand sites; mobile always stacks).
86
+ - `sections`: `{ coupon: "auto", notes: false, phone: "optional"|"required"|"hidden",
87
+ shipToDifferent: true, termsCheckbox: false }` — `phone: "required"` marks the
88
+ field required in the address spec (the blocker wording follows for free);
89
+ `termsCheckbox: true` gates place-order on an accepted checkbox worded by
90
+ `brand.termsLabel`.
91
+ - `continueHref="/"` — where the empty state sends people.
92
+ - `onPlaced={(order) => …}` — replaces the default `/order-received` navigation.
93
+ Advanced: the default flow already handles the card redirect and the offline
94
+ receipt; use this only when the user explicitly wants a custom post-order flow,
95
+ and remember card payments still return to `/order-received`.
96
+ - Order notes persist in `sessionStorage` while the customer bounces between cart
97
+ and checkout, and submit as the order's `customer_note`.
98
+
99
+ ### `<OrderReceivedPage />` — route it at `/order-received` (mandatory)
100
+
101
+ - `brand`: `paidTitle`, `paidBody`, `unpaidTitle`, `unpaidBody`, `backToStore`.
102
+ - `homeHref="/"`.
103
+ - Renders all `useOrderReturn` states — paid, unpaid (payment instructions +
104
+ pay-now link), cancelled (retry link), error (retry) — and the order's lines and
105
+ totals. Must stay under `<StorefrontProvider>`.
106
+
107
+ ## Wording & locales
108
+
109
+ Functional labels live in `src/commerce/storefront-ui/i18n/locales/` —
110
+ `en de es fr ja pt`, flat keys namespaced `common.*`, `cart.*`, `minicart.*`,
111
+ `checkout.*` (incl. `checkout.hint.*` and `checkout.blocker.*` for every blocker
112
+ code), `order.*`. Switching among the six is ONE edit: repoint
113
+ `import active from "./locales/en.js"` in `i18n/index.js`.
114
+
115
+ - **Reword one label in the site's voice** → the matching `brand` prop
116
+ (precedence: brand prop → active locale → en). Never edit a locale file for a
117
+ brand-voice request.
118
+ - **A language outside the six** → copy `locales/en.js` to `<lang>.js`, translate
119
+ every value (keep keys and `{placeholders}` exact), repoint the import.
120
+ - **A label with no brand prop** (a blocker line, a validation hint) → that is
121
+ functional copy; if the user insists on custom wording, a new locale file copied
122
+ from the active one with that value changed is the supported route.
123
+
124
+ ## Restyling beyond tokens
125
+
126
+ The stylesheet is plain, low-specificity CSS scoped under `.sfui` with stable
127
+ class names (`.sfui-btn`, `.sfui-panel`, `.sfui-line`, `.sfui-choice`,
128
+ `.sfui-summary`, `.sfui-drawer`, `.sfui-cart-button`, `.sfui-cart-badge`, …).
129
+ Override them from the app's `index.css` — later in the cascade, so equal
130
+ specificity wins:
131
+
132
+ ```css
133
+ .sfui .sfui-btn { text-transform: uppercase; letter-spacing: 0.08em; }
134
+ .sfui .sfui-panel { box-shadow: 0 1px 0 rgb(0 0 0 / 6%); border: 0; }
135
+ ```
136
+
137
+ Keep overrides in `index.css` (they survive kit updates); never edit
138
+ `storefront-ui.css`. The address form inside checkout is `<AddressFields>` —
139
+ already styled; further styling targets its `[data-part]` attributes **on the
140
+ element itself** (`select[data-part="control"]`, never `[data-part="control"] input`).
141
+
142
+ ## Recipes
143
+
144
+ - **Hide the coupon field everywhere**: `sections={{ coupon: false }}` on both
145
+ `CartPage` and `CheckoutPage` (default `"auto"` shows it only when the store has
146
+ coupons — server-decided via `has_coupons`, so day-2 coupons created in the
147
+ admin surface it automatically).
148
+ - **No cart page, drawer only**: drop the `/cart` route, omit `cartHref` on
149
+ `MiniCart`, point nav links at `/checkout`.
150
+ - **Upsells in the cart**: `slots.aboveSummary` rendering rows from
151
+ `useProduct(...)`'s `upsells`/`crossSells` with `useCart().addItem` — one-click
152
+ Add only for products without attributes (variants answer `400 variation_required`).
153
+ - **B2B-ish checkout**: `sections={{ phone: "required", termsCheckbox: true, notes: true }}`.
154
+ - **Custom thank-you flow**: keep `/order-received` mounted (payment links return
155
+ there), add `onPlaced` only for the extra hop the user asked for.