@base44/app-plugin-commerce 0.5.1 → 0.6.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 +2 -2
- package/package.json +1 -1
- package/skills/commerce/SKILL.md +64 -50
- package/skills/commerce/install/01-install.md +1 -1
- package/skills/commerce/install/02-storefront.md +165 -155
- package/skills/commerce/references/storefront-verification.md +0 -4
- package/src/commerce/storefront/AddressFields.jsx +150 -0
- package/src/commerce/storefront/StorefrontProvider.jsx +3 -10
- package/src/commerce/storefront/cartUI.jsx +17 -63
- package/src/commerce/storefront/index.js +6 -30
- package/src/commerce/storefront/useCheckout.jsx +0 -27
- package/skills/commerce/references/storefront-custom.md +0 -150
- package/skills/commerce/references/storefront-parts.md +0 -184
- package/src/commerce/storefront/parts/cart.jsx +0 -191
- package/src/commerce/storefront/parts/checkout.jsx +0 -540
- package/src/commerce/storefront/parts/drawer.jsx +0 -87
- package/src/commerce/storefront/parts/labels.jsx +0 -140
- package/src/commerce/storefront/parts/orderReceived.jsx +0 -200
- package/src/commerce/storefront/parts/parts.css +0 -215
- package/src/commerce/storefront/parts/shared.jsx +0 -137
- package/src/commerce/storefront/parts/visibility.js +0 -36
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import React, { useId, useState } from "react";
|
|
2
|
+
import { addressFieldSpec } from "@/commerce/utils";
|
|
3
|
+
import { useCheckoutContext } from "./useCheckout";
|
|
4
|
+
import { useCountries } from "./StorefrontProvider";
|
|
5
|
+
import { REQUIRED_BILLING_FIELDS } from "./address";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The checkout address form, headless — the one storefront section that ships
|
|
9
|
+
* as a component rather than as wiring reference, because hand-rolling it is
|
|
10
|
+
* where checkouts break: the state/province field must appear with the right
|
|
11
|
+
* options once a country is picked (shipping and taxes match on country *plus*
|
|
12
|
+
* state, so a form without it silently mis-prices US/CA/AU orders),
|
|
13
|
+
* `autoComplete` tokens are what make browser autofill work, and the server's
|
|
14
|
+
* "we don't ship there" belongs on the country field.
|
|
15
|
+
*
|
|
16
|
+
* It renders semantic fields and nothing else — no CSS ships with it. Every
|
|
17
|
+
* element carries `data-part` (`field`, `label`, `control`, `required`,
|
|
18
|
+
* `error`) plus `data-key`/`data-span`/`data-invalid`, so the store's own
|
|
19
|
+
* classes attach via `className`/`classes` props or `[data-part]` selectors.
|
|
20
|
+
* Field labels come from `addressFieldSpec` (plain conventions — rename any of
|
|
21
|
+
* them via `labels`). Required marks arm on first blur, never on load.
|
|
22
|
+
*
|
|
23
|
+
* Works inside `<CheckoutProvider>` (it reads `useCheckoutContext()`).
|
|
24
|
+
* `which="shipping"` renders null until `shipToDifferent` is on — the
|
|
25
|
+
* deliver-elsewhere checkbox itself stays yours, wired to
|
|
26
|
+
* `checkout.shipToDifferent` / `checkout.setShipToDifferent`.
|
|
27
|
+
*
|
|
28
|
+
* @param {object} props
|
|
29
|
+
* @param {"billing"|"shipping"} [props.which] which address this form edits.
|
|
30
|
+
* @param {boolean} [props.includeCompany] add the company field (default false).
|
|
31
|
+
* @param {boolean} [props.includePhone] keep the phone field (default true).
|
|
32
|
+
* @param {string[]} [props.omit] field keys to drop entirely.
|
|
33
|
+
* @param {Record<string, string>} [props.labels] label text per field key,
|
|
34
|
+
* merged over the spec's defaults (e.g. `{ postcode: "ZIP code" }`).
|
|
35
|
+
* @param {string} [props.selectPlaceholder] the selects' empty-option text.
|
|
36
|
+
* @param {Function} [props.inputRender] `({ field, options, invalid, ...dom })`
|
|
37
|
+
* — swaps the control only; spread `dom` onto your input.
|
|
38
|
+
* @param {Function} [props.fieldRender] `({ field, label, invalid, options, ...dom })`
|
|
39
|
+
* — replaces the whole labeled block.
|
|
40
|
+
* @param {string} [props.className] class for the wrapping element.
|
|
41
|
+
* @param {{field?: string, label?: string, control?: string, error?: string}} [props.classes]
|
|
42
|
+
*/
|
|
43
|
+
export function AddressFields({
|
|
44
|
+
which = "billing",
|
|
45
|
+
includeCompany = false,
|
|
46
|
+
includePhone = true,
|
|
47
|
+
omit,
|
|
48
|
+
labels,
|
|
49
|
+
selectPlaceholder = "Select…",
|
|
50
|
+
inputRender: InputRender,
|
|
51
|
+
fieldRender,
|
|
52
|
+
className,
|
|
53
|
+
classes,
|
|
54
|
+
}) {
|
|
55
|
+
const checkout = useCheckoutContext();
|
|
56
|
+
const { countries } = useCountries();
|
|
57
|
+
const idBase = useId();
|
|
58
|
+
const [touched, setTouched] = useState({});
|
|
59
|
+
|
|
60
|
+
const isBilling = which === "billing";
|
|
61
|
+
if (!isBilling && !checkout.shipToDifferent) return null;
|
|
62
|
+
|
|
63
|
+
const values = isBilling ? checkout.billing : checkout.shipping;
|
|
64
|
+
const set = isBilling ? checkout.updateBilling : checkout.updateShipping;
|
|
65
|
+
const omitted = new Set(omit ?? []);
|
|
66
|
+
const fields = addressFieldSpec({
|
|
67
|
+
countries,
|
|
68
|
+
country: values.country,
|
|
69
|
+
required: isBilling ? REQUIRED_BILLING_FIELDS : ["country", "city"],
|
|
70
|
+
includeEmail: isBilling, // one email per order, on billing
|
|
71
|
+
includeCompany,
|
|
72
|
+
includePhone,
|
|
73
|
+
}).filter((f) => !omitted.has(f.key));
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div data-part="address-fields" data-which={which} className={className}>
|
|
77
|
+
{fields.map((f) => {
|
|
78
|
+
const id = `${idBase}-${which}-${f.key}`;
|
|
79
|
+
const value = values[f.key] ?? "";
|
|
80
|
+
const invalid = Boolean(touched[f.key] && f.required && !String(value).trim());
|
|
81
|
+
const dom = {
|
|
82
|
+
id,
|
|
83
|
+
value,
|
|
84
|
+
autoComplete: f.autoComplete,
|
|
85
|
+
onChange: (e) => set({ [f.key]: e && e.target ? e.target.value : e }),
|
|
86
|
+
onBlur: () => setTouched((t) => (t[f.key] ? t : { ...t, [f.key]: true })),
|
|
87
|
+
};
|
|
88
|
+
const labelText = labels?.[f.key] ?? f.label;
|
|
89
|
+
if (fieldRender) {
|
|
90
|
+
return (
|
|
91
|
+
<React.Fragment key={f.key}>
|
|
92
|
+
{fieldRender({ field: f, label: labelText, invalid, options: f.options, ...dom })}
|
|
93
|
+
</React.Fragment>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return (
|
|
97
|
+
<div
|
|
98
|
+
key={f.key}
|
|
99
|
+
data-part="field"
|
|
100
|
+
data-key={f.key}
|
|
101
|
+
data-span={f.colSpan}
|
|
102
|
+
data-invalid={invalid || undefined}
|
|
103
|
+
className={classes?.field}
|
|
104
|
+
>
|
|
105
|
+
<label data-part="label" className={classes?.label} htmlFor={id}>
|
|
106
|
+
{labelText}
|
|
107
|
+
{f.required && (
|
|
108
|
+
<span data-part="required" aria-hidden="true">
|
|
109
|
+
*
|
|
110
|
+
</span>
|
|
111
|
+
)}
|
|
112
|
+
</label>
|
|
113
|
+
{InputRender ? (
|
|
114
|
+
<InputRender field={f} options={f.options} invalid={invalid} {...dom} />
|
|
115
|
+
) : f.type === "select" ? (
|
|
116
|
+
<select
|
|
117
|
+
data-part="control"
|
|
118
|
+
className={classes?.control}
|
|
119
|
+
aria-invalid={invalid || undefined}
|
|
120
|
+
aria-required={f.required || undefined}
|
|
121
|
+
{...dom}
|
|
122
|
+
>
|
|
123
|
+
<option value="">{selectPlaceholder}</option>
|
|
124
|
+
{f.options.map((o) => (
|
|
125
|
+
<option key={o.value} value={o.value}>
|
|
126
|
+
{o.label}
|
|
127
|
+
</option>
|
|
128
|
+
))}
|
|
129
|
+
</select>
|
|
130
|
+
) : (
|
|
131
|
+
<input
|
|
132
|
+
data-part="control"
|
|
133
|
+
className={classes?.control}
|
|
134
|
+
type={f.type}
|
|
135
|
+
aria-invalid={invalid || undefined}
|
|
136
|
+
aria-required={f.required || undefined}
|
|
137
|
+
{...dom}
|
|
138
|
+
/>
|
|
139
|
+
)}
|
|
140
|
+
{f.key === "country" && checkout.addressError && (
|
|
141
|
+
<p data-part="error" role="alert" className={classes?.error}>
|
|
142
|
+
{checkout.addressError.message}
|
|
143
|
+
</p>
|
|
144
|
+
)}
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
})}
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
@@ -12,7 +12,6 @@ import {
|
|
|
12
12
|
storefrontErrorCode,
|
|
13
13
|
storefrontErrorMessage,
|
|
14
14
|
} from "@/commerce/utils";
|
|
15
|
-
import { LabelsScope } from "./parts/labels";
|
|
16
15
|
|
|
17
16
|
/**
|
|
18
17
|
* StorefrontProvider — one client, one store-info cache, ONE shared cart.
|
|
@@ -27,9 +26,7 @@ import { LabelsScope } from "./parts/labels";
|
|
|
27
26
|
* it, so the cart badge and the cart page read different carts.
|
|
28
27
|
*
|
|
29
28
|
* Pass `base44`, or `store={createStorefront(base44)}` when other modules need
|
|
30
|
-
* the same client.
|
|
31
|
-
* layer — pass it once here and every part resolves its words from it (the
|
|
32
|
-
* key list and example live in the skill's install/02-storefront.md).
|
|
29
|
+
* the same client.
|
|
33
30
|
*
|
|
34
31
|
* What lives here, and why it must not be duplicated per page: the client (it
|
|
35
32
|
* owns the cart_token lifecycle); store info, cached for the session —
|
|
@@ -41,7 +38,7 @@ import { LabelsScope } from "./parts/labels";
|
|
|
41
38
|
|
|
42
39
|
const StorefrontContext = createContext(null);
|
|
43
40
|
|
|
44
|
-
export function StorefrontProvider({ base44, store,
|
|
41
|
+
export function StorefrontProvider({ base44, store, children }) {
|
|
45
42
|
const client = useMemo(
|
|
46
43
|
() => store ?? createStorefront(base44),
|
|
47
44
|
[store, base44],
|
|
@@ -112,11 +109,7 @@ export function StorefrontProvider({ base44, store, labels, children }) {
|
|
|
112
109
|
() => ({ client, info, infoError, cart, cartError, mutationError, runCart, clearCart }),
|
|
113
110
|
[client, info, infoError, cart, cartError, mutationError, runCart, clearCart],
|
|
114
111
|
);
|
|
115
|
-
return
|
|
116
|
-
<StorefrontContext.Provider value={value}>
|
|
117
|
-
<LabelsScope labels={labels}>{children}</LabelsScope>
|
|
118
|
-
</StorefrontContext.Provider>
|
|
119
|
-
);
|
|
112
|
+
return <StorefrontContext.Provider value={value}>{children}</StorefrontContext.Provider>;
|
|
120
113
|
}
|
|
121
114
|
|
|
122
115
|
/** Advanced escape hatch: the raw provider state. Prefer the hooks below. */
|
|
@@ -1,45 +1,26 @@
|
|
|
1
|
-
import React, {
|
|
2
|
-
createContext,
|
|
3
|
-
useCallback,
|
|
4
|
-
useContext,
|
|
5
|
-
useEffect,
|
|
6
|
-
useId,
|
|
7
|
-
useLayoutEffect,
|
|
8
|
-
useMemo,
|
|
9
|
-
useRef,
|
|
10
|
-
useState,
|
|
11
|
-
} from "react";
|
|
1
|
+
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
12
2
|
import { useLocation } from "react-router-dom";
|
|
13
3
|
|
|
14
4
|
/**
|
|
15
|
-
* Cart drawer state
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* - Esc closes; the route changing closes (`closeOnNavigate`); an item landing
|
|
22
|
-
* in the cart opens (`openOnAdd`, wired through `useAddToCart` — pass false
|
|
23
|
-
* for a navigate-to-bag flow).
|
|
24
|
-
* - **Focus.** Attach `panelRef` to your panel element: focus moves into it
|
|
25
|
-
* when the drawer opens and returns to whatever had it when the drawer
|
|
26
|
-
* closes (give the panel `tabIndex={-1}` so it can receive it).
|
|
27
|
-
* - **A closed-but-mounted panel is made `inert`** while `panelRef` is
|
|
28
|
-
* attached — the classic drawer bug is a panel translated off-screen whose
|
|
29
|
-
* buttons stay clickable, tab-able and readable to screen readers. Animate
|
|
30
|
-
* the slide freely; the closed panel stops being interactive by itself.
|
|
31
|
-
* - `panelId` — put it on your panel as `id`; the kit's `<CartDrawer.Trigger>`
|
|
32
|
-
* already points `aria-controls` at it.
|
|
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.
|
|
33
11
|
*
|
|
34
12
|
* Mount `<CartUIProvider>` once, inside `<StorefrontProvider>`, around the
|
|
35
|
-
* layout; the layout renders the
|
|
13
|
+
* layout; the layout then renders the panel off `open`.
|
|
36
14
|
*
|
|
37
|
-
* ⚑
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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.
|
|
41
22
|
*
|
|
42
|
-
* `useCartUI()` → `{ open, openCart, closeCart, toggleCart
|
|
23
|
+
* `useCartUI()` → `{ open, openCart, closeCart, toggleCart }`.
|
|
43
24
|
* `useCartUIOptional()` returns null instead of throwing (how `useAddToCart`
|
|
44
25
|
* integrates without requiring the provider).
|
|
45
26
|
*
|
|
@@ -48,9 +29,6 @@ import { useLocation } from "react-router-dom";
|
|
|
48
29
|
|
|
49
30
|
const CartUIContext = createContext(null);
|
|
50
31
|
|
|
51
|
-
/** Layout effect where there is a DOM; plain effect on the server (no warning). */
|
|
52
|
-
const useDrawerEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
53
|
-
|
|
54
32
|
/** Internal: closes the drawer whenever the route changes (needs a Router above). */
|
|
55
33
|
function CloseOnNavigate({ close }) {
|
|
56
34
|
const { pathname } = useLocation();
|
|
@@ -74,9 +52,6 @@ export function CartUIProvider({ closeOnNavigate = true, openOnAdd = true, child
|
|
|
74
52
|
const openCart = useCallback(() => setOpen(true), []);
|
|
75
53
|
const closeCart = useCallback(() => setOpen(false), []);
|
|
76
54
|
const toggleCart = useCallback(() => setOpen((o) => !o), []);
|
|
77
|
-
const panelId = useId();
|
|
78
|
-
const panelRef = useRef(null);
|
|
79
|
-
const restoreRef = useRef(null);
|
|
80
55
|
|
|
81
56
|
// Esc closes — the keyboard's way out is Esc and the named close button.
|
|
82
57
|
useEffect(() => {
|
|
@@ -88,36 +63,15 @@ export function CartUIProvider({ closeOnNavigate = true, openOnAdd = true, child
|
|
|
88
63
|
return () => window.removeEventListener("keydown", onKey);
|
|
89
64
|
}, [open]);
|
|
90
65
|
|
|
91
|
-
// Focus into the store's panel on open, back where it came from on close, and
|
|
92
|
-
// a closed-but-mounted panel is inert — all on whatever element the store
|
|
93
|
-
// attached `panelRef` to, so the drawer's markup and animation stay theirs.
|
|
94
|
-
// Layout effect: before paint, so an animating panel is never briefly live.
|
|
95
|
-
useDrawerEffect(() => {
|
|
96
|
-
const node = panelRef.current;
|
|
97
|
-
if (open) {
|
|
98
|
-
restoreRef.current = typeof document !== "undefined" ? document.activeElement : null;
|
|
99
|
-
if (node) {
|
|
100
|
-
node.inert = false;
|
|
101
|
-
node.focus?.({ preventScroll: true });
|
|
102
|
-
}
|
|
103
|
-
} else {
|
|
104
|
-
if (node) node.inert = true;
|
|
105
|
-
restoreRef.current?.focus?.();
|
|
106
|
-
restoreRef.current = null;
|
|
107
|
-
}
|
|
108
|
-
}, [open]);
|
|
109
|
-
|
|
110
66
|
const value = useMemo(
|
|
111
67
|
() => ({
|
|
112
68
|
open,
|
|
113
69
|
openCart,
|
|
114
70
|
closeCart,
|
|
115
71
|
toggleCart,
|
|
116
|
-
panelRef,
|
|
117
|
-
panelId,
|
|
118
72
|
onItemAdded: openOnAdd ? openCart : null,
|
|
119
73
|
}),
|
|
120
|
-
[open, openCart, closeCart, toggleCart,
|
|
74
|
+
[open, openCart, closeCart, toggleCart, openOnAdd],
|
|
121
75
|
);
|
|
122
76
|
|
|
123
77
|
return (
|
|
@@ -35,6 +35,10 @@
|
|
|
35
35
|
* - `useOrderReturn` — the mandatory `/order-received` page in one hook.
|
|
36
36
|
* - `ShippingMethodPicker` / `PaymentMethodPicker` — render-prop components for
|
|
37
37
|
* the two checkout choices that are store data, never hardcoded.
|
|
38
|
+
* - `AddressFields` — the ONE rendered component in the layer: the checkout
|
|
39
|
+
* address form (state/province appears per country, `autoComplete` kept,
|
|
40
|
+
* required marks arm on blur). Unstyled; style it via `data-part`
|
|
41
|
+
* selectors or its `className`/`classes` props.
|
|
38
42
|
*
|
|
39
43
|
* Re-exported from `@/commerce/utils` so one import line covers a page:
|
|
40
44
|
* `variantAxes` (axes → options with selected/disabled/stock derived),
|
|
@@ -62,23 +66,6 @@
|
|
|
62
66
|
*
|
|
63
67
|
* The catalog and product surfaces are where the design freedom lives: these
|
|
64
68
|
* hooks hand you resolved data, and the rendering is entirely yours.
|
|
65
|
-
*
|
|
66
|
-
* **Parts** — for the four commodity surfaces only (cart, drawer, checkout,
|
|
67
|
-
* order-received), `Checkout.*` / `Cart.*` / `CartDrawer.*` /
|
|
68
|
-
* `OrderReceived.*` render each section's correct semantic markup with zero
|
|
69
|
-
* copy and zero navigation: you place them in YOUR layout, and supply every
|
|
70
|
-
* word through `labels` (chain: part prop → page Root → `<StorefrontProvider
|
|
71
|
-
* labels={…}>`; a missing key renders a visible `⟨copy: …⟩` placeholder).
|
|
72
|
-
* They carry **layout geometry and nothing else** (`parts/parts.css`: field
|
|
73
|
-
* grids, row and thumbnail sizing, totals rows, the drawer panel — no color,
|
|
74
|
-
* type or border, every rule at zero specificity), so an unstyled store looks
|
|
75
|
-
* unfinished but never broken; the look arrives as your CSS on
|
|
76
|
-
* `data-part`/`data-state`, or via `className`/`classes`. Contract tables, the
|
|
77
|
-
* copy example and the `data-part` inventory: the commerce skill's
|
|
78
|
-
* install/02-storefront.md — use the parts from there, not from these files.
|
|
79
|
-
* They compose the hooks above, so dropping one section down to its hook is
|
|
80
|
-
* normal; they are also the app's own source, editable when a requirement
|
|
81
|
-
* outgrows their props.
|
|
82
69
|
*/
|
|
83
70
|
export {
|
|
84
71
|
StorefrontProvider,
|
|
@@ -89,9 +76,10 @@ export {
|
|
|
89
76
|
useCountries,
|
|
90
77
|
useCart,
|
|
91
78
|
} from "./StorefrontProvider";
|
|
92
|
-
export { useCheckout, CheckoutProvider, useCheckoutContext
|
|
79
|
+
export { useCheckout, CheckoutProvider, useCheckoutContext } from "./useCheckout";
|
|
93
80
|
export { useOrderReturn, orderReceivedUrl } from "./useOrderReturn";
|
|
94
81
|
export { ShippingMethodPicker, PaymentMethodPicker } from "./pickers";
|
|
82
|
+
export { AddressFields } from "./AddressFields";
|
|
95
83
|
export { CartUIProvider, useCartUI } from "./cartUI";
|
|
96
84
|
export {
|
|
97
85
|
REQUIRED_BILLING_FIELDS,
|
|
@@ -100,18 +88,6 @@ export {
|
|
|
100
88
|
isShippingAddressComplete,
|
|
101
89
|
} from "./address";
|
|
102
90
|
|
|
103
|
-
// ── parts: guided sections for the commodity surfaces ──────────────────────
|
|
104
|
-
// The parts' layout geometry (field grids, row/media sizing, totals rows, the
|
|
105
|
-
// drawer panel) — structure only, no look, every rule at zero specificity so
|
|
106
|
-
// the store's CSS wins. See parts/parts.css for the custom properties.
|
|
107
|
-
import "./parts/parts.css";
|
|
108
|
-
|
|
109
|
-
export { Checkout } from "./parts/checkout";
|
|
110
|
-
export { Cart } from "./parts/cart";
|
|
111
|
-
export { CartDrawer } from "./parts/drawer";
|
|
112
|
-
export { OrderReceived } from "./parts/orderReceived";
|
|
113
|
-
export { REQUIRED_LABEL_KEYS } from "./parts/labels";
|
|
114
|
-
|
|
115
91
|
// ── catalog ────────────────────────────────────────────────────────────────
|
|
116
92
|
export { useProductList, useCategories, useRibbons } from "./useProductList";
|
|
117
93
|
export { useProduct, useAddToCart } from "./useProduct";
|
|
@@ -381,30 +381,3 @@ export function useCheckoutContext() {
|
|
|
381
381
|
export function useCheckoutContextOptional() {
|
|
382
382
|
return useContext(CheckoutContext);
|
|
383
383
|
}
|
|
384
|
-
|
|
385
|
-
/**
|
|
386
|
-
* Is this component rendering inside a checkout (`Checkout.Root`, or your own
|
|
387
|
-
* `<CheckoutProvider>`)? For the one component a store writes once and shows in
|
|
388
|
-
* three places — the drawer, the cart page and the checkout's order summary —
|
|
389
|
-
* where a merchandising block belongs in the first two and is a distraction (or
|
|
390
|
-
* a way out of the funnel) in the third:
|
|
391
|
-
*
|
|
392
|
-
* function CartContents() {
|
|
393
|
-
* const inCheckout = useInCheckout();
|
|
394
|
-
* return (
|
|
395
|
-
* <>
|
|
396
|
-
* <Cart.Lines />
|
|
397
|
-
* {!inCheckout && <UpsellRail />} // your own upsell / related rail
|
|
398
|
-
* <Cart.Totals />
|
|
399
|
-
* </>
|
|
400
|
-
* );
|
|
401
|
-
* }
|
|
402
|
-
*
|
|
403
|
-
* A boolean, not a mode: nothing in the kit changes behavior from it. Prefer an
|
|
404
|
-
* explicit prop (`<CartContents showUpsell={false} />`) where the caller knows
|
|
405
|
-
* best; this is for the case where it doesn't — a shared component several
|
|
406
|
-
* pages deep.
|
|
407
|
-
*/
|
|
408
|
-
export function useInCheckout() {
|
|
409
|
-
return useContext(CheckoutContext) != null;
|
|
410
|
-
}
|
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
read_when: "A cart row, checkout section or receipt region needs structure the parts' props can't express, and you are about to build it on the raw hooks."
|
|
3
|
-
---
|
|
4
|
-
|
|
5
|
-
# Custom sections on the raw hooks
|
|
6
|
-
|
|
7
|
-
The parts (`Cart.*`, `CartDrawer.*`, `Checkout.*`, `OrderReceived.*`) are thin
|
|
8
|
-
compositions over the hooks below — same providers, same shared state — so
|
|
9
|
-
**replacing one section while keeping the others is normal**: a bespoke
|
|
10
|
-
shipping selector between `<Checkout.AddressFields>` and
|
|
11
|
-
`<Checkout.PaymentMethods>` reads the same checkout the parts do.
|
|
12
|
-
|
|
13
|
-
Work down this ladder; each rung is cheaper than the next:
|
|
14
|
-
|
|
15
|
-
1. **`classes` / CSS** on `data-part` — almost every "the part looks wrong" is this rung.
|
|
16
|
-
2. **Labels** — a wording problem is a copy-file problem, never a code problem.
|
|
17
|
-
3. **`…Render` overrides** — `optionRender`, `lineRender`, `itemRender`, `inputRender`/`fieldRender`: your element, the part's wiring.
|
|
18
|
-
4. **This file** — rebuild one section on its hook.
|
|
19
|
-
5. **Edit the part's source** (`src/commerce/storefront/parts/`) — legitimate when a *user requirement* exceeds the props; the edited part is the store's code to maintain.
|
|
20
|
-
|
|
21
|
-
Never re-implement what a hook does (quantity clamps, totals math, repricing,
|
|
22
|
-
drawer state) — that is where storefront bugs cluster. And keep the parts'
|
|
23
|
-
guarantees when you replace them: every state worded, busy scoped to the row,
|
|
24
|
-
a disabled button that says why.
|
|
25
|
-
|
|
26
|
-
## What the custom-section hooks resolve to
|
|
27
|
-
|
|
28
|
-
| Call | Resolves to |
|
|
29
|
-
|---|---|
|
|
30
|
-
| `useCartLine(item)` | `{ quantity, setQuantity, increase, decrease, remove, pending, error, canIncrease, canDecrease, maxQuantity, atMax, atMin }` — optimistic quantity, clicks coalesced (~250ms), rollback on rejection. `pending` is **this row's** flag: true while its request is out, false only after the new cart view lands — the only mutation-settled signal (`useCart().status` never returns to `"loading"`). |
|
|
31
|
-
| `useCartUI()` | `{ open, openCart, closeCart, toggleCart, panelRef, panelId }` — drawer state and behavior on `<CartUIProvider>` (Esc, open-on-add, close-on-navigate; `panelRef` moves focus in/out and makes a closed-but-mounted panel `inert`). The drawer's overlay and panel are your markup. |
|
|
32
|
-
| `useCheckoutContext()` | the address (`billing`, `updateBilling`, `shipping`, `updateShipping`, `shipToDifferent`, `setShipToDifferent`, `missingBillingFields`, `addressError`), the choices (`shippingStatus`, `shippingMethods`, `chosenShippingMethod`, `chooseShippingMethod`, `paymentMethods`, `paymentMethod`, `setPaymentMethod`, `selectedGateway`), and the gate (`blockers`, `canPlaceOrder`, `placing`, `stage`, `orderError`, `placeOrder`) — plus `cart`. Works under `<Checkout.Root>` (or your own `<CheckoutProvider>`). |
|
|
33
|
-
| `useOrderReturn()` | `{ status, order, lines, paymentLink, paymentInstructions, error, reload }` — `status`: `"loading" \| "paid" \| "unpaid" \| "cancelled" \| "error"`; `lines` normalized (`image` is `{src, alt}\|null`, `totalLabel` pre-formatted); an order's totals are **flat** (`order.total`, no `order.totals`). |
|
|
34
|
-
| `cartTotalsLines(cart, { formatMoney, labels })` / `orderTotalsLines(order, …)` | `[{ key, label, amount, formatted, hidden, emphasis }]` — keys `subtotal` `discount` `shipping` `tax` `total`; discount/tax are produced and flagged `hidden` when zero, so a mapping renderer can't drop them. |
|
|
35
|
-
| `addressFieldSpec({ countries, country, required, includeEmail })` | `[{ key, label, type, required, autoComplete, options, colSpan }]` — the state field appears (as a select where the country carries states) once `country` is passed. |
|
|
36
|
-
| `ShippingMethodPicker` / `PaymentMethodPicker` | render-prop components the parts wrap — `children({ status, methods, chosen, mustChoose, single, syncing, hint, … })` / `children({ gateways, selected, single, hint })`; every option decorated with `selected`, `select()`, `costLabel`. |
|
|
37
|
-
|
|
38
|
-
## Reference wiring
|
|
39
|
-
|
|
40
|
-
Words below come from the same copy file the parts read (`STORE_COPY`) — a
|
|
41
|
-
custom section never hardcodes them.
|
|
42
|
-
|
|
43
|
-
**A custom cart row** — `<CartLine>` binds `useCartLine` per row without a
|
|
44
|
-
hook-in-a-loop:
|
|
45
|
-
|
|
46
|
-
```jsx
|
|
47
|
-
{cart.items.map((item) => (
|
|
48
|
-
<CartLine key={item.item_key} line={item}>
|
|
49
|
-
{(l) => (
|
|
50
|
-
<li aria-busy={l.pending}> {/* busy scope is THIS row, never the cart */}
|
|
51
|
-
{item.name} {attributesLabel(item.attributes)} {formatMoney(item.total)}
|
|
52
|
-
<button onClick={l.decrease} disabled={!l.canDecrease || l.pending}
|
|
53
|
-
aria-label={STORE_COPY.aria.decrease(item.name)}>−</button>
|
|
54
|
-
{l.quantity}
|
|
55
|
-
<button onClick={l.increase} disabled={!l.canIncrease || l.pending}
|
|
56
|
-
aria-label={STORE_COPY.aria.increase(item.name)}>+</button>
|
|
57
|
-
<button onClick={l.remove} disabled={l.pending}
|
|
58
|
-
aria-label={STORE_COPY.aria.remove(item.name)}>×</button>
|
|
59
|
-
{l.error && <p role="alert">{l.error.message}</p>}
|
|
60
|
-
</li>
|
|
61
|
-
)}
|
|
62
|
-
</CartLine>
|
|
63
|
-
))}
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
Give repeated controls unique accessible names (the item's name in the label)
|
|
67
|
-
— three identical "Remove" buttons are ambiguous to a screen reader and to a
|
|
68
|
-
script driving the page.
|
|
69
|
-
|
|
70
|
-
**A custom shipping step** — the picker encodes the branching a checkout must
|
|
71
|
-
not skip:
|
|
72
|
-
|
|
73
|
-
```jsx
|
|
74
|
-
<ShippingMethodPicker>
|
|
75
|
-
{({ hint, mustChoose, methods, chosen }) => (
|
|
76
|
-
<fieldset>{/* renders null for a virtual cart */}
|
|
77
|
-
{hint && <p role={hint.severity === "error" ? "alert" : "status"}>
|
|
78
|
-
{hint.serverMessage ?? STORE_COPY.shipping[hint.code]}</p>}
|
|
79
|
-
{mustChoose && methods.map((m) => (
|
|
80
|
-
<label key={m.id}>
|
|
81
|
-
<input type="radio" name="ship" checked={m.selected} onChange={m.select} />
|
|
82
|
-
{m.title} {m.costLabel}
|
|
83
|
-
</label>
|
|
84
|
-
))}
|
|
85
|
-
{!mustChoose && chosen && <p>{chosen.title} {chosen.costLabel}</p>}
|
|
86
|
-
</fieldset>
|
|
87
|
-
)}
|
|
88
|
-
</ShippingMethodPicker>
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
Never render `cart.chosen_shipping_method` directly — it is a rate id; `chosen`
|
|
92
|
-
arrives resolved. A single option still *shows* what it is, never a picker of
|
|
93
|
-
one. `PaymentMethodPicker` is the same shape over `gateways` (titles and
|
|
94
|
-
descriptions are the admin's copy — render them, don't invent your own).
|
|
95
|
-
|
|
96
|
-
**A custom address form** — bind the spec, never a hand-typed field list:
|
|
97
|
-
|
|
98
|
-
```jsx
|
|
99
|
-
function AddressFields({ which }) {
|
|
100
|
-
const c = useCheckoutContext();
|
|
101
|
-
const { countries } = useCountries(); // [] until store info lands — never null
|
|
102
|
-
const isBilling = which === "billing";
|
|
103
|
-
const values = isBilling ? c.billing : c.shipping;
|
|
104
|
-
const set = isBilling ? c.updateBilling : c.updateShipping;
|
|
105
|
-
const fields = addressFieldSpec({
|
|
106
|
-
countries, country: values.country,
|
|
107
|
-
required: isBilling ? REQUIRED_BILLING_FIELDS : ["country", "city"],
|
|
108
|
-
includeEmail: isBilling, // one email per order, on billing
|
|
109
|
-
});
|
|
110
|
-
return fields.map((f) => (
|
|
111
|
-
<div key={f.key}>
|
|
112
|
-
<label htmlFor={`${which}-${f.key}`}>{STORE_COPY.fields[f.key]}{f.required && " *"}</label>
|
|
113
|
-
{f.type === "select" ? (
|
|
114
|
-
<select id={`${which}-${f.key}`} value={values[f.key] ?? ""} autoComplete={f.autoComplete}
|
|
115
|
-
onChange={(e) => set({ [f.key]: e.target.value })}>
|
|
116
|
-
<option value="">{STORE_COPY.fields.select_placeholder}</option>
|
|
117
|
-
{f.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
|
118
|
-
</select>
|
|
119
|
-
) : (
|
|
120
|
-
<input id={`${which}-${f.key}`} type={f.type} value={values[f.key] ?? ""}
|
|
121
|
-
autoComplete={f.autoComplete} onChange={(e) => set({ [f.key]: e.target.value })} />
|
|
122
|
-
)}
|
|
123
|
-
{/* "we don't ship there" is an address-level error — it belongs on country */}
|
|
124
|
-
{f.key === "country" && c.addressError && <span role="alert">{c.addressError.message}</span>}
|
|
125
|
-
</div>
|
|
126
|
-
));
|
|
127
|
-
}
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
⚑ Passing `country` is what makes the state/province field appear — rates and
|
|
131
|
-
taxes match on country *plus* state, so a form without it silently mis-prices
|
|
132
|
-
US/CA/AU orders. ⚑ Keep every `autoComplete` token — it is what makes browser
|
|
133
|
-
autofill work.
|
|
134
|
-
|
|
135
|
-
**A custom place-order region** — the gate explains itself:
|
|
136
|
-
|
|
137
|
-
```jsx
|
|
138
|
-
<button type="button" onClick={c.placeOrder} disabled={!c.canPlaceOrder || c.placing}>
|
|
139
|
-
{c.placing ? STORE_COPY.placeOrder.placing : STORE_COPY.placeOrder.label}
|
|
140
|
-
</button>
|
|
141
|
-
{c.orderError && <p role="alert">{c.orderError.message}</p>}
|
|
142
|
-
{!c.canPlaceOrder && c.blockers.map((code) => <p key={code}>{STORE_COPY.blockers[code]}</p>)}
|
|
143
|
-
```
|
|
144
|
-
|
|
145
|
-
`placeOrder` is safe as a direct `onClick` handler (an event is not read as
|
|
146
|
-
order fields) and resolves `{ ok, result }` / `{ ok: false, error }` without
|
|
147
|
-
throwing. ⚑ Keep the `stage === "submitted"` guard above any empty-cart branch
|
|
148
|
-
— placing an order clears the cart, and without the guard the page flashes
|
|
149
|
-
"your bag is empty" over a just-placed order (`Checkout.Root`'s gates encode
|
|
150
|
-
this ordering; a fully hand-built page must re-encode it).
|