@base44/app-plugin-commerce 0.4.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/skills/commerce/SKILL.md +13 -11
- package/skills/commerce/install/02-storefront.md +89 -99
- package/skills/commerce/references/catalog-rendering.md +2 -2
- 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 +5 -3
- 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/src/commerce/utils/index.js +4 -1
- package/src/commerce/utils/specs.js +95 -10
- package/skills/commerce/references/storefront-styling.md +0 -99
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
---
|
|
2
|
+
read_when: "You are writing the store's copy file for the parts, the CSS for the cart/drawer/checkout/receipt, or swapping one of their controls for your own element."
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# The parts' words and look
|
|
6
|
+
|
|
7
|
+
The two inputs that make the commodity surfaces *this* store's: the copy file
|
|
8
|
+
(every word the parts render) and the CSS (everything they look like). The parts
|
|
9
|
+
themselves are contracted in install/02-storefront.md.
|
|
10
|
+
|
|
11
|
+
## The copy file — every word, written once
|
|
12
|
+
|
|
13
|
+
The parts ship **no customer-facing string and no fallback**: a label that isn't
|
|
14
|
+
provided renders a visible `⟨copy: path⟩` placeholder and warns once in the
|
|
15
|
+
console. Copy the map below into `src/copy.js`, pass it once
|
|
16
|
+
(`<StorefrontProvider labels={STORE_COPY}>`), and ⚑ **rewrite every value in
|
|
17
|
+
this store's voice** — the English here is *reference*; shipped verbatim it is
|
|
18
|
+
how storefronts end up sounding alike (`aria.*` name controls for screen
|
|
19
|
+
readers; templates take the item's name):
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
export const STORE_COPY = {
|
|
23
|
+
placeOrder: { label: "Place order", placing: "Placing your order…" },
|
|
24
|
+
blockers: {
|
|
25
|
+
empty_cart: "Your bag is empty.", billing_incomplete: "Fill in your details above.",
|
|
26
|
+
shipping_address_incomplete: "Finish the delivery address.",
|
|
27
|
+
shipping_address_required: "Enter your address to see delivery options.",
|
|
28
|
+
shipping_method_required: "Choose a delivery option.",
|
|
29
|
+
shipping_not_available: "We can't deliver to that address.",
|
|
30
|
+
payment_method_required: "Choose how you'd like to pay.",
|
|
31
|
+
cart_loading: "One moment — loading your bag.", shipping_recalculating: "Updating delivery costs…",
|
|
32
|
+
},
|
|
33
|
+
shipping: {
|
|
34
|
+
missing_address: "Delivery options appear once your address is entered.",
|
|
35
|
+
none_available: "We don't deliver to that address yet.",
|
|
36
|
+
syncing: "Updating delivery options…",
|
|
37
|
+
},
|
|
38
|
+
payment: { none_available: "Checkout is unavailable right now — please try again later." },
|
|
39
|
+
coupon: { placeholder: "Gift or promo code", apply: "Apply", remove: "Remove" },
|
|
40
|
+
fields: {
|
|
41
|
+
first_name: "First name", last_name: "Last name", email: "Email", company: "Company",
|
|
42
|
+
address_1: "Address", address_2: "Apartment, suite (optional)", country: "Country",
|
|
43
|
+
city: "City", state: "State / Province", postcode: "Postal code",
|
|
44
|
+
phone: "Phone (optional)", select_placeholder: "Select…",
|
|
45
|
+
},
|
|
46
|
+
totals: { subtotal: "Subtotal", discount: "Discount", shipping: "Shipping", tax: "Tax", total: "Total" },
|
|
47
|
+
bank: { account_name: "Account name", account_number: "Account number", bank_name: "Bank",
|
|
48
|
+
sort_code: "Sort code", iban: "IBAN", bic: "BIC" },
|
|
49
|
+
aria: {
|
|
50
|
+
trigger: "Open your bag", drawer: "Your bag", close: "Close",
|
|
51
|
+
increase: (name) => `Add one more ${name}`, decrease: (name) => `Remove one ${name}`,
|
|
52
|
+
remove: (name) => `Remove ${name} from your bag`,
|
|
53
|
+
shipping_group: "Delivery options", payment_group: "Payment methods",
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Blocker lines are what a disabled place-order button says — one per code, naming
|
|
59
|
+
the thing the customer can fix (`cart_loading`/`shipping_recalculating` are quiet
|
|
60
|
+
transients, not errors); `shipping.*`/`payment.*` are the pickers' dead-ends,
|
|
61
|
+
with the server's own message rendered instead where one exists. The key tree is
|
|
62
|
+
frozen in code as `REQUIRED_LABEL_KEYS`, and a part or a page `Root` takes a
|
|
63
|
+
`labels` slice of the same shape when one surface needs different wording.
|
|
64
|
+
|
|
65
|
+
Headings, empty/loading/submitted screens, links and anything else in *your*
|
|
66
|
+
markup are ordinary JSX — the copy file covers only what the parts render.
|
|
67
|
+
|
|
68
|
+
## Styling the parts
|
|
69
|
+
|
|
70
|
+
The parts ship **layout geometry and nothing else** (`src/commerce/storefront/parts/parts.css`,
|
|
71
|
+
loaded automatically by `@/commerce/storefront`): field grids, labels above
|
|
72
|
+
full-width controls, media as a fixed square, rows as media + content +
|
|
73
|
+
controls with the money pushed right, totals as label-left/value-right. Nothing
|
|
74
|
+
in it carries color, background, border, radius, shadow, font or text
|
|
75
|
+
decoration — a release check enforces that — so an unstyled store reads as
|
|
76
|
+
*unfinished*, never as broken, and the look is entirely yours.
|
|
77
|
+
|
|
78
|
+
It also stops at the edge of a part: **arranging sections is yours** — the
|
|
79
|
+
checkout's columns, the space between sections, and the cart drawer, whose
|
|
80
|
+
overlay and panel you render and position yourself (`useCartUI()` keeps its
|
|
81
|
+
state, focus handling and inert-while-closed; see install/02-storefront.md).
|
|
82
|
+
|
|
83
|
+
**Every rule is wrapped in `:where()`, so its specificity is 0.** A plain
|
|
84
|
+
`[data-part="row"] { display: grid }` in your stylesheet wins — no `!important`,
|
|
85
|
+
no cascade fights, no need to know what the sheet did.
|
|
86
|
+
|
|
87
|
+
## Two ways in, one vocabulary
|
|
88
|
+
|
|
89
|
+
```css
|
|
90
|
+
/* index.css — selectors, next to the store's design classes */
|
|
91
|
+
[data-part="control"] { … }
|
|
92
|
+
[data-part="option"][data-state="selected"] { … }
|
|
93
|
+
[data-part="row"][data-pending] { opacity: .55; }
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
```jsx
|
|
97
|
+
{/* or classes, keyed by the same data-part names */}
|
|
98
|
+
<Cart.Lines className="bag" classes={{ row: "bag-row", media: "bag-thumb", "line-total": "price" }} />
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Tailwind reaches inner parts with arbitrary variants
|
|
102
|
+
(`className="[&_[data-part=option]]:choice-row"`) or `@apply` inside a design
|
|
103
|
+
class. Either way the names are the ones below.
|
|
104
|
+
|
|
105
|
+
## The minimum that makes a store look designed
|
|
106
|
+
|
|
107
|
+
⚑ **Style the controls.** Browser-default inputs are a white box in a system
|
|
108
|
+
font — on a dark or branded storefront that alone reads as unfinished:
|
|
109
|
+
|
|
110
|
+
```css
|
|
111
|
+
[data-part="control"], [data-part="input"] {
|
|
112
|
+
border: …; background: …; color: inherit; font: inherit; padding: …;
|
|
113
|
+
}
|
|
114
|
+
[data-part="control"]:focus-visible { outline: …; } /* keep a visible focus ring */
|
|
115
|
+
[data-part="field"][data-invalid] [data-part="control"] { border-color: …; }
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Then, in the store's own values: the labels (`[data-part="label"]` — size,
|
|
119
|
+
tracking, case), the option rows (`[data-part="option"]` — padding, border,
|
|
120
|
+
and the `[data-state="selected"]` treatment, which is the one control customers
|
|
121
|
+
look for), the totals (`[data-part="value"]`, `[data-emphasis]` for the total
|
|
122
|
+
line), the buttons (`[data-part="place-order"]`, `apply`, `remove`,
|
|
123
|
+
`increase`/`decrease`), and the notices (`[data-part="error"]`, `blocker`, `hint` —
|
|
124
|
+
`[data-severity="error"]` marks the loud ones). The drawer's own surface and
|
|
125
|
+
scrim are your elements, so they carry your classes, not `data-part` selectors.
|
|
126
|
+
|
|
127
|
+
**The parts add no outer margins** — space *between* sections comes from the
|
|
128
|
+
containers you wrap them in (`gap` on your checkout grid, your aside, your
|
|
129
|
+
drawer panel's column).
|
|
130
|
+
|
|
131
|
+
## Tuning the geometry
|
|
132
|
+
|
|
133
|
+
Set these anywhere — `:root`, a page, one part — instead of rewriting the rules:
|
|
134
|
+
|
|
135
|
+
| Custom property | Default | Controls |
|
|
136
|
+
|---|---|---|
|
|
137
|
+
| `--commerce-gap` | `1rem` | fields, rows, options, panel sections |
|
|
138
|
+
| `--commerce-gap-tight` | `0.4rem` | label→control, name→attributes, stepper |
|
|
139
|
+
| `--commerce-field-columns` | `2` | address-form columns (set `1` in a narrow aside or a media query) |
|
|
140
|
+
| `--commerce-media-size` | `4rem` | cart/summary thumbnail edge |
|
|
141
|
+
|
|
142
|
+
## `data-part` inventory
|
|
143
|
+
|
|
144
|
+
| Part root | Inner `data-part`s | State attributes |
|
|
145
|
+
|---|---|---|
|
|
146
|
+
| `address-fields`; `ship-to-different` | field · label · required · control · error | `data-which`, `data-key`, `data-span`, `data-invalid` |
|
|
147
|
+
| `shipping-methods` `payment-methods` | hint · option · option-input · option-label · option-cost / option-description · chosen | `data-state="selected"`, `data-syncing`, `data-severity` |
|
|
148
|
+
| `lines` `items`; `notices` | row · media · content · name · attributes · controls · stepper · increase · decrease · quantity · remove · line-total · error; notice | `data-pending`, `data-empty`, `data-code` |
|
|
149
|
+
| `totals`; `payment-instructions` | row · label · value; description · account | `data-key`, `data-emphasis` |
|
|
150
|
+
| `coupon-field` | input · apply · error · applied · code · remove | `data-busy` |
|
|
151
|
+
| `place-order` · `order-error` · `blockers` | blocker | `data-state="placing"`, `data-code` |
|
|
152
|
+
| `trigger` · `close` | — (the drawer's layer, scrim and panel are your elements) | `data-state="open\|closed"` |
|
|
153
|
+
|
|
154
|
+
`media` is an `<img>` when the line has an image and an empty `<div
|
|
155
|
+
data-part="media" data-empty>` when it doesn't — same box either way, so style
|
|
156
|
+
the placeholder (`[data-empty]`) rather than letting it render as a hole. ⚑ **A
|
|
157
|
+
cart, summary or receipt that shows no thumbnails is a `media={false}` prop, not
|
|
158
|
+
a CSS `display: none`** — the prop renders nothing at all, while hiding leaves an
|
|
159
|
+
empty box in the layout and the accessibility tree.
|
|
160
|
+
|
|
161
|
+
## Overrides — your element, the part's wiring
|
|
162
|
+
|
|
163
|
+
CSS covers how a control *looks*; these cover the cases where the **element
|
|
164
|
+
itself** is the design decision. Each one keeps the part's state, gating and
|
|
165
|
+
accessibility wiring and hands you what to render from:
|
|
166
|
+
|
|
167
|
+
| Prop | On | Receives | Replaces |
|
|
168
|
+
|---|---|---|---|
|
|
169
|
+
| `inputRender` | `AddressFields` | `{ field, id, value, onChange, onBlur, invalid, autoComplete, options }` | the input/select only (label, required mark and error stay) |
|
|
170
|
+
| `fieldRender` | `AddressFields` | the same, plus `label` | the whole labeled block |
|
|
171
|
+
| `controlRender` | `ShipToDifferent` | `{ checked, onChange, toggle }` | the checkbox — a switch, a segmented control, two buttons |
|
|
172
|
+
| `optionRender` | `ShippingMethods`, `PaymentMethods` | the decorated option (`title`, `costLabel`/`description`, `selected`, `select()`) | **the whole option element**, radio included |
|
|
173
|
+
| `stepperRender` | `Cart.Lines` | `(controls, item)` — the `useCartLine` set | the quantity control alone (a `<select>`, a number input, your buttons) |
|
|
174
|
+
| `inputRender` | `CouponField` | `{ value, onChange, onKeyDown, placeholder, disabled }` | the code field; apply button and messages stay |
|
|
175
|
+
| `lineRender` / `itemRender` | `Cart.Lines`, `Checkout.Items`, `OrderReceived.Items` | `(item, controls)` / `(item)` | a whole row |
|
|
176
|
+
|
|
177
|
+
⚑ **A replaced control still owes its semantics.** The kit's defaults are real
|
|
178
|
+
form controls; if yours are not, carry the equivalent — keyboard operability, a
|
|
179
|
+
readable state (`role="radio"` + `aria-checked`, `role="switch"`), and an
|
|
180
|
+
accessible name. `show` (above) is for *removing* an element; these are for
|
|
181
|
+
*replacing* it.
|
|
182
|
+
|
|
183
|
+
A whole section that needs different structure drops to its hook instead:
|
|
184
|
+
[`./storefront-custom.md`](./storefront-custom.md).
|
|
@@ -1,26 +1,45 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useId,
|
|
7
|
+
useLayoutEffect,
|
|
8
|
+
useMemo,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
} from "react";
|
|
2
12
|
import { useLocation } from "react-router-dom";
|
|
3
13
|
|
|
4
14
|
/**
|
|
5
|
-
* Cart drawer
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
15
|
+
* Cart drawer state and behavior, headless — **the drawer's surface is yours**:
|
|
16
|
+
* you render the overlay and the panel, and own their position, size, padding,
|
|
17
|
+
* animation and every class on them. What lives here is the part that is the
|
|
18
|
+
* same in every store and easy to get wrong:
|
|
19
|
+
*
|
|
20
|
+
* - `open` + `openCart`/`closeCart`/`toggleCart`.
|
|
21
|
+
* - Esc closes; the route changing closes (`closeOnNavigate`); an item landing
|
|
22
|
+
* in the cart opens (`openOnAdd`, wired through `useAddToCart` — pass false
|
|
23
|
+
* for a navigate-to-bag flow).
|
|
24
|
+
* - **Focus.** Attach `panelRef` to your panel element: focus moves into it
|
|
25
|
+
* when the drawer opens and returns to whatever had it when the drawer
|
|
26
|
+
* closes (give the panel `tabIndex={-1}` so it can receive it).
|
|
27
|
+
* - **A closed-but-mounted panel is made `inert`** while `panelRef` is
|
|
28
|
+
* attached — the classic drawer bug is a panel translated off-screen whose
|
|
29
|
+
* buttons stay clickable, tab-able and readable to screen readers. Animate
|
|
30
|
+
* the slide freely; the closed panel stops being interactive by itself.
|
|
31
|
+
* - `panelId` — put it on your panel as `id`; the kit's `<CartDrawer.Trigger>`
|
|
32
|
+
* already points `aria-controls` at it.
|
|
11
33
|
*
|
|
12
34
|
* Mount `<CartUIProvider>` once, inside `<StorefrontProvider>`, around the
|
|
13
|
-
* layout; the layout
|
|
35
|
+
* layout; the layout renders the drawer off `open`.
|
|
14
36
|
*
|
|
15
|
-
* ⚑
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* yours again — set the `inert` attribute while closed. The overlay is a
|
|
20
|
-
* click-away surface (`aria-hidden`, no tab stop) — it is not the close control;
|
|
21
|
-
* a named close button inside the panel is.
|
|
37
|
+
* ⚑ Your panel is the dialog: `role="dialog" aria-modal="true"` plus a name
|
|
38
|
+
* (`aria-label`/`aria-labelledby`). ⚑ The overlay is a click-away surface
|
|
39
|
+
* (`aria-hidden`, no tab stop) — it is *not* the close control; a named close
|
|
40
|
+
* button inside the panel is (`<CartDrawer.Close>`).
|
|
22
41
|
*
|
|
23
|
-
* `useCartUI()` → `{ open, openCart, closeCart, toggleCart }`.
|
|
42
|
+
* `useCartUI()` → `{ open, openCart, closeCart, toggleCart, panelRef, panelId }`.
|
|
24
43
|
* `useCartUIOptional()` returns null instead of throwing (how `useAddToCart`
|
|
25
44
|
* integrates without requiring the provider).
|
|
26
45
|
*
|
|
@@ -29,6 +48,9 @@ import { useLocation } from "react-router-dom";
|
|
|
29
48
|
|
|
30
49
|
const CartUIContext = createContext(null);
|
|
31
50
|
|
|
51
|
+
/** Layout effect where there is a DOM; plain effect on the server (no warning). */
|
|
52
|
+
const useDrawerEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
53
|
+
|
|
32
54
|
/** Internal: closes the drawer whenever the route changes (needs a Router above). */
|
|
33
55
|
function CloseOnNavigate({ close }) {
|
|
34
56
|
const { pathname } = useLocation();
|
|
@@ -52,6 +74,9 @@ export function CartUIProvider({ closeOnNavigate = true, openOnAdd = true, child
|
|
|
52
74
|
const openCart = useCallback(() => setOpen(true), []);
|
|
53
75
|
const closeCart = useCallback(() => setOpen(false), []);
|
|
54
76
|
const toggleCart = useCallback(() => setOpen((o) => !o), []);
|
|
77
|
+
const panelId = useId();
|
|
78
|
+
const panelRef = useRef(null);
|
|
79
|
+
const restoreRef = useRef(null);
|
|
55
80
|
|
|
56
81
|
// Esc closes — the keyboard's way out is Esc and the named close button.
|
|
57
82
|
useEffect(() => {
|
|
@@ -63,15 +88,36 @@ export function CartUIProvider({ closeOnNavigate = true, openOnAdd = true, child
|
|
|
63
88
|
return () => window.removeEventListener("keydown", onKey);
|
|
64
89
|
}, [open]);
|
|
65
90
|
|
|
91
|
+
// Focus into the store's panel on open, back where it came from on close, and
|
|
92
|
+
// a closed-but-mounted panel is inert — all on whatever element the store
|
|
93
|
+
// attached `panelRef` to, so the drawer's markup and animation stay theirs.
|
|
94
|
+
// Layout effect: before paint, so an animating panel is never briefly live.
|
|
95
|
+
useDrawerEffect(() => {
|
|
96
|
+
const node = panelRef.current;
|
|
97
|
+
if (open) {
|
|
98
|
+
restoreRef.current = typeof document !== "undefined" ? document.activeElement : null;
|
|
99
|
+
if (node) {
|
|
100
|
+
node.inert = false;
|
|
101
|
+
node.focus?.({ preventScroll: true });
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
if (node) node.inert = true;
|
|
105
|
+
restoreRef.current?.focus?.();
|
|
106
|
+
restoreRef.current = null;
|
|
107
|
+
}
|
|
108
|
+
}, [open]);
|
|
109
|
+
|
|
66
110
|
const value = useMemo(
|
|
67
111
|
() => ({
|
|
68
112
|
open,
|
|
69
113
|
openCart,
|
|
70
114
|
closeCart,
|
|
71
115
|
toggleCart,
|
|
116
|
+
panelRef,
|
|
117
|
+
panelId,
|
|
72
118
|
onItemAdded: openOnAdd ? openCart : null,
|
|
73
119
|
}),
|
|
74
|
-
[open, openCart, closeCart, toggleCart, openOnAdd],
|
|
120
|
+
[open, openCart, closeCart, toggleCart, panelId, openOnAdd],
|
|
75
121
|
);
|
|
76
122
|
|
|
77
123
|
return (
|
|
@@ -43,8 +43,10 @@
|
|
|
43
43
|
* gallery follows the variant selection without owning a second copy of it),
|
|
44
44
|
* `productRibbons` (ribbons are `{id, name}` objects, not strings — rendering
|
|
45
45
|
* one straight into JSX is React's "Objects are not valid as a React child"),
|
|
46
|
-
* `productSpecs` + `findSpec` (
|
|
47
|
-
*
|
|
46
|
+
* `productSpecs` + `findSpec` (spec rows carry an inferred render `type` so a
|
|
47
|
+
* weight reads as a figure and a composition as bars rather than every modifier
|
|
48
|
+
* as one grey table row; meta keys are free text, so featuring a particular
|
|
49
|
+
* spec needs a tolerant lookup, not an equality test),
|
|
48
50
|
* `attributesLabel`, `cartTotalsLines` / `orderTotalsLines`,
|
|
49
51
|
* `addressFieldSpec` (the checkout's field list, including the state/province
|
|
50
52
|
* field that silently mis-prices US/CA/AU orders when it is left out), and
|
|
@@ -87,7 +89,7 @@ export {
|
|
|
87
89
|
useCountries,
|
|
88
90
|
useCart,
|
|
89
91
|
} from "./StorefrontProvider";
|
|
90
|
-
export { useCheckout, CheckoutProvider, useCheckoutContext } from "./useCheckout";
|
|
92
|
+
export { useCheckout, CheckoutProvider, useCheckoutContext, useInCheckout } from "./useCheckout";
|
|
91
93
|
export { useOrderReturn, orderReceivedUrl } from "./useOrderReturn";
|
|
92
94
|
export { ShippingMethodPicker, PaymentMethodPicker } from "./pickers";
|
|
93
95
|
export { CartUIProvider, useCartUI } from "./cartUI";
|
|
@@ -3,6 +3,7 @@ import { attributesLabel } from "@/commerce/utils";
|
|
|
3
3
|
import { useCart, useFormatMoney } from "../StorefrontProvider";
|
|
4
4
|
import { CartLine } from "../useCartLine";
|
|
5
5
|
import { label, nameLabel, useResolvedLabels } from "./labels";
|
|
6
|
+
import { visible } from "./visibility";
|
|
6
7
|
import { CouponField, Totals } from "./shared";
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -34,10 +35,22 @@ const Ready = gate("Ready", "ready");
|
|
|
34
35
|
/**
|
|
35
36
|
* The line rows, with the interaction premade per row (optimistic quantity,
|
|
36
37
|
* coalesced clicks, rollback, per-row `pending` — never a cart-wide busy
|
|
37
|
-
* state).
|
|
38
|
-
*
|
|
38
|
+
* state).
|
|
39
|
+
*
|
|
40
|
+
* - `show` decides which of the row's elements render — `media`, `attributes`,
|
|
41
|
+
* `stepper`, `remove`, `lineTotal` (`show={{ media: false }}` is a text-only
|
|
42
|
+
* bag; `show={["media", "lineTotal"]}` is exactly those two). Nothing lands in
|
|
43
|
+
* the DOM for a hidden element, so it costs no layout and no screen-reader
|
|
44
|
+
* announcement. `stepper: false` still shows the quantity, as text.
|
|
45
|
+
* - `stepperRender(controls, item)` replaces the quantity control alone — a
|
|
46
|
+
* `<select>`, a number input, the store's own buttons — while the row keeps its
|
|
47
|
+
* layout (`controls` is the `useCartLine` set: `quantity`, `increase`,
|
|
48
|
+
* `decrease`, `setQuantity`, `canIncrease`, `canDecrease`, `pending`).
|
|
49
|
+
* - `lineRender(item, controls)` replaces a row wholesale; `item.slug` is there
|
|
50
|
+
* when the store's row links to the product page.
|
|
39
51
|
*/
|
|
40
|
-
function Lines({ lineRender, className, classes, labels: partLabels }) {
|
|
52
|
+
function Lines({ show, stepperRender, lineRender, className, classes, labels: partLabels }) {
|
|
53
|
+
const vis = visible(show);
|
|
41
54
|
const { status, cart } = useCart();
|
|
42
55
|
const formatMoney = useFormatMoney();
|
|
43
56
|
const L = useResolvedLabels(partLabels);
|
|
@@ -56,57 +69,68 @@ function Lines({ lineRender, className, classes, labels: partLabels }) {
|
|
|
56
69
|
aria-busy={l.pending}
|
|
57
70
|
className={classes?.row}
|
|
58
71
|
>
|
|
59
|
-
{
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
72
|
+
{vis("media") &&
|
|
73
|
+
(item.image ? (
|
|
74
|
+
<img data-part="media" className={classes?.media} src={item.image} alt="" loading="lazy" />
|
|
75
|
+
) : (
|
|
76
|
+
<div data-part="media" data-empty="" className={classes?.media} />
|
|
77
|
+
))}
|
|
64
78
|
<span data-part="content" className={classes?.content}>
|
|
65
79
|
<span data-part="name" className={classes?.name}>{item.name}</span>
|
|
66
|
-
{attributesLabel(item.attributes) ? (
|
|
80
|
+
{vis("attributes") && attributesLabel(item.attributes) ? (
|
|
67
81
|
<span data-part="attributes" className={classes?.attributes}>
|
|
68
82
|
{attributesLabel(item.attributes)}
|
|
69
83
|
</span>
|
|
70
84
|
) : null}
|
|
71
85
|
</span>
|
|
72
86
|
<span data-part="controls" className={classes?.controls}>
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
87
|
+
{vis("stepper") && stepperRender && stepperRender(l, item)}
|
|
88
|
+
{vis("stepper") && !stepperRender && (
|
|
89
|
+
<span data-part="stepper" className={classes?.stepper}>
|
|
90
|
+
<button
|
|
91
|
+
type="button"
|
|
92
|
+
data-part="decrease"
|
|
93
|
+
className={classes?.decrease}
|
|
94
|
+
onClick={l.decrease}
|
|
95
|
+
disabled={!l.canDecrease || l.pending}
|
|
96
|
+
aria-label={nameLabel(L, "aria.decrease", item.name)}
|
|
97
|
+
>
|
|
98
|
+
−
|
|
99
|
+
</button>
|
|
100
|
+
<span data-part="quantity" className={classes?.quantity}>{l.quantity}</span>
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
data-part="increase"
|
|
104
|
+
className={classes?.increase}
|
|
105
|
+
onClick={l.increase}
|
|
106
|
+
disabled={!l.canIncrease || l.pending}
|
|
107
|
+
aria-label={nameLabel(L, "aria.increase", item.name)}
|
|
108
|
+
>
|
|
109
|
+
+
|
|
110
|
+
</button>
|
|
111
|
+
</span>
|
|
112
|
+
)}
|
|
113
|
+
{!vis("stepper") && (
|
|
84
114
|
<span data-part="quantity" className={classes?.quantity}>{l.quantity}</span>
|
|
115
|
+
)}
|
|
116
|
+
{vis("remove") && (
|
|
85
117
|
<button
|
|
86
118
|
type="button"
|
|
87
|
-
data-part="
|
|
88
|
-
className={classes?.
|
|
89
|
-
onClick={l.
|
|
90
|
-
disabled={
|
|
91
|
-
aria-label={nameLabel(L, "aria.
|
|
119
|
+
data-part="remove"
|
|
120
|
+
className={classes?.remove}
|
|
121
|
+
onClick={l.remove}
|
|
122
|
+
disabled={l.pending}
|
|
123
|
+
aria-label={nameLabel(L, "aria.remove", item.name)}
|
|
92
124
|
>
|
|
93
|
-
|
|
125
|
+
×
|
|
94
126
|
</button>
|
|
95
|
-
|
|
96
|
-
<button
|
|
97
|
-
type="button"
|
|
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)}
|
|
103
|
-
>
|
|
104
|
-
×
|
|
105
|
-
</button>
|
|
106
|
-
</span>
|
|
107
|
-
<span data-part="line-total" className={classes?.["line-total"]}>
|
|
108
|
-
{formatMoney(item.total)}
|
|
127
|
+
)}
|
|
109
128
|
</span>
|
|
129
|
+
{vis("lineTotal") && (
|
|
130
|
+
<span data-part="line-total" className={classes?.["line-total"]}>
|
|
131
|
+
{formatMoney(item.total)}
|
|
132
|
+
</span>
|
|
133
|
+
)}
|
|
110
134
|
{l.error && (
|
|
111
135
|
<p data-part="error" role="alert" className={classes?.error}>{l.error.message}</p>
|
|
112
136
|
)}
|