@base44/app-plugin-commerce 0.2.6 → 0.3.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 +4 -4
- package/package.json +2 -2
- package/scripts/install.js +15 -0
- package/skills/commerce/SKILL.md +31 -13
- package/skills/commerce/docs/api-admin.md +1 -1
- package/skills/commerce/docs/api-storefront.md +12 -12
- package/skills/commerce/install/01-install.md +5 -2
- package/skills/commerce/install/02-storefront.md +179 -220
- package/skills/commerce/install/03-data.md +1 -1
- package/skills/commerce/references/catalog-rendering.md +37 -43
- package/skills/commerce/references/reviews.md +21 -14
- package/skills/commerce/references/store-settings.md +1 -1
- package/skills/commerce/references/storefront-verification.md +21 -15
- package/src/commerce/storefront/StorefrontProvider.jsx +65 -128
- package/src/commerce/storefront/cartUI.jsx +26 -117
- package/src/commerce/storefront/index.js +61 -98
- package/src/commerce/storefront/pickers.jsx +53 -81
- package/src/commerce/storefront/useCartLine.js +23 -130
- package/src/commerce/storefront/useCheckout.jsx +50 -43
- package/src/commerce/storefront/useOrderReturn.js +17 -7
- package/src/commerce/storefront/useProduct.js +54 -119
- package/src/commerce/storefront/useProductList.js +15 -28
- package/src/commerce/utils/address-spec.js +1 -1
- package/src/commerce/utils/images.js +1 -1
- package/src/commerce/utils/index.js +9 -9
- package/src/commerce/utils/price.js +2 -1
- package/src/commerce/utils/specs.js +41 -91
- package/src/commerce/utils/totals.js +7 -4
- package/src/commerce/storefront/useAddressForm.js +0 -175
- package/src/commerce/storefront/usePlaceOrder.js +0 -55
- package/src/commerce/storefront/useProductGallery.js +0 -78
- package/src/commerce/storefront/useProductPrice.js +0 -58
- package/src/commerce/storefront/useProductReviews.js +0 -242
- package/src/commerce/storefront/useStorefrontSeo.js +0 -204
- package/src/commerce/storefront/useTotalsLines.js +0 -109
- package/src/commerce/storefront/useUpsell.js +0 -90
|
@@ -5,24 +5,24 @@ skip_when: "The storefront pages already render against live data and pass the c
|
|
|
5
5
|
forget_when: "The checklist at the bottom passes — every page renders against the seeded catalog and an offline order completes."
|
|
6
6
|
carry_forward:
|
|
7
7
|
- "Payment gateways, currency and countries come from useStoreInfo() only — never off a cart (cart.payment_gateways is always undefined)."
|
|
8
|
-
- "A store with any coupons must have a coupon field (
|
|
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
9
|
- "/order-received is mandatory and renders useOrderReturn's states, including paymentInstructions — how a normal (offline) customer learns how to pay."
|
|
10
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
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
|
-
- "
|
|
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
|
---
|
|
15
16
|
|
|
16
17
|
# 02 — Storefront
|
|
17
18
|
|
|
18
|
-
One split decides everything here: **the logic is premade, the UI never is.** The hooks own checkout repricing, variant resolution, cart state,
|
|
19
|
+
One split decides everything here: **the logic is premade, the UI never is.** The hooks own checkout repricing, variant resolution, cart state, order-return verification — hand-writing any of it is where storefront bugs cluster, so **never re-implement what a hook does**. Every element, class, layout and **word** is yours; nothing in `@/commerce/storefront` renders markup, carries CSS, or contains a sentence a customer will read. **Decide how the store looks as if this kit did not exist**, then encode that look **once** as design classes ([below](#design-language--once-before-any-page)) and build every page in them — the snippets here are wiring reference, never design input.
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
**States and codes, not copy.** Where a state needs words you get the *state* (`buy.state`, `hint.code`, `blockers`) and write the words. So: never re-derive a state you were handed (a ternary chain over `adding`/`purchasable` re-implements `buy.state`, wrong), and never leave one unworded (a button with no text for `sold_out` renders empty).
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
- **Each hook's JSDoc is the API reference.** Open the hook's file when you need exact shapes; don't guess fields. Rules marked ⚑ must survive whatever design you build.
|
|
23
|
+
**This file is the whole job.** Every shape you need is in ["What each hook resolves to"](#what-each-hook-resolves-to) — you do not need to open the hook files while building, and doing so mid-build is the most expensive way to answer a question this page already answers. Rules marked ⚑ must survive whatever design you build.
|
|
24
24
|
|
|
25
|
-
**One import path: `@/commerce/storefront`.** Each section opens with its page's exact import line — copy it verbatim, then delete unused names.
|
|
25
|
+
**One import path: `@/commerce/storefront`.** Each section opens with its page's exact import line — copy it verbatim, then delete unused names. Everything a page needs is re-exported there, so a React page never imports `@/commerce/utils` directly. `useStoreInfo` is the name most often left out — it is the only source of store name and currency.
|
|
26
26
|
|
|
27
27
|
## Setup — once
|
|
28
28
|
|
|
@@ -56,332 +56,291 @@ import AdminApp from "@/commerce/admin";
|
|
|
56
56
|
|
|
57
57
|
⚑ **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`.
|
|
58
58
|
|
|
59
|
+
## Design language — once, before any page
|
|
60
|
+
|
|
61
|
+
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.**
|
|
62
|
+
|
|
63
|
+
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 — rather than inventing a phrase per call site. It is the half of the store's identity a kit cannot ship: "Add to bag" in every store built from this plugin is exactly how stores end up reading like each other.
|
|
64
|
+
|
|
65
|
+
**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 signature budget concentrates where the products are: **the product page stays the storefront's richest surface**, and its rules to render axes and specs *by what they are* hold at full force — that richness is semantic (what the controls and rows show), which costs words, not chrome. One navigation affordance per control (thumbnails *or* arrows, never both plus dots); checkout, bag and order-received are convention surfaces. Keep components small (~2–4K chars) — faster to emit, review and fix than one long page file.
|
|
66
|
+
|
|
67
|
+
## What each hook resolves to
|
|
68
|
+
|
|
69
|
+
Everything below is already unwrapped — no `.data`, no envelope. `formatMoney` throughout is `useFormatMoney()`.
|
|
70
|
+
|
|
71
|
+
| Call | Resolves to |
|
|
72
|
+
|---|---|
|
|
73
|
+
| `useStoreInfo()` | `{ info, settings, paymentGateways, countries, currencies, loading, error }` — `settings.store_name`, `settings.currency`. The ONLY source of gateways/currency/countries. |
|
|
74
|
+
| `useCountries()` | `{ countries, options, loading, error }` — `options` is `[{value, label}]` and **never null** (mapping `useStoreInfo().countries` raw white-screens a cold load). |
|
|
75
|
+
| `useFormatMoney()` | `(amount) => "€19.99"` in the store's currency. |
|
|
76
|
+
| `useProductList(params)` | `{ status, products, hasNext, next, loadMore, busy, refreshing, isEmpty, setParams, reload }` — `status`: `"loading" \| "ready" \| "empty" \| "error"`. |
|
|
77
|
+
| `useCategories()` / `useRibbons()` | `{ items, loading, error }` — `items` always an array. |
|
|
78
|
+
| `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 }`. |
|
|
79
|
+
| `useAddToCart(p)` | `{ state, disabled, addToCart, adding, error, soldOut, needsSelection, quantity, increase, decrease, canIncrease, canDecrease, showQuantity, reset }` — `state`: `"ready" \| "adding" \| "sold_out" \| "needs_selection"`. |
|
|
80
|
+
| `variantAxes(view, pick)` | `[{ key, name, selectedOption, options: [{ value, selected, disabled, outOfStock, pick }] }]`. |
|
|
81
|
+
| `productPrice(rowOrView, { formatMoney })` | `{ label, compareAtLabel, onSale, isFrom, isRange, min, max }` — `label` is what to render. |
|
|
82
|
+
| `productImages(product)` | `[{ src, name, alt }]`, de-duplicated. `[]` is legitimate → render your placeholder. |
|
|
83
|
+
| `productSpecs(product)` | `[{ key, label, titleLabel, value }]` from `meta_data`; `findSpec(rows, key)` looks one up ignoring case/spaces/`_`/`-`. Never match on `label` — meta keys are free text. |
|
|
84
|
+
| `useCart()` | `{ status, cart, itemCount, isEmpty, loading, error, mutationError, refresh, addItem, updateItem, removeItem, applyCoupon, removeCoupon }` — `status`: `"loading" \| "ready" \| "empty"`. |
|
|
85
|
+
| `cart.items[n]` | `{ item_key, product_id, variation_id, name, quantity, price, subtotal, total, image, attributes, sold_individually, purchasable }` — `attributes` is an **array** of `{name, option}`; `purchasable` is a **result object** `{ok, code, error}`, not a boolean. |
|
|
86
|
+
| `attributesLabel(item.attributes)` | `"Size: 42 · Color: Ivory"` (`""` when the product has no attributes). |
|
|
87
|
+
| `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. |
|
|
88
|
+
| `useCartLine(item)` | `{ quantity, setQuantity, increase, decrease, remove, pending, error, canIncrease, canDecrease, maxQuantity, atMax, atMin }`. |
|
|
89
|
+
| `useCartUI()` | `{ open, openCart, closeCart, toggleCart }`. |
|
|
90
|
+
| `useCheckoutContext()` | the address (`billing`, `updateBilling`, `shipping`, `updateShipping`, `shipToDifferent`, `setShipToDifferent`, `missingBillingFields`, `addressError`), the shipping state (`shippingStatus`, `shippingMethods`, `chosenShippingMethod`, `chooseShippingMethod`, `shippingSyncing`), the payment state (`paymentMethods`, `paymentMethod`, `setPaymentMethod`, `selectedGateway`, `singlePaymentMethod`) and the gate (`blockers`, `canPlaceOrder`, `placing`, `stage`, `orderError`, `placeOrder`) — plus `cart`. The pickers below read the shipping/payment parts for you. |
|
|
91
|
+
| `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`. |
|
|
92
|
+
|
|
59
93
|
## Product list / collection
|
|
60
94
|
|
|
61
95
|
```jsx
|
|
62
|
-
import { useProductList, useCategories,
|
|
96
|
+
import { useProductList, useCategories, useStoreInfo, useFormatMoney, productPrice, productImages } from "@/commerce/storefront";
|
|
63
97
|
```
|
|
64
98
|
|
|
65
|
-
(Drop `useCategories` with no filter bar
|
|
99
|
+
(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`.
|
|
66
100
|
|
|
67
|
-
|
|
68
|
-
const list = useProductList({ per_page: 24 });
|
|
69
|
-
const { settings } = useStoreInfo();
|
|
70
|
-
useStorefrontSeo(collectionSeo({ title: "…", products: list.products, storeName: settings?.store_name }));
|
|
71
|
-
|
|
72
|
-
if (list.status === "loading") return /* your loading state */;
|
|
73
|
-
if (list.status === "error") return /* your failure state, with a retry calling list.reload() */;
|
|
74
|
-
const products = list.products; // always an array — never null
|
|
75
|
-
```
|
|
101
|
+
⚑ **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.
|
|
76
102
|
|
|
77
|
-
|
|
103
|
+
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` on its own is a rolled-up from-price), `on_sale`, `short_description`, `stock_status`, `average_rating`/`rating_count`, `ribbons`, `productSpecs(row)`. ⚑ **Images are objects and the array may be empty** — render your placeholder, never a broken `<img>`. Full field matrix: [`../references/catalog-rendering.md`](../references/catalog-rendering.md). That list is an inventory, not a card design: lead with the one or two fields *these* products are judged on rather than the default name/price/stars trio.
|
|
78
104
|
|
|
79
|
-
|
|
105
|
+
⚑ **Ribbons belong in both views** — grid and product page. They are the merchant's own merchandising ("Limited", "Last pieces"), the most-skipped part of catalog rendering, and each links to its filtered listing (`/collection?ribbon_id=<id>`). Never render a bare "Ribbons:" label with nothing after it.
|
|
80
106
|
|
|
81
|
-
**Rails** (featured row, "new in") are the same hook with a filter (`{ featured: true, per_page: 4 }`). ⚑ Any filter may legitimately match nothing — render *nothing* then, never a heading over an empty row.
|
|
107
|
+
**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.
|
|
82
108
|
|
|
83
109
|
## Product page
|
|
84
110
|
|
|
85
111
|
```jsx
|
|
86
|
-
import { useProduct,
|
|
112
|
+
import { useProduct, useAddToCart, useStoreInfo, useFormatMoney, useStorefront, variantAxes, productImages, imageIndex, productSpecs, findSpec, storefrontErrorCode } from "@/commerce/storefront";
|
|
87
113
|
```
|
|
88
114
|
|
|
89
|
-
(
|
|
90
|
-
|
|
91
|
-
`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 the moment the product resolves.
|
|
115
|
+
`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.
|
|
92
116
|
|
|
93
117
|
```jsx
|
|
94
118
|
const p = useProduct(slug);
|
|
95
|
-
const
|
|
96
|
-
const buy = useAddToCartButton(p, { labels: { ready: "Add to bag" } });
|
|
97
|
-
const specs = useProductSpecs(p.product, { pick: ["material", "care"] });
|
|
119
|
+
const buy = useAddToCart(p);
|
|
98
120
|
const { settings } = useStoreInfo(); // the ONLY source of store name + currency
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (p.status === "
|
|
102
|
-
if (p.status === "not_found") return /* your 404 */;
|
|
121
|
+
const [picked, setPicked] = useState(null); // gallery: which thumbnail was clicked
|
|
122
|
+
if (p.status === "loading") return /* your loading screen */;
|
|
123
|
+
if (p.status === "not_found") return /* your 404, linking back to the catalog */;
|
|
103
124
|
const { product, view, price, categories } = p;
|
|
125
|
+
const images = productImages(product);
|
|
104
126
|
```
|
|
105
127
|
|
|
106
|
-
Build your layout from — all optional,
|
|
128
|
+
Build your layout from — all optional, **not one component style**:
|
|
129
|
+
|
|
130
|
+
- **Price** — `price.label`, plus `price.compareAtLabel` (struck through) on sale. Never read `product.price` directly: the parent's is a rolled-up from-price, and an incomplete selection has a *range* — `price.label` already renders both correctly.
|
|
131
|
+
- **Gallery** — `productImages(product)` is the thumbnail strip; the main frame comes from the **selection**, and the two are not the same list:
|
|
132
|
+
|
|
133
|
+
```jsx
|
|
134
|
+
const active = picked != null ? images[picked] : (view?.display?.image ?? images[0] ?? null);
|
|
135
|
+
const highlight = picked ?? imageIndex(images, view?.display?.image); // -1 = nothing to mark
|
|
136
|
+
useEffect(() => setPicked(null), [view?.variation?.id]); // a new variant re-takes the lead
|
|
137
|
+
```
|
|
107
138
|
|
|
108
|
-
|
|
109
|
-
- **
|
|
110
|
-
- **Variant selector** — `variantAxes(view, p.pick)` → one entry per axis: `{ key, name, selectedOption, options: [{ value, selected, disabled, outOfStock, pick }] }`. ⚑ **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. The shape of the map (the control itself is yours — swatches for a colour axis, chips with a size guide for a size axis; every axis as the identical chip row is a generated-page tell):
|
|
139
|
+
⚑ **Render the main frame from `view.display.image`, not from an index into the strip.** A variation's own image lives on the variation and is usually *not* in `product.images`, so indexing the strip shows the wrong colour the moment a variant is picked. `display.image` is variation-first with a parent fallback and is exactly the same shape as a strip entry (`{src, name, alt}`), which is why `imageIndex` can locate it when it *is* there and return `-1` when it isn't. `active === null` only when the product has no images at all — the one placeholder case.
|
|
140
|
+
- **Variant selector** — `variantAxes(view, p.pick)`, one entry per axis:
|
|
111
141
|
|
|
112
142
|
```jsx
|
|
113
143
|
{variantAxes(view, p.pick).map((axis) => (
|
|
114
144
|
<fieldset key={axis.key}>{/* label from axis.name / axis.selectedOption */}
|
|
115
145
|
{axis.options.map((o) => (
|
|
116
146
|
<button key={o.value} disabled={o.disabled} aria-pressed={o.selected} onClick={o.pick}>
|
|
117
|
-
{o.value}{/* o.outOfStock → mark visibly
|
|
147
|
+
{o.value}{/* o.outOfStock → mark visibly */}
|
|
118
148
|
</button>
|
|
119
149
|
))}
|
|
120
150
|
</fieldset>
|
|
121
151
|
))}
|
|
122
152
|
```
|
|
123
153
|
|
|
124
|
-
|
|
125
|
-
- **
|
|
126
|
-
- **Specs** — `useProductSpecs(product, { pick: [...] })`: `picked` are the rows to feature (matched case/`_`-insensitively — ⚑ **never match rows by `label` equality**, it silently misses), `rest` is the remainder, safe to render as plain rows. Each row carries `titleLabel` (display-cased) and a `type` with `number`/`unit`/`items` split out, so a weight can be a figure and a composition bars. ⚑ **Don't `.map()` everything into one uniform label/value table** — branch on `type` (or `key`) for the two or three specs that carry *this* product's meaning; `[]` means no section at all.
|
|
127
|
-
- **Breadcrumbs** — build from `categories` (`/collection?category_id=${c.id}`); skip on a flat catalog. Ribbons are labels, not breadcrumbs.
|
|
154
|
+
⚑ **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.
|
|
155
|
+
- **Buy box** — one button, and **you supply its four words**:
|
|
128
156
|
|
|
129
|
-
|
|
157
|
+
```jsx
|
|
158
|
+
const BUY = { ready: "…", adding: "…", sold_out: "…", needs_selection: "…" }; // this store's voice
|
|
159
|
+
<button type="button" onClick={buy.addToCart} disabled={buy.disabled}>{BUY[buy.state]}</button>
|
|
160
|
+
{buy.error?.message && <p role="alert">{buy.error.message}</p>}
|
|
161
|
+
```
|
|
130
162
|
|
|
131
|
-
|
|
163
|
+
⚑ **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.
|
|
164
|
+
- **Description** — `product.description` is HTML; render as rich text, `short_description` above it.
|
|
165
|
+
- **Specs** — `productSpecs(product)` rows from the admin's *Modifiers*. ⚑ **Look a particular spec up with `findSpec(rows, "care")`**, which ignores case, spaces, `_` and `-`. Meta keys are free text typed by whoever set the product up (`care`, `Care`, `Care Instructions`), so `rows.find(s => s.label === "Care")` silently never matches and the feature renders its fallback forever. **This is the product page's signature-moment candidate**: pick the two or three keys that carry *this* catalog's meaning and render each as what it is (a weight as a figure, a composition as bars, a provenance beside its place), then let the rest fall through to plain rows in your classes. Not everything into one uniform grey table; not a bespoke widget per row. `[]` means no section at all.
|
|
166
|
+
- **Breadcrumbs** — from `categories` (`/collection?category_id=${c.id}`); skip on a flat catalog. Ribbons are labels, not breadcrumbs.
|
|
167
|
+
- **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).
|
|
168
|
+
- **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).
|
|
132
169
|
|
|
133
170
|
## Cart / bag
|
|
134
171
|
|
|
135
172
|
```jsx
|
|
136
|
-
import { useCart, CartLine,
|
|
173
|
+
import { useCart, useCartLine, CartLine, useCartUI, useFormatMoney, attributesLabel, cartTotalsLines } from "@/commerce/storefront";
|
|
137
174
|
```
|
|
138
175
|
|
|
139
|
-
A cart *page* is optional (buy-now straight to checkout reads better for a single-piece store
|
|
176
|
+
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).
|
|
140
177
|
|
|
141
|
-
⚑ Rules: branch on `status`, never on emptiness while loading. Render `
|
|
178
|
+
⚑ 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): `applyCoupon(code)` resolves `{ok: false, message}` for a bad code rather than throwing, so render that message inline. Coupons are admin-only data, redeemable only through a field the customer types into; if none exists, don't seed coupons and don't write "use WELCOME10" in the copy.
|
|
142
179
|
|
|
143
180
|
⚑ **`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.
|
|
144
181
|
|
|
145
|
-
**Reference wiring** —
|
|
182
|
+
**Reference wiring** for one row — the rest of the page (notices, coupon field, totals, checkout link) is ordinary markup around it:
|
|
146
183
|
|
|
147
184
|
```jsx
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
)}
|
|
172
|
-
</CartLine>
|
|
173
|
-
))}
|
|
174
|
-
<CouponField /> {/* useCoupon: code/setCode, apply, applying, error, applied[] + remove */}
|
|
175
|
-
{totals.filter((t) => !t.hidden).map((t) => (
|
|
176
|
-
<div key={t.key}>{t.label} {t.formatted}</div> /* t.emphasis → the total row */
|
|
177
|
-
))}
|
|
178
|
-
<Link to="/checkout">Checkout</Link>
|
|
179
|
-
</>
|
|
180
|
-
);
|
|
181
|
-
}
|
|
185
|
+
const { status, cart } = useCart();
|
|
186
|
+
const formatMoney = useFormatMoney();
|
|
187
|
+
// guards on status first, then:
|
|
188
|
+
{cart.items.map((item) => (
|
|
189
|
+
<CartLine key={item.item_key} line={item}>
|
|
190
|
+
{(l) => (
|
|
191
|
+
<li aria-busy={l.pending}> {/* busy scope is THIS row, never the cart */}
|
|
192
|
+
{item.name} {attributesLabel(item.attributes)} {formatMoney(item.total)}
|
|
193
|
+
<button onClick={l.decrease} disabled={!l.canDecrease || l.pending}
|
|
194
|
+
aria-label={`Decrease quantity of ${item.name}`}>−</button>
|
|
195
|
+
{l.quantity}
|
|
196
|
+
<button onClick={l.increase} disabled={!l.canIncrease || l.pending}
|
|
197
|
+
aria-label={`Increase quantity of ${item.name}`}>+</button>
|
|
198
|
+
<button onClick={l.remove} disabled={l.pending}
|
|
199
|
+
aria-label={`Remove ${item.name}`}>Remove</button>
|
|
200
|
+
{l.error && <p role="alert">{l.error.message}</p>}
|
|
201
|
+
</li>
|
|
202
|
+
)}
|
|
203
|
+
</CartLine>
|
|
204
|
+
))}
|
|
205
|
+
{cartTotalsLines(cart, { formatMoney }).filter((t) => !t.hidden).map((t) => (
|
|
206
|
+
<div key={t.key}>{t.label} {t.formatted}</div> /* t.emphasis → the total row */
|
|
207
|
+
))}
|
|
182
208
|
```
|
|
183
209
|
|
|
184
|
-
No shipping estimator here — checkout reprices shipping and tax from the address. An upsell beside the lines
|
|
210
|
+
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 rename must not break the match). ⚑ **A one-click Add only works on a product with no attributes** — one that sells variants needs a `variation_id`, so `addItem({ product_id })` answers `400 variation_required`; link those tiles to the product page instead of adding from the rail.
|
|
185
211
|
|
|
186
212
|
### If the cart is a drawer
|
|
187
213
|
|
|
188
|
-
Same hooks, same rows. The drawer's *
|
|
214
|
+
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).
|
|
189
215
|
|
|
190
|
-
|
|
191
|
-
function StoreLayout() { // inside <CartUIProvider> (see Setup)
|
|
192
|
-
const ui = useCartUI();
|
|
193
|
-
const { itemCount } = useCart();
|
|
194
|
-
return (<>
|
|
195
|
-
<header>… <button {...ui.triggerProps} className="…">Bag ({itemCount})</button></header>
|
|
196
|
-
<Outlet />
|
|
197
|
-
<div {...ui.overlayProps} className={ui.open ? "…" : "hidden"} />
|
|
198
|
-
<aside {...ui.panelProps} className={ui.open ? "… translate-x-0" : "… translate-x-full"}>
|
|
199
|
-
<button {...ui.closeButtonProps} className="…">×</button>
|
|
200
|
-
{/* your rows: useCart + CartLine, as above */}
|
|
201
|
-
</aside>
|
|
202
|
-
</>);
|
|
203
|
-
}
|
|
204
|
-
```
|
|
205
|
-
|
|
206
|
-
⚑ Keep the panel mounted and animate with classes — `panelProps` makes it inert while closed, which is what an off-screen drawer needs and `aria-hidden` alone does not provide. The overlay is not a second close control; the named close button is `closeButtonProps`.
|
|
216
|
+
⚑ **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, you own that concern — set the `inert` attribute while closed. The overlay is a click-away surface, not the close control.
|
|
207
217
|
|
|
208
218
|
## Checkout
|
|
209
219
|
|
|
210
220
|
```jsx
|
|
211
|
-
import { CheckoutProvider, useCheckoutContext,
|
|
221
|
+
import { CheckoutProvider, useCheckoutContext, ShippingMethodPicker, PaymentMethodPicker, useCart, useCountries, useFormatMoney, cartTotalsLines, addressFieldSpec, REQUIRED_BILLING_FIELDS } from "@/commerce/storefront";
|
|
212
222
|
```
|
|
213
223
|
|
|
214
|
-
|
|
224
|
+
`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.
|
|
215
225
|
|
|
216
|
-
|
|
226
|
+
⚑ 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.
|
|
217
227
|
|
|
218
|
-
⚑
|
|
228
|
+
⚑ **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).
|
|
219
229
|
|
|
220
|
-
|
|
230
|
+
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.
|
|
221
231
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
return <CheckoutProvider><CheckoutForm /></CheckoutProvider>;
|
|
225
|
-
}
|
|
232
|
+
**Reference wiring** — the densest part of the storefront. `Checkout()` is just
|
|
233
|
+
`<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)):
|
|
226
234
|
|
|
235
|
+
```jsx
|
|
227
236
|
function CheckoutForm() {
|
|
228
237
|
const { status } = useCart();
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
if (
|
|
232
|
-
if (status === "
|
|
233
|
-
if (status === "empty") return /* "your bag is empty" */;
|
|
238
|
+
const c = useCheckoutContext();
|
|
239
|
+
if (c.stage === "submitted") return /* "taking you to your receipt" screen */;
|
|
240
|
+
if (status === "loading") return /* your loading screen */;
|
|
241
|
+
if (status === "empty") return /* your empty-bag screen */;
|
|
234
242
|
return (
|
|
235
243
|
<>
|
|
236
244
|
<AddressFields which="billing" />
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
onChange={(e) => checkout.setShipToDifferent(e.target.checked)} />
|
|
240
|
-
Deliver to a different address
|
|
241
|
-
</label>
|
|
242
|
-
{checkout.shipToDifferent && <AddressFields which="shipping" />}
|
|
245
|
+
{/* a checkbox on c.shipToDifferent / c.setShipToDifferent, your wording */}
|
|
246
|
+
{c.shipToDifferent && <AddressFields which="shipping" />}
|
|
243
247
|
|
|
244
248
|
<ShippingMethodPicker>
|
|
245
249
|
{({ hint, mustChoose, methods, chosen }) => (
|
|
246
250
|
<fieldset>{/* renders null for a virtual cart */}
|
|
247
|
-
{hint && <p role={hint.severity === "error" ? "alert" : "status"}>
|
|
251
|
+
{hint && <p role={hint.severity === "error" ? "alert" : "status"}>
|
|
252
|
+
{hint.serverMessage ?? SHIPPING_HINTS[hint.code]}</p>}
|
|
248
253
|
{mustChoose && methods.map((m) => (
|
|
249
|
-
<label key={m.id}
|
|
254
|
+
<label key={m.id}>
|
|
255
|
+
<input type="radio" name="shipping-method" checked={m.selected} onChange={m.select} />
|
|
256
|
+
{m.title} {m.costLabel}
|
|
257
|
+
</label>
|
|
250
258
|
))}
|
|
251
259
|
{!mustChoose && chosen && <p>{chosen.title} {chosen.costLabel}</p>}
|
|
252
260
|
</fieldset>
|
|
253
261
|
)}
|
|
254
262
|
</ShippingMethodPicker>
|
|
255
263
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
<fieldset>
|
|
259
|
-
{hint && <p role="alert">{hint.message}</p>}
|
|
260
|
-
{!single && gateways.map((g) => (
|
|
261
|
-
<label key={g.slug} {...g.labelProps}><input {...g.radioProps} /> {g.title} {g.description}</label>
|
|
262
|
-
))}
|
|
263
|
-
{single && selected && <p>{selected.title}</p>}
|
|
264
|
-
</fieldset>
|
|
265
|
-
)}
|
|
266
|
-
</PaymentMethodPicker>
|
|
264
|
+
{/* PaymentMethodPicker: same shape — hint, then gateways.map radios unless
|
|
265
|
+
`single`, then `selected.title`. Titles/descriptions are the admin's copy. */}
|
|
267
266
|
|
|
268
|
-
{/* summary: coupon field (if not in the cart) +
|
|
267
|
+
{/* summary: coupon field (if not in the cart) + cartTotalsLines(c.cart, { formatMoney }) */}
|
|
269
268
|
|
|
270
|
-
<button {
|
|
271
|
-
|
|
272
|
-
|
|
269
|
+
<button type="button" onClick={c.placeOrder} disabled={!c.canPlaceOrder || c.placing}>
|
|
270
|
+
{c.placing ? PLACING_LABEL : PLACE_ORDER_LABEL}
|
|
271
|
+
</button>
|
|
272
|
+
{c.orderError && <p role="alert">{c.orderError.message}</p>}
|
|
273
|
+
{!c.canPlaceOrder && c.blockers.map((code) => <p key={code}>{BLOCKERS[code]}</p>)}
|
|
273
274
|
</>
|
|
274
275
|
);
|
|
275
276
|
}
|
|
276
277
|
|
|
277
278
|
function AddressFields({ which }) {
|
|
278
|
-
const
|
|
279
|
+
const c = useCheckoutContext();
|
|
280
|
+
const { countries } = useCountries(); // [] until store info lands — never null
|
|
281
|
+
const isBilling = which === "billing";
|
|
282
|
+
const values = isBilling ? c.billing : c.shipping;
|
|
283
|
+
const set = isBilling ? c.updateBilling : c.updateShipping;
|
|
284
|
+
const fields = addressFieldSpec({
|
|
285
|
+
countries,
|
|
286
|
+
country: values.country,
|
|
287
|
+
required: isBilling ? REQUIRED_BILLING_FIELDS : ["country", "city"],
|
|
288
|
+
includeEmail: isBilling, // one email per order, on billing
|
|
289
|
+
});
|
|
279
290
|
return fields.map((f) => (
|
|
280
291
|
<div key={f.key}>
|
|
281
|
-
<label {
|
|
282
|
-
{f.
|
|
283
|
-
<select {
|
|
284
|
-
|
|
292
|
+
<label htmlFor={`${which}-${f.key}`}>{f.label}{f.required && " *"}</label>
|
|
293
|
+
{f.type === "select" ? (
|
|
294
|
+
<select id={`${which}-${f.key}`} value={values[f.key] ?? ""} autoComplete={f.autoComplete}
|
|
295
|
+
onChange={(e) => set({ [f.key]: e.target.value })}>
|
|
296
|
+
<option value="">{/* your placeholder */}</option>
|
|
285
297
|
{f.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
|
286
298
|
</select>
|
|
287
|
-
) :
|
|
288
|
-
|
|
299
|
+
) : (
|
|
300
|
+
<input id={`${which}-${f.key}`} type={f.type} value={values[f.key] ?? ""}
|
|
301
|
+
autoComplete={f.autoComplete} onChange={(e) => set({ [f.key]: e.target.value })} />
|
|
302
|
+
)}
|
|
303
|
+
{/* "we don't ship there" is an address-level error — it belongs on country */}
|
|
304
|
+
{f.key === "country" && c.addressError && <span role="alert">{c.addressError.message}</span>}
|
|
289
305
|
</div>
|
|
290
306
|
));
|
|
291
307
|
}
|
|
292
308
|
```
|
|
293
309
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
## Order received
|
|
297
|
-
|
|
298
|
-
```jsx
|
|
299
|
-
import { useOrderReturn, useTotalsLines } from "@/commerce/storefront";
|
|
300
|
-
```
|
|
301
|
-
|
|
302
|
-
**Mandatory route** — every payment link returns here, and confirming is what marks a card order paid. `useOrderReturn()` is the whole page's logic: it reads `order_id`/`order_key` from the URL, verifies with the provider (idempotent), and marks the page noindex itself.
|
|
310
|
+
Passing `country` is what makes the **state/province field appear, with the right options**, for the US, Canada and Australia. ⚑ **Never omit that field**: shipping rates and taxes match on country *plus* state, so a form without it mis-prices those orders with no error anywhere. ⚑ Keep each field's `autoComplete` token — it is what makes browser autofill work. Labels here are plain conventions; rename or restyle freely. `c.missingBillingFields` is the live list of what is still missing, if you want per-field marks — arm them on first edit rather than on load, so an untouched form doesn't open covered in "required" marks.
|
|
303
311
|
|
|
304
|
-
⚑
|
|
312
|
+
⚑ **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.
|
|
305
313
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
```jsx
|
|
309
|
-
function OrderReceived() {
|
|
310
|
-
const { status, order, lines, paymentLink, paymentInstructions, error, reload } = useOrderReturn();
|
|
311
|
-
const totals = useTotalsLines(order);
|
|
312
|
-
if (status === "loading") return /* confirming copy */;
|
|
313
|
-
if (status === "error") return <><p role="alert">{error.message}</p>
|
|
314
|
-
<button onClick={() => reload()}>Try again</button></>;
|
|
315
|
-
return (
|
|
316
|
-
<>
|
|
317
|
-
{status === "paid" && /* thank-you heading */}
|
|
318
|
-
{status === "unpaid" && <>{/* awaiting-payment heading */}
|
|
319
|
-
{paymentLink?.url && <a href={paymentLink.url}>Pay now</a>}</>}
|
|
320
|
-
{status === "cancelled" && <>{/* payment-cancelled heading */}
|
|
321
|
-
{paymentLink?.url && <a href={paymentLink.url}>Try payment again</a>}</>}
|
|
322
|
-
{order?.order_number && <p>Order {order.order_number}</p>}
|
|
323
|
-
{paymentInstructions && (
|
|
324
|
-
<section>{/* "How to pay" — the offline customer's next step */}
|
|
325
|
-
{paymentInstructions.description && <p>{paymentInstructions.description}</p>}
|
|
326
|
-
{paymentInstructions.account_details && Object.entries(paymentInstructions.account_details)
|
|
327
|
-
.map(([k, v]) => <p key={k}>{k.replace(/_/g, " ")}: {String(v)}</p>)}
|
|
328
|
-
</section>
|
|
329
|
-
)}
|
|
330
|
-
{lines.map((l, i) => <p key={i}>{l.name} {l.attributesLabel} × {l.quantity} — {l.totalLabel}</p>)}
|
|
331
|
-
{totals.filter((t) => !t.hidden).map((t) => <p key={t.key}>{t.label} {t.formatted}</p>)}
|
|
332
|
-
</>
|
|
333
|
-
);
|
|
334
|
-
}
|
|
335
|
-
```
|
|
336
|
-
|
|
337
|
-
## SEO — one line per page type
|
|
314
|
+
## Order received
|
|
338
315
|
|
|
339
316
|
```jsx
|
|
340
|
-
|
|
341
|
-
useStorefrontSeo(collectionSeo({ title, products: list.products })); // collection / home
|
|
342
|
-
// order-received is already noindex via useOrderReturn
|
|
317
|
+
import { useOrderReturn, useFormatMoney, orderTotalsLines } from "@/commerce/storefront";
|
|
343
318
|
```
|
|
344
319
|
|
|
345
|
-
|
|
320
|
+
**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.
|
|
346
321
|
|
|
347
|
-
|
|
322
|
+
⚑ 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.
|
|
348
323
|
|
|
349
|
-
|
|
350
|
-
|---|---|
|
|
351
|
-
| Checkout | ≤ 3.5K |
|
|
352
|
-
| Cart / bag | ≤ 3K (a drawer is its own component with its own 3K) |
|
|
353
|
-
| Order-received | ≤ 2.5K |
|
|
354
|
-
| Product page | ≤ 5K |
|
|
355
|
-
| Collection | ≤ 3K |
|
|
356
|
-
| Home | ≤ 5K — pure identity; hero/editorial earn their chars |
|
|
357
|
-
| Any single component file | ≤ 4K, hard ceiling 8K |
|
|
358
|
-
|
|
359
|
-
These budgets assume the hooks carry the logic and your markup carries only the design. Over budget ⇒ you are re-implementing something a hook or a prop set already does (an address field's attributes, a quantity clamp, totals math, drawer state) — go back to the hook and delete your version. Design detail is not what pushes a page over.
|
|
324
|
+
A receipt is a convention surface: your classes, every branch present, no bespoke widgets.
|
|
360
325
|
|
|
361
326
|
## Driving the storefront from a browser script?
|
|
362
327
|
|
|
363
|
-
The
|
|
328
|
+
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.
|
|
364
329
|
|
|
365
330
|
## Done — forget this file
|
|
366
331
|
|
|
367
332
|
- [ ] Catalog UI in whatever form fits the store, plus a checkout, plus `/order-received` rendering `useOrderReturn`'s states **including `paymentInstructions`**.
|
|
333
|
+
- [ ] `index.css` defines the store's design classes; pages carry class names, not repeated utility runs.
|
|
368
334
|
- [ ] **One** `<StorefrontProvider>` above every storefront route (layout-route pattern); one client, no hand-rolled `cart_token`.
|
|
369
|
-
- [ ] Every page's imports came from its section's import line;
|
|
370
|
-
- [ ] Pages branch on `status`; gateways/currency/countries read from `useStoreInfo()` only.
|
|
371
|
-
- [ ]
|
|
372
|
-
- [ ]
|
|
335
|
+
- [ ] Every page's imports came from its section's import line; no unused names.
|
|
336
|
+
- [ ] Pages branch on `status`; gateways/currency/countries read from `useStoreInfo()`/`useCountries()` only.
|
|
337
|
+
- [ ] 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.
|
|
338
|
+
- [ ] No re-implemented hook logic (button state precedence, quantity clamps, totals math, drawer state).
|
|
339
|
+
- [ ] Coupon field present if the store has coupons; a paging control rendered whenever `hasNext` is true; ribbons rendered in both the grid and the product page.
|
|
373
340
|
- [ ] Variant options: one control per axis, unbuyable options disabled, not hidden.
|
|
374
|
-
- [ ]
|
|
341
|
+
- [ ] Address form includes the state/province field and every `autoComplete` token.
|
|
342
|
+
- [ ] 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`.
|
|
375
343
|
- [ ] Checkout guards `stage === "submitted"` above its empty-cart branch.
|
|
376
|
-
- [ ] The storefront carries the design you settled on before reading this file —
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
Record these lines in your working notes; do not re-read this file.
|
|
380
|
-
|
|
381
|
-
- Payment gateways, currency and countries come from `useStoreInfo()` only — never off a cart (`cart.payment_gateways` is always undefined).
|
|
382
|
-
- A store with any coupons must have a coupon field (`useCoupon`) in the cart or the checkout, or its codes can never be redeemed.
|
|
383
|
-
- `/order-received` is mandatory and renders `useOrderReturn`'s states, including `paymentInstructions` — how a normal (offline) customer learns how to pay.
|
|
384
|
-
- Branch cart/list/product UI on `status`, never on `isEmpty`/nullable data — `isEmpty` is false while loading by design.
|
|
385
|
-
- Variant options that aren't buyable render disabled, never hidden; one control per axis, never a list of variations.
|
|
386
|
-
- 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".
|
|
387
|
-
- Spread the hook's prop set (`inputProps`, `radioProps`, `buttonProps`, `panelProps`, `moreProps`) and add `className` — never assemble those attributes by hand.
|
|
344
|
+
- [ ] The storefront carries the design you settled on before reading this file — design classes plus one or two signature moments per page; on the product page, axes and specs are rendered by what they are (not one uniform table, not one identical chip row); convention surfaces (checkout, bag, order-received) carry the classes and nothing bespoke.
|
|
345
|
+
|
|
346
|
+
Then copy this file's `carry_forward` lines (in its front matter) into your working notes, and do not re-read this file.
|