@base44/app-plugin-commerce 0.2.7 → 0.3.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.
Files changed (39) hide show
  1. package/README.md +4 -4
  2. package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
  3. package/base44/functions/commerce/seed-store/entry.ts +16 -2
  4. package/package.json +2 -2
  5. package/scripts/install.js +15 -0
  6. package/skills/commerce/SKILL.md +34 -17
  7. package/skills/commerce/docs/api-admin.md +1 -1
  8. package/skills/commerce/docs/api-storefront.md +12 -12
  9. package/skills/commerce/install/01-install.md +6 -8
  10. package/skills/commerce/install/02-storefront.md +180 -278
  11. package/skills/commerce/install/03-data.md +13 -11
  12. package/skills/commerce/references/catalog-rendering.md +37 -43
  13. package/skills/commerce/references/online-payments.md +10 -0
  14. package/skills/commerce/references/reviews.md +21 -14
  15. package/skills/commerce/references/store-settings.md +1 -1
  16. package/skills/commerce/references/storefront-verification.md +21 -15
  17. package/src/commerce/storefront/StorefrontProvider.jsx +65 -128
  18. package/src/commerce/storefront/cartUI.jsx +11 -30
  19. package/src/commerce/storefront/index.js +61 -98
  20. package/src/commerce/storefront/pickers.jsx +50 -64
  21. package/src/commerce/storefront/useCartLine.js +23 -130
  22. package/src/commerce/storefront/useCheckout.jsx +50 -43
  23. package/src/commerce/storefront/useOrderReturn.js +17 -7
  24. package/src/commerce/storefront/useProduct.js +41 -97
  25. package/src/commerce/storefront/useProductList.js +14 -22
  26. package/src/commerce/utils/address-spec.js +1 -1
  27. package/src/commerce/utils/images.js +1 -1
  28. package/src/commerce/utils/index.js +9 -9
  29. package/src/commerce/utils/price.js +2 -1
  30. package/src/commerce/utils/specs.js +41 -91
  31. package/src/commerce/utils/totals.js +7 -4
  32. package/src/commerce/storefront/useAddressForm.js +0 -166
  33. package/src/commerce/storefront/usePlaceOrder.js +0 -63
  34. package/src/commerce/storefront/useProductGallery.js +0 -78
  35. package/src/commerce/storefront/useProductPrice.js +0 -58
  36. package/src/commerce/storefront/useProductReviews.js +0 -242
  37. package/src/commerce/storefront/useStorefrontSeo.js +0 -204
  38. package/src/commerce/storefront/useTotalsLines.js +0 -109
  39. package/src/commerce/storefront/useUpsell.js +0 -90
