@base44/app-plugin-commerce 0.2.7 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +4 -4
  2. package/package.json +2 -2
  3. package/scripts/install.js +15 -0
  4. package/skills/commerce/SKILL.md +26 -12
  5. package/skills/commerce/docs/api-admin.md +1 -1
  6. package/skills/commerce/docs/api-storefront.md +12 -12
  7. package/skills/commerce/install/01-install.md +5 -2
  8. package/skills/commerce/install/02-storefront.md +167 -275
  9. package/skills/commerce/install/03-data.md +1 -1
  10. package/skills/commerce/references/catalog-rendering.md +37 -43
  11. package/skills/commerce/references/reviews.md +21 -14
  12. package/skills/commerce/references/store-settings.md +1 -1
  13. package/skills/commerce/references/storefront-verification.md +21 -15
  14. package/src/commerce/storefront/StorefrontProvider.jsx +65 -128
  15. package/src/commerce/storefront/cartUI.jsx +11 -30
  16. package/src/commerce/storefront/index.js +61 -98
  17. package/src/commerce/storefront/pickers.jsx +50 -64
  18. package/src/commerce/storefront/useCartLine.js +23 -130
  19. package/src/commerce/storefront/useCheckout.jsx +50 -43
  20. package/src/commerce/storefront/useOrderReturn.js +17 -7
  21. package/src/commerce/storefront/useProduct.js +41 -97
  22. package/src/commerce/storefront/useProductList.js +14 -22
  23. package/src/commerce/utils/address-spec.js +1 -1
  24. package/src/commerce/utils/images.js +1 -1
  25. package/src/commerce/utils/index.js +9 -9
  26. package/src/commerce/utils/price.js +2 -1
  27. package/src/commerce/utils/specs.js +41 -91
  28. package/src/commerce/utils/totals.js +7 -4
  29. package/src/commerce/storefront/useAddressForm.js +0 -166
  30. package/src/commerce/storefront/usePlaceOrder.js +0 -63
  31. package/src/commerce/storefront/useProductGallery.js +0 -78
  32. package/src/commerce/storefront/useProductPrice.js +0 -58
  33. package/src/commerce/storefront/useProductReviews.js +0 -242
  34. package/src/commerce/storefront/useStorefrontSeo.js +0 -204
  35. package/src/commerce/storefront/useTotalsLines.js +0 -109
  36. package/src/commerce/storefront/useUpsell.js +0 -90
@@ -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
- * // ❌ every product in every store, identical: one grey table
17
- * <dl>{productSpecs(product).map((s) => (
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
- * Design the two or three that carry *this* product's meaning (a weight set in
30
- * the display face, a provenance beside a map, a composition as bars) and let
31
- * the remainder fall through to the plain row. `key` is still there too, for
32
- * when one particular modifier of this catalog deserves its own treatment
33
- * regardless of type. And the rows need not sit in one block — a spec can go
34
- * under the gallery, beside the price, or inside the description.
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. `number`/`unit` are set for `numeric` and `duration`
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 admin's key verbatim ("light level"); `titleLabel` is
54
- // the display-cased form ("Light Level") — no store wants a lowercase <dt>.
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, ...classify(key, value) };
43
+ return { key, label, titleLabel, value };
57
44
  });
58
45
  }
59
46
 
