@base44/app-plugin-commerce 0.1.15 → 0.1.17
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 +11 -8
- package/base44/functions/commerce/seed-store/defaults.ts +6 -4
- 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 +9 -8
- package/skills/commerce/docs/api-admin.md +2 -2
- package/skills/commerce/docs/api-storefront.md +4 -4
- package/skills/commerce/installation-guidelines.md +4 -3
- package/skills/commerce/post-installation.md +341 -272
- package/skills/commerce/references/online-payments.md +5 -3
- 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
|
@@ -4,6 +4,8 @@ The store ships with a **Credit card** checkout option (`commerce.PaymentGateway
|
|
|
4
4
|
|
|
5
5
|
What it does **not** ship with is a payment provider. Wiring one (Stripe, PayPal, Adyen, a local PSP…) means implementing **one file** — `base44/shared/commerce/card-payment.ts`, four functions — and nothing else: no entity is touched, no caller or UI needs editing, and the payment webhook *function* is premade (it calls this file's `parseWebhook`).
|
|
6
6
|
|
|
7
|
+
**Does this store even need a provider?** Card payments are **optional and off by default**: `commerce/seed-store` enables the manual **`offline`** method (bank transfer, cash on delivery, pickup — no code at all) and leaves the **`card`** gateway disabled. **Enable cards only if this file is implemented, or is about to be in the same stretch of work** — an enabled card option with no provider behind it answers `503 no_card_payment_provider` at checkout, so the gateway switch (seed `payment_methods`) and this file always move together. [`post-installation.md` §4.1](../post-installation.md#41-off-by-default--enable-only-with-a-provider-wired) has the rule and the timing (short version: a fine thing to add, but at the end of a build, not the start — nothing depends on it).
|
|
8
|
+
|
|
7
9
|
| Function | Backs |
|
|
8
10
|
|---|---|
|
|
9
11
|
| `createCardPayment` | checkout + payment links: make a hosted payment page for `order.total`, return `{ url, reference }`, and attach `order.id`/`order.order_key` to the payment's metadata |
|
|
@@ -11,9 +13,9 @@ What it does **not** ship with is a payment provider. Wiring one (Stripe, PayPal
|
|
|
11
13
|
| `refundCardPayment` | admin refunds through the provider (optional — leave the stub to keep refunds manual) |
|
|
12
14
|
| `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
15
|
|
|
14
|
-
**Wiring Stripe? Don't start here** — [`post-installation.md` §
|
|
16
|
+
**Wiring Stripe? Don't start here** — [`post-installation.md` §4](../post-installation.md#42-wiring-a-provider--one-file) 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
17
|
|
|
16
|
-
|
|
18
|
+
The card option ships **disabled**, so on a default store none of this is customer-visible. Where the gateway has been enabled without the file, picking Credit card at checkout answers `503 no_card_payment_provider` (the storefront should offer the other methods) — implement the file, or switch the option back off in Settings → Payments. 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
19
|
|
|
18
20
|
## How the premade flow works
|
|
19
21
|
|
|
@@ -28,7 +30,7 @@ Until the file is implemented, picking Credit card at checkout answers `503 no_c
|
|
|
28
30
|
|
|
29
31
|
- **Replace `card-payment.ts` whole** — one write of the full new file, never a `find_replace` into the shipped stub: a partial patch leaves the original stubs behind and breaks every commerce function's deploy with duplicate-export bundle errors ("Multiple exports with the same name …"); the fix is always to rewrite the file whole.
|
|
30
32
|
- **Credentials** come from backend secrets/env (`Deno.env.get(...)`) — never from an entity, never from the client. On Base44, env vars are injected at deploy time; after adding a secret, redeploy the backend functions so they can see it.
|
|
31
|
-
- **Which methods the store offers is seed data** — `commerce/seed-store`'s `payment_methods` (e.g. `["card"]`) enables the listed gateways and disables the rest; don't edit `commerce.PaymentGateway` records to turn methods on or off.
|
|
33
|
+
- **Which methods the store offers is seed data** — `commerce/seed-store`'s `payment_methods` (e.g. `["offline", "card"]`) enables the listed gateways and disables the rest; don't edit `commerce.PaymentGateway` records to turn methods on or off. Omitting it means offline-only, cards off — so **enabling the card gateway is part of wiring a provider**, not a separate concern: a correct `card-payment.ts` is invisible at checkout until that call runs, and that call without the file gives customers a `503`.
|
|
32
34
|
- **Only the provider can say an order is paid.** `checkCardPaymentPaid` must ask the provider's API about the stored `reference`; never return true because a request claimed it. It should also check the payment **names this order** (compare the payment's metadata `order_id` to `order.id`) — that stops a reference to some other, genuinely paid payment being replayed against a different order.
|
|
33
35
|
- **Attach the metadata.** `createCardPayment` must put `order.id` and `order.order_key` on the payment's metadata (Stripe: `metadata` + `payment_intent_data.metadata`) — that echo is how `parseWebhook` names the order, and what the check above compares against.
|
|
34
36
|
- **`parseWebhook` never trusts a raw body.** Return `paid: false` (the verify-via-API nudge) unless you verified the provider's signature over the raw payload bytes; set `reference` only from a verified event, otherwise leave it unset so the premade flow uses the reference stored on the order at checkout.
|
|
@@ -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 §
|
|
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
|
+
}
|