@base44/app-plugin-commerce 0.4.1 → 0.5.1

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.
@@ -14,6 +14,7 @@ import { ShippingMethodPicker, PaymentMethodPicker } from "../pickers";
14
14
  import { useCart, useCountries, useFormatMoney } from "../StorefrontProvider";
15
15
  import { REQUIRED_BILLING_FIELDS } from "../address";
16
16
  import { LabelsScope, label, useResolvedLabels } from "./labels";
17
+ import { visible } from "./visibility";
17
18
  import { CouponField, Totals } from "./shared";
18
19
 
19
20
  /**
@@ -113,6 +114,7 @@ const Form = gate("Form", "form");
113
114
  */
114
115
  function AddressFields({
115
116
  which = "billing",
117
+ show,
116
118
  include,
117
119
  omit,
118
120
  inputRender: InputRender,
@@ -132,14 +134,21 @@ function AddressFields({
132
134
 
133
135
  const values = isBilling ? checkout.billing : checkout.shipping;
134
136
  const set = isBilling ? checkout.updateBilling : checkout.updateShipping;
137
+ // `show` is the current form (keys are field keys); `include`/`omit` are the
138
+ // older spelling and fold into the same predicate.
139
+ const vis = visible(show, {
140
+ company: include?.includes("company") ?? false,
141
+ phone: include ? include.includes("phone") : true,
142
+ ...Object.fromEntries((omit ?? []).map((k) => [k, false])),
143
+ });
135
144
  const fields = addressFieldSpec({
136
145
  countries,
137
146
  country: values.country,
138
147
  required: isBilling ? REQUIRED_BILLING_FIELDS : ["country", "city"],
139
- includeEmail: isBilling,
140
- includeCompany: include ? include.includes("company") : false,
141
- includePhone: include ? include.includes("phone") : true,
142
- }).filter((f) => !(omit ?? []).includes(f.key));
148
+ includeEmail: isBilling && vis("email"),
149
+ includeCompany: vis("company"),
150
+ includePhone: vis("phone"),
151
+ }).filter((f) => vis(f.key));
143
152
 
144
153
  return (
145
154
  <div data-part="address-fields" data-which={which} className={className}>
@@ -218,20 +227,36 @@ function AddressFields({
218
227
  );
219
228
  }
220
229
 
221
- /** The deliver-elsewhere toggle. Children are its label — the store's words. */
222
- function ShipToDifferent({ children, className, classes }) {
230
+ /**
231
+ * The deliver-elsewhere toggle. Children are its label — the store's words.
232
+ * `controlRender({ checked, onChange, toggle })` replaces the checkbox with
233
+ * whatever the design wants (a switch, a segmented control, a pair of buttons);
234
+ * keep it operable from the keyboard and give it a checked state a screen reader
235
+ * can read (`role="switch"` + `aria-checked`, or a real input inside).
236
+ */
237
+ function ShipToDifferent({ children, controlRender, className, classes }) {
223
238
  const checkout = useCheckoutContext();
239
+ const checked = checkout.shipToDifferent;
240
+ const set = checkout.setShipToDifferent;
241
+ const control = controlRender ? (
242
+ controlRender({ checked, onChange: (e) => set(e?.target ? e.target.checked : Boolean(e)), toggle: () => set(!checked) })
243
+ ) : (
244
+ <input
245
+ data-part="control"
246
+ className={classes?.control}
247
+ type="checkbox"
248
+ checked={checked}
249
+ onChange={(e) => set(e.target.checked)}
250
+ />
251
+ );
252
+ // Without the kit's own input there is nothing for a <label> to label, so the
253
+ // store's control stands on its own (it carries its own accessible name).
254
+ const Wrapper = controlRender ? "div" : "label";
224
255
  return (
225
- <label data-part="ship-to-different" className={className}>
226
- <input
227
- data-part="control"
228
- className={classes?.control}
229
- type="checkbox"
230
- checked={checkout.shipToDifferent}
231
- onChange={(e) => checkout.setShipToDifferent(e.target.checked)}
232
- />
256
+ <Wrapper data-part="ship-to-different" className={className}>
257
+ {control}
233
258
  <span data-part="label" className={classes?.label}>{children}</span>
234
- </label>
259
+ </Wrapper>
235
260
  );
236
261
  }
237
262
 
@@ -268,30 +293,28 @@ function ShippingMethods({ optionRender, className, classes, labels: partLabels
268
293
  </p>
269
294
  )}
270
295
  {showList &&
271
- methods.map((m) => (
272
- <label
273
- key={m.id}
274
- data-part="option"
275
- data-state={m.selected ? "selected" : undefined}
276
- className={classes?.option}
277
- >
278
- <input
279
- data-part="option-input"
280
- type="radio"
281
- name={groupName}
282
- checked={m.selected}
283
- onChange={() => Promise.resolve(m.select()).catch(() => {})}
284
- />
285
- {optionRender ? (
286
- optionRender(m)
287
- ) : (
288
- <>
289
- <span data-part="option-label" className={classes?.["option-label"]}>{m.title}</span>
290
- <span data-part="option-cost" className={classes?.["option-cost"]}>{m.costLabel}</span>
291
- </>
292
- )}
293
- </label>
294
- ))}
296
+ methods.map((m) =>
297
+ optionRender ? (
298
+ <React.Fragment key={m.id}>{optionRender(m)}</React.Fragment>
299
+ ) : (
300
+ <label
301
+ key={m.id}
302
+ data-part="option"
303
+ data-state={m.selected ? "selected" : undefined}
304
+ className={classes?.option}
305
+ >
306
+ <input
307
+ data-part="option-input"
308
+ type="radio"
309
+ name={groupName}
310
+ checked={m.selected}
311
+ onChange={() => Promise.resolve(m.select()).catch(() => {})}
312
+ />
313
+ <span data-part="option-label" className={classes?.["option-label"]}>{m.title}</span>
314
+ <span data-part="option-cost" className={classes?.["option-cost"]}>{m.costLabel}</span>
315
+ </label>
316
+ ),
317
+ )}
295
318
  {!showList && chosen && (
296
319
  <p data-part="chosen" className={classes?.chosen}>
297
320
  {optionRender ? (
@@ -380,8 +403,14 @@ function PaymentMethods({ optionRender, className, classes, labels: partLabels }
380
403
  );
381
404
  }
382
405
 
383
- /** The mini summary: read-only rows of what is being bought. `itemRender(item)` replaces a row. */
384
- function Items({ itemRender, className, classes }) {
406
+ /**
407
+ * The mini summary: read-only rows of what is being bought. `show` decides which
408
+ * elements render — `media`, `attributes`, `quantity`, `lineTotal` (a dense
409
+ * summary column often wants `show={{ media: false }}`); `itemRender(item)`
410
+ * replaces a row.
411
+ */
412
+ function Items({ show, itemRender, className, classes }) {
413
+ const vis = visible(show);
385
414
  const checkout = useCheckoutContext();
386
415
  const formatMoney = useFormatMoney();
387
416
  const items = checkout.cart?.items ?? [];
@@ -394,23 +423,28 @@ function Items({ itemRender, className, classes }) {
394
423
  itemRender(item)
395
424
  ) : (
396
425
  <>
397
- {item.image ? (
398
- <img data-part="media" className={classes?.media} src={item.image} alt="" loading="lazy" />
399
- ) : (
400
- <div data-part="media" data-empty="" className={classes?.media} />
401
- )}
426
+ {vis("media") &&
427
+ (item.image ? (
428
+ <img data-part="media" className={classes?.media} src={item.image} alt="" loading="lazy" />
429
+ ) : (
430
+ <div data-part="media" data-empty="" className={classes?.media} />
431
+ ))}
402
432
  <span data-part="content" className={classes?.content}>
403
433
  <span data-part="name" className={classes?.name}>{item.name}</span>
404
- {attributesLabel(item.attributes) ? (
434
+ {vis("attributes") && attributesLabel(item.attributes) ? (
405
435
  <span data-part="attributes" className={classes?.attributes}>
406
436
  {attributesLabel(item.attributes)}
407
437
  </span>
408
438
  ) : null}
409
439
  </span>
410
- <span data-part="quantity" className={classes?.quantity}>×{item.quantity}</span>
411
- <span data-part="line-total" className={classes?.["line-total"]}>
412
- {formatMoney(item.total)}
413
- </span>
440
+ {vis("quantity") && (
441
+ <span data-part="quantity" className={classes?.quantity}>×{item.quantity}</span>
442
+ )}
443
+ {vis("lineTotal") && (
444
+ <span data-part="line-total" className={classes?.["line-total"]}>
445
+ {formatMoney(item.total)}
446
+ </span>
447
+ )}
414
448
  </>
415
449
  )}
416
450
  </li>
@@ -451,10 +485,12 @@ function Blockers({ standalone = true, className, classes, labels: partLabels })
451
485
  /**
452
486
  * The gate, the button and the reasons, together so a disabled button always
453
487
  * says why. Label and placing-label are the store's; the order error is the
454
- * server's own words. On success `Checkout.Root`'s `onPlaced` fires (when the
488
+ * server's own words. `show={{ blockers: false }}` moves the reasons elsewhere
489
+ * (place `Checkout.Blockers` yourself — it also suppresses these on its own). On success `Checkout.Root`'s `onPlaced` fires (when the
455
490
  * page owns the post-order step).
456
491
  */
457
- function PlaceOrder({ showBlockers = true, className, classes, labels: partLabels }) {
492
+ function PlaceOrder({ show, showBlockers = true, className, classes, labels: partLabels }) {
493
+ const vis = visible(show, { blockers: showBlockers });
458
494
  const checkout = useCheckoutContext();
459
495
  const { onPlaced, hasOwnBlockers } = usePartsContext("PlaceOrder");
460
496
  const L = useResolvedLabels(partLabels);
@@ -474,12 +510,12 @@ function PlaceOrder({ showBlockers = true, className, classes, labels: partLabel
474
510
  >
475
511
  {checkout.placing ? label(L, "placeOrder.placing") : label(L, "placeOrder.label")}
476
512
  </button>
477
- {checkout.orderError && (
513
+ {vis("orderError") && checkout.orderError && (
478
514
  <p data-part="order-error" role="alert" className={classes?.["order-error"]}>
479
515
  {checkout.orderError.message}
480
516
  </p>
481
517
  )}
482
- {showBlockers && !hasOwnBlockers && (
518
+ {vis("blockers") && !hasOwnBlockers && (
483
519
  <Blockers standalone={false} className={classes?.blockers} labels={partLabels} />
484
520
  )}
485
521
  </>
@@ -1,24 +1,43 @@
1
- import React, { useEffect, useRef } from "react";
1
+ import React from "react";
2
2
  import { useCart } from "../StorefrontProvider";
3
3
  import { useCartUI } from "../cartUI";
4
4
  import { label, useResolvedLabels } from "./labels";
5
5
 
6
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.
7
+ * The cart drawer's two wired *elements* nothing more. **The drawer itself is
8
+ * the store's**: you render the overlay and the panel, and own where they sit,
9
+ * how wide they are, their padding and their animation. There is deliberately
10
+ * no `Panel` part: a kit that positions the drawer gets it wrong for half the
11
+ * designs it lands in (and there is no version of "which side, how wide, how it
12
+ * moves" that isn't the store's decision).
13
+ *
14
+ * The state and the fiddly behavior stay premade in `useCartUI()` (open/close,
15
+ * Esc, close-on-navigate, open-on-add, focus in and back out, inert while
16
+ * closed, `panelId`) — see `cartUI.jsx`. The shape of a drawer, then, is:
17
+ *
18
+ * const ui = useCartUI();
19
+ * …
20
+ * <CartDrawer.Trigger className="…">{({ count }) => …}</CartDrawer.Trigger>
21
+ * {ui.open && (
22
+ * <div className="…your fixed layer…">
23
+ * <div className="…your scrim…" aria-hidden="true" onClick={ui.closeCart} />
24
+ * <aside id={ui.panelId} ref={ui.panelRef} tabIndex={-1}
25
+ * role="dialog" aria-modal="true" aria-label="…"
26
+ * className="…your panel: side, width, padding, transition…">
27
+ * <CartDrawer.Close>…your glyph…</CartDrawer.Close>
28
+ * …Cart.* parts…
29
+ * </aside>
30
+ * </div>
31
+ * )}
32
+ *
33
+ * Contract, rules and labels: the commerce skill's install/02-storefront.md.
14
34
  */
15
35
 
16
- const PANEL_ID = "commerce-cart-drawer";
17
-
18
36
  /**
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.
37
+ * The header's cart button, wired: toggles the drawer, reports `aria-expanded`,
38
+ * and points `aria-controls` at `useCartUI().panelId` (put that on your panel).
39
+ * The icon is the store's — children, or a render prop `({ count, open }) => …`
40
+ * to badge it with the live item count.
22
41
  */
23
42
  function Trigger({ children, className, labels: partLabels }) {
24
43
  const L = useResolvedLabels(partLabels);
@@ -33,7 +52,7 @@ function Trigger({ children, className, labels: partLabels }) {
33
52
  className={className}
34
53
  aria-haspopup="dialog"
35
54
  aria-expanded={ui.open}
36
- aria-controls={PANEL_ID}
55
+ aria-controls={ui.panelId}
37
56
  aria-label={label(L, "aria.trigger")}
38
57
  onClick={ui.toggleCart}
39
58
  >
@@ -43,59 +62,9 @@ function Trigger({ children, className, labels: partLabels }) {
43
62
  }
44
63
 
45
64
  /**
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]`.
65
+ * The named close control the drawer's keyboard and screen-reader way out
66
+ * (a click-away scrim is not one). Children are the store's icon or text.
52
67
  */
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
68
  function Close({ children, className, labels: partLabels }) {
100
69
  const L = useResolvedLabels(partLabels);
101
70
  const ui = useCartUI();
@@ -114,6 +83,5 @@ function Close({ children, className, labels: partLabels }) {
114
83
 
115
84
  export const CartDrawer = {
116
85
  Trigger,
117
- Panel,
118
86
  Close,
119
87
  };
@@ -3,6 +3,7 @@ import { orderTotalsLines } from "@/commerce/utils";
3
3
  import { useFormatMoney } from "../StorefrontProvider";
4
4
  import { useOrderReturn } from "../useOrderReturn";
5
5
  import { LabelsScope, label, useResolvedLabels } from "./labels";
6
+ import { visible } from "./visibility";
6
7
  import { totalsLabels } from "./shared";
7
8
 
8
9
  /**
@@ -62,8 +63,13 @@ const Unpaid = gate("Unpaid", "unpaid");
62
63
  const Cancelled = gate("Cancelled", "cancelled");
63
64
  const ErrorState = gate("Error", "error");
64
65
 
65
- /** The order's lines, already normalized (image is `{src, alt}|null`, money pre-formatted). */
66
- function Items({ itemRender, className, classes }) {
66
+ /**
67
+ * The order's lines, already normalized (image is `{src, alt}|null`, money
68
+ * pre-formatted). `show` decides which elements render (`media`, `attributes`,
69
+ * `quantity`, `lineTotal`); `itemRender(line)` replaces a row.
70
+ */
71
+ function Items({ show, itemRender, className, classes }) {
72
+ const vis = visible(show);
67
73
  const ret = useReturnCtx("Items");
68
74
  const lines = ret.lines ?? [];
69
75
  if (!lines.length) return null;
@@ -75,19 +81,24 @@ function Items({ itemRender, className, classes }) {
75
81
  itemRender(line)
76
82
  ) : (
77
83
  <>
78
- {line.image ? (
79
- <img data-part="media" className={classes?.media} src={line.image.src} alt={line.image.alt} loading="lazy" />
80
- ) : (
81
- <div data-part="media" data-empty="" className={classes?.media} />
82
- )}
84
+ {vis("media") &&
85
+ (line.image ? (
86
+ <img data-part="media" className={classes?.media} src={line.image.src} alt={line.image.alt} loading="lazy" />
87
+ ) : (
88
+ <div data-part="media" data-empty="" className={classes?.media} />
89
+ ))}
83
90
  <span data-part="content" className={classes?.content}>
84
91
  <span data-part="name" className={classes?.name}>{line.name}</span>
85
- {line.attributesLabel ? (
92
+ {vis("attributes") && line.attributesLabel ? (
86
93
  <span data-part="attributes" className={classes?.attributes}>{line.attributesLabel}</span>
87
94
  ) : null}
88
95
  </span>
89
- <span data-part="quantity" className={classes?.quantity}>×{line.quantity}</span>
90
- <span data-part="line-total" className={classes?.["line-total"]}>{line.totalLabel}</span>
96
+ {vis("quantity") && (
97
+ <span data-part="quantity" className={classes?.quantity}{line.quantity}</span>
98
+ )}
99
+ {vis("lineTotal") && (
100
+ <span data-part="line-total" className={classes?.["line-total"]}>{line.totalLabel}</span>
101
+ )}
91
102
  </>
92
103
  )}
93
104
  </li>
@@ -97,13 +108,14 @@ function Items({ itemRender, className, classes }) {
97
108
  }
98
109
 
99
110
  /** The receipt's summary rows — an order's totals are FLAT, and this part owns that trap. */
100
- function Totals({ pick, className, classes, labels: partLabels }) {
111
+ function Totals({ show, pick, className, classes, labels: partLabels }) {
112
+ const vis = visible(show ?? pick);
101
113
  const ret = useReturnCtx("Totals");
102
114
  const L = useResolvedLabels(partLabels);
103
115
  const formatMoney = useFormatMoney();
104
116
  if (!ret.order) return null;
105
117
  const rows = orderTotalsLines(ret.order, { formatMoney, labels: totalsLabels(L) }).filter(
106
- (r) => !r.hidden && (!pick || pick.includes(r.key)),
118
+ (r) => !r.hidden && vis(r.key),
107
119
  );
108
120
  return (
109
121
  <dl data-part="totals" className={className}>
@@ -3,11 +3,15 @@
3
3
  *
4
4
  * The parts render correct markup; this sheet makes that markup *lay out*
5
5
  * correctly: labels above full-width controls in a two-column address grid, a
6
- * cart row as media + content + controls, totals as label-left/value-right,
7
- * a drawer that is actually a right-hand panel over an overlay. Without it
8
- * every store re-derives the same geometry from scratch, and a storefront that
9
- * gets it wrong reads as broken (staggered input widths, a product thumbnail
10
- * blown up to the column width, "SetColor: Magenta1$89.00" with no gaps).
6
+ * cart row as media + content + controls, totals as label-left/value-right.
7
+ * Without it every store re-derives the same geometry from scratch, and a
8
+ * storefront that gets it wrong reads as broken (staggered input widths, a
9
+ * product thumbnail blown up to the column width, "SetColor: Magenta1$89.00"
10
+ * with no gaps).
11
+ *
12
+ * It stops at the edge of a part. Anything that arranges *sections* — where the
13
+ * cart drawer sits, how wide it is, how it animates, the space between the
14
+ * checkout's columns — is the store's, and nothing here touches it.
11
15
  *
12
16
  * What this sheet deliberately does NOT contain — the store's identity, and
13
17
  * the reason an unstyled storefront still looks unfinished rather than
@@ -27,8 +31,6 @@
27
31
  * --commerce-gap-tight: 0.4rem; label→control, name→attributes
28
32
  * --commerce-field-columns: 2; address-form columns (1 on narrow)
29
33
  * --commerce-media-size: 4rem; cart/summary thumbnail edge
30
- * --commerce-drawer-width: 28rem; drawer panel width
31
- * --commerce-drawer-z: 50; drawer stacking context
32
34
  * }
33
35
  *
34
36
  * Loaded automatically: `@/commerce/storefront` imports this file. With a
@@ -208,25 +210,6 @@
208
210
  margin: 0;
209
211
  }
210
212
 
211
- /* ── cart drawer: a panel over an overlay, not a block in the header ───────── */
212
- :where([data-part="drawer"]) {
213
- position: fixed;
214
- inset: 0;
215
- z-index: var(--commerce-drawer-z, 50);
216
- display: flex;
217
- justify-content: flex-end;
218
- }
219
- :where([data-part="overlay"]) {
220
- position: absolute;
221
- inset: 0;
222
- }
223
- :where([data-part="panel"]) {
224
- position: relative;
225
- display: flex;
226
- flex-direction: column;
227
- gap: var(--commerce-gap, 1rem);
228
- inline-size: min(var(--commerce-drawer-width, 28rem), 100%);
229
- max-inline-size: 100%;
230
- block-size: 100%;
231
- overflow-y: auto;
232
- }
213
+ /* The cart drawer is deliberately absent: the store renders the overlay and
214
+ the panel and owns their side, width, padding and animation. `useCartUI()`
215
+ keeps the state and the behavior (focus, inert-while-closed, Esc). */
@@ -2,6 +2,7 @@ import React, { useState } from "react";
2
2
  import { cartTotalsLines } from "@/commerce/utils";
3
3
  import { useCart, useFormatMoney } from "../StorefrontProvider";
4
4
  import { label, useResolvedLabels } from "./labels";
5
+ import { visible } from "./visibility";
5
6
 
6
7
  /**
7
8
  * Parts shared by the cart and the checkout (exported as `Cart.CouponField` /
@@ -15,8 +16,12 @@ import { label, useResolvedLabels } from "./labels";
15
16
  * place this once (cart or checkout) or its codes can never be redeemed. An
16
17
  * invalid code renders the server's own message inline. Enter applies (no
17
18
  * <form> is rendered, so it nests safely inside one).
19
+ *
20
+ * `inputRender({ value, onChange, onKeyDown, placeholder, disabled })` swaps the
21
+ * field for the store's own control; the apply button, the applied codes and the
22
+ * failure line stay wired around it.
18
23
  */
19
- export function CouponField({ className, classes, labels: partLabels }) {
24
+ export function CouponField({ inputRender: InputRender, className, classes, labels: partLabels }) {
20
25
  const L = useResolvedLabels(partLabels);
21
26
  const { cart, applyCoupon, removeCoupon } = useCart();
22
27
  const [code, setCode] = useState("");
@@ -37,21 +42,25 @@ export function CouponField({ className, classes, labels: partLabels }) {
37
42
  const applied = cart?.coupon_codes ?? [];
38
43
  return (
39
44
  <div data-part="coupon-field" data-busy={busy || undefined} className={className}>
40
- <input
41
- data-part="input"
42
- className={classes?.input}
43
- value={code}
44
- placeholder={label(L, "coupon.placeholder")}
45
- aria-label={label(L, "coupon.placeholder")}
46
- disabled={busy}
47
- onChange={(e) => setCode(e.target.value)}
48
- onKeyDown={(e) => {
49
- if (e.key === "Enter") {
50
- e.preventDefault();
51
- submit();
52
- }
53
- }}
54
- />
45
+ {(() => {
46
+ const field = {
47
+ value: code,
48
+ placeholder: label(L, "coupon.placeholder"),
49
+ disabled: busy,
50
+ onChange: (e) => setCode(e?.target ? e.target.value : String(e ?? "")),
51
+ onKeyDown: (e) => {
52
+ if (e.key === "Enter") {
53
+ e.preventDefault();
54
+ submit();
55
+ }
56
+ },
57
+ };
58
+ return InputRender ? (
59
+ <InputRender {...field} />
60
+ ) : (
61
+ <input data-part="input" className={classes?.input} aria-label={field.placeholder} {...field} />
62
+ );
63
+ })()}
55
64
  <button
56
65
  type="button"
57
66
  data-part="apply"
@@ -85,17 +94,18 @@ export function CouponField({ className, classes, labels: partLabels }) {
85
94
 
86
95
  /**
87
96
  * The cart's summary lines — every non-hidden row from `cartTotalsLines`
88
- * (discount and tax included), `data-emphasis` on the total. `pick` narrows to
89
- * a subset of row keys (`["subtotal"]` for a drawer footer). Row names come
90
- * from `labels.totals`; amounts stay the engine's.
97
+ * (discount and tax included), `data-emphasis` on the total. `show` picks the
98
+ * rows by key: `show={["subtotal"]}` for a drawer footer, `show={{ tax: false }}`
99
+ * to drop one. Row names come from `labels.totals`; amounts stay the engine's.
91
100
  */
92
- export function Totals({ pick, className, classes, labels: partLabels }) {
101
+ export function Totals({ show, pick, className, classes, labels: partLabels }) {
102
+ const vis = visible(show ?? pick);
93
103
  const L = useResolvedLabels(partLabels);
94
104
  const { cart } = useCart();
95
105
  const formatMoney = useFormatMoney();
96
106
  if (!cart) return null;
97
107
  const rows = cartTotalsLines(cart, { formatMoney, labels: totalsLabels(L) }).filter(
98
- (r) => !r.hidden && (!pick || pick.includes(r.key)),
108
+ (r) => !r.hidden && vis(r.key),
99
109
  );
100
110
  return (
101
111
  <dl data-part="totals" className={className}>
@@ -0,0 +1,36 @@
1
+ /**
2
+ * `show` — the parts' display settings: which of a part's sub-elements render,
3
+ * decided in JSX rather than hidden with CSS. Hiding with `display: none` leaves
4
+ * the element in the layout and in the accessibility tree (an empty thumbnail
5
+ * box, a screen reader still announcing a row's attributes); `show` renders
6
+ * nothing at all.
7
+ *
8
+ * Two shapes, both accepted everywhere:
9
+ *
10
+ * show={{ media: false }} // overrides on the part's defaults
11
+ * show={["subtotal", "total"]} // exactly these, nothing else
12
+ *
13
+ * Keys are the part's `data-part` names (`media`, `attributes`, `stepper`,
14
+ * `lineTotal`, `blockers`, a totals row key, an address field key) — the same
15
+ * vocabulary as `classes` and the CSS selectors, so there is one set of names
16
+ * to know. Per-part key lists: the commerce skill's install/02-storefront.md.
17
+ */
18
+
19
+ /**
20
+ * Build the predicate a part asks for each sub-element.
21
+ *
22
+ * @param {object|string[]|undefined} show the part's `show` prop
23
+ * @param {Record<string, boolean>} [defaults] keys whose default is not `true`
24
+ * @returns {(key: string) => boolean}
25
+ */
26
+ export function visible(show, defaults = {}) {
27
+ if (Array.isArray(show)) {
28
+ const only = new Set(show);
29
+ return (key) => only.has(key);
30
+ }
31
+ return (key) => {
32
+ const asked = show?.[key];
33
+ if (asked !== undefined) return Boolean(asked);
34
+ return defaults[key] ?? true;
35
+ };
36
+ }
@@ -381,3 +381,30 @@ export function useCheckoutContext() {
381
381
  export function useCheckoutContextOptional() {
382
382
  return useContext(CheckoutContext);
383
383
  }
384
+
385
+ /**
386
+ * Is this component rendering inside a checkout (`Checkout.Root`, or your own
387
+ * `<CheckoutProvider>`)? For the one component a store writes once and shows in
388
+ * three places — the drawer, the cart page and the checkout's order summary —
389
+ * where a merchandising block belongs in the first two and is a distraction (or
390
+ * a way out of the funnel) in the third:
391
+ *
392
+ * function CartContents() {
393
+ * const inCheckout = useInCheckout();
394
+ * return (
395
+ * <>
396
+ * <Cart.Lines />
397
+ * {!inCheckout && <UpsellRail />} // your own upsell / related rail
398
+ * <Cart.Totals />
399
+ * </>
400
+ * );
401
+ * }
402
+ *
403
+ * A boolean, not a mode: nothing in the kit changes behavior from it. Prefer an
404
+ * explicit prop (`<CartContents showUpsell={false} />`) where the caller knows
405
+ * best; this is for the case where it doesn't — a shared component several
406
+ * pages deep.
407
+ */
408
+ export function useInCheckout() {
409
+ return useContext(CheckoutContext) != null;
410
+ }