@base44/app-plugin-commerce 0.1.19 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +25 -22
  2. package/base44/functions/commerce/admin-reports/entry.ts +1 -1
  3. package/base44/functions/commerce/seed-store/entry.ts +34 -0
  4. package/base44/functions/commerce/seed-store/seed-catalog.ts +39 -5
  5. package/base44/shared/commerce/card-payment.stripe.ts +178 -0
  6. package/base44/shared/commerce/scan.ts +1 -1
  7. package/base44/shared/commerce/sequence.ts +1 -1
  8. package/package.json +1 -1
  9. package/scripts/install.js +24 -14
  10. package/skills/commerce/SKILL.md +107 -51
  11. package/skills/commerce/docs/api-admin.md +89 -28
  12. package/skills/commerce/docs/api-storefront.md +113 -126
  13. package/skills/commerce/docs/entities.md +137 -0
  14. package/skills/commerce/install/01-install.md +101 -0
  15. package/skills/commerce/install/02-storefront.md +188 -0
  16. package/skills/commerce/install/03-data.md +162 -0
  17. package/skills/commerce/references/admin-product-form.md +10 -0
  18. package/skills/commerce/references/catalog-rendering.md +110 -0
  19. package/skills/commerce/references/emails.md +49 -12
  20. package/skills/commerce/references/guest-access-security.md +18 -5
  21. package/skills/commerce/references/online-payments.md +50 -149
  22. package/skills/commerce/references/operations.md +52 -0
  23. package/skills/commerce/references/reviews.md +31 -16
  24. package/skills/commerce/references/shipping-and-tax.md +110 -0
  25. package/skills/commerce/references/store-admin-agent.md +21 -0
  26. package/skills/commerce/references/store-settings.md +49 -0
  27. package/src/commerce/admin/README.md +2 -2
  28. package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
  29. package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -1
  30. package/src/commerce/storefront/StorefrontProvider.jsx +106 -20
  31. package/src/commerce/storefront/blocks/AddToCartBlock.jsx +86 -0
  32. package/src/commerce/storefront/blocks/AddressFieldsBlock.jsx +96 -0
  33. package/src/commerce/storefront/blocks/BreadcrumbsBlock.jsx +52 -0
  34. package/src/commerce/storefront/blocks/CartLinesBlock.jsx +98 -0
  35. package/src/commerce/storefront/blocks/CheckoutBlock.jsx +247 -0
  36. package/src/commerce/storefront/blocks/CouponFieldBlock.jsx +84 -0
  37. package/src/commerce/storefront/blocks/OrderReceivedBlock.jsx +129 -0
  38. package/src/commerce/storefront/blocks/ProductGalleryBlock.jsx +66 -0
  39. package/src/commerce/storefront/blocks/ProductSpecsBlock.jsx +33 -0
  40. package/src/commerce/storefront/blocks/ProductStripBlock.jsx +55 -0
  41. package/src/commerce/storefront/blocks/QuantityStepper.jsx +62 -0
  42. package/src/commerce/storefront/blocks/ReviewsBlock.jsx +191 -0
  43. package/src/commerce/storefront/blocks/TotalsBlock.jsx +42 -0
  44. package/src/commerce/storefront/blocks/VariantSelectorBlock.jsx +81 -0
  45. package/src/commerce/storefront/blocks/index.js +44 -0
  46. package/src/commerce/storefront/index.js +59 -21
  47. package/src/commerce/storefront/internal/useAsyncData.js +86 -0
  48. package/src/commerce/storefront/pickers.jsx +20 -5
  49. package/src/commerce/storefront/useAddressForm.js +96 -0
  50. package/src/commerce/storefront/useCartLine.js +184 -0
  51. package/src/commerce/storefront/useCheckout.jsx +38 -11
  52. package/src/commerce/storefront/useProduct.js +227 -0
  53. package/src/commerce/storefront/useProductGallery.js +74 -0
  54. package/src/commerce/storefront/useProductList.js +153 -0
  55. package/src/commerce/storefront/useProductPrice.js +58 -0
  56. package/src/commerce/storefront/useProductReviews.js +242 -0
  57. package/src/commerce/storefront/useStorefrontSeo.js +204 -0
  58. package/src/commerce/storefront/useTotalsLines.js +109 -0
  59. package/src/commerce/utils/address-spec.js +89 -0
  60. package/src/commerce/utils/images.js +45 -0
  61. package/src/commerce/utils/index.js +18 -6
  62. package/src/commerce/utils/price.js +95 -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 +10 -2
  66. package/skills/commerce/installation-guidelines.md +0 -93
  67. package/skills/commerce/post-installation.md +0 -495
  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
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Storefront blocks — working default markup for the parts of a shopfront that
3
+ * are the same in every store.
4
+ *
5
+ * The split is deliberate:
6
+ *
7
+ * - **Identity surfaces are yours**: home, collection/grid, the product page's
8
+ * layout, the product card, the theme. That is where "make it look like X"
9
+ * lives, and no markup ships for it.
10
+ * - **Commodity surfaces ship as blocks**: checkout, cart lines, totals, the
11
+ * coupon field, reviews, order-received, and the product page's internals
12
+ * (variant selector, gallery, specs, breadcrumbs, strips). Every store's
13
+ * version of these is functionally identical, and hand-writing them is where
14
+ * storefront bugs cluster.
15
+ *
16
+ * Blocks are thin compositions of the hooks in this package, styled by
17
+ * inheritance: semantic markup, theme tokens (`border-border`, `text-muted-
18
+ * foreground`, `bg-primary`), and a stable `data-commerce="…"` attribute on
19
+ * every meaningful element — so your CSS can restyle any part of them, and
20
+ * tests can target them. Each takes `className` and, where there is more than
21
+ * one region, `slots`. When a design needs different *structure*, drop to the
22
+ * hooks the block uses and rewrite that one region.
23
+ *
24
+ * A whole checkout page:
25
+ *
26
+ * import { CheckoutBlock } from "@/commerce/storefront";
27
+ * export default function Checkout() {
28
+ * return <main className="mx-auto max-w-3xl px-6 py-16"><CheckoutBlock /></main>;
29
+ * }
30
+ */
31
+ export { CartLinesBlock } from "./CartLinesBlock";
32
+ export { CheckoutBlock } from "./CheckoutBlock";
33
+ export { AddressFieldsBlock } from "./AddressFieldsBlock";
34
+ export { CouponFieldBlock } from "./CouponFieldBlock";
35
+ export { TotalsBlock } from "./TotalsBlock";
36
+ export { QuantityStepper } from "./QuantityStepper";
37
+ export { OrderReceivedBlock } from "./OrderReceivedBlock";
38
+ export { ReviewsBlock } from "./ReviewsBlock";
39
+ export { VariantSelectorBlock } from "./VariantSelectorBlock";
40
+ export { AddToCartBlock } from "./AddToCartBlock";
41
+ export { ProductGalleryBlock } from "./ProductGalleryBlock";
42
+ export { ProductSpecsBlock } from "./ProductSpecsBlock";
43
+ export { BreadcrumbsBlock } from "./BreadcrumbsBlock";
44
+ export { ProductStripBlock } from "./ProductStripBlock";
@@ -1,10 +1,15 @@
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 — hooks and ready-made blocks for the shopfront you
3
+ * build. Ships with the Base44 Commerce Template next to the framework-free
4
+ * `@/commerce/utils` (which it builds on); needs React and nothing else.
5
+ *
6
+ * **Two tiers.** The *identity* surfaces are yours to design home, the
7
+ * collection grid, the product page's layout, the product card, the theme. The
8
+ * *commodity* surfaces ship as working blocks that inherit your theme —
9
+ * checkout, cart lines, totals, coupon field, reviews, order-received, and the
10
+ * product page's internals. Restyle or replace them; never hand-roll their
11
+ * logic. Blocks are compositions of the hooks below, so outgrowing one means
12
+ * rewriting a single region against an API you already know.
8
13
  *
