@base44/app-plugin-commerce 0.1.20 → 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.
- package/README.md +25 -22
- package/base44/functions/commerce/admin-reports/entry.ts +1 -1
- package/base44/functions/commerce/seed-store/entry.ts +34 -0
- package/base44/functions/commerce/seed-store/seed-catalog.ts +39 -5
- package/base44/shared/commerce/card-payment.stripe.ts +178 -0
- package/base44/shared/commerce/scan.ts +1 -1
- package/base44/shared/commerce/sequence.ts +1 -1
- package/package.json +1 -1
- package/scripts/install.js +24 -14
- package/skills/commerce/SKILL.md +107 -51
- package/skills/commerce/docs/api-admin.md +89 -28
- package/skills/commerce/docs/api-storefront.md +113 -126
- package/skills/commerce/docs/entities.md +137 -0
- package/skills/commerce/install/01-install.md +101 -0
- package/skills/commerce/install/02-storefront.md +188 -0
- package/skills/commerce/install/03-data.md +162 -0
- package/skills/commerce/references/admin-product-form.md +10 -0
- package/skills/commerce/references/catalog-rendering.md +110 -0
- package/skills/commerce/references/emails.md +49 -12
- package/skills/commerce/references/guest-access-security.md +18 -5
- package/skills/commerce/references/online-payments.md +50 -149
- package/skills/commerce/references/operations.md +52 -0
- package/skills/commerce/references/reviews.md +31 -16
- package/skills/commerce/references/shipping-and-tax.md +110 -0
- package/skills/commerce/references/store-admin-agent.md +21 -0
- package/skills/commerce/references/store-settings.md +49 -0
- package/src/commerce/admin/README.md +2 -2
- package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
- package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -1
- package/src/commerce/storefront/StorefrontProvider.jsx +106 -20
- package/src/commerce/storefront/blocks/AddToCartBlock.jsx +86 -0
- package/src/commerce/storefront/blocks/AddressFieldsBlock.jsx +96 -0
- package/src/commerce/storefront/blocks/BreadcrumbsBlock.jsx +52 -0
- package/src/commerce/storefront/blocks/CartLinesBlock.jsx +98 -0
- package/src/commerce/storefront/blocks/CheckoutBlock.jsx +247 -0
- package/src/commerce/storefront/blocks/CouponFieldBlock.jsx +84 -0
- package/src/commerce/storefront/blocks/OrderReceivedBlock.jsx +129 -0
- package/src/commerce/storefront/blocks/ProductGalleryBlock.jsx +66 -0
- package/src/commerce/storefront/blocks/ProductSpecsBlock.jsx +33 -0
- package/src/commerce/storefront/blocks/ProductStripBlock.jsx +55 -0
- package/src/commerce/storefront/blocks/QuantityStepper.jsx +62 -0
- package/src/commerce/storefront/blocks/ReviewsBlock.jsx +191 -0
- package/src/commerce/storefront/blocks/TotalsBlock.jsx +42 -0
- package/src/commerce/storefront/blocks/VariantSelectorBlock.jsx +81 -0
- package/src/commerce/storefront/blocks/index.js +44 -0
- package/src/commerce/storefront/index.js +59 -21
- package/src/commerce/storefront/internal/useAsyncData.js +86 -0
- package/src/commerce/storefront/useAddressForm.js +96 -0
- package/src/commerce/storefront/useCartLine.js +184 -0
- package/src/commerce/storefront/useProduct.js +227 -0
- package/src/commerce/storefront/useProductGallery.js +74 -0
- package/src/commerce/storefront/useProductList.js +153 -0
- package/src/commerce/storefront/useProductPrice.js +58 -0
- package/src/commerce/storefront/useProductReviews.js +242 -0
- package/src/commerce/storefront/useStorefrontSeo.js +204 -0
- package/src/commerce/storefront/useTotalsLines.js +109 -0
- package/src/commerce/utils/address-spec.js +89 -0
- package/src/commerce/utils/images.js +45 -0
- package/src/commerce/utils/index.js +18 -6
- package/src/commerce/utils/price.js +95 -0
- package/src/commerce/utils/storefront.js +47 -3
- package/src/commerce/utils/totals.js +110 -0
- package/src/commerce/utils/variants.js +10 -2
- package/skills/commerce/installation-guidelines.md +0 -93
- package/skills/commerce/post-installation.md +0 -496
- package/skills/commerce/references/limits-and-performance.md +0 -16
- package/skills/commerce/references/media-and-downloads.md +0 -4
- package/skills/commerce/references/product-render.md +0 -89
- package/skills/commerce/references/scheduled-work.md +0 -19
- package/skills/commerce/references/storefront-product-page.md +0 -83
- 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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
|
24
|
-
* recalculation
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
* - `
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|