@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.
Files changed (59) hide show
  1. package/README.md +6 -6
  2. package/base44/agents/commerce/StoreAdmin.jsonc +1 -1
  3. package/base44/entities/commerce.OrderRefund.jsonc +1 -1
  4. package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
  5. package/base44/entities/commerce.Webhook.jsonc +1 -1
  6. package/base44/functions/commerce/admin-products/entry.ts +1 -1
  7. package/base44/functions/commerce/admin-reports/entry.ts +1 -1
  8. package/base44/functions/commerce/payments/entry.ts +2 -2
  9. package/base44/functions/commerce/seed-store/defaults.ts +1 -1
  10. package/base44/functions/commerce/seed-store/seed-catalog.ts +64 -6
  11. package/base44/functions/commerce/storefront-catalog/entry.ts +1 -1
  12. package/base44/functions/commerce/storefront-checkout/entry.ts +1 -1
  13. package/base44/shared/commerce/card-payment.stripe.ts +29 -9
  14. package/base44/shared/commerce/card-payment.ts +1 -1
  15. package/base44/shared/commerce/payments.ts +2 -2
  16. package/base44/shared/commerce/scan.ts +1 -1
  17. package/base44/shared/commerce/sequence.ts +2 -2
  18. package/package.json +1 -1
  19. package/scripts/install.js +1 -1
  20. package/skills/commerce/SKILL.md +36 -26
  21. package/skills/commerce/docs/api-storefront.md +6 -6
  22. package/skills/commerce/install/01-install.md +2 -2
  23. package/skills/commerce/install/02-storefront.md +381 -94
  24. package/skills/commerce/install/03-data.md +62 -19
  25. package/skills/commerce/references/catalog-rendering.md +6 -6
  26. package/skills/commerce/references/online-payments.md +5 -6
  27. package/skills/commerce/references/reviews.md +5 -5
  28. package/skills/commerce/references/shipping-and-tax.md +2 -0
  29. package/src/commerce/admin/README.md +6 -3
  30. package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
  31. package/src/commerce/admin/pages/products/Reviews.jsx +1 -1
  32. package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -1
  33. package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +1 -1
  34. package/src/commerce/storefront/index.js +45 -33
  35. package/src/commerce/storefront/useAddressForm.js +41 -8
  36. package/src/commerce/storefront/useCartLine.js +37 -0
  37. package/src/commerce/storefront/useCheckout.jsx +18 -6
  38. package/src/commerce/storefront/useOrderReturn.js +36 -10
  39. package/src/commerce/storefront/useProduct.js +72 -0
  40. package/src/commerce/storefront/useProductGallery.js +4 -0
  41. package/src/commerce/utils/index.js +9 -6
  42. package/src/commerce/utils/shipping-promos.js +2 -2
  43. package/src/commerce/utils/specs.js +26 -0
  44. package/src/commerce/utils/variants.js +49 -2
  45. package/src/commerce/storefront/blocks/AddToCartBlock.jsx +0 -86
  46. package/src/commerce/storefront/blocks/AddressFieldsBlock.jsx +0 -96
  47. package/src/commerce/storefront/blocks/BreadcrumbsBlock.jsx +0 -52
  48. package/src/commerce/storefront/blocks/CartLinesBlock.jsx +0 -98
  49. package/src/commerce/storefront/blocks/CheckoutBlock.jsx +0 -247
  50. package/src/commerce/storefront/blocks/CouponFieldBlock.jsx +0 -84
  51. package/src/commerce/storefront/blocks/OrderReceivedBlock.jsx +0 -129
  52. package/src/commerce/storefront/blocks/ProductGalleryBlock.jsx +0 -66
  53. package/src/commerce/storefront/blocks/ProductSpecsBlock.jsx +0 -33
  54. package/src/commerce/storefront/blocks/ProductStripBlock.jsx +0 -55
  55. package/src/commerce/storefront/blocks/QuantityStepper.jsx +0 -62
  56. package/src/commerce/storefront/blocks/ReviewsBlock.jsx +0 -191
  57. package/src/commerce/storefront/blocks/TotalsBlock.jsx +0 -42
  58. package/src/commerce/storefront/blocks/VariantSelectorBlock.jsx +0 -81
  59. 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 (by default)
66
- * redirects to the provider's payment page when the gateway is online.
67
- * Resolves to `{ ok: true, result }` or `{ ok: false, error }`; a manual-
68
- * gateway result carries `result.payment_instructions` to render.
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, reload } =
11
- * useOrderReturn();
12
- * // "loading" → spinner
13
- * // "paid" → thank-you + order summary (the order is now marked paid)
14
- * // "unpaid" → card order: offer paymentLink.url to pay now;
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
- return { ...result, reload };
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 `.agents/skills/commerce/references/catalog-rendering.md`.
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 `.agents/skills/commerce/docs/api-storefront.md`.
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 and ready-made UI blocks on top of this module, and a hook that
31
- * pre-composes these helpers is the difference between a rule that holds and a
32
- * rule you have to remember. Use this module directly for non-React code, and
33
- * inside your own custom logic.
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
- * `.agents/skills/commerce/docs/api-storefront.md` for the surrounding rules.
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
- * `.agents/skills/commerce/references/catalog-rendering.md` for the UI rules.
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
- }