@base44/app-plugin-commerce 0.6.8 → 0.6.9

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.
@@ -24,7 +24,7 @@
24
24
  },
25
25
  "review": {
26
26
  "type": "string",
27
- "description": "Review content"
27
+ "description": "Review text. May be empty for a stars-only review (submit-review requires text or a rating, not both)."
28
28
  },
29
29
  "rating": {
30
30
  "type": "integer",
@@ -38,7 +38,7 @@
38
38
  "description": "Derived: reviewer purchased the product. Set by commerce/storefront-catalog submit-review."
39
39
  }
40
40
  },
41
- "required": ["product_id", "review"],
41
+ "required": ["product_id"],
42
42
  "rls": {
43
43
  "read": { "user_condition": { "role": "admin" } },
44
44
  "create": { "user_condition": { "role": "admin" } },
@@ -396,12 +396,16 @@ async function submitReview(sr: any, p: any, user: any): Promise<any> {
396
396
  const review = String(p.review ?? "").trim();
397
397
  const rating = p.rating == null ? null : Math.floor(Number(p.rating));
398
398
 
399
- if (!review) {
400
- throw new HttpError(400, "review is required.", "review_incomplete");
401
- }
402
399
  if (rating != null && (rating < 0 || rating > 5)) {
403
400
  throw new HttpError(400, "Rating must be between 0 and 5.", "invalid_rating");
404
401
  }
402
+ // Text or stars — either alone is a valid review. Requiring text rejected
403
+ // every stars-only submission from a form that treats the write-up as
404
+ // optional, with a 400 the customer can't act on. Rating 0 is "unrated"
405
+ // (the aggregates ignore it), so it can't stand in for the missing text.
406
+ if (!review && !(rating != null && rating >= 1)) {
407
+ throw new HttpError(400, "A review needs text or a star rating.", "review_incomplete");
408
+ }
405
409
 
406
410
  const verified = await hasPurchased(sr, reviewerEmail, product.id);
407
411
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@base44/app-plugin-commerce",
3
- "version": "0.6.8",
3
+ "version": "0.6.9",
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",
@@ -118,7 +118,7 @@ batch (above).
118
118
  | Topic | Open when | Size |
119
119
  |---|---|---|
120
120
  | [`install/01-install.md`](./install/01-install.md) | installing — routes you to 02 and 03 | 6K |
121
- | [`install/02-storefront.md`](./install/02-storefront.md) | building storefront pages | 36K |
121
+ | [`install/02-storefront.md`](./install/02-storefront.md) | building storefront pages | 37K |
122
122
  | [`install/03-data.md`](./install/03-data.md) | seeding catalog, shipping rates/zones, payments; re-callable per slice | 11K |
123
123
  | [`docs/entities.md`](./docs/entities.md) | any direct entity read/write ("which entity holds X") | 11K |
124
124
  | [`references/catalog-rendering.md`](./references/catalog-rendering.md) | field shapes each catalog call returns, variant edge cases | 16K |
@@ -114,14 +114,14 @@ This is the **only** way a storefront can enumerate ribbons (the entity is admin
114
114
  No payload. Returns `{ "attributes": [ { ...attribute, "terms": [ ...values ] } ] }` — each attribute (`id, name, code, order`) with its values (`id, attribute_id, name, order, count`), both sorted by `order`; for filter UIs. Filter with `list-products` `attribute_id` (id or attribute **name**) + `attribute_term` (the value name); `code` is the stable key for a URL.
115
115
 
116
116
  ### `submit-review`
117
- **Payload:** `{ product_id, email?, reviewer?, review, rating? }` — `email` is required for guests (`400 email_required`); a signed-in caller's session email always wins.
117
+ **Payload:** `{ product_id, email?, reviewer?, review?, rating? }` — at least one of `review` (text) or `rating` (1–5); `email` is required for guests (`400 email_required`); a signed-in caller's session email always wins.
118
118
 
119
119
  > A React storefront reaches this call as `submitReview` (and the paginated list as `getProductReviews`) on the client from `@/commerce/storefront`'s `useStorefront()`, with the list itself already riding along on the product; policies and moderation are [`../references/reviews.md`](../references/reviews.md). Read on for the raw contract.
120
120
 
121
- **Public by default: anyone can review with an email address — no login.** A guest passes `email`; for a signed-in caller the session email always wins (the payload cannot impersonate). `reviewer` is the display name only, defaulting to the account's `full_name` then the email's local part. `rating` is optional (0–5). `verified` is derived from the email's order history. Status is `hold` unless `products.auto_approve_reviews` — the one server-side switch, so a hardcoded "awaiting approval" message is wrong when it is on. Stricter policies (login-gated, verified buyers only, rating required) are the storefront's own gate around this call — the server accepts any valid email: [`../references/reviews.md`](../references/reviews.md).
121
+ **Public by default: anyone can review with an email address — no login.** A guest passes `email`; for a signed-in caller the session email always wins (the payload cannot impersonate). `reviewer` is the display name only, defaulting to the account's `full_name` then the email's local part. **Text or stars — either alone is valid**: `rating` is 0–5 (0 = unrated, so a stars-only submission needs 1–5); `review_incomplete` fires only when both are missing. `verified` is derived from the email's order history. Status is `hold` unless `products.auto_approve_reviews` — the one server-side switch, so a hardcoded "awaiting approval" message is wrong when it is on. Stricter policies (login-gated, verified buyers only, rating required) are the storefront's own gate around this call — the server accepts any valid email: [`../references/reviews.md`](../references/reviews.md).
122
122
 
123
123
  **Response:** `{ "review_id", "status": "hold"|"approved", "verified": true }`
124
- **Errors:** `404 not_found`, `400 email_required|review_incomplete|invalid_rating`.
124
+ **Errors:** `404 not_found`, `400 email_required|review_incomplete` (neither text nor stars)`|invalid_rating`.
125
125
 
126
126
  ---
127
127
 
@@ -156,9 +156,13 @@ Build your layout from — all optional, **not one component style**:
156
156
  - **Variant selector** — `variantAxes(view, p.pick)`, one entry per axis:
157
157
 
158
158
  ```jsx
159
+ const SWATCH = { Ivory: "#F2EDE4", "Obsidian Black": "#101014" }; // this catalog's colour names → CSS
159
160
  {variantAxes(view, p.pick).map((axis) => (
160
161
  <fieldset key={axis.key}>{/* label from axis.name / axis.selectedOption */}
161
- {axis.options.map((o) => (
162
+ {axis.options.map((o) => /colou?r/i.test(axis.name) ? (
163
+ <button key={o.value} disabled={o.disabled} aria-pressed={o.selected} onClick={o.pick}
164
+ className="swatch" style={{ background: SWATCH[o.value] }} title={o.value} aria-label={o.value} />
165
+ ) : (
162
166
  <button key={o.value} disabled={o.disabled} aria-pressed={o.selected} onClick={o.pick}>
163
167
  {o.value}{/* o.outOfStock → mark visibly */}
164
168
  </button>
@@ -167,7 +171,7 @@ Build your layout from — all optional, **not one component style**:
167
171
  ))}
168
172
  ```
169
173
 
170
- ⚑ **One control per axis, never a list of variations**, and ⚑ **an unbuyable option renders `disabled`, never hidden** (`outOfStock` stays visible, just marked). `view.missingAxes` names what's unpicked. **Render each axis by what it is — pick the control per attribute**: a colour axis as colour circles with the name as a hint (a swatch alone leaves "Ivory" vs "Sand" unguessable), size chips with a size guide, a select for a long list a specialty control where it genuinely fits, not on every axis; yet every axis as the identical chip row is a generated-page tell. Any control keeps the contract: disabled, out-of-stock marked, selection visible.
174
+ ⚑ **One control per axis, never a list of variations**, and ⚑ **an unbuyable option renders `disabled`, never hidden** (`outOfStock` stays visible, just marked). `view.missingAxes` names what's unpicked. **Pick the control per attribute, as the branch above does**: a colour axis as colour circles (the name stays reachable `title`, `aria-label`, and the axis label showing `selectedOption`), size chips beside a size guide, a select for a long list; a specialty control where it genuinely fits, not on every axis but every axis as the same bare chip row is the flattest page this kit produces. Any control keeps the contract: disabled, out-of-stock marked, selection visible.
171
175
  - **Buy box** — one button, and **you supply its four words**:
172
176
 
173
177
  ```jsx
@@ -190,7 +194,7 @@ Build your layout from — all optional, **not one component style**:
190
194
 
191
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.
192
196
  - **Breadcrumbs** — from `categories` (`/collection?category_id=${c.id}`); skip on a flat catalog. Ribbons (`productRibbons(product)`) are labels, not breadcrumbs.
193
- - **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()`. ⚑ **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`). `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).
194
198
  - **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).
195
199
 
196
200
  ## Cart / bag
@@ -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 }`. `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.
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.
21
21
 
