@base44/app-plugin-commerce 0.2.5 → 0.2.7

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.
@@ -27,32 +27,50 @@
27
27
  * </Route>
28
28
  * // no shared layout? <StorefrontProvider …> <Routes>…</Routes> </StorefrontProvider>
29
29
  *
30
+ * Every hook hands back **plain states and handlers** — strings, booleans,
31
+ * arrays, callbacks (`buy.label`, `buy.disabled`, `buy.addToCart`, `f.value`,
32
+ * `f.set`) — never ready-made prop objects to spread. You write every element
33
+ * and every attribute; the hook guarantees the values are right.
34
+ *
30
35
  * ## Hooks
31
36
  * - `useStorefront` / `useStoreInfo` / `useFormatMoney` / `useMoney` — the
32
37
  * shared client, cached store info (the ONLY source of payment gateways,
33
38
  * currency and countries), money in the store's currency.
34
- * - `useProductList` / `useCategories` / `useRibbons` — a listing with paging,
35
- * filters, `refreshing`, and failure as a visible state.
36
- * - `useProduct` / `useAddToCart` / `useAddToCartButton` / `useProductPrice` /
37
- * `useProductGallery` the product page: fetch + variant selection +
39
+ * - `useProductList` / `useCategories` / `useRibbons` — a listing with paging
40
+ * (render the paging control off `hasNext` — a page that skips it caps the
41
+ * catalog), filters, `refreshing`, and failure as a visible state.
42
+ * - `useProduct` / `useAddToCart` / `useProductPrice` / `useProductGallery` /
43
+ * `useProductSpecs` — the product page: fetch + variant selection +
38
44
  * quantity + price + gallery, race-safe, with `status: "not_found"` and
39
- * every add-to-cart failure handled. `variantAxes(view, pick)` (from
40
- * `@/commerce/utils`, re-exported here) turns the resolved view into a
41
- * render-ready model for the selector you write.
45
+ * every add-to-cart failure handled; the buy box as
46
+ * `state`/`label`/`disabled`/`addToCart`. `variantAxes(view, pick)`
47
+ * (from `@/commerce/utils`, re-exported here) turns the resolved view into
48
+ * a render-ready model for the selector you write; `useProductSpecs` adds
49
+ * normalized `pick`/`get` lookup over `productSpecs`.
42
50
  * - `useProductReviews` — the review list and the submit form, with the store's
43
51
  * policy as a prop and field errors matching the server's codes.
44
52
  * - `useCart` / `useCartLine` / `useCoupon` — the shared cart (branch on
45
- * `status`, render `lines` and `notices`), quantity steppers that clamp and
46
- * recover, and the coupon field a store with coupons must have.
53
+ * `status`, render `lines` and `notices` money pre-formatted per line),
54
+ * quantity steppers that clamp and recover, and the coupon field a store
55
+ * with coupons must have.
56
+ * - `useCartUI` + `<CartUIProvider>` — the cart drawer's non-visual machinery:
57
+ * `open` + open/close/toggle handlers, Esc, close-on-navigate, open-on-add;
58
+ * render the drawer conditionally off `open`.
47
59
  * - `useCheckout` / `CheckoutProvider` / `useCheckoutContext` — the guided
48
60
  * checkout: address state with automatic debounced shipping/tax
49
61
  * recalculation, shipping and payment choice, a `canPlaceOrder` gate with
50
62
  * named blockers, `placeOrder` with both navigations handled (online →
51
63
  * provider redirect, manual → `/order-received`).
64
+ * - `usePlaceOrder` — the gate as one surface: `placeOrder`, `disabled`,
65
+ * `label`, `stage` (guards the just-placed-order frames), `blockers` in
66
+ * words.
52
67
  * - `useAddressForm` / `useCountries` / `useTotalsLines` /
53
- * `useCheckoutBlockers` — the address form as a field spec you map to your
54
- * own inputs (state included, country options never null), one totals
55
- * projection for cart and order, and blocker codes turned into copy.
68
+ * `useCheckoutBlockers` — the address form as a field spec, each field
69
+ * self-contained (`id`/`value`/`set`/`error`/`autoComplete`, `state`
70
+ * included, country options never null), one totals projection for cart
71
+ * and order, and blocker codes turned into copy.
72
+ * - `useUpsell` — one product offered beside another surface: fetch-or-row,
73
+ * already-in-cart matched by id, one-click add.
56
74
  * - `useOrderReturn` — the mandatory `/order-received` page in one hook:
