@base44/app-plugin-commerce 0.2.6 → 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 +31 -13
  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 +179 -220
  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 +26 -117
  16. package/src/commerce/storefront/index.js +61 -98
  17. package/src/commerce/storefront/pickers.jsx +53 -81
  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 +54 -119
  22. package/src/commerce/storefront/useProductList.js +15 -28
  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 -175
  30. package/src/commerce/storefront/usePlaceOrder.js +0 -55
  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
@@ -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
- }
@@ -39,50 +39,44 @@ function resolvePaymentMethod(gateways, picked) {
39
39
  * checkout must get right, so the page you build is only markup around it:
40
40
  *
41
41
  * - **Address → shipping/tax recalculation, automatic.** Edit the billing (or
42
- * separate shipping) address through `updateBilling`/`updateShipping`; the
43
- * moment the address is complete enough to price (default: country + city
44
- * see `isShippingAddressComplete`), the hook debounces and calls
45
- * `set-shipping-address`, which recalculates every shipping option, its
46
- * cost and the taxes. Half-typed addresses are never sent, an unchanged
47
- * address is never re-sent, and an address the store doesn't ship to
48
- * surfaces as `addressError`show it on the address fields.
49
- * - **Shipping choice.** `shippingStatus` mirrors the cart's state:
50
- * `auto_selected` (one option, already applied — just display
51
- * `chosenShippingMethod`), `chosen`, `choice_required` (render
52
- * `shippingMethods` and call `chooseShippingMethod(id)`), `missing_address`
53
- * (collect the address), `not_needed` (virtual cart — render nothing).
54
- * `singleShippingMethod` flags the one-option case, and `chosenShippingMethod`
55
- * is filled for it a single option is never left unselected.
42
+ * separate shipping) address through `updateBilling`/`updateShipping`; once
43
+ * the address is complete enough to price (default: country + city, see
44
+ * `isShippingAddressComplete`), the hook debounces and calls
45
+ * `set-shipping-address`, recalculating every option, its cost and the taxes.
46
+ * Half-typed addresses are never sent, an unchanged address is never re-sent,
47
+ * and an address the store doesn't ship to surfaces as `addressError` (the
48
+ * server's own words)render it on the address fields.
49
+ * - **Shipping choice.** `shippingStatus` mirrors the cart: `auto_selected`
50
+ * (one option, already applied — display `chosenShippingMethod`), `chosen`,
51
+ * `choice_required` (render `shippingMethods`, call
52
+ * `chooseShippingMethod(id)`), `missing_address`, `none_available`,
53
+ * `not_needed` (virtual cart — render nothing). `singleShippingMethod` flags
54
+ * the one-option case and `chosenShippingMethod` is filled for it, so a single
55
+ * option is never left unselected and never rendered as a picker of one.
56
56
  * - **Payment choice.** `paymentMethods` come from store info (their ONLY
57
- * source); a store with exactly one enabled gateway gets it selected
58
- * (`singlePaymentMethod`, with `selectedGateway` filled) from the first
59
- * render that has store info. The selection is derived from the current
60
- * gateway list, not remembered: when store info changes, a gateway that is
61
- * no longer enabled is dropped and a list that is down to one gateway
62
- * selects it — the customer's own pick survives as long as it stays enabled.
63
- * - **The gate.** `canPlaceOrder` + `blockers` say exactly what still stands
64
- * between the customer and the order drive the button's disabled state
65
- * and the "what's missing" hints from them instead of re-deriving.
66
- * - **placeOrder.** Sends the order, clears the shared cart, and navigates:
67
- * an online gateway redirects to the provider's payment page
68
- * (`redirectToPayment`), everything else lands on the order-received page
69
- * (`orderReceivedPath`, default `/order-received`) which is where a manual
70
- * order's payment instructions are rendered, so the offline default confirms
71
- * properly with no extra wiring. Resolves to `{ ok: true, result }` or
72
- * `{ ok: false, error }`. Pass `orderReceivedPath: null` to handle the
73
- * result yourself (a manual-gateway result carries
57
+ * source `cart.payment_gateways` is always undefined). One enabled gateway
58
+ * is selected from the first render that has store info. The selection is
59
+ * derived from the current list, not remembered: a gateway the admin disables
60
+ * drops out, and if that leaves one, it takes over.
61
+ * - **The gate.** `canPlaceOrder` + `blockers` say what still stands between the
62
+ * customer and the order drive the button's disabled state and the
63
+ * what's-missing lines from them instead of re-deriving.
64
+ * - **placeOrder.** Sends the order, clears the shared cart, and navigates: an
65
+ * online gateway redirects to the provider (`redirectToPayment`), everything
66
+ * else lands on `orderReceivedPath` (default `/order-received`) which is
67
+ * where a manual order's payment instructions are rendered, so the offline
68
+ * default confirms properly with no extra wiring. Resolves `{ ok: true,
69
+ * result }` or `{ ok: false, error }`. Pass `orderReceivedPath: null` to
70
+ * handle the result yourself (a manual result carries
74
71
  * `result.payment_instructions`).
