@base44/app-plugin-commerce 0.2.7 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +4 -4
  2. package/package.json +2 -2
  3. package/scripts/install.js +15 -0
  4. package/skills/commerce/SKILL.md +26 -12
  5. package/skills/commerce/docs/api-admin.md +1 -1
  6. package/skills/commerce/docs/api-storefront.md +12 -12
  7. package/skills/commerce/install/01-install.md +5 -2
  8. package/skills/commerce/install/02-storefront.md +167 -275
  9. package/skills/commerce/install/03-data.md +1 -1
  10. package/skills/commerce/references/catalog-rendering.md +37 -43
  11. package/skills/commerce/references/reviews.md +21 -14
  12. package/skills/commerce/references/store-settings.md +1 -1
  13. package/skills/commerce/references/storefront-verification.md +21 -15
  14. package/src/commerce/storefront/StorefrontProvider.jsx +65 -128
  15. package/src/commerce/storefront/cartUI.jsx +11 -30
  16. package/src/commerce/storefront/index.js +61 -98
  17. package/src/commerce/storefront/pickers.jsx +50 -64
  18. package/src/commerce/storefront/useCartLine.js +23 -130
  19. package/src/commerce/storefront/useCheckout.jsx +50 -43
  20. package/src/commerce/storefront/useOrderReturn.js +17 -7
  21. package/src/commerce/storefront/useProduct.js +41 -97
  22. package/src/commerce/storefront/useProductList.js +14 -22
  23. package/src/commerce/utils/address-spec.js +1 -1
  24. package/src/commerce/utils/images.js +1 -1
  25. package/src/commerce/utils/index.js +9 -9
  26. package/src/commerce/utils/price.js +2 -1
  27. package/src/commerce/utils/specs.js +41 -91
  28. package/src/commerce/utils/totals.js +7 -4
  29. package/src/commerce/storefront/useAddressForm.js +0 -166
  30. package/src/commerce/storefront/usePlaceOrder.js +0 -63
  31. package/src/commerce/storefront/useProductGallery.js +0 -78
  32. package/src/commerce/storefront/useProductPrice.js +0 -58
  33. package/src/commerce/storefront/useProductReviews.js +0 -242
  34. package/src/commerce/storefront/useStorefrontSeo.js +0 -204
  35. package/src/commerce/storefront/useTotalsLines.js +0 -109
  36. package/src/commerce/storefront/useUpsell.js +0 -90
@@ -5,39 +5,20 @@ import { useLocation } from "react-router-dom";
5
5
  * Cart drawer/panel state, headless: `open` plus the handlers to change it,
6
6
  * and the two behaviors every drawer needs but a hand-written one forgets —
7
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.
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.
11
11
  *
12
12
  * Mount `<CartUIProvider>` once, inside `<StorefrontProvider>`, around the
13
- * layout. Then the layout renders off `open`:
13
+ * layout; the layout then renders the panel off `open`.
14
14
  *
15
- * function StoreLayout() {
16
- * const ui = useCartUI();
17
- * const { itemCount } = useCart();
18
- * return (<>
19
- * <header>… <button type="button" onClick={ui.toggleCart}
20
- * aria-expanded={ui.open}>Bag ({itemCount})</button></header>
21
- * <Outlet />
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
- * </>)}
29
- * </>);
30
- * }
31
- *
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.
38
- *
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.
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
23
  * `useCartUI()` → `{ open, openCart, closeCart, toggleCart }`.
