@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.
@@ -11,7 +11,7 @@ What it does **not** ship with is a payment provider. Wiring one (Stripe, PayPal
11
11
  | `refundCardPayment` | admin refunds through the provider (optional — leave the stub to keep refunds manual) |
12
12
  | `parseWebhook` | webhook validation: name the order a provider event is about (from the echoed metadata), and vouch `paid: true` **only** after verifying the request signature over the raw body bytes |
13
13
 
14
- **Wiring Stripe? Don't start here** — [`post-installation.md` §2.2](../post-installation.md#22-payments--one-file-any-provider-stripe-as-the-reference) has the complete implementation of all four functions to paste in, plus the secret and webhook-endpoint steps. This reference is for other providers and for webhook customization.
14
+ **Wiring Stripe? Don't start here** — [`post-installation.md` §4](../post-installation.md#4-payments--one-file-any-provider-stripe-as-the-reference) has the complete implementation of all four functions to paste in, plus the secret and webhook-endpoint steps. This reference is for other providers and for webhook customization.
15
15
 
16
16
  Until the file is implemented, picking Credit card at checkout answers `503 no_card_payment_provider` (the storefront should offer the other methods); the admin can also switch the card option off in Settings → Payments to hide it. Every other payment option is **manual**: the order goes on-hold with the option's description as payment instructions, and the operator moves it on once the money arrives — those need no code at all, and the admin can add more of them in Settings → Payments.
17
17
 
@@ -207,7 +207,7 @@ export default function PaymentsSettings() {
207
207
  {/* The "card" option redirects to a provider-hosted payment page.
208
208
  Wiring a provider means implementing the four functions in
209
209
  shared/commerce/card-payment.ts (Stripe: paste-in in
210
- .agents/skills/commerce/post-installation.md §2.2; the payment
210
+ .agents/skills/commerce/post-installation.md §4; the payment
211
211
  webhook is premade). Until then, picking it at checkout answers
212
212
  503 no_card_payment_provider. Deliberately not shown to the
213
213
  store operator — it's developer guidance, not store
@@ -0,0 +1,216 @@
1
+ import React, {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from "react";
10
+ import { createStorefront, storefrontErrorCode, storefrontErrorMessage } from "@/commerce/utils";
11
+
12
+ /**
13
+ * StorefrontProvider — one client, one store-info cache, ONE shared cart.
14
+ *
15
+ * Mount it once, above every storefront page (product list, product page,
16
+ * cart, checkout, order-received):
17
+ *
18
+ * import { base44 } from "@/api/base44Client";
19
+ * <StorefrontProvider base44={base44}> ... </StorefrontProvider>
20
+ *
21
+ * or, if other modules also need the raw client, create it once and share it:
22
+ *
23
+ * // src/lib/storefront.js
24
+ * import { createStorefront } from "@/commerce/utils";
25
+ * import { base44 } from "@/api/base44Client";
26
+ * export const store = createStorefront(base44);
27
+ *
28
+ * <StorefrontProvider store={store}> ... </StorefrontProvider>
29
+ *
30
+ * What lives here (and why it must not be duplicated per page):
31
+ * - the `createStorefront` instance — it owns the cart_token lifecycle;
32
+ * - store info — payment_gateways, currency, countries/currencies come from
33
+ * `get-store-info` ONLY (never off a cart view), cached for the session;
34
+ * - the cart view — every mutation from any component funnels through one
35
+ * serialized queue, so a slow response can never clobber a newer cart, and
36
+ * a header badge, a cart drawer and the checkout all re-render from the
37
+ * same state.
38
+ */
39
+
40
+ const StorefrontContext = createContext(null);
41
+
42
+ export function StorefrontProvider({ base44, store, children }) {
43
+ const client = useMemo(
44
+ () => store ?? createStorefront(base44),
45
+ [store, base44],
46
+ );
47
+
48
+ // ── store info (cached by the client; mirrored into state once) ─────────
49
+ const [info, setInfo] = useState(null);
50
+ const [infoError, setInfoError] = useState(null);
51
+ useEffect(() => {
52
+ let mounted = true;
53
+ client.getStoreInfo().then(
54
+ (i) => mounted && setInfo(i),
55
+ (e) => mounted && setInfoError(e),
56
+ );
57
+ return () => {
58
+ mounted = false;
59
+ };
60
+ }, [client]);
61
+
62
+ // ── shared cart state ────────────────────────────────────────────────────
63
+ // undefined = still loading, null = no cart yet, object = the priced view.
64
+ const [cart, setCart] = useState(undefined);
65
+ const [cartError, setCartError] = useState(null);
66
+ const queue = useRef(Promise.resolve());
67
+
68
+ /**
69
+ * Run one cart call at a time, in order, and publish its returned view.
70
+ * Serializing is what keeps rapid quantity clicks (or an address sync racing
71
+ * an add-to-cart) from applying responses out of order.
72
+ */
73
+ const runCart = useCallback((fn) => {
74
+ const next = queue.current.catch(() => {}).then(fn);
75
+ queue.current = next;
76
+ return next.then((view) => {
77
+ setCart(view ?? null);
78
+ setCartError(null);
79
+ return view ?? null;
80
+ });
81
+ }, []);
82
+
83
+ /** Forget the cart locally (used after checkout consumes it). */
84
+ const clearCart = useCallback(() => setCart(null), []);
85
+
86
+ useEffect(() => {
87
+ runCart(() => client.getCart()).catch((e) => {
88
+ setCart(null);
89
+ setCartError(e);
90
+ });
91
+ }, [client, runCart]);
92
+
93
+ const value = useMemo(
94
+ () => ({ client, info, infoError, cart, cartError, runCart, clearCart }),
95
+ [client, info, infoError, cart, cartError, runCart, clearCart],
96
+ );
97
+ return <StorefrontContext.Provider value={value}>{children}</StorefrontContext.Provider>;
98
+ }
99
+
100
+ /** Advanced escape hatch: the raw provider state. Prefer the hooks below. */
101
+ export function useStorefrontState() {
102
+ const ctx = useContext(StorefrontContext);
103
+ if (!ctx) {
104
+ throw new Error(
105
+ "Storefront hooks need a <StorefrontProvider> above them — mount it once around your storefront routes.",
106
+ );
107
+ }
108
+ return ctx;
109
+ }
110
+
111
+ /** The shared `createStorefront` client — catalog calls go straight through it. */
112
+ export function useStorefront() {
113
+ return useStorefrontState().client;
114
+ }
115
+
116
+ /**
117
+ * Store info: `{ info, settings, paymentGateways, countries, currencies,
118
+ * loading, error }`. `info` is null while loading. This hook is the ONLY
119
+ * place to read payment gateways and currency from.
120
+ */
121
+ export function useStoreInfo() {
122
+ const { info, infoError } = useStorefrontState();
123
+ return {
124
+ info,
125
+ settings: info?.settings ?? null,
126
+ paymentGateways: info?.payment_gateways ?? null,
127
+ countries: info?.countries ?? null,
128
+ currencies: info?.currencies ?? null,
129
+ loading: !info && !infoError,
130
+ error: infoError,
131
+ };
132
+ }
133
+
134
+ /**
135
+ * `(amount) => "€19.99"` in the store's currency, per the viewer's locale.
136
+ * Falls back to a plain number while store info is still loading.
137
+ */
138
+ export function useFormatMoney() {
139
+ const { info } = useStorefrontState();
140
+ const currency = info?.settings?.currency;
141
+ return useCallback(
142
+ (amount) => {
143
+ const n = Number(amount) || 0;
144
+ if (!currency) return n.toFixed(2);
145
+ try {
146
+ return new Intl.NumberFormat(undefined, { style: "currency", currency }).format(n);
147
+ } catch {
148
+ return `${n.toFixed(2)} ${currency}`;
149
+ }
150
+ },
151
+ [currency],
152
+ );
153
+ }
154
+
155
+ /**
156
+ * The shared cart — state plus every cart mutation. All consumers see the
157
+ * same view; every action resolves to the fresh priced cart it published.
158
+ *
159
+ * const { cart, loading, itemCount, addItem, updateItem, removeItem,
160
+ * applyCoupon, removeCoupon, refresh } = useCart();
161
+ *
162
+ * - `cart` is `null` until something is added (render an empty state).
163
+ * - `addItem(view.addToCart, quantity)` — pass the resolved `addToCart` from
164
+ * `resolveSelection`; a product with attributes is rejected without its
165
+ * `variation_id` (`400 variation_required`).
166
+ * - `applyCoupon(code)` resolves to `{ ok, cart }` or `{ ok: false, code,
167
+ * message }` — an invalid code is expected flow, not an exception.
168
+ * - Render `cart.coupon_notices` / `cart.removed_items` when present: they
169
+ * say what auto-dropped and why.
170
+ */
171
+ export function useCart() {
172
+ const { client, cart, cartError, runCart } = useStorefrontState();
173
+
174
+ const refresh = useCallback(() => runCart(() => client.getCart()), [client, runCart]);
175
+ const addItem = useCallback(
176
+ (item, quantity = 1) => runCart(() => client.addItem({ quantity, ...item })),
177
+ [client, runCart],
178
+ );
179
+ const updateItem = useCallback(
180
+ (itemKey, quantity) => runCart(() => client.updateItem(itemKey, quantity)),
181
+ [client, runCart],
182
+ );
183
+ const removeItem = useCallback(
184
+ (itemKey) => runCart(() => client.removeItem(itemKey)),
185
+ [client, runCart],
186
+ );
187
+ const applyCoupon = useCallback(
188
+ async (code) => {
189
+ try {
190
+ return { ok: true, cart: await runCart(() => client.applyCoupon(code)) };
191
+ } catch (e) {
192
+ return { ok: false, code: storefrontErrorCode(e), message: storefrontErrorMessage(e) };
193
+ }
194
+ },
195
+ [client, runCart],
196
+ );
197
+ const removeCoupon = useCallback(
198
+ (code) => runCart(() => client.removeCoupon(code)),
199
+ [client, runCart],
200
+ );
201
+
202
+ const items = cart?.items ?? [];
203
+ return {
204
+ cart: cart ?? null,
205
+ loading: cart === undefined,
206
+ error: cartError,
207
+ itemCount: items.reduce((n, i) => n + (i.quantity || 0), 0),
208
+ isEmpty: !items.length,
209
+ refresh,
210
+ addItem,
211
+ updateItem,
212
+ removeItem,
213
+ applyCoupon,
214
+ removeCoupon,
215
+ };
216
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Address helpers for the guided checkout — framework-free (no React).
3
+ *
4
+ * Two different "complete" questions live here, and they are not the same:
5
+ *
6
+ * - `missingBillingFields` — what `place-order` will reject
7
+ * (`400 billing_incomplete`). The backend requires first_name, last_name,
8
+ * address_1, city, country and email on `billing`.
9
+ * - `isShippingAddressComplete` — when the address is filled in enough to be
10
+ * worth sending to `set-shipping-address`. Shipping rates and taxes match on
11
+ * **country + state only** (Shipping & Tax Location regions are
12
+ * continent/country/state — no postcode or city filters), so technically a
13
+ * country is enough to price the cart. Requiring the city too is the signal
14
+ * that the customer has actually finished typing the address block rather
15
+ * than being mid-form — that is what makes the recalculation *guided*
16
+ * instead of a request per keystroke.
17
+ */
18
+
19
+ /** The billing fields `place-order` rejects the order without. */
20
+ export const REQUIRED_BILLING_FIELDS = [
21
+ "first_name",
22
+ "last_name",
23
+ "address_1",
24
+ "city",
25
+ "country",
26
+ "email",
27
+ ];
28
+
29
+ /**
30
+ * The required billing fields that are still blank.
31
+ *
32
+ * @param {object} billing
33
+ * @param {string[]} [required]
34
+ * @returns {string[]} field names, in `required` order
35
+ */
36
+ export function missingBillingFields(billing, required = REQUIRED_BILLING_FIELDS) {
37
+ return required.filter((f) => !String(billing?.[f] ?? "").trim());
38
+ }
39
+
40
+ /**
41
+ * The slice of an address that `set-shipping-address` stores and the pricing
42
+ * engine reads — exactly `{ country, state, postcode, city }`, trimmed. Two
43
+ * addresses with the same slice price identically, so this is also the
44
+ * "did the address change in a way that matters?" comparison key.
45
+ *
46
+ * @param {object} address
47
+ * @returns {{country: string, state: string, postcode: string, city: string}}
48
+ */
49
+ export function shippingSlice(address) {
50
+ return {
51
+ country: String(address?.country ?? "").trim(),
52
+ state: String(address?.state ?? "").trim(),
53
+ postcode: String(address?.postcode ?? "").trim(),
54
+ city: String(address?.city ?? "").trim(),
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Is the address filled in enough to (re)calculate shipping and tax?
60
+ * Default rule: country + city present (see the header for why city).
61
+ * `useCheckout` accepts a custom predicate via its `addressComplete` option
62
+ * when a store wants a stricter or looser gate.
63
+ *
64
+ * @param {object} address
65
+ * @returns {boolean}
66
+ */
67
+ export function isShippingAddressComplete(address) {
68
+ const s = shippingSlice(address);
69
+ return Boolean(s.country && s.city);
70
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Storefront React layer — hooks + headless helpers for the cart, checkout
3
+ * and order-received parts of the shopfront you build. Ships with the Base44
4
+ * Commerce Template next to the framework-free `@/commerce/utils` (which it
5
+ * builds on); needs React and nothing else. NO visual components ship here —
6
+ * every pixel stays yours. The hooks own the contracts that are easy to get
7
+ * subtly wrong; you own the markup.
8
+ *
9
+ * Setup (once, above every storefront route):
10
+ *
11
+ * import { StorefrontProvider } from "@/commerce/storefront";
12
+ * import { base44 } from "@/api/base44Client";
13
+ * <StorefrontProvider base44={base44}> <Routes>…</Routes> </StorefrontProvider>
14
+ *
15
+ * - `StorefrontProvider` / `useStorefront` / `useStoreInfo` / `useFormatMoney`
16
+ * — one client, cached store info (the ONLY source of payment gateways,
17
+ * currency, countries), money formatting in the store's currency.
18
+ * - `useCart` — the shared cart: state + every mutation, serialized so
19
+ * responses never apply out of order; a header badge, a cart drawer and
20
+ * the checkout all see the same view.
21
+ * - `useCheckout` / `CheckoutProvider` / `useCheckoutContext` — the guided
22
+ * checkout: address form state with automatic debounced shipping/tax
23
+ * recalculation once the address is complete, shipping-method choice,
24
+ * payment-method choice, a `canPlaceOrder` gate with named blockers, and
25
+ * `placeOrder` with the online-payment redirect handled.
26
+ * - `ShippingMethodPicker` / `PaymentMethodPicker` — headless (render-prop)
27
+ * wrappers over the two choices that are store data, never hardcoded.
28
+ * - `useOrderReturn` — the mandatory `/order-received` page in one hook.
29
+ * - `address.js` — framework-free address-completeness rules (also exported).
30
+ *
31
+ * Catalog calls (product list, product page) intentionally stay thin — use
32
+ * `useStorefront()` for the client plus the variant helpers from
33
+ * `@/commerce/utils`; those views are where the design freedom lives.
34
+ */
35
+ export {
36
+ StorefrontProvider,
37
+ useStorefrontState,
38
+ useStorefront,
39
+ useStoreInfo,
40
+ useFormatMoney,
41
+ useCart,
42
+ } from "./StorefrontProvider";
43
+ export { useCheckout, CheckoutProvider, useCheckoutContext } from "./useCheckout";
44
+ export { useOrderReturn } from "./useOrderReturn";
45
+ export { ShippingMethodPicker, PaymentMethodPicker } from "./pickers";
46
+ export {
47
+ REQUIRED_BILLING_FIELDS,
48
+ missingBillingFields,
49
+ shippingSlice,
50
+ isShippingAddressComplete,
51
+ } from "./address";
@@ -0,0 +1,90 @@
1
+ import React from "react";
2
+ import { useCheckoutContextOptional } from "./useCheckout";
3
+
4
+ /**
5
+ * Headless pickers for the two checkout choices that are store data, never
6
+ * hardcoded. They render NOTHING themselves — your render-prop child is the
7
+ * whole UI — but they encode the branching every checkout must do, so a page
8
+ * can't skip a `shipping_status` state or invent a payment method.
9
+ *
10
+ * Both read the nearest <CheckoutProvider>, or take an explicit `checkout`
11
+ * prop when you called `useCheckout` yourself.
12
+ */
13
+
14
+ function resolveCheckout(name, prop, ctx) {
15
+ const checkout = prop ?? ctx;
16
+ if (!checkout) {
17
+ throw new Error(`<${name}> needs a <CheckoutProvider> above it, or a checkout prop.`);
18
+ }
19
+ return checkout;
20
+ }
21
+
22
+ /**
23
+ * Shipping options. Renders null for a virtual cart (`not_needed`) and while
24
+ * the cart is loading; otherwise calls `children` with:
25
+ *
26
+ * {
27
+ * status, // "missing_address" | "choice_required" | "chosen" | "auto_selected"
28
+ * methods, // [{ id, title, cost }] — what this address is offered
29
+ * chosen, // the chosen/auto-selected entry (title + cost), or null
30
+ * choose, // (id) => Promise — call with a method's id on pick
31
+ * mustChoose, // status === "choice_required" → render methods as a picker
32
+ * syncing, // an address edit is being repriced — show a subtle busy state
33
+ * addressError, // { code, message } | null — "we don't ship there" belongs
34
+ * } // on the address fields
35
+ *
36
+ * Render rules the child should follow: `missing_address` → say the options
37
+ * appear once the address is entered; `mustChoose` → a picker of `methods`;
38
+ * otherwise display `chosen.title` + its cost (never the raw id).
39
+ */
40
+ export function ShippingMethodPicker({ checkout: checkoutProp, children }) {
41
+ const checkout = resolveCheckout("ShippingMethodPicker", checkoutProp, useCheckoutContextOptional());
42
+ const {
43
+ shippingStatus: status,
44
+ shippingMethods: methods,
45
+ chosenShippingMethod: chosen,
46
+ chooseShippingMethod: choose,
47
+ shippingSyncing: syncing,
48
+ addressError,
49
+ } = checkout;
50
+ if (!status || status === "not_needed") return null;
51
+ return children({
52
+ status,
53
+ methods,
54
+ chosen,
55
+ choose,
56
+ mustChoose: status === "choice_required",
57
+ syncing,
58
+ addressError,
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Payment methods — every gateway the admin has ENABLED, from store info
64
+ * (their only source). Renders null while store info loads; otherwise calls
65
+ * `children` with:
66
+ *
67
+ * {
68
+ * gateways, // [{ slug, title, description, online }] — admin-owned data
69
+ * value, // the selected slug ("" while none)
70
+ * select, // (slug) => void
71
+ * selected, // the selected gateway entry, or null
72
+ * single, // exactly one gateway — pre-selected; skip the picker but
73
+ * } // still show its title so the customer knows how they pay
74
+ *
75
+ * Render rules: several gateways → a picker labeled with the admin's
76
+ * title/description; `single` → just display it; zero gateways → say checkout
77
+ * is unavailable instead of rendering a dead place-order button.
78
+ */
79
+ export function PaymentMethodPicker({ checkout: checkoutProp, children }) {
80
+ const checkout = resolveCheckout("PaymentMethodPicker", checkoutProp, useCheckoutContextOptional());
81
+ const { paymentMethods: gateways, paymentMethod: value, setPaymentMethod: select, selectedGateway: selected } = checkout;
82
+ if (!gateways) return null;
83
+ return children({
84
+ gateways,
85
+ value,
86
+ select,
87
+ selected,
88
+ single: gateways.length === 1,
89
+ });
90
+ }