@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.
- package/README.md +4 -4
- package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
- package/base44/functions/commerce/seed-store/entry.ts +16 -2
- package/package.json +2 -2
- package/scripts/install.js +15 -0
- package/skills/commerce/SKILL.md +34 -17
- package/skills/commerce/docs/api-admin.md +1 -1
- package/skills/commerce/docs/api-storefront.md +12 -12
- package/skills/commerce/install/01-install.md +6 -8
- package/skills/commerce/install/02-storefront.md +180 -278
- package/skills/commerce/install/03-data.md +13 -11
- package/skills/commerce/references/catalog-rendering.md +37 -43
- package/skills/commerce/references/online-payments.md +10 -0
- package/skills/commerce/references/reviews.md +21 -14
- package/skills/commerce/references/store-settings.md +1 -1
- package/skills/commerce/references/storefront-verification.md +21 -15
- package/src/commerce/storefront/StorefrontProvider.jsx +65 -128
- package/src/commerce/storefront/cartUI.jsx +11 -30
- package/src/commerce/storefront/index.js +61 -98
- package/src/commerce/storefront/pickers.jsx +50 -64
- package/src/commerce/storefront/useCartLine.js +23 -130
- package/src/commerce/storefront/useCheckout.jsx +50 -43
- package/src/commerce/storefront/useOrderReturn.js +17 -7
- package/src/commerce/storefront/useProduct.js +41 -97
- package/src/commerce/storefront/useProductList.js +14 -22
- package/src/commerce/utils/address-spec.js +1 -1
- package/src/commerce/utils/images.js +1 -1
- package/src/commerce/utils/index.js +9 -9
- package/src/commerce/utils/price.js +2 -1
- package/src/commerce/utils/specs.js +41 -91
- package/src/commerce/utils/totals.js +7 -4
- package/src/commerce/storefront/useAddressForm.js +0 -166
- package/src/commerce/storefront/usePlaceOrder.js +0 -63
- package/src/commerce/storefront/useProductGallery.js +0 -78
- package/src/commerce/storefront/useProductPrice.js +0 -58
- package/src/commerce/storefront/useProductReviews.js +0 -242
- package/src/commerce/storefront/useStorefrontSeo.js +0 -204
- package/src/commerce/storefront/useTotalsLines.js +0 -109
- 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…)
|
|
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,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",
|
|
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
|
|
|
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
|
|
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
|
|
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 |
|
|
16
|
+
| View | `commerce/storefront-catalog` action | Hook | Returns |
|
|
17
17
|
|---|---|---|---|
|
|
18
|
-
| Listing / grid / search / ribbon & category pages | `
|
|
19
|
-
| Product page | `
|
|
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
|
-
|
|
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
|
|
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
|
|
34
|
-
| `downloadable`, `virtual`, `weight`, `dimensions`, `attributes[]`, `meta_data` | ✅ | ✅ | `attributes[]` is the variant axes; `meta_data`
|
|
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 —
|
|
40
|
+
| `downloads[]`, `download_limit`, `download_expiry` | ❌ | ❌ | **Never** public — only via `commerce/storefront-account` `get-download` |
|
|
41
41
|
|
|
42
|
-
## 2. Two shapes, and
|
|
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 |
|
|
47
|
-
|
|
48
|
-
| **Single item** | `attributes`
|
|
49
|
-
| **Sells variants** | `attributes`
|
|
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
|
|
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
|
|
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 (
|
|
56
|
-
- **Images are objects, and may be absent.** Every stored image is `{src, name, alt}`, never a URL string — passing the object
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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)`
|
|
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
|
|
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`
|
|
89
|
-
| `priceRange` | `{min, max, on_sale, count}` while the selection is incomplete — render via `
|
|
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,
|
|
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
|
|
97
|
-
- **Incomplete selection.** Show the range from `
|
|
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`
|
|
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** —
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
|
4
|
-
skip_when: "
|
|
3
|
+
read_when: "You are building the review surface — the 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
|
|
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
|
|
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
|
|
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
|
|
28
|
+
## The policy is the store's
|
|
22
29
|
|
|
23
|
-
|
|
30
|
+
Which visitors may submit is a gate **you** render, in your own words. The three patterns worth knowing:
|
|
24
31
|
|
|
25
|
-
|
|
|
32
|
+
| Policy | Who may submit | How you implement it |
|
|
26
33
|
|---|---|---|
|
|
27
|
-
|
|
|
28
|
-
|
|
|
29
|
-
|
|
|
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
|
|
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**: `
|
|
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 `
|
|
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`
|
|
15
|
-
`"loading"` exactly once
|
|
16
|
-
state), never a fixed sleep. Then
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
46
|
-
|
|
47
|
-
the order
|
|
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).
|