@base44/app-plugin-commerce 0.3.3 → 0.4.0

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.
@@ -0,0 +1,471 @@
1
+ import React, { createContext, useCallback, useContext, useId, useMemo, useState } from "react";
2
+ import { addressFieldSpec, attributesLabel } from "@/commerce/utils";
3
+ import { CheckoutProvider, useCheckoutContext } from "../useCheckout";
4
+ import { ShippingMethodPicker, PaymentMethodPicker } from "../pickers";
5
+ import { useCart, useCountries, useFormatMoney } from "../StorefrontProvider";
6
+ import { REQUIRED_BILLING_FIELDS } from "../address";
7
+ import { LabelsScope, label, useResolvedLabels } from "./labels";
8
+ import { CouponField, Totals } from "./shared";
9
+
10
+ /**
11
+ * Checkout parts — the checkout's sections as unstyled, unworded components
12
+ * the store places into ITS OWN layout markup. Contract, styling
13
+ * (`data-part`), labels and placement: the commerce skill's
14
+ * install/02-storefront.md. Each part is a thin composition over `useCheckout`
15
+ * and the pickers; mixing a part with a custom section built on
16
+ * `useCheckoutContext()` is normal, and the parts are the app's source —
17
+ * editable when a requirement outgrows their props.
18
+ */
19
+
20
+ const CheckoutPartsContext = createContext(null);
21
+
22
+ /**
23
+ * Mounts `CheckoutProvider`, resolves the page's phase once (`submitted` >
24
+ * `loading` > `empty` > `form` — the ordering that kills the empty-bag flash
25
+ * over a just-placed order), and scopes `labels`. `orderReceivedPath` is
26
+ * REQUIRED: the one navigation this flow performs is an explicit decision —
27
+ * pass the store's own receipt route, or `null` (+ `onPlaced(result)`) to own
28
+ * the step entirely.
29
+ */
30
+ function Root({ labels, orderReceivedPath, onPlaced, options, checkout, children }) {
31
+ if (checkout == null && orderReceivedPath === undefined) {
32
+ throw new Error(
33
+ "<Checkout.Root> requires orderReceivedPath — the route the store's order-received page is mounted at (or null, with onPlaced, to handle the post-order step yourself). The kit never assumes a route.",
34
+ );
35
+ }
36
+ return (
37
+ <CheckoutProvider checkout={checkout} options={{ ...options, orderReceivedPath }}>
38
+ <LabelsScope labels={labels}>
39
+ <RootPhase onPlaced={onPlaced}>{children}</RootPhase>
40
+ </LabelsScope>
41
+ </CheckoutProvider>
42
+ );
43
+ }
44
+
45
+ function RootPhase({ onPlaced, children }) {
46
+ const checkout = useCheckoutContext();
47
+ const { status } = useCart();
48
+ const phase =
49
+ checkout.stage === "submitted"
50
+ ? "submitted"
51
+ : status === "loading"
52
+ ? "loading"
53
+ : status === "empty"
54
+ ? "empty"
55
+ : "form";
56
+ const value = useMemo(() => ({ phase, onPlaced }), [phase, onPlaced]);
57
+ return <CheckoutPartsContext.Provider value={value}>{children}</CheckoutPartsContext.Provider>;
58
+ }
59
+
60
+ function usePartsContext(name) {
61
+ const ctx = useContext(CheckoutPartsContext);
62
+ if (!ctx) throw new Error(`<Checkout.${name}> works inside <Checkout.Root> only.`);
63
+ return ctx;
64
+ }
65
+
66
+ /** Gates: children render only in the matching phase (node, or function of `{ checkout, cart }`). */
67
+ function gate(name, match) {
68
+ function Gate({ children }) {
69
+ const { phase } = usePartsContext(name);
70
+ const checkout = useCheckoutContext();
71
+ if (phase !== match) return null;
72
+ return typeof children === "function"
73
+ ? (children({ checkout, cart: checkout.cart }) ?? null)
74
+ : (children ?? null);
75
+ }
76
+ Gate.displayName = `Checkout.${name}`;
77
+ return Gate;
78
+ }
79
+
80
+ const Submitted = gate("Submitted", "submitted");
81
+ const Loading = gate("Loading", "loading");
82
+ const Empty = gate("Empty", "empty");
83
+ const Form = gate("Form", "form");
84
+
85
+ /**
86
+ * The address form off `addressFieldSpec`: state/province appears with the
87
+ * right options once a country is picked, `autoComplete` tokens stay on,
88
+ * required marks arm on first blur, and the server's "we don't ship there"
89
+ * lands on the country field. `which="shipping"` renders null until
90
+ * `shipToDifferent`. Overrides: `inputRender` swaps the control only,
91
+ * `fieldRender` the whole labeled block.
92
+ */
93
+ function AddressFields({
94
+ which = "billing",
95
+ include,
96
+ omit,
97
+ inputRender: InputRender,
98
+ fieldRender,
99
+ className,
100
+ classes,
101
+ labels: partLabels,
102
+ }) {
103
+ const checkout = useCheckoutContext();
104
+ const L = useResolvedLabels(partLabels);
105
+ const { countries } = useCountries();
106
+ const idBase = useId();
107
+ const [touched, setTouched] = useState({});
108
+
109
+ const isBilling = which === "billing";
110
+ if (!isBilling && !checkout.shipToDifferent) return null;
111
+
112
+ const values = isBilling ? checkout.billing : checkout.shipping;
113
+ const set = isBilling ? checkout.updateBilling : checkout.updateShipping;
114
+ const fields = addressFieldSpec({
115
+ countries,
116
+ country: values.country,
117
+ required: isBilling ? REQUIRED_BILLING_FIELDS : ["country", "city"],
118
+ includeEmail: isBilling,
119
+ includeCompany: include ? include.includes("company") : false,
120
+ includePhone: include ? include.includes("phone") : true,
121
+ }).filter((f) => !(omit ?? []).includes(f.key));
122
+
123
+ return (
124
+ <div data-part="address-fields" data-which={which} className={className}>
125
+ {fields.map((f) => {
126
+ const id = `${idBase}-${which}-${f.key}`;
127
+ const value = values[f.key] ?? "";
128
+ const invalid = Boolean(touched[f.key] && f.required && !String(value).trim());
129
+ const dom = {
130
+ id,
131
+ value,
132
+ autoComplete: f.autoComplete,
133
+ onChange: (e) => set({ [f.key]: e && e.target ? e.target.value : e }),
134
+ onBlur: () => setTouched((t) => (t[f.key] ? t : { ...t, [f.key]: true })),
135
+ };
136
+ const labelText = label(L, `fields.${f.key}`);
137
+ if (fieldRender) {
138
+ return (
139
+ <React.Fragment key={f.key}>
140
+ {fieldRender({ field: f, label: labelText, invalid, options: f.options, ...dom })}
141
+ </React.Fragment>
142
+ );
143
+ }
144
+ return (
145
+ <div
146
+ key={f.key}
147
+ data-part="field"
148
+ data-key={f.key}
149
+ data-span={f.colSpan}
150
+ data-invalid={invalid || undefined}
151
+ className={classes?.field}
152
+ >
153
+ <label data-part="label" className={classes?.label} htmlFor={id}>
154
+ {labelText}
155
+ {f.required && (
156
+ <span data-part="required" aria-hidden="true">
157
+ *
158
+ </span>
159
+ )}
160
+ </label>
161
+ {InputRender ? (
162
+ <InputRender field={f} options={f.options} invalid={invalid} {...dom} />
163
+ ) : f.type === "select" ? (
164
+ <select
165
+ data-part="control"
166
+ className={classes?.control}
167
+ aria-invalid={invalid || undefined}
168
+ aria-required={f.required || undefined}
169
+ {...dom}
170
+ >
171
+ <option value="">{label(L, "fields.select_placeholder")}</option>
172
+ {f.options.map((o) => (
173
+ <option key={o.value} value={o.value}>
174
+ {o.label}
175
+ </option>
176
+ ))}
177
+ </select>
178
+ ) : (
179
+ <input
180
+ data-part="control"
181
+ className={classes?.control}
182
+ type={f.type}
183
+ aria-invalid={invalid || undefined}
184
+ aria-required={f.required || undefined}
185
+ {...dom}
186
+ />
187
+ )}
188
+ {f.key === "country" && checkout.addressError && (
189
+ <p data-part="error" role="alert" className={classes?.error}>
190
+ {checkout.addressError.message}
191
+ </p>
192
+ )}
193
+ </div>
194
+ );
195
+ })}
196
+ </div>
197
+ );
198
+ }
199
+
200
+ /** The deliver-elsewhere toggle. Children are its label — the store's words. */
201
+ function ShipToDifferent({ children, className, classes }) {
202
+ const checkout = useCheckoutContext();
203
+ return (
204
+ <label data-part="ship-to-different" className={className}>
205
+ <input
206
+ data-part="control"
207
+ className={classes?.control}
208
+ type="checkbox"
209
+ checked={checkout.shipToDifferent}
210
+ onChange={(e) => checkout.setShipToDifferent(e.target.checked)}
211
+ />
212
+ <span data-part="label" className={classes?.label}>{children}</span>
213
+ </label>
214
+ );
215
+ }
216
+
217
+ /**
218
+ * The delivery choice, every branch premade: the hint line (`serverMessage`
219
+ * preferred over the store's own words), a radio list while there is a real
220
+ * choice, the chosen/auto-selected method *displayed* when there is one option
221
+ * (never a picker of one), null for a virtual cart. `optionRender(option)`
222
+ * replaces an option's content; the radio stays wired.
223
+ */
224
+ function ShippingMethods({ optionRender, className, classes, labels: partLabels }) {
225
+ const L = useResolvedLabels(partLabels);
226
+ const groupName = useId();
227
+ return (
228
+ <ShippingMethodPicker>
229
+ {({ status, methods, chosen, syncing, hint }) => {
230
+ const showList = methods.length > 1;
231
+ return (
232
+ <fieldset
233
+ data-part="shipping-methods"
234
+ data-state={status}
235
+ data-syncing={syncing || undefined}
236
+ className={className}
237
+ aria-label={label(L, "aria.shipping_group")}
238
+ >
239
+ {hint && (
240
+ <p
241
+ data-part="hint"
242
+ data-severity={hint.severity}
243
+ className={classes?.hint}
244
+ role={hint.severity === "error" ? "alert" : "status"}
245
+ >
246
+ {hint.serverMessage ?? label(L, `shipping.${hint.code}`)}
247
+ </p>
248
+ )}
249
+ {showList &&
250
+ methods.map((m) => (
251
+ <label
252
+ key={m.id}
253
+ data-part="option"
254
+ data-state={m.selected ? "selected" : undefined}
255
+ className={classes?.option}
256
+ >
257
+ <input
258
+ data-part="option-input"
259
+ type="radio"
260
+ name={groupName}
261
+ checked={m.selected}
262
+ onChange={() => Promise.resolve(m.select()).catch(() => {})}
263
+ />
264
+ {optionRender ? (
265
+ optionRender(m)
266
+ ) : (
267
+ <>
268
+ <span data-part="option-label" className={classes?.["option-label"]}>{m.title}</span>
269
+ <span data-part="option-cost" className={classes?.["option-cost"]}>{m.costLabel}</span>
270
+ </>
271
+ )}
272
+ </label>
273
+ ))}
274
+ {!showList && chosen && (
275
+ <p data-part="chosen" className={classes?.chosen}>
276
+ {optionRender ? (
277
+ optionRender(chosen)
278
+ ) : (
279
+ <>
280
+ <span data-part="option-label" className={classes?.["option-label"]}>{chosen.title}</span>
281
+ <span data-part="option-cost" className={classes?.["option-cost"]}>{chosen.costLabel}</span>
282
+ </>
283
+ )}
284
+ </p>
285
+ )}
286
+ </fieldset>
287
+ );
288
+ }}
289
+ </ShippingMethodPicker>
290
+ );
291
+ }
292
+
293
+ /**
294
+ * How to pay — every ENABLED gateway, titles and descriptions in the admin's
295
+ * own words. One gateway renders as the selection it already is; none renders
296
+ * the store's checkout-unavailable line. `optionRender(gateway)` replaces an
297
+ * option's content; the radio stays wired.
298
+ */
299
+ function PaymentMethods({ optionRender, className, classes, labels: partLabels }) {
300
+ const L = useResolvedLabels(partLabels);
301
+ const groupName = useId();
302
+ return (
303
+ <PaymentMethodPicker>
304
+ {({ gateways, selected, single, hint }) => (
305
+ <fieldset data-part="payment-methods" className={className} aria-label={label(L, "aria.payment_group")}>
306
+ {hint && (
307
+ <p data-part="hint" data-severity={hint.severity} className={classes?.hint} role="alert">
308
+ {label(L, "payment.none_available")}
309
+ </p>
310
+ )}
311
+ {!single &&
312
+ gateways.map((g) => (
313
+ <label
314
+ key={g.slug}
315
+ data-part="option"
316
+ data-state={g.selected ? "selected" : undefined}
317
+ className={classes?.option}
318
+ >
319
+ <input
320
+ data-part="option-input"
321
+ type="radio"
322
+ name={groupName}
323
+ checked={g.selected}
324
+ onChange={g.select}
325
+ />
326
+ {optionRender ? (
327
+ optionRender(g)
328
+ ) : (
329
+ <>
330
+ <span data-part="option-label" className={classes?.["option-label"]}>{g.title}</span>
331
+ {g.description && (
332
+ <span data-part="option-description" className={classes?.["option-description"]}>
333
+ {g.description}
334
+ </span>
335
+ )}
336
+ </>
337
+ )}
338
+ </label>
339
+ ))}
340
+ {single && selected && (
341
+ <p data-part="chosen" className={classes?.chosen}>
342
+ {optionRender ? (
343
+ optionRender(selected)
344
+ ) : (
345
+ <>
346
+ <span data-part="option-label" className={classes?.["option-label"]}>{selected.title}</span>
347
+ {selected.description && (
348
+ <span data-part="option-description" className={classes?.["option-description"]}>
349
+ {selected.description}
350
+ </span>
351
+ )}
352
+ </>
353
+ )}
354
+ </p>
355
+ )}
356
+ </fieldset>
357
+ )}
358
+ </PaymentMethodPicker>
359
+ );
360
+ }
361
+
362
+ /** The mini summary: read-only rows of what is being bought. `itemRender(item)` replaces a row. */
363
+ function Items({ itemRender, className, classes }) {
364
+ const checkout = useCheckoutContext();
365
+ const formatMoney = useFormatMoney();
366
+ const items = checkout.cart?.items ?? [];
367
+ if (!items.length) return null;
368
+ return (
369
+ <ul data-part="items" className={className}>
370
+ {items.map((item) => (
371
+ <li key={item.item_key} data-part="row" className={classes?.row}>
372
+ {itemRender ? (
373
+ itemRender(item)
374
+ ) : (
375
+ <>
376
+ {item.image ? (
377
+ <img data-part="media" className={classes?.media} src={item.image} alt="" loading="lazy" />
378
+ ) : (
379
+ <div data-part="media" data-empty="" className={classes?.media} />
380
+ )}
381
+ <span data-part="name" className={classes?.name}>{item.name}</span>
382
+ {attributesLabel(item.attributes) ? (
383
+ <span data-part="attributes" className={classes?.attributes}>
384
+ {attributesLabel(item.attributes)}
385
+ </span>
386
+ ) : null}
387
+ <span data-part="quantity" className={classes?.quantity}>{item.quantity}</span>
388
+ <span data-part="line-total" className={classes?.["line-total"]}>
389
+ {formatMoney(item.total)}
390
+ </span>
391
+ </>
392
+ )}
393
+ </li>
394
+ ))}
395
+ </ul>
396
+ );
397
+ }
398
+
399
+ /**
400
+ * The blocker lines — what still stands between the customer and the order,
401
+ * one line per code in the store's words, gone when the order can be placed.
402
+ * `PlaceOrder` renders these itself; use this part only to place them
403
+ * elsewhere (then pass `showBlockers={false}` to the button).
404
+ */
405
+ function Blockers({ className, classes, labels: partLabels }) {
406
+ const checkout = useCheckoutContext();
407
+ const L = useResolvedLabels(partLabels);
408
+ if (checkout.canPlaceOrder) return null;
409
+ return (
410
+ <div data-part="blockers" className={className}>
411
+ {checkout.blockers.map((code) => (
412
+ <p key={code} data-part="blocker" data-code={code} className={classes?.blocker}>
413
+ {label(L, `blockers.${code}`)}
414
+ </p>
415
+ ))}
416
+ </div>
417
+ );
418
+ }
419
+
420
+ /**
421
+ * The gate, the button and the reasons, together so a disabled button always
422
+ * says why. Label and placing-label are the store's; the order error is the
423
+ * server's own words. On success `Checkout.Root`'s `onPlaced` fires (when the
424
+ * page owns the post-order step).
425
+ */
426
+ function PlaceOrder({ showBlockers = true, className, classes, labels: partLabels }) {
427
+ const checkout = useCheckoutContext();
428
+ const { onPlaced } = usePartsContext("PlaceOrder");
429
+ const L = useResolvedLabels(partLabels);
430
+ const place = useCallback(async () => {
431
+ const res = await checkout.placeOrder(); // resolves {ok:false} — never throws
432
+ if (res?.ok && onPlaced) onPlaced(res.result);
433
+ }, [checkout, onPlaced]);
434
+ return (
435
+ <>
436
+ <button
437
+ type="button"
438
+ data-part="place-order"
439
+ data-state={checkout.placing ? "placing" : undefined}
440
+ className={className}
441
+ disabled={!checkout.canPlaceOrder || checkout.placing}
442
+ onClick={place}
443
+ >
444
+ {checkout.placing ? label(L, "placeOrder.placing") : label(L, "placeOrder.label")}
445
+ </button>
446
+ {checkout.orderError && (
447
+ <p data-part="order-error" role="alert" className={classes?.["order-error"]}>
448
+ {checkout.orderError.message}
449
+ </p>
450
+ )}
451
+ {showBlockers && <Blockers className={classes?.blockers} labels={partLabels} />}
452
+ </>
453
+ );
454
+ }
455
+
456
+ export const Checkout = {
457
+ Root,
458
+ Submitted,
459
+ Loading,
460
+ Empty,
461
+ Form,
462
+ AddressFields,
463
+ ShipToDifferent,
464
+ ShippingMethods,
465
+ PaymentMethods,
466
+ Items,
467
+ CouponField,
468
+ Totals,
469
+ PlaceOrder,
470
+ Blockers,
471
+ };
@@ -0,0 +1,119 @@
1
+ import React, { useEffect, useRef } from "react";
2
+ import { useCart } from "../StorefrontProvider";
3
+ import { useCartUI } from "../cartUI";
4
+ import { label, useResolvedLabels } from "./labels";
5
+
6
+ /**
7
+ * Cart-drawer parts. The open/close state (plus Esc, open-on-add and
8
+ * close-on-navigate) stays on `<CartUIProvider>` — mount it as before; these
9
+ * parts render the trigger and the dialog correctly so the classic drawer
10
+ * bugs (a hidden panel that stays clickable, an unnamed icon button, focus
11
+ * lost on close) cannot be written. Everything inside the panel is the
12
+ * store's markup — typically `Cart.*` parts plus its own checkout link.
13
+ * Contract, styling and labels: the commerce skill's install/02-storefront.md.
14
+ */
15
+
16
+ const PANEL_ID = "commerce-cart-drawer";
17
+
18
+ /**
19
+ * The header's cart button, wired (toggle, `aria-expanded`, dialog name from
20
+ * `labels.aria.trigger`). The icon is the store's: pass children, or a render
21
+ * prop `({ count, open }) => …` to badge it with the live item count.
22
+ */
23
+ function Trigger({ children, className, labels: partLabels }) {
24
+ const L = useResolvedLabels(partLabels);
25
+ const ui = useCartUI();
26
+ const { itemCount } = useCart();
27
+ const content = typeof children === "function" ? children({ count: itemCount, open: ui.open }) : children;
28
+ return (
29
+ <button
30
+ type="button"
31
+ data-part="trigger"
32
+ data-state={ui.open ? "open" : "closed"}
33
+ className={className}
34
+ aria-haspopup="dialog"
35
+ aria-expanded={ui.open}
36
+ aria-controls={PANEL_ID}
37
+ aria-label={label(L, "aria.trigger")}
38
+ onClick={ui.toggleCart}
39
+ >
40
+ {content}
41
+ </button>
42
+ );
43
+ }
44
+
45
+ /**
46
+ * The dialog done right: click-away overlay (not the close control — put a
47
+ * named `<CartDrawer.Close>` inside), `role="dialog"` + `aria-modal`, focus
48
+ * moved in on open and restored on close. Unmounted while closed by default;
49
+ * pass `keepMounted` to animate the slide and the closed panel is made
50
+ * `inert` for you. Position, size and motion are entirely the store's CSS on
51
+ * `[data-part="panel"]` / `[data-state]`.
52
+ */
53
+ function Panel({ children, keepMounted = false, className, classes, labels: partLabels }) {
54
+ const L = useResolvedLabels(partLabels);
55
+ const ui = useCartUI();
56
+ const open = ui.open;
57
+ const rootRef = useRef(null);
58
+ const panelRef = useRef(null);
59
+ const restoreRef = useRef(null);
60
+
61
+ // Focus in on open; hand it back on close.
62
+ useEffect(() => {
63
+ if (open) {
64
+ restoreRef.current = typeof document !== "undefined" ? document.activeElement : null;
65
+ panelRef.current?.focus?.();
66
+ } else if (restoreRef.current) {
67
+ restoreRef.current.focus?.();
68
+ restoreRef.current = null;
69
+ }
70
+ }, [open]);
71
+
72
+ // keepMounted: a closed panel must not be clickable, tab-able or readable.
73
+ useEffect(() => {
74
+ if (!keepMounted || !rootRef.current) return;
75
+ rootRef.current.inert = !open;
76
+ }, [keepMounted, open]);
77
+
78
+ if (!keepMounted && !open) return null;
79
+ return (
80
+ <div ref={rootRef} data-part="drawer" data-state={open ? "open" : "closed"} className={className}>
81
+ <div data-part="overlay" className={classes?.overlay} aria-hidden="true" onClick={ui.closeCart} />
82
+ <aside
83
+ ref={panelRef}
84
+ id={PANEL_ID}
85
+ data-part="panel"
86
+ className={classes?.panel}
87
+ role="dialog"
88
+ aria-modal="true"
89
+ aria-label={label(L, "aria.drawer")}
90
+ tabIndex={-1}
91
+ >
92
+ {children}
93
+ </aside>
94
+ </div>
95
+ );
96
+ }
97
+
98
+ /** The named close control. Children are the store's icon or text. */
99
+ function Close({ children, className, labels: partLabels }) {
100
+ const L = useResolvedLabels(partLabels);
101
+ const ui = useCartUI();
102
+ return (
103
+ <button
104
+ type="button"
105
+ data-part="close"
106
+ className={className}
107
+ aria-label={label(L, "aria.close")}
108
+ onClick={ui.closeCart}
109
+ >
110
+ {children}
111
+ </button>
112
+ );
113
+ }
114
+
115
+ export const CartDrawer = {
116
+ Trigger,
117
+ Panel,
118
+ Close,
119
+ };