@base44/app-plugin-commerce 0.2.7 → 0.3.2

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.
Files changed (39) hide show
  1. package/README.md +4 -4
  2. package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
  3. package/base44/functions/commerce/seed-store/entry.ts +16 -2
  4. package/package.json +2 -2
  5. package/scripts/install.js +15 -0
  6. package/skills/commerce/SKILL.md +34 -17
  7. package/skills/commerce/docs/api-admin.md +1 -1
  8. package/skills/commerce/docs/api-storefront.md +12 -12
  9. package/skills/commerce/install/01-install.md +6 -8
  10. package/skills/commerce/install/02-storefront.md +180 -278
  11. package/skills/commerce/install/03-data.md +13 -11
  12. package/skills/commerce/references/catalog-rendering.md +37 -43
  13. package/skills/commerce/references/online-payments.md +10 -0
  14. package/skills/commerce/references/reviews.md +21 -14
  15. package/skills/commerce/references/store-settings.md +1 -1
  16. package/skills/commerce/references/storefront-verification.md +21 -15
  17. package/src/commerce/storefront/StorefrontProvider.jsx +65 -128
  18. package/src/commerce/storefront/cartUI.jsx +11 -30
  19. package/src/commerce/storefront/index.js +61 -98
  20. package/src/commerce/storefront/pickers.jsx +50 -64
  21. package/src/commerce/storefront/useCartLine.js +23 -130
  22. package/src/commerce/storefront/useCheckout.jsx +50 -43
  23. package/src/commerce/storefront/useOrderReturn.js +17 -7
  24. package/src/commerce/storefront/useProduct.js +41 -97
  25. package/src/commerce/storefront/useProductList.js +14 -22
  26. package/src/commerce/utils/address-spec.js +1 -1
  27. package/src/commerce/utils/images.js +1 -1
  28. package/src/commerce/utils/index.js +9 -9
  29. package/src/commerce/utils/price.js +2 -1
  30. package/src/commerce/utils/specs.js +41 -91
  31. package/src/commerce/utils/totals.js +7 -4
  32. package/src/commerce/storefront/useAddressForm.js +0 -166
  33. package/src/commerce/storefront/usePlaceOrder.js +0 -63
  34. package/src/commerce/storefront/useProductGallery.js +0 -78
  35. package/src/commerce/storefront/useProductPrice.js +0 -58
  36. package/src/commerce/storefront/useProductReviews.js +0 -242
  37. package/src/commerce/storefront/useStorefrontSeo.js +0 -204
  38. package/src/commerce/storefront/useTotalsLines.js +0 -109
  39. package/src/commerce/storefront/useUpsell.js +0 -90
@@ -9,10 +9,24 @@ import { useFormatMoney } from "./StorefrontProvider";
9
9
  * can't skip a `shipping_status` state or invent a payment method.
10
10
  *
11
11
  * Every option comes decorated with plain states and one handler — `selected`
12
- * (boolean), `select()` (pick it) and pre-formatted money (`costLabel`) so
13
- * the child is markup + classes and nothing else. `hint` is the one message
14
- * the picker wants shown right now (or null) — render it and the status
15
- * branching is done.
12
+ * (boolean), `select()` (pick it) and money formatted in the store's currency
13
+ * (`costLabel`) — so the child is markup + classes and nothing else.
14
+ *
15
+ * `hint` is the one thing the picker needs said right now, as a **code**, never
16
+ * as copy: `{ code, severity: "info"|"error", serverMessage }`. The words are
17
+ * the store's — write one line per code (there are three) in the store's own
18
+ * voice. `serverMessage` is set only when the backend explained the situation
19
+ * itself (an undeliverable address); it is more specific than anything you can
20
+ * write, so prefer it when present:
21
+ *
22
+ * const SHIPPING_HINTS = { // your words, once, near the checkout
23
+ * missing_address: "Enter your address to see delivery options.",
24
+ * none_available: "We can't deliver to that address yet.",
25
+ * syncing: "Updating delivery options…",
26
+ * };
27
+ * {hint && <p role={hint.severity === "error" ? "alert" : "status"}>
28
+ * {hint.serverMessage ?? SHIPPING_HINTS[hint.code]}
29
+ * </p>}
16
30
  *
