@base44/app-plugin-commerce 0.2.6 → 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.
- package/README.md +4 -4
- package/package.json +2 -2
- package/scripts/install.js +15 -0
- package/skills/commerce/SKILL.md +31 -13
- 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 +5 -2
- package/skills/commerce/install/02-storefront.md +179 -220
- package/skills/commerce/install/03-data.md +1 -1
- package/skills/commerce/references/catalog-rendering.md +37 -43
- 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 +26 -117
- package/src/commerce/storefront/index.js +61 -98
- package/src/commerce/storefront/pickers.jsx +53 -81
- 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 +54 -119
- package/src/commerce/storefront/useProductList.js +15 -28
- 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 -175
- package/src/commerce/storefront/usePlaceOrder.js +0 -55
- 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
|
@@ -7,25 +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
|
-
* <button {...list.moreProps} className="…">Load more</button> // hides itself on the last page
|
|
10
|
+
* // status: "loading" | "ready" | "empty" | "error"
|
|
11
|
+
* // products, hasNext, next(), loadMore(), busy, setParams({ category_id })
|
|
13
12
|
*
|
|
14
|
-
* A short strip is the same hook with a small `per_page` — a featured rail,
|
|
15
|
-
*
|
|
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 })`.
|
|
16
15
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* Any filter may legitimately match nothing — render from
|
|
28
|
-
* `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.
|
|
29
26
|
*
|
|
30
27
|
* @param {object} [initialParams] `list-products` params (page/per_page and any filter)
|
|
31
28
|
* @param {{mode?: "pages"|"append", perPage?: number, keepPreviousData?: boolean}} [options]
|
|
@@ -105,18 +102,7 @@ export function useProductList(initialParams = {}, options = {}) {
|
|
|
105
102
|
const isEmpty = !loading && !error && products.length === 0;
|
|
106
103
|
const status = loading ? "loading" : error ? "error" : isEmpty ? "empty" : "ready";
|
|
107
104
|
|
|
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
|
-
|
|
118
105
|
return {
|
|
119
|
-
moreProps,
|
|
120
106
|
products,
|
|
121
107
|
page: data?.page ?? params.page ?? 1,
|
|
122
108
|
perPage: data?.per_page ?? params.per_page,
|
|
@@ -124,6 +110,7 @@ export function useProductList(initialParams = {}, options = {}) {
|
|
|
124
110
|
totalLoaded: products.length,
|
|
125
111
|
loading,
|
|
126
112
|
refreshing,
|
|
113
|
+
busy: loading || refreshing,
|
|
127
114
|
error,
|
|
128
115
|
isEmpty,
|
|
129
116
|
status,
|
|
@@ -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]
|
|
@@ -1,175 +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 setter AND its own
|
|
46
|
-
* ready-to-spread attribute sets, so the whole form is one map with your
|
|
47
|
-
* classes on it — never assemble `value`/`onChange`/`autoComplete` by hand:
|
|
48
|
-
*
|
|
49
|
-
* const { fields } = useAddressForm("billing");
|
|
50
|
-
* {fields.map(f => (
|
|
51
|
-
* <div key={f.key} className="…">
|
|
52
|
-
* <label {...f.labelProps}>{f.label}{f.required && " *"}</label>
|
|
53
|
-
* {f.isSelect ? (
|
|
54
|
-
* <select {...f.selectProps} className="…">
|
|
55
|
-
* <option value="">{f.placeholder}</option>
|
|
56
|
-
* {f.options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
|
57
|
-
* </select>
|
|
58
|
-
* ) : <input {...f.inputProps} className="…" />}
|
|
59
|
-
* {f.error && <span {...f.errorProps} className="…">{f.error}</span>}
|
|
60
|
-
* </div>
|
|
61
|
-
* ))}
|
|
62
|
-
*
|
|
63
|
-
* The prop sets carry id/name pairing, the `autoComplete` token, controlled
|
|
64
|
-
* `value` + `onChange`, `required`, blur tracking and the `aria-invalid` /
|
|
65
|
-
* `aria-describedby` wiring for the error line. `f.placeholder` resolves the
|
|
66
|
-
* select's empty option ("Select Country", or "Loading…" while countries
|
|
67
|
-
* arrive), so `countriesLoading` never needs handling by hand. `f.error` is
|
|
68
|
-
* per-field: "we don't ship there" lands on the country field, and a required
|
|
69
|
-
* field left empty reports itself after it has been visited.
|
|
70
|
-
*
|
|
71
|
-
* `f.set` is still there for custom controls — it accepts a value, the raw
|
|
72
|
-
* event (`onChange={f.set}`) or a `(key, value)` pair — as is the hook's
|
|
73
|
-
* top-level `set(key, value)`.
|
|
74
|
-
*
|
|
75
|
-
* Editing a field is all it takes to trigger the shipping/tax recalculation —
|
|
76
|
-
* `useCheckout` debounces and calls `set-shipping-address` once the address is
|
|
77
|
-
* complete enough to price. Two things the field list gets right that a
|
|
78
|
-
* hand-typed table did not: **`state` is present** (rates and taxes match on
|
|
79
|
-
* country + state, and it switches to a select for countries with
|
|
80
|
-
* subdivisions), and the country options are never null.
|
|
81
|
-
*
|
|
82
|
-
* @param {"billing"|"shipping"} [which]
|
|
83
|
-
* @param {{includeState?: boolean, includePhone?: boolean, includeCompany?: boolean}} [options]
|
|
84
|
-
* @returns {{fields: Array<{key: string, label: string, type: string,
|
|
85
|
-
* value: string, required: boolean, options: Array<object>, error: string|null,
|
|
86
|
-
* autoComplete: string, colSpan: number, set: (...args: any[]) => void,
|
|
87
|
-
* isSelect: boolean, placeholder: string|undefined, inputProps: object,
|
|
88
|
-
* selectProps: object, labelProps: object, errorProps: object}>,
|
|
89
|
-
* set: (key: string, value: any) => void, values: object, missing: Array<string>,
|
|
90
|
-
* complete: boolean, error: object|null, countriesLoading: boolean}}
|
|
91
|
-
*/
|
|
92
|
-
export function useAddressForm(which = "billing", options = {}) {
|
|
93
|
-
const checkout = useCheckoutContext();
|
|
94
|
-
const { countries, loading: countriesLoading } = useCountries();
|
|
95
|
-
|
|
96
|
-
const isBilling = which === "billing";
|
|
97
|
-
const values = isBilling ? checkout.billing : checkout.shipping;
|
|
98
|
-
const { updateBilling, updateShipping } = checkout;
|
|
99
|
-
const set = useCallback(
|
|
100
|
-
(key, value) => (isBilling ? updateBilling({ [key]: value }) : updateShipping({ [key]: value })),
|
|
101
|
-
[isBilling, updateBilling, updateShipping],
|
|
102
|
-
);
|
|
103
|
-
|
|
104
|
-
// `place-order` only enforces required fields on billing; a separate shipping
|
|
105
|
-
// address is priced, not validated field-by-field.
|
|
106
|
-
const missing = isBilling ? checkout.missingBillingFields : [];
|
|
107
|
-
|
|
108
|
-
// A required field reports itself as `error` only after it has been visited
|
|
109
|
-
// (blurred) — an untouched form must not open covered in "required" marks.
|
|
110
|
-
const [visited, setVisited] = useState({});
|
|
111
|
-
|
|
112
|
-
const fields = useMemo(() => {
|
|
113
|
-
const spec = addressFieldSpec({
|
|
114
|
-
countries,
|
|
115
|
-
country: values?.country,
|
|
116
|
-
required: isBilling ? REQUIRED_BILLING_FIELDS : ["country", "city"],
|
|
117
|
-
includeEmail: isBilling, // one email per order, on billing
|
|
118
|
-
...options,
|
|
119
|
-
});
|
|
120
|
-
return spec.map((f) => {
|
|
121
|
-
const value = values?.[f.key] ?? "";
|
|
122
|
-
// Self-contained: the field knows its own key, so a .map never has to
|
|
123
|
-
// reach back out to the hook's set() (and can't pass the wrong key).
|
|
124
|
-
const setField = (...args) => set(f.key, newValue(args));
|
|
125
|
-
// The address-level error ("we don't ship there") belongs on country.
|
|
126
|
-
const error =
|
|
127
|
-
f.key === "country" && checkout.addressError?.code === "shipping_not_available"
|
|
128
|
-
? checkout.addressError.message
|
|
129
|
-
: visited[f.key] && !value && missing.includes(f.key)
|
|
130
|
-
? `${f.label} is required.`
|
|
131
|
-
: null;
|
|
132
|
-
const id = `${which}-${f.key}`;
|
|
133
|
-
const errorId = `${id}-error`;
|
|
134
|
-
const shared = {
|
|
135
|
-
id,
|
|
136
|
-
name: id,
|
|
137
|
-
value,
|
|
138
|
-
required: f.required,
|
|
139
|
-
autoComplete: f.autoComplete,
|
|
140
|
-
onChange: setField,
|
|
141
|
-
onBlur: () => setVisited((v) => (v[f.key] ? v : { ...v, [f.key]: true })),
|
|
142
|
-
"aria-invalid": error ? true : undefined,
|
|
143
|
-
"aria-describedby": error ? errorId : undefined,
|
|
144
|
-
};
|
|
145
|
-
return {
|
|
146
|
-
...f,
|
|
147
|
-
value,
|
|
148
|
-
set: setField,
|
|
149
|
-
error,
|
|
150
|
-
isSelect: f.type === "select",
|
|
151
|
-
placeholder:
|
|
152
|
-
f.type === "select"
|
|
153
|
-
? f.key === "country" && countriesLoading
|
|
154
|
-
? "Loading…"
|
|
155
|
-
: `Select ${f.label}`
|
|
156
|
-
: undefined,
|
|
157
|
-
inputProps: { ...shared, type: f.type },
|
|
158
|
-
selectProps: shared,
|
|
159
|
-
labelProps: { htmlFor: id },
|
|
160
|
-
errorProps: { id: errorId, role: "alert" },
|
|
161
|
-
};
|
|
162
|
-
});
|
|
163
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
164
|
-
}, [countries, countriesLoading, values, which, isBilling, set, checkout.addressError, visited, missing.join(","), JSON.stringify(options)]);
|
|
165
|
-
|
|
166
|
-
return {
|
|
167
|
-
fields,
|
|
168
|
-
set,
|
|
169
|
-
values: values ?? {},
|
|
170
|
-
missing,
|
|
171
|
-
complete: missing.length === 0,
|
|
172
|
-
error: checkout.addressError ?? null,
|
|
173
|
-
countriesLoading,
|
|
174
|
-
};
|
|
175
|
-
}
|
|
@@ -1,55 +0,0 @@
|
|
|
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,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
|
-
}
|