@base44/app-plugin-commerce 0.9.5 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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 +61 -154
- package/skills/commerce/references/admin-localization.md +6 -4
- package/skills/commerce/references/storefront-ui.md +155 -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 +304 -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 +102 -0
- package/src/commerce/storefront-ui/i18n/locales/en.js +113 -0
- package/src/commerce/storefront-ui/i18n/locales/es.js +102 -0
- package/src/commerce/storefront-ui/i18n/locales/fr.js +102 -0
- package/src/commerce/storefront-ui/i18n/locales/ja.js +102 -0
- package/src/commerce/storefront-ui/i18n/locales/pt.js +102 -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 +452 -0
- package/src/commerce/storefront-ui/theme.js +76 -0
- package/src/commerce/utils/price.js +5 -3
|
@@ -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,304 @@
|
|
|
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
|
+
// AddressFields' built-in labels are English; the shipped checkout localizes
|
|
134
|
+
// them like every other label on the page.
|
|
135
|
+
const addressLabels = {
|
|
136
|
+
first_name: tt("checkout.address.first_name"),
|
|
137
|
+
last_name: tt("checkout.address.last_name"),
|
|
138
|
+
email: tt("checkout.address.email"),
|
|
139
|
+
company: tt("checkout.address.company"),
|
|
140
|
+
address_1: tt("checkout.address.address_1"),
|
|
141
|
+
address_2: tt("checkout.address.address_2"),
|
|
142
|
+
country: tt("checkout.address.country"),
|
|
143
|
+
city: tt("checkout.address.city"),
|
|
144
|
+
state: tt("checkout.address.state"),
|
|
145
|
+
postcode: tt("checkout.address.postcode"),
|
|
146
|
+
phone: tt(phone === "required" ? "checkout.address.phone" : "checkout.address.phoneOptional"),
|
|
147
|
+
};
|
|
148
|
+
const selectPlaceholder = tt("checkout.address.select");
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<SfuiRoot className="sfui-checkout" aria-labelledby="sfui-checkout-title">
|
|
152
|
+
<div className="sfui-checkout-inner" data-layout={layout}>
|
|
153
|
+
<div className="sfui-checkout-form">
|
|
154
|
+
<h1 id="sfui-checkout-title" className="sfui-heading sfui-h1">
|
|
155
|
+
{tt("checkout.title")}
|
|
156
|
+
</h1>
|
|
157
|
+
|
|
158
|
+
<section className="sfui-section sfui-address" aria-label={tt("checkout.contactTitle")}>
|
|
159
|
+
<h2 className="sfui-label">{tt("checkout.contactTitle")}</h2>
|
|
160
|
+
<AddressFields which="billing" includePhone={phone !== "hidden"} labels={addressLabels} selectPlaceholder={selectPlaceholder} />
|
|
161
|
+
{allowsShipToDifferent(shipToDifferent) && (
|
|
162
|
+
<label className="sfui-checkbox">
|
|
163
|
+
<input
|
|
164
|
+
type="checkbox"
|
|
165
|
+
checked={checkout.shipToDifferent}
|
|
166
|
+
onChange={(e) => checkout.setShipToDifferent(e.target.checked)}
|
|
167
|
+
/>
|
|
168
|
+
{tt("checkout.shipToDifferent")}
|
|
169
|
+
</label>
|
|
170
|
+
)}
|
|
171
|
+
{checkout.shipToDifferent && (
|
|
172
|
+
<>
|
|
173
|
+
<h2 className="sfui-label">{tt("checkout.shippingAddressTitle")}</h2>
|
|
174
|
+
<AddressFields which="shipping" includePhone={false} labels={addressLabels} selectPlaceholder={selectPlaceholder} />
|
|
175
|
+
</>
|
|
176
|
+
)}
|
|
177
|
+
</section>
|
|
178
|
+
|
|
179
|
+
<ShippingMethodPicker>
|
|
180
|
+
{({ methods, mustChoose, single, chosen, hint }) => (
|
|
181
|
+
<section className="sfui-section" aria-label={tt("checkout.shippingTitle")}>
|
|
182
|
+
<h2 className="sfui-label">{tt("checkout.shippingTitle")}</h2>
|
|
183
|
+
{mustChoose && (
|
|
184
|
+
<div className="sfui-choices" role="radiogroup" aria-label={tt("checkout.shippingTitle")}>
|
|
185
|
+
{methods.map((m) => (
|
|
186
|
+
<label key={m.id} className="sfui-choice" data-selected={m.selected || undefined}>
|
|
187
|
+
<input type="radio" name="sfui-shipping" checked={m.selected} onChange={m.select} />
|
|
188
|
+
<span className="sfui-choice-main">
|
|
189
|
+
<span className="sfui-choice-title">{m.title}</span>
|
|
190
|
+
</span>
|
|
191
|
+
<span className="sfui-choice-cost">
|
|
192
|
+
{m.cost === 0 ? tt("common.free") : m.costLabel}
|
|
193
|
+
</span>
|
|
194
|
+
</label>
|
|
195
|
+
))}
|
|
196
|
+
</div>
|
|
197
|
+
)}
|
|
198
|
+
{single && chosen && (
|
|
199
|
+
<div className="sfui-chosen-single">
|
|
200
|
+
<span>{chosen.title}</span>
|
|
201
|
+
<span>{chosen.cost === 0 ? tt("common.free") : chosen.costLabel}</span>
|
|
202
|
+
</div>
|
|
203
|
+
)}
|
|
204
|
+
{hint && (
|
|
205
|
+
<p
|
|
206
|
+
className={hint.severity === "error" ? "sfui-error" : "sfui-note"}
|
|
207
|
+
role={hint.severity === "error" ? "alert" : "status"}
|
|
208
|
+
>
|
|
209
|
+
{hint.serverMessage ?? tt(`checkout.hint.${hint.code}`)}
|
|
210
|
+
</p>
|
|
211
|
+
)}
|
|
212
|
+
</section>
|
|
213
|
+
)}
|
|
214
|
+
</ShippingMethodPicker>
|
|
215
|
+
|
|
216
|
+
<PaymentMethodPicker>
|
|
217
|
+
{({ gateways, mustChoose, single, selected, hint }) => (
|
|
218
|
+
<section className="sfui-section" aria-label={tt("checkout.paymentTitle")}>
|
|
219
|
+
<h2 className="sfui-label">{tt("checkout.paymentTitle")}</h2>
|
|
220
|
+
{mustChoose && (
|
|
221
|
+
<div className="sfui-choices" role="radiogroup" aria-label={tt("checkout.paymentTitle")}>
|
|
222
|
+
{gateways.map((g) => (
|
|
223
|
+
<label key={g.slug} className="sfui-choice" data-selected={g.selected || undefined}>
|
|
224
|
+
<input type="radio" name="sfui-payment" checked={g.selected} onChange={g.select} />
|
|
225
|
+
<span className="sfui-choice-main">
|
|
226
|
+
<span className="sfui-choice-title">{g.title}</span>
|
|
227
|
+
{g.description && <span className="sfui-choice-desc">{g.description}</span>}
|
|
228
|
+
</span>
|
|
229
|
+
</label>
|
|
230
|
+
))}
|
|
231
|
+
</div>
|
|
232
|
+
)}
|
|
233
|
+
{single && selected && (
|
|
234
|
+
<div className="sfui-chosen-single">
|
|
235
|
+
<span className="sfui-choice-main">
|
|
236
|
+
<span className="sfui-choice-title">{selected.title}</span>
|
|
237
|
+
{selected.description && <span className="sfui-choice-desc">{selected.description}</span>}
|
|
238
|
+
</span>
|
|
239
|
+
</div>
|
|
240
|
+
)}
|
|
241
|
+
{hint && (
|
|
242
|
+
<p className="sfui-error" role="alert">
|
|
243
|
+
{tt("checkout.hint.payment_none")}
|
|
244
|
+
</p>
|
|
245
|
+
)}
|
|
246
|
+
</section>
|
|
247
|
+
)}
|
|
248
|
+
</PaymentMethodPicker>
|
|
249
|
+
|
|
250
|
+
{notes && (
|
|
251
|
+
<section className="sfui-section">
|
|
252
|
+
<label className="sfui-label" htmlFor="sfui-checkout-note">
|
|
253
|
+
{tt("checkout.notes.label")}
|
|
254
|
+
</label>
|
|
255
|
+
<textarea
|
|
256
|
+
id="sfui-checkout-note"
|
|
257
|
+
value={note}
|
|
258
|
+
placeholder={tt("checkout.notes.placeholder")}
|
|
259
|
+
onChange={(e) => {
|
|
260
|
+
setNote(e.target.value);
|
|
261
|
+
writeOrderNote(e.target.value);
|
|
262
|
+
}}
|
|
263
|
+
/>
|
|
264
|
+
</section>
|
|
265
|
+
)}
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
<aside className="sfui-panel sfui-summary" aria-label={tt("checkout.summaryTitle")}>
|
|
269
|
+
<h2 className="sfui-heading sfui-h2">{tt("checkout.summaryTitle")}</h2>
|
|
270
|
+
{couponVisible(coupon, cart, info) && <CouponField tt={tt} />}
|
|
271
|
+
<TotalsBlock cart={cart} tt={tt} />
|
|
272
|
+
{termsCheckbox && (
|
|
273
|
+
<label className="sfui-checkbox">
|
|
274
|
+
<input type="checkbox" checked={termsAccepted} onChange={(e) => setTermsAccepted(e.target.checked)} />
|
|
275
|
+
{tt("checkout.terms")}
|
|
276
|
+
</label>
|
|
277
|
+
)}
|
|
278
|
+
<button type="button" className="sfui-btn" onClick={submit} disabled={disabled}>
|
|
279
|
+
{placing ? tt("checkout.placing") : tt("checkout.placeOrder")}
|
|
280
|
+
</button>
|
|
281
|
+
{/* A disabled button must say why: one line per blocker, localized. */}
|
|
282
|
+
{!canPlaceOrder && blockers.length > 0 && (
|
|
283
|
+
<ul className="sfui-blockers" aria-live="polite">
|
|
284
|
+
{blockers.map((code) => (
|
|
285
|
+
<li key={code}>{tt(`checkout.blocker.${code}`)}</li>
|
|
286
|
+
))}
|
|
287
|
+
</ul>
|
|
288
|
+
)}
|
|
289
|
+
{orderError && (
|
|
290
|
+
<p className="sfui-error" role="alert">
|
|
291
|
+
{orderError.code === "card_payment_in_preview"
|
|
292
|
+
? tt("checkout.error.card_payment_in_preview")
|
|
293
|
+
: orderError.message}
|
|
294
|
+
</p>
|
|
295
|
+
)}
|
|
296
|
+
</aside>
|
|
297
|
+
</div>
|
|
298
|
+
</SfuiRoot>
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function allowsShipToDifferent(section) {
|
|
303
|
+
return section !== false;
|
|
304
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import { Link } from "react-router-dom";
|
|
4
|
+
import { useCart, useCartUI, useFormatMoney } from "@/commerce/storefront";
|
|
5
|
+
import { makeT } from "./i18n/index.js";
|
|
6
|
+
import { CartLineRow, LoadingState, SfuiRoot } from "./internal.jsx";
|
|
7
|
+
|
|
8
|
+
const BRAND_KEYS = {
|
|
9
|
+
title: "minicart.title",
|
|
10
|
+
checkoutLabel: "minicart.checkout",
|
|
11
|
+
viewCartLabel: "minicart.viewCart",
|
|
12
|
+
emptyTitle: "minicart.empty.title",
|
|
13
|
+
emptyCta: "minicart.empty.cta",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The cart drawer — a complete overlay: portal, backdrop, dialog semantics,
|
|
18
|
+
* focus trap, body scroll-lock. Mount it ONCE inside the store's layout (never
|
|
19
|
+
* on a route); it opens and closes through <CartUIProvider>'s state, so
|
|
20
|
+
* `useAddToCart` flows and the store's cart button drive it with no extra
|
|
21
|
+
* wiring. Escape and close-on-navigate are the provider's job already.
|
|
22
|
+
*
|
|
23
|
+
* @param {object} props
|
|
24
|
+
* @param {Record<string,string>} [props.brand] wording overrides (title,
|
|
25
|
+
* checkoutLabel, viewCartLabel, emptyTitle, emptyCta).
|
|
26
|
+
* @param {"right"|"left"} [props.side="right"]
|
|
27
|
+
* @param {string} [props.checkoutHref="/checkout"]
|
|
28
|
+
* @param {string} [props.cartHref] renders a "view cart" link when given.
|
|
29
|
+
* @param {(item) => string} [props.productHref]
|
|
30
|
+
*/
|
|
31
|
+
export function MiniCart({ brand, side = "right", checkoutHref = "/checkout", cartHref, productHref }) {
|
|
32
|
+
const tt = makeT(brand, BRAND_KEYS);
|
|
33
|
+
const { open, closeCart } = useCartUI();
|
|
34
|
+
const { status, cart, itemCount } = useCart();
|
|
35
|
+
const formatMoney = useFormatMoney();
|
|
36
|
+
const panelRef = useRef(null);
|
|
37
|
+
const restoreRef = useRef(null);
|
|
38
|
+
|
|
39
|
+
// Focus management + scroll lock, for exactly the open window.
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!open) return;
|
|
42
|
+
restoreRef.current = document.activeElement;
|
|
43
|
+
const prevOverflow = document.body.style.overflow;
|
|
44
|
+
document.body.style.overflow = "hidden";
|
|
45
|
+
panelRef.current?.focus();
|
|
46
|
+
const trap = (e) => {
|
|
47
|
+
if (e.key !== "Tab" || !panelRef.current) return;
|
|
48
|
+
const focusables = panelRef.current.querySelectorAll(
|
|
49
|
+
'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
|
50
|
+
);
|
|
51
|
+
if (!focusables.length) return;
|
|
52
|
+
const first = focusables[0];
|
|
53
|
+
const last = focusables[focusables.length - 1];
|
|
54
|
+
if (e.shiftKey && document.activeElement === first) {
|
|
55
|
+
e.preventDefault();
|
|
56
|
+
last.focus();
|
|
57
|
+
} else if (!e.shiftKey && document.activeElement === last) {
|
|
58
|
+
e.preventDefault();
|
|
59
|
+
first.focus();
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
document.addEventListener("keydown", trap);
|
|
63
|
+
return () => {
|
|
64
|
+
document.removeEventListener("keydown", trap);
|
|
65
|
+
document.body.style.overflow = prevOverflow;
|
|
66
|
+
restoreRef.current?.focus?.();
|
|
67
|
+
};
|
|
68
|
+
}, [open]);
|
|
69
|
+
|
|
70
|
+
if (!open || typeof document === "undefined") return null;
|
|
71
|
+
|
|
72
|
+
return createPortal(
|
|
73
|
+
<SfuiRoot as="div">
|
|
74
|
+
<div className="sfui-drawer-backdrop" onClick={closeCart} aria-hidden="true" />
|
|
75
|
+
<div
|
|
76
|
+
className="sfui-drawer"
|
|
77
|
+
data-side={side}
|
|
78
|
+
role="dialog"
|
|
79
|
+
aria-modal="true"
|
|
80
|
+
aria-label={tt("minicart.title")}
|
|
81
|
+
tabIndex={-1}
|
|
82
|
+
ref={panelRef}
|
|
83
|
+
>
|
|
84
|
+
<div className="sfui-drawer-head">
|
|
85
|
+
<h2 className="sfui-heading sfui-h2">{tt("minicart.title")}</h2>
|
|
86
|
+
<button type="button" className="sfui-drawer-close" onClick={closeCart} aria-label={tt("minicart.close")}>
|
|
87
|
+
×
|
|
88
|
+
</button>
|
|
89
|
+
</div>
|
|
90
|
+
<div className="sfui-drawer-body">
|
|
91
|
+
{status === "loading" && <LoadingState />}
|
|
92
|
+
{status === "empty" && (
|
|
93
|
+
<div className="sfui-state">
|
|
94
|
+
<h3 className="sfui-heading sfui-h2">{tt("minicart.empty.title")}</h3>
|
|
95
|
+
<button type="button" className="sfui-btn sfui-btn-ghost sfui-btn-inline" onClick={closeCart}>
|
|
96
|
+
{tt("minicart.empty.cta")}
|
|
97
|
+
</button>
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
{status === "ready" && (
|
|
101
|
+
<ul className="sfui-lines">
|
|
102
|
+
{cart.items.map((item) => (
|
|
103
|
+
<CartLineRow key={item.item_key} item={item} tt={tt} productHref={productHref} compact />
|
|
104
|
+
))}
|
|
105
|
+
</ul>
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
{status === "ready" && (
|
|
109
|
+
<div className="sfui-drawer-foot">
|
|
110
|
+
<div className="sfui-drawer-subtotal">
|
|
111
|
+
<span>{tt("minicart.subtotal")}</span>
|
|
112
|
+
<span>{formatMoney(cart.totals?.subtotal)}</span>
|
|
113
|
+
</div>
|
|
114
|
+
<Link className="sfui-btn" to={checkoutHref} onClick={closeCart}>
|
|
115
|
+
{tt("minicart.checkout")}
|
|
116
|
+
</Link>
|
|
117
|
+
{cartHref && (
|
|
118
|
+
<Link className="sfui-btn sfui-btn-ghost" to={cartHref} onClick={closeCart}>
|
|
119
|
+
{tt("minicart.viewCart")} {itemCount ? `(${itemCount})` : ""}
|
|
120
|
+
</Link>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
</SfuiRoot>,
|
|
126
|
+
document.body,
|
|
127
|
+
);
|
|
128
|
+
}
|