@base44/app-plugin-commerce 0.9.4 → 0.10.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.
@@ -0,0 +1,154 @@
1
+ ---
2
+ read_when: "The user asks to change something on the shipped cart page, cart drawer, checkout, or order-received page — wording, layout, sections, styling beyond the theme block, an extra locale, or custom content inside them."
3
+ skip_when: "First install (stage 02's theme block + brand props are the whole job), or the change is on a page you built (list/product)."
4
+ ---
5
+
6
+ # storefront-ui — day-2 reference
7
+
8
+ `src/commerce/storefront-ui/` is **kit-owned**: a kit update re-copies the whole
9
+ directory, so direct edits to its files do not survive and are unsupported. Every
10
+ supported change goes through the surface below, in this order — most requests stop
11
+ at the first rung that fits:
12
+
13
+ 1. **Theme tokens** — colors, radius, heading font.
14
+ 2. **`brand` props** — any heading/CTA/empty-state wording.
15
+ 3. **`sections` / layout props** — show, hide, or require parts.
16
+ 4. **`slots`** — inject your own markup at the defined points.
17
+ 5. **CSS overrides in `index.css`** — restyle beyond tokens (below).
18
+ 6. **A new locale file** — the one in-directory edit that survives conceptually
19
+ (re-add it after a kit update; keep a copy in your notes).
20
+
21
+ If none of those can express the request, say so and ask the user before forking a
22
+ component: copy the file **out** of `storefront-ui/` into the app's own pages,
23
+ rename it, and own it from then on — never edit it in place.
24
+
25
+ ## Theme tokens — the full set
26
+
27
+ Set on `.sfui` in `index.css`. Colors are **pairs**; never override one side alone.
28
+ Unset tokens fall back to the app's shadcn variables (`--background`, `--primary`, …),
29
+ then to safe literals — an app with no block at all is already correct.
30
+
31
+ | pair / token | used for | default chain |
32
+ |---|---|---|
33
+ | `--sfui-surface` / `--sfui-on-surface` | page background context + primary text | `--background` / `--foreground` |
34
+ | `--sfui-subtle` / `--sfui-on-subtle` | panels, summary asides, secondary text | `--muted` / `--muted-foreground` |
35
+ | `--sfui-accent` / `--sfui-on-accent` | CTAs, selected choices, badge | `--primary` / `--primary-foreground` |
36
+ | `--sfui-danger` / `--sfui-on-danger` | errors, blockers | `--destructive` / `--destructive-foreground` |
37
+ | `--sfui-border` | hairlines, control borders | `--border` |
38
+ | `--sfui-radius` | panels/cards (controls derive ×0.6, or set `--sfui-radius-control`) | `--radius` → `0.75rem` |
39
+ | `--sfui-font-heading` | headings only — body text always inherits the site font | `inherit` |
40
+
41
+ **Contrast guard**: on mount each active pair is measured; a pair under 3:1 is reset
42
+ to its default (both sides) and a `console.warn` names it. So a "my colors aren't
43
+ applying" report usually means the pair failed contrast — fix the colors, don't
44
+ fight the guard.
45
+
46
+ ## Components & props
47
+
48
+ All props optional; the zero-prop render is complete.
49
+
50
+ ### `<CartPage />` — route it (usually `/cart`)
51
+
52
+ - `brand`: `title`, `summaryTitle`, `checkoutCta`, `continueShopping`,
53
+ `emptyTitle`, `emptyBody`, `emptyCta`, `note` (the small tax/shipping note).
54
+ - `sections`: `{ coupon: "auto"|true|false, notes: false, continueShopping: true, taxNote: true }`.
55
+ - `checkoutHref="/checkout"`, `continueHref="/"`,
56
+ `productHref={(item) => "/product/" + item.slug}` — line names link only when given.
57
+ - `slots`: `{ lineExtra({ item, line }), aboveSummary(), emptyState() }` —
58
+ `lineExtra` renders inside every row (gift-wrap toggle, availability note);
59
+ `aboveSummary` sits above the totals (trust badges, upsell rail); `emptyState`
60
+ replaces the designed empty state entirely.
61
+
62
+ ### `<MiniCart />` — mount ONCE in the layout, never on a route
63
+
64
+ - `brand`: `title`, `checkoutLabel`, `viewCartLabel`, `emptyTitle`, `emptyCta`.
65
+ - `side: "right"|"left"` (`"left"` reads better for RTL stores),
66
+ `checkoutHref="/checkout"`, `cartHref` (the "view cart" link renders only when
67
+ given — a store without a cart page just omits it), `productHref`.
68
+ - Open/close state is `<CartUIProvider>`'s: Esc, close-on-navigate and
69
+ open-on-add already work. Programmatic control is `useCartUI()`
70
+ (`openCart`/`closeCart`/`toggleCart`) — e.g. suppress open-on-add by rendering
71
+ the provider with its own options, not by patching MiniCart.
72
+
73
+ ### `<CartButton />` — optional header trigger
74
+
75
+ `className`, `label` (aria-label). It is only `useCartUI().toggleCart` +
76
+ `useCart().itemCount` around a bag icon — replacing it with the store's own button
77
+ on those two hooks is normal and supported.
78
+
79
+ ### `<CheckoutPage />` — route it (usually `/checkout`)
80
+
81
+ - `brand`: `title`, `contactTitle`, `shippingTitle`, `paymentTitle`,
82
+ `summaryTitle`, `submitLabel`, `termsLabel`, `emptyTitle`, `emptyCta`.
83
+ - `layout: "two-column"|"single"` — `"single"` stacks the summary under the form
84
+ (long forms, narrow brand sites; mobile always stacks).
85
+ - `sections`: `{ coupon: "auto", notes: false, phone: "optional"|"required"|"hidden",
86
+ shipToDifferent: true, termsCheckbox: false }` — `phone: "required"` marks the
87
+ field required in the address spec (the blocker wording follows for free);
88
+ `termsCheckbox: true` gates place-order on an accepted checkbox worded by
89
+ `brand.termsLabel`.
90
+ - `continueHref="/"` — where the empty state sends people.
91
+ - `onPlaced={(order) => …}` — replaces the default `/order-received` navigation.
92
+ Advanced: the default flow already handles the card redirect and the offline
93
+ receipt; use this only when the user explicitly wants a custom post-order flow,
94
+ and remember card payments still return to `/order-received`.
95
+ - Order notes persist in `sessionStorage` while the customer bounces between cart
96
+ and checkout, and submit as the order's `customer_note`.
97
+
98
+ ### `<OrderReceivedPage />` — route it at `/order-received` (mandatory)
99
+
100
+ - `brand`: `paidTitle`, `paidBody`, `unpaidTitle`, `unpaidBody`, `backToStore`.
101
+ - `homeHref="/"`.
102
+ - Renders all `useOrderReturn` states — paid, unpaid (payment instructions +
103
+ pay-now link), cancelled (retry link), error (retry) — and the order's lines and
104
+ totals. Must stay under `<StorefrontProvider>`.
105
+
106
+ ## Wording & locales
107
+
108
+ Functional labels live in `src/commerce/storefront-ui/i18n/locales/` —
109
+ `en de es fr ja pt`, flat keys namespaced `common.*`, `cart.*`, `minicart.*`,
110
+ `checkout.*` (incl. `checkout.hint.*` and `checkout.blocker.*` for every blocker
111
+ code), `order.*`. Switching among the six is ONE edit: repoint
112
+ `import active from "./locales/en.js"` in `i18n/index.js`.
113
+
114
+ - **Reword one label in the site's voice** → the matching `brand` prop
115
+ (precedence: brand prop → active locale → en). Never edit a locale file for a
116
+ brand-voice request.
117
+ - **A language outside the six** → copy `locales/en.js` to `<lang>.js`, translate
118
+ every value (keep keys and `{placeholders}` exact), repoint the import.
119
+ - **A label with no brand prop** (a blocker line, a validation hint) → that is
120
+ functional copy; if the user insists on custom wording, a new locale file copied
121
+ from the active one with that value changed is the supported route.
122
+
123
+ ## Restyling beyond tokens
124
+
125
+ The stylesheet is plain, low-specificity CSS scoped under `.sfui` with stable
126
+ class names (`.sfui-btn`, `.sfui-panel`, `.sfui-line`, `.sfui-choice`,
127
+ `.sfui-summary`, `.sfui-drawer`, `.sfui-cart-button`, `.sfui-cart-badge`, …).
128
+ Override them from the app's `index.css` — later in the cascade, so equal
129
+ specificity wins:
130
+
131
+ ```css
132
+ .sfui .sfui-btn { text-transform: uppercase; letter-spacing: 0.08em; }
133
+ .sfui .sfui-panel { box-shadow: 0 1px 0 rgb(0 0 0 / 6%); border: 0; }
134
+ ```
135
+
136
+ Keep overrides in `index.css` (they survive kit updates); never edit
137
+ `storefront-ui.css`. The address form inside checkout is `<AddressFields>` —
138
+ already styled; further styling targets its `[data-part]` attributes **on the
139
+ element itself** (`select[data-part="control"]`, never `[data-part="control"] input`).
140
+
141
+ ## Recipes
142
+
143
+ - **Hide the coupon field everywhere**: `sections={{ coupon: false }}` on both
144
+ `CartPage` and `CheckoutPage` (default `"auto"` shows it only when the store has
145
+ coupons — server-decided via `has_coupons`, so day-2 coupons created in the
146
+ admin surface it automatically).
147
+ - **No cart page, drawer only**: drop the `/cart` route, omit `cartHref` on
148
+ `MiniCart`, point nav links at `/checkout`.
149
+ - **Upsells in the cart**: `slots.aboveSummary` rendering rows from
150
+ `useProduct(...)`'s `upsells`/`crossSells` with `useCart().addItem` — one-click
151
+ Add only for products without attributes (variants answer `400 variation_required`).
152
+ - **B2B-ish checkout**: `sections={{ phone: "required", termsCheckbox: true, notes: true }}`.
153
+ - **Custom thank-you flow**: keep `/order-received` mounted (payment links return
154
+ there), add `onPlaced` only for the extra hop the user asked for.
@@ -8,10 +8,13 @@
8
8
  *