9
14
  * Setup (once, above every storefront route — it wraps <Routes>; placed as a
10
15
  * child of <Routes> React Router throws "is not a <Route> component"):
@@ -13,25 +18,40 @@
13
18
  * import { base44 } from "@/api/base44Client";
14
19
  * <StorefrontProvider base44={base44}> <Routes>…</Routes> </StorefrontProvider>
15
20
  *
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.
21
+ * ## Blocks (drop in, then restyle)
22
+ * `CheckoutBlock` · `CartLinesBlock` · `TotalsBlock` · `CouponFieldBlock` ·
23
+ * `QuantityStepper` · `OrderReceivedBlock` · `ReviewsBlock` ·
24
+ * `AddressFieldsBlock` · `VariantSelectorBlock` · `AddToCartBlock` ·
25
+ * `ProductGalleryBlock` · `ProductSpecsBlock` · `BreadcrumbsBlock` ·
26
+ * `ProductStripBlock` (needs your `renderCard` no default card ships).
27
+ *
28
+ * ## Hooks
29
+ * - `useStorefront` / `useStoreInfo` / `useFormatMoney` / `useMoney` — the
30
+ * shared client, cached store info (the ONLY source of payment gateways,
31
+ * currency and countries), money in the store's currency.
32
+ * - `useProductList` / `useCategories` / `useRibbons` — a listing with paging,
33
+ * filters, `refreshing`, and failure as a visible state.
34
+ * - `useProduct` / `useAddToCart` / `useProductPrice` / `useProductGallery` —
35
+ * the product page: fetch + variant selection + quantity + price + gallery,
36
+ * race-safe, with `status: "not_found"` and add-to-cart errors handled.
37
+ * - `useProductReviews` — the review list and the submit form, with the store's
38
+ * policy as a prop.
39
+ * - `useCart` / `useCartLine` / `useCoupon` — the shared cart (branch on
40
+ * `status`, render `lines` and `notices`), quantity steppers that clamp and
41
+ * recover, and the coupon field a store with coupons must have.
22
42
  * - `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.
43
+ * checkout: address state with automatic debounced shipping/tax
44
+ * recalculation, shipping and payment choice, a `canPlaceOrder` gate with
45
+ * named blockers, `placeOrder` with the online-payment redirect handled.
46
+ * - `useAddressForm` / `useCountries` / `useTotalsLines` /
47
+ * `useCheckoutBlockers` — the address form as fields (state included,
48
+ * country options never null), one totals projection for cart and order,
49
+ * and blocker codes turned into copy.
27
50
  * - `ShippingMethodPicker` / `PaymentMethodPicker` — headless (render-prop)
28
51
  * wrappers over the two choices that are store data, never hardcoded.
29
52
  * - `useOrderReturn` — the mandatory `/order-received` page in one hook.
30
- * - `address.js` framework-free address-completeness rules (also exported).
31
- *
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.
53
+ * - `useStorefrontSeo` + `productSeo` / `collectionSeo` / `orderSeo` — titles,
54
+ * meta and product structured data; receipts are `noindex`.
35
55
  */
36
56
  export {
37
57
  StorefrontProvider,
@@ -50,3 +70,21 @@ export {
50
70
  shippingSlice,
51
71
  isShippingAddressComplete,
52
72
  } from "./address";
73
+
74
+ // ── catalog ────────────────────────────────────────────────────────────────
75
+ export { useProductList, useCategories, useRibbons } from "./useProductList";
76
+ export { useProduct, useAddToCart } from "./useProduct";
77
+ export { useProductPrice, useMoney } from "./useProductPrice";
78
+ export { useProductGallery } from "./useProductGallery";
79
+ export { useProductReviews } from "./useProductReviews";
80
+
81
+ // ── cart & checkout ────────────────────────────────────────────────────────
82
+ export { useCartLine, useCoupon } from "./useCartLine";
83
+ export { useAddressForm, useCountries } from "./useAddressForm";
84
+ export { useTotalsLines, useCheckoutBlockers, blockerMessage } from "./useTotalsLines";
85
+
86
+ // ── SEO ────────────────────────────────────────────────────────────────────
87
+ export { useStorefrontSeo, productSeo, collectionSeo, orderSeo } from "./useStorefrontSeo";
88
+
89
+ // ── blocks (default markup for the commodity surfaces) ─────────────────────
90
+ export * from "./blocks";
@@ -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
+ }
@@ -27,15 +27,19 @@ function resolveCheckout(name, prop, ctx) {
27
27
  * status, // "missing_address" | "choice_required" | "chosen" | "auto_selected"
28
28
  * methods, // [{ id, title, cost }] — what this address is offered
29
29
  * chosen, // the chosen/auto-selected entry (title + cost), or null
30
+ * selected, // alias of `chosen` — the same name the payment picker uses
30
31
  * choose, // (id) => Promise — call with a method's id on pick
31
32
  * mustChoose, // status === "choice_required" → render methods as a picker
33
+ * single, // exactly one method offered — already chosen; skip the
34
+ * // picker but still show `chosen.title` and its cost
32
35
  * syncing, // an address edit is being repriced — show a subtle busy state
33
36
  * addressError, // { code, message } | null — "we don't ship there" belongs
34
37
  * } // on the address fields
35
38
  *
36
39
  * Render rules the child should follow: `missing_address` → say the options
37
40
  * appear once the address is entered; `mustChoose` → a picker of `methods`;
38
- * otherwise display `chosen.title` + its cost (never the raw id).
41
+ * otherwise display `chosen.title` + its cost (never the raw id). `single` and
42
+ * `mustChoose` are never both true, and `single` guarantees `chosen`.
39
43
  */
40
44
  export function ShippingMethodPicker({ checkout: checkoutProp, children }) {
41
45
  const checkout = resolveCheckout("ShippingMethodPicker", checkoutProp, useCheckoutContextOptional());
@@ -43,6 +47,7 @@ export function ShippingMethodPicker({ checkout: checkoutProp, children }) {
43
47
  shippingStatus: status,
44
48
  shippingMethods: methods,
45
49
  chosenShippingMethod: chosen,
50
+ singleShippingMethod: single,
46
51
  chooseShippingMethod: choose,
47
52
  shippingSyncing: syncing,
48
53
  addressError,
@@ -52,8 +57,10 @@ export function ShippingMethodPicker({ checkout: checkoutProp, children }) {
52
57
  status,
53
58
  methods,
54
59
  chosen,
60
+ selected: chosen,
55
61
  choose,
56
62
  mustChoose: status === "choice_required",
63
+ single: single ?? (methods.length === 1), // fallback: a hand-built checkout object
57
64
  syncing,
58
65
  addressError,
59
66
  });
@@ -69,22 +76,30 @@ export function ShippingMethodPicker({ checkout: checkoutProp, children }) {
69
76
  * value, // the selected slug ("" while none)
70
77
  * select, // (slug) => void
71
78
  * selected, // the selected gateway entry, or null
72
- * single, // exactly one gateway — pre-selected; skip the picker but
79
+ * single, // exactly one gateway — already selected; skip the picker but
73
80
  * } // still show its title so the customer knows how they pay
74
81
  *
75
82
  * Render rules: several gateways → a picker labeled with the admin's
76
83
  * title/description; `single` → just display it; zero gateways → say checkout
77
- * is unavailable instead of rendering a dead place-order button.
84
+ * is unavailable instead of rendering a dead place-order button. `single`
85
+ * guarantees `value` and `selected` — never render the one-gateway branch as
86
+ * "nothing selected yet" — and both re-resolve when store info changes.
78
87
  */
79
88
  export function PaymentMethodPicker({ checkout: checkoutProp, children }) {
80
89
  const checkout = resolveCheckout("PaymentMethodPicker", checkoutProp, useCheckoutContextOptional());
81
- const { paymentMethods: gateways, paymentMethod: value, setPaymentMethod: select, selectedGateway: selected } = checkout;
90
+ const {
91
+ paymentMethods: gateways,
92
+ paymentMethod: value,
93
+ setPaymentMethod: select,
94
+ selectedGateway: selected,
95
+ singlePaymentMethod: single,
96
+ } = checkout;
82
97
  if (!gateways) return null;
83
98
  return children({
84
99
  gateways,
85
100
  value,
86
101
  select,
87
102
  selected,
88
- single: gateways.length === 1,
103
+ single: single ?? (gateways.length === 1), // fallback: a hand-built checkout object
89
104
  });
90
105
  }
@@ -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,184 @@
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
+ * useCoupon — the coupon field. Small, and the difference between a store that
112
+ * can honour its own discounts and one that cannot.
113
+ *
114
+ * const c = useCoupon();
115
+ * <input value={c.code} onChange={(e) => c.setCode(e.target.value)} />
116
+ * <button onClick={c.apply} disabled={c.applying}>Apply</button>
117
+ * {c.error && <p role="alert">{c.error.message}</p>}
118
+ * {c.applied.map(a => <Chip key={a.code} onRemove={() => c.remove(a.code)} …/>)}
119
+ *
120
+ * Coupons are admin-only data — a storefront cannot list codes, so a seeded
121
+ * code is reachable **only** through a field the customer types it into. If the
122
+ * store has any coupons, this field must exist somewhere in the cart or the
123
+ * checkout, or those codes can never be redeemed.
124
+ *
125
+ * An invalid, expired or ineligible code is **expected flow**: `apply()`
126
+ * resolves `{ ok: false, error }` and never throws — render `error.message`
127
+ * inline next to the field.
128
+ */
129
+ export function useCoupon() {
130
+ const { cart, applyCoupon, removeCoupon } = useCart();
131
+ const [code, setCode] = useState("");
132
+ const [applying, setApplying] = useState(false);
133
+ const [error, setError] = useState(null);
134
+
135
+ const apply = useCallback(
136
+ async (explicit) => {
137
+ const value = String(explicit ?? code ?? "").trim();
138
+ if (!value) return { ok: false, error: { code: "empty", message: "Enter a code." } };
139
+ setApplying(true);
140
+ setError(null);
141
+ const res = await applyCoupon(value);
142
+ setApplying(false);
143
+ if (res.ok) {
144
+ setCode("");
145
+ return { ok: true, cart: res.cart };
146
+ }
147
+ const err = { code: res.code ?? "coupon_invalid", message: res.message };
148
+ setError(err);
149
+ return { ok: false, error: err };
150
+ },
151
+ [code, applyCoupon],
152
+ );
153
+
154
+ const remove = useCallback(
155
+ async (codeOrEntry) => {
156
+ const value = typeof codeOrEntry === "string" ? codeOrEntry : codeOrEntry?.code;
157
+ setError(null);
158
+ try {
159
+ await removeCoupon(value);
160
+ return { ok: true };
161
+ } catch (e) {
162
+ const err = { code: storefrontErrorCode(e) ?? "error", message: storefrontErrorMessage(e) };
163
+ setError(err);
164
+ return { ok: false, error: err };
165
+ }
166
+ },
167
+ [removeCoupon],
168
+ );
169
+
170
+ return {
171
+ code,
172
+ setCode,
173
+ apply,
174
+ remove,
175
+ applied: (cart?.coupons ?? []).map((c) => ({
176
+ code: c.code,
177
+ discount: c.discount ?? 0,
178
+ freeShipping: Boolean(c.free_shipping),
179
+ })),
180
+ applying,
181
+ error,
182
+ discountTotal: cart?.totals?.discount_total ?? 0,
183
+ };
184
+ }