@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.
@@ -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
- * filters, `refreshing`, and failure as a visible state.
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 + variant selection +
38
- * quantity + price + gallery, race-safe, with `status: "not_found"` and
39
- * every add-to-cart failure handled. `variantAxes(view, pick)` (from
40
- * `@/commerce/utils`, re-exported here) turns the resolved view into a
41
- * render-ready model for the selector you write.
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`), quantity steppers that clamp and
46
- * recover, and the coupon field a store with coupons must have.
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 you map to your
54
- * own inputs (state included, country options never null), one totals
55
- * projection for cart and order, and blocker codes turned into copy.
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, with their branching enumerated.
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" | "auto_selected"
28
- * methods, // [{ id, title, cost }] — what this address is offered
29
- * chosen, // the chosen/auto-selected entry (title + cost), or null
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 its cost
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
- * addressError, // { code, message } | null — "we don't ship there" belongs
37
- * } // on the address fields
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
- * Render rules the child should follow: `missing_address` → say the options
40
- * appear once the address is entered; `mustChoose` a picker of `methods`;
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: chosen,
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 }] — admin-owned data
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
- * } // still show its title so the customer knows how they pay
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
- * Render rules: several gateways a picker labeled with the admin's
83
- * title/description; `single` just display it; zero gateways say checkout
84
- * is unavailable instead of rendering a dead place-order button. `single`
85
- * guarantees `value` and `selected` — never render the one-gateway branch as
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** it carries its own setter, so a `.map`
46
- * never has to reach back out of the loop:
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
- * <label key={f.key}>
51
- * {f.label}{f.required && " *"}
52
- * {f.type === "select"
53
- * ? <select value={f.value} onChange={e => f.set(e.target.value)}>
54
- * {f.options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
55
- * </select>
56
- * : <input type={f.type} value={f.value} autoComplete={f.autoComplete}
57
- * onChange={e => f.set(e.target.value)} />}
58
- * {f.error && <span role="alert">{f.error}</span>}
59
- * </label>
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
- * `f.set` also accepts the raw event (`onChange={f.set}`) or a `(key, value)`
63
- * pair; the hook's top-level `set(key, value)` is still there for code that
64
- * writes a field outside the map.
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
- ...f,
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
- set: (...args) => set(f.key, newValue(args)),
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
- : null,
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}>−</button>
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}>+</button>
156
- * <button onClick={l.remove}>Remove</button>
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
  * )}