@base44/app-plugin-commerce 0.6.9 → 0.6.10
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.
|
|
3
|
+
"version": "0.6.10",
|
|
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,
|
|
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`).
|
|
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 exact — invented 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`
|
|
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` (
|
|
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. ⚑ **These are the API's names, not the entity's columns** — a form built on `content`/`reviewer_name`/`reviewer_email` is submitting nothing; those three are accepted as aliases, but the reviews you render back are always `{ id, reviewer, review, rating, verified, created_date }`, so an author read off `reviewer_name` renders 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,15 @@
|
|
|
24
24
|
* payload) returns an action's payload as-is.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
+
/** First of `keys` holding a non-empty string on `obj` ("" when none does). */
|
|
28
|
+
function firstFilled(obj, keys) {
|
|
29
|
+
for (const k of keys) {
|
|
30
|
+
const v = obj?.[k];
|
|
31
|
+
if (typeof v === "string" && v.trim()) return v.trim();
|
|
32
|
+
}
|
|
33
|
+
return "";
|
|
34
|
+
}
|
|
35
|
+
|
|
27
36
|
/** The stable error code a failed storefront call carries, if any. */
|
|
28
37
|
export function storefrontErrorCode(e) {
|
|
29
38
|
return e?.response?.data?.code ?? e?.data?.code ?? e?.code ?? null;
|
|
@@ -113,7 +122,16 @@ export function createStorefront(base44, { storageKey = "cart_token", storage }
|
|
|
113
122
|
* signed-in caller (the session's email always wins, so nobody can
|
|
114
123
|
* review as someone else). At least one of `review` (the text) or
|
|
115
124
|
* `rating` (1–5 stars) is required — a stars-only submission is valid,
|
|
116
|
-
* and so is text-only;
|
|
125
|
+
* and so is text-only; with neither this **throws synchronously**, naming
|
|
126
|
+
* the keys it did receive, instead of letting the server answer about a
|
|
127
|
+
* field the customer filled in.
|
|
128
|
+
*
|
|
129
|
+
* The payload is `{ product_id, review, rating, reviewer, email }` — the
|
|
130
|
+
* API's names, which are **not** the ProductReview entity's columns.
|
|
131
|
+
* Common near-misses (`content`, `text`, `body`, `comment`;
|
|
132
|
+
* `reviewer_name`, `name`; `reviewer_email`) are accepted as aliases, so a
|
|
133
|
+
* form written against the entity still submits. The reviews you render
|
|
134
|
+
* back always use the API's names: `{ reviewer, review, rating, verified }`.
|
|
117
135
|
*
|
|
118
136
|
* Resolves to `{ review_id, status, verified }` — **`status` is
|
|
119
137
|
* `"approved"` or `"hold"` depending on the store's `auto_approve_reviews`
|
|
@@ -121,7 +139,28 @@ export function createStorefront(base44, { storageKey = "cart_token", storage }
|
|
|
121
139
|
* moderation. Rejects with `email_required` | `review_incomplete` |
|
|
122
140
|
* `invalid_rating` | `not_found`.
|
|
123
141
|
*/
|
|
124
|
-
submitReview(
|
|
142
|
+
submitReview(payload = {}) {
|
|
143
|
+
const { product_id, rating } = payload;
|
|
144
|
+
// Accept the names a review form naturally reaches for. The payload keys
|
|
145
|
+
// are NOT the entity's columns, and a plain destructure dropped every
|
|
146
|
+
// mismatch on the floor: a form posting `content`/`reviewer_name`/
|
|
147
|
+
// `reviewer_email` sent no text at all and got back "review is required"
|
|
148
|
+
// — naming a field the customer had filled in. Canonical names win when
|
|
149
|
+
// both are present; anything still unrecognized throws below rather than
|
|
150
|
+
// vanishing.
|
|
151
|
+
const review = firstFilled(payload, ["review", "content", "text", "body", "comment"]);
|
|
152
|
+
const reviewer = firstFilled(payload, ["reviewer", "reviewer_name", "name", "author"]);
|
|
153
|
+
const email = firstFilled(payload, ["email", "reviewer_email"]);
|
|
154
|
+
const rated = rating != null && Number(rating) >= 1;
|
|
155
|
+
if (!review && !rated) {
|
|
156
|
+
// Fail here, not after a round trip: the server can only report the
|
|
157
|
+
// field it didn't receive, which is never the one that is wrong.
|
|
158
|
+
throw new Error(
|
|
159
|
+
`submitReview needs \`review\` text or a \`rating\` of 1-5. Received: ${
|
|
160
|
+
Object.keys(payload).join(", ") || "nothing"
|
|
161
|
+
}. The payload is { product_id, review, rating, reviewer, email } — not the entity's column names.`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
125
164
|
return inv("commerce/storefront-catalog", {
|
|
126
165
|
action: "submit-review",
|
|
127
166
|
product_id,
|