@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.
- package/README.md +10 -7
- package/base44/functions/commerce/seed-store/entry.ts +23 -18
- package/base44/functions/commerce/seed-store/seed-catalog.ts +150 -77
- package/base44/shared/commerce/card-payment.ts +1 -1
- package/base44/shared/commerce/payments.ts +1 -1
- package/package.json +1 -1
- package/scripts/install.js +8 -2
- package/skills/commerce/SKILL.md +8 -8
- package/skills/commerce/docs/api-admin.md +1 -1
- package/skills/commerce/docs/api-storefront.md +2 -2
- package/skills/commerce/installation-guidelines.md +4 -3
- package/skills/commerce/post-installation.md +287 -263
- package/skills/commerce/references/online-payments.md +1 -1
- package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +1 -1
- package/src/commerce/storefront/StorefrontProvider.jsx +216 -0
- package/src/commerce/storefront/address.js +70 -0
- package/src/commerce/storefront/index.js +51 -0
- package/src/commerce/storefront/pickers.jsx +90 -0
- package/src/commerce/storefront/useCheckout.jsx +279 -0
- package/src/commerce/storefront/useOrderReturn.js +56 -0
- package/src/commerce/utils/index.js +6 -0
- package/src/commerce/utils/storefront.js +9 -2
|
@@ -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) {
|