@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.
- package/README.md +25 -22
- package/base44/agents/commerce/StoreAdmin.jsonc +1 -1
- package/base44/entities/commerce.OrderRefund.jsonc +1 -1
- package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
- package/base44/entities/commerce.Webhook.jsonc +1 -1
- package/base44/functions/commerce/admin-products/entry.ts +1 -1
- package/base44/functions/commerce/admin-reports/entry.ts +1 -1
- package/base44/functions/commerce/payments/entry.ts +2 -2
- package/base44/functions/commerce/seed-store/defaults.ts +1 -1
- package/base44/functions/commerce/seed-store/entry.ts +34 -0
- package/base44/functions/commerce/seed-store/seed-catalog.ts +39 -5
- package/base44/functions/commerce/storefront-catalog/entry.ts +1 -1
- package/base44/functions/commerce/storefront-checkout/entry.ts +1 -1
- package/base44/shared/commerce/card-payment.stripe.ts +198 -0
- package/base44/shared/commerce/card-payment.ts +1 -1
- package/base44/shared/commerce/payments.ts +2 -2
- package/base44/shared/commerce/scan.ts +1 -1
- package/base44/shared/commerce/sequence.ts +2 -2
- package/package.json +1 -1
- package/scripts/install.js +24 -14
- package/skills/commerce/SKILL.md +117 -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 +444 -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 +49 -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 +6 -3
- package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
- package/src/commerce/admin/pages/products/Reviews.jsx +1 -1
- package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -1
- package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +1 -1
- package/src/commerce/storefront/StorefrontProvider.jsx +106 -20
- package/src/commerce/storefront/index.js +74 -24
- package/src/commerce/storefront/internal/useAsyncData.js +86 -0
- package/src/commerce/storefront/useAddressForm.js +96 -0
- package/src/commerce/storefront/useCartLine.js +221 -0
- package/src/commerce/storefront/useCheckout.jsx +18 -6
- package/src/commerce/storefront/useOrderReturn.js +36 -10
- package/src/commerce/storefront/useProduct.js +295 -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 +22 -7
- package/src/commerce/utils/price.js +95 -0
- package/src/commerce/utils/shipping-promos.js +2 -2
- package/src/commerce/utils/specs.js +26 -0
- package/src/commerce/utils/storefront.js +47 -3
- package/src/commerce/utils/totals.js +110 -0
- package/src/commerce/utils/variants.js +58 -3
- 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
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Storefront React layer —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
|
24
|
-
* recalculation
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* - `
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
-
*
|
|
33
|
-
* `
|
|
34
|
-
*
|
|
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
|
|
66
|
-
* redirects to the provider's payment page
|
|
67
|
-
*
|
|
68
|
-
*
|
|
66
|
+
* - **placeOrder.** Sends the order, clears the shared cart, and navigates:
|
|
67
|
+
* an online gateway redirects to the provider's payment page
|
|
68
|
+
* (`redirectToPayment`), everything else lands on the order-received page
|
|
69
|
+
* (`orderReceivedPath`, default `/order-received`) — which is where a manual
|
|
70
|
+
* order's payment instructions are rendered, so the offline default confirms
|
|
71
|
+
* properly with no extra wiring. Resolves to `{ ok: true, result }` or
|
|
72
|
+
* `{ ok: false, error }`. Pass `orderReceivedPath: null` to handle the
|
|
73
|
+
* result yourself (a manual-gateway result carries
|
|
74
|
+
* `result.payment_instructions`).
|
|
69
75
|
*
|
|
70
76
|
* Blocker codes, in the order checked: `cart_loading`, `empty_cart`,
|
|
71
77
|
* `billing_incomplete`, `shipping_address_incomplete`, `shipping_recalculating`,
|
|
@@ -73,7 +79,8 @@ function resolvePaymentMethod(gateways, picked) {
|
|
|
73
79
|
* `shipping_not_available`, `payment_method_required`.
|
|
74
80
|
*
|
|
75
81
|
* Options: `debounceMs` (600), `addressComplete` (predicate overriding the
|
|
76
|
-
* country+city rule), `requiredBillingFields`, `redirectToPayment` (true)
|
|
82
|
+
* country+city rule), `requiredBillingFields`, `redirectToPayment` (true),
|
|
83
|
+
* `orderReceivedPath` ("/order-received"; null disables the navigation).
|
|
77
84
|
*/
|
|
78
85
|
export function useCheckout(options = {}) {
|
|
79
86
|
const {
|
|
@@ -81,6 +88,7 @@ export function useCheckout(options = {}) {
|
|
|
81
88
|
addressComplete = isShippingAddressComplete,
|
|
82
89
|
requiredBillingFields = REQUIRED_BILLING_FIELDS,
|
|
83
90
|
redirectToPayment = true,
|
|
91
|
+
orderReceivedPath = "/order-received",
|
|
84
92
|
} = options;
|
|
85
93
|
|
|
86
94
|
const { client, info, cart, runCart, clearCart } = useStorefrontState();
|
|
@@ -204,6 +212,10 @@ export function useCheckout(options = {}) {
|
|
|
204
212
|
typeof window !== "undefined"
|
|
205
213
|
) {
|
|
206
214
|
window.location.assign(result.payment.checkout_url);
|
|
215
|
+
} else if (orderReceivedPath && !result.payment && typeof window !== "undefined") {
|
|
216
|
+
// A manual/offline order settles nothing online — land it on the
|
|
217
|
+
// order-received page, where its payment instructions are rendered.
|
|
218
|
+
window.location.assign(orderReceivedUrl(result, orderReceivedPath));
|
|
207
219
|
}
|
|
208
220
|
return { ok: true, result };
|
|
209
221
|
} catch (e) {
|
|
@@ -224,7 +236,7 @@ export function useCheckout(options = {}) {
|
|
|
224
236
|
setPlacing(false);
|
|
225
237
|
}
|
|
226
238
|
},
|
|
227
|
-
[placing, client, paymentMethod, billing, shipToDifferent, shipping, clearCart, redirectToPayment, runCart],
|
|
239
|
+
[placing, client, paymentMethod, billing, shipToDifferent, shipping, clearCart, redirectToPayment, orderReceivedPath, runCart],
|
|
228
240
|
);
|
|
229
241
|
|
|
230
242
|
return {
|
|
@@ -1,29 +1,36 @@
|
|
|
1
1
|
import { useCallback, useEffect, useState } from "react";
|
|
2
|
-
import { storefrontErrorCode, storefrontErrorMessage } from "@/commerce/utils";
|
|
2
|
+
import { orderLines, storefrontErrorCode, storefrontErrorMessage } from "@/commerce/utils";
|
|
3
3
|
import { useStorefrontState } from "./StorefrontProvider";
|
|
4
|
+
import { orderSeo, useStorefrontSeo } from "./useStorefrontSeo";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* useOrderReturn — the whole `/order-received` page in one hook. Mount the
|
|
7
8
|
* route (it is mandatory — every payment link returns here) and branch on
|
|
8
|
-
* `status
|
|
9
|
+
* `status`; the markup for every state is yours to write:
|
|
9
10
|
*
|
|
10
|
-
* const { status, order, paymentLink, paymentInstructions, error,
|
|
11
|
-
* useOrderReturn();
|
|
12
|
-
* // "loading" →
|
|
13
|
-
* // "paid" → thank-you + order summary (the order is now
|
|
14
|
-
* // "unpaid" → card order:
|
|
11
|
+
* const { status, order, lines, paymentLink, paymentInstructions, error,
|
|
12
|
+
* reload } = useOrderReturn();
|
|
13
|
+
* // "loading" → confirming copy (never an empty page)
|
|
14
|
+
* // "paid" → thank-you + order number + summary (the order is now paid)
|
|
15
|
+
* // "unpaid" → card order: link paymentLink.url to pay now;
|
|
15
16
|
* // manual order: render paymentInstructions
|
|
16
|
-
* // ({ description, account_details })
|
|
17
|
+
* // ({ description, account_details }) — these ARE how the
|
|
18
|
+
* // customer pays; a page that drops them strands the order
|
|
17
19
|
* // "cancelled" → payment was cancelled — offer paymentLink.url or support
|
|
18
20
|
* // "error" → render error.message with a retry via reload()
|
|
19
21
|
*
|
|
22
|
+
* `lines` are the order's items in the decorated cart-line shape, so the same
|
|
23
|
+
* row markup renders the bag and the confirmation; totals come from
|
|
24
|
+
* `useTotalsLines(order)`. The page is marked `noindex` automatically — a
|
|
25
|
+
* receipt carrying an order key must not rank (`seo: false` opts out).
|
|
26
|
+
*
|
|
20
27
|
* It reads `order_id`/`order_key`/`payment` from the URL itself and verifies
|
|
21
28
|
* with the payment provider server-side — safe and idempotent on every visit.
|
|
22
29
|
*
|
|
23
30
|
* `order` carries FLAT totals — `order.total`, `order.shipping_total`,
|
|
24
31
|
* `order.total_tax`; there is no `order.totals` object on it.
|
|
25
32
|
*/
|
|
26
|
-
export function useOrderReturn({ auto = true } = {}) {
|
|
33
|
+
export function useOrderReturn({ auto = true, seo = true } = {}) {
|
|
27
34
|
const { client } = useStorefrontState();
|
|
28
35
|
const [result, setResult] = useState({ status: auto ? "loading" : "idle" });
|
|
29
36
|
|
|
@@ -52,5 +59,24 @@ export function useOrderReturn({ auto = true } = {}) {
|
|
|
52
59
|
if (auto) reload();
|
|
53
60
|
}, [auto, reload]);
|
|
54
61
|
|
|
55
|
-
|
|
62
|
+
useStorefrontSeo(seo ? orderSeo(result.order ?? null) : null);
|
|
63
|
+
|
|
64
|
+
return { ...result, lines: orderLines(result.order ?? null), reload };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The URL a just-placed manual/offline order should land on: the order-received
|
|
69
|
+
* page, carrying the id + key `useOrderReturn` reads back. `useCheckout` builds
|
|
70
|
+
* this itself by default (`orderReceivedPath`); use it directly only in a
|
|
71
|
+
* custom `placeOrder` flow.
|
|
72
|
+
*
|
|
73
|
+
* @param {{order_id: string, order_key: string}} result from `placeOrder`
|
|
74
|
+
* @param {string} [path]
|
|
75
|
+
*/
|
|
76
|
+
export function orderReceivedUrl(result, path = "/order-received") {
|
|
77
|
+
const q = new URLSearchParams({
|
|
78
|
+
order_id: result?.order_id ?? "",
|
|
79
|
+
order_key: result?.order_key ?? "",
|
|
80
|
+
});
|
|
81
|
+
return `${path}?${q}`;
|
|
56
82
|
}
|