@base44/app-plugin-commerce 0.2.7 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/package.json +2 -2
- package/scripts/install.js +15 -0
- package/skills/commerce/SKILL.md +26 -12
- 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 +5 -2
- package/skills/commerce/install/02-storefront.md +167 -275
- package/skills/commerce/install/03-data.md +1 -1
- package/skills/commerce/references/catalog-rendering.md +37 -43
- 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
|
@@ -109,7 +109,7 @@ Continent codes in full, state regions, tax binding, VAT-on-shipping, day-2 edit
|
|
|
109
109
|
|
|
110
110
|
## Images
|
|
111
111
|
|
|
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.
|
|
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. 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
113
|
|
|
114
114
|
## Payments — the decision
|
|
115
115
|
|
|
@@ -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.
|
|
@@ -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).
|
|
@@ -8,8 +8,6 @@ import React, {
|
|
|
8
8
|
useState,
|
|
9
9
|
} from "react";
|
|
10
10
|
import {
|
|
11
|
-
attributesLabel,
|
|
12
|
-
cartTotalsLines,
|
|
13
11
|
createStorefront,
|
|
14
12
|
storefrontErrorCode,
|
|
15
13
|
storefrontErrorMessage,
|
|
@@ -18,43 +16,24 @@ import {
|
|
|
18
16
|
/**
|
|
19
17
|
* StorefrontProvider — one client, one store-info cache, ONE shared cart.
|
|
20
18
|
*
|
|
21
|
-
* Mount it once
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
19
|
+
* Mount it **once**, above every storefront page, on a pathless layout route
|
|
20
|
+
* wrapping the layout that renders `<Outlet/>` (`<StorefrontProvider
|
|
21
|
+
* base44={base44}><StoreLayout /></StorefrontProvider>` as that route's
|
|
22
|
+
* `element`); with no shared layout, wrap `<Routes>` instead. Two ways to get
|
|
23
|
+
* this wrong, both with symptoms:
|
|
24
|
+
* - as a child of `<Routes>` React Router throws "is not a `<Route>` component";
|
|
25
|
+
* - a layout that renders the provider *inside* itself leaves the nav outside
|
|
26
|
+
* it, so the cart badge and the cart page read different carts.
|
|
26
27
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* <Route path="/" element={<Home />} /> …
|
|
30
|
-
* </Route>
|
|
28
|
+
* Pass `base44`, or `store={createStorefront(base44)}` when other modules need
|
|
29
|
+
* the same client.
|
|
31
30
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* round either: a layout that renders the provider inside itself puts the nav
|
|
39
|
-
* outside it, so the badge and the cart page read different carts.
|
|
40
|
-
*
|
|
41
|
-
* or, if other modules also need the raw client, create it once and share it:
|
|
42
|
-
*
|
|
43
|
-
* // src/lib/storefront.js
|
|
44
|
-
* import { createStorefront } from "@/commerce/utils";
|
|
45
|
-
* import { base44 } from "@/api/base44Client";
|
|
46
|
-
* export const store = createStorefront(base44);
|
|
47
|
-
*
|
|
48
|
-
* <StorefrontProvider store={store}> ... </StorefrontProvider>
|
|
49
|
-
*
|
|
50
|
-
* What lives here (and why it must not be duplicated per page):
|
|
51
|
-
* - the `createStorefront` instance — it owns the cart_token lifecycle;
|
|
52
|
-
* - store info — payment_gateways, currency, countries/currencies come from
|
|
53
|
-
* `get-store-info` ONLY (never off a cart view), cached for the session;
|
|
54
|
-
* - the cart view — every mutation from any component funnels through one
|
|
55
|
-
* serialized queue, so a slow response can never clobber a newer cart, and
|
|
56
|
-
* a header badge, a cart drawer and the checkout all re-render from the
|
|
57
|
-
* same state.
|
|
31
|
+
* What lives here, and why it must not be duplicated per page: the client (it
|
|
32
|
+
* owns the cart_token lifecycle); store info, cached for the session —
|
|
33
|
+
* payment gateways, currency and countries come from `get-store-info` ONLY,
|
|
34
|
+
* never off a cart view; and the cart, whose every mutation funnels through one
|
|
35
|
+
* serialized queue, so a slow response cannot clobber a newer cart and the
|
|
36
|
+
* badge, the drawer and the checkout all re-render from one state.
|
|
58
37
|
*/
|
|
59
38
|
|
|
60
39
|
const StorefrontContext = createContext(null);
|
|
@@ -167,6 +146,22 @@ export function useStoreInfo() {
|
|
|
167
146
|
};
|
|
168
147
|
}
|
|
169
148
|
|
|
149
|
+
/**
|
|
150
|
+
* The store's country list, **always as an array** — `{ countries, options,
|
|
151
|
+
* loading, error }`, `options` as `{value, label}` for a `<select>`.
|
|
152
|
+
*
|
|
153
|
+
* `useStoreInfo().countries` is `null` until store info resolves, so mapping it
|
|
154
|
+
* directly white-screens the checkout on a cold load — the most severe defect
|
|
155
|
+
* observed in a generated storefront. Here the list is empty-then-full, never
|
|
156
|
+
* null, and `loading` says which.
|
|
157
|
+
*/
|
|
158
|
+
export function useCountries() {
|
|
159
|
+
const { countries, loading, error } = useStoreInfo();
|
|
160
|
+
const list = Array.isArray(countries) ? countries : [];
|
|
161
|
+
const options = useMemo(() => list.map((c) => ({ value: c.code, label: c.name })), [countries]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
162
|
+
return { countries: list, options, loading, error };
|
|
163
|
+
}
|
|
164
|
+
|
|
170
165
|
/**
|
|
171
166
|
* `(amount) => "€19.99"` in the store's currency, per the viewer's locale.
|
|
172
167
|
* Falls back to a plain number while store info is still loading.
|
|
@@ -189,55 +184,49 @@ export function useFormatMoney() {
|
|
|
189
184
|
}
|
|
190
185
|
|
|
191
186
|
/**
|
|
192
|
-
* The shared cart — state
|
|
193
|
-
*
|
|
187
|
+
* The shared cart — state plus every cart mutation. All consumers see the same
|
|
188
|
+
* view; every action resolves to the fresh priced cart it published.
|
|
194
189
|
*
|
|
195
|
-
* const { status,
|
|
196
|
-
* if (status === "loading") return <
|
|
197
|
-
* if (status === "empty") return <
|
|
198
|
-
*
|
|
190
|
+
* const { status, cart, updateItem, removeItem, applyCoupon } = useCart();
|
|
191
|
+
* if (status === "loading") return <YourSkeleton />;
|
|
192
|
+
* if (status === "empty") return <YourEmptyBag />;
|
|
193
|
+
* cart.items.map(item => …) // your rows; useCartLine(item) per row
|
|
199
194
|
*
|
|
200
195
|
* **Branch on `status`, not on `isEmpty`.** `isEmpty` is true while the cart is
|
|
201
196
|
* still loading (there are no items *yet*), so a page that renders its empty
|
|
202
197
|
* state from it flashes "your bag is empty" on every visit before the cart
|
|
203
198
|
* arrives. `status` separates the two: `"loading"` → `"empty"` | `"ready"`.
|
|
204
199
|
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
* `
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
* cart), cleared on the next success. Mutations still reject, so
|
|
216
|
-
* `useCartLine`/`useAddToCart` can handle failures locally.
|
|
217
|
-
* - `applyCoupon(code)` resolves to `{ ok, cart }` or `{ ok: false, code,
|
|
218
|
-
* message }` — an invalid code is expected flow, not an exception.
|
|
219
|
-
*
|
|
220
|
-
* ## When `status` settles — and what it does not cover
|
|
200
|
+
* Rendering `cart.items` is yours; three shapes in there are easy to misread,
|
|
201
|
+
* and `@/commerce/utils` has the helper for each:
|
|
202
|
+
* - `item.attributes` is an **array** of `{name, option}`, never a map →
|
|
203
|
+
* `attributesLabel(item.attributes)`.
|
|
204
|
+
* - `item.purchasable` is a **result object** `{ok, code, error}`, not a
|
|
205
|
+
* boolean — truthiness alone calls every line purchasable.
|
|
206
|
+
* - the cart's totals are **nested** (`cart.totals`), an order's are flat →
|
|
207
|
+
* `cartTotalsLines(cart, { formatMoney })` renders every line the store
|
|
208
|
+
* actually has, including the discount and tax rows a hand-written summary
|
|
209
|
+
* drops.
|
|
221
210
|
*
|
|
222
|
-
* `
|
|
223
|
-
*
|
|
224
|
-
* settles once, to `"empty"` or `"ready"`, and after that:
|
|
211
|
+
* Also render `cart.coupon_notices` / `cart.removed_items` when present: they
|
|
212
|
+
* say what auto-dropped and why, in the server's words.
|
|
225
213
|
*
|
|
226
|
-
* -
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
* checkout consumes the cart — never "still arriving".
|
|
234
|
-
* - It flips `"empty"` → `"ready"` when the first line lands, so a header badge
|
|
235
|
-
* and a drawer switch states off the same signal.
|
|
214
|
+
* - `mutationError` is the last failed mutation (insufficient stock, an expired
|
|
215
|
+
* cart) as `{code, message}`, cleared on the next success. Mutations still
|
|
216
|
+
* reject, so a caller can handle failures locally too.
|
|
217
|
+
* - `applyCoupon(code)` resolves `{ ok, cart }` or `{ ok: false, code, message }`
|
|
218
|
+
* — an invalid code is expected flow, not an exception. Coupons are admin-only
|
|
219
|
+
* data, so a seeded code is reachable **only** through a field the customer
|
|
220
|
+
* types it into: a store with coupons needs one, in the cart or the checkout.
|
|
236
221
|
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
222
|
+
* `status` settles **once** — `"loading"` means only that the session's first
|
|
223
|
+
* `getCart()` is outstanding. A mutation never returns it to `"loading"`
|
|
224
|
+
* (previous numbers stay on screen until the new view lands), so there is no
|
|
225
|
+
* cart-wide busy flag by design: the right busy scope is the row
|
|
226
|
+
* (`useCartLine().pending`) or the control that started it. `"empty"` therefore
|
|
227
|
+
* always means *loaded, with no items* — including after checkout consumes the
|
|
228
|
+
* cart. Anything waiting for a mutation to land waits on `pending`, never on
|
|
229
|
+
* `status`.
|
|
241
230
|
*/
|
|
242
231
|
export function useCart() {
|
|
243
232
|
const { client, cart, cartError, mutationError, runCart } = useStorefrontState();
|
|
@@ -270,58 +259,9 @@ export function useCart() {
|
|
|
270
259
|
[client, runCart],
|
|
271
260
|
);
|
|
272
261
|
|
|
273
|
-
const formatMoney = useFormatMoney();
|
|
274
262
|
const items = cart?.items ?? [];
|
|
275
263
|
const loading = cart === undefined;
|
|
276
264
|
|
|
277
|
-
const lines = useMemo(
|
|
278
|
-
() =>
|
|
279
|
-
items.map((item) => {
|
|
280
|
-
// A cart line's `purchasable` is a RESULT object ({ ok, code, error }),
|
|
281
|
-
// not a boolean — truthiness alone would call every line purchasable.
|
|
282
|
-
const purchasable = item.purchasable?.ok ?? Boolean(item.purchasable);
|
|
283
|
-
// `sold_individually` is the only per-line ceiling the cart view
|
|
284
|
-
// carries; real stock limits surface as `insufficient_stock` from the
|
|
285
|
-
// server when a customer tries to exceed them.
|
|
286
|
-
const maxQuantity = item.sold_individually ? 1 : Infinity;
|
|
287
|
-
return {
|
|
288
|
-
...item,
|
|
289
|
-
attributesLabel: attributesLabel(item.attributes),
|
|
290
|
-
image: item.image ? { src: item.image, alt: item.name ?? "" } : null,
|
|
291
|
-
totalLabel: item.total != null ? formatMoney(item.total) : "",
|
|
292
|
-
subtotalLabel: item.subtotal != null ? formatMoney(item.subtotal) : "",
|
|
293
|
-
unitPriceLabel: item.price != null ? formatMoney(item.price) : "",
|
|
294
|
-
maxQuantity,
|
|
295
|
-
canIncrease: item.quantity < maxQuantity,
|
|
296
|
-
canDecrease: item.quantity > 1,
|
|
297
|
-
purchasable,
|
|
298
|
-
unavailableReason: purchasable ? null : (item.purchasable?.error ?? null),
|
|
299
|
-
};
|
|
300
|
-
}),
|
|
301
|
-
[items, formatMoney],
|
|
302
|
-
);
|
|
303
|
-
|
|
304
|
-
const notices = useMemo(
|
|
305
|
-
() => [
|
|
306
|
-
...(cart?.coupon_notices ?? []).map((n) => ({
|
|
307
|
-
kind: "coupon",
|
|
308
|
-
code: n.error_code ?? n.code ?? "coupon_invalid",
|
|
309
|
-
message: n.error ?? `Coupon ${n.code} is no longer valid.`,
|
|
310
|
-
})),
|
|
311
|
-
...(cart?.removed_items ?? []).map((r) => ({
|
|
312
|
-
kind: "removed_item",
|
|
313
|
-
code: r.code ?? "unavailable",
|
|
314
|
-
message: r.reason ?? "An item is no longer available and was removed.",
|
|
315
|
-
})),
|
|
316
|
-
],
|
|
317
|
-
[cart],
|
|
318
|
-
);
|
|
319
|
-
|
|
320
|
-
const totalsLines = useMemo(
|
|
321
|
-
() => (cart ? cartTotalsLines(cart, { formatMoney }) : []),
|
|
322
|
-
[cart, formatMoney],
|
|
323
|
-
);
|
|
324
|
-
|
|
325
265
|
return {
|
|
326
266
|
cart: cart ?? null,
|
|
327
267
|
loading,
|
|
@@ -330,9 +270,6 @@ export function useCart() {
|
|
|
330
270
|
itemCount: items.reduce((n, i) => n + (i.quantity || 0), 0),
|
|
331
271
|
isEmpty: !items.length,
|
|
332
272
|
status: loading ? "loading" : items.length ? "ready" : "empty",
|
|
333
|
-
lines,
|
|
334
|
-
notices,
|
|
335
|
-
totalsLines,
|
|
336
273
|
refresh,
|
|
337
274
|
addItem,
|
|
338
275
|
updateItem,
|