@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.
- package/README.md +4 -4
- package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
- package/base44/functions/commerce/seed-store/entry.ts +16 -2
- package/package.json +2 -2
- package/scripts/install.js +15 -0
- package/skills/commerce/SKILL.md +34 -17
- package/skills/commerce/docs/api-admin.md +1 -1
- package/skills/commerce/docs/api-storefront.md +12 -12
- package/skills/commerce/install/01-install.md +6 -8
- package/skills/commerce/install/02-storefront.md +180 -278
- package/skills/commerce/install/03-data.md +13 -11
- package/skills/commerce/references/catalog-rendering.md +37 -43
- package/skills/commerce/references/online-payments.md +10 -0
- package/skills/commerce/references/reviews.md +21 -14
- package/skills/commerce/references/store-settings.md +1 -1
- package/skills/commerce/references/storefront-verification.md +21 -15
- package/src/commerce/storefront/StorefrontProvider.jsx +65 -128
- package/src/commerce/storefront/cartUI.jsx +11 -30
- package/src/commerce/storefront/index.js +61 -98
- package/src/commerce/storefront/pickers.jsx +50 -64
- package/src/commerce/storefront/useCartLine.js +23 -130
- package/src/commerce/storefront/useCheckout.jsx +50 -43
- package/src/commerce/storefront/useOrderReturn.js +17 -7
- package/src/commerce/storefront/useProduct.js +41 -97
- package/src/commerce/storefront/useProductList.js +14 -22
- package/src/commerce/utils/address-spec.js +1 -1
- package/src/commerce/utils/images.js +1 -1
- package/src/commerce/utils/index.js +9 -9
- package/src/commerce/utils/price.js +2 -1
- package/src/commerce/utils/specs.js +41 -91
- package/src/commerce/utils/totals.js +7 -4
- package/src/commerce/storefront/useAddressForm.js +0 -166
- package/src/commerce/storefront/usePlaceOrder.js +0 -63
- package/src/commerce/storefront/useProductGallery.js +0 -78
- package/src/commerce/storefront/useProductPrice.js +0 -58
- package/src/commerce/storefront/useProductReviews.js +0 -242
- package/src/commerce/storefront/useStorefrontSeo.js +0 -204
- package/src/commerce/storefront/useTotalsLines.js +0 -109
- 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
|
|
13
|
-
* the child is markup + classes and nothing else.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* {
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* {
|
|
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
|
|
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",
|
|
86
|
+
? { code: "missing_address", severity: "info", serverMessage: null }
|
|
88
87
|
: status === "none_available" || addressError?.code === "shipping_not_available"
|
|
89
|
-
? { code: "none_available", severity: "error",
|
|
88
|
+
? { code: "none_available", severity: "error", serverMessage: addressError?.message ?? null }
|
|
90
89
|
: syncing
|
|
91
|
-
? { code: "syncing", severity: "info",
|
|
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
|
-
*
|
|
111
|
-
*
|
|
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
|
-
* {
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
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.
|
|
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",
|
|
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
|
-
*
|
|
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
|
-
*
|
|
26
|
-
* the
|
|
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
|
-
*
|
|
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
|
|
127
|
+
* It exists so an `items.map(...)` doesn't tempt a hook call inside a loop:
|
|
148
128
|
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
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
|
-
*
|
|
169
|
-
*
|
|
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`;
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* `set-shipping-address`,
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* - **Shipping choice.** `shippingStatus` mirrors the cart
|
|
50
|
-
*
|
|
51
|
-
* `
|
|
52
|
-
* `
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* is
|
|
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
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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`)
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
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.
|
|
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
|
-
...
|
|
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
|
|
23
|
-
* `
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
|
|
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
|
}
|