@base44/app-plugin-commerce 0.2.1 → 0.2.3
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 +6 -6
- package/base44/agents/commerce/StoreAdmin.jsonc +1 -1
- package/base44/entities/commerce.OrderRefund.jsonc +1 -1
- package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
- package/base44/entities/commerce.Webhook.jsonc +1 -1
- package/base44/functions/commerce/admin-products/entry.ts +1 -1
- package/base44/functions/commerce/admin-reports/entry.ts +1 -1
- package/base44/functions/commerce/payments/entry.ts +2 -2
- package/base44/functions/commerce/seed-store/defaults.ts +1 -1
- package/base44/functions/commerce/seed-store/seed-catalog.ts +64 -6
- package/base44/functions/commerce/storefront-catalog/entry.ts +1 -1
- package/base44/functions/commerce/storefront-checkout/entry.ts +1 -1
- package/base44/shared/commerce/card-payment.stripe.ts +29 -9
- package/base44/shared/commerce/card-payment.ts +1 -1
- package/base44/shared/commerce/payments.ts +2 -2
- package/base44/shared/commerce/scan.ts +1 -1
- package/base44/shared/commerce/sequence.ts +2 -2
- package/package.json +1 -1
- package/scripts/install.js +1 -1
- package/skills/commerce/SKILL.md +36 -26
- package/skills/commerce/docs/api-storefront.md +6 -6
- package/skills/commerce/install/01-install.md +2 -2
- package/skills/commerce/install/02-storefront.md +381 -94
- package/skills/commerce/install/03-data.md +62 -19
- package/skills/commerce/references/catalog-rendering.md +6 -6
- package/skills/commerce/references/online-payments.md +5 -6
- package/skills/commerce/references/reviews.md +5 -5
- package/skills/commerce/references/shipping-and-tax.md +2 -0
- package/src/commerce/admin/README.md +6 -3
- package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
- package/src/commerce/admin/pages/products/Reviews.jsx +1 -1
- package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -1
- package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +1 -1
- package/src/commerce/storefront/index.js +45 -33
- package/src/commerce/storefront/useAddressForm.js +41 -8
- package/src/commerce/storefront/useCartLine.js +37 -0
- package/src/commerce/storefront/useCheckout.jsx +18 -6
- package/src/commerce/storefront/useOrderReturn.js +36 -10
- package/src/commerce/storefront/useProduct.js +72 -0
- package/src/commerce/storefront/useProductGallery.js +4 -0
- package/src/commerce/utils/index.js +9 -6
- package/src/commerce/utils/shipping-promos.js +2 -2
- package/src/commerce/utils/specs.js +26 -0
- package/src/commerce/utils/variants.js +49 -2
- package/src/commerce/storefront/blocks/AddToCartBlock.jsx +0 -86
- package/src/commerce/storefront/blocks/AddressFieldsBlock.jsx +0 -96
- package/src/commerce/storefront/blocks/BreadcrumbsBlock.jsx +0 -52
- package/src/commerce/storefront/blocks/CartLinesBlock.jsx +0 -98
- package/src/commerce/storefront/blocks/CheckoutBlock.jsx +0 -247
- package/src/commerce/storefront/blocks/CouponFieldBlock.jsx +0 -84
- package/src/commerce/storefront/blocks/OrderReceivedBlock.jsx +0 -129
- package/src/commerce/storefront/blocks/ProductGalleryBlock.jsx +0 -66
- package/src/commerce/storefront/blocks/ProductSpecsBlock.jsx +0 -33
- package/src/commerce/storefront/blocks/ProductStripBlock.jsx +0 -55
- package/src/commerce/storefront/blocks/QuantityStepper.jsx +0 -62
- package/src/commerce/storefront/blocks/ReviewsBlock.jsx +0 -191
- package/src/commerce/storefront/blocks/TotalsBlock.jsx +0 -42
- package/src/commerce/storefront/blocks/VariantSelectorBlock.jsx +0 -81
- package/src/commerce/storefront/blocks/index.js +0 -44
|
@@ -21,7 +21,7 @@ What lands in the app, how the admin gets mounted, and the order to do the rest
|
|
|
21
21
|
| `base44/entities/commerce.*.jsonc` | 20 entity schemas, all admin-only RLS |
|
|
22
22
|
| `base44/functions/commerce/` + `shared/` + `agents/` | 16 functions, the engine, the StoreAdmin copilot |
|
|
23
23
|
| `src/commerce/admin/` | the finished admin app — **don't validate it, it ships tested** |
|
|
24
|
-
| `src/commerce/storefront/` + `utils/` | the hooks
|
|
24
|
+
| `src/commerce/storefront/` + `utils/` | the headless hooks you build the storefront UI on ([`./02-storefront.md`](./02-storefront.md)) |
|
|
25
25
|
| `.agents/skills/commerce/` | these docs |
|
|
26
26
|
|
|
27
27
|
<details>
|
|
@@ -61,7 +61,7 @@ import { Navigate } from "react-router-dom";
|
|
|
61
61
|
|
|
62
62
|
- **The `/*` splat is required.** The admin renders nested routes; a bare `path="/store-admin"` matches only the dashboard and every deeper link 404s. Mounting elsewhere: `<AdminApp basePath="/backoffice" />` — the prefix *without* the splat.
|
|
63
63
|
- **Give `/` something.** A blank Base44 app has no `/` route, so after mounting only the admin the app's own URL renders "page not found", which reads exactly like a broken install. Redirect until the storefront exists.
|
|
64
|
-
- **`/order-received` is mandatory**, even for a store that only ever takes offline payments. Every payment link (checkout, the admin's payment link, emails) returns there, and confirming is what marks an order paid — without the route a paying customer hits a 404 and the order stays unpaid. The page
|
|
64
|
+
- **`/order-received` is mandatory**, even for a store that only ever takes offline payments. Every payment link (checkout, the admin's payment link, emails) returns there, and confirming is what marks an order paid — without the route a paying customer hits a 404 and the order stays unpaid. The page is one hook, `useOrderReturn()`, plus your markup for its states ([`./02-storefront.md`](./02-storefront.md)). A different path must be set in Settings → General → *Payment return path*.
|
|
65
65
|
|
|
66
66
|
## Admin-role enforcement — do not weaken
|
|
67
67
|
|
|
@@ -5,19 +5,57 @@ skip_when: "The storefront pages already render against live data and pass the c
|
|
|
5
5
|
forget_when: "The checklist at the bottom passes — every page renders against the seeded catalog and an offline order completes."
|
|
6
6
|
carry_forward:
|
|
7
7
|
- "Payment gateways, currency and countries come from useStoreInfo() only — never off a cart (cart.payment_gateways is always undefined)."
|
|
8
|
-
- "A store with any coupons must have a coupon field
|
|
9
|
-
- "/order-received renders
|
|
8
|
+
- "A store with any coupons must have a coupon field (useCoupon) in the cart or the checkout, or its codes can never be redeemed."
|
|
9
|
+
- "/order-received is mandatory and renders useOrderReturn's states, including paymentInstructions — how a normal (offline) customer learns how to pay."
|
|
10
10
|
- "Branch cart/list/product UI on `status`, never on `isEmpty`/nullable data — `isEmpty` is false while loading by design."
|
|
11
|
+
- "Variant options that aren't buyable render disabled, never hidden; one control per axis, never a list of variations."
|
|
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\"."
|
|
11
13
|
---
|
|
12
14
|
|
|
13
15
|
# 02 — Storefront
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
- **
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
One split decides everything here: **the logic is premade, the UI never is.**
|
|
18
|
+
|
|
19
|
+
- **Logic — hooks, shipped.** Checkout repricing from the address, variant
|
|
20
|
+
resolution, cart state, coupon redemption, review policies, order-return
|
|
21
|
+
verification. Every store's version of these is functionally identical, and
|
|
22
|
+
hand-writing them is where storefront bugs cluster: **never re-implement what
|
|
23
|
+
a hook does.**
|
|
24
|
+
- **UI — yours, always.** Every element, class, layout and word of copy on
|
|
25
|
+
every page. Nothing in `@/commerce/storefront` renders markup or carries CSS,
|
|
26
|
+
so there is no component to drop in and nothing to restyle — the design is
|
|
27
|
+
the part of the storefront only you can do, and it should be designed, not
|
|
28
|
+
assembled.
|
|
29
|
+
|
|
30
|
+
## Before you begin
|
|
31
|
+
|
|
32
|
+
**Decide how the store looks as if this kit did not exist** — identity, type,
|
|
33
|
+
palette, spacing, the shape of a card, how a checkout is laid out — from the
|
|
34
|
+
brief and your own judgement. Then use this file for **how to wire it**:
|
|
35
|
+
everything below is implementation reference and **none of it is design input**.
|
|
36
|
+
The bare tags, flat structure and placeholder copy show where the data goes in
|
|
37
|
+
the fewest characters; they are not a look to adopt, keep or tweak. The finished
|
|
38
|
+
store should look like what you would have built with no kit at all — the kit's
|
|
39
|
+
only job is to make it cost far less code.
|
|
40
|
+
|
|
41
|
+
Each hook returns a complete view-model — a `status` to branch on,
|
|
42
|
+
ready-to-map arrays, handlers, error objects — and its **doc comment (JSDoc) is
|
|
43
|
+
the API reference**: open the hook's file when you need exact shapes; don't
|
|
44
|
+
guess fields. This file gives you the routing, each surface's hook, and the
|
|
45
|
+
render rules that keep a store correct (marked ⚑ — these must survive whatever
|
|
46
|
+
design you build).
|
|
47
|
+
|
|
48
|
+
Where you get a **reference implementation** and where you get only the hook is
|
|
49
|
+
deliberate: **cart, checkout and order-received** have reference code below —
|
|
50
|
+
their wiring is dense enough that reading it is cheaper than deriving it, and
|
|
51
|
+
they are conventions (a form, a receipt) where familiarity beats invention.
|
|
52
|
+
The **identity surfaces** — home, collection, the card, the product page's
|
|
53
|
+
layout — get hooks only, on purpose: reference markup there would make every
|
|
54
|
+
store look the same, and their design is the work only you can do. Either way
|
|
55
|
+
the hooks are high-level enough that a page is a handful of calls plus your
|
|
56
|
+
markup — writing more code than the budgets at the bottom allow means you are
|
|
57
|
+
re-deriving logic a hook already owns. Everything imports from
|
|
58
|
+
`@/commerce/storefront`.
|
|
21
59
|
|
|
22
60
|
## Setup — once
|
|
23
61
|
|
|
@@ -39,9 +77,16 @@ import { base44 } from "@/api/base44Client";
|
|
|
39
77
|
</BrowserRouter>
|
|
40
78
|
```
|
|
41
79
|
|
|
42
|
-
The provider owns the shared client, the store-info cache and **one** shared
|
|
80
|
+
The provider owns the shared client, the store-info cache and **one** shared
|
|
81
|
+
cart, so a header badge, a drawer and the checkout render the same state. Never
|
|
82
|
+
mount a second provider, and never touch the `cart_token` — the provider owns
|
|
83
|
+
its whole lifecycle.
|
|
43
84
|
|
|
44
|
-
> ⚠ **`<Routes>` accepts only `<Route>` children.** Nesting the provider inside
|
|
85
|
+
> ⚠ **`<Routes>` accepts only `<Route>` children.** Nesting the provider inside
|
|
86
|
+
> it — the natural reading of "wrap the storefront routes" — throws at render:
|
|
87
|
+
> `Error: [StorefrontProvider] is not a <Route> component. All component children of <Routes> must be a <Route> or <React.Fragment>`.
|
|
88
|
+
> To scope the provider to storefront routes only, use a pathless **layout
|
|
89
|
+
> route**, the one place a wrapper is legal:
|
|
45
90
|
> ```jsx
|
|
46
91
|
> <Route element={<StorefrontProvider base44={base44}><Outlet /></StorefrontProvider>}>
|
|
47
92
|
> <Route path="/" element={<Home />} />
|
|
@@ -50,124 +95,361 @@ The provider owns the shared client, the store-info cache and **one** shared car
|
|
|
50
95
|
> <Route path="/store-admin/*" element={<AdminApp />} /> {/* outside the provider */}
|
|
51
96
|
> ```
|
|
52
97
|
|
|
53
|
-
## Product list / collection
|
|
98
|
+
## Product list / collection
|
|
99
|
+
|
|
100
|
+
`useProductList(params)` → `{ status, products, hasNext, next, refreshing,
|
|
101
|
+
setParams, reload }`. ⚑ `status` is `"loading" | "ready" | "empty" | "error"`
|
|
102
|
+
— branch on it, so a failed request renders as a failure instead of an empty
|
|
103
|
+
grid. `setParams({ category_id, search, on_sale, min_price, in_stock_only })`
|
|
104
|
+
resets to page 1 and keeps the current rows on screen (`refreshing`) while the
|
|
105
|
+
page loads. `useCategories()` / `useRibbons()` → `{ items }` (arrays, children
|
|
106
|
+
nested).
|
|
107
|
+
|
|
108
|
+
Your card renders `name`, `images[0]?.src` (⚑ **images are `{src,name,alt}`
|
|
109
|
+
objects and the array may be empty — render a placeholder, never a broken
|
|
110
|
+
`<img>`**), `useProductPrice(row).label` (already "From €19.99" when the
|
|
111
|
+
product sells variants — there is no product `type` flag), `on_sale`,
|
|
112
|
+
`short_description`, `stock_status`, `average_rating`/`rating_count`,
|
|
113
|
+
`ribbons`. Full field matrix:
|
|
114
|
+
[`../references/catalog-rendering.md`](../references/catalog-rendering.md).
|
|
115
|
+
|
|
116
|
+
**Rails** (featured row, "new in") are the same hook with a filter
|
|
117
|
+
(`{ featured: true, per_page: 4 }`). ⚑ Any filter may legitimately match
|
|
118
|
+
nothing — render *nothing* then, never a heading over an empty row. Upsells
|
|
119
|
+
beside a product need no query: `p.upsells` / `p.crossSells` from `useProduct`.
|
|
120
|
+
|
|
121
|
+
## Product page
|
|
122
|
+
|
|
123
|
+
`useProduct(slug)` (or `{ id }`) owns fetch + variant selection + quantity +
|
|
124
|
+
price, race-safe, selection mirrored to the URL. ⚑ `status: "not_found"` is a
|
|
125
|
+
404 page, not a spinner.
|
|
126
|
+
|
|
127
|
+
⚑ **Call every hook above the status guards.** This page needs more than one,
|
|
128
|
+
and a hook placed after an early `return` runs on some renders but not others —
|
|
129
|
+
React then throws *"Rendered more hooks than during the previous render"* the
|
|
130
|
+
moment the product resolves. All of these tolerate a null/loading product
|
|
131
|
+
precisely so they can sit at the top:
|
|
54
132
|
|
|
55
133
|
```jsx
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (list.status === "empty") return <EmptyState />;
|
|
61
|
-
|
|
62
|
-
{list.products.map((p) => <MyCard key={p.id} product={p} />)} {/* your card */}
|
|
63
|
-
{list.hasNext && <button onClick={list.next}>Next</button>}
|
|
64
|
-
// list.setParams({ category_id, search, on_sale: true, min_price, in_stock_only })
|
|
65
|
-
// → resets to page 1, keeps the current rows on screen (list.refreshing) while the page loads
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
`status` is `"loading" | "ready" | "empty" | "error"` — branch on it, so a failed request renders as a failure instead of an empty grid. `useRibbons()` has the same shape as `useCategories()`.
|
|
69
|
-
|
|
70
|
-
A card uses `name`, `images[0]?.src` (**images are `{src,name,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`. Full field matrix: [`../references/catalog-rendering.md`](../references/catalog-rendering.md).
|
|
134
|
+
const p = useProduct(slug);
|
|
135
|
+
const g = useProductGallery(p.product, p.view);
|
|
136
|
+
const buy = useAddToCartButton(p, { onAdded: () => navigate("/bag") });
|
|
137
|
+
useStorefrontSeo(productSeo(p.product, p.view, { storeName, currency }));
|
|
71
138
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
```jsx
|
|
75
|
-
<ProductStripBlock params={{ featured: true, per_page: 4 }} title="Featured"
|
|
76
|
-
renderCard={(p) => <MyCard key={p.id} product={p} />} />
|
|
77
|
-
// or products={p.upsells} (from useProduct) for rows you already have. A filter may match nothing —
|
|
78
|
-
// the block renders nothing at all rather than a heading over an empty row.
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
## Product page — custom layout, blocks inside
|
|
82
|
-
|
|
83
|
-
```jsx
|
|
84
|
-
const p = useProduct(slug); // slug from the route; { id } also works
|
|
85
|
-
if (p.status === "loading") return <Skeleton />;
|
|
86
|
-
if (p.status === "not_found") return <NotFound />; // a 404 is a status, not a spinner
|
|
139
|
+
if (p.status === "loading") return /* your loading state */;
|
|
140
|
+
if (p.status === "not_found") return /* your 404 */;
|
|
87
141
|
const { product, view, price, categories } = p;
|
|
88
|
-
|
|
89
|
-
<BreadcrumbsBlock categories={categories} current={product.name} />
|
|
90
|
-
<ProductGalleryBlock product={product} view={view} imageClassName="aspect-[3/4] object-cover" />
|
|
91
|
-
<h1>{product.name}</h1>
|
|
92
|
-
<p>{price.label}{price.compareAtLabel && <s>{price.compareAtLabel}</s>}</p>
|
|
93
|
-
<VariantSelectorBlock view={view} onPick={p.pick} /> {/* one control per axis */}
|
|
94
|
-
<AddToCartBlock product={p} onAdded={() => navigate("/bag")} /> {/* pass the whole hook result */}
|
|
95
|
-
<div dangerouslySetInnerHTML={{ __html: product.description }} /> {/* HTML — render as rich text */}
|
|
96
|
-
<ProductSpecsBlock product={product} /> {/* product.meta_data → spec table */}
|
|
97
|
-
<ReviewsBlock product={product} />
|
|
98
142
|
```
|
|
99
143
|
|
|
100
|
-
`
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
144
|
+
(`variantAxes` and `productSpecs` are plain functions, not hooks — they can go
|
|
145
|
+
anywhere.) Build your layout from:
|
|
146
|
+
|
|
147
|
+
- **Price** — `price.label`, plus `price.compareAtLabel` (struck through) when
|
|
148
|
+
on sale. Never read `product.price` directly — the parent's price is a
|
|
149
|
+
rolled-up from-price.
|
|
150
|
+
- **Gallery** — `g` from above → `{ hasImages, images, active, activeIndex,
|
|
151
|
+
setActiveIndex, next, prev }`. The active image already follows the variant
|
|
152
|
+
selection; `hasImages: false` means render your placeholder.
|
|
153
|
+
- **Variant selector** — `variantAxes(view, p.pick)` → one entry per axis:
|
|
154
|
+
`{ key, name, selectedOption, options: [{ value, selected, disabled,
|
|
155
|
+
outOfStock, pick }] }`. Map it to any control — buttons, swatches, a dropdown.
|
|
156
|
+
⚑ **One control per axis, never a list of variations** (`Red / S`, `Red / M`,
|
|
157
|
+
… is n × m noise), and ⚑ **an unbuyable option renders `disabled`, never
|
|
158
|
+
hidden** (`outOfStock` stays visible, just marked) — a customer who can't see
|
|
159
|
+
that a size exists assumes the store doesn't carry it. `view.missingAxes`
|
|
160
|
+
names what's still unpicked. The shape of the map (the one interaction agents
|
|
161
|
+
reliably get wrong — the control itself is yours):
|
|
162
|
+
|
|
163
|
+
```jsx
|
|
164
|
+
{variantAxes(view, p.pick).map((axis) => (
|
|
165
|
+
<fieldset key={axis.key}>{/* label from axis.name / axis.selectedOption */}
|
|
166
|
+
{axis.options.map((o) => (
|
|
167
|
+
<button key={o.value} disabled={o.disabled} aria-pressed={o.selected} onClick={o.pick}>
|
|
168
|
+
{o.value}{/* o.outOfStock → mark visibly, keep clickable-looking off */}
|
|
169
|
+
</button>
|
|
170
|
+
))}
|
|
171
|
+
</fieldset>
|
|
172
|
+
))}
|
|
173
|
+
```
|
|
174
|
+
- **Buy box** — `buy` from above → `{ add, adding, error, disabled, soldOut,
|
|
175
|
+
needsSelection, quantity, increase, decrease, canIncrease, canDecrease,
|
|
176
|
+
showQuantity }`. It gates on purchasability, recovers from every add failure
|
|
177
|
+
and clamps quantity to stock and `sold_individually`. ⚑ Render `error.message`
|
|
178
|
+
inline; ⚑ `showQuantity: false` means no stepper (only 1 can be bought); the
|
|
179
|
+
button label should reflect `adding`/`soldOut`/`needsSelection` — the words
|
|
180
|
+
are yours.
|
|
181
|
+
- **Description** — `product.description` is HTML; render as rich text
|
|
182
|
+
(`dangerouslySetInnerHTML`), `short_description` above it.
|
|
183
|
+
- **Specs** — `productSpecs(product)` → `[{ key, label, value }]` from
|
|
184
|
+
`meta_data` (Material, Care). `[]` means no section at all.
|
|
185
|
+
- **Breadcrumbs** — build from `categories`
|
|
186
|
+
(`/collection?category_id=${c.id}`); skip on a flat catalog. Ribbons are
|
|
187
|
+
labels, not breadcrumbs.
|
|
188
|
+
|
|
189
|
+
All optional — include what this store's products actually have.
|
|
190
|
+
|
|
191
|
+
### Reviews — optional
|
|
192
|
+
|
|
193
|
+
**Build reviews only if the store wants them** — because the brief asks, or the
|
|
194
|
+
products are the kind customers rate. No review UI means no reviews, and that
|
|
195
|
+
is a complete outcome. (If you skip them, don't put star ratings on cards
|
|
196
|
+
either — an average of nothing is `0`.)
|
|
197
|
+
|
|
198
|
+
`useProductReviews(product, { policy, user })` is the whole surface: `items`,
|
|
199
|
+
paging (`hasNext`/`loadMore`), `averageRating`/`ratingCount`, and the submit
|
|
200
|
+
form — `form`/`setField`/`fieldErrors` (matching the server's error codes),
|
|
201
|
+
`valid`, `submit`, `requiresEmail` (false for a signed-in visitor),
|
|
202
|
+
`reviewBlockedReason` (`"login_required"` / `"not_a_buyer"` under the stricter
|
|
203
|
+
policies). ⚑ The confirmation copy is `message`, **taken from the server's
|
|
204
|
+
response** — a store with auto-approval on says "published", not "awaiting
|
|
205
|
+
approval", so render `message`, never your own text. `policy` is
|
|
206
|
+
`"open" | "login" | "verified_buyers"`. Details beyond this:
|
|
207
|
+
[`../references/reviews.md`](../references/reviews.md).
|
|
208
|
+
|
|
209
|
+
## Cart / bag
|
|
210
|
+
|
|
211
|
+
A cart *page* is optional: a store selling one made-to-order piece reads better
|
|
212
|
+
as buy-now straight to checkout. The surface is four hooks: `useCart()`
|
|
213
|
+
(`status`, `lines`, `notices`), `CartLine` (headless render-prop binding
|
|
214
|
+
`useCartLine` per row — quantity stepping that clamps, coalesces and recovers),
|
|
215
|
+
`useTotalsLines()`, `useCoupon()`.
|
|
216
|
+
|
|
217
|
+
⚑ Rules: branch on `status`, never on emptiness while loading. Render
|
|
218
|
+
`notices` — they say what auto-dropped from the cart and why. Render every
|
|
219
|
+
non-`hidden` totals line rather than hardcoding subtotal/total — a hand-written
|
|
220
|
+
summary omits discount and tax, then stops adding up the day a coupon or a tax
|
|
221
|
+
rate exists. **A store with any coupons must have a coupon field** (here or in
|
|
222
|
+
the checkout): coupons are admin-only data, redeemable only through a field the
|
|
223
|
+
customer types into — if no field exists anywhere, don't seed coupons and don't
|
|
224
|
+
write "use WELCOME10" in the copy.
|
|
225
|
+
|
|
226
|
+
**Reference implementation** — read once for the wiring, then write your own
|
|
227
|
+
page: the structure below is correct, the presentation is deliberately absent.
|
|
228
|
+
Restyle, rearrange, split into your own components; the ⚑ rules are the part
|
|
229
|
+
that must survive.
|
|
107
230
|
|
|
108
231
|
```jsx
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
232
|
+
function Bag() {
|
|
233
|
+
const { status, lines, notices } = useCart();
|
|
234
|
+
const totals = useTotalsLines();
|
|
235
|
+
const formatMoney = useFormatMoney();
|
|
236
|
+
if (status === "loading") return /* your loading state */;
|
|
237
|
+
if (status === "empty") return /* your empty-bag state, linking back to the catalog */;
|
|
238
|
+
return (
|
|
239
|
+
<>
|
|
240
|
+
{notices.map((n, i) => <p key={i} role="status">{n.message}</p>)}
|
|
241
|
+
{lines.map((line) => (
|
|
242
|
+
<CartLine key={line.item_key} line={line}>
|
|
243
|
+
{(l) => ( /* line: name, attributesLabel, image, total — l: the controls */
|
|
244
|
+
<li>
|
|
245
|
+
{line.name} {line.attributesLabel}
|
|
246
|
+
<button onClick={l.decrease} disabled={!l.canDecrease || l.pending}>−</button>
|
|
247
|
+
{l.quantity}
|
|
248
|
+
<button onClick={l.increase} disabled={!l.canIncrease || l.pending}>+</button>
|
|
249
|
+
<button onClick={l.remove}>Remove</button>
|
|
250
|
+
{formatMoney(line.total)}
|
|
251
|
+
{l.error && <p role="alert">{l.error.message}</p>}
|
|
252
|
+
</li>
|
|
253
|
+
)}
|
|
254
|
+
</CartLine>
|
|
255
|
+
))}
|
|
256
|
+
<CouponField /> {/* useCoupon: code/setCode, apply, applying, error, applied[] + remove */}
|
|
257
|
+
{totals.filter((l) => !l.hidden).map((l) => (
|
|
258
|
+
<div key={l.key}>{l.label} {l.formatted}</div> /* l.emphasis → the total row */
|
|
259
|
+
))}
|
|
260
|
+
<Link to="/checkout">Checkout</Link>
|
|
261
|
+
</>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
117
264
|
```
|
|
118
265
|
|
|
119
|
-
|
|
266
|
+
No shipping estimator here — checkout reprices shipping and tax from the
|
|
267
|
+
address.
|
|
268
|
+
|
|
269
|
+
## Checkout
|
|
270
|
+
|
|
271
|
+
The state machine is `useCheckout`, shared across the page's regions by
|
|
272
|
+
`CheckoutProvider` + `useCheckoutContext()`. It reprices shipping/tax from the
|
|
273
|
+
address automatically (debounced, never on a half-typed address), derives the
|
|
274
|
+
shipping and payment choices, gates the button (`canPlaceOrder` +
|
|
275
|
+
`useCheckoutBlockers()` in words), and `placeOrder()` handles **both**
|
|
276
|
+
navigations — online gateway → provider redirect, everything else →
|
|
277
|
+
`/order-received`. The address form comes from `useAddressForm(which)` as a
|
|
278
|
+
field spec (`state` collected, country options never null); the two
|
|
279
|
+
store-data choices come through the headless `ShippingMethodPicker` /
|
|
280
|
+
`PaymentMethodPicker`, whose render props enumerate every branch.
|
|
281
|
+
|
|
282
|
+
⚑ Rules: handle every picker branch (they exist because every one occurs in a
|
|
283
|
+
normal store); a single shipping or payment option still *shows* what it is —
|
|
284
|
+
never a picker of one, never "nothing selected"; zero gateways → say checkout
|
|
285
|
+
is unavailable instead of a dead button; keep each field's `autoComplete` (the
|
|
286
|
+
spec provides it) and render `f.error` — "we don't ship there" arrives on the
|
|
287
|
+
country field; show `orderError.message` and the blockers so the gate explains
|
|
288
|
+
itself. ⚑ Payment methods, currency and countries come from `useStoreInfo()`
|
|
289
|
+
only — `cart.payment_gateways` is always `undefined`, and a default store
|
|
290
|
+
offers `offline` only ([`./03-data.md`](./03-data.md)).
|
|
291
|
+
|
|
292
|
+
**Reference implementation** — the densest wiring in the storefront; read it,
|
|
293
|
+
then build yours around it. Structure correct, presentation absent.
|
|
120
294
|
|
|
121
|
-
|
|
295
|
+
```jsx
|
|
296
|
+
function Checkout() { // hooks read the context BELOW the provider
|
|
297
|
+
return <CheckoutProvider><CheckoutForm /></CheckoutProvider>;
|
|
298
|
+
}
|
|
122
299
|
|
|
123
|
-
|
|
300
|
+
function CheckoutForm() {
|
|
301
|
+
const { status } = useCart();
|
|
302
|
+
const checkout = useCheckoutContext();
|
|
303
|
+
const blockers = useCheckoutBlockers();
|
|
304
|
+
const formatMoney = useFormatMoney();
|
|
305
|
+
if (status === "loading") return /* loading */;
|
|
306
|
+
if (status === "empty") return /* "your bag is empty" — a checkout with nothing says so */;
|
|
307
|
+
return (
|
|
308
|
+
<>
|
|
309
|
+
<AddressFields which="billing" />
|
|
310
|
+
<label>
|
|
311
|
+
<input type="checkbox" checked={checkout.shipToDifferent}
|
|
312
|
+
onChange={(e) => checkout.setShipToDifferent(e.target.checked)} />
|
|
313
|
+
Deliver to a different address
|
|
314
|
+
</label>
|
|
315
|
+
{checkout.shipToDifferent && <AddressFields which="shipping" />}
|
|
316
|
+
|
|
317
|
+
<ShippingMethodPicker>
|
|
318
|
+
{({ status, methods, chosen, choose, mustChoose, syncing }) => (
|
|
319
|
+
<fieldset>{/* syncing → subtle busy state; renders null for a virtual cart */}
|
|
320
|
+
{status === "missing_address" && <p>Delivery options appear once your address is entered.</p>}
|
|
321
|
+
{status === "none_available" && <p role="alert">We don't deliver to that address yet.</p>}
|
|
322
|
+
{mustChoose && methods.map((m) => (
|
|
323
|
+
<label key={m.id}>
|
|
324
|
+
<input type="radio" checked={chosen?.id === m.id} onChange={() => choose(m.id)} />
|
|
325
|
+
{m.title} {formatMoney(m.cost)}
|
|
326
|
+
</label>
|
|
327
|
+
))}
|
|
328
|
+
{!mustChoose && chosen && <p>{chosen.title} {formatMoney(chosen.cost)}</p>}
|
|
329
|
+
</fieldset>
|
|
330
|
+
)}
|
|
331
|
+
</ShippingMethodPicker>
|
|
332
|
+
|
|
333
|
+
<PaymentMethodPicker>
|
|
334
|
+
{({ gateways, value, select, selected, single }) => (
|
|
335
|
+
<fieldset>
|
|
336
|
+
{gateways.length === 0 && <p role="alert">No payment method is available right now.</p>}
|
|
337
|
+
{!single && gateways.map((g) => (
|
|
338
|
+
<label key={g.slug}>
|
|
339
|
+
<input type="radio" checked={value === g.slug} onChange={() => select(g.slug)} />
|
|
340
|
+
{g.title} {g.description}
|
|
341
|
+
</label>
|
|
342
|
+
))}
|
|
343
|
+
{single && selected && <p>{selected.title}</p>}
|
|
344
|
+
</fieldset>
|
|
345
|
+
)}
|
|
346
|
+
</PaymentMethodPicker>
|
|
347
|
+
|
|
348
|
+
{/* summary: coupon field (if not in the cart) + useTotalsLines(), as in the cart page */}
|
|
349
|
+
|
|
350
|
+
<button disabled={!checkout.canPlaceOrder || checkout.placing} onClick={() => checkout.placeOrder()}>
|
|
351
|
+
{checkout.placing ? "Placing your order…" : "Place order"}
|
|
352
|
+
</button>
|
|
353
|
+
{checkout.orderError && <p role="alert">{checkout.orderError.message}</p>}
|
|
354
|
+
{!checkout.canPlaceOrder && blockers.map((b) => <p key={b.code}>{b.message}</p>)}
|
|
355
|
+
</>
|
|
356
|
+
);
|
|
357
|
+
}
|
|
124
358
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
return
|
|
128
|
-
<
|
|
129
|
-
|
|
130
|
-
|
|
359
|
+
function AddressFields({ which }) {
|
|
360
|
+
const { fields, countriesLoading } = useAddressForm(which);
|
|
361
|
+
return fields.map((f) => ( /* each field carries its own setter: f.set */
|
|
362
|
+
<label key={f.key}>
|
|
363
|
+
{f.label}{f.required && " *"}
|
|
364
|
+
{f.type === "select" ? (
|
|
365
|
+
<select value={f.value} onChange={(e) => f.set(e.target.value)} autoComplete={f.autoComplete}>
|
|
366
|
+
<option value="">{f.key === "country" && countriesLoading ? "Loading…" : `Select ${f.label}`}</option>
|
|
367
|
+
{f.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
|
368
|
+
</select>
|
|
369
|
+
) : (
|
|
370
|
+
<input type={f.type} value={f.value} required={f.required}
|
|
371
|
+
onChange={(e) => f.set(e.target.value)} autoComplete={f.autoComplete} />
|
|
372
|
+
)}
|
|
373
|
+
{f.error && <span role="alert">{f.error}</span>}
|
|
374
|
+
</label>
|
|
375
|
+
));
|
|
131
376
|
}
|
|
132
377
|
```
|
|
133
378
|
|
|
134
|
-
|
|
379
|
+
## Order received
|
|
135
380
|
|
|
136
|
-
|
|
381
|
+
**Mandatory route** — every payment link returns here, and confirming is what
|
|
382
|
+
marks a card order paid. `useOrderReturn()` is the whole page's logic: it reads
|
|
383
|
+
`order_id`/`order_key` from the URL, verifies with the provider (idempotent),
|
|
384
|
+
and marks the page noindex itself.
|
|
137
385
|
|
|
138
|
-
|
|
386
|
+
⚑ Rules: render all five states — never a blank page while `"loading"`, and a
|
|
387
|
+
retry via `reload()` on `"error"`. ⚑ **Never drop `paymentInstructions`**: a
|
|
388
|
+
manual/offline order settles outside the store, so these ARE how the store's
|
|
389
|
+
default customer learns how to pay — render them whenever present, on any
|
|
390
|
+
state. ⚑ An order's totals are **flat** — `order.total`; there is no
|
|
391
|
+
`order.totals` (use `useTotalsLines(order)`).
|
|
139
392
|
|
|
140
|
-
|
|
393
|
+
**Reference implementation** — a receipt is a convention, not an identity
|
|
394
|
+
surface: take this structure, restyle it to the store, keep every branch.
|
|
141
395
|
|
|
142
396
|
```jsx
|
|
143
|
-
|
|
397
|
+
function OrderReceived() {
|
|
398
|
+
const { status, order, lines, paymentLink, paymentInstructions, error, reload } = useOrderReturn();
|
|
399
|
+
const totals = useTotalsLines(order);
|
|
400
|
+
const formatMoney = useFormatMoney();
|
|
401
|
+
if (status === "loading") return /* confirming copy */;
|
|
402
|
+
if (status === "error") return <><p role="alert">{error.message}</p>
|
|
403
|
+
<button onClick={() => reload()}>Try again</button></>;
|
|
404
|
+
return (
|
|
405
|
+
<>
|
|
406
|
+
{status === "paid" && /* thank-you heading */}
|
|
407
|
+
{status === "unpaid" && <>{/* awaiting-payment heading */}
|
|
408
|
+
{paymentLink?.url && <a href={paymentLink.url}>Pay now</a>}</>}
|
|
409
|
+
{status === "cancelled" && <>{/* payment-cancelled heading */}
|
|
410
|
+
{paymentLink?.url && <a href={paymentLink.url}>Try payment again</a>}</>}
|
|
411
|
+
{order?.order_number && <p>Order {order.order_number}</p>}
|
|
412
|
+
{paymentInstructions && (
|
|
413
|
+
<section>{/* "How to pay" — the offline customer's next step */}
|
|
414
|
+
{paymentInstructions.description && <p>{paymentInstructions.description}</p>}
|
|
415
|
+
{paymentInstructions.account_details && Object.entries(paymentInstructions.account_details)
|
|
416
|
+
.map(([k, v]) => <p key={k}>{k.replace(/_/g, " ")}: {String(v)}</p>)}
|
|
417
|
+
</section>
|
|
418
|
+
)}
|
|
419
|
+
{lines.map((l, i) => <p key={i}>{l.name} {l.attributesLabel} × {l.quantity} — {formatMoney(l.total)}</p>)}
|
|
420
|
+
{totals.filter((t) => !t.hidden).map((t) => <p key={t.key}>{t.label} {t.formatted}</p>)}
|
|
421
|
+
</>
|
|
422
|
+
);
|
|
423
|
+
}
|
|
144
424
|
```
|
|
145
425
|
|
|
146
|
-
Mandatory route. It renders all five states, including the two hand-written pages drop: **`paymentInstructions` for a manual/offline order** — the default gateway, so this is how the store's normal customer learns how to pay — and the pay-now link for an unpaid card order. It is `noindex`, as a receipt carrying an order key should be. Custom version: `useOrderReturn()` + `useTotalsLines(order)` (an order's totals are **flat** — `order.total`; there is no `order.totals`).
|
|
147
|
-
|
|
148
426
|
## SEO — one line per page type
|
|
149
427
|
|
|
150
428
|
```jsx
|
|
151
429
|
useStorefrontSeo(productSeo(p.product, p.view, { storeName, currency })); // product page
|
|
152
430
|
useStorefrontSeo(collectionSeo({ title, products: list.products })); // collection / home
|
|
153
|
-
|
|
431
|
+
// order-received is already noindex via useOrderReturn
|
|
154
432
|
```
|
|
155
433
|
|
|
156
|
-
|
|
434
|
+
The `*Seo` builders tolerate a null product, so this sits with the other hooks
|
|
435
|
+
above the status guards.
|
|
157
436
|
|
|
158
437
|
## Per-page output budgets
|
|
159
438
|
|
|
160
439
|
| Page | budget (chars) | rationale |
|
|
161
440
|
|---|---|---|
|
|
162
|
-
| Checkout | ≤
|
|
163
|
-
| Cart / bag | ≤
|
|
164
|
-
| Order-received | ≤
|
|
165
|
-
| Product page | ≤
|
|
441
|
+
| Checkout | ≤ 5K | your markup over the reference above — the logic is all hook calls |
|
|
442
|
+
| Cart / bag | ≤ 3K | `useCart` + `CartLine` rows + totals + coupon + empty state |
|
|
443
|
+
| Order-received | ≤ 2.5K | five states + payment instructions + summary |
|
|
444
|
+
| Product page | ≤ 5K | your layout and type around `useProduct`, `variantAxes`, `useAddToCartButton`, the gallery |
|
|
166
445
|
| Collection | ≤ 3K | `useProductList` + custom card + pagination controls |
|
|
167
|
-
| Home | ≤ 5K | pure identity
|
|
446
|
+
| Home | ≤ 5K | pure identity — hero/editorial earn their chars |
|
|
168
447
|
| Any single component file | ≤ 4K, hard ceiling 8K | Base1 evidence: decode is 34% of wall; a 12K file is a 45s write batch |
|
|
169
448
|
|
|
170
|
-
|
|
449
|
+
These budgets assume the hooks carry the logic and your markup carries only the
|
|
450
|
+
design. Over budget ⇒ you are re-implementing something a hook does — an
|
|
451
|
+
address spec, a quantity clamp, totals math, variant resolution, add-to-cart
|
|
452
|
+
error recovery. Go back to the hook and delete your version.
|
|
171
453
|
|
|
172
454
|
## Done — forget this file
|
|
173
455
|
|
|
@@ -175,14 +457,19 @@ Over budget ⇒ extract components, or adopt the block you are re-implementing.
|
|
|
175
457
|
- [ ] **One** `<StorefrontProvider>` above every storefront route, wrapping `<Routes>` (or a layout route's `<Outlet/>`); one client, no hand-rolled `cart_token`.
|
|
176
458
|
- [ ] Pages branch on `status`; no page maps a possibly-null list or shows an empty state while loading.
|
|
177
459
|
- [ ] Gateways/currency/countries read from `useStoreInfo()` only.
|
|
178
|
-
- [ ] If the store has coupons, a coupon field exists in the cart or the checkout.
|
|
179
|
-
- [ ] `/order-received` renders
|
|
460
|
+
- [ ] If the store has coupons, a coupon field (`useCoupon`) exists in the cart or the checkout.
|
|
461
|
+
- [ ] `/order-received` renders `useOrderReturn`'s states **including `paymentInstructions`**.
|
|
462
|
+
- [ ] Variant options render one control per axis; unbuyable options are disabled, not hidden.
|
|
463
|
+
- [ ] No hook logic was re-implemented by hand (address fields, quantity clamps, totals rows, shipping/payment branching).
|
|
464
|
+
- [ ] The storefront carries the design you settled on before reading this file — no page ships the reference snippets' bare structure or placeholder copy.
|
|
180
465
|
- [ ] Every page is within its budget above.
|
|
181
466
|
- [ ] A real purchase completes in the preview — pick a variant, add it, check out, place an offline order, land on `/order-received`.
|
|
182
467
|
|
|
183
468
|
Record these lines in your working notes; do not re-read this file.
|
|
184
469
|
|
|
185
470
|
- Payment gateways, currency and countries come from `useStoreInfo()` only — never off a cart (`cart.payment_gateways` is always undefined).
|
|
186
|
-
- A store with any coupons must have a coupon field
|
|
187
|
-
- `/order-received` renders
|
|
471
|
+
- A store with any coupons must have a coupon field (`useCoupon`) in the cart or the checkout, or its codes can never be redeemed.
|
|
472
|
+
- `/order-received` is mandatory and renders `useOrderReturn`'s states, including `paymentInstructions` — how a normal (offline) customer learns how to pay.
|
|
188
473
|
- Branch cart/list/product UI on `status`, never on `isEmpty`/nullable data — `isEmpty` is false while loading by design.
|
|
474
|
+
- Variant options that aren't buyable render disabled, never hidden; one control per axis, never a list of variations.
|
|
475
|
+
- 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".
|