@base44/app-plugin-commerce 0.1.18 → 0.1.20
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/package.json +1 -1
- package/skills/commerce/docs/api-admin.md +2 -1
- package/skills/commerce/docs/api-storefront.md +14 -0
- package/skills/commerce/post-installation.md +45 -11
- package/src/commerce/storefront/StorefrontProvider.jsx +7 -3
- package/src/commerce/storefront/index.js +2 -1
- package/src/commerce/storefront/pickers.jsx +20 -5
- package/src/commerce/storefront/useCheckout.jsx +38 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@base44/app-plugin-commerce",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.20",
|
|
4
4
|
"description": "Base44 Commerce plugin — entities, backend functions, shared commerce engine, admin UI and the commerce skill, shipped as copyable source",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"base44",
|
|
@@ -16,7 +16,8 @@ Two access styles. **Reads are direct** entity SDK calls; **mutations with side
|
|
|
16
16
|
| commerce.ProductReview | direct | **`commerce/admin-reviews`** | rating recalculation |
|
|
17
17
|
| commerce.ProductCategory, commerce.ProductRibbon | direct | **direct CRUD**, or `commerce/admin-products` `save-term`/`delete-term`/`list-terms` (the API/agent path) | category slug uniqueness; ribbon get-or-create by name |
|
|
18
18
|
| commerce.ProductAttribute, commerce.ProductAttributeTerm | direct | **direct CRUD**, or `commerce/admin-products` `save-term`/`delete-term`/`list-terms` (the API/agent path) | attribute `code` uniqueness; value rename rewrites products; attribute delete cascades its values |
|
|
19
|
-
| commerce.ShippingTaxLocation
|
|
19
|
+
| commerce.ShippingTaxLocation | direct | **direct CRUD** | config; consumed by the pricing engine at read time |
|
|
20
|
+
| commerce.PaymentGateway | direct | **`commerce/seed-store`** `payment_methods` to switch methods on/off; **direct CRUD** to change a record (add or rename a manual option, its `description`, bank accounts, `order`) | one seed call converges every gateway row to the given set, so "enable cards", "card-only" and "offline-only" are one idempotent call; the record's own fields are ordinary config — the admin's Settings → Payments screen edits them directly |
|
|
20
21
|
| commerce.StoreSettings | direct | **direct CRUD** (one record per `group_id`) | grouped config |
|
|
21
22
|
| commerce.Webhook | direct | **direct CRUD** (+ `commerce/admin-webhooks` for test/redeliver) | definition is data; dispatch is engine |
|
|
22
23
|
| commerce.WebhookDelivery, commerce.EmailLog | direct (read-only logs) | written by the engine | audit logs |
|
|
@@ -89,6 +89,20 @@ Each row is the product record (minus paywalled fields) **plus a resolved `ribbo
|
|
|
89
89
|
|
|
90
90
|
**Filters stack.** `category_id`, `ribbon_id`, `attribute_id` + `attribute_term`, `min_price`/`max_price`, `on_sale`, `featured` and `in_stock_only` are ANDed, so "Dresses + gift + on sale" is one request. Build the controls from [`list-categories`](#list-categories), [`list-ribbons`](#list-ribbons) (its `count` gives you "Gift (12)") and [`list-attributes`](#list-attributes), and mirror active filters into the URL so a filtered listing is shareable and survives reload.
|
|
91
91
|
|
|
92
|
+
**The same call also serves a short strip anywhere else** — a homepage hero or banner, a "featured" row, a "new in" rail, related picks beside an article. `per_page` bounds the result and the filters/sort choose what lands in it, so a small curated slice is one request; there is no separate endpoint, and no reason to fetch a big page and slice it client-side. Available if a page wants one:
|
|
93
|
+
|
|
94
|
+
```js
|
|
95
|
+
const inv = (fn, payload) => base44.functions.invoke(fn, payload).then(r => r.data.data);
|
|
96
|
+
const cat = (payload) => inv("commerce/storefront-catalog", { action: "list-products", ...payload });
|
|
97
|
+
|
|
98
|
+
const { products: featured } = await cat({ featured: true, per_page: 4 });
|
|
99
|
+
const { products: newest } = await cat({ sort: "-created_date", per_page: 6 });
|
|
100
|
+
const { products: deals } = await cat({ on_sale: true, in_stock_only: true, per_page: 4 });
|
|
101
|
+
const { products: topInCat } = await cat({ category_id, sort: "popularity", per_page: 4 });
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`featured` is the admin's own per-product flag (the star in the products list, *Featured product* in the editor's Publish box; `commerce/seed-store` accepts `featured: true` per product), so a featured strip is curated store data rather than slugs pinned in the UI. Rows are the ordinary listing rows — same fields a grid card renders ([`../references/product-render.md`](../references/product-render.md)). **A filter may legitimately match nothing** (no product starred, nothing discounted), so drive the block off the returned array's length — hide it or fall back to another slice — instead of assuming rows came back; `has_next` tells you whether a "see all" link has more behind it.
|
|
105
|
+
|
|
92
106
|
### `get-product`
|
|
93
107
|
**Payload:** `{ id }` **or** `{ slug }`; optional `reviews_page` (1), `reviews_per_page` (10, max 50).
|
|
94
108
|
|
|
@@ -64,17 +64,35 @@ Even if the client guard were bypassed, layers 2 and 3 keep the store data safe.
|
|
|
64
64
|
|
|
65
65
|
No visitor UI ships — and no visual component ships either: **every pixel of the shopfront stays yours to design**. What ships is the logic: the storefront **API**, the framework-free helpers in `@/commerce/utils`, and the React layer in `@/commerce/storefront` — hooks and headless pickers owning the contracts every store must get right. The catalog views (§2.1–2.2) are deliberately the thinnest, because that is where storefronts differ most; the cart and checkout (§2.3–2.4) are more guided, because shipping recalculation, payment methods and the place-order gate work the same in every store. **None of it waits on anything**: every shape you build against is documented right here, so seeding (§3) runs in parallel with building these pages (§0 has the schedule) — kick off image generation, write the storefront while it renders, seed when the URLs are back. Live data is only needed once, to see real products on the finished pages. **Payments are not a prerequisite for any of this** — the whole buy path down to `place-order` is built and reviewable before a provider exists (a card gateway with no provider simply answers `503 no_card_payment_provider`, and §2.4 shows the graceful fallback), which is why the payment decision comes after these pages work, not before (§4). The four chunks below are the whole happy path; open [`docs/api-storefront.md`](./docs/api-storefront.md) only for what's beyond them (attribute/price filters, reviews, customer accounts, refunds), and [`references/product-render.md`](./references/product-render.md) for which fields belong in which view.
|
|
66
66
|
|
|
67
|
-
**Set up once** — mount the provider above every storefront route. It owns the shared API client, the store-info cache and ONE shared cart, so a header badge, a cart drawer and the checkout all render the same state:
|
|
67
|
+
**Set up once** — mount the provider **around `<Routes>`**, so it sits above every storefront route. It owns the shared API client, the store-info cache and ONE shared cart, so a header badge, a cart drawer and the checkout all render the same state:
|
|
68
68
|
|
|
69
69
|
```jsx
|
|
70
70
|
import { StorefrontProvider } from "@/commerce/storefront";
|
|
71
71
|
import { base44 } from "@/api/base44Client";
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
72
|
+
import AdminApp from "@/commerce/admin";
|
|
73
|
+
|
|
74
|
+
<BrowserRouter> {/* the app's existing router, wherever it lives */}
|
|
75
|
+
<StorefrontProvider base44={base44}> {/* wraps the router's <Routes>, not a child of it */}
|
|
76
|
+
<Routes> {/* ONE <Routes> — merge storefront routes into the app's */}
|
|
77
|
+
<Route path="/" element={<Home />} />
|
|
78
|
+
<Route path="/product/:slug" element={<ProductPage />} />
|
|
79
|
+
<Route path="/checkout" element={<Checkout />} />
|
|
80
|
+
<Route path="/order-received" element={<OrderReceived />} />
|
|
81
|
+
<Route path="/store-admin/*" element={<AdminApp />} />
|
|
82
|
+
</Routes>
|
|
83
|
+
</StorefrontProvider>
|
|
84
|
+
</BrowserRouter>
|
|
76
85
|
```
|
|
77
86
|
|
|
87
|
+
> ⚠ **`<Routes>` accepts only `<Route>` children — the provider goes outside it.** Nesting it inside (the natural reading of "wrap the storefront routes", and the usual mistake) throws at render: `Error: [StorefrontProvider] is not a <Route> component. All component children of <Routes> must be a <Route> or <React.Fragment>`. Wrapping the whole `<Routes>` as above is the simplest correct shape and fine even with the admin route inside — the admin doesn't use the storefront hooks. Same rule per file: an app that already has a `<Routes>` gets the new pages **added to it**, never a second `<Routes>` block. To scope the provider to storefront routes only, use a pathless **layout route** — the one place a wrapper component is legal — and render `<Outlet />` inside it:
|
|
88
|
+
> ```jsx
|
|
89
|
+
> <Route element={<StorefrontProvider base44={base44}><Outlet /></StorefrontProvider>}>
|
|
90
|
+
> <Route path="/" element={<Home />} /> {/* these share one cart */}
|
|
91
|
+
> <Route path="/checkout" element={<Checkout />} />
|
|
92
|
+
> </Route>
|
|
93
|
+
> <Route path="/store-admin/*" element={<AdminApp />} /> {/* outside the provider */}
|
|
94
|
+
> ```
|
|
95
|
+
|
|
78
96
|
The provider owns the two things hand-rolled storefronts keep getting wrong, so **don't reimplement either — and never mount a second provider or create a second client**: the **`cart_token` lifecycle** (sent with every cart/checkout call, re-persisted from every response — a stale token silently starts a fresh cart; rolling 48 h expiry; cleared when checkout consumes the cart) and the **store-info cache** (`payment_gateways`, currency, countries live **only** on `get-store-info` — the cart view never carries them). Inside the tree, `useStorefront()` returns the shared client for catalog calls, and `store.inv(fn, payload)` on it is the raw escape hatch (it unwraps the `{ success, data }` envelope). If non-React code also needs the client, create it once in `src/lib/storefront.js` (`createStorefront(base44)` from `@/commerce/utils`) and pass that same instance via `<StorefrontProvider store={store}>`.
|
|
79
97
|
|
|
80
98
|
**What each hook / client method resolves to** — every envelope is already unwrapped, so take these shapes at face value (no `.data`, and no `.categories` on a list result):
|
|
@@ -111,6 +129,19 @@ const { products, page, per_page, has_next } = await store.listProducts({
|
|
|
111
129
|
|
|
112
130
|
Each row is a full product record — for a card use `name`, `images[0]?.src` (**may be empty — render a placeholder, never a broken `<img>`**), `price`, `regular_price`, `on_sale` (sale badge), `short_description`, `stock_status`, `average_rating`/`rating_count` (stars cost no extra call) and `ribbons` (`[{ id, name }]`, may be absent — labels like "Best Seller" for the card corner). **There is no product type flag**: `product.attributes?.length > 0` means the product sells variants and its `price` is a *from*-price rolled up from the cheapest variant — render it as "From …". Categories for the nav come from `store.listCategories()` — an **array** of root categories with subcategories nested under `children` (map over it directly; there is no `{ categories }` wrapper on the result). That is the whole card — no other call or reference needed for the list view.
|
|
113
131
|
|
|
132
|
+
**A short strip of products is the same call with a small `per_page`.** Products are not confined to the grid — a homepage hero or banner, a "featured" row, a "new in" rail, a few picks beside an article are all one `listProducts` call: `per_page` bounds how many come back and the filters/sort decide which ones. No separate endpoint, and never fetch a big page to slice client-side. Use this if a page of yours wants such a strip; the catalog pages above are complete without one:
|
|
133
|
+
|
|
134
|
+
```js
|
|
135
|
+
const { products: featured } = await store.listProducts({ featured: true, per_page: 4 });
|
|
136
|
+
const { products: newest } = await store.listProducts({ sort: "-created_date", per_page: 6 });
|
|
137
|
+
const { products: deals } = await store.listProducts({ on_sale: true, per_page: 4 });
|
|
138
|
+
const { products: topInCat } = await store.listProducts({ category_id, sort: "popularity", per_page: 4 });
|
|
139
|
+
// filters stack (featured + category_id + in_stock_only …); sort: -created_date | name |
|
|
140
|
+
// price | -price | popularity | rating. Rows are the same shape the cards above render.
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`featured` is the merchant's own flag — the star in the admin's product list and *Featured product* in the editor's Publish box — and `seed-store` accepts `featured: true` per product, so such a row stays curated store data instead of a hardcoded list of slugs. **Any filter can legitimately match nothing** (nobody has starred a product, nothing is discounted): render the block from the returned array's length — hide it, or fall back to another slice — rather than assuming rows came back.
|
|
144
|
+
|
|
114
145
|
**Carry forward:** each card links to the product page by **`slug`**.
|
|
115
146
|
|
|
116
147
|
### 2.2 Product page — variant selection included
|
|
@@ -189,7 +220,7 @@ Shipping cost on the cart page: a store with exactly **one shipping location** s
|
|
|
189
220
|
|
|
190
221
|
- **address form state** (`billing`/`updateBilling`, an optional separate `shipping`/`updateShipping` behind `setShipToDifferent`), with `missingBillingFields` tracking what `place-order` would reject;
|
|
191
222
|
- **automatic shipping/tax recalculation**: the moment the address is complete enough to price (default: country + city), the hook debounces and calls `set-shipping-address`, repricing every shipping option, its cost and the taxes — half-typed addresses are never sent, an unchanged address is never re-sent, and an address the store doesn't ship to surfaces as `addressError` to show **on the address fields**;
|
|
192
|
-
- **the shipping choice** (`shippingStatus`, `shippingMethods`, `chosenShippingMethod`, `chooseShippingMethod`) and **the payment choice** (`paymentMethods` from store info — their only source; a
|
|
223
|
+
- **the shipping choice** (`shippingStatus`, `shippingMethods`, `chosenShippingMethod`, `chooseShippingMethod`) and **the payment choice** (`paymentMethods` from store info — their only source); on both, **a single option is a selected option** — one shipping rate arrives already applied as `chosenShippingMethod`, one enabled gateway is `selectedGateway` from the first render that has store info, and both re-resolve when the data changes (`singleShippingMethod` / `singlePaymentMethod` flag the case);
|
|
193
224
|
- **the gate**: `canPlaceOrder` + named `blockers`, and `placeOrder` with the online-payment redirect handled.
|
|
194
225
|
|
|
195
226
|
Share one instance across the page's components with `CheckoutProvider` and build each step as your own markup:
|
|
@@ -233,16 +264,17 @@ const { billing, updateBilling, missingBillingFields,
|
|
|
233
264
|
The two store-data choices — **never hardcode either** — come pre-branched through the headless pickers (they render nothing themselves; the render prop is the whole UI):
|
|
234
265
|
|
|
235
266
|
```jsx
|
|
236
|
-
<ShippingMethodPicker>{({ status, methods, chosen, choose, mustChoose, syncing }) => (
|
|
267
|
+
<ShippingMethodPicker>{({ status, methods, chosen, choose, mustChoose, single, syncing }) => (
|
|
237
268
|
// renders null for you on virtual carts (status "not_needed")
|
|
238
269
|
// status "missing_address" → say options appear once the address is entered
|
|
239
270
|
// mustChoose → render methods [{ id, title, cost }] as a picker → choose(m.id)
|
|
271
|
+
// single → already selected; skip the picker, still show chosen.title + cost
|
|
240
272
|
// otherwise → display chosen.title + chosen.cost (never a raw id); dim while syncing
|
|
241
273
|
)}</ShippingMethodPicker>
|
|
242
274
|
|
|
243
275
|
<PaymentMethodPicker>{({ gateways, value, select, selected, single }) => (
|
|
244
276
|
// several → picker labeled with the admin's title/description → select(g.slug)
|
|
245
|
-
// single →
|
|
277
|
+
// single → already selected; skip the picker but still show selected.title
|
|
246
278
|
// none → checkout cannot complete — say so instead of rendering a dead button
|
|
247
279
|
)}</PaymentMethodPicker>
|
|
248
280
|
```
|
|
@@ -422,7 +454,7 @@ Decide from what the user actually asked for:
|
|
|
422
454
|
|
|
423
455
|
Whichever row applies, **say where payments landed** in your summary to the user — including "offline only, cards off". A store's owner should never discover their payment configuration from a customer who couldn't pay.
|
|
424
456
|
|
|
425
|
-
Changing the answer later is one more seed call, not surgery: `commerce/seed-store` with just `{ payment_methods: [...] }` is idempotent (no catalog needed — products are skipped) and converges **every** gateway row to that set, so an offline-only store can add cards, or go card-only, at any point.
|
|
457
|
+
Changing the answer later is one more seed call, not surgery: `commerce/seed-store` with just `{ payment_methods: [...] }` is idempotent (no catalog needed — products are skipped) and converges **every** gateway row to that set, so an offline-only store can add cards, or go card-only, at any point. That call is the on/off switch; editing a `commerce.PaymentGateway` record directly is for the record's own fields (a new or renamed manual option, its description, bank accounts, ordering).
|
|
426
458
|
|
|
427
459
|
### 4.2 Wiring a provider — one file
|
|
428
460
|
|
|
@@ -440,7 +472,9 @@ Four steps, whichever provider it is:
|
|
|
440
472
|
1. **Implement the four functions** in `base44/shared/commerce/card-payment.ts` against the provider's API. **No provider ships with the template** — the file arrives as stubs, and [`references/online-payments.md`](./references/online-payments.md) holds the per-provider rules plus a complete **Stripe** implementation to paste if Stripe is the provider the store chose. Write the file **whole, in one write. Never `find_replace` into the stubs**: a partial patch leaves the originals behind and breaks every commerce function's deploy with duplicate-export bundle errors (`Multiple exports with the same name "createCardPayment"` → rewrite the file whole).
|
|
441
473
|
2. **Store the provider's API credential as a backend app secret** (read with `Deno.env.get(...)` — never in code, never in an entity). Test credentials work end to end. This is the only step that needs the user, so it sets the timing of the whole section (§4.1): ask when the store is standing, not in the install's first message — and if the answer takes a while, keep the rest of the work moving rather than idling on it.
|
|
442
474
|
3. **Register the premade webhook URL** — `https://<app-domain>/functions/commerce/payment-webhook` — with the provider, for its "payment succeeded" event, so orders are confirmed even when the buyer pays and closes the tab. There is **no signing secret to store**: events are treated as nudges and verified against the provider's API. (Per-provider registration calls are in the reference; the user can also do it in the provider's dashboard.)
|
|
443
|
-
4. **Enable the gateway** — the `card` row is seeded **off** (§4.1), so the file alone changes nothing a customer sees.
|
|
475
|
+
4. **Enable the gateway** — the `card` row is seeded **off** (§4.1), so the file alone changes nothing a customer sees. **Turning a method on or off is the seed call:** `commerce/seed-store` with `{ payment_methods: ["offline", "card"] }` (or `["card"]` for card-only) — safe on a seeded store (no catalog needed, products are skipped), idempotent, and it converges every gateway row to that set, so the same call also goes card-only or switches cards back off later. Reach for direct CRUD on `commerce.PaymentGateway` when the **record itself** needs changing — adding a new manual option, renaming one, editing its description or bank accounts, reordering them — which is what the admin's Settings → Payments screen does.
|
|
476
|
+
|
|
477
|
+
Skipping this step is the usual reason a freshly wired provider "doesn't show up at checkout"; doing it *without* steps 1–2 is what produces `503 no_card_payment_provider`.
|
|
444
478
|
|
|
445
479
|
That's it — checkout redirect, `/order-received` confirmation, the webhook, the admin's "Check payment" button, payment links and provider refunds all run through this one file.
|
|
446
480
|
|
|
@@ -454,7 +488,7 @@ Post-installation is complete when every line below holds. **Do not validate the
|
|
|
454
488
|
- [ ] `commerce/seed-store` ran once and reported the catalog — real products, final image URLs; `payment_methods` passed only if the store's methods differ from the default (offline on, cards off).
|
|
455
489
|
- [ ] **Catalog pages and a checkout exist** — the catalog UI in whatever form fits the store (a product list, product pages, or both — one can be enough), a cart step only if the store wants one (§2.3), and the checkout built on `useCheckout` with `/order-received` rendering `useOrderReturn` (§2.4).
|
|
456
490
|
- [ ] **Every coupon the store has can actually be redeemed** — if `coupons` was seeded (or the operator has codes), a code field with `applyCoupon` exists in the cart or the checkout, showing `discount_total` and rendering invalid codes inline (§2.3/§2.4). No field → don't seed coupons and don't mention codes in the copy.
|
|
457
|
-
- [ ] **One `<StorefrontProvider>`** above every storefront route — no second client, no hand-rolled `cart_token` handling, payment gateways read from `useStoreInfo()` only (never off a cart), and cart state everywhere through `useCart()`.
|
|
491
|
+
- [ ] **One `<StorefrontProvider>`** above every storefront route — wrapping `<Routes>` (or a layout route's `<Outlet />`), never placed as a child of `<Routes>`, which React Router rejects (§2) — no second client, no hand-rolled `cart_token` handling, payment gateways read from `useStoreInfo()` only (never off a cart), and cart state everywhere through `useCart()`.
|
|
458
492
|
- [ ] **Cards are either off, or on with a provider behind them** (§4.1) — the default (offline enabled, `card` off) is a complete state and needs nothing. If you enabled cards, the provider must be wired: `card-payment.ts` written whole, secret stored, webhook registered. Enabled without a wired provider means `503 no_card_payment_provider` at checkout; wired without enabling means customers never see the option. Say where payments landed when you hand over.
|
|
459
493
|
|
|
460
494
|
## 6. Next
|
|
@@ -13,10 +13,14 @@ import { createStorefront, storefrontErrorCode, storefrontErrorMessage } from "@
|
|
|
13
13
|
* StorefrontProvider — one client, one store-info cache, ONE shared cart.
|
|
14
14
|
*
|
|
15
15
|
* Mount it once, above every storefront page (product list, product page,
|
|
16
|
-
* cart, checkout, order-received)
|
|
16
|
+
* cart, checkout, order-received) — it wraps <Routes>, it is NOT a <Route>:
|
|
17
17
|
*
|
|
18
18
|
* import { base44 } from "@/api/base44Client";
|
|
19
|
-
* <StorefrontProvider base44={base44}>
|
|
19
|
+
* <StorefrontProvider base44={base44}> <Routes>…</Routes> </StorefrontProvider>
|
|
20
|
+
*
|
|
21
|
+
* Inside <Routes> it throws ("is not a <Route> component"), since React Router
|
|
22
|
+
* allows only <Route>/<Fragment> children there. To cover just some routes,
|
|
23
|
+
* use a pathless layout route: element={<StorefrontProvider …><Outlet/></…>}.
|
|
20
24
|
*
|
|
21
25
|
* or, if other modules also need the raw client, create it once and share it:
|
|
22
26
|
*
|
|
@@ -102,7 +106,7 @@ export function useStorefrontState() {
|
|
|
102
106
|
const ctx = useContext(StorefrontContext);
|
|
103
107
|
if (!ctx) {
|
|
104
108
|
throw new Error(
|
|
105
|
-
"Storefront hooks need a <StorefrontProvider> above them — mount it once around your
|
|
109
|
+
"Storefront hooks need a <StorefrontProvider> above them — mount it once around your <Routes> (it wraps the router; it is not a <Route>, and inside <Routes> React Router rejects it).",
|
|
106
110
|
);
|
|
107
111
|
}
|
|
108
112
|
return ctx;
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* every pixel stays yours. The hooks own the contracts that are easy to get
|
|
7
7
|
* subtly wrong; you own the markup.
|
|
8
8
|
*
|
|
9
|
-
* Setup (once, above every storefront route
|
|
9
|
+
* Setup (once, above every storefront route — it wraps <Routes>; placed as a
|
|
10
|
+
* child of <Routes> React Router throws "is not a <Route> component"):
|
|
10
11
|
*
|
|
11
12
|
* import { StorefrontProvider } from "@/commerce/storefront";
|
|
12
13
|
* import { base44 } from "@/api/base44Client";
|
|
@@ -27,15 +27,19 @@ function resolveCheckout(name, prop, ctx) {
|
|
|
27
27
|
* status, // "missing_address" | "choice_required" | "chosen" | "auto_selected"
|
|
28
28
|
* methods, // [{ id, title, cost }] — what this address is offered
|
|
29
29
|
* chosen, // the chosen/auto-selected entry (title + cost), or null
|
|
30
|
+
* selected, // alias of `chosen` — the same name the payment picker uses
|
|
30
31
|
* choose, // (id) => Promise — call with a method's id on pick
|
|
31
32
|
* mustChoose, // status === "choice_required" → render methods as a picker
|
|
33
|
+
* single, // exactly one method offered — already chosen; skip the
|
|
34
|
+
* // picker but still show `chosen.title` and its cost
|
|
32
35
|
* syncing, // an address edit is being repriced — show a subtle busy state
|
|
33
36
|
* addressError, // { code, message } | null — "we don't ship there" belongs
|
|
34
37
|
* } // on the address fields
|
|
35
38
|
*
|
|
36
39
|
* Render rules the child should follow: `missing_address` → say the options
|
|
37
40
|
* appear once the address is entered; `mustChoose` → a picker of `methods`;
|
|
38
|
-
* otherwise display `chosen.title` + its cost (never the raw id).
|
|
41
|
+
* otherwise display `chosen.title` + its cost (never the raw id). `single` and
|
|
42
|
+
* `mustChoose` are never both true, and `single` guarantees `chosen`.
|
|
39
43
|
*/
|
|
40
44
|
export function ShippingMethodPicker({ checkout: checkoutProp, children }) {
|
|
41
45
|
const checkout = resolveCheckout("ShippingMethodPicker", checkoutProp, useCheckoutContextOptional());
|
|
@@ -43,6 +47,7 @@ export function ShippingMethodPicker({ checkout: checkoutProp, children }) {
|
|
|
43
47
|
shippingStatus: status,
|
|
44
48
|
shippingMethods: methods,
|
|
45
49
|
chosenShippingMethod: chosen,
|
|
50
|
+
singleShippingMethod: single,
|
|
46
51
|
chooseShippingMethod: choose,
|
|
47
52
|
shippingSyncing: syncing,
|
|
48
53
|
addressError,
|
|
@@ -52,8 +57,10 @@ export function ShippingMethodPicker({ checkout: checkoutProp, children }) {
|
|
|
52
57
|
status,
|
|
53
58
|
methods,
|
|
54
59
|
chosen,
|
|
60
|
+
selected: chosen,
|
|
55
61
|
choose,
|
|
56
62
|
mustChoose: status === "choice_required",
|
|
63
|
+
single: single ?? (methods.length === 1), // fallback: a hand-built checkout object
|
|
57
64
|
syncing,
|
|
58
65
|
addressError,
|
|
59
66
|
});
|
|
@@ -69,22 +76,30 @@ export function ShippingMethodPicker({ checkout: checkoutProp, children }) {
|
|
|
69
76
|
* value, // the selected slug ("" while none)
|
|
70
77
|
* select, // (slug) => void
|
|
71
78
|
* selected, // the selected gateway entry, or null
|
|
72
|
-
* single, // exactly one gateway —
|
|
79
|
+
* single, // exactly one gateway — already selected; skip the picker but
|
|
73
80
|
* } // still show its title so the customer knows how they pay
|
|
74
81
|
*
|
|
75
82
|
* Render rules: several gateways → a picker labeled with the admin's
|
|
76
83
|
* title/description; `single` → just display it; zero gateways → say checkout
|
|
77
|
-
* is unavailable instead of rendering a dead place-order button.
|
|
84
|
+
* is unavailable instead of rendering a dead place-order button. `single`
|
|
85
|
+
* guarantees `value` and `selected` — never render the one-gateway branch as
|
|
86
|
+
* "nothing selected yet" — and both re-resolve when store info changes.
|
|
78
87
|
*/
|
|
79
88
|
export function PaymentMethodPicker({ checkout: checkoutProp, children }) {
|
|
80
89
|
const checkout = resolveCheckout("PaymentMethodPicker", checkoutProp, useCheckoutContextOptional());
|
|
81
|
-
const {
|
|
90
|
+
const {
|
|
91
|
+
paymentMethods: gateways,
|
|
92
|
+
paymentMethod: value,
|
|
93
|
+
setPaymentMethod: select,
|
|
94
|
+
selectedGateway: selected,
|
|
95
|
+
singlePaymentMethod: single,
|
|
96
|
+
} = checkout;
|
|
82
97
|
if (!gateways) return null;
|
|
83
98
|
return children({
|
|
84
99
|
gateways,
|
|
85
100
|
value,
|
|
86
101
|
select,
|
|
87
102
|
selected,
|
|
88
|
-
single: gateways.length === 1,
|
|
103
|
+
single: single ?? (gateways.length === 1), // fallback: a hand-built checkout object
|
|
89
104
|
});
|
|
90
105
|
}
|
|
@@ -21,6 +21,18 @@ const EMPTY_ADDRESS = Object.freeze({
|
|
|
21
21
|
phone: "",
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* The gateway a checkout is actually paying with: the customer's pick while it
|
|
26
|
+
* is still an enabled gateway, otherwise the only gateway there is — one option
|
|
27
|
+
* is not a choice. Derived on every render from the current store info, so it
|
|
28
|
+
* follows the data instead of trailing it by an effect.
|
|
29
|
+
*/
|
|
30
|
+
function resolvePaymentMethod(gateways, picked) {
|
|
31
|
+
if (!gateways?.length) return "";
|
|
32
|
+
if (picked && gateways.some((g) => g.slug === picked)) return picked;
|
|
33
|
+
return gateways.length === 1 ? gateways[0].slug : "";
|
|
34
|
+
}
|
|
35
|
+
|
|
24
36
|
/**
|
|
25
37
|
* useCheckout — the guided checkout state machine. It owns the parts every
|
|
26
38
|
* checkout must get right, so the page you build is only markup around it:
|
|
@@ -38,8 +50,15 @@ const EMPTY_ADDRESS = Object.freeze({
|
|
|
38
50
|
* `chosenShippingMethod`), `chosen`, `choice_required` (render
|
|
39
51
|
* `shippingMethods` and call `chooseShippingMethod(id)`), `missing_address`
|
|
40
52
|
* (collect the address), `not_needed` (virtual cart — render nothing).
|
|
53
|
+
* `singleShippingMethod` flags the one-option case, and `chosenShippingMethod`
|
|
54
|
+
* is filled for it — a single option is never left unselected.
|
|
41
55
|
* - **Payment choice.** `paymentMethods` come from store info (their ONLY
|
|
42
|
-
* source); a store with exactly one enabled gateway gets it
|
|
56
|
+
* source); a store with exactly one enabled gateway gets it selected
|
|
57
|
+
* (`singlePaymentMethod`, with `selectedGateway` filled) from the first
|
|
58
|
+
* render that has store info. The selection is derived from the current
|
|
59
|
+
* gateway list, not remembered: when store info changes, a gateway that is
|
|
60
|
+
* no longer enabled is dropped and a list that is down to one gateway
|
|
61
|
+
* selects it — the customer's own pick survives as long as it stays enabled.
|
|
43
62
|
* - **The gate.** `canPlaceOrder` + `blockers` say exactly what still stands
|
|
44
63
|
* between the customer and the order — drive the button's disabled state
|
|
45
64
|
* and the "what's missing" hints from them instead of re-deriving.
|
|
@@ -70,7 +89,7 @@ export function useCheckout(options = {}) {
|
|
|
70
89
|
const [billing, setBilling] = useState({ ...EMPTY_ADDRESS, email: "" });
|
|
71
90
|
const [shipping, setShipping] = useState({ ...EMPTY_ADDRESS });
|
|
72
91
|
const [shipToDifferent, setShipToDifferent] = useState(false);
|
|
73
|
-
const [
|
|
92
|
+
const [pickedPaymentMethod, setPaymentMethod] = useState("");
|
|
74
93
|
const [addressError, setAddressError] = useState(null);
|
|
75
94
|
const [syncing, setSyncing] = useState(false);
|
|
76
95
|
const [syncedKey, setSyncedKey] = useState(undefined);
|
|
@@ -124,24 +143,30 @@ export function useCheckout(options = {}) {
|
|
|
124
143
|
// ── shipping choice (from the shared cart view) ──────────────────────────
|
|
125
144
|
const shippingStatus = cart?.shipping_status ?? null;
|
|
126
145
|
const shippingMethods = cart?.available_shipping_methods ?? [];
|
|
146
|
+
const singleShippingMethod = shippingMethods.length === 1;
|
|
147
|
+
// The backend auto-selects when it offers exactly one rate (`auto_selected`)
|
|
148
|
+
// and echoes it back on the cart, so the lookup normally finds it. The
|
|
149
|
+
// fallback covers the seam where a freshly repriced address offers one rate
|
|
150
|
+
// the cart's stored id hasn't caught up with: a single option is selected,
|
|
151
|
+
// and must read as selected, from the moment it is offered.
|
|
127
152
|
const chosenShippingMethod =
|
|
128
|
-
shippingMethods.find((m) => m.id === cart?.chosen_shipping_method) ??
|
|
153
|
+
shippingMethods.find((m) => m.id === cart?.chosen_shipping_method) ??
|
|
154
|
+
(singleShippingMethod ? shippingMethods[0] : null);
|
|
129
155
|
const chooseShippingMethod = useCallback(
|
|
130
156
|
(methodId) => runCart(() => client.chooseShippingMethod(methodId)),
|
|
131
157
|
[client, runCart],
|
|
132
158
|
);
|
|
133
159
|
|
|
134
160
|
// ── payment choice (gateways live on store info ONLY) ────────────────────
|
|
161
|
+
// The pick is state; the *method* is derived, so a lone gateway is selected
|
|
162
|
+
// on the very first render that has store info (never a frame of "nothing
|
|
163
|
+
// selected", never a wasted effect pass) and every one of these re-resolves
|
|
164
|
+
// the moment the gateway list changes — a gateway the admin just disabled
|
|
165
|
+
// drops out, and if that leaves exactly one, it takes over immediately.
|
|
135
166
|
const paymentMethods = info?.payment_gateways ?? null;
|
|
136
|
-
|
|
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]);
|
|
167
|
+
const paymentMethod = resolvePaymentMethod(paymentMethods, pickedPaymentMethod);
|
|
144
168
|
const selectedGateway = paymentMethods?.find((g) => g.slug === paymentMethod) ?? null;
|
|
169
|
+
const singlePaymentMethod = paymentMethods?.length === 1;
|
|
145
170
|
|
|
146
171
|
// ── the gate ─────────────────────────────────────────────────────────────
|
|
147
172
|
const missingBilling = missingBillingFields(billing, requiredBillingFields);
|
|
@@ -220,12 +245,14 @@ export function useCheckout(options = {}) {
|
|
|
220
245
|
shippingStatus,
|
|
221
246
|
shippingMethods,
|
|
222
247
|
chosenShippingMethod,
|
|
248
|
+
singleShippingMethod,
|
|
223
249
|
chooseShippingMethod,
|
|
224
250
|
// payment choice
|
|
225
251
|
paymentMethods,
|
|
226
252
|
paymentMethod,
|
|
227
253
|
setPaymentMethod,
|
|
228
254
|
selectedGateway,
|
|
255
|
+
singlePaymentMethod,
|
|
229
256
|
// the gate + the order
|
|
230
257
|
blockers,
|
|
231
258
|
canPlaceOrder,
|