@base44/app-plugin-commerce 0.3.1 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
- package/base44/functions/commerce/seed-store/entry.ts +16 -2
- package/package.json +1 -1
- package/skills/commerce/SKILL.md +9 -6
- package/skills/commerce/docs/api-storefront.md +4 -2
- package/skills/commerce/install/01-install.md +1 -6
- package/skills/commerce/install/02-storefront.md +32 -21
- package/skills/commerce/install/03-data.md +12 -10
- package/skills/commerce/references/catalog-rendering.md +33 -2
- package/skills/commerce/references/online-payments.md +10 -0
- package/src/commerce/storefront/index.js +10 -0
- package/src/commerce/storefront/types.js +120 -0
- package/src/commerce/storefront/useProduct.js +18 -2
- package/src/commerce/storefront/useProductList.js +13 -4
- package/src/commerce/utils/index.js +10 -4
- package/src/commerce/utils/ribbons.js +51 -0
- package/src/commerce/utils/types.js +193 -0
package/README.md
CHANGED
|
@@ -11,8 +11,8 @@ It provides a full-featured **commerce data model and behavior** (variant-driven
|
|
|
11
11
|
- **Payments: manual methods work out of the box; online cards are opt-in** — the seed enables the manual `offline` method (bank transfer, cash on delivery, pickup: on-hold + instructions, no code) and leaves the `card` gateway **disabled**. The order side of card payments *is* premade — checkout routing, payment links for unpaid orders, two idempotent confirmation paths (customer return + webhook) and refund records — so a store that opts in wires a provider by implementing **four functions in one file**, `base44/shared/commerce/card-payment.ts`. **For Stripe there is nothing to write**: `base44/shared/commerce/card-payment.stripe.ts` is a complete implementation used as-is — copy it over the stub and enable the gateway. Any other provider (PayPal, Adyen, a local PSP) follows the same shape. Enable the gateway only with a provider behind it, or checkout answers `503 no_card_payment_provider`. The rule and timing: [`skills/commerce/install/03-data.md`](./skills/commerce/install/03-data.md); provider mechanics: [`skills/commerce/references/online-payments.md`](./skills/commerce/references/online-payments.md). The admin can add more manual methods in Settings → Payments.
|
|
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
|
-
- **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`
|
|
15
|
-
- **Storefront React layer** (`src/commerce/storefront/`) — **headless: the logic is premade, the UI never is.** Nothing in the layer renders markup or carries CSS; every element, class and word of copy in the storefront you build is yours, so a brief like "make it feel like <site>" applies to the whole store, checkout included. **It ships no customer-facing copy either**: where a state needs words you get the *state* — `buy.state`, a picker's `hint.code`, the checkout's `blockers`, a review's `status` — and write the sentence. What ships is every piece of logic that is the same in all stores: `StorefrontProvider` (+ `useStorefront`/`useStoreInfo`/`useFormatMoney`/`useCountries`), `useProductList`/`useCategories`/`useRibbons`, `useProduct`/`useAddToCart`, `useCart`/`useCartLine` (+ `useCartUI`/`CartUIProvider` for a drawer), `useCheckout`/`CheckoutProvider`/`useCheckoutContext`, `useOrderReturn`/`orderReceivedUrl` — plus three render-prop components that stay just as headless (`ShippingMethodPicker`/`PaymentMethodPicker` for the two checkout choices that are store data, `CartLine` for per-row cart bindings), and the framework-free view-model helpers re-exported so one import line covers a page (`variantAxes`, `productPrice`, `productImages`, `productSpecs`, `attributesLabel`, `cartTotalsLines`/`orderTotalsLines`, `addressFieldSpec`). Each hook's doc comment states the render rules that keep a store correct (an unbuyable variant option renders disabled, not hidden; a receipt page must render `paymentInstructions`; …). Needs React and nothing else.
|
|
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/`) — **headless: the logic is premade, the UI never is.** Nothing in the layer renders markup or carries CSS; every element, class and word of copy in the storefront you build is yours, so a brief like "make it feel like <site>" applies to the whole store, checkout included. **It ships no customer-facing copy either**: where a state needs words you get the *state* — `buy.state`, a picker's `hint.code`, the checkout's `blockers`, a review's `status` — and write the sentence. What ships is every piece of logic that is the same in all stores: `StorefrontProvider` (+ `useStorefront`/`useStoreInfo`/`useFormatMoney`/`useCountries`), `useProductList`/`useCategories`/`useRibbons`, `useProduct`/`useAddToCart`, `useCart`/`useCartLine` (+ `useCartUI`/`CartUIProvider` for a drawer), `useCheckout`/`CheckoutProvider`/`useCheckoutContext`, `useOrderReturn`/`orderReceivedUrl` — plus three render-prop components that stay just as headless (`ShippingMethodPicker`/`PaymentMethodPicker` for the two checkout choices that are store data, `CartLine` for per-row cart bindings), 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`). Each hook's doc comment states the render rules that keep a store correct (an unbuyable variant option renders disabled, not hidden; a receipt page must render `paymentInstructions`; …) and names its return type from `storefront/types.js`, so a page reads a field's shape off the hook instead of off a backend function. 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
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "commerce.PaymentGateway",
|
|
3
3
|
"type": "object",
|
|
4
|
-
"description": "Payment gateway configuration. Seeded: offline (settled outside the store) and card (processed by commerce/payments + commerce/payment-webhook via shared/commerce/card-payment.ts). Secrets are never stored here; use Base44 secrets.",
|
|
4
|
+
"description": "Payment gateway configuration. Seeded: offline (settled outside the store) and card (processed by commerce/payments + commerce/payment-webhook via shared/commerce/card-payment.ts). Enable or disable a gateway with commerce/seed-store payment_methods — e.g. { payment_methods: [\"offline\", \"card\"] }, valid at any time and the whole payload — never by writing enabled on this row: the seeder owns the on/off state and the next call overwrites a hand edit. Secrets are never stored here; use Base44 secrets.",
|
|
5
5
|
"properties": {
|
|
6
6
|
"slug": {
|
|
7
7
|
"type": "string",
|
|
@@ -47,6 +47,14 @@
|
|
|
47
47
|
* the one call covers "card-only" stores with no entity editing. Explicit
|
|
48
48
|
* values always win, on first seed and re-runs alike (like `currency`).
|
|
49
49
|
*
|
|
50
|
+
* **Every key is independent, and the call is the day-2 tool as much as the
|
|
51
|
+
* install one.** A later call carries only the slice being changed — enabling
|
|
52
|
+
* cards on a live store is `{ payment_methods: ["offline", "card"] }` and
|
|
53
|
+
* nothing else. Omitted keys are not reset: no `products` means no catalog work
|
|
54
|
+
* at all, and no `locations` leaves the store's shipping untouched. This is the
|
|
55
|
+
* supported way to change store configuration; editing `commerce.PaymentGateway`
|
|
56
|
+
* (or the settings rows) by hand is not.
|
|
57
|
+
*
|
|
50
58
|
* `store_name` is required on a first seed: a function's env is only
|
|
51
59
|
* BASE44_APP_ID, so it cannot read the app's name, and subjects need one.
|
|
52
60
|
* `currency` is an ISO code and `weight_unit`/`dimension_unit` are the
|
|
@@ -249,9 +257,15 @@ Deno.serve(async (req) => {
|
|
|
249
257
|
if (catalogSpec?.locations?.length && !catalogSpec.locations.some((l: any) => !l.regions?.length)) {
|
|
250
258
|
warnings.push("no_catchall_location: addresses outside your locations will get shipping_not_available — add a location with rest_of_world: true if you ship worldwide");
|
|
251
259
|
}
|
|
260
|
+
// The same reasoning applies to a later call that simply doesn't mention
|
|
261
|
+
// shipping — flipping `payment_methods` on a live store, say. The merchant's
|
|
262
|
+
// locations are still the shipping story, so the test is whether the store
|
|
263
|
+
// has ANY location, not whether one carries this default's name: matching by
|
|
264
|
+
// name would drop a free worldwide rate behind a catch-all called anything
|
|
265
|
+
// else, which is the exact record the paragraph above refuses to create.
|
|
252
266
|
if (!catalogSpec?.locations?.length) {
|
|
253
|
-
const
|
|
254
|
-
if (!
|
|
267
|
+
const existing = (await sr.entities["commerce.ShippingTaxLocation"].list(undefined, 1)) ?? [];
|
|
268
|
+
if (!existing.length) {
|
|
255
269
|
await sr.entities["commerce.ShippingTaxLocation"].create(REST_OF_WORLD_LOCATION);
|
|
256
270
|
seeded.locations++;
|
|
257
271
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@base44/app-plugin-commerce",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
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
|
@@ -33,8 +33,11 @@ moments. Read nothing else up front.
|
|
|
33
33
|
`base44.entities["commerce.Product"]`. `commerce__Product` and `Product` do
|
|
34
34
|
not exist. The map is [`docs/entities.md`](./docs/entities.md); never scan
|
|
35
35
|
`base44/entities/` for a name.
|
|
36
|
-
- **Store configuration is declared
|
|
37
|
-
zones, currency, payment methods — never assembled by editing
|
|
36
|
+
- **Store configuration is declared through `commerce/seed-store`** — catalog,
|
|
37
|
+
shipping zones, currency, payment methods — never assembled by editing
|
|
38
|
+
records. It is idempotent and every key is independent, so it is the day-2
|
|
39
|
+
tool as much as the install one: a later call carries only the slice it
|
|
40
|
+
changes — enabling cards is `{ payment_methods: ["offline", "card"] }`, whole
|
|
38
41
|
([`install/03-data.md`](./install/03-data.md)).
|
|
39
42
|
- **Don't weaken the admin gating** — three layers (UI guard, entity RLS,
|
|
40
43
|
`requireAdmin()`); keep all three when touching routes or schemas
|
|
@@ -105,13 +108,13 @@ Open a file when its work starts — not while planning.
|
|
|
105
108
|
|
|
106
109
|
| Topic | Open when | Size |
|
|
107
110
|
|---|---|---|
|
|
108
|
-
| [`install/01-install.md`](./install/01-install.md) | installing — routes you to 02 and 03 |
|
|
111
|
+
| [`install/01-install.md`](./install/01-install.md) | installing — routes you to 02 and 03 | 5K |
|
|
109
112
|
| [`install/02-storefront.md`](./install/02-storefront.md) | building storefront pages | 33K |
|
|
110
|
-
| [`install/03-data.md`](./install/03-data.md) | seeding catalog/
|
|
113
|
+
| [`install/03-data.md`](./install/03-data.md) | seeding catalog, shipping rates/zones, payments; re-callable per slice | 11K |
|
|
111
114
|
| [`docs/entities.md`](./docs/entities.md) | any direct entity read/write ("which entity holds X") | 11K |
|
|
112
|
-
| [`references/catalog-rendering.md`](./references/catalog-rendering.md) |
|
|
115
|
+
| [`references/catalog-rendering.md`](./references/catalog-rendering.md) | field shapes each catalog call returns, variant edge cases | 16K |
|
|
113
116
|
| [`references/shipping-and-tax.md`](./references/shipping-and-tax.md) | zones beyond 03's recipe, taxes, day-2 edits | 8K |
|
|
114
|
-
| [`references/online-payments.md`](./references/online-payments.md) |
|
|
117
|
+
| [`references/online-payments.md`](./references/online-payments.md) | enabling card payments, or wiring the provider — at install or any time later | 8K |
|
|
115
118
|
| [`references/storefront-verification.md`](./references/storefront-verification.md) | driving the storefront from a browser script | 3K |
|
|
116
119
|
| [`references/reviews.md`](./references/reviews.md) | review policies (login-gated, verified buyers), moderation | 5K |
|
|
117
120
|
| [`references/store-settings.md`](./references/store-settings.md) | changing store behavior through settings keys | 5K |
|
|
@@ -59,7 +59,7 @@ Only `status: "publish"` products are returned — the admin's single **Visible*
|
|
|
59
59
|
|
|
60
60
|
**Response:** `{ "products": [Product...], "page": 1, "per_page": 12, "has_next": true }`
|
|
61
61
|
|
|
62
|
-
Each row is the product record (minus paywalled fields) **plus a resolved `ribbons` array** (`[{ id, name }]`), so cards show ribbons without a second call. `categories` are **not** resolved — `category_ids` only.
|
|
62
|
+
Each row is the product record (minus paywalled fields) **plus a resolved `ribbons` array** (`[{ id, name }]` — objects, and the key is omitted entirely when no row on the page carries a ribbon), so cards show ribbons without a second call. `categories` are **not** resolved — `category_ids` only. Every field's exact shape, what a row can and can't show, and how to add a `get-product`-only field to this call instead of fetching per card: [`../references/catalog-rendering.md`](../references/catalog-rendering.md).
|
|
63
63
|
|
|
64
64
|
**Filters stack** — all of them are ANDed, so "Dresses + gift + on sale" is one request. Build the controls from [`list-categories`](#list-categories), [`list-ribbons`](#list-ribbons) (its `count` gives you "Gift (12)") and [`list-attributes`](#list-attributes), and mirror active filters into the URL so a filtered listing is shareable and survives reload.
|
|
65
65
|
|
|
@@ -84,7 +84,7 @@ await cat({ category_id, sort: "popularity", per_page: 4 }); // top in catego
|
|
|
84
84
|
"product": { Product },
|
|
85
85
|
"variations": [ ProductVariation... ], // publishable only; [] when the product has no attributes
|
|
86
86
|
"categories": [ ProductCategory... ],
|
|
87
|
-
"ribbons": [ ProductRibbon... ],
|
|
87
|
+
"ribbons": [ ProductRibbon... ], // { id, name, count } records, beside the product — not on it
|
|
88
88
|
"reviews": { "items": [ { "id", "reviewer", "review", "rating", "verified", "created_date" } ],
|
|
89
89
|
"page": 1, "per_page": 10, "has_next": false,
|
|
90
90
|
"average_rating": 4.5, "rating_count": 12 },
|
|
@@ -94,6 +94,8 @@ await cat({ category_id, sort: "popularity", per_page: 4 }); // top in catego
|
|
|
94
94
|
```
|
|
95
95
|
**Errors:** `404 not_found` (missing / not published / hidden).
|
|
96
96
|
|
|
97
|
+
In React, `useProduct` normalizes those ribbons to a listing row's `{ id, name }` and attaches them to `product`, so one card component can render a row *or* this product (`productRibbons(product)`); ribbon counts for a filter come from [`list-ribbons`](#list-ribbons).
|
|
98
|
+
|
|
97
99
|
> **`variations[]` is not a list of choices to show** — variant rule 1 applies, and variant prices come from `variations[]`, never `product.price` (a rolled-up from-price). In React the product page's hooks in `@/commerce/storefront` resolve this for you. The non-React resolver sample (`resolveSelection` from `@/commerce/utils`) and the variant deep-dive: [`../references/catalog-rendering.md`](../references/catalog-rendering.md).
|
|
98
100
|
|
|
99
101
|
### `list-categories`
|
|
@@ -67,9 +67,4 @@ Storefront functions are public on purpose (per-action verification, above). To
|
|
|
67
67
|
|
|
68
68
|
Then continue: **[`./02-storefront.md`](./02-storefront.md) when you start building UI**, **[`./03-data.md`](./03-data.md) when you start the seed payload**. Do **not** read them now, and open no `references/` or `docs/` file during an install — a file read early costs its size on every later call.
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
- Admin enforcement is three layers — AuthGuard (UI), admin-only entity RLS, requireAdmin() in every admin function. Never weaken any of them.
|
|
73
|
-
- `/order-received` must exist as a route: every payment link returns there, and confirming is what marks an order paid.
|
|
74
|
-
- Interleave: start image generation first → mount admin + build the storefront while images render → seed when the URLs are back → payments last.
|
|
75
|
-
- Entities are dotted + bracket-syntax only (`base44.entities["commerce.X"]`); the map is [`../docs/entities.md`](../docs/entities.md) — never scan `base44/entities/`.
|
|
70
|
+
Then copy this file's `carry_forward` lines (in its front matter) into your working notes, and do not re-read this file.
|
|
@@ -16,13 +16,13 @@ carry_forward:
|
|
|
16
16
|
|
|
17
17
|
# 02 — Storefront
|
|
18
18
|
|
|
19
|
-
One split decides everything here: **the logic is premade, the UI never is.** The hooks own checkout repricing, variant resolution, cart state, order-return verification — hand-writing any of it is where storefront bugs cluster, so **never re-implement what a hook does**. Every element, class, layout and **word** is yours; nothing in `@/commerce/storefront` renders markup
|
|
19
|
+
One split decides everything here: **the logic is premade, the UI never is.** The hooks own checkout repricing, variant resolution, cart state, order-return verification — hand-writing any of it is where storefront bugs cluster, so **never re-implement what a hook does**. Every element, class, layout and **word** is yours; nothing in `@/commerce/storefront` renders markup or carries CSS. **Decide how the store looks as if this kit did not exist**, then encode it **once** as design classes ([below](#design-language--once-before-any-page)) — the snippets here are wiring reference, never design input.
|
|
20
20
|
|
|
21
21
|
**States and codes, not copy.** Where a state needs words you get the *state* (`buy.state`, `hint.code`, `blockers`) and write the words. So: never re-derive a state you were handed (a ternary chain over `adding`/`purchasable` re-implements `buy.state`, wrong), and never leave one unworded (a button with no text for `sold_out` renders empty).
|
|
22
22
|
|
|
23
|
-
**This file is the whole job.** Every shape you need is in ["What each hook resolves to"](#what-each-hook-resolves-to) —
|
|
23
|
+
**This file is the whole job.** Every shape you need is in ["What each hook resolves to"](#what-each-hook-resolves-to) — don't open the hook files while building; that is the most expensive way to answer a question this page already answers. Rules marked ⚑ must survive whatever design you build.
|
|
24
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
|
|
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.
|
|
26
26
|
|
|
27
27
|
## Setup — once
|
|
28
28
|
|
|
@@ -45,13 +45,23 @@ import AdminApp from "@/commerce/admin";
|
|
|
45
45
|
}>
|
|
46
46
|
<Route path="/" element={<Home />} />
|
|
47
47
|
<Route path="/product/:slug" element={<ProductPage />} />
|
|
48
|
-
|
|
49
|
-
<Route path="/checkout" element={<Checkout />} />
|
|
50
|
-
<Route path="/order-received" element={<OrderReceived />} />
|
|
48
|
+
{/* /bag, /checkout, and /order-received — which is mandatory */}
|
|
51
49
|
</Route>
|
|
52
50
|
<Route path="/store-admin/*" element={<AdminApp />} /> {/* own chrome, outside the provider */}
|
|
53
51
|
</Routes>
|
|
54
52
|
</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
|
+
}
|
|
55
65
|
```
|
|
56
66
|
|
|
57
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 (or throws). With no shared chrome, wrap `<Routes>` in the provider instead; a provider *inside* `<Routes>` throws ("is not a `<Route>` component"). The provider owns the shared client, store info and **one** shared cart — never mount a second one, never touch `cart_token`.
|
|
@@ -60,9 +70,9 @@ import AdminApp from "@/commerce/admin";
|
|
|
60
70
|
|
|
61
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`, a heading scale, a price style — 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.**
|
|
62
72
|
|
|
63
|
-
The store's words work the same way: the states these hooks hand you recur across pages (an empty bag, an unbuyable product, an undeliverable address), so write that copy once in the store's voice — a small map per surface, as the sections below show
|
|
73
|
+
The store's words work the same way: the states these hooks hand you recur across pages (an empty bag, an unbuyable product, an undeliverable address), so write that copy once in the store's voice — a small map per surface, as the sections below show. It is the half of a store's identity a kit cannot ship.
|
|
64
74
|
|
|
65
|
-
**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 hero, the one product-page module that shows what these products are judged on — and render everything else as conventions in the classes. The
|
|
75
|
+
**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 hero, the one product-page module that shows what these products are judged on — and render everything else as conventions in the classes. **The product page stays the storefront's richest surface**, and that richness is semantic: what the controls and rows *show*, which costs words rather than chrome. One navigation affordance per control (thumbnails *or* arrows, never both plus dots); checkout, bag and order-received are convention surfaces. Keep components small (~2–4K chars) — faster to emit, review and fix than one long page file.
|
|
66
76
|
|
|
67
77
|
## What each hook resolves to
|
|
68
78
|
|
|
@@ -80,6 +90,7 @@ Everything below is already unwrapped — no `.data`, no envelope. `formatMoney`
|
|
|
80
90
|
| `variantAxes(view, pick)` | `[{ key, name, selectedOption, options: [{ value, selected, disabled, outOfStock, pick }] }]`. |
|
|
81
91
|
| `productPrice(rowOrView, { formatMoney })` | `{ label, compareAtLabel, onSale, isFrom, isRange, min, max }` — `label` is what to render. |
|
|
82
92
|
| `productImages(product)` | `[{ src, name, alt }]`, de-duplicated. `[]` is legitimate → render your placeholder. |
|
|
93
|
+
| `productRibbons(product)` | `[{ id, name }]` — **objects**, and the field can be absent; takes a listing row or `useProduct().product`. |
|
|
83
94
|
| `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. |
|
|
84
95
|
| `useCart()` | `{ status, cart, itemCount, isEmpty, loading, error, mutationError, refresh, addItem, updateItem, removeItem, applyCoupon, removeCoupon }` — `status`: `"loading" \| "ready" \| "empty"`. |
|
|
85
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}`; `purchasable` is a **result object** `{ok, code, error}`, not a boolean. |
|
|
@@ -87,29 +98,29 @@ Everything below is already unwrapped — no `.data`, no envelope. `formatMoney`
|
|
|
87
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. |
|
|
88
99
|
| `useCartLine(item)` | `{ quantity, setQuantity, increase, decrease, remove, pending, error, canIncrease, canDecrease, maxQuantity, atMax, atMin }`. |
|
|
89
100
|
| `useCartUI()` | `{ open, openCart, closeCart, toggleCart }`. |
|
|
90
|
-
| `useCheckoutContext()` | the address (`billing`, `updateBilling`, `shipping`, `updateShipping`, `shipToDifferent`, `setShipToDifferent`, `missingBillingFields`, `addressError`), the shipping
|
|
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. |
|
|
91
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`. |
|
|
92
103
|
|
|
93
104
|
## Product list / collection
|
|
94
105
|
|
|
95
106
|
```jsx
|
|
96
|
-
import { useProductList, useCategories, useStoreInfo, useFormatMoney, productPrice, productImages } from "@/commerce/storefront";
|
|
107
|
+
import { useProductList, useCategories, useStoreInfo, useFormatMoney, productPrice, productImages, productRibbons } from "@/commerce/storefront";
|
|
97
108
|
```
|
|
98
109
|
|
|
99
110
|
(Drop `useCategories` with no filter bar; add `useRibbons` for a ribbon filter.) `const list = useProductList({ per_page: 24 })`, then guards on `list.status` before any markup — ⚑ branch on `status`, so a failed request renders as a failure instead of an empty grid, with a retry calling `list.reload`.
|
|
100
111
|
|
|
101
112
|
⚑ **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.
|
|
102
113
|
|
|
103
|
-
A card can render `name`, `productImages(row)[0]`, `productPrice(row, { formatMoney }).label` (already "From €19.99" when the product sells variants — there is no product `type` flag, and `product.price`
|
|
114
|
+
A card can render `name`, `productImages(row)[0]`, `productPrice(row, { formatMoney }).label` (already "From €19.99" when the product sells variants — there is no product `type` flag, and `product.price` alone 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 list is an inventory, not a card design: lead with the one or two fields *these* products are judged on rather than the default name/price/stars trio.
|
|
104
115
|
|
|
105
|
-
⚑ **Ribbons belong in both views** — grid and product page. They are the merchant's own merchandising ("Limited", "Last pieces"),
|
|
116
|
+
⚑ **Ribbons belong in both views** — grid and product page. They are the merchant's own merchandising ("Limited", "Last pieces"), and each links to its filtered listing (`/collection?ribbon_id=<id>`). `productRibbons(row)` hands you `{id, name}` **objects** — render `r.name`, key the link on `r.id`; the entry itself in JSX is React's "Objects are not valid as a React child". Never render a bare "Ribbons:" label with nothing after it.
|
|
106
117
|
|
|
107
118
|
**Rails** (featured row, "new in") are the same hook with a filter (`{ featured: true, per_page: 4 }`) — `featured` is the merchant's own flag, so the rail stays curated store data instead of hardcoded slugs. ⚑ Any filter may legitimately match nothing — render *nothing* then, never a heading over an empty row.
|
|
108
119
|
|
|
109
120
|
## Product page
|
|
110
121
|
|
|
111
122
|
```jsx
|
|
112
|
-
import { useProduct, useAddToCart, useStoreInfo, useFormatMoney, useStorefront, variantAxes, productImages, imageIndex, productSpecs, findSpec, storefrontErrorCode } from "@/commerce/storefront";
|
|
123
|
+
import { useProduct, useAddToCart, useStoreInfo, useFormatMoney, useStorefront, variantAxes, productImages, imageIndex, productRibbons, productSpecs, findSpec, storefrontErrorCode } from "@/commerce/storefront";
|
|
113
124
|
```
|
|
114
125
|
|
|
115
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 precisely so they can sit at the top; a hook after an early `return` crashes React with "Rendered more hooks than during the previous render" the moment the product resolves.
|
|
@@ -136,7 +147,7 @@ Build your layout from — all optional, **not one component style**:
|
|
|
136
147
|
useEffect(() => setPicked(null), [view?.variation?.id]); // a new variant re-takes the lead
|
|
137
148
|
```
|
|
138
149
|
|
|
139
|
-
⚑ **
|
|
150
|
+
⚑ **The main frame comes from `view.display.image`, never from an index into the strip.** A variation's image lives on the variation and is usually *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` locates it when it is there and returns `-1` when it isn't. `active === null` only when the product has no images at all.
|
|
140
151
|
- **Variant selector** — `variantAxes(view, p.pick)`, one entry per axis:
|
|
141
152
|
|
|
142
153
|
```jsx
|
|
@@ -162,8 +173,8 @@ Build your layout from — all optional, **not one component style**:
|
|
|
162
173
|
|
|
163
174
|
⚑ **Text for every state, and the gate from the hook.** `buy.state` resolves the precedence — never re-derive `disabled` from your own ternary chain, and never leave a state unworded (the button renders empty). ⚑ `buy.showQuantity: false` means no stepper. With `<CartUIProvider>` mounted, a successful add opens the drawer by itself.
|
|
164
175
|
- **Description** — `product.description` is HTML; render as rich text, `short_description` above it.
|
|
165
|
-
- **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
|
|
166
|
-
- **Breadcrumbs** — from `categories` (`/collection?category_id=${c.id}`); skip on a flat catalog. Ribbons are labels, not breadcrumbs.
|
|
176
|
+
- **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
|
+
- **Breadcrumbs** — from `categories` (`/collection?category_id=${c.id}`); skip on a flat catalog. Ribbons (`productRibbons(product)`) are labels, not breadcrumbs.
|
|
167
178
|
- **Reviews, only if the store wants them** — no review UI is a complete outcome (then no star ratings on cards either: an average of nothing is `0`). `p.reviews` arrives with the product as `{ items, page, per_page, has_next }`; 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 every store that auto-approves — and refresh the list after, or the review doesn't appear. Field codes, policies and moderation: [`../references/reviews.md`](../references/reviews.md).
|
|
168
179
|
- **Title** — give each page type its own `<title>` and description; a store whose every page shares one static title is invisible to search. Nothing here emits structured data either — if the store wants rich results, emit your own `Product`/`Offer` JSON-LD from `product` and `view.display` (price, currency, availability).
|
|
169
180
|
|
|
@@ -175,7 +186,7 @@ import { useCart, useCartLine, CartLine, useCartUI, useFormatMoney, attributesLa
|
|
|
175
186
|
|
|
176
187
|
A cart *page* is optional — decide from what the store sells (buy-now straight to checkout reads better for a single-piece store; a grocery basket needs a page).
|
|
177
188
|
|
|
178
|
-
⚑ 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)
|
|
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.
|
|
179
190
|
|
|
180
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.
|
|
181
192
|
|
|
@@ -207,13 +218,13 @@ const formatMoney = useFormatMoney();
|
|
|
207
218
|
))}
|
|
208
219
|
```
|
|
209
220
|
|
|
210
|
-
No shipping estimator here — checkout reprices shipping and tax from the address. An upsell beside the lines needs no query: `p.upsells` / `p.crossSells` from `useProduct` are rows you can render and add with `addItem`, matching "already in the bag" by product id, never by display name
|
|
221
|
+
No shipping estimator here — checkout reprices shipping and tax from the address. An upsell beside the lines needs no query: `p.upsells` / `p.crossSells` from `useProduct` are rows you can render and add with `addItem`, matching "already in the bag" by product id, never by display name. ⚑ **A one-click Add only works on a product with no attributes**: one that sells variants answers `400 variation_required`, so link those tiles to the product page instead.
|
|
211
222
|
|
|
212
223
|
### If the cart is a drawer
|
|
213
224
|
|
|
214
225
|
Same hooks, same rows. The drawer's *state* — open/close, Esc, close-on-route-change, open-on-add — is `useCartUI()`; the markup is yours: a trigger in the header (`onClick={ui.toggleCart}`, `aria-expanded={ui.open}`), then `{ui.open && …}` rendering a click-away overlay plus your panel (`role="dialog" aria-modal="true"`, a named close button inside it).
|
|
215
226
|
|
|
216
|
-
⚑ **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,
|
|
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.
|
|
217
228
|
|
|
218
229
|
## Checkout
|
|
219
230
|
|
|
@@ -307,7 +318,7 @@ function AddressFields({ which }) {
|
|
|
307
318
|
}
|
|
308
319
|
```
|
|
309
320
|
|
|
310
|
-
Passing `country` is what makes the
|
|
321
|
+
⚑ **Passing `country` is what makes the state/province field appear**, with the right options for the US, Canada and Australia — and shipping rates and taxes match on country *plus* state, so a form without that field mis-prices those orders with no error anywhere. ⚑ Keep each field's `autoComplete` token; it is what makes browser autofill work. Labels are plain conventions — rename or restyle freely. `c.missingBillingFields` is the live list of what is still missing, if you want per-field marks; arm them on first edit, not on load.
|
|
311
322
|
|
|
312
323
|
⚑ **The `stage === "submitted"` guard goes above the empty-cart branch** — placing an order clears the cart before the browser navigates, and without the guard the page flashes an empty bag over a just-placed order.
|
|
313
324
|
|
|
@@ -341,6 +352,6 @@ The cart is optimistic and debounced, so a script that acts faster than it settl
|
|
|
341
352
|
- [ ] Address form includes the state/province field and every `autoComplete` token.
|
|
342
353
|
- [ ] Cart rows scope busy state to the row; repeated controls have unique accessible names; a drawer uses `useCartUI` and is rendered conditionally on `ui.open`.
|
|
343
354
|
- [ ] Checkout guards `stage === "submitted"` above its empty-cart branch.
|
|
344
|
-
- [ ] The storefront carries the design you settled on before reading this file — design classes plus one or two signature moments per page;
|
|
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.
|
|
345
356
|
|
|
346
357
|
Then copy this file's `carry_forward` lines (in its front matter) into your working notes, and do not re-read this file.
|
|
@@ -22,7 +22,7 @@ A fresh install has **no settings and no catalog**. One admin-only, idempotent c
|
|
|
22
22
|
|
|
23
23
|
**`store_name` is required on a first seed** — the app's name as the platform shows it (`base44/config.jsonc` → `name` can be stale; ask if unsure). **`currency`** is an ISO code (`"EUR"`); formatting follows the viewer's locale, nothing else to set. Explicit values always win, first seed and re-runs alike.
|
|
24
24
|
|
|
25
|
-
The working call — `name` is the only required product key; give each product the keys its own catalog entry actually has and leave the rest out. The **full key list** (sale windows, downloads, tax, backorders, dimensions…)
|
|
25
|
+
The working call — `name` is the only required product key; give each product the keys its own catalog entry actually has and leave the rest out. The **full key list** (sale windows, downloads, tax, backorders, dimensions…) lives in `api-admin.md` — open it only if the catalog needs one:
|
|
26
26
|
|
|
27
27
|
```js
|
|
28
28
|
try {
|
|
@@ -45,11 +45,10 @@ try {
|
|
|
45
45
|
categories: ["Shoes"], // get-or-created by display name
|
|
46
46
|
ribbons: ["Best Seller"], // flat labels, not a hierarchy
|
|
47
47
|
|
|
48
|
-
// Descriptive
|
|
49
|
-
// types each one). NOT variant axes, NOT ribbons. Strings; `_` hides.
|
|
48
|
+
// Descriptive spec rows (productSpecs). NOT axes, NOT ribbons; `_` hides.
|
|
50
49
|
meta_data: [
|
|
51
50
|
{ key: "Material", value: "Recycled knit upper" },
|
|
52
|
-
{ key: "Weight",
|
|
51
|
+
{ key: "Weight", value: "248 g" },
|
|
53
52
|
],
|
|
54
53
|
|
|
55
54
|
attributes: [ // the axes → one selector each
|
|
@@ -76,7 +75,7 @@ try {
|
|
|
76
75
|
|
|
77
76
|
**Running this through a code-execution tool? Return `res.data`, never the raw response** — the raw response carries circular objects and fails `Converting circular structure to JSON` *even when the seed succeeded*; a thrown error needs `e.response?.data` for the same reason.
|
|
78
77
|
|
|
79
|
-
Reference taxonomy by **display name** — existing records are matched case-insensitively and reused. The seeder derives slugs, checks SKU uniqueness, prices variations, and **rolls the parent's price up from the cheapest publishable variant** — never set a variant parent's price yourself. Unknown keys are rejected, so typos surface. **Idempotency:** a product whose `sku` (or derived slug) exists is skipped and reported — safe to retry.
|
|
78
|
+
Reference taxonomy by **display name** — existing records are matched case-insensitively and reused. The seeder derives slugs, checks SKU uniqueness, prices variations, and **rolls the parent's price up from the cheapest publishable variant** — never set a variant parent's price yourself. Unknown keys are rejected, so typos surface. **Idempotency:** a product whose `sku` (or derived slug) exists is skipped and reported — safe to retry. Bad payloads fail **400** `invalid_payload` with `errors: [{ path, error }]` before anything is written; per-call limits (≤100 products, ≤500 variations, ≤50 locations) and the full key list are in [`../docs/api-admin.md`](../docs/api-admin.md#commerceseed-store).
|
|
80
79
|
|
|
81
80
|
The response reports everything; these matter downstream:
|
|
82
81
|
|
|
@@ -94,16 +93,19 @@ Each location is a scope plus its rates and taxes; locations match in payload or
|
|
|
94
93
|
|
|
95
94
|
```js
|
|
96
95
|
locations: [
|
|
97
|
-
{ name: "Europe", continents: ["EU"], shipping_rates: [{ name: "Standard",
|
|
96
|
+
{ name: "Europe", continents: ["EU"], shipping_rates: [{ name: "Standard", cost: 20 },
|
|
97
|
+
{ name: "Express", cost: 35 }] },
|
|
98
98
|
{ name: "Worldwide", rest_of_world: true, shipping_rates: [{ name: "International", cost: 100 }] },
|
|
99
99
|
]
|
|
100
100
|
```
|
|
101
101
|
|
|
102
|
+
⚑ **Speeds go in one location's `shipping_rates`; zones are separate locations.** Exactly one location matches an address, so several rates in it become the customer's pick at checkout (`shipping_status: "choice_required"`), while a second location with the same scope is simply dead — never matched, its rates never offered.
|
|
103
|
+
|
|
102
104
|
- **`continents: ["EU"]`** spares you a 51-country list — and `EU` is the *continent* Europe, not the European Union.
|
|
103
|
-
- **`rest_of_world: true`** is the catch-all
|
|
104
|
-
-
|
|
105
|
+
- **`rest_of_world: true`** is the catch-all — there is **no country code meaning "everywhere"**, and improvised ones (`["*"]`, `["ALL"]`, alpha-3 `["USA"]`) fail **400**. The narrower scope is `countries: ["IL", "DE"]`; matching is country + state only.
|
|
106
|
+
- The matched location supplies the taxes too (`tax_groups`, `shipping_tax`) and `free_over` zeroes a rate above a subtotal — reference below.
|
|
105
107
|
|
|
106
|
-
**The catch-all trap.** Passing any `locations` suppresses the seeded worldwide fallback, so scoped locations with nothing behind them
|
|
108
|
+
**The catch-all trap.** Passing any `locations` suppresses the seeded worldwide fallback, so scoped locations with nothing behind them answer `shipping_not_available` to every other address. Plausibly intended, so it warns rather than fails: **read `warnings` on every seed** and either add a `rest_of_world` location or state the restriction to the user. If the brief named tiered rates, price a cart against one address per zone to check them (`set-shipping-address` → `available_shipping_methods`; `[]` means no location matched).
|
|
107
109
|
|
|
108
110
|
Continent codes in full, state regions, tax binding, VAT-on-shipping, day-2 edits: [`../references/shipping-and-tax.md`](../references/shipping-and-tax.md).
|
|
109
111
|
|
|
@@ -115,7 +117,7 @@ Continent codes in full, state regions, tax binding, VAT-on-shipping, day-2 edit
|
|
|
115
117
|
|
|
116
118
|
Online card payments are **off by default**: the seeded store takes the manual `offline` method and is complete and payable — the order goes on-hold and `/order-received` renders the gateway's payment instructions, no code, no credentials. **Enable `card` only if a provider is wired in the same stretch of work**: an enabled card option with nothing behind it answers **`503 no_card_payment_provider`** the moment a customer picks it.
|
|
117
119
|
|
|
118
|
-
`payment_methods` is the on/off switch — the listed slugs are enabled, every other row disabled
|
|
120
|
+
`payment_methods` is the on/off switch — the listed slugs are enabled, every other row disabled. ⚑ **Never write `enabled` on the `commerce.PaymentGateway` row instead.** **Seeding need not happen all at once:** every key is independent, so re-call `commerce/seed-store` whenever one slice of configuration changes, carrying only that slice — `{ payment_methods: ["offline", "card"] }` is a complete standalone call, as valid on a live store weeks later as during the install (`store_name` is required only on a store's first seed).
|
|
119
121
|
|
|
120
122
|
| The request says | Decision |
|
|
121
123
|
|---|---|
|
|
@@ -7,6 +7,7 @@ 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`."
|
|
8
8
|
- "A variant parent's `price` is a FROM price — render it through productPrice(row, { formatMoney }), never as the price."
|
|
9
9
|
- "Product images are objects `{src, name, alt}` and the array can be empty — go through productImages/normalizeImage and render a placeholder."
|
|
10
|
+
- "Ribbons are objects `{id, name}` and the field can be absent — go through productRibbons(product), which works on a listing row and on useProduct's product alike."
|
|
10
11
|
---
|
|
11
12
|
|
|
12
13
|
# Rendering the catalog: listing and product page
|
|
@@ -32,13 +33,43 @@ A listing **row** is the product record (minus paywalled fields) plus resolved `
|
|
|
32
33
|
| `sku`, `stock_status`, `stock_quantity`, `manage_stock`, `backorders` | ✅ | ✅ | |
|
|
33
34
|
| `average_rating`, `rating_count`, `total_sales` | ✅ | ✅ | Enough for stars on a card; the reviews themselves aren't |
|
|
34
35
|
| `downloadable`, `virtual`, `weight`, `dimensions`, `attributes[]`, `meta_data` | ✅ | ✅ | `attributes[]` is the variant axes; `meta_data` the descriptive **modifiers** (`productSpecs(product)` → spec rows) |
|
|
35
|
-
| **`ribbons`** (resolved), `ribbon_ids`, `category_ids` | ✅ | ✅ |
|
|
36
|
+
| **`ribbons`** (resolved), `ribbon_ids`, `category_ids` | ✅ | ✅ | `{id, name}` objects on a row; `get-product` returns the records beside the product, which `useProduct` normalizes and attaches |
|
|
36
37
|
| **`categories`** (resolved) | ❌ *ids only* | ✅ | §6 to add them to rows |
|
|
37
38
|
| **`variations[]`** (per-variant price/stock/image/attributes) | ❌ | ✅ | Why a product with variants can't be fully priced from a row |
|
|
38
39
|
| **`reviews`** (paged items + aggregates) | ❌ | ✅ | Rows still carry the aggregate numbers |
|
|
39
40
|
| **`upsells`, `cross_sells`** (summaries) | ❌ | ✅ | `{id, name, slug, price, on_sale, image}` |
|
|
40
41
|
| `downloads[]`, `download_limit`, `download_expiry` | ❌ | ❌ | **Never** public — only via `commerce/storefront-account` `get-download` |
|
|
41
42
|
|
|
43
|
+
### What each field actually holds
|
|
44
|
+
|
|
45
|
+
Most of the interesting ones are **arrays of objects** where a card tends to
|
|
46
|
+
assume strings — `{p.ribbons[0]}` in JSX is React's *"Objects are not valid as a
|
|
47
|
+
React child"*, `<img src={p.images[0]}>` a broken image on every product at
|
|
48
|
+
once. The whole list, so none of it has to be read out of the backend:
|
|
49
|
+
|
|
50
|
+
| Field | Shape | Render it through |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| `images[]` | `{src, name, alt}` — **objects**; `[]` is legitimate | `productImages(product)` |
|
|
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` |
|
|
55
|
+
| `attributes[]` | `{attribute_id, name, position, options: string[]}` — one entry per **axis** | `variantAxes(view, pick)` |
|
|
56
|
+
| `default_attributes[]` | `{attribute_id, name, option}` — the merchant's pre-selection | `defaultSelection` (automatic in `useProduct`) |
|
|
57
|
+
| `dimensions` | `{length, width, height}` in the store's unit | — |
|
|
58
|
+
| `category_ids[]`, `ribbon_ids[]` | id **strings** | `category_id` / `ribbon_id` filters |
|
|
59
|
+
| `categories[]` *(get-product)* | full records — `{id, name, slug, parent_id, image, menu_order, count}` | breadcrumb links on `c.id` |
|
|
60
|
+
| `variations[]` *(get-product)* | `{id, product_id, attributes: [{attribute_id, name, option}], price, sale_price, on_sale, sku, image, stock_status, stock_quantity, …}` | `view`, never the list itself (§5) |
|
|
61
|
+
| `reviews` *(get-product)* | `{items: [{id, reviewer, review, rating, verified, created_date}], page, per_page, has_next, average_rating, rating_count}` | — |
|
|
62
|
+
| `upsells[]`, `cross_sells[]` *(get-product)* | `{id, name, slug, price, on_sale, image, stock_status}` — here `image` **is** a plain URL string | — |
|
|
63
|
+
|
|
64
|
+
The React layer carries all of this as types the hooks name on their `@returns`
|
|
65
|
+
— `StorefrontProduct` and the rest in `src/commerce/utils/types.js`, each hook's
|
|
66
|
+
result in `src/commerce/storefront/types.js` — so an editor answers "what is in
|
|
67
|
+
this field?" on hover. And `useProduct` attaches its resolved `ribbons` to
|
|
68
|
+
`product` in the listing row's `{id, name}` shape, which is why one card
|
|
69
|
+
component can render a row *or* the product page's product. (Ribbon `count` is
|
|
70
|
+
not in that shape: filter counts come from `useRibbons()` / `list-ribbons`,
|
|
71
|
+
which counts only the products the catalog would list.)
|
|
72
|
+
|
|
42
73
|
## 2. Two shapes, and three rules the exports own
|
|
43
74
|
|
|
44
75
|
There is **no product `type` field**. `attributes[]` tells the shapes apart:
|
|
@@ -70,7 +101,7 @@ Both lists are inventories of what the data supports — **not a layout and not
|
|
|
70
101
|
|
|
71
102
|
Ribbons are flat, cross-cutting labels ("Best Seller", "New", "Gift"); categories are the hierarchical spine. Generated storefronts routinely omit ribbons entirely. Don't.
|
|
72
103
|
|
|
73
|
-
- **
|
|
104
|
+
- **Both views carry resolved ribbons**, so a card needs no extra call: `productRibbons(product)` reads a listing row and `useProduct`'s product alike (the page can also take `p.ribbons`), and always answers `[{id, name}]` — including when the row has no `ribbons` field at all. Render `r.name`, never the entry itself; the product page's row sits near the metadata, lighter than the breadcrumb. One or two per card is useful, more is noise, and `[]` means render nothing — never a dangling "Ribbons:" label.
|
|
74
105
|
- **Every ribbon links to a filtered listing** — `list-products` with `ribbon_id`, never a dead label. A ribbon has no slug, so key the URL on its id (`/shop?ribbon=<id>`) to stay shareable across reloads.
|
|
75
106
|
- **Offer ribbons as a filter** from `useRibbons()` / `list-ribbons`, which hides ribbons no published product carries and gives a `count` for labels like "Gift (12)". `ribbon_id` stacks with `category_id`, price, `on_sale`, `featured`, `in_stock_only`.
|
|
76
107
|
- Ribbons are not breadcrumbs and never variant options — a size or colour is an `attribute`.
|
|
@@ -17,6 +17,16 @@ What it does **not** ship is a live provider. Wiring one means **one file** —
|
|
|
17
17
|
|
|
18
18
|
Card payments are **off by default** and are the last thing to add, never the first — the rule, the timing and the decision table live in [`../install/03-data.md`](../install/03-data.md). Everything below assumes that decision is made.
|
|
19
19
|
|
|
20
|
+
## Enabling the gateway — one seed call, at any time
|
|
21
|
+
|
|
22
|
+
⚑ **Turning `card` on is a `commerce/seed-store` call, never an edit to `commerce.PaymentGateway`.** The gateway row is derived data: the seeder enables every slug you list and disables every one you don't, so writing `enabled` on the record by hand is undone by the next seed and skips the checks the seeder makes.
|
|
23
|
+
|
|
24
|
+
```js
|
|
25
|
+
await base44.functions.invoke("commerce/seed-store", { payment_methods: ["offline", "card"] });
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
That is the **whole payload**. `seed-store` is idempotent and every key is independent, so this is as valid on a live store months later as it is during the install: no `products` key means no catalog work, no `locations` key leaves shipping exactly as it is, and `store_name` is only required on a store's very first seed. Drop `"offline"` from the array for a card-only store — the list is the complete set of enabled methods, not an addition to it.
|
|
29
|
+
|
|
20
30
|
| Function | Backs |
|
|
21
31
|
|---|---|
|
|
22
32
|
| `createCardPayment(sr, order, {successUrl, cancelUrl, customerEmail})` | checkout + payment links: a hosted page for `order.total`, returning `{ url, reference }`, with `order.id`/`order.order_key` on the payment's metadata |
|
|
@@ -41,6 +41,8 @@
|
|
|
41
41
|
* `productPrice` (the from-price and incomplete-selection range rules),
|
|
42
42
|
* `productImages` + `imageIndex` (an image's position in that list — how a
|
|
43
43
|
* gallery follows the variant selection without owning a second copy of it),
|
|
44
|
+
* `productRibbons` (ribbons are `{id, name}` objects, not strings — rendering
|
|
45
|
+
* one straight into JSX is React's "Objects are not valid as a React child"),
|
|
44
46
|
* `productSpecs` + `findSpec` (meta keys are free text, so featuring a
|
|
45
47
|
* particular spec needs a tolerant lookup, not an equality test),
|
|
46
48
|
* `attributesLabel`, `cartTotalsLines` / `orderTotalsLines`,
|
|
@@ -49,6 +51,13 @@
|
|
|
49
51
|
* `storefrontErrorCode` / `storefrontErrorMessage` for the calls you make
|
|
50
52
|
* yourself — every rejection from the client carries a code worth branching on.
|
|
51
53
|
*
|
|
54
|
+
* **What a field holds** — every catalog shape a page renders from is written
|
|
55
|
+
* down in `./types.js` (`StorefrontProduct`, `ProductRibbon`, `ProductImage`,
|
|
56
|
+
* `ProductCategory`, `ProductSummary`, and each hook's result), and the hooks
|
|
57
|
+
* carry those types on their `@returns`. Read that instead of a backend
|
|
58
|
+
* function's source; the render rules that go with the shapes stay in the doc
|
|
59
|
+
* comments here and in the skill's references/catalog-rendering.md.
|
|
60
|
+
*
|
|
52
61
|
* The catalog and product surfaces are where the design freedom lives: these
|
|
53
62
|
* hooks hand you resolved data, and the rendering is entirely yours.
|
|
54
63
|
*/
|
|
@@ -85,6 +94,7 @@ export {
|
|
|
85
94
|
productPrice,
|
|
86
95
|
productImages,
|
|
87
96
|
imageIndex,
|
|
97
|
+
productRibbons,
|
|
88
98
|
productSpecs,
|
|
89
99
|
findSpec,
|
|
90
100
|
attributesLabel,
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the catalog hooks resolve to — the return shapes of `useProductList`,
|
|
3
|
+
* `useProduct`, `useCategories` and `useRibbons`, so a page never has to open a
|
|
4
|
+
* hook (or a backend function) to learn what a field holds.
|
|
5
|
+
*
|
|
6
|
+
* **Types only** — JSDoc `@typedef`s, no runtime code. The product shapes
|
|
7
|
+
* themselves live one layer down in `@/commerce/utils` (framework-free) and are
|
|
8
|
+
* re-declared here so this file is the only one a React page needs:
|
|
9
|
+
* `StorefrontProduct`, `ProductRibbon`, `ProductImage`, `ProductCategory`,
|
|
10
|
+
* `StorefrontVariation`, `ProductSummary`, `ProductReviews`.
|
|
11
|
+
*
|
|
12
|
+
* The rendering rules that go with these shapes are in each hook's doc comment
|
|
13
|
+
* and in the commerce skill's references/catalog-rendering.md — a type says a
|
|
14
|
+
* ribbon is `{id, name}`, not that an unbuyable variant option renders disabled
|
|
15
|
+
* rather than hidden.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** @typedef {import("../utils/types.js").StorefrontProduct} StorefrontProduct */
|
|
19
|
+
/** @typedef {import("../utils/types.js").StorefrontVariation} StorefrontVariation */
|
|
20
|
+
/** @typedef {import("../utils/types.js").ProductImage} ProductImage */
|
|
21
|
+
/** @typedef {import("../utils/types.js").ProductRibbon} ProductRibbon */
|
|
22
|
+
/** @typedef {import("../utils/types.js").ProductCategory} ProductCategory */
|
|
23
|
+
/** @typedef {import("../utils/types.js").ProductSummary} ProductSummary */
|
|
24
|
+
/** @typedef {import("../utils/types.js").ProductReview} ProductReview */
|
|
25
|
+
/** @typedef {import("../utils/types.js").ProductReviews} ProductReviews */
|
|
26
|
+
/** @typedef {import("../utils/types.js").ProductMeta} ProductMeta */
|
|
27
|
+
/** @typedef {import("../utils/types.js").ProductAttributeAxis} ProductAttributeAxis */
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A failed storefront call, as the hooks expose it. `code` is the backend's
|
|
31
|
+
* stable code (`not_found`, `out_of_stock`, …) — branch on it, render `message`.
|
|
32
|
+
*
|
|
33
|
+
* @typedef {object} StorefrontError
|
|
34
|
+
* @property {string|null} code
|
|
35
|
+
* @property {string} message
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `useProductList(params, options)` — a catalog listing with its state solved.
|
|
40
|
+
*
|
|
41
|
+
* @typedef {object} UseProductListResult
|
|
42
|
+
* @property {StorefrontProduct[]} products — the current page (accumulated, in `mode: "append"`)
|
|
43
|
+
* @property {"loading"|"ready"|"empty"|"error"} status — what the page renders from
|
|
44
|
+
* @property {number} page
|
|
45
|
+
* @property {number} perPage
|
|
46
|
+
* @property {boolean} hasNext — true ⇒ render a paging control, or the catalog is capped at one page
|
|
47
|
+
* @property {number} totalLoaded
|
|
48
|
+
* @property {boolean} loading
|
|
49
|
+
* @property {boolean} refreshing
|
|
50
|
+
* @property {boolean} busy — `loading || refreshing`; what a paging button disables on
|
|
51
|
+
* @property {StorefrontError|null} error
|
|
52
|
+
* @property {boolean} isEmpty — never true while loading
|
|
53
|
+
* @property {object} params — the live `list-products` params
|
|
54
|
+
* @property {(patch: object) => void} setParams — any change but `page` returns to page 1
|
|
55
|
+
* @property {() => void} resetParams
|
|
56
|
+
* @property {() => void} next
|
|
57
|
+
* @property {() => void} prev
|
|
58
|
+
* @property {(page: number) => void} goToPage
|
|
59
|
+
* @property {() => void} loadMore — the paging call in `mode: "append"`
|
|
60
|
+
* @property {() => void} reload
|
|
61
|
+
* @property {() => void} reloadQuiet
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `useProduct(ref, options)` — the product page's data and selection lifecycle.
|
|
66
|
+
* `view` is `resolveSelection`'s render model (`axes`, `display`, `purchasable`,
|
|
67
|
+
* `addToCart`, `priceRange`, `complete`, `missingAxes` — bind controls to it,
|
|
68
|
+
* never to `product.*`), and `price` is `productPrice`'s from-price/range rules.
|
|
69
|
+
*
|
|
70
|
+
* @typedef {object} UseProductResult
|
|
71
|
+
* @property {"loading"|"ready"|"not_found"|"error"} status — `"not_found"` is a 404 page, not a spinner
|
|
72
|
+
* @property {boolean} loading
|
|
73
|
+
* @property {StorefrontError|null} error — null when the product simply isn't there
|
|
74
|
+
* @property {boolean} notFound
|
|
75
|
+
* @property {() => void} reload
|
|
76
|
+
* @property {StorefrontProduct|null} product — carries the resolved `ribbons`, like a listing row
|
|
77
|
+
* @property {StorefrontVariation[]} variations — publishable only; `[]` without attributes
|
|
78
|
+
* @property {ProductCategory[]} categories — resolved records, for a breadcrumb
|
|
79
|
+
* @property {ProductRibbon[]} ribbons — `{id, name}`, same array as `product.ribbons`
|
|
80
|
+
* @property {ProductSummary[]} upsells
|
|
81
|
+
* @property {ProductSummary[]} crossSells
|
|
82
|
+
* @property {ProductReviews|null} reviews
|
|
83
|
+
* @property {object|null} view — the resolved selection view
|
|
84
|
+
* @property {object} selection — `{ [axisKey]: option }`
|
|
85
|
+
* @property {(axisKey: string, option: string) => void} pick
|
|
86
|
+
* @property {(selection: object) => void} setSelection
|
|
87
|
+
* @property {() => void} resetSelection
|
|
88
|
+
* @property {number} quantity
|
|
89
|
+
* @property {(n: number) => void} setQuantity
|
|
90
|
+
* @property {() => void} incQuantity
|
|
91
|
+
* @property {() => void} decQuantity
|
|
92
|
+
* @property {number} maxQuantity — respects `sold_individually` and tracked stock
|
|
93
|
+
* @property {boolean} canIncrease
|
|
94
|
+
* @property {{label: string, compareAtLabel: string|null, onSale: boolean,
|
|
95
|
+
* isFrom: boolean, isRange: boolean, min: number|null, max: number|null}} price
|
|
96
|
+
*/
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* `useCategories()` — the category tree, roots with `children` nested.
|
|
100
|
+
*
|
|
101
|
+
* @typedef {object} UseCategoriesResult
|
|
102
|
+
* @property {ProductCategory[]} items — always an array
|
|
103
|
+
* @property {boolean} loading
|
|
104
|
+
* @property {StorefrontError|null} error
|
|
105
|
+
* @property {() => void} reload
|
|
106
|
+
*/
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* `useRibbons()` — the ribbon list for a filter or a "shop by ribbon" nav.
|
|
110
|
+
* `count` is tallied from the products the catalog would actually list, so
|
|
111
|
+
* "Gift (12)" and the ribbon's listing agree.
|
|
112
|
+
*
|
|
113
|
+
* @typedef {object} UseRibbonsResult
|
|
114
|
+
* @property {Array<ProductRibbon & {count: number}>} items — always an array
|
|
115
|
+
* @property {boolean} loading
|
|
116
|
+
* @property {StorefrontError|null} error
|
|
117
|
+
* @property {() => void} reload
|
|
118
|
+
*/
|
|
119
|
+
|
|
120
|
+
export {};
|
|
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|
|
2
2
|
import {
|
|
3
3
|
defaultSelection,
|
|
4
4
|
productPrice,
|
|
5
|
+
productRibbons,
|
|
5
6
|
resolveSelection,
|
|
6
7
|
selectOption,
|
|
7
8
|
selectionFromParams,
|
|
@@ -32,9 +33,15 @@ import { useAsyncData } from "./internal/useAsyncData";
|
|
|
32
33
|
* flight can never paint over a newer one; and the selection is mirrored to the
|
|
33
34
|
* URL (`?color=Ivory`) so a chosen variant is linkable and survives a reload.
|
|
34
35
|
*
|
|
36
|
+
* `product` is a `StorefrontProduct` (`src/commerce/utils/types.js`) carrying
|
|
37
|
+
* the resolved `ribbons` exactly as a listing row does, so one card component
|
|
38
|
+
* can render from either — `productImages(product)` and
|
|
39
|
+
* `productRibbons(product)` both hand back objects, never strings.
|
|
40
|
+
*
|
|
35
41
|
* @param {string|{id: string}} ref slug or `{ id }`
|
|
36
42
|
* @param {{syncSelectionToUrl?: boolean, reviewsPerPage?: number,
|
|
37
43
|
* initialSelection?: object}} [options]
|
|
44
|
+
* @returns {import("./types.js").UseProductResult}
|
|
38
45
|
*/
|
|
39
46
|
export function useProduct(ref, options = {}) {
|
|
40
47
|
const { syncSelectionToUrl = true, reviewsPerPage, initialSelection } = options;
|
|
@@ -47,7 +54,16 @@ export function useProduct(ref, options = {}) {
|
|
|
47
54
|
{ keepPreviousData: false },
|
|
48
55
|
);
|
|
49
56
|
|
|
50
|
-
|
|
57
|
+
// `get-product` returns the ribbons *beside* the product; a listing row
|
|
58
|
+
// carries them ON the row. Attach them here so both surfaces read one field
|
|
59
|
+
// in one shape — `productRibbons(product)` works on a card and on this page —
|
|
60
|
+
// and normalize to the row's `{id, name}`: the full record's `count` counts
|
|
61
|
+
// drafts and drifts, so filter counts come from `useRibbons()` instead.
|
|
62
|
+
const ribbons = useMemo(() => productRibbons(data?.ribbons ?? []), [data]);
|
|
63
|
+
const product = useMemo(
|
|
64
|
+
() => (data?.product ? { ...data.product, ribbons } : null),
|
|
65
|
+
[data, ribbons],
|
|
66
|
+
);
|
|
51
67
|
const variations = data?.variations ?? [];
|
|
52
68
|
|
|
53
69
|
// ── selection ──────────────────────────────────────────────────────────────
|
|
@@ -141,7 +157,7 @@ export function useProduct(ref, options = {}) {
|
|
|
141
157
|
product,
|
|
142
158
|
variations,
|
|
143
159
|
categories: data?.categories ?? [],
|
|
144
|
-
ribbons
|
|
160
|
+
ribbons,
|
|
145
161
|
upsells: data?.upsells ?? [],
|
|
146
162
|
crossSells: data?.cross_sells ?? [],
|
|
147
163
|
reviews: data?.reviews ?? null,
|
|
@@ -24,10 +24,17 @@ import { useAsyncData } from "./internal/useAsyncData";
|
|
|
24
24
|
* legitimately match nothing — render from `isEmpty`, never on the assumption
|
|
25
25
|
* that rows came back.
|
|
26
26
|
*
|
|
27
|
+
* Each row is a `StorefrontProduct` — the whole published product record, with
|
|
28
|
+
* `images` as `{src, name, alt}` objects and the resolved `ribbons` as
|
|
29
|
+
* `{id, name}` objects (both are objects, both may be missing; go through
|
|
30
|
+
* `productImages` / `productRibbons`). The field list is
|
|
31
|
+
* `src/commerce/utils/types.js`.
|
|
32
|
+
*
|
|
27
33
|
* @param {object} [initialParams] `list-products` params (page/per_page and any filter)
|
|
28
34
|
* @param {{mode?: "pages"|"append", perPage?: number, keepPreviousData?: boolean}} [options]
|
|
29
35
|
* `mode: "append"` accumulates pages for a "load more" / infinite-scroll
|
|
30
36
|
* catalog; `loadMore()` is then the paging call.
|
|
37
|
+
* @returns {import("./types.js").UseProductListResult}
|
|
31
38
|
*/
|
|
32
39
|
export function useProductList(initialParams = {}, options = {}) {
|
|
33
40
|
const { mode = "pages", perPage: perPageOption, keepPreviousData = true } = options;
|
|
@@ -131,7 +138,7 @@ export function useProductList(initialParams = {}, options = {}) {
|
|
|
131
138
|
* under `children`. One cached call; use it for navigation and for the
|
|
132
139
|
* `category_id` filter on `useProductList`.
|
|
133
140
|
*
|
|
134
|
-
* @returns {
|
|
141
|
+
* @returns {import("./types.js").UseCategoriesResult}
|
|
135
142
|
*/
|
|
136
143
|
export function useCategories() {
|
|
137
144
|
const store = useStorefront();
|
|
@@ -140,10 +147,12 @@ export function useCategories() {
|
|
|
140
147
|
}
|
|
141
148
|
|
|
142
149
|
/**
|
|
143
|
-
* Ribbons — an ARRAY of `{ id, name, count }
|
|
144
|
-
*
|
|
150
|
+
* Ribbons — an ARRAY of `{ id, name, count }` **objects**, for the `ribbon_id`
|
|
151
|
+
* filter's option list and a "shop by ribbon" nav. A product's own ribbons ride
|
|
152
|
+
* on the product (`productRibbons(product)`); this is the store's whole set,
|
|
153
|
+
* with the counts a filter labels itself with ("Gift (12)").
|
|
145
154
|
*
|
|
146
|
-
* @returns {
|
|
155
|
+
* @returns {import("./types.js").UseRibbonsResult}
|
|
147
156
|
*/
|
|
148
157
|
export function useRibbons() {
|
|
149
158
|
const store = useStorefront();
|
|
@@ -25,16 +25,21 @@
|
|
|
25
25
|
* - `address-spec.js` — `addressFieldSpec`: the checkout address form as data,
|
|
26
26
|
* with country/state options that are always arrays.
|
|
27
27
|
* - `images.js` — `productImages`: images normalized to `{src, name, alt}`.
|
|
28
|
+
* - `ribbons.js` — `productRibbons`: ribbons normalized to `{id, name}` — they
|
|
29
|
+
* are objects, and the field is absent on a listing page that carries none.
|
|
28
30
|
* - `specs.js` — `productSpecs`: `meta_data` → descriptive rows (`key`, `label`,
|
|
29
31
|
* `titleLabel`, `value`). Match rows by `key`, never by `label`.
|
|
32
|
+
* - `types.js` — types only: `StorefrontProduct` and the rest of the catalog
|
|
33
|
+
* shapes as JSDoc typedefs, so what a field holds is readable from the
|
|
34
|
+
* frontend instead of from the backend function's source.
|
|
30
35
|
*
|
|
31
36
|
* Building the storefront in React? Import from **`@/commerce/storefront`** and
|
|
32
37
|
* nothing else — it adds the headless hooks and re-exports the helpers a page
|
|
33
38
|
* actually needs (`variantAxes`, `productPrice`, `productImages`,
|
|
34
|
-
* `productSpecs`, `attributesLabel`, `cartTotalsLines`,
|
|
35
|
-
* one import line covers a page. Neither layer ships
|
|
36
|
-
* styling and copy belong to the storefront you build. Use
|
|
37
|
-
* for non-React code and inside your own custom logic.
|
|
39
|
+
* `productRibbons`, `productSpecs`, `attributesLabel`, `cartTotalsLines`,
|
|
40
|
+
* `orderTotalsLines`), so one import line covers a page. Neither layer ships
|
|
41
|
+
* any UI: all markup, styling and copy belong to the storefront you build. Use
|
|
42
|
+
* this module directly for non-React code and inside your own custom logic.
|
|
38
43
|
*/
|
|
39
44
|
export * from "./storefront.js";
|
|
40
45
|
export * from "./variants.js";
|
|
@@ -43,4 +48,5 @@ export * from "./price.js";
|
|
|
43
48
|
export * from "./totals.js";
|
|
44
49
|
export * from "./address-spec.js";
|
|
45
50
|
export * from "./images.js";
|
|
51
|
+
export * from "./ribbons.js";
|
|
46
52
|
export * from "./specs.js";
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Product ribbons, normalized — the merchant's own merchandising labels
|
|
3
|
+
* ("Best Seller", "Last pieces"), rendered on cards and on the product page.
|
|
4
|
+
*
|
|
5
|
+
* Every ribbon is an **object** — `{ id, name }` — never a string, and
|
|
6
|
+
* `{product.ribbons[0]}` in JSX is React's "Objects are not valid as a React
|
|
7
|
+
* child" on the first product that carries one. Two more facts a hand-written
|
|
8
|
+
* `product.ribbons.map(...)` gets wrong: the field is **absent**, not `[]`, on a
|
|
9
|
+
* listing page where no row carries a ribbon (`list-products` only decorates
|
|
10
|
+
* when there is something to decorate), and the product page's ribbons arrive
|
|
11
|
+
* beside the product rather than on it. `productRibbons` takes any of those —
|
|
12
|
+
* a listing row, `useProduct().product`, the raw `get-product` payload, or the
|
|
13
|
+
* array itself — and always answers with the same clean array.
|
|
14
|
+
*
|
|
15
|
+
* ```jsx
|
|
16
|
+
* {productRibbons(product).map((r) => (
|
|
17
|
+
* <a key={r.id} href={`/collection?ribbon_id=${r.id}`}>{r.name}</a>
|
|
18
|
+
* ))}
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* `[]` means render nothing — never a dangling "Ribbons:" label. A ribbon has
|
|
22
|
+
* no slug, so a link keys on `r.id`; counts for a filter's labels ("Gift (12)")
|
|
23
|
+
* come from `useRibbons()` / `list-ribbons`, not from here.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A product's ribbons as renderable `{ id, name }` entries, de-duplicated.
|
|
28
|
+
*
|
|
29
|
+
* @param {object|Array<object|string>|null|undefined} source a listing row, a
|
|
30
|
+
* product, the `get-product` payload — anything carrying `ribbons` — or the
|
|
31
|
+
* ribbon array itself
|
|
32
|
+
* @returns {Array<{id: string, name: string}>} may be empty; `name` is the
|
|
33
|
+
* merchant's own text, unchanged
|
|
34
|
+
*/
|
|
35
|
+
export function productRibbons(source) {
|
|
36
|
+
const raw = Array.isArray(source) ? source : (source?.ribbons ?? []);
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
const out = [];
|
|
39
|
+
for (const entry of raw) {
|
|
40
|
+
// A bare string is accepted because seed-store takes `ribbons: ["Gift"]`,
|
|
41
|
+
// so that shape turns up in code that moves catalog data around.
|
|
42
|
+
const name = String((typeof entry === "string" ? entry : entry?.name) ?? "").trim();
|
|
43
|
+
if (!name) continue;
|
|
44
|
+
const id = String((typeof entry === "string" ? "" : entry?.id) ?? "");
|
|
45
|
+
const key = id || `name:${name.toLowerCase()}`;
|
|
46
|
+
if (seen.has(key)) continue;
|
|
47
|
+
seen.add(key);
|
|
48
|
+
out.push({ id, name });
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The catalog shapes, written down once — what `list-products` and
|
|
3
|
+
* `get-product` actually hand a storefront, so the answer to "what is in
|
|
4
|
+
* `ribbons[0]`?" is here rather than in the backend function's source.
|
|
5
|
+
*
|
|
6
|
+
* **This module is types only** — JSDoc `@typedef`s, no runtime code. Editors
|
|
7
|
+
* resolve them through the `@returns` annotations on the storefront hooks and
|
|
8
|
+
* helpers (`useProductList().products` is a `StorefrontProduct[]`), and they
|
|
9
|
+
* read on their own as the field list a card or a product page can render.
|
|
10
|
+
*
|
|
11
|
+
* Three shapes are objects where a storefront tends to assume strings — the
|
|
12
|
+
* whole reason this file exists:
|
|
13
|
+
*
|
|
14
|
+
* - `images[]` → `{ src, name, alt }`, and the array may be empty
|
|
15
|
+
* (`productImages(product)`)
|
|
16
|
+
* - `ribbons[]` → `{ id, name }`, and the field may be absent
|
|
17
|
+
* (`productRibbons(product)`)
|
|
18
|
+
* - `meta_data[]` → `{ key, value }` with free-text keys (`productSpecs(product)`)
|
|
19
|
+
*
|
|
20
|
+
* …while `ProductSummary.image` (upsells and cross-sells) *is* a plain URL
|
|
21
|
+
* string. Go through the helpers and the difference stops mattering.
|
|
22
|
+
*
|
|
23
|
+
* Field-by-field notes on what each view can render:
|
|
24
|
+
* the commerce skill's references/catalog-rendering.md.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A product image. Stored on the product; `alt` falls back to `name`.
|
|
29
|
+
*
|
|
30
|
+
* @typedef {object} ProductImage
|
|
31
|
+
* @property {string} src
|
|
32
|
+
* @property {string} name
|
|
33
|
+
* @property {string} alt
|
|
34
|
+
* @property {number} [position]
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A ribbon as a storefront sees it — a flat merchandising label. The full
|
|
39
|
+
* `commerce.ProductRibbon` record is admin-only; a storefront gets `{id, name}`
|
|
40
|
+
* on catalog rows and `{id, name, count}` from `list-ribbons` / `useRibbons()`.
|
|
41
|
+
*
|
|
42
|
+
* @typedef {object} ProductRibbon
|
|
43
|
+
* @property {string} id — the `list-products` `ribbon_id` filter value; ribbons have no slug
|
|
44
|
+
* @property {string} name
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A category. `get-product` returns the full records; `list-categories` /
|
|
49
|
+
* `useCategories()` nests the tree under `children`.
|
|
50
|
+
*
|
|
51
|
+
* @typedef {object} ProductCategory
|
|
52
|
+
* @property {string} id
|
|
53
|
+
* @property {string} name
|
|
54
|
+
* @property {string} slug
|
|
55
|
+
* @property {string} [parent_id] — empty at top level
|
|
56
|
+
* @property {string} [description]
|
|
57
|
+
* @property {{src: string, alt: string}} [image]
|
|
58
|
+
* @property {number} [menu_order]
|
|
59
|
+
* @property {number} [count] — derived, admin-maintained; may drift
|
|
60
|
+
* @property {ProductCategory[]} [children] — `list-categories` only
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* One variant **axis** on a product (Size, Color). A non-empty `attributes[]`
|
|
65
|
+
* is what makes a product sell variants — there is no `type` field.
|
|
66
|
+
*
|
|
67
|
+
* @typedef {object} ProductAttributeAxis
|
|
68
|
+
* @property {string} attribute_id
|
|
69
|
+
* @property {string} name
|
|
70
|
+
* @property {number} [position] — the order axes are presented in
|
|
71
|
+
* @property {string[]} options — the values this product comes in
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A descriptive property (the admin's *Modifiers*) — never a selector.
|
|
76
|
+
* Keys are free text: look rows up with `findSpec`, never by `label`.
|
|
77
|
+
*
|
|
78
|
+
* @typedef {object} ProductMeta
|
|
79
|
+
* @property {string} key
|
|
80
|
+
* @property {string} value
|
|
81
|
+
*/
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A published catalog product, as returned by `list-products` (one row) and
|
|
85
|
+
* `get-product` (`product`). It is the `commerce.Product` record minus the
|
|
86
|
+
* paywalled download fields, plus the resolved `ribbons`.
|
|
87
|
+
*
|
|
88
|
+
* @typedef {object} StorefrontProduct
|
|
89
|
+
* @property {string} id
|
|
90
|
+
* @property {string} name
|
|
91
|
+
* @property {string} slug
|
|
92
|
+
* @property {"publish"} status — only published products are ever returned
|
|
93
|
+
* @property {boolean} [featured] — the merchant's own flag, for curated rails
|
|
94
|
+
* @property {string} [description] — HTML; render as rich text
|
|
95
|
+
* @property {string} [short_description]
|
|
96
|
+
* @property {string} [sku]
|
|
97
|
+
* @property {number|null} [price] — with variants this is a **from** price; render via `productPrice`
|
|
98
|
+
* @property {number|null} [regular_price]
|
|
99
|
+
* @property {number|null} [sale_price]
|
|
100
|
+
* @property {boolean} [on_sale]
|
|
101
|
+
* @property {string} [date_on_sale_from]
|
|
102
|
+
* @property {string} [date_on_sale_to]
|
|
103
|
+
* @property {ProductImage[]} [images] — **objects**, first is primary, may be empty
|
|
104
|
+
* @property {ProductRibbon[]} [ribbons] — resolved `{id, name}`; **absent** when the page carries none
|
|
105
|
+
* @property {string[]} [ribbon_ids]
|
|
106
|
+
* @property {string[]} [category_ids] — rows carry ids only; `get-product` resolves the records
|
|
107
|
+
* @property {ProductAttributeAxis[]} [attributes] — non-empty ⇒ sells variants
|
|
108
|
+
* @property {Array<{attribute_id: string, name: string, option: string}>} [default_attributes]
|
|
109
|
+
* @property {ProductMeta[]} [meta_data] — descriptive rows; use `productSpecs`
|
|
110
|
+
* @property {"instock"|"outofstock"|"onbackorder"} [stock_status]
|
|
111
|
+
* @property {boolean} [manage_stock]
|
|
112
|
+
* @property {number|null} [stock_quantity] — null when stock isn't tracked
|
|
113
|
+
* @property {"no"|"notify"|"yes"} [backorders]
|
|
114
|
+
* @property {boolean} [sold_individually] — caps quantity at 1
|
|
115
|
+
* @property {boolean} [virtual]
|
|
116
|
+
* @property {boolean} [downloadable] — the files themselves are never public
|
|
117
|
+
* @property {number} [average_rating]
|
|
118
|
+
* @property {number} [rating_count]
|
|
119
|
+
* @property {number} [total_sales]
|
|
120
|
+
* @property {number|null} [weight]
|
|
121
|
+
* @property {{length: number, width: number, height: number}} [dimensions]
|
|
122
|
+
* @property {string[]} [upsell_ids]
|
|
123
|
+
* @property {string[]} [cross_sell_ids]
|
|
124
|
+
* @property {string} [created_date]
|
|
125
|
+
* @property {string} [updated_date]
|
|
126
|
+
*/
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* One stocked combination of a product's axes. `get-product` only, publishable
|
|
130
|
+
* only — a product with attributes is sellable **only** through a variation id.
|
|
131
|
+
* Bind the UI to `resolveSelection`'s view, not to this list.
|
|
132
|
+
*
|
|
133
|
+
* @typedef {object} StorefrontVariation
|
|
134
|
+
* @property {string} id
|
|
135
|
+
* @property {string} product_id
|
|
136
|
+
* @property {Array<{attribute_id: string, name: string, option: string}>} attributes
|
|
137
|
+
* @property {"publish"|"private"|"draft"} [status]
|
|
138
|
+
* @property {string} [sku]
|
|
139
|
+
* @property {number|null} [price]
|
|
140
|
+
* @property {number|null} [regular_price]
|
|
141
|
+
* @property {number|null} [sale_price]
|
|
142
|
+
* @property {boolean} [on_sale]
|
|
143
|
+
* @property {ProductImage|string|null} [image] — variation-owned, usually **not** in `product.images`
|
|
144
|
+
* @property {"instock"|"outofstock"|"onbackorder"} [stock_status]
|
|
145
|
+
* @property {"yes"|"no"|"parent"} [manage_stock]
|
|
146
|
+
* @property {number|null} [stock_quantity]
|
|
147
|
+
* @property {"no"|"notify"|"yes"} [backorders]
|
|
148
|
+
* @property {number|null} [weight]
|
|
149
|
+
* @property {{length: number, width: number, height: number}} [dimensions]
|
|
150
|
+
* @property {string} [description]
|
|
151
|
+
* @property {boolean} [virtual]
|
|
152
|
+
* @property {boolean} [downloadable]
|
|
153
|
+
*/
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* An upsell / cross-sell tile. Deliberately thin — and note `image` is a plain
|
|
157
|
+
* URL **string** here, not the `{src, name, alt}` object `images[]` carries.
|
|
158
|
+
*
|
|
159
|
+
* @typedef {object} ProductSummary
|
|
160
|
+
* @property {string} id
|
|
161
|
+
* @property {string} name
|
|
162
|
+
* @property {string} slug
|
|
163
|
+
* @property {number|null} price
|
|
164
|
+
* @property {boolean} on_sale
|
|
165
|
+
* @property {string} image — URL, `""` when the product has no image
|
|
166
|
+
* @property {"instock"|"outofstock"|"onbackorder"} stock_status
|
|
167
|
+
*/
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* One approved review. Reviewer emails are never public.
|
|
171
|
+
*
|
|
172
|
+
* @typedef {object} ProductReview
|
|
173
|
+
* @property {string} id
|
|
174
|
+
* @property {string} reviewer — display name
|
|
175
|
+
* @property {string} review
|
|
176
|
+
* @property {number} rating — 0–5
|
|
177
|
+
* @property {boolean} verified — the reviewer bought it
|
|
178
|
+
* @property {string} created_date
|
|
179
|
+
*/
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The paged review block that rides along with `get-product`.
|
|
183
|
+
*
|
|
184
|
+
* @typedef {object} ProductReviews
|
|
185
|
+
* @property {ProductReview[]} items
|
|
186
|
+
* @property {number} page
|
|
187
|
+
* @property {number} per_page
|
|
188
|
+
* @property {boolean} has_next
|
|
189
|
+
* @property {number} average_rating
|
|
190
|
+
* @property {number} rating_count
|
|
191
|
+
*/
|
|
192
|
+
|
|
193
|
+
export {};
|