57
75
  * status, order, `lines`, `paymentLink`, `paymentInstructions`, noindex.
58
76
  * - `useStorefrontSeo` + `productSeo` / `collectionSeo` / `orderSeo` — titles,
@@ -60,7 +78,8 @@
60
78
  *
61
79
  * ## Render-prop components (headless — children is a function, no markup ships)
62
80
  * - `ShippingMethodPicker` / `PaymentMethodPicker` — the two checkout choices
63
- * that are store data, never hardcoded, with their branching enumerated.
81
+ * that are store data, never hardcoded; options arrive decorated with
82
+ * `selected`/`select()`/`costLabel` and a single `hint` message.
64
83
  * - `CartLine` — per-line `useCartLine` binding for your cart rows, so a
65
84
  * `lines.map(...)` never calls a hook in a loop.
66
85
  *
@@ -71,6 +90,8 @@
71
90
  * inferred `type` (`numeric` with `number`/`unit` split out, `duration`,
72
91
  * `location`, `list` with `items`, `text`), so a weight can be a figure and
73
92
  * a composition bars instead of every modifier being one grey table row.
93
+ * - `productImages(product)` — images normalized to `{src, name, alt}` and
94
+ * de-duplicated; `[]` means render your placeholder.
74
95
  */
75
96
  export {
76
97
  StorefrontProvider,
@@ -81,8 +102,10 @@ export {
81
102
  useCart,
82
103
  } from "./StorefrontProvider";
83
104
  export { useCheckout, CheckoutProvider, useCheckoutContext } from "./useCheckout";
105
+ export { usePlaceOrder } from "./usePlaceOrder";
84
106
  export { useOrderReturn, orderReceivedUrl } from "./useOrderReturn";
85
107
  export { ShippingMethodPicker, PaymentMethodPicker } from "./pickers";
108
+ export { CartUIProvider, useCartUI } from "./cartUI";
86
109
  export {
87
110
  REQUIRED_BILLING_FIELDS,
88
111
  missingBillingFields,
@@ -92,7 +115,8 @@ export {
92
115
 
93
116
  // ── catalog ────────────────────────────────────────────────────────────────
94
117
  export { useProductList, useCategories, useRibbons } from "./useProductList";
95
- export { useProduct, useAddToCart, useAddToCartButton } from "./useProduct";
118
+ export { useProduct, useAddToCart, useProductSpecs } from "./useProduct";
119
+ export { useUpsell } from "./useUpsell";
96
120
  export { useProductPrice, useMoney } from "./useProductPrice";
97
121
  export { useProductGallery } from "./useProductGallery";
98
122
  export { useProductReviews } from "./useProductReviews";
@@ -106,4 +130,4 @@ export { useTotalsLines, useCheckoutBlockers, blockerMessage } from "./useTotals
106
130
  export { useStorefrontSeo, productSeo, collectionSeo, orderSeo } from "./useStorefrontSeo";
107
131
 
108
132
  // ── view-model helpers (framework-free, from @/commerce/utils) ─────────────
109
- export { variantAxes, productSpecs } from "@/commerce/utils";
133
+ export { variantAxes, productSpecs, productImages } from "@/commerce/utils";
@@ -1,5 +1,6 @@
1
1
  import React from "react";
2
2
  import { useCheckoutContextOptional } from "./useCheckout";
3
+ import { useFormatMoney } from "./StorefrontProvider";
3
4
 
4
5
  /**
5
6
  * Headless pickers for the two checkout choices that are store data, never
@@ -7,6 +8,12 @@ import { useCheckoutContextOptional } from "./useCheckout";
7
8
  * whole UI — but they encode the branching every checkout must do, so a page
8
9
  * can't skip a `shipping_status` state or invent a payment method.
9
10
  *
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.
16
+ *
10
17
  * Both read the nearest <CheckoutProvider>, or take an explicit `checkout`
11
18
  * prop when you called `useCheckout` yourself.
12
19
  */
@@ -24,25 +31,40 @@ function resolveCheckout(name, prop, ctx) {
24
31
  * the cart is loading; otherwise calls `children` with:
25
32
  *
26
33
  * {
27
- * status, // "missing_address" | "choice_required" | "chosen" | "auto_selected"
28
- * methods, // [{ id, title, cost }] — what this address is offered
29
- * chosen, // the chosen/auto-selected entry (title + cost), or null
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
30
39
  * selected, // alias of `chosen` — the same name the payment picker uses
31
40
  * choose, // (id) => Promise — call with a method's id on pick
32
41
  * mustChoose, // status === "choice_required" → render methods as a picker
33
42
  * single, // exactly one method offered — already chosen; skip the
34
- * // picker but still show `chosen.title` and its cost
43
+ * // picker but still show `chosen.title` and `chosen.costLabel`
35
44
  * syncing, // an address edit is being repriced — show a subtle busy state
36
- * addressError, // { code, message } | null — "we don't ship there" belongs
37
- * } // on the address fields
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
50
+ *
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>}
38
61
  *
39
- * Render rules the child should follow: `missing_address` → say the options
40
- * appear once the address is entered; `mustChoose` a picker of `methods`;
41
- * otherwise display `chosen.title` + its cost (never the raw id). `single` and
42
- * `mustChoose` are never both true, and `single` guarantees `chosen`.
62
+ * `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.
43
64
  */
44
65
  export function ShippingMethodPicker({ checkout: checkoutProp, children }) {
45
66
  const checkout = resolveCheckout("ShippingMethodPicker", checkoutProp, useCheckoutContextOptional());
67
+ const formatMoney = useFormatMoney();
46
68
  const {
47
69
  shippingStatus: status,
48
70
  shippingMethods: methods,
@@ -53,15 +75,32 @@ export function ShippingMethodPicker({ checkout: checkoutProp, children }) {
53
75
  addressError,
54
76
  } = checkout;
55
77
  if (!status || status === "not_needed") return null;
78
+ const decorate = (m) =>
79
+ m && {
80
+ ...m,
81
+ selected: m.id === chosen?.id,
82
+ costLabel: formatMoney(m.cost),
83
+ select: () => choose(m.id),
84
+ };
85
+ const hint =
86
+ status === "missing_address"
87
+ ? { code: "missing_address", severity: "info", message: "Delivery options appear once your address is entered." }
88
+ : 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." }
90
+ : syncing
91
+ ? { code: "syncing", severity: "info", message: "Updating delivery options…" }
92
+ : null;
93
+ const decoratedChosen = decorate(chosen) ?? null;
56
94
  return children({
57
95
  status,
58
- methods,
59
- chosen,
60
- selected: chosen,
96
+ methods: methods.map(decorate),
97
+ chosen: decoratedChosen,
98
+ selected: decoratedChosen,
61
99
  choose,
62
100
  mustChoose: status === "choice_required",
63
101
  single: single ?? (methods.length === 1), // fallback: a hand-built checkout object
64
102
  syncing,
103
+ hint,
65
104
  addressError,
66
105
  });
67
106
  }
@@ -72,18 +111,31 @@ export function ShippingMethodPicker({ checkout: checkoutProp, children }) {
72
111
  * `children` with:
73
112
  *
74
113
  * {
75
- * gateways, // [{ slug, title, description, online }] — admin-owned data
114
+ * gateways, // [{ slug, title, description, online, selected,
115
+ * // select }] — admin-owned data, decorated
76
116
  * value, // the selected slug ("" while none)
77
117
  * select, // (slug) => void
78
118
  * selected, // the selected gateway entry, or null
79
119
  * single, // exactly one gateway — already selected; skip the picker but
80
- * } // still show its title so the customer knows how they pay
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:
125
+ *
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>}
81
134
  *
82
- * Render rules: several gateways a picker labeled with the admin's
83
- * title/description; `single` just display it; zero gateways say checkout
84
- * is unavailable instead of rendering a dead place-order button. `single`
85
- * guarantees `value` and `selected` — never render the one-gateway branch as
86
- * "nothing selected yet" — and both re-resolve when store info changes.
135
+ * `single` guarantees `value` and `selected` never render the one-gateway
136
+ * 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
138
+ * invent your own.
87
139
  */
88
140
  export function PaymentMethodPicker({ checkout: checkoutProp, children }) {
89
141
  const checkout = resolveCheckout("PaymentMethodPicker", checkoutProp, useCheckoutContextOptional());
@@ -91,15 +143,24 @@ export function PaymentMethodPicker({ checkout: checkoutProp, children }) {
91
143
  paymentMethods: gateways,
92
144
  paymentMethod: value,
93
145
  setPaymentMethod: select,
94
- selectedGateway: selected,
95
146
  singlePaymentMethod: single,
96
147
  } = checkout;
97
148
  if (!gateways) return null;
149
+ const decorated = gateways.map((g) => ({
150
+ ...g,
151
+ selected: g.slug === value,
152
+ select: () => select(g.slug),
153
+ }));
154
+ const hint =
155
+ gateways.length === 0
156
+ ? { code: "none_available", severity: "error", message: "No payment method is available right now." }
157
+ : null;
98
158
  return children({
99
- gateways,
159
+ gateways: decorated,
100
160
  value,
101
161
  select,
102
- selected,
162
+ selected: decorated.find((g) => g.selected) ?? null,
103
163
  single: single ?? (gateways.length === 1), // fallback: a hand-built checkout object
164
+ hint,
104
165
  });
105
166
  }
@@ -1,4 +1,4 @@
1
- import { useCallback, useMemo } from "react";
1
+ import { useCallback, useMemo, useState } from "react";
2
2
  import { addressFieldSpec } from "@/commerce/utils";
3
3
  import { useStoreInfo } from "./StorefrontProvider";
4
4
  import { useCheckoutContext } from "./useCheckout";
@@ -42,26 +42,38 @@ function newValue(args) {
42
42
  * useAddressForm — the checkout address form as a field list bound to the
43
43
  * guided checkout. Needs a `<CheckoutProvider>` above it.
44
44
  *
45
- * Every field is **self-contained** it carries its own setter, so a `.map`
46
- * never has to reach back out of the loop:
45
+ * Every field is **self-contained**: it carries its own value, setter, id,
46
+ * `autoComplete` token and error, so the whole form is one map and every
47
+ * element and attribute in it is yours:
47
48
  *
48
49
  * const { fields } = useAddressForm("billing");
49
50
  * {fields.map(f => (
50
- * <label key={f.key}>
51
- * {f.label}{f.required && " *"}
52
- * {f.type === "select"
53
- * ? <select value={f.value} onChange={e => f.set(e.target.value)}>
54
- * {f.options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
55
- * </select>
56
- * : <input type={f.type} value={f.value} autoComplete={f.autoComplete}
57
- * onChange={e => f.set(e.target.value)} />}
58
- * {f.error && <span role="alert">{f.error}</span>}
59
- * </label>
51
+ * <div key={f.key} className="…">
52
+ * <label htmlFor={f.id}>{f.label}{f.required && " *"}</label>
53
+ * {f.isSelect ? (
54
+ * <select id={f.id} value={f.value} onChange={f.set}
55
+ * autoComplete={f.autoComplete} className="…">
56
+ * <option value="">{f.placeholder}</option>
57
+ * {f.options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
58
+ * </select>
59
+ * ) : <input id={f.id} type={f.type} value={f.value} onChange={f.set}
60
+ * autoComplete={f.autoComplete} className="…" />}
61
+ * {f.error && <span role="alert" className="…">{f.error}</span>}
62
+ * </div>
60
63
  * ))}
61
64
  *
62
- * `f.set` also accepts the raw event (`onChange={f.set}`) or a `(key, value)`
63
- * pair; the hook's top-level `set(key, value)` is still there for code that
64
- * writes a field outside the map.
65
+ * ⚑ Keep `value={f.value}` + `onChange={f.set}` (the pair that binds the field
66
+ * to checkout) and `autoComplete={f.autoComplete}` (browsers fill addresses in
67
+ * one gesture with it, field by field without). `f.placeholder` resolves the
68
+ * select's empty option ("Select Country", or "Loading…" while countries
69
+ * arrive), so `countriesLoading` never needs handling by hand. `f.error` is
70
+ * per-field: "we don't ship there" lands on the country field, and a required
71
+ * field reports itself once it has been edited and left empty — untouched
72
+ * fields stay quiet here and surface through the place-order `blockers`.
73
+ *
74
+ * `f.set` works with custom controls too — it accepts a value, the raw change
75
+ * event (`onChange={f.set}`) or a `(key, value)` pair — as does the hook's
76
+ * top-level `set(key, value)`.
65
77
  *
66
78
  * Editing a field is all it takes to trigger the shipping/tax recalculation —
67
79
  * `useCheckout` debounces and calls `set-shipping-address` once the address is
@@ -72,9 +84,10 @@ function newValue(args) {
72
84
  *
73
85
  * @param {"billing"|"shipping"} [which]
74
86
  * @param {{includeState?: boolean, includePhone?: boolean, includeCompany?: boolean}} [options]
75
- * @returns {{fields: Array<{key: string, label: string, type: string,
87
+ * @returns {{fields: Array<{key: string, id: string, label: string, type: string,
76
88
  * value: string, required: boolean, options: Array<object>, error: string|null,
77
- * autoComplete: string, colSpan: number, set: (...args: any[]) => void}>,
89
+ * autoComplete: string, colSpan: number, set: (...args: any[]) => void,
90
+ * isSelect: boolean, placeholder: string|undefined}>,
78
91
  * set: (key: string, value: any) => void, values: object, missing: Array<string>,
79
92
  * complete: boolean, error: object|null, countriesLoading: boolean}}
80
93
  */
@@ -94,6 +107,11 @@ export function useAddressForm(which = "billing", options = {}) {
94
107
  // address is priced, not validated field-by-field.
95
108
  const missing = isBilling ? checkout.missingBillingFields : [];
96
109
 
110
+ // A required field reports itself as `error` only after it has been edited —
111
+ // an untouched form must not open covered in "required" marks. (Fields never
112
+ // touched at all surface through the place-order blockers instead.)
113
+ const [visited, setVisited] = useState({});
114
+
97
115
  const fields = useMemo(() => {
98
116
  const spec = addressFieldSpec({
99
117
  countries,
@@ -102,20 +120,39 @@ export function useAddressForm(which = "billing", options = {}) {
102
120
  includeEmail: isBilling, // one email per order, on billing
103
121
  ...options,
104
122
  });
105
- return spec.map((f) => ({
106
- ...f,
107
- value: values?.[f.key] ?? "",
123
+ return spec.map((f) => {
124
+ const value = values?.[f.key] ?? "";
108
125
  // Self-contained: the field knows its own key, so a .map never has to
109
126
  // reach back out to the hook's set() (and can't pass the wrong key).
110
- set: (...args) => set(f.key, newValue(args)),
127
+ // The first edit marks the field visited, arming its required check.
128
+ const setField = (...args) => {
129
+ setVisited((v) => (v[f.key] ? v : { ...v, [f.key]: true }));
130
+ set(f.key, newValue(args));
131
+ };
111
132
  // The address-level error ("we don't ship there") belongs on country.
112
- error:
133
+ const error =
113
134
  f.key === "country" && checkout.addressError?.code === "shipping_not_available"
114
135
  ? checkout.addressError.message
115
- : null,
116
- }));
136
+ : visited[f.key] && !value && missing.includes(f.key)
137
+ ? `${f.label} is required.`
138
+ : null;
139
+ return {
140
+ ...f,
141
+ id: `${which}-${f.key}`,
142
+ value,
143
+ set: setField,
144
+ error,
145
+ isSelect: f.type === "select",
146
+ placeholder:
147
+ f.type === "select"
148
+ ? f.key === "country" && countriesLoading
149
+ ? "Loading…"
150
+ : `Select ${f.label}`
151
+ : undefined,
152
+ };
153
+ });
117
154
  // eslint-disable-next-line react-hooks/exhaustive-deps
118
- }, [countries, values, isBilling, set, checkout.addressError, JSON.stringify(options)]);
155
+ }, [countries, countriesLoading, values, which, isBilling, set, checkout.addressError, visited, missing.join(","), JSON.stringify(options)]);
119
156
 
120
157
  return {
121
158
  fields,
@@ -135,6 +135,8 @@ export function useCartLine(line, { debounceMs = 250 } = {}) {
135
135
  atMin: quantity <= 1,
136
136
  attributesLabel: line?.attributesLabel ?? "",
137
137
  image: line?.image ?? null,
138
+ totalLabel: line?.totalLabel ?? "",
139
+ unitPriceLabel: line?.unitPriceLabel ?? "",
138
140
  };
139
141
  }
140
142
 
@@ -148,12 +150,15 @@ export function useCartLine(line, { debounceMs = 250 } = {}) {
148
150
  * {lines.map(line => (
149
151
  * <CartLine key={line.item_key} line={line}>
150
152
  * {(l) => (
151
- * <li>
153
+ * <li aria-busy={l.pending}>
152
154
  * {line.name} {line.attributesLabel}
153
- * <button onClick={l.decrease} disabled={!l.canDecrease || l.pending}>−</button>
155
+ * <button onClick={l.decrease} disabled={!l.canDecrease || l.pending}
156
+ * aria-label={`Decrease quantity of ${line.name}`}>−</button>
154
157
  * {l.quantity}
155
- * <button onClick={l.increase} disabled={!l.canIncrease || l.pending}>+</button>
156
- * <button onClick={l.remove}>Remove</button>
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}
157
162
  * {l.error && <p role="alert">{l.error.message}</p>}
158
163
  * </li>
159
164
  * )}
@@ -89,6 +89,12 @@ function resolvePaymentMethod(gateways, picked) {
89
89
  * `shipping_address_required`, `shipping_method_required`,
90
90
  * `shipping_not_available`, `payment_method_required`.
91
91
  *
92
+ * `stage` is `"editing" | "placing" | "submitted"` (`submitted` = the order was
93
+ * accepted; the navigation away is already in flight). **Guard on it before the
94
+ * cart's empty branch** — `placeOrder` clears the cart, so a page that checks
95
+ * 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.
97
+ *
92
98
  * Options: `debounceMs` (600), `addressComplete` (predicate overriding the
93
99
  * country+city rule), `requiredBillingFields`, `redirectToPayment` (true),
94
100
  * `orderReceivedPath` ("/order-received"; null disables the navigation).
@@ -113,6 +119,7 @@ export function useCheckout(options = {}) {
113
119
  const [syncing, setSyncing] = useState(false);
114
120
  const [syncedKey, setSyncedKey] = useState(undefined);
115
121
  const [placing, setPlacing] = useState(false);
122
+ const [submitted, setSubmitted] = useState(false);
116
123
  const [orderError, setOrderError] = useState(null);
117
124
 
118
125
  const updateBilling = useCallback((patch) => setBilling((b) => ({ ...b, ...patch })), []);
@@ -216,6 +223,10 @@ export function useCheckout(options = {}) {
216
223
  ...(shipToDifferent ? { shipping } : {}),
217
224
  ...extra, // customer_note, success_url/cancel_url overrides, …
218
225
  });
226
+ // `submitted` flips BEFORE the cart clears, in the same commit — the
227
+ // page's `stage === "submitted"` guard is what stands between a placed
228
+ // order and a flash of "your bag is empty" while the browser navigates.
229
+ setSubmitted(true);
219
230
  clearCart(); // checkout consumed the cart
220
231
  if (
221
232
  redirectToPayment &&
@@ -280,6 +291,8 @@ export function useCheckout(options = {}) {
280
291
  blockers,
281
292
  canPlaceOrder,
282
293
  placing,
294
+ submitted,
295
+ stage: placing ? "placing" : submitted ? "submitted" : "editing",
283
296
  orderError,
284
297
  placeOrder,
285
298
  // underlying data, for convenience
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useEffect, useState } from "react";
2
2
  import { orderLines, storefrontErrorCode, storefrontErrorMessage } from "@/commerce/utils";
3
- import { useStorefrontState } from "./StorefrontProvider";
3
+ import { useFormatMoney, useStorefrontState } from "./StorefrontProvider";
4
4
  import { orderSeo, useStorefrontSeo } from "./useStorefrontSeo";
5
5
 
6
6
  /**
@@ -19,9 +19,10 @@ import { orderSeo, useStorefrontSeo } from "./useStorefrontSeo";
19
19
  * // "cancelled" → payment was cancelled — offer paymentLink.url or support
20
20
  * // "error" → render error.message with a retry via reload()
21
21
  *
22
- * `lines` are the order's items in the decorated cart-line shape, so the same
23
- * row markup renders the bag and the confirmation; totals come from
24
- * `useTotalsLines(order)`. The page is marked `noindex` automatically a
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
25
26
  * receipt carrying an order key must not rank (`seo: false` opts out).
26
27
  *
27
28
  * It reads `order_id`/`order_key`/`payment` from the URL itself and verifies
@@ -32,6 +33,7 @@ import { orderSeo, useStorefrontSeo } from "./useStorefrontSeo";
32
33
  */
33
34
  export function useOrderReturn({ auto = true, seo = true } = {}) {
34
35
  const { client } = useStorefrontState();
36
+ const formatMoney = useFormatMoney();
35
37
  const [result, setResult] = useState({ status: auto ? "loading" : "idle" });
36
38
 
37
39
  const reload = useCallback(
@@ -61,7 +63,7 @@ export function useOrderReturn({ auto = true, seo = true } = {}) {
61
63
 
62
64
  useStorefrontSeo(seo ? orderSeo(result.order ?? null) : null);
63
65
 
64
- return { ...result, lines: orderLines(result.order ?? null), reload };
66
+ return { ...result, lines: orderLines(result.order ?? null, { formatMoney }), reload };
65
67
  }
66
68
 
67
69
  /**
@@ -0,0 +1,63 @@
1
+ import { useCallback } from "react";
2
+ import { useCheckoutContext } from "./useCheckout";
3
+ import { useCheckoutBlockers } from "./useTotalsLines";
4
+
5
+ /**
6
+ * usePlaceOrder — the place-order gate as plain states and one handler. Needs
7
+ * a `<CheckoutProvider>` above it. The whole bottom of a checkout page:
8
+ *
9
+ * const order = usePlaceOrder();
10
+ * if (order.stage === "submitted") return <p>Order placed — taking you to your receipt…</p>;
11
+ * …
12
+ * <button type="button" onClick={order.placeOrder} disabled={order.disabled} className="…">
13
+ * {order.label}
14
+ * </button>
15
+ * {order.error && <p role="alert" className="…">{order.error.message}</p>}
16
+ * {!order.canPlaceOrder && order.blockers.map(b => <p key={b.code}>{b.message}</p>)}
17
+ *
18
+ * ⚑ **The `stage === "submitted"` guard goes above the page's empty-cart
19
+ * branch.** Placing an order clears the cart before the browser navigates
20
+ * away; without the guard the page flashes "your bag is empty" over a
21
+ * just-placed order. `stage` is `"editing" | "placing" | "submitted"`.
22
+ *
23
+ * `disabled` is the gate plus in-flight (`!canPlaceOrder || placing`); `label`
24
+ * follows `placing` and is overridable via `labels: { idle, placing }`.
25
+ * `placeOrder` is safe as an `onClick` handler directly — a click event passed
26
+ * to it is ignored (an explicit `extra` object is still forwarded). `blockers`
27
+ * are the disabled button's reasons in words (`useCheckoutBlockers`), each
28
+ * with a `field` to anchor it next to the input that fixes it. Pass
29
+ * `blockerLabels` to override that copy per code.
30
+ *
31
+ * @param {{labels?: {idle?: string, placing?: string},
32
+ * blockerLabels?: Record<string, string>}} [options]
33
+ * @returns {{placeOrder: (extra?: object) => Promise<object>, disabled: boolean,
34
+ * label: string, stage: "editing"|"placing"|"submitted", submitted: boolean,
35
+ * placing: boolean, canPlaceOrder: boolean, error: object|null,
36
+ * blockers: Array<{code: string, message: string, field: string|null}>}}
37
+ */
38
+ export function usePlaceOrder({ labels, blockerLabels } = {}) {
39
+ const checkout = useCheckoutContext();
40
+ const blockers = useCheckoutBlockers({ labels: blockerLabels });
41
+ const { canPlaceOrder, placing, submitted, stage, orderError, placeOrder } = checkout;
42
+
43
+ // Usable as onClick={order.placeOrder}: a DOM/React event is not `extra`.
44
+ const place = useCallback(
45
+ (extra) => {
46
+ const isEvent = extra && typeof extra === "object" && ("nativeEvent" in extra || "target" in extra);
47
+ return placeOrder(isEvent ? undefined : extra);
48
+ },
49
+ [placeOrder],
50
+ );
51
+
52
+ return {
53
+ placeOrder: place,
54
+ disabled: !canPlaceOrder || placing,
55
+ label: placing ? (labels?.placing ?? "Placing your order…") : (labels?.idle ?? "Place order"),
56
+ stage,
57
+ submitted,
58
+ placing,
59
+ canPlaceOrder,
60
+ error: orderError,
61
+ blockers,
62
+ };
63
+ }