@base44/app-plugin-commerce 0.4.0 → 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.
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Layout geometry for the commerce parts — **structure only, never a look.**
3
+ *
4
+ * The parts render correct markup; this sheet makes that markup *lay out*
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
+ * 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.
15
+ *
16
+ * What this sheet deliberately does NOT contain — the store's identity, and
17
+ * the reason an unstyled storefront still looks unfinished rather than
18
+ * finished-and-generic: no color, no background, no border (beyond zeroing the
19
+ * ones the browser puts on elements the parts chose, like `fieldset`), no
20
+ * radius, no shadow, no font, no text decoration. A release check enforces
21
+ * that list, so this file cannot drift into a theme.
22
+ *
23
+ * **Overriding is free.** Every rule is wrapped in `:where()`, so its
24
+ * specificity is 0 — any selector the store writes wins without `!important`,
25
+ * including a bare `[data-part="row"] { … }`. Tune the built-in geometry
26
+ * through the custom properties below (set them on `:root`, a page, or one
27
+ * part), or replace a rule outright.
28
+ *
29
+ * :root {
30
+ * --commerce-gap: 1rem; gap between fields, rows, options
31
+ * --commerce-gap-tight: 0.4rem; label→control, name→attributes
32
+ * --commerce-field-columns: 2; address-form columns (1 on narrow)
33
+ * --commerce-media-size: 4rem; cart/summary thumbnail edge
34
+ * }
35
+ *
36
+ * Loaded automatically: `@/commerce/storefront` imports this file. With a
37
+ * bundler that does not take CSS imports from JS, delete that import line and
38
+ * `@import "@/commerce/storefront/parts/parts.css";` from the app's stylesheet
39
+ * instead.
40
+ */
41
+
42
+ :where([data-part]) {
43
+ box-sizing: border-box;
44
+ }
45
+
46
+ /* ── address form ─────────────────────────────────────────────────────────── */
47
+ :where([data-part="address-fields"]) {
48
+ display: grid;
49
+ grid-template-columns: repeat(var(--commerce-field-columns, 2), minmax(0, 1fr));
50
+ gap: var(--commerce-gap, 1rem);
51
+ }
52
+ :where([data-part="field"]) {
53
+ display: grid;
54
+ align-content: start;
55
+ gap: var(--commerce-gap-tight, 0.4rem);
56
+ min-inline-size: 0;
57
+ }
58
+ :where([data-part="field"][data-span="2"]) {
59
+ grid-column: 1 / -1;
60
+ }
61
+ :where([data-part="label"]) {
62
+ display: block;
63
+ }
64
+ :where([data-part="control"]) {
65
+ inline-size: 100%;
66
+ min-inline-size: 0;
67
+ }
68
+ :where([data-part="ship-to-different"]) {
69
+ display: flex;
70
+ align-items: center;
71
+ gap: var(--commerce-gap-tight, 0.4rem);
72
+ }
73
+ :where([data-part="ship-to-different"] [data-part="control"]) {
74
+ inline-size: auto;
75
+ flex: none;
76
+ }
77
+
78
+ /* ── shipping / payment choices ───────────────────────────────────────────── */
79
+ :where([data-part="shipping-methods"], [data-part="payment-methods"]) {
80
+ display: grid;
81
+ gap: var(--commerce-gap-tight, 0.4rem);
82
+ /* the parts chose <fieldset>; neutralize what the browser draws on it */
83
+ margin: 0;
84
+ padding: 0;
85
+ border: 0;
86
+ min-inline-size: 0;
87
+ }
88
+ :where([data-part="option"], [data-part="chosen"]) {
89
+ display: flex;
90
+ flex-wrap: wrap;
91
+ align-items: center;
92
+ gap: var(--commerce-gap-tight, 0.4rem);
93
+ margin: 0;
94
+ }
95
+ :where([data-part="option-input"]) {
96
+ flex: none;
97
+ }
98
+ :where([data-part="option-cost"]) {
99
+ margin-inline-start: auto;
100
+ }
101
+ :where([data-part="option-description"]) {
102
+ flex-basis: 100%;
103
+ }
104
+
105
+ /* ── line rows: cart lines, checkout summary, receipt items ───────────────── */
106
+ :where([data-part="lines"], [data-part="items"], [data-part="notices"]) {
107
+ display: grid;
108
+ gap: var(--commerce-gap, 1rem);
109
+ margin: 0;
110
+ padding: 0;
111
+ list-style: none;
112
+ }
113
+ :where([data-part="row"]) {
114
+ display: flex;
115
+ flex-wrap: wrap;
116
+ align-items: center;
117
+ gap: var(--commerce-gap-tight, 0.4rem);
118
+ min-inline-size: 0;
119
+ }
120
+ :where([data-part="media"]) {
121
+ flex: none;
122
+ inline-size: var(--commerce-media-size, 4rem);
123
+ max-inline-size: 100%;
124
+ aspect-ratio: 1;
125
+ object-fit: cover;
126
+ }
127
+ :where(img[data-part="media"]) {
128
+ display: block;
129
+ block-size: auto;
130
+ }
131
+ :where([data-part="content"]) {
132
+ display: grid;
133
+ align-content: center;
134
+ gap: calc(var(--commerce-gap-tight, 0.4rem) / 2);
135
+ flex: 1 1 8rem;
136
+ min-inline-size: 0;
137
+ }
138
+ :where([data-part="controls"]) {
139
+ display: flex;
140
+ align-items: center;
141
+ gap: var(--commerce-gap-tight, 0.4rem);
142
+ flex: none;
143
+ }
144
+ :where([data-part="stepper"]) {
145
+ display: inline-flex;
146
+ align-items: center;
147
+ gap: var(--commerce-gap-tight, 0.4rem);
148
+ }
149
+ :where([data-part="line-total"]) {
150
+ margin-inline-start: auto;
151
+ }
152
+ :where([data-part="row"] [data-part="error"]) {
153
+ flex-basis: 100%;
154
+ margin: 0;
155
+ }
156
+
157
+ /* ── totals, payment instructions (label ↔ value pairs) ───────────────────── */
158
+ :where([data-part="totals"], [data-part="account"]) {
159
+ display: grid;
160
+ gap: var(--commerce-gap-tight, 0.4rem);
161
+ margin: 0;
162
+ }
163
+ :where([data-part="totals"] > *, [data-part="account"] > *) {
164
+ display: flex;
165
+ align-items: baseline;
166
+ gap: var(--commerce-gap-tight, 0.4rem);
167
+ min-inline-size: 0;
168
+ }
169
+ :where([data-part="value"]) {
170
+ margin: 0;
171
+ margin-inline-start: auto;
172
+ }
173
+
174
+ /* ── coupon field ─────────────────────────────────────────────────────────── */
175
+ :where([data-part="coupon-field"]) {
176
+ display: flex;
177
+ flex-wrap: wrap;
178
+ align-items: center;
179
+ gap: var(--commerce-gap-tight, 0.4rem);
180
+ }
181
+ :where([data-part="coupon-field"] [data-part="input"]) {
182
+ flex: 1 1 10rem;
183
+ min-inline-size: 0;
184
+ }
185
+ :where([data-part="coupon-field"] [data-part="error"], [data-part="applied"]) {
186
+ flex-basis: 100%;
187
+ margin: 0;
188
+ }
189
+ :where([data-part="applied"]) {
190
+ display: flex;
191
+ align-items: center;
192
+ gap: var(--commerce-gap-tight, 0.4rem);
193
+ }
194
+
195
+ /* ── place order + its reasons ─────────────────────────────────────────────── */
196
+ :where([data-part="blockers"]) {
197
+ display: grid;
198
+ gap: calc(var(--commerce-gap-tight, 0.4rem) / 2);
199
+ }
200
+ :where([data-part="blocker"], [data-part="order-error"], [data-part="hint"]) {
201
+ margin: 0;
202
+ }
203
+
204
+ /* ── payment instructions ─────────────────────────────────────────────────── */
205
+ :where([data-part="payment-instructions"]) {
206
+ display: grid;
207
+ gap: var(--commerce-gap, 1rem);
208
+ }
209
+ :where([data-part="description"]) {
210
+ margin: 0;
211
+ }
212
+
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
+ }