@base44/app-plugin-commerce 0.1.20 → 0.2.2

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 (73) hide show
  1. package/README.md +25 -22
  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/entry.ts +34 -0
  11. package/base44/functions/commerce/seed-store/seed-catalog.ts +39 -5
  12. package/base44/functions/commerce/storefront-catalog/entry.ts +1 -1
  13. package/base44/functions/commerce/storefront-checkout/entry.ts +1 -1
  14. package/base44/shared/commerce/card-payment.stripe.ts +198 -0
  15. package/base44/shared/commerce/card-payment.ts +1 -1
  16. package/base44/shared/commerce/payments.ts +2 -2
  17. package/base44/shared/commerce/scan.ts +1 -1
  18. package/base44/shared/commerce/sequence.ts +2 -2
  19. package/package.json +1 -1
  20. package/scripts/install.js +24 -14
  21. package/skills/commerce/SKILL.md +117 -51
  22. package/skills/commerce/docs/api-admin.md +89 -28
  23. package/skills/commerce/docs/api-storefront.md +113 -126
  24. package/skills/commerce/docs/entities.md +137 -0
  25. package/skills/commerce/install/01-install.md +101 -0
  26. package/skills/commerce/install/02-storefront.md +444 -0
  27. package/skills/commerce/install/03-data.md +162 -0
  28. package/skills/commerce/references/admin-product-form.md +10 -0
  29. package/skills/commerce/references/catalog-rendering.md +110 -0
  30. package/skills/commerce/references/emails.md +49 -12
  31. package/skills/commerce/references/guest-access-security.md +18 -5
  32. package/skills/commerce/references/online-payments.md +49 -149
  33. package/skills/commerce/references/operations.md +52 -0
  34. package/skills/commerce/references/reviews.md +31 -16
  35. package/skills/commerce/references/shipping-and-tax.md +110 -0
  36. package/skills/commerce/references/store-admin-agent.md +21 -0
  37. package/skills/commerce/references/store-settings.md +49 -0
  38. package/src/commerce/admin/README.md +6 -3
  39. package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
  40. package/src/commerce/admin/pages/products/Reviews.jsx +1 -1
  41. package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -1
  42. package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +1 -1
  43. package/src/commerce/storefront/StorefrontProvider.jsx +106 -20
  44. package/src/commerce/storefront/index.js +74 -24
  45. package/src/commerce/storefront/internal/useAsyncData.js +86 -0
  46. package/src/commerce/storefront/useAddressForm.js +96 -0
  47. package/src/commerce/storefront/useCartLine.js +221 -0
  48. package/src/commerce/storefront/useCheckout.jsx +18 -6
  49. package/src/commerce/storefront/useOrderReturn.js +36 -10
  50. package/src/commerce/storefront/useProduct.js +295 -0
  51. package/src/commerce/storefront/useProductGallery.js +74 -0
  52. package/src/commerce/storefront/useProductList.js +153 -0
  53. package/src/commerce/storefront/useProductPrice.js +58 -0
  54. package/src/commerce/storefront/useProductReviews.js +242 -0
  55. package/src/commerce/storefront/useStorefrontSeo.js +204 -0
  56. package/src/commerce/storefront/useTotalsLines.js +109 -0
  57. package/src/commerce/utils/address-spec.js +89 -0
  58. package/src/commerce/utils/images.js +45 -0
  59. package/src/commerce/utils/index.js +22 -7
  60. package/src/commerce/utils/price.js +95 -0
  61. package/src/commerce/utils/shipping-promos.js +2 -2
  62. package/src/commerce/utils/specs.js +26 -0
  63. package/src/commerce/utils/storefront.js +47 -3
  64. package/src/commerce/utils/totals.js +110 -0
  65. package/src/commerce/utils/variants.js +58 -3
  66. package/skills/commerce/installation-guidelines.md +0 -93
  67. package/skills/commerce/post-installation.md +0 -496
  68. package/skills/commerce/references/limits-and-performance.md +0 -16
  69. package/skills/commerce/references/media-and-downloads.md +0 -4
  70. package/skills/commerce/references/product-render.md +0 -89
  71. package/skills/commerce/references/scheduled-work.md +0 -19
  72. package/skills/commerce/references/storefront-product-page.md +0 -83
  73. package/skills/commerce/references/webhooks.md +0 -10
