@base44/app-plugin-commerce 0.1.19 → 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.19",
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",
@@ -220,7 +220,7 @@ Shipping cost on the cart page: a store with exactly **one shipping location** s
220
220
 
221
221
  - **address form state** (`billing`/`updateBilling`, an optional separate `shipping`/`updateShipping` behind `setShipToDifferent`), with `missingBillingFields` tracking what `place-order` would reject;
222
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**;
223
- - **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);
224
224
  - **the gate**: `canPlaceOrder` + named `blockers`, and `placeOrder` with the online-payment redirect handled.
225
225
 
226
226
  Share one instance across the page's components with `CheckoutProvider` and build each step as your own markup:
@@ -264,16 +264,17 @@ const { billing, updateBilling, missingBillingFields,
264
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):
265
265
 
266
266
  ```jsx
267
- <ShippingMethodPicker>{({ status, methods, chosen, choose, mustChoose, syncing }) => (
267
+ <ShippingMethodPicker>{({ status, methods, chosen, choose, mustChoose, single, syncing }) => (
268
268
  // renders null for you on virtual carts (status "not_needed")
269
269
  // status "missing_address" → say options appear once the address is entered
270
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
271
272
  // otherwise → display chosen.title + chosen.cost (never a raw id); dim while syncing
272
273
  )}</ShippingMethodPicker>
273
274
 
274
275
  <PaymentMethodPicker>{({ gateways, value, select, selected, single }) => (
275
276
  // several → picker labeled with the admin's title/description → select(g.slug)
276
- // single → pre-selected; skip the picker but still show selected.title
277
+ // single → already selected; skip the picker but still show selected.title
277
278
  // none → checkout cannot complete — say so instead of rendering a dead button
278
279
  )}</PaymentMethodPicker>
279
280
  ```
@@ -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,