@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@base44/app-plugin-commerce",
3
- "version": "0.1.18",
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, commerce.PaymentGateway | direct | **direct CRUD** | config; consumed by the pricing engine at read time |
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
- <StorefrontProvider base44={base44}>
74
- {/* storefront routes */}
75
- </StorefrontProvider>
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 store with exactly one enabled gateway gets it pre-selected);
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 → pre-selected; skip the picker but still show selected.title
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. Never edit `commerce.PaymentGateway` records yourself.
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. 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, and it converges every gateway row to that set. 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`.
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}> ... </StorefrontProvider>
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 storefront routes.",
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 — pre-selected; skip the picker but
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 { paymentMethods: gateways, paymentMethod: value, setPaymentMethod: select, selectedGateway: selected } = checkout;
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 pre-selected.
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 [paymentMethod, setPaymentMethod] = useState("");
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) ?? null;
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
- 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]);
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,