@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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/skills/commerce/SKILL.md +12 -9
- package/skills/commerce/install/02-storefront.md +75 -113
- 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 +19 -10
- package/src/commerce/storefront/parts/cart.jsx +76 -48
- package/src/commerce/storefront/parts/checkout.jsx +133 -64
- package/src/commerce/storefront/parts/drawer.jsx +35 -67
- package/src/commerce/storefront/parts/orderReceived.jsx +28 -14
- package/src/commerce/storefront/parts/parts.css +215 -0
- 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
|
@@ -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
|
-
|
|
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
|
+
}
|