@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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/skills/commerce/SKILL.md +68 -76
- package/skills/commerce/docs/api-admin.md +11 -50
- package/skills/commerce/docs/api-storefront.md +25 -118
- package/skills/commerce/install/01-install.md +20 -49
- package/skills/commerce/install/02-storefront.md +204 -431
- package/skills/commerce/install/03-data.md +43 -109
- package/skills/commerce/references/admin-product-form.md +26 -0
- package/skills/commerce/references/catalog-rendering.md +9 -9
- package/skills/commerce/references/online-payments.md +4 -15
- package/skills/commerce/references/operations.md +19 -1
- package/skills/commerce/references/storefront-verification.md +47 -0
- package/src/commerce/storefront/StorefrontProvider.jsx +8 -3
- package/src/commerce/storefront/cartUI.jsx +118 -0
- package/src/commerce/storefront/index.js +39 -15
- package/src/commerce/storefront/pickers.jsx +84 -23
- package/src/commerce/storefront/useAddressForm.js +63 -26
- package/src/commerce/storefront/useCartLine.js +9 -4
- package/src/commerce/storefront/useCheckout.jsx +13 -0
- package/src/commerce/storefront/useOrderReturn.js +7 -5
- package/src/commerce/storefront/usePlaceOrder.js +63 -0
- package/src/commerce/storefront/useProduct.js +112 -41
- package/src/commerce/storefront/useProductList.js +7 -0
- package/src/commerce/storefront/useUpsell.js +90 -0
- package/src/commerce/utils/specs.js +6 -2
- package/src/commerce/utils/totals.js +23 -12
|
@@ -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
|
-
*
|
|
166
|
+
* useAddItem — internal: 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
|
-
*
|
|
167
|
-
*
|
|
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
|
|
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
|
-
*
|
|
231
|
-
* markup
|
|
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 =
|
|
235
|
-
* <button
|
|
236
|
-
* {buy.
|
|
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
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
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
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
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
|
|
262
|
-
const { add, adding, error, reset } =
|
|
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
|
|
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)
|
|
269
|
-
|
|
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
|
-
|
|
302
|
+
addToCart,
|
|
283
303
|
adding,
|
|
284
304
|
error,
|
|
285
305
|
reset,
|
|
286
|
-
disabled
|
|
306
|
+
disabled,
|
|
287
307
|
soldOut,
|
|
288
|
-
needsSelection
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
}
|