@base44/app-plugin-commerce 0.2.7 → 0.3.2
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 +4 -4
- package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
- package/base44/functions/commerce/seed-store/entry.ts +16 -2
- package/package.json +2 -2
- package/scripts/install.js +15 -0
- package/skills/commerce/SKILL.md +34 -17
- package/skills/commerce/docs/api-admin.md +1 -1
- package/skills/commerce/docs/api-storefront.md +12 -12
- package/skills/commerce/install/01-install.md +6 -8
- package/skills/commerce/install/02-storefront.md +180 -278
- package/skills/commerce/install/03-data.md +13 -11
- package/skills/commerce/references/catalog-rendering.md +37 -43
- package/skills/commerce/references/online-payments.md +10 -0
- package/skills/commerce/references/reviews.md +21 -14
- package/skills/commerce/references/store-settings.md +1 -1
- package/skills/commerce/references/storefront-verification.md +21 -15
- package/src/commerce/storefront/StorefrontProvider.jsx +65 -128
- package/src/commerce/storefront/cartUI.jsx +11 -30
- package/src/commerce/storefront/index.js +61 -98
- package/src/commerce/storefront/pickers.jsx +50 -64
- package/src/commerce/storefront/useCartLine.js +23 -130
- package/src/commerce/storefront/useCheckout.jsx +50 -43
- package/src/commerce/storefront/useOrderReturn.js +17 -7
- package/src/commerce/storefront/useProduct.js +41 -97
- package/src/commerce/storefront/useProductList.js +14 -22
- package/src/commerce/utils/address-spec.js +1 -1
- package/src/commerce/utils/images.js +1 -1
- package/src/commerce/utils/index.js +9 -9
- package/src/commerce/utils/price.js +2 -1
- package/src/commerce/utils/specs.js +41 -91
- package/src/commerce/utils/totals.js +7 -4
- package/src/commerce/storefront/useAddressForm.js +0 -166
- package/src/commerce/storefront/usePlaceOrder.js +0 -63
- package/src/commerce/storefront/useProductGallery.js +0 -78
- package/src/commerce/storefront/useProductPrice.js +0 -58
- package/src/commerce/storefront/useProductReviews.js +0 -242
- package/src/commerce/storefront/useStorefrontSeo.js +0 -204
- package/src/commerce/storefront/useTotalsLines.js +0 -109
- package/src/commerce/storefront/useUpsell.js +0 -90
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
2
|
import {
|
|
3
3
|
defaultSelection,
|
|
4
|
-
|
|
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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
|
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
|
-
*
|
|
167
|
-
*
|
|
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
|
|
171
|
-
*
|
|
172
|
-
*
|
|
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
|
-
*
|
|
175
|
-
* `
|
|
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
|
-
|
|
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
|
|
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
|
|
232
|
+
* const buy = useAddToCart(p);
|
|
239
233
|
* <button type="button" onClick={buy.addToCart} disabled={buy.disabled} className="…">
|
|
240
|
-
* {buy.
|
|
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
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
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
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
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",
|
|
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
|
|
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
|
-
* //
|
|
11
|
-
* //
|
|
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,
|
|
20
|
-
*
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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;
|
|
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
|
|
@@ -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
|
|
29
|
-
* `
|
|
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?
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* and
|
|
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; `
|
|
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:
|
|
@@ -6,42 +6,29 @@
|
|
|
6
6
|
* **not** attributes and not ribbons: they describe the product, they don't
|
|
7
7
|
* select a variant. Hidden keys (leading `_`) and empty values are skipped.
|
|
8
8
|
*
|
|
9
|
-
* Each row carries a `type` — inferred from the value (and, for `"location"`,
|
|
10
|
-
* the key) — so the rendering decision is already made for you. **A `.map()`
|
|
11
|
-
* into one uniform label/value table is the fallback, not the target:** the
|
|
12
|
-
* types exist because a carat weight and a care instruction are not the same
|
|
13
|
-
* kind of fact and should not look alike.
|
|
14
|
-
*
|
|
15
9
|
* ```jsx
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* <div key={s.key}><dt>{s.label}</dt><dd>{s.value}</dd></div>))}</dl>
|
|
19
|
-
*
|
|
20
|
-
* // ✅ branch on type — the figures read as figures, the rest stays a row
|
|
21
|
-
* {productSpecs(product).map((s) =>
|
|
22
|
-
* s.type === "numeric" ? <Figure key={s.key} label={s.label} n={s.number} unit={s.unit} />
|
|
23
|
-
* : s.type === "location" ? <Sourced key={s.key} place={s.value} /> // a located line, a pin
|
|
24
|
-
* : s.type === "list" ? <Bars key={s.key} parts={s.items} /> // composition, materials
|
|
25
|
-
* : s.type === "duration" ? <Lead key={s.key} label={s.label} value={s.value} />
|
|
26
|
-
* : <Row key={s.key} label={s.label} value={s.value} />)}
|
|
10
|
+
* const specs = productSpecs(product);
|
|
11
|
+
* const care = findSpec(specs, "care"); // not specs.find(s => s.label === "Care")
|
|
27
12
|
* ```
|
|
28
13
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
14
|
+
* **Look rows up with `findSpec`, never by matching `label`.** Meta keys are
|
|
15
|
+
* free text typed by whoever set the product up, so the same fact is `care`,
|
|
16
|
+
* `Care`, `care_instructions` or `Care Instructions` across two catalogs — an
|
|
17
|
+
* equality test on `label` silently never matches and the feature renders its
|
|
18
|
+
* fallback forever (observed in a live store). `titleLabel` is the display-cased
|
|
19
|
+
* form, for when you do want to print the key as a heading.
|
|
20
|
+
*
|
|
21
|
+
* A `.map()` into one uniform label/value table is the fallback, not the
|
|
22
|
+
* target: a carat weight and a care instruction are not the same kind of fact
|
|
23
|
+
* and need not look alike. Which two or three of *this* catalog's modifiers
|
|
24
|
+
* carry meaning — and how each is rendered — is a design decision about this
|
|
25
|
+
* store, made from its own data. The rows need not sit in one block either: a
|
|
26
|
+
* spec can go under the gallery, beside the price, or inside the description.
|
|
35
27
|
*
|
|
36
28
|
* @param {object} product
|
|
37
|
-
* @returns {Array<{key: string, label: string, titleLabel: string, value: string
|
|
38
|
-
* type: "numeric"|"duration"|"location"|"list"|"text",
|
|
39
|
-
* number: number|null, unit: string|null, items: string[]}>}
|
|
29
|
+
* @returns {Array<{key: string, label: string, titleLabel: string, value: string}>}
|
|
40
30
|
* `[]` when the product has no visible meta_data — render nothing, not an
|
|
41
|
-
* empty section. `
|
|
42
|
-
* (`unit` is `""` for a bare number), `items` for `list`, and are
|
|
43
|
-
* `null`/`[]` otherwise. `value` is always the store's own text, unchanged —
|
|
44
|
-
* the extra fields are there to render *with*, never a replacement for it.
|
|
31
|
+
* empty section. `value` is always the store's own text, unchanged.
|
|
45
32
|
*/
|
|
46
33
|
export function productSpecs(product) {
|
|
47
34
|
return (product?.meta_data ?? [])
|
|
@@ -50,70 +37,33 @@ export function productSpecs(product) {
|
|
|
50
37
|
const key = String(m.key);
|
|
51
38
|
const value = String(m.value);
|
|
52
39
|
const label = key.replace(/_/g, " ");
|
|
53
|
-
// `label` is the
|
|
54
|
-
// the display-cased form
|
|
40
|
+
// `label` is the key with underscores opened up, in whatever case it was
|
|
41
|
+
// typed; `titleLabel` is the display-cased form, for printing as a <dt>.
|
|
55
42
|
const titleLabel = label.replace(/(^|\s)\p{Ll}/gu, (c) => c.toUpperCase());
|
|
56
|
-
return { key, label, titleLabel, value
|
|
43
|
+
return { key, label, titleLabel, value };
|
|
57
44
|
});
|
|
58
45
|
}
|
|
59
46
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
if (
|
|
75
|
-
|
|
76
|
-
return { ...plain, type, number: qty.number, unit: qty.unit };
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const items = parseList(value);
|
|
80
|
-
if (items) return { ...plain, type: "list", items };
|
|
81
|
-
|
|
82
|
-
return plain;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** "0.75 ct" → {number: 0.75, unit: "ct"}; "18" → {number: 18, unit: ""}. */
|
|
86
|
-
function parseQuantity(value) {
|
|
87
|
-
const m = /^([-+]?[\d.,]+)\s*(.*)$/.exec(value);
|
|
88
|
-
if (!m) return null;
|
|
89
|
-
const number = toNumber(m[1]);
|
|
90
|
-
if (number === null) return null;
|
|
91
|
-
const unit = m[2].trim();
|
|
92
|
-
// A unit is a word or two of symbols/letters. Anything longer is prose that
|
|
93
|
-
// happens to start with a number ("2 pieces, hand-cut in the studio").
|
|
94
|
-
if (unit && (!/^[\p{L}%°µ"'/²³.\- ]{1,12}$/u.test(unit) || unit.split(/\s+/).length > 2)) return null;
|
|
95
|
-
return { number, unit };
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** Grouped thousands are separators; a lone comma between digits is a decimal. */
|
|
99
|
-
function toNumber(raw) {
|
|
100
|
-
let s = raw.replace(/\s/g, "");
|
|
101
|
-
if (/^[-+]?\d{1,3}(,\d{3})+(\.\d+)?$/.test(s)) s = s.replace(/,/g, "");
|
|
102
|
-
else if (/^[-+]?\d+,\d+$/.test(s)) s = s.replace(",", ".");
|
|
103
|
-
else if (s.includes(",")) return null;
|
|
104
|
-
const n = Number(s);
|
|
105
|
-
return Number.isFinite(n) ? n : null;
|
|
47
|
+
/**
|
|
48
|
+
* Find one spec row by key, tolerantly: case, spaces, `_` and `-` are all
|
|
49
|
+
* ignored, so `findSpec(rows, "care_instructions")` matches a row the merchant
|
|
50
|
+
* typed as `Care Instructions`. Returns the row or `null`.
|
|
51
|
+
*
|
|
52
|
+
* This is the lookup to use whenever a page features *particular* specs (a
|
|
53
|
+
* weight rendered as a figure, a provenance beside its place), because meta
|
|
54
|
+
* keys are free text and an exact match on one spelling is a silent miss.
|
|
55
|
+
*
|
|
56
|
+
* @param {Array<{key: string}>} rows from `productSpecs(product)`
|
|
57
|
+
* @param {string} key the key you mean, in any spelling
|
|
58
|
+
*/
|
|
59
|
+
export function findSpec(rows, key) {
|
|
60
|
+
const want = normalizeSpecKey(key);
|
|
61
|
+
if (!want) return null;
|
|
62
|
+
return (rows ?? []).find((r) => normalizeSpecKey(r?.key) === want) ?? null;
|
|
106
63
|
}
|
|
107
64
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
.
|
|
112
|
-
.
|
|
113
|
-
.filter(Boolean);
|
|
114
|
-
if (parts.length < 2) return null;
|
|
115
|
-
// Short fragments with words in them — not a sentence that happens to have commas.
|
|
116
|
-
if (parts.some((p) => p.length > 24 || p.split(/\s+/).length > 3 || /[.!?]/.test(p))) return null;
|
|
117
|
-
if (!parts.some((p) => /\p{L}/u.test(p))) return null;
|
|
118
|
-
return parts;
|
|
119
|
-
}
|
|
65
|
+
const normalizeSpecKey = (k) =>
|
|
66
|
+
String(k ?? "")
|
|
67
|
+
.toLowerCase()
|
|
68
|
+
.replace(/[\s_-]+/g, "")
|
|
69
|
+
.trim();
|
|
@@ -90,10 +90,13 @@ export function orderTotalsLines(order, { formatMoney, labels = {} } = {}) {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
|
-
* An order's line items
|
|
94
|
-
*
|
|
95
|
-
* `
|
|
96
|
-
*
|
|
93
|
+
* An order's line items, pre-chewed for rendering so one component can serve
|
|
94
|
+
* the bag, the checkout summary and the confirmation — `image` is
|
|
95
|
+
* `{src, alt}|null` and `totalLabel` is pre-formatted, sparing a receipt the
|
|
96
|
+
* two traps of a raw `line_items` entry (an image stored as an object, money
|
|
97
|
+
* as a number). A cart row is the same shape once you derive it from
|
|
98
|
+
* `cart.items[n]` yourself — `attributesLabel(item.attributes)` and
|
|
99
|
+
* `formatMoney(item.total)`. Pass `formatMoney`; `useOrderReturn` does.
|
|
97
100
|
*
|
|
98
101
|
* @param {object} order
|
|
99
102
|
* @param {{formatMoney?: (n: number) => string}} [opts]
|