60
- const LOCATION_KEY =
61
- /(origin|provenance|made[\s_-]?in|country|region|sourced|source|location|city|terroir|appellation|distillery|winery|atelier|workshop)/i;
62
-
63
- const DURATION_UNIT =
64
- /^(sec|secs|second|seconds|min|mins|minute|minutes|hr|hrs|hour|hours|day|days|week|weeks|month|months|year|years|yr|yrs)$/i;
65
-
66
- /** Infer the render-relevant shape of one spec value. Never throws. */
67
- function classify(key, raw) {
68
- const value = raw.trim();
69
- const plain = { type: "text", number: null, unit: null, items: [] };
70
-
71
- if (LOCATION_KEY.test(key)) return { ...plain, type: "location" };
72
-
73
- const qty = parseQuantity(value);
74
- if (qty) {
75
- const type = DURATION_UNIT.test(qty.unit) ? "duration" : "numeric";
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
- /** "70% wool / 30% cashmere" → ["70% wool", "30% cashmere"]. */
109
- function parseList(value) {
110
- const parts = value
111
- .split(/\s*[,;|·•/]\s*/)
112
- .map((p) => p.trim())
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 in the same shape as decorated cart lines, so one
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).
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]
@@ -1,166 +0,0 @@
1
- import { useCallback, useMemo, useState } from "react";
2
- import { addressFieldSpec } from "@/commerce/utils";
3
- import { useStoreInfo } from "./StorefrontProvider";
4
- import { useCheckoutContext } from "./useCheckout";
5
- import { REQUIRED_BILLING_FIELDS } from "./address";
6
-
7
- /**
8
- * The store's country list, **always as an array**.
9
- *
10
- * const { options, loading } = useCountries();
11
- * <select>{options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}</select>
12
- *
13
- * `useStoreInfo().countries` is `null` until store info resolves, so mapping it
14
- * directly white-screens the checkout on a cold load — the most severe defect
15
- * observed in a generated storefront. Here the list is empty-then-full, never
16
- * null, and `loading` says which.
17
- */
18
- export function useCountries() {
19
- const { countries, loading, error } = useStoreInfo();
20
- const list = Array.isArray(countries) ? countries : [];
21
- const options = useMemo(() => list.map((c) => ({ value: c.code, label: c.name })), [countries]); // eslint-disable-line react-hooks/exhaustive-deps
22
- return { countries: list, options, loading, error };
23
- }
24
-
25
- /**
26
- * Read the new value out of whatever a field's `set` was handed. All three
27
- * forms an onChange is plausibly written as work, so a field setter can't be
28
- * called "wrong":
29
- *
30
- * f.set(e.target.value) // the value
31
- * f.set(e) // the change event (onChange={f.set})
32
- * f.set(f.key, e.target.value) // key + value, mirroring the top-level set()
33
- */
34
- function newValue(args) {
35
- if (args.length >= 2) return args[1];
36
- const first = args[0];
37
- if (first && typeof first === "object" && "target" in first) return first.target?.value ?? "";
38
- return first;
39
- }
40
-
41
- /**
42
- * useAddressForm — the checkout address form as a field list bound to the
43
- * guided checkout. Needs a `<CheckoutProvider>` above it.
44
- *
45
- * Every field is **self-contained**: it carries its own value, setter, id,
46
- * `autoComplete` token and error, so the whole form is one map and every
47
- * element and attribute in it is yours:
48
- *
49
- * const { fields } = useAddressForm("billing");
50
- * {fields.map(f => (
51
- * <div key={f.key} className="…">
52
- * <label htmlFor={f.id}>{f.label}{f.required && " *"}</label>
53
- * {f.isSelect ? (
54
- * <select id={f.id} value={f.value} onChange={f.set}
55
- * autoComplete={f.autoComplete} className="…">
56
- * <option value="">{f.placeholder}</option>
57
- * {f.options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
58
- * </select>
59
- * ) : <input id={f.id} type={f.type} value={f.value} onChange={f.set}
60
- * autoComplete={f.autoComplete} className="…" />}
61
- * {f.error && <span role="alert" className="…">{f.error}</span>}
62
- * </div>
63
- * ))}
64
- *
65
- * ⚑ Keep `value={f.value}` + `onChange={f.set}` (the pair that binds the field
66
- * to checkout) and `autoComplete={f.autoComplete}` (browsers fill addresses in
67
- * one gesture with it, field by field without). `f.placeholder` resolves the
68
- * select's empty option ("Select Country", or "Loading…" while countries
69
- * arrive), so `countriesLoading` never needs handling by hand. `f.error` is
70
- * per-field: "we don't ship there" lands on the country field, and a required
71
- * field reports itself once it has been edited and left empty — untouched
72
- * fields stay quiet here and surface through the place-order `blockers`.
73
- *
74
- * `f.set` works with custom controls too — it accepts a value, the raw change
75
- * event (`onChange={f.set}`) or a `(key, value)` pair — as does the hook's
76
- * top-level `set(key, value)`.
77
- *
78
- * Editing a field is all it takes to trigger the shipping/tax recalculation —
79
- * `useCheckout` debounces and calls `set-shipping-address` once the address is
80
- * complete enough to price. Two things the field list gets right that a
81
- * hand-typed table did not: **`state` is present** (rates and taxes match on
82
- * country + state, and it switches to a select for countries with
83
- * subdivisions), and the country options are never null.
84
- *
85
- * @param {"billing"|"shipping"} [which]
86
- * @param {{includeState?: boolean, includePhone?: boolean, includeCompany?: boolean}} [options]
87
- * @returns {{fields: Array<{key: string, id: string, label: string, type: string,
88
- * value: string, required: boolean, options: Array<object>, error: string|null,
89
- * autoComplete: string, colSpan: number, set: (...args: any[]) => void,
90
- * isSelect: boolean, placeholder: string|undefined}>,
91
- * set: (key: string, value: any) => void, values: object, missing: Array<string>,
92
- * complete: boolean, error: object|null, countriesLoading: boolean}}
93
- */
94
- export function useAddressForm(which = "billing", options = {}) {
95
- const checkout = useCheckoutContext();
96
- const { countries, loading: countriesLoading } = useCountries();
97
-
98
- const isBilling = which === "billing";
99
- const values = isBilling ? checkout.billing : checkout.shipping;
100
- const { updateBilling, updateShipping } = checkout;
101
- const set = useCallback(
102
- (key, value) => (isBilling ? updateBilling({ [key]: value }) : updateShipping({ [key]: value })),
103
- [isBilling, updateBilling, updateShipping],
104
- );
105
-
106
- // `place-order` only enforces required fields on billing; a separate shipping
107
- // address is priced, not validated field-by-field.
108
- const missing = isBilling ? checkout.missingBillingFields : [];
109
-
110
- // A required field reports itself as `error` only after it has been edited —
111
- // an untouched form must not open covered in "required" marks. (Fields never
112
- // touched at all surface through the place-order blockers instead.)
113
- const [visited, setVisited] = useState({});
114
-
115
- const fields = useMemo(() => {
116
- const spec = addressFieldSpec({
117
- countries,
118
- country: values?.country,
119
- required: isBilling ? REQUIRED_BILLING_FIELDS : ["country", "city"],
120
- includeEmail: isBilling, // one email per order, on billing
121
- ...options,
122
- });
123
- return spec.map((f) => {
124
- const value = values?.[f.key] ?? "";
125
- // Self-contained: the field knows its own key, so a .map never has to
126
- // reach back out to the hook's set() (and can't pass the wrong key).
127
- // The first edit marks the field visited, arming its required check.
128
- const setField = (...args) => {
129
- setVisited((v) => (v[f.key] ? v : { ...v, [f.key]: true }));
130
- set(f.key, newValue(args));
131
- };
132
- // The address-level error ("we don't ship there") belongs on country.
133
- const error =
134
- f.key === "country" && checkout.addressError?.code === "shipping_not_available"
135
- ? checkout.addressError.message
136
- : visited[f.key] && !value && missing.includes(f.key)
137
- ? `${f.label} is required.`
138
- : null;
139
- return {
140
- ...f,
141
- id: `${which}-${f.key}`,
142
- value,
143
- set: setField,
144
- error,
145
- isSelect: f.type === "select",
146
- placeholder:
147
- f.type === "select"
148
- ? f.key === "country" && countriesLoading
149
- ? "Loading…"
150
- : `Select ${f.label}`
151
- : undefined,
152
- };
153
- });
154
- // eslint-disable-next-line react-hooks/exhaustive-deps
155
- }, [countries, countriesLoading, values, which, isBilling, set, checkout.addressError, visited, missing.join(","), JSON.stringify(options)]);
156
-
157
- return {
158
- fields,
159
- set,
160
- values: values ?? {},
161
- missing,
162
- complete: missing.length === 0,
163
- error: checkout.addressError ?? null,
164
- countriesLoading,
165
- };
166
- }
@@ -1,63 +0,0 @@
1
- import { useCallback } from "react";
2
- import { useCheckoutContext } from "./useCheckout";
3
- import { useCheckoutBlockers } from "./useTotalsLines";
4
-
5
- /**
6
- * usePlaceOrder — the place-order gate as plain states and one handler. Needs
7
- * a `<CheckoutProvider>` above it. The whole bottom of a checkout page:
8
- *
9
- * const order = usePlaceOrder();
10
- * if (order.stage === "submitted") return <p>Order placed — taking you to your receipt…</p>;
11
- * …
12
- * <button type="button" onClick={order.placeOrder} disabled={order.disabled} className="…">
13
- * {order.label}
14
- * </button>
15
- * {order.error && <p role="alert" className="…">{order.error.message}</p>}
16
- * {!order.canPlaceOrder && order.blockers.map(b => <p key={b.code}>{b.message}</p>)}
17
- *
18
- * ⚑ **The `stage === "submitted"` guard goes above the page's empty-cart
19
- * branch.** Placing an order clears the cart before the browser navigates
20
- * away; without the guard the page flashes "your bag is empty" over a
21
- * just-placed order. `stage` is `"editing" | "placing" | "submitted"`.
22
- *
23
- * `disabled` is the gate plus in-flight (`!canPlaceOrder || placing`); `label`
24
- * follows `placing` and is overridable via `labels: { idle, placing }`.
25
- * `placeOrder` is safe as an `onClick` handler directly — a click event passed
26
- * to it is ignored (an explicit `extra` object is still forwarded). `blockers`
27
- * are the disabled button's reasons in words (`useCheckoutBlockers`), each
28
- * with a `field` to anchor it next to the input that fixes it. Pass
29
- * `blockerLabels` to override that copy per code.
30
- *
31
- * @param {{labels?: {idle?: string, placing?: string},
32
- * blockerLabels?: Record<string, string>}} [options]
33
- * @returns {{placeOrder: (extra?: object) => Promise<object>, disabled: boolean,
34
- * label: string, stage: "editing"|"placing"|"submitted", submitted: boolean,
35
- * placing: boolean, canPlaceOrder: boolean, error: object|null,
36
- * blockers: Array<{code: string, message: string, field: string|null}>}}
37
- */
38
- export function usePlaceOrder({ labels, blockerLabels } = {}) {
39
- const checkout = useCheckoutContext();
40
- const blockers = useCheckoutBlockers({ labels: blockerLabels });
41
- const { canPlaceOrder, placing, submitted, stage, orderError, placeOrder } = checkout;
42
-
43
- // Usable as onClick={order.placeOrder}: a DOM/React event is not `extra`.
44
- const place = useCallback(
45
- (extra) => {
46
- const isEvent = extra && typeof extra === "object" && ("nativeEvent" in extra || "target" in extra);
47
- return placeOrder(isEvent ? undefined : extra);
48
- },
49
- [placeOrder],
50
- );
51
-
52
- return {
53
- placeOrder: place,
54
- disabled: !canPlaceOrder || placing,
55
- label: placing ? (labels?.placing ?? "Placing your order…") : (labels?.idle ?? "Place order"),
56
- stage,
57
- submitted,
58
- placing,
59
- canPlaceOrder,
60
- error: orderError,
61
- blockers,
62
- };
63
- }
@@ -1,78 +0,0 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
- import { imageIndex, productImages } from "@/commerce/utils";
3
-
4
- /**
5
- * useProductGallery — the gallery's non-visual state: normalized images, the
6
- * active index, and the variant-follows-selection behaviour.
7
- *
8
- * const g = useProductGallery(product, view);
9
- * {g.hasImages
10
- * ? <img src={g.active.src} alt={g.active.alt} />
11
- * : <MyPlaceholder />}
12
- * {g.images.map((img, i) => <Thumb key={img.src} onClick={() => g.setActiveIndex(i)} …/>)}
13
- *
14
- * The aspect ratio, crossfade and thumbnail styling stay yours — only the state
15
- * moves here, and with it three details worth having by default: images are
16
- * normalized to `{src, name, alt}` (they are stored as objects, so a raw
17
- * `images[0]` as `src` renders broken), `hasImages: false` is an explicit
18
- * placeholder signal rather than a collapsed element, and **a selection change
19
- * moves the active image to the variation's own picture while a manual pick
20
- * still wins until the selection changes again** — highlight, not replace.
21
- *
22
- * A null/not-yet-loaded product is fine (`hasImages: false`), so call this with
23
- * the other hooks **above** the page's `loading`/`not_found` guards — a hook
24
- * below an early return breaks the hook order the next render.
25
- *
26
- * @param {object} product
27
- * @param {object|null} [view] a `resolveSelection` view; its
28
- * `display.image` is the variation's image
29
- */
30
- export function useProductGallery(product, view = null) {
31
- const images = useMemo(() => productImages(product), [product]);
32
- const [activeIndex, setActiveIndex] = useState(0);
33
- const manualPick = useRef(false);
34
-
35
- const variationImage = view?.display?.image ?? null;
36
- const variationIndex = useMemo(() => imageIndex(images, variationImage), [images, variationImage]);
37
- const selectionKey = JSON.stringify(view?.selection ?? {});
38
-
39
- // A new product resets everything; a new selection re-arms the follow.
40
- useEffect(() => {
41
- manualPick.current = false;
42
- setActiveIndex(0);
43
- }, [product?.id]);
44
-
45
- useEffect(() => {
46
- manualPick.current = false;
47
- }, [selectionKey]);
48
-
49
- useEffect(() => {
50
- if (manualPick.current || variationIndex < 0) return;
51
- setActiveIndex(variationIndex);
52
- }, [variationIndex]);
53
-
54
- const select = useCallback(
55
- (index) => {
56
- manualPick.current = true;
57
- setActiveIndex(Math.max(0, Math.min(images.length - 1, index)));
58
- },
59
- [images.length],
60
- );
61
- const next = useCallback(() => select((activeIndex + 1) % Math.max(1, images.length)), [activeIndex, images.length, select]);
62
- const prev = useCallback(
63
- () => select((activeIndex - 1 + Math.max(1, images.length)) % Math.max(1, images.length)),
64
- [activeIndex, images.length, select],
65
- );
66
-
67
- const safeIndex = Math.min(activeIndex, Math.max(0, images.length - 1));
68
- return {
69
- images,
70
- activeIndex: safeIndex,
71
- setActiveIndex: select,
72
- next,
73
- prev,
74
- active: images[safeIndex] ?? null,
75
- hasImages: images.length > 0,
76
- variationIndex,
77
- };
78
- }
@@ -1,58 +0,0 @@
1
- import { useCallback, useMemo } from "react";
2
- import { productPrice } from "@/commerce/utils";
3
- import { useFormatMoney, useStoreInfo } from "./StorefrontProvider";
4
-
5
- /**
6
- * Money formatting in the store's currency, with the pieces a custom price UI
7
- * needs (parity with the admin's `useMoney`).
8
- *
9
- * @returns {{format: (n: number) => string, formatRange: (a: number, b: number) => string,
10
- * code: string|null, symbol: string, decimals: number}}
11
- */
12
- export function useMoney() {
13
- const format = useFormatMoney();
14
- const { settings } = useStoreInfo();
15
- const code = settings?.currency ?? null;
16
- const formatRange = useCallback((min, max) => `${format(min)} – ${format(max)}`, [format]);
17
- const symbol = useMemo(() => {
18
- if (!code) return "";
19
- try {
20
- // The currency part of a formatted zero — locale-correct, no symbol table.
21
- return new Intl.NumberFormat(undefined, { style: "currency", currency: code })
22
- .formatToParts(0)
23
- .find((p) => p.type === "currency")?.value ?? code;
24
- } catch {
25
- return code;
26
- }
27
- }, [code]);
28
- const decimals = useMemo(() => {
29
- if (!code) return 2;
30
- try {
31
- return new Intl.NumberFormat(undefined, { style: "currency", currency: code })
32
- .resolvedOptions().maximumFractionDigits ?? 2;
33
- } catch {
34
- return 2;
35
- }
36
- }, [code]);
37
-
38
- return { format, formatRange, code, symbol, decimals };
39
- }
40
-
41
- /**
42
- * The rendered price of a product row **or** a resolved selection view, in the
43
- * store's currency:
44
- *
45
- * const price = useProductPrice(row); // a card: "From €19.99"
46
- * const price = useProductPrice(view); // a product page: "€19.99 – €23.99"
47
- * // until the selection resolves
48
- * <span>{price.label}</span>
49
- * {price.compareAtLabel && <s>{price.compareAtLabel}</s>}
50
- *
51
- * One call for both views is the point — the from-price rule cannot be honoured
52
- * on the grid and forgotten on the product page. See `productPrice` for the
53
- * rules themselves.
54
- */
55
- export function useProductPrice(rowOrView) {
56
- const formatMoney = useFormatMoney();
57
- return useMemo(() => productPrice(rowOrView, { formatMoney }), [rowOrView, formatMoney]);
58
- }