@base44/app-plugin-commerce 0.6.7 → 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.
- package/base44/entities/commerce.ProductReview.jsonc +2 -2
- package/base44/functions/commerce/storefront-catalog/entry.ts +7 -3
- package/package.json +1 -1
- package/skills/commerce/SKILL.md +2 -2
- package/skills/commerce/docs/api-storefront.md +3 -3
- package/skills/commerce/install/02-storefront.md +9 -5
- package/skills/commerce/references/reviews.md +7 -6
- package/src/commerce/admin/layout/Sidebar.jsx +15 -5
- package/src/commerce/admin/pages/products/Reviews.jsx +4 -1
- package/src/commerce/storefront/pickers.jsx +19 -5
- package/src/commerce/storefront/useCheckout.jsx +28 -3
- package/src/commerce/utils/storefront.js +3 -1
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
},
|
|
25
25
|
"review": {
|
|
26
26
|
"type": "string",
|
|
27
|
-
"description": "Review
|
|
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"
|
|
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.
|
|
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",
|
package/skills/commerce/SKILL.md
CHANGED
|
@@ -118,14 +118,14 @@ 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 |
|
|
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 |
|
|
125
125
|
| [`references/shipping-and-tax.md`](./references/shipping-and-tax.md) | zones beyond 03's recipe, taxes, day-2 edits | 8K |
|
|
126
126
|
| [`references/online-payments.md`](./references/online-payments.md) | enabling card payments, or wiring the provider — at install or any time later | 8K |
|
|
127
127
|
| [`references/storefront-verification.md`](./references/storefront-verification.md) | driving the storefront from a browser script | 3K |
|
|
128
|
-
| [`references/reviews.md`](./references/reviews.md) | review policies (login-gated, verified buyers), moderation |
|
|
128
|
+
| [`references/reviews.md`](./references/reviews.md) | review policies (open by default; login-gated, verified buyers), moderation | 6K |
|
|
129
129
|
| [`references/store-settings.md`](./references/store-settings.md) | changing store behavior through settings keys | 5K |
|
|
130
130
|
| [`references/emails.md`](./references/emails.md) | order-email recipients, subjects, the log | 5K |
|
|
131
131
|
| [`references/admin-product-form.md`](./references/admin-product-form.md) | editing the shipped product editor | 6K |
|
|
@@ -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
|
|
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
|
|
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
|
|
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. **
|
|
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()
|
|
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
|
|
@@ -249,7 +253,7 @@ import { CheckoutProvider, useCheckoutContext, AddressFields, ShippingMethodPick
|
|
|
249
253
|
|
|
250
254
|
`useCheckout` reprices shipping/tax from the address automatically (debounced, never on a half-typed address), derives the shipping and payment choices, gates the button, and `placeOrder()` handles **both** navigations — online gateway → provider redirect, everything else → `/order-received` — as **full page loads** (`<CheckoutProvider options={{ orderReceivedPath: null }}>` for a router transition instead). `CheckoutProvider` shares it across the page's regions.
|
|
251
255
|
|
|
252
|
-
⚑ 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". 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.
|
|
256
|
+
⚑ 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.
|
|
253
257
|
|
|
254
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).
|
|
255
259
|
|
|
@@ -282,7 +286,7 @@ function CheckoutForm() {
|
|
|
282
286
|
{m.title} {m.costLabel}
|
|
283
287
|
</label>
|
|
284
288
|
))}
|
|
285
|
-
{!mustChoose && chosen && <p>{chosen.title} {chosen.costLabel}</p>}
|
|
289
|
+
{!mustChoose && chosen && <p>{chosen.title} {chosen.costLabel}</p>}{/* the lone option */}
|
|
286
290
|
</fieldset>
|
|
287
291
|
)}
|
|
288
292
|
</ShippingMethodPicker>
|
|
@@ -5,6 +5,7 @@ skip_when: "The product page already renders `p.reviews` and submits through `su
|
|
|
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
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
|
+
- "The review form renders for every visitor by default — guests included, with an email field. Login-gating is an explicit store request, implemented as one conditional around the form."
|
|
8
9
|
---
|
|
9
10
|
|
|
10
11
|
# Reviews
|
|
@@ -16,7 +17,7 @@ Reviews are **part of the happy path**, not an extra: the backend always shipped
|
|
|
16
17
|
Both live on the storefront client in `@/commerce/utils` — in React, `useStorefront()` is that client:
|
|
17
18
|
|
|
18
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.
|
|
19
|
-
- **`submitReview({ product_id, review
|
|
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
21
|
|
|
21
22
|
## What ships
|
|
22
23
|
|
|
@@ -25,17 +26,17 @@ Both live on the storefront client in `@/commerce/utils` — in React, `useStore
|
|
|
25
26
|
- `storefront-account` `my-reviews` lists a signed-in customer's own — what a "My reviews" account tab renders from;
|
|
26
27
|
- moderation is in the admin (Products → Reviews); `commerce/admin-reviews` recalculates the product's rating on every status change.
|
|
27
28
|
|
|
28
|
-
## The policy is the store's
|
|
29
|
+
## The policy is the store's — and open is the default
|
|
29
30
|
|
|
30
|
-
Which visitors may submit is a gate **you** render, in your own words. The
|
|
31
|
+
Which visitors may submit is a gate **you** render, in your own words — and **the default is no gate at all**. Build the form for every visitor, signed in or not (that is the server's own rule); a review form a guest cannot use is a policy the store has to ask for, never something to add on your own initiative. The two stricter patterns are each one small change away when the store does ask:
|
|
31
32
|
|
|
32
33
|
| Policy | Who may submit | How you implement it |
|
|
33
34
|
|---|---|---|
|
|
34
|
-
| **Open
|
|
35
|
-
| **Login-gated** | a signed-in visitor only |
|
|
35
|
+
| **Open — the default** | anyone with a valid email | render the form for everyone; the email field is required for guests, hidden when signed in |
|
|
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 |
|
|
36
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 |
|
|
37
38
|
|
|
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)
|
|
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.
|
|
39
40
|
|
|
40
41
|
## Auto-approval and moderation
|
|
41
42
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React, { useEffect, useRef, useState } from "react";
|
|
2
|
-
import { NavLink, useLocation } from "react-router-dom";
|
|
2
|
+
import { Link, NavLink, useLocation } from "react-router-dom";
|
|
3
3
|
import { Badge } from "@/components/ui/badge";
|
|
4
4
|
import {
|
|
5
5
|
BadgePercent,
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
Settings,
|
|
10
10
|
ShoppingCart,
|
|
11
11
|
Sparkles,
|
|
12
|
+
Store,
|
|
12
13
|
Users,
|
|
13
14
|
} from "lucide-react";
|
|
14
15
|
import { call } from "../lib/api";
|
|
@@ -105,8 +106,17 @@ export default function Sidebar({ onNavigate, onOpenBot }) {
|
|
|
105
106
|
</div>
|
|
106
107
|
))}
|
|
107
108
|
</nav>
|
|
108
|
-
|
|
109
|
-
|
|
109
|
+
<div className="space-y-0.5 border-t p-3">
|
|
110
|
+
{/* The way back out — the storefront the merchant is managing. */}
|
|
111
|
+
<Link
|
|
112
|
+
to="/"
|
|
113
|
+
onClick={onNavigate}
|
|
114
|
+
className="flex items-center gap-2.5 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
115
|
+
>
|
|
116
|
+
<Store className="h-4 w-4 shrink-0" />
|
|
117
|
+
<span className="flex-1">View storefront</span>
|
|
118
|
+
</Link>
|
|
119
|
+
{onOpenBot && (
|
|
110
120
|
<button
|
|
111
121
|
type="button"
|
|
112
122
|
onClick={onOpenBot}
|
|
@@ -115,8 +125,8 @@ export default function Sidebar({ onNavigate, onOpenBot }) {
|
|
|
115
125
|
<Sparkles className="h-4 w-4 shrink-0 text-primary" />
|
|
116
126
|
<span className="flex-1 text-left">StoreAdmin bot</span>
|
|
117
127
|
</button>
|
|
118
|
-
|
|
119
|
-
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
120
130
|
</div>
|
|
121
131
|
);
|
|
122
132
|
}
|
|
@@ -137,7 +137,10 @@ export default function Reviews() {
|
|
|
137
137
|
label: "Review",
|
|
138
138
|
render: (row) => (
|
|
139
139
|
<div className="max-w-md">
|
|
140
|
-
|
|
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
|
),
|
|
@@ -50,12 +50,20 @@ function resolveCheckout(name, prop, ctx) {
|
|
|
50
50
|
* chosen the chosen/auto-selected entry, or null
|
|
51
51
|
* selected alias of `chosen` (the name the payment picker uses)
|
|
52
52
|
* choose (id) => Promise
|
|
53
|
-
* mustChoose
|
|
53
|
+
* mustChoose more than one method → render methods as a picker
|
|
54
54
|
* single exactly one method offered — already chosen
|
|
55
|
+
* choosing a pick is in flight — `selected` already reflects it
|
|
55
56
|
* syncing an address edit is being repriced
|
|
56
57
|
* hint { code, severity, serverMessage } | null (see above)
|
|
57
58
|
* addressError { code, message } | null — the server's own words
|
|
58
59
|
*
|
|
60
|
+
* `mustChoose` stays true after a choice is made — with several methods the
|
|
61
|
+
* radios keep rendering, the chosen one checked, so the customer can change
|
|
62
|
+
* their mind; collapsing a made choice to static text strands it. A pick
|
|
63
|
+
* reflects in `selected` from the click (optimistically, while the priced view
|
|
64
|
+
* catches up), so never disable the options while `choosing`/`syncing` — the
|
|
65
|
+
* hint line is the affordance for that, not a locked control.
|
|
66
|
+
*
|
|
59
67
|
* `single` and `mustChoose` are never both true, and `single` guarantees
|
|
60
68
|
* `chosen` — a single option still *shows* what it is (title + `costLabel`),
|
|
61
69
|
* never a picker of one. Never render `cart.chosen_shipping_method` directly:
|
|
@@ -70,6 +78,7 @@ export function ShippingMethodPicker({ checkout: checkoutProp, children }) {
|
|
|
70
78
|
chosenShippingMethod: chosen,
|
|
71
79
|
singleShippingMethod: single,
|
|
72
80
|
chooseShippingMethod: choose,
|
|
81
|
+
shippingChoicePending: choosing,
|
|
73
82
|
shippingSyncing: syncing,
|
|
74
83
|
addressError,
|
|
75
84
|
} = checkout;
|
|
@@ -96,8 +105,11 @@ export function ShippingMethodPicker({ checkout: checkoutProp, children }) {
|
|
|
96
105
|
chosen: decoratedChosen,
|
|
97
106
|
selected: decoratedChosen,
|
|
98
107
|
choose,
|
|
99
|
-
|
|
108
|
+
// True whenever there is a real choice — including after one is made, so a
|
|
109
|
+
// made choice stays changeable instead of collapsing to static text.
|
|
110
|
+
mustChoose: status === "choice_required" || methods.length > 1,
|
|
100
111
|
single: single ?? (methods.length === 1), // fallback: a hand-built checkout object
|
|
112
|
+
choosing: choosing ?? false,
|
|
101
113
|
syncing,
|
|
102
114
|
hint,
|
|
103
115
|
addressError,
|
|
@@ -119,9 +131,11 @@ export function ShippingMethodPicker({ checkout: checkoutProp, children }) {
|
|
|
119
131
|
*
|
|
120
132
|
* `single` guarantees `value` and `selected` — never render the one-gateway
|
|
121
133
|
* branch as "nothing selected yet" — and both re-resolve when store info
|
|
122
|
-
* changes. A
|
|
123
|
-
*
|
|
124
|
-
*
|
|
134
|
+
* changes. A pick is plain local state: it reflects the instant it is clicked,
|
|
135
|
+
* with no server round trip — so never disable these options while the cart is
|
|
136
|
+
* repricing elsewhere. A default-seeded store offers `offline` only, so never
|
|
137
|
+
* hardcode a card option. Titles and descriptions are the admin's copy: render
|
|
138
|
+
* them, don't invent your own.
|
|
125
139
|
*/
|
|
126
140
|
export function PaymentMethodPicker({ checkout: checkoutProp, children }) {
|
|
127
141
|
const checkout = resolveCheckout("PaymentMethodPicker", checkoutProp, useCheckoutContextOptional());
|
|
@@ -69,6 +69,10 @@ function resolvePaymentMethod(gateways, picked) {
|
|
|
69
69
|
* `not_needed` (virtual cart — render nothing). `singleShippingMethod` flags
|
|
70
70
|
* the one-option case and `chosenShippingMethod` is filled for it, so a single
|
|
71
71
|
* option is never left unselected and never rendered as a picker of one.
|
|
72
|
+
* **A pick applies optimistically**: `chosenShippingMethod` reflects it from
|
|
73
|
+
* the click, while the priced view catches up in the background
|
|
74
|
+
* (`shippingChoicePending` is true for that window) — a radio that waits for
|
|
75
|
+
* the server to look checked reads as a picker that ignored the click.
|
|
72
76
|
* - **Payment choice.** `paymentMethods` come from store info (their ONLY
|
|
73
77
|
* source — `cart.payment_gateways` is always undefined). One enabled gateway
|
|
74
78
|
* is selected from the first render that has store info. The selection is
|
|
@@ -193,18 +197,33 @@ export function useCheckout(options = {}) {
|
|
|
193
197
|
const shippingStatus = cart?.shipping_status ?? null;
|
|
194
198
|
const shippingMethods = cart?.available_shipping_methods ?? [];
|
|
195
199
|
const singleShippingMethod = shippingMethods.length === 1;
|
|
200
|
+
// The optimistic pick: `choose-shipping-method` is a priced round trip, and a
|
|
201
|
+
// radio that only looks checked once the response lands reads as a picker
|
|
202
|
+
// that ignored the click. The pick covers exactly the in-flight window — it
|
|
203
|
+
// clears when its own call settles, so the confirmed view (or, on failure,
|
|
204
|
+
// the cart's stored choice) takes back over without a flicker.
|
|
205
|
+
const [pickedShippingMethod, setPickedShippingMethod] = useState("");
|
|
196
206
|
// The backend auto-selects when it offers exactly one rate (`auto_selected`)
|
|
197
207
|
// and echoes it back on the cart, so the lookup normally finds it. The
|
|
198
208
|
// fallback covers the seam where a freshly repriced address offers one rate
|
|
199
209
|
// the cart's stored id hasn't caught up with: a single option is selected,
|
|
200
210
|
// and must read as selected, from the moment it is offered.
|
|
201
211
|
const chosenShippingMethod =
|
|
212
|
+
shippingMethods.find((m) => m.id === pickedShippingMethod) ??
|
|
202
213
|
shippingMethods.find((m) => m.id === cart?.chosen_shipping_method) ??
|
|
203
214
|
(singleShippingMethod ? shippingMethods[0] : null);
|
|
204
215
|
const chooseShippingMethod = useCallback(
|
|
205
|
-
(methodId) =>
|
|
216
|
+
(methodId) => {
|
|
217
|
+
setPickedShippingMethod(methodId);
|
|
218
|
+
return runCart(() => client.chooseShippingMethod(methodId)).finally(() =>
|
|
219
|
+
// Only the call's own pick is cleared — a newer click stays optimistic
|
|
220
|
+
// until its own call settles (the queue serializes them in order).
|
|
221
|
+
setPickedShippingMethod((cur) => (cur === methodId ? "" : cur)),
|
|
222
|
+
);
|
|
223
|
+
},
|
|
206
224
|
[client, runCart],
|
|
207
225
|
);
|
|
226
|
+
const shippingChoicePending = !!pickedShippingMethod;
|
|
208
227
|
|
|
209
228
|
// ── payment choice (gateways live on store info ONLY) ────────────────────
|
|
210
229
|
// The pick is state; the *method* is derived, so a lone gateway is selected
|
|
@@ -224,9 +243,14 @@ export function useCheckout(options = {}) {
|
|
|
224
243
|
else if (!cart || !cart.items?.length) blockers.push("empty_cart");
|
|
225
244
|
if (missingBilling.length) blockers.push("billing_incomplete");
|
|
226
245
|
if (shipToDifferent && !complete) blockers.push("shipping_address_incomplete");
|
|
227
|
-
if (syncPending || syncing) blockers.push("shipping_recalculating");
|
|
246
|
+
if (syncPending || syncing || shippingChoicePending) blockers.push("shipping_recalculating");
|
|
228
247
|
if (shippingStatus === "missing_address") blockers.push("shipping_address_required");
|
|
229
|
-
|
|
248
|
+
// A pick in flight already shows as selected — flagging "choose one" over a
|
|
249
|
+
// visibly chosen radio contradicts the screen; the transient code above
|
|
250
|
+
// covers the window, and the blocker returns if the pick fails.
|
|
251
|
+
if (shippingStatus === "choice_required" && !shippingChoicePending) {
|
|
252
|
+
blockers.push("shipping_method_required");
|
|
253
|
+
}
|
|
230
254
|
if (shippingStatus === "none_available" || addressError?.code === "shipping_not_available") {
|
|
231
255
|
blockers.push("shipping_not_available");
|
|
232
256
|
}
|
|
@@ -322,6 +346,7 @@ export function useCheckout(options = {}) {
|
|
|
322
346
|
chosenShippingMethod,
|
|
323
347
|
singleShippingMethod,
|
|
324
348
|
chooseShippingMethod,
|
|
349
|
+
shippingChoicePending,
|
|
325
350
|
// payment choice
|
|
326
351
|
paymentMethods,
|
|
327
352
|
paymentMethod,
|
|
@@ -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). `
|
|
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`
|