@base44/app-plugin-commerce 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/base44/agents/commerce/StoreAdmin.jsonc +1 -1
- package/base44/entities/commerce.OrderRefund.jsonc +1 -1
- package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
- package/base44/entities/commerce.Webhook.jsonc +1 -1
- package/base44/functions/commerce/admin-products/entry.ts +1 -1
- package/base44/functions/commerce/admin-reports/entry.ts +1 -1
- package/base44/functions/commerce/payments/entry.ts +2 -2
- package/base44/functions/commerce/seed-store/defaults.ts +1 -1
- package/base44/functions/commerce/storefront-catalog/entry.ts +1 -1
- package/base44/functions/commerce/storefront-checkout/entry.ts +1 -1
- package/base44/shared/commerce/card-payment.stripe.ts +29 -9
- package/base44/shared/commerce/card-payment.ts +1 -1
- package/base44/shared/commerce/payments.ts +2 -2
- package/base44/shared/commerce/scan.ts +1 -1
- package/base44/shared/commerce/sequence.ts +2 -2
- package/package.json +1 -1
- package/scripts/install.js +1 -1
- package/skills/commerce/SKILL.md +36 -26
- package/skills/commerce/docs/api-storefront.md +6 -6
- package/skills/commerce/install/01-install.md +2 -2
- package/skills/commerce/install/02-storefront.md +355 -99
- package/skills/commerce/install/03-data.md +5 -5
- package/skills/commerce/references/catalog-rendering.md +6 -6
- package/skills/commerce/references/online-payments.md +5 -6
- package/skills/commerce/references/reviews.md +5 -5
- package/src/commerce/admin/README.md +6 -3
- package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
- package/src/commerce/admin/pages/products/Reviews.jsx +1 -1
- package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -1
- package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +1 -1
- package/src/commerce/storefront/index.js +45 -33
- package/src/commerce/storefront/useCartLine.js +37 -0
- package/src/commerce/storefront/useCheckout.jsx +18 -6
- package/src/commerce/storefront/useOrderReturn.js +36 -10
- package/src/commerce/storefront/useProduct.js +68 -0
- package/src/commerce/utils/index.js +9 -6
- package/src/commerce/utils/shipping-promos.js +2 -2
- package/src/commerce/utils/specs.js +26 -0
- package/src/commerce/utils/variants.js +49 -2
- package/src/commerce/storefront/blocks/AddToCartBlock.jsx +0 -86
- package/src/commerce/storefront/blocks/AddressFieldsBlock.jsx +0 -96
- package/src/commerce/storefront/blocks/BreadcrumbsBlock.jsx +0 -52
- package/src/commerce/storefront/blocks/CartLinesBlock.jsx +0 -98
- package/src/commerce/storefront/blocks/CheckoutBlock.jsx +0 -247
- package/src/commerce/storefront/blocks/CouponFieldBlock.jsx +0 -84
- package/src/commerce/storefront/blocks/OrderReceivedBlock.jsx +0 -129
- package/src/commerce/storefront/blocks/ProductGalleryBlock.jsx +0 -66
- package/src/commerce/storefront/blocks/ProductSpecsBlock.jsx +0 -33
- package/src/commerce/storefront/blocks/ProductStripBlock.jsx +0 -55
- package/src/commerce/storefront/blocks/QuantityStepper.jsx +0 -62
- package/src/commerce/storefront/blocks/ReviewsBlock.jsx +0 -191
- package/src/commerce/storefront/blocks/TotalsBlock.jsx +0 -42
- package/src/commerce/storefront/blocks/VariantSelectorBlock.jsx +0 -81
- package/src/commerce/storefront/blocks/index.js +0 -44
|
@@ -5,19 +5,45 @@ skip_when: "The storefront pages already render against live data and pass the c
|
|
|
5
5
|
forget_when: "The checklist at the bottom passes — every page renders against the seeded catalog and an offline order completes."
|
|
6
6
|
carry_forward:
|
|
7
7
|
- "Payment gateways, currency and countries come from useStoreInfo() only — never off a cart (cart.payment_gateways is always undefined)."
|
|
8
|
-
- "A store with any coupons must have a coupon field
|
|
9
|
-
- "/order-received renders
|
|
8
|
+
- "A store with any coupons must have a coupon field (useCoupon) in the cart or the checkout, or its codes can never be redeemed."
|
|
9
|
+
- "/order-received is mandatory and renders useOrderReturn's states, including paymentInstructions — how a normal (offline) customer learns how to pay."
|
|
10
10
|
- "Branch cart/list/product UI on `status`, never on `isEmpty`/nullable data — `isEmpty` is false while loading by design."
|
|
11
|
+
- "Variant options that aren't buyable render disabled, never hidden; one control per axis, never a list of variations."
|
|
11
12
|
---
|
|
12
13
|
|
|
13
14
|
# 02 — Storefront
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
- **
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
One split decides everything here: **the logic is premade, the UI never is.**
|
|
17
|
+
|
|
18
|
+
- **Logic — hooks, shipped.** Checkout repricing from the address, variant
|
|
19
|
+
resolution, cart state, coupon redemption, review policies, order-return
|
|
20
|
+
verification. Every store's version of these is functionally identical, and
|
|
21
|
+
hand-writing them is where storefront bugs cluster: **never re-implement what
|
|
22
|
+
a hook does.**
|
|
23
|
+
- **UI — yours, always.** Every element, class, layout and word of copy on
|
|
24
|
+
every page. Nothing in `@/commerce/storefront` renders markup or carries CSS,
|
|
25
|
+
and this file deliberately doesn't hand you page bodies either — the design
|
|
26
|
+
is the part of the storefront only you can do, and it should be designed,
|
|
27
|
+
not assembled.
|
|
28
|
+
|
|
29
|
+
Each hook returns a complete view-model — a `status` to branch on,
|
|
30
|
+
ready-to-map arrays, handlers, error objects — and its **doc comment (JSDoc) is
|
|
31
|
+
the API reference**: open the hook's file when you need exact shapes; don't
|
|
32
|
+
guess fields. This file gives you the routing, each surface's hook, and the
|
|
33
|
+
render rules that keep a store correct (marked ⚑ — these must survive whatever
|
|
34
|
+
design you build).
|
|
35
|
+
|
|
36
|
+
Where you get a **reference implementation** and where you get only the hook is
|
|
37
|
+
deliberate: **cart, checkout and order-received** have reference code below —
|
|
38
|
+
their wiring is dense enough that reading it is cheaper than deriving it, and
|
|
39
|
+
they are conventions (a form, a receipt) where familiarity beats invention.
|
|
40
|
+
The **identity surfaces** — home, collection, the card, the product page's
|
|
41
|
+
layout — get hooks only, on purpose: reference markup there would make every
|
|
42
|
+
store look the same, and their design is the work only you can do. Either way
|
|
43
|
+
the hooks are high-level enough that a page is a handful of calls plus your
|
|
44
|
+
markup — writing more code than the budgets at the bottom allow means you are
|
|
45
|
+
re-deriving logic a hook already owns. Everything imports from
|
|
46
|
+
`@/commerce/storefront`.
|
|
21
47
|
|
|
22
48
|
## Setup — once
|
|
23
49
|
|
|
@@ -39,9 +65,16 @@ import { base44 } from "@/api/base44Client";
|
|
|
39
65
|
</BrowserRouter>
|
|
40
66
|
```
|
|
41
67
|
|
|
42
|
-
The provider owns the shared client, the store-info cache and **one** shared
|
|
68
|
+
The provider owns the shared client, the store-info cache and **one** shared
|
|
69
|
+
cart, so a header badge, a drawer and the checkout render the same state. Never
|
|
70
|
+
mount a second provider, and never touch the `cart_token` — the provider owns
|
|
71
|
+
its whole lifecycle.
|
|
43
72
|
|
|
44
|
-
> ⚠ **`<Routes>` accepts only `<Route>` children.** Nesting the provider inside
|
|
73
|
+
> ⚠ **`<Routes>` accepts only `<Route>` children.** Nesting the provider inside
|
|
74
|
+
> it — the natural reading of "wrap the storefront routes" — throws at render:
|
|
75
|
+
> `Error: [StorefrontProvider] is not a <Route> component. All component children of <Routes> must be a <Route> or <React.Fragment>`.
|
|
76
|
+
> To scope the provider to storefront routes only, use a pathless **layout
|
|
77
|
+
> route**, the one place a wrapper is legal:
|
|
45
78
|
> ```jsx
|
|
46
79
|
> <Route element={<StorefrontProvider base44={base44}><Outlet /></StorefrontProvider>}>
|
|
47
80
|
> <Route path="/" element={<Home />} />
|
|
@@ -50,124 +83,343 @@ The provider owns the shared client, the store-info cache and **one** shared car
|
|
|
50
83
|
> <Route path="/store-admin/*" element={<AdminApp />} /> {/* outside the provider */}
|
|
51
84
|
> ```
|
|
52
85
|
|
|
53
|
-
## Product list / collection
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
`
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
86
|
+
## Product list / collection
|
|
87
|
+
|
|
88
|
+
`useProductList(params)` → `{ status, products, hasNext, next, refreshing,
|
|
89
|
+
setParams, reload }`. ⚑ `status` is `"loading" | "ready" | "empty" | "error"`
|
|
90
|
+
— branch on it, so a failed request renders as a failure instead of an empty
|
|
91
|
+
grid. `setParams({ category_id, search, on_sale, min_price, in_stock_only })`
|
|
92
|
+
resets to page 1 and keeps the current rows on screen (`refreshing`) while the
|
|
93
|
+
page loads. `useCategories()` / `useRibbons()` → `{ items }` (arrays, children
|
|
94
|
+
nested).
|
|
95
|
+
|
|
96
|
+
Your card renders `name`, `images[0]?.src` (⚑ **images are `{src,name,alt}`
|
|
97
|
+
objects and the array may be empty — render a placeholder, never a broken
|
|
98
|
+
`<img>`**), `useProductPrice(row).label` (already "From €19.99" when the
|
|
99
|
+
product sells variants — there is no product `type` flag), `on_sale`,
|
|
100
|
+
`short_description`, `stock_status`, `average_rating`/`rating_count`,
|
|
101
|
+
`ribbons`. Full field matrix:
|
|
102
|
+
[`../references/catalog-rendering.md`](../references/catalog-rendering.md).
|
|
103
|
+
|
|
104
|
+
**Rails** (featured row, "new in") are the same hook with a filter
|
|
105
|
+
(`{ featured: true, per_page: 4 }`). ⚑ Any filter may legitimately match
|
|
106
|
+
nothing — render *nothing* then, never a heading over an empty row. Upsells
|
|
107
|
+
beside a product need no query: `p.upsells` / `p.crossSells` from `useProduct`.
|
|
108
|
+
|
|
109
|
+
## Product page
|
|
110
|
+
|
|
111
|
+
`useProduct(slug)` (or `{ id }`) owns fetch + variant selection + quantity +
|
|
112
|
+
price, race-safe, selection mirrored to the URL. ⚑ `status: "not_found"` is a
|
|
113
|
+
404 page, not a spinner. Destructure `{ product, view, price, categories }`
|
|
114
|
+
and build your layout from:
|
|
115
|
+
|
|
116
|
+
- **Price** — `price.label`, plus `price.compareAtLabel` (struck through) when
|
|
117
|
+
on sale. Never read `product.price` directly — the parent's price is a
|
|
118
|
+
rolled-up from-price.
|
|
119
|
+
- **Gallery** — `useProductGallery(product, view)` → `{ hasImages, images,
|
|
120
|
+
active, activeIndex, setActiveIndex, next, prev }`. The active image already
|
|
121
|
+
follows the variant selection; `hasImages: false` means render your
|
|
122
|
+
placeholder.
|
|
123
|
+
- **Variant selector** — `variantAxes(view, p.pick)` → one entry per axis:
|
|
124
|
+
`{ key, name, selectedOption, options: [{ value, selected, disabled,
|
|
125
|
+
outOfStock, pick }] }`. Map it to any control — buttons, swatches, a dropdown.
|
|
126
|
+
⚑ **One control per axis, never a list of variations** (`Red / S`, `Red / M`,
|
|
127
|
+
… is n × m noise), and ⚑ **an unbuyable option renders `disabled`, never
|
|
128
|
+
hidden** (`outOfStock` stays visible, just marked) — a customer who can't see
|
|
129
|
+
that a size exists assumes the store doesn't carry it. `view.missingAxes`
|
|
130
|
+
names what's still unpicked. The shape of the map (the one interaction agents
|
|
131
|
+
reliably get wrong — the control itself is yours):
|
|
132
|
+
|
|
133
|
+
```jsx
|
|
134
|
+
{variantAxes(view, p.pick).map((axis) => (
|
|
135
|
+
<fieldset key={axis.key}>{/* label from axis.name / axis.selectedOption */}
|
|
136
|
+
{axis.options.map((o) => (
|
|
137
|
+
<button key={o.value} disabled={o.disabled} aria-pressed={o.selected} onClick={o.pick}>
|
|
138
|
+
{o.value}{/* o.outOfStock → mark visibly, keep clickable-looking off */}
|
|
139
|
+
</button>
|
|
140
|
+
))}
|
|
141
|
+
</fieldset>
|
|
142
|
+
))}
|
|
143
|
+
```
|
|
144
|
+
- **Buy box** — `useAddToCartButton(p, { onAdded })` → `{ add, adding, error,
|
|
145
|
+
disabled, soldOut, needsSelection, quantity, increase, decrease, canIncrease,
|
|
146
|
+
canDecrease, showQuantity }`. It gates on purchasability, recovers from every
|
|
147
|
+
add failure and clamps quantity to stock and `sold_individually`. ⚑ Render
|
|
148
|
+
`error.message` inline; ⚑ `showQuantity: false` means no stepper (only 1 can
|
|
149
|
+
be bought); the button label should reflect `adding`/`soldOut`/
|
|
150
|
+
`needsSelection` — the words are yours.
|
|
151
|
+
- **Description** — `product.description` is HTML; render as rich text
|
|
152
|
+
(`dangerouslySetInnerHTML`), `short_description` above it.
|
|
153
|
+
- **Specs** — `productSpecs(product)` → `[{ key, label, value }]` from
|
|
154
|
+
`meta_data` (Material, Care). `[]` means no section at all.
|
|
155
|
+
- **Breadcrumbs** — build from `categories`
|
|
156
|
+
(`/collection?category_id=${c.id}`); skip on a flat catalog. Ribbons are
|
|
157
|
+
labels, not breadcrumbs.
|
|
158
|
+
|
|
159
|
+
All optional — include what this store's products actually have.
|
|
160
|
+
|
|
161
|
+
### Reviews — optional
|
|
162
|
+
|
|
163
|
+
**Build reviews only if the store wants them** — because the brief asks, or the
|
|
164
|
+
products are the kind customers rate. No review UI means no reviews, and that
|
|
165
|
+
is a complete outcome. (If you skip them, don't put star ratings on cards
|
|
166
|
+
either — an average of nothing is `0`.)
|
|
167
|
+
|
|
168
|
+
`useProductReviews(product, { policy, user })` is the whole surface: `items`,
|
|
169
|
+
paging (`hasNext`/`loadMore`), `averageRating`/`ratingCount`, and the submit
|
|
170
|
+
form — `form`/`setField`/`fieldErrors` (matching the server's error codes),
|
|
171
|
+
`valid`, `submit`, `requiresEmail` (false for a signed-in visitor),
|
|
172
|
+
`reviewBlockedReason` (`"login_required"` / `"not_a_buyer"` under the stricter
|
|
173
|
+
policies). ⚑ The confirmation copy is `message`, **taken from the server's
|
|
174
|
+
response** — a store with auto-approval on says "published", not "awaiting
|
|
175
|
+
approval", so render `message`, never your own text. `policy` is
|
|
176
|
+
`"open" | "login" | "verified_buyers"`. Details beyond this:
|
|
177
|
+
[`../references/reviews.md`](../references/reviews.md).
|
|
178
|
+
|
|
179
|
+
## Cart / bag
|
|
180
|
+
|
|
181
|
+
A cart *page* is optional: a store selling one made-to-order piece reads better
|
|
182
|
+
as buy-now straight to checkout. The surface is four hooks: `useCart()`
|
|
183
|
+
(`status`, `lines`, `notices`), `CartLine` (headless render-prop binding
|
|
184
|
+
`useCartLine` per row — quantity stepping that clamps, coalesces and recovers),
|
|
185
|
+
`useTotalsLines()`, `useCoupon()`.
|
|
186
|
+
|
|
187
|
+
⚑ Rules: branch on `status`, never on emptiness while loading. Render
|
|
188
|
+
`notices` — they say what auto-dropped from the cart and why. Render every
|
|
189
|
+
non-`hidden` totals line rather than hardcoding subtotal/total — a hand-written
|
|
190
|
+
summary omits discount and tax, then stops adding up the day a coupon or a tax
|
|
191
|
+
rate exists. **A store with any coupons must have a coupon field** (here or in
|
|
192
|
+
the checkout): coupons are admin-only data, redeemable only through a field the
|
|
193
|
+
customer types into — if no field exists anywhere, don't seed coupons and don't
|
|
194
|
+
write "use WELCOME10" in the copy.
|
|
195
|
+
|
|
196
|
+
**Reference implementation** — read once for the wiring, then write your own
|
|
197
|
+
page: the structure below is correct, the presentation is deliberately absent.
|
|
198
|
+
Restyle, rearrange, split into your own components; the ⚑ rules are the part
|
|
199
|
+
that must survive.
|
|
82
200
|
|
|
83
201
|
```jsx
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
<p
|
|
93
|
-
|
|
94
|
-
<
|
|
95
|
-
|
|
96
|
-
<
|
|
97
|
-
|
|
202
|
+
function Bag() {
|
|
203
|
+
const { status, lines, notices } = useCart();
|
|
204
|
+
const totals = useTotalsLines();
|
|
205
|
+
const formatMoney = useFormatMoney();
|
|
206
|
+
if (status === "loading") return /* your loading state */;
|
|
207
|
+
if (status === "empty") return /* your empty-bag state, linking back to the catalog */;
|
|
208
|
+
return (
|
|
209
|
+
<>
|
|
210
|
+
{notices.map((n, i) => <p key={i} role="status">{n.message}</p>)}
|
|
211
|
+
{lines.map((line) => (
|
|
212
|
+
<CartLine key={line.item_key} line={line}>
|
|
213
|
+
{(l) => ( /* line: name, attributesLabel, image, total — l: the controls */
|
|
214
|
+
<li>
|
|
215
|
+
{line.name} {line.attributesLabel}
|
|
216
|
+
<button onClick={l.decrease} disabled={!l.canDecrease || l.pending}>−</button>
|
|
217
|
+
{l.quantity}
|
|
218
|
+
<button onClick={l.increase} disabled={!l.canIncrease || l.pending}>+</button>
|
|
219
|
+
<button onClick={l.remove}>Remove</button>
|
|
220
|
+
{formatMoney(line.total)}
|
|
221
|
+
{l.error && <p role="alert">{l.error.message}</p>}
|
|
222
|
+
</li>
|
|
223
|
+
)}
|
|
224
|
+
</CartLine>
|
|
225
|
+
))}
|
|
226
|
+
<CouponField /> {/* useCoupon: code/setCode, apply, applying, error, applied[] + remove */}
|
|
227
|
+
{totals.filter((l) => !l.hidden).map((l) => (
|
|
228
|
+
<div key={l.key}>{l.label} {l.formatted}</div> /* l.emphasis → the total row */
|
|
229
|
+
))}
|
|
230
|
+
<Link to="/checkout">Checkout</Link>
|
|
231
|
+
</>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
98
234
|
```
|
|
99
235
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
236
|
+
No shipping estimator here — checkout reprices shipping and tax from the
|
|
237
|
+
address.
|
|
238
|
+
|
|
239
|
+
## Checkout
|
|
240
|
+
|
|
241
|
+
The state machine is `useCheckout`, shared across the page's regions by
|
|
242
|
+
`CheckoutProvider` + `useCheckoutContext()`. It reprices shipping/tax from the
|
|
243
|
+
address automatically (debounced, never on a half-typed address), derives the
|
|
244
|
+
shipping and payment choices, gates the button (`canPlaceOrder` +
|
|
245
|
+
`useCheckoutBlockers()` in words), and `placeOrder()` handles **both**
|
|
246
|
+
navigations — online gateway → provider redirect, everything else →
|
|
247
|
+
`/order-received`. The address form comes from `useAddressForm(which)` as a
|
|
248
|
+
field spec (`state` collected, country options never null); the two
|
|
249
|
+
store-data choices come through the headless `ShippingMethodPicker` /
|
|
250
|
+
`PaymentMethodPicker`, whose render props enumerate every branch.
|
|
251
|
+
|
|
252
|
+
⚑ Rules: handle every picker branch (they exist because every one occurs in a
|
|
253
|
+
normal store); a single shipping or payment option still *shows* what it is —
|
|
254
|
+
never a picker of one, never "nothing selected"; zero gateways → say checkout
|
|
255
|
+
is unavailable instead of a dead button; keep each field's `autoComplete` (the
|
|
256
|
+
spec provides it) and render `f.error` — "we don't ship there" arrives on the
|
|
257
|
+
country field; show `orderError.message` and the blockers so the gate explains
|
|
258
|
+
itself. ⚑ Payment methods, currency and countries come from `useStoreInfo()`
|
|
259
|
+
only — `cart.payment_gateways` is always `undefined`, and a default store
|
|
260
|
+
offers `offline` only ([`./03-data.md`](./03-data.md)).
|
|
261
|
+
|
|
262
|
+
**Reference implementation** — the densest wiring in the storefront; read it,
|
|
263
|
+
then build yours around it. Structure correct, presentation absent.
|
|
107
264
|
|
|
108
265
|
```jsx
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
<CartLinesBlock /> {/* lines, variant labels, steppers, and the notices saying what auto-dropped */}
|
|
114
|
-
{/* <CartLinesBlock/> already renders the coupon field (showCoupon, default on) */}
|
|
115
|
-
<TotalsBlock /> {/* subtotal · discount · shipping · tax · total, zero rows hidden */}
|
|
116
|
-
<Link to="/checkout">Checkout</Link>
|
|
117
|
-
```
|
|
118
|
-
|
|
119
|
-
**A store with any coupons must have a coupon field, or its codes can never be redeemed.** Coupons are admin-only data — a storefront cannot list codes, so the only way in is a field the customer types into. Both `<CartLinesBlock/>` and `<CheckoutBlock/>` render one by default (`showCoupon`), so the safe outcome is the one you get for free; pass `showCoupon={false}` only for a store with no codes. Standalone, the field is `<CouponFieldBlock/>`. If no field exists anywhere, don't seed coupons and don't write "use WELCOME10" in the copy.
|
|
120
|
-
|
|
121
|
-
No shipping estimator here — `useCheckout` reprices shipping and tax from the address. Custom rows: `useCartLine(line)` (optimistic, clamped, coalesced) and `useCoupon()`.
|
|
266
|
+
function Checkout() { // hooks read the context BELOW the provider
|
|
267
|
+
return <CheckoutProvider><CheckoutForm /></CheckoutProvider>;
|
|
268
|
+
}
|
|
122
269
|
|
|
123
|
-
|
|
270
|
+
function CheckoutForm() {
|
|
271
|
+
const { status } = useCart();
|
|
272
|
+
const checkout = useCheckoutContext();
|
|
273
|
+
const blockers = useCheckoutBlockers();
|
|
274
|
+
const formatMoney = useFormatMoney();
|
|
275
|
+
if (status === "loading") return /* loading */;
|
|
276
|
+
if (status === "empty") return /* "your bag is empty" — a checkout with nothing says so */;
|
|
277
|
+
return (
|
|
278
|
+
<>
|
|
279
|
+
<AddressFields which="billing" />
|
|
280
|
+
<label>
|
|
281
|
+
<input type="checkbox" checked={checkout.shipToDifferent}
|
|
282
|
+
onChange={(e) => checkout.setShipToDifferent(e.target.checked)} />
|
|
283
|
+
Deliver to a different address
|
|
284
|
+
</label>
|
|
285
|
+
{checkout.shipToDifferent && <AddressFields which="shipping" />}
|
|
286
|
+
|
|
287
|
+
<ShippingMethodPicker>
|
|
288
|
+
{({ status, methods, chosen, choose, mustChoose, syncing }) => (
|
|
289
|
+
<fieldset>{/* syncing → subtle busy state; renders null for a virtual cart */}
|
|
290
|
+
{status === "missing_address" && <p>Delivery options appear once your address is entered.</p>}
|
|
291
|
+
{status === "none_available" && <p role="alert">We don't deliver to that address yet.</p>}
|
|
292
|
+
{mustChoose && methods.map((m) => (
|
|
293
|
+
<label key={m.id}>
|
|
294
|
+
<input type="radio" checked={chosen?.id === m.id} onChange={() => choose(m.id)} />
|
|
295
|
+
{m.title} {formatMoney(m.cost)}
|
|
296
|
+
</label>
|
|
297
|
+
))}
|
|
298
|
+
{!mustChoose && chosen && <p>{chosen.title} {formatMoney(chosen.cost)}</p>}
|
|
299
|
+
</fieldset>
|
|
300
|
+
)}
|
|
301
|
+
</ShippingMethodPicker>
|
|
302
|
+
|
|
303
|
+
<PaymentMethodPicker>
|
|
304
|
+
{({ gateways, value, select, selected, single }) => (
|
|
305
|
+
<fieldset>
|
|
306
|
+
{gateways.length === 0 && <p role="alert">No payment method is available right now.</p>}
|
|
307
|
+
{!single && gateways.map((g) => (
|
|
308
|
+
<label key={g.slug}>
|
|
309
|
+
<input type="radio" checked={value === g.slug} onChange={() => select(g.slug)} />
|
|
310
|
+
{g.title} {g.description}
|
|
311
|
+
</label>
|
|
312
|
+
))}
|
|
313
|
+
{single && selected && <p>{selected.title}</p>}
|
|
314
|
+
</fieldset>
|
|
315
|
+
)}
|
|
316
|
+
</PaymentMethodPicker>
|
|
317
|
+
|
|
318
|
+
{/* summary: coupon field (if not in the cart) + useTotalsLines(), as in the cart page */}
|
|
319
|
+
|
|
320
|
+
<button disabled={!checkout.canPlaceOrder || checkout.placing} onClick={() => checkout.placeOrder()}>
|
|
321
|
+
{checkout.placing ? "Placing your order…" : "Place order"}
|
|
322
|
+
</button>
|
|
323
|
+
{checkout.orderError && <p role="alert">{checkout.orderError.message}</p>}
|
|
324
|
+
{!checkout.canPlaceOrder && blockers.map((b) => <p key={b.code}>{b.message}</p>)}
|
|
325
|
+
</>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
124
328
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
return
|
|
128
|
-
<
|
|
129
|
-
|
|
130
|
-
|
|
329
|
+
function AddressFields({ which }) {
|
|
330
|
+
const { fields, set, countriesLoading } = useAddressForm(which);
|
|
331
|
+
return fields.map((f) => (
|
|
332
|
+
<label key={f.key}>
|
|
333
|
+
{f.label}{f.required && " *"}
|
|
334
|
+
{f.type === "select" ? (
|
|
335
|
+
<select value={f.value} onChange={(e) => set(f.key, e.target.value)} autoComplete={f.autoComplete}>
|
|
336
|
+
<option value="">{f.key === "country" && countriesLoading ? "Loading…" : `Select ${f.label}`}</option>
|
|
337
|
+
{f.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
|
338
|
+
</select>
|
|
339
|
+
) : (
|
|
340
|
+
<input type={f.type} value={f.value} required={f.required}
|
|
341
|
+
onChange={(e) => set(f.key, e.target.value)} autoComplete={f.autoComplete} />
|
|
342
|
+
)}
|
|
343
|
+
{f.error && <span role="alert">{f.error}</span>}
|
|
344
|
+
</label>
|
|
345
|
+
));
|
|
131
346
|
}
|
|
132
347
|
```
|
|
133
348
|
|
|
134
|
-
|
|
349
|
+
## Order received
|
|
135
350
|
|
|
136
|
-
|
|
351
|
+
**Mandatory route** — every payment link returns here, and confirming is what
|
|
352
|
+
marks a card order paid. `useOrderReturn()` is the whole page's logic: it reads
|
|
353
|
+
`order_id`/`order_key` from the URL, verifies with the provider (idempotent),
|
|
354
|
+
and marks the page noindex itself.
|
|
137
355
|
|
|
138
|
-
|
|
356
|
+
⚑ Rules: render all five states — never a blank page while `"loading"`, and a
|
|
357
|
+
retry via `reload()` on `"error"`. ⚑ **Never drop `paymentInstructions`**: a
|
|
358
|
+
manual/offline order settles outside the store, so these ARE how the store's
|
|
359
|
+
default customer learns how to pay — render them whenever present, on any
|
|
360
|
+
state. ⚑ An order's totals are **flat** — `order.total`; there is no
|
|
361
|
+
`order.totals` (use `useTotalsLines(order)`).
|
|
139
362
|
|
|
140
|
-
|
|
363
|
+
**Reference implementation** — a receipt is a convention, not an identity
|
|
364
|
+
surface: take this structure, restyle it to the store, keep every branch.
|
|
141
365
|
|
|
142
366
|
```jsx
|
|
143
|
-
|
|
367
|
+
function OrderReceived() {
|
|
368
|
+
const { status, order, lines, paymentLink, paymentInstructions, error, reload } = useOrderReturn();
|
|
369
|
+
const totals = useTotalsLines(order);
|
|
370
|
+
const formatMoney = useFormatMoney();
|
|
371
|
+
if (status === "loading") return /* confirming copy */;
|
|
372
|
+
if (status === "error") return <><p role="alert">{error.message}</p>
|
|
373
|
+
<button onClick={() => reload()}>Try again</button></>;
|
|
374
|
+
return (
|
|
375
|
+
<>
|
|
376
|
+
{status === "paid" && /* thank-you heading */}
|
|
377
|
+
{status === "unpaid" && <>{/* awaiting-payment heading */}
|
|
378
|
+
{paymentLink?.url && <a href={paymentLink.url}>Pay now</a>}</>}
|
|
379
|
+
{status === "cancelled" && <>{/* payment-cancelled heading */}
|
|
380
|
+
{paymentLink?.url && <a href={paymentLink.url}>Try payment again</a>}</>}
|
|
381
|
+
{order?.order_number && <p>Order {order.order_number}</p>}
|
|
382
|
+
{paymentInstructions && (
|
|
383
|
+
<section>{/* "How to pay" — the offline customer's next step */}
|
|
384
|
+
{paymentInstructions.description && <p>{paymentInstructions.description}</p>}
|
|
385
|
+
{paymentInstructions.account_details && Object.entries(paymentInstructions.account_details)
|
|
386
|
+
.map(([k, v]) => <p key={k}>{k.replace(/_/g, " ")}: {String(v)}</p>)}
|
|
387
|
+
</section>
|
|
388
|
+
)}
|
|
389
|
+
{lines.map((l, i) => <p key={i}>{l.name} {l.attributesLabel} × {l.quantity} — {formatMoney(l.total)}</p>)}
|
|
390
|
+
{totals.filter((t) => !t.hidden).map((t) => <p key={t.key}>{t.label} {t.formatted}</p>)}
|
|
391
|
+
</>
|
|
392
|
+
);
|
|
393
|
+
}
|
|
144
394
|
```
|
|
145
395
|
|
|
146
|
-
Mandatory route. It renders all five states, including the two hand-written pages drop: **`paymentInstructions` for a manual/offline order** — the default gateway, so this is how the store's normal customer learns how to pay — and the pay-now link for an unpaid card order. It is `noindex`, as a receipt carrying an order key should be. Custom version: `useOrderReturn()` + `useTotalsLines(order)` (an order's totals are **flat** — `order.total`; there is no `order.totals`).
|
|
147
|
-
|
|
148
396
|
## SEO — one line per page type
|
|
149
397
|
|
|
150
398
|
```jsx
|
|
151
399
|
useStorefrontSeo(productSeo(p.product, p.view, { storeName, currency })); // product page
|
|
152
400
|
useStorefrontSeo(collectionSeo({ title, products: list.products })); // collection / home
|
|
153
|
-
|
|
401
|
+
// order-received is already noindex via useOrderReturn
|
|
154
402
|
```
|
|
155
403
|
|
|
156
|
-
It is a hook — call it above the page's early returns (the builders tolerate a
|
|
404
|
+
It is a hook — call it above the page's early returns (the builders tolerate a
|
|
405
|
+
null product).
|
|
157
406
|
|
|
158
407
|
## Per-page output budgets
|
|
159
408
|
|
|
160
409
|
| Page | budget (chars) | rationale |
|
|
161
410
|
|---|---|---|
|
|
162
|
-
| Checkout | ≤
|
|
163
|
-
| Cart / bag | ≤
|
|
164
|
-
| Order-received | ≤
|
|
165
|
-
| Product page | ≤
|
|
411
|
+
| Checkout | ≤ 5K | your markup over the reference above — the logic is all hook calls |
|
|
412
|
+
| Cart / bag | ≤ 3K | `useCart` + `CartLine` rows + totals + coupon + empty state |
|
|
413
|
+
| Order-received | ≤ 2.5K | five states + payment instructions + summary |
|
|
414
|
+
| Product page | ≤ 5K | your layout and type around `useProduct`, `variantAxes`, `useAddToCartButton`, the gallery |
|
|
166
415
|
| Collection | ≤ 3K | `useProductList` + custom card + pagination controls |
|
|
167
|
-
| Home | ≤ 5K | pure identity
|
|
416
|
+
| Home | ≤ 5K | pure identity — hero/editorial earn their chars |
|
|
168
417
|
| Any single component file | ≤ 4K, hard ceiling 8K | Base1 evidence: decode is 34% of wall; a 12K file is a 45s write batch |
|
|
169
418
|
|
|
170
|
-
|
|
419
|
+
These budgets assume the hooks carry the logic and your markup carries only the
|
|
420
|
+
design. Over budget ⇒ you are re-implementing something a hook does — an
|
|
421
|
+
address spec, a quantity clamp, totals math, variant resolution, add-to-cart
|
|
422
|
+
error recovery. Go back to the hook and delete your version.
|
|
171
423
|
|
|
172
424
|
## Done — forget this file
|
|
173
425
|
|
|
@@ -175,14 +427,18 @@ Over budget ⇒ extract components, or adopt the block you are re-implementing.
|
|
|
175
427
|
- [ ] **One** `<StorefrontProvider>` above every storefront route, wrapping `<Routes>` (or a layout route's `<Outlet/>`); one client, no hand-rolled `cart_token`.
|
|
176
428
|
- [ ] Pages branch on `status`; no page maps a possibly-null list or shows an empty state while loading.
|
|
177
429
|
- [ ] Gateways/currency/countries read from `useStoreInfo()` only.
|
|
178
|
-
- [ ] If the store has coupons, a coupon field exists in the cart or the checkout.
|
|
179
|
-
- [ ] `/order-received` renders
|
|
430
|
+
- [ ] If the store has coupons, a coupon field (`useCoupon`) exists in the cart or the checkout.
|
|
431
|
+
- [ ] `/order-received` renders `useOrderReturn`'s states **including `paymentInstructions`**.
|
|
432
|
+
- [ ] Variant options render one control per axis; unbuyable options are disabled, not hidden.
|
|
433
|
+
- [ ] No hook logic was re-implemented by hand (address fields, quantity clamps, totals rows, shipping/payment branching).
|
|
434
|
+
- [ ] The storefront looks designed — the reference implementations above were adapted into this store's design, not shipped bare.
|
|
180
435
|
- [ ] Every page is within its budget above.
|
|
181
436
|
- [ ] A real purchase completes in the preview — pick a variant, add it, check out, place an offline order, land on `/order-received`.
|
|
182
437
|
|
|
183
438
|
Record these lines in your working notes; do not re-read this file.
|
|
184
439
|
|
|
185
440
|
- Payment gateways, currency and countries come from `useStoreInfo()` only — never off a cart (`cart.payment_gateways` is always undefined).
|
|
186
|
-
- A store with any coupons must have a coupon field
|
|
187
|
-
- `/order-received` renders
|
|
441
|
+
- A store with any coupons must have a coupon field (`useCoupon`) in the cart or the checkout, or its codes can never be redeemed.
|
|
442
|
+
- `/order-received` is mandatory and renders `useOrderReturn`'s states, including `paymentInstructions` — how a normal (offline) customer learns how to pay.
|
|
188
443
|
- Branch cart/list/product UI on `status`, never on `isEmpty`/nullable data — `isEmpty` is false while loading by design.
|
|
444
|
+
- Variant options that aren't buyable render disabled, never hidden; one control per axis, never a list of variations.
|
|
@@ -133,16 +133,16 @@ Both failure modes are silent at seed time and visible only in the store, so che
|
|
|
133
133
|
|
|
134
134
|
| What the request says | Decision |
|
|
135
135
|
|---|---|
|
|
136
|
-
| A provider is named
|
|
137
|
-
| Selling online implied, no provider named | cards are a reasonable read of *what* to offer, never of *when*:
|
|
136
|
+
| A provider is named ("use Stripe") | copy the provider file, enable `card` — whenever convenient; it blocks nothing |
|
|
137
|
+
| Selling online implied, no provider named | cards are a reasonable read of *what* to offer, never of *when*: raise the provider **after** the store works |
|
|
138
138
|
| Paid another way (transfer, COD, invoice, pickup, quotes) | nothing to do — the default already is exactly this |
|
|
139
139
|
| Payments not mentioned at all | leave the default and **mention it at handover** |
|
|
140
140
|
|
|
141
|
-
**Timing: late, never the opening move.** Payments have no dependency edge into anything — the admin, the seed and the entire buy path down to `place-order` work with no provider — so
|
|
141
|
+
**Timing: late, never the opening move.** Payments have no dependency edge into anything — the admin, the seed and the entire buy path down to `place-order` work with no provider — so putting the provider connection before the store exists only stalls the build. Any instruction that treats a payment provider as a required install step is superseded by this section.
|
|
142
142
|
|
|
143
143
|
**Say where payments landed** in your handover summary, including "offline only, cards off". A store's owner should never discover their payment configuration from a customer who couldn't pay.
|
|
144
144
|
|
|
145
|
-
*Only for a store that opted into cards:*
|
|
145
|
+
*Only for a store that opted into cards:* **if it is Stripe, the code is already written** — `base44/shared/commerce/card-payment.stripe.ts` is a complete implementation, used **as-is**. Copy it over `base44/shared/commerce/card-payment.ts` (`fs.copyFileSync`, whole file — never a partial edit, which leaves duplicate exports and breaks every commerce function's deploy) and enable the gateway with `payment_methods: ["offline", "card"]`. Nothing in it needs filling in and no key belongs in the code; it reads the credential the app's Stripe connection publishes. Any other provider means implementing four functions in that one file — [`../references/online-payments.md`](../references/online-payments.md).
|
|
146
146
|
|
|
147
147
|
## Done — forget this file
|
|
148
148
|
|
|
@@ -150,7 +150,7 @@ Both failure modes are silent at seed time and visible only in the store, so che
|
|
|
150
150
|
- [ ] `warnings` in the response is empty, or every warning is deliberate and stated to the user.
|
|
151
151
|
- [ ] Shipping is expressed in `locations` (with a catch-all if the store ships worldwide), not patched into entities afterwards.
|
|
152
152
|
- [ ] `coupons` seeded only if a coupon field exists ([`./02-storefront.md`](./02-storefront.md)).
|
|
153
|
-
- [ ] Cards are either off, or on with
|
|
153
|
+
- [ ] Cards are either off, or on with the provider file copied whole and the `card` gateway enabled.
|
|
154
154
|
- [ ] Product slugs from `catalog.products[]` recorded, and the storefront links by them.
|
|
155
155
|
- [ ] If the brief named tiered rates, each named region prices to its rate (the `set-shipping-address` check above).
|
|
156
156
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
stage: reference
|
|
3
3
|
read_when: "You are asking which fields a card or a product page can actually show, or hit a variant edge case (unavailable combinations, attributes with no variations, price ranges, linkable selections)."
|
|
4
|
-
skip_when: "The listing and product page render correctly from useProductList / useProduct and the
|
|
4
|
+
skip_when: "The listing and product page render correctly from useProductList / useProduct and the render-model helpers — the quick start in ../install/02-storefront.md covers the happy path."
|
|
5
5
|
forget_when: "Cards and the product page render the fields you intended, variant selection resolves to a variation, and add-to-cart succeeds."
|
|
6
6
|
carry_forward:
|
|
7
7
|
- "There is no product `type` field: a non-empty `attributes[]` is what makes a product sell variants, and such a product is only sellable via a `variation_id`."
|
|
@@ -31,7 +31,7 @@ A listing **row** is the product record itself (minus paywalled fields) plus res
|
|
|
31
31
|
| `images[]`, `featured`, `short_description`, `description` | ✅ | ✅ | Cards normally use `images[0]` + `short_description`; every entry is an **object** — §2 |
|
|
32
32
|
| `sku`, `stock_status`, `stock_quantity`, `manage_stock`, `backorders` | ✅ | ✅ | |
|
|
33
33
|
| `average_rating`, `rating_count`, `total_sales` | ✅ | ✅ | Enough for stars on a card; the reviews themselves are not in a row |
|
|
34
|
-
| `downloadable`, `virtual`, `weight`, `dimensions`, `attributes[]`, `meta_data` | ✅ | ✅ | `attributes[]` is the variant axes; `meta_data` is the descriptive **modifiers** (
|
|
34
|
+
| `downloadable`, `virtual`, `weight`, `dimensions`, `attributes[]`, `meta_data` | ✅ | ✅ | `attributes[]` is the variant axes; `meta_data` is the descriptive **modifiers** (`productSpecs(product)` turns them into spec-table rows) |
|
|
35
35
|
| **`ribbons`** (resolved), `ribbon_ids`, `category_ids` | ✅ | ✅ | Rows carry `{id, name}` ribbons; `get-product` returns the full records |
|
|
36
36
|
| **`categories`** (resolved) | ❌ *ids only* | ✅ | §6 to add them to rows |
|
|
37
37
|
| **`variations[]`** (per-variant price/stock/image/attributes) | ❌ | ✅ | Why a product with variants can't be fully priced from a row |
|
|
@@ -53,14 +53,14 @@ A product with attributes is **only** sellable through a variant: `add-item` wit
|
|
|
53
53
|
Three rules used to be prose here and are now enforced by exports — use them and they can't drift between views:
|
|
54
54
|
|
|
55
55
|
- **From-price.** `admin-products` (and the seeder) roll a parent's `regular_price`/`price`/`on_sale` up from the cheapest publishable variant on every save, so the parent price is real, sortable and filterable — but it is the **lowest** price, not *the* price. `productPrice(rowOrView, {formatMoney})` / `useProductPrice(rowOrView)` accept **either** a listing row or a `resolveSelection` view and return `{label, compareAtLabel, onSale, isFrom, isRange, min, max}`: "From €19.99" on a card, a range on an unresolved page, the exact price once resolved.
|
|
56
|
-
- **Images are objects, and may be absent.** Every stored image is `{src, name, alt}`, never a URL string, and `images` can legitimately be empty. `productImages(product)` / `normalizeImage(entry)` return clean entries (non-empty `src`, defaulted `alt`), and an empty array is the *render your placeholder* signal — `useProductGallery`
|
|
56
|
+
- **Images are objects, and may be absent.** Every stored image is `{src, name, alt}`, never a URL string, and `images` can legitimately be empty. `productImages(product)` / `normalizeImage(entry)` return clean entries (non-empty `src`, defaulted `alt`), and an empty array is the *render your placeholder* signal — `useProductGallery` builds on them (`hasImages`). Passing the object itself to an `<img src>` fails the load and shows the placeholder for every product in the store.
|
|
57
57
|
- **Modifiers are not attributes.** `meta_data` (Material, Care, GTIN) is a spec table — never a selector, never a ribbon.
|
|
58
58
|
|
|
59
59
|
## 3. What each view renders
|
|
60
60
|
|
|
61
|
-
**Card:** image, name, `price.label`, sale badge from `on_sale`, stars from `average_rating`/`rating_count`, out-of-stock state from `stock_status`, one or two ribbons. Link the whole card to the product page
|
|
61
|
+
**Card:** image, name, `price.label`, sale badge from `on_sale`, stars from `average_rating`/`rating_count`, out-of-stock state from `stock_status`, one or two ribbons. Link the whole card to the product page — like every other piece of storefront UI, the card is entirely yours to design.
|
|
62
62
|
|
|
63
|
-
**Product page:** gallery, name, price, variant selectors, stock, `short_description` then `description`, SKU, categories breadcrumb, ribbons, reviews, then upsells/cross-sells. Everything except
|
|
63
|
+
**Product page:** gallery, name, price, variant selectors, stock, `short_description` then `description`, SKU, categories breadcrumb, ribbons, reviews, then upsells/cross-sells. Everything except the markup has a hook or helper: `useProductGallery`, `variantAxes(view, pick)`, `useAddToCartButton`, `productSpecs(product)`, `useProductReviews`, `p.upsells`/`p.crossSells`.
|
|
64
64
|
|
|
65
65
|
## 4. Ribbons — in **both** views
|
|
66
66
|
|
|
@@ -73,7 +73,7 @@ Ribbons are flat, cross-cutting labels ("Best Seller", "New", "Gift"); categorie
|
|
|
73
73
|
|
|
74
74
|
## 5. Variant selection
|
|
75
75
|
|
|
76
|
-
The one interaction agents reliably get wrong.
|
|
76
|
+
The one interaction agents reliably get wrong. `variantAxes(view, pick)` hands you the render-ready model that encodes the rule: **one control per axis, never a list of variations** (`Red / S`, `Red / M`, … is `n × m` noise that hides the product's structure), and an option that isn't buyable renders **disabled, not removed** (`o.disabled`; `o.outOfStock` stays visible, just marked). Map it to any control — buttons, swatches, dropdowns — and call `o.pick()` on select.
|
|
77
77
|
|
|
78
78
|
Underneath, `useProduct` composes the framework-free helpers in `src/commerce/utils/variants.js` — `defaultSelection` → `selectOption` on a click → `resolveSelection` for the view. Every `product.attributes[]` entry is an **axis** in `position` order; every `variations[]` record is one combination. Bind the UI to `view`, not to `product.*`, or a selection changes nothing:
|
|
79
79
|
|