43
24
  * `useCartUIOptional()` returns null instead of throwing (how `useAddToCart`
@@ -1,97 +1,56 @@
1
1
  /**
2
2
  * Storefront React layer — **headless**: hooks and render-prop components that
3
- * own the store's logic and hand you the data; they render nothing and carry
4
- * no styling. Every element, class and word of copy in the storefront is
5
- * written by you, against these APIs. Ships with the Base44 Commerce Template
6
- * next to the framework-free `@/commerce/utils` (which it builds on); needs
7
- * React and nothing else.
3
+ * own the store's logic and hand you the data. They render nothing, carry no
4
+ * styling, and ship no customer-facing copy — every element, class and word in
5
+ * the storefront is written by you, against these APIs. Ships with the Base44
6
+ * Commerce Plugin next to the framework-free `@/commerce/utils` (which it
7
+ * builds on); needs React and nothing else.
8
8
  *
9
9
  * The split: **logic is premade, UI never is.** Checkout repricing, variant
10
- * resolution, cart state, review policies, order-return verification — done
11
- * here, and hand-rolling any of it is where storefront bugs cluster. What a
12
- * checkout or a product page *looks like* is the store's identity, and no two
13
- * stores should share it — so nothing here emits markup. Each hook returns a
14
- * complete view-model (statuses to branch on, ready-to-map arrays, handlers,
15
- * error objects), and each doc comment states the render rules that keep the
16
- * store correct (e.g. an unbuyable variant option renders *disabled, not
17
- * hidden*; a receipt page must render `paymentInstructions`).
10
+ * resolution, cart state, order-return verification — done here, and
11
+ * hand-rolling any of it is where storefront bugs cluster. What a checkout or a
12
+ * product page *looks like* is the store's identity, and no two stores should
13
+ * share it — so nothing here emits markup, and every state arrives as a **code**
14
+ * (`state`, `status`, `hint.code`, `blockers`) that you write the words for.
18
15
  *
19
- * Setup (once, above every storefront route on a pathless layout route,
20
- * wrapping the layout that renders <Outlet/>; as a child of <Routes> React
21
- * Router throws "is not a <Route> component"):
16
+ * `StorefrontProvider` mounts once, above every storefront route (see its own
17
+ * doc comment getting the mounting wrong is the one setup error worth
18
+ * knowing). Each hook's doc comment is its contract; the map:
22
19
  *
23
- * import { StorefrontProvider } from "@/commerce/storefront";
24
- * import { base44 } from "@/api/base44Client";
25
- * <Route element={<StorefrontProvider base44={base44}><StoreLayout /></StorefrontProvider>}>
26
- * <Route path="/" element={<Home />} /> …
27
- * </Route>
28
- * // no shared layout? <StorefrontProvider …> <Routes>…</Routes> </StorefrontProvider>
29
- *
30
- * 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.
34
- *
35
- * ## Hooks
36
- * - `useStorefront` / `useStoreInfo` / `useFormatMoney` / `useMoney` — the
20
+ * - `useStorefront` / `useStoreInfo` / `useFormatMoney` / `useCountries` — the
37
21
  * shared client, cached store info (the ONLY source of payment gateways,
38
- * currency and countries), money in the store's currency.
39
- * - `useProductList` / `useCategories` / `useRibbons` — a listing with paging
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)`
47
- * (from `@/commerce/utils`, re-exported here) turns the resolved view into
48
- * a render-ready model for the selector you write; `useProductSpecs` adds
49
- * normalized `pick`/`get` lookup over `productSpecs`.
50
- * - `useProductReviews` — the review list and the submit form, with the store's
51
- * policy as a prop and field errors matching the server's codes.
52
- * - `useCart` / `useCartLine` / `useCoupon` — the shared cart (branch on
53
- * `status`, render `lines` and `notices` — money pre-formatted per line),
54
- * quantity steppers that clamp and recover, and the coupon field a store
55
- * with coupons must have.
56
- * - `useCartUI` + `<CartUIProvider>` — the cart drawer's non-visual machinery:
57
- * `open` + open/close/toggle handlers, Esc, close-on-navigate, open-on-add;
58
- * render the drawer conditionally off `open`.
22
+ * currency and countries), money in the store's currency, and the country
23
+ * list as an array that is never null.
24
+ * - `useProductList` / `useCategories` / `useRibbons` — a listing with paging,
25
+ * the whole server-side filter surface, and failure as a visible state.
26
+ * - `useProduct` / `useAddToCart` the product page: fetch + variant selection
27
+ * + quantity, race-safe; the buy box as `state`/`disabled`/`addToCart`.
28
+ * - `useCart` / `useCartLine` / `CartLine` — the shared cart (branch on
29
+ * `status`, map `cart.items` into your own rows) plus quantity steppers that
30
+ * clamp, coalesce rapid clicks and roll back on rejection.
31
+ * - `useCartUI` + `<CartUIProvider>` a cart drawer's non-visual state.
59
32
  * - `useCheckout` / `CheckoutProvider` / `useCheckoutContext` — the guided
60
- * checkout: address state with automatic debounced shipping/tax
61
- * recalculation, shipping and payment choice, a `canPlaceOrder` gate with
62
- * named blockers, `placeOrder` with both navigations handled (online
63
- * provider redirect, manual `/order-received`).
64
- * - `usePlaceOrder` the gate as one surface: `placeOrder`, `disabled`,
65
- * `label`, `stage` (guards the just-placed-order frames), `blockers` in
66
- * words.
67
- * - `useAddressForm` / `useCountries` / `useTotalsLines` /
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
- * - `useUpsell` — one product offered beside another surface: fetch-or-row,
73
- * already-in-cart matched by id, one-click add.
74
- * - `useOrderReturn` — the mandatory `/order-received` page in one hook:
75
- * status, order, `lines`, `paymentLink`, `paymentInstructions`, noindex.
76
- * - `useStorefrontSeo` + `productSeo` / `collectionSeo` / `orderSeo` — titles,
77
- * meta and product structured data; receipts are `noindex`.
33
+ * checkout: automatic shipping/tax recalculation, shipping and payment
34
+ * choice, a `canPlaceOrder` gate with named blockers, `placeOrder`.
35
+ * - `useOrderReturn` the mandatory `/order-received` page in one hook.
36
+ * - `ShippingMethodPicker` / `PaymentMethodPicker` — render-prop components for
37
+ * the two checkout choices that are store data, never hardcoded.
78
38
  *
79
- * ## Render-prop components (headless children is a function, no markup ships)
80
- * - `ShippingMethodPicker` / `PaymentMethodPicker` the two checkout choices
81
- * that are store data, never hardcoded; options arrive decorated with
82
- * `selected`/`select()`/`costLabel` and a single `hint` message.
83
- * - `CartLine` per-line `useCartLine` binding for your cart rows, so a
84
- * `lines.map(...)` never calls a hook in a loop.
39
+ * Re-exported from `@/commerce/utils` so one import line covers a page:
40
+ * `variantAxes` (axes options with selected/disabled/stock derived),
41
+ * `productPrice` (the from-price and incomplete-selection range rules),
42
+ * `productImages` + `imageIndex` (an image's position in that list — how a
43
+ * gallery follows the variant selection without owning a second copy of it),
44
+ * `productSpecs` + `findSpec` (meta keys are free text, so featuring a
45
+ * particular spec needs a tolerant lookup, not an equality test),
46
+ * `attributesLabel`, `cartTotalsLines` / `orderTotalsLines`,
47
+ * `addressFieldSpec` (the checkout's field list, including the state/province
48
+ * field that silently mis-prices US/CA/AU orders when it is left out), and
49
+ * `storefrontErrorCode` / `storefrontErrorMessage` for the calls you make
50
+ * yourself — every rejection from the client carries a code worth branching on.
85
51
  *
86
- * ## Helpers re-exported from `@/commerce/utils`
87
- * - `variantAxes(view, pick)` axes options with selected/disabled/stock
88
- * state derived, for the variant selector you write.
89
- * - `productSpecs(product)` — `meta_data` → descriptive rows carrying an
90
- * inferred `type` (`numeric` with `number`/`unit` split out, `duration`,
91
- * `location`, `list` with `items`, `text`), so a weight can be a figure and
92
- * a composition bars instead of every modifier being one grey table row.
93
- * - `productImages(product)` — images normalized to `{src, name, alt}` and
94
- * de-duplicated; `[]` means render your placeholder.
52
+ * The catalog and product surfaces are where the design freedom lives: these
53
+ * hooks hand you resolved data, and the rendering is entirely yours.
95
54
  */
96
55
  export {
97
56
  StorefrontProvider,
@@ -99,10 +58,10 @@ export {
99
58
  useStorefront,
100
59
  useStoreInfo,
101
60
  useFormatMoney,
61
+ useCountries,
102
62
  useCart,
103
63
  } from "./StorefrontProvider";
104
64
  export { useCheckout, CheckoutProvider, useCheckoutContext } from "./useCheckout";
105
- export { usePlaceOrder } from "./usePlaceOrder";
106
65
  export { useOrderReturn, orderReceivedUrl } from "./useOrderReturn";
107
66
  export { ShippingMethodPicker, PaymentMethodPicker } from "./pickers";
108
67
  export { CartUIProvider, useCartUI } from "./cartUI";
@@ -115,19 +74,23 @@ export {
115
74
 
116
75
  // ── catalog ────────────────────────────────────────────────────────────────
117
76
  export { useProductList, useCategories, useRibbons } from "./useProductList";
118
- export { useProduct, useAddToCart, useProductSpecs } from "./useProduct";
119
- export { useUpsell } from "./useUpsell";
120
- export { useProductPrice, useMoney } from "./useProductPrice";
121
- export { useProductGallery } from "./useProductGallery";
122
- export { useProductReviews } from "./useProductReviews";
77
+ export { useProduct, useAddToCart } from "./useProduct";
123
78
 
124
- // ── cart & checkout ────────────────────────────────────────────────────────
125
- export { useCartLine, useCoupon, CartLine } from "./useCartLine";
126
- export { useAddressForm, useCountries } from "./useAddressForm";
127
- export { useTotalsLines, useCheckoutBlockers, blockerMessage } from "./useTotalsLines";
128
-
129
- // ── SEO ────────────────────────────────────────────────────────────────────
130
- export { useStorefrontSeo, productSeo, collectionSeo, orderSeo } from "./useStorefrontSeo";
79
+ // ── cart ───────────────────────────────────────────────────────────────────
80
+ export { useCartLine, CartLine } from "./useCartLine";
131
81
 
132
82
  // ── view-model helpers (framework-free, from @/commerce/utils) ─────────────
133
- export { variantAxes, productSpecs, productImages } from "@/commerce/utils";
83
+ export {
84
+ variantAxes,
85
+ productPrice,
86
+ productImages,
87
+ imageIndex,
88
+ productSpecs,
89
+ findSpec,
90
+ attributesLabel,
91
+ cartTotalsLines,
92
+ orderTotalsLines,
93
+ addressFieldSpec,
94
+ storefrontErrorCode,
95
+ storefrontErrorMessage,
96
+ } from "@/commerce/utils";
@@ -9,10 +9,24 @@ import { useFormatMoney } from "./StorefrontProvider";
9
9
  * can't skip a `shipping_status` state or invent a payment method.
10
10
  *
11
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.
12
+ * (boolean), `select()` (pick it) and money formatted in the store's currency
13
+ * (`costLabel`) — so the child is markup + classes and nothing else.
14
+ *
15
+ * `hint` is the one thing the picker needs said right now, as a **code**, never
16
+ * as copy: `{ code, severity: "info"|"error", serverMessage }`. The words are
17
+ * the store's — write one line per code (there are three) in the store's own
18
+ * voice. `serverMessage` is set only when the backend explained the situation
19
+ * itself (an undeliverable address); it is more specific than anything you can
20
+ * write, so prefer it when present:
21
+ *
22
+ * const SHIPPING_HINTS = { // your words, once, near the checkout
23
+ * missing_address: "Enter your address to see delivery options.",
24
+ * none_available: "We can't deliver to that address yet.",
25
+ * syncing: "Updating delivery options…",
26
+ * };
27
+ * {hint && <p role={hint.severity === "error" ? "alert" : "status"}>
28
+ * {hint.serverMessage ?? SHIPPING_HINTS[hint.code]}
29
+ * </p>}
16
30
  *
17
31
  * Both read the nearest <CheckoutProvider>, or take an explicit `checkout`
18
32
  * prop when you called `useCheckout` yourself.
@@ -27,40 +41,25 @@ function resolveCheckout(name, prop, ctx) {
27
41
  }
28
42
 
29
43
  /**
30
- * Shipping options. Renders null for a virtual cart (`not_needed`) and while
31
- * the cart is loading; otherwise calls `children` with:
32
- *
33
- * {
34
- * status, // "missing_address" | "choice_required" | "chosen"
35
- * // | "auto_selected" | "none_available"
36
- * methods, // [{ id, title, cost, costLabel, selected,
37
- * // select }] — what this address is offered
38
- * chosen, // the chosen/auto-selected entry (with costLabel), or null
39
- * selected, // alias of `chosen` — the same name the payment picker uses
40
- * choose, // (id) => Promise — call with a method's id on pick
41
- * mustChoose, // status === "choice_required" → render methods as a picker
42
- * single, // exactly one method offered — already chosen; skip the
43
- * // picker but still show `chosen.title` and `chosen.costLabel`
44
- * syncing, // an address edit is being repriced — show a subtle busy state
45
- * hint, // { code, message, severity: "info"|"error" } | null — the
46
- * // one notice to show now (address missing / not deliverable
47
- * // / repricing); render `hint.message`, done
48
- * addressError, // { code, message } | null — also surfaces on the address
49
- * } // form's country field
44
+ * Shipping options. Renders null for a virtual cart (`not_needed`) and while the
45
+ * cart is loading; otherwise calls `children` with:
50
46
  *
51
- * 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} className="…">
56
- * <input type="radio" name="shipping-method" checked={m.selected} onChange={m.select}
57
- * className="" /> {m.title} <span>{m.costLabel}</span>
58
- * </label>
59
- * ))}
60
- * {!mustChoose && chosen && <p>{chosen.title} {chosen.costLabel}</p>}
47
+ * status "missing_address" | "choice_required" | "chosen"
48
+ * | "auto_selected" | "none_available"
49
+ * methods [{ id, title, cost, costLabel, selected, select }]
50
+ * chosen the chosen/auto-selected entry, or null
51
+ * selected alias of `chosen` (the name the payment picker uses)
52
+ * choose (id) => Promise
53
+ * mustChoose status === "choice_required" render methods as a picker
54
+ * single exactly one method offered — already chosen
55
+ * syncing an address edit is being repriced
56
+ * hint { code, severity, serverMessage } | null (see above)
57
+ * addressError { code, message } | null — the server's own words
61
58
  *
62
59
  * `single` and `mustChoose` are never both true, and `single` guarantees
63
- * `chosen` — a single option still *shows* what it is, never a picker of one.
60
+ * `chosen` — a single option still *shows* what it is (title + `costLabel`),
61
+ * never a picker of one. Never render `cart.chosen_shipping_method` directly:
62
+ * it is the rate's id, which is why `chosen` is handed to you resolved.
64
63
  */
65
64
  export function ShippingMethodPicker({ checkout: checkoutProp, children }) {
66
65
  const checkout = resolveCheckout("ShippingMethodPicker", checkoutProp, useCheckoutContextOptional());
@@ -84,11 +83,11 @@ export function ShippingMethodPicker({ checkout: checkoutProp, children }) {
84
83
  };
85
84
  const hint =
86
85
  status === "missing_address"
87
- ? { code: "missing_address", severity: "info", message: "Delivery options appear once your address is entered." }
86
+ ? { code: "missing_address", severity: "info", serverMessage: null }
88
87
  : status === "none_available" || addressError?.code === "shipping_not_available"
89
- ? { code: "none_available", severity: "error", message: addressError?.message ?? "We don't deliver to that address yet." }
88
+ ? { code: "none_available", severity: "error", serverMessage: addressError?.message ?? null }
90
89
  : syncing
91
- ? { code: "syncing", severity: "info", message: "Updating delivery options…" }
90
+ ? { code: "syncing", severity: "info", serverMessage: null }
92
91
  : null;
93
92
  const decoratedChosen = decorate(chosen) ?? null;
94
93
  return children({
@@ -106,35 +105,22 @@ export function ShippingMethodPicker({ checkout: checkoutProp, children }) {
106
105
  }
107
106
 
108
107
  /**
109
- * Payment methods — every gateway the admin has ENABLED, from store info
110
- * (their only source). Renders null while store info loads; otherwise calls
111
- * `children` with:
112
- *
113
- * {
114
- * gateways, // [{ slug, title, description, online, selected,
115
- * // select }] — admin-owned data, decorated
116
- * value, // the selected slug ("" while none)
117
- * select, // (slug) => void
118
- * selected, // the selected gateway entry, or null
119
- * single, // exactly one gateway — already selected; skip the picker but
120
- * // still show its title so the customer knows how they pay
121
- * hint, // { code, message, severity } | null — set when there are no
122
- * } // gateways: checkout is unavailable, say so
123
- *
124
- * The child's whole job:
108
+ * Payment methods — every gateway the admin has ENABLED, from store info (their
109
+ * only source). Renders null while store info loads; otherwise calls `children`
110
+ * with:
125
111
  *
126
- * {hint && <p role="alert">{hint.message}</p>}
127
- * {!single && gateways.map(g => (
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>
131
- * </label>
132
- * ))}
133
- * {single && selected && <p>{selected.title} — {selected.description}</p>}
112
+ * gateways [{ slug, title, description, online, selected, select }]
113
+ * value the selected slug ("" while none)
114
+ * select (slug) => void
115
+ * selected the selected gateway entry, or null
116
+ * single exactly one gateway — already selected
117
+ * hint { code: "none_available", severity, serverMessage } | null —
118
+ * no gateways at all: say checkout is unavailable, in your words
134
119
  *
135
120
  * `single` guarantees `value` and `selected` — never render the one-gateway
136
121
  * branch as "nothing selected yet" — and both re-resolve when store info
137
- * changes. Titles and descriptions are the admin's copy: render them, don't
122
+ * changes. A default-seeded store offers `offline` only, so never hardcode a
123
+ * card option. Titles and descriptions are the admin's copy: render them, don't
138
124
  * invent your own.
139
125
  */
140
126
  export function PaymentMethodPicker({ checkout: checkoutProp, children }) {
@@ -153,7 +139,7 @@ export function PaymentMethodPicker({ checkout: checkoutProp, children }) {
153
139
  }));
154
140
  const hint =
155
141
  gateways.length === 0
156
- ? { code: "none_available", severity: "error", message: "No payment method is available right now." }
142
+ ? { code: "none_available", severity: "error", serverMessage: null }
157
143
  : null;
158
144
  return children({
159
145
  gateways: decorated,
@@ -20,39 +20,23 @@ import { useCart } from "./StorefrontProvider";
20
20
  * request per settle instead of one per click; and `sold_individually` is
21
21
  * respected, so a one-per-customer product has no working "+".
22
22
  *
23
- * ## When `pending` settles the exact window
23
+ * **When `pending` settles.** It is this row's flag, not the cart's, and not
24
+ * true for the whole gesture: a click only sets the optimistic quantity and
25
+ * (re)starts the 250ms timer, so a burst of clicks sends **one** request;
26
+ * `pending` goes true when that request leaves (`remove()` skips the wait); it
27
+ * returns to false only after the server's new cart view has been published
28
+ * through the provider's queue. So `pending === false` with `error === null`
29
+ * means this row, the cart's totals and any badge are all settled — it is the
30
+ * only mutation-settled signal (`useCart().status` never returns to
31
+ * `"loading"` for a mutation), and what a script driving the page waits on.
32
+ * Scope the busy state to this row: greying the whole cart for a 250ms step
33
+ * reads as a page-wide stall. On failure the quantity rolls back to what the
34
+ * server still holds and `error` is `{ code, message }`.
24
35
  *
25
- * `pending` is this line's own flag, not the cart's, and it is **not** true for
26
- * the whole gesture:
36
+ * It owns the quantity only. The row's own content name, `attributesLabel`,
37
+ * image, money — you render from the `cart.items[n]` you passed in.
27
38
  *
28
- * 1. **Click `pending` stays `false`.** `increase`/`decrease` only set the
29
- * optimistic `quantity` and (re)start a `debounceMs` (250ms) timer. Nothing
30
- * is in flight yet, and a further click restarts the timer, so a burst of
31
- * clicks sends **one** request for the final number.
32
- * 2. **Debounce elapses → `pending` becomes `true`** and the request goes out.
33
- * `remove()` skips this step: it cancels the timer and goes `pending`
34
- * immediately.
35
- * 3. **`pending` returns to `false` only after the server's new cart view has
36
- * been published to the provider** — the awaited mutation resolves through
37
- * the provider's serialized queue, which sets the shared cart state before
38
- * the await returns. So `pending === false` with `error === null` means this
39
- * row's quantity, the cart's totals and any dependent badge are settled, not
40
- * merely that the request finished.
41
- *
42
- * Two consequences worth designing for. **Disable and mark only this row**
43
- * (`disabled={!l.canIncrease || l.pending}`) — `pending` says nothing about the
44
- * other lines, and greying the whole cart because one stepper is busy makes a
45
- * 250ms update look like a page-wide stall. And **`pending` is the only
46
- * mutation-settled signal**: `useCart().status` never returns to `"loading"`
47
- * for a mutation (see its doc comment), so a caller that needs to know an
48
- * update landed — a script driving the page, a queued follow-up action — waits
49
- * on this flag, per row, and not on cart `status`.
50
- *
51
- * On failure `pending` returns to `false`, the optimistic quantity rolls back
52
- * to what the server still holds, and `error` is `{ code, message }`.
53
- *
54
- * @param {object} line a decorated line from `useCart().lines` (a raw
55
- * `cart.items[n]` works too — it just has no `maxQuantity` hint)
39
+ * @param {object} line one `cart.items[n]` from `useCart()`
56
40
  * @param {{debounceMs?: number}} [options]
57
41
  */
58
42
  export function useCartLine(line, { debounceMs = 250 } = {}) {
@@ -133,10 +117,6 @@ export function useCartLine(line, { debounceMs = 250 } = {}) {
133
117
  maxQuantity,
134
118
  atMax: quantity >= maxQuantity,
135
119
  atMin: quantity <= 1,
136
- attributesLabel: line?.attributesLabel ?? "",
137
- image: line?.image ?? null,
138
- totalLabel: line?.totalLabel ?? "",
139
- unitPriceLabel: line?.unitPriceLabel ?? "",
140
120
  };
141
121
  }
142
122
 
@@ -144,29 +124,18 @@ export function useCartLine(line, { debounceMs = 250 } = {}) {
144
124
  * CartLine — headless per-line binding for the rows of a cart you render
145
125
  * yourself. It renders **nothing**: the render function you pass as `children`
146
126
  * receives the `useCartLine` controls for that line and returns your markup.
147
- * It exists so a `lines.map(...)` doesn't tempt a hook call inside a loop:
127
+ * It exists so an `items.map(...)` doesn't tempt a hook call inside a loop:
148
128
  *
149
- * const { lines } = useCart();
150
- * {lines.map(line => (
151
- * <CartLine key={line.item_key} line={line}>
152
- * {(l) => (
153
- * <li aria-busy={l.pending}>
154
- * {line.name} {line.attributesLabel}
155
- * <button onClick={l.decrease} disabled={!l.canDecrease || l.pending}
156
- * aria-label={`Decrease quantity of ${line.name}`}>−</button>
157
- * {l.quantity}
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}
162
- * {l.error && <p role="alert">{l.error.message}</p>}
163
- * </li>
164
- * )}
129
+ * {cart.items.map(item => (
130
+ * <CartLine key={item.item_key} line={item}>
131
+ * {(l) => <li aria-busy={l.pending}>…your row, using item and l…</li>}
165
132
  * </CartLine>
166
133
  * ))}
167
134
  *
168
- * Equivalent to extracting your own row component that calls `useCartLine` —
169
- * use whichever reads better in your page.
135
+ * Give each row's controls their own accessible name (`Remove ${item.name}`):
136
+ * three buttons all named "Remove" are ambiguous to a screen reader and to a
137
+ * script driving the page. Equivalent to extracting your own row component that
138
+ * calls `useCartLine` — use whichever reads better in your page.
170
139
  *
171
140
  * @param {{line: object, options?: {debounceMs?: number},
172
141
  * children: (controls: object) => React.ReactNode}} props
@@ -179,79 +148,3 @@ export function CartLine({ line, options, children }) {
179
148
  if (!line) return null;
180
149
  return children(controls);
181
150
  }
182
-
183
- /**
184
- * useCoupon — the coupon field. Small, and the difference between a store that
185
- * can honour its own discounts and one that cannot.
186
- *
187
- * const c = useCoupon();
188
- * <input value={c.code} onChange={(e) => c.setCode(e.target.value)} />
189
- * <button onClick={c.apply} disabled={c.applying}>Apply</button>
190
- * {c.error && <p role="alert">{c.error.message}</p>}
191
- * {c.applied.map(a => <Chip key={a.code} onRemove={() => c.remove(a.code)} …/>)}
192
- *
193
- * Coupons are admin-only data — a storefront cannot list codes, so a seeded
194
- * code is reachable **only** through a field the customer types it into. If the
195
- * store has any coupons, this field must exist somewhere in the cart or the
196
- * checkout, or those codes can never be redeemed.
197
- *
198
- * An invalid, expired or ineligible code is **expected flow**: `apply()`
199
- * resolves `{ ok: false, error }` and never throws — render `error.message`
200
- * inline next to the field.
201
- */
202
- export function useCoupon() {
203
- const { cart, applyCoupon, removeCoupon } = useCart();
204
- const [code, setCode] = useState("");
205
- const [applying, setApplying] = useState(false);
206
- const [error, setError] = useState(null);
207
-
208
- const apply = useCallback(
209
- async (explicit) => {
210
- const value = String(explicit ?? code ?? "").trim();
211
- if (!value) return { ok: false, error: { code: "empty", message: "Enter a code." } };
212
- setApplying(true);
213
- setError(null);
214
- const res = await applyCoupon(value);
215
- setApplying(false);
216
- if (res.ok) {
217
- setCode("");
218
- return { ok: true, cart: res.cart };
219
- }
220
- const err = { code: res.code ?? "coupon_invalid", message: res.message };
221
- setError(err);
222
- return { ok: false, error: err };
223
- },
224
- [code, applyCoupon],
225
- );
226
-
227
- const remove = useCallback(
228
- async (codeOrEntry) => {
229
- const value = typeof codeOrEntry === "string" ? codeOrEntry : codeOrEntry?.code;
230
- setError(null);
231
- try {
232
- await removeCoupon(value);
233
- return { ok: true };
234
- } catch (e) {
235
- const err = { code: storefrontErrorCode(e) ?? "error", message: storefrontErrorMessage(e) };
236
- setError(err);
237
- return { ok: false, error: err };
238
- }
239
- },
240
- [removeCoupon],
241
- );
242
-
243
- return {
244
- code,
245
- setCode,
246
- apply,
247
- remove,
248
- applied: (cart?.coupons ?? []).map((c) => ({
249
- code: c.code,
250
- discount: c.discount ?? 0,
251
- freeShipping: Boolean(c.free_shipping),
252
- })),
253
- applying,
254
- error,
255
- discountTotal: cart?.totals?.discount_total ?? 0,
256
- };
257
- }