75
72
  *
76
- * Both navigations are **full page loads** (`window.location.assign`), not
77
- * router transitions: the provider hop has to leave the app, and the
78
- * order-received page is built to boot from the URL alone (`order_id` +
79
- * `order_key`), so a reload there is correct and shareable. Consequences
80
- * worth knowing: React state does not survive it, and a browser script
81
- * driving checkout loses its page context at this point — the order is
82
- * still placed, so verify by navigating fresh to
83
- * `orderReceivedUrl(result)`. For a client-side transition instead, pass
84
- * `orderReceivedPath: null` and `navigate(orderReceivedUrl(result))`
85
- * yourself.
73
+ * Both navigations are **full page loads** (`window.location.assign`): the
74
+ * provider hop leaves the app, and the receipt boots from the URL alone
75
+ * (`order_id` + `order_key`), so a reload there is correct and shareable.
76
+ * React state does not survive it, and a browser script driving checkout
77
+ * loses its page context here the order *is* placed, so verify by
78
+ * navigating fresh to `orderReceivedUrl(result)`. For a client-side
79
+ * transition, pass `orderReceivedPath: null` and navigate yourself.
86
80
  *
87
81
  * Blocker codes, in the order checked: `cart_loading`, `empty_cart`,
88
82
  * `billing_incomplete`, `shipping_address_incomplete`, `shipping_recalculating`,
@@ -93,7 +87,14 @@ function resolvePaymentMethod(gateways, picked) {
93
87
  * accepted; the navigation away is already in flight). **Guard on it before the
94
88
  * cart's empty branch** — `placeOrder` clears the cart, so a page that checks
95
89
  * only `cart.status` repaints "your bag is empty" over a just-placed order for
96
- * the frames before the browser leaves. `usePlaceOrder` packages this gate.
90
+ * the frames before the browser leaves.
91
+ *
92
+ * The button is `disabled={!canPlaceOrder || placing}`, and `placeOrder` works
93
+ * as an `onClick` handler directly (a click event is not read as order fields).
94
+ * A disabled button must still say why: render one line per `blockers` code, in
95
+ * the store's own words — the silent disabled button is the most common
96
+ * checkout dead end. The codes are listed above; each maps to one thing the
97
+ * customer can fix.
97
98
  *
98
99
  * Options: `debounceMs` (600), `addressComplete` (predicate overriding the
99
100
  * country+city rule), `requiredBillingFields`, `redirectToPayment` (true),
