@base44/app-plugin-commerce 0.4.1 → 0.5.1
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 +13 -11
- package/skills/commerce/install/02-storefront.md +89 -99
- package/skills/commerce/references/catalog-rendering.md +2 -2
- package/skills/commerce/references/storefront-custom.md +1 -1
- package/skills/commerce/references/storefront-parts.md +184 -0
- package/src/commerce/storefront/cartUI.jsx +63 -17
- package/src/commerce/storefront/index.js +5 -3
- package/src/commerce/storefront/parts/cart.jsx +64 -40
- package/src/commerce/storefront/parts/checkout.jsx +91 -55
- package/src/commerce/storefront/parts/drawer.jsx +35 -67
- package/src/commerce/storefront/parts/orderReceived.jsx +24 -12
- package/src/commerce/storefront/parts/parts.css +12 -29
- package/src/commerce/storefront/parts/shared.jsx +31 -21
- package/src/commerce/storefront/parts/visibility.js +36 -0
- package/src/commerce/storefront/useCheckout.jsx +27 -0
- package/src/commerce/utils/index.js +4 -1
- package/src/commerce/utils/specs.js +95 -10
- package/skills/commerce/references/storefront-styling.md +0 -99
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` and `ribbons.js` normalize the two catalog fields that are arrays of objects (`{src, name, alt}` images, `{id, name}` ribbons) rather than strings; `types.js` writes the catalog shapes down as JSDoc typedefs (`StorefrontProduct` and the rest), so what a field holds is answerable from the frontend; `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/`) — two tiers, and neither ships a look or a sentence. **The catalog tier is headless hooks** — `StorefrontProvider` (+ `useStorefront`/`useStoreInfo`/`useFormatMoney`/`useCountries`), `useProductList`/`useCategories`/`useRibbons`, `useProduct`/`useAddToCart`, `useCart`/`useCartLine`/`CartLine`, `useCheckout`/`CheckoutProvider`, `useOrderReturn`, the `ShippingMethodPicker`/`PaymentMethodPicker` render-prop components, and the framework-free view-model helpers re-exported so one import line covers a page (`variantAxes`, `productPrice`, `productImages`, `productRibbons`, `productSpecs`, `attributesLabel`, `cartTotalsLines`/`orderTotalsLines`, `addressFieldSpec`) — the product list and product page are built from these, entirely in the store's own markup, classes and words. **The commodity tier is parts** — `Cart.*`, `
|
|
15
|
+
- **Storefront React layer** (`src/commerce/storefront/`) — two tiers, and neither ships a look or a sentence. **The catalog tier is headless hooks** — `StorefrontProvider` (+ `useStorefront`/`useStoreInfo`/`useFormatMoney`/`useCountries`), `useProductList`/`useCategories`/`useRibbons`, `useProduct`/`useAddToCart`, `useCart`/`useCartLine`/`CartLine`, `useCheckout`/`CheckoutProvider`, `useOrderReturn`, the `ShippingMethodPicker`/`PaymentMethodPicker` render-prop components, and the framework-free view-model helpers re-exported so one import line covers a page (`variantAxes`, `productPrice`, `productImages`, `productRibbons`, `productSpecs`, `attributesLabel`, `cartTotalsLines`/`orderTotalsLines`, `addressFieldSpec`) — the product list and product page are built from these, entirely in the store's own markup, classes and words. **The commodity tier is parts** — `Cart.*`, `Checkout.*`, `OrderReceived.*` compound components that render each section of the cart contents, checkout and receipt as correct semantic markup carrying **layout geometry and nothing else** (field grids, labels above full-width controls, square thumbnails, label-left/value-right totals — enforced by a release gate to contain no color, type or border), with **zero copy and zero navigation**: the store's page is the layout around them; styling comes via `data-part`/`data-state` selectors or `className`/`classes` props; every word (blocker lines, field labels, button text, aria names) comes from one agent-written copy file passed as `labels` (a missing key renders a visible `⟨copy: …⟩` placeholder — the code has no English fallback); and every navigation affordance (the cart's checkout link, `Checkout.Root`'s required `orderReceivedPath`, `OrderReceived.PaymentLink`'s render-prop child) is the store's own element and route. The **cart drawer stays the store's own surface** — which side it enters from, its width, padding and animation — while `useCartUI()` keeps the state and the fiddly behavior (Esc, close-on-navigate, open-on-add, focus in and back out, inert while closed) and `CartDrawer.Trigger`/`Close` are the two wired buttons. Parts compose the hooks, so replacing one section with a hook-built custom region is a normal move (`useInCheckout()` even lets one shared cart-contents component drop its upsell rail inside the checkout) — and the parts are copied source, editable when a requirement outgrows their props. 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.
|
|
3
|
+
"version": "0.5.1",
|
|
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
|
@@ -54,15 +54,17 @@ feel like <site>" is your work, done as with no kit; encode that identity
|
|
|
54
54
|
method. **Never re-implement what a hook does** — that is where storefront
|
|
55
55
|
bugs cluster.
|
|
56
56
|
|
|
57
|
-
**The commodity surfaces — cart
|
|
58
|
-
placed, not written**: `Cart.*` / `
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
`index.css`
|
|
62
|
-
props), and every word comes from **one copy file**
|
|
63
|
-
missing key renders a visible `⟨copy: …⟩`. The kit ships
|
|
64
|
-
sentence anywhere: a kit sentence rendered into every store is
|
|
65
|
-
end up alike.
|
|
57
|
+
**The commodity surfaces — cart contents, checkout, order-received — are
|
|
58
|
+
placed, not written**: `Cart.*` / `Checkout.*` / `OrderReceived.*` parts render
|
|
59
|
+
each section's correct markup, carrying **layout geometry and nothing else** —
|
|
60
|
+
no color, type or border, no copy, no navigation. Your layout arranges the
|
|
61
|
+
sections, your `index.css` gives them their look (`[data-part]` selectors,
|
|
62
|
+
`className`/`classes` props), and every word comes from **one copy file**
|
|
63
|
+
passed as `labels` — a missing key renders a visible `⟨copy: …⟩`. The kit ships
|
|
64
|
+
no look and no sentence anywhere: a kit sentence rendered into every store is
|
|
65
|
+
how storefronts end up alike. **The cart drawer stays the store's own surface**
|
|
66
|
+
(side, width, padding, animation); `useCartUI()` keeps its state and behavior,
|
|
67
|
+
and `CartDrawer.Trigger`/`Close` are its two wired buttons.
|
|
66
68
|
|
|
67
69
|
```jsx
|
|
68
70
|
import { Cart, CartDrawer, Checkout, OrderReceived, useProduct } from "@/commerce/storefront";
|
|
@@ -106,13 +108,13 @@ Open a file when its work starts — not while planning.
|
|
|
106
108
|
| Topic | Open when | Size |
|
|
107
109
|
|---|---|---|
|
|
108
110
|
| [`install/01-install.md`](./install/01-install.md) | installing — routes you to 02 and 03 | 5K |
|
|
109
|
-
| [`install/02-storefront.md`](./install/02-storefront.md) | building storefront pages |
|
|
111
|
+
| [`install/02-storefront.md`](./install/02-storefront.md) | building storefront pages | 34K |
|
|
110
112
|
| [`install/03-data.md`](./install/03-data.md) | seeding catalog, shipping rates/zones, payments; re-callable per slice | 11K |
|
|
111
113
|
| [`docs/entities.md`](./docs/entities.md) | any direct entity read/write ("which entity holds X") | 11K |
|
|
112
114
|
| [`references/catalog-rendering.md`](./references/catalog-rendering.md) | field shapes each catalog call returns, variant edge cases | 16K |
|
|
113
115
|
| [`references/shipping-and-tax.md`](./references/shipping-and-tax.md) | zones beyond 03's recipe, taxes, day-2 edits | 8K |
|
|
114
116
|
| [`references/online-payments.md`](./references/online-payments.md) | enabling card payments, or wiring the provider — at install or any time later | 8K |
|
|
115
|
-
| [`references/storefront-
|
|
117
|
+
| [`references/storefront-parts.md`](./references/storefront-parts.md) | the parts' copy map, CSS selectors and control overrides | 10K |
|
|
116
118
|
| [`references/storefront-custom.md`](./references/storefront-custom.md) | a custom cart row / checkout section on the raw hooks | 8K |
|
|
117
119
|
| [`references/storefront-verification.md`](./references/storefront-verification.md) | driving the storefront from a browser script | 3K |
|
|
118
120
|
| [`references/reviews.md`](./references/reviews.md) | review policies (login-gated, verified buyers), moderation | 5K |
|
|
@@ -11,15 +11,16 @@ carry_forward:
|
|
|
11
11
|
- "Variant options that aren't buyable render disabled, never hidden; one control per axis, never a list of variations."
|
|
12
12
|
- "No copy ships: every word the parts render comes from the store's copy file (labels); a ⟨copy: …⟩ placeholder on screen is unfinished work."
|
|
13
13
|
- "Identity is encoded once — design classes in index.css (parts styled via [data-part] selectors, controls always among them) plus one or two signature moments per page."
|
|
14
|
+
- "The product page is the richest surface and budgeted for it (~8K chars): productSpecs rows carry a type — branch on it, never .map() the list into one grey label/value table."
|
|
14
15
|
---
|
|
15
16
|
|
|
16
17
|
# 02 — Storefront
|
|
17
18
|
|
|
18
|
-
Two tiers decide everything here. **The catalog surfaces — product list and product page — are fully yours**: the logic ships as hooks, every element, class and word is the store's own, and this is where the design effort goes. **The commodity surfaces — cart, drawer, checkout, order-received — are placed, not written**: `Cart.*` / `CartDrawer.*` / `Checkout.*` / `OrderReceived.*` parts render each section's correct markup with **
|
|
19
|
+
Two tiers decide everything here. **The catalog surfaces — product list and product page — are fully yours**: the logic ships as hooks, every element, class and word is the store's own, and this is where the design effort goes. **The commodity surfaces — cart, drawer, checkout, order-received — are placed, not written**: `Cart.*` / `CartDrawer.*` / `Checkout.*` / `OrderReceived.*` parts render each section's correct markup with **no look, no copy and no navigation**; your page is the layout around them, your copy file their words, your `index.css` their look. Nothing in the kit ships a look or a sentence — **decide how the store looks and sounds as if the kit did not exist**, then encode it once (design classes + the copy file); the snippets here are wiring reference, never design input. Where a state needs words you get the *state* (`buy.state`, a blocker code) and write the words; never re-derive a state you were handed, never leave one unworded.
|
|
19
20
|
|
|
20
|
-
**This file is the
|
|
21
|
+
**This file is the job.** Every shape and contract is in the tables here — **don't open the hook or part files while building**. The parts are the app's own source (`src/commerce/storefront/parts/`), so they *can* be edited — only when a user requirement exceeds their props, after the cheaper rungs (`classes`/CSS → labels → `…Render` overrides → the hooks, [`../references/storefront-custom.md`](../references/storefront-custom.md)); an edited part is the store's code to maintain. Rules marked ⚑ must survive whatever design you build.
|
|
21
22
|
|
|
22
|
-
**One import path: `@/commerce/storefront`.** Each section opens with its import line — copy it,
|
|
23
|
+
**One import path: `@/commerce/storefront`.** Each section opens with its import line — copy it, drop unused names.
|
|
23
24
|
|
|
24
25
|
## Setup — once
|
|
25
26
|
|
|
@@ -28,7 +29,7 @@ Nearly every store has shared chrome, so **start from a pathless layout route**
|
|
|
28
29
|
```jsx
|
|
29
30
|
import { Routes, Route, Outlet } from "react-router-dom";
|
|
30
31
|
import { StorefrontProvider, CartUIProvider, useCart } from "@/commerce/storefront";
|
|
31
|
-
import { STORE_COPY } from "@/copy"; //
|
|
32
|
+
import { STORE_COPY } from "@/copy"; // your copy file — see The copy file
|
|
32
33
|
import { base44 } from "@/api/base44Client";
|
|
33
34
|
import AdminApp from "@/commerce/admin";
|
|
34
35
|
|
|
@@ -54,66 +55,31 @@ import AdminApp from "@/commerce/admin";
|
|
|
54
55
|
|
|
55
56
|
## Design language — once, before any page
|
|
56
57
|
|
|
57
|
-
The cost driver of a generated storefront is not wiring — it is decoration repeated inline. Encode identity **once**: in `index.css`, set the palette and type scale, then define the store's recurring surfaces as **10–15 composable classes**
|
|
58
|
+
The cost driver of a generated storefront is not wiring — it is decoration repeated inline. Encode identity **once**: in `index.css`, set the palette and type scale, then define the store's recurring surfaces as **10–15 composable classes** named in *this* store's language (`.panel`, `.btn-cta`, `.label-mono`, `.field`, `.choice-row`). Pages then carry short class names plus a little layout. ⚑ **A utility run that appears twice becomes a class.** The words work the same way: one copy file, written once.
|
|
58
59
|
|
|
59
|
-
**Concentrate identity; don't diffuse it.** The classes carry the look
|
|
60
|
+
**Concentrate identity; don't diffuse it.** The classes carry the look; on top of them spend bespoke markup on **one or two signature moments per page**. **The product page stays the richest surface**, and that richness is semantic: what the controls and rows *show*. Checkout, bag and receipt are convention surfaces — parts give the structure, your classes the look.
|
|
61
|
+
|
|
62
|
+
⚑ **Budget by surface, and spend the product page's.** Convention surfaces are ~2–4K chars each; **the product page gets ~8K and the collection ~5K**, because rendering axes and specs *by what they are* is exactly what those chars buy — a product page that came in at 3K is the generic one. Over budget means re-implemented hook logic (a quantity clamp, totals math, variant resolution), never too much design: find your version, delete it, call the hook.
|
|
60
63
|
|
|
61
64
|
## The parts — shared contract
|
|
62
65
|
|
|
63
|
-
A part renders one section's correct semantic markup — every branch, guard and aria attribute — and nothing else. Your markup arranges the parts (grid, order, sticky aside, accordion steps — a hidden part loses no state; the
|
|
66
|
+
A part renders one section's correct semantic markup — every branch, guard and aria attribute — and nothing else. Your markup arranges the parts (grid, order, sticky aside, accordion steps — a hidden part loses no state; the gate still counts every blocker). What you pass carries the identity:
|
|
64
67
|
|
|
65
|
-
- **Styling.**
|
|
66
|
-
- **Words.**
|
|
67
|
-
- **Navigation — never rendered, never assumed.** A gate renders *your* element when the state calls for it (function children hand you the data); `OrderReceived.PaymentLink` hands `{ url }` to
|
|
68
|
+
- **Styling.** Parts ship **layout geometry only** (field grids, labels above full-width controls, square thumbnails, label-left/value-right totals) and **no** color, border, radius, shadow or font — unstyled reads unfinished, never broken. The look is yours: elements carry `data-part` + state attributes, every shipped rule sits at **specificity 0** (`:where()`) so your plain selector wins, and `className`/`classes` take the same names. ⚑ **Style the controls at minimum** — `[data-part="control"]`/`[data-part="input"]` need `border`, `background`, `color: inherit`, `font: inherit`, `padding` and a focus ring, or the store ships browser-default white boxes. Arranging *sections* stays yours (columns, gaps, the drawer): parts add no outer margins. Selectors, geometry vars and what else repays styling: [`../references/storefront-parts.md`](../references/storefront-parts.md).
|
|
69
|
+
- **Words.** Your copy file, passed once on `StorefrontProvider labels`; override per Root or part with the same shape. A missing key renders a visible `⟨copy: path⟩` and warns — never silent, never English.
|
|
70
|
+
- **Navigation — never rendered, never assumed.** A gate renders *your* element when the state calls for it (function children hand you the data); `OrderReceived.PaymentLink` hands `{ url }` to yours; `Checkout.Root` requires `orderReceivedPath`. Nothing assumes a route, or even an anchor.
|
|
71
|
+
- **Display settings — `show`, never CSS.** Which sub-elements a part renders is a prop: `show={{ media: false }}` (overrides) or `show={["subtotal", "total"]}` (exactly these). Keys are the same `data-part` names, per part below. ⚑ **Turn an element off with `show`, not `display: none`** — the prop renders nothing, while hiding leaves an empty box in the layout and the accessibility tree.
|
|
72
|
+
- **Overrides — your element, the part's wiring.** Where the *element type* is the design decision, one prop swaps it and the part keeps the state and aria: `inputRender`/`fieldRender` (address fields), `controlRender` (ship-to-different), `optionRender` (a shipping/payment option — **the whole element**, radio included: call `option.select()`, show `option.selected`), `stepperRender` (cart quantity), `inputRender` (coupon), `lineRender`/`itemRender` (a whole row). ⚑ A replaced control still owes keyboard operability, a readable state and a name. Signatures: [`../references/storefront-parts.md`](../references/storefront-parts.md).
|
|
68
73
|
|
|
69
74
|
A section the props can't express drops to its hook — same shared state, mixing is safe: [`../references/storefront-custom.md`](../references/storefront-custom.md).
|
|
70
75
|
|
|
71
76
|
### The copy file
|
|
72
77
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
```js
|
|
76
|
-
export const STORE_COPY = {
|
|
77
|
-
placeOrder: { label: "Place order", placing: "Placing your order…" },
|
|
78
|
-
blockers: {
|
|
79
|
-
empty_cart: "Your bag is empty.", billing_incomplete: "Fill in your details above.",
|
|
80
|
-
shipping_address_incomplete: "Finish the delivery address.",
|
|
81
|
-
shipping_address_required: "Enter your address to see delivery options.",
|
|
82
|
-
shipping_method_required: "Choose a delivery option.",
|
|
83
|
-
shipping_not_available: "We can't deliver to that address.",
|
|
84
|
-
payment_method_required: "Choose how you'd like to pay.",
|
|
85
|
-
cart_loading: "One moment — loading your bag.", shipping_recalculating: "Updating delivery costs…",
|
|
86
|
-
},
|
|
87
|
-
shipping: {
|
|
88
|
-
missing_address: "Delivery options appear once your address is entered.",
|
|
89
|
-
none_available: "We don't deliver to that address yet.",
|
|
90
|
-
syncing: "Updating delivery options…",
|
|
91
|
-
},
|
|
92
|
-
payment: { none_available: "Checkout is unavailable right now — please try again later." },
|
|
93
|
-
coupon: { placeholder: "Gift or promo code", apply: "Apply", remove: "Remove" },
|
|
94
|
-
fields: {
|
|
95
|
-
first_name: "First name", last_name: "Last name", email: "Email", company: "Company",
|
|
96
|
-
address_1: "Address", address_2: "Apartment, suite (optional)", country: "Country",
|
|
97
|
-
city: "City", state: "State / Province", postcode: "Postal code",
|
|
98
|
-
phone: "Phone (optional)", select_placeholder: "Select…",
|
|
99
|
-
},
|
|
100
|
-
totals: { subtotal: "Subtotal", discount: "Discount", shipping: "Shipping", tax: "Tax", total: "Total" },
|
|
101
|
-
bank: { account_name: "Account name", account_number: "Account number", bank_name: "Bank",
|
|
102
|
-
sort_code: "Sort code", iban: "IBAN", bic: "BIC" },
|
|
103
|
-
aria: {
|
|
104
|
-
trigger: "Open your bag", drawer: "Your bag", close: "Close",
|
|
105
|
-
increase: (name) => `Add one more ${name}`, decrease: (name) => `Remove one ${name}`,
|
|
106
|
-
remove: (name) => `Remove ${name} from your bag`,
|
|
107
|
-
shipping_group: "Delivery options", payment_group: "Payment methods",
|
|
108
|
-
},
|
|
109
|
-
};
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
Blocker lines are what a disabled place-order button says — one per code, naming the thing the customer can fix (`cart_loading`/`shipping_recalculating` are quiet transients, not errors); `shipping.*`/`payment.*` are the pickers' dead-ends, with the server's own message rendered instead where one exists. The key tree is frozen in code as `REQUIRED_LABEL_KEYS`.
|
|
78
|
+
The parts render **no kit-authored word**: every string — visible copy, field labels, totals row names, aria names — comes from one map you write in the store's voice and pass once (`<StorefrontProvider labels={STORE_COPY}>`, Setup above); a part or page `Root` takes a `labels` slice to override one surface. Groups: `placeOrder`, `blockers` (one line per code, each naming what the customer can fix), `shipping`/`payment` (picker dead-ends), `coupon`, `fields`, `totals`, `bank`, `aria`. ⚑ **A key you don't provide renders a visible `⟨copy: …⟩` placeholder** and warns — never English, never silent, and never something to ship. **Copy the reference map from [`../references/storefront-parts.md`](../references/storefront-parts.md) and rewrite every value** — the same file carries the styling contract, so open it once for both.
|
|
113
79
|
|
|
114
80
|
## What each hook resolves to
|
|
115
81
|
|
|
116
|
-
|
|
82
|
+
Already unwrapped — no `.data`, no envelope; `formatMoney` is `useFormatMoney()`. This serves the catalog surfaces and row/option overrides; custom-section shapes (`useCartLine`, `useCartUI`, `useCheckoutContext`, `useOrderReturn`, `cartTotalsLines`/`orderTotalsLines`) are in [`../references/storefront-custom.md`](../references/storefront-custom.md).
|
|
117
83
|
|
|
118
84
|
| Call | Resolves to |
|
|
119
85
|
|---|---|
|
|
@@ -128,7 +94,7 @@ Everything below is already unwrapped — no `.data`, no envelope; `formatMoney`
|
|
|
128
94
|
| `productPrice(rowOrView, { formatMoney })` | `{ label, compareAtLabel, onSale, isFrom, isRange, min, max }` — `label` is what to render. |
|
|
129
95
|
| `productImages(product)` | `[{ src, name, alt }]`, de-duplicated. `[]` is legitimate → render your placeholder. |
|
|
130
96
|
| `productRibbons(product)` | `[{ id, name }]` — **objects**, and the field can be absent; takes a listing row or `useProduct().product`. |
|
|
131
|
-
| `productSpecs(product)` | `[{ key, label, titleLabel, value }]` from `meta_data
|
|
97
|
+
| `productSpecs(product)` | `[{ key, label, titleLabel, value, type, number, unit, items }]` from `meta_data` — `type` is `"numeric" \| "duration" \| "location" \| "list" \| "text"`, inferred, with `number`/`unit` split out for the first two and `items` for a list. `findSpec(rows, key)` looks one up ignoring case/spaces/`_`/`-`. Never match on `label` — meta keys are free text. |
|
|
132
98
|
| `useCart()` | `{ status, cart, itemCount, isEmpty, loading, error, mutationError, refresh, addItem, updateItem, removeItem, applyCoupon, removeCoupon }` — `status`: `"loading" \| "ready" \| "empty"`. |
|
|
133
99
|
| `cart.items[n]` | `{ item_key, product_id, variation_id, name, slug, quantity, price, subtotal, total, image, attributes, sold_individually, purchasable }` — `attributes` is an **array** of `{name, option}` (`attributesLabel(item.attributes)` → `"Size: 42 · Color: Ivory"`); `purchasable` is a **result object** `{ok, code, error}`, not a boolean; `slug` is the line's product-page link. |
|
|
134
100
|
|
|
@@ -138,15 +104,15 @@ Everything below is already unwrapped — no `.data`, no envelope; `formatMoney`
|
|
|
138
104
|
import { useProductList, useCategories, useStoreInfo, useFormatMoney, productPrice, productImages, productRibbons } from "@/commerce/storefront";
|
|
139
105
|
```
|
|
140
106
|
|
|
141
|
-
(Drop `useCategories` with no filter bar; add `useRibbons` for a ribbon filter.) `const list = useProductList({ per_page: 24 })`, then
|
|
107
|
+
(Drop `useCategories` with no filter bar; add `useRibbons` for a ribbon filter.) `const list = useProductList({ per_page: 24 })`, then guard on `list.status` before any markup — ⚑ branch on `status`, so a failed request renders as a failure, not an empty grid, with a retry calling `list.reload`.
|
|
142
108
|
|
|
143
109
|
⚑ **Render paging whenever `hasNext` is true** — `{list.hasNext && <button type="button" onClick={list.next} disabled={list.busy}>…</button>}` (append mode: `list.loadMore`); a page that renders nothing for paging ships a catalog silently capped at `per_page`. Drive filters from `useCategories()`/`useRibbons()` data via `setParams`, never from hardcoded names — a renamed ribbon must not strand a dead button.
|
|
144
110
|
|
|
145
|
-
A card can render `name`, `productImages(row)[0]`, `productPrice(row, { formatMoney }).label` (already "From €19.99" when the product sells variants — there is no
|
|
111
|
+
A card can render `name`, `productImages(row)[0]`, `productPrice(row, { formatMoney }).label` (already "From €19.99" when the product sells variants — there is no `type` flag, and `product.price` is a rolled-up from-price), `on_sale`, `short_description`, `stock_status`, `average_rating`/`rating_count`, `productRibbons(row)`, `productSpecs(row)`. ⚑ **Images and ribbons are objects, either may be empty** — render your placeholder, never a broken `<img>` or a raw object. Field matrix: [`../references/catalog-rendering.md`](../references/catalog-rendering.md). That is an inventory, not a card design and not an order to render in. An even grid of identical cards, each carrying the same name/price/stars trio, is where a generated store lands by default and almost never where this catalog belongs: give the grid a rhythm (a hero piece spanning two columns, an editorial break between rows, a denser tile for a large catalog), and lead each card with the one or two fields *these* products are judged on — carat weight, focal length, edition size, ABV — read off `productSpecs(row)`, not the fields every store shows.
|
|
146
112
|
|
|
147
|
-
⚑ **Ribbons belong in both views** — grid and product page. They are the merchant's own merchandising ("Limited", "Last pieces"),
|
|
113
|
+
⚑ **Ribbons belong in both views** — grid and product page. They are the merchant's own merchandising ("Limited", "Last pieces"), each linking to its filtered listing (`/collection?ribbon_id=<id>`). `productRibbons(row)` hands you `{id, name}` **objects** — render `r.name`, key on `r.id`; the object in JSX is React's "Objects are not valid as a React child". Never render a bare "Ribbons:" label with nothing after it.
|
|
148
114
|
|
|
149
|
-
**Rails** (featured row, "new in") are the same hook with a filter (`{ featured: true, per_page: 4 }`) — `featured` is the merchant's
|
|
115
|
+
**Rails** (featured row, "new in") are the same hook with a filter (`{ featured: true, per_page: 4 }`) — `featured` is the merchant's flag, so the rail stays curated store data, not hardcoded slugs. ⚑ Any filter may match nothing — render *nothing* then, never a heading over an empty row.
|
|
150
116
|
|
|
151
117
|
## Product page
|
|
152
118
|
|
|
@@ -154,7 +120,7 @@ A card can render `name`, `productImages(row)[0]`, `productPrice(row, { formatMo
|
|
|
154
120
|
import { useProduct, useAddToCart, useStoreInfo, useFormatMoney, useStorefront, variantAxes, productImages, imageIndex, productRibbons, productSpecs, findSpec, storefrontErrorCode } from "@/commerce/storefront";
|
|
155
121
|
```
|
|
156
122
|
|
|
157
|
-
`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. ⚑ **
|
|
123
|
+
`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. ⚑ **Every hook goes above the status guards** — they tolerate a null/loading product; a hook after an early `return` crashes React with "Rendered more hooks than during the previous render" once the product resolves.
|
|
158
124
|
|
|
159
125
|
```jsx
|
|
160
126
|
const p = useProduct(slug);
|
|
@@ -178,7 +144,7 @@ Build your layout from — all optional, **not one component style**:
|
|
|
178
144
|
useEffect(() => setPicked(null), [view?.variation?.id]); // a new variant re-takes the lead
|
|
179
145
|
```
|
|
180
146
|
|
|
181
|
-
⚑ **The main frame comes from `view.display.image`, never
|
|
147
|
+
⚑ **The main frame comes from `view.display.image`, never an index into the strip.** A variation's image lives on the variation and usually is *not* in `product.images`, so indexing the strip shows the wrong colour as soon as a variant is picked. `display.image` is variation-first with a parent fallback, in the same `{src, name, alt}` shape as a strip entry — which is why `imageIndex` finds it when present and returns `-1` when not. `active === null` only when the product has no images.
|
|
182
148
|
- **Variant selector** — `variantAxes(view, p.pick)`, one entry per axis:
|
|
183
149
|
|
|
184
150
|
```jsx
|
|
@@ -193,7 +159,7 @@ Build your layout from — all optional, **not one component style**:
|
|
|
193
159
|
))}
|
|
194
160
|
```
|
|
195
161
|
|
|
196
|
-
⚑ **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
|
|
162
|
+
⚑ **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 plus a size guide for a size axis; every axis as the identical chip row is a generated-page tell, and the differentiation is semantic (what the control *shows*), not chrome.
|
|
197
163
|
- **Buy box** — one button, and **you supply its four words**:
|
|
198
164
|
|
|
199
165
|
```jsx
|
|
@@ -202,12 +168,22 @@ Build your layout from — all optional, **not one component style**:
|
|
|
202
168
|
{buy.error?.message && <p role="alert">{buy.error.message}</p>}
|
|
203
169
|
```
|
|
204
170
|
|
|
205
|
-
⚑ **Text for every state, and the gate from the hook.** `buy.state` resolves the precedence — never re-derive `disabled` from
|
|
206
|
-
- **Description** — `product.description` is HTML; render as rich text, `short_description` above
|
|
207
|
-
- **Specs** — `productSpecs(product)` rows from the admin's *Modifiers
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
171
|
+
⚑ **Text for every state, and the gate from the hook.** `buy.state` resolves the precedence — never re-derive `disabled` from a ternary chain, never leave a state unworded (the button renders empty). ⚑ `buy.showQuantity: false` means no stepper. With `<CartUIProvider>` mounted, a successful add opens the drawer.
|
|
172
|
+
- **Description** — `product.description` is HTML; render as rich text, `short_description` above.
|
|
173
|
+
- **Specs** — `productSpecs(product)` rows from the admin's *Modifiers*, **the product page's signature-moment candidate**. Every row arrives pre-classified, so the branch point is already there and one uniform table is a choice, not a default:
|
|
174
|
+
|
|
175
|
+
```jsx
|
|
176
|
+
{productSpecs(product).map((s) =>
|
|
177
|
+
s.type === "numeric" ? <Figure key={s.key} label={s.label} n={s.number} unit={s.unit} />
|
|
178
|
+
: s.type === "list" ? <Bars key={s.key} parts={s.items} /> // composition, materials
|
|
179
|
+
: s.type === "location" ? <Sourced key={s.key} place={s.value} /> // origin, provenance
|
|
180
|
+
: <Row key={s.key} label={s.titleLabel} value={s.value} />)}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
⚑ **Never `.map()` the whole list into one grey label/value table** — that is the single most reliable tell of a generated product page. Design the two or three rows that carry *this* catalog's meaning as what they are (a weight set in the display face, a composition as bars, a provenance beside its place); let the rest fall through to the plain row, and don't feel obliged to keep them in one block — a spec can sit under the gallery, beside the price, or inside the description. Branch on `s.key` too where one particular modifier deserves its own treatment regardless of type. ⚑ **Look a spec up with `findSpec(rows, "care")`** (ignores case, spaces, `_`, `-`): meta keys are free text (`care`, `Care`, `Care Instructions`), so `rows.find(s => s.label === "Care")` silently never matches and renders the fallback forever. `[]` means no section.
|
|
184
|
+
- **Breadcrumbs** — from `categories` (`/collection?category_id=${c.id}`); skip on a flat catalog. Ribbons are labels, not breadcrumbs.
|
|
185
|
+
- **Reviews, only if the store wants them** — no review UI is a complete outcome (then no star ratings on cards: an average of nothing is `0`). `p.reviews` arrives with the product; submitting is `submitReview` off `useStorefront()`, open to guests. ⚑ Derive the confirmation from the response's `status` (`"approved"` vs `"hold"`) — a hardcoded "awaiting approval" lies to auto-approving stores — and refresh after. Policies: [`../references/reviews.md`](../references/reviews.md).
|
|
186
|
+
- **Title** — a `<title>` and description per page type; one static title everywhere is invisible to search. No structured data ships — for rich results emit your own `Product`/`Offer` JSON-LD from `product` and `view.display`.
|
|
211
187
|
|
|
212
188
|
## Cart / bag
|
|
213
189
|
|
|
@@ -215,38 +191,50 @@ Build your layout from — all optional, **not one component style**:
|
|
|
215
191
|
import { Cart, CartDrawer } from "@/commerce/storefront";
|
|
216
192
|
```
|
|
217
193
|
|
|
218
|
-
A cart *page* is optional
|
|
194
|
+
A cart *page* is optional (buy-now-to-checkout suits a single-piece store; a grocery basket needs a page). Page or drawer, the same parts. Below as a drawer: `<CartUIProvider>` (Setup) owns state and behavior — open/close, Esc, open-on-add, close-on-navigate, focus in and back out, `inert` while closed — while **the drawer's surface is entirely yours**: side, width, padding, animation. A cart page skips the provider and the `ui.open` wrapper.
|
|
219
195
|
|
|
220
196
|
```jsx
|
|
197
|
+
const ui = useCartUI(); // open + handlers + panelRef/panelId
|
|
198
|
+
|
|
221
199
|
<header>{/* your nav */}
|
|
222
200
|
<CartDrawer.Trigger className="nav-icon">
|
|
223
201
|
{({ count }) => (<><YourBagGlyph />{count > 0 && <span className="badge">{count}</span>}</>)}
|
|
224
202
|
</CartDrawer.Trigger>
|
|
225
203
|
</header>
|
|
226
204
|
|
|
227
|
-
|
|
228
|
-
<
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
205
|
+
{ui.open && ( /* the drawer's SURFACE is yours: */
|
|
206
|
+
<div className="drawer-layer"> /* fixed layer — side, z-index, transition */
|
|
207
|
+
<div className="drawer-scrim" aria-hidden="true" onClick={ui.closeCart} />
|
|
208
|
+
<aside id={ui.panelId} ref={ui.panelRef} tabIndex={-1}
|
|
209
|
+
role="dialog" aria-modal="true" aria-label={/* your words */}
|
|
210
|
+
className="drawer-panel"> /* width, padding, layout, animation */
|
|
211
|
+
<header>{/* your title */}<CartDrawer.Close><YourXGlyph /></CartDrawer.Close></header>
|
|
212
|
+
<Cart.Empty>{/* your empty state + your own link back into the shop */}</Cart.Empty>
|
|
213
|
+
<Cart.Ready>
|
|
214
|
+
<Cart.Notices />
|
|
215
|
+
<Cart.Lines classes={{ row: "bag-row", media: "bag-thumb" }} />
|
|
216
|
+
<Cart.CouponField />
|
|
217
|
+
<Cart.Totals show={["subtotal"]} />
|
|
218
|
+
{/* your checkout affordance — your element, your route */}
|
|
219
|
+
</Cart.Ready>
|
|
220
|
+
</aside>
|
|
221
|
+
</div>
|
|
222
|
+
)}
|
|
238
223
|
```
|
|
239
224
|
|
|
240
225
|
| Part | Renders | Props |
|
|
241
226
|
|---|---|---|
|
|
242
227
|
| `Cart.Loading` / `Empty` / `Ready` | gates — children only in that cart status, so emptiness-while-loading can't render; function children get `{ cart, itemCount }` | — |
|
|
243
|
-
| `Cart.Lines` | the rows:
|
|
228
|
+
| `Cart.Lines` | the rows: thumbnail, name, attributes, stepper (optimistic, coalesces clicks, rolls back — busy scoped to **that row**, never the cart), remove, line total, per-row errors in the server's words | `show`: `media` `attributes` `stepper` `remove` `lineTotal`; `stepperRender`, `lineRender` (`item.slug` for your product link) |
|
|
244
229
|
| `Cart.Notices` | expired coupons + vanished products, the server's words — a line never disappears unexplained | — |
|
|
245
|
-
| `Cart.CouponField` | input + apply + applied codes with remove; an invalid code renders inline |
|
|
246
|
-
| `Cart.Totals` | every non-hidden summary line, discount and tax included, `data-emphasis` on the total | `
|
|
247
|
-
| `CartDrawer.Trigger/
|
|
230
|
+
| `Cart.CouponField` | input + apply + applied codes with remove; an invalid code renders inline | `inputRender` |
|
|
231
|
+
| `Cart.Totals` | every non-hidden summary line, discount and tax included, `data-emphasis` on the total | `show`: row keys (`["subtotal"]` for a drawer footer) |
|
|
232
|
+
| `CartDrawer.Trigger` / `Close` | the two wired buttons — the trigger toggles and reports `aria-expanded`/`aria-controls` (render prop gets `{ count, open }`); `Close` is the named way out. **No panel part ships**: the overlay, the panel, their side, width, padding and animation are yours | children |
|
|
233
|
+
| `useCartUI()` | `{ open, openCart, closeCart, toggleCart, panelRef, panelId }` — plus Esc, close-on-navigate and open-on-add. Attach `panelRef` and focus moves into your panel on open and back out on close, and a panel you keep mounted to animate is made `inert` while closed | — |
|
|
234
|
+
|
|
235
|
+
⚑ Drawer rules, now that the surface is yours: render it conditionally (`{ui.open && …}`) or keep it mounted to animate and let `panelRef` handle `inert`; the panel takes `role="dialog" aria-modal="true"`, a name, `tabIndex={-1}` and `id={ui.panelId}`; the scrim is click-away only (`aria-hidden`, no tab stop) — `<CartDrawer.Close>` is the close control.
|
|
248
236
|
|
|
249
|
-
⚑ **A store with any coupons places `CouponField` once** (cart or checkout) — codes are redeemable only through a field; no field → don't seed codes. **Upsells**: `useProduct().upsells` rows added via `useCart().addItem`, matched by product id — ⚑ one-click add only for a product with no attributes (others answer `400 variation_required`; link those tiles to the product page).
|
|
237
|
+
⚑ **A store with any coupons places `CouponField` once** (cart or checkout) — codes are redeemable only through a field; no field → don't seed codes. **Upsells**: `useProduct().upsells` rows added via `useCart().addItem`, matched by product id — ⚑ one-click add only for a product with no attributes (others answer `400 variation_required`; link those tiles to the product page). A cart-contents component reused in the drawer, the cart page *and* the checkout summary drops what a checkout shouldn't carry via `useInCheckout()` — a merchandising rail is a way out of the funnel there.
|
|
250
238
|
|
|
251
239
|
## Checkout
|
|
252
240
|
|
|
@@ -254,9 +242,9 @@ A cart *page* is optional — decide from what the store sells (buy-now-to-check
|
|
|
254
242
|
import { Checkout } from "@/commerce/storefront";
|
|
255
243
|
```
|
|
256
244
|
|
|
257
|
-
`Checkout.Root` mounts the guided state — address edits reprice shipping/tax automatically (debounced, never on a half-typed address), choices derive from store data — and resolves the
|
|
245
|
+
`Checkout.Root` mounts the guided state — address edits reprice shipping/tax automatically (debounced, never on a half-typed address), choices derive from store data — and resolves the phase: `Submitted` renders above everything (placing an order clears the bag, and this ordering kills the empty-bag flash over it), then `Loading` / `Empty` / `Form`.
|
|
258
246
|
|
|
259
|
-
⚑ **`orderReceivedPath` is required** —
|
|
247
|
+
⚑ **`orderReceivedPath` is required** — the route *your* receipt page is mounted at; the kit assumes none. The landing is a **full page load** by design (the provider hop leaves the app; the receipt boots from the URL alone — a browser script must navigate fresh to it, not await a transition). `null` + `onPlaced(result)` hands the step to you.
|
|
260
248
|
|
|
261
249
|
```jsx
|
|
262
250
|
<Checkout.Root orderReceivedPath="/order-received">
|
|
@@ -287,16 +275,16 @@ import { Checkout } from "@/commerce/storefront";
|
|
|
287
275
|
|---|---|---|
|
|
288
276
|
| `Checkout.Root` | the provider, the phase, the labels scope | `orderReceivedPath` (**required**; or `null` + `onPlaced`), `labels`, `options`, `checkout` |
|
|
289
277
|
| `Submitted` / `Loading` / `Empty` / `Form` | gates; function children get `{ checkout, cart }` | — |
|
|
290
|
-
| `AddressFields` | the spec-driven fields: state/province appears once a country is picked (rates and taxes match on country *plus* state — its absence silently mis-prices US/CA/AU orders); `autoComplete` on; required marks arm on blur; "we don't ship there" lands on country; `which="shipping"` renders only when toggled | `which
|
|
291
|
-
| `ShipToDifferent` | the wired toggle; children are its label | children |
|
|
292
|
-
| `ShippingMethods` | every branch: the hint line (server's message preferred
|
|
278
|
+
| `AddressFields` | the spec-driven fields: state/province appears once a country is picked (rates and taxes match on country *plus* state — its absence silently mis-prices US/CA/AU orders); `autoComplete` on; required marks arm on blur; "we don't ship there" lands on country; `which="shipping"` renders only when toggled | `which`; `show` keyed by field (`company` off by default); `inputRender`, `fieldRender` |
|
|
279
|
+
| `ShipToDifferent` | the wired toggle; children are its label | children, `controlRender` |
|
|
280
|
+
| `ShippingMethods` | every branch: the hint line (server's message preferred), a radio list while there is a real choice, the single/auto-selected option *displayed* — never a picker of one, never "nothing selected" — null for a virtual cart | `optionRender(m)` — the whole option |
|
|
293
281
|
| `PaymentMethods` | every **enabled** gateway from store info, titles/descriptions in the admin's words (a default store offers `offline` only — never hardcode a card option); one renders as the selection it is; none renders your unavailable line | `optionRender(g)` |
|
|
294
|
-
| `Items` | the read-only mini summary | `itemRender
|
|
295
|
-
| `CouponField` / `Totals` | same parts as the cart's |
|
|
296
|
-
| `PlaceOrder` | the button (your two labels), the server's order error, and the blocker lines — ⚑ a disabled button always says why | `
|
|
297
|
-
| `Blockers` | the reasons alone, for custom placement
|
|
282
|
+
| `Items` | the read-only mini summary | `show`: `media` `attributes` `quantity` `lineTotal`; `itemRender` |
|
|
283
|
+
| `CouponField` / `Totals` | same parts as the cart's | `inputRender` / `show` |
|
|
284
|
+
| `PlaceOrder` | the button (your two labels), the server's order error, and the blocker lines — ⚑ a disabled button always says why | `show`: `blockers` `orderError` |
|
|
285
|
+
| `Blockers` | the reasons alone, for custom placement — the button's own copy stands down by itself | — |
|
|
298
286
|
|
|
299
|
-
|
|
287
|
+
**Multi-step checkout is your markup**: wrap parts in your step containers — a hidden step loses nothing, the gate still counts every blocker.
|
|
300
288
|
|
|
301
289
|
## Order received
|
|
302
290
|
|
|
@@ -313,8 +301,8 @@ import { OrderReceived } from "@/commerce/storefront";
|
|
|
313
301
|
{({ order }) => (<>{/* your thank-you, order #{order.order_number} */}
|
|
314
302
|
<OrderReceived.Items /><OrderReceived.Totals /></>)}
|
|
315
303
|
</OrderReceived.Paid>
|
|
316
|
-
<OrderReceived.Unpaid>{/* your
|
|
317
|
-
<OrderReceived.PaymentInstructions /> {/* ⚑
|
|
304
|
+
<OrderReceived.Unpaid>{/* your "not settled yet" wording */}
|
|
305
|
+
<OrderReceived.PaymentInstructions /> {/* ⚑ below */}
|
|
318
306
|
<OrderReceived.PaymentLink>{({ url }) => (/* your pay-now element on url */)}</OrderReceived.PaymentLink>
|
|
319
307
|
<OrderReceived.Items /><OrderReceived.Totals />
|
|
320
308
|
</OrderReceived.Unpaid>
|
|
@@ -323,20 +311,22 @@ import { OrderReceived } from "@/commerce/storefront";
|
|
|
323
311
|
</OrderReceived.Root>
|
|
324
312
|
```
|
|
325
313
|
|
|
326
|
-
⚑ **`PaymentInstructions` goes on the unpaid branch.** A default-seeded store settles manually (bank transfer, cash on delivery), so the instructions ARE how its customers pay — a receipt without them strands every real order. It renders the admin's description plus account details (your `bank.*` labels); null for card orders. `PaymentLink` renders your element only when a live link exists; the gates' function children get `{ order, paymentLink, paymentInstructions, error, reload }`
|
|
314
|
+
⚑ **`PaymentInstructions` goes on the unpaid branch.** A default-seeded store settles manually (bank transfer, cash on delivery), so the instructions ARE how its customers pay — a receipt without them strands every real order. It renders the admin's description plus account details (your `bank.*` labels); null for card orders. `PaymentLink` renders your element only when a live link exists; the gates' function children get `{ order, paymentLink, paymentInstructions, error, reload }`. `Items` takes the same `show`/`itemRender` as the cart; `Totals` owns the flat-totals trap (`order.total`, never `order.totals`).
|
|
327
315
|
|
|
328
|
-
**Driving the storefront from a browser script?** The cart is optimistic and debounced, so a script
|
|
316
|
+
**Driving the storefront from a browser script?** The cart is optimistic and debounced, so a script acting faster than it settles sees a working store as broken — read [`../references/storefront-verification.md`](../references/storefront-verification.md) **before** writing it.
|
|
329
317
|
|
|
330
318
|
## Done — forget this file
|
|
331
319
|
|
|
332
320
|
- [ ] Catalog UI in whatever form fits the store; cart (and/or drawer), checkout and `/order-received` built from the parts — all five receipt gates present, `PaymentInstructions` placed on unpaid.
|
|
321
|
+
- [ ] A drawer, if any, is the store's own surface (side, width, padding, animation) over `useCartUI()`: `panelRef` + `panelId` on the panel, `role="dialog" aria-modal` + a name, the scrim not doubling as close.
|
|
333
322
|
- [ ] One copy file passed via `<StorefrontProvider labels={…}>`, **every value rewritten in the store's voice** — no `⟨copy: …⟩` on any page, no reference-example sentence shipped verbatim; the buy button reads for all four `buy.state` values.
|
|
334
|
-
- [ ] `index.css` defines the design classes; parts styled via `[data-part]`/`classes` — **controls carry border/background/font/padding + a focus ring**, option rows show their selected state,
|
|
323
|
+
- [ ] `index.css` defines the design classes; parts styled via `[data-part]`/`classes` — **controls carry border/background/font/padding + a focus ring**, option rows show their selected state, nothing left browser-default; pages carry class names, not repeated utility runs.
|
|
335
324
|
- [ ] **One** `<StorefrontProvider>` above every storefront route (layout-route pattern); no hand-rolled `cart_token`; imports from each section's import line.
|
|
336
|
-
- [ ] Custom
|
|
325
|
+
- [ ] Custom sections branch on `status` and never re-implement part/hook logic; gateways/currency/countries from `useStoreInfo()`/`useCountries()` only.
|
|
337
326
|
- [ ] Coupon field placed if the store has coupons; a paging control whenever `hasNext`; ribbons in both the grid and the product page.
|
|
338
327
|
- [ ] Variant options: one control per axis, unbuyable options disabled, not hidden.
|
|
339
328
|
- [ ] Navigation affordances are yours: the checkout link in `Cart.Ready`, `orderReceivedPath` on `Checkout.Root`, `PaymentLink`'s child — the kit rendered none of them.
|
|
340
|
-
- [ ] The storefront carries the design you settled on before reading this file — design classes plus one or two signature moments per page; the product page stays
|
|
329
|
+
- [ ] The storefront carries the design you settled on before reading this file — design classes plus one or two signature moments per page; the product page stays richest.
|
|
330
|
+
- [ ] **Specs and axes render by what they are**: the product page branches on `productSpecs` `type`/`key` for the rows that carry this catalog's meaning, and the grid has a rhythm — no page ships one uniform grey label/value table or one identical chip row per axis.
|
|
341
331
|
|
|
342
332
|
Then copy this file's `carry_forward` lines into your working notes, and do not re-read this file.
|
|
@@ -51,7 +51,7 @@ once. The whole list, so none of it has to be read out of the backend:
|
|
|
51
51
|
|---|---|---|
|
|
52
52
|
| `images[]` | `{src, name, alt}` — **objects**; `[]` is legitimate | `productImages(product)` |
|
|
53
53
|
| `ribbons[]` | `{id, name}` — **objects**; the field is **absent**, not `[]`, when no row on the page carries one | `productRibbons(product)` |
|
|
54
|
-
| `meta_data[]` | `{key, value}` — keys are free text | `productSpecs(product)` + `findSpec` |
|
|
54
|
+
| `meta_data[]` | `{key, value}` — keys are free text | `productSpecs(product)` (adds a render `type`) + `findSpec` |
|
|
55
55
|
| `attributes[]` | `{attribute_id, name, position, options: string[]}` — one entry per **axis** | `variantAxes(view, pick)` |
|
|
56
56
|
| `default_attributes[]` | `{attribute_id, name, option}` — the merchant's pre-selection | `defaultSelection` (automatic in `useProduct`) |
|
|
57
57
|
| `dimensions` | `{length, width, height}` in the store's unit | — |
|
|
@@ -95,7 +95,7 @@ Both lists are inventories of what the data supports — **not a layout and not
|
|
|
95
95
|
|
|
96
96
|
**Product page:** gallery, name, price, variant selectors, stock, `short_description` then `description`, SKU, categories breadcrumb, ribbons, reviews, upsells/cross-sells. All but the markup is handed to you: `productImages(product)` + your own active index, `variantAxes(view, pick)`, `useAddToCart`, `productSpecs(product)`, `p.reviews` (+ `submitReview` off `useStorefront()`), and `p.upsells`/`p.crossSells` — added with `useCart().addItem`, matching "already in cart" by product id.
|
|
97
97
|
|
|
98
|
-
**Attributes and modifiers are individually designable.** `productSpecs(product)` rows are `{key, label, titleLabel, value}` and
|
|
98
|
+
**Attributes and modifiers are individually designable.** `productSpecs(product)` rows are `{key, label, titleLabel, value}` plus an inferred `type` — `"numeric"` / `"duration"` (with `number` and `unit` split out), `"location"`, `"list"` (with `items`), `"text"` — so the rendering branch is already made for you: a weight can be a figure, a composition bars, a provenance a place. ⚑ **Never `.map()` the whole list into one grey label/value table**; design the two or three rows this catalog is judged on and let the rest fall through to a plain row. ⚑ **Look a row up with `findSpec(rows, "care")`**, which ignores case, spaces, `_` and `-`: meta keys are free text typed per product (`care`, `Care`, `Care Instructions`), so an equality test on `label` — or on one spelling of `key` — silently never fires and the feature renders its fallback forever; `titleLabel` is the display-cased form, for a heading. What a row *looks* like is a per-catalog design decision — not one uniform chip row per axis and one grey table ([`../install/02-storefront.md`](../install/02-storefront.md)); §5's rules govern selector *behaviour*, never its form.
|
|
99
99
|
|
|
100
100
|
## 4. Ribbons — in **both** views
|
|
101
101
|
|
|
@@ -28,7 +28,7 @@ a disabled button that says why.
|
|
|
28
28
|
| Call | Resolves to |
|
|
29
29
|
|---|---|
|
|
30
30
|
| `useCartLine(item)` | `{ quantity, setQuantity, increase, decrease, remove, pending, error, canIncrease, canDecrease, maxQuantity, atMax, atMin }` — optimistic quantity, clicks coalesced (~250ms), rollback on rejection. `pending` is **this row's** flag: true while its request is out, false only after the new cart view lands — the only mutation-settled signal (`useCart().status` never returns to `"loading"`). |
|
|
31
|
-
| `useCartUI()` | `{ open, openCart, closeCart, toggleCart }` —
|
|
31
|
+
| `useCartUI()` | `{ open, openCart, closeCart, toggleCart, panelRef, panelId }` — drawer state and behavior on `<CartUIProvider>` (Esc, open-on-add, close-on-navigate; `panelRef` moves focus in/out and makes a closed-but-mounted panel `inert`). The drawer's overlay and panel are your markup. |
|
|
32
32
|
| `useCheckoutContext()` | the address (`billing`, `updateBilling`, `shipping`, `updateShipping`, `shipToDifferent`, `setShipToDifferent`, `missingBillingFields`, `addressError`), the choices (`shippingStatus`, `shippingMethods`, `chosenShippingMethod`, `chooseShippingMethod`, `paymentMethods`, `paymentMethod`, `setPaymentMethod`, `selectedGateway`), and the gate (`blockers`, `canPlaceOrder`, `placing`, `stage`, `orderError`, `placeOrder`) — plus `cart`. Works under `<Checkout.Root>` (or your own `<CheckoutProvider>`). |
|
|
33
33
|
| `useOrderReturn()` | `{ status, order, lines, paymentLink, paymentInstructions, error, reload }` — `status`: `"loading" \| "paid" \| "unpaid" \| "cancelled" \| "error"`; `lines` normalized (`image` is `{src, alt}\|null`, `totalLabel` pre-formatted); an order's totals are **flat** (`order.total`, no `order.totals`). |
|
|
34
34
|
| `cartTotalsLines(cart, { formatMoney, labels })` / `orderTotalsLines(order, …)` | `[{ key, label, amount, formatted, hidden, emphasis }]` — keys `subtotal` `discount` `shipping` `tax` `total`; discount/tax are produced and flagged `hidden` when zero, so a mapping renderer can't drop them. |
|