@base44/app-plugin-commerce 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/skills/commerce/SKILL.md +55 -48
- package/skills/commerce/install/01-install.md +1 -1
- package/skills/commerce/install/02-storefront.md +165 -155
- package/skills/commerce/references/storefront-verification.md +0 -4
- package/src/commerce/storefront/AddressFields.jsx +150 -0
- package/src/commerce/storefront/StorefrontProvider.jsx +3 -10
- package/src/commerce/storefront/cartUI.jsx +17 -63
- package/src/commerce/storefront/index.js +6 -30
- package/src/commerce/storefront/useCheckout.jsx +0 -27
- package/skills/commerce/references/storefront-custom.md +0 -150
- package/skills/commerce/references/storefront-parts.md +0 -184
- package/src/commerce/storefront/parts/cart.jsx +0 -191
- package/src/commerce/storefront/parts/checkout.jsx +0 -540
- package/src/commerce/storefront/parts/drawer.jsx +0 -87
- package/src/commerce/storefront/parts/labels.jsx +0 -140
- package/src/commerce/storefront/parts/orderReceived.jsx +0 -200
- package/src/commerce/storefront/parts/parts.css +0 -215
- package/src/commerce/storefront/parts/shared.jsx +0 -137
- package/src/commerce/storefront/parts/visibility.js +0 -36
|
@@ -5,22 +5,25 @@ skip_when: "The storefront pages already render against live data and pass the c
|
|
|
5
5
|
forget_when: "The checklist at the bottom passes — every page renders against the seeded catalog and an offline order completes."
|
|
6
6
|
carry_forward:
|
|
7
7
|
- "Payment gateways, currency and countries come from useStoreInfo() only — never off a cart (cart.payment_gateways is always undefined)."
|
|
8
|
-
- "A store with any coupons must
|
|
9
|
-
- "
|
|
10
|
-
- "Branch
|
|
8
|
+
- "A store with any coupons must have a coupon field (useCart().applyCoupon) in the cart or the checkout, or its codes can never be redeemed."
|
|
9
|
+
- "/order-received is mandatory and renders useOrderReturn's states, including paymentInstructions — how a normal (offline) customer learns how to pay."
|
|
10
|
+
- "Branch cart/list/product UI on `status`, never on `isEmpty`/nullable data — `isEmpty` is false while loading by design."
|
|
11
11
|
- "Variant options that aren't buyable render disabled, never hidden; one control per axis, never a list of variations."
|
|
12
|
-
- "
|
|
13
|
-
- "
|
|
12
|
+
- "Every hook on a page goes above its status guards — a hook after an early return crashes with \"Rendered more hooks than during the previous render\"."
|
|
13
|
+
- "The kit ships no copy: hooks hand you state codes (buy.state, hint.code, blockers) and you write every word — including the reason a disabled button is disabled."
|
|
14
|
+
- "Identity is encoded once — design classes in index.css plus one or two signature moments per page; a utility run that appears twice becomes a class."
|
|
14
15
|
- "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."
|
|
15
16
|
---
|
|
16
17
|
|
|
17
18
|
# 02 — Storefront
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
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.
|
|
20
21
|
|
|
21
|
-
**
|
|
22
|
+
**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).
|
|
22
23
|
|
|
23
|
-
**
|
|
24
|
+
**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.
|
|
25
|
+
|
|
26
|
+
**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.
|
|
24
27
|
|
|
25
28
|
## Setup — once
|
|
26
29
|
|
|
@@ -28,58 +31,55 @@ Nearly every store has shared chrome, so **start from a pathless layout route**
|
|
|
28
31
|
|
|
29
32
|
```jsx
|
|
30
33
|
import { Routes, Route, Outlet } from "react-router-dom";
|
|
31
|
-
import { StorefrontProvider, CartUIProvider
|
|
32
|
-
import { STORE_COPY } from "@/copy"; // your copy file — see The copy file
|
|
34
|
+
import { StorefrontProvider, CartUIProvider } from "@/commerce/storefront";
|
|
33
35
|
import { base44 } from "@/api/base44Client";
|
|
34
36
|
import AdminApp from "@/commerce/admin";
|
|
35
37
|
|
|
36
38
|
<BrowserRouter>
|
|
37
39
|
<Routes> {/* ONE <Routes> — merge new pages into the app's */}
|
|
38
40
|
<Route element={
|
|
39
|
-
<StorefrontProvider base44={base44}
|
|
40
|
-
<CartUIProvider> {/* only if the cart is a drawer */}
|
|
41
|
+
<StorefrontProvider base44={base44}>
|
|
42
|
+
<CartUIProvider> {/* only if the cart is a drawer — see Cart below */}
|
|
41
43
|
<StoreLayout /> {/* YOURS: header + <Outlet/> + footer + drawer */}
|
|
42
44
|
</CartUIProvider>
|
|
43
45
|
</StorefrontProvider>
|
|
44
46
|
}>
|
|
45
47
|
<Route path="/" element={<Home />} />
|
|
46
48
|
<Route path="/product/:slug" element={<ProductPage />} />
|
|
47
|
-
{/* /bag, /checkout, /order-received
|
|
49
|
+
{/* /bag, /checkout, and /order-received — which is mandatory */}
|
|
48
50
|
</Route>
|
|
49
51
|
<Route path="/store-admin/*" element={<AdminApp />} /> {/* own chrome, outside the provider */}
|
|
50
52
|
</Routes>
|
|
51
53
|
</BrowserRouter>
|
|
54
|
+
|
|
55
|
+
// …and the layout that route renders. Yours to design; the shape is the point:
|
|
56
|
+
function StoreLayout() {
|
|
57
|
+
const { itemCount } = useCart(); // one cart, shared with every page
|
|
58
|
+
return (
|
|
59
|
+
<>
|
|
60
|
+
<header>{/* nav + your cart trigger, showing itemCount */}</header>
|
|
61
|
+
<Outlet /> {/* the routed page lands here */}
|
|
62
|
+
<footer>…</footer>{/* + the drawer, if the cart is one — see Cart below */}
|
|
63
|
+
</>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
52
66
|
```
|
|
53
67
|
|
|
54
|
-
⚑ **The nesting is provider → layout → `<Outlet/>`, never the reverse** — a layout that renders the provider inside itself leaves the nav's cart badge on a different cart. With no shared chrome, wrap `<Routes>` in the provider instead; a provider *inside* `<Routes>` throws ("is not a `<Route>` component"). The provider owns the shared client, store info and **one** shared cart — never mount a second, never touch `cart_token`.
|
|
68
|
+
⚑ **The nesting is provider → layout → `<Outlet/>`, never the reverse** — a layout that renders the provider inside itself leaves the nav's cart badge on a different cart (or throws). With no shared chrome, wrap `<Routes>` in the provider instead; a provider *inside* `<Routes>` throws ("is not a `<Route>` component"). The provider owns the shared client, store info and **one** shared cart — never mount a second one, never touch `cart_token`.
|
|
55
69
|
|
|
56
70
|
## Design language — once, before any page
|
|
57
71
|
|
|
58
|
-
The cost driver of a generated storefront is not wiring — it is decoration repeated inline. Encode identity **once**: in `index.css`, set the palette and type scale, then define the store's recurring surfaces as **10–15 composable classes** named in *this* store's language (`.panel`, `.btn-cta`, `.label-mono`, `.field`, `.choice-row
|
|
59
|
-
|
|
60
|
-
**Concentrate identity; don't diffuse it.** The classes carry the look; on top of them spend bespoke markup on **one or two signature moments per page**. **The product page stays the richest surface**, and that richness is semantic: what the controls and rows *show*. Checkout, bag and receipt are convention surfaces — parts give the structure, your classes the look.
|
|
61
|
-
|
|
62
|
-
⚑ **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.
|
|
63
|
-
|
|
64
|
-
## The parts — shared contract
|
|
72
|
+
The cost driver of a generated storefront is not wiring — it is decoration repeated inline. Encode identity **once**: in `index.css`, set the palette and type scale, then define the store's recurring surfaces as **10–15 composable classes** in Tailwind's components layer, named in *this* store's language (`.panel`, `.btn-cta`, `.label-mono`, `.field`, `.choice-row`, a heading scale, a price style — whatever *this* store repeats). Pages then carry short class names plus a couple of layout utilities. ⚑ **A utility run that appears twice becomes a class.**
|
|
65
73
|
|
|
66
|
-
|
|
74
|
+
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.
|
|
67
75
|
|
|
68
|
-
|
|
69
|
-
- **Words.** Your copy file, passed once on `StorefrontProvider labels`; override per Root or part with the same shape. A missing key renders a visible `⟨copy: path⟩` and warns — never silent, never English.
|
|
70
|
-
- **Navigation — never rendered, never assumed.** A gate renders *your* element when the state calls for it (function children hand you the data); `OrderReceived.PaymentLink` hands `{ url }` to yours; `Checkout.Root` requires `orderReceivedPath`. Nothing assumes a route, or even an anchor.
|
|
71
|
-
- **Display settings — `show`, never CSS.** Which sub-elements a part renders is a prop: `show={{ media: false }}` (overrides) or `show={["subtotal", "total"]}` (exactly these). Keys are the same `data-part` names, per part below. ⚑ **Turn an element off with `show`, not `display: none`** — the prop renders nothing, while hiding leaves an empty box in the layout and the accessibility tree.
|
|
72
|
-
- **Overrides — your element, the part's wiring.** Where the *element type* is the design decision, one prop swaps it and the part keeps the state and aria: `inputRender`/`fieldRender` (address fields), `controlRender` (ship-to-different), `optionRender` (a shipping/payment option — **the whole element**, radio included: call `option.select()`, show `option.selected`), `stepperRender` (cart quantity), `inputRender` (coupon), `lineRender`/`itemRender` (a whole row). ⚑ A replaced control still owes keyboard operability, a readable state and a name. Signatures: [`../references/storefront-parts.md`](../references/storefront-parts.md).
|
|
76
|
+
**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.
|
|
73
77
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
### The copy file
|
|
77
|
-
|
|
78
|
-
The parts render **no kit-authored word**: every string — visible copy, field labels, totals row names, aria names — comes from one map you write in the store's voice and pass once (`<StorefrontProvider labels={STORE_COPY}>`, Setup above); a part or page `Root` takes a `labels` slice to override one surface. Groups: `placeOrder`, `blockers` (one line per code, each naming what the customer can fix), `shipping`/`payment` (picker dead-ends), `coupon`, `fields`, `totals`, `bank`, `aria`. ⚑ **A key you don't provide renders a visible `⟨copy: …⟩` placeholder** and warns — never English, never silent, and never something to ship. **Copy the reference map from [`../references/storefront-parts.md`](../references/storefront-parts.md) and rewrite every value** — the same file carries the styling contract, so open it once for both.
|
|
78
|
+
⚑ **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.
|
|
79
79
|
|
|
80
80
|
## What each hook resolves to
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
Everything below is already unwrapped — no `.data`, no envelope. `formatMoney` throughout is `useFormatMoney()`.
|
|
83
83
|
|
|
84
84
|
| Call | Resolves to |
|
|
85
85
|
|---|---|
|
|
@@ -96,7 +96,13 @@ Already unwrapped — no `.data`, no envelope; `formatMoney` is `useFormatMoney(
|
|
|
96
96
|
| `productRibbons(product)` | `[{ id, name }]` — **objects**, and the field can be absent; takes a listing row or `useProduct().product`. |
|
|
97
97
|
| `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. |
|
|
98
98
|
| `useCart()` | `{ status, cart, itemCount, isEmpty, loading, error, mutationError, refresh, addItem, updateItem, removeItem, applyCoupon, removeCoupon }` — `status`: `"loading" \| "ready" \| "empty"`. |
|
|
99
|
-
| `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}
|
|
99
|
+
| `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. |
|
|
100
|
+
| `attributesLabel(item.attributes)` | `"Size: 42 · Color: Ivory"` (`""` when the product has no attributes). |
|
|
101
|
+
| `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. |
|
|
102
|
+
| `useCartLine(item)` | `{ quantity, setQuantity, increase, decrease, remove, pending, error, canIncrease, canDecrease, maxQuantity, atMax, atMin }`. |
|
|
103
|
+
| `useCartUI()` | `{ open, openCart, closeCart, toggleCart }`. |
|
|
104
|
+
| `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. |
|
|
105
|
+
| `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`. |
|
|
100
106
|
|
|
101
107
|
## Product list / collection
|
|
102
108
|
|
|
@@ -104,15 +110,15 @@ Already unwrapped — no `.data`, no envelope; `formatMoney` is `useFormatMoney(
|
|
|
104
110
|
import { useProductList, useCategories, useStoreInfo, useFormatMoney, productPrice, productImages, productRibbons } from "@/commerce/storefront";
|
|
105
111
|
```
|
|
106
112
|
|
|
107
|
-
(Drop `useCategories` with no filter bar; add `useRibbons` for a ribbon filter.) `const list = useProductList({ per_page: 24 })`, then
|
|
113
|
+
(Drop `useCategories` with no filter bar; add `useRibbons` for a ribbon filter.) `const list = useProductList({ per_page: 24 })`, then guards on `list.status` before any markup — ⚑ branch on `status`, so a failed request renders as a failure instead of an empty grid, with a retry calling `list.reload`.
|
|
108
114
|
|
|
109
115
|
⚑ **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.
|
|
110
116
|
|
|
111
|
-
A card can render `name`, `productImages(row)[0]`, `productPrice(row, { formatMoney }).label` (already "From €19.99" when the product sells variants — there is no `type` flag, and `product.price` 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 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, edition size, ABV — read off `productSpecs(row)`, not the fields every store shows.
|
|
117
|
+
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, edition size, ABV — read off `productSpecs(row)`, not the fields every store shows.
|
|
112
118
|
|
|
113
|
-
⚑ **Ribbons belong in both views** — grid and product page. They are the merchant's own merchandising ("Limited", "Last pieces"), each
|
|
119
|
+
⚑ **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.
|
|
114
120
|
|
|
115
|
-
**Rails** (featured row, "new in") are the same hook with a filter (`{ featured: true, per_page: 4 }`) — `featured` is the merchant's flag, so the rail stays curated store data
|
|
121
|
+
**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.
|
|
116
122
|
|
|
117
123
|
## Product page
|
|
118
124
|
|
|
@@ -120,7 +126,7 @@ A card can render `name`, `productImages(row)[0]`, `productPrice(row, { formatMo
|
|
|
120
126
|
import { useProduct, useAddToCart, useStoreInfo, useFormatMoney, useStorefront, variantAxes, productImages, imageIndex, productRibbons, productSpecs, findSpec, storefrontErrorCode } from "@/commerce/storefront";
|
|
121
127
|
```
|
|
122
128
|
|
|
123
|
-
`useProduct(slug)` (or `{ id }`) owns fetch + variant selection + quantity + price, race-safe, selection mirrored to the URL. ⚑ `status: "not_found"` is a 404 page, not a spinner. ⚑ **
|
|
129
|
+
`useProduct(slug)` (or `{ id }`) owns fetch + variant selection + quantity + price, race-safe, selection mirrored to the URL. ⚑ `status: "not_found"` is a 404 page, not a spinner. ⚑ **Call every hook above the status guards** — they all tolerate a null/loading product precisely so they can sit at the top; a hook after an early `return` crashes React with "Rendered more hooks than during the previous render" the moment the product resolves.
|
|
124
130
|
|
|
125
131
|
```jsx
|
|
126
132
|
const p = useProduct(slug);
|
|
@@ -144,7 +150,7 @@ Build your layout from — all optional, **not one component style**:
|
|
|
144
150
|
useEffect(() => setPicked(null), [view?.variation?.id]); // a new variant re-takes the lead
|
|
145
151
|
```
|
|
146
152
|
|
|
147
|
-
⚑ **The main frame comes from `view.display.image`, never an index into the strip.** A variation's image lives on the variation and usually
|
|
153
|
+
⚑ **The main frame comes from `view.display.image`, never from an index into the strip.** A variation's image lives on the variation and is usually *not* in `product.images`, so indexing the strip shows the wrong colour as soon as a variant is picked. `display.image` is variation-first with a parent fallback, in the same `{src, name, alt}` shape as a strip entry — which is why `imageIndex` locates it when it is there and returns `-1` when it isn't. `active === null` only when the product has no images at all.
|
|
148
154
|
- **Variant selector** — `variantAxes(view, p.pick)`, one entry per axis:
|
|
149
155
|
|
|
150
156
|
```jsx
|
|
@@ -159,7 +165,7 @@ Build your layout from — all optional, **not one component style**:
|
|
|
159
165
|
))}
|
|
160
166
|
```
|
|
161
167
|
|
|
162
|
-
⚑ **One control per axis, never a list of variations**, and ⚑ **an unbuyable option renders `disabled`, never hidden** (`outOfStock` stays visible, just marked). `view.missingAxes` names what's unpicked. **Render each axis by what it is** — swatches for a colour axis, chips
|
|
168
|
+
⚑ **One control per axis, never a list of variations**, and ⚑ **an unbuyable option renders `disabled`, never hidden** (`outOfStock` stays visible, just marked). `view.missingAxes` names what's unpicked. **Render each axis by what it is** — swatches for a colour axis, chips with a size guide beside a size axis; every axis as the identical chip row is a generated-page tell. That differentiation is semantic — what the control *shows* — built from your classes, not extra chrome around each row.
|
|
163
169
|
- **Buy box** — one button, and **you supply its four words**:
|
|
164
170
|
|
|
165
171
|
```jsx
|
|
@@ -168,8 +174,8 @@ Build your layout from — all optional, **not one component style**:
|
|
|
168
174
|
{buy.error?.message && <p role="alert">{buy.error.message}</p>}
|
|
169
175
|
```
|
|
170
176
|
|
|
171
|
-
⚑ **Text for every state, and the gate from the hook.** `buy.state` resolves the precedence — never re-derive `disabled` from
|
|
172
|
-
- **Description** — `product.description` is HTML; render as rich text, `short_description` above.
|
|
177
|
+
⚑ **Text for every state, and the gate from the hook.** `buy.state` resolves the precedence — never re-derive `disabled` from your own ternary chain, and never leave a state unworded (the button renders empty). ⚑ `buy.showQuantity: false` means no stepper. With `<CartUIProvider>` mounted, a successful add opens the drawer by itself.
|
|
178
|
+
- **Description** — `product.description` is HTML; render as rich text, `short_description` above it.
|
|
173
179
|
- **Specs** — `productSpecs(product)` rows from the admin's *Modifiers*, **the product page's signature-moment candidate**. Every row arrives pre-classified, so the branch point is already there and one uniform table is a choice, not a default:
|
|
174
180
|
|
|
175
181
|
```jsx
|
|
@@ -180,153 +186,157 @@ Build your layout from — all optional, **not one component style**:
|
|
|
180
186
|
: <Row key={s.key} label={s.titleLabel} value={s.value} />)}
|
|
181
187
|
```
|
|
182
188
|
|
|
183
|
-
⚑ **Never `.map()` the whole list into one grey label/value table** — that is the single most reliable tell of a generated product page. Design the two or three rows that carry *this* catalog's meaning as what they are (a weight set in the display face, a composition as bars, a provenance beside its place); let the rest fall through to the plain row, and don't feel obliged to keep them in one block — a spec can sit under the gallery, beside the price, or inside the description. Branch on `s.key` too where one particular modifier deserves its own treatment regardless of type. ⚑ **Look a spec up with `findSpec(rows, "care")`** (ignores case, spaces, `_`, `-`): meta keys are free text (`care`, `Care`, `Care Instructions`), so `rows.find(s => s.label === "Care")` silently never matches and renders the fallback forever. `[]` means no section.
|
|
184
|
-
- **Breadcrumbs** — from `categories` (`/collection?category_id=${c.id}`); skip on a flat catalog. Ribbons are labels, not breadcrumbs.
|
|
185
|
-
- **Reviews, only if the store wants them** — no review UI is a complete outcome (then no star ratings on cards: an average of nothing is `0`). `p.reviews` arrives with the product
|
|
186
|
-
- **Title** —
|
|
189
|
+
⚑ **Never `.map()` the whole list into one grey label/value table** — that is the single most reliable tell of a generated product page. Design the two or three rows that carry *this* catalog's meaning as what they are (a weight set in the display face, a composition as bars, a provenance beside its place); let the rest fall through to the plain row, and don't feel obliged to keep them in one block — a spec can sit under the gallery, beside the price, or inside the description. Branch on `s.key` too where one particular modifier deserves its own treatment regardless of type. ⚑ **Look a spec up with `findSpec(rows, "care")`** (ignores case, spaces, `_`, `-`): meta keys are free text (`care`, `Care`, `Care Instructions`), so `rows.find(s => s.label === "Care")` silently never matches and renders the fallback forever. `[]` means no section at all.
|
|
190
|
+
- **Breadcrumbs** — from `categories` (`/collection?category_id=${c.id}`); skip on a flat catalog. Ribbons (`productRibbons(product)`) are labels, not breadcrumbs.
|
|
191
|
+
- **Reviews, only if the store wants them** — no review UI is a complete outcome (then no star ratings on cards either: an average of nothing is `0`). `p.reviews` arrives with the product as `{ items, page, per_page, has_next }`; submitting is `submitReview` off `useStorefront()`, open to guests. ⚑ Derive the confirmation from the response's `status` (`"approved"` vs `"hold"`) — a hardcoded "awaiting approval" lies to every store that auto-approves — and refresh the list after, or the review doesn't appear. Field codes, policies and moderation: [`../references/reviews.md`](../references/reviews.md).
|
|
192
|
+
- **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).
|
|
187
193
|
|
|
188
194
|
## Cart / bag
|
|
189
195
|
|
|
190
196
|
```jsx
|
|
191
|
-
import {
|
|
197
|
+
import { useCart, useCartLine, CartLine, useCartUI, useFormatMoney, attributesLabel, cartTotalsLines } from "@/commerce/storefront";
|
|
192
198
|
```
|
|
193
199
|
|
|
194
|
-
A cart *page* is optional (buy-now
|
|
200
|
+
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).
|
|
201
|
+
|
|
202
|
+
⚑ 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.
|
|
203
|
+
|
|
204
|
+
⚑ **`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.
|
|
205
|
+
|
|
206
|
+
**Reference wiring** for one row — the rest of the page (notices, coupon field, totals, checkout link) is ordinary markup around it:
|
|
195
207
|
|
|
196
208
|
```jsx
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
{
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
</aside>
|
|
221
|
-
</div>
|
|
222
|
-
)}
|
|
209
|
+
const { status, cart } = useCart();
|
|
210
|
+
const formatMoney = useFormatMoney();
|
|
211
|
+
// guards on status first, then:
|
|
212
|
+
{cart.items.map((item) => (
|
|
213
|
+
<CartLine key={item.item_key} line={item}>
|
|
214
|
+
{(l) => (
|
|
215
|
+
<li aria-busy={l.pending}> {/* busy scope is THIS row, never the cart */}
|
|
216
|
+
{item.name} {attributesLabel(item.attributes)} {formatMoney(item.total)}
|
|
217
|
+
<button onClick={l.decrease} disabled={!l.canDecrease || l.pending}
|
|
218
|
+
aria-label={`Decrease quantity of ${item.name}`}>−</button>
|
|
219
|
+
{l.quantity}
|
|
220
|
+
<button onClick={l.increase} disabled={!l.canIncrease || l.pending}
|
|
221
|
+
aria-label={`Increase quantity of ${item.name}`}>+</button>
|
|
222
|
+
<button onClick={l.remove} disabled={l.pending}
|
|
223
|
+
aria-label={`Remove ${item.name}`}>Remove</button>
|
|
224
|
+
{l.error && <p role="alert">{l.error.message}</p>}
|
|
225
|
+
</li>
|
|
226
|
+
)}
|
|
227
|
+
</CartLine>
|
|
228
|
+
))}
|
|
229
|
+
{cartTotalsLines(cart, { formatMoney }).filter((t) => !t.hidden).map((t) => (
|
|
230
|
+
<div key={t.key}>{t.label} {t.formatted}</div> /* t.emphasis → the total row */
|
|
231
|
+
))}
|
|
223
232
|
```
|
|
224
233
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
| `Cart.Lines` | the rows: thumbnail, name, attributes, stepper (optimistic, coalesces clicks, rolls back — busy scoped to **that row**, never the cart), remove, line total, per-row errors in the server's words | `show`: `media` `attributes` `stepper` `remove` `lineTotal`; `stepperRender`, `lineRender` (`item.slug` for your product link) |
|
|
229
|
-
| `Cart.Notices` | expired coupons + vanished products, the server's words — a line never disappears unexplained | — |
|
|
230
|
-
| `Cart.CouponField` | input + apply + applied codes with remove; an invalid code renders inline | `inputRender` |
|
|
231
|
-
| `Cart.Totals` | every non-hidden summary line, discount and tax included, `data-emphasis` on the total | `show`: row keys (`["subtotal"]` for a drawer footer) |
|
|
232
|
-
| `CartDrawer.Trigger` / `Close` | the two wired buttons — the trigger toggles and reports `aria-expanded`/`aria-controls` (render prop gets `{ count, open }`); `Close` is the named way out. **No panel part ships**: the overlay, the panel, their side, width, padding and animation are yours | children |
|
|
233
|
-
| `useCartUI()` | `{ open, openCart, closeCart, toggleCart, panelRef, panelId }` — plus Esc, close-on-navigate and open-on-add. Attach `panelRef` and focus moves into your panel on open and back out on close, and a panel you keep mounted to animate is made `inert` while closed | — |
|
|
234
|
+
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.
|
|
235
|
+
|
|
236
|
+
### If the cart is a drawer
|
|
234
237
|
|
|
235
|
-
|
|
238
|
+
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).
|
|
236
239
|
|
|
237
|
-
⚑ **
|
|
240
|
+
⚑ **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.
|
|
238
241
|
|
|
239
242
|
## Checkout
|
|
240
243
|
|
|
241
244
|
```jsx
|
|
242
|
-
import {
|
|
245
|
+
import { CheckoutProvider, useCheckoutContext, AddressFields, ShippingMethodPicker, PaymentMethodPicker, useCart, useFormatMoney, cartTotalsLines } from "@/commerce/storefront";
|
|
243
246
|
```
|
|
244
247
|
|
|
245
|
-
`
|
|
248
|
+
`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.
|
|
249
|
+
|
|
250
|
+
⚑ Rules: render each picker's `hint` and every branch; a single shipping or payment option still *shows* what it is — never a picker of one, never "nothing selected". Render `addressError` on the address fields. ⚑ Payment methods, currency and countries come from `useStoreInfo()`/`useCountries()` only — `cart.payment_gateways` is always `undefined`, and a default store offers `offline` only, so never hardcode a card option.
|
|
251
|
+
|
|
252
|
+
⚑ **A disabled place-order button must say why** — the silent disabled button is the most common checkout dead end. `blockers` is an array of codes; write one line per code, in the store's voice, anchored near the field that fixes it: `empty_cart` (bag is empty) · `billing_incomplete` (required address fields — `missingBillingFields` names them) · `shipping_address_incomplete` (the separate delivery address) · `shipping_address_required` (no address to price yet) · `shipping_method_required` (choose a delivery option) · `shipping_not_available` (this address can't be delivered to) · `payment_method_required` (choose how to pay) · `cart_loading` / `shipping_recalculating` (transient — a quiet "one moment", not an error).
|
|
253
|
+
|
|
254
|
+
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.
|
|
246
255
|
|
|
247
|
-
|
|
256
|
+
**Reference wiring** — the densest part of the storefront. `Checkout()` is just
|
|
257
|
+
`<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)):
|
|
248
258
|
|
|
249
259
|
```jsx
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
</
|
|
260
|
+
function CheckoutForm() {
|
|
261
|
+
const { status } = useCart();
|
|
262
|
+
const c = useCheckoutContext();
|
|
263
|
+
if (c.stage === "submitted") return /* "taking you to your receipt" screen */;
|
|
264
|
+
if (status === "loading") return /* your loading screen */;
|
|
265
|
+
if (status === "empty") return /* your empty-bag screen */;
|
|
266
|
+
return (
|
|
267
|
+
<>
|
|
268
|
+
<AddressFields which="billing" />
|
|
269
|
+
{/* a checkbox on c.shipToDifferent / c.setShipToDifferent, your wording */}
|
|
270
|
+
<AddressFields which="shipping" /> {/* renders null until shipToDifferent */}
|
|
271
|
+
|
|
272
|
+
<ShippingMethodPicker>
|
|
273
|
+
{({ hint, mustChoose, methods, chosen }) => (
|
|
274
|
+
<fieldset>{/* renders null for a virtual cart */}
|
|
275
|
+
{hint && <p role={hint.severity === "error" ? "alert" : "status"}>
|
|
276
|
+
{hint.serverMessage ?? SHIPPING_HINTS[hint.code]}</p>}
|
|
277
|
+
{mustChoose && methods.map((m) => (
|
|
278
|
+
<label key={m.id}>
|
|
279
|
+
<input type="radio" name="shipping-method" checked={m.selected} onChange={m.select} />
|
|
280
|
+
{m.title} {m.costLabel}
|
|
281
|
+
</label>
|
|
282
|
+
))}
|
|
283
|
+
{!mustChoose && chosen && <p>{chosen.title} {chosen.costLabel}</p>}
|
|
284
|
+
</fieldset>
|
|
285
|
+
)}
|
|
286
|
+
</ShippingMethodPicker>
|
|
287
|
+
|
|
288
|
+
{/* PaymentMethodPicker: same shape — hint, then gateways.map radios unless
|
|
289
|
+
`single`, then `selected.title`. Titles/descriptions are the admin's copy. */}
|
|
290
|
+
|
|
291
|
+
{/* summary: coupon field (if not in the cart) + cartTotalsLines(c.cart, { formatMoney }) */}
|
|
292
|
+
|
|
293
|
+
<button type="button" onClick={c.placeOrder} disabled={!c.canPlaceOrder || c.placing}>
|
|
294
|
+
{c.placing ? PLACING_LABEL : PLACE_ORDER_LABEL}
|
|
295
|
+
</button>
|
|
296
|
+
{c.orderError && <p role="alert">{c.orderError.message}</p>}
|
|
297
|
+
{!c.canPlaceOrder && c.blockers.map((code) => <p key={code}>{BLOCKERS[code]}</p>)}
|
|
298
|
+
</>
|
|
299
|
+
);
|
|
300
|
+
}
|
|
272
301
|
```
|
|
273
302
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
| `ShipToDifferent` | the wired toggle; children are its label | children, `controlRender` |
|
|
280
|
-
| `ShippingMethods` | every branch: the hint line (server's message preferred), a radio list while there is a real choice, the single/auto-selected option *displayed* — never a picker of one, never "nothing selected" — null for a virtual cart | `optionRender(m)` — the whole option |
|
|
281
|
-
| `PaymentMethods` | every **enabled** gateway from store info, titles/descriptions in the admin's words (a default store offers `offline` only — never hardcode a card option); one renders as the selection it is; none renders your unavailable line | `optionRender(g)` |
|
|
282
|
-
| `Items` | the read-only mini summary | `show`: `media` `attributes` `quantity` `lineTotal`; `itemRender` |
|
|
283
|
-
| `CouponField` / `Totals` | same parts as the cart's | `inputRender` / `show` |
|
|
284
|
-
| `PlaceOrder` | the button (your two labels), the server's order error, and the blocker lines — ⚑ a disabled button always says why | `show`: `blockers` `orderError` |
|
|
285
|
-
| `Blockers` | the reasons alone, for custom placement — the button's own copy stands down by itself | — |
|
|
286
|
-
|
|
287
|
-
**Multi-step checkout is your markup**: wrap parts in your step containers — a hidden step loses nothing, the gate still counts every blocker.
|
|
303
|
+
**`<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`.
|
|
304
|
+
|
|
305
|
+
It ships **no CSS**: 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 }}`. Props: `includeCompany` (default false), `includePhone` (default true), `omit={["…"]}`, `labels={{ postcode: "ZIP code" }}` (over `addressFieldSpec`'s plain-convention defaults), `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 still missing, if you want your own per-field marks.
|
|
306
|
+
|
|
307
|
+
⚑ **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.
|
|
288
308
|
|
|
289
309
|
## Order received
|
|
290
310
|
|
|
291
311
|
```jsx
|
|
292
|
-
import {
|
|
312
|
+
import { useOrderReturn, useFormatMoney, orderTotalsLines } from "@/commerce/storefront";
|
|
293
313
|
```
|
|
294
314
|
|
|
295
|
-
**Mandatory route** — every payment link returns here
|
|
315
|
+
**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.
|
|
296
316
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
<OrderReceived.Paid>
|
|
301
|
-
{({ order }) => (<>{/* your thank-you, order #{order.order_number} */}
|
|
302
|
-
<OrderReceived.Items /><OrderReceived.Totals /></>)}
|
|
303
|
-
</OrderReceived.Paid>
|
|
304
|
-
<OrderReceived.Unpaid>{/* your "not settled yet" wording */}
|
|
305
|
-
<OrderReceived.PaymentInstructions /> {/* ⚑ below */}
|
|
306
|
-
<OrderReceived.PaymentLink>{({ url }) => (/* your pay-now element on url */)}</OrderReceived.PaymentLink>
|
|
307
|
-
<OrderReceived.Items /><OrderReceived.Totals />
|
|
308
|
-
</OrderReceived.Unpaid>
|
|
309
|
-
<OrderReceived.Cancelled>{/* your wording; PaymentLink works here too */}</OrderReceived.Cancelled>
|
|
310
|
-
<OrderReceived.Error>{({ error, reload }) => (/* error.message + your retry calling reload() */)}</OrderReceived.Error>
|
|
311
|
-
</OrderReceived.Root>
|
|
312
|
-
```
|
|
317
|
+
⚑ 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.
|
|
318
|
+
|
|
319
|
+
A receipt is a convention surface: your classes, every branch present, no bespoke widgets.
|
|
313
320
|
|
|
314
|
-
|
|
321
|
+
## Driving the storefront from a browser script?
|
|
315
322
|
|
|
316
|
-
|
|
323
|
+
The cart is optimistic and debounced, so a script that acts faster than it settles sees a working store as broken. Read [`../references/storefront-verification.md`](../references/storefront-verification.md) **before** writing the script — not after it fails.
|
|
317
324
|
|
|
318
325
|
## Done — forget this file
|
|
319
326
|
|
|
320
|
-
- [ ] Catalog UI in whatever form fits the store
|
|
321
|
-
- [ ]
|
|
322
|
-
- [ ] One
|
|
323
|
-
- [ ]
|
|
324
|
-
- [ ]
|
|
325
|
-
- [ ]
|
|
326
|
-
- [ ]
|
|
327
|
+
- [ ] Catalog UI in whatever form fits the store, plus a checkout, plus `/order-received` rendering `useOrderReturn`'s states **including `paymentInstructions`**.
|
|
328
|
+
- [ ] `index.css` defines the store's design classes; pages carry class names, not repeated utility runs.
|
|
329
|
+
- [ ] **One** `<StorefrontProvider>` above every storefront route (layout-route pattern); one client, no hand-rolled `cart_token`.
|
|
330
|
+
- [ ] Every page's imports came from its section's import line; no unused names.
|
|
331
|
+
- [ ] Pages branch on `status`; gateways/currency/countries read from `useStoreInfo()`/`useCountries()` only.
|
|
332
|
+
- [ ] 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.
|
|
333
|
+
- [ ] No re-implemented hook logic (button state precedence, quantity clamps, totals math, drawer state).
|
|
334
|
+
- [ ] 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.
|
|
327
335
|
- [ ] Variant options: one control per axis, unbuyable options disabled, not hidden.
|
|
328
|
-
- [ ]
|
|
329
|
-
- [ ]
|
|
336
|
+
- [ ] The checkout's addresses render through `<AddressFields>` (both `which` values), styled in the store's classes — not a hand-rolled field list.
|
|
337
|
+
- [ ] 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`.
|
|
338
|
+
- [ ] Checkout guards `stage === "submitted"` above its empty-cart branch.
|
|
339
|
+
- [ ] 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.
|
|
330
340
|
- [ ] **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.
|
|
331
341
|
|
|
332
|
-
Then copy this file's `carry_forward` lines into your working notes, and do not re-read this file.
|
|
342
|
+
Then copy this file's `carry_forward` lines (in its front matter) into your working notes, and do not re-read this file.
|
|
@@ -51,7 +51,3 @@ DOM is briefly right about the *intent* and wrong about the *state*.
|
|
|
51
51
|
(`/order-received?order_id=…&order_key=…` — the ids come back in
|
|
52
52
|
`placeOrder`'s result, and `commerce/admin-orders` `search` has the order
|
|
53
53
|
either way).
|
|
54
|
-
- **Sweep for unfinished copy.** A part whose label is missing renders a
|
|
55
|
-
visible `⟨copy: some.path⟩` placeholder. `⟨copy:` must appear on **no**
|
|
56
|
-
page the script visits — finding one means the store's copy file is
|
|
57
|
-
incomplete, a build defect on par with a broken button.
|