@base44/app-plugin-commerce 0.2.1 → 0.2.3
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 +6 -6
- package/base44/agents/commerce/StoreAdmin.jsonc +1 -1
- package/base44/entities/commerce.OrderRefund.jsonc +1 -1
- package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
- package/base44/entities/commerce.Webhook.jsonc +1 -1
- package/base44/functions/commerce/admin-products/entry.ts +1 -1
- package/base44/functions/commerce/admin-reports/entry.ts +1 -1
- package/base44/functions/commerce/payments/entry.ts +2 -2
- package/base44/functions/commerce/seed-store/defaults.ts +1 -1
- package/base44/functions/commerce/seed-store/seed-catalog.ts +64 -6
- package/base44/functions/commerce/storefront-catalog/entry.ts +1 -1
- package/base44/functions/commerce/storefront-checkout/entry.ts +1 -1
- package/base44/shared/commerce/card-payment.stripe.ts +29 -9
- package/base44/shared/commerce/card-payment.ts +1 -1
- package/base44/shared/commerce/payments.ts +2 -2
- package/base44/shared/commerce/scan.ts +1 -1
- package/base44/shared/commerce/sequence.ts +2 -2
- package/package.json +1 -1
- package/scripts/install.js +1 -1
- package/skills/commerce/SKILL.md +36 -26
- package/skills/commerce/docs/api-storefront.md +6 -6
- package/skills/commerce/install/01-install.md +2 -2
- package/skills/commerce/install/02-storefront.md +381 -94
- package/skills/commerce/install/03-data.md +62 -19
- package/skills/commerce/references/catalog-rendering.md +6 -6
- package/skills/commerce/references/online-payments.md +5 -6
- package/skills/commerce/references/reviews.md +5 -5
- package/skills/commerce/references/shipping-and-tax.md +2 -0
- package/src/commerce/admin/README.md +6 -3
- package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
- package/src/commerce/admin/pages/products/Reviews.jsx +1 -1
- package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -1
- package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +1 -1
- package/src/commerce/storefront/index.js +45 -33
- package/src/commerce/storefront/useAddressForm.js +41 -8
- package/src/commerce/storefront/useCartLine.js +37 -0
- package/src/commerce/storefront/useCheckout.jsx +18 -6
- package/src/commerce/storefront/useOrderReturn.js +36 -10
- package/src/commerce/storefront/useProduct.js +72 -0
- package/src/commerce/storefront/useProductGallery.js +4 -0
- package/src/commerce/utils/index.js +9 -6
- package/src/commerce/utils/shipping-promos.js +2 -2
- package/src/commerce/utils/specs.js +26 -0
- package/src/commerce/utils/variants.js +49 -2
- package/src/commerce/storefront/blocks/AddToCartBlock.jsx +0 -86
- package/src/commerce/storefront/blocks/AddressFieldsBlock.jsx +0 -96
- package/src/commerce/storefront/blocks/BreadcrumbsBlock.jsx +0 -52
- package/src/commerce/storefront/blocks/CartLinesBlock.jsx +0 -98
- package/src/commerce/storefront/blocks/CheckoutBlock.jsx +0 -247
- package/src/commerce/storefront/blocks/CouponFieldBlock.jsx +0 -84
- package/src/commerce/storefront/blocks/OrderReceivedBlock.jsx +0 -129
- package/src/commerce/storefront/blocks/ProductGalleryBlock.jsx +0 -66
- package/src/commerce/storefront/blocks/ProductSpecsBlock.jsx +0 -33
- package/src/commerce/storefront/blocks/ProductStripBlock.jsx +0 -55
- package/src/commerce/storefront/blocks/QuantityStepper.jsx +0 -62
- package/src/commerce/storefront/blocks/ReviewsBlock.jsx +0 -191
- package/src/commerce/storefront/blocks/TotalsBlock.jsx +0 -42
- package/src/commerce/storefront/blocks/VariantSelectorBlock.jsx +0 -81
- package/src/commerce/storefront/blocks/index.js +0 -44
|
@@ -107,6 +107,43 @@ export function useCartLine(line, { debounceMs = 250 } = {}) {
|
|
|
107
107
|
};
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* CartLine — headless per-line binding for the rows of a cart you render
|
|
112
|
+
* yourself. It renders **nothing**: the render function you pass as `children`
|
|
113
|
+
* receives the `useCartLine` controls for that line and returns your markup.
|
|
114
|
+
* It exists so a `lines.map(...)` doesn't tempt a hook call inside a loop:
|
|
115
|
+
*
|
|
116
|
+
* const { lines } = useCart();
|
|
117
|
+
* {lines.map(line => (
|
|
118
|
+
* <CartLine key={line.item_key} line={line}>
|
|
119
|
+
* {(l) => (
|
|
120
|
+
* <li>
|
|
121
|
+
* {line.name} {line.attributesLabel}
|
|
122
|
+
* <button onClick={l.decrease} disabled={!l.canDecrease || l.pending}>−</button>
|
|
123
|
+
* {l.quantity}
|
|
124
|
+
* <button onClick={l.increase} disabled={!l.canIncrease || l.pending}>+</button>
|
|
125
|
+
* <button onClick={l.remove}>Remove</button>
|
|
126
|
+
* {l.error && <p role="alert">{l.error.message}</p>}
|
|
127
|
+
* </li>
|
|
128
|
+
* )}
|
|
129
|
+
* </CartLine>
|
|
130
|
+
* ))}
|
|
131
|
+
*
|
|
132
|
+
* Equivalent to extracting your own row component that calls `useCartLine` —
|
|
133
|
+
* use whichever reads better in your page.
|
|
134
|
+
*
|
|
135
|
+
* @param {{line: object, options?: {debounceMs?: number},
|
|
136
|
+
* children: (controls: object) => React.ReactNode}} props
|
|
137
|
+
*/
|
|
138
|
+
export function CartLine({ line, options, children }) {
|
|
139
|
+
const controls = useCartLine(line, options);
|
|
140
|
+
if (typeof children !== "function") {
|
|
141
|
+
throw new Error("<CartLine> is headless: pass a render function as its only child.");
|
|
142
|
+
}
|
|
143
|
+
if (!line) return null;
|
|
144
|
+
return children(controls);
|
|
145
|
+
}
|
|
146
|
+
|
|
110
147
|
/**
|
|
111
148
|
* useCoupon — the coupon field. Small, and the difference between a store that
|
|
112
149
|
* can honour its own discounts and one that cannot.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { createContext, useCallback, useContext, useEffect, useState } from "react";
|
|
2
2
|
import { storefrontErrorCode, storefrontErrorMessage } from "@/commerce/utils";
|
|
3
3
|
import { useStorefrontState } from "./StorefrontProvider";
|
|
4
|
+
import { orderReceivedUrl } from "./useOrderReturn";
|
|
4
5
|
import {
|
|
5
6
|
REQUIRED_BILLING_FIELDS,
|
|
6
7
|
isShippingAddressComplete,
|
|
@@ -62,10 +63,15 @@ function resolvePaymentMethod(gateways, picked) {
|
|
|
62
63
|
* - **The gate.** `canPlaceOrder` + `blockers` say exactly what still stands
|
|
63
64
|
* between the customer and the order — drive the button's disabled state
|
|
64
65
|
* and the "what's missing" hints from them instead of re-deriving.
|
|
65
|
-
* - **placeOrder.** Sends the order, clears the shared cart, and
|
|
66
|
-
* redirects to the provider's payment page
|
|
67
|
-
*
|
|
68
|
-
*
|
|
66
|
+
* - **placeOrder.** Sends the order, clears the shared cart, and navigates:
|
|
67
|
+
* an online gateway redirects to the provider's payment page
|
|
68
|
+
* (`redirectToPayment`), everything else lands on the order-received page
|
|
69
|
+
* (`orderReceivedPath`, default `/order-received`) — which is where a manual
|
|
70
|
+
* order's payment instructions are rendered, so the offline default confirms
|
|
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
|
|
74
|
+
* `result.payment_instructions`).
|
|
69
75
|
*
|
|
70
76
|
* Blocker codes, in the order checked: `cart_loading`, `empty_cart`,
|
|
71
77
|
* `billing_incomplete`, `shipping_address_incomplete`, `shipping_recalculating`,
|
|
@@ -73,7 +79,8 @@ function resolvePaymentMethod(gateways, picked) {
|
|
|
73
79
|
* `shipping_not_available`, `payment_method_required`.
|
|
74
80
|
*
|
|
75
81
|
* Options: `debounceMs` (600), `addressComplete` (predicate overriding the
|
|
76
|
-
* country+city rule), `requiredBillingFields`, `redirectToPayment` (true)
|
|
82
|
+
* country+city rule), `requiredBillingFields`, `redirectToPayment` (true),
|
|
83
|
+
* `orderReceivedPath` ("/order-received"; null disables the navigation).
|
|
77
84
|
*/
|
|
78
85
|
export function useCheckout(options = {}) {
|
|
79
86
|
const {
|
|
@@ -81,6 +88,7 @@ export function useCheckout(options = {}) {
|
|
|
81
88
|
addressComplete = isShippingAddressComplete,
|
|
82
89
|
requiredBillingFields = REQUIRED_BILLING_FIELDS,
|
|
83
90
|
redirectToPayment = true,
|
|
91
|
+
orderReceivedPath = "/order-received",
|
|
84
92
|
} = options;
|
|
85
93
|
|
|
86
94
|
const { client, info, cart, runCart, clearCart } = useStorefrontState();
|
|
@@ -204,6 +212,10 @@ export function useCheckout(options = {}) {
|
|
|
204
212
|
typeof window !== "undefined"
|
|
205
213
|
) {
|
|
206
214
|
window.location.assign(result.payment.checkout_url);
|
|
215
|
+
} else if (orderReceivedPath && !result.payment && typeof window !== "undefined") {
|
|
216
|
+
// A manual/offline order settles nothing online — land it on the
|
|
217
|
+
// order-received page, where its payment instructions are rendered.
|
|
218
|
+
window.location.assign(orderReceivedUrl(result, orderReceivedPath));
|
|
207
219
|
}
|
|
208
220
|
return { ok: true, result };
|
|
209
221
|
} catch (e) {
|
|
@@ -224,7 +236,7 @@ export function useCheckout(options = {}) {
|
|
|
224
236
|
setPlacing(false);
|
|
225
237
|
}
|
|
226
238
|
},
|
|
227
|
-
[placing, client, paymentMethod, billing, shipToDifferent, shipping, clearCart, redirectToPayment, runCart],
|
|
239
|
+
[placing, client, paymentMethod, billing, shipToDifferent, shipping, clearCart, redirectToPayment, orderReceivedPath, runCart],
|
|
228
240
|
);
|
|
229
241
|
|
|
230
242
|
return {
|
|
@@ -1,29 +1,36 @@
|
|
|
1
1
|
import { useCallback, useEffect, useState } from "react";
|
|
2
|
-
import { storefrontErrorCode, storefrontErrorMessage } from "@/commerce/utils";
|
|
2
|
+
import { orderLines, storefrontErrorCode, storefrontErrorMessage } from "@/commerce/utils";
|
|
3
3
|
import { useStorefrontState } from "./StorefrontProvider";
|
|
4
|
+
import { orderSeo, useStorefrontSeo } from "./useStorefrontSeo";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* useOrderReturn — the whole `/order-received` page in one hook. Mount the
|
|
7
8
|
* route (it is mandatory — every payment link returns here) and branch on
|
|
8
|
-
* `status
|
|
9
|
+
* `status`; the markup for every state is yours to write:
|
|
9
10
|
*
|
|
10
|
-
* const { status, order, paymentLink, paymentInstructions, error,
|
|
11
|
-
* useOrderReturn();
|
|
12
|
-
* // "loading" →
|
|
13
|
-
* // "paid" → thank-you + order summary (the order is now
|
|
14
|
-
* // "unpaid" → card order:
|
|
11
|
+
* const { status, order, lines, paymentLink, paymentInstructions, error,
|
|
12
|
+
* reload } = useOrderReturn();
|
|
13
|
+
* // "loading" → confirming copy (never an empty page)
|
|
14
|
+
* // "paid" → thank-you + order number + summary (the order is now paid)
|
|
15
|
+
* // "unpaid" → card order: link paymentLink.url to pay now;
|
|
15
16
|
* // manual order: render paymentInstructions
|
|
16
|
-
* // ({ description, account_details })
|
|
17
|
+
* // ({ description, account_details }) — these ARE how the
|
|
18
|
+
* // customer pays; a page that drops them strands the order
|
|
17
19
|
* // "cancelled" → payment was cancelled — offer paymentLink.url or support
|
|
18
20
|
* // "error" → render error.message with a retry via reload()
|
|
19
21
|
*
|
|
22
|
+
* `lines` are the order's items in the decorated cart-line shape, so the same
|
|
23
|
+
* row markup renders the bag and the confirmation; totals come from
|
|
24
|
+
* `useTotalsLines(order)`. The page is marked `noindex` automatically — a
|
|
25
|
+
* receipt carrying an order key must not rank (`seo: false` opts out).
|
|
26
|
+
*
|
|
20
27
|
* It reads `order_id`/`order_key`/`payment` from the URL itself and verifies
|
|
21
28
|
* with the payment provider server-side — safe and idempotent on every visit.
|
|
22
29
|
*
|
|
23
30
|
* `order` carries FLAT totals — `order.total`, `order.shipping_total`,
|
|
24
31
|
* `order.total_tax`; there is no `order.totals` object on it.
|
|
25
32
|
*/
|
|
26
|
-
export function useOrderReturn({ auto = true } = {}) {
|
|
33
|
+
export function useOrderReturn({ auto = true, seo = true } = {}) {
|
|
27
34
|
const { client } = useStorefrontState();
|
|
28
35
|
const [result, setResult] = useState({ status: auto ? "loading" : "idle" });
|
|
29
36
|
|
|
@@ -52,5 +59,24 @@ export function useOrderReturn({ auto = true } = {}) {
|
|
|
52
59
|
if (auto) reload();
|
|
53
60
|
}, [auto, reload]);
|
|
54
61
|
|
|
55
|
-
|
|
62
|
+
useStorefrontSeo(seo ? orderSeo(result.order ?? null) : null);
|
|
63
|
+
|
|
64
|
+
return { ...result, lines: orderLines(result.order ?? null), reload };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The URL a just-placed manual/offline order should land on: the order-received
|
|
69
|
+
* page, carrying the id + key `useOrderReturn` reads back. `useCheckout` builds
|
|
70
|
+
* this itself by default (`orderReceivedPath`); use it directly only in a
|
|
71
|
+
* custom `placeOrder` flow.
|
|
72
|
+
*
|
|
73
|
+
* @param {{order_id: string, order_key: string}} result from `placeOrder`
|
|
74
|
+
* @param {string} [path]
|
|
75
|
+
*/
|
|
76
|
+
export function orderReceivedUrl(result, path = "/order-received") {
|
|
77
|
+
const q = new URLSearchParams({
|
|
78
|
+
order_id: result?.order_id ?? "",
|
|
79
|
+
order_key: result?.order_key ?? "",
|
|
80
|
+
});
|
|
81
|
+
return `${path}?${q}`;
|
|
56
82
|
}
|
|
@@ -225,3 +225,75 @@ export function useAddToCart() {
|
|
|
225
225
|
|
|
226
226
|
return { add, adding, error, lastAdded, reset };
|
|
227
227
|
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* useAddToCartButton — the buy button's whole state machine, ready to bind to
|
|
231
|
+
* markup you write. Pass the entire `useProduct` result:
|
|
232
|
+
*
|
|
233
|
+
* const p = useProduct(slug);
|
|
234
|
+
* const buy = useAddToCartButton(p, { onAdded: () => navigate("/bag") });
|
|
235
|
+
* <button disabled={buy.disabled} onClick={buy.add}>
|
|
236
|
+
* {buy.adding ? "Adding…" : buy.soldOut ? "Sold out"
|
|
237
|
+
* : buy.needsSelection ? "Select options" : "Add to bag"}
|
|
238
|
+
* </button>
|
|
239
|
+
* {buy.error && <p role="alert">{buy.error.message}</p>}
|
|
240
|
+
*
|
|
241
|
+
* What it wires so a hand-written buy box can't drop it: the button is gated on
|
|
242
|
+
* `view.purchasable`; a rejected add (sold out, stale variant) lands in `error`
|
|
243
|
+
* instead of leaving the button stuck on "Adding…"; a stale-variant rejection
|
|
244
|
+
* reloads the product; and the quantity controls respect `sold_individually`
|
|
245
|
+
* and tracked stock (`showQuantity` is false when only 1 can be bought — render
|
|
246
|
+
* no stepper then). Every label and every element is yours.
|
|
247
|
+
*
|
|
248
|
+
* A not-yet-loaded product is fine (`disabled: true`), so call this next to
|
|
249
|
+
* `useProduct` **above** the page's `loading`/`not_found` guards — a hook below
|
|
250
|
+
* an early return breaks the hook order the next render.
|
|
251
|
+
*
|
|
252
|
+
* @param {object} product the whole `useProduct` result
|
|
253
|
+
* @param {{onAdded?: (cart: object) => void}} [options]
|
|
254
|
+
* @returns {{add: () => Promise<object>, adding: boolean, error: object|null,
|
|
255
|
+
* reset: () => void, disabled: boolean, soldOut: boolean,
|
|
256
|
+
* needsSelection: boolean, purchasable: boolean,
|
|
257
|
+
* quantity: number, setQuantity: (n: number) => void, increase: () => void,
|
|
258
|
+
* decrease: () => void, canIncrease: boolean, canDecrease: boolean,
|
|
259
|
+
* maxQuantity: number, showQuantity: boolean}}
|
|
260
|
+
*/
|
|
261
|
+
export function useAddToCartButton(product, { onAdded } = {}) {
|
|
262
|
+
const { add, adding, error, reset } = useAddToCart();
|
|
263
|
+
const view = product?.view ?? null;
|
|
264
|
+
|
|
265
|
+
const submit = useCallback(async () => {
|
|
266
|
+
if (!view) return { ok: false, error: { code: "no_product", message: "Product not loaded." } };
|
|
267
|
+
const res = await add(view.addToCart, product.quantity);
|
|
268
|
+
if (res.ok) onAdded?.(res.cart);
|
|
269
|
+
else if (res.error?.shouldReload) product.reload?.();
|
|
270
|
+
return res;
|
|
271
|
+
}, [add, view, product, onAdded]);
|
|
272
|
+
|
|
273
|
+
// A variable product with an incomplete selection isn't sold out — it needs
|
|
274
|
+
// a pick; only a resolved (or simple) unpurchasable view reads as sold out.
|
|
275
|
+
const soldOut = view
|
|
276
|
+
? view.isVariable
|
|
277
|
+
? view.complete && !view.purchasable
|
|
278
|
+
: !view.purchasable
|
|
279
|
+
: false;
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
add: submit,
|
|
283
|
+
adding,
|
|
284
|
+
error,
|
|
285
|
+
reset,
|
|
286
|
+
disabled: !view?.purchasable || adding,
|
|
287
|
+
soldOut,
|
|
288
|
+
needsSelection: Boolean(view?.isVariable && !view.complete),
|
|
289
|
+
purchasable: Boolean(view?.purchasable),
|
|
290
|
+
quantity: product?.quantity ?? 1,
|
|
291
|
+
setQuantity: product?.setQuantity ?? (() => {}),
|
|
292
|
+
increase: product?.incQuantity ?? (() => {}),
|
|
293
|
+
decrease: product?.decQuantity ?? (() => {}),
|
|
294
|
+
canIncrease: Boolean(product?.canIncrease),
|
|
295
|
+
canDecrease: (product?.quantity ?? 1) > 1,
|
|
296
|
+
maxQuantity: product?.maxQuantity ?? 1,
|
|
297
|
+
showQuantity: (product?.maxQuantity ?? 1) > 1,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
@@ -19,6 +19,10 @@ import { imageIndex, productImages } from "@/commerce/utils";
|
|
|
19
19
|
* moves the active image to the variation's own picture while a manual pick
|
|
20
20
|
* still wins until the selection changes again** — highlight, not replace.
|
|
21
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
|
+
*
|
|
22
26
|
* @param {object} product
|
|
23
27
|
* @param {object|null} [view] a `resolveSelection` view; its
|
|
24
28
|
* `display.image` is the variation's image
|
|
@@ -13,10 +13,10 @@
|
|
|
13
13
|
* checkout/return-page calls. Create ONE instance and import it everywhere.
|
|
14
14
|
* - `variants.js` — variant selection: map attribute selections (Size, Color)
|
|
15
15
|
* to a `ProductVariation` and back, per-option availability, variant price
|
|
16
|
-
* ranges. See
|
|
16
|
+
* ranges. See the commerce skill's references/catalog-rendering.md.
|
|
17
17
|
* - `shipping-promos.js` — read the store's real free-shipping configuration so
|
|
18
18
|
* "Free shipping over €150" copy states a configured rule, not an invented
|
|
19
|
-
* number. See
|
|
19
|
+
* number. See the commerce skill's docs/api-storefront.md.
|
|
20
20
|
* - `price.js` — `productPrice`: the from-price and price-range rules, encoded
|
|
21
21
|
* once so a card and a product page cannot disagree.
|
|
22
22
|
* - `totals.js` — `cartTotalsLines` / `orderTotalsLines` / `orderLines` /
|
|
@@ -25,12 +25,14 @@
|
|
|
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` → spec-table rows.
|
|
28
29
|
*
|
|
29
30
|
* Building the storefront in React? **Prefer `@/commerce/storefront`** — it
|
|
30
|
-
* layers hooks
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
31
|
+
* layers headless hooks on top of this module, and a hook that pre-composes
|
|
32
|
+
* these helpers is the difference between a rule that holds and a rule you
|
|
33
|
+
* have to remember. Neither layer ships any UI: all markup and styling belong
|
|
34
|
+
* to the storefront you build. Use this module directly for non-React code,
|
|
35
|
+
* and inside your own custom logic.
|
|
34
36
|
*/
|
|
35
37
|
export * from "./storefront.js";
|
|
36
38
|
export * from "./variants.js";
|
|
@@ -39,3 +41,4 @@ export * from "./price.js";
|
|
|
39
41
|
export * from "./totals.js";
|
|
40
42
|
export * from "./address-spec.js";
|
|
41
43
|
export * from "./images.js";
|
|
44
|
+
export * from "./specs.js";
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
* `available_shipping_methods` after `set-shipping-address` — that is computed
|
|
24
24
|
* by the same engine and needs no extra exposure.
|
|
25
25
|
*
|
|
26
|
-
* Framework-free and dependency-free. See
|
|
27
|
-
*
|
|
26
|
+
* Framework-free and dependency-free. See the commerce skill's
|
|
27
|
+
* docs/api-storefront.md for the surrounding rules.
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
30
|
/**
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Product spec rows — the descriptive properties a product page lists in a
|
|
3
|
+
* table (Material, Care, Fit).
|
|
4
|
+
*
|
|
5
|
+
* These live in `product.meta_data` (the admin's *Modifiers* section) and are
|
|
6
|
+
* **not** attributes and not ribbons: they describe the product, they don't
|
|
7
|
+
* select a variant. Hidden keys (leading `_`) and empty values are skipped.
|
|
8
|
+
* You render the rows yourself:
|
|
9
|
+
*
|
|
10
|
+
* const specs = productSpecs(product);
|
|
11
|
+
* {specs.length > 0 && <dl>{specs.map(s =>
|
|
12
|
+
* <div key={s.key}><dt>{s.label}</dt><dd>{s.value}</dd></div>)}</dl>}
|
|
13
|
+
*
|
|
14
|
+
* @param {object} product
|
|
15
|
+
* @returns {Array<{key: string, label: string, value: string}>} `[]` when the
|
|
16
|
+
* product has no visible meta_data — render nothing, not an empty section.
|
|
17
|
+
*/
|
|
18
|
+
export function productSpecs(product) {
|
|
19
|
+
return (product?.meta_data ?? [])
|
|
20
|
+
.filter((m) => m?.key && !String(m.key).startsWith("_") && m.value != null && m.value !== "")
|
|
21
|
+
.map((m) => ({
|
|
22
|
+
key: String(m.key),
|
|
23
|
+
label: String(m.key).replace(/_/g, " "),
|
|
24
|
+
value: String(m.value),
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Framework-free, dependency-free, no I/O — feed them the `{ product,
|
|
13
13
|
* variations }` pair from `commerce/storefront-catalog` `get-product` (the
|
|
14
|
-
* admin's variation editor can use them too). See
|
|
15
|
-
*
|
|
14
|
+
* admin's variation editor can use them too). See the commerce skill's
|
|
15
|
+
* references/catalog-rendering.md for the UI rules.
|
|
16
16
|
*
|
|
17
17
|
* A `selection` throughout is a plain object keyed by axis key (see
|
|
18
18
|
* `attributeKey`): `{ "<color-attr-id>": "Red", "<size-attr-id>": "M" }`.
|
|
@@ -435,3 +435,50 @@ export function selectionFromParams(product, variations, params) {
|
|
|
435
435
|
}
|
|
436
436
|
return selection;
|
|
437
437
|
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* A resolved view's axes as a render-ready model for the variant selector *you*
|
|
441
|
+
* write: one entry per axis, one entry per option, with selected / stock /
|
|
442
|
+
* disabled state already derived. All markup and styling are yours; the two
|
|
443
|
+
* rules this model encodes are **one control per axis** (never a flat list of
|
|
444
|
+
* variations) and **an unbuyable option renders disabled, not hidden** — a
|
|
445
|
+
* customer who can't see that a size exists assumes the store doesn't carry it.
|
|
446
|
+
*
|
|
447
|
+
* const { view, pick } = useProduct(slug);
|
|
448
|
+
* variantAxes(view, pick).map(axis =>
|
|
449
|
+
* // axis.name, axis.selectedOption
|
|
450
|
+
* axis.options.map(o =>
|
|
451
|
+
* // o.value, o.selected, o.disabled (don't render clickable),
|
|
452
|
+
* // o.outOfStock (strike through / mark, still visible), o.pick()
|
|
453
|
+
* ));
|
|
454
|
+
*
|
|
455
|
+
* @param {object|null} view from `resolveSelection` / `useProduct().view`
|
|
456
|
+
* @param {(axisKey: string, option: string) => void} [pick] click handler to
|
|
457
|
+
* bind per option (`useProduct().pick`)
|
|
458
|
+
* @returns {Array<{key: string, name: string, selectedOption: string|null,
|
|
459
|
+
* options: Array<{value: string, selected: boolean, state: string,
|
|
460
|
+
* outOfStock: boolean, disabled: boolean, pick: () => void}>}>}
|
|
461
|
+
* `[]` for a product without variants — render nothing.
|
|
462
|
+
*/
|
|
463
|
+
export function variantAxes(view, pick) {
|
|
464
|
+
if (!view?.isVariable) return [];
|
|
465
|
+
return (view.axes ?? []).map((axis) => {
|
|
466
|
+
const selectedOption = view.selection?.[axis.key] || null;
|
|
467
|
+
return {
|
|
468
|
+
key: axis.key,
|
|
469
|
+
name: axis.name,
|
|
470
|
+
selectedOption,
|
|
471
|
+
options: (axis.options ?? []).map((option) => {
|
|
472
|
+
const state = view.availability?.[axis.key]?.[option] ?? OPTION_AVAILABLE;
|
|
473
|
+
return {
|
|
474
|
+
value: option,
|
|
475
|
+
selected: option === selectedOption,
|
|
476
|
+
state,
|
|
477
|
+
outOfStock: state === OPTION_OUT_OF_STOCK,
|
|
478
|
+
disabled: state === OPTION_UNAVAILABLE,
|
|
479
|
+
pick: () => pick?.(axis.key, option),
|
|
480
|
+
};
|
|
481
|
+
}),
|
|
482
|
+
};
|
|
483
|
+
});
|
|
484
|
+
}
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { useAddToCart } from "../useProduct";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* The buy button, with quantity, stock state and every add-to-cart failure
|
|
6
|
-
* handled.
|
|
7
|
-
*
|
|
8
|
-
* const p = useProduct(slug);
|
|
9
|
-
* <AddToCartBlock product={p} onAdded={() => navigate("/bag")} />
|
|
10
|
-
*
|
|
11
|
-
* Pass the whole `useProduct` result as `product` and it wires itself: the
|
|
12
|
-
* button is gated on `view.purchasable`, the quantity respects
|
|
13
|
-
* `sold_individually` and tracked stock, a rejected add (sold out, stale
|
|
14
|
-
* variant) renders inline instead of leaving the button stuck on "Adding…", and
|
|
15
|
-
* a stale-variant error reloads the product.
|
|
16
|
-
*
|
|
17
|
-
* @param {{product: object, className?: string, label?: string,
|
|
18
|
-
* addingLabel?: string, showQuantity?: boolean,
|
|
19
|
-
* onAdded?: (cart: object) => void}} props
|
|
20
|
-
*/
|
|
21
|
-
export function AddToCartBlock({
|
|
22
|
-
product,
|
|
23
|
-
className = "",
|
|
24
|
-
label = "Add to bag",
|
|
25
|
-
addingLabel = "Adding…",
|
|
26
|
-
showQuantity = true,
|
|
27
|
-
onAdded,
|
|
28
|
-
}) {
|
|
29
|
-
const { add, adding, error } = useAddToCart();
|
|
30
|
-
if (!product?.view) return null;
|
|
31
|
-
|
|
32
|
-
const { view, quantity, incQuantity, decQuantity, canIncrease, maxQuantity, reload } = product;
|
|
33
|
-
const soldOut = view.isVariable ? view.complete && !view.purchasable : !view.purchasable;
|
|
34
|
-
|
|
35
|
-
const onClick = async () => {
|
|
36
|
-
const res = await add(view.addToCart, quantity);
|
|
37
|
-
if (res.ok) onAdded?.(res.cart);
|
|
38
|
-
else if (res.error?.shouldReload) reload?.();
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
return (
|
|
42
|
-
<div data-commerce="add-to-cart-block" className={className}>
|
|
43
|
-
{showQuantity && maxQuantity > 1 && (
|
|
44
|
-
<div className="mb-3 flex items-center gap-1">
|
|
45
|
-
<button
|
|
46
|
-
type="button"
|
|
47
|
-
data-commerce="product-quantity-decrease"
|
|
48
|
-
onClick={decQuantity}
|
|
49
|
-
disabled={quantity <= 1}
|
|
50
|
-
aria-label="Decrease quantity"
|
|
51
|
-
className="h-9 w-9 border border-border disabled:opacity-40"
|
|
52
|
-
>
|
|
53
|
-
−
|
|
54
|
-
</button>
|
|
55
|
-
<span data-commerce="product-quantity" className="w-10 text-center text-sm">{quantity}</span>
|
|
56
|
-
<button
|
|
57
|
-
type="button"
|
|
58
|
-
data-commerce="product-quantity-increase"
|
|
59
|
-
onClick={incQuantity}
|
|
60
|
-
disabled={!canIncrease}
|
|
61
|
-
aria-label="Increase quantity"
|
|
62
|
-
className="h-9 w-9 border border-border disabled:opacity-40"
|
|
63
|
-
>
|
|
64
|
-
+
|
|
65
|
-
</button>
|
|
66
|
-
</div>
|
|
67
|
-
)}
|
|
68
|
-
|
|
69
|
-
<button
|
|
70
|
-
type="button"
|
|
71
|
-
data-commerce="add-to-cart"
|
|
72
|
-
onClick={onClick}
|
|
73
|
-
disabled={!view.purchasable || adding}
|
|
74
|
-
className="w-full bg-primary px-6 py-3 text-sm text-primary-foreground disabled:opacity-50"
|
|
75
|
-
>
|
|
76
|
-
{adding ? addingLabel : soldOut ? "Sold out" : !view.complete ? "Select options" : label}
|
|
77
|
-
</button>
|
|
78
|
-
|
|
79
|
-
{error && (
|
|
80
|
-
<p data-commerce="add-to-cart-error" role="alert" className="mt-2 text-sm text-destructive">
|
|
81
|
-
{error.message}
|
|
82
|
-
</p>
|
|
83
|
-
)}
|
|
84
|
-
</div>
|
|
85
|
-
);
|
|
86
|
-
}
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { useAddressForm } from "../useAddressForm";
|
|
3
|
-
import { useCheckoutContext } from "../useCheckout";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* The checkout address form, rendered from the field spec — so `state` is
|
|
7
|
-
* collected (rates and taxes match on country + state), the country select
|
|
8
|
-
* never maps a null list, and browser autofill works.
|
|
9
|
-
*
|
|
10
|
-
* <AddressFieldsBlock /> // billing
|
|
11
|
-
* <AddressFieldsBlock which="shipping" /> // a separate delivery address
|
|
12
|
-
*
|
|
13
|
-
* Needs a `<CheckoutProvider>` above it (`<CheckoutBlock/>` supplies one).
|
|
14
|
-
*
|
|
15
|
-
* @param {{which?: "billing"|"shipping", className?: string, legend?: string,
|
|
16
|
-
* includeCompany?: boolean, includePhone?: boolean}} props
|
|
17
|
-
*/
|
|
18
|
-
export function AddressFieldsBlock({
|
|
19
|
-
which = "billing",
|
|
20
|
-
className = "",
|
|
21
|
-
legend,
|
|
22
|
-
includeCompany = false,
|
|
23
|
-
includePhone = true,
|
|
24
|
-
}) {
|
|
25
|
-
const { fields, set, countriesLoading } = useAddressForm(which, { includeCompany, includePhone });
|
|
26
|
-
|
|
27
|
-
return (
|
|
28
|
-
<fieldset data-commerce={`address-${which}`} className={className}>
|
|
29
|
-
{legend && <legend className="mb-3 text-xs uppercase tracking-wide text-muted-foreground">{legend}</legend>}
|
|
30
|
-
<div className="grid grid-cols-2 gap-3">
|
|
31
|
-
{fields.map((f) => (
|
|
32
|
-
<label
|
|
33
|
-
key={f.key}
|
|
34
|
-
className={f.colSpan === 2 ? "col-span-2" : "col-span-2 sm:col-span-1"}
|
|
35
|
-
>
|
|
36
|
-
<span className="block text-xs text-muted-foreground">
|
|
37
|
-
{f.label}
|
|
38
|
-
{f.required && <span aria-hidden> *</span>}
|
|
39
|
-
</span>
|
|
40
|
-
{f.type === "select" ? (
|
|
41
|
-
<select
|
|
42
|
-
data-commerce={`address-field-${f.key}`}
|
|
43
|
-
value={f.value}
|
|
44
|
-
required={f.required}
|
|
45
|
-
onChange={(e) => set(f.key, e.target.value)}
|
|
46
|
-
autoComplete={f.autoComplete}
|
|
47
|
-
disabled={f.key === "country" && countriesLoading}
|
|
48
|
-
className="mt-1 w-full border border-border bg-transparent px-3 py-2 text-sm"
|
|
49
|
-
>
|
|
50
|
-
<option value="">
|
|
51
|
-
{f.key === "country" && countriesLoading ? "Loading…" : `Select ${f.label.toLowerCase()}`}
|
|
52
|
-
</option>
|
|
53
|
-
{f.options.map((o) => (
|
|
54
|
-
<option key={o.value} value={o.value}>
|
|
55
|
-
{o.label}
|
|
56
|
-
</option>
|
|
57
|
-
))}
|
|
58
|
-
</select>
|
|
59
|
-
) : (
|
|
60
|
-
<input
|
|
61
|
-
data-commerce={`address-field-${f.key}`}
|
|
62
|
-
type={f.type}
|
|
63
|
-
value={f.value}
|
|
64
|
-
required={f.required}
|
|
65
|
-
onChange={(e) => set(f.key, e.target.value)}
|
|
66
|
-
autoComplete={f.autoComplete}
|
|
67
|
-
className="mt-1 w-full border border-border bg-transparent px-3 py-2 text-sm"
|
|
68
|
-
/>
|
|
69
|
-
)}
|
|
70
|
-
{f.error && (
|
|
71
|
-
<span role="alert" className="mt-1 block text-xs text-destructive">
|
|
72
|
-
{f.error}
|
|
73
|
-
</span>
|
|
74
|
-
)}
|
|
75
|
-
</label>
|
|
76
|
-
))}
|
|
77
|
-
</div>
|
|
78
|
-
{which === "billing" && <ShipToDifferentToggle />}
|
|
79
|
-
</fieldset>
|
|
80
|
-
);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function ShipToDifferentToggle() {
|
|
84
|
-
const { shipToDifferent, setShipToDifferent } = useCheckoutContext();
|
|
85
|
-
return (
|
|
86
|
-
<label className="mt-3 flex items-center gap-2 text-sm">
|
|
87
|
-
<input
|
|
88
|
-
type="checkbox"
|
|
89
|
-
data-commerce="ship-to-different"
|
|
90
|
-
checked={shipToDifferent}
|
|
91
|
-
onChange={(e) => setShipToDifferent(e.target.checked)}
|
|
92
|
-
/>
|
|
93
|
-
Deliver to a different address
|
|
94
|
-
</label>
|
|
95
|
-
);
|
|
96
|
-
}
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Category breadcrumbs for a product page.
|
|
5
|
-
*
|
|
6
|
-
* <BreadcrumbsBlock categories={categories} current={product.name} />
|
|
7
|
-
*
|
|
8
|
-
* Ribbons are deliberately NOT breadcrumbs — they are labels ("Best Seller"),
|
|
9
|
-
* not places in the catalog. Pass `renderLink` to use your router's `<Link>`
|
|
10
|
-
* instead of a plain anchor.
|
|
11
|
-
*
|
|
12
|
-
* @param {{categories?: Array<object>, current?: string, className?: string,
|
|
13
|
-
* homeLabel?: string, homeHref?: string, categoryHref?: (c: object) => string,
|
|
14
|
-
* renderLink?: (o: {href: string, label: string}) => React.ReactNode}} props
|
|
15
|
-
*/
|
|
16
|
-
export function BreadcrumbsBlock({
|
|
17
|
-
categories = [],
|
|
18
|
-
current,
|
|
19
|
-
className = "",
|
|
20
|
-
homeLabel = "Home",
|
|
21
|
-
homeHref = "/",
|
|
22
|
-
categoryHref = (c) => `/collection?category_id=${c.id}`,
|
|
23
|
-
renderLink,
|
|
24
|
-
}) {
|
|
25
|
-
const crumbs = [
|
|
26
|
-
{ href: homeHref, label: homeLabel },
|
|
27
|
-
...categories.map((c) => ({ href: categoryHref(c), label: c.name })),
|
|
28
|
-
];
|
|
29
|
-
|
|
30
|
-
const link = ({ href, label }) =>
|
|
31
|
-
renderLink ? (
|
|
32
|
-
renderLink({ href, label })
|
|
33
|
-
) : (
|
|
34
|
-
<a href={href} className="underline-offset-2 hover:underline">
|
|
35
|
-
{label}
|
|
36
|
-
</a>
|
|
37
|
-
);
|
|
38
|
-
|
|
39
|
-
return (
|
|
40
|
-
<nav data-commerce="breadcrumbs" aria-label="Breadcrumb" className={`text-xs text-muted-foreground ${className}`}>
|
|
41
|
-
<ol className="flex flex-wrap items-center gap-1.5">
|
|
42
|
-
{crumbs.map((c) => (
|
|
43
|
-
<li key={`${c.href}-${c.label}`} className="flex items-center gap-1.5">
|
|
44
|
-
{link(c)}
|
|
45
|
-
<span aria-hidden>/</span>
|
|
46
|
-
</li>
|
|
47
|
-
))}
|
|
48
|
-
{current && <li aria-current="page">{current}</li>}
|
|
49
|
-
</ol>
|
|
50
|
-
</nav>
|
|
51
|
-
);
|
|
52
|
-
}
|