@base44/app-plugin-commerce 0.1.15 → 0.1.16

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.
@@ -0,0 +1,279 @@
1
+ import React, { createContext, useCallback, useContext, useEffect, useState } from "react";
2
+ import { storefrontErrorCode, storefrontErrorMessage } from "@/commerce/utils";
3
+ import { useStorefrontState } from "./StorefrontProvider";
4
+ import {
5
+ REQUIRED_BILLING_FIELDS,
6
+ isShippingAddressComplete,
7
+ missingBillingFields,
8
+ shippingSlice,
9
+ } from "./address";
10
+
11
+ const EMPTY_ADDRESS = Object.freeze({
12
+ first_name: "",
13
+ last_name: "",
14
+ company: "",
15
+ address_1: "",
16
+ address_2: "",
17
+ city: "",
18
+ state: "",
19
+ postcode: "",
20
+ country: "",
21
+ phone: "",
22
+ });
23
+
24
+ /**
25
+ * useCheckout — the guided checkout state machine. It owns the parts every
26
+ * checkout must get right, so the page you build is only markup around it:
27
+ *
28
+ * - **Address → shipping/tax recalculation, automatic.** Edit the billing (or
29
+ * separate shipping) address through `updateBilling`/`updateShipping`; the
30
+ * moment the address is complete enough to price (default: country + city —
31
+ * see `isShippingAddressComplete`), the hook debounces and calls
32
+ * `set-shipping-address`, which recalculates every shipping option, its
33
+ * cost and the taxes. Half-typed addresses are never sent, an unchanged
34
+ * address is never re-sent, and an address the store doesn't ship to
35
+ * surfaces as `addressError` — show it on the address fields.
36
+ * - **Shipping choice.** `shippingStatus` mirrors the cart's state:
37
+ * `auto_selected` (one option, already applied — just display
38
+ * `chosenShippingMethod`), `chosen`, `choice_required` (render
39
+ * `shippingMethods` and call `chooseShippingMethod(id)`), `missing_address`
40
+ * (collect the address), `not_needed` (virtual cart — render nothing).
41
+ * - **Payment choice.** `paymentMethods` come from store info (their ONLY
42
+ * source); a store with exactly one enabled gateway gets it pre-selected.
43
+ * - **The gate.** `canPlaceOrder` + `blockers` say exactly what still stands
44
+ * between the customer and the order — drive the button's disabled state
45
+ * and the "what's missing" hints from them instead of re-deriving.
46
+ * - **placeOrder.** Sends the order, clears the shared cart, and (by default)
47
+ * redirects to the provider's payment page when the gateway is online.
48
+ * Resolves to `{ ok: true, result }` or `{ ok: false, error }`; a manual-
49
+ * gateway result carries `result.payment_instructions` to render.
50
+ *
51
+ * Blocker codes, in the order checked: `cart_loading`, `empty_cart`,
52
+ * `billing_incomplete`, `shipping_address_incomplete`, `shipping_recalculating`,
53
+ * `shipping_address_required`, `shipping_method_required`,
54
+ * `shipping_not_available`, `payment_method_required`.
55
+ *
56
+ * Options: `debounceMs` (600), `addressComplete` (predicate overriding the
57
+ * country+city rule), `requiredBillingFields`, `redirectToPayment` (true).
58
+ */
59
+ export function useCheckout(options = {}) {
60
+ const {
61
+ debounceMs = 600,
62
+ addressComplete = isShippingAddressComplete,
63
+ requiredBillingFields = REQUIRED_BILLING_FIELDS,
64
+ redirectToPayment = true,
65
+ } = options;
66
+
67
+ const { client, info, cart, runCart, clearCart } = useStorefrontState();
68
+
69
+ // ── form state ───────────────────────────────────────────────────────────
70
+ const [billing, setBilling] = useState({ ...EMPTY_ADDRESS, email: "" });
71
+ const [shipping, setShipping] = useState({ ...EMPTY_ADDRESS });
72
+ const [shipToDifferent, setShipToDifferent] = useState(false);
73
+ const [paymentMethod, setPaymentMethod] = useState("");
74
+ const [addressError, setAddressError] = useState(null);
75
+ const [syncing, setSyncing] = useState(false);
76
+ const [syncedKey, setSyncedKey] = useState(undefined);
77
+ const [placing, setPlacing] = useState(false);
78
+ const [orderError, setOrderError] = useState(null);
79
+
80
+ const updateBilling = useCallback((patch) => setBilling((b) => ({ ...b, ...patch })), []);
81
+ const updateShipping = useCallback((patch) => setShipping((s) => ({ ...s, ...patch })), []);
82
+
83
+ // ── address → shipping/tax recalculation ────────────────────────────────
84
+ const shippingAddress = shipToDifferent ? shipping : billing;
85
+ const slice = shippingSlice(shippingAddress);
86
+ const sliceKey = JSON.stringify(slice);
87
+ const complete = addressComplete(shippingAddress);
88
+
89
+ // Adopt whatever address the cart already carries (returning shopper) as
90
+ // "synced", so an untouched form doesn't resend it.
91
+ useEffect(() => {
92
+ if (syncedKey !== undefined || cart === undefined) return;
93
+ setSyncedKey(JSON.stringify(shippingSlice(cart?.shipping_address ?? {})));
94
+ }, [cart, syncedKey]);
95
+
96
+ const syncPending = syncedKey !== undefined && complete && sliceKey !== syncedKey;
97
+
98
+ useEffect(() => {
99
+ if (!syncPending || !cart || !cart.items?.length) return;
100
+ const t = setTimeout(async () => {
101
+ setSyncing(true);
102
+ setAddressError(null);
103
+ try {
104
+ await runCart(() => client.setShippingAddress(JSON.parse(sliceKey)));
105
+ setSyncedKey(sliceKey);
106
+ } catch (e) {
107
+ // Mark the address as seen either way — retrying the same failing
108
+ // address in a loop helps nobody; the customer editing it re-arms.
109
+ setSyncedKey(sliceKey);
110
+ setAddressError({
111
+ code: storefrontErrorCode(e) ?? "error",
112
+ message: storefrontErrorMessage(e),
113
+ });
114
+ } finally {
115
+ setSyncing(false);
116
+ }
117
+ }, debounceMs);
118
+ return () => clearTimeout(t);
119
+ }, [syncPending, sliceKey, cart, client, runCart, debounceMs]);
120
+
121
+ /** Force a re-sync of the current address (e.g. a "Recalculate" affordance). */
122
+ const recalculateShipping = useCallback(() => setSyncedKey("__stale__"), []);
123
+
124
+ // ── shipping choice (from the shared cart view) ──────────────────────────
125
+ const shippingStatus = cart?.shipping_status ?? null;
126
+ const shippingMethods = cart?.available_shipping_methods ?? [];
127
+ const chosenShippingMethod =
128
+ shippingMethods.find((m) => m.id === cart?.chosen_shipping_method) ?? null;
129
+ const chooseShippingMethod = useCallback(
130
+ (methodId) => runCart(() => client.chooseShippingMethod(methodId)),
131
+ [client, runCart],
132
+ );
133
+
134
+ // ── payment choice (gateways live on store info ONLY) ────────────────────
135
+ const paymentMethods = info?.payment_gateways ?? null;
136
+ useEffect(() => {
137
+ if (!paymentMethods) return;
138
+ if (paymentMethod && !paymentMethods.some((g) => g.slug === paymentMethod)) {
139
+ setPaymentMethod("");
140
+ } else if (!paymentMethod && paymentMethods.length === 1) {
141
+ setPaymentMethod(paymentMethods[0].slug); // one option is not a choice
142
+ }
143
+ }, [paymentMethods, paymentMethod]);
144
+ const selectedGateway = paymentMethods?.find((g) => g.slug === paymentMethod) ?? null;
145
+
146
+ // ── the gate ─────────────────────────────────────────────────────────────
147
+ const missingBilling = missingBillingFields(billing, requiredBillingFields);
148
+ const blockers = [];
149
+ if (cart === undefined) blockers.push("cart_loading");
150
+ else if (!cart || !cart.items?.length) blockers.push("empty_cart");
151
+ if (missingBilling.length) blockers.push("billing_incomplete");
152
+ if (shipToDifferent && !complete) blockers.push("shipping_address_incomplete");
153
+ if (syncPending || syncing) blockers.push("shipping_recalculating");
154
+ if (shippingStatus === "missing_address") blockers.push("shipping_address_required");
155
+ if (shippingStatus === "choice_required") blockers.push("shipping_method_required");
156
+ if (shippingStatus === "none_available" || addressError?.code === "shipping_not_available") {
157
+ blockers.push("shipping_not_available");
158
+ }
159
+ if (!paymentMethod) blockers.push("payment_method_required");
160
+ const canPlaceOrder = blockers.length === 0;
161
+
162
+ // ── place the order ──────────────────────────────────────────────────────
163
+ const placeOrder = useCallback(
164
+ async (extra = {}) => {
165
+ if (placing) return { ok: false, error: { code: "placing", message: "Order already being placed." } };
166
+ setPlacing(true);
167
+ setOrderError(null);
168
+ try {
169
+ const result = await client.placeOrder({
170
+ payment_method: paymentMethod,
171
+ billing,
172
+ ...(shipToDifferent ? { shipping } : {}),
173
+ ...extra, // customer_note, success_url/cancel_url overrides, …
174
+ });
175
+ clearCart(); // checkout consumed the cart
176
+ if (
177
+ redirectToPayment &&
178
+ result.payment?.status === "requires_payment" &&
179
+ typeof window !== "undefined"
180
+ ) {
181
+ window.location.assign(result.payment.checkout_url);
182
+ }
183
+ return { ok: true, result };
184
+ } catch (e) {
185
+ const error = {
186
+ code: storefrontErrorCode(e) ?? "error",
187
+ message: storefrontErrorMessage(e),
188
+ };
189
+ setOrderError(error);
190
+ // These mean the cart changed underneath the page — re-read it.
191
+ if (
192
+ ["items_unavailable", "coupon_invalid", "empty_cart",
193
+ "shipping_method_required", "invalid_shipping_method"].includes(error.code)
194
+ ) {
195
+ runCart(() => client.getCart()).catch(() => {});
196
+ }
197
+ return { ok: false, error };
198
+ } finally {
199
+ setPlacing(false);
200
+ }
201
+ },
202
+ [placing, client, paymentMethod, billing, shipToDifferent, shipping, clearCart, redirectToPayment, runCart],
203
+ );
204
+
205
+ return {
206
+ // form state
207
+ billing,
208
+ updateBilling,
209
+ shipping,
210
+ updateShipping,
211
+ shipToDifferent,
212
+ setShipToDifferent,
213
+ missingBillingFields: missingBilling,
214
+ // address → shipping/tax recalculation
215
+ shippingAddressComplete: complete,
216
+ shippingSyncing: syncPending || syncing,
217
+ addressError,
218
+ recalculateShipping,
219
+ // shipping choice
220
+ shippingStatus,
221
+ shippingMethods,
222
+ chosenShippingMethod,
223
+ chooseShippingMethod,
224
+ // payment choice
225
+ paymentMethods,
226
+ paymentMethod,
227
+ setPaymentMethod,
228
+ selectedGateway,
229
+ // the gate + the order
230
+ blockers,
231
+ canPlaceOrder,
232
+ placing,
233
+ orderError,
234
+ placeOrder,
235
+ // underlying data, for convenience
236
+ cart: cart ?? null,
237
+ storeInfo: info,
238
+ };
239
+ }
240
+
241
+ const CheckoutContext = createContext(null);
242
+
243
+ /**
244
+ * Share one `useCheckout` between the components of a checkout page (address
245
+ * form, shipping step, payment step, summary, place-order button) without
246
+ * prop-drilling:
247
+ *
248
+ * <CheckoutProvider>
249
+ * <AddressFields /> // useCheckoutContext() inside
250
+ * <ShippingMethodPicker>…</ShippingMethodPicker>
251
+ * <PaymentMethodPicker>…</PaymentMethodPicker>
252
+ * <PlaceOrderButton />
253
+ * </CheckoutProvider>
254
+ *
255
+ * Pass `options` through to `useCheckout`, or pass an existing `checkout`
256
+ * object if the page already called the hook itself.
257
+ */
258
+ export function CheckoutProvider({ checkout, options, children }) {
259
+ const own = useCheckout(options);
260
+ return (
261
+ <CheckoutContext.Provider value={checkout ?? own}>{children}</CheckoutContext.Provider>
262
+ );
263
+ }
264
+
265
+ /** The checkout shared by the nearest <CheckoutProvider>. */
266
+ export function useCheckoutContext() {
267
+ const ctx = useContext(CheckoutContext);
268
+ if (!ctx) {
269
+ throw new Error(
270
+ "useCheckoutContext needs a <CheckoutProvider> above it (or pass the checkout object down yourself).",
271
+ );
272
+ }
273
+ return ctx;
274
+ }
275
+
276
+ /** Internal: context accessor that tolerates absence (for the pickers). */
277
+ export function useCheckoutContextOptional() {
278
+ return useContext(CheckoutContext);
279
+ }
@@ -0,0 +1,56 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+ import { storefrontErrorCode, storefrontErrorMessage } from "@/commerce/utils";
3
+ import { useStorefrontState } from "./StorefrontProvider";
4
+
5
+ /**
6
+ * useOrderReturn — the whole `/order-received` page in one hook. Mount the
7
+ * route (it is mandatory — every payment link returns here) and branch on
8
+ * `status`:
9
+ *
10
+ * const { status, order, paymentLink, paymentInstructions, error, reload } =
11
+ * useOrderReturn();
12
+ * // "loading" → spinner
13
+ * // "paid" → thank-you + order summary (the order is now marked paid)
14
+ * // "unpaid" → card order: offer paymentLink.url to pay now;
15
+ * // manual order: render paymentInstructions
16
+ * // ({ description, account_details })
17
+ * // "cancelled" → payment was cancelled — offer paymentLink.url or support
18
+ * // "error" → render error.message with a retry via reload()
19
+ *
20
+ * It reads `order_id`/`order_key`/`payment` from the URL itself and verifies
21
+ * with the payment provider server-side — safe and idempotent on every visit.
22
+ *
23
+ * `order` carries FLAT totals — `order.total`, `order.shipping_total`,
24
+ * `order.total_tax`; there is no `order.totals` object on it.
25
+ */
26
+ export function useOrderReturn({ auto = true } = {}) {
27
+ const { client } = useStorefrontState();
28
+ const [result, setResult] = useState({ status: auto ? "loading" : "idle" });
29
+
30
+ const reload = useCallback(
31
+ async (params) => {
32
+ setResult({ status: "loading" });
33
+ try {
34
+ const res = await client.completeReturn(params);
35
+ setResult({
36
+ status: res.state, // "paid" | "unpaid" | "cancelled"
37
+ order: res.order,
38
+ paymentLink: res.payment_link ?? null,
39
+ paymentInstructions: res.payment_instructions ?? null,
40
+ });
41
+ } catch (e) {
42
+ setResult({
43
+ status: "error",
44
+ error: { code: storefrontErrorCode(e) ?? "error", message: storefrontErrorMessage(e) },
45
+ });
46
+ }
47
+ },
48
+ [client],
49
+ );
50
+
51
+ useEffect(() => {
52
+ if (auto) reload();
53
+ }, [auto, reload]);
54
+
55
+ return { ...result, reload };
56
+ }
@@ -17,6 +17,12 @@
17
17
  * - `shipping-promos.js` — read the store's real free-shipping configuration so
