@base44/app-plugin-commerce 0.2.6 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/package.json +2 -2
- package/scripts/install.js +15 -0
- package/skills/commerce/SKILL.md +31 -13
- package/skills/commerce/docs/api-admin.md +1 -1
- package/skills/commerce/docs/api-storefront.md +12 -12
- package/skills/commerce/install/01-install.md +5 -2
- package/skills/commerce/install/02-storefront.md +179 -220
- package/skills/commerce/install/03-data.md +1 -1
- package/skills/commerce/references/catalog-rendering.md +37 -43
- package/skills/commerce/references/reviews.md +21 -14
- package/skills/commerce/references/store-settings.md +1 -1
- package/skills/commerce/references/storefront-verification.md +21 -15
- package/src/commerce/storefront/StorefrontProvider.jsx +65 -128
- package/src/commerce/storefront/cartUI.jsx +26 -117
- package/src/commerce/storefront/index.js +61 -98
- package/src/commerce/storefront/pickers.jsx +53 -81
- package/src/commerce/storefront/useCartLine.js +23 -130
- package/src/commerce/storefront/useCheckout.jsx +50 -43
- package/src/commerce/storefront/useOrderReturn.js +17 -7
- package/src/commerce/storefront/useProduct.js +54 -119
- package/src/commerce/storefront/useProductList.js +15 -28
- package/src/commerce/utils/address-spec.js +1 -1
- package/src/commerce/utils/images.js +1 -1
- package/src/commerce/utils/index.js +9 -9
- package/src/commerce/utils/price.js +2 -1
- package/src/commerce/utils/specs.js +41 -91
- package/src/commerce/utils/totals.js +7 -4
- package/src/commerce/storefront/useAddressForm.js +0 -175
- package/src/commerce/storefront/usePlaceOrder.js +0 -55
- package/src/commerce/storefront/useProductGallery.js +0 -78
- package/src/commerce/storefront/useProductPrice.js +0 -58
- package/src/commerce/storefront/useProductReviews.js +0 -242
- package/src/commerce/storefront/useStorefrontSeo.js +0 -204
- package/src/commerce/storefront/useTotalsLines.js +0 -109
- package/src/commerce/storefront/useUpsell.js +0 -90
|
@@ -1,59 +1,28 @@
|
|
|
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`; pass false
|
|
9
|
+
* for a navigate-to-bag flow). Esc closes it. You own every element, class and
|
|
10
|
+
* attribute.
|
|
21
11
|
*
|
|
22
12
|
* Mount `<CartUIProvider>` once, inside `<StorefrontProvider>`, around the
|
|
23
|
-
* layout
|
|
13
|
+
* layout; the layout then renders the panel off `open`.
|
|
24
14
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* <button {...ui.closeButtonProps} className="…">×</button>
|
|
33
|
-
* {…your cart rows: useCart + CartLine…}
|
|
34
|
-
* </aside>
|
|
35
|
-
* </>);
|
|
36
|
-
* }
|
|
15
|
+
* ⚑ **Render the drawer conditionally — `{ui.open && …}`.** The classic drawer
|
|
16
|
+
* bug is a panel translated off-screen but still mounted: its buttons stay
|
|
17
|
+
* clickable, tab-able and visible to screen readers. Unmounting when closed is
|
|
18
|
+
* the trivial fix; if you keep it mounted to animate the slide, that concern is
|
|
19
|
+
* yours again — set the `inert` attribute while closed. The overlay is a
|
|
20
|
+
* click-away surface (`aria-hidden`, no tab stop) — it is not the close control;
|
|
21
|
+
* a named close button inside the panel is.
|
|
37
22
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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).
|
|
23
|
+
* `useCartUI()` → `{ open, openCart, closeCart, toggleCart }`.
|
|
24
|
+
* `useCartUIOptional()` returns null instead of throwing (how `useAddToCart`
|
|
25
|
+
* integrates without requiring the provider).
|
|
57
26
|
*
|
|
58
27
|
* A store whose cart is a page, not a drawer, skips this provider entirely.
|
|
59
28
|
*/
|
|
@@ -75,32 +44,14 @@ function CloseOnNavigate({ close }) {
|
|
|
75
44
|
}
|
|
76
45
|
|
|
77
46
|
/**
|
|
78
|
-
* @param {{closeOnNavigate?: boolean, openOnAdd?: boolean,
|
|
79
|
-
* children: React.ReactNode}} props
|
|
80
|
-
* tech and the default trigger/close labels (default "Cart").
|
|
47
|
+
* @param {{closeOnNavigate?: boolean, openOnAdd?: boolean,
|
|
48
|
+
* children: React.ReactNode}} props
|
|
81
49
|
*/
|
|
82
|
-
export function CartUIProvider({ closeOnNavigate = true, openOnAdd = true,
|
|
50
|
+
export function CartUIProvider({ closeOnNavigate = true, openOnAdd = true, children }) {
|
|
83
51
|
const [open, setOpen] = useState(false);
|
|
84
52
|
const openCart = useCallback(() => setOpen(true), []);
|
|
85
53
|
const closeCart = useCallback(() => setOpen(false), []);
|
|
86
54
|
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
55
|
|
|
105
56
|
// Esc closes — the keyboard's way out is Esc and the named close button.
|
|
106
57
|
useEffect(() => {
|
|
@@ -112,58 +63,16 @@ export function CartUIProvider({ closeOnNavigate = true, openOnAdd = true, label
|
|
|
112
63
|
return () => window.removeEventListener("keydown", onKey);
|
|
113
64
|
}, [open]);
|
|
114
65
|
|
|
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 {
|
|
66
|
+
const value = useMemo(
|
|
67
|
+
() => ({
|
|
131
68
|
open,
|
|
132
69
|
openCart,
|
|
133
70
|
closeCart,
|
|
134
71
|
toggleCart,
|
|
135
72
|
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]);
|
|
73
|
+
}),
|
|
74
|
+
[open, openCart, closeCart, toggleCart, openOnAdd],
|
|
75
|
+
);
|
|
167
76
|
|
|
168
77
|
return (
|
|
169
78
|
<CartUIContext.Provider value={value}>
|
|
@@ -1,97 +1,56 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Storefront React layer — **headless**: hooks and render-prop components that
|
|
3
|
-
* own the store's logic and hand you the data
|
|
4
|
-
* no
|
|
5
|
-
* written by you, against these APIs. Ships with the Base44
|
|
6
|
-
* next to the framework-free `@/commerce/utils` (which it
|
|
7
|
-
* React and nothing else.
|
|
3
|
+
* own the store's logic and hand you the data. They render nothing, carry no
|
|
4
|
+
* styling, and ship no customer-facing copy — every element, class and word in
|
|
5
|
+
* the storefront is written by you, against these APIs. Ships with the Base44
|
|
6
|
+
* Commerce Plugin next to the framework-free `@/commerce/utils` (which it
|
|
7
|
+
* builds on); needs React and nothing else.
|
|
8
8
|
*
|
|
9
9
|
* The split: **logic is premade, UI never is.** Checkout repricing, variant
|
|
10
|
-
* resolution, cart state,
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* error objects), and each doc comment states the render rules that keep the
|
|
16
|
-
* store correct (e.g. an unbuyable variant option renders *disabled, not
|
|
17
|
-
* hidden*; a receipt page must render `paymentInstructions`).
|
|
10
|
+
* resolution, cart state, order-return verification — done here, and
|
|
11
|
+
* hand-rolling any of it is where storefront bugs cluster. What a checkout or a
|
|
12
|
+
* product page *looks like* is the store's identity, and no two stores should
|
|
13
|
+
* share it — so nothing here emits markup, and every state arrives as a **code**
|
|
14
|
+
* (`state`, `status`, `hint.code`, `blockers`) that you write the words for.
|
|
18
15
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
16
|
+
* `StorefrontProvider` mounts once, above every storefront route (see its own
|
|
17
|
+
* doc comment — getting the mounting wrong is the one setup error worth
|
|
18
|
+
* knowing). Each hook's doc comment is its contract; the map:
|
|
22
19
|
*
|
|
23
|
-
*
|
|
24
|
-
* import { base44 } from "@/api/base44Client";
|
|
25
|
-
* <Route element={<StorefrontProvider base44={base44}><StoreLayout /></StorefrontProvider>}>
|
|
26
|
-
* <Route path="/" element={<Home />} /> …
|
|
27
|
-
* </Route>
|
|
28
|
-
* // no shared layout? <StorefrontProvider …> <Routes>…</Routes> </StorefrontProvider>
|
|
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
|
-
*
|
|
36
|
-
* ## Hooks
|
|
37
|
-
* - `useStorefront` / `useStoreInfo` / `useFormatMoney` / `useMoney` — the
|
|
20
|
+
* - `useStorefront` / `useStoreInfo` / `useFormatMoney` / `useCountries` — the
|
|
38
21
|
* shared client, cached store info (the ONLY source of payment gateways,
|
|
39
|
-
* currency and countries), money in the store's currency
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* - `useProduct` / `useAddToCart`
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* `status
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* a render-ready model for the selector you write; `useProductSpecs` adds
|
|
50
|
-
* normalized `pick`/`get` lookup over `productSpecs`.
|
|
51
|
-
* - `useProductReviews` — the review list and the submit form, with the store's
|
|
52
|
-
* policy as a prop and field errors matching the server's codes.
|
|
53
|
-
* - `useCart` / `useCartLine` / `useCoupon` — the shared cart (branch on
|
|
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.
|
|
22
|
+
* currency and countries), money in the store's currency, and the country
|
|
23
|
+
* list as an array that is never null.
|
|
24
|
+
* - `useProductList` / `useCategories` / `useRibbons` — a listing with paging,
|
|
25
|
+
* the whole server-side filter surface, and failure as a visible state.
|
|
26
|
+
* - `useProduct` / `useAddToCart` — the product page: fetch + variant selection
|
|
27
|
+
* + quantity, race-safe; the buy box as `state`/`disabled`/`addToCart`.
|
|
28
|
+
* - `useCart` / `useCartLine` / `CartLine` — the shared cart (branch on
|
|
29
|
+
* `status`, map `cart.items` into your own rows) plus quantity steppers that
|
|
30
|
+
* clamp, coalesce rapid clicks and roll back on rejection.
|
|
31
|
+
* - `useCartUI` + `<CartUIProvider>` — a cart drawer's non-visual state.
|
|
60
32
|
* - `useCheckout` / `CheckoutProvider` / `useCheckoutContext` — the guided
|
|
61
|
-
* checkout:
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* `stage` (guards the just-placed-order frames), `blockers` in words.
|
|
67
|
-
* - `useAddressForm` / `useCountries` / `useTotalsLines` /
|
|
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.
|
|
74
|
-
* - `useOrderReturn` — the mandatory `/order-received` page in one hook:
|
|
75
|
-
* status, order, `lines`, `paymentLink`, `paymentInstructions`, noindex.
|
|
76
|
-
* - `useStorefrontSeo` + `productSeo` / `collectionSeo` / `orderSeo` — titles,
|
|
77
|
-
* meta and product structured data; receipts are `noindex`.
|
|
33
|
+
* checkout: automatic shipping/tax recalculation, shipping and payment
|
|
34
|
+
* choice, a `canPlaceOrder` gate with named blockers, `placeOrder`.
|
|
35
|
+
* - `useOrderReturn` — the mandatory `/order-received` page in one hook.
|
|
36
|
+
* - `ShippingMethodPicker` / `PaymentMethodPicker` — render-prop components for
|
|
37
|
+
* the two checkout choices that are store data, never hardcoded.
|
|
78
38
|
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
39
|
+
* Re-exported from `@/commerce/utils` so one import line covers a page:
|
|
40
|
+
* `variantAxes` (axes → options with selected/disabled/stock derived),
|
|
41
|
+
* `productPrice` (the from-price and incomplete-selection range rules),
|
|
42
|
+
* `productImages` + `imageIndex` (an image's position in that list — how a
|
|
43
|
+
* gallery follows the variant selection without owning a second copy of it),
|
|
44
|
+
* `productSpecs` + `findSpec` (meta keys are free text, so featuring a
|
|
45
|
+
* particular spec needs a tolerant lookup, not an equality test),
|
|
46
|
+
* `attributesLabel`, `cartTotalsLines` / `orderTotalsLines`,
|
|
47
|
+
* `addressFieldSpec` (the checkout's field list, including the state/province
|
|
48
|
+
* field that silently mis-prices US/CA/AU orders when it is left out), and
|
|
49
|
+
* `storefrontErrorCode` / `storefrontErrorMessage` for the calls you make
|
|
50
|
+
* yourself — every rejection from the client carries a code worth branching on.
|
|
85
51
|
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* state derived, for the variant selector you write.
|
|
89
|
-
* - `productSpecs(product)` — `meta_data` → descriptive rows carrying an
|
|
90
|
-
* inferred `type` (`numeric` with `number`/`unit` split out, `duration`,
|
|
91
|
-
* `location`, `list` with `items`, `text`), so a weight can be a figure and
|
|
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.
|
|
52
|
+
* The catalog and product surfaces are where the design freedom lives: these
|
|
53
|
+
* hooks hand you resolved data, and the rendering is entirely yours.
|
|
95
54
|
*/
|
|
96
55
|
export {
|
|
97
56
|
StorefrontProvider,
|
|
@@ -99,10 +58,10 @@ export {
|
|
|
99
58
|
useStorefront,
|
|
100
59
|
useStoreInfo,
|
|
101
60
|
useFormatMoney,
|
|
61
|
+
useCountries,
|
|
102
62
|
useCart,
|
|
103
63
|
} from "./StorefrontProvider";
|
|
104
64
|
export { useCheckout, CheckoutProvider, useCheckoutContext } from "./useCheckout";
|
|
105
|
-
export { usePlaceOrder } from "./usePlaceOrder";
|
|
106
65
|
export { useOrderReturn, orderReceivedUrl } from "./useOrderReturn";
|
|
107
66
|
export { ShippingMethodPicker, PaymentMethodPicker } from "./pickers";
|
|
108
67
|
export { CartUIProvider, useCartUI } from "./cartUI";
|
|
@@ -115,19 +74,23 @@ export {
|
|
|
115
74
|
|
|
116
75
|
// ── catalog ────────────────────────────────────────────────────────────────
|
|
117
76
|
export { useProductList, useCategories, useRibbons } from "./useProductList";
|
|
118
|
-
export { useProduct, useAddToCart
|
|
119
|
-
export { useUpsell } from "./useUpsell";
|
|
120
|
-
export { useProductPrice, useMoney } from "./useProductPrice";
|
|
121
|
-
export { useProductGallery } from "./useProductGallery";
|
|
122
|
-
export { useProductReviews } from "./useProductReviews";
|
|
77
|
+
export { useProduct, useAddToCart } from "./useProduct";
|
|
123
78
|
|
|
124
|
-
// ── cart
|
|
125
|
-
export { useCartLine,
|
|
126
|
-
export { useAddressForm, useCountries } from "./useAddressForm";
|
|
127
|
-
export { useTotalsLines, useCheckoutBlockers, blockerMessage } from "./useTotalsLines";
|
|
128
|
-
|
|
129
|
-
// ── SEO ────────────────────────────────────────────────────────────────────
|
|
130
|
-
export { useStorefrontSeo, productSeo, collectionSeo, orderSeo } from "./useStorefrontSeo";
|
|
79
|
+
// ── cart ───────────────────────────────────────────────────────────────────
|
|
80
|
+
export { useCartLine, CartLine } from "./useCartLine";
|
|
131
81
|
|
|
132
82
|
// ── view-model helpers (framework-free, from @/commerce/utils) ─────────────
|
|
133
|
-
export {
|
|
83
|
+
export {
|
|
84
|
+
variantAxes,
|
|
85
|
+
productPrice,
|
|
86
|
+
productImages,
|
|
87
|
+
imageIndex,
|
|
88
|
+
productSpecs,
|
|
89
|
+
findSpec,
|
|
90
|
+
attributesLabel,
|
|
91
|
+
cartTotalsLines,
|
|
92
|
+
orderTotalsLines,
|
|
93
|
+
addressFieldSpec,
|
|
94
|
+
storefrontErrorCode,
|
|
95
|
+
storefrontErrorMessage,
|
|
96
|
+
} from "@/commerce/utils";
|
|
@@ -8,11 +8,25 @@ 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.
|
|
14
|
-
*
|
|
15
|
-
* is
|
|
11
|
+
* Every option comes decorated with plain states and one handler — `selected`
|
|
12
|
+
* (boolean), `select()` (pick it) and money formatted in the store's currency
|
|
13
|
+
* (`costLabel`) — so the child is markup + classes and nothing else.
|
|
14
|
+
*
|
|
15
|
+
* `hint` is the one thing the picker needs said right now, as a **code**, never
|
|
16
|
+
* as copy: `{ code, severity: "info"|"error", serverMessage }`. The words are
|
|
17
|
+
* the store's — write one line per code (there are three) in the store's own
|
|
18
|
+
* voice. `serverMessage` is set only when the backend explained the situation
|
|
19
|
+
* itself (an undeliverable address); it is more specific than anything you can
|
|
20
|
+
* write, so prefer it when present:
|
|
21
|
+
*
|
|
22
|
+
* const SHIPPING_HINTS = { // your words, once, near the checkout
|
|
23
|
+
* missing_address: "Enter your address to see delivery options.",
|
|
24
|
+
* none_available: "We can't deliver to that address yet.",
|
|
25
|
+
* syncing: "Updating delivery options…",
|
|
26
|
+
* };
|
|
27
|
+
* {hint && <p role={hint.severity === "error" ? "alert" : "status"}>
|
|
28
|
+
* {hint.serverMessage ?? SHIPPING_HINTS[hint.code]}
|
|
29
|
+
* </p>}
|
|
16
30
|
*
|
|
17
31
|
* Both read the nearest <CheckoutProvider>, or take an explicit `checkout`
|
|
18
32
|
* prop when you called `useCheckout` yourself.
|
|
@@ -27,39 +41,25 @@ function resolveCheckout(name, prop, ctx) {
|
|
|
27
41
|
}
|
|
28
42
|
|
|
29
43
|
/**
|
|
30
|
-
* Shipping options. Renders null for a virtual cart (`not_needed`) and while
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* {
|
|
34
|
-
* status, // "missing_address" | "choice_required" | "chosen"
|
|
35
|
-
* // | "auto_selected" | "none_available"
|
|
36
|
-
* methods, // [{ id, title, cost, costLabel, selected,
|
|
37
|
-
* // radioProps, labelProps }] — what this address is offered
|
|
38
|
-
* chosen, // the chosen/auto-selected entry (with costLabel), or null
|
|
39
|
-
* selected, // alias of `chosen` — the same name the payment picker uses
|
|
40
|
-
* choose, // (id) => Promise — call with a method's id on pick
|
|
41
|
-
* mustChoose, // status === "choice_required" → render methods as a picker
|
|
42
|
-
* single, // exactly one method offered — already chosen; skip the
|
|
43
|
-
* // picker but still show `chosen.title` and `chosen.costLabel`
|
|
44
|
-
* syncing, // an address edit is being repriced — show a subtle busy state
|
|
45
|
-
* hint, // { code, message, severity: "info"|"error" } | null — the
|
|
46
|
-
* // one notice to show now (address missing / not deliverable
|
|
47
|
-
* // / repricing); render `hint.message`, done
|
|
48
|
-
* addressError, // { code, message } | null — also surfaces on the address
|
|
49
|
-
* } // form's country field
|
|
44
|
+
* Shipping options. Renders null for a virtual cart (`not_needed`) and while the
|
|
45
|
+
* cart is loading; otherwise calls `children` with:
|
|
50
46
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* {
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
47
|
+
* status "missing_address" | "choice_required" | "chosen"
|
|
48
|
+
* | "auto_selected" | "none_available"
|
|
49
|
+
* methods [{ id, title, cost, costLabel, selected, select }]
|
|
50
|
+
* chosen the chosen/auto-selected entry, or null
|
|
51
|
+
* selected alias of `chosen` (the name the payment picker uses)
|
|
52
|
+
* choose (id) => Promise
|
|
53
|
+
* mustChoose status === "choice_required" → render methods as a picker
|
|
54
|
+
* single exactly one method offered — already chosen
|
|
55
|
+
* syncing an address edit is being repriced
|
|
56
|
+
* hint { code, severity, serverMessage } | null (see above)
|
|
57
|
+
* addressError { code, message } | null — the server's own words
|
|
60
58
|
*
|
|
61
59
|
* `single` and `mustChoose` are never both true, and `single` guarantees
|
|
62
|
-
* `chosen` — a single option still *shows* what it is
|
|
60
|
+
* `chosen` — a single option still *shows* what it is (title + `costLabel`),
|
|
61
|
+
* never a picker of one. Never render `cart.chosen_shipping_method` directly:
|
|
62
|
+
* it is the rate's id, which is why `chosen` is handed to you resolved.
|
|
63
63
|
*/
|
|
64
64
|
export function ShippingMethodPicker({ checkout: checkoutProp, children }) {
|
|
65
65
|
const checkout = resolveCheckout("ShippingMethodPicker", checkoutProp, useCheckoutContextOptional());
|
|
@@ -79,23 +79,15 @@ export function ShippingMethodPicker({ checkout: checkoutProp, children }) {
|
|
|
79
79
|
...m,
|
|
80
80
|
selected: m.id === chosen?.id,
|
|
81
81
|
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}` },
|
|
82
|
+
select: () => choose(m.id),
|
|
91
83
|
};
|
|
92
84
|
const hint =
|
|
93
85
|
status === "missing_address"
|
|
94
|
-
? { code: "missing_address", severity: "info",
|
|
86
|
+
? { code: "missing_address", severity: "info", serverMessage: null }
|
|
95
87
|
: status === "none_available" || addressError?.code === "shipping_not_available"
|
|
96
|
-
? { code: "none_available", severity: "error",
|
|
88
|
+
? { code: "none_available", severity: "error", serverMessage: addressError?.message ?? null }
|
|
97
89
|
: syncing
|
|
98
|
-
? { code: "syncing", severity: "info",
|
|
90
|
+
? { code: "syncing", severity: "info", serverMessage: null }
|
|
99
91
|
: null;
|
|
100
92
|
const decoratedChosen = decorate(chosen) ?? null;
|
|
101
93
|
return children({
|
|
@@ -113,34 +105,22 @@ export function ShippingMethodPicker({ checkout: checkoutProp, children }) {
|
|
|
113
105
|
}
|
|
114
106
|
|
|
115
107
|
/**
|
|
116
|
-
* Payment methods — every gateway the admin has ENABLED, from store info
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* {
|
|
121
|
-
* gateways, // [{ slug, title, description, online, selected,
|
|
122
|
-
* // radioProps, labelProps }] — admin-owned data, decorated
|
|
123
|
-
* value, // the selected slug ("" while none)
|
|
124
|
-
* select, // (slug) => void
|
|
125
|
-
* selected, // the selected gateway entry, or null
|
|
126
|
-
* single, // exactly one gateway — already selected; skip the picker but
|
|
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:
|
|
108
|
+
* Payment methods — every gateway the admin has ENABLED, from store info (their
|
|
109
|
+
* only source). Renders null while store info loads; otherwise calls `children`
|
|
110
|
+
* with:
|
|
132
111
|
*
|
|
133
|
-
* {
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
112
|
+
* gateways [{ slug, title, description, online, selected, select }]
|
|
113
|
+
* value the selected slug ("" while none)
|
|
114
|
+
* select (slug) => void
|
|
115
|
+
* selected the selected gateway entry, or null
|
|
116
|
+
* single exactly one gateway — already selected
|
|
117
|
+
* hint { code: "none_available", severity, serverMessage } | null —
|
|
118
|
+
* no gateways at all: say checkout is unavailable, in your words
|
|
140
119
|
*
|
|
141
120
|
* `single` guarantees `value` and `selected` — never render the one-gateway
|
|
142
121
|
* branch as "nothing selected yet" — and both re-resolve when store info
|
|
143
|
-
* changes.
|
|
122
|
+
* changes. A default-seeded store offers `offline` only, so never hardcode a
|
|
123
|
+
* card option. Titles and descriptions are the admin's copy: render them, don't
|
|
144
124
|
* invent your own.
|
|
145
125
|
*/
|
|
146
126
|
export function PaymentMethodPicker({ checkout: checkoutProp, children }) {
|
|
@@ -155,19 +135,11 @@ export function PaymentMethodPicker({ checkout: checkoutProp, children }) {
|
|
|
155
135
|
const decorated = gateways.map((g) => ({
|
|
156
136
|
...g,
|
|
157
137
|
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}` },
|
|
138
|
+
select: () => select(g.slug),
|
|
167
139
|
}));
|
|
168
140
|
const hint =
|
|
169
141
|
gateways.length === 0
|
|
170
|
-
? { code: "none_available", severity: "error",
|
|
142
|
+
? { code: "none_available", severity: "error", serverMessage: null }
|
|
171
143
|
: null;
|
|
172
144
|
return children({
|
|
173
145
|
gateways: decorated,
|