@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
@@ -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,18 +161,21 @@ export function useProduct(ref, options = {}) {
163
161
  }
164
162
 
165
163
  /**
166
- * useAddItem — internal: the raw add-to-cart call with its failure states
167
- * handled. `add(addToCartRef, quantity)` **never throws** and always resolves —
164
+ * Internal: the raw add-to-cart call with its failure states handled.
165
+ * `add(addToCartRef, quantity)` **never throws** and always resolves —
168
166
  * `{ ok: true, cart }` or `{ ok: false, error: { code, message, shouldReload } }`.
169
167
  * That matters because the natural hand-written version (`await addItem(...)`
170
- * with no catch) leaves a button stuck on "Adding…" forever the first time a
171
- * variant sells out. `shouldReload` is set for `variation_not_found` — the
172
- * page's data is stale, so call the product's `reload()`.
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()`.
173
171
  *
174
- * Pages use `useAddToCart(product)` below; this is the shared plumbing it and
175
- * `useUpsell` build on.
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.
175
+ *
176
+ * Pages use `useAddToCart(product)` below.
176
177
  */
177
- export function useAddItem() {
178
+ function useAddItem() {
178
179
  const { addItem } = useCart();
179
180
  const [adding, setAdding] = useState(false);
180
181
  const [error, setError] = useState(null);
@@ -184,11 +185,7 @@ export function useAddItem() {
184
185
  async (addToCartRef, quantity = 1) => {
185
186
  if (adding) return { ok: false, error: { code: "adding", message: "Already adding." } };
186
187
  if (!addToCartRef) {
187
- const err = {
188
- code: "variation_required",
189
- message: "Choose an option first.",
190
- shouldReload: false,
191
- };
188
+ const err = { code: "variation_required", message: null, shouldReload: false };
192
189
  setError(err);
193
190
  return { ok: false, error: err };
194
191
  }
@@ -222,57 +219,53 @@ export function useAddItem() {
222
219
  return { add, adding, error, lastAdded, reset };
223
220
  }
224
221
 
225
- const BUY_LABELS = {
226
- ready: "Add to bag",
227
- adding: "Adding…",
228
- sold_out: "Sold out",
229
- needs_selection: "Select options",
230
- };
231
-
232
222
  /**
233
223
  * useAddToCart — the buy box's whole state machine as plain states and
234
- * handlers; every element and attribute of the markup is yours. Pass the
235
- * entire `useProduct` result:
224
+ * handlers; every element, attribute and **word** of the markup is yours. Pass
225
+ * the entire `useProduct` result:
236
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
+ * };
237
231
  * const p = useProduct(slug);
238
- * const buy = useAddToCart(p, { labels: { ready: "Add to bag" } });
232
+ * const buy = useAddToCart(p);
239
233
  * <button type="button" onClick={buy.addToCart} disabled={buy.disabled} className="…">
240
- * {buy.label}
234
+ * {BUY[buy.state]}
241
235
  * </button>
242
- * {buy.error && <p role="alert">{buy.error.message}</p>}
236
+ * {buy.error?.message && <p role="alert">{buy.error.message}</p>}
243
237
  *
244
238
  * `state` is `"ready" | "adding" | "sold_out" | "needs_selection"` — the
245
- * precedence is resolved here, not in a ternary chain and `label` follows it
246
- * (override any of the four via `labels`; the words are still yours). ⚑ Render
247
- * `{buy.label}` as the button's text and gate it with `disabled={buy.disabled}`
248
- * a button without both shows nothing or stays clickable while sold out.
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.
249
244
  * With a `<CartUIProvider>` mounted, a successful add opens the cart drawer by
250
245
  * itself (its `openOnAdd`); `onAdded` remains for a navigate-to-bag flow.
251
246
  *
252
247
  * What it solves so a hand-written buy box can't drop it: `addToCart()` never
253
248
  * throws — a rejected add (sold out, stale variant) lands in `error` instead
254
- * of leaving the button stuck on "Adding…"; a stale-variant rejection reloads
255
- * the product; and the quantity controls respect `sold_individually` and
256
- * tracked stock (`showQuantity` is false when only 1 can be bought — render no
257
- * stepper then).
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).
258
253
  *
259
254
  * A not-yet-loaded product is fine (`disabled: true`), so call this next to
260
255
  * `useProduct` **above** the page's `loading`/`not_found` guards — a hook below
261
256
  * an early return breaks the hook order the next render.
262
257
  *
263
258
  * @param {object} product the whole `useProduct` result
264
- * @param {{onAdded?: (cart: object) => void,
265
- * labels?: {ready?: string, adding?: string, sold_out?: string,
266
- * needs_selection?: string}}} [options]
259
+ * @param {{onAdded?: (cart: object) => void}} [options]
267
260
  * @returns {{addToCart: () => Promise<object>, adding: boolean,
268
261
  * error: object|null, reset: () => void, disabled: boolean,
269
262
  * soldOut: boolean, needsSelection: boolean, purchasable: boolean,
270
- * state: "ready"|"adding"|"sold_out"|"needs_selection", label: string,
263
+ * state: "ready"|"adding"|"sold_out"|"needs_selection",
271
264
  * quantity: number, setQuantity: (n: number) => void, increase: () => void,
272
265
  * decrease: () => void, canIncrease: boolean, canDecrease: boolean,
273
266
  * maxQuantity: number, showQuantity: boolean}}
274
267
  */
275
- export function useAddToCart(product, { onAdded, labels } = {}) {
268
+ export function useAddToCart(product, { onAdded } = {}) {
276
269
  const { add, adding, error, reset } = useAddItem();
277
270
  const cartUI = useCartUIOptional();
278
271
  const view = product?.view ?? null;
@@ -308,7 +301,6 @@ export function useAddToCart(product, { onAdded, labels } = {}) {
308
301
  needsSelection,
309
302
  purchasable: Boolean(view?.purchasable),
310
303
  state,
311
- label: labels?.[state] ?? BUY_LABELS[state],
312
304
  quantity: product?.quantity ?? 1,
313
305
  setQuantity: product?.setQuantity ?? (() => {}),
314
306
  increase: product?.incQuantity ?? (() => {}),
@@ -320,51 +312,3 @@ export function useAddToCart(product, { onAdded, labels } = {}) {
320
312
  };
321
313
  }
322
314
 
323
- const normalizeSpecKey = (k) =>
324
- String(k ?? "")
325
- .toLowerCase()
326
- .replace(/[\s_-]+/g, " ")
327
- .trim();
328
-
329
- /**
330
- * useProductSpecs — `productSpecs` plus the lookup every page that features
331
- * specific specs needs, with the matching solved. **Never match spec rows by
332
- * `label` string equality** — a row's `label` is the admin's meta key verbatim
333
- * (`"light level"`, lowercase), so `specs.find(s => s.label === "Light")`
334
- * silently never matches and the feature renders its fallback forever. `get`
335
- * and `pick` here match case-, `_`- and `-`-insensitively.
336
- *
337
- * const specs = useProductSpecs(product, { pick: ["light", "water", "humidity"] });
338
- * specs.picked // the requested rows, in your order (missing ones skipped)
339
- * specs.rest // everything else — safe to render as the remainder,
340
- * // the picked rows are already excluded
341
- * specs.get("light_level") // one row or null
342
- * specs.rows // all rows (== productSpecs(product))
343
- *
344
- * Rows carry `titleLabel` (display-cased) next to the verbatim `label`, and the
345
- * `type`/`number`/`unit`/`items` fields for type-driven rendering — see
346
- * `productSpecs`.
347
- *
348
- * @param {object|null} product tolerates null/loading — call above status guards
349
- * @param {{pick?: string[]}} [options]
350
- * @returns {{rows: Array<object>, picked: Array<object>, rest: Array<object>,
351
- * get: (key: string) => object|null, has: (key: string) => boolean}}
352
- */
353
- export function useProductSpecs(product, { pick = [] } = {}) {
354
- const pickKey = JSON.stringify(pick);
355
- return useMemo(() => {
356
- const rows = productSpecs(product);
357
- const byKey = new Map(rows.map((r) => [normalizeSpecKey(r.key), r]));
358
- const get = (key) => byKey.get(normalizeSpecKey(key)) ?? null;
359
- const picked = pick.map(get).filter(Boolean);
360
- const pickedSet = new Set(picked);
361
- return {
362
- rows,
363
- picked,
364
- rest: rows.filter((r) => !pickedSet.has(r)),
365
- get,
366
- has: (key) => byKey.has(normalizeSpecKey(key)),
367
- };
368
- // eslint-disable-next-line react-hooks/exhaustive-deps
369
- }, [product, pickKey]);
370
- }
@@ -7,30 +7,22 @@ import { useAsyncData } from "./internal/useAsyncData";
7
7
  * loading vs refreshing, and failure as a visible state.
8
8
  *
9
9
  * const list = useProductList({ per_page: 12, sort: "-created_date" });
10
- * // list.status: "loading" | "ready" | "empty" | "error"
11
- * // list.products, list.hasNext, list.next(), list.setParams({ category_id })
12
- * {list.hasNext && (
13
- * <button type="button" onClick={list.next} disabled={list.busy} className="…">Next</button>
14
- * )}
15
- * // append mode: onClick={list.loadMore} — same hasNext gate. ⚑ Render the
16
- * // paging control whenever `hasNext` is true, or the catalog is silently
17
- * // capped at one page.
10
+ * // status: "loading" | "ready" | "empty" | "error"
11
+ * // products, hasNext, next(), loadMore(), busy, setParams({ category_id })
18
12
  *
19
- * A short strip is the same hook with a small `per_page` — a featured rail, a
20
- * "new in" row, four picks beside an article:
13
+ * A short strip is the same hook with a small `per_page` — a featured rail, four
14
+ * picks beside an article: `useProductList({ featured: true, per_page: 4 })`.
21
15
  *
22
- * const featured = useProductList({ featured: true, per_page: 4 });
23
- *
24
- * What it fixes versus a hand-written fetch effect: `has_next` is honoured (so
25
- * the catalog isn't silently capped at one page), a failed request renders as
26
- * `status: "error"` instead of an empty grid, `isEmpty` is never true while
27
- * loading, changing a filter resets to page 1 and keeps the current rows on
28
- * screen while the new page loads, and the whole server-side filter surface
29
- * (`search`, `category_id`, `ribbon_id`, `featured`, `on_sale`, `min_price`,
30
- * `max_price`, `in_stock_only`, `sort`) is reachable through `setParams`.
31
- *
32
- * Any filter may legitimately match nothing — render from
33
- * `products.length`/`isEmpty`, never on the assumption that rows came back.
16
+ * **Render a paging control whenever `hasNext` is true**, or the catalog is
17
+ * silently capped at one page (`next()` for pages, `loadMore()` in append mode).
18
+ * The rest of what it fixes versus a hand-written fetch effect: a failed request
19
+ * is `status: "error"`, not an empty grid; `isEmpty` is never true while loading;
20
+ * a filter change resets to page 1 and keeps the current rows on screen while
21
+ * the new page loads; and the whole server-side filter surface (`search`,
22
+ * `category_id`, `ribbon_id`, `featured`, `on_sale`, `min_price`, `max_price`,
23
+ * `in_stock_only`, `sort`) is reachable through `setParams`. Any filter may
24
+ * legitimately match nothing — render from `isEmpty`, never on the assumption
25
+ * that rows came back.
34
26
  *
35
27
  * @param {object} [initialParams] `list-products` params (page/per_page and any filter)
36
28
  * @param {{mode?: "pages"|"append", perPage?: number, keepPreviousData?: boolean}} [options]
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The address form, as data. Framework-free; `useAddressForm` binds it to the
2
+ * The address form, as data. Framework-free; bind it to the
3
3
  * checkout and the store's country list.
4
4
  *
5
5
  * Why a spec instead of markup: a hand-typed field table drifts from what the
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Product images, normalized. Framework-free; `useProductGallery` adds the
2
+ * Product images, normalized. Framework-free; a gallery adds the
3
3
  * gallery's index state on top.
4
4
  *
5
5
  * Every stored image is an **object** — `{ src, name, alt }` — never a URL
@@ -25,16 +25,16 @@
25
25
  * - `address-spec.js` — `addressFieldSpec`: the checkout address form as data,
26
26
  * with country/state options that are always arrays.
27
27
  * - `images.js` — `productImages`: images normalized to `{src, name, alt}`.
28
- * - `specs.js` — `productSpecs`: `meta_data` → descriptive rows with an inferred
29
- * `type` (numeric/duration/location/list/text), so each can be rendered as
30
- * what it is rather than as another label/value row.
28
+ * - `specs.js` — `productSpecs`: `meta_data` → descriptive rows (`key`, `label`,
29
+ * `titleLabel`, `value`). Match rows by `key`, never by `label`.
31
30
  *
32
- * Building the storefront in React? **Prefer `@/commerce/storefront`** — it
33
- * layers headless hooks on top of this module, and a hook that pre-composes
34
- * these helpers is the difference between a rule that holds and a rule you
35
- * have to remember. Neither layer ships any UI: all markup and styling belong
36
- * to the storefront you build. Use this module directly for non-React code,
37
- * and inside your own custom logic.
31
+ * Building the storefront in React? Import from **`@/commerce/storefront`** and
32
+ * nothing else it adds the headless hooks and re-exports the helpers a page
33
+ * actually needs (`variantAxes`, `productPrice`, `productImages`,
34
+ * `productSpecs`, `attributesLabel`, `cartTotalsLines`, `orderTotalsLines`), so
35
+ * one import line covers a page. Neither layer ships any UI: all markup,
36
+ * styling and copy belong to the storefront you build. Use this module directly
37
+ * for non-React code and inside your own custom logic.
38
38
  */
39
39
  export * from "./storefront.js";
40
40
  export * from "./variants.js";
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Price presentation — the one place the store's pricing *rules* turn into
3
- * strings. Framework-free; `useProductPrice` binds it to the store currency.
3
+ * strings. Framework-free; pass `formatMoney` from `useFormatMoney()` to bind it to
4
+ * the store's currency.
4
5
  *
5
6
  * Two rules live here, and both are easy to get wrong in one view while
6
7
  * getting them right in another: