@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.
@@ -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. `labels` (optional) is the store's copy map for the parts
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, labels, children }) {
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 and behavior, headless **the drawer's surface is yours**:
16
- * you render the overlay and the panel, and own their position, size, padding,
17
- * animation and every class on them. What lives here is the part that is the
18
- * same in every store and easy to get wrong:
19
- *
20
- * - `open` + `openCart`/`closeCart`/`toggleCart`.
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 drawer off `open`.
13
+ * layout; the layout then renders the panel off `open`.
36
14
  *
37
- * ⚑ Your panel is the dialog: `role="dialog" aria-modal="true"` plus a name
38
- * (`aria-label`/`aria-labelledby`). The overlay is a click-away surface
39
- * (`aria-hidden`, no tab stop) it is *not* the close control; a named close
40
- * button inside the panel is (`<CartDrawer.Close>`).
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, panelRef, panelId }`.
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, panelId, openOnAdd],
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, useInCheckout } from "./useCheckout";
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).