@base44/app-plugin-commerce 0.3.4 → 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.
@@ -0,0 +1,140 @@
1
+ import React, { createContext, useContext, useMemo } from "react";
2
+
3
+ /**
4
+ * Labels plumbing for the parts layer. The parts ship NO customer-facing
5
+ * string and no English fallback: every word resolves through this chain —
6
+ * part `labels` prop → nearest scope (`Checkout.Root`, `OrderReceived.Root`) →
7
+ * `<StorefrontProvider labels={…}>` — and a missing key renders a visible
8
+ * `⟨copy: path⟩` placeholder plus one console warning. The full key list and
9
+ * a copyable English reference map live in the commerce skill's
10
+ * install/02-storefront.md; the store rewrites every value in its own voice.
11
+ */
12
+
13
+ /** Every label path the parts resolve. The docs' copy example must cover exactly these. */
14
+ export const REQUIRED_LABEL_KEYS = [
15
+ "placeOrder.label",
16
+ "placeOrder.placing",
17
+ "blockers.empty_cart",
18
+ "blockers.billing_incomplete",
19
+ "blockers.shipping_address_incomplete",
20
+ "blockers.shipping_address_required",
21
+ "blockers.shipping_method_required",
22
+ "blockers.shipping_not_available",
23
+ "blockers.payment_method_required",
24
+ "blockers.cart_loading",
25
+ "blockers.shipping_recalculating",
26
+ "shipping.missing_address",
27
+ "shipping.none_available",
28
+ "shipping.syncing",
29
+ "payment.none_available",
30
+ "coupon.placeholder",
31
+ "coupon.apply",
32
+ "coupon.remove",
33
+ "fields.first_name",
34
+ "fields.last_name",
35
+ "fields.email",
36
+ "fields.company",
37
+ "fields.address_1",
38
+ "fields.address_2",
39
+ "fields.country",
40
+ "fields.city",
41
+ "fields.state",
42
+ "fields.postcode",
43
+ "fields.phone",
44
+ "fields.select_placeholder",
45
+ "totals.subtotal",
46
+ "totals.discount",
47
+ "totals.shipping",
48
+ "totals.tax",
49
+ "totals.total",
50
+ "bank.account_name",
51
+ "bank.account_number",
52
+ "bank.bank_name",
53
+ "bank.sort_code",
54
+ "bank.iban",
55
+ "bank.bic",
56
+ "aria.trigger",
57
+ "aria.drawer",
58
+ "aria.close",
59
+ "aria.increase",
60
+ "aria.decrease",
61
+ "aria.remove",
62
+ "aria.shipping_group",
63
+ "aria.payment_group",
64
+ ];
65
+
66
+ const LabelsContext = createContext(null);
67
+
68
+ /** Two-level merge: `override`'s groups merge over `base`'s, key by key. */
69
+ export function mergeLabels(base, override) {
70
+ if (!base) return override ?? null;
71
+ if (!override) return base;
72
+ const out = { ...base };
73
+ for (const key of Object.keys(override)) {
74
+ const b = base[key];
75
+ const o = override[key];
76
+ out[key] =
77
+ b && o && typeof b === "object" && typeof o === "object" && !Array.isArray(b) && !Array.isArray(o)
78
+ ? { ...b, ...o }
79
+ : o;
80
+ }
81
+ return out;
82
+ }
83
+
84
+ /** Scope provider: merges `labels` over whatever is already in scope. */
85
+ export function LabelsScope({ labels, children }) {
86
+ const parent = useContext(LabelsContext);
87
+ const merged = useMemo(() => mergeLabels(parent, labels), [parent, labels]);
88
+ return <LabelsContext.Provider value={merged}>{children}</LabelsContext.Provider>;
89
+ }
90
+
91
+ /** The labels in scope, with a part-level override merged on top. */
92
+ export function useResolvedLabels(partLabels) {
93
+ const ctx = useContext(LabelsContext);
94
+ return useMemo(() => mergeLabels(ctx, partLabels), [ctx, partLabels]);
95
+ }
96
+
97
+ const warned = new Set();
98
+ function missing(path) {
99
+ if (!warned.has(path)) {
100
+ warned.add(path);
101
+ if (typeof console !== "undefined") {
102
+ console.warn(
103
+ `[commerce] Missing label "${path}" — the page renders a placeholder until the store's copy provides it (the key list and a reference map are in the commerce skill's install/02-storefront.md).`,
104
+ );
105
+ }
106
+ }
107
+ return `⟨copy: ${path}⟩`;
108
+ }
109
+
110
+ function walk(labels, path) {
111
+ let v = labels;
112
+ for (const key of path.split(".")) {
113
+ if (v == null || typeof v !== "object") return undefined;
114
+ v = v[key];
115
+ }
116
+ return v;
117
+ }
118
+
119
+ /** A plain label string, or the visible placeholder when unprovided. */
120
+ export function label(labels, path) {
121
+ const v = walk(labels, path);
122
+ return typeof v === "string" && v !== "" ? v : missing(path);
123
+ }
124
+
125
+ /**
126
+ * An accessible-name template resolved with the item's name — the stored
127
+ * value may be a function `(name) => string` or a string containing `{name}`.
128
+ */
129
+ export function nameLabel(labels, path, name) {
130
+ const v = walk(labels, path);
131
+ if (typeof v === "function") {
132
+ try {
133
+ return String(v(name));
134
+ } catch {
135
+ return missing(path);
136
+ }
137
+ }
138
+ if (typeof v === "string" && v !== "") return v.replace("{name}", String(name ?? ""));
139
+ return missing(path);
140
+ }
@@ -0,0 +1,188 @@
1
+ import React, { createContext, useContext } from "react";
2
+ import { orderTotalsLines } from "@/commerce/utils";
3
+ import { useFormatMoney } from "../StorefrontProvider";
4
+ import { useOrderReturn } from "../useOrderReturn";
5
+ import { LabelsScope, label, useResolvedLabels } from "./labels";
6
+ import { totalsLabels } from "./shared";
7
+
8
+ /**
9
+ * Order-received parts — the mandatory receipt route as placeable sections.
10
+ * Provider-agnostic by construction: everything renders from the return
11
+ * payload (`order`, `payment_link`, `payment_instructions`) — no provider is
12
+ * imported or named, so the stub, the Stripe file, any other card-payment
13
+ * implementation and the manual methods all behave identically. Contract,
14
+ * styling and labels: the commerce skill's install/02-storefront.md.
15
+ */
16
+
17
+ const OrderReturnContext = createContext(null);
18
+
19
+ /** Resolves `useOrderReturn()` once and scopes `labels`; the gates below branch on it. */
20
+ function Root({ labels, children }) {
21
+ const ret = useOrderReturn();
22
+ return (
23
+ <LabelsScope labels={labels}>
24
+ <OrderReturnContext.Provider value={ret}>{children}</OrderReturnContext.Provider>
25
+ </LabelsScope>
26
+ );
27
+ }
28
+
29
+ function useReturnCtx(name) {
30
+ const ctx = useContext(OrderReturnContext);
31
+ if (!ctx) throw new Error(`<OrderReceived.${name}> works inside <OrderReceived.Root> only.`);
32
+ return ctx;
33
+ }
34
+
35
+ /**
36
+ * Gates over the five return states. Render ALL five — a blank frame while
37
+ * "loading" or a wordless "error" strands a real customer. Children may be a
38
+ * node or a function of `{ order, paymentLink, paymentInstructions, error,
39
+ * reload }` (render `error.message`, retry via `reload()`).
40
+ */
41
+ function gate(name, match) {
42
+ function Gate({ children }) {
43
+ const ret = useReturnCtx(name);
44
+ if (ret.status !== match) return null;
45
+ return typeof children === "function"
46
+ ? (children({
47
+ order: ret.order ?? null,
48
+ paymentLink: ret.paymentLink ?? null,
49
+ paymentInstructions: ret.paymentInstructions ?? null,
50
+ error: ret.error ?? null,
51
+ reload: ret.reload,
52
+ }) ?? null)
53
+ : (children ?? null);
54
+ }
55
+ Gate.displayName = `OrderReceived.${name}`;
56
+ return Gate;
57
+ }
58
+
59
+ const Loading = gate("Loading", "loading");
60
+ const Paid = gate("Paid", "paid");
61
+ const Unpaid = gate("Unpaid", "unpaid");
62
+ const Cancelled = gate("Cancelled", "cancelled");
63
+ const ErrorState = gate("Error", "error");
64
+
65
+ /** The order's lines, already normalized (image is `{src, alt}|null`, money pre-formatted). */
66
+ function Items({ itemRender, className, classes }) {
67
+ const ret = useReturnCtx("Items");
68
+ const lines = ret.lines ?? [];
69
+ if (!lines.length) return null;
70
+ return (
71
+ <ul data-part="items" className={className}>
72
+ {lines.map((line, i) => (
73
+ <li key={`${line.sku || line.name}-${i}`} data-part="row" className={classes?.row}>
74
+ {itemRender ? (
75
+ itemRender(line)
76
+ ) : (
77
+ <>
78
+ {line.image ? (
79
+ <img data-part="media" className={classes?.media} src={line.image.src} alt={line.image.alt} loading="lazy" />
80
+ ) : (
81
+ <div data-part="media" data-empty="" className={classes?.media} />
82
+ )}
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>
90
+ <span data-part="line-total" className={classes?.["line-total"]}>{line.totalLabel}</span>
91
+ </>
92
+ )}
93
+ </li>
94
+ ))}
95
+ </ul>
96
+ );
97
+ }
98
+
99
+ /** The receipt's summary rows — an order's totals are FLAT, and this part owns that trap. */
100
+ function Totals({ pick, className, classes, labels: partLabels }) {
101
+ const ret = useReturnCtx("Totals");
102
+ const L = useResolvedLabels(partLabels);
103
+ const formatMoney = useFormatMoney();
104
+ if (!ret.order) return null;
105
+ const rows = orderTotalsLines(ret.order, { formatMoney, labels: totalsLabels(L) }).filter(
106
+ (r) => !r.hidden && (!pick || pick.includes(r.key)),
107
+ );
108
+ return (
109
+ <dl data-part="totals" className={className}>
110
+ {rows.map((r) => (
111
+ <div
112
+ key={r.key}
113
+ data-part="row"
114
+ data-key={r.key}
115
+ data-emphasis={r.emphasis || undefined}
116
+ className={classes?.row}
117
+ >
118
+ <dt data-part="label" className={classes?.label}>{r.label}</dt>
119
+ <dd data-part="value" className={classes?.value}>{r.formatted}</dd>
120
+ </div>
121
+ ))}
122
+ </dl>
123
+ );
124
+ }
125
+
126
+ const BANK_FIELDS = ["account_name", "account_number", "bank_name", "sort_code", "iban", "bic"];
127
+
128
+ /**
129
+ * How a manual/offline order gets paid — the admin's description plus each
130
+ * configured account's details. These ARE the store's default payment flow:
131
+ * an unpaid manual order's receipt must place this part. Renders null when
132
+ * the order carries no instructions (e.g. a card order).
133
+ */
134
+ function PaymentInstructions({ className, classes, labels: partLabels }) {
135
+ const ret = useReturnCtx("PaymentInstructions");
136
+ const L = useResolvedLabels(partLabels);
137
+ const pi = ret.paymentInstructions;
138
+ if (!pi) return null;
139
+ const accounts = Array.isArray(pi.account_details) ? pi.account_details : [];
140
+ return (
141
+ <div data-part="payment-instructions" className={className}>
142
+ {pi.description && (
143
+ <p data-part="description" className={classes?.description}>{pi.description}</p>
144
+ )}
145
+ {accounts.map((acc, i) => (
146
+ <dl key={i} data-part="account" className={classes?.account}>
147
+ {BANK_FIELDS.filter((k) => acc?.[k]).map((k) => (
148
+ <div key={k} data-part="row" data-key={k} className={classes?.row}>
149
+ <dt data-part="label" className={classes?.label}>{label(L, `bank.${k}`)}</dt>
150
+ <dd data-part="value" className={classes?.value}>{acc[k]}</dd>
151
+ </div>
152
+ ))}
153
+ </dl>
154
+ ))}
155
+ </div>
156
+ );
157
+ }
158
+
159
+ /**
160
+ * The navigation-wrapper pattern: renders its function child ONLY when the
161
+ * order has a live payment link (unpaid/cancelled card orders) — the element
162
+ * is entirely the store's: `{({ url }) => <a href={url}>…</a>}` or whatever
163
+ * the store navigates with. The kit never renders the link itself.
164
+ */
165
+ function PaymentLink({ children }) {
166
+ const ret = useReturnCtx("PaymentLink");
167
+ const url = ret.paymentLink?.url;
168
+ if (!url) return null;
169
+ if (typeof children !== "function") {
170
+ throw new Error(
171
+ "<OrderReceived.PaymentLink> takes a function child: ({ url }) => your own element — the kit never renders navigation.",
172
+ );
173
+ }
174
+ return <>{children({ url })}</>;
175
+ }
176
+
177
+ export const OrderReceived = {
178
+ Root,
179
+ Loading,
180
+ Paid,
181
+ Unpaid,
182
+ Cancelled,
183
+ Error: ErrorState,
184
+ Items,
185
+ Totals,
186
+ PaymentInstructions,
187
+ PaymentLink,
188
+ };
@@ -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
+ }
@@ -0,0 +1,127 @@
1
+ import React, { useState } from "react";
2
+ import { cartTotalsLines } from "@/commerce/utils";
3
+ import { useCart, useFormatMoney } from "../StorefrontProvider";
4
+ import { label, useResolvedLabels } from "./labels";
5
+
6
+ /**
7
+ * Parts shared by the cart and the checkout (exported as `Cart.CouponField` /
8
+ * `Checkout.CouponField` and `Cart.Totals` / `Checkout.Totals` — one
9
+ * component each, both read the ONE shared cart). Contract, styling and
10
+ * labels: the commerce skill's install/02-storefront.md.
11
+ */
12
+
13
+ /**
14
+ * The coupon input + apply + applied-codes row. A store with any coupons must
15
+ * place this once (cart or checkout) or its codes can never be redeemed. An
16
+ * invalid code renders the server's own message inline. Enter applies (no
17
+ * <form> is rendered, so it nests safely inside one).
18
+ */
19
+ export function CouponField({ className, classes, labels: partLabels }) {
20
+ const L = useResolvedLabels(partLabels);
21
+ const { cart, applyCoupon, removeCoupon } = useCart();
22
+ const [code, setCode] = useState("");
23
+ const [busy, setBusy] = useState(false);
24
+ const [error, setError] = useState(null);
25
+
26
+ const submit = async () => {
27
+ const value = code.trim();
28
+ if (!value || busy) return;
29
+ setBusy(true);
30
+ setError(null);
31
+ const res = await applyCoupon(value); // resolves {ok:false} — never throws
32
+ if (res.ok) setCode("");
33
+ else setError(res);
34
+ setBusy(false);
35
+ };
36
+
37
+ const applied = cart?.coupon_codes ?? [];
38
+ return (
39
+ <div data-part="coupon-field" data-busy={busy || undefined} className={className}>
40
+ <input
41
+ data-part="input"
42
+ className={classes?.input}
43
+ value={code}
44
+ placeholder={label(L, "coupon.placeholder")}
45
+ aria-label={label(L, "coupon.placeholder")}
46
+ disabled={busy}
47
+ onChange={(e) => setCode(e.target.value)}
48
+ onKeyDown={(e) => {
49
+ if (e.key === "Enter") {
50
+ e.preventDefault();
51
+ submit();
52
+ }
53
+ }}
54
+ />
55
+ <button
56
+ type="button"
57
+ data-part="apply"
58
+ className={classes?.apply}
59
+ onClick={submit}
60
+ disabled={busy || !code.trim()}
61
+ >
62
+ {label(L, "coupon.apply")}
63
+ </button>
64
+ {error && (
65
+ <p data-part="error" role="alert" className={classes?.error}>
66
+ {error.message}
67
+ </p>
68
+ )}
69
+ {applied.map((c) => (
70
+ <span key={c} data-part="applied" className={classes?.applied}>
71
+ <span data-part="code" className={classes?.code}>{c}</span>
72
+ <button
73
+ type="button"
74
+ data-part="remove"
75
+ className={classes?.remove}
76
+ onClick={() => removeCoupon(c).catch(() => {})}
77
+ >
78
+ {label(L, "coupon.remove")}
79
+ </button>
80
+ </span>
81
+ ))}
82
+ </div>
83
+ );
84
+ }
85
+
86
+ /**
87
+ * The cart's summary lines — every non-hidden row from `cartTotalsLines`
88
+ * (discount and tax included), `data-emphasis` on the total. `pick` narrows to
89
+ * a subset of row keys (`["subtotal"]` for a drawer footer). Row names come
90
+ * from `labels.totals`; amounts stay the engine's.
91
+ */
92
+ export function Totals({ pick, className, classes, labels: partLabels }) {
93
+ const L = useResolvedLabels(partLabels);
94
+ const { cart } = useCart();
95
+ const formatMoney = useFormatMoney();
96
+ if (!cart) return null;
97
+ const rows = cartTotalsLines(cart, { formatMoney, labels: totalsLabels(L) }).filter(
98
+ (r) => !r.hidden && (!pick || pick.includes(r.key)),
99
+ );
100
+ return (
101
+ <dl data-part="totals" className={className}>
102
+ {rows.map((r) => (
103
+ <div
104
+ key={r.key}
105
+ data-part="row"
106
+ data-key={r.key}
107
+ data-emphasis={r.emphasis || undefined}
108
+ className={classes?.row}
109
+ >
110
+ <dt data-part="label" className={classes?.label}>{r.label}</dt>
111
+ <dd data-part="value" className={classes?.value}>{r.formatted}</dd>
112
+ </div>
113
+ ))}
114
+ </dl>
115
+ );
116
+ }
117
+
118
+ /** The five static row names, resolved through the labels chain (never the utils' defaults). */
119
+ export function totalsLabels(L) {
120
+ return {
121
+ subtotal: label(L, "totals.subtotal"),
122
+ discount: label(L, "totals.discount"),
123
+ shipping: label(L, "totals.shipping"),
124
+ tax: label(L, "totals.tax"),
125
+ total: label(L, "totals.total"),
126
+ };
127
+ }