9
9
  * The split: **logic is premade, UI never is.** Checkout repricing, variant
10
10
  * resolution, cart state, order-return verification — done here, and
11
- * hand-rolling any of it is where storefront bugs cluster. What a checkout or a
12
- * product page *looks like* is the store's identity, and no two stores should
13
- * share it — so nothing here emits markup, and every state arrives as a **code**
11
+ * hand-rolling any of it is where storefront bugs cluster. What a catalog
12
+ * page *looks like* is the store's identity, and no two stores should share
13
+ * it — so nothing here emits markup, and every state arrives as a **code**
14
14
  * (`state`, `status`, `hint.code`, `blockers`) that you write the words for.
15
+ * The conversion surfaces (cart page, drawer, checkout, order received) ship
16
+ * finished on top of this layer in `@/commerce/storefront-ui` — mount those
17
+ * instead of building them from these hooks.
15
18
  *
16
19
  * `StorefrontProvider` mounts once, above every storefront route (see its own
17
20
  * doc comment — getting the mounting wrong is the one setup error worth
@@ -0,0 +1,33 @@
1
+ import React from "react";
2
+ import { useCart, useCartUI } from "@/commerce/storefront";
3
+ import { t } from "./i18n/index.js";
4
+
5
+ /**
6
+ * Optional header trigger for the MiniCart: a bag icon with a live item-count
7
+ * badge, wired to the cart drawer. The header itself stays the store's brand
8
+ * surface — use this for the wiring and restyle it via .sfui-cart-button /
9
+ * .sfui-cart-badge (or replace it with your own button calling
10
+ * `useCartUI().toggleCart` and rendering `useCart().itemCount`).
11
+ */
12
+ export function CartButton({ className = "", label }) {
13
+ const { toggleCart } = useCartUI();
14
+ const { itemCount } = useCart();
15
+ return (
16
+ <button
17
+ type="button"
18
+ className={`sfui sfui-cart-button ${className}`}
19
+ onClick={toggleCart}
20
+ aria-label={label ?? t("minicart.button")}
21
+ >
22
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
23
+ <path d="M6 7h12l-1 13H7L6 7Z" strokeLinejoin="round" />
24
+ <path d="M9 9V6a3 3 0 0 1 6 0v3" strokeLinecap="round" />
25
+ </svg>
26
+ {itemCount > 0 && (
27
+ <span className="sfui-cart-badge" aria-hidden="true">
28
+ {itemCount > 99 ? "99+" : itemCount}
29
+ </span>
30
+ )}
31
+ </button>
32
+ );
33
+ }
@@ -0,0 +1,140 @@
1
+ import React, { useState } from "react";
2
+ import { Link } from "react-router-dom";
3
+ import { useCart, useStoreInfo } from "@/commerce/storefront";
4
+ import { makeT } from "./i18n/index.js";
5
+ import {
6
+ CartLineRow,
7
+ CouponField,
8
+ ErrorState,
9
+ LoadingState,
10
+ SfuiRoot,
11
+ TotalsBlock,
12
+ couponVisible,
13
+ readOrderNote,
14
+ writeOrderNote,
15
+ } from "./internal.jsx";
16
+
17
+ const BRAND_KEYS = {
18
+ title: "cart.title",
19
+ summaryTitle: "cart.summaryTitle",
20
+ checkoutCta: "cart.checkoutCta",
21
+ continueShopping: "cart.continueShopping",
22
+ emptyTitle: "cart.empty.title",
23
+ emptyBody: "cart.empty.body",
24
+ emptyCta: "cart.empty.cta",
25
+ note: "cart.taxNote",
26
+ };
27
+
28
+ /**
29
+ * The routed cart page — complete: line rows with row-scoped quantity editing,
30
+ * the full totals breakdown, coupons, optional order notes, a designed empty
31
+ * state. Mount it on a route inside the store's layout; everything inside is
32
+ * shipped and tested.
33
+ *
34
+ * @param {object} props
35
+ * @param {Record<string,string>} [props.brand] wording overrides (title,
36
+ * summaryTitle, checkoutCta, continueShopping, emptyTitle, emptyBody,
37
+ * emptyCta, note) — the store's voice; labels beyond these are localized
38
+ * via ./i18n.
39
+ * @param {object} [props.sections] `{ coupon: "auto"|true|false, notes: false,
40
+ * continueShopping: true, taxNote: true }`
41
+ * @param {string} [props.checkoutHref="/checkout"]
42
+ * @param {string} [props.continueHref="/"]
43
+ * @param {(item) => string} [props.productHref] line names link to the product
44
+ * when given; plain text otherwise (never a guessed route).
45
+ * @param {object} [props.slots] `{ lineExtra?({item,line}), aboveSummary?(),
46
+ * emptyState?() }`
47
+ */
48
+ export function CartPage({
49
+ brand,
50
+ sections = {},
51
+ checkoutHref = "/checkout",
52
+ continueHref = "/",
53
+ productHref,
54
+ slots = {},
55
+ }) {
56
+ const tt = makeT(brand, BRAND_KEYS);
57
+ const cartApi = useCart();
58
+ const { info } = useStoreInfo();
59
+ const [note, setNote] = useState(readOrderNote);
60
+ const { status, cart } = cartApi;
61
+ const {
62
+ coupon = "auto",
63
+ notes = false,
64
+ continueShopping = true,
65
+ taxNote = true,
66
+ } = sections;
67
+
68
+ return (
69
+ <SfuiRoot className="sfui-cart" aria-labelledby="sfui-cart-title">
70
+ <div className="sfui-cart-inner">
71
+ <div>
72
+ <h1 id="sfui-cart-title" className="sfui-heading sfui-h1" style={{ marginBlockEnd: "1rem" }}>
73
+ {tt("cart.title")}
74
+ </h1>
75
+ {status === "loading" && <LoadingState />}
76
+ {status === "empty" &&
77
+ (slots.emptyState ? (
78
+ slots.emptyState()
79
+ ) : (
80
+ <div className="sfui-state">
81
+ <h2 className="sfui-heading sfui-h2">{tt("cart.empty.title")}</h2>
82
+ <p className="sfui-muted">{tt("cart.empty.body")}</p>
83
+ <Link className="sfui-btn sfui-btn-inline" to={continueHref}>
84
+ {tt("cart.empty.cta")}
85
+ </Link>
86
+ </div>
87
+ ))}
88
+ {status === "ready" && cartApi.error && <ErrorState onRetry={cartApi.refresh} />}
89
+ {status === "ready" && (
90
+ <ul className="sfui-lines">
91
+ {cart.items.map((item) => (
92
+ <CartLineRow
93
+ key={item.item_key}
94
+ item={item}
95
+ tt={tt}
96
+ productHref={productHref}
97
+ lineExtra={slots.lineExtra}
98
+ />
99
+ ))}
100
+ </ul>
101
+ )}
102
+ </div>
103
+
104
+ {status === "ready" && (
105
+ <aside className="sfui-panel sfui-summary" aria-label={tt("cart.summaryTitle")}>
106
+ <h2 className="sfui-heading sfui-h2">{tt("cart.summaryTitle")}</h2>
107
+ {slots.aboveSummary && slots.aboveSummary()}
108
+ {couponVisible(coupon, cart, info) && <CouponField tt={tt} />}
109
+ {notes && (
110
+ <div>
111
+ <label className="sfui-label" htmlFor="sfui-cart-note">
112
+ {tt("cart.notes.label")}
113
+ </label>
114
+ <textarea
115
+ id="sfui-cart-note"
116
+ value={note}
117
+ placeholder={tt("cart.notes.placeholder")}
118
+ onChange={(e) => {
119
+ setNote(e.target.value);
120
+ writeOrderNote(e.target.value);
121
+ }}
122
+ />
123
+ </div>
124
+ )}
125
+ <TotalsBlock cart={cart} tt={tt} />
126
+ {taxNote && <p className="sfui-note">{tt("cart.taxNote")}</p>}
127
+ <Link className="sfui-btn" to={checkoutHref}>
128
+ {tt("cart.checkoutCta")}
129
+ </Link>
130
+ {continueShopping && (
131
+ <Link className="sfui-btn sfui-btn-ghost" to={continueHref}>
132
+ {tt("cart.continueShopping")}
133
+ </Link>
134
+ )}
135
+ </aside>
136
+ )}
137
+ </div>
138
+ </SfuiRoot>
139
+ );
140
+ }
@@ -0,0 +1,287 @@
1
+ import React, { useState } from "react";
2
+ import { Link } from "react-router-dom";
3
+ import {
4
+ AddressFields,
5
+ CheckoutProvider,
6
+ PaymentMethodPicker,
7
+ ShippingMethodPicker,
8
+ useCheckoutContext,
9
+ useStoreInfo,
10
+ } from "@/commerce/storefront";
11
+ import { makeT } from "./i18n/index.js";
12
+ import {
13
+ CouponField,
14
+ LoadingState,
15
+ SfuiRoot,
16
+ TotalsBlock,
17
+ couponVisible,
18
+ readOrderNote,
19
+ writeOrderNote,
20
+ } from "./internal.jsx";
21
+
22
+ const BRAND_KEYS = {
23
+ title: "checkout.title",
24
+ contactTitle: "checkout.contactTitle",
25
+ shippingTitle: "checkout.shippingTitle",
26
+ paymentTitle: "checkout.paymentTitle",
27
+ summaryTitle: "checkout.summaryTitle",
28
+ submitLabel: "checkout.placeOrder",
29
+ termsLabel: "checkout.terms",
30
+ emptyTitle: "checkout.empty.title",
31
+ emptyCta: "checkout.empty.cta",
32
+ };
33
+
34
+ /**
35
+ * The complete checkout: contact + billing address (AddressFields), optional
36
+ * separate delivery address, shipping choice, payment choice, order summary,
37
+ * blockers, placeOrder — with the offline payment-instructions flow and the
38
+ * card payment-link redirect handled by useCheckout. Mount it on a route; a
39
+ * store customizes it through the theme tokens, `brand` wording and
40
+ * `sections`, never by editing this file.
41
+ *
42
+ * @param {object} props
43
+ * @param {Record<string,string>} [props.brand] wording overrides (title,
44
+ * contactTitle, shippingTitle, paymentTitle, summaryTitle, submitLabel,
45
+ * termsLabel, emptyTitle, emptyCta).
46
+ * @param {"two-column"|"single"} [props.layout="two-column"]
47
+ * @param {object} [props.sections] `{ coupon: "auto"|true|false, notes: false,
48
+ * phone: "optional"|"required"|"hidden", shipToDifferent: true,
49
+ * termsCheckbox: false }` — `phone: "required"` is enforced by marking the
50
+ * field required in the address spec via requiredBillingFields.
51
+ * @param {string} [props.continueHref="/"] where the empty state sends people.
52
+ * @param {(order) => void} [props.onPlaced] replaces the default
53
+ * order-received navigation (advanced; the default flow is complete).
54
+ */
55
+ export function CheckoutPage({
56
+ brand,
57
+ layout = "two-column",
58
+ sections = {},
59
+ continueHref = "/",
60
+ onPlaced,
61
+ }) {
62
+ const {
63
+ phone = "optional",
64
+ shipToDifferent: allowShipToDifferent = true,
65
+ } = sections;
66
+ const options = {
67
+ ...(onPlaced ? { orderReceivedPath: null } : {}),
68
+ ...(phone === "required"
69
+ ? { requiredBillingFields: ["first_name", "last_name", "address_1", "city", "country", "email", "phone"] }
70
+ : {}),
71
+ };
72
+ return (
73
+ <CheckoutProvider options={options}>
74
+ <CheckoutBody
75
+ brand={brand}
76
+ layout={layout}
77
+ sections={{ ...sections, phone, shipToDifferent: allowShipToDifferent }}
78
+ continueHref={continueHref}
79
+ onPlaced={onPlaced}
80
+ />
81
+ </CheckoutProvider>
82
+ );
83
+ }
84
+
85
+ function CheckoutBody({ brand, layout, sections, continueHref, onPlaced }) {
86
+ const tt = makeT(brand, BRAND_KEYS);
87
+ const checkout = useCheckoutContext();
88
+ const { info } = useStoreInfo();
89
+ const [note, setNote] = useState(readOrderNote);
90
+ const [termsAccepted, setTermsAccepted] = useState(false);
91
+ const { coupon = "auto", notes = false, phone, shipToDifferent, termsCheckbox = false } = sections;
92
+ const { cart, stage, blockers, canPlaceOrder, placing, orderError } = checkout;
93
+
94
+ const submit = async () => {
95
+ const extra = note.trim() ? { customer_note: note.trim() } : {};
96
+ const res = await checkout.placeOrder(extra);
97
+ if (res.ok) {
98
+ writeOrderNote("");
99
+ if (onPlaced) onPlaced(res.result);
100
+ }
101
+ };
102
+
103
+ // The submitted guard renders BEFORE the empty-cart branch: placeOrder clears
104
+ // the cart, so checking cart state first repaints "empty" over a just-placed
105
+ // order for the frames before the browser navigates away.
106
+ if (stage === "submitted") {
107
+ return (
108
+ <SfuiRoot className="sfui-checkout">
109
+ <div className="sfui-checkout-inner">
110
+ <LoadingState label={tt("checkout.submitted")} />
111
+ </div>
112
+ </SfuiRoot>
113
+ );
114
+ }
115
+
116
+ if (cart === null || (cart && !cart.items?.length)) {
117
+ return (
118
+ <SfuiRoot className="sfui-checkout">
119
+ <div className="sfui-checkout-inner">
120
+ <div className="sfui-state">
121
+ <h1 className="sfui-heading sfui-h1">{tt("checkout.empty.title")}</h1>
122
+ <Link className="sfui-btn sfui-btn-inline" to={continueHref}>
123
+ {tt("checkout.empty.cta")}
124
+ </Link>
125
+ </div>
126
+ </div>
127
+ </SfuiRoot>
128
+ );
129
+ }
130
+
131
+ const disabled = !canPlaceOrder || placing || (termsCheckbox && !termsAccepted);
132
+
133
+ return (
134
+ <SfuiRoot className="sfui-checkout" aria-labelledby="sfui-checkout-title">
135
+ <div className="sfui-checkout-inner" data-layout={layout}>
136
+ <div className="sfui-checkout-form">
137
+ <h1 id="sfui-checkout-title" className="sfui-heading sfui-h1">
138
+ {tt("checkout.title")}
139
+ </h1>
140
+
141
+ <section className="sfui-section sfui-address" aria-label={tt("checkout.contactTitle")}>
142
+ <h2 className="sfui-label">{tt("checkout.contactTitle")}</h2>
143
+ <AddressFields which="billing" includePhone={phone !== "hidden"} />
144
+ {allowsShipToDifferent(shipToDifferent) && (
145
+ <label className="sfui-checkbox">
146
+ <input
147
+ type="checkbox"
148
+ checked={checkout.shipToDifferent}
149
+ onChange={(e) => checkout.setShipToDifferent(e.target.checked)}
150
+ />
151
+ {tt("checkout.shipToDifferent")}
152
+ </label>
153
+ )}
154
+ {checkout.shipToDifferent && (
155
+ <>
156
+ <h2 className="sfui-label">{tt("checkout.shippingAddressTitle")}</h2>
157
+ <AddressFields which="shipping" includePhone={false} />
158
+ </>
159
+ )}
160
+ </section>
161
+
162
+ <ShippingMethodPicker>
163
+ {({ methods, mustChoose, single, chosen, hint }) => (
164
+ <section className="sfui-section" aria-label={tt("checkout.shippingTitle")}>
165
+ <h2 className="sfui-label">{tt("checkout.shippingTitle")}</h2>
166
+ {mustChoose && (
167
+ <div className="sfui-choices" role="radiogroup" aria-label={tt("checkout.shippingTitle")}>
168
+ {methods.map((m) => (
169
+ <label key={m.id} className="sfui-choice" data-selected={m.selected || undefined}>
170
+ <input type="radio" name="sfui-shipping" checked={m.selected} onChange={m.select} />
171
+ <span className="sfui-choice-main">
172
+ <span className="sfui-choice-title">{m.title}</span>
173
+ </span>
174
+ <span className="sfui-choice-cost">
175
+ {m.cost === 0 ? tt("common.free") : m.costLabel}
176
+ </span>
177
+ </label>
178
+ ))}
179
+ </div>
180
+ )}
181
+ {single && chosen && (
182
+ <div className="sfui-chosen-single">
183
+ <span>{chosen.title}</span>
184
+ <span>{chosen.cost === 0 ? tt("common.free") : chosen.costLabel}</span>
185
+ </div>
186
+ )}
187
+ {hint && (
188
+ <p
189
+ className={hint.severity === "error" ? "sfui-error" : "sfui-note"}
190
+ role={hint.severity === "error" ? "alert" : "status"}
191
+ >
192
+ {hint.serverMessage ?? tt(`checkout.hint.${hint.code}`)}
193
+ </p>
194
+ )}
195
+ </section>
196
+ )}
197
+ </ShippingMethodPicker>
198
+
199
+ <PaymentMethodPicker>
200
+ {({ gateways, mustChoose, single, selected, hint }) => (
201
+ <section className="sfui-section" aria-label={tt("checkout.paymentTitle")}>
202
+ <h2 className="sfui-label">{tt("checkout.paymentTitle")}</h2>
203
+ {mustChoose && (
204
+ <div className="sfui-choices" role="radiogroup" aria-label={tt("checkout.paymentTitle")}>
205
+ {gateways.map((g) => (
206
+ <label key={g.slug} className="sfui-choice" data-selected={g.selected || undefined}>
207
+ <input type="radio" name="sfui-payment" checked={g.selected} onChange={g.select} />
208
+ <span className="sfui-choice-main">
209
+ <span className="sfui-choice-title">{g.title}</span>
210
+ {g.description && <span className="sfui-choice-desc">{g.description}</span>}
211
+ </span>
212
+ </label>
213
+ ))}
214
+ </div>
215
+ )}
216
+ {single && selected && (
217
+ <div className="sfui-chosen-single">
218
+ <span className="sfui-choice-main">
219
+ <span className="sfui-choice-title">{selected.title}</span>
220
+ {selected.description && <span className="sfui-choice-desc">{selected.description}</span>}
221
+ </span>
222
+ </div>
223
+ )}
224
+ {hint && (
225
+ <p className="sfui-error" role="alert">
226
+ {tt("checkout.hint.payment_none")}
227
+ </p>
228
+ )}
229
+ </section>
230
+ )}
231
+ </PaymentMethodPicker>
232
+
233
+ {notes && (
234
+ <section className="sfui-section">
235
+ <label className="sfui-label" htmlFor="sfui-checkout-note">
236
+ {tt("checkout.notes.label")}
237
+ </label>
238
+ <textarea
239
+ id="sfui-checkout-note"
240
+ value={note}
241
+ placeholder={tt("checkout.notes.placeholder")}
242
+ onChange={(e) => {
243
+ setNote(e.target.value);
244
+ writeOrderNote(e.target.value);
245
+ }}
246
+ />
247
+ </section>
248
+ )}
249
+ </div>
250
+
251
+ <aside className="sfui-panel sfui-summary" aria-label={tt("checkout.summaryTitle")}>
252
+ <h2 className="sfui-heading sfui-h2">{tt("checkout.summaryTitle")}</h2>
253
+ {couponVisible(coupon, cart, info) && <CouponField tt={tt} />}
254
+ <TotalsBlock cart={cart} tt={tt} />
255
+ {termsCheckbox && (
256
+ <label className="sfui-checkbox">
257
+ <input type="checkbox" checked={termsAccepted} onChange={(e) => setTermsAccepted(e.target.checked)} />
258
+ {tt("checkout.terms")}
259
+ </label>
260
+ )}
261
+ <button type="button" className="sfui-btn" onClick={submit} disabled={disabled}>
262
+ {placing ? tt("checkout.placing") : tt("checkout.placeOrder")}
263
+ </button>
264
+ {/* A disabled button must say why: one line per blocker, localized. */}
265
+ {!canPlaceOrder && blockers.length > 0 && (
266
+ <ul className="sfui-blockers" aria-live="polite">
267
+ {blockers.map((code) => (
268
+ <li key={code}>{tt(`checkout.blocker.${code}`)}</li>
269
+ ))}
270
+ </ul>
271
+ )}
272
+ {orderError && (
273
+ <p className="sfui-error" role="alert">
274
+ {orderError.code === "card_payment_in_preview"
275
+ ? tt("checkout.error.card_payment_in_preview")
276
+ : orderError.message}
277
+ </p>
278
+ )}
279
+ </aside>
280
+ </div>
281
+ </SfuiRoot>
282
+ );
283
+ }
284
+
285
+ function allowsShipToDifferent(section) {
286
+ return section !== false;
287
+ }