@base44/app-plugin-commerce 0.2.5 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/skills/commerce/SKILL.md +68 -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 +204 -431
- 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 +9 -9
- 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 +118 -0
- package/src/commerce/storefront/index.js +39 -15
- package/src/commerce/storefront/pickers.jsx +84 -23
- package/src/commerce/storefront/useAddressForm.js +63 -26
- 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 +63 -0
- package/src/commerce/storefront/useProduct.js +112 -41
- package/src/commerce/storefront/useProductList.js +7 -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,40 @@ 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
|
+
- "Hooks return plain states and handlers (label, disabled, value, set, addToCart) — you write every element and attribute from them; render the state-driven text (buy.label, f.error, hint.message) and never re-derive what a hook already resolves."
|
|
14
|
+
- "Identity is encoded once — design classes in index.css plus one or two signature moments per page; a utility run that appears twice becomes a class, full-screen states go through the shared StatusScreen, and every page is measured (wc -c) against its char budget."
|
|
13
15
|
---
|
|
14
16
|
|
|
15
17
|
# 02 — Storefront
|
|
16
18
|
|
|
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.
|
|
19
|
+
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 encode that look **once** as design classes (["Design language"](#design-language--once-before-any-page) below) and build every page in them — the snippets in this file are implementation reference, not design input.
|
|
20
|
+
|
|
21
|
+
Two mechanics to hold everywhere:
|
|
22
|
+
|
|
23
|
+
- **States and handlers, not magic.** Hooks return plain values — strings, booleans, arrays, callbacks (`buy.label`, `buy.disabled`, `buy.addToCart`, `f.value`, `f.set`) — never ready-made prop objects to spread. You write every element and every attribute; the hook guarantees the values are right. The flip side: **render what the hook resolves** — the button's text is `{buy.label}`, its gate is `disabled={buy.disabled}` — and never re-derive a state the hook already owns (a ternary chain over `adding`/`purchasable` re-implements `buy.state` wrong).
|
|
24
|
+
- **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.
|
|
25
|
+
|
|
26
|
+
**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
27
|
|
|
66
28
|
## Setup — once
|
|
67
29
|
|
|
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:
|
|
30
|
+
Nearly every store has shared chrome, so **start from a pathless layout route** — it also keeps the admin outside the storefront's provider:
|
|
72
31
|
|
|
73
32
|
```jsx
|
|
74
33
|
import { Routes, Route, Outlet } from "react-router-dom";
|
|
75
|
-
import { StorefrontProvider } from "@/commerce/storefront";
|
|
34
|
+
import { StorefrontProvider, CartUIProvider } from "@/commerce/storefront";
|
|
76
35
|
import { base44 } from "@/api/base44Client";
|
|
77
36
|
import AdminApp from "@/commerce/admin";
|
|
78
37
|
|
|
79
|
-
// StoreLayout is YOURS: <Nav/> (its cart badge calls useCart) + <Outlet/> + <Footer/>.
|
|
80
|
-
function StoreLayout() { return <><Nav /><Outlet /><Footer /></>; }
|
|
81
|
-
|
|
82
38
|
<BrowserRouter>
|
|
83
39
|
<Routes> {/* ONE <Routes> — merge new pages into the app's */}
|
|
84
|
-
<Route element={
|
|
40
|
+
<Route element={
|
|
41
|
+
<StorefrontProvider base44={base44}>
|
|
42
|
+
<CartUIProvider> {/* only if the cart is a drawer — see Cart below */}
|
|
43
|
+
<StoreLayout /> {/* YOURS: header + <Outlet/> + footer + drawer */}
|
|
44
|
+
</CartUIProvider>
|
|
45
|
+
</StorefrontProvider>
|
|
46
|
+
}>
|
|
85
47
|
<Route path="/" element={<Home />} />
|
|
86
48
|
<Route path="/product/:slug" element={<ProductPage />} />
|
|
87
49
|
<Route path="/bag" element={<Bag />} />
|
|
@@ -93,30 +55,53 @@ function StoreLayout() { return <><Nav /><Outlet /><Footer /></>; }
|
|
|
93
55
|
</BrowserRouter>
|
|
94
56
|
```
|
|
95
57
|
|
|
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.
|
|
58
|
+
⚑ **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`.
|
|
101
59
|
|
|
102
|
-
|
|
103
|
-
Then wrap `<Routes>` directly and skip the layout route:
|
|
60
|
+
## Design language — once, before any page
|
|
104
61
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
```
|
|
62
|
+
The measured cost driver of a generated storefront is not wiring — it is decoration repeated inline. In audited builds ~35% of every hand-written file was className strings, the same 150-char utility run pasted onto every panel, button and heading. Encode the store's identity **once**, then spend it everywhere:
|
|
63
|
+
|
|
64
|
+
- In `index.css`, set the palette/typography variables, then define the store's recurring surfaces as **10–15 composable classes** in Tailwind's components layer, named in *this* store's language:
|
|
65
|
+
|
|
66
|
+
```css
|
|
67
|
+
@layer components {
|
|
68
|
+
.panel { @apply border border-border/40 bg-card p-6; }
|
|
69
|
+
.btn-cta { @apply w-full py-4 bg-primary text-primary-foreground text-xs uppercase tracking-[0.2em] hover:bg-primary/90 transition-colors disabled:opacity-40 disabled:cursor-not-allowed; }
|
|
70
|
+
.label-mono { @apply text-xs uppercase tracking-[0.2em] text-muted-foreground; }
|
|
71
|
+
.field { @apply w-full border border-border bg-transparent px-4 py-3 text-sm outline-none focus:border-primary; }
|
|
72
|
+
.choice-row { @apply flex items-center gap-4 p-4 border border-border/40 cursor-pointer transition-colors hover:border-primary/50; }
|
|
73
|
+
/* …heading scale, link style, price, alert — whatever THIS store repeats */
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Pages then carry short names plus at most a couple of layout utilities. ⚑ **A utility run that appears twice becomes a class** — pasting the same string onto a second element means it belongs in `index.css`.
|
|
78
|
+
|
|
79
|
+
- **One `<StatusScreen>` component for every full-screen state.** Loading, empty, error, not-found and order-submitted screens are one layout with different words. It is stateless — every hook already hands the page a plain `status` enum to branch on — so the whole component is this, restyled:
|
|
110
80
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
81
|
+
```jsx
|
|
82
|
+
// src/components/store/StatusScreen.jsx — the store's full-screen states, styled once
|
|
83
|
+
export default function StatusScreen({ title, message, action, busy }) {
|
|
84
|
+
return (
|
|
85
|
+
<div role="status" aria-busy={busy || undefined} className="…"> {/* centered, full-height */}
|
|
86
|
+
{busy && <span className="…" aria-hidden="true" />} {/* your spinner/pulse */}
|
|
87
|
+
<h1 className="…">{title}</h1>
|
|
88
|
+
{message && <p className="…">{message}</p>}
|
|
89
|
+
{action}
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Every page's guards then become one-liners:
|
|
96
|
+
|
|
97
|
+
```jsx
|
|
98
|
+
if (p.status === "loading") return <StatusScreen busy title="Loading…" />;
|
|
99
|
+
if (p.status === "not_found") return <StatusScreen title="Not found" action={<Link to="/">Back to the shop</Link>} />;
|
|
100
|
+
```
|
|
115
101
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
its whole lifecycle.
|
|
102
|
+
⚑ Once `StatusScreen` exists, no page hand-draws a full-screen status branch — and don't wrap it in a status-mapping hook either; the copy per state is the page's, in words.
|
|
103
|
+
|
|
104
|
+
**Concentrate identity; don't diffuse it.** The design classes already carry the store's look on every surface. On top of them, spend bespoke markup on **one or two signature moments per page** — the hero, the one product-page module that shows what these products are judged on — and render everything else as conventions in the store's classes. The signature budget concentrates where the products are: **the product page stays the storefront's richest surface** — its section's rules to render axes and specs *by what they are* hold with full force; the richness there is semantic (what the controls and rows show), which costs words, not chrome. One navigation affordance per control (a gallery gets thumbnails *or* arrows, never both plus dots); checkout, bag and order-received are convention surfaces — the classes style them, no bespoke widgets. Identity is legible in the tokens and the signature moments; decorating every element produces bulk, not character.
|
|
120
105
|
|
|
121
106
|
## Product list / collection
|
|
122
107
|
|
|
@@ -124,103 +109,52 @@ its whole lifecycle.
|
|
|
124
109
|
import { useProductList, useCategories, useProductPrice, useStoreInfo, useStorefrontSeo, collectionSeo, productSpecs } from "@/commerce/storefront";
|
|
125
110
|
```
|
|
126
111
|
|
|
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:
|
|
112
|
+
(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
113
|
|
|
131
114
|
```jsx
|
|
132
115
|
const list = useProductList({ per_page: 24 });
|
|
133
116
|
const { settings } = useStoreInfo();
|
|
134
117
|
useStorefrontSeo(collectionSeo({ title: "…", products: list.products, storeName: settings?.store_name }));
|
|
135
118
|
|
|
136
|
-
if (list.status === "loading") return
|
|
137
|
-
if (list.status === "error") return
|
|
138
|
-
const products = list.products; // always an array — never null
|
|
119
|
+
if (list.status === "loading") return <StatusScreen busy title="…" />;
|
|
120
|
+
if (list.status === "error") return <StatusScreen title="…" action={<button type="button" onClick={list.reload}>Try again</button>} />;
|
|
121
|
+
const products = list.products; // always an array — never null
|
|
139
122
|
```
|
|
140
123
|
|
|
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`.
|
|
124
|
+
`useProductList(params)` → `{ status, products, hasNext, next, loadMore, busy, 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 paging whenever `hasNext` is true** — `{list.hasNext && <button type="button" onClick={list.next} disabled={list.busy}>Next</button>}` (append mode: `onClick={list.loadMore}` for "Load more"); 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).
|
|
125
|
+
|
|
126
|
+
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: lead each card with the one or two fields *these* products are judged on (via `productSpecs`) rather than the default name/price/stars trio — but the card is your design classes plus those fields, not a bespoke widget.
|
|
127
|
+
|
|
128
|
+
**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
129
|
|
|
175
130
|
## Product page
|
|
176
131
|
|
|
177
132
|
```jsx
|
|
178
|
-
import { useProduct, useProductGallery,
|
|
133
|
+
import { useProduct, useProductGallery, useAddToCart, useProductSpecs, useStoreInfo, useStorefrontSeo, productSeo, variantAxes } from "@/commerce/storefront";
|
|
179
134
|
```
|
|
180
135
|
|
|
181
|
-
(Add `useProductReviews` only if the store has reviews; drop `
|
|
182
|
-
these products carry no modifiers.)
|
|
136
|
+
(Add `useProductReviews` only if the store has reviews; drop `useProductSpecs` if these products carry no modifiers.)
|
|
183
137
|
|
|
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:
|
|
138
|
+
`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
139
|
|
|
194
140
|
```jsx
|
|
195
141
|
const p = useProduct(slug);
|
|
196
142
|
const g = useProductGallery(p.product, p.view);
|
|
197
|
-
const buy =
|
|
143
|
+
const buy = useAddToCart(p, { labels: { ready: "Add to bag" } });
|
|
144
|
+
const specs = useProductSpecs(p.product, { pick: ["material", "care"] });
|
|
198
145
|
const { settings } = useStoreInfo(); // the ONLY source of store name + currency
|
|
199
146
|
useStorefrontSeo(productSeo(p.product, p.view, { storeName: settings?.store_name, currency: settings?.currency }));
|
|
200
147
|
|
|
201
|
-
if (p.status === "loading") return
|
|
202
|
-
if (p.status === "not_found") return /*
|
|
148
|
+
if (p.status === "loading") return <StatusScreen busy title="…" />;
|
|
149
|
+
if (p.status === "not_found") return <StatusScreen title="…" action={/* link back to the catalog */} />;
|
|
203
150
|
const { product, view, price, categories } = p;
|
|
204
151
|
```
|
|
205
152
|
|
|
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):
|
|
153
|
+
Build your layout from — all optional, each one hook, **not one component style**:
|
|
154
|
+
|
|
155
|
+
- **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.
|
|
156
|
+
- **Gallery** — `g` → `{ hasImages, images, active, activeIndex, setActiveIndex, next, prev }`. The active image follows the variant selection; `hasImages: false` means render your placeholder.
|
|
157
|
+
- **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. **Render each axis by what it is** — swatches for a colour axis, chips with a size guide beside a size axis; every axis as the identical chip row is a generated-page tell. The differentiation is semantic — what the control *shows* — built from your classes, not extra chrome around each row. The shape of the map:
|
|
224
158
|
|
|
225
159
|
```jsx
|
|
226
160
|
{variantAxes(view, p.pick).map((axis) => (
|
|
@@ -233,129 +167,42 @@ anywhere.) Build your layout from:
|
|
|
233
167
|
</fieldset>
|
|
234
168
|
))}
|
|
235
169
|
```
|
|
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
170
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
265
|
-
Design the two or three that carry *this* product's meaning; branch on `s.key`
|
|
266
|
-
instead when one particular modifier deserves its own treatment. And they need
|
|
267
|
-
not sit in one block — a spec can go under the gallery, beside the price, or
|
|
268
|
-
inside the description.
|
|
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.
|
|
171
|
+
- **Buy box** — `<button type="button" onClick={buy.addToCart} disabled={buy.disabled}>{buy.label}</button>` is the whole button. ⚑ **Both the text and the gate come from the hook** — `{buy.label}` follows the ready/adding/sold-out/needs-selection precedence (`buy.state`; override copy via `labels`), so never hardcode the button text or re-derive `disabled` from your own ternary chain. ⚑ 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.
|
|
172
|
+
- **Description** — `product.description` is HTML; render as rich text, `short_description` above it.
|
|
173
|
+
- **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. **This is the product page's one signature-moment candidate**: branch on `type` (or `key`) for the two or three `picked` specs that carry *this* product's meaning, and render `rest` as plain rows in your classes — not everything into one uniform table, and not a bespoke widget per spec either. `[]` means no section at all.
|
|
174
|
+
- **Breadcrumbs** — build from `categories` (`/collection?category_id=${c.id}`); skip on a flat catalog. Ribbons are labels, not breadcrumbs.
|
|
281
175
|
|
|
282
176
|
### Reviews — optional
|
|
283
177
|
|
|
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).
|
|
178
|
+
**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
179
|
|
|
300
180
|
## Cart / bag
|
|
301
181
|
|
|
302
182
|
```jsx
|
|
303
|
-
import { useCart, CartLine, useTotalsLines, useCoupon,
|
|
183
|
+
import { useCart, CartLine, useTotalsLines, useCoupon, useCartUI, useUpsell } from "@/commerce/storefront";
|
|
304
184
|
```
|
|
305
185
|
|
|
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.
|
|
186
|
+
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()`.
|
|
187
|
+
|
|
188
|
+
⚑ 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.
|
|
189
|
+
|
|
190
|
+
⚑ **`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.
|
|
191
|
+
|
|
192
|
+
**Reference wiring** — structure correct, presentation deliberately absent; restyle and rearrange, keep the ⚑ rules:
|
|
345
193
|
|
|
346
194
|
```jsx
|
|
347
195
|
function Bag() {
|
|
348
196
|
const { status, lines, notices } = useCart();
|
|
349
197
|
const totals = useTotalsLines();
|
|
350
|
-
|
|
351
|
-
if (status === "
|
|
352
|
-
if (status === "empty") return /* your empty-bag state, linking back to the catalog */;
|
|
198
|
+
if (status === "loading") return <StatusScreen busy title="…" />;
|
|
199
|
+
if (status === "empty") return <StatusScreen title="…" action={/* link back to the catalog */} />;
|
|
353
200
|
return (
|
|
354
201
|
<>
|
|
355
202
|
{notices.map((n, i) => <p key={i} role="status">{n.message}</p>)}
|
|
356
203
|
{lines.map((line) => (
|
|
357
204
|
<CartLine key={line.item_key} line={line}>
|
|
358
|
-
{(l) => ( /* line: name, attributesLabel, image,
|
|
205
|
+
{(l) => ( /* line: name, attributesLabel, image, totalLabel — l: the controls */
|
|
359
206
|
<li aria-busy={l.pending}> {/* busy scope is THIS row, never the cart */}
|
|
360
207
|
{line.name} {line.attributesLabel}
|
|
361
208
|
<button onClick={l.decrease} disabled={!l.canDecrease || l.pending}
|
|
@@ -365,15 +212,15 @@ function Bag() {
|
|
|
365
212
|
aria-label={`Increase quantity of ${line.name}`}>+</button>
|
|
366
213
|
<button onClick={l.remove} disabled={l.pending}
|
|
367
214
|
aria-label={`Remove ${line.name}`}>Remove</button>
|
|
368
|
-
{
|
|
215
|
+
{l.totalLabel}
|
|
369
216
|
{l.error && <p role="alert">{l.error.message}</p>}
|
|
370
217
|
</li>
|
|
371
218
|
)}
|
|
372
219
|
</CartLine>
|
|
373
220
|
))}
|
|
374
221
|
<CouponField /> {/* useCoupon: code/setCode, apply, applying, error, applied[] + remove */}
|
|
375
|
-
{totals.filter((
|
|
376
|
-
<div key={
|
|
222
|
+
{totals.filter((t) => !t.hidden).map((t) => (
|
|
223
|
+
<div key={t.key}>{t.label} {t.formatted}</div> /* t.emphasis → the total row */
|
|
377
224
|
))}
|
|
378
225
|
<Link to="/checkout">Checkout</Link>
|
|
379
226
|
</>
|
|
@@ -381,69 +228,46 @@ function Bag() {
|
|
|
381
228
|
}
|
|
382
229
|
```
|
|
383
230
|
|
|
384
|
-
No shipping estimator here — checkout reprices shipping and tax from the
|
|
385
|
-
address.
|
|
231
|
+
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
232
|
|
|
387
233
|
### If the cart is a drawer
|
|
388
234
|
|
|
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:
|
|
235
|
+
Same hooks, same rows. The drawer's *state* — open/close, Esc, close-on-route-change, open-on-add — is `useCartUI` (`{ open, openCart, closeCart, toggleCart }`); the markup is yours:
|
|
394
236
|
|
|
395
237
|
```jsx
|
|
396
|
-
|
|
397
|
-
|
|
238
|
+
function StoreLayout() { // inside <CartUIProvider> (see Setup)
|
|
239
|
+
const ui = useCartUI();
|
|
240
|
+
const { itemCount } = useCart();
|
|
241
|
+
return (<>
|
|
242
|
+
<header>… <button type="button" onClick={ui.toggleCart} aria-expanded={ui.open}
|
|
243
|
+
className="…">Bag ({itemCount})</button></header>
|
|
244
|
+
<Outlet />
|
|
245
|
+
{ui.open && (<>
|
|
246
|
+
<div onClick={ui.closeCart} aria-hidden="true" className="…" />
|
|
247
|
+
<aside role="dialog" aria-modal="true" aria-label="Cart" className="…">
|
|
248
|
+
<button type="button" onClick={ui.closeCart} aria-label="Close cart" className="…">×</button>
|
|
249
|
+
{/* your rows: useCart + CartLine, as above */}
|
|
250
|
+
</aside>
|
|
251
|
+
</>)}
|
|
252
|
+
</>);
|
|
253
|
+
}
|
|
398
254
|
```
|
|
399
255
|
|
|
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.
|
|
256
|
+
⚑ **Render the drawer conditionally (`{ui.open && …}`), exactly as above.** The classic drawer bug is a panel translated off-screen but still mounted — its buttons stay clickable, tab-able and visible to screen readers. If you keep it mounted to animate the slide, you own that concern: set the `inert` attribute while closed. The overlay is a click-away surface, not the close control — the named close button inside the panel is.
|
|
412
257
|
|
|
413
258
|
## Checkout
|
|
414
259
|
|
|
415
260
|
```jsx
|
|
416
|
-
import { CheckoutProvider, useCheckoutContext,
|
|
261
|
+
import { CheckoutProvider, useCheckoutContext, usePlaceOrder, useAddressForm, ShippingMethodPicker, PaymentMethodPicker, useCart, useTotalsLines, useCoupon } from "@/commerce/storefront";
|
|
417
262
|
```
|
|
418
263
|
|
|
419
264
|
(`useCoupon` only if the coupon field lives here rather than in the cart.)
|
|
420
265
|
|
|
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.
|
|
266
|
+
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).
|
|
267
|
+
|
|
268
|
+
⚑ 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"; wire each address field from its own states — `value={f.value}` `onChange={f.set}` `autoComplete={f.autoComplete}`, and render `f.error` ("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.
|
|
269
|
+
|
|
270
|
+
**Reference implementation** — the densest wiring in the storefront; read it, then build yours around it:
|
|
447
271
|
|
|
448
272
|
```jsx
|
|
449
273
|
function Checkout() { // hooks read the context BELOW the provider
|
|
@@ -453,10 +277,10 @@ function Checkout() { // hooks read the context BELOW the provider
|
|
|
453
277
|
function CheckoutForm() {
|
|
454
278
|
const { status } = useCart();
|
|
455
279
|
const checkout = useCheckoutContext();
|
|
456
|
-
const
|
|
457
|
-
|
|
458
|
-
if (status === "loading") return
|
|
459
|
-
if (status === "empty") return
|
|
280
|
+
const order = usePlaceOrder();
|
|
281
|
+
if (order.stage === "submitted") return <StatusScreen busy title="…" />; // "taking you to your receipt"
|
|
282
|
+
if (status === "loading") return <StatusScreen busy title="…" />;
|
|
283
|
+
if (status === "empty") return <StatusScreen title="…" action={/* link back to the catalog */} />;
|
|
460
284
|
return (
|
|
461
285
|
<>
|
|
462
286
|
<AddressFields which="billing" />
|
|
@@ -468,28 +292,27 @@ function CheckoutForm() {
|
|
|
468
292
|
{checkout.shipToDifferent && <AddressFields which="shipping" />}
|
|
469
293
|
|
|
470
294
|
<ShippingMethodPicker>
|
|
471
|
-
{({
|
|
472
|
-
<fieldset>{/*
|
|
473
|
-
{
|
|
474
|
-
{status === "none_available" && <p role="alert">We don't deliver to that address yet.</p>}
|
|
295
|
+
{({ hint, mustChoose, methods, chosen }) => (
|
|
296
|
+
<fieldset>{/* renders null for a virtual cart */}
|
|
297
|
+
{hint && <p role={hint.severity === "error" ? "alert" : "status"}>{hint.message}</p>}
|
|
475
298
|
{mustChoose && methods.map((m) => (
|
|
476
299
|
<label key={m.id}>
|
|
477
|
-
<input type="radio" checked={
|
|
478
|
-
{m.title} {
|
|
300
|
+
<input type="radio" name="shipping-method" checked={m.selected} onChange={m.select} />
|
|
301
|
+
{m.title} {m.costLabel}
|
|
479
302
|
</label>
|
|
480
303
|
))}
|
|
481
|
-
{!mustChoose && chosen && <p>{chosen.title} {
|
|
304
|
+
{!mustChoose && chosen && <p>{chosen.title} {chosen.costLabel}</p>}
|
|
482
305
|
</fieldset>
|
|
483
306
|
)}
|
|
484
307
|
</ShippingMethodPicker>
|
|
485
308
|
|
|
486
309
|
<PaymentMethodPicker>
|
|
487
|
-
{({
|
|
310
|
+
{({ hint, single, gateways, selected }) => (
|
|
488
311
|
<fieldset>
|
|
489
|
-
{
|
|
312
|
+
{hint && <p role="alert">{hint.message}</p>}
|
|
490
313
|
{!single && gateways.map((g) => (
|
|
491
314
|
<label key={g.slug}>
|
|
492
|
-
<input type="radio" checked={
|
|
315
|
+
<input type="radio" name="payment-method" checked={g.selected} onChange={g.select} />
|
|
493
316
|
{g.title} {g.description}
|
|
494
317
|
</label>
|
|
495
318
|
))}
|
|
@@ -500,64 +323,51 @@ function CheckoutForm() {
|
|
|
500
323
|
|
|
501
324
|
{/* summary: coupon field (if not in the cart) + useTotalsLines(), as in the cart page */}
|
|
502
325
|
|
|
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>)}
|
|
326
|
+
<button type="button" onClick={order.placeOrder} disabled={order.disabled}>{order.label}</button>
|
|
327
|
+
{order.error && <p role="alert">{order.error.message}</p>}
|
|
328
|
+
{!order.canPlaceOrder && order.blockers.map((b) => <p key={b.code}>{b.message}</p>)}
|
|
508
329
|
</>
|
|
509
330
|
);
|
|
510
331
|
}
|
|
511
332
|
|
|
512
333
|
function AddressFields({ which }) {
|
|
513
|
-
const { fields
|
|
514
|
-
return fields.map((f) => (
|
|
515
|
-
<
|
|
516
|
-
{f.label}{f.required && " *"}
|
|
517
|
-
{f.
|
|
518
|
-
<select value={f.value} onChange={
|
|
519
|
-
<option value="">{f.
|
|
334
|
+
const { fields } = useAddressForm(which);
|
|
335
|
+
return fields.map((f) => (
|
|
336
|
+
<div key={f.key}>
|
|
337
|
+
<label htmlFor={f.id}>{f.label}{f.required && " *"}</label>
|
|
338
|
+
{f.isSelect ? (
|
|
339
|
+
<select id={f.id} value={f.value} onChange={f.set} autoComplete={f.autoComplete}>
|
|
340
|
+
<option value="">{f.placeholder}</option>
|
|
520
341
|
{f.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
|
521
342
|
</select>
|
|
522
|
-
) :
|
|
523
|
-
<input type={f.type} value={f.value} required={f.required}
|
|
524
|
-
onChange={(e) => f.set(e.target.value)} autoComplete={f.autoComplete} />
|
|
525
|
-
)}
|
|
343
|
+
) : <input id={f.id} type={f.type} value={f.value} onChange={f.set} autoComplete={f.autoComplete} />}
|
|
526
344
|
{f.error && <span role="alert">{f.error}</span>}
|
|
527
|
-
</
|
|
345
|
+
</div>
|
|
528
346
|
));
|
|
529
347
|
}
|
|
530
348
|
```
|
|
531
349
|
|
|
350
|
+
⚑ **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.
|
|
351
|
+
|
|
532
352
|
## Order received
|
|
533
353
|
|
|
534
354
|
```jsx
|
|
535
|
-
import { useOrderReturn, useTotalsLines
|
|
355
|
+
import { useOrderReturn, useTotalsLines } from "@/commerce/storefront";
|
|
536
356
|
```
|
|
537
357
|
|
|
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.
|
|
358
|
+
**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
359
|
|
|
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)`).
|
|
360
|
+
⚑ 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
361
|
|
|
550
|
-
**Reference implementation** — a receipt is a convention, not an identity
|
|
551
|
-
surface: take this structure, restyle it to the store, keep every branch.
|
|
362
|
+
**Reference implementation** — a receipt is a convention, not an identity surface: take this structure, restyle it, keep every branch.
|
|
552
363
|
|
|
553
364
|
```jsx
|
|
554
365
|
function OrderReceived() {
|
|
555
366
|
const { status, order, lines, paymentLink, paymentInstructions, error, reload } = useOrderReturn();
|
|
556
367
|
const totals = useTotalsLines(order);
|
|
557
|
-
|
|
558
|
-
if (status === "
|
|
559
|
-
|
|
560
|
-
<button onClick={() => reload()}>Try again</button></>;
|
|
368
|
+
if (status === "loading") return <StatusScreen busy title="…" />; // confirming copy
|
|
369
|
+
if (status === "error") return <StatusScreen title="…" message={error.message}
|
|
370
|
+
action={<button type="button" onClick={() => reload()}>Try again</button>} />;
|
|
561
371
|
return (
|
|
562
372
|
<>
|
|
563
373
|
{status === "paid" && /* thank-you heading */}
|
|
@@ -573,7 +383,7 @@ function OrderReceived() {
|
|
|
573
383
|
.map(([k, v]) => <p key={k}>{k.replace(/_/g, " ")}: {String(v)}</p>)}
|
|
574
384
|
</section>
|
|
575
385
|
)}
|
|
576
|
-
{lines.map((l, i) => <p key={i}>{l.name} {l.attributesLabel} × {l.quantity} — {
|
|
386
|
+
{lines.map((l, i) => <p key={i}>{l.name} {l.attributesLabel} × {l.quantity} — {l.totalLabel}</p>)}
|
|
577
387
|
{totals.filter((t) => !t.hidden).map((t) => <p key={t.key}>{t.label} {t.formatted}</p>)}
|
|
578
388
|
</>
|
|
579
389
|
);
|
|
@@ -588,88 +398,49 @@ useStorefrontSeo(collectionSeo({ title, products: list.products })); // col
|
|
|
588
398
|
// order-received is already noindex via useOrderReturn
|
|
589
399
|
```
|
|
590
400
|
|
|
591
|
-
The `*Seo` builders tolerate a null product, so this sits with the other hooks
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
|
599
|
-
|
|
|
600
|
-
|
|
|
601
|
-
|
|
|
602
|
-
|
|
|
603
|
-
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
faster than the cart settles
|
|
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).
|
|
401
|
+
The `*Seo` builders tolerate a null product, so this sits with the other hooks above the status guards.
|
|
402
|
+
|
|
403
|
+
## Per-page output budgets — measured, not aspirational
|
|
404
|
+
|
|
405
|
+
| Page | budget (chars) |
|
|
406
|
+
|---|---|
|
|
407
|
+
| Checkout | ≤ 3.5K |
|
|
408
|
+
| Cart / bag | ≤ 3K (a drawer is its own component with its own 3K) |
|
|
409
|
+
| Order-received | ≤ 2.5K |
|
|
410
|
+
| Product page | ≤ 5K |
|
|
411
|
+
| Collection | ≤ 3K |
|
|
412
|
+
| Home | ≤ 5K — identity lives in the hero + one editorial moment, in your classes |
|
|
413
|
+
| Any single component file | ≤ 4K, hard ceiling 8K |
|
|
414
|
+
|
|
415
|
+
When the storefront builds clean, **measure** (adjust the paths to your component folder names):
|
|
416
|
+
|
|
417
|
+
```bash
|
|
418
|
+
wc -c src/pages/*.jsx src/components/store*/*.jsx
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
A page over budget has one of two problems — fix in this order, and never by dropping states, branches or ⚑ rules:
|
|
422
|
+
|
|
423
|
+
1. **Inline decoration that belongs in `index.css`** — this is where the chars measurably are. Extract every repeated utility run into the design classes, fold hand-drawn status branches into `StatusScreen`, and cut any second navigation affordance or off-signature widget.
|
|
424
|
+
2. **Re-implemented hook logic** (a buy button's state precedence, a quantity clamp, totals math, drawer state) — go back to the hook and delete your version.
|
|
425
|
+
|
|
426
|
+
## Driving the storefront from a browser script?
|
|
427
|
+
|
|
428
|
+
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
429
|
|
|
657
430
|
## Done — forget this file
|
|
658
431
|
|
|
659
|
-
- [ ] Catalog UI
|
|
660
|
-
- [ ]
|
|
661
|
-
- [ ]
|
|
662
|
-
- [ ]
|
|
663
|
-
- [ ]
|
|
664
|
-
- [ ]
|
|
665
|
-
- [ ]
|
|
666
|
-
- [ ] Variant options
|
|
667
|
-
- [ ] Cart rows scope
|
|
668
|
-
- [ ]
|
|
669
|
-
- [ ]
|
|
670
|
-
- [ ]
|
|
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.
|
|
672
|
-
- [ ] Every page is within its budget above.
|
|
432
|
+
- [ ] Catalog UI in whatever form fits the store, plus a checkout, plus `/order-received` rendering `useOrderReturn`'s states **including `paymentInstructions`**.
|
|
433
|
+
- [ ] `index.css` defines the store's design classes; pages carry class names, not repeated utility runs; every full-screen state goes through the shared `StatusScreen`.
|
|
434
|
+
- [ ] **One** `<StorefrontProvider>` above every storefront route (layout-route pattern); one client, no hand-rolled `cart_token`.
|
|
435
|
+
- [ ] Every page's imports came from its section's import line; nothing imported from `@/commerce/utils`; no unused names.
|
|
436
|
+
- [ ] Pages branch on `status`; gateways/currency/countries read from `useStoreInfo()` only.
|
|
437
|
+
- [ ] Every control renders the hook's states — button text is `buy.label`/`order.label`, gates are the hook's `disabled`, address fields wire `value={f.value}` `onChange={f.set}` `autoComplete={f.autoComplete}` — no re-implemented hook logic.
|
|
438
|
+
- [ ] Coupon field present if the store has coupons; a paging control rendered whenever `hasNext` is true.
|
|
439
|
+
- [ ] Variant options: one control per axis, unbuyable options disabled, not hidden.
|
|
440
|
+
- [ ] Cart rows scope busy state to the row; repeated controls have unique accessible names; a drawer uses `useCartUI` and is rendered conditionally on `ui.open` (closes on route change by itself).
|
|
441
|
+
- [ ] Checkout guards `stage === "submitted"` above its empty-cart branch.
|
|
442
|
+
- [ ] The storefront carries the design you settled on before reading this file — encoded in the design classes plus one or two signature moments per page; on the product page, axes and specs are rendered by what they are (not one uniform table, not one identical chip row); convention surfaces (checkout, bag, order-received) carry the classes and nothing bespoke.
|
|
443
|
+
- [ ] Measured with `wc -c` — every page within its budget above.
|
|
673
444
|
|
|
674
445
|
Record these lines in your working notes; do not re-read this file.
|
|
675
446
|
|
|
@@ -679,3 +450,5 @@ Record these lines in your working notes; do not re-read this file.
|
|
|
679
450
|
- Branch cart/list/product UI on `status`, never on `isEmpty`/nullable data — `isEmpty` is false while loading by design.
|
|
680
451
|
- Variant options that aren't buyable render disabled, never hidden; one control per axis, never a list of variations.
|
|
681
452
|
- 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".
|
|
453
|
+
- Hooks return plain states and handlers (`label`, `disabled`, `value`, `set`, `addToCart`) — you write every element and attribute from them; render the state-driven text (`buy.label`, `f.error`, `hint.message`) and never re-derive what a hook already resolves.
|
|
454
|
+
- Identity is encoded once — design classes in `index.css` plus one or two signature moments per page; a utility run that appears twice becomes a class, full-screen states go through the shared `StatusScreen`, and every page is measured (`wc -c`) against its char budget.
|