@base44/app-plugin-commerce 0.2.5 → 0.2.6
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/package.json +1 -1
- package/skills/commerce/SKILL.md +64 -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 +133 -427
- 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 +7 -7
- 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 +190 -0
- package/src/commerce/storefront/index.js +39 -15
- package/src/commerce/storefront/pickers.jsx +98 -23
- package/src/commerce/storefront/useAddressForm.js +71 -25
- 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 +55 -0
- package/src/commerce/storefront/useProduct.js +93 -13
- package/src/commerce/storefront/useProductList.js +12 -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
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useId,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from "react";
|
|
11
|
+
import { useLocation } from "react-router-dom";
|
|
12
|
+
import { useCart } from "./StorefrontProvider";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Cart drawer/panel state, headless. Every store with a cart drawer needs the
|
|
16
|
+
* same non-visual machinery, and it is where hand-written drawers reliably go
|
|
17
|
+
* wrong: the drawer must close on route change, be **inert** when closed (not
|
|
18
|
+
* merely invisible — hidden-but-mounted buttons stay clickable and tab-able),
|
|
19
|
+
* carry dialog semantics, close on Esc, and return focus to its trigger.
|
|
20
|
+
* `useCartUI` owns all of it; you own every pixel.
|
|
21
|
+
*
|
|
22
|
+
* Mount `<CartUIProvider>` once, inside `<StorefrontProvider>`, around the
|
|
23
|
+
* layout. Then the layout is three spreads:
|
|
24
|
+
*
|
|
25
|
+
* function StoreLayout() {
|
|
26
|
+
* const ui = useCartUI();
|
|
27
|
+
* return (<>
|
|
28
|
+
* <header>… <button {...ui.triggerProps} className="…">Bag ({itemCount})</button></header>
|
|
29
|
+
* <Outlet />
|
|
30
|
+
* <div {...ui.overlayProps} className={ui.open ? "…" : "…"} />
|
|
31
|
+
* <aside {...ui.panelProps} className={ui.open ? "…translate-x-0" : "…translate-x-full"}>
|
|
32
|
+
* <button {...ui.closeButtonProps} className="…">×</button>
|
|
33
|
+
* {…your cart rows: useCart + CartLine…}
|
|
34
|
+
* </aside>
|
|
35
|
+
* </>);
|
|
36
|
+
* }
|
|
37
|
+
*
|
|
38
|
+
* What each prop set carries:
|
|
39
|
+
* - `triggerProps` — onClick (toggle), `aria-expanded`/`aria-controls`/
|
|
40
|
+
* `aria-haspopup`, an item-count `aria-label`, and the ref used to restore
|
|
41
|
+
* focus on close. Spread your own `aria-label` after it to override.
|
|
42
|
+
* - `panelProps` — `role="dialog"`, `aria-modal`, `aria-label`, `data-state`
|
|
43
|
+
* (`"open"|"closed"` — style/animate off it), focus target on open, and the
|
|
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).
|
|
52
|
+
*
|
|
53
|
+
* `useCartUI()` → `{ open, openCart, closeCart, toggleCart, triggerProps,
|
|
54
|
+
* overlayProps, panelProps, closeButtonProps }`. `useCartUIOptional()` returns
|
|
55
|
+
* null instead of throwing (how `useAddToCartButton` integrates without
|
|
56
|
+
* requiring the provider).
|
|
57
|
+
*
|
|
58
|
+
* A store whose cart is a page, not a drawer, skips this provider entirely.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
const CartUIContext = createContext(null);
|
|
62
|
+
|
|
63
|
+
/** Internal: closes the drawer whenever the route changes (needs a Router above). */
|
|
64
|
+
function CloseOnNavigate({ close }) {
|
|
65
|
+
const { pathname } = useLocation();
|
|
66
|
+
const first = useRef(true);
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (first.current) {
|
|
69
|
+
first.current = false;
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
close();
|
|
73
|
+
}, [pathname, close]);
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {{closeOnNavigate?: boolean, openOnAdd?: boolean, label?: string,
|
|
79
|
+
* children: React.ReactNode}} props `label` names the dialog for assistive
|
|
80
|
+
* tech and the default trigger/close labels (default "Cart").
|
|
81
|
+
*/
|
|
82
|
+
export function CartUIProvider({ closeOnNavigate = true, openOnAdd = true, label = "Cart", children }) {
|
|
83
|
+
const [open, setOpen] = useState(false);
|
|
84
|
+
const openCart = useCallback(() => setOpen(true), []);
|
|
85
|
+
const closeCart = useCallback(() => setOpen(false), []);
|
|
86
|
+
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
|
+
|
|
105
|
+
// Esc closes — the keyboard's way out is Esc and the named close button.
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (!open) return;
|
|
108
|
+
const onKey = (e) => {
|
|
109
|
+
if (e.key === "Escape") setOpen(false);
|
|
110
|
+
};
|
|
111
|
+
window.addEventListener("keydown", onKey);
|
|
112
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
113
|
+
}, [open]);
|
|
114
|
+
|
|
115
|
+
// Focus follows the dialog: into the panel on open, back to the trigger on
|
|
116
|
+
// close — without it, focus is stranded inside an inert subtree.
|
|
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 {
|
|
131
|
+
open,
|
|
132
|
+
openCart,
|
|
133
|
+
closeCart,
|
|
134
|
+
toggleCart,
|
|
135
|
+
onItemAdded: openOnAdd ? openCart : null,
|
|
136
|
+
triggerProps: {
|
|
137
|
+
type: "button",
|
|
138
|
+
ref: triggerRef,
|
|
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]);
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<CartUIContext.Provider value={value}>
|
|
170
|
+
{closeOnNavigate && <CloseOnNavigate close={closeCart} />}
|
|
171
|
+
{children}
|
|
172
|
+
</CartUIContext.Provider>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The nearest <CartUIProvider>'s controls; throws without one. */
|
|
177
|
+
export function useCartUI() {
|
|
178
|
+
const ctx = useContext(CartUIContext);
|
|
179
|
+
if (!ctx) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
"useCartUI needs a <CartUIProvider> above it — mount it once inside <StorefrontProvider>, around your layout.",
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
return ctx;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Internal-ish: null instead of throwing, for optional integrations. */
|
|
188
|
+
export function useCartUIOptional() {
|
|
189
|
+
return useContext(CartUIContext);
|
|
190
|
+
}
|
|
@@ -27,32 +27,50 @@
|
|
|
27
27
|
* </Route>
|
|
28
28
|
* // no shared layout? <StorefrontProvider …> <Routes>…</Routes> </StorefrontProvider>
|
|
29
29
|
*
|
|
30
|
+
* Most hooks hand back **ready-to-spread prop sets** next to the raw data —
|
|
31
|
+
* `f.inputProps`, `m.radioProps`, `buy.buttonProps`, `ui.panelProps`,
|
|
32
|
+
* `list.moreProps` — so your markup is elements + classes and the wiring
|
|
33
|
+
* (handlers, ids, aria, disabled logic) can't be re-derived wrong. Spread
|
|
34
|
+
* first, put your `className` after.
|
|
35
|
+
*
|
|
30
36
|
* ## Hooks
|
|
31
37
|
* - `useStorefront` / `useStoreInfo` / `useFormatMoney` / `useMoney` — the
|
|
32
38
|
* shared client, cached store info (the ONLY source of payment gateways,
|
|
33
39
|
* currency and countries), money in the store's currency.
|
|
34
|
-
* - `useProductList` / `useCategories` / `useRibbons` — a listing with paging
|
|
35
|
-
*
|
|
40
|
+
* - `useProductList` / `useCategories` / `useRibbons` — a listing with paging
|
|
41
|
+
* (`moreProps` — spread it and paging can't be dropped), filters,
|
|
42
|
+
* `refreshing`, and failure as a visible state.
|
|
36
43
|
* - `useProduct` / `useAddToCart` / `useAddToCartButton` / `useProductPrice` /
|
|
37
|
-
* `useProductGallery` — the product page: fetch +
|
|
38
|
-
* quantity + price + gallery, race-safe, with
|
|
39
|
-
* every add-to-cart failure handled
|
|
40
|
-
*
|
|
41
|
-
*
|
|
44
|
+
* `useProductGallery` / `useProductSpecs` — the product page: fetch +
|
|
45
|
+
* variant selection + quantity + price + gallery, race-safe, with
|
|
46
|
+
* `status: "not_found"` and every add-to-cart failure handled; the buy
|
|
47
|
+
* button as `buttonProps` + `state`/`label`. `variantAxes(view, pick)`
|
|
48
|
+
* (from `@/commerce/utils`, re-exported here) turns the resolved view into
|
|
49
|
+
* a render-ready model for the selector you write; `useProductSpecs` adds
|
|
50
|
+
* normalized `pick`/`get` lookup over `productSpecs`.
|
|
42
51
|
* - `useProductReviews` — the review list and the submit form, with the store's
|
|
43
52
|
* policy as a prop and field errors matching the server's codes.
|
|
44
53
|
* - `useCart` / `useCartLine` / `useCoupon` — the shared cart (branch on
|
|
45
|
-
* `status`, render `lines` and `notices`
|
|
46
|
-
* recover, and the coupon field a store
|
|
54
|
+
* `status`, render `lines` and `notices` — money pre-formatted per line),
|
|
55
|
+
* quantity steppers that clamp and recover, and the coupon field a store
|
|
56
|
+
* with coupons must have.
|
|
57
|
+
* - `useCartUI` + `<CartUIProvider>` — the cart drawer's non-visual machinery:
|
|
58
|
+
* open state, trigger/overlay/panel/close prop sets with dialog semantics,
|
|
59
|
+
* inert-when-closed, Esc, close-on-navigate, open-on-add.
|
|
47
60
|
* - `useCheckout` / `CheckoutProvider` / `useCheckoutContext` — the guided
|
|
48
61
|
* checkout: address state with automatic debounced shipping/tax
|
|
49
62
|
* recalculation, shipping and payment choice, a `canPlaceOrder` gate with
|
|
50
63
|
* named blockers, `placeOrder` with both navigations handled (online →
|
|
51
64
|
* provider redirect, manual → `/order-received`).
|
|
65
|
+
* - `usePlaceOrder` — the gate as one surface: `buttonProps`, `label`,
|
|
66
|
+
* `stage` (guards the just-placed-order frames), `blockers` in words.
|
|
52
67
|
* - `useAddressForm` / `useCountries` / `useTotalsLines` /
|
|
53
|
-
* `useCheckoutBlockers` — the address form as a field spec
|
|
54
|
-
*
|
|
55
|
-
* projection for cart and order,
|
|
68
|
+
* `useCheckoutBlockers` — the address form as a field spec with per-field
|
|
69
|
+
* `inputProps`/`selectProps`/`labelProps`/`errorProps` (state included,
|
|
70
|
+
* country options never null), one totals projection for cart and order,
|
|
71
|
+
* 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
|
+
* `radioProps`/`labelProps`/`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, useAddToCartButton, 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 ready-to-spread prop sets**
|
|
12
|
+
* (`radioProps` / `labelProps`) and pre-formatted money (`costLabel`), so the
|
|
13
|
+
* child is markup + classes and nothing else. `hint` is the one message the
|
|
14
|
+
* picker wants shown right now (or null) — render it and the status branching
|
|
15
|
+
* 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,39 @@ 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
|
+
* // radioProps, labelProps }] — 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} {...m.labelProps} className="…">
|
|
56
|
+
* <input {...m.radioProps} className="…" /> {m.title} <span>{m.costLabel}</span>
|
|
57
|
+
* </label>
|
|
58
|
+
* ))}
|
|
59
|
+
* {!mustChoose && chosen && <p>{chosen.title} {chosen.costLabel}</p>}
|
|
38
60
|
*
|
|
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`.
|
|
61
|
+
* `single` and `mustChoose` are never both true, and `single` guarantees
|
|
62
|
+
* `chosen` — a single option still *shows* what it is, never a picker of one.
|
|
43
63
|
*/
|
|
44
64
|
export function ShippingMethodPicker({ checkout: checkoutProp, children }) {
|
|
45
65
|
const checkout = resolveCheckout("ShippingMethodPicker", checkoutProp, useCheckoutContextOptional());
|
|
66
|
+
const formatMoney = useFormatMoney();
|
|
46
67
|
const {
|
|
47
68
|
shippingStatus: status,
|
|
48
69
|
shippingMethods: methods,
|
|
@@ -53,15 +74,40 @@ export function ShippingMethodPicker({ checkout: checkoutProp, children }) {
|
|
|
53
74
|
addressError,
|
|
54
75
|
} = checkout;
|
|
55
76
|
if (!status || status === "not_needed") return null;
|
|
77
|
+
const decorate = (m) =>
|
|
78
|
+
m && {
|
|
79
|
+
...m,
|
|
80
|
+
selected: m.id === chosen?.id,
|
|
81
|
+
costLabel: formatMoney(m.cost),
|
|
82
|
+
radioProps: {
|
|
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}` },
|
|
91
|
+
};
|
|
92
|
+
const hint =
|
|
93
|
+
status === "missing_address"
|
|
94
|
+
? { code: "missing_address", severity: "info", message: "Delivery options appear once your address is entered." }
|
|
95
|
+
: status === "none_available" || addressError?.code === "shipping_not_available"
|
|
96
|
+
? { code: "none_available", severity: "error", message: addressError?.message ?? "We don't deliver to that address yet." }
|
|
97
|
+
: syncing
|
|
98
|
+
? { code: "syncing", severity: "info", message: "Updating delivery options…" }
|
|
99
|
+
: null;
|
|
100
|
+
const decoratedChosen = decorate(chosen) ?? null;
|
|
56
101
|
return children({
|
|
57
102
|
status,
|
|
58
|
-
methods,
|
|
59
|
-
chosen,
|
|
60
|
-
selected:
|
|
103
|
+
methods: methods.map(decorate),
|
|
104
|
+
chosen: decoratedChosen,
|
|
105
|
+
selected: decoratedChosen,
|
|
61
106
|
choose,
|
|
62
107
|
mustChoose: status === "choice_required",
|
|
63
108
|
single: single ?? (methods.length === 1), // fallback: a hand-built checkout object
|
|
64
109
|
syncing,
|
|
110
|
+
hint,
|
|
65
111
|
addressError,
|
|
66
112
|
});
|
|
67
113
|
}
|
|
@@ -72,18 +118,30 @@ export function ShippingMethodPicker({ checkout: checkoutProp, children }) {
|
|
|
72
118
|
* `children` with:
|
|
73
119
|
*
|
|
74
120
|
* {
|
|
75
|
-
* gateways, // [{ slug, title, description, online
|
|
121
|
+
* gateways, // [{ slug, title, description, online, selected,
|
|
122
|
+
* // radioProps, labelProps }] — admin-owned data, decorated
|
|
76
123
|
* value, // the selected slug ("" while none)
|
|
77
124
|
* select, // (slug) => void
|
|
78
125
|
* selected, // the selected gateway entry, or null
|
|
79
126
|
* single, // exactly one gateway — already selected; skip the picker but
|
|
80
|
-
*
|
|
127
|
+
* // still show its title so the customer knows how they pay
|
|
128
|
+
* hint, // { code, message, severity } | null — set when there are no
|
|
129
|
+
* } // gateways: checkout is unavailable, say so
|
|
130
|
+
*
|
|
131
|
+
* The child's whole job:
|
|
132
|
+
*
|
|
133
|
+
* {hint && <p role="alert">{hint.message}</p>}
|
|
134
|
+
* {!single && gateways.map(g => (
|
|
135
|
+
* <label key={g.slug} {...g.labelProps} className="…">
|
|
136
|
+
* <input {...g.radioProps} className="…" /> {g.title} <span>{g.description}</span>
|
|
137
|
+
* </label>
|
|
138
|
+
* ))}
|
|
139
|
+
* {single && selected && <p>{selected.title} — {selected.description}</p>}
|
|
81
140
|
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* "nothing selected yet" — and both re-resolve when store info changes.
|
|
141
|
+
* `single` guarantees `value` and `selected` — never render the one-gateway
|
|
142
|
+
* branch as "nothing selected yet" — and both re-resolve when store info
|
|
143
|
+
* changes. Titles and descriptions are the admin's copy: render them, don't
|
|
144
|
+
* invent your own.
|
|
87
145
|
*/
|
|
88
146
|
export function PaymentMethodPicker({ checkout: checkoutProp, children }) {
|
|
89
147
|
const checkout = resolveCheckout("PaymentMethodPicker", checkoutProp, useCheckoutContextOptional());
|
|
@@ -91,15 +149,32 @@ export function PaymentMethodPicker({ checkout: checkoutProp, children }) {
|
|
|
91
149
|
paymentMethods: gateways,
|
|
92
150
|
paymentMethod: value,
|
|
93
151
|
setPaymentMethod: select,
|
|
94
|
-
selectedGateway: selected,
|
|
95
152
|
singlePaymentMethod: single,
|
|
96
153
|
} = checkout;
|
|
97
154
|
if (!gateways) return null;
|
|
155
|
+
const decorated = gateways.map((g) => ({
|
|
156
|
+
...g,
|
|
157
|
+
selected: g.slug === value,
|
|
158
|
+
radioProps: {
|
|
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}` },
|
|
167
|
+
}));
|
|
168
|
+
const hint =
|
|
169
|
+
gateways.length === 0
|
|
170
|
+
? { code: "none_available", severity: "error", message: "No payment method is available right now." }
|
|
171
|
+
: null;
|
|
98
172
|
return children({
|
|
99
|
-
gateways,
|
|
173
|
+
gateways: decorated,
|
|
100
174
|
value,
|
|
101
175
|
select,
|
|
102
|
-
selected,
|
|
176
|
+
selected: decorated.find((g) => g.selected) ?? null,
|
|
103
177
|
single: single ?? (gateways.length === 1), // fallback: a hand-built checkout object
|
|
178
|
+
hint,
|
|
104
179
|
});
|
|
105
180
|
}
|
|
@@ -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,35 @@ 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 setter AND its own
|
|
46
|
+
* ready-to-spread attribute sets, so the whole form is one map with your
|
|
47
|
+
* classes on it — never assemble `value`/`onChange`/`autoComplete` by hand:
|
|
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.error && <span
|
|
59
|
-
* </
|
|
51
|
+
* <div key={f.key} className="…">
|
|
52
|
+
* <label {...f.labelProps}>{f.label}{f.required && " *"}</label>
|
|
53
|
+
* {f.isSelect ? (
|
|
54
|
+
* <select {...f.selectProps} className="…">
|
|
55
|
+
* <option value="">{f.placeholder}</option>
|
|
56
|
+
* {f.options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
|
57
|
+
* </select>
|
|
58
|
+
* ) : <input {...f.inputProps} className="…" />}
|
|
59
|
+
* {f.error && <span {...f.errorProps} className="…">{f.error}</span>}
|
|
60
|
+
* </div>
|
|
60
61
|
* ))}
|
|
61
62
|
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
63
|
+
* The prop sets carry id/name pairing, the `autoComplete` token, controlled
|
|
64
|
+
* `value` + `onChange`, `required`, blur tracking and the `aria-invalid` /
|
|
65
|
+
* `aria-describedby` wiring for the error line. `f.placeholder` resolves the
|
|
66
|
+
* select's empty option ("Select Country", or "Loading…" while countries
|
|
67
|
+
* arrive), so `countriesLoading` never needs handling by hand. `f.error` is
|
|
68
|
+
* per-field: "we don't ship there" lands on the country field, and a required
|
|
69
|
+
* field left empty reports itself after it has been visited.
|
|
70
|
+
*
|
|
71
|
+
* `f.set` is still there for custom controls — it accepts a value, the raw
|
|
72
|
+
* event (`onChange={f.set}`) or a `(key, value)` pair — as is the hook's
|
|
73
|
+
* top-level `set(key, value)`.
|
|
65
74
|
*
|
|
66
75
|
* Editing a field is all it takes to trigger the shipping/tax recalculation —
|
|
67
76
|
* `useCheckout` debounces and calls `set-shipping-address` once the address is
|
|
@@ -74,7 +83,9 @@ function newValue(args) {
|
|
|
74
83
|
* @param {{includeState?: boolean, includePhone?: boolean, includeCompany?: boolean}} [options]
|
|
75
84
|
* @returns {{fields: Array<{key: string, label: string, type: string,
|
|
76
85
|
* value: string, required: boolean, options: Array<object>, error: string|null,
|
|
77
|
-
* autoComplete: string, colSpan: number, set: (...args: any[]) => void
|
|
86
|
+
* autoComplete: string, colSpan: number, set: (...args: any[]) => void,
|
|
87
|
+
* isSelect: boolean, placeholder: string|undefined, inputProps: object,
|
|
88
|
+
* selectProps: object, labelProps: object, errorProps: object}>,
|
|
78
89
|
* set: (key: string, value: any) => void, values: object, missing: Array<string>,
|
|
79
90
|
* complete: boolean, error: object|null, countriesLoading: boolean}}
|
|
80
91
|
*/
|
|
@@ -94,6 +105,10 @@ export function useAddressForm(which = "billing", options = {}) {
|
|
|
94
105
|
// address is priced, not validated field-by-field.
|
|
95
106
|
const missing = isBilling ? checkout.missingBillingFields : [];
|
|
96
107
|
|
|
108
|
+
// A required field reports itself as `error` only after it has been visited
|
|
109
|
+
// (blurred) — an untouched form must not open covered in "required" marks.
|
|
110
|
+
const [visited, setVisited] = useState({});
|
|
111
|
+
|
|
97
112
|
const fields = useMemo(() => {
|
|
98
113
|
const spec = addressFieldSpec({
|
|
99
114
|
countries,
|
|
@@ -102,20 +117,51 @@ export function useAddressForm(which = "billing", options = {}) {
|
|
|
102
117
|
includeEmail: isBilling, // one email per order, on billing
|
|
103
118
|
...options,
|
|
104
119
|
});
|
|
105
|
-
return spec.map((f) =>
|
|
106
|
-
|
|
107
|
-
value: values?.[f.key] ?? "",
|
|
120
|
+
return spec.map((f) => {
|
|
121
|
+
const value = values?.[f.key] ?? "";
|
|
108
122
|
// Self-contained: the field knows its own key, so a .map never has to
|
|
109
123
|
// reach back out to the hook's set() (and can't pass the wrong key).
|
|
110
|
-
|
|
124
|
+
const setField = (...args) => set(f.key, newValue(args));
|
|
111
125
|
// The address-level error ("we don't ship there") belongs on country.
|
|
112
|
-
error
|
|
126
|
+
const error =
|
|
113
127
|
f.key === "country" && checkout.addressError?.code === "shipping_not_available"
|
|
114
128
|
? checkout.addressError.message
|
|
115
|
-
:
|
|
116
|
-
|
|
129
|
+
: visited[f.key] && !value && missing.includes(f.key)
|
|
130
|
+
? `${f.label} is required.`
|
|
131
|
+
: 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
|
+
return {
|
|
146
|
+
...f,
|
|
147
|
+
value,
|
|
148
|
+
set: setField,
|
|
149
|
+
error,
|
|
150
|
+
isSelect: f.type === "select",
|
|
151
|
+
placeholder:
|
|
152
|
+
f.type === "select"
|
|
153
|
+
? f.key === "country" && countriesLoading
|
|
154
|
+
? "Loading…"
|
|
155
|
+
: `Select ${f.label}`
|
|
156
|
+
: undefined,
|
|
157
|
+
inputProps: { ...shared, type: f.type },
|
|
158
|
+
selectProps: shared,
|
|
159
|
+
labelProps: { htmlFor: id },
|
|
160
|
+
errorProps: { id: errorId, role: "alert" },
|
|
161
|
+
};
|
|
162
|
+
});
|
|
117
163
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
118
|
-
}, [countries, values, isBilling, set, checkout.addressError, JSON.stringify(options)]);
|
|
164
|
+
}, [countries, countriesLoading, values, which, isBilling, set, checkout.addressError, visited, missing.join(","), JSON.stringify(options)]);
|
|
119
165
|
|
|
120
166
|
return {
|
|
121
167
|
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
|
* )}
|