@base44/app-plugin-commerce 0.6.9 → 0.6.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@base44/app-plugin-commerce",
3
- "version": "0.6.9",
3
+ "version": "0.6.11",
4
4
  "description": "Base44 Commerce plugin — entities, backend functions, shared commerce engine, admin UI and the commerce skill, shipped as copyable source",
5
5
  "keywords": [
6
6
  "base44",
@@ -116,7 +116,7 @@ import { useProductList, useCategories, useStoreInfo, useFormatMoney, productPri
116
116
 
117
117
  ⚑ **Render paging whenever `hasNext` is true** — `{list.hasNext && <button type="button" onClick={list.next} disabled={list.busy}>…</button>}` (append mode: `list.loadMore`); a page that renders nothing for paging ships a catalog silently capped at `per_page`. Drive filters from `useCategories()`/`useRibbons()` data via `setParams`, never from hardcoded names — a renamed ribbon must not strand a dead button.
118
118
 
119
- A card can render `name`, `productImages(row)[0]`, `productPrice(row, { formatMoney }).label` (already "From €19.99" when the product sells variants — there is no product `type` flag, and `product.price` alone is a rolled-up from-price), `on_sale`, `short_description`, `stock_status`, `average_rating`/`rating_count`, `productRibbons(row)`, `productSpecs(row)`. ⚑ **Images and ribbons are objects, either may be empty** — render your placeholder, never a broken `<img>` or a raw object. Field matrix: [`../references/catalog-rendering.md`](../references/catalog-rendering.md). That list is an inventory, not a card design and not an order to render in. An even grid of identical cards, each carrying the same name/price/stars trio, is where a generated store lands by default and almost never where this catalog belongs: give the grid a rhythm (a hero piece spanning two columns, an editorial break between rows, a denser tile for a large catalog), and lead each card with the one or two fields *these* products are judged on — carat weight, focal length, edition size, ABV — read off `productSpecs(row)`, not the fields every store shows.
119
+ A card can render `name`, `productImages(row)[0]`, `productPrice(row, { formatMoney }).label` (already "From €19.99" when the product sells variants — there is no product `type` flag, and `product.price` alone is a rolled-up from-price), `on_sale`, `short_description`, `stock_status`, `average_rating`/`rating_count`, `productRibbons(row)`, `productSpecs(row)`. ⚑ **Images and ribbons are objects, either may be empty** — render your placeholder, never a broken `<img>` or a raw object. Field matrix: [`../references/catalog-rendering.md`](../references/catalog-rendering.md). That list is an inventory, not a card design and not an order to render in. An even grid of identical cards, each carrying the same name/price/stars trio, is where a generated store lands by default and almost never where this catalog belongs: give the grid a rhythm (a hero piece spanning two columns, an editorial break between rows, a denser tile for a large catalog), and lead each card with the one or two fields *these* products are judged on — carat weight, focal length, ABV — read off `productSpecs(row)`.
120
120
 
121
121
  ⚑ **Ribbons belong in both views** — grid and product page. They are the merchant's own merchandising ("Limited", "Last pieces"), and each links to its filtered listing (`/collection?ribbon_id=<id>`). `productRibbons(row)` hands you `{id, name}` **objects** — render `r.name`, key the link on `r.id`; the entry itself in JSX is React's "Objects are not valid as a React child". Never render a bare "Ribbons:" label with nothing after it. ⚑ **A ribbon link inside a card that is itself a link nests `<a>` in `<a>`** — invalid, React warns. In the grid use plain labels, or link the image and title rather than the whole card; keep ribbon links on the product page.
122
122
 
@@ -194,7 +194,15 @@ Build your layout from — all optional, **not one component style**:
194
194
 
195
195
  ⚑ **Never `.map()` the whole list into one grey label/value table** — that is the single most reliable tell of a generated product page. Design the two or three rows that carry *this* catalog's meaning as what they are (a weight set in the display face, a composition as bars, a provenance beside its place); let the rest fall through to the plain row, and don't feel obliged to keep them in one block — a spec can sit under the gallery, beside the price, or inside the description. Branch on `s.key` too where one particular modifier deserves its own treatment regardless of type. ⚑ **Look a spec up with `findSpec(rows, "care")`** (ignores case, spaces, `_`, `-`): meta keys are free text (`care`, `Care`, `Care Instructions`), so `rows.find(s => s.label === "Care")` silently never matches and renders the fallback forever. `[]` means no section at all.
196
196
  - **Breadcrumbs** — from `categories` (`/collection?category_id=${c.id}`); skip on a flat catalog. Ribbons (`productRibbons(product)`) are labels, not breadcrumbs.
197
- - **Reviews, only if the store wants them** — no review UI is a complete outcome (then no star ratings on cards either: an average of nothing is `0`). `p.reviews` arrives with the product as `{ items, page, per_page, has_next }`; submitting is `submitReview` off `useStorefront()` text or stars, either alone submits. ⚑ **The form renders for every visitor by default** (guests supply an email; hide the field when signed in) login-gate it only when the store asks. ⚑ Derive the confirmation from the response's `status` (`"approved"` vs `"hold"`) — a hardcoded "awaiting approval" lies to every store that auto-approves — and refresh the list after, or the review doesn't appear. Field codes, policies and moderation: [`../references/reviews.md`](../references/reviews.md).
197
+ - **Reviews, only if the store wants them** — no review UI is a complete outcome (then no star ratings on cards either: an average of nothing is `0`). **Both shapes are exactinvented names fail silently** (a 400 naming a field you *did* fill in; blank authors):
198
+
199
+ ```jsx
200
+ p.reviews // { items, page, per_page, has_next }
201
+ p.reviews.items[0] // { id, reviewer, review, rating, verified, created_date }
202
+ await submitReview({ product_id, review, rating, reviewer, email }) // NOT content/reviewer_name/reviewer_email
203
+ ```
204
+
205
+ Text or stars — either alone submits. ⚑ **The form renders for every visitor by default** (guests supply an email; hide it when signed in) — login-gate it only when the store asks. ⚑ Derive the confirmation from the response's `status` (`"approved"` vs `"hold"`) — a hardcoded "awaiting approval" lies to every auto-approving store — and refresh the list after, or the review doesn't appear. Codes, policies, moderation: [`../references/reviews.md`](../references/reviews.md).
198
206
  - **Title** — give each page type its own `<title>` and description; a store whose every page shares one static title is invisible to search. Nothing here emits structured data either — if the store wants rich results, emit your own `Product`/`Offer` JSON-LD from `product` and `view.display` (price, currency, availability).
199
207
 
200
208
  ## Cart / bag
@@ -255,7 +263,7 @@ import { CheckoutProvider, useCheckoutContext, AddressFields, ShippingMethodPick
255
263
 
256
264
  ⚑ Rules: render each picker's `hint` and every branch; a single shipping or payment option still *shows* what it is — never a picker of one, never "nothing selected". ⚑ Picks are instant: both pickers reflect a click immediately (shipping optimistically), and `mustChoose` stays true after a choice — the radios keep rendering, still changeable; never disable options while `syncing`/`choosing` (the hint covers it). Render `addressError` on the address fields. ⚑ Payment methods, currency and countries come from `useStoreInfo()`/`useCountries()` only — `cart.payment_gateways` is always `undefined`, and a default store offers `offline` only, so never hardcode a card option.
257
265
 
258
- ⚑ **A disabled place-order button must say why** — the silent disabled button is the most common checkout dead end. `blockers` is an array of codes; write one line per code, in the store's voice, anchored near the field that fixes it: `empty_cart` (bag is empty) · `billing_incomplete` (required address fields — `missingBillingFields` names them) · `shipping_address_incomplete` (the separate delivery address) · `shipping_address_required` (no address to price yet) · `shipping_method_required` (choose a delivery option) · `shipping_not_available` (this address can't be delivered to) · `payment_method_required` (choose how to pay) · `cart_loading` / `shipping_recalculating` (transient — a quiet "one moment", not an error).
266
+ ⚑ **A disabled place-order button must say why** — the silent disabled button is the most common checkout dead end. `blockers` is an array of codes; write one line per code, in the store's voice, anchored near the field that fixes it: `empty_cart` · `billing_incomplete` (required address fields — `missingBillingFields` names them) · `shipping_address_incomplete` (the separate delivery address) · `shipping_address_required` (no address to price yet) · `shipping_method_required` (choose a delivery option) · `shipping_not_available` (can't deliver there) · `payment_method_required` · `cart_loading` / `shipping_recalculating` (transient — a quiet "one moment", not an error).
259
267
 
260
268
  The pickers' `hint.code` works the same way (`missing_address`, `none_available`, `syncing` for shipping; `none_available` for payment): write those words once, and prefer `hint.serverMessage` when it is set — the backend's explanation is more specific than anything you can write.
261
269
 
@@ -308,7 +316,7 @@ function CheckoutForm() {
308
316
 
309
317
  **`<AddressFields>` is the one shipped component — use it, never hand-roll the address form.** It owns what hand-rolled forms get wrong: the state/province field appears with the right options once a country is picked (shipping rates and taxes match on country *plus* state, so a form without it mis-prices US/CA/AU orders with no error anywhere), every field keeps its `autoComplete` token (what makes browser autofill work), required marks arm on first blur, and the server's "we don't ship there" lands on the country field. `which="shipping"` renders null until `shipToDifferent` is on — the deliver-elsewhere checkbox itself is yours, wired to `c.shipToDifferent` / `c.setShipToDifferent`.
310
318
 
311
- It ships **no CSS** bar a `max-width:100%` cap on the selects (an unstyled checkout must not scroll sideways): every element carries `data-part` (`address-fields`, `field`, `label`, `control`, `required`, `error`) plus `data-key` (the field) and `data-span` (1 or 2 — the field's natural width in a two-column grid), so style it in your `index.css` via `[data-part]` selectors or pass `className`/`classes={{ field, label, control, error }}`. ⚑ **`data-part` sits on the element, not a wrapper** — `select[data-part="control"]`, never `[data-part="control"] input`: the descendant form matches nothing and ships the form unstyled. Props: `includeCompany` (default false), `includePhone` (default true), `omit={["…"]}`, `labels={{ postcode: "ZIP code" }}` (over `addressFieldSpec`'s plain-convention defaults), `selectPlaceholder`, and two escape hatches — `inputRender` swaps the control only (spread the handed `dom` props onto your input), `fieldRender` replaces the whole labeled block. `c.missingBillingFields` stays the live list of what is still missing, if you want your own per-field marks.
319
+ It ships **no CSS** bar a `max-width:100%` cap on the selects (an unstyled checkout must not scroll sideways): every element carries `data-part` (`address-fields`, `field`, `label`, `control`, `required`, `error`) plus `data-key` (the field) and `data-span` (1 or 2 — the field's natural width in a two-column grid), so style it in your `index.css` via `[data-part]` selectors or pass `className`/`classes={{ field, label, control, error }}`. ⚑ **`data-part` sits on the element, not a wrapper** — `select[data-part="control"]`, never `[data-part="control"] input`: the descendant form matches nothing and ships the form unstyled. Props: `includeCompany` (false), `includePhone` (true), `omit={["…"]}`, `labels={{ postcode: "ZIP code" }}`, `selectPlaceholder`, and two escape hatches — `inputRender` swaps the control only (spread the handed `dom` props onto your input), `fieldRender` replaces the whole labeled block. `c.missingBillingFields` stays the live list of what is missing, for your own per-field marks.
312
320
 
313
321
  ⚑ **The `stage === "submitted"` guard goes above the empty-cart branch** — placing an order clears the cart before the browser navigates, and without the guard the page flashes an empty bag over a just-placed order.
314
322
 
@@ -17,7 +17,7 @@ Reviews are **part of the happy path**, not an extra: the backend always shipped
17
17
  Both live on the storefront client in `@/commerce/utils` — in React, `useStorefront()` is that client:
18
18
 
19
19
  - **`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.
20
- - **`submitReview({ product_id, review?, rating?, reviewer?, email? })`** → `{ review_id, status, verified }`. **Text or stars — at least one**: `review` is the body text, `rating` is **1–5 stars** (0 counts as unrated); stars-only and text-only are both valid, only both missing rejects. `reviewer` is the display name. It **rejects** with `email_required` | `review_incomplete` (neither text nor stars) | `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.
20
+ - **`submitReview({ product_id, review?, rating?, reviewer?, email? })`** → `{ review_id, status, verified }`. **Text or stars — at least one**: `review` is the body text, `rating` is **1–5 stars** (0 counts as unrated); stars-only and text-only are both valid, only both missing rejects. `reviewer` is the display name. ⚑ **One set of names, both directions** — the payload above, and rows back as `{ id, reviewer, review, rating, verified, created_date }`. They are the API's names, **not** the ProductReview entity's columns: a near-miss (`content`, `text`, `body`, `comment`; `reviewer_name`, `name`, `author`; `reviewer_email`) throws client-side naming the field it should be, rather than being aliased — a store that posts `content` reads `reviewer_name` back too, so a submit patched into working would still render every author blank. It **rejects** with `email_required` | `review_incomplete` (neither text nor stars) | `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.
21
21
 
22
22
  ## What ships
23
23
 
@@ -24,6 +24,42 @@
24
24
  * payload) returns an action's payload as-is.
25
25
  */
26
26
 
27
+ /**
28
+ * Review payload keys a form reaches for instead of the API's, and what each
29
+ * one should be. These are **rejected by name, never aliased**: a review is
30
+ * submitted with these keys and rendered back with the API's, so a store that
31
+ * posts `content` also reads `reviewer_name` off the response and shows every
32
+ * author as "Anonymous". Accepting the alias would fix the submit and leave the
33
+ * list broken — with nothing failing anywhere to say so. One set of names,
34
+ * enforced at the first call that gets them wrong.
35
+ */
36
+ const REVIEW_FIELD_FIXES = {
37
+ content: "review",
38
+ text: "review",
39
+ body: "review",
40
+ comment: "review",
41
+ reviewer_name: "reviewer",
42
+ name: "reviewer",
43
+ author: "reviewer",
44
+ reviewer_email: "email",
45
+ };
46
+
47
+ const REVIEW_SHAPES =
48
+ "The payload is { product_id, review, rating, reviewer, email }, and reviews come " +
49
+ "back as { id, reviewer, review, rating, verified, created_date } — the same names " +
50
+ "on both sides, and not the ProductReview entity's columns.";
51
+
52
+ /** Throw when a payload uses a near-miss name and leaves the real field empty. */
53
+ function assertReviewFieldNames(payload) {
54
+ const wrong = Object.keys(payload).filter((k) => {
55
+ const canonical = REVIEW_FIELD_FIXES[k];
56
+ return canonical && !String(payload[canonical] ?? "").trim();
57
+ });
58
+ if (!wrong.length) return;
59
+ const fixes = wrong.map((k) => `\`${k}\` should be \`${REVIEW_FIELD_FIXES[k]}\``).join(", ");
60
+ throw new Error(`submitReview: ${fixes}. ${REVIEW_SHAPES}`);
61
+ }
62
+
27
63
  /** The stable error code a failed storefront call carries, if any. */
28
64
  export function storefrontErrorCode(e) {
29
65
  return e?.response?.data?.code ?? e?.data?.code ?? e?.code ?? null;
@@ -113,7 +149,19 @@ export function createStorefront(base44, { storageKey = "cart_token", storage }
113
149
  * signed-in caller (the session's email always wins, so nobody can
114
150
  * review as someone else). At least one of `review` (the text) or
115
151
  * `rating` (1–5 stars) is required — a stars-only submission is valid,
116
- * and so is text-only; only both missing rejects (`review_incomplete`).
152
+ * and so is text-only; with neither this **throws synchronously**, naming
153
+ * the keys it did receive, instead of letting the server answer about a
154
+ * field the customer filled in.
155
+ *
156
+ * **One set of names, both directions.** The payload is `{ product_id,
157
+ * review, rating, reviewer, email }` and a review comes back as `{ id,
158
+ * reviewer, review, rating, verified, created_date }` — not the
159
+ * ProductReview entity's columns. A near-miss (`content`, `text`, `body`,
160
+ * `comment`; `reviewer_name`, `name`, `author`; `reviewer_email`) throws
161
+ * naming the field it should be, and is deliberately **not** aliased: a
162
+ * store that posts `content` reads `reviewer_name` back too, so accepting
163
+ * the alias would fix the submit and leave every author rendering blank,
164
+ * with nothing failing to say why.
117
165
  *
118
166
  * Resolves to `{ review_id, status, verified }` — **`status` is
119
167
  * `"approved"` or `"hold"` depending on the store's `auto_approve_reviews`
@@ -121,7 +169,21 @@ export function createStorefront(base44, { storageKey = "cart_token", storage }
121
169
  * moderation. Rejects with `email_required` | `review_incomplete` |
122
170
  * `invalid_rating` | `not_found`.
123
171
  */
124
- submitReview({ product_id, review, rating, reviewer, email } = {}) {
172
+ submitReview(payload = {}) {
173
+ const { product_id, review, rating, reviewer, email } = payload;
174
+ // A wrong field name used to vanish in the destructure: the text never
175
+ // left the browser and the server answered "review is required" about a
176
+ // field the customer had filled in. Name the mistake instead, here,
177
+ // where the fix also applies to the list the store renders back.
178
+ assertReviewFieldNames(payload);
179
+ const rated = rating != null && Number(rating) >= 1;
180
+ if (!String(review ?? "").trim() && !rated) {
181
+ throw new Error(
182
+ `submitReview needs \`review\` text or a \`rating\` of 1-5. Received: ${
183
+ Object.keys(payload).join(", ") || "nothing"
184
+ }. ${REVIEW_SHAPES}`,
185
+ );
186
+ }
125
187
  return inv("commerce/storefront-catalog", {
126
188
  action: "submit-review",
127
189
  product_id,