@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.
- package/README.md +6 -3
- package/base44/functions/commerce/storefront-catalog/entry.ts +5 -0
- package/package.json +1 -1
- package/scripts/install.js +3 -0
- package/skills/commerce/SKILL.md +36 -39
- package/skills/commerce/docs/api-storefront.md +1 -1
- package/skills/commerce/installation/install.md +76 -165
- package/skills/commerce/references/admin-localization.md +6 -4
- package/skills/commerce/references/storefront-ui.md +154 -0
- package/src/commerce/storefront/index.js +6 -3
- package/src/commerce/storefront-ui/CartButton.jsx +33 -0
- package/src/commerce/storefront-ui/CartPage.jsx +140 -0
- package/src/commerce/storefront-ui/CheckoutPage.jsx +287 -0
- package/src/commerce/storefront-ui/MiniCart.jsx +128 -0
- package/src/commerce/storefront-ui/OrderReceivedPage.jsx +159 -0
- package/src/commerce/storefront-ui/i18n/index.js +51 -0
- package/src/commerce/storefront-ui/i18n/locales/de.js +89 -0
- package/src/commerce/storefront-ui/i18n/locales/en.js +100 -0
- package/src/commerce/storefront-ui/i18n/locales/es.js +89 -0
- package/src/commerce/storefront-ui/i18n/locales/fr.js +89 -0
- package/src/commerce/storefront-ui/i18n/locales/ja.js +89 -0
- package/src/commerce/storefront-ui/i18n/locales/pt.js +89 -0
- package/src/commerce/storefront-ui/index.js +16 -0
- package/src/commerce/storefront-ui/internal.jsx +218 -0
- package/src/commerce/storefront-ui/storefront-ui.css +448 -0
- package/src/commerce/storefront-ui/theme.js +76 -0
|
@@ -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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
+
}
|