@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
|
@@ -20,39 +20,23 @@ 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
|
-
*
|
|
23
|
+
* **When `pending` settles.** It is this row's flag, not the cart's, and not
|
|
24
|
+
* true for the whole gesture: a click only sets the optimistic quantity and
|
|
25
|
+
* (re)starts the 250ms timer, so a burst of clicks sends **one** request;
|
|
26
|
+
* `pending` goes true when that request leaves (`remove()` skips the wait); it
|
|
27
|
+
* returns to false only after the server's new cart view has been published
|
|
28
|
+
* through the provider's queue. So `pending === false` with `error === null`
|
|
29
|
+
* means this row, the cart's totals and any badge are all settled — it is the
|
|
30
|
+
* only mutation-settled signal (`useCart().status` never returns to
|
|
31
|
+
* `"loading"` for a mutation), and what a script driving the page waits on.
|
|
32
|
+
* Scope the busy state to this row: greying the whole cart for a 250ms step
|
|
33
|
+
* reads as a page-wide stall. On failure the quantity rolls back to what the
|
|
34
|
+
* server still holds and `error` is `{ code, message }`.
|
|
24
35
|
*
|
|
25
|
-
*
|
|
26
|
-
* the
|
|
36
|
+
* It owns the quantity only. The row's own content — name, `attributesLabel`,
|
|
37
|
+
* image, money — you render from the `cart.items[n]` you passed in.
|
|
27
38
|
*
|
|
28
|
-
*
|
|
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
|
-
*
|
|
54
|
-
* @param {object} line a decorated line from `useCart().lines` (a raw
|
|
55
|
-
* `cart.items[n]` works too — it just has no `maxQuantity` hint)
|
|
39
|
+
* @param {object} line one `cart.items[n]` from `useCart()`
|
|
56
40
|
* @param {{debounceMs?: number}} [options]
|
|
57
41
|
*/
|
|
58
42
|
export function useCartLine(line, { debounceMs = 250 } = {}) {
|
|
@@ -133,10 +117,6 @@ export function useCartLine(line, { debounceMs = 250 } = {}) {
|
|
|
133
117
|
maxQuantity,
|
|
134
118
|
atMax: quantity >= maxQuantity,
|
|
135
119
|
atMin: quantity <= 1,
|
|
136
|
-
attributesLabel: line?.attributesLabel ?? "",
|
|
137
|
-
image: line?.image ?? null,
|
|
138
|
-
totalLabel: line?.totalLabel ?? "",
|
|
139
|
-
unitPriceLabel: line?.unitPriceLabel ?? "",
|
|
140
120
|
};
|
|
141
121
|
}
|
|
142
122
|
|
|
@@ -144,29 +124,18 @@ export function useCartLine(line, { debounceMs = 250 } = {}) {
|
|
|
144
124
|
* CartLine — headless per-line binding for the rows of a cart you render
|
|
145
125
|
* yourself. It renders **nothing**: the render function you pass as `children`
|
|
146
126
|
* receives the `useCartLine` controls for that line and returns your markup.
|
|
147
|
-
* It exists so
|
|
127
|
+
* It exists so an `items.map(...)` doesn't tempt a hook call inside a loop:
|
|
148
128
|
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
* {(l) => (
|
|
153
|
-
* <li aria-busy={l.pending}>
|
|
154
|
-
* {line.name} {line.attributesLabel}
|
|
155
|
-
* <button onClick={l.decrease} disabled={!l.canDecrease || l.pending}
|
|
156
|
-
* aria-label={`Decrease quantity of ${line.name}`}>−</button>
|
|
157
|
-
* {l.quantity}
|
|
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}
|
|
162
|
-
* {l.error && <p role="alert">{l.error.message}</p>}
|
|
163
|
-
* </li>
|
|
164
|
-
* )}
|
|
129
|
+
* {cart.items.map(item => (
|
|
130
|
+
* <CartLine key={item.item_key} line={item}>
|
|
131
|
+
* {(l) => <li aria-busy={l.pending}>…your row, using item and l…</li>}
|
|
165
132
|
* </CartLine>
|
|
166
133
|
* ))}
|
|
167
134
|
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
135
|
+
* Give each row's controls their own accessible name (`Remove ${item.name}`):
|
|
136
|
+
* three buttons all named "Remove" are ambiguous to a screen reader and to a
|
|
137
|
+
* script driving the page. Equivalent to extracting your own row component that
|
|
138
|
+
* calls `useCartLine` — use whichever reads better in your page.
|
|
170
139
|
*
|
|
171
140
|
* @param {{line: object, options?: {debounceMs?: number},
|
|
172
141
|
* children: (controls: object) => React.ReactNode}} props
|
|
@@ -179,79 +148,3 @@ export function CartLine({ line, options, children }) {
|
|
|
179
148
|
if (!line) return null;
|
|
180
149
|
return children(controls);
|
|
181
150
|
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* useCoupon — the coupon field. Small, and the difference between a store that
|
|
185
|
-
* can honour its own discounts and one that cannot.
|
|
186
|
-
*
|
|
187
|
-
* const c = useCoupon();
|
|
188
|
-
* <input value={c.code} onChange={(e) => c.setCode(e.target.value)} />
|
|
189
|
-
* <button onClick={c.apply} disabled={c.applying}>Apply</button>
|
|
190
|
-
* {c.error && <p role="alert">{c.error.message}</p>}
|
|
191
|
-
* {c.applied.map(a => <Chip key={a.code} onRemove={() => c.remove(a.code)} …/>)}
|
|
192
|
-
*
|
|
193
|
-
* Coupons are admin-only data — a storefront cannot list codes, so a seeded
|
|
194
|
-
* code is reachable **only** through a field the customer types it into. If the
|
|
195
|
-
* store has any coupons, this field must exist somewhere in the cart or the
|
|
196
|
-
* checkout, or those codes can never be redeemed.
|
|
197
|
-
*
|
|
198
|
-
* An invalid, expired or ineligible code is **expected flow**: `apply()`
|
|
199
|
-
* resolves `{ ok: false, error }` and never throws — render `error.message`
|
|
200
|
-
* inline next to the field.
|
|
201
|
-
*/
|
|
202
|
-
export function useCoupon() {
|
|
203
|
-
const { cart, applyCoupon, removeCoupon } = useCart();
|
|
204
|
-
const [code, setCode] = useState("");
|
|
205
|
-
const [applying, setApplying] = useState(false);
|
|
206
|
-
const [error, setError] = useState(null);
|
|
207
|
-
|
|
208
|
-
const apply = useCallback(
|
|
209
|
-
async (explicit) => {
|
|
210
|
-
const value = String(explicit ?? code ?? "").trim();
|
|
211
|
-
if (!value) return { ok: false, error: { code: "empty", message: "Enter a code." } };
|
|
212
|
-
setApplying(true);
|
|
213
|
-
setError(null);
|
|
214
|
-
const res = await applyCoupon(value);
|
|
215
|
-
setApplying(false);
|
|
216
|
-
if (res.ok) {
|
|
217
|
-
setCode("");
|
|
218
|
-
return { ok: true, cart: res.cart };
|
|
219
|
-
}
|
|
220
|
-
const err = { code: res.code ?? "coupon_invalid", message: res.message };
|
|
221
|
-
setError(err);
|
|
222
|
-
return { ok: false, error: err };
|
|
223
|
-
},
|
|
224
|
-
[code, applyCoupon],
|
|
225
|
-
);
|
|
226
|
-
|
|
227
|
-
const remove = useCallback(
|
|
228
|
-
async (codeOrEntry) => {
|
|
229
|
-
const value = typeof codeOrEntry === "string" ? codeOrEntry : codeOrEntry?.code;
|
|
230
|
-
setError(null);
|
|
231
|
-
try {
|
|
232
|
-
await removeCoupon(value);
|
|
233
|
-
return { ok: true };
|
|
234
|
-
} catch (e) {
|
|
235
|
-
const err = { code: storefrontErrorCode(e) ?? "error", message: storefrontErrorMessage(e) };
|
|
236
|
-
setError(err);
|
|
237
|
-
return { ok: false, error: err };
|
|
238
|
-
}
|
|
239
|
-
},
|
|
240
|
-
[removeCoupon],
|
|
241
|
-
);
|
|
242
|
-
|
|
243
|
-
return {
|
|
244
|
-
code,
|
|
245
|
-
setCode,
|
|
246
|
-
apply,
|
|
247
|
-
remove,
|
|
248
|
-
applied: (cart?.coupons ?? []).map((c) => ({
|
|
249
|
-
code: c.code,
|
|
250
|
-
discount: c.discount ?? 0,
|
|
251
|
-
freeShipping: Boolean(c.free_shipping),
|
|
252
|
-
})),
|
|
253
|
-
applying,
|
|
254
|
-
error,
|
|
255
|
-
discountTotal: cart?.totals?.discount_total ?? 0,
|
|
256
|
-
};
|
|
257
|
-
}
|
|
@@ -39,50 +39,44 @@ function resolvePaymentMethod(gateways, picked) {
|
|
|
39
39
|
* checkout must get right, so the page you build is only markup around it:
|
|
40
40
|
*
|
|
41
41
|
* - **Address → shipping/tax recalculation, automatic.** Edit the billing (or
|
|
42
|
-
* separate shipping) address through `updateBilling`/`updateShipping`;
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* `set-shipping-address`,
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* - **Shipping choice.** `shippingStatus` mirrors the cart
|
|
50
|
-
*
|
|
51
|
-
* `
|
|
52
|
-
* `
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* is
|
|
42
|
+
* separate shipping) address through `updateBilling`/`updateShipping`; once
|
|
43
|
+
* the address is complete enough to price (default: country + city, see
|
|
44
|
+
* `isShippingAddressComplete`), the hook debounces and calls
|
|
45
|
+
* `set-shipping-address`, recalculating every option, its cost and the taxes.
|
|
46
|
+
* Half-typed addresses are never sent, an unchanged address is never re-sent,
|
|
47
|
+
* and an address the store doesn't ship to surfaces as `addressError` (the
|
|
48
|
+
* server's own words) — render it on the address fields.
|
|
49
|
+
* - **Shipping choice.** `shippingStatus` mirrors the cart: `auto_selected`
|
|
50
|
+
* (one option, already applied — display `chosenShippingMethod`), `chosen`,
|
|
51
|
+
* `choice_required` (render `shippingMethods`, call
|
|
52
|
+
* `chooseShippingMethod(id)`), `missing_address`, `none_available`,
|
|
53
|
+
* `not_needed` (virtual cart — render nothing). `singleShippingMethod` flags
|
|
54
|
+
* the one-option case and `chosenShippingMethod` is filled for it, so a single
|
|
55
|
+
* option is never left unselected and never rendered as a picker of one.
|
|
56
56
|
* - **Payment choice.** `paymentMethods` come from store info (their ONLY
|
|
57
|
-
* source
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* properly with no extra wiring. Resolves to `{ ok: true, result }` or
|
|
72
|
-
* `{ ok: false, error }`. Pass `orderReceivedPath: null` to handle the
|
|
73
|
-
* result yourself (a manual-gateway result carries
|
|
57
|
+
* source — `cart.payment_gateways` is always undefined). One enabled gateway
|
|
58
|
+
* is selected from the first render that has store info. The selection is
|
|
59
|
+
* derived from the current list, not remembered: a gateway the admin disables
|
|
60
|
+
* drops out, and if that leaves one, it takes over.
|
|
61
|
+
* - **The gate.** `canPlaceOrder` + `blockers` say what still stands between the
|
|
62
|
+
* customer and the order — drive the button's disabled state and the
|
|
63
|
+
* what's-missing lines from them instead of re-deriving.
|
|
64
|
+
* - **placeOrder.** Sends the order, clears the shared cart, and navigates: an
|
|
65
|
+
* online gateway redirects to the provider (`redirectToPayment`), everything
|
|
66
|
+
* else lands on `orderReceivedPath` (default `/order-received`) — which is
|
|
67
|
+
* where a manual order's payment instructions are rendered, so the offline
|
|
68
|
+
* default confirms properly with no extra wiring. Resolves `{ ok: true,
|
|
69
|
+
* result }` or `{ ok: false, error }`. Pass `orderReceivedPath: null` to
|
|
70
|
+
* handle the result yourself (a manual result carries
|
|
74
71
|
* `result.payment_instructions`).
|
|
75
72
|
*
|
|
76
|
-
* Both navigations are **full page loads** (`window.location.assign`)
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
* `orderReceivedUrl(result)`. For a client-side transition instead, pass
|
|
84
|
-
* `orderReceivedPath: null` and `navigate(orderReceivedUrl(result))`
|
|
85
|
-
* yourself.
|
|
73
|
+
* Both navigations are **full page loads** (`window.location.assign`): the
|
|
74
|
+
* provider hop leaves the app, and the receipt boots from the URL alone
|
|
75
|
+
* (`order_id` + `order_key`), so a reload there is correct and shareable.
|
|
76
|
+
* React state does not survive it, and a browser script driving checkout
|
|
77
|
+
* loses its page context here — the order *is* placed, so verify by
|
|
78
|
+
* navigating fresh to `orderReceivedUrl(result)`. For a client-side
|
|
79
|
+
* transition, pass `orderReceivedPath: null` and navigate yourself.
|
|
86
80
|
*
|
|
87
81
|
* Blocker codes, in the order checked: `cart_loading`, `empty_cart`,
|
|
88
82
|
* `billing_incomplete`, `shipping_address_incomplete`, `shipping_recalculating`,
|
|
@@ -93,7 +87,14 @@ function resolvePaymentMethod(gateways, picked) {
|
|
|
93
87
|
* accepted; the navigation away is already in flight). **Guard on it before the
|
|
94
88
|
* cart's empty branch** — `placeOrder` clears the cart, so a page that checks
|
|
95
89
|
* only `cart.status` repaints "your bag is empty" over a just-placed order for
|
|
96
|
-
* the frames before the browser leaves.
|
|
90
|
+
* the frames before the browser leaves.
|
|
91
|
+
*
|
|
92
|
+
* The button is `disabled={!canPlaceOrder || placing}`, and `placeOrder` works
|
|
93
|
+
* as an `onClick` handler directly (a click event is not read as order fields).
|
|
94
|
+
* A disabled button must still say why: render one line per `blockers` code, in
|
|
95
|
+
* the store's own words — the silent disabled button is the most common
|
|
96
|
+
* checkout dead end. The codes are listed above; each maps to one thing the
|
|
97
|
+
* customer can fix.
|
|
97
98
|
*
|
|
98
99
|
* Options: `debounceMs` (600), `addressComplete` (predicate overriding the
|
|
99
100
|
* country+city rule), `requiredBillingFields`, `redirectToPayment` (true),
|
|
@@ -214,6 +215,12 @@ export function useCheckout(options = {}) {
|
|
|
214
215
|
const placeOrder = useCallback(
|
|
215
216
|
async (extra = {}) => {
|
|
216
217
|
if (placing) return { ok: false, error: { code: "placing", message: "Order already being placed." } };
|
|
218
|
+
// Safe as `onClick={checkout.placeOrder}`: a DOM/React event is not
|
|
219
|
+
// `extra`. Spreading one into the order payload would send a circular
|
|
220
|
+
// SyntheticEvent as order fields — an explicit object still forwards.
|
|
221
|
+
const isEvent =
|
|
222
|
+
extra && typeof extra === "object" && ("nativeEvent" in extra || "target" in extra);
|
|
223
|
+
const fields = isEvent ? {} : extra;
|
|
217
224
|
setPlacing(true);
|
|
218
225
|
setOrderError(null);
|
|
219
226
|
try {
|
|
@@ -221,7 +228,7 @@ export function useCheckout(options = {}) {
|
|
|
221
228
|
payment_method: paymentMethod,
|
|
222
229
|
billing,
|
|
223
230
|
...(shipToDifferent ? { shipping } : {}),
|
|
224
|
-
...
|
|
231
|
+
...fields, // customer_note, success_url/cancel_url overrides, …
|
|
225
232
|
});
|
|
226
233
|
// `submitted` flips BEFORE the cart clears, in the same commit — the
|
|
227
234
|
// page's `stage === "submitted"` guard is what stands between a placed
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useState } from "react";
|
|
2
2
|
import { orderLines, storefrontErrorCode, storefrontErrorMessage } from "@/commerce/utils";
|
|
3
3
|
import { useFormatMoney, useStorefrontState } from "./StorefrontProvider";
|
|
4
|
-
import { orderSeo, useStorefrontSeo } from "./useStorefrontSeo";
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
6
|
* useOrderReturn — the whole `/order-received` page in one hook. Mount the
|
|
@@ -19,11 +18,12 @@ import { orderSeo, useStorefrontSeo } from "./useStorefrontSeo";
|
|
|
19
18
|
* // "cancelled" → payment was cancelled — offer paymentLink.url or support
|
|
20
19
|
* // "error" → render error.message with a retry via reload()
|
|
21
20
|
*
|
|
22
|
-
* `lines` are the order's items
|
|
23
|
-
* `
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
21
|
+
* `lines` are the order's items normalized for rendering — `attributesLabel`,
|
|
22
|
+
* `image` as `{src, alt}|null`, `totalLabel` pre-formatted — because a receipt
|
|
23
|
+
* reusing cart-row markup otherwise reads `item.image` as an object and paints
|
|
24
|
+
* a broken image. Totals: `orderTotalsLines(order, { formatMoney })` from
|
|
25
|
+
* `@/commerce/utils`. The page is marked `noindex` automatically — a receipt
|
|
26
|
+
* carrying an order key must not rank (`seo: false` opts out).
|
|
27
27
|
*
|
|
28
28
|
* It reads `order_id`/`order_key`/`payment` from the URL itself and verifies
|
|
29
29
|
* with the payment provider server-side — safe and idempotent on every visit.
|
|
@@ -61,7 +61,17 @@ export function useOrderReturn({ auto = true, seo = true } = {}) {
|
|
|
61
61
|
if (auto) reload();
|
|
62
62
|
}, [auto, reload]);
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
// A receipt's URL carries an order key, so the page must not rank. One meta
|
|
65
|
+
// tag, added on mount and removed on unmount; the rest of this page's head —
|
|
66
|
+
// title, description — is yours to set. `seo: false` opts out entirely.
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (!seo || typeof document === "undefined") return undefined;
|
|
69
|
+
const tag = document.createElement("meta");
|
|
70
|
+
tag.setAttribute("name", "robots");
|
|
71
|
+
tag.setAttribute("content", "noindex, nofollow");
|
|
72
|
+
document.head.appendChild(tag);
|
|
73
|
+
return () => tag.remove();
|
|
74
|
+
}, [seo]);
|
|
65
75
|
|
|
66
76
|
return { ...result, lines: orderLines(result.order ?? null, { formatMoney }), reload };
|
|
67
77
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
2
|
import {
|
|
3
3
|
defaultSelection,
|
|
4
|
-
|
|
4
|
+
productPrice,
|
|
5
5
|
resolveSelection,
|
|
6
6
|
selectOption,
|
|
7
7
|
selectionFromParams,
|
|
@@ -9,20 +9,17 @@ import {
|
|
|
9
9
|
storefrontErrorCode,
|
|
10
10
|
storefrontErrorMessage,
|
|
11
11
|
} from "@/commerce/utils";
|
|
12
|
-
import { useCart, useStorefront } from "./StorefrontProvider";
|
|
12
|
+
import { useCart, useFormatMoney, useStorefront } from "./StorefrontProvider";
|
|
13
13
|
import { useCartUIOptional } from "./cartUI";
|
|
14
14
|
import { useAsyncData } from "./internal/useAsyncData";
|
|
15
|
-
import { useProductPrice } from "./useProductPrice";
|
|
16
15
|
|
|
17
16
|
/**
|
|
18
17
|
* useProduct — the product page's whole data and selection lifecycle.
|
|
19
18
|
*
|
|
20
|
-
* const { status, product, view, pick, price, quantity
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* // render: price.label, view.axes (one control each), view.availability,
|
|
25
|
-
* // view.purchasable, view.addToCart
|
|
19
|
+
* const { status, product, view, pick, price, quantity } = useProduct(slug);
|
|
20
|
+
* // status: "loading" | "ready" | "not_found" | "error"
|
|
21
|
+
* // then: price.label, view.axes (one control each — variantAxes(view, pick)
|
|
22
|
+
* // makes them render-ready), view.purchasable, view.addToCart
|
|
26
23
|
*
|
|
27
24
|
* It composes the variant helpers so their rules hold by default:
|
|
28
25
|
* `defaultSelection` seeds the merchant's defaults, `selectOption` applies a
|
|
@@ -121,7 +118,8 @@ export function useProduct(ref, options = {}) {
|
|
|
121
118
|
const incQuantity = useCallback(() => setQuantity(quantity + 1), [quantity, setQuantity]);
|
|
122
119
|
const decQuantity = useCallback(() => setQuantity(quantity - 1), [quantity, setQuantity]);
|
|
123
120
|
|
|
124
|
-
const
|
|
121
|
+
const formatMoney = useFormatMoney();
|
|
122
|
+
const price = useMemo(() => productPrice(view, { formatMoney }), [view, formatMoney]);
|
|
125
123
|
|
|
126
124
|
const notFound = error?.code === "not_found";
|
|
127
125
|
const status = loading
|
|
@@ -163,24 +161,21 @@ export function useProduct(ref, options = {}) {
|
|
|
163
161
|
}
|
|
164
162
|
|
|
165
163
|
/**
|
|
166
|
-
*
|
|
164
|
+
* Internal: the raw add-to-cart call with its failure states handled.
|
|
165
|
+
* `add(addToCartRef, quantity)` **never throws** and always resolves —
|
|
166
|
+
* `{ ok: true, cart }` or `{ ok: false, error: { code, message, shouldReload } }`.
|
|
167
|
+
* That matters because the natural hand-written version (`await addItem(...)`
|
|
168
|
+
* with no catch) leaves a button stuck mid-add forever the first time a variant
|
|
169
|
+
* sells out. `shouldReload` is set for `variation_not_found` — the page's data
|
|
170
|
+
* is stale, so call the product's `reload()`.
|
|
167
171
|
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
* {adding ? "Adding…" : "Add to bag"}
|
|
172
|
-
* </button>
|
|
173
|
-
* {error && <p role="alert">{error.message}</p>}
|
|
174
|
-
*
|
|
175
|
-
* `add()` **never throws** and always resolves — `{ ok: true, cart }` or
|
|
176
|
-
* `{ ok: false, error: { code, message, shouldReload } }`. That matters because
|
|
177
|
-
* the natural hand-written version (`await addItem(...)` with no catch) leaves
|
|
178
|
-
* the button stuck on "Adding…" forever the first time a variant sells out.
|
|
172
|
+
* `error.message` is the server's own words when the server rejected the add;
|
|
173
|
+
* for the local `variation_required` guard it is null, because `useAddToCart`'s
|
|
174
|
+
* `state === "needs_selection"` is what a page renders for that.
|
|
179
175
|
*
|
|
180
|
-
*
|
|
181
|
-
* so call the product's `reload()`.
|
|
176
|
+
* Pages use `useAddToCart(product)` below.
|
|
182
177
|
*/
|
|
183
|
-
|
|
178
|
+
function useAddItem() {
|
|
184
179
|
const { addItem } = useCart();
|
|
185
180
|
const [adding, setAdding] = useState(false);
|
|
186
181
|
const [error, setError] = useState(null);
|
|
@@ -190,11 +185,7 @@ export function useAddToCart() {
|
|
|
190
185
|
async (addToCartRef, quantity = 1) => {
|
|
191
186
|
if (adding) return { ok: false, error: { code: "adding", message: "Already adding." } };
|
|
192
187
|
if (!addToCartRef) {
|
|
193
|
-
const err = {
|
|
194
|
-
code: "variation_required",
|
|
195
|
-
message: "Choose an option first.",
|
|
196
|
-
shouldReload: false,
|
|
197
|
-
};
|
|
188
|
+
const err = { code: "variation_required", message: null, shouldReload: false };
|
|
198
189
|
setError(err);
|
|
199
190
|
return { ok: false, error: err };
|
|
200
191
|
}
|
|
@@ -228,59 +219,58 @@ export function useAddToCart() {
|
|
|
228
219
|
return { add, adding, error, lastAdded, reset };
|
|
229
220
|
}
|
|
230
221
|
|
|
231
|
-
const BUY_LABELS = {
|
|
232
|
-
ready: "Add to bag",
|
|
233
|
-
adding: "Adding…",
|
|
234
|
-
sold_out: "Sold out",
|
|
235
|
-
needs_selection: "Select options",
|
|
236
|
-
};
|
|
237
|
-
|
|
238
222
|
/**
|
|
239
|
-
*
|
|
240
|
-
*
|
|
223
|
+
* useAddToCart — the buy box's whole state machine as plain states and
|
|
224
|
+
* handlers; every element, attribute and **word** of the markup is yours. Pass
|
|
225
|
+
* the entire `useProduct` result:
|
|
241
226
|
*
|
|
227
|
+
* const BUY = { // your words, written once per store
|
|
228
|
+
* ready: "Add to bag", adding: "Adding…",
|
|
229
|
+
* sold_out: "Sold out", needs_selection: "Choose a size",
|
|
230
|
+
* };
|
|
242
231
|
* const p = useProduct(slug);
|
|
243
|
-
* const buy =
|
|
244
|
-
* <button {
|
|
245
|
-
*
|
|
232
|
+
* const buy = useAddToCart(p);
|
|
233
|
+
* <button type="button" onClick={buy.addToCart} disabled={buy.disabled} className="…">
|
|
234
|
+
* {BUY[buy.state]}
|
|
235
|
+
* </button>
|
|
236
|
+
* {buy.error?.message && <p role="alert">{buy.error.message}</p>}
|
|
246
237
|
*
|
|
247
238
|
* `state` is `"ready" | "adding" | "sold_out" | "needs_selection"` — the
|
|
248
|
-
* precedence is resolved here, not in a ternary chain
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
239
|
+
* precedence is resolved here, not in a ternary chain you have to get right.
|
|
240
|
+
* ⚑ The button needs text for **every** state and `disabled={buy.disabled}`, or
|
|
241
|
+
* it renders empty or stays clickable while sold out. The kit ships no labels on
|
|
242
|
+
* purpose: "Add to bag" in every store built from it is how stores end up
|
|
243
|
+
* looking like each other.
|
|
244
|
+
* With a `<CartUIProvider>` mounted, a successful add opens the cart drawer by
|
|
252
245
|
* itself (its `openOnAdd`); `onAdded` remains for a navigate-to-bag flow.
|
|
253
246
|
*
|
|
254
|
-
* What it
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
247
|
+
* What it solves so a hand-written buy box can't drop it: `addToCart()` never
|
|
248
|
+
* throws — a rejected add (sold out, stale variant) lands in `error` instead
|
|
249
|
+
* of leaving the button stuck mid-add; a stale-variant rejection reloads the
|
|
250
|
+
* product; and the quantity controls respect `sold_individually` and tracked
|
|
251
|
+
* stock (`showQuantity` is false when only 1 can be bought — render no stepper
|
|
252
|
+
* then).
|
|
260
253
|
*
|
|
261
254
|
* A not-yet-loaded product is fine (`disabled: true`), so call this next to
|
|
262
255
|
* `useProduct` **above** the page's `loading`/`not_found` guards — a hook below
|
|
263
256
|
* an early return breaks the hook order the next render.
|
|
264
257
|
*
|
|
265
258
|
* @param {object} product the whole `useProduct` result
|
|
266
|
-
* @param {{onAdded?: (cart: object) => void
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
* needsSelection: boolean, purchasable: boolean,
|
|
272
|
-
* state: "ready"|"adding"|"sold_out"|"needs_selection", label: string,
|
|
273
|
-
* buttonProps: object,
|
|
259
|
+
* @param {{onAdded?: (cart: object) => void}} [options]
|
|
260
|
+
* @returns {{addToCart: () => Promise<object>, adding: boolean,
|
|
261
|
+
* error: object|null, reset: () => void, disabled: boolean,
|
|
262
|
+
* soldOut: boolean, needsSelection: boolean, purchasable: boolean,
|
|
263
|
+
* state: "ready"|"adding"|"sold_out"|"needs_selection",
|
|
274
264
|
* quantity: number, setQuantity: (n: number) => void, increase: () => void,
|
|
275
265
|
* decrease: () => void, canIncrease: boolean, canDecrease: boolean,
|
|
276
266
|
* maxQuantity: number, showQuantity: boolean}}
|
|
277
267
|
*/
|
|
278
|
-
export function
|
|
279
|
-
const { add, adding, error, reset } =
|
|
268
|
+
export function useAddToCart(product, { onAdded } = {}) {
|
|
269
|
+
const { add, adding, error, reset } = useAddItem();
|
|
280
270
|
const cartUI = useCartUIOptional();
|
|
281
271
|
const view = product?.view ?? null;
|
|
282
272
|
|
|
283
|
-
const
|
|
273
|
+
const addToCart = useCallback(async () => {
|
|
284
274
|
if (!view) return { ok: false, error: { code: "no_product", message: "Product not loaded." } };
|
|
285
275
|
const res = await add(view.addToCart, product.quantity);
|
|
286
276
|
if (res.ok) {
|
|
@@ -302,7 +292,7 @@ export function useAddToCartButton(product, { onAdded, labels } = {}) {
|
|
|
302
292
|
const disabled = !view?.purchasable || adding;
|
|
303
293
|
|
|
304
294
|
return {
|
|
305
|
-
|
|
295
|
+
addToCart,
|
|
306
296
|
adding,
|
|
307
297
|
error,
|
|
308
298
|
reset,
|
|
@@ -311,13 +301,6 @@ export function useAddToCartButton(product, { onAdded, labels } = {}) {
|
|
|
311
301
|
needsSelection,
|
|
312
302
|
purchasable: Boolean(view?.purchasable),
|
|
313
303
|
state,
|
|
314
|
-
label: labels?.[state] ?? BUY_LABELS[state],
|
|
315
|
-
buttonProps: {
|
|
316
|
-
type: "button",
|
|
317
|
-
onClick: submit,
|
|
318
|
-
disabled,
|
|
319
|
-
"aria-busy": adding || undefined,
|
|
320
|
-
},
|
|
321
304
|
quantity: product?.quantity ?? 1,
|
|
322
305
|
setQuantity: product?.setQuantity ?? (() => {}),
|
|
323
306
|
increase: product?.incQuantity ?? (() => {}),
|
|
@@ -329,51 +312,3 @@ export function useAddToCartButton(product, { onAdded, labels } = {}) {
|
|
|
329
312
|
};
|
|
330
313
|
}
|
|
331
314
|
|
|
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
|
-
}
|