@base44/app-plugin-commerce 0.4.0 → 0.4.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.
- package/package.json +1 -1
- package/skills/commerce/SKILL.md +1 -0
- package/skills/commerce/install/02-storefront.md +3 -17
- package/skills/commerce/references/storefront-styling.md +99 -0
- package/src/commerce/storefront/index.js +18 -9
- package/src/commerce/storefront/parts/cart.jsx +37 -33
- package/src/commerce/storefront/parts/checkout.jsx +47 -14
- package/src/commerce/storefront/parts/orderReceived.jsx +7 -5
- package/src/commerce/storefront/parts/parts.css +232 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@base44/app-plugin-commerce",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Base44 Commerce plugin — entities, backend functions, shared commerce engine, admin UI and the commerce skill, shipped as copyable source",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"base44",
|
package/skills/commerce/SKILL.md
CHANGED
|
@@ -112,6 +112,7 @@ Open a file when its work starts — not while planning.
|
|
|
112
112
|
| [`references/catalog-rendering.md`](./references/catalog-rendering.md) | field shapes each catalog call returns, variant edge cases | 16K |
|
|
113
113
|
| [`references/shipping-and-tax.md`](./references/shipping-and-tax.md) | zones beyond 03's recipe, taxes, day-2 edits | 8K |
|
|
114
114
|
| [`references/online-payments.md`](./references/online-payments.md) | enabling card payments, or wiring the provider — at install or any time later | 8K |
|
|
115
|
+
| [`references/storefront-styling.md`](./references/storefront-styling.md) | writing the CSS for the cart/checkout parts (selectors, geometry vars) | 5K |
|
|
115
116
|
| [`references/storefront-custom.md`](./references/storefront-custom.md) | a custom cart row / checkout section on the raw hooks | 8K |
|
|
116
117
|
| [`references/storefront-verification.md`](./references/storefront-verification.md) | driving the storefront from a browser script | 3K |
|
|
117
118
|
| [`references/reviews.md`](./references/reviews.md) | review policies (login-gated, verified buyers), moderation | 5K |
|
|
@@ -10,7 +10,7 @@ carry_forward:
|
|
|
10
10
|
- "Branch custom hook-built UI on `status`, never on `isEmpty`/nullable data; every hook goes above its status guards."
|
|
11
11
|
- "Variant options that aren't buyable render disabled, never hidden; one control per axis, never a list of variations."
|
|
12
12
|
- "No copy ships: every word the parts render comes from the store's copy file (labels); a ⟨copy: …⟩ placeholder on screen is unfinished work."
|
|
13
|
-
- "Identity is encoded once — design classes in index.css (parts styled via [data-part] selectors) plus one or two signature moments per page."
|
|
13
|
+
- "Identity is encoded once — design classes in index.css (parts styled via [data-part] selectors, controls always among them) plus one or two signature moments per page."
|
|
14
14
|
---
|
|
15
15
|
|
|
16
16
|
# 02 — Storefront
|
|
@@ -62,7 +62,7 @@ The cost driver of a generated storefront is not wiring — it is decoration rep
|
|
|
62
62
|
|
|
63
63
|
A part renders one section's correct semantic markup — every branch, guard and aria attribute — and nothing else. Your markup arranges the parts (grid, order, sticky aside, accordion steps — a hidden part loses no state; the place-order gate still counts every blocker). Three inputs carry the identity:
|
|
64
64
|
|
|
65
|
-
- **Styling.**
|
|
65
|
+
- **Styling.** The parts ship **layout geometry only** — field grids, labels above full-width controls, square thumbnails, label-left/value-right totals, the drawer as a right-hand panel — and **no** color, border, radius, shadow or font, so an unstyled store reads as unfinished, never broken. The look is yours: every element carries `data-part` + state attributes (`[data-part="option"][data-state="selected"]`, `[data-part="row"][data-pending]`), each shipped rule sits at **specificity 0** (`:where()`), so a plain selector of yours always wins, and `className`/`classes` take the same names (`classes={{ row: "bag-row", "line-total": "price" }}`). ⚑ **Style the controls at minimum** — `[data-part="control"]`/`[data-part="input"]` need `border`, `background`, `color: inherit`, `font: inherit`, `padding` and a focus ring, or the store ships browser-default white boxes. Space *between* sections is your containers' `gap`; the parts add no outer margins. Selector inventory, the geometry's custom properties (`--commerce-gap`, `--commerce-media-size`, `--commerce-field-columns`, …) and what else repays styling: [`../references/storefront-styling.md`](../references/storefront-styling.md).
|
|
66
66
|
- **Words.** The copy file below, passed once on `StorefrontProvider labels`; override per Root or part with the same shape. A missing key renders a visible `⟨copy: path⟩` and warns — never silent, never English.
|
|
67
67
|
- **Navigation — never rendered, never assumed.** A gate renders *your* element when the state calls for it (function children hand you the data); `OrderReceived.PaymentLink` hands `{ url }` to your element; `Checkout.Root` requires `orderReceivedPath`. Nothing assumes a route, or even an anchor.
|
|
68
68
|
|
|
@@ -111,20 +111,6 @@ export const STORE_COPY = {
|
|
|
111
111
|
|
|
112
112
|
Blocker lines are what a disabled place-order button says — one per code, naming the thing the customer can fix (`cart_loading`/`shipping_recalculating` are quiet transients, not errors); `shipping.*`/`payment.*` are the pickers' dead-ends, with the server's own message rendered instead where one exists. The key tree is frozen in code as `REQUIRED_LABEL_KEYS`.
|
|
113
113
|
|
|
114
|
-
### `data-part` inventory
|
|
115
|
-
|
|
116
|
-
| Part root | Inner `data-part`s | State attributes |
|
|
117
|
-
|---|---|---|
|
|
118
|
-
| `address-fields`; `ship-to-different` | field · label · required · control · error | `data-which`, `data-key`, `data-span`, `data-invalid` |
|
|
119
|
-
| `shipping-methods` `payment-methods` | hint · option · option-input · option-label · option-cost / option-description · chosen | `data-state="selected"`, `data-syncing`, `data-severity` |
|
|
120
|
-
| `lines` `items`; `notices` | row · media · name · attributes · quantity · line-total · error; notice; `lines` adds stepper · increase · decrease · remove | `data-pending`, `data-empty`, `data-code` |
|
|
121
|
-
| `totals`; `payment-instructions` | row · label · value; description · account | `data-key`, `data-emphasis` |
|
|
122
|
-
| `coupon-field` | input · apply · error · applied · code · remove | `data-busy` |
|
|
123
|
-
| `place-order` · `order-error` · `blockers` | blocker | `data-state="placing"`, `data-code` |
|
|
124
|
-
| `trigger` · `drawer` | overlay · panel; `close` | `data-state="open\|closed"` |
|
|
125
|
-
|
|
126
|
-
Tailwind-first styling reaches inner parts with arbitrary variants (`[&_[data-part=option]]:flex …`) or `@apply` inside the design classes.
|
|
127
|
-
|
|
128
114
|
## What each hook resolves to
|
|
129
115
|
|
|
130
116
|
Everything below is already unwrapped — no `.data`, no envelope; `formatMoney` is `useFormatMoney()`. This table serves the catalog surfaces and row/option overrides; the custom-section shapes (`useCartLine`, `useCartUI`, `useCheckoutContext`, `useOrderReturn`, `cartTotalsLines`/`orderTotalsLines`) live in [`../references/storefront-custom.md`](../references/storefront-custom.md).
|
|
@@ -345,7 +331,7 @@ import { OrderReceived } from "@/commerce/storefront";
|
|
|
345
331
|
|
|
346
332
|
- [ ] Catalog UI in whatever form fits the store; cart (and/or drawer), checkout and `/order-received` built from the parts — all five receipt gates present, `PaymentInstructions` placed on unpaid.
|
|
347
333
|
- [ ] One copy file passed via `<StorefrontProvider labels={…}>`, **every value rewritten in the store's voice** — no `⟨copy: …⟩` on any page, no reference-example sentence shipped verbatim; the buy button reads for all four `buy.state` values.
|
|
348
|
-
- [ ] `index.css` defines the design classes; parts styled via `[data-part]`/`classes` — nothing left browser-default; pages carry class names, not repeated utility runs.
|
|
334
|
+
- [ ] `index.css` defines the design classes; parts styled via `[data-part]`/`classes` — **controls carry border/background/font/padding + a focus ring**, option rows show their selected state, the drawer panel has a surface, nothing left browser-default; pages carry class names, not repeated utility runs.
|
|
349
335
|
- [ ] **One** `<StorefrontProvider>` above every storefront route (layout-route pattern); no hand-rolled `cart_token`; imports from each section's import line.
|
|
350
336
|
- [ ] Custom hook-built sections branch on `status` and never re-implement part/hook logic; gateways/currency/countries from `useStoreInfo()`/`useCountries()` only.
|
|
351
337
|
- [ ] Coupon field placed if the store has coupons; a paging control whenever `hasNext`; ribbons in both the grid and the product page.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
---
|
|
2
|
+
read_when: "You are writing the CSS for the cart, drawer, checkout or receipt — the parts' look."
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Styling the parts
|
|
6
|
+
|
|
7
|
+
The parts ship **layout geometry and nothing else** (`src/commerce/storefront/parts/parts.css`,
|
|
8
|
+
loaded automatically by `@/commerce/storefront`): field grids, labels above
|
|
9
|
+
full-width controls, media as a fixed square, rows as media + content +
|
|
10
|
+
controls with the money pushed right, totals as label-left/value-right, the
|
|
11
|
+
drawer as a right-hand panel over a full-viewport overlay. Nothing in it
|
|
12
|
+
carries color, background, border, radius, shadow, font or text decoration — a
|
|
13
|
+
release check enforces that — so an unstyled store reads as *unfinished*, never
|
|
14
|
+
as broken, and the look is entirely yours.
|
|
15
|
+
|
|
16
|
+
**Every rule is wrapped in `:where()`, so its specificity is 0.** A plain
|
|
17
|
+
`[data-part="row"] { display: grid }` in your stylesheet wins — no `!important`,
|
|
18
|
+
no cascade fights, no need to know what the sheet did.
|
|
19
|
+
|
|
20
|
+
## Two ways in, one vocabulary
|
|
21
|
+
|
|
22
|
+
```css
|
|
23
|
+
/* index.css — selectors, next to the store's design classes */
|
|
24
|
+
[data-part="control"] { … }
|
|
25
|
+
[data-part="option"][data-state="selected"] { … }
|
|
26
|
+
[data-part="row"][data-pending] { opacity: .55; }
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```jsx
|
|
30
|
+
{/* or classes, keyed by the same data-part names */}
|
|
31
|
+
<Cart.Lines className="bag" classes={{ row: "bag-row", media: "bag-thumb", "line-total": "price" }} />
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Tailwind reaches inner parts with arbitrary variants
|
|
35
|
+
(`className="[&_[data-part=option]]:choice-row"`) or `@apply` inside a design
|
|
36
|
+
class. Either way the names are the ones below.
|
|
37
|
+
|
|
38
|
+
## The minimum that makes a store look designed
|
|
39
|
+
|
|
40
|
+
⚑ **Style the controls.** Browser-default inputs are a white box in a system
|
|
41
|
+
font — on a dark or branded storefront that alone reads as unfinished:
|
|
42
|
+
|
|
43
|
+
```css
|
|
44
|
+
[data-part="control"], [data-part="input"] {
|
|
45
|
+
border: …; background: …; color: inherit; font: inherit; padding: …;
|
|
46
|
+
}
|
|
47
|
+
[data-part="control"]:focus-visible { outline: …; } /* keep a visible focus ring */
|
|
48
|
+
[data-part="field"][data-invalid] [data-part="control"] { border-color: …; }
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Then, in the store's own values: the labels (`[data-part="label"]` — size,
|
|
52
|
+
tracking, case), the option rows (`[data-part="option"]` — padding, border,
|
|
53
|
+
and the `[data-state="selected"]` treatment, which is the one control customers
|
|
54
|
+
look for), the totals (`[data-part="value"]`, `[data-emphasis]` for the total
|
|
55
|
+
line), the buttons (`[data-part="place-order"]`, `apply`, `remove`,
|
|
56
|
+
`increase`/`decrease`), the notices (`[data-part="error"]`, `blocker`, `hint` —
|
|
57
|
+
`[data-severity="error"]` marks the loud ones), and the drawer surface
|
|
58
|
+
(`[data-part="panel"]` needs a background of its own, `[data-part="overlay"]` a
|
|
59
|
+
scrim).
|
|
60
|
+
|
|
61
|
+
**The parts add no outer margins** — space *between* sections comes from the
|
|
62
|
+
containers you wrap them in (`gap` on your checkout grid, your aside, your
|
|
63
|
+
drawer panel's column).
|
|
64
|
+
|
|
65
|
+
## Tuning the built-in geometry
|
|
66
|
+
|
|
67
|
+
Set these anywhere — `:root`, a page, one part — instead of rewriting the rules:
|
|
68
|
+
|
|
69
|
+
| Custom property | Default | Controls |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| `--commerce-gap` | `1rem` | fields, rows, options, panel sections |
|
|
72
|
+
| `--commerce-gap-tight` | `0.4rem` | label→control, name→attributes, stepper |
|
|
73
|
+
| `--commerce-field-columns` | `2` | address-form columns (set `1` in a narrow aside or a media query) |
|
|
74
|
+
| `--commerce-media-size` | `4rem` | cart/summary thumbnail edge |
|
|
75
|
+
| `--commerce-drawer-width` | `28rem` | drawer panel width |
|
|
76
|
+
| `--commerce-drawer-z` | `50` | drawer stacking order (raise above a sticky header) |
|
|
77
|
+
|
|
78
|
+
## `data-part` inventory
|
|
79
|
+
|
|
80
|
+
| Part root | Inner `data-part`s | State attributes |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| `address-fields`; `ship-to-different` | field · label · required · control · error | `data-which`, `data-key`, `data-span`, `data-invalid` |
|
|
83
|
+
| `shipping-methods` `payment-methods` | hint · option · option-input · option-label · option-cost / option-description · chosen | `data-state="selected"`, `data-syncing`, `data-severity` |
|
|
84
|
+
| `lines` `items`; `notices` | row · media · content · name · attributes · controls · stepper · increase · decrease · quantity · remove · line-total · error; notice | `data-pending`, `data-empty`, `data-code` |
|
|
85
|
+
| `totals`; `payment-instructions` | row · label · value; description · account | `data-key`, `data-emphasis` |
|
|
86
|
+
| `coupon-field` | input · apply · error · applied · code · remove | `data-busy` |
|
|
87
|
+
| `place-order` · `order-error` · `blockers` | blocker | `data-state="placing"`, `data-code` |
|
|
88
|
+
| `trigger` · `drawer` | overlay · panel; `close` | `data-state="open\|closed"` |
|
|
89
|
+
|
|
90
|
+
`media` is an `<img>` when the line has an image and an empty `<div
|
|
91
|
+
data-part="media" data-empty>` when it doesn't — same box either way, so style
|
|
92
|
+
the placeholder (`[data-empty]`) rather than letting it render as a hole.
|
|
93
|
+
|
|
94
|
+
## When CSS isn't enough
|
|
95
|
+
|
|
96
|
+
A control that needs different markup takes the part's render override
|
|
97
|
+
(`inputRender`, `optionRender`, `lineRender`, `itemRender`, `fieldRender`) —
|
|
98
|
+
your element, the part's wiring. A whole section that needs different structure
|
|
99
|
+
drops to its hook: [`./storefront-custom.md`](./storefront-custom.md).
|
|
@@ -64,15 +64,19 @@
|
|
|
64
64
|
* **Parts** — for the four commodity surfaces only (cart, drawer, checkout,
|
|
65
65
|
* order-received), `Checkout.*` / `Cart.*` / `CartDrawer.*` /
|
|
66
66
|
* `OrderReceived.*` render each section's correct semantic markup with zero
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
67
|
+
* copy and zero navigation: you place them in YOUR layout, and supply every
|
|
68
|
+
* word through `labels` (chain: part prop → page Root → `<StorefrontProvider
|
|
69
|
+
* labels={…}>`; a missing key renders a visible `⟨copy: …⟩` placeholder).
|
|
70
|
+
* They carry **layout geometry and nothing else** (`parts/parts.css`: field
|
|
71
|
+
* grids, row and thumbnail sizing, totals rows, the drawer panel — no color,
|
|
72
|
+
* type or border, every rule at zero specificity), so an unstyled store looks
|
|
73
|
+
* unfinished but never broken; the look arrives as your CSS on
|
|
74
|
+
* `data-part`/`data-state`, or via `className`/`classes`. Contract tables, the
|
|
75
|
+
* copy example and the `data-part` inventory: the commerce skill's
|
|
76
|
+
* install/02-storefront.md — use the parts from there, not from these files.
|
|
77
|
+
* They compose the hooks above, so dropping one section down to its hook is
|
|
78
|
+
* normal; they are also the app's own source, editable when a requirement
|
|
79
|
+
* outgrows their props.
|
|
76
80
|
*/
|
|
77
81
|
export {
|
|
78
82
|
StorefrontProvider,
|
|
@@ -95,6 +99,11 @@ export {
|
|
|
95
99
|
} from "./address";
|
|
96
100
|
|
|
97
101
|
// ── parts: guided sections for the commodity surfaces ──────────────────────
|
|
102
|
+
// The parts' layout geometry (field grids, row/media sizing, totals rows, the
|
|
103
|
+
// drawer panel) — structure only, no look, every rule at zero specificity so
|
|
104
|
+
// the store's CSS wins. See parts/parts.css for the custom properties.
|
|
105
|
+
import "./parts/parts.css";
|
|
106
|
+
|
|
98
107
|
export { Checkout } from "./parts/checkout";
|
|
99
108
|
export { Cart } from "./parts/cart";
|
|
100
109
|
export { CartDrawer } from "./parts/drawer";
|
|
@@ -61,45 +61,49 @@ function Lines({ lineRender, className, classes, labels: partLabels }) {
|
|
|
61
61
|
) : (
|
|
62
62
|
<div data-part="media" data-empty="" className={classes?.media} />
|
|
63
63
|
)}
|
|
64
|
-
<span data-part="
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
{
|
|
64
|
+
<span data-part="content" className={classes?.content}>
|
|
65
|
+
<span data-part="name" className={classes?.name}>{item.name}</span>
|
|
66
|
+
{attributesLabel(item.attributes) ? (
|
|
67
|
+
<span data-part="attributes" className={classes?.attributes}>
|
|
68
|
+
{attributesLabel(item.attributes)}
|
|
69
|
+
</span>
|
|
70
|
+
) : null}
|
|
71
|
+
</span>
|
|
72
|
+
<span data-part="controls" className={classes?.controls}>
|
|
73
|
+
<span data-part="stepper" className={classes?.stepper}>
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
data-part="decrease"
|
|
77
|
+
className={classes?.decrease}
|
|
78
|
+
onClick={l.decrease}
|
|
79
|
+
disabled={!l.canDecrease || l.pending}
|
|
80
|
+
aria-label={nameLabel(L, "aria.decrease", item.name)}
|
|
81
|
+
>
|
|
82
|
+
−
|
|
83
|
+
</button>
|
|
84
|
+
<span data-part="quantity" className={classes?.quantity}>{l.quantity}</span>
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
data-part="increase"
|
|
88
|
+
className={classes?.increase}
|
|
89
|
+
onClick={l.increase}
|
|
90
|
+
disabled={!l.canIncrease || l.pending}
|
|
91
|
+
aria-label={nameLabel(L, "aria.increase", item.name)}
|
|
92
|
+
>
|
|
93
|
+
+
|
|
94
|
+
</button>
|
|
68
95
|
</span>
|
|
69
|
-
) : null}
|
|
70
|
-
<span data-part="stepper" className={classes?.stepper}>
|
|
71
|
-
<button
|
|
72
|
-
type="button"
|
|
73
|
-
data-part="decrease"
|
|
74
|
-
className={classes?.decrease}
|
|
75
|
-
onClick={l.decrease}
|
|
76
|
-
disabled={!l.canDecrease || l.pending}
|
|
77
|
-
aria-label={nameLabel(L, "aria.decrease", item.name)}
|
|
78
|
-
>
|
|
79
|
-
−
|
|
80
|
-
</button>
|
|
81
|
-
<span data-part="quantity" className={classes?.quantity}>{l.quantity}</span>
|
|
82
96
|
<button
|
|
83
97
|
type="button"
|
|
84
|
-
data-part="
|
|
85
|
-
className={classes?.
|
|
86
|
-
onClick={l.
|
|
87
|
-
disabled={
|
|
88
|
-
aria-label={nameLabel(L, "aria.
|
|
98
|
+
data-part="remove"
|
|
99
|
+
className={classes?.remove}
|
|
100
|
+
onClick={l.remove}
|
|
101
|
+
disabled={l.pending}
|
|
102
|
+
aria-label={nameLabel(L, "aria.remove", item.name)}
|
|
89
103
|
>
|
|
90
|
-
|
|
104
|
+
×
|
|
91
105
|
</button>
|
|
92
106
|
</span>
|
|
93
|
-
<button
|
|
94
|
-
type="button"
|
|
95
|
-
data-part="remove"
|
|
96
|
-
className={classes?.remove}
|
|
97
|
-
onClick={l.remove}
|
|
98
|
-
disabled={l.pending}
|
|
99
|
-
aria-label={nameLabel(L, "aria.remove", item.name)}
|
|
100
|
-
>
|
|
101
|
-
×
|
|
102
|
-
</button>
|
|
103
107
|
<span data-part="line-total" className={classes?.["line-total"]}>
|
|
104
108
|
{formatMoney(item.total)}
|
|
105
109
|
</span>
|
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useId,
|
|
7
|
+
useLayoutEffect,
|
|
8
|
+
useMemo,
|
|
9
|
+
useState,
|
|
10
|
+
} from "react";
|
|
2
11
|
import { addressFieldSpec, attributesLabel } from "@/commerce/utils";
|
|
3
12
|
import { CheckoutProvider, useCheckoutContext } from "../useCheckout";
|
|
4
13
|
import { ShippingMethodPicker, PaymentMethodPicker } from "../pickers";
|
|
@@ -19,6 +28,9 @@ import { CouponField, Totals } from "./shared";
|
|
|
19
28
|
|
|
20
29
|
const CheckoutPartsContext = createContext(null);
|
|
21
30
|
|
|
31
|
+
/** Layout effect where there is a DOM; plain effect on the server (no warning). */
|
|
32
|
+
const useCommitEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
33
|
+
|
|
22
34
|
/**
|
|
23
35
|
* Mounts `CheckoutProvider`, resolves the page's phase once (`submitted` >
|
|
24
36
|
* `loading` > `empty` > `form` — the ordering that kills the empty-bag flash
|
|
@@ -53,7 +65,16 @@ function RootPhase({ onPlaced, children }) {
|
|
|
53
65
|
: status === "empty"
|
|
54
66
|
? "empty"
|
|
55
67
|
: "form";
|
|
56
|
-
|
|
68
|
+
// A page that places <Checkout.Blockers> itself must not ALSO get the copy
|
|
69
|
+
// `PlaceOrder` renders by default — the same three reasons printed twice is a
|
|
70
|
+
// real defect an eval caught. Standalone instances register here (before
|
|
71
|
+
// paint), and the button's own copy stands down while one exists.
|
|
72
|
+
const [ownBlockers, setOwnBlockers] = useState(0);
|
|
73
|
+
const registerBlockers = useCallback((delta) => setOwnBlockers((n) => n + delta), []);
|
|
74
|
+
const value = useMemo(
|
|
75
|
+
() => ({ phase, onPlaced, hasOwnBlockers: ownBlockers > 0, registerBlockers }),
|
|
76
|
+
[phase, onPlaced, ownBlockers, registerBlockers],
|
|
77
|
+
);
|
|
57
78
|
return <CheckoutPartsContext.Provider value={value}>{children}</CheckoutPartsContext.Provider>;
|
|
58
79
|
}
|
|
59
80
|
|
|
@@ -378,13 +399,15 @@ function Items({ itemRender, className, classes }) {
|
|
|
378
399
|
) : (
|
|
379
400
|
<div data-part="media" data-empty="" className={classes?.media} />
|
|
380
401
|
)}
|
|
381
|
-
<span data-part="
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
{
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
402
|
+
<span data-part="content" className={classes?.content}>
|
|
403
|
+
<span data-part="name" className={classes?.name}>{item.name}</span>
|
|
404
|
+
{attributesLabel(item.attributes) ? (
|
|
405
|
+
<span data-part="attributes" className={classes?.attributes}>
|
|
406
|
+
{attributesLabel(item.attributes)}
|
|
407
|
+
</span>
|
|
408
|
+
) : null}
|
|
409
|
+
</span>
|
|
410
|
+
<span data-part="quantity" className={classes?.quantity}>×{item.quantity}</span>
|
|
388
411
|
<span data-part="line-total" className={classes?.["line-total"]}>
|
|
389
412
|
{formatMoney(item.total)}
|
|
390
413
|
</span>
|
|
@@ -399,12 +422,20 @@ function Items({ itemRender, className, classes }) {
|
|
|
399
422
|
/**
|
|
400
423
|
* The blocker lines — what still stands between the customer and the order,
|
|
401
424
|
* one line per code in the store's words, gone when the order can be placed.
|
|
402
|
-
* `PlaceOrder` renders these
|
|
403
|
-
*
|
|
425
|
+
* `PlaceOrder` renders these next to the button by default; placing this part
|
|
426
|
+
* anywhere under `Checkout.Root` moves them there instead — the button's own
|
|
427
|
+
* copy stands down automatically, so the reasons are never printed twice.
|
|
404
428
|
*/
|
|
405
|
-
function Blockers({ className, classes, labels: partLabels }) {
|
|
429
|
+
function Blockers({ standalone = true, className, classes, labels: partLabels }) {
|
|
406
430
|
const checkout = useCheckoutContext();
|
|
431
|
+
const { registerBlockers } = usePartsContext("Blockers");
|
|
407
432
|
const L = useResolvedLabels(partLabels);
|
|
433
|
+
// Registered before paint, so the button's built-in copy never flashes.
|
|
434
|
+
useCommitEffect(() => {
|
|
435
|
+
if (!standalone) return undefined;
|
|
436
|
+
registerBlockers(1);
|
|
437
|
+
return () => registerBlockers(-1);
|
|
438
|
+
}, [standalone, registerBlockers]);
|
|
408
439
|
if (checkout.canPlaceOrder) return null;
|
|
409
440
|
return (
|
|
410
441
|
<div data-part="blockers" className={className}>
|
|
@@ -425,7 +456,7 @@ function Blockers({ className, classes, labels: partLabels }) {
|
|
|
425
456
|
*/
|
|
426
457
|
function PlaceOrder({ showBlockers = true, className, classes, labels: partLabels }) {
|
|
427
458
|
const checkout = useCheckoutContext();
|
|
428
|
-
const { onPlaced } = usePartsContext("PlaceOrder");
|
|
459
|
+
const { onPlaced, hasOwnBlockers } = usePartsContext("PlaceOrder");
|
|
429
460
|
const L = useResolvedLabels(partLabels);
|
|
430
461
|
const place = useCallback(async () => {
|
|
431
462
|
const res = await checkout.placeOrder(); // resolves {ok:false} — never throws
|
|
@@ -448,7 +479,9 @@ function PlaceOrder({ showBlockers = true, className, classes, labels: partLabel
|
|
|
448
479
|
{checkout.orderError.message}
|
|
449
480
|
</p>
|
|
450
481
|
)}
|
|
451
|
-
{showBlockers &&
|
|
482
|
+
{showBlockers && !hasOwnBlockers && (
|
|
483
|
+
<Blockers standalone={false} className={classes?.blockers} labels={partLabels} />
|
|
484
|
+
)}
|
|
452
485
|
</>
|
|
453
486
|
);
|
|
454
487
|
}
|
|
@@ -80,11 +80,13 @@ function Items({ itemRender, className, classes }) {
|
|
|
80
80
|
) : (
|
|
81
81
|
<div data-part="media" data-empty="" className={classes?.media} />
|
|
82
82
|
)}
|
|
83
|
-
<span data-part="
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
83
|
+
<span data-part="content" className={classes?.content}>
|
|
84
|
+
<span data-part="name" className={classes?.name}>{line.name}</span>
|
|
85
|
+
{line.attributesLabel ? (
|
|
86
|
+
<span data-part="attributes" className={classes?.attributes}>{line.attributesLabel}</span>
|
|
87
|
+
) : null}
|
|
88
|
+
</span>
|
|
89
|
+
<span data-part="quantity" className={classes?.quantity}>×{line.quantity}</span>
|
|
88
90
|
<span data-part="line-total" className={classes?.["line-total"]}>{line.totalLabel}</span>
|
|
89
91
|
</>
|
|
90
92
|
)}
|
|
@@ -0,0 +1,232 @@
|
|
|
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
|
+
* 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).
|
|
11
|
+
*
|
|
12
|
+
* What this sheet deliberately does NOT contain — the store's identity, and
|
|
13
|
+
* the reason an unstyled storefront still looks unfinished rather than
|
|
14
|
+
* finished-and-generic: no color, no background, no border (beyond zeroing the
|
|
15
|
+
* ones the browser puts on elements the parts chose, like `fieldset`), no
|
|
16
|
+
* radius, no shadow, no font, no text decoration. A release check enforces
|
|
17
|
+
* that list, so this file cannot drift into a theme.
|
|
18
|
+
*
|
|
19
|
+
* **Overriding is free.** Every rule is wrapped in `:where()`, so its
|
|
20
|
+
* specificity is 0 — any selector the store writes wins without `!important`,
|
|
21
|
+
* including a bare `[data-part="row"] { … }`. Tune the built-in geometry
|
|
22
|
+
* through the custom properties below (set them on `:root`, a page, or one
|
|
23
|
+
* part), or replace a rule outright.
|
|
24
|
+
*
|
|
25
|
+
* :root {
|
|
26
|
+
* --commerce-gap: 1rem; gap between fields, rows, options
|
|
27
|
+
* --commerce-gap-tight: 0.4rem; label→control, name→attributes
|
|
28
|
+
* --commerce-field-columns: 2; address-form columns (1 on narrow)
|
|
29
|
+
* --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
|
+
* }
|
|
33
|
+
*
|
|
34
|
+
* Loaded automatically: `@/commerce/storefront` imports this file. With a
|
|
35
|
+
* bundler that does not take CSS imports from JS, delete that import line and
|
|
36
|
+
* `@import "@/commerce/storefront/parts/parts.css";` from the app's stylesheet
|
|
37
|
+
* instead.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
:where([data-part]) {
|
|
41
|
+
box-sizing: border-box;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* ── address form ─────────────────────────────────────────────────────────── */
|
|
45
|
+
:where([data-part="address-fields"]) {
|
|
46
|
+
display: grid;
|
|
47
|
+
grid-template-columns: repeat(var(--commerce-field-columns, 2), minmax(0, 1fr));
|
|
48
|
+
gap: var(--commerce-gap, 1rem);
|
|
49
|
+
}
|
|
50
|
+
:where([data-part="field"]) {
|
|
51
|
+
display: grid;
|
|
52
|
+
align-content: start;
|
|
53
|
+
gap: var(--commerce-gap-tight, 0.4rem);
|
|
54
|
+
min-inline-size: 0;
|
|
55
|
+
}
|
|
56
|
+
:where([data-part="field"][data-span="2"]) {
|
|
57
|
+
grid-column: 1 / -1;
|
|
58
|
+
}
|
|
59
|
+
:where([data-part="label"]) {
|
|
60
|
+
display: block;
|
|
61
|
+
}
|
|
62
|
+
:where([data-part="control"]) {
|
|
63
|
+
inline-size: 100%;
|
|
64
|
+
min-inline-size: 0;
|
|
65
|
+
}
|
|
66
|
+
:where([data-part="ship-to-different"]) {
|
|
67
|
+
display: flex;
|
|
68
|
+
align-items: center;
|
|
69
|
+
gap: var(--commerce-gap-tight, 0.4rem);
|
|
70
|
+
}
|
|
71
|
+
:where([data-part="ship-to-different"] [data-part="control"]) {
|
|
72
|
+
inline-size: auto;
|
|
73
|
+
flex: none;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/* ── shipping / payment choices ───────────────────────────────────────────── */
|
|
77
|
+
:where([data-part="shipping-methods"], [data-part="payment-methods"]) {
|
|
78
|
+
display: grid;
|
|
79
|
+
gap: var(--commerce-gap-tight, 0.4rem);
|
|
80
|
+
/* the parts chose <fieldset>; neutralize what the browser draws on it */
|
|
81
|
+
margin: 0;
|
|
82
|
+
padding: 0;
|
|
83
|
+
border: 0;
|
|
84
|
+
min-inline-size: 0;
|
|
85
|
+
}
|
|
86
|
+
:where([data-part="option"], [data-part="chosen"]) {
|
|
87
|
+
display: flex;
|
|
88
|
+
flex-wrap: wrap;
|
|
89
|
+
align-items: center;
|
|
90
|
+
gap: var(--commerce-gap-tight, 0.4rem);
|
|
91
|
+
margin: 0;
|
|
92
|
+
}
|
|
93
|
+
:where([data-part="option-input"]) {
|
|
94
|
+
flex: none;
|
|
95
|
+
}
|
|
96
|
+
:where([data-part="option-cost"]) {
|
|
97
|
+
margin-inline-start: auto;
|
|
98
|
+
}
|
|
99
|
+
:where([data-part="option-description"]) {
|
|
100
|
+
flex-basis: 100%;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* ── line rows: cart lines, checkout summary, receipt items ───────────────── */
|
|
104
|
+
:where([data-part="lines"], [data-part="items"], [data-part="notices"]) {
|
|
105
|
+
display: grid;
|
|
106
|
+
gap: var(--commerce-gap, 1rem);
|
|
107
|
+
margin: 0;
|
|
108
|
+
padding: 0;
|
|
109
|
+
list-style: none;
|
|
110
|
+
}
|
|
111
|
+
:where([data-part="row"]) {
|
|
112
|
+
display: flex;
|
|
113
|
+
flex-wrap: wrap;
|
|
114
|
+
align-items: center;
|
|
115
|
+
gap: var(--commerce-gap-tight, 0.4rem);
|
|
116
|
+
min-inline-size: 0;
|
|
117
|
+
}
|
|
118
|
+
:where([data-part="media"]) {
|
|
119
|
+
flex: none;
|
|
120
|
+
inline-size: var(--commerce-media-size, 4rem);
|
|
121
|
+
max-inline-size: 100%;
|
|
122
|
+
aspect-ratio: 1;
|
|
123
|
+
object-fit: cover;
|
|
124
|
+
}
|
|
125
|
+
:where(img[data-part="media"]) {
|
|
126
|
+
display: block;
|
|
127
|
+
block-size: auto;
|
|
128
|
+
}
|
|
129
|
+
:where([data-part="content"]) {
|
|
130
|
+
display: grid;
|
|
131
|
+
align-content: center;
|
|
132
|
+
gap: calc(var(--commerce-gap-tight, 0.4rem) / 2);
|
|
133
|
+
flex: 1 1 8rem;
|
|
134
|
+
min-inline-size: 0;
|
|
135
|
+
}
|
|
136
|
+
:where([data-part="controls"]) {
|
|
137
|
+
display: flex;
|
|
138
|
+
align-items: center;
|
|
139
|
+
gap: var(--commerce-gap-tight, 0.4rem);
|
|
140
|
+
flex: none;
|
|
141
|
+
}
|
|
142
|
+
:where([data-part="stepper"]) {
|
|
143
|
+
display: inline-flex;
|
|
144
|
+
align-items: center;
|
|
145
|
+
gap: var(--commerce-gap-tight, 0.4rem);
|
|
146
|
+
}
|
|
147
|
+
:where([data-part="line-total"]) {
|
|
148
|
+
margin-inline-start: auto;
|
|
149
|
+
}
|
|
150
|
+
:where([data-part="row"] [data-part="error"]) {
|
|
151
|
+
flex-basis: 100%;
|
|
152
|
+
margin: 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/* ── totals, payment instructions (label ↔ value pairs) ───────────────────── */
|
|
156
|
+
:where([data-part="totals"], [data-part="account"]) {
|
|
157
|
+
display: grid;
|
|
158
|
+
gap: var(--commerce-gap-tight, 0.4rem);
|
|
159
|
+
margin: 0;
|
|
160
|
+
}
|
|
161
|
+
:where([data-part="totals"] > *, [data-part="account"] > *) {
|
|
162
|
+
display: flex;
|
|
163
|
+
align-items: baseline;
|
|
164
|
+
gap: var(--commerce-gap-tight, 0.4rem);
|
|
165
|
+
min-inline-size: 0;
|
|
166
|
+
}
|
|
167
|
+
:where([data-part="value"]) {
|
|
168
|
+
margin: 0;
|
|
169
|
+
margin-inline-start: auto;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/* ── coupon field ─────────────────────────────────────────────────────────── */
|
|
173
|
+
:where([data-part="coupon-field"]) {
|
|
174
|
+
display: flex;
|
|
175
|
+
flex-wrap: wrap;
|
|
176
|
+
align-items: center;
|
|
177
|
+
gap: var(--commerce-gap-tight, 0.4rem);
|
|
178
|
+
}
|
|
179
|
+
:where([data-part="coupon-field"] [data-part="input"]) {
|
|
180
|
+
flex: 1 1 10rem;
|
|
181
|
+
min-inline-size: 0;
|
|
182
|
+
}
|
|
183
|
+
:where([data-part="coupon-field"] [data-part="error"], [data-part="applied"]) {
|
|
184
|
+
flex-basis: 100%;
|
|
185
|
+
margin: 0;
|
|
186
|
+
}
|
|
187
|
+
:where([data-part="applied"]) {
|
|
188
|
+
display: flex;
|
|
189
|
+
align-items: center;
|
|
190
|
+
gap: var(--commerce-gap-tight, 0.4rem);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/* ── place order + its reasons ─────────────────────────────────────────────── */
|
|
194
|
+
:where([data-part="blockers"]) {
|
|
195
|
+
display: grid;
|
|
196
|
+
gap: calc(var(--commerce-gap-tight, 0.4rem) / 2);
|
|
197
|
+
}
|
|
198
|
+
:where([data-part="blocker"], [data-part="order-error"], [data-part="hint"]) {
|
|
199
|
+
margin: 0;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* ── payment instructions ─────────────────────────────────────────────────── */
|
|
203
|
+
:where([data-part="payment-instructions"]) {
|
|
204
|
+
display: grid;
|
|
205
|
+
gap: var(--commerce-gap, 1rem);
|
|
206
|
+
}
|
|
207
|
+
:where([data-part="description"]) {
|
|
208
|
+
margin: 0;
|
|
209
|
+
}
|
|
210
|
+
|
|
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
|
+
}
|