@@ -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…) is in [`../docs/api-admin.md`](../docs/api-admin.md#commerceseed-store) — open it only if the catalog needs one of those:
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 properties the product page's spec rows (productSpecs
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", value: "248 g" }, // "<number> <unit>" → a numeric row
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. **Limits:** ≤100 products, ≤500 variations, ≤50 locations per call. Bad payloads fail **400** `invalid_payload` with `errors: [{ path, error }]` before anything is written.
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,28 +93,31 @@ 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", cost: 20 }] },
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. There is **no country code meaning "everywhere"** (`["*"]`, `["ALL"]`, alpha-3 like `["USA"]` all fail **400**); other scopes are `countries: ["IL", "DE"]` or `regions: [{ type: "state", code: "US:CA" }]`. Matching is country + state only.
104
- - A matched location supplies **both** shipping rates and tax groups (`tax_groups`, `shipping_tax`, `free_over` see the reference below).
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 mean every other address gets `shipping_not_available` at checkout. Plausibly intended, so it is a warning, not an error: **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, assert them once by pricing a cart against one address per zone (`set-shipping-address` → `available_shipping_methods`; a `[]` means no location matched).
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
 
110
112
  ## Images
111
113
 
112
- **The URL you seed is the URL the store serves.** Resolve each to its final, permanent URL before seeding: the app's image generation, or `base44.integrations.Core.UploadFile({ file })` → public URL. A temporary/**signed** URL (expiry params in the query string are the tell) must be re-hosted via `UploadFile` first — the failure is silent at seed time and visible only in the store. Seeding imageless and patching later is allowed but is open debt; close it before handover.
114
+ **The URL you seed is the URL the store serves.** Resolve each to its final, permanent URL before seeding: the app's image generation, or `base44.integrations.Core.UploadFile({ file })` → public URL. A temporary/**signed** URL (expiry params in the query string are the tell) must be re-hosted via `UploadFile` first — the failure is silent at seed time and visible only in the store. Cheap insurance before seeding: fetch one or two of the URLs and check they answer 200 with an image content type. Seeding imageless and patching later is allowed but is open debt; close it before handover.
113
115
 
114
116
  ## Payments — the decision
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; it is idempotent and needs no catalog, so `{ payment_methods: ["offline", "card"] }` alone is the later switch.
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
  |---|---|
@@ -5,7 +5,7 @@ skip_when: "The listing and product page render correctly from useProductList /
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`."
8
- - "A variant parent's `price` is a FROM price — render it through productPrice/useProductPrice, never as the price."
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
10
  ---
11
11
 
@@ -13,16 +13,16 @@ carry_forward:
13
13
 
14
14
  Both storefront surfaces render the same catalog record from a different call:
15
15
 
16
- | View | Call | Hook | Returns |
16
+ | View | `commerce/storefront-catalog` action | Hook | Returns |
17
17
  |---|---|---|---|
18
- | Listing / grid / search / ribbon & category pages | `commerce/storefront-catalog` `list-products` | `useProductList` | `{ products: [row…], page, per_page, has_next }` |
19
- | Product page | `commerce/storefront-catalog` `get-product` | `useProduct` | `{ product, variations, categories, ribbons, reviews, upsells, cross_sells }` |
18
+ | Listing / grid / search / ribbon & category pages | `list-products` | `useProductList` | `{ products: [row…], page, per_page, has_next }` |
19
+ | Product page | `get-product` | `useProduct` | `{ product, variations, categories, ribbons, reviews, upsells, cross_sells }` |
20
20
 
21
- Request/response shapes: [`../docs/api-storefront.md`](../docs/api-storefront.md). You choose what belongs in each view — but you can only render what the call returns, and §1 is that boundary.
21
+ Shapes: [`../docs/api-storefront.md`](../docs/api-storefront.md). What belongs in each view is yours — you can only render what the call returns, and §1 is that boundary.
22
22
 
23
23
  ## 1. Field availability
24
24
 
25
- A listing **row** is the product record itself (minus paywalled fields) plus resolved `ribbons`. `get-product` adds everything that needs a second read.
25
+ A listing **row** is the product record (minus paywalled fields) plus resolved `ribbons`; `get-product` adds everything that needs a second read.
26
26
 
27
27
  | Data | `list-products` row | `get-product` | Notes |
28
28
  |---|---|---|---|
@@ -30,85 +30,79 @@ A listing **row** is the product record itself (minus paywalled fields) plus res
30
30
  | `price`, `regular_price`, `sale_price`, `on_sale` | ✅ | ✅ | With variants the parent `price` is a *from* price — §2 |
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
- | `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** (`productSpecs(product)` turns them into typed spec rows) |
33
+ | `average_rating`, `rating_count`, `total_sales` | ✅ | ✅ | Enough for stars on a card; the reviews themselves aren't |
34
+ | `downloadable`, `virtual`, `weight`, `dimensions`, `attributes[]`, `meta_data` | ✅ | ✅ | `attributes[]` is the variant axes; `meta_data` the descriptive **modifiers** (`productSpecs(product)` spec 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 |
38
38
  | **`reviews`** (paged items + aggregates) | ❌ | ✅ | Rows still carry the aggregate numbers |
39
39
  | **`upsells`, `cross_sells`** (summaries) | ❌ | ✅ | `{id, name, slug, price, on_sale, image}` |
40
- | `downloads[]`, `download_limit`, `download_expiry` | ❌ | ❌ | **Never** public — reached only through `commerce/storefront-account` `get-download` |
40
+ | `downloads[]`, `download_limit`, `download_expiry` | ❌ | ❌ | **Never** public — only via `commerce/storefront-account` `get-download` |
41
41
 
42
- ## 2. Two shapes, and the three rules the code now owns
42
+ ## 2. Two shapes, and three rules the exports own
43
43
 
44
44
  There is **no product `type` field**. `attributes[]` tells the shapes apart:
45
45
 
46
- | Shape | Test | Card | Product page |
47
- |---|---|---|---|
48
- | **Single item** | `attributes` is empty | price, and Add to cart straight from the card if you want one | one Add to cart — `resolveSelection` returns no axes, `complete: true`, a ready `addToCart` |
49
- | **Sells variants** | `attributes` is non-empty | price **range or "From €19"**, never selectors — link through | one control per axis §5 |
46
+ | Shape | Test | Renders as |
47
+ |---|---|---|
48
+ | **Single item** | `attributes` empty | one price; one Add to cart, from the card too if you want (`resolveSelection` gives no axes, `complete: true`, a ready `addToCart`) |
49
+ | **Sells variants** | `attributes` non-empty | card: price **range or "From €19"**, never selectors — link through. Page: one control per axis (§5) |
50
50
 
51
- A product with attributes is **only** sellable through a variant: `add-item` without a `variation_id` is `400 variation_required`, with no fall-back to the parent. So one with attributes but no variations is unsellable by design, not by accident (§5 covers rendering that state).
51
+ A product with attributes is **only** sellable through a variant `add-item` with no `variation_id` is `400 variation_required`, no parent fall-back so attributes with no variations is unsellable by design, not by accident (§5 renders it).
52
52
 
53
- Three rules are enforced by exports use them and they can't drift between views:
53
+ Three rules the exports enforce, so two views can't drift apart:
54
54
 
55
- - **From-price.** A parent's `regular_price`/`price`/`on_sale` are rolled up from the cheapest publishable variant on every save (by `admin-products` and the seeder) — real, sortable, filterable, but the **lowest** price, not *the* price. `productPrice(rowOrView, {formatMoney})` / `useProductPrice(rowOrView)` take a listing row **or** a `resolveSelection` view and return `{label, compareAtLabel, onSale, isFrom, isRange, min, max}`: "From €19.99" on a card, a range unresolved, the exact price resolved.
56
- - **Images are objects, and may be absent.** Every stored image is `{src, name, alt}`, never a URL string — passing the object itself to `<img src>` fails the load — and `images` can legitimately be empty. `productImages(product)` / `normalizeImage(entry)` return clean entries (non-empty `src`, defaulted `alt`); an empty array means *render your placeholder* (`useProductGallery` builds on them, `hasImages`).
57
- - **Modifiers are not attributes.** `meta_data` (Material, Care, GTIN) is descriptive — never a selector, never a ribbon; how it renders is a design decision (§3), a control is what it can never become.
55
+ - **From-price.** A parent's `regular_price`/`price`/`on_sale` are rolled up from the cheapest publishable variant on every save (`admin-products`, the seeder) — real, sortable, filterable, but the **lowest** price, not *the* price. `productPrice(rowOrView, { formatMoney })` (`formatMoney` from `useFormatMoney()`) takes a listing row **or** a `resolveSelection` view `{label, compareAtLabel, onSale, isFrom, isRange, min, max}`: "From €19.99" on a card, a range unresolved, the exact price once resolved.
56
+ - **Images are objects, and may be absent.** Every stored image is `{src, name, alt}`, never a URL string — passing the object to an `<img src>` fails the load, on every product at once — and `images` can legitimately be empty. `productImages(product)` / `normalizeImage(entry)` return clean, de-duplicated entries (non-empty `src`, defaulted `alt`); `[]` means *render your placeholder*. Same trap one level in: `view.display.image` is that normalized object or **`null`** — render `display.image?.src`/`?.alt`, and `null` (no variation *or* parent image) is the one placeholder case.
57
+ - **Modifiers are not attributes.** `meta_data` (Material, Care, GTIN) is descriptive — never a selector, never a ribbon; how it renders is a design decision (§3), a control is what it can never be.
58
58
 
59
59
  ## 3. What each view *can* render
60
60
 
61
- Both lists below are field inventories what the data supports — **not a layout and not an order**; a store rendering exactly these fields in exactly this sequence is the generic storefront every generated catalog produces.
61
+ Both lists are inventories of what the data supports — **not a layout and not an order**: rendering exactly these fields in this sequence is the generic storefront.
62
62
 
63
- **Card:** image, name, `price.label`, sale badge from `on_sale`, stars from `average_rating`/`rating_count`, out-of-stock state from `stock_status`, one or two ribbons — plus anything else on the row (`weight`, `dimensions`, `meta_data` via `productSpecs`). Link the whole card to the product page; the layout is yours.
63
+ **Card:** image, name, `price.label`, a sale badge from `on_sale`, stars from `average_rating`/`rating_count`, out-of-stock from `stock_status`, one or two ribbons — plus anything else on the row (`weight`, `dimensions`, `productSpecs`). Link the whole card through; the layout is yours.
64
64
 
65
- **Product page:** gallery, name, price, variant selectors, stock, `short_description` then `description`, SKU, categories breadcrumb, ribbons, reviews, then upsells/cross-sells. Everything except the markup has a hook or helper: `useProductGallery`, `variantAxes(view, pick)`, `useAddToCart`, `productSpecs(product)`, `useProductReviews`, `p.upsells`/`p.crossSells`.
65
+ **Product page:** gallery, name, price, variant selectors, stock, `short_description` then `description`, SKU, categories breadcrumb, ribbons, reviews, upsells/cross-sells. All but the markup is handed to you: `productImages(product)` + your own active index, `variantAxes(view, pick)`, `useAddToCart`, `productSpecs(product)`, `p.reviews` (+ `submitReview` off `useStorefront()`), and `p.upsells`/`p.crossSells` — added with `useCart().addItem`, matching "already in cart" by product id.
66
66
 
67
- **Attributes and modifiers are individually designable.** `productSpecs` rows carry `key`/`label`/`titleLabel` (display-cased) plus an inferred `type` (`numeric` with `number`/`unit` split out, `duration`, `location`, `list` with `items`, `text`); `useProductSpecs(product, { pick })` is the React wrapper, its get/pick case/underscore-insensitive. Branch on `type`/`key` instead of mapping everything into one uniform chip row per axis and one grey label/value table — the design guidance is [`../install/02-storefront.md`](../install/02-storefront.md)'s; §5's rules govern selector *behaviour*, never its form.
67
+ **Attributes and modifiers are individually designable.** `productSpecs(product)` rows are `{key, label, titleLabel, value}` and nothing more. ⚑ **Look a row up with `findSpec(rows, "care")`**, which ignores case, spaces, `_` and `-`: meta keys are free text typed per product (`care`, `Care`, `Care Instructions`), so an equality test on `label` — or on one spelling of `key` silently never fires and the feature renders its fallback forever; `titleLabel` is the display-cased form, for a heading. What a row *looks* like is a per-catalog design decision — not one uniform chip row per axis and one grey label/value table ([`../install/02-storefront.md`](../install/02-storefront.md)); §5's rules govern selector *behaviour*, never its form.
68
68
 
69
69
  ## 4. Ribbons — in **both** views
70
70
 
71
71
  Ribbons are flat, cross-cutting labels ("Best Seller", "New", "Gift"); categories are the hierarchical spine. Generated storefronts routinely omit ribbons entirely. Don't.
72
72
 
73
- - **Listing rows carry resolved `ribbons`**, so a card renders them with no extra call; the product page renders the top-level `ribbons` near the metadata, lighter than the breadcrumb. One or two per card is useful, more is noise, and an empty array means render nothing — never a dangling "Ribbons:" label.
74
- - **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 keep the page shareable across reloads.
73
+ - **Listing rows carry resolved `ribbons`** a card renders them with no extra call; the product page renders its top-level `ribbons` 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
+ - **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
75
  - **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
76
  - Ribbons are not breadcrumbs and never variant options — a size or colour is an `attribute`.
77
77
 
78
78
  ## 5. Variant selection
79
79
 
80
- 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.
80
+ The one interaction agents reliably get wrong. `variantAxes(view, pick)` is the render-ready model, and it encodes the rule: **one control per axis, never a list of variations** (`Red / S`, `Red / M`, … is `n × m` noise hiding the product's structure); an unbuyable option 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.
81
81
 
82
- 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:
82
+ Underneath, `useProduct` composes the framework-free helpers in `src/commerce/utils/variants.js`: `defaultSelection` → `selectOption` per click → `resolveSelection`. That middle step keeps dead ends out — it holds the just-picked option and clears **only** conflicting axes (Red/L → click Blue → `{Color: Blue}`, size re-opens), never parking the customer on a combination that resolves to nothing. Every `product.attributes[]` entry is an **axis** in `position` order, every `variations[]` record one combination. Bind the UI to `view`, not `product.*`, or a selection changes nothing:
83
83
 
84
84
  | `view` field | Binds to |
85
85
  |---|---|
86
86
  | `axes` | the controls: `[{ key, name, attribute_id, options[] }]`, dead options already dropped |
87
87
  | `availability[axisKey][option]` | `"available"` / `"out_of_stock"` / `"unavailable"` (constants `OPTION_*`) |
88
- | `display` | image, price, `regular_price`, `on_sale`, SKU, stock, weight/dimensions, description — variation-first with parent fallback; `display.image` is normalized or `null` |
89
- | `priceRange` | `{min, max, on_sale, count}` while the selection is incomplete — render via `useProductPrice(view)` |
88
+ | `display` | image, price, `regular_price`, `on_sale`, SKU, stock, weight/dimensions, description — variation-first with parent fallback; `display.image` per §2 |
89
+ | `priceRange` | `{min, max, on_sale, count}` while the selection is incomplete — render via `productPrice(view, { formatMoney })` |
90
90
  | `complete` / `missingAxes` | the button label: `Select a ${view.missingAxes[0]?.name}` |
91
91
  | `purchasable` / `addToCart` | gate **Add to cart** on `purchasable`, send `addToCart` (`{product_id, variation_id}`, or `null`) |
92
- | `variation` / `candidates` / `isVariable` / `selection` | the record itself, the variations still reachable, and the state the selector renders from |
92
+ | `variation` / `candidates` / `isVariable` / `selection` | the record itself, the variations still reachable, the state the selector renders from |
93
93
 
94
94
  Three decisions the helpers can't make for you:
95
95
 
96
- - **Unavailable vs out of stock.** `"unavailable"` (no such combination) → **disable, don't hide**; options that vanish and reappear as the customer clicks are disorienting. `"out_of_stock"` → visible and labelled. `onbackorder` counts as available and purchasable — label it ("Ships in 2–3 weeks"), don't disable it.
97
- - **Incomplete selection.** Show the range from `useProductPrice(view)` — never `€0`, never the bare parent price — and keep Add to cart disabled with a hint at what's missing.
98
- - **Linkability.** `useProduct` mirrors the selection into the URL (`?color=Ivory`) and hydrates from it, so a variant is shareable and survives a reload; `selectionToParams`/`selectionFromParams` do a custom round-trip, and `selectionForVariation` hydrates controls from a cart line or an `?variation=` link.
96
+ - **Unavailable vs out of stock.** `"unavailable"` (no such combination) → **disable, don't hide**; options that vanish and reappear as the customer clicks are disorienting. `"out_of_stock"` → visible, labelled. `onbackorder` is available and purchasable — label it ("Ships in 2–3 weeks"), don't disable it.
97
+ - **Incomplete selection.** Show the range from `productPrice(view, { formatMoney })` — never `€0`, never the bare parent price — and keep Add to cart disabled with a hint at what's missing.
98
+ - **Linkability.** `useProduct` mirrors the selection into the URL (`?color=Ivory`) and hydrates from it, so a variant is shareable and survives a reload; `selectionToParams`/`selectionFromParams` are that round-trip by hand, `selectionForVariation` hydrates controls from a cart line or an `?variation=` link.
99
99
 
100
- **Attributes but no usable variations** — what attaching an attribute and stopping leaves behind deliberately gets no parent fallback, because `add-item` would reject it: empty `axes[].options`, `purchasable: false`, `addToCart: null`. Render it as unavailable rather than painting empty selector groups, and guard the label (with no attributes at all `missingAxes[0]?.name` is `undefined` → "Select a undefined").
100
+ **Attributes but no usable variations** — attaching an attribute and stopping — gets no parent fallback by design, since `add-item` would reject it: empty `axes[].options`, `purchasable: false`, `addToCart: null`. Render it as unavailable rather than painting empty selector groups, and guard the label: with no attributes at all `missingAxes[0]?.name` is `undefined` → "Select a undefined".
101
101
 
102
- **Add to cart** goes through `useAddToCart(product)`, whose `addToCart()` never throws and maps the codes: `variation_required` (a page bug empty `variation_id` on a product with attributes), `out_of_stock` / `insufficient_stock`, and `variation_not_found` (the catalog changed under the page → the hook reloads the product itself). `product.sold_individually` caps quantity at 1, already reflected in `useProduct().maxQuantity`. Two behaviors that matter only if you hand-roll the view model: non-publishable variations never leak an option into the UI, and an empty `option` on a variation axis means **"any"**.
102
+ **Add to cart** goes through `useAddToCart(product)`: `addToCart()` never throws and maps the codes `variation_required` (a page bug: empty `variation_id` on a product with attributes), `out_of_stock`/`insufficient_stock`, `variation_not_found` (the catalog changed under the page → the hook reloads it). `product.sold_individually` caps quantity at 1, reflected in `useProduct().maxQuantity`. Two behaviors only a hand-rolled view model gets wrong: non-publishable variations never leak an option into the UI, and an empty `option` on an axis means **"any"**.
103
103
 
104
104
  ## 6. Adding a `get-product`-only field to the listing
105
105
 
106
- If the store needs something a row doesn't carry resolved categories are the common one — resolve it **in the listing call**, never with `get-product` per card (N requests for one grid). `list-products` resolves its page slice through a helper in `base44/functions/commerce/storefront-catalog/entry.ts`:
107
-
108
- ```ts
109
- const pageItems = await withRibbons(sr, products.slice(start, start + perPage).map(publicProduct));
110
- ```
111
-
112
- `withRibbons` reads the taxonomy **once per request** and maps ids onto the rows — not once per row. Extend that pattern: read the entity for the whole page, build an id→record `Map`, attach the short shape each card needs. Keep it to fields the UI renders; a listing payload is served on every browse.
106
+ Something a row doesn't carry (resolved categories, typically) gets resolved **in the listing call**, never `get-product` per card (N requests for one grid). `list-products` resolves its page slice through one helper in `base44/functions/commerce/storefront-catalog/entry.ts` — `await withRibbons(sr, pageRows.map(publicProduct))` — which reads the taxonomy **once per request**, not once per row, and maps ids onto the rows. Extend that pattern: read the entity for the whole page, build an id→record `Map`, attach the short shape each card needs. Keep it to fields the UI renders — a listing payload is served on every browse.
113
107
 
114
- Don't add `variations` to every row — the heaviest read in the catalog, and a grid doesn't need it; if cards must show true ranges, precompute `price_min`/`price_max`. And don't relax entity RLS to read the catalog from the client — it is admin-only by design ([`guest-access-security.md`](./guest-access-security.md)); widen the function's response instead.
108
+ Don't add `variations` to every row — the heaviest read in the catalog, and a grid doesn't need it; if cards must show true ranges, precompute `price_min`/`price_max`. And don't relax entity RLS to read the catalog client-side — it is admin-only by design ([`guest-access-security.md`](./guest-access-security.md)); widen the function's response instead.
@@ -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 |
@@ -1,41 +1,48 @@
1
1
  ---
2
2
  stage: reference
3
- read_when: "You need review behaviour beyond useProductReviewsmoderation, auto-approval, a customer's own reviews, or a policy the three built-in ones don't cover."
4
- skip_when: "useProductReviews is on the product page that is list + submit + policy + the right confirmation copy already."
3
+ read_when: "You are building the review surfacethe list, the form, a policy (login-gated, verified buyers) or need moderation, auto-approval, or a customer's own reviews."
4
+ skip_when: "The product page already renders `p.reviews` and submits through `submitReview`, with the confirmation copy following the response's `status`."
5
5
  forget_when: "A review submits, appears (or is held) as the store's auto-approve setting dictates, and the aggregate rating renders."
6
6
  carry_forward:
7
- - "Reviews are part of the happy path: one hook. The confirmation copy must come from the submit response, never hardcoded."
7
+ - "Reviews are part of the happy path: the list arrives with the product, submitting is one client call. The confirmation copy must come from the submit response's `status`, never hardcoded."
8
8
  ---
9
9
 
10
10
  # Reviews
11
11
 
12
- Reviews are **part of the happy path**, not an extra: the backend always shipped complete, and the whole UI contract is one hook. Unless the store explicitly doesn't want them, put them on the product page: `useProductReviews(product, { policy, user, requireRating, perPage })` the list with paging (`items`, `hasNext`/`loadMore`), the aggregates (`averageRating`, `ratingCount`), the whole form contract (`form`/`setField`/`fieldErrors`/`valid`/`submit`/`message`) and the policy gate (`canReview`, `reviewBlockedReason`, `requiresEmail`); exact shapes in its JSDoc. The markup around it — stars, rows, the form is yours, like all storefront UI.
12
+ Reviews are **part of the happy path**, not an extra: the backend always shipped complete, and the storefront's half is two calls. Unless the store explicitly doesn't want them, put them on the product page `useProduct(slug)` already hands you `p.reviews` (the first page of approved reviews plus the aggregates, fetched with the product), and submitting is one method. There is no reviews hook and no review markup: the stars, the rows, the form and every word around them are yours, like all storefront UI.
13
+
14
+ ## The two client calls
15
+
16
+ Both live on the storefront client in `@/commerce/utils` — in React, `useStorefront()` is that client:
17
+
18
+ - **`getProductReviews(slugOrRef, { page, per_page })`** → `{ items, page, per_page, has_next, average_rating, rating_count }`. The same reviews `get-product` returns — page or refresh the list without re-fetching the page; `useProduct(slug, { reviewsPerPage })` sizes the first one.
19
+ - **`submitReview({ product_id, review, rating?, reviewer?, email? })`** → `{ review_id, status, verified }`. `review` is the body text and is required; `rating` is optional, **0–5**; `reviewer` is the display name. It **rejects** with `email_required` | `review_incomplete` | `invalid_rating` | `not_found` — catch it, read `storefrontErrorCode(e)`, and land each code on its own field, so a failed submit says what to fix instead of resolving into nothing. After an approved submission, refresh the list yourself so the review actually appears.
13
20
 
14
21
  ## What ships
15
22
 
16
23
  - `get-product` returns **paginated `reviews`** plus `average_rating`, `rating_count` and a `verified` flag per review; `list-products` rows carry the aggregates, so **stars on cards cost no extra call**;
17
- - `submit-review` accepts `{ product_id, email, reviewer?, review, rating? }` from **anyone — no login**. A signed-in caller's session email always wins (the payload cannot impersonate); a guest supplies `email`; `verified` comes from that email's order history. The three error codes (`400 email_required | review_incomplete | invalid_rating`) land on their fields via `fieldErrors` instead of rejecting into nothing;
24
+ - `submit-review` accepts `{ product_id, email, reviewer?, review, rating? }` from **anyone — no login**. A signed-in caller's session email always wins (the payload cannot impersonate); a guest supplies `email`; `verified` comes from that email's order history;
18
25
  - `storefront-account` `my-reviews` lists a signed-in customer's own — what a "My reviews" account tab renders from;
19
26
  - moderation is in the admin (Products → Reviews); `commerce/admin-reviews` recalculates the product's rating on every status change.
20
27
 
21
- ## The `policy` prop
28
+ ## The policy is the store's
22
29
 
23
- `policy` is the store's review rule as one option, replacing the patterns a storefront used to implement by hand. Pass your app's current user alongside it (`useProductReviews(product, { policy: "login", user })`) — the stricter policies need it:
30
+ Which visitors may submit is a gate **you** render, in your own words. The three patterns worth knowing:
24
31
 
25
- | `policy` | Who may submit | Blocked as |
32
+ | Policy | Who may submit | How you implement it |
26
33
  |---|---|---|
27
- | `"open"` (default) | anyone with a valid email the server's own rule | |
28
- | `"login"` | a signed-in visitor only | `reviewBlockedReason: "login_required"` |
29
- | `"verified_buyers"` | someone whose own orders include a `processing`/`completed` order for this product (checked via `my-orders`) | `"not_a_buyer"` |
34
+ | **Open** (the server's own rule) | anyone with a valid email | render the form for everyone; the email field is required for guests |
35
+ | **Login-gated** | a signed-in visitor only | render the form only when your app has a user; otherwise your "sign in to review" line |
36
+ | **Verified buyers** | someone whose own orders include a `processing`/`completed` order for this product | check `storefront-account` `my-orders` for the product, gate on the result |
30
37
 
31
- Policies are **UI-side by design**: the server accepts any valid email, so a stricter rule is exactly this gate. `requireRating: true` makes the stars mandatory; `requiresEmail` is `false` for a signed-in visitor, so the field is hidden (the session's email wins server-side anyway). A policy that must hold against handcrafted API calls too belongs in a backend function of your own wrapping `submit-review`. An honest middle ground for most stores: accept everything and render the `verified` flag as a "Verified purchase" badge.
38
+ Policies are **UI-side by design**: the server accepts any valid email, so a stricter rule is exactly this gate and a policy that must hold against handcrafted API calls too belongs in a backend function of your own wrapping `submit-review`. Either way: hide the email field for a signed-in visitor (the session's email wins server-side), and make the stars mandatory by validating before you call. An honest middle ground for most stores: accept everything and render the `verified` flag as a "Verified purchase" badge.
32
39
 
33
40
  ## Auto-approval and moderation
34
41
 
35
42
  `products.auto_approve_reviews` ([`store-settings.md`](./store-settings.md), toggle on the admin's Reviews screen) is the **only** server-side switch: off (the default) holds every review as `hold` for moderation; on publishes immediately.
36
43
 
37
- So **the confirmation copy must come from the response**: `submit()` resolves `{ ok, status, verified }` with `status` `"approved"` or `"hold"`, and `message` follows it. A hardcoded "awaiting approval" lies to every store with auto-approval on — and after an approved submission the hook refreshes the list so the review actually appears.
44
+ So **the confirmation copy must come from the response**: `submitReview` resolves `{ review_id, status, verified }` with `status` `"approved"` or `"hold"`, and your message follows it. A hardcoded "awaiting approval" lies to every store with auto-approval on — and only the approved case is worth refreshing the list for.
38
45
 
39
- Disabling reviews store-wide is simply building no review UI; there is no server switch to keep in sync.
46
+ Disabling reviews store-wide is simply building no review UI; there is no server switch to keep in sync (and then no star ratings on cards either — an average of nothing is `0`).
40
47
 
41
48
  Shapes and error details: [`../docs/api-storefront.md`](../docs/api-storefront.md#submit-review). Where ratings belong per view: [`catalog-rendering.md`](./catalog-rendering.md).
@@ -15,7 +15,7 @@ Six records, one per group: `general`, `products`, `inventory`, `tax`, `shipping
15
15
  |---|---|---|
16
16
  | **`general`** | | |
17
17
  | `order_received_path` | `"/order-received"` | The route a paying customer returns to; `shared/commerce/payments.ts` builds the provider's success/cancel URLs from it. A value that doesn't match your route is a 404 after payment and an order never confirmed. |
18
- | `currency` | `"USD"` | ISO code, **a value not a format**. Stamped on orders by `storefront-checkout`; published on `get-store-info` for `useMoney`/`useFormatMoney`. |
18
+ | `currency` | `"USD"` | ISO code, **a value not a format**. Stamped on orders by `storefront-checkout`; published on `get-store-info` for `useFormatMoney`. |
19
19
  | `weight_unit` / `dimension_unit` | `"kg"` / `"cm"` | Labels only — nothing converts. Admin product form, `get-store-info`. |
20
20
  | **`products`** | | |
21
21
  | `auto_approve_reviews` | `false` | The **only** server-enforced review rule: `submit-review` returns `status: "hold"` or `"approved"`. Confirmation copy comes from that response ([`reviews.md`](./reviews.md)). |
@@ -11,19 +11,24 @@ faster than the cart settles: the hooks are optimistic and debounced, so the
11
11
  DOM is briefly right about the *intent* and wrong about the *state*.
12
12
 
13
13
  - **Wait for the cart, then for each row.** Two waits, neither optional. Before
14
- the first action, wait for the initial load to settle — `status` leaves
15
- `"loading"` exactly once, so the signal is the loaded UI (a row, or the empty
16
- state), never a fixed sleep. Then after every stepper click wait for **that
17
- row**: the click starts a 250ms debounce before the request even leaves, so
18
- reading the quantity or total straight after gives the optimistic number and
19
- stale totals, and two quick clicks send **one** request for the final number.
20
- Wait for the row's busy state to clear (`aria-busy`, re-enabled buttons)
21
- before reading or clicking again.
14
+ the first action, wait for the initial load to settle — `useCart().status`
15
+ leaves `"loading"` exactly once and never returns there for a mutation, so the
16
+ signal is the loaded UI (a row, or the empty state), never a fixed sleep. Then
17
+ after every stepper click wait for **that row**: `useCartLine` starts a 250ms
18
+ debounce before the request even leaves, so reading the quantity or total
19
+ straight after gives the optimistic number and stale totals, and two quick
20
+ clicks send **one** request for the final number. The row's `pending` is the
21
+ only mutation-settled signal there is (`pending === false` with no `error`
22
+ means the row, the totals and any badge have all landed) — wait for the busy
23
+ state the page renders from it (`aria-busy`, re-enabled buttons) before
24
+ reading or clicking again.
22
25
  - **Scope actions to the visible drawer.** With a drawer, the page can hold two
23
- "Remove" buttons for one line — drawer and cart page behind it. A drawer built
24
- on `useCartUI` is unmounted while closed, so its copies can't be hit by
25
- mistake but still query inside the open drawer's container, not the
26
- document. A click that seems to do nothing usually hit a hidden copy.
26
+ "Remove" buttons for one line — drawer and cart page behind it. `useCartUI`
27
+ only hands the page an `open` flag: whether the closed drawer is unmounted
28
+ (`{ui.open && …}`) or merely translated off-screen is the store's choice, so
29
+ assume the duplicates exist and query inside the open drawer's container,
30
+ never the document. A click that seems to do nothing usually hit a hidden
31
+ copy.
27
32
  - **Remove lines one at a time.** Clicking every "Remove" in one pass fails on
28
33
  its own terms: cart calls are serialized, each removal re-renders the list,
29
34
  and buttons collected up front are detached by the time the loop reaches
@@ -42,6 +47,7 @@ DOM is briefly right about the *intent* and wrong about the *state*.
42
47
  so a script that placed an order loses its page context and can land back at
43
48
  `/` — while the order itself was created normally. That is the hard
44
49
  navigation, not a broken redirect. The confirmation is reachable at any time
45
- from a fresh navigation to `/order-received?order_id=…&order_key=…` (the ids
46
- come back in `placeOrder`'s result, and `commerce/admin-orders` `search` has
47
- the order either way).
50
+ from a fresh navigation to `orderReceivedUrl(result)`
51
+ (`/order-received?order_id=…&order_key=…` the ids come back in
52
+ `placeOrder`'s result, and `commerce/admin-orders` `search` has the order
53
+ either way).