17
31
  * Both read the nearest <CheckoutProvider>, or take an explicit `checkout`
18
32
  * prop when you called `useCheckout` yourself.
@@ -27,40 +41,25 @@ function resolveCheckout(name, prop, ctx) {
27
41
  }
28
42
 
29
43
  /**
30
- * Shipping options. Renders null for a virtual cart (`not_needed`) and while
31
- * the cart is loading; otherwise calls `children` with:
32
- *
33
- * {
34
- * status, // "missing_address" | "choice_required" | "chosen"
35
- * // | "auto_selected" | "none_available"
36
- * methods, // [{ id, title, cost, costLabel, selected,
37
- * // select }] — what this address is offered
38
- * chosen, // the chosen/auto-selected entry (with costLabel), or null
39
- * selected, // alias of `chosen` — the same name the payment picker uses
40
- * choose, // (id) => Promise — call with a method's id on pick
41
- * mustChoose, // status === "choice_required" → render methods as a picker
42
- * single, // exactly one method offered — already chosen; skip the
43
- * // picker but still show `chosen.title` and `chosen.costLabel`
44
- * syncing, // an address edit is being repriced — show a subtle busy state
45
- * hint, // { code, message, severity: "info"|"error" } | null — the
46
- * // one notice to show now (address missing / not deliverable
47
- * // / repricing); render `hint.message`, done
48
- * addressError, // { code, message } | null — also surfaces on the address
49
- * } // form's country field
44
+ * Shipping options. Renders null for a virtual cart (`not_needed`) and while the
45
+ * cart is loading; otherwise calls `children` with:
50
46
  *
51
- * The child's whole job:
52
- *
53
- * {hint && <p role={hint.severity === "error" ? "alert" : "status"}>{hint.message}</p>}
54
- * {mustChoose && methods.map(m => (
55
- * <label key={m.id} className="…">
56
- * <input type="radio" name="shipping-method" checked={m.selected} onChange={m.select}
57
- * className="" /> {m.title} <span>{m.costLabel}</span>
58
- * </label>
59
- * ))}
60
- * {!mustChoose && chosen && <p>{chosen.title} {chosen.costLabel}</p>}
47
+ * status "missing_address" | "choice_required" | "chosen"
48
+ * | "auto_selected" | "none_available"
49
+ * methods [{ id, title, cost, costLabel, selected, select }]
50
+ * chosen the chosen/auto-selected entry, or null
51
+ * selected alias of `chosen` (the name the payment picker uses)
52
+ * choose (id) => Promise
53
+ * mustChoose status === "choice_required" render methods as a picker
54
+ * single exactly one method offered — already chosen
55
+ * syncing an address edit is being repriced
56
+ * hint { code, severity, serverMessage } | null (see above)
57
+ * addressError { code, message } | null — the server's own words
61
58
  *
62
59
  * `single` and `mustChoose` are never both true, and `single` guarantees
63
- * `chosen` — a single option still *shows* what it is, never a picker of one.
60
+ * `chosen` — a single option still *shows* what it is (title + `costLabel`),
61
+ * never a picker of one. Never render `cart.chosen_shipping_method` directly:
62
+ * it is the rate's id, which is why `chosen` is handed to you resolved.
64
63
  */
