@base44/app-plugin-commerce 0.2.5 → 0.2.6

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.
@@ -89,6 +89,12 @@ function resolvePaymentMethod(gateways, picked) {
89
89
  * `shipping_address_required`, `shipping_method_required`,
90
90
  * `shipping_not_available`, `payment_method_required`.
91
91
  *
92
+ * `stage` is `"editing" | "placing" | "submitted"` (`submitted` = the order was
93
+ * accepted; the navigation away is already in flight). **Guard on it before the
94
+ * cart's empty branch** — `placeOrder` clears the cart, so a page that checks
95
+ * 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.
97
+ *
92
98
  * Options: `debounceMs` (600), `addressComplete` (predicate overriding the
93
99
  * country+city rule), `requiredBillingFields`, `redirectToPayment` (true),
94
100
  * `orderReceivedPath` ("/order-received"; null disables the navigation).
@@ -113,6 +119,7 @@ export function useCheckout(options = {}) {
113
119
  const [syncing, setSyncing] = useState(false);
114
120
  const [syncedKey, setSyncedKey] = useState(undefined);
115
121
  const [placing, setPlacing] = useState(false);
122
+ const [submitted, setSubmitted] = useState(false);
116
123
  const [orderError, setOrderError] = useState(null);
117
124
 
118
125
  const updateBilling = useCallback((patch) => setBilling((b) => ({ ...b, ...patch })), []);
@@ -216,6 +223,10 @@ export function useCheckout(options = {}) {
216
223
  ...(shipToDifferent ? { shipping } : {}),
217
224
  ...extra, // customer_note, success_url/cancel_url overrides, …
218
225
  });
226
+ // `submitted` flips BEFORE the cart clears, in the same commit — the
227
+ // page's `stage === "submitted"` guard is what stands between a placed
228
+ // order and a flash of "your bag is empty" while the browser navigates.
229
+ setSubmitted(true);
219
230
  clearCart(); // checkout consumed the cart
220
231
  if (
221
232
  redirectToPayment &&
@@ -280,6 +291,8 @@ export function useCheckout(options = {}) {
280
291
  blockers,
281
292
  canPlaceOrder,
282
293
  placing,
294
+ submitted,
295
+ stage: placing ? "placing" : submitted ? "submitted" : "editing",
283
296
  orderError,
284
297
  placeOrder,
285
298
  // underlying data, for convenience
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useEffect, useState } from "react";
2
2
  import { orderLines, storefrontErrorCode, storefrontErrorMessage } from "@/commerce/utils";
3
- import { useStorefrontState } from "./StorefrontProvider";
3
+ import { useFormatMoney, useStorefrontState } from "./StorefrontProvider";
4
4
  import { orderSeo, useStorefrontSeo } from "./useStorefrontSeo";
5
5
 
6
6
  /**
@@ -19,9 +19,10 @@ import { orderSeo, useStorefrontSeo } from "./useStorefrontSeo";
19
19
  * // "cancelled" → payment was cancelled — offer paymentLink.url or support
20
20
  * // "error" → render error.message with a retry via reload()
21
21
  *
22
- * `lines` are the order's items in the decorated cart-line shape, so the same
23
- * row markup renders the bag and the confirmation; totals come from
24
- * `useTotalsLines(order)`. The page is marked `noindex` automatically a
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
25
26
  * receipt carrying an order key must not rank (`seo: false` opts out).
26
27
  *
27
28
  * It reads `order_id`/`order_key`/`payment` from the URL itself and verifies
@@ -32,6 +33,7 @@ import { orderSeo, useStorefrontSeo } from "./useStorefrontSeo";
32
33
  */
33
34
  export function useOrderReturn({ auto = true, seo = true } = {}) {
34
35
  const { client } = useStorefrontState();
36
+ const formatMoney = useFormatMoney();
35
37
  const [result, setResult] = useState({ status: auto ? "loading" : "idle" });
36
38
 
37
39
  const reload = useCallback(
@@ -61,7 +63,7 @@ export function useOrderReturn({ auto = true, seo = true } = {}) {
61
63
 
62
64
  useStorefrontSeo(seo ? orderSeo(result.order ?? null) : null);
63
65
 
64
- return { ...result, lines: orderLines(result.order ?? null), reload };
66
+ return { ...result, lines: orderLines(result.order ?? null, { formatMoney }), reload };
65
67
  }
66
68
 
67
69
  /**
@@ -0,0 +1,55 @@
1
+ import { useCheckoutContext } from "./useCheckout";
2
+ import { useCheckoutBlockers } from "./useTotalsLines";
3
+
4
+ /**
5
+ * usePlaceOrder — the place-order gate as one spreadable surface. Needs a
6
+ * `<CheckoutProvider>` above it. The whole bottom of a checkout page:
7
+ *
8
+ * const order = usePlaceOrder();
9
+ * if (order.stage === "submitted") return <p>Order placed — taking you to your receipt…</p>;
10
+ * …
11
+ * <button {...order.buttonProps} className="…">{order.label}</button>
12
+ * {order.error && <p {...order.errorProps} className="…">{order.error.message}</p>}
13
+ * {!order.canPlaceOrder && order.blockers.map(b => <p key={b.code}>{b.message}</p>)}
14
+ *
15
+ * ⚑ **The `stage === "submitted"` guard goes above the page's empty-cart
16
+ * branch.** Placing an order clears the cart before the browser navigates
17
+ * away; without the guard the page flashes "your bag is empty" over a
18
+ * just-placed order. `stage` is `"editing" | "placing" | "submitted"`.
19
+ *
20
+ * `buttonProps` carries `onClick`, `disabled` (gate + in-flight) and
21
+ * `aria-busy`; `label` follows `placing` and is overridable via
22
+ * `labels: { idle, placing }`. `blockers` are the disabled button's reasons in
23
+ * words (`useCheckoutBlockers`), each with a `field` to anchor it next to the
24
+ * input that fixes it. Pass `blockerLabels` to override that copy per code.
25
+ *
26
+ * @param {{labels?: {idle?: string, placing?: string},
27
+ * blockerLabels?: Record<string, string>}} [options]
28
+ * @returns {{buttonProps: object, label: string,
29
+ * stage: "editing"|"placing"|"submitted", submitted: boolean,
30
+ * placing: boolean, canPlaceOrder: boolean, error: object|null,
31
+ * errorProps: object, blockers: Array<{code: string, message: string,
32
+ * field: string|null}>, placeOrder: (extra?: object) => Promise<object>}}
33
+ */
34
+ export function usePlaceOrder({ labels, blockerLabels } = {}) {
35
+ const checkout = useCheckoutContext();
36
+ const blockers = useCheckoutBlockers({ labels: blockerLabels });
37
+ const { canPlaceOrder, placing, submitted, stage, orderError, placeOrder } = checkout;
38
+ return {
39
+ buttonProps: {
40
+ type: "button",
41
+ onClick: () => placeOrder(),
42
+ disabled: !canPlaceOrder || placing,
43
+ "aria-busy": placing || undefined,
44
+ },
45
+ label: placing ? (labels?.placing ?? "Placing your order…") : (labels?.idle ?? "Place order"),
46
+ stage,
47
+ submitted,
48
+ placing,
49
+ canPlaceOrder,
50
+ error: orderError,
51
+ errorProps: { role: "alert" },
52
+ blockers,
53
+ placeOrder,
54
+ };
55
+ }
@@ -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
 
@@ -226,49 +228,67 @@ export function useAddToCart() {
226
228
  return { add, adding, error, lastAdded, reset };
227
229
  }
228
230
 
231
+ const BUY_LABELS = {
232
+ ready: "Add to bag",
233
+ adding: "Adding…",
234
+ sold_out: "Sold out",
235
+ needs_selection: "Select options",
236
+ };
237
+
229
238
  /**
230
239
  * useAddToCartButton — the buy button's whole state machine, ready to bind to
231
240
  * markup you write. Pass the entire `useProduct` result:
232
241
  *
233
242
  * 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
- * </button>
243
+ * const buy = useAddToCartButton(p, { labels: { ready: "Add to bag" } });
244
+ * <button {...buy.buttonProps} className="…">{buy.label}</button>
239
245
  * {buy.error && <p role="alert">{buy.error.message}</p>}
240
246
  *
247
+ * `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
252
+ * itself (its `openOnAdd`); `onAdded` remains for a navigate-to-bag flow.
253
+ *
241
254
  * What it wires so a hand-written buy box can't drop it: the button is gated on
242
255
  * `view.purchasable`; a rejected add (sold out, stale variant) lands in `error`
243
256
  * instead of leaving the button stuck on "Adding…"; a stale-variant rejection
244
257
  * reloads the product; and the quantity controls respect `sold_individually`
245
258
  * and tracked stock (`showQuantity` is false when only 1 can be bought — render
246
- * no stepper then). Every label and every element is yours.
259
+ * no stepper then).
247
260
  *
248
261
  * A not-yet-loaded product is fine (`disabled: true`), so call this next to
249
262
  * `useProduct` **above** the page's `loading`/`not_found` guards — a hook below
250
263
  * an early return breaks the hook order the next render.
251
264
  *
252
265
  * @param {object} product the whole `useProduct` result
253
- * @param {{onAdded?: (cart: object) => void}} [options]
266
+ * @param {{onAdded?: (cart: object) => void,
267
+ * labels?: {ready?: string, adding?: string, sold_out?: string,
268
+ * needs_selection?: string}}} [options]
254
269
  * @returns {{add: () => Promise<object>, adding: boolean, error: object|null,
255
270
  * reset: () => void, disabled: boolean, soldOut: boolean,
256
271
  * needsSelection: boolean, purchasable: boolean,
272
+ * state: "ready"|"adding"|"sold_out"|"needs_selection", label: string,
273
+ * buttonProps: object,
257
274
  * quantity: number, setQuantity: (n: number) => void, increase: () => void,
258
275
  * decrease: () => void, canIncrease: boolean, canDecrease: boolean,
259
276
  * maxQuantity: number, showQuantity: boolean}}
260
277
  */
261
- export function useAddToCartButton(product, { onAdded } = {}) {
278
+ export function useAddToCartButton(product, { onAdded, labels } = {}) {
262
279
  const { add, adding, error, reset } = useAddToCart();
280
+ const cartUI = useCartUIOptional();
263
281
  const view = product?.view ?? null;
264
282
 
265
283
  const submit = useCallback(async () => {
266
284
  if (!view) return { ok: false, error: { code: "no_product", message: "Product not loaded." } };
267
285
  const res = await add(view.addToCart, product.quantity);
268
- if (res.ok) onAdded?.(res.cart);
269
- else if (res.error?.shouldReload) product.reload?.();
286
+ if (res.ok) {
287
+ cartUI?.onItemAdded?.();
288
+ onAdded?.(res.cart);
289
+ } else if (res.error?.shouldReload) product.reload?.();
270
290
  return res;
271
- }, [add, view, product, onAdded]);
291
+ }, [add, view, product, onAdded, cartUI]);
272
292
 
273
293
  // A variable product with an incomplete selection isn't sold out — it needs
274
294
  // a pick; only a resolved (or simple) unpurchasable view reads as sold out.
@@ -277,16 +297,27 @@ export function useAddToCartButton(product, { onAdded } = {}) {
277
297
  ? view.complete && !view.purchasable
278
298
  : !view.purchasable
279
299
  : false;
300
+ const needsSelection = Boolean(view?.isVariable && !view.complete);
301
+ const state = adding ? "adding" : soldOut ? "sold_out" : needsSelection ? "needs_selection" : "ready";
302
+ const disabled = !view?.purchasable || adding;
280
303
 
281
304
  return {
282
305
  add: submit,
283
306
  adding,
284
307
  error,
285
308
  reset,
286
- disabled: !view?.purchasable || adding,
309
+ disabled,
287
310
  soldOut,
288
- needsSelection: Boolean(view?.isVariable && !view.complete),
311
+ needsSelection,
289
312
  purchasable: Boolean(view?.purchasable),
313
+ state,
314
+ label: labels?.[state] ?? BUY_LABELS[state],
315
+ buttonProps: {
316
+ type: "button",
317
+ onClick: submit,
318
+ disabled,
319
+ "aria-busy": adding || undefined,
320
+ },
290
321
  quantity: product?.quantity ?? 1,
291
322
  setQuantity: product?.setQuantity ?? (() => {}),
292
323
  increase: product?.incQuantity ?? (() => {}),
@@ -297,3 +328,52 @@ export function useAddToCartButton(product, { onAdded } = {}) {
297
328
  showQuantity: (product?.maxQuantity ?? 1) > 1,
298
329
  };
299
330
  }
331
+
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
+ }
@@ -9,6 +9,7 @@ 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
+ * <button {...list.moreProps} className="…">Load more</button> // hides itself on the last page
12
13
  *
13
14
  * A short strip is the same hook with a small `per_page` — a featured rail, a
14
15
  * "new in" row, four picks beside an article:
@@ -104,7 +105,18 @@ export function useProductList(initialParams = {}, options = {}) {
104
105
  const isEmpty = !loading && !error && products.length === 0;
105
106
  const status = loading ? "loading" : error ? "error" : isEmpty ? "empty" : "ready";
106
107
 
108
+ // Spread on the "Load more" / "Next" button — it disables while a page is in
109
+ // flight and removes itself when there is no next page, so paging can't be
110
+ // silently dropped (`hasNext` unrendered = a catalog capped at one page).
111
+ const moreProps = {
112
+ type: "button",
113
+ onClick: appendMode ? loadMore : next,
114
+ disabled: loading || refreshing || !hasNext,
115
+ hidden: !hasNext,
116
+ };
117
+
107
118
  return {
119
+ moreProps,
108
120
  products,
109
121
  page: data?.page ?? params.page ?? 1,
110
122
  perPage: data?.per_page ?? params.per_page,
@@ -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 { useAddToCart } 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 } = useAddToCart();
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
  }