@@ -214,6 +215,12 @@ export function useCheckout(options = {}) {
214
215
  const placeOrder = useCallback(
215
216
  async (extra = {}) => {
216
217
  if (placing) return { ok: false, error: { code: "placing", message: "Order already being placed." } };
218
+ // Safe as `onClick={checkout.placeOrder}`: a DOM/React event is not
219
+ // `extra`. Spreading one into the order payload would send a circular
220
+ // SyntheticEvent as order fields — an explicit object still forwards.
221
+ const isEvent =
222
+ extra && typeof extra === "object" && ("nativeEvent" in extra || "target" in extra);
223
+ const fields = isEvent ? {} : extra;
217
224
  setPlacing(true);
218
225
  setOrderError(null);
219
226
  try {
@@ -221,7 +228,7 @@ export function useCheckout(options = {}) {
221
228
  payment_method: paymentMethod,
222
229
  billing,
223
230
  ...(shipToDifferent ? { shipping } : {}),
224
- ...extra, // customer_note, success_url/cancel_url overrides, …
231
+ ...fields, // customer_note, success_url/cancel_url overrides, …
225
232
  });
226
233
  // `submitted` flips BEFORE the cart clears, in the same commit — the
227
234
  // page's `stage === "submitted"` guard is what stands between a placed
@@ -1,7 +1,6 @@
1
1
  import { useCallback, useEffect, useState } from "react";
2
2
  import { orderLines, storefrontErrorCode, storefrontErrorMessage } from "@/commerce/utils";
3
3
  import { useFormatMoney, useStorefrontState } from "./StorefrontProvider";
4
- import { orderSeo, useStorefrontSeo } from "./useStorefrontSeo";
5
4
 
6
5
  /**
7
6
  * useOrderReturn — the whole `/order-received` page in one hook. Mount the
@@ -19,11 +18,12 @@ import { orderSeo, useStorefrontSeo } from "./useStorefrontSeo";
19
18
  * // "cancelled" → payment was cancelled — offer paymentLink.url or support
20
19
  * // "error" → render error.message with a retry via reload()
21
20
  *
22
- * `lines` are the order's items in the decorated cart-line shape —
23
- * `attributesLabel`, `image` as `{src, alt}|null`, `totalLabel` pre-formatted —
24
- * so the same row markup renders the bag and the confirmation; totals come
25
- * from `useTotalsLines(order)`. The page is marked `noindex` automatically — a
26
- * receipt carrying an order key must not rank (`seo: false` opts out).
21
+ * `lines` are the order's items normalized for rendering `attributesLabel`,
22
+ * `image` as `{src, alt}|null`, `totalLabel` pre-formatted — because a receipt
23
+ * reusing cart-row markup otherwise reads `item.image` as an object and paints
24
+ * a broken image. Totals: `orderTotalsLines(order, { formatMoney })` from
25
+ * `@/commerce/utils`. The page is marked `noindex` automatically a receipt
26
+ * carrying an order key must not rank (`seo: false` opts out).
27
27
  *
28
28
  * It reads `order_id`/`order_key`/`payment` from the URL itself and verifies
29
29
  * with the payment provider server-side — safe and idempotent on every visit.
@@ -61,7 +61,17 @@ export function useOrderReturn({ auto = true, seo = true } = {}) {
61
61
  if (auto) reload();
62
62
  }, [auto, reload]);
63
63
 
64
- useStorefrontSeo(seo ? orderSeo(result.order ?? null) : null);
64
+ // A receipt's URL carries an order key, so the page must not rank. One meta
65
+ // tag, added on mount and removed on unmount; the rest of this page's head —
66
+ // title, description — is yours to set. `seo: false` opts out entirely.
67
+ useEffect(() => {
68
+ if (!seo || typeof document === "undefined") return undefined;
69
+ const tag = document.createElement("meta");
70
+ tag.setAttribute("name", "robots");
71
+ tag.setAttribute("content", "noindex, nofollow");
72
+ document.head.appendChild(tag);
73
+ return () => tag.remove();
74
+ }, [seo]);
65
75
 
66
76
  return { ...result, lines: orderLines(result.order ?? null, { formatMoney }), reload };
67
77
  }
@@ -1,7 +1,7 @@
1
1
  import { useCallback, useEffect, useMemo, useState } from "react";
2
2
  import {
3
3
  defaultSelection,
4
- productSpecs,
4
+ productPrice,
5
5
  resolveSelection,
6
6
  selectOption,
7
7
  selectionFromParams,
@@ -9,20 +9,17 @@ import {
9
9
  storefrontErrorCode,
10
10
  storefrontErrorMessage,
11
11
  } from "@/commerce/utils";
12
- import { useCart, useStorefront } from "./StorefrontProvider";
12
+ import { useCart, useFormatMoney, useStorefront } from "./StorefrontProvider";
13
13
  import { useCartUIOptional } from "./cartUI";
14
14
  import { useAsyncData } from "./internal/useAsyncData";
15
- import { useProductPrice } from "./useProductPrice";
16
15
 
17
16
  /**
18
17
  * useProduct — the product page's whole data and selection lifecycle.
19
18
  *
20
- * const { status, product, view, pick, price, quantity, incQuantity } =
21
- * useProduct(slug);
22
- * if (status === "loading") return <Skeleton />;
23
- * if (status === "not_found") return <NotFound />;
24
- * // render: price.label, view.axes (one control each), view.availability,
25
- * // view.purchasable, view.addToCart
19
+ * const { status, product, view, pick, price, quantity } = useProduct(slug);
20
+ * // status: "loading" | "ready" | "not_found" | "error"
21
+ * // then: price.label, view.axes (one control each variantAxes(view, pick)
22
+ * // makes them render-ready), view.purchasable, view.addToCart
26
23
  *
27
24
  * It composes the variant helpers so their rules hold by default:
28
25
  * `defaultSelection` seeds the merchant's defaults, `selectOption` applies a
@@ -121,7 +118,8 @@ export function useProduct(ref, options = {}) {
121
118
  const incQuantity = useCallback(() => setQuantity(quantity + 1), [quantity, setQuantity]);
122
119
  const decQuantity = useCallback(() => setQuantity(quantity - 1), [quantity, setQuantity]);
123
120
 
124
- const price = useProductPrice(view);
121
+ const formatMoney = useFormatMoney();
122
+ const price = useMemo(() => productPrice(view, { formatMoney }), [view, formatMoney]);
125
123
 
126
124
  const notFound = error?.code === "not_found";
127
125
  const status = loading
@@ -163,24 +161,21 @@ export function useProduct(ref, options = {}) {
163
161
  }
164
162
 
165
163
  /**
166
- * useAddToCart add-to-cart with its failure states handled.
164
+ * Internal: the raw add-to-cart call with its failure states handled.
165
+ * `add(addToCartRef, quantity)` **never throws** and always resolves —
166
+ * `{ ok: true, cart }` or `{ ok: false, error: { code, message, shouldReload } }`.
167
+ * That matters because the natural hand-written version (`await addItem(...)`
168
+ * with no catch) leaves a button stuck mid-add forever the first time a variant
169
+ * sells out. `shouldReload` is set for `variation_not_found` — the page's data
170
+ * is stale, so call the product's `reload()`.
167
171
  *
168
- * const { add, adding, error } = useAddToCart();
169
- * <button disabled={!view.purchasable || adding}
170
- * onClick={() => add(view.addToCart, quantity)}>
171
- * {adding ? "Adding…" : "Add to bag"}
172
- * </button>
173
- * {error && <p role="alert">{error.message}</p>}
174
- *
175
- * `add()` **never throws** and always resolves — `{ ok: true, cart }` or
176
- * `{ ok: false, error: { code, message, shouldReload } }`. That matters because
177
- * the natural hand-written version (`await addItem(...)` with no catch) leaves
178
- * the button stuck on "Adding…" forever the first time a variant sells out.
172
+ * `error.message` is the server's own words when the server rejected the add;
173
+ * for the local `variation_required` guard it is null, because `useAddToCart`'s
174
+ * `state === "needs_selection"` is what a page renders for that.
179
175
  *
180
- * `shouldReload` is set for `variation_not_found` — the page's data is stale,
181
- * so call the product's `reload()`.
176
+ * Pages use `useAddToCart(product)` below.
182
177
  */
183
- export function useAddToCart() {
178
+ function useAddItem() {
184
179
  const { addItem } = useCart();
185
180
  const [adding, setAdding] = useState(false);
186
181
  const [error, setError] = useState(null);
@@ -190,11 +185,7 @@ export function useAddToCart() {
190
185
  async (addToCartRef, quantity = 1) => {
191
186
  if (adding) return { ok: false, error: { code: "adding", message: "Already adding." } };
192
187
  if (!addToCartRef) {
193
- const err = {
194
- code: "variation_required",
195
- message: "Choose an option first.",
196
- shouldReload: false,
197
- };
188
+ const err = { code: "variation_required", message: null, shouldReload: false };
198
189
  setError(err);
199
190
  return { ok: false, error: err };
200
191
  }
@@ -228,59 +219,58 @@ export function useAddToCart() {
228
219
  return { add, adding, error, lastAdded, reset };
229
220
  }
230
221
 
231
- const BUY_LABELS = {
232
- ready: "Add to bag",
233
- adding: "Adding…",
234
- sold_out: "Sold out",
235
- needs_selection: "Select options",
236
- };
237
-
238
222
  /**
239
- * useAddToCartButton — the buy button's whole state machine, ready to bind to
240
- * markup you write. Pass the entire `useProduct` result:
223
+ * useAddToCart — the buy box's whole state machine as plain states and
224
+ * handlers; every element, attribute and **word** of the markup is yours. Pass
225
+ * the entire `useProduct` result:
241
226
  *
227
+ * const BUY = { // your words, written once per store
228
+ * ready: "Add to bag", adding: "Adding…",
229
+ * sold_out: "Sold out", needs_selection: "Choose a size",
230
+ * };
242
231
  * const p = useProduct(slug);
243
- * const buy = useAddToCartButton(p, { labels: { ready: "Add to bag" } });
244
- * <button {...buy.buttonProps} className="…">{buy.label}</button>
245
- * {buy.error && <p role="alert">{buy.error.message}</p>}
232
+ * const buy = useAddToCart(p);
233
+ * <button type="button" onClick={buy.addToCart} disabled={buy.disabled} className="…">
234
+ * {BUY[buy.state]}
235
+ * </button>
236
+ * {buy.error?.message && <p role="alert">{buy.error.message}</p>}
246
237
  *
247
238
  * `state` is `"ready" | "adding" | "sold_out" | "needs_selection"` — the
248
- * precedence is resolved here, not in a ternary chain and `label` follows it
249
- * (override any of the four via `labels`; the words are still yours).
250
- * `buttonProps` carries onClick, the purchasability gate and `aria-busy`. With
251
- * a `<CartUIProvider>` mounted, a successful add opens the cart drawer by
239
+ * precedence is resolved here, not in a ternary chain you have to get right.
240
+ * The button needs text for **every** state and `disabled={buy.disabled}`, or
241
+ * it renders empty or stays clickable while sold out. The kit ships no labels on
242
+ * purpose: "Add to bag" in every store built from it is how stores end up
243
+ * looking like each other.
244
+ * With a `<CartUIProvider>` mounted, a successful add opens the cart drawer by
252
245
  * itself (its `openOnAdd`); `onAdded` remains for a navigate-to-bag flow.
253
246
  *
254
- * What it wires so a hand-written buy box can't drop it: the button is gated on
255
- * `view.purchasable`; a rejected add (sold out, stale variant) lands in `error`
256
- * instead of leaving the button stuck on "Adding…"; a stale-variant rejection
257
- * reloads the product; and the quantity controls respect `sold_individually`
258
- * and tracked stock (`showQuantity` is false when only 1 can be bought — render
259
- * no stepper then).
247
+ * What it solves so a hand-written buy box can't drop it: `addToCart()` never
248
+ * throws a rejected add (sold out, stale variant) lands in `error` instead
249
+ * of leaving the button stuck mid-add; a stale-variant rejection reloads the
250
+ * product; and the quantity controls respect `sold_individually` and tracked
251
+ * stock (`showQuantity` is false when only 1 can be bought — render no stepper
252
+ * then).
260
253
  *
261
254
  * A not-yet-loaded product is fine (`disabled: true`), so call this next to
262
255
  * `useProduct` **above** the page's `loading`/`not_found` guards — a hook below
263
256
  * an early return breaks the hook order the next render.
264
257
  *
265
258
  * @param {object} product the whole `useProduct` result
266
- * @param {{onAdded?: (cart: object) => void,
267
- * labels?: {ready?: string, adding?: string, sold_out?: string,
268
- * needs_selection?: string}}} [options]
269
- * @returns {{add: () => Promise<object>, adding: boolean, error: object|null,
270
- * reset: () => void, disabled: boolean, soldOut: boolean,
271
- * needsSelection: boolean, purchasable: boolean,
272
- * state: "ready"|"adding"|"sold_out"|"needs_selection", label: string,
273
- * buttonProps: object,
259
+ * @param {{onAdded?: (cart: object) => void}} [options]
260
+ * @returns {{addToCart: () => Promise<object>, adding: boolean,
261
+ * error: object|null, reset: () => void, disabled: boolean,
262
+ * soldOut: boolean, needsSelection: boolean, purchasable: boolean,
263
+ * state: "ready"|"adding"|"sold_out"|"needs_selection",
274
264
  * quantity: number, setQuantity: (n: number) => void, increase: () => void,
275
265
  * decrease: () => void, canIncrease: boolean, canDecrease: boolean,
276
266
  * maxQuantity: number, showQuantity: boolean}}
277
267
  */
278
- export function useAddToCartButton(product, { onAdded, labels } = {}) {
279
- const { add, adding, error, reset } = useAddToCart();
268
+ export function useAddToCart(product, { onAdded } = {}) {
269
+ const { add, adding, error, reset } = useAddItem();
280
270
  const cartUI = useCartUIOptional();
281
271
  const view = product?.view ?? null;
282
272
 
283
- const submit = useCallback(async () => {
273
+ const addToCart = useCallback(async () => {
284
274
  if (!view) return { ok: false, error: { code: "no_product", message: "Product not loaded." } };
285
275
  const res = await add(view.addToCart, product.quantity);
286
276
  if (res.ok) {
@@ -302,7 +292,7 @@ export function useAddToCartButton(product, { onAdded, labels } = {}) {
302
292
  const disabled = !view?.purchasable || adding;
303
293
 
304
294
  return {
305
- add: submit,
295
+ addToCart,
306
296
  adding,
307
297
  error,
308
298
  reset,
@@ -311,13 +301,6 @@ export function useAddToCartButton(product, { onAdded, labels } = {}) {
311
301
  needsSelection,
312
302
  purchasable: Boolean(view?.purchasable),
313
303
  state,
314
- label: labels?.[state] ?? BUY_LABELS[state],
315
- buttonProps: {
316
- type: "button",
317
- onClick: submit,
318
- disabled,
319
- "aria-busy": adding || undefined,
320
- },
321
304
  quantity: product?.quantity ?? 1,
322
305
  setQuantity: product?.setQuantity ?? (() => {}),
323
306
  increase: product?.incQuantity ?? (() => {}),
@@ -329,51 +312,3 @@ export function useAddToCartButton(product, { onAdded, labels } = {}) {
329
312
  };
330
313
  }
331
314
 
332
- const normalizeSpecKey = (k) =>
333
- String(k ?? "")
334
- .toLowerCase()
335
- .replace(/[\s_-]+/g, " ")
336
- .trim();
337
-
338
- /**
339
- * useProductSpecs — `productSpecs` plus the lookup every page that features
340
- * specific specs needs, with the matching solved. **Never match spec rows by
341
- * `label` string equality** — a row's `label` is the admin's meta key verbatim
342
- * (`"light level"`, lowercase), so `specs.find(s => s.label === "Light")`
343
- * silently never matches and the feature renders its fallback forever. `get`
344
- * and `pick` here match case-, `_`- and `-`-insensitively.
345
- *
346
- * const specs = useProductSpecs(product, { pick: ["light", "water", "humidity"] });
347
- * specs.picked // the requested rows, in your order (missing ones skipped)
348
- * specs.rest // everything else — safe to render as the remainder,
349
- * // the picked rows are already excluded
350
- * specs.get("light_level") // one row or null
351
- * specs.rows // all rows (== productSpecs(product))
352
- *
353
- * Rows carry `titleLabel` (display-cased) next to the verbatim `label`, and the
354
- * `type`/`number`/`unit`/`items` fields for type-driven rendering — see
355
- * `productSpecs`.
356
- *
357
- * @param {object|null} product tolerates null/loading — call above status guards
358
- * @param {{pick?: string[]}} [options]
359
- * @returns {{rows: Array<object>, picked: Array<object>, rest: Array<object>,
360
- * get: (key: string) => object|null, has: (key: string) => boolean}}
361
- */
362
- export function useProductSpecs(product, { pick = [] } = {}) {
363
- const pickKey = JSON.stringify(pick);
364
- return useMemo(() => {
365
- const rows = productSpecs(product);
366
- const byKey = new Map(rows.map((r) => [normalizeSpecKey(r.key), r]));
367
- const get = (key) => byKey.get(normalizeSpecKey(key)) ?? null;
368
- const picked = pick.map(get).filter(Boolean);
369
- const pickedSet = new Set(picked);
370
- return {
371
- rows,
372
- picked,
373
- rest: rows.filter((r) => !pickedSet.has(r)),
374
- get,
375
- has: (key) => byKey.has(normalizeSpecKey(key)),
376
- };
377
- // eslint-disable-next-line react-hooks/exhaustive-deps
378
- }, [product, pickKey]);
379
- }