@base44/app-plugin-commerce 0.5.1 → 0.6.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 +2 -2
- package/package.json +1 -1
- package/skills/commerce/SKILL.md +64 -50
- package/skills/commerce/install/01-install.md +1 -1
- package/skills/commerce/install/02-storefront.md +165 -155
- package/skills/commerce/references/storefront-verification.md +0 -4
- package/src/commerce/storefront/AddressFields.jsx +150 -0
- package/src/commerce/storefront/StorefrontProvider.jsx +3 -10
- package/src/commerce/storefront/cartUI.jsx +17 -63
- package/src/commerce/storefront/index.js +6 -30
- package/src/commerce/storefront/useCheckout.jsx +0 -27
- package/skills/commerce/references/storefront-custom.md +0 -150
- package/skills/commerce/references/storefront-parts.md +0 -184
- package/src/commerce/storefront/parts/cart.jsx +0 -191
- package/src/commerce/storefront/parts/checkout.jsx +0 -540
- package/src/commerce/storefront/parts/drawer.jsx +0 -87
- package/src/commerce/storefront/parts/labels.jsx +0 -140
- package/src/commerce/storefront/parts/orderReceived.jsx +0 -200
- package/src/commerce/storefront/parts/parts.css +0 -215
- package/src/commerce/storefront/parts/shared.jsx +0 -137
- package/src/commerce/storefront/parts/visibility.js +0 -36
|
@@ -1,540 +0,0 @@
|
|
|
1
|
-
import React, {
|
|
2
|
-
createContext,
|
|
3
|
-
useCallback,
|
|
4
|
-
useContext,
|
|
5
|
-
useEffect,
|
|
6
|
-
useId,
|
|
7
|
-
useLayoutEffect,
|
|
8
|
-
useMemo,
|
|
9
|
-
useState,
|
|
10
|
-
} from "react";
|
|
11
|
-
import { addressFieldSpec, attributesLabel } from "@/commerce/utils";
|
|
12
|
-
import { CheckoutProvider, useCheckoutContext } from "../useCheckout";
|
|
13
|
-
import { ShippingMethodPicker, PaymentMethodPicker } from "../pickers";
|
|
14
|
-
import { useCart, useCountries, useFormatMoney } from "../StorefrontProvider";
|
|
15
|
-
import { REQUIRED_BILLING_FIELDS } from "../address";
|
|
16
|
-
import { LabelsScope, label, useResolvedLabels } from "./labels";
|
|
17
|
-
import { visible } from "./visibility";
|
|
18
|
-
import { CouponField, Totals } from "./shared";
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Checkout parts — the checkout's sections as unstyled, unworded components
|
|
22
|
-
* the store places into ITS OWN layout markup. Contract, styling
|
|
23
|
-
* (`data-part`), labels and placement: the commerce skill's
|
|
24
|
-
* install/02-storefront.md. Each part is a thin composition over `useCheckout`
|
|
25
|
-
* and the pickers; mixing a part with a custom section built on
|
|
26
|
-
* `useCheckoutContext()` is normal, and the parts are the app's source —
|
|
27
|
-
* editable when a requirement outgrows their props.
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
const CheckoutPartsContext = createContext(null);
|
|
31
|
-
|
|
32
|
-
/** Layout effect where there is a DOM; plain effect on the server (no warning). */
|
|
33
|
-
const useCommitEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Mounts `CheckoutProvider`, resolves the page's phase once (`submitted` >
|
|
37
|
-
* `loading` > `empty` > `form` — the ordering that kills the empty-bag flash
|
|
38
|
-
* over a just-placed order), and scopes `labels`. `orderReceivedPath` is
|
|
39
|
-
* REQUIRED: the one navigation this flow performs is an explicit decision —
|
|
40
|
-
* pass the store's own receipt route, or `null` (+ `onPlaced(result)`) to own
|
|
41
|
-
* the step entirely.
|
|
42
|
-
*/
|
|
43
|
-
function Root({ labels, orderReceivedPath, onPlaced, options, checkout, children }) {
|
|
44
|
-
if (checkout == null && orderReceivedPath === undefined) {
|
|
45
|
-
throw new Error(
|
|
46
|
-
"<Checkout.Root> requires orderReceivedPath — the route the store's order-received page is mounted at (or null, with onPlaced, to handle the post-order step yourself). The kit never assumes a route.",
|
|
47
|
-
);
|
|
48
|
-
}
|
|
49
|
-
return (
|
|
50
|
-
<CheckoutProvider checkout={checkout} options={{ ...options, orderReceivedPath }}>
|
|
51
|
-
<LabelsScope labels={labels}>
|
|
52
|
-
<RootPhase onPlaced={onPlaced}>{children}</RootPhase>
|
|
53
|
-
</LabelsScope>
|
|
54
|
-
</CheckoutProvider>
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function RootPhase({ onPlaced, children }) {
|
|
59
|
-
const checkout = useCheckoutContext();
|
|
60
|
-
const { status } = useCart();
|
|
61
|
-
const phase =
|
|
62
|
-
checkout.stage === "submitted"
|
|
63
|
-
? "submitted"
|
|
64
|
-
: status === "loading"
|
|
65
|
-
? "loading"
|
|
66
|
-
: status === "empty"
|
|
67
|
-
? "empty"
|
|
68
|
-
: "form";
|
|
69
|
-
// A page that places <Checkout.Blockers> itself must not ALSO get the copy
|
|
70
|
-
// `PlaceOrder` renders by default — the same three reasons printed twice is a
|
|
71
|
-
// real defect an eval caught. Standalone instances register here (before
|
|
72
|
-
// paint), and the button's own copy stands down while one exists.
|
|
73
|
-
const [ownBlockers, setOwnBlockers] = useState(0);
|
|
74
|
-
const registerBlockers = useCallback((delta) => setOwnBlockers((n) => n + delta), []);
|
|
75
|
-
const value = useMemo(
|
|
76
|
-
() => ({ phase, onPlaced, hasOwnBlockers: ownBlockers > 0, registerBlockers }),
|
|
77
|
-
[phase, onPlaced, ownBlockers, registerBlockers],
|
|
78
|
-
);
|
|
79
|
-
return <CheckoutPartsContext.Provider value={value}>{children}</CheckoutPartsContext.Provider>;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function usePartsContext(name) {
|
|
83
|
-
const ctx = useContext(CheckoutPartsContext);
|
|
84
|
-
if (!ctx) throw new Error(`<Checkout.${name}> works inside <Checkout.Root> only.`);
|
|
85
|
-
return ctx;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** Gates: children render only in the matching phase (node, or function of `{ checkout, cart }`). */
|
|
89
|
-
function gate(name, match) {
|
|
90
|
-
function Gate({ children }) {
|
|
91
|
-
const { phase } = usePartsContext(name);
|
|
92
|
-
const checkout = useCheckoutContext();
|
|
93
|
-
if (phase !== match) return null;
|
|
94
|
-
return typeof children === "function"
|
|
95
|
-
? (children({ checkout, cart: checkout.cart }) ?? null)
|
|
96
|
-
: (children ?? null);
|
|
97
|
-
}
|
|
98
|
-
Gate.displayName = `Checkout.${name}`;
|
|
99
|
-
return Gate;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const Submitted = gate("Submitted", "submitted");
|
|
103
|
-
const Loading = gate("Loading", "loading");
|
|
104
|
-
const Empty = gate("Empty", "empty");
|
|
105
|
-
const Form = gate("Form", "form");
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* The address form off `addressFieldSpec`: state/province appears with the
|
|
109
|
-
* right options once a country is picked, `autoComplete` tokens stay on,
|
|
110
|
-
* required marks arm on first blur, and the server's "we don't ship there"
|
|
111
|
-
* lands on the country field. `which="shipping"` renders null until
|
|
112
|
-
* `shipToDifferent`. Overrides: `inputRender` swaps the control only,
|
|
113
|
-
* `fieldRender` the whole labeled block.
|
|
114
|
-
*/
|
|
115
|
-
function AddressFields({
|
|
116
|
-
which = "billing",
|
|
117
|
-
show,
|
|
118
|
-
include,
|
|
119
|
-
omit,
|
|
120
|
-
inputRender: InputRender,
|
|
121
|
-
fieldRender,
|
|
122
|
-
className,
|
|
123
|
-
classes,
|
|
124
|
-
labels: partLabels,
|
|
125
|
-
}) {
|
|
126
|
-
const checkout = useCheckoutContext();
|
|
127
|
-
const L = useResolvedLabels(partLabels);
|
|
128
|
-
const { countries } = useCountries();
|
|
129
|
-
const idBase = useId();
|
|
130
|
-
const [touched, setTouched] = useState({});
|
|
131
|
-
|
|
132
|
-
const isBilling = which === "billing";
|
|
133
|
-
if (!isBilling && !checkout.shipToDifferent) return null;
|
|
134
|
-
|
|
135
|
-
const values = isBilling ? checkout.billing : checkout.shipping;
|
|
136
|
-
const set = isBilling ? checkout.updateBilling : checkout.updateShipping;
|
|
137
|
-
// `show` is the current form (keys are field keys); `include`/`omit` are the
|
|
138
|
-
// older spelling and fold into the same predicate.
|
|
139
|
-
const vis = visible(show, {
|
|
140
|
-
company: include?.includes("company") ?? false,
|
|
141
|
-
phone: include ? include.includes("phone") : true,
|
|
142
|
-
...Object.fromEntries((omit ?? []).map((k) => [k, false])),
|
|
143
|
-
});
|
|
144
|
-
const fields = addressFieldSpec({
|
|
145
|
-
countries,
|
|
146
|
-
country: values.country,
|
|
147
|
-
required: isBilling ? REQUIRED_BILLING_FIELDS : ["country", "city"],
|
|
148
|
-
includeEmail: isBilling && vis("email"),
|
|
149
|
-
includeCompany: vis("company"),
|
|
150
|
-
includePhone: vis("phone"),
|
|
151
|
-
}).filter((f) => vis(f.key));
|
|
152
|
-
|
|
153
|
-
return (
|
|
154
|
-
<div data-part="address-fields" data-which={which} className={className}>
|
|
155
|
-
{fields.map((f) => {
|
|
156
|
-
const id = `${idBase}-${which}-${f.key}`;
|
|
157
|
-
const value = values[f.key] ?? "";
|
|
158
|
-
const invalid = Boolean(touched[f.key] && f.required && !String(value).trim());
|
|
159
|
-
const dom = {
|
|
160
|
-
id,
|
|
161
|
-
value,
|
|
162
|
-
autoComplete: f.autoComplete,
|
|
163
|
-
onChange: (e) => set({ [f.key]: e && e.target ? e.target.value : e }),
|
|
164
|
-
onBlur: () => setTouched((t) => (t[f.key] ? t : { ...t, [f.key]: true })),
|
|
165
|
-
};
|
|
166
|
-
const labelText = label(L, `fields.${f.key}`);
|
|
167
|
-
if (fieldRender) {
|
|
168
|
-
return (
|
|
169
|
-
<React.Fragment key={f.key}>
|
|
170
|
-
{fieldRender({ field: f, label: labelText, invalid, options: f.options, ...dom })}
|
|
171
|
-
</React.Fragment>
|
|
172
|
-
);
|
|
173
|
-
}
|
|
174
|
-
return (
|
|
175
|
-
<div
|
|
176
|
-
key={f.key}
|
|
177
|
-
data-part="field"
|
|
178
|
-
data-key={f.key}
|
|
179
|
-
data-span={f.colSpan}
|
|
180
|
-
data-invalid={invalid || undefined}
|
|
181
|
-
className={classes?.field}
|
|
182
|
-
>
|
|
183
|
-
<label data-part="label" className={classes?.label} htmlFor={id}>
|
|
184
|
-
{labelText}
|
|
185
|
-
{f.required && (
|
|
186
|
-
<span data-part="required" aria-hidden="true">
|
|
187
|
-
*
|
|
188
|
-
</span>
|
|
189
|
-
)}
|
|
190
|
-
</label>
|
|
191
|
-
{InputRender ? (
|
|
192
|
-
<InputRender field={f} options={f.options} invalid={invalid} {...dom} />
|
|
193
|
-
) : f.type === "select" ? (
|
|
194
|
-
<select
|
|
195
|
-
data-part="control"
|
|
196
|
-
className={classes?.control}
|
|
197
|
-
aria-invalid={invalid || undefined}
|
|
198
|
-
aria-required={f.required || undefined}
|
|
199
|
-
{...dom}
|
|
200
|
-
>
|
|
201
|
-
<option value="">{label(L, "fields.select_placeholder")}</option>
|
|
202
|
-
{f.options.map((o) => (
|
|
203
|
-
<option key={o.value} value={o.value}>
|
|
204
|
-
{o.label}
|
|
205
|
-
</option>
|
|
206
|
-
))}
|
|
207
|
-
</select>
|
|
208
|
-
) : (
|
|
209
|
-
<input
|
|
210
|
-
data-part="control"
|
|
211
|
-
className={classes?.control}
|
|
212
|
-
type={f.type}
|
|
213
|
-
aria-invalid={invalid || undefined}
|
|
214
|
-
aria-required={f.required || undefined}
|
|
215
|
-
{...dom}
|
|
216
|
-
/>
|
|
217
|
-
)}
|
|
218
|
-
{f.key === "country" && checkout.addressError && (
|
|
219
|
-
<p data-part="error" role="alert" className={classes?.error}>
|
|
220
|
-
{checkout.addressError.message}
|
|
221
|
-
</p>
|
|
222
|
-
)}
|
|
223
|
-
</div>
|
|
224
|
-
);
|
|
225
|
-
})}
|
|
226
|
-
</div>
|
|
227
|
-
);
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* The deliver-elsewhere toggle. Children are its label — the store's words.
|
|
232
|
-
* `controlRender({ checked, onChange, toggle })` replaces the checkbox with
|
|
233
|
-
* whatever the design wants (a switch, a segmented control, a pair of buttons);
|
|
234
|
-
* keep it operable from the keyboard and give it a checked state a screen reader
|
|
235
|
-
* can read (`role="switch"` + `aria-checked`, or a real input inside).
|
|
236
|
-
*/
|
|
237
|
-
function ShipToDifferent({ children, controlRender, className, classes }) {
|
|
238
|
-
const checkout = useCheckoutContext();
|
|
239
|
-
const checked = checkout.shipToDifferent;
|
|
240
|
-
const set = checkout.setShipToDifferent;
|
|
241
|
-
const control = controlRender ? (
|
|
242
|
-
controlRender({ checked, onChange: (e) => set(e?.target ? e.target.checked : Boolean(e)), toggle: () => set(!checked) })
|
|
243
|
-
) : (
|
|
244
|
-
<input
|
|
245
|
-
data-part="control"
|
|
246
|
-
className={classes?.control}
|
|
247
|
-
type="checkbox"
|
|
248
|
-
checked={checked}
|
|
249
|
-
onChange={(e) => set(e.target.checked)}
|
|
250
|
-
/>
|
|
251
|
-
);
|
|
252
|
-
// Without the kit's own input there is nothing for a <label> to label, so the
|
|
253
|
-
// store's control stands on its own (it carries its own accessible name).
|
|
254
|
-
const Wrapper = controlRender ? "div" : "label";
|
|
255
|
-
return (
|
|
256
|
-
<Wrapper data-part="ship-to-different" className={className}>
|
|
257
|
-
{control}
|
|
258
|
-
<span data-part="label" className={classes?.label}>{children}</span>
|
|
259
|
-
</Wrapper>
|
|
260
|
-
);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/**
|
|
264
|
-
* The delivery choice, every branch premade: the hint line (`serverMessage`
|
|
265
|
-
* preferred over the store's own words), a radio list while there is a real
|
|
266
|
-
* choice, the chosen/auto-selected method *displayed* when there is one option
|
|
267
|
-
* (never a picker of one), null for a virtual cart. `optionRender(option)`
|
|
268
|
-
* replaces an option's content; the radio stays wired.
|
|
269
|
-
*/
|
|
270
|
-
function ShippingMethods({ optionRender, className, classes, labels: partLabels }) {
|
|
271
|
-
const L = useResolvedLabels(partLabels);
|
|
272
|
-
const groupName = useId();
|
|
273
|
-
return (
|
|
274
|
-
<ShippingMethodPicker>
|
|
275
|
-
{({ status, methods, chosen, syncing, hint }) => {
|
|
276
|
-
const showList = methods.length > 1;
|
|
277
|
-
return (
|
|
278
|
-
<fieldset
|
|
279
|
-
data-part="shipping-methods"
|
|
280
|
-
data-state={status}
|
|
281
|
-
data-syncing={syncing || undefined}
|
|
282
|
-
className={className}
|
|
283
|
-
aria-label={label(L, "aria.shipping_group")}
|
|
284
|
-
>
|
|
285
|
-
{hint && (
|
|
286
|
-
<p
|
|
287
|
-
data-part="hint"
|
|
288
|
-
data-severity={hint.severity}
|
|
289
|
-
className={classes?.hint}
|
|
290
|
-
role={hint.severity === "error" ? "alert" : "status"}
|
|
291
|
-
>
|
|
292
|
-
{hint.serverMessage ?? label(L, `shipping.${hint.code}`)}
|
|
293
|
-
</p>
|
|
294
|
-
)}
|
|
295
|
-
{showList &&
|
|
296
|
-
methods.map((m) =>
|
|
297
|
-
optionRender ? (
|
|
298
|
-
<React.Fragment key={m.id}>{optionRender(m)}</React.Fragment>
|
|
299
|
-
) : (
|
|
300
|
-
<label
|
|
301
|
-
key={m.id}
|
|
302
|
-
data-part="option"
|
|
303
|
-
data-state={m.selected ? "selected" : undefined}
|
|
304
|
-
className={classes?.option}
|
|
305
|
-
>
|
|
306
|
-
<input
|
|
307
|
-
data-part="option-input"
|
|
308
|
-
type="radio"
|
|
309
|
-
name={groupName}
|
|
310
|
-
checked={m.selected}
|
|
311
|
-
onChange={() => Promise.resolve(m.select()).catch(() => {})}
|
|
312
|
-
/>
|
|
313
|
-
<span data-part="option-label" className={classes?.["option-label"]}>{m.title}</span>
|
|
314
|
-
<span data-part="option-cost" className={classes?.["option-cost"]}>{m.costLabel}</span>
|
|
315
|
-
</label>
|
|
316
|
-
),
|
|
317
|
-
)}
|
|
318
|
-
{!showList && chosen && (
|
|
319
|
-
<p data-part="chosen" className={classes?.chosen}>
|
|
320
|
-
{optionRender ? (
|
|
321
|
-
optionRender(chosen)
|
|
322
|
-
) : (
|
|
323
|
-
<>
|
|
324
|
-
<span data-part="option-label" className={classes?.["option-label"]}>{chosen.title}</span>
|
|
325
|
-
<span data-part="option-cost" className={classes?.["option-cost"]}>{chosen.costLabel}</span>
|
|
326
|
-
</>
|
|
327
|
-
)}
|
|
328
|
-
</p>
|
|
329
|
-
)}
|
|
330
|
-
</fieldset>
|
|
331
|
-
);
|
|
332
|
-
}}
|
|
333
|
-
</ShippingMethodPicker>
|
|
334
|
-
);
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
/**
|
|
338
|
-
* How to pay — every ENABLED gateway, titles and descriptions in the admin's
|
|
339
|
-
* own words. One gateway renders as the selection it already is; none renders
|
|
340
|
-
* the store's checkout-unavailable line. `optionRender(gateway)` replaces an
|
|
341
|
-
* option's content; the radio stays wired.
|
|
342
|
-
*/
|
|
343
|
-
function PaymentMethods({ optionRender, className, classes, labels: partLabels }) {
|
|
344
|
-
const L = useResolvedLabels(partLabels);
|
|
345
|
-
const groupName = useId();
|
|
346
|
-
return (
|
|
347
|
-
<PaymentMethodPicker>
|
|
348
|
-
{({ gateways, selected, single, hint }) => (
|
|
349
|
-
<fieldset data-part="payment-methods" className={className} aria-label={label(L, "aria.payment_group")}>
|
|
350
|
-
{hint && (
|
|
351
|
-
<p data-part="hint" data-severity={hint.severity} className={classes?.hint} role="alert">
|
|
352
|
-
{label(L, "payment.none_available")}
|
|
353
|
-
</p>
|
|
354
|
-
)}
|
|
355
|
-
{!single &&
|
|
356
|
-
gateways.map((g) => (
|
|
357
|
-
<label
|
|
358
|
-
key={g.slug}
|
|
359
|
-
data-part="option"
|
|
360
|
-
data-state={g.selected ? "selected" : undefined}
|
|
361
|
-
className={classes?.option}
|
|
362
|
-
>
|
|
363
|
-
<input
|
|
364
|
-
data-part="option-input"
|
|
365
|
-
type="radio"
|
|
366
|
-
name={groupName}
|
|
367
|
-
checked={g.selected}
|
|
368
|
-
onChange={g.select}
|
|
369
|
-
/>
|
|
370
|
-
{optionRender ? (
|
|
371
|
-
optionRender(g)
|
|
372
|
-
) : (
|
|
373
|
-
<>
|
|
374
|
-
<span data-part="option-label" className={classes?.["option-label"]}>{g.title}</span>
|
|
375
|
-
{g.description && (
|
|
376
|
-
<span data-part="option-description" className={classes?.["option-description"]}>
|
|
377
|
-
{g.description}
|
|
378
|
-
</span>
|
|
379
|
-
)}
|
|
380
|
-
</>
|
|
381
|
-
)}
|
|
382
|
-
</label>
|
|
383
|
-
))}
|
|
384
|
-
{single && selected && (
|
|
385
|
-
<p data-part="chosen" className={classes?.chosen}>
|
|
386
|
-
{optionRender ? (
|
|
387
|
-
optionRender(selected)
|
|
388
|
-
) : (
|
|
389
|
-
<>
|
|
390
|
-
<span data-part="option-label" className={classes?.["option-label"]}>{selected.title}</span>
|
|
391
|
-
{selected.description && (
|
|
392
|
-
<span data-part="option-description" className={classes?.["option-description"]}>
|
|
393
|
-
{selected.description}
|
|
394
|
-
</span>
|
|
395
|
-
)}
|
|
396
|
-
</>
|
|
397
|
-
)}
|
|
398
|
-
</p>
|
|
399
|
-
)}
|
|
400
|
-
</fieldset>
|
|
401
|
-
)}
|
|
402
|
-
</PaymentMethodPicker>
|
|
403
|
-
);
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
/**
|
|
407
|
-
* The mini summary: read-only rows of what is being bought. `show` decides which
|
|
408
|
-
* elements render — `media`, `attributes`, `quantity`, `lineTotal` (a dense
|
|
409
|
-
* summary column often wants `show={{ media: false }}`); `itemRender(item)`
|
|
410
|
-
* replaces a row.
|
|
411
|
-
*/
|
|
412
|
-
function Items({ show, itemRender, className, classes }) {
|
|
413
|
-
const vis = visible(show);
|
|
414
|
-
const checkout = useCheckoutContext();
|
|
415
|
-
const formatMoney = useFormatMoney();
|
|
416
|
-
const items = checkout.cart?.items ?? [];
|
|
417
|
-
if (!items.length) return null;
|
|
418
|
-
return (
|
|
419
|
-
<ul data-part="items" className={className}>
|
|
420
|
-
{items.map((item) => (
|
|
421
|
-
<li key={item.item_key} data-part="row" className={classes?.row}>
|
|
422
|
-
{itemRender ? (
|
|
423
|
-
itemRender(item)
|
|
424
|
-
) : (
|
|
425
|
-
<>
|
|
426
|
-
{vis("media") &&
|
|
427
|
-
(item.image ? (
|
|
428
|
-
<img data-part="media" className={classes?.media} src={item.image} alt="" loading="lazy" />
|
|
429
|
-
) : (
|
|
430
|
-
<div data-part="media" data-empty="" className={classes?.media} />
|
|
431
|
-
))}
|
|
432
|
-
<span data-part="content" className={classes?.content}>
|
|
433
|
-
<span data-part="name" className={classes?.name}>{item.name}</span>
|
|
434
|
-
{vis("attributes") && attributesLabel(item.attributes) ? (
|
|
435
|
-
<span data-part="attributes" className={classes?.attributes}>
|
|
436
|
-
{attributesLabel(item.attributes)}
|
|
437
|
-
</span>
|
|
438
|
-
) : null}
|
|
439
|
-
</span>
|
|
440
|
-
{vis("quantity") && (
|
|
441
|
-
<span data-part="quantity" className={classes?.quantity}>×{item.quantity}</span>
|
|
442
|
-
)}
|
|
443
|
-
{vis("lineTotal") && (
|
|
444
|
-
<span data-part="line-total" className={classes?.["line-total"]}>
|
|
445
|
-
{formatMoney(item.total)}
|
|
446
|
-
</span>
|
|
447
|
-
)}
|
|
448
|
-
</>
|
|
449
|
-
)}
|
|
450
|
-
</li>
|
|
451
|
-
))}
|
|
452
|
-
</ul>
|
|
453
|
-
);
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
/**
|
|
457
|
-
* The blocker lines — what still stands between the customer and the order,
|
|
458
|
-
* one line per code in the store's words, gone when the order can be placed.
|
|
459
|
-
* `PlaceOrder` renders these next to the button by default; placing this part
|
|
460
|
-
* anywhere under `Checkout.Root` moves them there instead — the button's own
|
|
461
|
-
* copy stands down automatically, so the reasons are never printed twice.
|
|
462
|
-
*/
|
|
463
|
-
function Blockers({ standalone = true, className, classes, labels: partLabels }) {
|
|
464
|
-
const checkout = useCheckoutContext();
|
|
465
|
-
const { registerBlockers } = usePartsContext("Blockers");
|
|
466
|
-
const L = useResolvedLabels(partLabels);
|
|
467
|
-
// Registered before paint, so the button's built-in copy never flashes.
|
|
468
|
-
useCommitEffect(() => {
|
|
469
|
-
if (!standalone) return undefined;
|
|
470
|
-
registerBlockers(1);
|
|
471
|
-
return () => registerBlockers(-1);
|
|
472
|
-
}, [standalone, registerBlockers]);
|
|
473
|
-
if (checkout.canPlaceOrder) return null;
|
|
474
|
-
return (
|
|
475
|
-
<div data-part="blockers" className={className}>
|
|
476
|
-
{checkout.blockers.map((code) => (
|
|
477
|
-
<p key={code} data-part="blocker" data-code={code} className={classes?.blocker}>
|
|
478
|
-
{label(L, `blockers.${code}`)}
|
|
479
|
-
</p>
|
|
480
|
-
))}
|
|
481
|
-
</div>
|
|
482
|
-
);
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
/**
|
|
486
|
-
* The gate, the button and the reasons, together so a disabled button always
|
|
487
|
-
* says why. Label and placing-label are the store's; the order error is the
|
|
488
|
-
* server's own words. `show={{ blockers: false }}` moves the reasons elsewhere
|
|
489
|
-
* (place `Checkout.Blockers` yourself — it also suppresses these on its own). On success `Checkout.Root`'s `onPlaced` fires (when the
|
|
490
|
-
* page owns the post-order step).
|
|
491
|
-
*/
|
|
492
|
-
function PlaceOrder({ show, showBlockers = true, className, classes, labels: partLabels }) {
|
|
493
|
-
const vis = visible(show, { blockers: showBlockers });
|
|
494
|
-
const checkout = useCheckoutContext();
|
|
495
|
-
const { onPlaced, hasOwnBlockers } = usePartsContext("PlaceOrder");
|
|
496
|
-
const L = useResolvedLabels(partLabels);
|
|
497
|
-
const place = useCallback(async () => {
|
|
498
|
-
const res = await checkout.placeOrder(); // resolves {ok:false} — never throws
|
|
499
|
-
if (res?.ok && onPlaced) onPlaced(res.result);
|
|
500
|
-
}, [checkout, onPlaced]);
|
|
501
|
-
return (
|
|
502
|
-
<>
|
|
503
|
-
<button
|
|
504
|
-
type="button"
|
|
505
|
-
data-part="place-order"
|
|
506
|
-
data-state={checkout.placing ? "placing" : undefined}
|
|
507
|
-
className={className}
|
|
508
|
-
disabled={!checkout.canPlaceOrder || checkout.placing}
|
|
509
|
-
onClick={place}
|
|
510
|
-
>
|
|
511
|
-
{checkout.placing ? label(L, "placeOrder.placing") : label(L, "placeOrder.label")}
|
|
512
|
-
</button>
|
|
513
|
-
{vis("orderError") && checkout.orderError && (
|
|
514
|
-
<p data-part="order-error" role="alert" className={classes?.["order-error"]}>
|
|
515
|
-
{checkout.orderError.message}
|
|
516
|
-
</p>
|
|
517
|
-
)}
|
|
518
|
-
{vis("blockers") && !hasOwnBlockers && (
|
|
519
|
-
<Blockers standalone={false} className={classes?.blockers} labels={partLabels} />
|
|
520
|
-
)}
|
|
521
|
-
</>
|
|
522
|
-
);
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
export const Checkout = {
|
|
526
|
-
Root,
|
|
527
|
-
Submitted,
|
|
528
|
-
Loading,
|
|
529
|
-
Empty,
|
|
530
|
-
Form,
|
|
531
|
-
AddressFields,
|
|
532
|
-
ShipToDifferent,
|
|
533
|
-
ShippingMethods,
|
|
534
|
-
PaymentMethods,
|
|
535
|
-
Items,
|
|
536
|
-
CouponField,
|
|
537
|
-
Totals,
|
|
538
|
-
PlaceOrder,
|
|
539
|
-
Blockers,
|
|
540
|
-
};
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { useCart } from "../StorefrontProvider";
|
|
3
|
-
import { useCartUI } from "../cartUI";
|
|
4
|
-
import { label, useResolvedLabels } from "./labels";
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* The cart drawer's two wired *elements* — nothing more. **The drawer itself is
|
|
8
|
-
* the store's**: you render the overlay and the panel, and own where they sit,
|
|
9
|
-
* how wide they are, their padding and their animation. There is deliberately
|
|
10
|
-
* no `Panel` part: a kit that positions the drawer gets it wrong for half the
|
|
11
|
-
* designs it lands in (and there is no version of "which side, how wide, how it
|
|
12
|
-
* moves" that isn't the store's decision).
|
|
13
|
-
*
|
|
14
|
-
* The state and the fiddly behavior stay premade in `useCartUI()` (open/close,
|
|
15
|
-
* Esc, close-on-navigate, open-on-add, focus in and back out, inert while
|
|
16
|
-
* closed, `panelId`) — see `cartUI.jsx`. The shape of a drawer, then, is:
|
|
17
|
-
*
|
|
18
|
-
* const ui = useCartUI();
|
|
19
|
-
* …
|
|
20
|
-
* <CartDrawer.Trigger className="…">{({ count }) => …}</CartDrawer.Trigger>
|
|
21
|
-
* {ui.open && (
|
|
22
|
-
* <div className="…your fixed layer…">
|
|
23
|
-
* <div className="…your scrim…" aria-hidden="true" onClick={ui.closeCart} />
|
|
24
|
-
* <aside id={ui.panelId} ref={ui.panelRef} tabIndex={-1}
|
|
25
|
-
* role="dialog" aria-modal="true" aria-label="…"
|
|
26
|
-
* className="…your panel: side, width, padding, transition…">
|
|
27
|
-
* <CartDrawer.Close>…your glyph…</CartDrawer.Close>
|
|
28
|
-
* …Cart.* parts…
|
|
29
|
-
* </aside>
|
|
30
|
-
* </div>
|
|
31
|
-
* )}
|
|
32
|
-
*
|
|
33
|
-
* Contract, rules and labels: the commerce skill's install/02-storefront.md.
|
|
34
|
-
*/
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* The header's cart button, wired: toggles the drawer, reports `aria-expanded`,
|
|
38
|
-
* and points `aria-controls` at `useCartUI().panelId` (put that on your panel).
|
|
39
|
-
* The icon is the store's — children, or a render prop `({ count, open }) => …`
|
|
40
|
-
* to badge it with the live item count.
|
|
41
|
-
*/
|
|
42
|
-
function Trigger({ children, className, labels: partLabels }) {
|
|
43
|
-
const L = useResolvedLabels(partLabels);
|
|
44
|
-
const ui = useCartUI();
|
|
45
|
-
const { itemCount } = useCart();
|
|
46
|
-
const content = typeof children === "function" ? children({ count: itemCount, open: ui.open }) : children;
|
|
47
|
-
return (
|
|
48
|
-
<button
|
|
49
|
-
type="button"
|
|
50
|
-
data-part="trigger"
|
|
51
|
-
data-state={ui.open ? "open" : "closed"}
|
|
52
|
-
className={className}
|
|
53
|
-
aria-haspopup="dialog"
|
|
54
|
-
aria-expanded={ui.open}
|
|
55
|
-
aria-controls={ui.panelId}
|
|
56
|
-
aria-label={label(L, "aria.trigger")}
|
|
57
|
-
onClick={ui.toggleCart}
|
|
58
|
-
>
|
|
59
|
-
{content}
|
|
60
|
-
</button>
|
|
61
|
-
);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* The named close control — the drawer's keyboard and screen-reader way out
|
|
66
|
-
* (a click-away scrim is not one). Children are the store's icon or text.
|
|
67
|
-
*/
|
|
68
|
-
function Close({ children, className, labels: partLabels }) {
|
|
69
|
-
const L = useResolvedLabels(partLabels);
|
|
70
|
-
const ui = useCartUI();
|
|
71
|
-
return (
|
|
72
|
-
<button
|
|
73
|
-
type="button"
|
|
74
|
-
data-part="close"
|
|
75
|
-
className={className}
|
|
76
|
-
aria-label={label(L, "aria.close")}
|
|
77
|
-
onClick={ui.closeCart}
|
|
78
|
-
>
|
|
79
|
-
{children}
|
|
80
|
-
</button>
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export const CartDrawer = {
|
|
85
|
-
Trigger,
|
|
86
|
-
Close,
|
|
87
|
-
};
|