22
22
  ## What ships
23
23
 
@@ -36,7 +36,7 @@ Which visitors may submit is a gate **you** render, in your own words — and **
36
36
  | **Login-gated** | a signed-in visitor only | one conditional around the same form: render it when your app has a user, otherwise your "sign in to review" line |
37
37
  | **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 |
38
38
 
39
- 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.
39
+ 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). The server needs only one of text/stars — if your form makes either (or both) mandatory, validate before you call, so the customer meets your words rather than a raw 400. An honest middle ground for most stores: accept everything and render the `verified` flag as a "Verified purchase" badge.
40
40
 
41
41
  ## Auto-approval and moderation
42
42
 
@@ -137,7 +137,10 @@ export default function Reviews() {
137
137
  label: "Review",
138
138
  render: (row) => (
139
139
  <div className="max-w-md">
140
- <p className="line-clamp-2 text-sm">{row.review}</p>
140
+ {/* A rating-only review is valid — say so, or the row reads as blank. */}
141
+ {row.review
142
+ ? <p className="line-clamp-2 text-sm">{row.review}</p>
143
+ : <p className="text-sm italic text-muted-foreground">Rating only — no text</p>}
141
144
  {row.verified && <span className="text-xs text-green-700">✓ Verified owner</span>}
142
145
  </div>
143
146
  ),
@@ -111,7 +111,9 @@ export function createStorefront(base44, { storageKey = "cart_token", storage }
111
111
  /**
112
112
  * Submit a review. `email` is required for a guest and ignored for a
113
113
  * signed-in caller (the session's email always wins, so nobody can
114
- * review as someone else). `rating` is optional, 0–5.
114
+ * review as someone else). At least one of `review` (the text) or
115
+ * `rating` (1–5 stars) is required — a stars-only submission is valid,
116
+ * and so is text-only; only both missing rejects (`review_incomplete`).
115
117
  *
116
118
  * Resolves to `{ review_id, status, verified }` — **`status` is
117
119
  * `"approved"` or `"hold"` depending on the store's `auto_approve_reviews`