@base44/app-plugin-commerce 0.3.4 → 0.4.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 +2 -2
- package/base44/functions/commerce/storefront-cart/cart-pricing.ts +1 -0
- package/base44/functions/commerce/storefront-checkout/cart-pricing.ts +1 -0
- package/package.json +1 -1
- package/skills/commerce/SKILL.md +46 -50
- package/skills/commerce/docs/api-storefront.md +1 -1
- package/skills/commerce/install/01-install.md +4 -1
- package/skills/commerce/install/02-storefront.md +169 -184
- package/skills/commerce/references/storefront-custom.md +150 -0
- package/skills/commerce/references/storefront-styling.md +99 -0
- package/skills/commerce/references/storefront-verification.md +4 -0
- package/src/commerce/storefront/StorefrontProvider.jsx +10 -3
- package/src/commerce/storefront/index.js +29 -0
- package/src/commerce/storefront/parts/cart.jsx +167 -0
- package/src/commerce/storefront/parts/checkout.jsx +504 -0
- package/src/commerce/storefront/parts/drawer.jsx +119 -0
- package/src/commerce/storefront/parts/labels.jsx +140 -0
- package/src/commerce/storefront/parts/orderReceived.jsx +188 -0
- package/src/commerce/storefront/parts/parts.css +232 -0
- package/src/commerce/storefront/parts/shared.jsx +127 -0
|
@@ -5,24 +5,21 @@ skip_when: "The storefront pages already render against live data and pass the c
|
|
|
5
5
|
forget_when: "The checklist at the bottom passes — every page renders against the seeded catalog and an offline order completes."
|
|
6
6
|
carry_forward:
|
|
7
7
|
- "Payment gateways, currency and countries come from useStoreInfo() only — never off a cart (cart.payment_gateways is always undefined)."
|
|
8
|
-
- "A store with any coupons must
|
|
9
|
-
- "
|
|
10
|
-
- "Branch
|
|
8
|
+
- "A store with any coupons must place a CouponField part once (cart or checkout), or its codes can never be redeemed."
|
|
9
|
+
- "The receipt route is mandatory: OrderReceived.Root with all five gates, PaymentInstructions on the unpaid branch."
|
|
10
|
+
- "Branch custom hook-built UI on `status`, never on `isEmpty`/nullable data; every hook goes above its status guards."
|
|
11
11
|
- "Variant options that aren't buyable render disabled, never hidden; one control per axis, never a list of variations."
|
|
12
|
-
- "
|
|
13
|
-
- "
|
|
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."
|
|
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
|
+
- "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."
|
|
15
14
|
---
|
|
16
15
|
|
|
17
16
|
# 02 — Storefront
|
|
18
17
|
|
|
19
|
-
|
|
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 **zero CSS, zero copy and zero 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.
|
|
20
19
|
|
|
21
|
-
**
|
|
20
|
+
**This file is the whole job.** Every shape and contract you need 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 genuinely 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.
|
|
22
21
|
|
|
23
|
-
**
|
|
24
|
-
|
|
25
|
-
**One import path: `@/commerce/storefront`.** Each section opens with its page's exact import line — copy it verbatim, then delete unused names. Everything a page needs is re-exported there, so a React page never imports `@/commerce/utils` directly. `useStoreInfo` is the name most often left out.
|
|
22
|
+
**One import path: `@/commerce/storefront`.** Each section opens with its import line — copy it, delete unused names.
|
|
26
23
|
|
|
27
24
|
## Setup — once
|
|
28
25
|
|
|
@@ -30,53 +27,93 @@ Nearly every store has shared chrome, so **start from a pathless layout route**
|
|
|
30
27
|
|
|
31
28
|
```jsx
|
|
32
29
|
import { Routes, Route, Outlet } from "react-router-dom";
|
|
33
|
-
import { StorefrontProvider, CartUIProvider } from "@/commerce/storefront";
|
|
30
|
+
import { StorefrontProvider, CartUIProvider, useCart } from "@/commerce/storefront";
|
|
31
|
+
import { STORE_COPY } from "@/copy"; // the copy file, below
|
|
34
32
|
import { base44 } from "@/api/base44Client";
|
|
35
33
|
import AdminApp from "@/commerce/admin";
|
|
36
34
|
|
|
37
35
|
<BrowserRouter>
|
|
38
36
|
<Routes> {/* ONE <Routes> — merge new pages into the app's */}
|
|
39
37
|
<Route element={
|
|
40
|
-
<StorefrontProvider base44={base44}>
|
|
41
|
-
<CartUIProvider> {/* only if the cart is a drawer
|
|
38
|
+
<StorefrontProvider base44={base44} labels={STORE_COPY}>
|
|
39
|
+
<CartUIProvider> {/* only if the cart is a drawer */}
|
|
42
40
|
<StoreLayout /> {/* YOURS: header + <Outlet/> + footer + drawer */}
|
|
43
41
|
</CartUIProvider>
|
|
44
42
|
</StorefrontProvider>
|
|
45
43
|
}>
|
|
46
44
|
<Route path="/" element={<Home />} />
|
|
47
45
|
<Route path="/product/:slug" element={<ProductPage />} />
|
|
48
|
-
{/* /bag, /checkout,
|
|
46
|
+
{/* /bag, /checkout, /order-received (mandatory) */}
|
|
49
47
|
</Route>
|
|
50
48
|
<Route path="/store-admin/*" element={<AdminApp />} /> {/* own chrome, outside the provider */}
|
|
51
49
|
</Routes>
|
|
52
50
|
</BrowserRouter>
|
|
53
|
-
|
|
54
|
-
// …and the layout that route renders. Yours to design; the shape is the point:
|
|
55
|
-
function StoreLayout() {
|
|
56
|
-
const { itemCount } = useCart(); // one cart, shared with every page
|
|
57
|
-
return (
|
|
58
|
-
<>
|
|
59
|
-
<header>{/* nav + your cart trigger, showing itemCount */}</header>
|
|
60
|
-
<Outlet /> {/* the routed page lands here */}
|
|
61
|
-
<footer>…</footer>{/* + the drawer, if the cart is one — see Cart below */}
|
|
62
|
-
</>
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
51
|
```
|
|
66
52
|
|
|
67
|
-
⚑ **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
|
|
53
|
+
⚑ **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. 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, never touch `cart_token`.
|
|
68
54
|
|
|
69
55
|
## Design language — once, before any page
|
|
70
56
|
|
|
71
|
-
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** in Tailwind's components layer, named in *this* store's language (`.panel`, `.btn-cta`, `.label-mono`, `.field`, `.choice-row
|
|
72
|
-
|
|
73
|
-
|
|
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** in Tailwind's components layer, named in *this* store's language (`.panel`, `.btn-cta`, `.label-mono`, `.field`, `.choice-row` — whatever *this* store repeats). Pages then carry short class names plus a couple of layout utilities. ⚑ **A utility run that appears twice becomes a class.** The store's words work the same way: one copy file in the store's voice (below), written once.
|
|
58
|
+
|
|
59
|
+
**Concentrate identity; don't diffuse it.** The classes carry the look everywhere; on top of them, spend bespoke markup on **one or two signature moments per page**. **The product page stays the storefront's richest surface**, and that richness is semantic: what the controls and rows *show*. Checkout, bag and order-received are convention surfaces — the parts render their structure, your classes carry their look. Keep components small (~2–4K chars).
|
|
60
|
+
|
|
61
|
+
## The parts — shared contract
|
|
62
|
+
|
|
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 place-order gate still counts every blocker). Three inputs carry the identity:
|
|
64
|
+
|
|
65
|
+
- **Styling.** The parts ship **layout geometry only** — field grids, labels above full-width controls, square thumbnails, label-left/value-right totals, the drawer as a right-hand panel — and **no** color, border, radius, shadow or font, so an unstyled store reads as unfinished, never broken. The look is yours: every element carries `data-part` + state attributes (`[data-part="option"][data-state="selected"]`, `[data-part="row"][data-pending]`), each shipped rule sits at **specificity 0** (`:where()`), so a plain selector of yours always wins, and `className`/`classes` take the same names (`classes={{ row: "bag-row", "line-total": "price" }}`). ⚑ **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. Space *between* sections is your containers' `gap`; the parts add no outer margins. Selector inventory, the geometry's custom properties (`--commerce-gap`, `--commerce-media-size`, `--commerce-field-columns`, …) and what else repays styling: [`../references/storefront-styling.md`](../references/storefront-styling.md).
|
|
66
|
+
- **Words.** The copy file below, 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.
|
|
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 your element; `Checkout.Root` requires `orderReceivedPath`. Nothing assumes a route, or even an anchor.
|
|
68
|
+
|
|
69
|
+
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
|
+
|
|
71
|
+
### The copy file
|
|
72
|
+
|
|
73
|
+
Copy this map into `src/copy.js`, then ⚑ **rewrite every value in this store's voice** — the English here is *reference*; shipped verbatim it is how storefronts end up sounding alike (`aria.*` name controls for screen readers; templates take the item's name):
|
|
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
|
+
```
|
|
74
111
|
|
|
75
|
-
|
|
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`.
|
|
76
113
|
|
|
77
114
|
## What each hook resolves to
|
|
78
115
|
|
|
79
|
-
Everything below is already unwrapped — no `.data`, no envelope
|
|
116
|
+
Everything below is already unwrapped — no `.data`, no envelope; `formatMoney` is `useFormatMoney()`. This table serves the catalog surfaces and row/option overrides; the custom-section shapes (`useCartLine`, `useCartUI`, `useCheckoutContext`, `useOrderReturn`, `cartTotalsLines`/`orderTotalsLines`) live in [`../references/storefront-custom.md`](../references/storefront-custom.md).
|
|
80
117
|
|
|
81
118
|
| Call | Resolves to |
|
|
82
119
|
|---|---|
|
|
@@ -93,13 +130,7 @@ Everything below is already unwrapped — no `.data`, no envelope. `formatMoney`
|
|
|
93
130
|
| `productRibbons(product)` | `[{ id, name }]` — **objects**, and the field can be absent; takes a listing row or `useProduct().product`. |
|
|
94
131
|
| `productSpecs(product)` | `[{ key, label, titleLabel, value }]` from `meta_data`; `findSpec(rows, key)` looks one up ignoring case/spaces/`_`/`-`. Never match on `label` — meta keys are free text. |
|
|
95
132
|
| `useCart()` | `{ status, cart, itemCount, isEmpty, loading, error, mutationError, refresh, addItem, updateItem, removeItem, applyCoupon, removeCoupon }` — `status`: `"loading" \| "ready" \| "empty"`. |
|
|
96
|
-
| `cart.items[n]` | `{ item_key, product_id, variation_id, name, quantity, price, subtotal, total, image, attributes, sold_individually, purchasable }` — `attributes` is an **array** of `{name, option}
|
|
97
|
-
| `attributesLabel(item.attributes)` | `"Size: 42 · Color: Ivory"` (`""` when the product has no attributes). |
|
|
98
|
-
| `cartTotalsLines(cart, { formatMoney })` | `[{ key, label, amount, formatted, hidden, emphasis }]` — every line the store has, incl. discount and tax. `orderTotalsLines(order, …)` is the same shape for a receipt. Pass `labels: {…}` to rename a row. |
|
|
99
|
-
| `useCartLine(item)` | `{ quantity, setQuantity, increase, decrease, remove, pending, error, canIncrease, canDecrease, maxQuantity, atMax, atMin }`. |
|
|
100
|
-
| `useCartUI()` | `{ open, openCart, closeCart, toggleCart }`. |
|
|
101
|
-
| `useCheckoutContext()` | the address (`billing`, `updateBilling`, `shipping`, `updateShipping`, `shipToDifferent`, `setShipToDifferent`, `missingBillingFields`, `addressError`), the shipping and payment state (the pickers read those for you), and the gate: `blockers`, `canPlaceOrder`, `placing`, `stage`, `orderError`, `placeOrder` — plus `cart`. The Checkout section wires all of it. |
|
|
102
|
-
| `useOrderReturn()` | `{ status, order, lines, paymentLink, paymentInstructions, error, reload }` — `status`: `"loading" \| "paid" \| "unpaid" \| "cancelled" \| "error"`. An order's totals are **flat** (`order.total`); there is no `order.totals`. |
|
|
133
|
+
| `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. |
|
|
103
134
|
|
|
104
135
|
## Product list / collection
|
|
105
136
|
|
|
@@ -123,7 +154,7 @@ A card can render `name`, `productImages(row)[0]`, `productPrice(row, { formatMo
|
|
|
123
154
|
import { useProduct, useAddToCart, useStoreInfo, useFormatMoney, useStorefront, variantAxes, productImages, imageIndex, productRibbons, productSpecs, findSpec, storefrontErrorCode } from "@/commerce/storefront";
|
|
124
155
|
```
|
|
125
156
|
|
|
126
|
-
`useProduct(slug)` (or `{ id }`) owns fetch + variant selection + quantity + price, race-safe, selection mirrored to the URL. ⚑ `status: "not_found"` is a 404 page, not a spinner. ⚑ **Call every hook above the status guards** — they all tolerate a null/loading product
|
|
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. ⚑ **Call every hook above the status guards** — they all tolerate a null/loading product; a hook after an early `return` crashes React with "Rendered more hooks than during the previous render" the moment the product resolves.
|
|
127
158
|
|
|
128
159
|
```jsx
|
|
129
160
|
const p = useProduct(slug);
|
|
@@ -175,183 +206,137 @@ Build your layout from — all optional, **not one component style**:
|
|
|
175
206
|
- **Description** — `product.description` is HTML; render as rich text, `short_description` above it.
|
|
176
207
|
- **Specs** — `productSpecs(product)` rows from the admin's *Modifiers*. ⚑ **Look a particular spec up with `findSpec(rows, "care")`**, which ignores case, spaces, `_` and `-`. Meta keys are free text (`care`, `Care`, `Care Instructions`), so `rows.find(s => s.label === "Care")` silently never matches and renders the fallback forever. **This is the product page's signature-moment candidate**: pick the two or three keys that carry *this* catalog's meaning and render each as what it is (a weight as a figure, a composition as bars, a provenance beside its place), then let the rest fall through to plain rows in your classes. Not one uniform grey table; not a bespoke widget per row. `[]` means no section at all.
|
|
177
208
|
- **Breadcrumbs** — from `categories` (`/collection?category_id=${c.id}`); skip on a flat catalog. Ribbons (`productRibbons(product)`) are labels, not breadcrumbs.
|
|
178
|
-
- **Reviews, only if the store wants them** — no review UI is a complete outcome (then no star ratings on cards
|
|
179
|
-
- **Title** — give each page type its own `<title>` and description;
|
|
209
|
+
- **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 the list after. Policies and moderation: [`../references/reviews.md`](../references/reviews.md).
|
|
210
|
+
- **Title** — give each page type its own `<title>` and description; 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`.
|
|
180
211
|
|
|
181
212
|
## Cart / bag
|
|
182
213
|
|
|
183
214
|
```jsx
|
|
184
|
-
import {
|
|
215
|
+
import { Cart, CartDrawer } from "@/commerce/storefront";
|
|
185
216
|
```
|
|
186
217
|
|
|
187
|
-
A cart *page* is optional — decide from what the store sells (buy-now
|
|
188
|
-
|
|
189
|
-
⚑ Rules: branch on `status`, never on emptiness while loading. Render `cart.coupon_notices` (`[{ code, error, error_code }]` — a coupon that stopped validating) and `cart.removed_items` (`[{ item_key, product_id, reason, code }]` — a product that vanished or was unpublished): render `error`/`reason`, the server's own words, or a line disappears from the bag with no explanation. Render every non-`hidden` line from `cartTotalsLines` rather than hardcoding subtotal/total — a hand-written summary omits discount and tax, then stops adding up the day a coupon or tax rate exists. **A store with any coupons must have a coupon field** (here or in the checkout) — they are admin-only data, redeemable only through a field the customer types into. `applyCoupon(code)` resolves `{ok: false, message}` for a bad code rather than throwing, so render that inline. No field means no coupons: don't seed them, don't name a code in the copy.
|
|
190
|
-
|
|
191
|
-
⚑ **`pending` is one row's flag**: it goes true when that row's debounced request leaves and false only after the new cart view lands — so disable and mark only that row (`disabled={l.pending}`, `aria-busy` on the row), never the whole cart. `status` never returns to `"loading"` for a mutation; there is deliberately no cart-wide busy flag. ⚑ **Repeated controls need unique accessible names** — three "Remove" buttons name nothing; put the line in the label.
|
|
192
|
-
|
|
193
|
-
**Reference wiring** for one row — the rest of the page (notices, coupon field, totals, checkout link) is ordinary markup around it:
|
|
218
|
+
A cart *page* is optional — decide from what the store sells (buy-now-to-checkout suits a single-piece store; a grocery basket needs a page). Page or drawer, the same parts in your layout — here as a drawer (`<CartUIProvider>`, mounted in Setup, owns open/close, Esc, open-on-add, close-on-navigate; a page skips the `CartDrawer.*` wrapper and provider):
|
|
194
219
|
|
|
195
220
|
```jsx
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
)}
|
|
214
|
-
</CartLine>
|
|
215
|
-
))}
|
|
216
|
-
{cartTotalsLines(cart, { formatMoney }).filter((t) => !t.hidden).map((t) => (
|
|
217
|
-
<div key={t.key}>{t.label} {t.formatted}</div> /* t.emphasis → the total row */
|
|
218
|
-
))}
|
|
221
|
+
<header>{/* your nav */}
|
|
222
|
+
<CartDrawer.Trigger className="nav-icon">
|
|
223
|
+
{({ count }) => (<><YourBagGlyph />{count > 0 && <span className="badge">{count}</span>}</>)}
|
|
224
|
+
</CartDrawer.Trigger>
|
|
225
|
+
</header>
|
|
226
|
+
|
|
227
|
+
<CartDrawer.Panel className="drawer">
|
|
228
|
+
<header>{/* your title */}<CartDrawer.Close><YourXGlyph /></CartDrawer.Close></header>
|
|
229
|
+
<Cart.Empty>{/* your empty state + your own link back into the shop */}</Cart.Empty>
|
|
230
|
+
<Cart.Ready>
|
|
231
|
+
<Cart.Notices />
|
|
232
|
+
<Cart.Lines classes={{ row: "bag-row", media: "bag-thumb" }} />
|
|
233
|
+
<Cart.CouponField />
|
|
234
|
+
<Cart.Totals pick={["subtotal"]} />
|
|
235
|
+
{/* your checkout affordance — your element, your route */}
|
|
236
|
+
</Cart.Ready>
|
|
237
|
+
</CartDrawer.Panel>
|
|
219
238
|
```
|
|
220
239
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
240
|
+
| Part | Renders | Props |
|
|
241
|
+
|---|---|---|
|
|
242
|
+
| `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: media, 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 | `lineRender(item, l)` replaces a row — `item.slug` for your product link |
|
|
244
|
+
| `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 | `pick={["subtotal"]}` |
|
|
247
|
+
| `CartDrawer.Trigger/Panel/Close` | the wired header button (render prop gets `{ count, open }`); the dialog done right — click-away overlay (never the close control), focus in/restored, unmounted while closed (`keepMounted` to animate → the closed panel goes `inert`); the named close button | children; `keepMounted` |
|
|
224
248
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
⚑ **Render the drawer conditionally — `{ui.open && …}`.** 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, set the `inert` attribute while closed. The overlay is a click-away surface, not the close control.
|
|
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). Drawer position, width and motion are your CSS on `[data-part="panel"]` / `[data-state]`.
|
|
228
250
|
|
|
229
251
|
## Checkout
|
|
230
252
|
|
|
231
253
|
```jsx
|
|
232
|
-
import {
|
|
254
|
+
import { Checkout } from "@/commerce/storefront";
|
|
233
255
|
```
|
|
234
256
|
|
|
235
|
-
`
|
|
236
|
-
|
|
237
|
-
⚑ 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". Render `addressError` on the address fields. ⚑ Payment methods, currency and countries come from `useStoreInfo()`/`useCountries()` only — `cart.payment_gateways` is always `undefined`, and a default store offers `offline` only, so never hardcode a card option.
|
|
238
|
-
|
|
239
|
-
⚑ **A disabled place-order button must say why** — the silent disabled button is the most common checkout dead end. `blockers` is an array of codes; write one line per code, in the store's voice, anchored near the field that fixes it: `empty_cart` (bag is empty) · `billing_incomplete` (required address fields — `missingBillingFields` names them) · `shipping_address_incomplete` (the separate delivery address) · `shipping_address_required` (no address to price yet) · `shipping_method_required` (choose a delivery option) · `shipping_not_available` (this address can't be delivered to) · `payment_method_required` (choose how to pay) · `cart_loading` / `shipping_recalculating` (transient — a quiet "one moment", not an error).
|
|
240
|
-
|
|
241
|
-
The pickers' `hint.code` works the same way (`missing_address`, `none_available`, `syncing` for shipping; `none_available` for payment): write those words once, and prefer `hint.serverMessage` when it is set — the backend's explanation is more specific than anything you can write.
|
|
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 page's phase: `Submitted` renders above everything (placing an order clears the bag; this ordering kills the empty-bag flash over a just-placed order), then `Loading` / `Empty` / `Form`.
|
|
242
258
|
|
|
243
|
-
|
|
244
|
-
`<CheckoutProvider><CheckoutForm /></CheckoutProvider>`; the hooks read the context **below** the provider. `SHIPPING_HINTS`, `PAYMENT_HINTS`, `BLOCKERS` and the two button labels below are *your* copy maps, written once (see [Design language](#design-language--once-before-any-page)):
|
|
259
|
+
⚑ **`orderReceivedPath` is required** — pass the route *your* order-received 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 wait for a transition). `null` + `onPlaced(result)` hands the step to you.
|
|
245
260
|
|
|
246
261
|
```jsx
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
<input type="radio" name="shipping-method" checked={m.selected} onChange={m.select} />
|
|
267
|
-
{m.title} {m.costLabel}
|
|
268
|
-
</label>
|
|
269
|
-
))}
|
|
270
|
-
{!mustChoose && chosen && <p>{chosen.title} {chosen.costLabel}</p>}
|
|
271
|
-
</fieldset>
|
|
272
|
-
)}
|
|
273
|
-
</ShippingMethodPicker>
|
|
274
|
-
|
|
275
|
-
{/* PaymentMethodPicker: same shape — hint, then gateways.map radios unless
|
|
276
|
-
`single`, then `selected.title`. Titles/descriptions are the admin's copy. */}
|
|
277
|
-
|
|
278
|
-
{/* summary: coupon field (if not in the cart) + cartTotalsLines(c.cart, { formatMoney }) */}
|
|
279
|
-
|
|
280
|
-
<button type="button" onClick={c.placeOrder} disabled={!c.canPlaceOrder || c.placing}>
|
|
281
|
-
{c.placing ? PLACING_LABEL : PLACE_ORDER_LABEL}
|
|
282
|
-
</button>
|
|
283
|
-
{c.orderError && <p role="alert">{c.orderError.message}</p>}
|
|
284
|
-
{!c.canPlaceOrder && c.blockers.map((code) => <p key={code}>{BLOCKERS[code]}</p>)}
|
|
285
|
-
</>
|
|
286
|
-
);
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
function AddressFields({ which }) {
|
|
290
|
-
const c = useCheckoutContext();
|
|
291
|
-
const { countries } = useCountries(); // [] until store info lands — never null
|
|
292
|
-
const isBilling = which === "billing";
|
|
293
|
-
const values = isBilling ? c.billing : c.shipping;
|
|
294
|
-
const set = isBilling ? c.updateBilling : c.updateShipping;
|
|
295
|
-
const fields = addressFieldSpec({
|
|
296
|
-
countries,
|
|
297
|
-
country: values.country,
|
|
298
|
-
required: isBilling ? REQUIRED_BILLING_FIELDS : ["country", "city"],
|
|
299
|
-
includeEmail: isBilling, // one email per order, on billing
|
|
300
|
-
});
|
|
301
|
-
return fields.map((f) => (
|
|
302
|
-
<div key={f.key}>
|
|
303
|
-
<label htmlFor={`${which}-${f.key}`}>{f.label}{f.required && " *"}</label>
|
|
304
|
-
{f.type === "select" ? (
|
|
305
|
-
<select id={`${which}-${f.key}`} value={values[f.key] ?? ""} autoComplete={f.autoComplete}
|
|
306
|
-
onChange={(e) => set({ [f.key]: e.target.value })}>
|
|
307
|
-
<option value="">{/* your placeholder */}</option>
|
|
308
|
-
{f.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
|
309
|
-
</select>
|
|
310
|
-
) : (
|
|
311
|
-
<input id={`${which}-${f.key}`} type={f.type} value={values[f.key] ?? ""}
|
|
312
|
-
autoComplete={f.autoComplete} onChange={(e) => set({ [f.key]: e.target.value })} />
|
|
313
|
-
)}
|
|
314
|
-
{/* "we don't ship there" is an address-level error — it belongs on country */}
|
|
315
|
-
{f.key === "country" && c.addressError && <span role="alert">{c.addressError.message}</span>}
|
|
262
|
+
<Checkout.Root orderReceivedPath="/order-received">
|
|
263
|
+
<Checkout.Submitted>{/* your "taking you to your receipt…" screen */}</Checkout.Submitted>
|
|
264
|
+
<Checkout.Loading>{/* your loader */}</Checkout.Loading>
|
|
265
|
+
<Checkout.Empty>{/* your empty state + your own link back */}</Checkout.Empty>
|
|
266
|
+
<Checkout.Form>
|
|
267
|
+
<div className="checkout-grid">{/* YOUR layout: order, columns, headings, steps */}
|
|
268
|
+
<section>
|
|
269
|
+
<Checkout.AddressFields which="billing" />
|
|
270
|
+
<Checkout.ShipToDifferent>{/* your label */}</Checkout.ShipToDifferent>
|
|
271
|
+
<Checkout.AddressFields which="shipping" />
|
|
272
|
+
<Checkout.ShippingMethods />
|
|
273
|
+
<Checkout.PaymentMethods />
|
|
274
|
+
</section>
|
|
275
|
+
<aside>
|
|
276
|
+
<Checkout.Items />
|
|
277
|
+
<Checkout.CouponField />
|
|
278
|
+
<Checkout.Totals />
|
|
279
|
+
<Checkout.PlaceOrder className="btn-cta" />
|
|
280
|
+
</aside>
|
|
316
281
|
</div>
|
|
317
|
-
|
|
318
|
-
|
|
282
|
+
</Checkout.Form>
|
|
283
|
+
</Checkout.Root>
|
|
319
284
|
```
|
|
320
285
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
286
|
+
| Part | Renders | Props |
|
|
287
|
+
|---|---|---|
|
|
288
|
+
| `Checkout.Root` | the provider, the phase, the labels scope | `orderReceivedPath` (**required**; or `null` + `onPlaced`), `labels`, `options`, `checkout` |
|
|
289
|
+
| `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`, `include={["company"]}`, `omit`, `inputRender`, `fieldRender` |
|
|
291
|
+
| `ShipToDifferent` | the wired toggle; children are its label | children |
|
|
292
|
+
| `ShippingMethods` | every branch: the hint line (server's message preferred over yours), 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)` — option content; the radio stays wired |
|
|
293
|
+
| `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(item)` |
|
|
295
|
+
| `CouponField` / `Totals` | same parts as the cart's | — / `pick` |
|
|
296
|
+
| `PlaceOrder` | the button (your two labels), the server's order error, and the blocker lines — ⚑ a disabled button always says why | `showBlockers` (default true) |
|
|
297
|
+
| `Blockers` | the reasons alone, for custom placement (then `showBlockers={false}`) | — |
|
|
298
|
+
|
|
299
|
+
`inputRender` is the middle tier when the fields need your own control — more authored code, more styled: `({ field, id, value, onChange, onBlur, invalid, autoComplete, options }) => <YourField …/>`; `fieldRender` replaces the whole labeled block. **Multi-step checkout is your markup**: wrap parts in your step containers — a hidden step loses nothing, the gate still counts every blocker.
|
|
324
300
|
|
|
325
301
|
## Order received
|
|
326
302
|
|
|
327
303
|
```jsx
|
|
328
|
-
import {
|
|
304
|
+
import { OrderReceived } from "@/commerce/storefront";
|
|
329
305
|
```
|
|
330
306
|
|
|
331
|
-
**Mandatory route** — every payment link returns here
|
|
332
|
-
|
|
333
|
-
⚑ Rules: render all five states — never a blank page while `"loading"`, a retry via `reload()` on `"error"`, and `paymentLink.url` on `"unpaid"`/`"cancelled"` when present. ⚑ **Never drop `paymentInstructions`** (`{ description, account_details }`) — a manual/offline order settles outside the store, so these ARE how the store's default customer learns how to pay. ⚑ An order's totals are flat (`order.total`, no `order.totals`) — use `orderTotalsLines(order, { formatMoney })`. `lines` are the order's items already normalized (`name`, `attributesLabel`, `quantity`, `image` as `{src, alt}|null`, `totalLabel`), because a receipt reusing cart-row markup otherwise paints a broken image.
|
|
307
|
+
**Mandatory route** — every payment link returns here; visiting verifies with the provider (idempotent) and the page marks itself noindex. ⚑ Render **all five** gates — a blank frame while loading or a wordless error strands a real customer:
|
|
334
308
|
|
|
335
|
-
|
|
309
|
+
```jsx
|
|
310
|
+
<OrderReceived.Root>
|
|
311
|
+
<OrderReceived.Loading>{/* confirming copy — never a blank page */}</OrderReceived.Loading>
|
|
312
|
+
<OrderReceived.Paid>
|
|
313
|
+
{({ order }) => (<>{/* your thank-you, order #{order.order_number} */}
|
|
314
|
+
<OrderReceived.Items /><OrderReceived.Totals /></>)}
|
|
315
|
+
</OrderReceived.Paid>
|
|
316
|
+
<OrderReceived.Unpaid>{/* your wording for "not settled yet" */}
|
|
317
|
+
<OrderReceived.PaymentInstructions /> {/* ⚑ see below */}
|
|
318
|
+
<OrderReceived.PaymentLink>{({ url }) => (/* your pay-now element on url */)}</OrderReceived.PaymentLink>
|
|
319
|
+
<OrderReceived.Items /><OrderReceived.Totals />
|
|
320
|
+
</OrderReceived.Unpaid>
|
|
321
|
+
<OrderReceived.Cancelled>{/* your wording; PaymentLink works here too */}</OrderReceived.Cancelled>
|
|
322
|
+
<OrderReceived.Error>{({ error, reload }) => (/* error.message + your retry calling reload() */)}</OrderReceived.Error>
|
|
323
|
+
</OrderReceived.Root>
|
|
324
|
+
```
|
|
336
325
|
|
|
337
|
-
|
|
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 }` for anything custom. `Items` rows arrive normalized; `Totals` owns the flat-totals trap (`order.total`, never `order.totals`).
|
|
338
327
|
|
|
339
|
-
The cart is optimistic and debounced, so a script that acts faster than it settles sees a working store as broken
|
|
328
|
+
**Driving the storefront from a browser script?** The cart is optimistic and debounced, so a script that acts faster than it settles sees a working store as broken — read [`../references/storefront-verification.md`](../references/storefront-verification.md) **before** writing the script, not after it fails.
|
|
340
329
|
|
|
341
330
|
## Done — forget this file
|
|
342
331
|
|
|
343
|
-
- [ ] Catalog UI in whatever form fits the store
|
|
344
|
-
- [ ]
|
|
345
|
-
- [ ]
|
|
346
|
-
- [ ]
|
|
347
|
-
- [ ]
|
|
348
|
-
- [ ]
|
|
349
|
-
- [ ] No re-implemented hook logic (button state precedence, quantity clamps, totals math, drawer state).
|
|
350
|
-
- [ ] Coupon field present if the store has coupons; a paging control rendered whenever `hasNext` is true; ribbons rendered in both the grid and the product page.
|
|
332
|
+
- [ ] 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.
|
|
333
|
+
- [ ] 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, the drawer panel has a surface, nothing left browser-default; pages carry class names, not repeated utility runs.
|
|
335
|
+
- [ ] **One** `<StorefrontProvider>` above every storefront route (layout-route pattern); no hand-rolled `cart_token`; imports from each section's import line.
|
|
336
|
+
- [ ] Custom hook-built sections branch on `status` and never re-implement part/hook logic; gateways/currency/countries from `useStoreInfo()`/`useCountries()` only.
|
|
337
|
+
- [ ] Coupon field placed if the store has coupons; a paging control whenever `hasNext`; ribbons in both the grid and the product page.
|
|
351
338
|
- [ ] Variant options: one control per axis, unbuyable options disabled, not hidden.
|
|
352
|
-
- [ ]
|
|
353
|
-
- [ ]
|
|
354
|
-
- [ ] Checkout guards `stage === "submitted"` above its empty-cart branch.
|
|
355
|
-
- [ ] The storefront carries the design you settled on before reading this file — design classes plus one or two signature moments per page; axes and specs rendered by what they are (not one uniform table, not one identical chip row); convention surfaces carry the classes and nothing bespoke.
|
|
339
|
+
- [ ] 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 the richest surface.
|
|
356
341
|
|
|
357
|
-
Then copy this file's `carry_forward` lines
|
|
342
|
+
Then copy this file's `carry_forward` lines into your working notes, and do not re-read this file.
|