@base44/app-plugin-commerce 0.2.6 → 0.2.7

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