@base44/app-plugin-commerce 0.2.6 → 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 +10 -6
- package/skills/commerce/docs/api-storefront.md +2 -2
- package/skills/commerce/install/02-storefront.md +115 -48
- package/skills/commerce/references/catalog-rendering.md +2 -2
- package/skills/commerce/references/storefront-verification.md +3 -3
- package/src/commerce/storefront/cartUI.jsx +38 -110
- package/src/commerce/storefront/index.js +22 -22
- package/src/commerce/storefront/pickers.jsx +15 -29
- package/src/commerce/storefront/useAddressForm.js +27 -36
- package/src/commerce/storefront/usePlaceOrder.js +29 -21
- package/src/commerce/storefront/useProduct.js +34 -43
- package/src/commerce/storefront/useProductList.js +7 -12
- package/src/commerce/storefront/useUpsell.js +2 -2
|
@@ -1,59 +1,47 @@
|
|
|
1
|
-
import React, {
|
|
2
|
-
createContext,
|
|
3
|
-
useCallback,
|
|
4
|
-
useContext,
|
|
5
|
-
useEffect,
|
|
6
|
-
useId,
|
|
7
|
-
useMemo,
|
|
8
|
-
useRef,
|
|
9
|
-
useState,
|
|
10
|
-
} from "react";
|
|
1
|
+
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
11
2
|
import { useLocation } from "react-router-dom";
|
|
12
|
-
import { useCart } from "./StorefrontProvider";
|
|
13
3
|
|
|
14
4
|
/**
|
|
15
|
-
* Cart drawer/panel state, headless
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
5
|
+
* Cart drawer/panel state, headless: `open` plus the handlers to change it,
|
|
6
|
+
* and the two behaviors every drawer needs but a hand-written one forgets —
|
|
7
|
+
* it closes when the route changes (`closeOnNavigate`) and it opens when an
|
|
8
|
+
* item lands in the cart (`openOnAdd`, wired through `useAddToCart` /
|
|
9
|
+
* `useUpsell`; pass false for a navigate-to-bag flow). Esc closes it. You own
|
|
10
|
+
* every element, class and attribute.
|
|
21
11
|
*
|
|
22
12
|
* Mount `<CartUIProvider>` once, inside `<StorefrontProvider>`, around the
|
|
23
|
-
* layout. Then the layout
|
|
13
|
+
* layout. Then the layout renders off `open`:
|
|
24
14
|
*
|
|
25
15
|
* function StoreLayout() {
|
|
26
16
|
* const ui = useCartUI();
|
|
17
|
+
* const { itemCount } = useCart();
|
|
27
18
|
* return (<>
|
|
28
|
-
* <header>… <button
|
|
19
|
+
* <header>… <button type="button" onClick={ui.toggleCart}
|
|
20
|
+
* aria-expanded={ui.open}>Bag ({itemCount})</button></header>
|
|
29
21
|
* <Outlet />
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* <
|
|
33
|
-
*
|
|
34
|
-
*
|
|
22
|
+
* {ui.open && (<>
|
|
23
|
+
* <div onClick={ui.closeCart} aria-hidden="true" className="…" />
|
|
24
|
+
* <aside role="dialog" aria-modal="true" aria-label="Cart" className="…">
|
|
25
|
+
* <button type="button" onClick={ui.closeCart} aria-label="Close cart">×</button>
|
|
26
|
+
* {…your cart rows: useCart + CartLine…}
|
|
27
|
+
* </aside>
|
|
28
|
+
* </>)}
|
|
35
29
|
* </>);
|
|
36
30
|
* }
|
|
37
31
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* **inert** DOM property while closed, so a translated-off-screen drawer's
|
|
45
|
-
* controls genuinely stop existing for clicks, Tab and screen readers. Keep
|
|
46
|
-
* the panel mounted (animate with classes); don't also conditionally unmount.
|
|
47
|
-
* - `overlayProps` — click-to-close, `aria-hidden`, no tab stop. The overlay is
|
|
48
|
-
* not a second "close" control; the named close button is `closeButtonProps`.
|
|
49
|
-
* - Esc closes; route changes close (`closeOnNavigate`); a successful
|
|
50
|
-
* add-to-cart opens (`openOnAdd`, via `useAddToCartButton` — pass false for a
|
|
51
|
-
* navigate-to-bag flow instead).
|
|
32
|
+
* ⚑ **Render the drawer conditionally (`{ui.open && …}`), as above.** The
|
|
33
|
+
* classic drawer bug is a panel that is translated off-screen but still
|
|
34
|
+
* mounted: its buttons stay clickable, tab-able and visible to screen readers.
|
|
35
|
+
* Unmounting it when closed is the trivial fix. If you keep it mounted to
|
|
36
|
+
* animate the slide, that concern is yours again — set the `inert` attribute
|
|
37
|
+
* while closed.
|
|
52
38
|
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
39
|
+
* The overlay is a click-away surface (`aria-hidden`, no tab stop needed) —
|
|
40
|
+
* it is not the close control; a named close button inside the panel is.
|
|
41
|
+
*
|
|
42
|
+
* `useCartUI()` → `{ open, openCart, closeCart, toggleCart }`.
|
|
43
|
+
* `useCartUIOptional()` returns null instead of throwing (how `useAddToCart`
|
|
44
|
+
* integrates without requiring the provider).
|
|
57
45
|
*
|
|
58
46
|
* A store whose cart is a page, not a drawer, skips this provider entirely.
|
|
59
47
|
*/
|
|
@@ -75,32 +63,14 @@ function CloseOnNavigate({ close }) {
|
|
|
75
63
|
}
|
|
76
64
|
|
|
77
65
|
/**
|
|
78
|
-
* @param {{closeOnNavigate?: boolean, openOnAdd?: boolean,
|
|
79
|
-
* children: React.ReactNode}} props
|
|
80
|
-
* tech and the default trigger/close labels (default "Cart").
|
|
66
|
+
* @param {{closeOnNavigate?: boolean, openOnAdd?: boolean,
|
|
67
|
+
* children: React.ReactNode}} props
|
|
81
68
|
*/
|
|
82
|
-
export function CartUIProvider({ closeOnNavigate = true, openOnAdd = true,
|
|
69
|
+
export function CartUIProvider({ closeOnNavigate = true, openOnAdd = true, children }) {
|
|
83
70
|
const [open, setOpen] = useState(false);
|
|
84
71
|
const openCart = useCallback(() => setOpen(true), []);
|
|
85
72
|
const closeCart = useCallback(() => setOpen(false), []);
|
|
86
73
|
const toggleCart = useCallback(() => setOpen((o) => !o), []);
|
|
87
|
-
const { itemCount } = useCart();
|
|
88
|
-
|
|
89
|
-
const panelId = useId();
|
|
90
|
-
const panelRef = useRef(null);
|
|
91
|
-
const triggerRef = useRef(null);
|
|
92
|
-
const openRef = useRef(open);
|
|
93
|
-
openRef.current = open;
|
|
94
|
-
|
|
95
|
-
// `inert` is set as a DOM property (not a JSX attribute): React 18 has no
|
|
96
|
-
// boolean `inert` support, and the property form works on 18 and 19 alike.
|
|
97
|
-
const setPanelRef = useCallback((node) => {
|
|
98
|
-
panelRef.current = node;
|
|
99
|
-
if (node) node.inert = !openRef.current;
|
|
100
|
-
}, []);
|
|
101
|
-
useEffect(() => {
|
|
102
|
-
if (panelRef.current) panelRef.current.inert = !open;
|
|
103
|
-
}, [open]);
|
|
104
74
|
|
|
105
75
|
// Esc closes — the keyboard's way out is Esc and the named close button.
|
|
106
76
|
useEffect(() => {
|
|
@@ -112,58 +82,16 @@ export function CartUIProvider({ closeOnNavigate = true, openOnAdd = true, label
|
|
|
112
82
|
return () => window.removeEventListener("keydown", onKey);
|
|
113
83
|
}, [open]);
|
|
114
84
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const wasOpen = useRef(false);
|
|
118
|
-
useEffect(() => {
|
|
119
|
-
if (open) {
|
|
120
|
-
wasOpen.current = true;
|
|
121
|
-
panelRef.current?.focus?.();
|
|
122
|
-
} else if (wasOpen.current) {
|
|
123
|
-
wasOpen.current = false;
|
|
124
|
-
triggerRef.current?.focus?.();
|
|
125
|
-
}
|
|
126
|
-
}, [open]);
|
|
127
|
-
|
|
128
|
-
const value = useMemo(() => {
|
|
129
|
-
const state = open ? "open" : "closed";
|
|
130
|
-
return {
|
|
85
|
+
const value = useMemo(
|
|
86
|
+
() => ({
|
|
131
87
|
open,
|
|
132
88
|
openCart,
|
|
133
89
|
closeCart,
|
|
134
90
|
toggleCart,
|
|
135
91
|
onItemAdded: openOnAdd ? openCart : null,
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
onClick: toggleCart,
|
|
140
|
-
"aria-haspopup": "dialog",
|
|
141
|
-
"aria-expanded": open,
|
|
142
|
-
"aria-controls": panelId,
|
|
143
|
-
"aria-label": `${label}, ${itemCount} ${itemCount === 1 ? "item" : "items"}`,
|
|
144
|
-
},
|
|
145
|
-
overlayProps: {
|
|
146
|
-
onClick: closeCart,
|
|
147
|
-
"aria-hidden": true,
|
|
148
|
-
tabIndex: -1,
|
|
149
|
-
"data-state": state,
|
|
150
|
-
},
|
|
151
|
-
panelProps: {
|
|
152
|
-
ref: setPanelRef,
|
|
153
|
-
id: panelId,
|
|
154
|
-
role: "dialog",
|
|
155
|
-
"aria-modal": true,
|
|
156
|
-
"aria-label": label,
|
|
157
|
-
tabIndex: -1,
|
|
158
|
-
"data-state": state,
|
|
159
|
-
},
|
|
160
|
-
closeButtonProps: {
|
|
161
|
-
type: "button",
|
|
162
|
-
onClick: closeCart,
|
|
163
|
-
"aria-label": `Close ${label.toLowerCase()}`,
|
|
164
|
-
},
|
|
165
|
-
};
|
|
166
|
-
}, [open, openCart, closeCart, toggleCart, openOnAdd, panelId, setPanelRef, label, itemCount]);
|
|
92
|
+
}),
|
|
93
|
+
[open, openCart, closeCart, toggleCart, openOnAdd],
|
|
94
|
+
);
|
|
167
95
|
|
|
168
96
|
return (
|
|
169
97
|
<CartUIContext.Provider value={value}>
|
|
@@ -27,24 +27,23 @@
|
|
|
27
27
|
* </Route>
|
|
28
28
|
* // no shared layout? <StorefrontProvider …> <Routes>…</Routes> </StorefrontProvider>
|
|
29
29
|
*
|
|
30
|
-
*
|
|
31
|
-
* `
|
|
32
|
-
* `
|
|
33
|
-
*
|
|
34
|
-
* first, put your `className` after.
|
|
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.
|
|
35
34
|
*
|
|
36
35
|
* ## Hooks
|
|
37
36
|
* - `useStorefront` / `useStoreInfo` / `useFormatMoney` / `useMoney` — the
|
|
38
37
|
* shared client, cached store info (the ONLY source of payment gateways,
|
|
39
38
|
* currency and countries), money in the store's currency.
|
|
40
39
|
* - `useProductList` / `useCategories` / `useRibbons` — a listing with paging
|
|
41
|
-
* (`
|
|
42
|
-
* `refreshing`, and failure as a visible state.
|
|
43
|
-
* - `useProduct` / `useAddToCart` / `
|
|
44
|
-
* `
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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 +
|
|
44
|
+
* quantity + price + gallery, race-safe, with `status: "not_found"` and
|
|
45
|
+
* every add-to-cart failure handled; the buy box as
|
|
46
|
+
* `state`/`label`/`disabled`/`addToCart`. `variantAxes(view, pick)`
|
|
48
47
|
* (from `@/commerce/utils`, re-exported here) turns the resolved view into
|
|
49
48
|
* a render-ready model for the selector you write; `useProductSpecs` adds
|
|
50
49
|
* normalized `pick`/`get` lookup over `productSpecs`.
|
|
@@ -55,20 +54,21 @@
|
|
|
55
54
|
* quantity steppers that clamp and recover, and the coupon field a store
|
|
56
55
|
* with coupons must have.
|
|
57
56
|
* - `useCartUI` + `<CartUIProvider>` — the cart drawer's non-visual machinery:
|
|
58
|
-
* open
|
|
59
|
-
*
|
|
57
|
+
* `open` + open/close/toggle handlers, Esc, close-on-navigate, open-on-add;
|
|
58
|
+
* render the drawer conditionally off `open`.
|
|
60
59
|
* - `useCheckout` / `CheckoutProvider` / `useCheckoutContext` — the guided
|
|
61
60
|
* checkout: address state with automatic debounced shipping/tax
|
|
62
61
|
* recalculation, shipping and payment choice, a `canPlaceOrder` gate with
|
|
63
62
|
* named blockers, `placeOrder` with both navigations handled (online →
|
|
64
63
|
* provider redirect, manual → `/order-received`).
|
|
65
|
-
* - `usePlaceOrder` — the gate as one surface: `
|
|
66
|
-
* `stage` (guards the just-placed-order frames), `blockers` in
|
|
64
|
+
* - `usePlaceOrder` — the gate as one surface: `placeOrder`, `disabled`,
|
|
65
|
+
* `label`, `stage` (guards the just-placed-order frames), `blockers` in
|
|
66
|
+
* words.
|
|
67
67
|
* - `useAddressForm` / `useCountries` / `useTotalsLines` /
|
|
68
|
-
* `useCheckoutBlockers` — the address form as a field spec
|
|
69
|
-
* `
|
|
70
|
-
* country options never null), one totals projection for cart
|
|
71
|
-
* 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
72
|
* - `useUpsell` — one product offered beside another surface: fetch-or-row,
|
|
73
73
|
* already-in-cart matched by id, one-click add.
|
|
74
74
|
* - `useOrderReturn` — the mandatory `/order-received` page in one hook:
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
* ## Render-prop components (headless — children is a function, no markup ships)
|
|
80
80
|
* - `ShippingMethodPicker` / `PaymentMethodPicker` — the two checkout choices
|
|
81
81
|
* that are store data, never hardcoded; options arrive decorated with
|
|
82
|
-
* `
|
|
82
|
+
* `selected`/`select()`/`costLabel` and a single `hint` message.
|
|
83
83
|
* - `CartLine` — per-line `useCartLine` binding for your cart rows, so a
|
|
84
84
|
* `lines.map(...)` never calls a hook in a loop.
|
|
85
85
|
*
|
|
@@ -115,7 +115,7 @@ export {
|
|
|
115
115
|
|
|
116
116
|
// ── catalog ────────────────────────────────────────────────────────────────
|
|
117
117
|
export { useProductList, useCategories, useRibbons } from "./useProductList";
|
|
118
|
-
export { useProduct, useAddToCart,
|
|
118
|
+
export { useProduct, useAddToCart, useProductSpecs } from "./useProduct";
|
|
119
119
|
export { useUpsell } from "./useUpsell";
|
|
120
120
|
export { useProductPrice, useMoney } from "./useProductPrice";
|
|
121
121
|
export { useProductGallery } from "./useProductGallery";
|
|
@@ -8,11 +8,11 @@ import { useFormatMoney } from "./StorefrontProvider";
|
|
|
8
8
|
* whole UI — but they encode the branching every checkout must do, so a page
|
|
9
9
|
* can't skip a `shipping_status` state or invent a payment method.
|
|
10
10
|
*
|
|
11
|
-
* Every option comes
|
|
12
|
-
* (`
|
|
13
|
-
* child is markup + classes and nothing else. `hint` is the one message
|
|
14
|
-
* picker wants shown right now (or null) — render it and the status
|
|
15
|
-
* is done.
|
|
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
16
|
*
|
|
17
17
|
* Both read the nearest <CheckoutProvider>, or take an explicit `checkout`
|
|
18
18
|
* prop when you called `useCheckout` yourself.
|
|
@@ -34,7 +34,7 @@ function resolveCheckout(name, prop, ctx) {
|
|
|
34
34
|
* status, // "missing_address" | "choice_required" | "chosen"
|
|
35
35
|
* // | "auto_selected" | "none_available"
|
|
36
36
|
* methods, // [{ id, title, cost, costLabel, selected,
|
|
37
|
-
* //
|
|
37
|
+
* // select }] — what this address is offered
|
|
38
38
|
* chosen, // the chosen/auto-selected entry (with costLabel), or null
|
|
39
39
|
* selected, // alias of `chosen` — the same name the payment picker uses
|
|
40
40
|
* choose, // (id) => Promise — call with a method's id on pick
|
|
@@ -52,8 +52,9 @@ function resolveCheckout(name, prop, ctx) {
|
|
|
52
52
|
*
|
|
53
53
|
* {hint && <p role={hint.severity === "error" ? "alert" : "status"}>{hint.message}</p>}
|
|
54
54
|
* {mustChoose && methods.map(m => (
|
|
55
|
-
* <label key={m.id}
|
|
56
|
-
* <input
|
|
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>
|
|
57
58
|
* </label>
|
|
58
59
|
* ))}
|
|
59
60
|
* {!mustChoose && chosen && <p>{chosen.title} {chosen.costLabel}</p>}
|
|
@@ -79,15 +80,7 @@ export function ShippingMethodPicker({ checkout: checkoutProp, children }) {
|
|
|
79
80
|
...m,
|
|
80
81
|
selected: m.id === chosen?.id,
|
|
81
82
|
costLabel: formatMoney(m.cost),
|
|
82
|
-
|
|
83
|
-
type: "radio",
|
|
84
|
-
name: "shipping-method",
|
|
85
|
-
id: `shipping-method-${m.id}`,
|
|
86
|
-
value: m.id,
|
|
87
|
-
checked: m.id === chosen?.id,
|
|
88
|
-
onChange: () => choose(m.id),
|
|
89
|
-
},
|
|
90
|
-
labelProps: { htmlFor: `shipping-method-${m.id}` },
|
|
83
|
+
select: () => choose(m.id),
|
|
91
84
|
};
|
|
92
85
|
const hint =
|
|
93
86
|
status === "missing_address"
|
|
@@ -119,7 +112,7 @@ export function ShippingMethodPicker({ checkout: checkoutProp, children }) {
|
|
|
119
112
|
*
|
|
120
113
|
* {
|
|
121
114
|
* gateways, // [{ slug, title, description, online, selected,
|
|
122
|
-
* //
|
|
115
|
+
* // select }] — admin-owned data, decorated
|
|
123
116
|
* value, // the selected slug ("" while none)
|
|
124
117
|
* select, // (slug) => void
|
|
125
118
|
* selected, // the selected gateway entry, or null
|
|
@@ -132,8 +125,9 @@ export function ShippingMethodPicker({ checkout: checkoutProp, children }) {
|
|
|
132
125
|
*
|
|
133
126
|
* {hint && <p role="alert">{hint.message}</p>}
|
|
134
127
|
* {!single && gateways.map(g => (
|
|
135
|
-
* <label key={g.slug}
|
|
136
|
-
* <input
|
|
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>
|
|
137
131
|
* </label>
|
|
138
132
|
* ))}
|
|
139
133
|
* {single && selected && <p>{selected.title} — {selected.description}</p>}
|
|
@@ -155,15 +149,7 @@ export function PaymentMethodPicker({ checkout: checkoutProp, children }) {
|
|
|
155
149
|
const decorated = gateways.map((g) => ({
|
|
156
150
|
...g,
|
|
157
151
|
selected: g.slug === value,
|
|
158
|
-
|
|
159
|
-
type: "radio",
|
|
160
|
-
name: "payment-method",
|
|
161
|
-
id: `payment-method-${g.slug}`,
|
|
162
|
-
value: g.slug,
|
|
163
|
-
checked: g.slug === value,
|
|
164
|
-
onChange: () => select(g.slug),
|
|
165
|
-
},
|
|
166
|
-
labelProps: { htmlFor: `payment-method-${g.slug}` },
|
|
152
|
+
select: () => select(g.slug),
|
|
167
153
|
}));
|
|
168
154
|
const hint =
|
|
169
155
|
gateways.length === 0
|
|
@@ -42,34 +42,37 @@ 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
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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:
|
|
48
48
|
*
|
|
49
49
|
* const { fields } = useAddressForm("billing");
|
|
50
50
|
* {fields.map(f => (
|
|
51
51
|
* <div key={f.key} className="…">
|
|
52
|
-
* <label {
|
|
52
|
+
* <label htmlFor={f.id}>{f.label}{f.required && " *"}</label>
|
|
53
53
|
* {f.isSelect ? (
|
|
54
|
-
* <select {
|
|
54
|
+
* <select id={f.id} value={f.value} onChange={f.set}
|
|
55
|
+
* autoComplete={f.autoComplete} className="…">
|
|
55
56
|
* <option value="">{f.placeholder}</option>
|
|
56
57
|
* {f.options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
|
57
58
|
* </select>
|
|
58
|
-
* ) : <input {
|
|
59
|
-
*
|
|
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>}
|
|
60
62
|
* </div>
|
|
61
63
|
* ))}
|
|
62
64
|
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
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
|
|
66
68
|
* select's empty option ("Select Country", or "Loading…" while countries
|
|
67
69
|
* arrive), so `countriesLoading` never needs handling by hand. `f.error` is
|
|
68
70
|
* per-field: "we don't ship there" lands on the country field, and a required
|
|
69
|
-
* field
|
|
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`.
|
|
70
73
|
*
|
|
71
|
-
* `f.set`
|
|
72
|
-
* event (`onChange={f.set}`) or a `(key, value)` pair — as
|
|
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
|
|
73
76
|
* top-level `set(key, value)`.
|
|
74
77
|
*
|
|
75
78
|
* Editing a field is all it takes to trigger the shipping/tax recalculation —
|
|
@@ -81,11 +84,10 @@ function newValue(args) {
|
|
|
81
84
|
*
|
|
82
85
|
* @param {"billing"|"shipping"} [which]
|
|
83
86
|
* @param {{includeState?: boolean, includePhone?: boolean, includeCompany?: boolean}} [options]
|
|
84
|
-
* @returns {{fields: Array<{key: string, label: string, type: string,
|
|
87
|
+
* @returns {{fields: Array<{key: string, id: string, label: string, type: string,
|
|
85
88
|
* value: string, required: boolean, options: Array<object>, error: string|null,
|
|
86
89
|
* autoComplete: string, colSpan: number, set: (...args: any[]) => void,
|
|
87
|
-
* isSelect: boolean, placeholder: string|undefined
|
|
88
|
-
* selectProps: object, labelProps: object, errorProps: object}>,
|
|
90
|
+
* isSelect: boolean, placeholder: string|undefined}>,
|
|
89
91
|
* set: (key: string, value: any) => void, values: object, missing: Array<string>,
|
|
90
92
|
* complete: boolean, error: object|null, countriesLoading: boolean}}
|
|
91
93
|
*/
|
|
@@ -105,8 +107,9 @@ export function useAddressForm(which = "billing", options = {}) {
|
|
|
105
107
|
// address is priced, not validated field-by-field.
|
|
106
108
|
const missing = isBilling ? checkout.missingBillingFields : [];
|
|
107
109
|
|
|
108
|
-
// A required field reports itself as `error` only after it has been
|
|
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.)
|
|
110
113
|
const [visited, setVisited] = useState({});
|
|
111
114
|
|
|
112
115
|
const fields = useMemo(() => {
|
|
@@ -121,7 +124,11 @@ export function useAddressForm(which = "billing", options = {}) {
|
|
|
121
124
|
const value = values?.[f.key] ?? "";
|
|
122
125
|
// Self-contained: the field knows its own key, so a .map never has to
|
|
123
126
|
// reach back out to the hook's set() (and can't pass the wrong key).
|
|
124
|
-
|
|
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
|
+
};
|
|
125
132
|
// The address-level error ("we don't ship there") belongs on country.
|
|
126
133
|
const error =
|
|
127
134
|
f.key === "country" && checkout.addressError?.code === "shipping_not_available"
|
|
@@ -129,21 +136,9 @@ export function useAddressForm(which = "billing", options = {}) {
|
|
|
129
136
|
: visited[f.key] && !value && missing.includes(f.key)
|
|
130
137
|
? `${f.label} is required.`
|
|
131
138
|
: null;
|
|
132
|
-
const id = `${which}-${f.key}`;
|
|
133
|
-
const errorId = `${id}-error`;
|
|
134
|
-
const shared = {
|
|
135
|
-
id,
|
|
136
|
-
name: id,
|
|
137
|
-
value,
|
|
138
|
-
required: f.required,
|
|
139
|
-
autoComplete: f.autoComplete,
|
|
140
|
-
onChange: setField,
|
|
141
|
-
onBlur: () => setVisited((v) => (v[f.key] ? v : { ...v, [f.key]: true })),
|
|
142
|
-
"aria-invalid": error ? true : undefined,
|
|
143
|
-
"aria-describedby": error ? errorId : undefined,
|
|
144
|
-
};
|
|
145
139
|
return {
|
|
146
140
|
...f,
|
|
141
|
+
id: `${which}-${f.key}`,
|
|
147
142
|
value,
|
|
148
143
|
set: setField,
|
|
149
144
|
error,
|
|
@@ -154,10 +149,6 @@ export function useAddressForm(which = "billing", options = {}) {
|
|
|
154
149
|
? "Loading…"
|
|
155
150
|
: `Select ${f.label}`
|
|
156
151
|
: undefined,
|
|
157
|
-
inputProps: { ...shared, type: f.type },
|
|
158
|
-
selectProps: shared,
|
|
159
|
-
labelProps: { htmlFor: id },
|
|
160
|
-
errorProps: { id: errorId, role: "alert" },
|
|
161
152
|
};
|
|
162
153
|
});
|
|
163
154
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -1,15 +1,18 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
1
2
|
import { useCheckoutContext } from "./useCheckout";
|
|
2
3
|
import { useCheckoutBlockers } from "./useTotalsLines";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
|
-
* usePlaceOrder — the place-order gate as one
|
|
6
|
-
* `<CheckoutProvider>` above it. The whole bottom of a checkout page:
|
|
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:
|
|
7
8
|
*
|
|
8
9
|
* const order = usePlaceOrder();
|
|
9
10
|
* if (order.stage === "submitted") return <p>Order placed — taking you to your receipt…</p>;
|
|
10
11
|
* …
|
|
11
|
-
* <button {
|
|
12
|
-
*
|
|
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>}
|
|
13
16
|
* {!order.canPlaceOrder && order.blockers.map(b => <p key={b.code}>{b.message}</p>)}
|
|
14
17
|
*
|
|
15
18
|
* ⚑ **The `stage === "submitted"` guard goes above the page's empty-cart
|
|
@@ -17,39 +20,44 @@ import { useCheckoutBlockers } from "./useTotalsLines";
|
|
|
17
20
|
* away; without the guard the page flashes "your bag is empty" over a
|
|
18
21
|
* just-placed order. `stage` is `"editing" | "placing" | "submitted"`.
|
|
19
22
|
*
|
|
20
|
-
* `
|
|
21
|
-
*
|
|
22
|
-
* `
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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.
|
|
25
30
|
*
|
|
26
31
|
* @param {{labels?: {idle?: string, placing?: string},
|
|
27
32
|
* blockerLabels?: Record<string, string>}} [options]
|
|
28
|
-
* @returns {{
|
|
29
|
-
* stage: "editing"|"placing"|"submitted", submitted: boolean,
|
|
33
|
+
* @returns {{placeOrder: (extra?: object) => Promise<object>, disabled: boolean,
|
|
34
|
+
* label: string, stage: "editing"|"placing"|"submitted", submitted: boolean,
|
|
30
35
|
* placing: boolean, canPlaceOrder: boolean, error: object|null,
|
|
31
|
-
*
|
|
32
|
-
* field: string|null}>, placeOrder: (extra?: object) => Promise<object>}}
|
|
36
|
+
* blockers: Array<{code: string, message: string, field: string|null}>}}
|
|
33
37
|
*/
|
|
34
38
|
export function usePlaceOrder({ labels, blockerLabels } = {}) {
|
|
35
39
|
const checkout = useCheckoutContext();
|
|
36
40
|
const blockers = useCheckoutBlockers({ labels: blockerLabels });
|
|
37
41
|
const { canPlaceOrder, placing, submitted, stage, orderError, placeOrder } = checkout;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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);
|
|
44
48
|
},
|
|
49
|
+
[placeOrder],
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
placeOrder: place,
|
|
54
|
+
disabled: !canPlaceOrder || placing,
|
|
45
55
|
label: placing ? (labels?.placing ?? "Placing your order…") : (labels?.idle ?? "Place order"),
|
|
46
56
|
stage,
|
|
47
57
|
submitted,
|
|
48
58
|
placing,
|
|
49
59
|
canPlaceOrder,
|
|
50
60
|
error: orderError,
|
|
51
|
-
errorProps: { role: "alert" },
|
|
52
61
|
blockers,
|
|
53
|
-
placeOrder,
|
|
54
62
|
};
|
|
55
63
|
}
|