@base44/app-plugin-commerce 0.2.4 → 0.2.6
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/package.json +1 -1
- package/skills/commerce/SKILL.md +64 -76
- package/skills/commerce/docs/api-admin.md +11 -50
- package/skills/commerce/docs/api-storefront.md +25 -118
- package/skills/commerce/install/01-install.md +20 -49
- package/skills/commerce/install/02-storefront.md +185 -319
- package/skills/commerce/install/03-data.md +43 -106
- package/skills/commerce/references/admin-product-form.md +26 -0
- package/skills/commerce/references/catalog-rendering.md +8 -8
- package/skills/commerce/references/online-payments.md +4 -15
- package/skills/commerce/references/operations.md +19 -1
- package/skills/commerce/references/storefront-verification.md +47 -0
- package/src/commerce/storefront/StorefrontProvider.jsx +45 -8
- package/src/commerce/storefront/cartUI.jsx +190 -0
- package/src/commerce/storefront/index.js +50 -20
- package/src/commerce/storefront/pickers.jsx +98 -23
- package/src/commerce/storefront/useAddressForm.js +71 -25
- package/src/commerce/storefront/useCartLine.js +40 -4
- package/src/commerce/storefront/useCheckout.jsx +13 -0
- package/src/commerce/storefront/useOrderReturn.js +7 -5
- package/src/commerce/storefront/usePlaceOrder.js +55 -0
- package/src/commerce/storefront/useProduct.js +93 -13
- package/src/commerce/storefront/useProductList.js +12 -0
- package/src/commerce/storefront/useUpsell.js +90 -0
- package/src/commerce/utils/index.js +3 -2
- package/src/commerce/utils/specs.js +104 -17
- package/src/commerce/utils/totals.js +23 -12
|
@@ -10,165 +10,104 @@ carry_forward:
|
|
|
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
|
+
- "Spread the hook's prop set (inputProps, radioProps, buttonProps, panelProps, moreProps) and add className — never assemble those attributes by hand."
|
|
13
14
|
---
|
|
14
15
|
|
|
15
16
|
# 02 — Storefront
|
|
16
17
|
|
|
17
|
-
One split decides everything here: **the logic is premade, the UI never is.**
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
every page. Nothing in `@/commerce/storefront` renders markup or carries CSS,
|
|
26
|
-
so there is no component to drop in and nothing to restyle — the design is
|
|
27
|
-
the part of the storefront only you can do, and it should be designed, not
|
|
28
|
-
assembled.
|
|
29
|
-
|
|
30
|
-
## Before you begin
|
|
31
|
-
|
|
32
|
-
**Decide how the store looks as if this kit did not exist** — identity, type,
|
|
33
|
-
palette, spacing, the shape of a card, how a checkout is laid out — from the
|
|
34
|
-
brief and your own judgement. Then use this file for **how to wire it**:
|
|
35
|
-
everything below is implementation reference and **none of it is design input**.
|
|
36
|
-
The bare tags, flat structure and placeholder copy show where the data goes in
|
|
37
|
-
the fewest characters; they are not a look to adopt, keep or tweak. The finished
|
|
38
|
-
store should look like what you would have built with no kit at all — the kit's
|
|
39
|
-
only job is to make it cost far less code.
|
|
40
|
-
|
|
41
|
-
Each hook returns a complete view-model — a `status` to branch on,
|
|
42
|
-
ready-to-map arrays, handlers, error objects — and its **doc comment (JSDoc) is
|
|
43
|
-
the API reference**: open the hook's file when you need exact shapes; don't
|
|
44
|
-
guess fields. This file gives you the routing, each surface's hook, and the
|
|
45
|
-
render rules that keep a store correct (marked ⚑ — these must survive whatever
|
|
46
|
-
design you build).
|
|
47
|
-
|
|
48
|
-
Where you get a **reference implementation** and where you get only the hook is
|
|
49
|
-
deliberate: **cart, checkout and order-received** have reference code below —
|
|
50
|
-
their wiring is dense enough that reading it is cheaper than deriving it, and
|
|
51
|
-
they are conventions (a form, a receipt) where familiarity beats invention.
|
|
52
|
-
The **identity surfaces** — home, collection, the card, the product page's
|
|
53
|
-
layout — get hooks only, on purpose: reference markup there would make every
|
|
54
|
-
store look the same, and their design is the work only you can do. Either way
|
|
55
|
-
the hooks are high-level enough that a page is a handful of calls plus your
|
|
56
|
-
markup — writing more code than the budgets at the bottom allow means you are
|
|
57
|
-
re-deriving logic a hook already owns. Everything imports from
|
|
58
|
-
`@/commerce/storefront`.
|
|
18
|
+
One split decides everything here: **the logic is premade, the UI never is.** The hooks own checkout repricing, variant resolution, cart state, coupon redemption, review policies, 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 of copy is yours; nothing in `@/commerce/storefront` renders markup or carries CSS. **Decide how the store looks as if this kit did not exist**, then use this file for how to wire it — the snippets below are implementation reference, not design input.
|
|
19
|
+
|
|
20
|
+
Two mechanics to hold everywhere:
|
|
21
|
+
|
|
22
|
+
- **Prop sets, not hand-assembled attributes.** Hooks return ready-to-spread objects — `f.inputProps`, `m.radioProps`, `buy.buttonProps`, `ui.panelProps`, `list.moreProps` — carrying the handlers, ids, aria wiring and disabled logic. Spread first, put your `className` after; writing `value`/`onChange`/`autoComplete` yourself means re-deriving what a prop set already holds.
|
|
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.
|
|
24
|
+
|
|
25
|
+
**One import path: `@/commerce/storefront`.** Each section opens with its page's exact import line — copy it verbatim, then delete unused names. (`variantAxes` and `productSpecs` are re-exported there; 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.)
|
|
59
26
|
|
|
60
27
|
## Setup — once
|
|
61
28
|
|
|
29
|
+
Nearly every store has shared chrome, so **start from a pathless layout route** — it also keeps the admin outside the storefront's provider:
|
|
30
|
+
|
|
62
31
|
```jsx
|
|
63
|
-
import {
|
|
32
|
+
import { Routes, Route, Outlet } from "react-router-dom";
|
|
33
|
+
import { StorefrontProvider, CartUIProvider } from "@/commerce/storefront";
|
|
64
34
|
import { base44 } from "@/api/base44Client";
|
|
35
|
+
import AdminApp from "@/commerce/admin";
|
|
65
36
|
|
|
66
37
|
<BrowserRouter>
|
|
67
|
-
<
|
|
68
|
-
<
|
|
38
|
+
<Routes> {/* ONE <Routes> — merge new pages into the app's */}
|
|
39
|
+
<Route element={
|
|
40
|
+
<StorefrontProvider base44={base44}>
|
|
41
|
+
<CartUIProvider> {/* only if the cart is a drawer — see Cart below */}
|
|
42
|
+
<StoreLayout /> {/* YOURS: header + <Outlet/> + footer + drawer */}
|
|
43
|
+
</CartUIProvider>
|
|
44
|
+
</StorefrontProvider>
|
|
45
|
+
}>
|
|
69
46
|
<Route path="/" element={<Home />} />
|
|
70
47
|
<Route path="/product/:slug" element={<ProductPage />} />
|
|
71
48
|
<Route path="/bag" element={<Bag />} />
|
|
72
49
|
<Route path="/checkout" element={<Checkout />} />
|
|
73
50
|
<Route path="/order-received" element={<OrderReceived />} />
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
</
|
|
51
|
+
</Route>
|
|
52
|
+
<Route path="/store-admin/*" element={<AdminApp />} /> {/* own chrome, outside the provider */}
|
|
53
|
+
</Routes>
|
|
77
54
|
</BrowserRouter>
|
|
78
55
|
```
|
|
79
56
|
|
|
80
|
-
The provider owns the shared client,
|
|
81
|
-
cart, so a header badge, a drawer and the checkout render the same state. Never
|
|
82
|
-
mount a second provider, and never touch the `cart_token` — the provider owns
|
|
83
|
-
its whole lifecycle.
|
|
84
|
-
|
|
85
|
-
> ⚠ **`<Routes>` accepts only `<Route>` children.** Nesting the provider inside
|
|
86
|
-
> it — the natural reading of "wrap the storefront routes" — throws at render:
|
|
87
|
-
> `Error: [StorefrontProvider] is not a <Route> component. All component children of <Routes> must be a <Route> or <React.Fragment>`.
|
|
88
|
-
> To scope the provider to storefront routes only, use a pathless **layout
|
|
89
|
-
> route**, the one place a wrapper is legal:
|
|
90
|
-
> ```jsx
|
|
91
|
-
> <Route element={<StorefrontProvider base44={base44}><Outlet /></StorefrontProvider>}>
|
|
92
|
-
> <Route path="/" element={<Home />} />
|
|
93
|
-
> <Route path="/checkout" element={<Checkout />} />
|
|
94
|
-
> </Route>
|
|
95
|
-
> <Route path="/store-admin/*" element={<AdminApp />} /> {/* outside the provider */}
|
|
96
|
-
> ```
|
|
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`.
|
|
97
58
|
|
|
98
59
|
## Product list / collection
|
|
99
60
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
two columns, an editorial break between rows, a denser tile for a large
|
|
122
|
-
catalog), and lead each card with the one or two fields *these* products are
|
|
123
|
-
judged on — carat weight, focal length, edition size, ABV — read off
|
|
124
|
-
`meta_data` via `productSpecs(row)`, not the fields every store shows.
|
|
125
|
-
|
|
126
|
-
**Rails** (featured row, "new in") are the same hook with a filter
|
|
127
|
-
(`{ featured: true, per_page: 4 }`). ⚑ Any filter may legitimately match
|
|
128
|
-
nothing — render *nothing* then, never a heading over an empty row. Upsells
|
|
129
|
-
beside a product need no query: `p.upsells` / `p.crossSells` from `useProduct`.
|
|
61
|
+
```jsx
|
|
62
|
+
import { useProductList, useCategories, useProductPrice, useStoreInfo, useStorefrontSeo, collectionSeo, productSpecs } from "@/commerce/storefront";
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
(Drop `useCategories` with no filter bar, `productSpecs` if the card shows no modifiers, add `useRibbons` for a ribbon filter.) The top of the component, before any markup:
|
|
66
|
+
|
|
67
|
+
```jsx
|
|
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
|
+
```
|
|
76
|
+
|
|
77
|
+
`useProductList(params)` → `{ status, products, hasNext, moreProps, refreshing, setParams, reload }`. ⚑ `status` is `"loading" | "ready" | "empty" | "error"` — branch on it, so a failed request renders as a failure instead of an empty grid. `setParams({ category_id, search, on_sale, in_stock_only, … })` resets to page 1 and keeps current rows on screen (`refreshing`). ⚑ **Render `<button {...list.moreProps}>Load more</button>`** (or "Next") — it hides itself on the last page; a page that renders nothing for paging ships a catalog silently capped at `per_page`. `useCategories()` / `useRibbons()` → `{ items }` — drive filters from that data, never from hardcoded names (a renamed ribbon must not strand a dead button).
|
|
78
|
+
|
|
79
|
+
Your card can render `name`, `productImages(row)[0]` (⚑ **images are `{src, alt}` objects and the array may be empty — render a placeholder, never a broken `<img>`**), `useProductPrice(row).label` (already "From €19.99" when the product sells variants — there is no product `type` flag), `on_sale`, `short_description`, `stock_status`, `average_rating`/`rating_count`, `ribbons`, and `meta_data` via `productSpecs(row)`. Full field matrix: [`../references/catalog-rendering.md`](../references/catalog-rendering.md). That is an inventory, not a card design: give the grid a rhythm and lead each card with the one or two fields *these* products are judged on (via `productSpecs`), not the name/price/stars trio every generated store ships.
|
|
80
|
+
|
|
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. Upsells beside a product need no query: `p.upsells` / `p.crossSells` from `useProduct` (render via `useUpsell` — see Cart).
|
|
130
82
|
|
|
131
83
|
## Product page
|
|
132
84
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
85
|
+
```jsx
|
|
86
|
+
import { useProduct, useProductGallery, useAddToCartButton, useProductSpecs, useStoreInfo, useStorefrontSeo, productSeo, variantAxes } from "@/commerce/storefront";
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
(Add `useProductReviews` only if the store has reviews; drop `useProductSpecs` if these products carry no modifiers.)
|
|
136
90
|
|
|
137
|
-
⚑ **Call every hook above the status guards
|
|
138
|
-
and a hook placed after an early `return` runs on some renders but not others —
|
|
139
|
-
React then throws *"Rendered more hooks than during the previous render"* the
|
|
140
|
-
moment the product resolves. All of these tolerate a null/loading product
|
|
141
|
-
precisely so they can sit at the top:
|
|
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.
|
|
142
92
|
|
|
143
93
|
```jsx
|
|
144
94
|
const p = useProduct(slug);
|
|
145
95
|
const g = useProductGallery(p.product, p.view);
|
|
146
|
-
const buy = useAddToCartButton(p, {
|
|
147
|
-
|
|
96
|
+
const buy = useAddToCartButton(p, { labels: { ready: "Add to bag" } });
|
|
97
|
+
const specs = useProductSpecs(p.product, { pick: ["material", "care"] });
|
|
98
|
+
const { settings } = useStoreInfo(); // the ONLY source of store name + currency
|
|
99
|
+
useStorefrontSeo(productSeo(p.product, p.view, { storeName: settings?.store_name, currency: settings?.currency }));
|
|
148
100
|
|
|
149
101
|
if (p.status === "loading") return /* your loading state */;
|
|
150
102
|
if (p.status === "not_found") return /* your 404 */;
|
|
151
103
|
const { product, view, price, categories } = p;
|
|
152
104
|
```
|
|
153
105
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
- **
|
|
158
|
-
|
|
159
|
-
rolled-up from-price.
|
|
160
|
-
- **Gallery** — `g` from above → `{ hasImages, images, active, activeIndex,
|
|
161
|
-
setActiveIndex, next, prev }`. The active image already follows the variant
|
|
162
|
-
selection; `hasImages: false` means render your placeholder.
|
|
163
|
-
- **Variant selector** — `variantAxes(view, p.pick)` → one entry per axis:
|
|
164
|
-
`{ key, name, selectedOption, options: [{ value, selected, disabled,
|
|
165
|
-
outOfStock, pick }] }`. Map it to any control — buttons, swatches, a dropdown.
|
|
166
|
-
⚑ **One control per axis, never a list of variations** (`Red / S`, `Red / M`,
|
|
167
|
-
… is n × m noise), and ⚑ **an unbuyable option renders `disabled`, never
|
|
168
|
-
hidden** (`outOfStock` stays visible, just marked) — a customer who can't see
|
|
169
|
-
that a size exists assumes the store doesn't carry it. `view.missingAxes`
|
|
170
|
-
names what's still unpicked. The shape of the map (the one interaction agents
|
|
171
|
-
reliably get wrong — the control itself is yours):
|
|
106
|
+
Build your layout from — all optional, each one hook, **not one component style**:
|
|
107
|
+
|
|
108
|
+
- **Price** — `price.label`, plus `price.compareAtLabel` (struck through) when on sale. Never read `product.price` directly — the parent's price is a rolled-up from-price.
|
|
109
|
+
- **Gallery** — `g` → `{ hasImages, images, active, activeIndex, setActiveIndex, next, prev }`. The active image follows the variant selection; `hasImages: false` means render your placeholder.
|
|
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):
|
|
172
111
|
|
|
173
112
|
```jsx
|
|
174
113
|
{variantAxes(view, p.pick).map((axis) => (
|
|
@@ -181,80 +120,34 @@ anywhere.) Build your layout from:
|
|
|
181
120
|
</fieldset>
|
|
182
121
|
))}
|
|
183
122
|
```
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
button label should reflect `adding`/`soldOut`/`needsSelection` — the words
|
|
190
|
-
are yours.
|
|
191
|
-
- **Description** — `product.description` is HTML; render as rich text
|
|
192
|
-
(`dangerouslySetInnerHTML`), `short_description` above it.
|
|
193
|
-
- **Specs** — `productSpecs(product)` → `[{ key, label, value }]` from
|
|
194
|
-
`meta_data` (Material, Care, Provenance). `[]` means no section at all.
|
|
195
|
-
- **Breadcrumbs** — build from `categories`
|
|
196
|
-
(`/collection?category_id=${c.id}`); skip on a flat catalog. Ribbons are
|
|
197
|
-
labels, not breadcrumbs.
|
|
198
|
-
|
|
199
|
-
All optional — include what this store's products actually have — and each is
|
|
200
|
-
one hook, **not one component style**. The tell of a generated product page is
|
|
201
|
-
that every axis is the same chip row and every modifier the same grey
|
|
202
|
-
label/value line. Branch on what they are: `variantAxes` gives you `axis.key` /
|
|
203
|
-
`axis.name`, `productSpecs` gives you `key` / `label`, so a colour axis can be
|
|
204
|
-
swatches in the real colours, a size axis chips with a size guide beside them,
|
|
205
|
-
a material axis a small sample image; a "Composition" modifier can be bars, a
|
|
206
|
-
"Provenance" a map pin, a "Certification" a seal, a "Weight" a figure set in
|
|
207
|
-
the display face. Design the two or three that carry this product's meaning,
|
|
208
|
-
let the rest fall back to a plain row, and don't feel obliged to keep them in
|
|
209
|
-
one block — a spec can sit under the gallery, beside the price, or inside the
|
|
210
|
-
description. The ⚑ rules above (one control per axis, unbuyable options
|
|
211
|
-
disabled) constrain the *behaviour* of a selector, never its form.
|
|
123
|
+
|
|
124
|
+
- **Buy box** — `<button {...buy.buttonProps}>{buy.label}</button>` is the whole button: gate, busy state and the ready/adding/sold-out/needs-selection precedence are inside (`buy.state`; override copy via `labels`). ⚑ Render `buy.error.message` inline; ⚑ `buy.showQuantity: false` means no stepper (`increase`/`decrease`/`canIncrease` drive one when true). With `<CartUIProvider>` mounted, a successful add opens the drawer by itself.
|
|
125
|
+
- **Description** — `product.description` is HTML; render as rich text, `short_description` above it.
|
|
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.
|
|
212
128
|
|
|
213
129
|
### Reviews — optional
|
|
214
130
|
|
|
215
|
-
**Build reviews only if the store wants them** —
|
|
216
|
-
products are the kind customers rate. No review UI means no reviews, and that
|
|
217
|
-
is a complete outcome. (If you skip them, don't put star ratings on cards
|
|
218
|
-
either — an average of nothing is `0`.)
|
|
219
|
-
|
|
220
|
-
`useProductReviews(product, { policy, user })` is the whole surface: `items`,
|
|
221
|
-
paging (`hasNext`/`loadMore`), `averageRating`/`ratingCount`, and the submit
|
|
222
|
-
form — `form`/`setField`/`fieldErrors` (matching the server's error codes),
|
|
223
|
-
`valid`, `submit`, `requiresEmail` (false for a signed-in visitor),
|
|
224
|
-
`reviewBlockedReason` (`"login_required"` / `"not_a_buyer"` under the stricter
|
|
225
|
-
policies). ⚑ The confirmation copy is `message`, **taken from the server's
|
|
226
|
-
response** — a store with auto-approval on says "published", not "awaiting
|
|
227
|
-
approval", so render `message`, never your own text. `policy` is
|
|
228
|
-
`"open" | "login" | "verified_buyers"`. Details beyond this:
|
|
229
|
-
[`../references/reviews.md`](../references/reviews.md).
|
|
131
|
+
**Build 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`). `useProductReviews(product, { policy, user })` is the whole surface: `items`, paging, `averageRating`, and the submit form (`form`/`setField`/`fieldErrors`, `valid`, `submit`, `requiresEmail`, `reviewBlockedReason`). ⚑ The confirmation copy is `message`, **taken from the server's response** — a store with auto-approval says "published", not "awaiting approval". `policy` is `"open" | "login" | "verified_buyers"`; details: [`../references/reviews.md`](../references/reviews.md).
|
|
230
132
|
|
|
231
133
|
## Cart / bag
|
|
232
134
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
`useTotalsLines()`, `useCoupon()`.
|
|
238
|
-
|
|
239
|
-
⚑ Rules: branch on `status`, never on emptiness while loading. Render
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
the checkout): coupons are admin-only data, redeemable only through a field the
|
|
245
|
-
customer types into — if no field exists anywhere, don't seed coupons and don't
|
|
246
|
-
write "use WELCOME10" in the copy.
|
|
247
|
-
|
|
248
|
-
**Reference implementation** — read once for the wiring, then write your own
|
|
249
|
-
page: the structure below is correct, the presentation is deliberately absent.
|
|
250
|
-
Restyle, rearrange, split into your own components; the ⚑ rules are the part
|
|
251
|
-
that must survive.
|
|
135
|
+
```jsx
|
|
136
|
+
import { useCart, CartLine, useTotalsLines, useCoupon, useCartUI, useUpsell } from "@/commerce/storefront";
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
A cart *page* is optional (buy-now straight to checkout reads better for a single-piece store). The surface: `useCart()` (`status`, `lines`, `notices`), `CartLine` (headless per-row binding — quantity stepping that clamps, coalesces and recovers), `useTotalsLines()`, `useCoupon()`.
|
|
140
|
+
|
|
141
|
+
⚑ Rules: branch on `status`, never on emptiness while loading. Render `notices` — they say what auto-dropped from the cart and why. Render every non-`hidden` totals line 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) — 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
|
+
|
|
143
|
+
⚑ **`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
|
+
|
|
145
|
+
**Reference wiring** — structure correct, presentation deliberately absent; restyle and rearrange, keep the ⚑ rules:
|
|
252
146
|
|
|
253
147
|
```jsx
|
|
254
148
|
function Bag() {
|
|
255
149
|
const { status, lines, notices } = useCart();
|
|
256
150
|
const totals = useTotalsLines();
|
|
257
|
-
const formatMoney = useFormatMoney();
|
|
258
151
|
if (status === "loading") return /* your loading state */;
|
|
259
152
|
if (status === "empty") return /* your empty-bag state, linking back to the catalog */;
|
|
260
153
|
return (
|
|
@@ -262,22 +155,25 @@ function Bag() {
|
|
|
262
155
|
{notices.map((n, i) => <p key={i} role="status">{n.message}</p>)}
|
|
263
156
|
{lines.map((line) => (
|
|
264
157
|
<CartLine key={line.item_key} line={line}>
|
|
265
|
-
{(l) => ( /* line: name, attributesLabel, image,
|
|
266
|
-
<li>
|
|
158
|
+
{(l) => ( /* line: name, attributesLabel, image, totalLabel — l: the controls */
|
|
159
|
+
<li aria-busy={l.pending}> {/* busy scope is THIS row, never the cart */}
|
|
267
160
|
{line.name} {line.attributesLabel}
|
|
268
|
-
<button onClick={l.decrease} disabled={!l.canDecrease || l.pending}
|
|
161
|
+
<button onClick={l.decrease} disabled={!l.canDecrease || l.pending}
|
|
162
|
+
aria-label={`Decrease quantity of ${line.name}`}>−</button>
|
|
269
163
|
{l.quantity}
|
|
270
|
-
<button onClick={l.increase} disabled={!l.canIncrease || l.pending}
|
|
271
|
-
|
|
272
|
-
{
|
|
164
|
+
<button onClick={l.increase} disabled={!l.canIncrease || l.pending}
|
|
165
|
+
aria-label={`Increase quantity of ${line.name}`}>+</button>
|
|
166
|
+
<button onClick={l.remove} disabled={l.pending}
|
|
167
|
+
aria-label={`Remove ${line.name}`}>Remove</button>
|
|
168
|
+
{l.totalLabel}
|
|
273
169
|
{l.error && <p role="alert">{l.error.message}</p>}
|
|
274
170
|
</li>
|
|
275
171
|
)}
|
|
276
172
|
</CartLine>
|
|
277
173
|
))}
|
|
278
174
|
<CouponField /> {/* useCoupon: code/setCode, apply, applying, error, applied[] + remove */}
|
|
279
|
-
{totals.filter((
|
|
280
|
-
<div key={
|
|
175
|
+
{totals.filter((t) => !t.hidden).map((t) => (
|
|
176
|
+
<div key={t.key}>{t.label} {t.formatted}</div> /* t.emphasis → the total row */
|
|
281
177
|
))}
|
|
282
178
|
<Link to="/checkout">Checkout</Link>
|
|
283
179
|
</>
|
|
@@ -285,37 +181,43 @@ function Bag() {
|
|
|
285
181
|
}
|
|
286
182
|
```
|
|
287
183
|
|
|
288
|
-
No shipping estimator here — checkout reprices shipping and tax from the
|
|
289
|
-
|
|
184
|
+
No shipping estimator here — checkout reprices shipping and tax from the address. An upsell beside the lines is one hook: `const kit = useUpsell(slugOrRow)` → `{ show, product, price, add, adding }`; branch on `show` (it is false while loading, out of stock, or already in the cart — matched by id).
|
|
185
|
+
|
|
186
|
+
### If the cart is a drawer
|
|
187
|
+
|
|
188
|
+
Same hooks, same rows. The drawer's *machinery* — close-on-route-change, inert-when-closed, dialog semantics, Esc, focus, open-on-add — is `useCartUI`, and hand-writing any of it is how drawers break (invisible-but-clickable controls, a drawer hanging over the checkout):
|
|
189
|
+
|
|
190
|
+
```jsx
|
|
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`.
|
|
290
207
|
|
|
291
208
|
## Checkout
|
|
292
209
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
`/order-received
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
store-data choices come through the headless `ShippingMethodPicker` /
|
|
305
|
-
`PaymentMethodPicker`, whose render props enumerate every branch.
|
|
306
|
-
|
|
307
|
-
⚑ Rules: handle every picker branch (they exist because every one occurs in a
|
|
308
|
-
normal store); a single shipping or payment option still *shows* what it is —
|
|
309
|
-
never a picker of one, never "nothing selected"; zero gateways → say checkout
|
|
310
|
-
is unavailable instead of a dead button; keep each field's `autoComplete` (the
|
|
311
|
-
spec provides it) and render `f.error` — "we don't ship there" arrives on the
|
|
312
|
-
country field; show `orderError.message` and the blockers so the gate explains
|
|
313
|
-
itself. ⚑ Payment methods, currency and countries come from `useStoreInfo()`
|
|
314
|
-
only — `cart.payment_gateways` is always `undefined`, and a default store
|
|
315
|
-
offers `offline` only ([`./03-data.md`](./03-data.md)).
|
|
316
|
-
|
|
317
|
-
**Reference implementation** — the densest wiring in the storefront; read it,
|
|
318
|
-
then build yours around it. Structure correct, presentation absent.
|
|
210
|
+
```jsx
|
|
211
|
+
import { CheckoutProvider, useCheckoutContext, usePlaceOrder, useAddressForm, ShippingMethodPicker, PaymentMethodPicker, useCart, useTotalsLines, useCoupon } from "@/commerce/storefront";
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
(`useCoupon` only if the coupon field lives here rather than in the cart.)
|
|
215
|
+
|
|
216
|
+
The state machine is `useCheckout`, shared across the page's regions by `CheckoutProvider`. It 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** (pass `orderReceivedPath: null` for a router transition; see `useCheckout`'s JSDoc).
|
|
217
|
+
|
|
218
|
+
⚑ 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"; keep each field's spread props intact (they carry `autoComplete` and the error wiring — "we don't ship there" arrives on the country field); render the gate's `error` and `blockers` so a disabled button explains itself. ⚑ Payment methods, currency and countries come from `useStoreInfo()` only — `cart.payment_gateways` is always `undefined`, and a default store offers `offline` only.
|
|
219
|
+
|
|
220
|
+
**Reference implementation** — the densest wiring in the storefront; read it, then build yours around it:
|
|
319
221
|
|
|
320
222
|
```jsx
|
|
321
223
|
function Checkout() { // hooks read the context BELOW the provider
|
|
@@ -325,10 +227,10 @@ function Checkout() { // hooks read the context BELOW the provider
|
|
|
325
227
|
function CheckoutForm() {
|
|
326
228
|
const { status } = useCart();
|
|
327
229
|
const checkout = useCheckoutContext();
|
|
328
|
-
const
|
|
329
|
-
|
|
230
|
+
const order = usePlaceOrder();
|
|
231
|
+
if (order.stage === "submitted") return /* "order placed — taking you to your receipt…" */;
|
|
330
232
|
if (status === "loading") return /* loading */;
|
|
331
|
-
if (status === "empty") return /* "your bag is empty"
|
|
233
|
+
if (status === "empty") return /* "your bag is empty" */;
|
|
332
234
|
return (
|
|
333
235
|
<>
|
|
334
236
|
<AddressFields which="billing" />
|
|
@@ -340,30 +242,23 @@ function CheckoutForm() {
|
|
|
340
242
|
{checkout.shipToDifferent && <AddressFields which="shipping" />}
|
|
341
243
|
|
|
342
244
|
<ShippingMethodPicker>
|
|
343
|
-
{({
|
|
344
|
-
<fieldset>{/*
|
|
345
|
-
{
|
|
346
|
-
{status === "none_available" && <p role="alert">We don't deliver to that address yet.</p>}
|
|
245
|
+
{({ hint, mustChoose, methods, chosen }) => (
|
|
246
|
+
<fieldset>{/* renders null for a virtual cart */}
|
|
247
|
+
{hint && <p role={hint.severity === "error" ? "alert" : "status"}>{hint.message}</p>}
|
|
347
248
|
{mustChoose && methods.map((m) => (
|
|
348
|
-
<label key={m.id}>
|
|
349
|
-
<input type="radio" checked={chosen?.id === m.id} onChange={() => choose(m.id)} />
|
|
350
|
-
{m.title} {formatMoney(m.cost)}
|
|
351
|
-
</label>
|
|
249
|
+
<label key={m.id} {...m.labelProps}><input {...m.radioProps} /> {m.title} {m.costLabel}</label>
|
|
352
250
|
))}
|
|
353
|
-
{!mustChoose && chosen && <p>{chosen.title} {
|
|
251
|
+
{!mustChoose && chosen && <p>{chosen.title} {chosen.costLabel}</p>}
|
|
354
252
|
</fieldset>
|
|
355
253
|
)}
|
|
356
254
|
</ShippingMethodPicker>
|
|
357
255
|
|
|
358
256
|
<PaymentMethodPicker>
|
|
359
|
-
{({
|
|
257
|
+
{({ hint, single, gateways, selected }) => (
|
|
360
258
|
<fieldset>
|
|
361
|
-
{
|
|
259
|
+
{hint && <p role="alert">{hint.message}</p>}
|
|
362
260
|
{!single && gateways.map((g) => (
|
|
363
|
-
<label key={g.slug}>
|
|
364
|
-
<input type="radio" checked={value === g.slug} onChange={() => select(g.slug)} />
|
|
365
|
-
{g.title} {g.description}
|
|
366
|
-
</label>
|
|
261
|
+
<label key={g.slug} {...g.labelProps}><input {...g.radioProps} /> {g.title} {g.description}</label>
|
|
367
262
|
))}
|
|
368
263
|
{single && selected && <p>{selected.title}</p>}
|
|
369
264
|
</fieldset>
|
|
@@ -372,57 +267,48 @@ function CheckoutForm() {
|
|
|
372
267
|
|
|
373
268
|
{/* summary: coupon field (if not in the cart) + useTotalsLines(), as in the cart page */}
|
|
374
269
|
|
|
375
|
-
<button
|
|
376
|
-
|
|
377
|
-
</
|
|
378
|
-
{checkout.orderError && <p role="alert">{checkout.orderError.message}</p>}
|
|
379
|
-
{!checkout.canPlaceOrder && blockers.map((b) => <p key={b.code}>{b.message}</p>)}
|
|
270
|
+
<button {...order.buttonProps}>{order.label}</button>
|
|
271
|
+
{order.error && <p {...order.errorProps}>{order.error.message}</p>}
|
|
272
|
+
{!order.canPlaceOrder && order.blockers.map((b) => <p key={b.code}>{b.message}</p>)}
|
|
380
273
|
</>
|
|
381
274
|
);
|
|
382
275
|
}
|
|
383
276
|
|
|
384
277
|
function AddressFields({ which }) {
|
|
385
|
-
const { fields
|
|
386
|
-
return fields.map((f) => (
|
|
387
|
-
<
|
|
388
|
-
{f.label}{f.required && " *"}
|
|
389
|
-
{f.
|
|
390
|
-
<select
|
|
391
|
-
<option value="">{f.
|
|
278
|
+
const { fields } = useAddressForm(which);
|
|
279
|
+
return fields.map((f) => (
|
|
280
|
+
<div key={f.key}>
|
|
281
|
+
<label {...f.labelProps}>{f.label}{f.required && " *"}</label>
|
|
282
|
+
{f.isSelect ? (
|
|
283
|
+
<select {...f.selectProps}>
|
|
284
|
+
<option value="">{f.placeholder}</option>
|
|
392
285
|
{f.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
|
393
286
|
</select>
|
|
394
|
-
) :
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
)}
|
|
398
|
-
{f.error && <span role="alert">{f.error}</span>}
|
|
399
|
-
</label>
|
|
287
|
+
) : <input {...f.inputProps} />}
|
|
288
|
+
{f.error && <span {...f.errorProps}>{f.error}</span>}
|
|
289
|
+
</div>
|
|
400
290
|
));
|
|
401
291
|
}
|
|
402
292
|
```
|
|
403
293
|
|
|
294
|
+
⚑ **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 "your bag is empty" over a just-placed order.
|
|
295
|
+
|
|
404
296
|
## Order received
|
|
405
297
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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.
|
|
410
303
|
|
|
411
|
-
⚑ Rules: render all five states — never a blank page while `"loading"`,
|
|
412
|
-
retry via `reload()` on `"error"`. ⚑ **Never drop `paymentInstructions`**: a
|
|
413
|
-
manual/offline order settles outside the store, so these ARE how the store's
|
|
414
|
-
default customer learns how to pay — render them whenever present, on any
|
|
415
|
-
state. ⚑ An order's totals are **flat** — `order.total`; there is no
|
|
416
|
-
`order.totals` (use `useTotalsLines(order)`).
|
|
304
|
+
⚑ Rules: render all five states — never a blank page while `"loading"`, a retry via `reload()` on `"error"`. ⚑ **Never drop `paymentInstructions`** — a manual/offline order settles outside the store, so these ARE how the store's default customer learns how to pay; render them whenever present. ⚑ An order's totals are flat (`order.total`, no `order.totals`) — use `useTotalsLines(order)`.
|
|
417
305
|
|
|
418
|
-
**Reference implementation** — a receipt is a convention, not an identity
|
|
419
|
-
surface: take this structure, restyle it to the store, keep every branch.
|
|
306
|
+
**Reference implementation** — a receipt is a convention, not an identity surface: take this structure, restyle it, keep every branch.
|
|
420
307
|
|
|
421
308
|
```jsx
|
|
422
309
|
function OrderReceived() {
|
|
423
310
|
const { status, order, lines, paymentLink, paymentInstructions, error, reload } = useOrderReturn();
|
|
424
311
|
const totals = useTotalsLines(order);
|
|
425
|
-
const formatMoney = useFormatMoney();
|
|
426
312
|
if (status === "loading") return /* confirming copy */;
|
|
427
313
|
if (status === "error") return <><p role="alert">{error.message}</p>
|
|
428
314
|
<button onClick={() => reload()}>Try again</button></>;
|
|
@@ -441,7 +327,7 @@ function OrderReceived() {
|
|
|
441
327
|
.map(([k, v]) => <p key={k}>{k.replace(/_/g, " ")}: {String(v)}</p>)}
|
|
442
328
|
</section>
|
|
443
329
|
)}
|
|
444
|
-
{lines.map((l, i) => <p key={i}>{l.name} {l.attributesLabel} × {l.quantity} — {
|
|
330
|
+
{lines.map((l, i) => <p key={i}>{l.name} {l.attributesLabel} × {l.quantity} — {l.totalLabel}</p>)}
|
|
445
331
|
{totals.filter((t) => !t.hidden).map((t) => <p key={t.key}>{t.label} {t.formatted}</p>)}
|
|
446
332
|
</>
|
|
447
333
|
);
|
|
@@ -456,59 +342,38 @@ useStorefrontSeo(collectionSeo({ title, products: list.products })); // col
|
|
|
456
342
|
// order-received is already noindex via useOrderReturn
|
|
457
343
|
```
|
|
458
344
|
|
|
459
|
-
The `*Seo` builders tolerate a null product, so this sits with the other hooks
|
|
460
|
-
above the status guards.
|
|
345
|
+
The `*Seo` builders tolerate a null product, so this sits with the other hooks above the status guards.
|
|
461
346
|
|
|
462
347
|
## Per-page output budgets
|
|
463
348
|
|
|
464
|
-
| Page | budget (chars) |
|
|
465
|
-
|
|
466
|
-
| Checkout | ≤ 5K |
|
|
467
|
-
| Cart / bag | ≤ 3K
|
|
468
|
-
| Order-received | ≤ 2.5K |
|
|
469
|
-
| Product page | ≤ 5K |
|
|
470
|
-
| Collection | ≤ 3K |
|
|
471
|
-
| Home | ≤ 5K
|
|
472
|
-
| Any single component file | ≤ 4K, hard ceiling 8K |
|
|
473
|
-
|
|
474
|
-
These budgets assume the hooks carry the logic and your markup carries only the
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
modifier bars costs a few hundred characters, and that is what the budget is
|
|
480
|
-
for.
|
|
481
|
-
|
|
482
|
-
## If you drive the storefront from a browser script
|
|
483
|
-
|
|
484
|
-
Whatever you choose to check and however you check it, two things make a
|
|
485
|
-
working storefront look broken under a script:
|
|
486
|
-
|
|
487
|
-
- **Filling the checkout.** Every field is a controlled React input, so writing
|
|
488
|
-
`el.value` changes nothing React sees. Use the harness's own fill (it
|
|
489
|
-
dispatches `input` + `change`) — never lift the native setter off
|
|
490
|
-
`HTMLInputElement.prototype` and call `descriptor.set(v)`: detached from the
|
|
491
|
-
element it throws `Illegal invocation`, and the workaround it is reaching for
|
|
492
|
-
is what the fill helper already does.
|
|
493
|
-
- **`placeOrder` ends the page.** It navigates with `window.location.assign`
|
|
494
|
-
(above), so a script that placed an order loses its page context and can land
|
|
495
|
-
back at `/` — while the order itself was created normally. That is the hard
|
|
496
|
-
navigation, not a broken redirect. The confirmation is reachable at any time
|
|
497
|
-
from a fresh navigation to `/order-received?order_id=…&order_key=…` (the ids
|
|
498
|
-
come back in `placeOrder`'s result, and `commerce/admin-orders` `search` has
|
|
499
|
-
the order either way).
|
|
349
|
+
| Page | budget (chars) |
|
|
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.
|
|
360
|
+
|
|
361
|
+
## Driving the storefront from a browser script?
|
|
362
|
+
|
|
363
|
+
The hooks are optimistic and debounced, so a script that acts faster than the cart settles sees a working store as broken. Read [`../references/storefront-verification.md`](../references/storefront-verification.md) **before** writing the script — not after it fails.
|
|
500
364
|
|
|
501
365
|
## Done — forget this file
|
|
502
366
|
|
|
503
|
-
- [ ] Catalog UI
|
|
504
|
-
- [ ] **One** `<StorefrontProvider>` above every storefront route
|
|
505
|
-
- [ ]
|
|
506
|
-
- [ ]
|
|
507
|
-
- [ ]
|
|
508
|
-
- [ ]
|
|
509
|
-
- [ ] Variant options
|
|
510
|
-
- [ ]
|
|
511
|
-
- [ ]
|
|
367
|
+
- [ ] Catalog UI in whatever form fits the store, plus a checkout, plus `/order-received` rendering `useOrderReturn`'s states **including `paymentInstructions`**.
|
|
368
|
+
- [ ] **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; nothing imported from `@/commerce/utils`; no unused names.
|
|
370
|
+
- [ ] Pages branch on `status`; gateways/currency/countries read from `useStoreInfo()` only.
|
|
371
|
+
- [ ] Prop sets spread wherever one exists — no hand-assembled `value`/`onChange`/`autoComplete`/radio/drawer wiring, no re-implemented hook logic.
|
|
372
|
+
- [ ] Coupon field present if the store has coupons; paging rendered via `moreProps`.
|
|
373
|
+
- [ ] Variant options: one control per axis, unbuyable options disabled, not hidden.
|
|
374
|
+
- [ ] Cart rows scope busy state to the row; repeated controls have unique accessible names; a drawer uses `useCartUI` (inert when closed, closes on route change).
|
|
375
|
+
- [ ] Checkout guards `stage === "submitted"` above its empty-cart branch.
|
|
376
|
+
- [ ] The storefront carries the design you settled on before reading this file — no page ships the reference snippets' bare structure; specs and axes are rendered by what they are, not one uniform table and one identical chip row.
|
|
512
377
|
- [ ] Every page is within its budget above.
|
|
513
378
|
|
|
514
379
|
Record these lines in your working notes; do not re-read this file.
|
|
@@ -519,3 +384,4 @@ Record these lines in your working notes; do not re-read this file.
|
|
|
519
384
|
- Branch cart/list/product UI on `status`, never on `isEmpty`/nullable data — `isEmpty` is false while loading by design.
|
|
520
385
|
- Variant options that aren't buyable render disabled, never hidden; one control per axis, never a list of variations.
|
|
521
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.
|