@base44/app-plugin-commerce 0.4.1 → 0.5.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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/skills/commerce/SKILL.md +12 -10
- package/skills/commerce/install/02-storefront.md +74 -98
- package/skills/commerce/references/storefront-custom.md +1 -1
- package/skills/commerce/references/storefront-parts.md +184 -0
- package/src/commerce/storefront/cartUI.jsx +63 -17
- package/src/commerce/storefront/index.js +1 -1
- package/src/commerce/storefront/parts/cart.jsx +64 -40
- package/src/commerce/storefront/parts/checkout.jsx +91 -55
- package/src/commerce/storefront/parts/drawer.jsx +35 -67
- package/src/commerce/storefront/parts/orderReceived.jsx +24 -12
- package/src/commerce/storefront/parts/parts.css +12 -29
- package/src/commerce/storefront/parts/shared.jsx +31 -21
- package/src/commerce/storefront/parts/visibility.js +36 -0
- package/src/commerce/storefront/useCheckout.jsx +27 -0
- package/skills/commerce/references/storefront-styling.md +0 -99
|
@@ -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:
|
|
141
|
-
includePhone:
|
|
142
|
-
}).filter((f) =>
|
|
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
|
-
/**
|
|
222
|
-
|
|
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
|
-
<
|
|
226
|
-
|
|
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
|
-
</
|
|
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
|
-
|
|
273
|
-
key={m.id}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
/**
|
|
384
|
-
|
|
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
|
-
{
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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.
|
|
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
|
-
{
|
|
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
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
20
|
-
* `
|
|
21
|
-
* prop `({ count, open }) => …`
|
|
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={
|
|
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
|
|
47
|
-
*
|
|
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
|
-
/**
|
|
66
|
-
|
|
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
|
-
{
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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 && (
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
/*
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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. `
|
|
89
|
-
*
|
|
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 && (
|
|
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
|
+
}
|