@base44/app-plugin-commerce 0.2.6 → 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 +10 -6
- package/skills/commerce/docs/api-storefront.md +2 -2
- package/skills/commerce/install/02-storefront.md +115 -48
- package/skills/commerce/references/catalog-rendering.md +2 -2
- package/skills/commerce/references/storefront-verification.md +3 -3
- package/src/commerce/storefront/cartUI.jsx +38 -110
- package/src/commerce/storefront/index.js +22 -22
- package/src/commerce/storefront/pickers.jsx +15 -29
- package/src/commerce/storefront/useAddressForm.js +27 -36
- package/src/commerce/storefront/usePlaceOrder.js +29 -21
- package/src/commerce/storefront/useProduct.js +34 -43
- package/src/commerce/storefront/useProductList.js +7 -12
- package/src/commerce/storefront/useUpsell.js +2 -2
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ It provides a full-featured **commerce data model and behavior** (variant-driven
|
|
|
12
12
|
- **Shared commerce engine** (`base44/shared/commerce/`) — totals, tax, shipping, coupons, stock, order lifecycle, webhook dispatch (HMAC-signed), emails, card-payment plumbing, plus static country/currency/continent data.
|
|
13
13
|
- **Admin UI** (`src/commerce/admin/`) — a React/Tailwind/shadcn admin with a familiar store back-office information architecture: dashboard, orders, products, coupons, customers, reports, and full settings including webhooks. Admin-role gated.
|
|
14
14
|
- **Storefront helpers** (`src/commerce/utils/`) — framework-free, dependency-free modules for the shopfront you build: `storefront.js` is the API client (`createStorefront(base44)` — cart-token lifecycle, cached store-info, catalog/cart/checkout/reviews/return-page calls); `variants.js` maps an attribute selection (Size, Color) onto a `ProductVariation` and back, plus per-option availability and price ranges; `price.js` encodes the from-price and price-range rules; `totals.js` projects a cart *or* an order into one summary shape; `address-spec.js` is the checkout address form as data; `images.js` normalizes catalog images; `shipping-promos.js` reads the store's real free-shipping configuration so "Free shipping over €150" states a configured rule rather than an invented number.
|
|
15
|
-
- **Storefront React layer** (`src/commerce/storefront/`) — **headless: the logic is premade, the UI never is.** Nothing in the layer renders markup or carries CSS; every element, class and word of copy in the storefront you build is yours, so a brief like "make it feel like <site>" applies to the whole store, checkout included. What ships is every piece of logic that is the same in all stores, as hooks returning complete view-models: `StorefrontProvider`, `useProductList`/`useCategories`/`useRibbons`, `useProduct`/`useAddToCart`/`
|
|
15
|
+
- **Storefront React layer** (`src/commerce/storefront/`) — **headless: the logic is premade, the UI never is.** Nothing in the layer renders markup or carries CSS; every element, class and word of copy in the storefront you build is yours, so a brief like "make it feel like <site>" applies to the whole store, checkout included. What ships is every piece of logic that is the same in all stores, as hooks returning complete view-models: `StorefrontProvider`, `useProductList`/`useCategories`/`useRibbons`, `useProduct`/`useAddToCart`/`useProductPrice`/`useProductGallery` (+ the `variantAxes`/`productSpecs` render-model helpers), `useProductReviews`, `useCart`/`useCartLine`/`useCoupon`, `useCheckout` + `useAddressForm`/`useTotalsLines`/`useCheckoutBlockers`, `useOrderReturn`, and `useStorefrontSeo` — plus three render-prop components that stay just as headless (`ShippingMethodPicker`/`PaymentMethodPicker` for the two checkout choices that are store data, `CartLine` for per-row cart bindings). Each hook's doc comment states the render rules that keep a store correct (an unbuyable variant option renders disabled, not hidden; a receipt page must render `paymentInstructions`; …). Needs React and nothing else.
|
|
16
16
|
- **StoreAdmin agent + bot** — an AI copilot (`base44/agents/commerce/StoreAdmin.jsonc`, registered as `commerce/StoreAdmin`) with the `commerce/*` functions attached directly as tools (calls run as the chatting user → `requireAdmin()` still applies), variant-aware order editing, plus a chat panel in the admin sidebar with GFM markdown-table rendering.
|
|
17
17
|
- **Docs** — this README plus the commerce skill folder [`skills/commerce/`](./skills/commerce/): [`SKILL.md`](./skills/commerce/SKILL.md) is the map every agent starts from (and the only path the platform needs to know); [`install/`](./skills/commerce/install/) holds the three stage files that are the whole install (`01-install` → `02-storefront` → `03-data`, each read at the moment its work starts and dropped when its checklist passes); [`references/`](./skills/commerce/references/) holds per-topic guides opened only on demand; [`docs/`](./skills/commerce/docs/) holds the data-model map ([`entities.md`](./skills/commerce/docs/entities.md)) and the two API references. The whole folder is installed into the app at `.agents/skills/commerce/` so agents pick it up natively.
|
|
18
18
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@base44/app-plugin-commerce",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"description": "Base44 Commerce plugin — entities, backend functions, shared commerce engine, admin UI and the commerce skill, shipped as copyable source",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"base44",
|
package/skills/commerce/SKILL.md
CHANGED
|
@@ -47,15 +47,19 @@ moments. Read nothing else up front.
|
|
|
47
47
|
|
|
48
48
|
**The UI is yours, all of it** — every page, element, class and word of copy.
|
|
49
49
|
The kit deliberately ships **no markup and no CSS anywhere**; a brief like
|
|
50
|
-
"make it feel like <site>" is your work, done as you would with no kit.
|
|
50
|
+
"make it feel like <site>" is your work, done as you would with no kit. Encode
|
|
51
|
+
that identity **once** — design classes in `index.css`, spent across every
|
|
52
|
+
page — not as decoration re-typed on every element;
|
|
53
|
+
[`install/02-storefront.md`](./install/02-storefront.md) opens with the
|
|
54
|
+
method.
|
|
51
55
|
|
|
52
56
|
**The logic ships as headless hooks** (`@/commerce/storefront`) — checkout
|
|
53
57
|
repricing, variant resolution, cart state, coupons, reviews, order-return
|
|
54
|
-
verification, cart-drawer
|
|
55
|
-
(`status` to branch on, ready-to-map arrays,
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
verification, cart-drawer state. Each returns a complete view-model as **plain
|
|
59
|
+
states and handlers** (`status` to branch on, ready-to-map arrays, `label`s
|
|
60
|
+
and `disabled` flags, callbacks) — you write every element and attribute from
|
|
61
|
+
them. **Never re-implement what a hook does** — that is where storefront bugs
|
|
62
|
+
cluster.
|
|
59
63
|
|
|
60
64
|
```jsx
|
|
61
65
|
import { useCart, CartLine } from "@/commerce/storefront";
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The raw HTTP/SDK surface behind a customer-facing shopfront. **A React storefront should not call most of it directly** — `src/commerce/storefront/` ships the logic layer, and this file is the reference for what lies beyond it, for non-React and headless clients, and for the exact payloads and error codes.
|
|
4
4
|
|
|
5
|
-
**Logic is premade, UI never is.** In a React app, every surface here has a headless hook in `@/commerce/storefront` — `useProductList`, `useProduct` (+ `variantAxes`, `
|
|
5
|
+
**Logic is premade, UI never is.** In a React app, every surface here has a headless hook in `@/commerce/storefront` — `useProductList`, `useProduct` (+ `variantAxes`, `useAddToCart`, `useProductSpecs`, `useUpsell`), `useCart`/`useCartLine`/`useCoupon`/`useCartUI` (+ `CartUIProvider`), `useCheckout`/`usePlaceOrder` (+ `useAddressForm` and the pickers), `useOrderReturn`, `useProductReviews`, `useStorefrontSeo`. The hooks own the API calls and the branching below and hand you a view-model; **all markup and styling are yours** — nothing in the kit renders UI. Never hand-roll a hook's logic. Framework-free helpers (API client, variant resolution, price and totals rules, free-shipping rules) live in `src/commerce/utils/`.
|
|
6
6
|
|
|
7
7
|
Entities and direct CRUD → [`entities.md`](./entities.md) · admin surface → [`api-admin.md`](./api-admin.md)
|
|
8
8
|
|
|
@@ -94,7 +94,7 @@ await cat({ category_id, sort: "popularity", per_page: 4 }); // top in catego
|
|
|
94
94
|
```
|
|
95
95
|
**Errors:** `404 not_found` (missing / not published / hidden).
|
|
96
96
|
|
|
97
|
-
> **`variations[]` is not a list of choices to show** — variant rule 1 applies, and variant prices come from `variations[]`, never `product.price` (a rolled-up from-price). React: `useProduct` + `
|
|
97
|
+
> **`variations[]` is not a list of choices to show** — variant rule 1 applies, and variant prices come from `variations[]`, never `product.price` (a rolled-up from-price). React: `useProduct` + `useAddToCart`. The non-React resolver sample (`resolveSelection` from `@/commerce/utils`) and the variant deep-dive: [`../references/catalog-rendering.md`](../references/catalog-rendering.md).
|
|
98
98
|
|
|
99
99
|
### `list-categories`
|
|
100
100
|
No payload. Returns a nested tree: `{ "categories": [ { ...category, "children": [...] } ] }` sorted by `menu_order` then name.
|
|
@@ -10,16 +10,17 @@ 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
|
-
- "
|
|
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."
|
|
14
15
|
---
|
|
15
16
|
|
|
16
17
|
# 02 — Storefront
|
|
17
18
|
|
|
18
|
-
One split decides everything here: **the logic is premade, the UI never is.** The hooks own checkout repricing, variant resolution, cart state, 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
|
|
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.
|
|
19
20
|
|
|
20
21
|
Two mechanics to hold everywhere:
|
|
21
22
|
|
|
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).
|
|
23
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.
|
|
24
25
|
|
|
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.)
|
|
@@ -56,6 +57,52 @@ import AdminApp from "@/commerce/admin";
|
|
|
56
57
|
|
|
57
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`.
|
|
58
59
|
|
|
60
|
+
## Design language — once, before any page
|
|
61
|
+
|
|
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:
|
|
80
|
+
|
|
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
|
+
```
|
|
101
|
+
|
|
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.
|
|
105
|
+
|
|
59
106
|
## Product list / collection
|
|
60
107
|
|
|
61
108
|
```jsx
|
|
@@ -69,21 +116,21 @@ const list = useProductList({ per_page: 24 });
|
|
|
69
116
|
const { settings } = useStoreInfo();
|
|
70
117
|
useStorefrontSeo(collectionSeo({ title: "…", products: list.products, storeName: settings?.store_name }));
|
|
71
118
|
|
|
72
|
-
if (list.status === "loading") return
|
|
73
|
-
if (list.status === "error") return
|
|
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>} />;
|
|
74
121
|
const products = list.products; // always an array — never null
|
|
75
122
|
```
|
|
76
123
|
|
|
77
|
-
`useProductList(params)` → `{ status, products, hasNext,
|
|
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).
|
|
78
125
|
|
|
79
|
-
Your card can render `name`, `productImages(row)[0]` (⚑ **images are `{src, alt}` objects and the array may be empty — render a placeholder, never a broken `<img>`**), `useProductPrice(row).label` (already "From €19.99" when the product sells variants — there is no product `type` flag), `on_sale`, `short_description`, `stock_status`, `average_rating`/`rating_count`, `ribbons`, and `meta_data` via `productSpecs(row)`. Full field matrix: [`../references/catalog-rendering.md`](../references/catalog-rendering.md). That is an inventory, not a card design:
|
|
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.
|
|
80
127
|
|
|
81
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).
|
|
82
129
|
|
|
83
130
|
## Product page
|
|
84
131
|
|
|
85
132
|
```jsx
|
|
86
|
-
import { useProduct, useProductGallery,
|
|
133
|
+
import { useProduct, useProductGallery, useAddToCart, useProductSpecs, useStoreInfo, useStorefrontSeo, productSeo, variantAxes } from "@/commerce/storefront";
|
|
87
134
|
```
|
|
88
135
|
|
|
89
136
|
(Add `useProductReviews` only if the store has reviews; drop `useProductSpecs` if these products carry no modifiers.)
|
|
@@ -93,13 +140,13 @@ import { useProduct, useProductGallery, useAddToCartButton, useProductSpecs, use
|
|
|
93
140
|
```jsx
|
|
94
141
|
const p = useProduct(slug);
|
|
95
142
|
const g = useProductGallery(p.product, p.view);
|
|
96
|
-
const buy =
|
|
143
|
+
const buy = useAddToCart(p, { labels: { ready: "Add to bag" } });
|
|
97
144
|
const specs = useProductSpecs(p.product, { pick: ["material", "care"] });
|
|
98
145
|
const { settings } = useStoreInfo(); // the ONLY source of store name + currency
|
|
99
146
|
useStorefrontSeo(productSeo(p.product, p.view, { storeName: settings?.store_name, currency: settings?.currency }));
|
|
100
147
|
|
|
101
|
-
if (p.status === "loading") return
|
|
102
|
-
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 */} />;
|
|
103
150
|
const { product, view, price, categories } = p;
|
|
104
151
|
```
|
|
105
152
|
|
|
@@ -107,7 +154,7 @@ Build your layout from — all optional, each one hook, **not one component styl
|
|
|
107
154
|
|
|
108
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.
|
|
109
156
|
- **Gallery** — `g` → `{ hasImages, images, active, activeIndex, setActiveIndex, next, prev }`. The active image follows the variant selection; `hasImages: false` means render your placeholder.
|
|
110
|
-
- **Variant selector** — `variantAxes(view, p.pick)` → one entry per axis: `{ key, name, selectedOption, options: [{ value, selected, disabled, outOfStock, pick }] }`. ⚑ **One control per axis, never a list of variations**, and ⚑ **an unbuyable option renders `disabled`, never hidden** (`outOfStock` stays visible, just marked). `view.missingAxes` names what's unpicked.
|
|
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:
|
|
111
158
|
|
|
112
159
|
```jsx
|
|
113
160
|
{variantAxes(view, p.pick).map((axis) => (
|
|
@@ -121,9 +168,9 @@ Build your layout from — all optional, each one hook, **not one component styl
|
|
|
121
168
|
))}
|
|
122
169
|
```
|
|
123
170
|
|
|
124
|
-
- **Buy box** — `<button {
|
|
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.
|
|
125
172
|
- **Description** — `product.description` is HTML; render as rich text, `short_description` above it.
|
|
126
|
-
- **Specs** — `useProductSpecs(product, { pick: [...] })`: `picked` are the rows to feature (matched case/`_`-insensitively — ⚑ **never match rows by `label` equality**, it silently misses), `rest` is the remainder, safe to render as plain rows. Each row carries `titleLabel` (display-cased) and a `type` with `number`/`unit`/`items` split out, so a weight can be a figure and a composition bars.
|
|
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.
|
|
127
174
|
- **Breadcrumbs** — build from `categories` (`/collection?category_id=${c.id}`); skip on a flat catalog. Ribbons are labels, not breadcrumbs.
|
|
128
175
|
|
|
129
176
|
### Reviews — optional
|
|
@@ -148,8 +195,8 @@ A cart *page* is optional (buy-now straight to checkout reads better for a singl
|
|
|
148
195
|
function Bag() {
|
|
149
196
|
const { status, lines, notices } = useCart();
|
|
150
197
|
const totals = useTotalsLines();
|
|
151
|
-
if (status === "loading") return
|
|
152
|
-
if (status === "empty") return
|
|
198
|
+
if (status === "loading") return <StatusScreen busy title="…" />;
|
|
199
|
+
if (status === "empty") return <StatusScreen title="…" action={/* link back to the catalog */} />;
|
|
153
200
|
return (
|
|
154
201
|
<>
|
|
155
202
|
{notices.map((n, i) => <p key={i} role="status">{n.message}</p>)}
|
|
@@ -185,25 +232,28 @@ No shipping estimator here — checkout reprices shipping and tax from the addre
|
|
|
185
232
|
|
|
186
233
|
### If the cart is a drawer
|
|
187
234
|
|
|
188
|
-
Same hooks, same rows. The drawer's *
|
|
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:
|
|
189
236
|
|
|
190
237
|
```jsx
|
|
191
238
|
function StoreLayout() { // inside <CartUIProvider> (see Setup)
|
|
192
239
|
const ui = useCartUI();
|
|
193
240
|
const { itemCount } = useCart();
|
|
194
241
|
return (<>
|
|
195
|
-
<header>… <button {
|
|
242
|
+
<header>… <button type="button" onClick={ui.toggleCart} aria-expanded={ui.open}
|
|
243
|
+
className="…">Bag ({itemCount})</button></header>
|
|
196
244
|
<Outlet />
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
<
|
|
200
|
-
|
|
201
|
-
|
|
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
|
+
</>)}
|
|
202
252
|
</>);
|
|
203
253
|
}
|
|
204
254
|
```
|
|
205
255
|
|
|
206
|
-
⚑
|
|
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.
|
|
207
257
|
|
|
208
258
|
## Checkout
|
|
209
259
|
|
|
@@ -215,7 +265,7 @@ import { CheckoutProvider, useCheckoutContext, usePlaceOrder, useAddressForm, Sh
|
|
|
215
265
|
|
|
216
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).
|
|
217
267
|
|
|
218
|
-
⚑ Rules: render each picker's `hint` and every branch; a single shipping or payment option still *shows* what it is — never a picker of one, never "nothing selected";
|
|
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.
|
|
219
269
|
|
|
220
270
|
**Reference implementation** — the densest wiring in the storefront; read it, then build yours around it:
|
|
221
271
|
|
|
@@ -228,9 +278,9 @@ function CheckoutForm() {
|
|
|
228
278
|
const { status } = useCart();
|
|
229
279
|
const checkout = useCheckoutContext();
|
|
230
280
|
const order = usePlaceOrder();
|
|
231
|
-
if (order.stage === "submitted") return
|
|
232
|
-
if (status === "loading") return
|
|
233
|
-
if (status === "empty") return
|
|
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 */} />;
|
|
234
284
|
return (
|
|
235
285
|
<>
|
|
236
286
|
<AddressFields which="billing" />
|
|
@@ -246,7 +296,10 @@ function CheckoutForm() {
|
|
|
246
296
|
<fieldset>{/* renders null for a virtual cart */}
|
|
247
297
|
{hint && <p role={hint.severity === "error" ? "alert" : "status"}>{hint.message}</p>}
|
|
248
298
|
{mustChoose && methods.map((m) => (
|
|
249
|
-
<label key={m.id}
|
|
299
|
+
<label key={m.id}>
|
|
300
|
+
<input type="radio" name="shipping-method" checked={m.selected} onChange={m.select} />
|
|
301
|
+
{m.title} {m.costLabel}
|
|
302
|
+
</label>
|
|
250
303
|
))}
|
|
251
304
|
{!mustChoose && chosen && <p>{chosen.title} {chosen.costLabel}</p>}
|
|
252
305
|
</fieldset>
|
|
@@ -258,7 +311,10 @@ function CheckoutForm() {
|
|
|
258
311
|
<fieldset>
|
|
259
312
|
{hint && <p role="alert">{hint.message}</p>}
|
|
260
313
|
{!single && gateways.map((g) => (
|
|
261
|
-
<label key={g.slug}
|
|
314
|
+
<label key={g.slug}>
|
|
315
|
+
<input type="radio" name="payment-method" checked={g.selected} onChange={g.select} />
|
|
316
|
+
{g.title} {g.description}
|
|
317
|
+
</label>
|
|
262
318
|
))}
|
|
263
319
|
{single && selected && <p>{selected.title}</p>}
|
|
264
320
|
</fieldset>
|
|
@@ -267,8 +323,8 @@ function CheckoutForm() {
|
|
|
267
323
|
|
|
268
324
|
{/* summary: coupon field (if not in the cart) + useTotalsLines(), as in the cart page */}
|
|
269
325
|
|
|
270
|
-
<button {
|
|
271
|
-
{order.error && <p
|
|
326
|
+
<button type="button" onClick={order.placeOrder} disabled={order.disabled}>{order.label}</button>
|
|
327
|
+
{order.error && <p role="alert">{order.error.message}</p>}
|
|
272
328
|
{!order.canPlaceOrder && order.blockers.map((b) => <p key={b.code}>{b.message}</p>)}
|
|
273
329
|
</>
|
|
274
330
|
);
|
|
@@ -278,14 +334,14 @@ function AddressFields({ which }) {
|
|
|
278
334
|
const { fields } = useAddressForm(which);
|
|
279
335
|
return fields.map((f) => (
|
|
280
336
|
<div key={f.key}>
|
|
281
|
-
<label {
|
|
337
|
+
<label htmlFor={f.id}>{f.label}{f.required && " *"}</label>
|
|
282
338
|
{f.isSelect ? (
|
|
283
|
-
<select {
|
|
339
|
+
<select id={f.id} value={f.value} onChange={f.set} autoComplete={f.autoComplete}>
|
|
284
340
|
<option value="">{f.placeholder}</option>
|
|
285
341
|
{f.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
|
286
342
|
</select>
|
|
287
|
-
) : <input {
|
|
288
|
-
{f.error && <span
|
|
343
|
+
) : <input id={f.id} type={f.type} value={f.value} onChange={f.set} autoComplete={f.autoComplete} />}
|
|
344
|
+
{f.error && <span role="alert">{f.error}</span>}
|
|
289
345
|
</div>
|
|
290
346
|
));
|
|
291
347
|
}
|
|
@@ -309,9 +365,9 @@ import { useOrderReturn, useTotalsLines } from "@/commerce/storefront";
|
|
|
309
365
|
function OrderReceived() {
|
|
310
366
|
const { status, order, lines, paymentLink, paymentInstructions, error, reload } = useOrderReturn();
|
|
311
367
|
const totals = useTotalsLines(order);
|
|
312
|
-
if (status === "loading") return
|
|
313
|
-
if (status === "error") return
|
|
314
|
-
<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>} />;
|
|
315
371
|
return (
|
|
316
372
|
<>
|
|
317
373
|
{status === "paid" && /* thank-you heading */}
|
|
@@ -344,7 +400,7 @@ useStorefrontSeo(collectionSeo({ title, products: list.products })); // col
|
|
|
344
400
|
|
|
345
401
|
The `*Seo` builders tolerate a null product, so this sits with the other hooks above the status guards.
|
|
346
402
|
|
|
347
|
-
## Per-page output budgets
|
|
403
|
+
## Per-page output budgets — measured, not aspirational
|
|
348
404
|
|
|
349
405
|
| Page | budget (chars) |
|
|
350
406
|
|---|---|
|
|
@@ -353,10 +409,19 @@ The `*Seo` builders tolerate a null product, so this sits with the other hooks a
|
|
|
353
409
|
| Order-received | ≤ 2.5K |
|
|
354
410
|
| Product page | ≤ 5K |
|
|
355
411
|
| Collection | ≤ 3K |
|
|
356
|
-
| Home | ≤ 5K —
|
|
412
|
+
| Home | ≤ 5K — identity lives in the hero + one editorial moment, in your classes |
|
|
357
413
|
| Any single component file | ≤ 4K, hard ceiling 8K |
|
|
358
414
|
|
|
359
|
-
|
|
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.
|
|
360
425
|
|
|
361
426
|
## Driving the storefront from a browser script?
|
|
362
427
|
|
|
@@ -365,16 +430,17 @@ The hooks are optimistic and debounced, so a script that acts faster than the ca
|
|
|
365
430
|
## Done — forget this file
|
|
366
431
|
|
|
367
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`.
|
|
368
434
|
- [ ] **One** `<StorefrontProvider>` above every storefront route (layout-route pattern); one client, no hand-rolled `cart_token`.
|
|
369
435
|
- [ ] Every page's imports came from its section's import line; nothing imported from `@/commerce/utils`; no unused names.
|
|
370
436
|
- [ ] Pages branch on `status`; gateways/currency/countries read from `useStoreInfo()` only.
|
|
371
|
-
- [ ]
|
|
372
|
-
- [ ] Coupon field present if the store has coupons; paging rendered
|
|
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.
|
|
373
439
|
- [ ] Variant options: one control per axis, unbuyable options disabled, not hidden.
|
|
374
|
-
- [ ] Cart rows scope busy state to the row; repeated controls have unique accessible names; a drawer uses `useCartUI`
|
|
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).
|
|
375
441
|
- [ ] Checkout guards `stage === "submitted"` above its empty-cart branch.
|
|
376
|
-
- [ ] The storefront carries the design you settled on before reading this file —
|
|
377
|
-
- [ ]
|
|
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.
|
|
378
444
|
|
|
379
445
|
Record these lines in your working notes; do not re-read this file.
|
|
380
446
|
|
|
@@ -384,4 +450,5 @@ Record these lines in your working notes; do not re-read this file.
|
|
|
384
450
|
- Branch cart/list/product UI on `status`, never on `isEmpty`/nullable data — `isEmpty` is false while loading by design.
|
|
385
451
|
- Variant options that aren't buyable render disabled, never hidden; one control per axis, never a list of variations.
|
|
386
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".
|
|
387
|
-
-
|
|
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.
|
|
@@ -62,7 +62,7 @@ Both lists below are field inventories — what the data supports — **not a la
|
|
|
62
62
|
|
|
63
63
|
**Card:** image, name, `price.label`, sale badge from `on_sale`, stars from `average_rating`/`rating_count`, out-of-stock state from `stock_status`, one or two ribbons — plus anything else on the row (`weight`, `dimensions`, `meta_data` via `productSpecs`). Link the whole card to the product page; the layout is yours.
|
|
64
64
|
|
|
65
|
-
**Product page:** gallery, name, price, variant selectors, stock, `short_description` then `description`, SKU, categories breadcrumb, ribbons, reviews, then upsells/cross-sells. Everything except the markup has a hook or helper: `useProductGallery`, `variantAxes(view, pick)`, `
|
|
65
|
+
**Product page:** gallery, name, price, variant selectors, stock, `short_description` then `description`, SKU, categories breadcrumb, ribbons, reviews, then upsells/cross-sells. Everything except the markup has a hook or helper: `useProductGallery`, `variantAxes(view, pick)`, `useAddToCart`, `productSpecs(product)`, `useProductReviews`, `p.upsells`/`p.crossSells`.
|
|
66
66
|
|
|
67
67
|
**Attributes and modifiers are individually designable.** `productSpecs` rows carry `key`/`label`/`titleLabel` (display-cased) plus an inferred `type` (`numeric` with `number`/`unit` split out, `duration`, `location`, `list` with `items`, `text`); `useProductSpecs(product, { pick })` is the React wrapper, its get/pick case/underscore-insensitive. Branch on `type`/`key` instead of mapping everything into one uniform chip row per axis and one grey label/value table — the design guidance is [`../install/02-storefront.md`](../install/02-storefront.md)'s; §5's rules govern selector *behaviour*, never its form.
|
|
68
68
|
|
|
@@ -99,7 +99,7 @@ Three decisions the helpers can't make for you:
|
|
|
99
99
|
|
|
100
100
|
**Attributes but no usable variations** — what attaching an attribute and stopping leaves behind — deliberately gets no parent fallback, because `add-item` would reject it: empty `axes[].options`, `purchasable: false`, `addToCart: null`. Render it as unavailable rather than painting empty selector groups, and guard the label (with no attributes at all `missingAxes[0]?.name` is `undefined` → "Select a undefined").
|
|
101
101
|
|
|
102
|
-
**Add to cart** goes through `useAddToCart()`,
|
|
102
|
+
**Add to cart** goes through `useAddToCart(product)`, whose `addToCart()` never throws and maps the codes: `variation_required` (a page bug — empty `variation_id` on a product with attributes), `out_of_stock` / `insufficient_stock`, and `variation_not_found` (the catalog changed under the page → the hook reloads the product itself). `product.sold_individually` caps quantity at 1, already reflected in `useProduct().maxQuantity`. Two behaviors that matter only if you hand-roll the view model: non-publishable variations never leak an option into the UI, and an empty `option` on a variation axis means **"any"**.
|
|
103
103
|
|
|
104
104
|
## 6. Adding a `get-product`-only field to the listing
|
|
105
105
|
|
|
@@ -21,9 +21,9 @@ DOM is briefly right about the *intent* and wrong about the *state*.
|
|
|
21
21
|
before reading or clicking again.
|
|
22
22
|
- **Scope actions to the visible drawer.** With a drawer, the page can hold two
|
|
23
23
|
"Remove" buttons for one line — drawer and cart page behind it. A drawer built
|
|
24
|
-
on `useCartUI` is
|
|
25
|
-
but still query inside the open drawer's container, not the
|
|
26
|
-
that seems to do nothing usually hit
|
|
24
|
+
on `useCartUI` is unmounted while closed, so its copies can't be hit by
|
|
25
|
+
mistake — but still query inside the open drawer's container, not the
|
|
26
|
+
document. A click that seems to do nothing usually hit a hidden copy.
|
|
27
27
|
- **Remove lines one at a time.** Clicking every "Remove" in one pass fails on
|
|
28
28
|
its own terms: cart calls are serialized, each removal re-renders the list,
|
|
29
29
|
and buttons collected up front are detached by the time the loop reaches
|