@base44/app-plugin-commerce 0.2.5 → 0.2.7

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.
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useEffect, useMemo, useState } from "react";
2
2
  import {
3
3
  defaultSelection,
4
+ productSpecs,
4
5
  resolveSelection,
5
6
  selectOption,
6
7
  selectionFromParams,
@@ -9,6 +10,7 @@ import {
9
10
  storefrontErrorMessage,
10
11
  } from "@/commerce/utils";
11
12
  import { useCart, useStorefront } from "./StorefrontProvider";
13
+ import { useCartUIOptional } from "./cartUI";
12
14
  import { useAsyncData } from "./internal/useAsyncData";
13
15
  import { useProductPrice } from "./useProductPrice";
14
16
 
@@ -161,24 +163,18 @@ export function useProduct(ref, options = {}) {
161
163
  }
162
164
 
163
165
  /**
164
- * useAddToCart — add-to-cart with its failure states handled.
166
+ * useAddIteminternal: the raw add-to-cart call with its failure states
167
+ * handled. `add(addToCartRef, quantity)` **never throws** and always resolves —
168
+ * `{ ok: true, cart }` or `{ ok: false, error: { code, message, shouldReload } }`.
169
+ * 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()`.
165
173
  *
166
- * const { add, adding, error } = useAddToCart();
167
- * <button disabled={!view.purchasable || adding}
168
- * onClick={() => add(view.addToCart, quantity)}>
169
- * {adding ? "Adding…" : "Add to bag"}
170
- * </button>
171
- * {error && <p role="alert">{error.message}</p>}
172
- *
173
- * `add()` **never throws** and always resolves — `{ ok: true, cart }` or
174
- * `{ ok: false, error: { code, message, shouldReload } }`. That matters because
175
- * the natural hand-written version (`await addItem(...)` with no catch) leaves
176
- * the button stuck on "Adding…" forever the first time a variant sells out.
177
- *
178
- * `shouldReload` is set for `variation_not_found` — the page's data is stale,
179
- * so call the product's `reload()`.
174
+ * Pages use `useAddToCart(product)` below; this is the shared plumbing it and
175
+ * `useUpsell` build on.
180
176
  */
181
- export function useAddToCart() {
177
+ export function useAddItem() {
182
178
  const { addItem } = useCart();
183
179
  const [adding, setAdding] = useState(false);
184
180
  const [error, setError] = useState(null);
@@ -226,49 +222,70 @@ export function useAddToCart() {
226
222
  return { add, adding, error, lastAdded, reset };
227
223
  }
228
224
 
225
+ const BUY_LABELS = {
226
+ ready: "Add to bag",
227
+ adding: "Adding…",
228
+ sold_out: "Sold out",
229
+ needs_selection: "Select options",
230
+ };
231
+
229
232
  /**
230
- * useAddToCartButton — the buy button's whole state machine, ready to bind to
231
- * markup you write. Pass the entire `useProduct` result:
233
+ * 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:
232
236
  *
233
237
  * const p = useProduct(slug);
234
- * const buy = useAddToCartButton(p, { onAdded: () => navigate("/bag") });
235
- * <button disabled={buy.disabled} onClick={buy.add}>
236
- * {buy.adding ? "Adding…" : buy.soldOut ? "Sold out"
237
- * : buy.needsSelection ? "Select options" : "Add to bag"}
238
+ * const buy = useAddToCart(p, { labels: { ready: "Add to bag" } });
239
+ * <button type="button" onClick={buy.addToCart} disabled={buy.disabled} className="…">
240
+ * {buy.label}
238
241
  * </button>
239
242
  * {buy.error && <p role="alert">{buy.error.message}</p>}
240
243
  *
241
- * What it wires so a hand-written buy box can't drop it: the button is gated on
242
- * `view.purchasable`; a rejected add (sold out, stale variant) lands in `error`
243
- * instead of leaving the button stuck on "Adding…"; a stale-variant rejection
244
- * reloads the product; and the quantity controls respect `sold_individually`
245
- * and tracked stock (`showQuantity` is false when only 1 can be bought — render
246
- * no stepper then). Every label and every element is yours.
244
+ * `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.
249
+ * With a `<CartUIProvider>` mounted, a successful add opens the cart drawer by
250
+ * itself (its `openOnAdd`); `onAdded` remains for a navigate-to-bag flow.
251
+ *
252
+ * What it solves so a hand-written buy box can't drop it: `addToCart()` never
253
+ * 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).
247
258
  *
248
259
  * A not-yet-loaded product is fine (`disabled: true`), so call this next to
249
260
  * `useProduct` **above** the page's `loading`/`not_found` guards — a hook below
250
261
  * an early return breaks the hook order the next render.
251
262
  *
252
263
  * @param {object} product the whole `useProduct` result
253
- * @param {{onAdded?: (cart: object) => void}} [options]
254
- * @returns {{add: () => Promise<object>, adding: boolean, error: object|null,
255
- * reset: () => void, disabled: boolean, soldOut: boolean,
256
- * needsSelection: boolean, purchasable: boolean,
264
+ * @param {{onAdded?: (cart: object) => void,
265
+ * labels?: {ready?: string, adding?: string, sold_out?: string,
266
+ * needs_selection?: string}}} [options]
267
+ * @returns {{addToCart: () => Promise<object>, adding: boolean,
268
+ * error: object|null, reset: () => void, disabled: boolean,
269
+ * soldOut: boolean, needsSelection: boolean, purchasable: boolean,
270
+ * state: "ready"|"adding"|"sold_out"|"needs_selection", label: string,
257
271
  * quantity: number, setQuantity: (n: number) => void, increase: () => void,
258
272
  * decrease: () => void, canIncrease: boolean, canDecrease: boolean,
259
273
  * maxQuantity: number, showQuantity: boolean}}
260
274
  */
261
- export function useAddToCartButton(product, { onAdded } = {}) {
262
- const { add, adding, error, reset } = useAddToCart();
275
+ export function useAddToCart(product, { onAdded, labels } = {}) {
276
+ const { add, adding, error, reset } = useAddItem();
277
+ const cartUI = useCartUIOptional();
263
278
  const view = product?.view ?? null;
264
279
 
265
- const submit = useCallback(async () => {
280
+ const addToCart = useCallback(async () => {
266
281
  if (!view) return { ok: false, error: { code: "no_product", message: "Product not loaded." } };
267
282
  const res = await add(view.addToCart, product.quantity);
268
- if (res.ok) onAdded?.(res.cart);
269
- else if (res.error?.shouldReload) product.reload?.();
283
+ if (res.ok) {
284
+ cartUI?.onItemAdded?.();
285
+ onAdded?.(res.cart);
286
+ } else if (res.error?.shouldReload) product.reload?.();
270
287
  return res;
271
- }, [add, view, product, onAdded]);
288
+ }, [add, view, product, onAdded, cartUI]);
272
289
 
273
290
  // A variable product with an incomplete selection isn't sold out — it needs
274
291
  // a pick; only a resolved (or simple) unpurchasable view reads as sold out.
@@ -277,16 +294,21 @@ export function useAddToCartButton(product, { onAdded } = {}) {
277
294
  ? view.complete && !view.purchasable
278
295
  : !view.purchasable
279
296
  : false;
297
+ const needsSelection = Boolean(view?.isVariable && !view.complete);
298
+ const state = adding ? "adding" : soldOut ? "sold_out" : needsSelection ? "needs_selection" : "ready";
299
+ const disabled = !view?.purchasable || adding;
280
300
 
281
301
  return {
282
- add: submit,
302
+ addToCart,
283
303
  adding,
284
304
  error,
285
305
  reset,
286
- disabled: !view?.purchasable || adding,
306
+ disabled,
287
307
  soldOut,
288
- needsSelection: Boolean(view?.isVariable && !view.complete),
308
+ needsSelection,
289
309
  purchasable: Boolean(view?.purchasable),
310
+ state,
311
+ label: labels?.[state] ?? BUY_LABELS[state],
290
312
  quantity: product?.quantity ?? 1,
291
313
  setQuantity: product?.setQuantity ?? (() => {}),
292
314
  increase: product?.incQuantity ?? (() => {}),
@@ -297,3 +319,52 @@ export function useAddToCartButton(product, { onAdded } = {}) {
297
319
  showQuantity: (product?.maxQuantity ?? 1) > 1,
298
320
  };
299
321
  }
322
+
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
+ }
@@ -9,6 +9,12 @@ import { useAsyncData } from "./internal/useAsyncData";
9
9
  * const list = useProductList({ per_page: 12, sort: "-created_date" });
10
10
  * // list.status: "loading" | "ready" | "empty" | "error"
11
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.
12
18
  *
13
19
  * A short strip is the same hook with a small `per_page` — a featured rail, a
14
20
  * "new in" row, four picks beside an article:
@@ -112,6 +118,7 @@ export function useProductList(initialParams = {}, options = {}) {
112
118
  totalLoaded: products.length,
113
119
  loading,
114
120
  refreshing,
121
+ busy: loading || refreshing,
115
122
  error,
116
123
  isEmpty,
117
124
  status,
@@ -0,0 +1,90 @@
1
+ import { useCallback } from "react";
2
+ import { productImages } from "@/commerce/utils";
3
+ import { useCart, useStorefront } from "./StorefrontProvider";
4
+ import { useCartUIOptional } from "./cartUI";
5
+ import { useAsyncData } from "./internal/useAsyncData";
6
+ import { useAddItem } from "./useProduct";
7
+ import { useProductPrice } from "./useProductPrice";
8
+
9
+ /**
10
+ * useUpsell — one product offered beside another surface (a cart drawer's
11
+ * "add the care kit", a checkout's "complete the set", a cross-sell strip
12
+ * entry), with the lifecycle solved:
13
+ *
14
+ * const kit = useUpsell("care-kit"); // slug or { id }…
15
+ * const kit = useUpsell(p.crossSells[0]); // …or a row you already have
16
+ * {kit.show && (
17
+ * <aside className="…">
18
+ * {kit.image && <img src={kit.image.src} alt={kit.image.alt} className="…" />}
19
+ * {kit.product.name} {kit.price.label}
20
+ * <button type="button" onClick={kit.add} disabled={kit.adding}>Add</button>
21
+ * {kit.error && <p role="alert">{kit.error.message}</p>}
22
+ * </aside>
23
+ * )}
24
+ *
25
+ * `show` is the one flag to branch on: it is false while loading, on a fetch
26
+ * failure, when the product is out of stock, and **when it is already in the
27
+ * cart** — matched by product id, never by display name (a rename must not
28
+ * break the match). Passing a row you already hold (`product.upsells` /
29
+ * `product.crossSells` from `useProduct`, or a `useProductList` row) skips the
30
+ * fetch entirely — prefer that when the row is on hand.
31
+ *
32
+ * `add()` never throws; a product with options can't be one-click added
33
+ * (`needsSelection` — link to its page instead). With `<CartUIProvider>`
34
+ * mounted, a successful add opens the drawer.
35
+ *
36
+ * @param {string|{id: string}|object} ref slug, `{ id }`, or a product row
37
+ * @param {{quantity?: number}} [options]
38
+ * @returns {{show: boolean, inCart: boolean, needsSelection: boolean,
39
+ * product: object|null, image: {src: string, alt: string}|null,
40
+ * price: object, add: () => Promise<object>, adding: boolean,
41
+ * error: object|null}}
42
+ */
43
+ export function useUpsell(ref, { quantity = 1 } = {}) {
44
+ const store = useStorefront();
45
+ const { cart } = useCart();
46
+ const cartUI = useCartUIOptional();
47
+
48
+ // A row (has id + name) is used as-is; a slug or { id } is fetched.
49
+ const isRow = Boolean(ref && typeof ref === "object" && ref.id && ref.name !== undefined);
50
+ const refKey = isRow ? `row:${ref.id}` : typeof ref === "string" ? ref : JSON.stringify(ref ?? null);
51
+
52
+ const { data, loading, error: fetchError } = useAsyncData(
53
+ () => (isRow || !ref ? Promise.resolve(null) : store.getProduct(ref)),
54
+ [store, refKey], // eslint-disable-line react-hooks/exhaustive-deps
55
+ );
56
+
57
+ const product = isRow ? ref : (data?.product ?? null);
58
+ const needsSelection = isRow
59
+ ? (ref.attributes?.length ?? 0) > 0
60
+ : (data?.variations?.length ?? 0) > 0;
61
+
62
+ const price = useProductPrice(product);
63
+ const { add: rawAdd, adding, error, reset } = useAddItem();
64
+
65
+ const add = useCallback(async () => {
66
+ if (!product) return { ok: false, error: { code: "no_product", message: "Product not loaded." } };
67
+ if (needsSelection) {
68
+ return { ok: false, error: { code: "variation_required", message: "Choose an option first." } };
69
+ }
70
+ const res = await rawAdd({ product_id: product.id }, quantity);
71
+ if (res.ok) cartUI?.onItemAdded?.();
72
+ return res;
73
+ }, [product, needsSelection, rawAdd, quantity, cartUI]);
74
+
75
+ const inCart = Boolean(product) && (cart?.items ?? []).some((i) => i.product_id === product.id);
76
+ const busy = !isRow && Boolean(ref) && loading;
77
+
78
+ return {
79
+ show: !busy && !fetchError && Boolean(product) && !inCart && product?.stock_status !== "outofstock",
80
+ inCart,
81
+ needsSelection,
82
+ product,
83
+ image: productImages(product)[0] ?? null,
84
+ price,
85
+ add,
86
+ adding,
87
+ error,
88
+ reset,
89
+ };
90
+ }
@@ -34,7 +34,7 @@
34
34
  * under the gallery, beside the price, or inside the description.
35
35
  *
36
36
  * @param {object} product
37
- * @returns {Array<{key: string, label: string, value: string,
37
+ * @returns {Array<{key: string, label: string, titleLabel: string, value: string,
38
38
  * type: "numeric"|"duration"|"location"|"list"|"text",
39
39
  * number: number|null, unit: string|null, items: string[]}>}
40
40
  * `[]` when the product has no visible meta_data — render nothing, not an
@@ -49,7 +49,11 @@ export function productSpecs(product) {
49
49
  .map((m) => {
50
50
  const key = String(m.key);
51
51
  const value = String(m.value);
52
- return { key, label: key.replace(/_/g, " "), value, ...classify(key, value) };
52
+ const label = key.replace(/_/g, " ");
53
+ // `label` is the admin's key verbatim ("light level"); `titleLabel` is
54
+ // the display-cased form ("Light Level") — no store wants a lowercase <dt>.
55
+ const titleLabel = label.replace(/(^|\s)\p{Ll}/gu, (c) => c.toUpperCase());
56
+ return { key, label, titleLabel, value, ...classify(key, value) };
53
57
  });
54
58
  }
55
59
 
@@ -91,20 +91,31 @@ export function orderTotalsLines(order, { formatMoney, labels = {} } = {}) {
91
91
 
92
92
  /**
93
93
  * An order's line items in the same shape as decorated cart lines, so one
94
- * component renders the bag, the checkout summary and the confirmation.
94
+ * component renders the bag, the checkout summary and the confirmation
95
+ * `image` is `{src, alt}|null` and `totalLabel` is pre-formatted, exactly as
96
+ * on `useCart().lines` (pass `formatMoney`; `useOrderReturn` does).
95
97
  *
96
98
  * @param {object} order
99
+ * @param {{formatMoney?: (n: number) => string}} [opts]
97
100
  * @returns {Array<{name: string, attributesLabel: string, quantity: number,
98
- * total: number, image: object|null, sku: string}>}
101
+ * total: number, totalLabel: string, image: {src: string, alt: string}|null,
102
+ * sku: string}>}
99
103
  */
100
- export function orderLines(order) {
101
- return (order?.line_items ?? []).map((it) => ({
102
- ...it,
103
- name: it.name ?? "",
104
- attributesLabel: attributesLabel(it.attributes ?? it.meta_data),
105
- quantity: num(it.quantity),
106
- total: num(it.total ?? it.subtotal),
107
- image: it.image ?? null,
108
- sku: it.sku ?? "",
109
- }));
104
+ export function orderLines(order, { formatMoney } = {}) {
105
+ return (order?.line_items ?? []).map((it) => {
106
+ const total = num(it.total ?? it.subtotal);
107
+ // An order item's image may be stored as a bare URL or an object — either
108
+ // way it leaves here as {src, alt}|null, the cart-line shape.
109
+ const src = typeof it.image === "string" ? it.image : it.image?.src;
110
+ return {
111
+ ...it,
112
+ name: it.name ?? "",
113
+ attributesLabel: attributesLabel(it.attributes ?? it.meta_data),
114
+ quantity: num(it.quantity),
115
+ total,
116
+ totalLabel: money(formatMoney, total),
117
+ image: src ? { src, alt: (typeof it.image === "object" ? it.image?.alt : "") || it.name || "" } : null,
118
+ sku: it.sku ?? "",
119
+ };
120
+ });
110
121
  }