@base44/app-plugin-commerce 0.5.0 → 0.6.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.
@@ -1,150 +0,0 @@
1
- ---
2
- read_when: "A cart row, checkout section or receipt region needs structure the parts' props can't express, and you are about to build it on the raw hooks."
3
- ---
4
-
5
- # Custom sections on the raw hooks
6
-
7
- The parts (`Cart.*`, `CartDrawer.*`, `Checkout.*`, `OrderReceived.*`) are thin
8
- compositions over the hooks below — same providers, same shared state — so
9
- **replacing one section while keeping the others is normal**: a bespoke
10
- shipping selector between `<Checkout.AddressFields>` and
11
- `<Checkout.PaymentMethods>` reads the same checkout the parts do.
12
-
13
- Work down this ladder; each rung is cheaper than the next:
14
-
15
- 1. **`classes` / CSS** on `data-part` — almost every "the part looks wrong" is this rung.
16
- 2. **Labels** — a wording problem is a copy-file problem, never a code problem.
17
- 3. **`…Render` overrides** — `optionRender`, `lineRender`, `itemRender`, `inputRender`/`fieldRender`: your element, the part's wiring.
18
- 4. **This file** — rebuild one section on its hook.
19
- 5. **Edit the part's source** (`src/commerce/storefront/parts/`) — legitimate when a *user requirement* exceeds the props; the edited part is the store's code to maintain.
20
-
21
- Never re-implement what a hook does (quantity clamps, totals math, repricing,
22
- drawer state) — that is where storefront bugs cluster. And keep the parts'
23
- guarantees when you replace them: every state worded, busy scoped to the row,
24
- a disabled button that says why.
25
-
26
- ## What the custom-section hooks resolve to
27
-
28
- | Call | Resolves to |
29
- |---|---|
30
- | `useCartLine(item)` | `{ quantity, setQuantity, increase, decrease, remove, pending, error, canIncrease, canDecrease, maxQuantity, atMax, atMin }` — optimistic quantity, clicks coalesced (~250ms), rollback on rejection. `pending` is **this row's** flag: true while its request is out, false only after the new cart view lands — the only mutation-settled signal (`useCart().status` never returns to `"loading"`). |
31
- | `useCartUI()` | `{ open, openCart, closeCart, toggleCart, panelRef, panelId }` — drawer state and behavior on `<CartUIProvider>` (Esc, open-on-add, close-on-navigate; `panelRef` moves focus in/out and makes a closed-but-mounted panel `inert`). The drawer's overlay and panel are your markup. |
32
- | `useCheckoutContext()` | the address (`billing`, `updateBilling`, `shipping`, `updateShipping`, `shipToDifferent`, `setShipToDifferent`, `missingBillingFields`, `addressError`), the choices (`shippingStatus`, `shippingMethods`, `chosenShippingMethod`, `chooseShippingMethod`, `paymentMethods`, `paymentMethod`, `setPaymentMethod`, `selectedGateway`), and the gate (`blockers`, `canPlaceOrder`, `placing`, `stage`, `orderError`, `placeOrder`) — plus `cart`. Works under `<Checkout.Root>` (or your own `<CheckoutProvider>`). |
33
- | `useOrderReturn()` | `{ status, order, lines, paymentLink, paymentInstructions, error, reload }` — `status`: `"loading" \| "paid" \| "unpaid" \| "cancelled" \| "error"`; `lines` normalized (`image` is `{src, alt}\|null`, `totalLabel` pre-formatted); an order's totals are **flat** (`order.total`, no `order.totals`). |
34
- | `cartTotalsLines(cart, { formatMoney, labels })` / `orderTotalsLines(order, …)` | `[{ key, label, amount, formatted, hidden, emphasis }]` — keys `subtotal` `discount` `shipping` `tax` `total`; discount/tax are produced and flagged `hidden` when zero, so a mapping renderer can't drop them. |
35
- | `addressFieldSpec({ countries, country, required, includeEmail })` | `[{ key, label, type, required, autoComplete, options, colSpan }]` — the state field appears (as a select where the country carries states) once `country` is passed. |
36
- | `ShippingMethodPicker` / `PaymentMethodPicker` | render-prop components the parts wrap — `children({ status, methods, chosen, mustChoose, single, syncing, hint, … })` / `children({ gateways, selected, single, hint })`; every option decorated with `selected`, `select()`, `costLabel`. |
37
-
38
- ## Reference wiring
39
-
40
- Words below come from the same copy file the parts read (`STORE_COPY`) — a
41
- custom section never hardcodes them.
42
-
43
- **A custom cart row** — `<CartLine>` binds `useCartLine` per row without a
44
- hook-in-a-loop:
45
-
46
- ```jsx
47
- {cart.items.map((item) => (
48
- <CartLine key={item.item_key} line={item}>
49
- {(l) => (
50
- <li aria-busy={l.pending}> {/* busy scope is THIS row, never the cart */}
51
- {item.name} {attributesLabel(item.attributes)} {formatMoney(item.total)}
52
- <button onClick={l.decrease} disabled={!l.canDecrease || l.pending}
53
- aria-label={STORE_COPY.aria.decrease(item.name)}>−</button>
54
- {l.quantity}
55
- <button onClick={l.increase} disabled={!l.canIncrease || l.pending}
56
- aria-label={STORE_COPY.aria.increase(item.name)}>+</button>
57
- <button onClick={l.remove} disabled={l.pending}
58
- aria-label={STORE_COPY.aria.remove(item.name)}>×</button>
59
- {l.error && <p role="alert">{l.error.message}</p>}
60
- </li>
61
- )}
62
- </CartLine>
63
- ))}
64
- ```
65
-
66
- Give repeated controls unique accessible names (the item's name in the label)
67
- — three identical "Remove" buttons are ambiguous to a screen reader and to a
68
- script driving the page.
69
-
70
- **A custom shipping step** — the picker encodes the branching a checkout must
71
- not skip:
72
-
73
- ```jsx
74
- <ShippingMethodPicker>
75
- {({ hint, mustChoose, methods, chosen }) => (
76
- <fieldset>{/* renders null for a virtual cart */}
77
- {hint && <p role={hint.severity === "error" ? "alert" : "status"}>
78
- {hint.serverMessage ?? STORE_COPY.shipping[hint.code]}</p>}
79
- {mustChoose && methods.map((m) => (
80
- <label key={m.id}>
81
- <input type="radio" name="ship" checked={m.selected} onChange={m.select} />
82
- {m.title} {m.costLabel}
83
- </label>
84
- ))}
85
- {!mustChoose && chosen && <p>{chosen.title} {chosen.costLabel}</p>}
86
- </fieldset>
87
- )}
88
- </ShippingMethodPicker>
89
- ```
90
-
91
- Never render `cart.chosen_shipping_method` directly — it is a rate id; `chosen`
92
- arrives resolved. A single option still *shows* what it is, never a picker of
93
- one. `PaymentMethodPicker` is the same shape over `gateways` (titles and
94
- descriptions are the admin's copy — render them, don't invent your own).
95
-
96
- **A custom address form** — bind the spec, never a hand-typed field list:
97
-
98
- ```jsx
99
- function AddressFields({ which }) {
100
- const c = useCheckoutContext();
101
- const { countries } = useCountries(); // [] until store info lands — never null
102
- const isBilling = which === "billing";
103
- const values = isBilling ? c.billing : c.shipping;
104
- const set = isBilling ? c.updateBilling : c.updateShipping;
105
- const fields = addressFieldSpec({
106
- countries, country: values.country,
107
- required: isBilling ? REQUIRED_BILLING_FIELDS : ["country", "city"],
108
- includeEmail: isBilling, // one email per order, on billing
109
- });
110
- return fields.map((f) => (
111
- <div key={f.key}>
112
- <label htmlFor={`${which}-${f.key}`}>{STORE_COPY.fields[f.key]}{f.required && " *"}</label>
113
- {f.type === "select" ? (
114
- <select id={`${which}-${f.key}`} value={values[f.key] ?? ""} autoComplete={f.autoComplete}
115
- onChange={(e) => set({ [f.key]: e.target.value })}>
116
- <option value="">{STORE_COPY.fields.select_placeholder}</option>
117
- {f.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
118
- </select>
119
- ) : (
120
- <input id={`${which}-${f.key}`} type={f.type} value={values[f.key] ?? ""}
121
- autoComplete={f.autoComplete} onChange={(e) => set({ [f.key]: e.target.value })} />
122
- )}
123
- {/* "we don't ship there" is an address-level error — it belongs on country */}
124
- {f.key === "country" && c.addressError && <span role="alert">{c.addressError.message}</span>}
125
- </div>
126
- ));
127
- }
128
- ```
129
-
130
- ⚑ Passing `country` is what makes the state/province field appear — rates and
131
- taxes match on country *plus* state, so a form without it silently mis-prices
132
- US/CA/AU orders. ⚑ Keep every `autoComplete` token — it is what makes browser
133
- autofill work.
134
-
135
- **A custom place-order region** — the gate explains itself:
136
-
137
- ```jsx
138
- <button type="button" onClick={c.placeOrder} disabled={!c.canPlaceOrder || c.placing}>
139
- {c.placing ? STORE_COPY.placeOrder.placing : STORE_COPY.placeOrder.label}
140
- </button>
141
- {c.orderError && <p role="alert">{c.orderError.message}</p>}
142
- {!c.canPlaceOrder && c.blockers.map((code) => <p key={code}>{STORE_COPY.blockers[code]}</p>)}
143
- ```
144
-
145
- `placeOrder` is safe as a direct `onClick` handler (an event is not read as
146
- order fields) and resolves `{ ok, result }` / `{ ok: false, error }` without
147
- throwing. ⚑ Keep the `stage === "submitted"` guard above any empty-cart branch
148
- — placing an order clears the cart, and without the guard the page flashes
149
- "your bag is empty" over a just-placed order (`Checkout.Root`'s gates encode
150
- this ordering; a fully hand-built page must re-encode it).
@@ -1,184 +0,0 @@
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,191 +0,0 @@
1
- import React from "react";
2
- import { attributesLabel } from "@/commerce/utils";
3
- import { useCart, useFormatMoney } from "../StorefrontProvider";
4
- import { CartLine } from "../useCartLine";
5
- import { label, nameLabel, useResolvedLabels } from "./labels";
6
- import { visible } from "./visibility";
7
- import { CouponField, Totals } from "./shared";
8
-
9
- /**
10
- * Cart parts — the bag's sections, unstyled and unworded, for a cart page or
11
- * a drawer's contents alike. Contract, styling (`data-part`), labels and
12
- * placement: the commerce skill's install/02-storefront.md. These read the
13
- * ONE shared cart on `<StorefrontProvider>`; there is no `Cart.Root`.
14
- *
15
- * `Loading` / `Empty` / `Ready` are gates: children render only in the
16
- * matching cart status, so branching on emptiness-while-loading cannot be
17
- * written. Children may be a node or a function of `{ cart, itemCount }`.
18
- * The checkout link belongs in the store's own markup inside `<Cart.Ready>` —
19
- * no part renders navigation.
20
- */
21
- function gate(name, match) {
22
- function Gate({ children }) {
23
- const { status, cart, itemCount } = useCart();
24
- if (status !== match) return null;
25
- return typeof children === "function" ? (children({ cart, itemCount }) ?? null) : (children ?? null);
26
- }
27
- Gate.displayName = `Cart.${name}`;
28
- return Gate;
29
- }
30
-
31
- const Loading = gate("Loading", "loading");
32
- const Empty = gate("Empty", "empty");
33
- const Ready = gate("Ready", "ready");
34
-
35
- /**
36
- * The line rows, with the interaction premade per row (optimistic quantity,
37
- * coalesced clicks, rollback, per-row `pending` — never a cart-wide busy
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.
51
- */
52
- function Lines({ show, stepperRender, lineRender, className, classes, labels: partLabels }) {
53
- const vis = visible(show);
54
- const { status, cart } = useCart();
55
- const formatMoney = useFormatMoney();
56
- const L = useResolvedLabels(partLabels);
57
- if (status !== "ready") return null;
58
- return (
59
- <ul data-part="lines" className={className}>
60
- {cart.items.map((item) => (
61
- <CartLine key={item.item_key} line={item}>
62
- {(l) =>
63
- lineRender ? (
64
- lineRender(item, l)
65
- ) : (
66
- <li
67
- data-part="row"
68
- data-pending={l.pending || undefined}
69
- aria-busy={l.pending}
70
- className={classes?.row}
71
- >
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
- ))}
78
- <span data-part="content" className={classes?.content}>
79
- <span data-part="name" className={classes?.name}>{item.name}</span>
80
- {vis("attributes") && attributesLabel(item.attributes) ? (
81
- <span data-part="attributes" className={classes?.attributes}>
82
- {attributesLabel(item.attributes)}
83
- </span>
84
- ) : null}
85
- </span>
86
- <span data-part="controls" className={classes?.controls}>
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") && (
114
- <span data-part="quantity" className={classes?.quantity}>{l.quantity}</span>
115
- )}
116
- {vis("remove") && (
117
- <button
118
- type="button"
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)}
124
- >
125
- ×
126
- </button>
127
- )}
128
- </span>
129
- {vis("lineTotal") && (
130
- <span data-part="line-total" className={classes?.["line-total"]}>
131
- {formatMoney(item.total)}
132
- </span>
133
- )}
134
- {l.error && (
135
- <p data-part="error" role="alert" className={classes?.error}>{l.error.message}</p>
136
- )}
137
- {item.purchasable && !item.purchasable.ok && (
138
- <p data-part="error" data-code={item.purchasable.code} role="alert" className={classes?.error}>
139
- {item.purchasable.error}
140
- </p>
141
- )}
142
- </li>
143
- )
144
- }
145
- </CartLine>
146
- ))}
147
- </ul>
148
- );
149
- }
150
-
151
- /**
152
- * What auto-dropped and why, in the server's own words — expired coupons
153
- * (`coupon_notices`) and vanished products (`removed_items`). Renders null
154
- * when there is nothing to say; place it above the lines so a disappearing
155
- * row never goes unexplained.
156
- */
157
- function Notices({ className, classes }) {
158
- const { cart } = useCart();
159
- const notices = [
160
- ...(cart?.coupon_notices ?? []).map((n, i) => ({
161
- key: `coupon-${n.code ?? i}`,
162
- code: n.error_code,
163
- text: n.error,
164
- })),
165
- ...(cart?.removed_items ?? []).map((r, i) => ({
166
- key: `removed-${r.item_key ?? i}`,
167
- code: r.code,
168
- text: r.reason,
169
- })),
170
- ];
171
- if (!notices.length) return null;
172
- return (
173
- <ul data-part="notices" role="status" className={className}>
174
- {notices.map((n) => (
175
- <li key={n.key} data-part="notice" data-code={n.code} className={classes?.notice}>
176
- {n.text}
177
- </li>
178
- ))}
179
- </ul>
180
- );
181
- }
182
-
183
- export const Cart = {
184
- Loading,
185
- Empty,
186
- Ready,
187
- Lines,
188
- Notices,
189
- CouponField,
190
- Totals,
191
- };