@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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/skills/commerce/SKILL.md +68 -76
- package/skills/commerce/docs/api-admin.md +11 -50
- package/skills/commerce/docs/api-storefront.md +25 -118
- package/skills/commerce/install/01-install.md +20 -49
- package/skills/commerce/install/02-storefront.md +204 -431
- package/skills/commerce/install/03-data.md +43 -109
- package/skills/commerce/references/admin-product-form.md +26 -0
- package/skills/commerce/references/catalog-rendering.md +9 -9
- package/skills/commerce/references/online-payments.md +4 -15
- package/skills/commerce/references/operations.md +19 -1
- package/skills/commerce/references/storefront-verification.md +47 -0
- package/src/commerce/storefront/StorefrontProvider.jsx +8 -3
- package/src/commerce/storefront/cartUI.jsx +118 -0
- package/src/commerce/storefront/index.js +39 -15
- package/src/commerce/storefront/pickers.jsx +84 -23
- package/src/commerce/storefront/useAddressForm.js +63 -26
- package/src/commerce/storefront/useCartLine.js +9 -4
- package/src/commerce/storefront/useCheckout.jsx +13 -0
- package/src/commerce/storefront/useOrderReturn.js +7 -5
- package/src/commerce/storefront/usePlaceOrder.js +63 -0
- package/src/commerce/storefront/useProduct.js +112 -41
- package/src/commerce/storefront/useProductList.js +7 -0
- package/src/commerce/storefront/useUpsell.js +90 -0
- package/src/commerce/utils/specs.js +6 -2
- package/src/commerce/utils/totals.js +23 -12
|
@@ -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
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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`
|
|
46
|
-
* recover, and the coupon field a store
|
|
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
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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
|
|
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,
|
|
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"
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
|
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
|
-
*
|
|
37
|
-
*
|
|
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
|
-
*
|
|
40
|
-
*
|
|
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:
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
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
|
|
46
|
-
*
|
|
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
|
-
* <
|
|
51
|
-
* {f.label}{f.required && " *"}
|
|
52
|
-
* {f.
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* </
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* {f.
|
|
59
|
-
*
|
|
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.
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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}
|
|
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}
|
|
156
|
-
*
|
|
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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
+
}
|