65
64
  export function ShippingMethodPicker({ checkout: checkoutProp, children }) {
66
65
  const checkout = resolveCheckout("ShippingMethodPicker", checkoutProp, useCheckoutContextOptional());
@@ -84,11 +83,11 @@ export function ShippingMethodPicker({ checkout: checkoutProp, children }) {
84
83
  };
85
84
  const hint =
86
85
  status === "missing_address"
87
- ? { code: "missing_address", severity: "info", message: "Delivery options appear once your address is entered." }
86
+ ? { code: "missing_address", severity: "info", serverMessage: null }
88
87
  : status === "none_available" || addressError?.code === "shipping_not_available"
89
- ? { code: "none_available", severity: "error", message: addressError?.message ?? "We don't deliver to that address yet." }
88
+ ? { code: "none_available", severity: "error", serverMessage: addressError?.message ?? null }
90
89
  : syncing
91
- ? { code: "syncing", severity: "info", message: "Updating delivery options…" }
90
+ ? { code: "syncing", severity: "info", serverMessage: null }
92
91
  : null;
93
92
  const decoratedChosen = decorate(chosen) ?? null;
94
93
  return children({
@@ -106,35 +105,22 @@ export function ShippingMethodPicker({ checkout: checkoutProp, children }) {
106
105
  }
107
106
 
108
107
  /**
109
- * Payment methods — every gateway the admin has ENABLED, from store info
110
- * (their only source). Renders null while store info loads; otherwise calls
111
- * `children` with:
112
- *
113
- * {
114
- * gateways, // [{ slug, title, description, online, selected,
115
- * // select }] — admin-owned data, decorated
116
- * value, // the selected slug ("" while none)
117
- * select, // (slug) => void
118
- * selected, // the selected gateway entry, or null
119
- * single, // exactly one gateway — already selected; skip the picker but
120
- * // still show its title so the customer knows how they pay
121
- * hint, // { code, message, severity } | null — set when there are no
122
- * } // gateways: checkout is unavailable, say so
123
- *
124
- * The child's whole job:
108
+ * Payment methods — every gateway the admin has ENABLED, from store info (their
109
+ * only source). Renders null while store info loads; otherwise calls `children`
110
+ * with:
125
111
  *
126
- * {hint && <p role="alert">{hint.message}</p>}
127
- * {!single && gateways.map(g => (
128
- * <label key={g.slug} className="…">
129
- * <input type="radio" name="payment-method" checked={g.selected} onChange={g.select}
130
- * className="…" /> {g.title} <span>{g.description}</span>
131
- * </label>
132
- * ))}
133
- * {single && selected && <p>{selected.title} — {selected.description}</p>}
112
+ * gateways [{ slug, title, description, online, selected, select }]
113
+ * value the selected slug ("" while none)
114
+ * select (slug) => void
115
+ * selected the selected gateway entry, or null
116
+ * single exactly one gateway — already selected
117
+ * hint { code: "none_available", severity, serverMessage } | null —
118
+ * no gateways at all: say checkout is unavailable, in your words
134
119
  *
135
120
  * `single` guarantees `value` and `selected` — never render the one-gateway
136
121
  * branch as "nothing selected yet" — and both re-resolve when store info
137
- * changes. Titles and descriptions are the admin's copy: render them, don't
122
+ * changes. A default-seeded store offers `offline` only, so never hardcode a
123
+ * card option. Titles and descriptions are the admin's copy: render them, don't
138
124
  * invent your own.
139
125
  */
140
126
  export function PaymentMethodPicker({ checkout: checkoutProp, children }) {
@@ -153,7 +139,7 @@ export function PaymentMethodPicker({ checkout: checkoutProp, children }) {
153
139
  }));
154
140
  const hint =
155
141
  gateways.length === 0
156
- ? { code: "none_available", severity: "error", message: "No payment method is available right now." }
142
+ ? { code: "none_available", severity: "error", serverMessage: null }
157
143
  : null;
158
144
  return children({
159
145
  gateways: decorated,
@@ -20,39 +20,23 @@ import { useCart } from "./StorefrontProvider";
20
20
  * request per settle instead of one per click; and `sold_individually` is
21
21
  * respected, so a one-per-customer product has no working "+".
22
22
  *
23
- * ## When `pending` settles the exact window
23
+ * **When `pending` settles.** It is this row's flag, not the cart's, and not
24
+ * true for the whole gesture: a click only sets the optimistic quantity and
25
+ * (re)starts the 250ms timer, so a burst of clicks sends **one** request;
26
+ * `pending` goes true when that request leaves (`remove()` skips the wait); it
27
+ * returns to false only after the server's new cart view has been published
28
+ * through the provider's queue. So `pending === false` with `error === null`
29
+ * means this row, the cart's totals and any badge are all settled — it is the
30
+ * only mutation-settled signal (`useCart().status` never returns to
31
+ * `"loading"` for a mutation), and what a script driving the page waits on.
32
+ * Scope the busy state to this row: greying the whole cart for a 250ms step
33
+ * reads as a page-wide stall. On failure the quantity rolls back to what the
34
+ * server still holds and `error` is `{ code, message }`.
24
35
  *
25
- * `pending` is this line's own flag, not the cart's, and it is **not** true for
26
- * the whole gesture:
36
+ * It owns the quantity only. The row's own content name, `attributesLabel`,
37
+ * image, money — you render from the `cart.items[n]` you passed in.
27
38
  *
28
- * 1. **Click `pending` stays `false`.** `increase`/`decrease` only set the
29
- * optimistic `quantity` and (re)start a `debounceMs` (250ms) timer. Nothing
30
- * is in flight yet, and a further click restarts the timer, so a burst of
31
- * clicks sends **one** request for the final number.
32
- * 2. **Debounce elapses → `pending` becomes `true`** and the request goes out.
33
- * `remove()` skips this step: it cancels the timer and goes `pending`
34
- * immediately.
35
- * 3. **`pending` returns to `false` only after the server's new cart view has
36
- * been published to the provider** — the awaited mutation resolves through
37
- * the provider's serialized queue, which sets the shared cart state before
38
- * the await returns. So `pending === false` with `error === null` means this
39
- * row's quantity, the cart's totals and any dependent badge are settled, not
40
- * merely that the request finished.
41
- *
42
- * Two consequences worth designing for. **Disable and mark only this row**
43
- * (`disabled={!l.canIncrease || l.pending}`) — `pending` says nothing about the
44
- * other lines, and greying the whole cart because one stepper is busy makes a
45
- * 250ms update look like a page-wide stall. And **`pending` is the only
46
- * mutation-settled signal**: `useCart().status` never returns to `"loading"`
47
- * for a mutation (see its doc comment), so a caller that needs to know an
48
- * update landed — a script driving the page, a queued follow-up action — waits
49
- * on this flag, per row, and not on cart `status`.
50
- *
51
- * On failure `pending` returns to `false`, the optimistic quantity rolls back
52
- * to what the server still holds, and `error` is `{ code, message }`.
53
- *
54
- * @param {object} line a decorated line from `useCart().lines` (a raw
55
- * `cart.items[n]` works too — it just has no `maxQuantity` hint)
39
+ * @param {object} line one `cart.items[n]` from `useCart()`
56
40
  * @param {{debounceMs?: number}} [options]
57
41
  */
58
42
  export function useCartLine(line, { debounceMs = 250 } = {}) {
@@ -133,10 +117,6 @@ export function useCartLine(line, { debounceMs = 250 } = {}) {
133
117
  maxQuantity,
134
118
  atMax: quantity >= maxQuantity,
135
119
  atMin: quantity <= 1,
136
- attributesLabel: line?.attributesLabel ?? "",
137
- image: line?.image ?? null,
138
- totalLabel: line?.totalLabel ?? "",
139
- unitPriceLabel: line?.unitPriceLabel ?? "",
140
120
  };
141
121
  }
142
122
 
@@ -144,29 +124,18 @@ export function useCartLine(line, { debounceMs = 250 } = {}) {
144
124
  * CartLine — headless per-line binding for the rows of a cart you render
145
125
  * yourself. It renders **nothing**: the render function you pass as `children`
146
126
  * receives the `useCartLine` controls for that line and returns your markup.
147
- * It exists so a `lines.map(...)` doesn't tempt a hook call inside a loop:
127
+ * It exists so an `items.map(...)` doesn't tempt a hook call inside a loop:
148
128
  *
149
- * const { lines } = useCart();
150
- * {lines.map(line => (
151
- * <CartLine key={line.item_key} line={line}>
152
- * {(l) => (
153
- * <li aria-busy={l.pending}>
154
- * {line.name} {line.attributesLabel}
155
- * <button onClick={l.decrease} disabled={!l.canDecrease || l.pending}
156
- * aria-label={`Decrease quantity of ${line.name}`}>−</button>
157
- * {l.quantity}
158
- * <button onClick={l.increase} disabled={!l.canIncrease || l.pending}
159
- * aria-label={`Increase quantity of ${line.name}`}>+</button>
160
- * <button onClick={l.remove} aria-label={`Remove ${line.name}`}>Remove</button>
161
- * {l.totalLabel}
162
- * {l.error && <p role="alert">{l.error.message}</p>}
163
- * </li>
164
- * )}
129
+ * {cart.items.map(item => (
130
+ * <CartLine key={item.item_key} line={item}>
131
+ * {(l) => <li aria-busy={l.pending}>…your row, using item and l…</li>}
165
132
  * </CartLine>
166
133
  * ))}
167
134
  *
168
- * Equivalent to extracting your own row component that calls `useCartLine` —
169
- * use whichever reads better in your page.
135
+ * Give each row's controls their own accessible name (`Remove ${item.name}`):
136
+ * three buttons all named "Remove" are ambiguous to a screen reader and to a
137
+ * script driving the page. Equivalent to extracting your own row component that
138
+ * calls `useCartLine` — use whichever reads better in your page.
170
139
  *
171
140
  * @param {{line: object, options?: {debounceMs?: number},
172
141
  * children: (controls: object) => React.ReactNode}} props
@@ -179,79 +148,3 @@ export function CartLine({ line, options, children }) {
179
148
  if (!line) return null;
180
149
  return children(controls);
181
150
  }
182
-
183
- /**
184
- * useCoupon — the coupon field. Small, and the difference between a store that
185
- * can honour its own discounts and one that cannot.
186
- *
187
- * const c = useCoupon();
188
- * <input value={c.code} onChange={(e) => c.setCode(e.target.value)} />
189
- * <button onClick={c.apply} disabled={c.applying}>Apply</button>
190
- * {c.error && <p role="alert">{c.error.message}</p>}
191
- * {c.applied.map(a => <Chip key={a.code} onRemove={() => c.remove(a.code)} …/>)}
192
- *
193
- * Coupons are admin-only data — a storefront cannot list codes, so a seeded
194
- * code is reachable **only** through a field the customer types it into. If the
195
- * store has any coupons, this field must exist somewhere in the cart or the
196
- * checkout, or those codes can never be redeemed.
197
- *
198
- * An invalid, expired or ineligible code is **expected flow**: `apply()`
199
- * resolves `{ ok: false, error }` and never throws — render `error.message`
200
- * inline next to the field.
201
- */
202
- export function useCoupon() {
203
- const { cart, applyCoupon, removeCoupon } = useCart();
204
- const [code, setCode] = useState("");
205
- const [applying, setApplying] = useState(false);
206
- const [error, setError] = useState(null);
207
-
208
- const apply = useCallback(
209
- async (explicit) => {
210
- const value = String(explicit ?? code ?? "").trim();
211
- if (!value) return { ok: false, error: { code: "empty", message: "Enter a code." } };
212
- setApplying(true);
213
- setError(null);
214
- const res = await applyCoupon(value);
215
- setApplying(false);
216
- if (res.ok) {
217
- setCode("");
218
- return { ok: true, cart: res.cart };
219
- }
220
- const err = { code: res.code ?? "coupon_invalid", message: res.message };
221
- setError(err);
222
- return { ok: false, error: err };
223
- },
224
- [code, applyCoupon],
225
- );
226
-
227
- const remove = useCallback(
228
- async (codeOrEntry) => {
229
- const value = typeof codeOrEntry === "string" ? codeOrEntry : codeOrEntry?.code;
230
- setError(null);
231
- try {
232
- await removeCoupon(value);
233
- return { ok: true };
234
- } catch (e) {
235
- const err = { code: storefrontErrorCode(e) ?? "error", message: storefrontErrorMessage(e) };
236
- setError(err);
237
- return { ok: false, error: err };
238
- }
239
- },
240
- [removeCoupon],
241
- );
242
-
243
- return {
244
- code,
245
- setCode,
246
- apply,
247
- remove,
248
- applied: (cart?.coupons ?? []).map((c) => ({
249
- code: c.code,
250
- discount: c.discount ?? 0,
251
- freeShipping: Boolean(c.free_shipping),
252
- })),
253
- applying,
254
- error,
255
- discountTotal: cart?.totals?.discount_total ?? 0,
256
- };
257
- }
@@ -39,50 +39,44 @@ function resolvePaymentMethod(gateways, picked) {
39
39
  * checkout must get right, so the page you build is only markup around it:
40
40
  *
41
41
  * - **Address → shipping/tax recalculation, automatic.** Edit the billing (or
42
- * separate shipping) address through `updateBilling`/`updateShipping`; the
43
- * moment the address is complete enough to price (default: country + city
44
- * see `isShippingAddressComplete`), the hook debounces and calls
45
- * `set-shipping-address`, which recalculates every shipping option, its
46
- * cost and the taxes. Half-typed addresses are never sent, an unchanged
47
- * address is never re-sent, and an address the store doesn't ship to
48
- * surfaces as `addressError`show it on the address fields.
49
- * - **Shipping choice.** `shippingStatus` mirrors the cart's state:
50
- * `auto_selected` (one option, already applied — just display
51
- * `chosenShippingMethod`), `chosen`, `choice_required` (render
52
- * `shippingMethods` and call `chooseShippingMethod(id)`), `missing_address`
53
- * (collect the address), `not_needed` (virtual cart — render nothing).
54
- * `singleShippingMethod` flags the one-option case, and `chosenShippingMethod`
55
- * is filled for it a single option is never left unselected.
42
+ * separate shipping) address through `updateBilling`/`updateShipping`; once
43
+ * the address is complete enough to price (default: country + city, see
44
+ * `isShippingAddressComplete`), the hook debounces and calls
45
+ * `set-shipping-address`, recalculating every option, its cost and the taxes.
46
+ * Half-typed addresses are never sent, an unchanged address is never re-sent,
47
+ * and an address the store doesn't ship to surfaces as `addressError` (the
48
+ * server's own words)render it on the address fields.
49
+ * - **Shipping choice.** `shippingStatus` mirrors the cart: `auto_selected`
50
+ * (one option, already applied — display `chosenShippingMethod`), `chosen`,
51
+ * `choice_required` (render `shippingMethods`, call
52
+ * `chooseShippingMethod(id)`), `missing_address`, `none_available`,
53
+ * `not_needed` (virtual cart — render nothing). `singleShippingMethod` flags
54
+ * the one-option case and `chosenShippingMethod` is filled for it, so a single
55
+ * option is never left unselected and never rendered as a picker of one.
56
56
  * - **Payment choice.** `paymentMethods` come from store info (their ONLY
57
- * source); a store with exactly one enabled gateway gets it selected
58
- * (`singlePaymentMethod`, with `selectedGateway` filled) from the first
59
- * render that has store info. The selection is derived from the current
60
- * gateway list, not remembered: when store info changes, a gateway that is
61
- * no longer enabled is dropped and a list that is down to one gateway
62
- * selects it — the customer's own pick survives as long as it stays enabled.
63
- * - **The gate.** `canPlaceOrder` + `blockers` say exactly what still stands
64
- * between the customer and the order drive the button's disabled state
65
- * and the "what's missing" hints from them instead of re-deriving.
66
- * - **placeOrder.** Sends the order, clears the shared cart, and navigates:
67
- * an online gateway redirects to the provider's payment page
68
- * (`redirectToPayment`), everything else lands on the order-received page
69
- * (`orderReceivedPath`, default `/order-received`) which is where a manual
70
- * order's payment instructions are rendered, so the offline default confirms
71
- * properly with no extra wiring. Resolves to `{ ok: true, result }` or
72
- * `{ ok: false, error }`. Pass `orderReceivedPath: null` to handle the
73
- * result yourself (a manual-gateway result carries
57
+ * source `cart.payment_gateways` is always undefined). One enabled gateway
58
+ * is selected from the first render that has store info. The selection is
59
+ * derived from the current list, not remembered: a gateway the admin disables
60
+ * drops out, and if that leaves one, it takes over.
61
+ * - **The gate.** `canPlaceOrder` + `blockers` say what still stands between the
62
+ * customer and the order drive the button's disabled state and the
63
+ * what's-missing lines from them instead of re-deriving.
64
+ * - **placeOrder.** Sends the order, clears the shared cart, and navigates: an
65
+ * online gateway redirects to the provider (`redirectToPayment`), everything
66
+ * else lands on `orderReceivedPath` (default `/order-received`) which is
67
+ * where a manual order's payment instructions are rendered, so the offline
68
+ * default confirms properly with no extra wiring. Resolves `{ ok: true,
69
+ * result }` or `{ ok: false, error }`. Pass `orderReceivedPath: null` to
70
+ * handle the result yourself (a manual result carries
74
71
  * `result.payment_instructions`).
75
72
  *
76
- * Both navigations are **full page loads** (`window.location.assign`), not
77
- * router transitions: the provider hop has to leave the app, and the
78
- * order-received page is built to boot from the URL alone (`order_id` +
79
- * `order_key`), so a reload there is correct and shareable. Consequences
80
- * worth knowing: React state does not survive it, and a browser script
81
- * driving checkout loses its page context at this point — the order is
82
- * still placed, so verify by navigating fresh to
83
- * `orderReceivedUrl(result)`. For a client-side transition instead, pass
84
- * `orderReceivedPath: null` and `navigate(orderReceivedUrl(result))`
85
- * yourself.
73
+ * Both navigations are **full page loads** (`window.location.assign`): the
74
+ * provider hop leaves the app, and the receipt boots from the URL alone
75
+ * (`order_id` + `order_key`), so a reload there is correct and shareable.
76
+ * React state does not survive it, and a browser script driving checkout
77
+ * loses its page context here the order *is* placed, so verify by
78
+ * navigating fresh to `orderReceivedUrl(result)`. For a client-side
79
+ * transition, pass `orderReceivedPath: null` and navigate yourself.
86
80
  *
87
81
  * Blocker codes, in the order checked: `cart_loading`, `empty_cart`,
88
82
  * `billing_incomplete`, `shipping_address_incomplete`, `shipping_recalculating`,
@@ -93,7 +87,14 @@ function resolvePaymentMethod(gateways, picked) {
93
87
  * accepted; the navigation away is already in flight). **Guard on it before the
94
88
  * cart's empty branch** — `placeOrder` clears the cart, so a page that checks
95
89
  * only `cart.status` repaints "your bag is empty" over a just-placed order for
96
- * the frames before the browser leaves. `usePlaceOrder` packages this gate.
90
+ * the frames before the browser leaves.
91
+ *
92
+ * The button is `disabled={!canPlaceOrder || placing}`, and `placeOrder` works
93
+ * as an `onClick` handler directly (a click event is not read as order fields).
94
+ * A disabled button must still say why: render one line per `blockers` code, in
95
+ * the store's own words — the silent disabled button is the most common
96
+ * checkout dead end. The codes are listed above; each maps to one thing the
97
+ * customer can fix.
97
98
  *
98
99
  * Options: `debounceMs` (600), `addressComplete` (predicate overriding the
99
100
  * country+city rule), `requiredBillingFields`, `redirectToPayment` (true),
@@ -214,6 +215,12 @@ export function useCheckout(options = {}) {
214
215
  const placeOrder = useCallback(
215
216
  async (extra = {}) => {
216
217
  if (placing) return { ok: false, error: { code: "placing", message: "Order already being placed." } };
218
+ // Safe as `onClick={checkout.placeOrder}`: a DOM/React event is not
219
+ // `extra`. Spreading one into the order payload would send a circular
220
+ // SyntheticEvent as order fields — an explicit object still forwards.
221
+ const isEvent =
222
+ extra && typeof extra === "object" && ("nativeEvent" in extra || "target" in extra);
223
+ const fields = isEvent ? {} : extra;
217
224
  setPlacing(true);
218
225
  setOrderError(null);
219
226
  try {
@@ -221,7 +228,7 @@ export function useCheckout(options = {}) {
221
228
  payment_method: paymentMethod,
222
229
  billing,
223
230
  ...(shipToDifferent ? { shipping } : {}),
224
- ...extra, // customer_note, success_url/cancel_url overrides, …
231
+ ...fields, // customer_note, success_url/cancel_url overrides, …
225
232
  });
226
233
  // `submitted` flips BEFORE the cart clears, in the same commit — the
227
234
  // page's `stage === "submitted"` guard is what stands between a placed
@@ -1,7 +1,6 @@
1
1
  import { useCallback, useEffect, useState } from "react";
2
2
  import { orderLines, storefrontErrorCode, storefrontErrorMessage } from "@/commerce/utils";
3
3
  import { useFormatMoney, useStorefrontState } from "./StorefrontProvider";
4
- import { orderSeo, useStorefrontSeo } from "./useStorefrontSeo";
5
4
 
6
5
  /**
7
6
  * useOrderReturn — the whole `/order-received` page in one hook. Mount the
@@ -19,11 +18,12 @@ import { orderSeo, useStorefrontSeo } from "./useStorefrontSeo";
19
18
  * // "cancelled" → payment was cancelled — offer paymentLink.url or support
20
19
  * // "error" → render error.message with a retry via reload()
21
20
  *
22
- * `lines` are the order's items in the decorated cart-line shape —
23
- * `attributesLabel`, `image` as `{src, alt}|null`, `totalLabel` pre-formatted —
24
- * so the same row markup renders the bag and the confirmation; totals come
25
- * from `useTotalsLines(order)`. The page is marked `noindex` automatically — a
26
- * receipt carrying an order key must not rank (`seo: false` opts out).
21
+ * `lines` are the order's items normalized for rendering `attributesLabel`,
22
+ * `image` as `{src, alt}|null`, `totalLabel` pre-formatted — because a receipt
23
+ * reusing cart-row markup otherwise reads `item.image` as an object and paints
24
+ * a broken image. Totals: `orderTotalsLines(order, { formatMoney })` from
25
+ * `@/commerce/utils`. The page is marked `noindex` automatically a receipt
26
+ * carrying an order key must not rank (`seo: false` opts out).
27
27
  *
28
28
  * It reads `order_id`/`order_key`/`payment` from the URL itself and verifies
29
29
  * with the payment provider server-side — safe and idempotent on every visit.
@@ -61,7 +61,17 @@ export function useOrderReturn({ auto = true, seo = true } = {}) {
61
61
  if (auto) reload();
62
62
  }, [auto, reload]);
63
63
 
64
- useStorefrontSeo(seo ? orderSeo(result.order ?? null) : null);
64
+ // A receipt's URL carries an order key, so the page must not rank. One meta
65
+ // tag, added on mount and removed on unmount; the rest of this page's head —
66
+ // title, description — is yours to set. `seo: false` opts out entirely.
67
+ useEffect(() => {
68
+ if (!seo || typeof document === "undefined") return undefined;
69
+ const tag = document.createElement("meta");
70
+ tag.setAttribute("name", "robots");
71
+ tag.setAttribute("content", "noindex, nofollow");
72
+ document.head.appendChild(tag);
73
+ return () => tag.remove();
74
+ }, [seo]);
65
75
 
66
76
  return { ...result, lines: orderLines(result.order ?? null, { formatMoney }), reload };
67
77
  }