@base44/app-plugin-commerce 0.2.4 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/commerce/SKILL.md +64 -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 +185 -319
- package/skills/commerce/install/03-data.md +43 -106
- package/skills/commerce/references/admin-product-form.md +26 -0
- package/skills/commerce/references/catalog-rendering.md +8 -8
- 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 +45 -8
- package/src/commerce/storefront/cartUI.jsx +190 -0
- package/src/commerce/storefront/index.js +50 -20
- package/src/commerce/storefront/pickers.jsx +98 -23
- package/src/commerce/storefront/useAddressForm.js +71 -25
- package/src/commerce/storefront/useCartLine.js +40 -4
- package/src/commerce/storefront/useCheckout.jsx +13 -0
- package/src/commerce/storefront/useOrderReturn.js +7 -5
- package/src/commerce/storefront/usePlaceOrder.js +55 -0
- package/src/commerce/storefront/useProduct.js +93 -13
- package/src/commerce/storefront/useProductList.js +12 -0
- package/src/commerce/storefront/useUpsell.js +90 -0
- package/src/commerce/utils/index.js +3 -2
- package/src/commerce/utils/specs.js +104 -17
- package/src/commerce/utils/totals.js +23 -12
|
@@ -20,6 +20,37 @@ import { useCart } from "./StorefrontProvider";
|
|
|
20
20
|
* request per settle instead of one per click; and `sold_individually` is
|
|
21
21
|
* respected, so a one-per-customer product has no working "+".
|
|
22
22
|
*
|
|
23
|
+
* ## When `pending` settles — the exact window
|
|
24
|
+
*
|
|
25
|
+
* `pending` is this line's own flag, not the cart's, and it is **not** true for
|
|
26
|
+
* the whole gesture:
|
|
27
|
+
*
|
|
28
|
+
* 1. **Click → `pending` stays `false`.** `increase`/`decrease` only set the
|
|
29
|
+
* optimistic `quantity` and (re)start a `debounceMs` (250ms) timer. Nothing
|
|
30
|
+
* is in flight yet, and a further click restarts the timer, so a burst of
|
|
31
|
+
* clicks sends **one** request for the final number.
|
|
32
|
+
* 2. **Debounce elapses → `pending` becomes `true`** and the request goes out.
|
|
33
|
+
* `remove()` skips this step: it cancels the timer and goes `pending`
|
|
34
|
+
* immediately.
|
|
35
|
+
* 3. **`pending` returns to `false` only after the server's new cart view has
|
|
36
|
+
* been published to the provider** — the awaited mutation resolves through
|
|
37
|
+
* the provider's serialized queue, which sets the shared cart state before
|
|
38
|
+
* the await returns. So `pending === false` with `error === null` means this
|
|
39
|
+
* row's quantity, the cart's totals and any dependent badge are settled, not
|
|
40
|
+
* merely that the request finished.
|
|
41
|
+
*
|
|
42
|
+
* Two consequences worth designing for. **Disable and mark only this row**
|
|
43
|
+
* (`disabled={!l.canIncrease || l.pending}`) — `pending` says nothing about the
|
|
44
|
+
* other lines, and greying the whole cart because one stepper is busy makes a
|
|
45
|
+
* 250ms update look like a page-wide stall. And **`pending` is the only
|
|
46
|
+
* mutation-settled signal**: `useCart().status` never returns to `"loading"`
|
|
47
|
+
* for a mutation (see its doc comment), so a caller that needs to know an
|
|
48
|
+
* update landed — a script driving the page, a queued follow-up action — waits
|
|
49
|
+
* on this flag, per row, and not on cart `status`.
|
|
50
|
+
*
|
|
51
|
+
* On failure `pending` returns to `false`, the optimistic quantity rolls back
|
|
52
|
+
* to what the server still holds, and `error` is `{ code, message }`.
|
|
53
|
+
*
|
|
23
54
|
* @param {object} line a decorated line from `useCart().lines` (a raw
|
|
24
55
|
* `cart.items[n]` works too — it just has no `maxQuantity` hint)
|
|
25
56
|
* @param {{debounceMs?: number}} [options]
|
|
@@ -104,6 +135,8 @@ export function useCartLine(line, { debounceMs = 250 } = {}) {
|
|
|
104
135
|
atMin: quantity <= 1,
|
|
105
136
|
attributesLabel: line?.attributesLabel ?? "",
|
|
106
137
|
image: line?.image ?? null,
|
|
138
|
+
totalLabel: line?.totalLabel ?? "",
|
|
139
|
+
unitPriceLabel: line?.unitPriceLabel ?? "",
|
|
107
140
|
};
|
|
108
141
|
}
|
|
109
142
|
|
|
@@ -117,12 +150,15 @@ export function useCartLine(line, { debounceMs = 250 } = {}) {
|
|
|
117
150
|
* {lines.map(line => (
|
|
118
151
|
* <CartLine key={line.item_key} line={line}>
|
|
119
152
|
* {(l) => (
|
|
120
|
-
* <li>
|
|
153
|
+
* <li aria-busy={l.pending}>
|
|
121
154
|
* {line.name} {line.attributesLabel}
|
|
122
|
-
* <button onClick={l.decrease} disabled={!l.canDecrease || l.pending}
|
|
155
|
+
* <button onClick={l.decrease} disabled={!l.canDecrease || l.pending}
|
|
156
|
+
* aria-label={`Decrease quantity of ${line.name}`}>−</button>
|
|
123
157
|
* {l.quantity}
|
|
124
|
-
* <button onClick={l.increase} disabled={!l.canIncrease || l.pending}
|
|
125
|
-
*
|
|
158
|
+
* <button onClick={l.increase} disabled={!l.canIncrease || l.pending}
|
|
159
|
+
* aria-label={`Increase quantity of ${line.name}`}>+</button>
|
|
160
|
+
* <button onClick={l.remove} aria-label={`Remove ${line.name}`}>Remove</button>
|
|
161
|
+
* {l.totalLabel}
|
|
126
162
|
* {l.error && <p role="alert">{l.error.message}</p>}
|
|
127
163
|
* </li>
|
|
128
164
|
* )}
|
|
@@ -89,6 +89,12 @@ function resolvePaymentMethod(gateways, picked) {
|
|
|
89
89
|
* `shipping_address_required`, `shipping_method_required`,
|
|
90
90
|
* `shipping_not_available`, `payment_method_required`.
|
|
91
91
|
*
|
|
92
|
+
* `stage` is `"editing" | "placing" | "submitted"` (`submitted` = the order was
|
|
93
|
+
* accepted; the navigation away is already in flight). **Guard on it before the
|
|
94
|
+
* cart's empty branch** — `placeOrder` clears the cart, so a page that checks
|
|
95
|
+
* only `cart.status` repaints "your bag is empty" over a just-placed order for
|
|
96
|
+
* the frames before the browser leaves. `usePlaceOrder` packages this gate.
|
|
97
|
+
*
|
|
92
98
|
* Options: `debounceMs` (600), `addressComplete` (predicate overriding the
|
|
93
99
|
* country+city rule), `requiredBillingFields`, `redirectToPayment` (true),
|
|
94
100
|
* `orderReceivedPath` ("/order-received"; null disables the navigation).
|
|
@@ -113,6 +119,7 @@ export function useCheckout(options = {}) {
|
|
|
113
119
|
const [syncing, setSyncing] = useState(false);
|
|
114
120
|
const [syncedKey, setSyncedKey] = useState(undefined);
|
|
115
121
|
const [placing, setPlacing] = useState(false);
|
|
122
|
+
const [submitted, setSubmitted] = useState(false);
|
|
116
123
|
const [orderError, setOrderError] = useState(null);
|
|
117
124
|
|
|
118
125
|
const updateBilling = useCallback((patch) => setBilling((b) => ({ ...b, ...patch })), []);
|
|
@@ -216,6 +223,10 @@ export function useCheckout(options = {}) {
|
|
|
216
223
|
...(shipToDifferent ? { shipping } : {}),
|
|
217
224
|
...extra, // customer_note, success_url/cancel_url overrides, …
|
|
218
225
|
});
|
|
226
|
+
// `submitted` flips BEFORE the cart clears, in the same commit — the
|
|
227
|
+
// page's `stage === "submitted"` guard is what stands between a placed
|
|
228
|
+
// order and a flash of "your bag is empty" while the browser navigates.
|
|
229
|
+
setSubmitted(true);
|
|
219
230
|
clearCart(); // checkout consumed the cart
|
|
220
231
|
if (
|
|
221
232
|
redirectToPayment &&
|
|
@@ -280,6 +291,8 @@ export function useCheckout(options = {}) {
|
|
|
280
291
|
blockers,
|
|
281
292
|
canPlaceOrder,
|
|
282
293
|
placing,
|
|
294
|
+
submitted,
|
|
295
|
+
stage: placing ? "placing" : submitted ? "submitted" : "editing",
|
|
283
296
|
orderError,
|
|
284
297
|
placeOrder,
|
|
285
298
|
// underlying data, for convenience
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useState } from "react";
|
|
2
2
|
import { orderLines, storefrontErrorCode, storefrontErrorMessage } from "@/commerce/utils";
|
|
3
|
-
import { useStorefrontState } from "./StorefrontProvider";
|
|
3
|
+
import { useFormatMoney, useStorefrontState } from "./StorefrontProvider";
|
|
4
4
|
import { orderSeo, useStorefrontSeo } from "./useStorefrontSeo";
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -19,9 +19,10 @@ import { orderSeo, useStorefrontSeo } from "./useStorefrontSeo";
|
|
|
19
19
|
* // "cancelled" → payment was cancelled — offer paymentLink.url or support
|
|
20
20
|
* // "error" → render error.message with a retry via reload()
|
|
21
21
|
*
|
|
22
|
-
* `lines` are the order's items in the decorated cart-line shape
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* `lines` are the order's items in the decorated cart-line shape —
|
|
23
|
+
* `attributesLabel`, `image` as `{src, alt}|null`, `totalLabel` pre-formatted —
|
|
24
|
+
* so the same row markup renders the bag and the confirmation; totals come
|
|
25
|
+
* from `useTotalsLines(order)`. The page is marked `noindex` automatically — a
|
|
25
26
|
* receipt carrying an order key must not rank (`seo: false` opts out).
|
|
26
27
|
*
|
|
27
28
|
* It reads `order_id`/`order_key`/`payment` from the URL itself and verifies
|
|
@@ -32,6 +33,7 @@ import { orderSeo, useStorefrontSeo } from "./useStorefrontSeo";
|
|
|
32
33
|
*/
|
|
33
34
|
export function useOrderReturn({ auto = true, seo = true } = {}) {
|
|
34
35
|
const { client } = useStorefrontState();
|
|
36
|
+
const formatMoney = useFormatMoney();
|
|
35
37
|
const [result, setResult] = useState({ status: auto ? "loading" : "idle" });
|
|
36
38
|
|
|
37
39
|
const reload = useCallback(
|
|
@@ -61,7 +63,7 @@ export function useOrderReturn({ auto = true, seo = true } = {}) {
|
|
|
61
63
|
|
|
62
64
|
useStorefrontSeo(seo ? orderSeo(result.order ?? null) : null);
|
|
63
65
|
|
|
64
|
-
return { ...result, lines: orderLines(result.order ?? null), reload };
|
|
66
|
+
return { ...result, lines: orderLines(result.order ?? null, { formatMoney }), reload };
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
/**
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useCheckoutContext } from "./useCheckout";
|
|
2
|
+
import { useCheckoutBlockers } from "./useTotalsLines";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* usePlaceOrder — the place-order gate as one spreadable surface. Needs a
|
|
6
|
+
* `<CheckoutProvider>` above it. The whole bottom of a checkout page:
|
|
7
|
+
*
|
|
8
|
+
* const order = usePlaceOrder();
|
|
9
|
+
* if (order.stage === "submitted") return <p>Order placed — taking you to your receipt…</p>;
|
|
10
|
+
* …
|
|
11
|
+
* <button {...order.buttonProps} className="…">{order.label}</button>
|
|
12
|
+
* {order.error && <p {...order.errorProps} className="…">{order.error.message}</p>}
|
|
13
|
+
* {!order.canPlaceOrder && order.blockers.map(b => <p key={b.code}>{b.message}</p>)}
|
|
14
|
+
*
|
|
15
|
+
* ⚑ **The `stage === "submitted"` guard goes above the page's empty-cart
|
|
16
|
+
* branch.** Placing an order clears the cart before the browser navigates
|
|
17
|
+
* away; without the guard the page flashes "your bag is empty" over a
|
|
18
|
+
* just-placed order. `stage` is `"editing" | "placing" | "submitted"`.
|
|
19
|
+
*
|
|
20
|
+
* `buttonProps` carries `onClick`, `disabled` (gate + in-flight) and
|
|
21
|
+
* `aria-busy`; `label` follows `placing` and is overridable via
|
|
22
|
+
* `labels: { idle, placing }`. `blockers` are the disabled button's reasons in
|
|
23
|
+
* words (`useCheckoutBlockers`), each with a `field` to anchor it next to the
|
|
24
|
+
* input that fixes it. Pass `blockerLabels` to override that copy per code.
|
|
25
|
+
*
|
|
26
|
+
* @param {{labels?: {idle?: string, placing?: string},
|
|
27
|
+
* blockerLabels?: Record<string, string>}} [options]
|
|
28
|
+
* @returns {{buttonProps: object, label: string,
|
|
29
|
+
* stage: "editing"|"placing"|"submitted", submitted: boolean,
|
|
30
|
+
* placing: boolean, canPlaceOrder: boolean, error: object|null,
|
|
31
|
+
* errorProps: object, blockers: Array<{code: string, message: string,
|
|
32
|
+
* field: string|null}>, placeOrder: (extra?: object) => Promise<object>}}
|
|
33
|
+
*/
|
|
34
|
+
export function usePlaceOrder({ labels, blockerLabels } = {}) {
|
|
35
|
+
const checkout = useCheckoutContext();
|
|
36
|
+
const blockers = useCheckoutBlockers({ labels: blockerLabels });
|
|
37
|
+
const { canPlaceOrder, placing, submitted, stage, orderError, placeOrder } = checkout;
|
|
38
|
+
return {
|
|
39
|
+
buttonProps: {
|
|
40
|
+
type: "button",
|
|
41
|
+
onClick: () => placeOrder(),
|
|
42
|
+
disabled: !canPlaceOrder || placing,
|
|
43
|
+
"aria-busy": placing || undefined,
|
|
44
|
+
},
|
|
45
|
+
label: placing ? (labels?.placing ?? "Placing your order…") : (labels?.idle ?? "Place order"),
|
|
46
|
+
stage,
|
|
47
|
+
submitted,
|
|
48
|
+
placing,
|
|
49
|
+
canPlaceOrder,
|
|
50
|
+
error: orderError,
|
|
51
|
+
errorProps: { role: "alert" },
|
|
52
|
+
blockers,
|
|
53
|
+
placeOrder,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
2
|
import {
|
|
3
3
|
defaultSelection,
|
|
4
|
+
productSpecs,
|
|
4
5
|
resolveSelection,
|
|
5
6
|
selectOption,
|
|
6
7
|
selectionFromParams,
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
storefrontErrorMessage,
|
|
10
11
|
} from "@/commerce/utils";
|
|
11
12
|
import { useCart, useStorefront } from "./StorefrontProvider";
|
|
13
|
+
import { useCartUIOptional } from "./cartUI";
|
|
12
14
|
import { useAsyncData } from "./internal/useAsyncData";
|
|
13
15
|
import { useProductPrice } from "./useProductPrice";
|
|
14
16
|
|
|
@@ -226,49 +228,67 @@ export function useAddToCart() {
|
|
|
226
228
|
return { add, adding, error, lastAdded, reset };
|
|
227
229
|
}
|
|
228
230
|
|
|
231
|
+
const BUY_LABELS = {
|
|
232
|
+
ready: "Add to bag",
|
|
233
|
+
adding: "Adding…",
|
|
234
|
+
sold_out: "Sold out",
|
|
235
|
+
needs_selection: "Select options",
|
|
236
|
+
};
|
|
237
|
+
|
|
229
238
|
/**
|
|
230
239
|
* useAddToCartButton — the buy button's whole state machine, ready to bind to
|
|
231
240
|
* markup you write. Pass the entire `useProduct` result:
|
|
232
241
|
*
|
|
233
242
|
* const p = useProduct(slug);
|
|
234
|
-
* const buy = useAddToCartButton(p, {
|
|
235
|
-
* <button
|
|
236
|
-
* {buy.adding ? "Adding…" : buy.soldOut ? "Sold out"
|
|
237
|
-
* : buy.needsSelection ? "Select options" : "Add to bag"}
|
|
238
|
-
* </button>
|
|
243
|
+
* const buy = useAddToCartButton(p, { labels: { ready: "Add to bag" } });
|
|
244
|
+
* <button {...buy.buttonProps} className="…">{buy.label}</button>
|
|
239
245
|
* {buy.error && <p role="alert">{buy.error.message}</p>}
|
|
240
246
|
*
|
|
247
|
+
* `state` is `"ready" | "adding" | "sold_out" | "needs_selection"` — the
|
|
248
|
+
* precedence is resolved here, not in a ternary chain — and `label` follows it
|
|
249
|
+
* (override any of the four via `labels`; the words are still yours).
|
|
250
|
+
* `buttonProps` carries onClick, the purchasability gate and `aria-busy`. With
|
|
251
|
+
* a `<CartUIProvider>` mounted, a successful add opens the cart drawer by
|
|
252
|
+
* itself (its `openOnAdd`); `onAdded` remains for a navigate-to-bag flow.
|
|
253
|
+
*
|
|
241
254
|
* What it wires so a hand-written buy box can't drop it: the button is gated on
|
|
242
255
|
* `view.purchasable`; a rejected add (sold out, stale variant) lands in `error`
|
|
243
256
|
* instead of leaving the button stuck on "Adding…"; a stale-variant rejection
|
|
244
257
|
* reloads the product; and the quantity controls respect `sold_individually`
|
|
245
258
|
* and tracked stock (`showQuantity` is false when only 1 can be bought — render
|
|
246
|
-
* no stepper then).
|
|
259
|
+
* no stepper then).
|
|
247
260
|
*
|
|
248
261
|
* A not-yet-loaded product is fine (`disabled: true`), so call this next to
|
|
249
262
|
* `useProduct` **above** the page's `loading`/`not_found` guards — a hook below
|
|
250
263
|
* an early return breaks the hook order the next render.
|
|
251
264
|
*
|
|
252
265
|
* @param {object} product the whole `useProduct` result
|
|
253
|
-
* @param {{onAdded?: (cart: object) => void
|
|
266
|
+
* @param {{onAdded?: (cart: object) => void,
|
|
267
|
+
* labels?: {ready?: string, adding?: string, sold_out?: string,
|
|
268
|
+
* needs_selection?: string}}} [options]
|
|
254
269
|
* @returns {{add: () => Promise<object>, adding: boolean, error: object|null,
|
|
255
270
|
* reset: () => void, disabled: boolean, soldOut: boolean,
|
|
256
271
|
* needsSelection: boolean, purchasable: boolean,
|
|
272
|
+
* state: "ready"|"adding"|"sold_out"|"needs_selection", label: string,
|
|
273
|
+
* buttonProps: object,
|
|
257
274
|
* quantity: number, setQuantity: (n: number) => void, increase: () => void,
|
|
258
275
|
* decrease: () => void, canIncrease: boolean, canDecrease: boolean,
|
|
259
276
|
* maxQuantity: number, showQuantity: boolean}}
|
|
260
277
|
*/
|
|
261
|
-
export function useAddToCartButton(product, { onAdded } = {}) {
|
|
278
|
+
export function useAddToCartButton(product, { onAdded, labels } = {}) {
|
|
262
279
|
const { add, adding, error, reset } = useAddToCart();
|
|
280
|
+
const cartUI = useCartUIOptional();
|
|
263
281
|
const view = product?.view ?? null;
|
|
264
282
|
|
|
265
283
|
const submit = useCallback(async () => {
|
|
266
284
|
if (!view) return { ok: false, error: { code: "no_product", message: "Product not loaded." } };
|
|
267
285
|
const res = await add(view.addToCart, product.quantity);
|
|
268
|
-
if (res.ok)
|
|
269
|
-
|
|
286
|
+
if (res.ok) {
|
|
287
|
+
cartUI?.onItemAdded?.();
|
|
288
|
+
onAdded?.(res.cart);
|
|
289
|
+
} else if (res.error?.shouldReload) product.reload?.();
|
|
270
290
|
return res;
|
|
271
|
-
}, [add, view, product, onAdded]);
|
|
291
|
+
}, [add, view, product, onAdded, cartUI]);
|
|
272
292
|
|
|
273
293
|
// A variable product with an incomplete selection isn't sold out — it needs
|
|
274
294
|
// a pick; only a resolved (or simple) unpurchasable view reads as sold out.
|
|
@@ -277,16 +297,27 @@ export function useAddToCartButton(product, { onAdded } = {}) {
|
|
|
277
297
|
? view.complete && !view.purchasable
|
|
278
298
|
: !view.purchasable
|
|
279
299
|
: false;
|
|
300
|
+
const needsSelection = Boolean(view?.isVariable && !view.complete);
|
|
301
|
+
const state = adding ? "adding" : soldOut ? "sold_out" : needsSelection ? "needs_selection" : "ready";
|
|
302
|
+
const disabled = !view?.purchasable || adding;
|
|
280
303
|
|
|
281
304
|
return {
|
|
282
305
|
add: submit,
|
|
283
306
|
adding,
|
|
284
307
|
error,
|
|
285
308
|
reset,
|
|
286
|
-
disabled
|
|
309
|
+
disabled,
|
|
287
310
|
soldOut,
|
|
288
|
-
needsSelection
|
|
311
|
+
needsSelection,
|
|
289
312
|
purchasable: Boolean(view?.purchasable),
|
|
313
|
+
state,
|
|
314
|
+
label: labels?.[state] ?? BUY_LABELS[state],
|
|
315
|
+
buttonProps: {
|
|
316
|
+
type: "button",
|
|
317
|
+
onClick: submit,
|
|
318
|
+
disabled,
|
|
319
|
+
"aria-busy": adding || undefined,
|
|
320
|
+
},
|
|
290
321
|
quantity: product?.quantity ?? 1,
|
|
291
322
|
setQuantity: product?.setQuantity ?? (() => {}),
|
|
292
323
|
increase: product?.incQuantity ?? (() => {}),
|
|
@@ -297,3 +328,52 @@ export function useAddToCartButton(product, { onAdded } = {}) {
|
|
|
297
328
|
showQuantity: (product?.maxQuantity ?? 1) > 1,
|
|
298
329
|
};
|
|
299
330
|
}
|
|
331
|
+
|
|
332
|
+
const normalizeSpecKey = (k) =>
|
|
333
|
+
String(k ?? "")
|
|
334
|
+
.toLowerCase()
|
|
335
|
+
.replace(/[\s_-]+/g, " ")
|
|
336
|
+
.trim();
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* useProductSpecs — `productSpecs` plus the lookup every page that features
|
|
340
|
+
* specific specs needs, with the matching solved. **Never match spec rows by
|
|
341
|
+
* `label` string equality** — a row's `label` is the admin's meta key verbatim
|
|
342
|
+
* (`"light level"`, lowercase), so `specs.find(s => s.label === "Light")`
|
|
343
|
+
* silently never matches and the feature renders its fallback forever. `get`
|
|
344
|
+
* and `pick` here match case-, `_`- and `-`-insensitively.
|
|
345
|
+
*
|
|
346
|
+
* const specs = useProductSpecs(product, { pick: ["light", "water", "humidity"] });
|
|
347
|
+
* specs.picked // the requested rows, in your order (missing ones skipped)
|
|
348
|
+
* specs.rest // everything else — safe to render as the remainder,
|
|
349
|
+
* // the picked rows are already excluded
|
|
350
|
+
* specs.get("light_level") // one row or null
|
|
351
|
+
* specs.rows // all rows (== productSpecs(product))
|
|
352
|
+
*
|
|
353
|
+
* Rows carry `titleLabel` (display-cased) next to the verbatim `label`, and the
|
|
354
|
+
* `type`/`number`/`unit`/`items` fields for type-driven rendering — see
|
|
355
|
+
* `productSpecs`.
|
|
356
|
+
*
|
|
357
|
+
* @param {object|null} product tolerates null/loading — call above status guards
|
|
358
|
+
* @param {{pick?: string[]}} [options]
|
|
359
|
+
* @returns {{rows: Array<object>, picked: Array<object>, rest: Array<object>,
|
|
360
|
+
* get: (key: string) => object|null, has: (key: string) => boolean}}
|
|
361
|
+
*/
|
|
362
|
+
export function useProductSpecs(product, { pick = [] } = {}) {
|
|
363
|
+
const pickKey = JSON.stringify(pick);
|
|
364
|
+
return useMemo(() => {
|
|
365
|
+
const rows = productSpecs(product);
|
|
366
|
+
const byKey = new Map(rows.map((r) => [normalizeSpecKey(r.key), r]));
|
|
367
|
+
const get = (key) => byKey.get(normalizeSpecKey(key)) ?? null;
|
|
368
|
+
const picked = pick.map(get).filter(Boolean);
|
|
369
|
+
const pickedSet = new Set(picked);
|
|
370
|
+
return {
|
|
371
|
+
rows,
|
|
372
|
+
picked,
|
|
373
|
+
rest: rows.filter((r) => !pickedSet.has(r)),
|
|
374
|
+
get,
|
|
375
|
+
has: (key) => byKey.has(normalizeSpecKey(key)),
|
|
376
|
+
};
|
|
377
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
378
|
+
}, [product, pickKey]);
|
|
379
|
+
}
|
|
@@ -9,6 +9,7 @@ import { useAsyncData } from "./internal/useAsyncData";
|
|
|
9
9
|
* const list = useProductList({ per_page: 12, sort: "-created_date" });
|
|
10
10
|
* // list.status: "loading" | "ready" | "empty" | "error"
|
|
11
11
|
* // list.products, list.hasNext, list.next(), list.setParams({ category_id })
|
|
12
|
+
* <button {...list.moreProps} className="…">Load more</button> // hides itself on the last page
|
|
12
13
|
*
|
|
13
14
|
* A short strip is the same hook with a small `per_page` — a featured rail, a
|
|
14
15
|
* "new in" row, four picks beside an article:
|
|
@@ -104,7 +105,18 @@ export function useProductList(initialParams = {}, options = {}) {
|
|
|
104
105
|
const isEmpty = !loading && !error && products.length === 0;
|
|
105
106
|
const status = loading ? "loading" : error ? "error" : isEmpty ? "empty" : "ready";
|
|
106
107
|
|
|
108
|
+
// Spread on the "Load more" / "Next" button — it disables while a page is in
|
|
109
|
+
// flight and removes itself when there is no next page, so paging can't be
|
|
110
|
+
// silently dropped (`hasNext` unrendered = a catalog capped at one page).
|
|
111
|
+
const moreProps = {
|
|
112
|
+
type: "button",
|
|
113
|
+
onClick: appendMode ? loadMore : next,
|
|
114
|
+
disabled: loading || refreshing || !hasNext,
|
|
115
|
+
hidden: !hasNext,
|
|
116
|
+
};
|
|
117
|
+
|
|
107
118
|
return {
|
|
119
|
+
moreProps,
|
|
108
120
|
products,
|
|
109
121
|
page: data?.page ?? params.page ?? 1,
|
|
110
122
|
perPage: data?.per_page ?? params.per_page,
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import { productImages } from "@/commerce/utils";
|
|
3
|
+
import { useCart, useStorefront } from "./StorefrontProvider";
|
|
4
|
+
import { useCartUIOptional } from "./cartUI";
|
|
5
|
+
import { useAsyncData } from "./internal/useAsyncData";
|
|
6
|
+
import { useAddToCart } from "./useProduct";
|
|
7
|
+
import { useProductPrice } from "./useProductPrice";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* useUpsell — one product offered beside another surface (a cart drawer's
|
|
11
|
+
* "add the care kit", a checkout's "complete the set", a cross-sell strip
|
|
12
|
+
* entry), with the lifecycle solved:
|
|
13
|
+
*
|
|
14
|
+
* const kit = useUpsell("care-kit"); // slug or { id }…
|
|
15
|
+
* const kit = useUpsell(p.crossSells[0]); // …or a row you already have
|
|
16
|
+
* {kit.show && (
|
|
17
|
+
* <aside className="…">
|
|
18
|
+
* {kit.image && <img src={kit.image.src} alt={kit.image.alt} className="…" />}
|
|
19
|
+
* {kit.product.name} {kit.price.label}
|
|
20
|
+
* <button type="button" onClick={kit.add} disabled={kit.adding}>Add</button>
|
|
21
|
+
* {kit.error && <p role="alert">{kit.error.message}</p>}
|
|
22
|
+
* </aside>
|
|
23
|
+
* )}
|
|
24
|
+
*
|
|
25
|
+
* `show` is the one flag to branch on: it is false while loading, on a fetch
|
|
26
|
+
* failure, when the product is out of stock, and **when it is already in the
|
|
27
|
+
* cart** — matched by product id, never by display name (a rename must not
|
|
28
|
+
* break the match). Passing a row you already hold (`product.upsells` /
|
|
29
|
+
* `product.crossSells` from `useProduct`, or a `useProductList` row) skips the
|
|
30
|
+
* fetch entirely — prefer that when the row is on hand.
|
|
31
|
+
*
|
|
32
|
+
* `add()` never throws; a product with options can't be one-click added
|
|
33
|
+
* (`needsSelection` — link to its page instead). With `<CartUIProvider>`
|
|
34
|
+
* mounted, a successful add opens the drawer.
|
|
35
|
+
*
|
|
36
|
+
* @param {string|{id: string}|object} ref slug, `{ id }`, or a product row
|
|
37
|
+
* @param {{quantity?: number}} [options]
|
|
38
|
+
* @returns {{show: boolean, inCart: boolean, needsSelection: boolean,
|
|
39
|
+
* product: object|null, image: {src: string, alt: string}|null,
|
|
40
|
+
* price: object, add: () => Promise<object>, adding: boolean,
|
|
41
|
+
* error: object|null}}
|
|
42
|
+
*/
|
|
43
|
+
export function useUpsell(ref, { quantity = 1 } = {}) {
|
|
44
|
+
const store = useStorefront();
|
|
45
|
+
const { cart } = useCart();
|
|
46
|
+
const cartUI = useCartUIOptional();
|
|
47
|
+
|
|
48
|
+
// A row (has id + name) is used as-is; a slug or { id } is fetched.
|
|
49
|
+
const isRow = Boolean(ref && typeof ref === "object" && ref.id && ref.name !== undefined);
|
|
50
|
+
const refKey = isRow ? `row:${ref.id}` : typeof ref === "string" ? ref : JSON.stringify(ref ?? null);
|
|
51
|
+
|
|
52
|
+
const { data, loading, error: fetchError } = useAsyncData(
|
|
53
|
+
() => (isRow || !ref ? Promise.resolve(null) : store.getProduct(ref)),
|
|
54
|
+
[store, refKey], // eslint-disable-line react-hooks/exhaustive-deps
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const product = isRow ? ref : (data?.product ?? null);
|
|
58
|
+
const needsSelection = isRow
|
|
59
|
+
? (ref.attributes?.length ?? 0) > 0
|
|
60
|
+
: (data?.variations?.length ?? 0) > 0;
|
|
61
|
+
|
|
62
|
+
const price = useProductPrice(product);
|
|
63
|
+
const { add: rawAdd, adding, error, reset } = useAddToCart();
|
|
64
|
+
|
|
65
|
+
const add = useCallback(async () => {
|
|
66
|
+
if (!product) return { ok: false, error: { code: "no_product", message: "Product not loaded." } };
|
|
67
|
+
if (needsSelection) {
|
|
68
|
+
return { ok: false, error: { code: "variation_required", message: "Choose an option first." } };
|
|
69
|
+
}
|
|
70
|
+
const res = await rawAdd({ product_id: product.id }, quantity);
|
|
71
|
+
if (res.ok) cartUI?.onItemAdded?.();
|
|
72
|
+
return res;
|
|
73
|
+
}, [product, needsSelection, rawAdd, quantity, cartUI]);
|
|
74
|
+
|
|
75
|
+
const inCart = Boolean(product) && (cart?.items ?? []).some((i) => i.product_id === product.id);
|
|
76
|
+
const busy = !isRow && Boolean(ref) && loading;
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
show: !busy && !fetchError && Boolean(product) && !inCart && product?.stock_status !== "outofstock",
|
|
80
|
+
inCart,
|
|
81
|
+
needsSelection,
|
|
82
|
+
product,
|
|
83
|
+
image: productImages(product)[0] ?? null,
|
|
84
|
+
price,
|
|
85
|
+
add,
|
|
86
|
+
adding,
|
|
87
|
+
error,
|
|
88
|
+
reset,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -25,8 +25,9 @@
|
|
|
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
|
-
* can be rendered
|
|
28
|
+
* - `specs.js` — `productSpecs`: `meta_data` → descriptive rows with an inferred
|
|
29
|
+
* `type` (numeric/duration/location/list/text), so each can be rendered as
|
|
30
|
+
* what it is rather than as another label/value row.
|
|
30
31
|
*
|
|
31
32
|
* Building the storefront in React? **Prefer `@/commerce/storefront`** — it
|
|
32
33
|
* layers headless hooks on top of this module, and a hook that pre-composes
|
|
@@ -1,32 +1,119 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Product spec rows — the descriptive properties a product page shows
|
|
3
|
-
* (Material, Care, Fit, Provenance, Composition).
|
|
3
|
+
* (Material, Care, Fit, Provenance, Composition, Weight).
|
|
4
4
|
*
|
|
5
5
|
* These live in `product.meta_data` (the admin's *Modifiers* section) and are
|
|
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
|
-
* You render the rows yourself, and `key` is there so you don't have to render
|
|
9
|
-
* them all the same way — a uniform list is the fallback, not the target:
|
|
10
8
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* ```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} />)}
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
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.
|
|
19
35
|
*
|
|
20
36
|
* @param {object} product
|
|
21
|
-
* @returns {Array<{key: string, label: string,
|
|
22
|
-
*
|
|
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[]}>}
|
|
40
|
+
* `[]` 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.
|
|
23
45
|
*/
|
|
24
46
|
export function productSpecs(product) {
|
|
25
47
|
return (product?.meta_data ?? [])
|
|
26
48
|
.filter((m) => m?.key && !String(m.key).startsWith("_") && m.value != null && m.value !== "")
|
|
27
|
-
.map((m) =>
|
|
28
|
-
key
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
49
|
+
.map((m) => {
|
|
50
|
+
const key = String(m.key);
|
|
51
|
+
const value = String(m.value);
|
|
52
|
+
const label = key.replace(/_/g, " ");
|
|
53
|
+
// `label` is the admin's key verbatim ("light level"); `titleLabel` is
|
|
54
|
+
// the display-cased form ("Light Level") — no store wants a lowercase <dt>.
|
|
55
|
+
const titleLabel = label.replace(/(^|\s)\p{Ll}/gu, (c) => c.toUpperCase());
|
|
56
|
+
return { key, label, titleLabel, value, ...classify(key, value) };
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
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;
|
|
106
|
+
}
|
|
107
|
+
|
|
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;
|
|
32
119
|
}
|