@base44/app-plugin-commerce 0.2.5 → 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 +133 -427
- package/skills/commerce/install/03-data.md +43 -109
- package/skills/commerce/references/admin-product-form.md +26 -0
- package/skills/commerce/references/catalog-rendering.md +7 -7
- 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 +8 -3
- package/src/commerce/storefront/cartUI.jsx +190 -0
- package/src/commerce/storefront/index.js +39 -15
- package/src/commerce/storefront/pickers.jsx +98 -23
- package/src/commerce/storefront/useAddressForm.js +71 -25
- package/src/commerce/storefront/useCartLine.js +9 -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/specs.js +6 -2
- package/src/commerce/utils/totals.js +23 -12
|
@@ -10,78 +10,39 @@ 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.
|
|
58
|
-
|
|
59
|
-
**One import path: `@/commerce/storefront`.** Each section below opens with its
|
|
60
|
-
page's exact import line — **copy it verbatim** instead of assembling one from
|
|
61
|
-
memory, then delete any name you don't end up using. `variantAxes` and
|
|
62
|
-
`productSpecs` live in `@/commerce/utils` but are re-exported here, so a React
|
|
63
|
-
page never imports from `@/commerce/utils` directly; `useStoreInfo` is the name
|
|
64
|
-
most often left out, and it is the only source of store name and currency.
|
|
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.)
|
|
65
26
|
|
|
66
27
|
## Setup — once
|
|
67
28
|
|
|
68
|
-
Nearly every store has shared chrome
|
|
69
|
-
**start from the layout route** — a pathless `<Route>` whose element is your
|
|
70
|
-
layout, rendering `<Outlet/>` where the page goes. It also keeps the admin
|
|
71
|
-
outside the storefront's provider and chrome:
|
|
29
|
+
Nearly every store has shared chrome, so **start from a pathless layout route** — it also keeps the admin outside the storefront's provider:
|
|
72
30
|
|
|
73
31
|
```jsx
|
|
74
32
|
import { Routes, Route, Outlet } from "react-router-dom";
|
|
75
|
-
import { StorefrontProvider } from "@/commerce/storefront";
|
|
33
|
+
import { StorefrontProvider, CartUIProvider } from "@/commerce/storefront";
|
|
76
34
|
import { base44 } from "@/api/base44Client";
|
|
77
35
|
import AdminApp from "@/commerce/admin";
|
|
78
36
|
|
|
79
|
-
// StoreLayout is YOURS: <Nav/> (its cart badge calls useCart) + <Outlet/> + <Footer/>.
|
|
80
|
-
function StoreLayout() { return <><Nav /><Outlet /><Footer /></>; }
|
|
81
|
-
|
|
82
37
|
<BrowserRouter>
|
|
83
38
|
<Routes> {/* ONE <Routes> — merge new pages into the app's */}
|
|
84
|
-
<Route element={
|
|
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
|
+
}>
|
|
85
46
|
<Route path="/" element={<Home />} />
|
|
86
47
|
<Route path="/product/:slug" element={<ProductPage />} />
|
|
87
48
|
<Route path="/bag" element={<Bag />} />
|
|
@@ -93,30 +54,7 @@ function StoreLayout() { return <><Nav /><Outlet /><Footer /></>; }
|
|
|
93
54
|
</BrowserRouter>
|
|
94
55
|
```
|
|
95
56
|
|
|
96
|
-
⚑ **The nesting is provider → layout → `<Outlet/>`, never the reverse
|
|
97
|
-
layout that renders the provider *inside* itself leaves the nav above (or
|
|
98
|
-
outside) it, so the header badge and the cart page read different carts — and a
|
|
99
|
-
`useCart` in the nav throws outright. The provider goes on the layout route's
|
|
100
|
-
element, wrapping your layout component.
|
|
101
|
-
|
|
102
|
-
**No shared chrome** (each page draws its own header, or there is one page)?
|
|
103
|
-
Then wrap `<Routes>` directly and skip the layout route:
|
|
104
|
-
|
|
105
|
-
```jsx
|
|
106
|
-
<StorefrontProvider base44={base44}> {/* wraps <Routes> — never a child of it */}
|
|
107
|
-
<Routes>…</Routes>
|
|
108
|
-
</StorefrontProvider>
|
|
109
|
-
```
|
|
110
|
-
|
|
111
|
-
> ⚠ **`<Routes>` accepts only `<Route>` children.** Putting the provider inside
|
|
112
|
-
> it — the natural reading of "wrap the storefront routes" — throws at render:
|
|
113
|
-
> `Error: [StorefrontProvider] is not a <Route> component. All component children of <Routes> must be a <Route> or <React.Fragment>`.
|
|
114
|
-
> A pathless layout route (above) is the one place a wrapper is legal.
|
|
115
|
-
|
|
116
|
-
The provider owns the shared client, the store-info cache and **one** shared
|
|
117
|
-
cart, so a header badge, a drawer and the checkout render the same state. Never
|
|
118
|
-
mount a second provider, and never touch the `cart_token` — the provider owns
|
|
119
|
-
its whole lifecycle.
|
|
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`.
|
|
120
58
|
|
|
121
59
|
## Product list / collection
|
|
122
60
|
|
|
@@ -124,9 +62,7 @@ its whole lifecycle.
|
|
|
124
62
|
import { useProductList, useCategories, useProductPrice, useStoreInfo, useStorefrontSeo, collectionSeo, productSpecs } from "@/commerce/storefront";
|
|
125
63
|
```
|
|
126
64
|
|
|
127
|
-
(Drop `useCategories` with no filter bar, `productSpecs` if the card shows no
|
|
128
|
-
modifiers. Add `useRibbons` for a ribbon filter.) The top of the component,
|
|
129
|
-
before any markup — every hook above the guards, and `products` derived once:
|
|
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:
|
|
130
66
|
|
|
131
67
|
```jsx
|
|
132
68
|
const list = useProductList({ per_page: 24 });
|
|
@@ -135,66 +71,30 @@ useStorefrontSeo(collectionSeo({ title: "…", products: list.products, storeNam
|
|
|
135
71
|
|
|
136
72
|
if (list.status === "loading") return /* your loading state */;
|
|
137
73
|
if (list.status === "error") return /* your failure state, with a retry calling list.reload() */;
|
|
138
|
-
const products = list.products; // always an array — never null
|
|
74
|
+
const products = list.products; // always an array — never null
|
|
139
75
|
```
|
|
140
76
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
— branch on it, so a failed request renders as a failure instead of an empty
|
|
147
|
-
grid. `setParams({ category_id, search, on_sale, min_price, in_stock_only })`
|
|
148
|
-
resets to page 1 and keeps the current rows on screen (`refreshing`) while the
|
|
149
|
-
page loads. `useCategories()` / `useRibbons()` → `{ items }` (arrays, children
|
|
150
|
-
nested).
|
|
151
|
-
|
|
152
|
-
Your card can render `name`, `images[0]?.src` (⚑ **images are `{src,name,alt}`
|
|
153
|
-
objects and the array may be empty — render a placeholder, never a broken
|
|
154
|
-
`<img>`**), `useProductPrice(row).label` (already "From €19.99" when the
|
|
155
|
-
product sells variants — there is no product `type` flag), `on_sale`,
|
|
156
|
-
`short_description`, `stock_status`, `average_rating`/`rating_count`,
|
|
157
|
-
`ribbons` — and a row carries the whole product record, so `weight`,
|
|
158
|
-
`dimensions`, `attributes[]` and `meta_data` are there too. Full field matrix:
|
|
159
|
-
[`../references/catalog-rendering.md`](../references/catalog-rendering.md).
|
|
160
|
-
|
|
161
|
-
That is an inventory of what you *can* show, not a card design and not a list
|
|
162
|
-
to render in order. An even grid of identical cards, each with the same
|
|
163
|
-
name/price/stars trio, is where a generated store lands by default and almost
|
|
164
|
-
never where this catalog belongs: give the grid a rhythm (a hero piece spanning
|
|
165
|
-
two columns, an editorial break between rows, a denser tile for a large
|
|
166
|
-
catalog), and lead each card with the one or two fields *these* products are
|
|
167
|
-
judged on — carat weight, focal length, edition size, ABV — read off
|
|
168
|
-
`meta_data` via `productSpecs(row)`, not the fields every store shows.
|
|
169
|
-
|
|
170
|
-
**Rails** (featured row, "new in") are the same hook with a filter
|
|
171
|
-
(`{ featured: true, per_page: 4 }`). ⚑ Any filter may legitimately match
|
|
172
|
-
nothing — render *nothing* then, never a heading over an empty row. Upsells
|
|
173
|
-
beside a product need no query: `p.upsells` / `p.crossSells` from `useProduct`.
|
|
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).
|
|
174
82
|
|
|
175
83
|
## Product page
|
|
176
84
|
|
|
177
85
|
```jsx
|
|
178
|
-
import { useProduct, useProductGallery, useAddToCartButton, useStoreInfo, useStorefrontSeo, productSeo, variantAxes
|
|
86
|
+
import { useProduct, useProductGallery, useAddToCartButton, useProductSpecs, useStoreInfo, useStorefrontSeo, productSeo, variantAxes } from "@/commerce/storefront";
|
|
179
87
|
```
|
|
180
88
|
|
|
181
|
-
(Add `useProductReviews` only if the store has reviews; drop `
|
|
182
|
-
these products carry no modifiers.)
|
|
89
|
+
(Add `useProductReviews` only if the store has reviews; drop `useProductSpecs` if these products carry no modifiers.)
|
|
183
90
|
|
|
184
|
-
`useProduct(slug)` (or `{ id }`) owns fetch + variant selection + quantity +
|
|
185
|
-
price, race-safe, selection mirrored to the URL. ⚑ `status: "not_found"` is a
|
|
186
|
-
404 page, not a spinner.
|
|
187
|
-
|
|
188
|
-
⚑ **Call every hook above the status guards.** This page needs more than one,
|
|
189
|
-
and a hook placed after an early `return` runs on some renders but not others —
|
|
190
|
-
React then throws *"Rendered more hooks than during the previous render"* the
|
|
191
|
-
moment the product resolves. All of these tolerate a null/loading product
|
|
192
|
-
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.
|
|
193
92
|
|
|
194
93
|
```jsx
|
|
195
94
|
const p = useProduct(slug);
|
|
196
95
|
const g = useProductGallery(p.product, p.view);
|
|
197
|
-
const buy = useAddToCartButton(p, {
|
|
96
|
+
const buy = useAddToCartButton(p, { labels: { ready: "Add to bag" } });
|
|
97
|
+
const specs = useProductSpecs(p.product, { pick: ["material", "care"] });
|
|
198
98
|
const { settings } = useStoreInfo(); // the ONLY source of store name + currency
|
|
199
99
|
useStorefrontSeo(productSeo(p.product, p.view, { storeName: settings?.store_name, currency: settings?.currency }));
|
|
200
100
|
|
|
@@ -203,24 +103,11 @@ if (p.status === "not_found") return /* your 404 */;
|
|
|
203
103
|
const { product, view, price, categories } = p;
|
|
204
104
|
```
|
|
205
105
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
- **
|
|
210
|
-
|
|
211
|
-
rolled-up from-price.
|
|
212
|
-
- **Gallery** — `g` from above → `{ hasImages, images, active, activeIndex,
|
|
213
|
-
setActiveIndex, next, prev }`. The active image already follows the variant
|
|
214
|
-
selection; `hasImages: false` means render your placeholder.
|
|
215
|
-
- **Variant selector** — `variantAxes(view, p.pick)` → one entry per axis:
|
|
216
|
-
`{ key, name, selectedOption, options: [{ value, selected, disabled,
|
|
217
|
-
outOfStock, pick }] }`. Map it to any control — buttons, swatches, a dropdown.
|
|
218
|
-
⚑ **One control per axis, never a list of variations** (`Red / S`, `Red / M`,
|
|
219
|
-
… is n × m noise), and ⚑ **an unbuyable option renders `disabled`, never
|
|
220
|
-
hidden** (`outOfStock` stays visible, just marked) — a customer who can't see
|
|
221
|
-
that a size exists assumes the store doesn't carry it. `view.missingAxes`
|
|
222
|
-
names what's still unpicked. The shape of the map (the one interaction agents
|
|
223
|
-
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):
|
|
224
111
|
|
|
225
112
|
```jsx
|
|
226
113
|
{variantAxes(view, p.pick).map((axis) => (
|
|
@@ -233,121 +120,34 @@ anywhere.) Build your layout from:
|
|
|
233
120
|
</fieldset>
|
|
234
121
|
))}
|
|
235
122
|
```
|
|
236
|
-
- **Buy box** — `buy` from above → `{ add, adding, error, disabled, soldOut,
|
|
237
|
-
needsSelection, quantity, increase, decrease, canIncrease, canDecrease,
|
|
238
|
-
showQuantity }`. It gates on purchasability, recovers from every add failure
|
|
239
|
-
and clamps quantity to stock and `sold_individually`. ⚑ Render `error.message`
|
|
240
|
-
inline; ⚑ `showQuantity: false` means no stepper (only 1 can be bought); the
|
|
241
|
-
button label should reflect `adding`/`soldOut`/`needsSelection` — the words
|
|
242
|
-
are yours.
|
|
243
|
-
- **Description** — `product.description` is HTML; render as rich text
|
|
244
|
-
(`dangerouslySetInnerHTML`), `short_description` above it.
|
|
245
|
-
- **Specs** — `productSpecs(product)` → `[{ key, label, value, type, number,
|
|
246
|
-
unit, items }]` from `meta_data` (Material, Care, Provenance, Weight). `[]`
|
|
247
|
-
means no section at all. ⚑ **Don't `.map()` it into one uniform label/value
|
|
248
|
-
table** — that is the most reliable tell of a generated product page. Every
|
|
249
|
-
row's `type` is inferred for you so the branch point is already there:
|
|
250
|
-
`"numeric"` (with `number` and `unit` split out), `"duration"`, `"location"`,
|
|
251
|
-
`"list"` (with `items`), `"text"`.
|
|
252
|
-
|
|
253
|
-
```jsx
|
|
254
|
-
// ❌ what a generated store ships: one grey table, every store the same
|
|
255
|
-
<dl>{specs.map((s) => <div key={s.key}><dt>{s.label}</dt><dd>{s.value}</dd></div>)}</dl>
|
|
256
|
-
|
|
257
|
-
// ✅ the figures read as figures, the rest falls through to a plain row
|
|
258
|
-
{productSpecs(product).map((s) =>
|
|
259
|
-
s.type === "numeric" ? <Figure key={s.key} label={s.label} n={s.number} unit={s.unit} /> // 0.75 ct, set large
|
|
260
|
-
: s.type === "list" ? <Bars key={s.key} parts={s.items} /> // 70% wool / 30% cashmere
|
|
261
|
-
: s.type === "location" ? <Sourced key={s.key} place={s.value} /> // a located line, a pin
|
|
262
|
-
: <Row key={s.key} label={s.label} value={s.value} />)}
|
|
263
|
-
```
|
|
264
123
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
- **Breadcrumbs** — build from `categories`
|
|
270
|
-
(`/collection?category_id=${c.id}`); skip on a flat catalog. Ribbons are
|
|
271
|
-
labels, not breadcrumbs.
|
|
272
|
-
|
|
273
|
-
All optional — include what this store's products actually have — and each is
|
|
274
|
-
one hook, **not one component style**. The same rule the specs bullet states
|
|
275
|
-
applies to the axes: `variantAxes` gives you `axis.key` / `axis.name` so a
|
|
276
|
-
colour axis can be swatches in the real colours, a size axis chips with a size
|
|
277
|
-
guide beside them, a material axis a small sample image — every axis rendered as
|
|
278
|
-
the identical chip row is the other half of the generated-page tell. The ⚑ rules
|
|
279
|
-
above (one control per axis, unbuyable options disabled) constrain the
|
|
280
|
-
*behaviour* of a selector, never its form.
|
|
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.
|
|
281
128
|
|
|
282
129
|
### Reviews — optional
|
|
283
130
|
|
|
284
|
-
**Build reviews only if the store wants them** —
|
|
285
|
-
products are the kind customers rate. No review UI means no reviews, and that
|
|
286
|
-
is a complete outcome. (If you skip them, don't put star ratings on cards
|
|
287
|
-
either — an average of nothing is `0`.)
|
|
288
|
-
|
|
289
|
-
`useProductReviews(product, { policy, user })` is the whole surface: `items`,
|
|
290
|
-
paging (`hasNext`/`loadMore`), `averageRating`/`ratingCount`, and the submit
|
|
291
|
-
form — `form`/`setField`/`fieldErrors` (matching the server's error codes),
|
|
292
|
-
`valid`, `submit`, `requiresEmail` (false for a signed-in visitor),
|
|
293
|
-
`reviewBlockedReason` (`"login_required"` / `"not_a_buyer"` under the stricter
|
|
294
|
-
policies). ⚑ The confirmation copy is `message`, **taken from the server's
|
|
295
|
-
response** — a store with auto-approval on says "published", not "awaiting
|
|
296
|
-
approval", so render `message`, never your own text. `policy` is
|
|
297
|
-
`"open" | "login" | "verified_buyers"`. Details beyond this:
|
|
298
|
-
[`../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).
|
|
299
132
|
|
|
300
133
|
## Cart / bag
|
|
301
134
|
|
|
302
135
|
```jsx
|
|
303
|
-
import { useCart, CartLine, useTotalsLines, useCoupon,
|
|
136
|
+
import { useCart, CartLine, useTotalsLines, useCoupon, useCartUI, useUpsell } from "@/commerce/storefront";
|
|
304
137
|
```
|
|
305
138
|
|
|
306
|
-
A cart *page* is optional
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
`
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
`notices` — they say what auto-dropped from the cart and why. Render every
|
|
314
|
-
non-`hidden` totals line rather than hardcoding subtotal/total — a hand-written
|
|
315
|
-
summary omits discount and tax, then stops adding up the day a coupon or a tax
|
|
316
|
-
rate exists. **A store with any coupons must have a coupon field** (here or in
|
|
317
|
-
the checkout): coupons are admin-only data, redeemable only through a field the
|
|
318
|
-
customer types into — if no field exists anywhere, don't seed coupons and don't
|
|
319
|
-
write "use WELCOME10" in the copy.
|
|
320
|
-
|
|
321
|
-
⚑ **`pending` is one row's flag, and it stays up until that update finishes.**
|
|
322
|
-
The window is not the click: `increase`/`decrease` set the optimistic number and
|
|
323
|
-
start a 250ms debounce, `pending` goes true when the request leaves, and returns
|
|
324
|
-
to false **only after the new cart view has landed** — so `pending === false`
|
|
325
|
-
with no `error` means that row's quantity and the totals are settled, not just
|
|
326
|
-
that a request returned. (`remove()` skips the debounce, `pending` immediately.)
|
|
327
|
-
So **disable and mark only that row** — `disabled={l.pending}` on its own
|
|
328
|
-
controls, `aria-busy` on the row — since `pending` says nothing about the other
|
|
329
|
-
lines, and stalling the whole cart over one 250ms stepper reads as a broken page.
|
|
330
|
-
And note **`status` never returns to `"loading"` for a mutation**: it settles
|
|
331
|
-
once, on first load, then only moves between `"empty"` and `"ready"`. There is
|
|
332
|
-
deliberately no cart-wide busy flag — `status` is the page's shape, `pending` is
|
|
333
|
-
"did that change land".
|
|
334
|
-
|
|
335
|
-
⚑ **Repeated controls need unique accessible names.** A three-line cart renders
|
|
336
|
-
three buttons named "Remove", and "+" or "×" alone names nothing at all — put the
|
|
337
|
-
line in the label (``aria-label={`Remove ${line.name}`}``, likewise ±, and a
|
|
338
|
-
drawer's close button). Identical or empty names are ambiguous to a screen reader
|
|
339
|
-
and to anything driving the page by name.
|
|
340
|
-
|
|
341
|
-
**Reference implementation** — read once for the wiring, then write your own
|
|
342
|
-
page: the structure below is correct, the presentation is deliberately absent.
|
|
343
|
-
Restyle, rearrange, split into your own components; the ⚑ rules are the part
|
|
344
|
-
that must survive.
|
|
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:
|
|
345
146
|
|
|
346
147
|
```jsx
|
|
347
148
|
function Bag() {
|
|
348
149
|
const { status, lines, notices } = useCart();
|
|
349
150
|
const totals = useTotalsLines();
|
|
350
|
-
const formatMoney = useFormatMoney();
|
|
351
151
|
if (status === "loading") return /* your loading state */;
|
|
352
152
|
if (status === "empty") return /* your empty-bag state, linking back to the catalog */;
|
|
353
153
|
return (
|
|
@@ -355,7 +155,7 @@ function Bag() {
|
|
|
355
155
|
{notices.map((n, i) => <p key={i} role="status">{n.message}</p>)}
|
|
356
156
|
{lines.map((line) => (
|
|
357
157
|
<CartLine key={line.item_key} line={line}>
|
|
358
|
-
{(l) => ( /* line: name, attributesLabel, image,
|
|
158
|
+
{(l) => ( /* line: name, attributesLabel, image, totalLabel — l: the controls */
|
|
359
159
|
<li aria-busy={l.pending}> {/* busy scope is THIS row, never the cart */}
|
|
360
160
|
{line.name} {line.attributesLabel}
|
|
361
161
|
<button onClick={l.decrease} disabled={!l.canDecrease || l.pending}
|
|
@@ -365,15 +165,15 @@ function Bag() {
|
|
|
365
165
|
aria-label={`Increase quantity of ${line.name}`}>+</button>
|
|
366
166
|
<button onClick={l.remove} disabled={l.pending}
|
|
367
167
|
aria-label={`Remove ${line.name}`}>Remove</button>
|
|
368
|
-
{
|
|
168
|
+
{l.totalLabel}
|
|
369
169
|
{l.error && <p role="alert">{l.error.message}</p>}
|
|
370
170
|
</li>
|
|
371
171
|
)}
|
|
372
172
|
</CartLine>
|
|
373
173
|
))}
|
|
374
174
|
<CouponField /> {/* useCoupon: code/setCode, apply, applying, error, applied[] + remove */}
|
|
375
|
-
{totals.filter((
|
|
376
|
-
<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 */
|
|
377
177
|
))}
|
|
378
178
|
<Link to="/checkout">Checkout</Link>
|
|
379
179
|
</>
|
|
@@ -381,69 +181,43 @@ function Bag() {
|
|
|
381
181
|
}
|
|
382
182
|
```
|
|
383
183
|
|
|
384
|
-
No shipping estimator here — checkout reprices shipping and tax from the
|
|
385
|
-
address.
|
|
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).
|
|
386
185
|
|
|
387
186
|
### If the cart is a drawer
|
|
388
187
|
|
|
389
|
-
Same hooks, same rows —
|
|
390
|
-
|
|
391
|
-
**Close it on navigation.** A product link or "Checkout" inside the drawer
|
|
392
|
-
changes the route with the drawer still open, leaving it hanging over the new
|
|
393
|
-
page. One effect covers every link inside, so no link has to remember:
|
|
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):
|
|
394
189
|
|
|
395
190
|
```jsx
|
|
396
|
-
|
|
397
|
-
|
|
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
|
+
}
|
|
398
204
|
```
|
|
399
205
|
|
|
400
|
-
|
|
401
|
-
`translate`, its buttons stay focusable and clickable — Tab walks into an
|
|
402
|
-
invisible cart, and a click lands on a "Remove" nobody can see. Either don't
|
|
403
|
-
render it (`{open && <Drawer/>}`) or, if it stays mounted for the transition, use
|
|
404
|
-
`hidden` / `visibility: hidden` / `inert`. ⚑ Never `aria-hidden` alone: that
|
|
405
|
-
hides it from a screen reader while leaving it fully clickable.
|
|
406
|
-
|
|
407
|
-
**The backdrop is not the close button.** Both close the drawer; only one is a
|
|
408
|
-
control. Name the visible button (`aria-label="Close cart"`) and leave the
|
|
409
|
-
backdrop an unnamed overlay — `onClick={close}`, `aria-hidden="true"`, no tab
|
|
410
|
-
stop. Two elements named "Close cart" are ambiguous to a screen reader and to
|
|
411
|
-
anything driving the page; the keyboard's way out is Esc and the button.
|
|
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`.
|
|
412
207
|
|
|
413
208
|
## Checkout
|
|
414
209
|
|
|
415
210
|
```jsx
|
|
416
|
-
import { CheckoutProvider, useCheckoutContext,
|
|
211
|
+
import { CheckoutProvider, useCheckoutContext, usePlaceOrder, useAddressForm, ShippingMethodPicker, PaymentMethodPicker, useCart, useTotalsLines, useCoupon } from "@/commerce/storefront";
|
|
417
212
|
```
|
|
418
213
|
|
|
419
214
|
(`useCoupon` only if the coupon field lives here rather than in the cart.)
|
|
420
215
|
|
|
421
|
-
The state machine is `useCheckout`, shared across the page's regions by
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
navigations — online gateway → provider redirect, everything else →
|
|
427
|
-
`/order-received`. Both are **full page loads** (`window.location.assign`),
|
|
428
|
-
which is why the order-received page boots from the URL alone; pass
|
|
429
|
-
`orderReceivedPath: null` and `navigate(orderReceivedUrl(result))` if you want
|
|
430
|
-
a router transition instead. The address form comes from `useAddressForm(which)` as a
|
|
431
|
-
field spec (`state` collected, country options never null); the two
|
|
432
|
-
store-data choices come through the headless `ShippingMethodPicker` /
|
|
433
|
-
`PaymentMethodPicker`, whose render props enumerate every branch.
|
|
434
|
-
|
|
435
|
-
⚑ Rules: handle every picker branch (they exist because every one occurs in a
|
|
436
|
-
normal store); a single shipping or payment option still *shows* what it is —
|
|
437
|
-
never a picker of one, never "nothing selected"; zero gateways → say checkout
|
|
438
|
-
is unavailable instead of a dead button; keep each field's `autoComplete` (the
|
|
439
|
-
spec provides it) and render `f.error` — "we don't ship there" arrives on the
|
|
440
|
-
country field; show `orderError.message` and the blockers so the gate explains
|
|
441
|
-
itself. ⚑ Payment methods, currency and countries come from `useStoreInfo()`
|
|
442
|
-
only — `cart.payment_gateways` is always `undefined`, and a default store
|
|
443
|
-
offers `offline` only ([`./03-data.md`](./03-data.md)).
|
|
444
|
-
|
|
445
|
-
**Reference implementation** — the densest wiring in the storefront; read it,
|
|
446
|
-
then build yours around it. Structure correct, presentation absent.
|
|
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:
|
|
447
221
|
|
|
448
222
|
```jsx
|
|
449
223
|
function Checkout() { // hooks read the context BELOW the provider
|
|
@@ -453,10 +227,10 @@ function Checkout() { // hooks read the context BELOW the provider
|
|
|
453
227
|
function CheckoutForm() {
|
|
454
228
|
const { status } = useCart();
|
|
455
229
|
const checkout = useCheckoutContext();
|
|
456
|
-
const
|
|
457
|
-
|
|
230
|
+
const order = usePlaceOrder();
|
|
231
|
+
if (order.stage === "submitted") return /* "order placed — taking you to your receipt…" */;
|
|
458
232
|
if (status === "loading") return /* loading */;
|
|
459
|
-
if (status === "empty") return /* "your bag is empty"
|
|
233
|
+
if (status === "empty") return /* "your bag is empty" */;
|
|
460
234
|
return (
|
|
461
235
|
<>
|
|
462
236
|
<AddressFields which="billing" />
|
|
@@ -468,30 +242,23 @@ function CheckoutForm() {
|
|
|
468
242
|
{checkout.shipToDifferent && <AddressFields which="shipping" />}
|
|
469
243
|
|
|
470
244
|
<ShippingMethodPicker>
|
|
471
|
-
{({
|
|
472
|
-
<fieldset>{/*
|
|
473
|
-
{
|
|
474
|
-
{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>}
|
|
475
248
|
{mustChoose && methods.map((m) => (
|
|
476
|
-
<label key={m.id}>
|
|
477
|
-
<input type="radio" checked={chosen?.id === m.id} onChange={() => choose(m.id)} />
|
|
478
|
-
{m.title} {formatMoney(m.cost)}
|
|
479
|
-
</label>
|
|
249
|
+
<label key={m.id} {...m.labelProps}><input {...m.radioProps} /> {m.title} {m.costLabel}</label>
|
|
480
250
|
))}
|
|
481
|
-
{!mustChoose && chosen && <p>{chosen.title} {
|
|
251
|
+
{!mustChoose && chosen && <p>{chosen.title} {chosen.costLabel}</p>}
|
|
482
252
|
</fieldset>
|
|
483
253
|
)}
|
|
484
254
|
</ShippingMethodPicker>
|
|
485
255
|
|
|
486
256
|
<PaymentMethodPicker>
|
|
487
|
-
{({
|
|
257
|
+
{({ hint, single, gateways, selected }) => (
|
|
488
258
|
<fieldset>
|
|
489
|
-
{
|
|
259
|
+
{hint && <p role="alert">{hint.message}</p>}
|
|
490
260
|
{!single && gateways.map((g) => (
|
|
491
|
-
<label key={g.slug}>
|
|
492
|
-
<input type="radio" checked={value === g.slug} onChange={() => select(g.slug)} />
|
|
493
|
-
{g.title} {g.description}
|
|
494
|
-
</label>
|
|
261
|
+
<label key={g.slug} {...g.labelProps}><input {...g.radioProps} /> {g.title} {g.description}</label>
|
|
495
262
|
))}
|
|
496
263
|
{single && selected && <p>{selected.title}</p>}
|
|
497
264
|
</fieldset>
|
|
@@ -500,61 +267,48 @@ function CheckoutForm() {
|
|
|
500
267
|
|
|
501
268
|
{/* summary: coupon field (if not in the cart) + useTotalsLines(), as in the cart page */}
|
|
502
269
|
|
|
503
|
-
<button
|
|
504
|
-
|
|
505
|
-
</
|
|
506
|
-
{checkout.orderError && <p role="alert">{checkout.orderError.message}</p>}
|
|
507
|
-
{!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>)}
|
|
508
273
|
</>
|
|
509
274
|
);
|
|
510
275
|
}
|
|
511
276
|
|
|
512
277
|
function AddressFields({ which }) {
|
|
513
|
-
const { fields
|
|
514
|
-
return fields.map((f) => (
|
|
515
|
-
<
|
|
516
|
-
{f.label}{f.required && " *"}
|
|
517
|
-
{f.
|
|
518
|
-
<select
|
|
519
|
-
<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>
|
|
520
285
|
{f.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
|
521
286
|
</select>
|
|
522
|
-
) :
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
)}
|
|
526
|
-
{f.error && <span role="alert">{f.error}</span>}
|
|
527
|
-
</label>
|
|
287
|
+
) : <input {...f.inputProps} />}
|
|
288
|
+
{f.error && <span {...f.errorProps}>{f.error}</span>}
|
|
289
|
+
</div>
|
|
528
290
|
));
|
|
529
291
|
}
|
|
530
292
|
```
|
|
531
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
|
+
|
|
532
296
|
## Order received
|
|
533
297
|
|
|
534
298
|
```jsx
|
|
535
|
-
import { useOrderReturn, useTotalsLines
|
|
299
|
+
import { useOrderReturn, useTotalsLines } from "@/commerce/storefront";
|
|
536
300
|
```
|
|
537
301
|
|
|
538
|
-
**Mandatory route** — every payment link returns here, and confirming is what
|
|
539
|
-
marks a card order paid. `useOrderReturn()` is the whole page's logic: it reads
|
|
540
|
-
`order_id`/`order_key` from the URL, verifies with the provider (idempotent),
|
|
541
|
-
and marks the page noindex itself.
|
|
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.
|
|
542
303
|
|
|
543
|
-
⚑ Rules: render all five states — never a blank page while `"loading"`,
|
|
544
|
-
retry via `reload()` on `"error"`. ⚑ **Never drop `paymentInstructions`**: a
|
|
545
|
-
manual/offline order settles outside the store, so these ARE how the store's
|
|
546
|
-
default customer learns how to pay — render them whenever present, on any
|
|
547
|
-
state. ⚑ An order's totals are **flat** — `order.total`; there is no
|
|
548
|
-
`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)`.
|
|
549
305
|
|
|
550
|
-
**Reference implementation** — a receipt is a convention, not an identity
|
|
551
|
-
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.
|
|
552
307
|
|
|
553
308
|
```jsx
|
|
554
309
|
function OrderReceived() {
|
|
555
310
|
const { status, order, lines, paymentLink, paymentInstructions, error, reload } = useOrderReturn();
|
|
556
311
|
const totals = useTotalsLines(order);
|
|
557
|
-
const formatMoney = useFormatMoney();
|
|
558
312
|
if (status === "loading") return /* confirming copy */;
|
|
559
313
|
if (status === "error") return <><p role="alert">{error.message}</p>
|
|
560
314
|
<button onClick={() => reload()}>Try again</button></>;
|
|
@@ -573,7 +327,7 @@ function OrderReceived() {
|
|
|
573
327
|
.map(([k, v]) => <p key={k}>{k.replace(/_/g, " ")}: {String(v)}</p>)}
|
|
574
328
|
</section>
|
|
575
329
|
)}
|
|
576
|
-
{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>)}
|
|
577
331
|
{totals.filter((t) => !t.hidden).map((t) => <p key={t.key}>{t.label} {t.formatted}</p>)}
|
|
578
332
|
</>
|
|
579
333
|
);
|
|
@@ -588,87 +342,38 @@ useStorefrontSeo(collectionSeo({ title, products: list.products })); // col
|
|
|
588
342
|
// order-received is already noindex via useOrderReturn
|
|
589
343
|
```
|
|
590
344
|
|
|
591
|
-
The `*Seo` builders tolerate a null product, so this sits with the other hooks
|
|
592
|
-
above the status guards.
|
|
345
|
+
The `*Seo` builders tolerate a null product, so this sits with the other hooks above the status guards.
|
|
593
346
|
|
|
594
347
|
## Per-page output budgets
|
|
595
348
|
|
|
596
|
-
| Page | budget (chars) |
|
|
597
|
-
|
|
598
|
-
| Checkout | ≤ 5K |
|
|
599
|
-
| Cart / bag | ≤ 3K
|
|
600
|
-
| Order-received | ≤ 2.5K |
|
|
601
|
-
| Product page | ≤ 5K |
|
|
602
|
-
| Collection | ≤ 3K |
|
|
603
|
-
| Home | ≤ 5K
|
|
604
|
-
| Any single component file | ≤ 4K, hard ceiling 8K |
|
|
605
|
-
|
|
606
|
-
These budgets assume the hooks carry the logic and your markup carries only the
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
modifier bars costs a few hundred characters, and that is what the budget is
|
|
612
|
-
for.
|
|
613
|
-
|
|
614
|
-
## If you drive the storefront from a browser script
|
|
615
|
-
|
|
616
|
-
Whatever you choose to check and however you check it, these are what make a
|
|
617
|
-
working storefront look broken under a script. The common cause is acting
|
|
618
|
-
faster than the cart settles: the hooks are optimistic and debounced, so the DOM
|
|
619
|
-
is briefly right about the *intent* and wrong about the *state*.
|
|
620
|
-
|
|
621
|
-
- **Wait for the cart, then for each row.** Two waits, neither optional. Before
|
|
622
|
-
the first action, wait for the initial load to settle — `status` leaves
|
|
623
|
-
`"loading"` exactly once, so the signal is the loaded UI (a row, or the empty
|
|
624
|
-
state), never a fixed sleep. Then after every stepper click wait for **that
|
|
625
|
-
row**: the click starts a 250ms debounce before the request even leaves, so
|
|
626
|
-
reading the quantity or total straight after gives the optimistic number and
|
|
627
|
-
stale totals, and two quick clicks send **one** request for the final number.
|
|
628
|
-
Wait for the row's busy state to clear (`aria-busy`, re-enabled buttons) before
|
|
629
|
-
reading or clicking again.
|
|
630
|
-
- **Scope actions to the visible drawer.** With a drawer, the page can hold two
|
|
631
|
-
"Remove" buttons for one line — drawer and cart page behind it — and a
|
|
632
|
-
closed-but-mounted drawer keeps its copies clickable. Query inside the open
|
|
633
|
-
drawer's container, not the document. A click that seems to do nothing usually
|
|
634
|
-
hit the hidden copy.
|
|
635
|
-
- **Remove lines one at a time.** Clicking every "Remove" in one pass fails on
|
|
636
|
-
its own terms: cart calls are serialized, each removal re-renders the list, and
|
|
637
|
-
buttons collected up front are detached by the time the loop reaches them.
|
|
638
|
-
Remove one, wait for the row to disappear, then the next.
|
|
639
|
-
- **Verify the checkout navigation before cleaning up.** Confirm you are on
|
|
640
|
-
`/checkout` — URL plus a field of the form on screen — before emptying the cart
|
|
641
|
-
or moving on. Tearing the cart down while still on the cart page, or
|
|
642
|
-
mid-navigation, produces an empty checkout that reads as a routing bug.
|
|
643
|
-
- **Filling the checkout.** Every field is a controlled React input, so writing
|
|
644
|
-
`el.value` changes nothing React sees. Use the harness's own fill (it
|
|
645
|
-
dispatches `input` + `change`) — never lift the native setter off
|
|
646
|
-
`HTMLInputElement.prototype` and call `descriptor.set(v)`: detached from the
|
|
647
|
-
element it throws `Illegal invocation`, and the workaround it is reaching for
|
|
648
|
-
is what the fill helper already does.
|
|
649
|
-
- **`placeOrder` ends the page.** It navigates with `window.location.assign`
|
|
650
|
-
(above), so a script that placed an order loses its page context and can land
|
|
651
|
-
back at `/` — while the order itself was created normally. That is the hard
|
|
652
|
-
navigation, not a broken redirect. The confirmation is reachable at any time
|
|
653
|
-
from a fresh navigation to `/order-received?order_id=…&order_key=…` (the ids
|
|
654
|
-
come back in `placeOrder`'s result, and `commerce/admin-orders` `search` has
|
|
655
|
-
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.
|
|
656
364
|
|
|
657
365
|
## Done — forget this file
|
|
658
366
|
|
|
659
|
-
- [ ] Catalog UI
|
|
660
|
-
- [ ] **One** `<StorefrontProvider>` above every storefront route
|
|
661
|
-
- [ ] Every page's imports came from its section's import line
|
|
662
|
-
- [ ] Pages branch on `status`;
|
|
663
|
-
- [ ]
|
|
664
|
-
- [ ]
|
|
665
|
-
- [ ]
|
|
666
|
-
- [ ]
|
|
667
|
-
- [ ]
|
|
668
|
-
- [ ]
|
|
669
|
-
- [ ] No hook logic was re-implemented by hand (address fields, quantity clamps, totals rows, shipping/payment branching).
|
|
670
|
-
- [ ] The storefront carries the design you settled on before reading this file — no page ships the reference snippets' bare structure or placeholder copy.
|
|
671
|
-
- [ ] Specs and axes are rendered by what they are (`productSpecs`' `type`/`key`, `axis.key`) — not one uniform label/value table and one identical chip row.
|
|
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.
|
|
672
377
|
- [ ] Every page is within its budget above.
|
|
673
378
|
|
|
674
379
|
Record these lines in your working notes; do not re-read this file.
|
|
@@ -679,3 +384,4 @@ Record these lines in your working notes; do not re-read this file.
|
|
|
679
384
|
- Branch cart/list/product UI on `status`, never on `isEmpty`/nullable data — `isEmpty` is false while loading by design.
|
|
680
385
|
- Variant options that aren't buyable render disabled, never hidden; one control per axis, never a list of variations.
|
|
681
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.
|