@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.
- package/README.md +6 -3
- package/base44/functions/commerce/storefront-catalog/entry.ts +5 -0
- package/package.json +1 -1
- package/scripts/install.js +3 -0
- package/skills/commerce/SKILL.md +36 -39
- package/skills/commerce/docs/api-storefront.md +1 -1
- package/skills/commerce/installation/install.md +61 -154
- package/skills/commerce/references/admin-localization.md +6 -4
- package/skills/commerce/references/storefront-ui.md +155 -0
- package/src/commerce/storefront/index.js +6 -3
- package/src/commerce/storefront-ui/CartButton.jsx +33 -0
- package/src/commerce/storefront-ui/CartPage.jsx +140 -0
- package/src/commerce/storefront-ui/CheckoutPage.jsx +304 -0
- package/src/commerce/storefront-ui/MiniCart.jsx +128 -0
- package/src/commerce/storefront-ui/OrderReceivedPage.jsx +159 -0
- package/src/commerce/storefront-ui/i18n/index.js +51 -0
- package/src/commerce/storefront-ui/i18n/locales/de.js +102 -0
- package/src/commerce/storefront-ui/i18n/locales/en.js +113 -0
- package/src/commerce/storefront-ui/i18n/locales/es.js +102 -0
- package/src/commerce/storefront-ui/i18n/locales/fr.js +102 -0
- package/src/commerce/storefront-ui/i18n/locales/ja.js +102 -0
- package/src/commerce/storefront-ui/i18n/locales/pt.js +102 -0
- package/src/commerce/storefront-ui/index.js +16 -0
- package/src/commerce/storefront-ui/internal.jsx +218 -0
- package/src/commerce/storefront-ui/storefront-ui.css +452 -0
- package/src/commerce/storefront-ui/theme.js +76 -0
- package/src/commerce/utils/price.js +5 -3
|
@@ -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
|
-
- "
|
|
15
|
-
- "
|
|
16
|
-
- "Branch
|
|
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
|
-
- "
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
**
|
|
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
|
|
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> {/*
|
|
157
|
-
<StoreLayout /> {/* YOURS: header + <Outlet/> + footer +
|
|
157
|
+
<CartUIProvider> {/* the drawer's state — keep 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
|
-
|
|
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
|
|
176
|
+
<header>{/* nav + <CartButton /> (or your own trigger on useCartUI/useCart) */}</header>
|
|
175
177
|
<Outlet /> {/* the routed page lands here */}
|
|
176
|
-
<footer>…</footer>
|
|
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)
|
|
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.**
|
|
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
|
-
| `
|
|
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
|
|
320
|
+
## Cart, drawer, checkout, order received — shipped
|
|
324
321
|
|
|
325
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
### Theme — one block, pairs only
|
|
376
327
|
|
|
377
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
343
|
+
That is the entire vocabulary — nothing 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
|
-
|
|
345
|
+
### Brand wording — props, in the store's voice
|
|
435
346
|
|
|
436
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
457
|
-
- [ ] `index.css`
|
|
458
|
-
- [ ]
|
|
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
|
-
- [ ]
|
|
461
|
-
- [ ] Every state
|
|
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
|
-
- [ ]
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
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.
|