@@ -1,10 +1,20 @@
1
1
  /**
2
- * Storefront React layer — hooks + headless helpers for the cart, checkout
3
- * and order-received parts of the shopfront you build. Ships with the Base44
4
- * Commerce Template next to the framework-free `@/commerce/utils` (which it
5
- * builds on); needs React and nothing else. NO visual components ship here
6
- * every pixel stays yours. The hooks own the contracts that are easy to get
7
- * subtly wrong; you own the markup.
2
+ * Storefront React layer — **headless**: hooks and render-prop components that
3
+ * own the store's logic and hand you the data; they render nothing and carry
4
+ * no styling. Every element, class and word of copy in the storefront is
5
+ * written by you, against these APIs. Ships with the Base44 Commerce Template
6
+ * next to the framework-free `@/commerce/utils` (which it builds on); needs
7
+ * React and nothing else.
8
+ *
9
+ * The split: **logic is premade, UI never is.** Checkout repricing, variant
10
+ * resolution, cart state, review policies, order-return verification — done
11
+ * here, and hand-rolling any of it is where storefront bugs cluster. What a
12
+ * checkout or a product page *looks like* is the store's identity, and no two
13
+ * stores should share it — so nothing here emits markup. Each hook returns a
14
+ * complete view-model (statuses to branch on, ready-to-map arrays, handlers,
15
+ * error objects), and each doc comment states the render rules that keep the
16
+ * store correct (e.g. an unbuyable variant option renders *disabled, not
17
+ * hidden*; a receipt page must render `paymentInstructions`).
8
18
  *
9
19
  * Setup (once, above every storefront route — it wraps <Routes>; placed as a
10
20
  * child of <Routes> React Router throws "is not a <Route> component"):
@@ -13,25 +23,47 @@
13
23
  * import { base44 } from "@/api/base44Client";
14
24
  * <StorefrontProvider base44={base44}> <Routes>…</Routes> </StorefrontProvider>
15
25
  *
16
- * - `StorefrontProvider` / `useStorefront` / `useStoreInfo` / `useFormatMoney`
17
- * one client, cached store info (the ONLY source of payment gateways,
18
- * currency, countries), money formatting in the store's currency.
19
- * - `useCart` the shared cart: state + every mutation, serialized so
20
- * responses never apply out of order; a header badge, a cart drawer and
21
- * the checkout all see the same view.
26
+ * ## Hooks
27
+ * - `useStorefront` / `useStoreInfo` / `useFormatMoney` / `useMoney` the
28
+ * shared client, cached store info (the ONLY source of payment gateways,
29
+ * currency and countries), money in the store's currency.
30
+ * - `useProductList` / `useCategories` / `useRibbons` a listing with paging,
31
+ * filters, `refreshing`, and failure as a visible state.
32
+ * - `useProduct` / `useAddToCart` / `useAddToCartButton` / `useProductPrice` /
33
+ * `useProductGallery` — the product page: fetch + variant selection +
34
+ * quantity + price + gallery, race-safe, with `status: "not_found"` and
35
+ * every add-to-cart failure handled. `variantAxes(view, pick)` (from
36
+ * `@/commerce/utils`, re-exported here) turns the resolved view into a
37
+ * render-ready model for the selector you write.
38
+ * - `useProductReviews` — the review list and the submit form, with the store's
39
+ * policy as a prop and field errors matching the server's codes.
40
+ * - `useCart` / `useCartLine` / `useCoupon` — the shared cart (branch on
41
+ * `status`, render `lines` and `notices`), quantity steppers that clamp and
42
+ * recover, and the coupon field a store with coupons must have.
22
43
  * - `useCheckout` / `CheckoutProvider` / `useCheckoutContext` — the guided
23
- * checkout: address form state with automatic debounced shipping/tax
24
- * recalculation once the address is complete, shipping-method choice,
25
- * payment-method choice, a `canPlaceOrder` gate with named blockers, and
26
- * `placeOrder` with the online-payment redirect handled.
27
- * - `ShippingMethodPicker` / `PaymentMethodPicker` headless (render-prop)
28
- * wrappers over the two choices that are store data, never hardcoded.
29
- * - `useOrderReturn` the mandatory `/order-received` page in one hook.
30
- * - `address.js` framework-free address-completeness rules (also exported).
44
+ * checkout: address state with automatic debounced shipping/tax
45
+ * recalculation, shipping and payment choice, a `canPlaceOrder` gate with
46
+ * named blockers, `placeOrder` with both navigations handled (online →
47
+ * provider redirect, manual → `/order-received`).
48
+ * - `useAddressForm` / `useCountries` / `useTotalsLines` /
49
+ * `useCheckoutBlockers` the address form as a field spec you map to your
50
+ * own inputs (state included, country options never null), one totals
51
+ * projection for cart and order, and blocker codes turned into copy.
52
+ * - `useOrderReturn` — the mandatory `/order-received` page in one hook:
53
+ * status, order, `lines`, `paymentLink`, `paymentInstructions`, noindex.
54
+ * - `useStorefrontSeo` + `productSeo` / `collectionSeo` / `orderSeo` — titles,
55
+ * meta and product structured data; receipts are `noindex`.
56
+ *
57
+ * ## Render-prop components (headless — children is a function, no markup ships)
58
+ * - `ShippingMethodPicker` / `PaymentMethodPicker` — the two checkout choices
59
+ * that are store data, never hardcoded, with their branching enumerated.
60
+ * - `CartLine` — per-line `useCartLine` binding for your cart rows, so a
61
+ * `lines.map(...)` never calls a hook in a loop.
31
62
  *
32
- * Catalog calls (product list, product page) intentionally stay thin — use
33
- * `useStorefront()` for the client plus the variant helpers from
34
- * `@/commerce/utils`; those views are where the design freedom lives.
63
+ * ## Helpers re-exported from `@/commerce/utils`
64
+ * - `variantAxes(view, pick)` axes options with selected/disabled/stock
65
+ * state derived, for the variant selector you write.
66
+ * - `productSpecs(product)` — `meta_data` → spec-table rows.
35
67
  */