18
18
  * "Free shipping over €150" copy states a configured rule, not an invented
19
19
  * number. See `.agents/skills/commerce/docs/api-storefront.md`.
20
+ *
21
+ * Building the storefront in React? `@/commerce/storefront` layers hooks on
22
+ * top of this module — a shared-cart provider, the guided-checkout hook
23
+ * (automatic shipping/tax recalculation on address changes), headless
24
+ * shipping/payment pickers and the order-received hook. Prefer those for cart
25
+ * and checkout; use this module directly for catalog views and non-React code.
20
26
  */
21
27
  export * from "./storefront.js";
22
28
  export * from "./variants.js";
@@ -29,6 +29,13 @@ export function storefrontErrorCode(e) {
29
29
  return e?.response?.data?.code ?? e?.data?.code ?? e?.code ?? null;
30
30
  }
31
31
 
32
+ /** The human-readable message a failed storefront call carries. */
33
+ export function storefrontErrorMessage(e) {
34
+ return (
35
+ e?.response?.data?.error ?? e?.data?.error ?? e?.message ?? "Something went wrong."
36
+ );
37
+ }
38
+
32
39
  export function createStorefront(base44, { storageKey = "cart_token", storage } = {}) {
33
40
  const bag = storage ?? (typeof localStorage !== "undefined" ? localStorage : null);
34
41
 
@@ -106,8 +113,8 @@ export function createStorefront(base44, { storageKey = "cart_token", storage }
106
113
  applyCoupon(code) {
107
114
  return cartAction("apply-coupon", { code });
108
115
  },
109
- removeCoupon() {
110
- return cartAction("remove-coupon");
116
+ removeCoupon(code) {
117
+ return cartAction("remove-coupon", { code });
111
118
  },
112
119
  /** Recalculates shipping options + cost; 400 shipping_not_available belongs on the address form. */
113
120
  setShippingAddress(address) {