36
68
  export {
37
69
  StorefrontProvider,
@@ -42,7 +74,7 @@ export {
42
74
  useCart,
43
75
  } from "./StorefrontProvider";
44
76
  export { useCheckout, CheckoutProvider, useCheckoutContext } from "./useCheckout";
45
- export { useOrderReturn } from "./useOrderReturn";
77
+ export { useOrderReturn, orderReceivedUrl } from "./useOrderReturn";
46
78
  export { ShippingMethodPicker, PaymentMethodPicker } from "./pickers";
47
79
  export {
48
80
  REQUIRED_BILLING_FIELDS,
@@ -50,3 +82,21 @@ export {
50
82
  shippingSlice,
51
83
  isShippingAddressComplete,
52
84
  } from "./address";
85
+
86
+ // ── catalog ────────────────────────────────────────────────────────────────
87
+ export { useProductList, useCategories, useRibbons } from "./useProductList";
88
+ export { useProduct, useAddToCart, useAddToCartButton } from "./useProduct";
89
+ export { useProductPrice, useMoney } from "./useProductPrice";
90
+ export { useProductGallery } from "./useProductGallery";
91
+ export { useProductReviews } from "./useProductReviews";
92
+
93
+ // ── cart & checkout ────────────────────────────────────────────────────────
94
+ export { useCartLine, useCoupon, CartLine } from "./useCartLine";
95
+ export { useAddressForm, useCountries } from "./useAddressForm";
96
+ export { useTotalsLines, useCheckoutBlockers, blockerMessage } from "./useTotalsLines";
97
+
98
+ // ── SEO ────────────────────────────────────────────────────────────────────
99
+ export { useStorefrontSeo, productSeo, collectionSeo, orderSeo } from "./useStorefrontSeo";
100
+
101
+ // ── view-model helpers (framework-free, from @/commerce/utils) ─────────────
102
+ export { variantAxes, productSpecs } from "@/commerce/utils";
@@ -0,0 +1,86 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { storefrontErrorCode, storefrontErrorMessage } from "@/commerce/utils";
3
+
4
+ /**
5
+ * Internal async primitive for the storefront hooks — the storefront twin of
6
+ * the admin's `useAsync`/`usePagedList`. Not exported from the package: it
7
+ * exists so every catalog hook gets the same three properties for free, each
8
+ * of which a hand-written `useEffect` reliably misses:
9
+ *
10
+ * - **Race-safety.** Only the newest request may publish. Two `getProduct`
11
+ * calls in flight (the customer clicked a second product) can otherwise
12
+ * resolve out of order and paint the wrong page.
13
+ * - **`loading` vs `refreshing`.** First load is `loading`; a reload caused by
14
+ * changed params is `refreshing` with the previous data still on screen, so
15
+ * a filter change doesn't flash an empty grid.
16
+ * - **Failure is a state, not a silence.** An error sets `error` and (with
17
+ * `keepPreviousData`) leaves the last good data in place — the common
18
+ * hand-rolled shape, `.finally(() => setLoading(false))`, renders a failed
19
+ * fetch as a legitimately empty result.
20
+ *
21
+ * @param {(signal: {cancelled: boolean}) => Promise<any>} fn the request
22
+ * @param {Array<any>} deps re-runs when these change (like useEffect's)
23
+ * @param {{keepPreviousData?: boolean, enabled?: boolean, initialData?: any}} [opts]
24
+ */
25
+ export function useAsyncData(fn, deps, { keepPreviousData = true, enabled = true, initialData = null } = {}) {
26
+ const [state, setState] = useState({
27
+ data: initialData,
28
+ loading: enabled,
29
+ refreshing: false,
30
+ error: null,
31
+ });
32
+ // Monotonic request id: a response may only publish if it is still the newest.
33
+ const requestId = useRef(0);
34
+ const hasData = useRef(initialData != null);
35
+ const fnRef = useRef(fn);
36
+ fnRef.current = fn;
37
+
38
+ const run = useCallback(
39
+ async ({ quiet = false } = {}) => {
40
+ if (!enabled) return;
41
+ const id = ++requestId.current;
42
+ const firstLoad = !hasData.current;
43
+ if (!quiet) {
44
+ setState((s) => ({
45
+ ...s,
46
+ loading: firstLoad,
47
+ refreshing: !firstLoad,
48
+ data: keepPreviousData ? s.data : firstLoad ? s.data : null,
49
+ }));
50
+ }
51
+ try {
52
+ const data = await fnRef.current();
53
+ if (id !== requestId.current) return; // a newer request won
54
+ hasData.current = true;
55
+ setState({ data, loading: false, refreshing: false, error: null });
56
+ } catch (e) {
57
+ if (id !== requestId.current) return;
58
+ setState((s) => ({
59
+ data: keepPreviousData ? s.data : null,
60
+ loading: false,
61
+ refreshing: false,
62
+ error: { code: storefrontErrorCode(e) ?? "error", message: storefrontErrorMessage(e) },
63
+ }));
64
+ }
65
+ },
66
+ [enabled, keepPreviousData],
67
+ );
68
+
69
+ useEffect(() => {
70
+ run();
71
+ // Bump the id on unmount/dep-change so an in-flight response can't publish.
72
+ return () => {
73
+ requestId.current += 1;
74
+ };
75
+ // eslint-disable-next-line react-hooks/exhaustive-deps
76
+ }, [run, ...deps]);
77
+
78
+ const reload = useCallback(() => run(), [run]);
79
+ const reloadQuiet = useCallback(() => run({ quiet: true }), [run]);
80
+ const setData = useCallback((next) => {
81
+ hasData.current = true;
82
+ setState((s) => ({ ...s, data: typeof next === "function" ? next(s.data) : next }));
83
+ }, []);
84
+
85
+ return { ...state, reload, reloadQuiet, setData };
86
+ }
@@ -0,0 +1,96 @@
1
+ import { useMemo } from "react";
2
+ import { addressFieldSpec } from "@/commerce/utils";
3
+ import { useStoreInfo } from "./StorefrontProvider";
4
+ import { useCheckoutContext } from "./useCheckout";
5
+ import { REQUIRED_BILLING_FIELDS } from "./address";
6
+
7
+ /**
8
+ * The store's country list, **always as an array**.
9
+ *
10
+ * const { options, loading } = useCountries();
11
+ * <select>{options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}</select>
12
+ *
13
+ * `useStoreInfo().countries` is `null` until store info resolves, so mapping it
14
+ * directly white-screens the checkout on a cold load — the most severe defect
15
+ * observed in a generated storefront. Here the list is empty-then-full, never
16
+ * null, and `loading` says which.
17
+ */
18
+ export function useCountries() {
19
+ const { countries, loading, error } = useStoreInfo();
20
+ const list = Array.isArray(countries) ? countries : [];
21
+ const options = useMemo(() => list.map((c) => ({ value: c.code, label: c.name })), [countries]); // eslint-disable-line react-hooks/exhaustive-deps
22
+ return { countries: list, options, loading, error };
23
+ }
24
+
25
+ /**
26
+ * useAddressForm — the checkout address form as a field list bound to the
27
+ * guided checkout. Needs a `<CheckoutProvider>` above it.
28
+ *
29
+ * const { fields, set } = useAddressForm("billing");
30
+ * {fields.map(f => (
31
+ * <label key={f.key}>
32
+ * {f.label}{f.required && " *"}
33
+ * {f.type === "select"
34
+ * ? <select value={f.value} onChange={e => set(f.key, e.target.value)}>
35
+ * {f.options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
36
+ * </select>
37
+ * : <input type={f.type} value={f.value} autoComplete={f.autoComplete}
38
+ * onChange={e => set(f.key, e.target.value)} />}
39
+ * {f.error && <span role="alert">{f.error}</span>}
40
+ * </label>
41
+ * ))}
42
+ *
43
+ * Editing a field is all it takes to trigger the shipping/tax recalculation —
44
+ * `useCheckout` debounces and calls `set-shipping-address` once the address is
45
+ * complete enough to price. Two things the field list gets right that a
46
+ * hand-typed table did not: **`state` is present** (rates and taxes match on
47
+ * country + state, and it switches to a select for countries with
48
+ * subdivisions), and the country options are never null.
49
+ *
50
+ * @param {"billing"|"shipping"} [which]
51
+ * @param {{includeState?: boolean, includePhone?: boolean, includeCompany?: boolean}} [options]
52
+ */
53
+ export function useAddressForm(which = "billing", options = {}) {
54
+ const checkout = useCheckoutContext();
55
+ const { countries, loading: countriesLoading } = useCountries();
56
+
57
+ const isBilling = which === "billing";
58
+ const values = isBilling ? checkout.billing : checkout.shipping;
59
+ const set = isBilling
60
+ ? (key, value) => checkout.updateBilling({ [key]: value })
61
+ : (key, value) => checkout.updateShipping({ [key]: value });
62
+
63
+ // `place-order` only enforces required fields on billing; a separate shipping
64
+ // address is priced, not validated field-by-field.
65
+ const missing = isBilling ? checkout.missingBillingFields : [];
66
+
67
+ const fields = useMemo(() => {
68
+ const spec = addressFieldSpec({
69
+ countries,
70
+ country: values?.country,
71
+ required: isBilling ? REQUIRED_BILLING_FIELDS : ["country", "city"],
72
+ includeEmail: isBilling, // one email per order, on billing
73
+ ...options,
74
+ });
75
+ return spec.map((f) => ({
76
+ ...f,
77
+ value: values?.[f.key] ?? "",
78
+ // The address-level error ("we don't ship there") belongs on country.
79
+ error:
80
+ f.key === "country" && checkout.addressError?.code === "shipping_not_available"
81
+ ? checkout.addressError.message
82
+ : null,
83
+ }));
84
+ // eslint-disable-next-line react-hooks/exhaustive-deps
85
+ }, [countries, values, isBilling, checkout.addressError, JSON.stringify(options)]);
86
+
87
+ return {
88
+ fields,
89
+ set,
90
+ values: values ?? {},
91
+ missing,
92
+ complete: missing.length === 0,
93
+ error: checkout.addressError ?? null,
94
+ countriesLoading,
95
+ };
96
+ }
@@ -0,0 +1,221 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { storefrontErrorCode, storefrontErrorMessage } from "@/commerce/utils";
3
+ import { useCart } from "./StorefrontProvider";
4
+
5
+ /**
6
+ * useCartLine — one cart line's quantity control, with the interaction solved.
7
+ *
8
+ * const l = useCartLine(line);
9
+ * <button onClick={l.decrease} disabled={!l.canDecrease || l.pending}>−</button>
10
+ * <span>{l.quantity}</span>
11
+ * <button onClick={l.increase} disabled={!l.canIncrease || l.pending}>+</button>
12
+ * <button onClick={l.remove}>Remove</button>
13
+ * {l.error && <p role="alert">{l.error.message}</p>}
14
+ *
15
+ * What the obvious version (`onClick={() => updateItem(key, qty + 1)}`) gets
16
+ * wrong, and this does not: the mutation is **awaited and caught**, so a stock
17
+ * rejection shows as `error` instead of an unhandled rejection with the number
18
+ * silently snapping back; the displayed quantity is **optimistic** and rolls
19
+ * back only if the server disagrees; rapid clicks are **coalesced** into one
20
+ * request per settle instead of one per click; and `sold_individually` is
21
+ * respected, so a one-per-customer product has no working "+".
22
+ *
23
+ * @param {object} line a decorated line from `useCart().lines` (a raw
24
+ * `cart.items[n]` works too — it just has no `maxQuantity` hint)
25
+ * @param {{debounceMs?: number}} [options]
26
+ */
27
+ export function useCartLine(line, { debounceMs = 250 } = {}) {
28
+ const { updateItem, removeItem } = useCart();
29
+ const serverQuantity = line?.quantity ?? 1;
30
+ const maxQuantity = line?.maxQuantity ?? (line?.sold_individually ? 1 : Infinity);
31
+
32
+ const [optimistic, setOptimistic] = useState(null);
33
+ const [pending, setPending] = useState(false);
34
+ const [error, setError] = useState(null);
35
+ const timer = useRef(null);
36
+ const target = useRef(null);
37
+
38
+ // The server is the truth: whenever its quantity lands, drop the optimistic
39
+ // value (whether it agreed with us or not).
40
+ useEffect(() => {
41
+ setOptimistic(null);
42
+ }, [serverQuantity]);
43
+
44
+ useEffect(() => () => clearTimeout(timer.current), []);
45
+
46
+ const quantity = optimistic ?? serverQuantity;
47
+
48
+ const commit = useCallback(async () => {
49
+ const next = target.current;
50
+ if (next == null || next === serverQuantity) return;
51
+ setPending(true);
52
+ setError(null);
53
+ try {
54
+ await updateItem(line.item_key, next);
55
+ } catch (e) {
56
+ setOptimistic(null); // roll back to what the server still holds
57
+ setError({ code: storefrontErrorCode(e) ?? "error", message: storefrontErrorMessage(e) });
58
+ } finally {
59
+ setPending(false);
60
+ target.current = null;
61
+ }
62
+ }, [line?.item_key, serverQuantity, updateItem]);
63
+
64
+ const setQuantity = useCallback(
65
+ (n) => {
66
+ const clamped = Math.max(1, Math.min(maxQuantity, Math.floor(Number(n) || 1)));
67
+ setOptimistic(clamped);
68
+ setError(null);
69
+ target.current = clamped;
70
+ clearTimeout(timer.current);
71
+ timer.current = setTimeout(commit, debounceMs);
72
+ },
73
+ [maxQuantity, commit, debounceMs],
74
+ );
75
+
76
+ const increase = useCallback(() => setQuantity(quantity + 1), [quantity, setQuantity]);
77
+ const decrease = useCallback(() => setQuantity(quantity - 1), [quantity, setQuantity]);
78
+
79
+ const remove = useCallback(async () => {
80
+ clearTimeout(timer.current);
81
+ setPending(true);
82
+ setError(null);
83
+ try {
84
+ await removeItem(line.item_key);
85
+ } catch (e) {
86
+ setError({ code: storefrontErrorCode(e) ?? "error", message: storefrontErrorMessage(e) });
87
+ } finally {
88
+ setPending(false);
89
+ }
90
+ }, [line?.item_key, removeItem]);
91
+
92
+ return {
93
+ quantity,
94
+ setQuantity,
95
+ increase,
96
+ decrease,
97
+ remove,
98
+ pending,
99
+ error,
100
+ canIncrease: quantity < maxQuantity,
101
+ canDecrease: quantity > 1,
102
+ maxQuantity,
103
+ atMax: quantity >= maxQuantity,
104
+ atMin: quantity <= 1,
105
+ attributesLabel: line?.attributesLabel ?? "",
106
+ image: line?.image ?? null,
107
+ };
108
+ }
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
+
147
+ /**
148
+ * useCoupon — the coupon field. Small, and the difference between a store that
149
+ * can honour its own discounts and one that cannot.
150
+ *
151
+ * const c = useCoupon();
152
+ * <input value={c.code} onChange={(e) => c.setCode(e.target.value)} />
153
+ * <button onClick={c.apply} disabled={c.applying}>Apply</button>
154
+ * {c.error && <p role="alert">{c.error.message}</p>}
155
+ * {c.applied.map(a => <Chip key={a.code} onRemove={() => c.remove(a.code)} …/>)}
156
+ *
157
+ * Coupons are admin-only data — a storefront cannot list codes, so a seeded
158
+ * code is reachable **only** through a field the customer types it into. If the
159
+ * store has any coupons, this field must exist somewhere in the cart or the
160
+ * checkout, or those codes can never be redeemed.
161
+ *
162
+ * An invalid, expired or ineligible code is **expected flow**: `apply()`
163
+ * resolves `{ ok: false, error }` and never throws — render `error.message`
164
+ * inline next to the field.
165
+ */
166
+ export function useCoupon() {
167
+ const { cart, applyCoupon, removeCoupon } = useCart();
168
+ const [code, setCode] = useState("");
169
+ const [applying, setApplying] = useState(false);
170
+ const [error, setError] = useState(null);
171
+
172
+ const apply = useCallback(
173
+ async (explicit) => {
174
+ const value = String(explicit ?? code ?? "").trim();
175
+ if (!value) return { ok: false, error: { code: "empty", message: "Enter a code." } };
176
+ setApplying(true);
177
+ setError(null);
178
+ const res = await applyCoupon(value);
179
+ setApplying(false);
180
+ if (res.ok) {
181
+ setCode("");
182
+ return { ok: true, cart: res.cart };
183
+ }
184
+ const err = { code: res.code ?? "coupon_invalid", message: res.message };
185
+ setError(err);
186
+ return { ok: false, error: err };
187
+ },
188
+ [code, applyCoupon],
189
+ );
190
+
191
+ const remove = useCallback(
192
+ async (codeOrEntry) => {
193
+ const value = typeof codeOrEntry === "string" ? codeOrEntry : codeOrEntry?.code;
194
+ setError(null);
195
+ try {
196
+ await removeCoupon(value);
197
+ return { ok: true };
198
+ } catch (e) {
199
+ const err = { code: storefrontErrorCode(e) ?? "error", message: storefrontErrorMessage(e) };
200
+ setError(err);
201
+ return { ok: false, error: err };
202
+ }
203
+ },
204
+ [removeCoupon],
205
+ );
206
+
207
+ return {
208
+ code,
209
+ setCode,
210
+ apply,
211
+ remove,
212
+ applied: (cart?.coupons ?? []).map((c) => ({
213
+ code: c.code,
214
+ discount: c.discount ?? 0,
215
+ freeShipping: Boolean(c.free_shipping),
216
+ })),
217
+ applying,
218
+ error,
219
+ discountTotal: cart?.totals?.discount_total ?? 0,
220
+ };
221
+ }
@@ -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
  }