@tonycodes-ui/mortar 0.0.0-stage → 0.11.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/AGENTS.md +10 -0
- package/CHANGELOG.md +276 -0
- package/CONTRIBUTING.md +48 -0
- package/DESIGN.md +51 -0
- package/LICENSE +21 -0
- package/README.md +155 -2
- package/TESTING.md +83 -0
- package/baseline-policy.json +16 -0
- package/bin/mortar.mjs +258 -0
- package/catalog.json +9703 -0
- package/cli/audit.mjs +943 -0
- package/cli/data.mjs +176 -0
- package/cli/mcp.mjs +216 -0
- package/cli/migrations.mjs +128 -0
- package/cli/packet.mjs +267 -0
- package/cli/search.mjs +246 -0
- package/cli/tools.mjs +229 -0
- package/design-principles.json +30 -0
- package/dist/agent-data.json +1 -0
- package/dist/application.d.ts +340 -0
- package/dist/application.js +240 -0
- package/dist/auth.d.ts +121 -0
- package/dist/auth.js +97 -0
- package/dist/brand.d.ts +69 -0
- package/dist/brand.js +266 -0
- package/dist/calendar.d.ts +75 -0
- package/dist/calendar.js +310 -0
- package/dist/choice.d.ts +36 -0
- package/dist/choice.js +36 -0
- package/dist/composer.d.ts +28 -0
- package/dist/composer.js +47 -0
- package/dist/display.d.ts +136 -0
- package/dist/display.js +75 -0
- package/dist/docs.d.ts +145 -0
- package/dist/docs.js +141 -0
- package/dist/ecommerce.d.ts +261 -0
- package/dist/ecommerce.js +177 -0
- package/dist/feedback.d.ts +80 -0
- package/dist/feedback.js +98 -0
- package/dist/foundation.d.ts +42 -0
- package/dist/foundation.js +121 -0
- package/dist/heading.d.ts +5 -0
- package/dist/heading.js +9 -0
- package/dist/icons.d.ts +50 -0
- package/dist/icons.js +70 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +17 -0
- package/dist/inspection.d.ts +24 -0
- package/dist/inspection.js +14 -0
- package/dist/internal.d.ts +492 -0
- package/dist/internal.js +273 -0
- package/dist/layout.d.ts +107 -0
- package/dist/layout.js +66 -0
- package/dist/marketing-sections.d.ts +161 -0
- package/dist/marketing-sections.js +58 -0
- package/dist/marketing.d.ts +254 -0
- package/dist/marketing.js +150 -0
- package/dist/navigation.d.ts +35 -0
- package/dist/navigation.js +44 -0
- package/dist/primitives.d.ts +258 -0
- package/dist/primitives.js +244 -0
- package/dist/showcase.d.ts +29 -0
- package/dist/showcase.js +10 -0
- package/dist/workspace.d.ts +80 -0
- package/dist/workspace.js +48 -0
- package/docs/agents.md +127 -0
- package/docs/auth-flows.md +71 -0
- package/docs/migrating-to-0.6.md +94 -0
- package/docs/theming.md +170 -0
- package/package.json +94 -4
- package/skills/mortar/SKILL.md +36 -0
- package/src/application.css +1487 -0
- package/src/application.tsx +1413 -0
- package/src/auth.tsx +452 -0
- package/src/brand.ts +335 -0
- package/src/calendar.css +468 -0
- package/src/calendar.tsx +868 -0
- package/src/choice.tsx +240 -0
- package/src/composer.tsx +158 -0
- package/src/data.css +690 -0
- package/src/display.tsx +452 -0
- package/src/docs.css +704 -0
- package/src/docs.tsx +737 -0
- package/src/ecommerce.css +810 -0
- package/src/ecommerce.tsx +1033 -0
- package/src/feedback.tsx +388 -0
- package/src/foundation.css +1867 -0
- package/src/foundation.tsx +227 -0
- package/src/heading.ts +13 -0
- package/src/icons.tsx +237 -0
- package/src/index.ts +230 -0
- package/src/inspection.tsx +91 -0
- package/src/internal.tsx +614 -0
- package/src/layout.tsx +342 -0
- package/src/marketing-sections.tsx +621 -0
- package/src/marketing.css +2400 -0
- package/src/marketing.tsx +1156 -0
- package/src/navigation.tsx +118 -0
- package/src/overlays.css +350 -0
- package/src/primitives.tsx +1135 -0
- package/src/showcase.tsx +120 -0
- package/src/tokens.css +449 -0
- package/src/workspace.tsx +333 -0
- package/styles.css +56 -0
|
@@ -0,0 +1,1033 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useEffect,
|
|
3
|
+
useId,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
type RefObject,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { KitLink } from "./navigation.js";
|
|
10
|
+
import {
|
|
11
|
+
ActionLink,
|
|
12
|
+
Button,
|
|
13
|
+
Checkbox,
|
|
14
|
+
EmptyState,
|
|
15
|
+
Modal,
|
|
16
|
+
} from "./primitives.js";
|
|
17
|
+
import { Avatar, FilterChip } from "./display.js";
|
|
18
|
+
import { ProgressSteps, type ProgressStep } from "./application.js";
|
|
19
|
+
import { ChevronDown, Star } from "./icons.js";
|
|
20
|
+
import { childLevel, headingTag, type HeadingLevel } from "./heading.js";
|
|
21
|
+
import {
|
|
22
|
+
AsComponentRoot,
|
|
23
|
+
Swatch,
|
|
24
|
+
TermList,
|
|
25
|
+
componentRoot,
|
|
26
|
+
cx,
|
|
27
|
+
} from "./internal.js";
|
|
28
|
+
|
|
29
|
+
export interface ProductImage {
|
|
30
|
+
src: string;
|
|
31
|
+
/** Describe the product shown; images are the main product information. */
|
|
32
|
+
alt: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ProductCard {
|
|
36
|
+
id: string;
|
|
37
|
+
name: string;
|
|
38
|
+
href: string;
|
|
39
|
+
/** Preformatted price, e.g. "£32" or "From £18". */
|
|
40
|
+
price: string;
|
|
41
|
+
image: ProductImage;
|
|
42
|
+
/** Colour, variant or category shown under the name. */
|
|
43
|
+
meta?: string;
|
|
44
|
+
badge?: string;
|
|
45
|
+
/** Supporting text under the name, e.g. in the card variant. */
|
|
46
|
+
description?: string;
|
|
47
|
+
/** Available colours as data: `swatch` is any CSS colour the consumer supplies; the label is announced. */
|
|
48
|
+
swatches?: { label: string; swatch: string }[];
|
|
49
|
+
/** Control under the card, e.g. an "Add to bag" button; it sits above the card link. */
|
|
50
|
+
action?: ReactNode;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ProductListProps {
|
|
54
|
+
/** Accessible name of the list. */
|
|
55
|
+
label: string;
|
|
56
|
+
products: ProductCard[];
|
|
57
|
+
columns?: 2 | 3 | 4;
|
|
58
|
+
/** Heading level of each product name (default 3). */
|
|
59
|
+
itemHeadingLevel?: HeadingLevel;
|
|
60
|
+
/**
|
|
61
|
+
* `cards` frames each product with a border, for listings with full
|
|
62
|
+
* details; `overlay` sets the name and price on a panel over the image.
|
|
63
|
+
*/
|
|
64
|
+
variant?: "plain" | "cards" | "overlay";
|
|
65
|
+
id?: string;
|
|
66
|
+
className?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Grid of product cards; each card's name link covers the card. */
|
|
70
|
+
export function ProductList({
|
|
71
|
+
label,
|
|
72
|
+
products,
|
|
73
|
+
columns = 4,
|
|
74
|
+
itemHeadingLevel = 3,
|
|
75
|
+
variant = "plain",
|
|
76
|
+
id,
|
|
77
|
+
className,
|
|
78
|
+
}: ProductListProps) {
|
|
79
|
+
const Heading = headingTag(itemHeadingLevel);
|
|
80
|
+
const overlay = variant === "overlay";
|
|
81
|
+
return (
|
|
82
|
+
<ul
|
|
83
|
+
id={id}
|
|
84
|
+
{...componentRoot("ProductList")}
|
|
85
|
+
className={cx("m-product-list", className)}
|
|
86
|
+
aria-label={label}
|
|
87
|
+
data-columns={columns}
|
|
88
|
+
data-variant={variant === "plain" ? undefined : variant}
|
|
89
|
+
>
|
|
90
|
+
{products.map((product) => {
|
|
91
|
+
const text = (
|
|
92
|
+
<>
|
|
93
|
+
<div className="m-product-info">
|
|
94
|
+
<Heading className="m-product-name">
|
|
95
|
+
<KitLink href={product.href}>{product.name}</KitLink>
|
|
96
|
+
</Heading>
|
|
97
|
+
{product.meta && <p className="m-product-meta">{product.meta}</p>}
|
|
98
|
+
{product.description && (
|
|
99
|
+
<p className="m-product-summary">{product.description}</p>
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
<p className="m-product-price">{product.price}</p>
|
|
103
|
+
</>
|
|
104
|
+
);
|
|
105
|
+
return (
|
|
106
|
+
<li key={product.id}>
|
|
107
|
+
<div className="m-product-image">
|
|
108
|
+
<img
|
|
109
|
+
src={product.image.src}
|
|
110
|
+
alt={product.image.alt}
|
|
111
|
+
loading="lazy"
|
|
112
|
+
/>
|
|
113
|
+
{product.badge && (
|
|
114
|
+
<span className="m-product-badge">{product.badge}</span>
|
|
115
|
+
)}
|
|
116
|
+
{overlay && <div className="m-product-overlay">{text}</div>}
|
|
117
|
+
</div>
|
|
118
|
+
{!overlay && text}
|
|
119
|
+
{product.swatches && product.swatches.length > 0 && (
|
|
120
|
+
<ul
|
|
121
|
+
className="m-product-swatches"
|
|
122
|
+
aria-label={`${product.name} colours`}
|
|
123
|
+
>
|
|
124
|
+
{product.swatches.map((colour) => (
|
|
125
|
+
<li key={colour.label}>
|
|
126
|
+
<Swatch color={colour.swatch} label={colour.label} />
|
|
127
|
+
</li>
|
|
128
|
+
))}
|
|
129
|
+
</ul>
|
|
130
|
+
)}
|
|
131
|
+
{product.action && (
|
|
132
|
+
<div className="m-product-card-action">{product.action}</div>
|
|
133
|
+
)}
|
|
134
|
+
</li>
|
|
135
|
+
);
|
|
136
|
+
})}
|
|
137
|
+
</ul>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface ProductOption {
|
|
142
|
+
id: string;
|
|
143
|
+
label: string;
|
|
144
|
+
values: {
|
|
145
|
+
value: string;
|
|
146
|
+
label: string;
|
|
147
|
+
/** CSS colour for a swatch; the label remains the accessible name. */
|
|
148
|
+
swatch?: string;
|
|
149
|
+
disabled?: boolean;
|
|
150
|
+
}[];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface ProductOverviewProps {
|
|
154
|
+
name: string;
|
|
155
|
+
price: string;
|
|
156
|
+
description?: ReactNode;
|
|
157
|
+
images: ProductImage[];
|
|
158
|
+
rating?: { value: number; count: number; href?: string };
|
|
159
|
+
options?: ProductOption[];
|
|
160
|
+
/** Selected value per option id. */
|
|
161
|
+
selected?: Record<string, string>;
|
|
162
|
+
onSelect?: (option: string, value: string) => void;
|
|
163
|
+
/** Called when the add form is submitted (button click or Enter). */
|
|
164
|
+
onAdd?: () => void;
|
|
165
|
+
addLabel?: string;
|
|
166
|
+
/** Shows the add button as busy while the item is being added. */
|
|
167
|
+
loading?: boolean;
|
|
168
|
+
details?: string[];
|
|
169
|
+
/** Level of the product name heading (default 1: the product page's title). */
|
|
170
|
+
headingLevel?: HeadingLevel;
|
|
171
|
+
/** Omit the name heading when a container (e.g. a dialog title) already shows it. */
|
|
172
|
+
hideName?: boolean;
|
|
173
|
+
/** `grid` shows every image at once: the first large, the rest tiered beneath it. */
|
|
174
|
+
gallery?: "thumbnails" | "grid";
|
|
175
|
+
id?: string;
|
|
176
|
+
className?: string;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Product page body: gallery, price, rating, option pickers, add to bag and details. */
|
|
180
|
+
export function ProductOverview({
|
|
181
|
+
name,
|
|
182
|
+
price,
|
|
183
|
+
description,
|
|
184
|
+
images,
|
|
185
|
+
rating,
|
|
186
|
+
options = [],
|
|
187
|
+
selected = {},
|
|
188
|
+
onSelect,
|
|
189
|
+
onAdd,
|
|
190
|
+
addLabel = "Add to bag",
|
|
191
|
+
loading = false,
|
|
192
|
+
details = [],
|
|
193
|
+
headingLevel = 1,
|
|
194
|
+
hideName = false,
|
|
195
|
+
gallery = "thumbnails",
|
|
196
|
+
id,
|
|
197
|
+
className,
|
|
198
|
+
}: ProductOverviewProps) {
|
|
199
|
+
const [current, setCurrent] = useState(0);
|
|
200
|
+
// A new image set (another product, or another colour) starts at its first image.
|
|
201
|
+
const imageKey = images.map((image) => image.src).join("\n");
|
|
202
|
+
const [shownKey, setShownKey] = useState(imageKey);
|
|
203
|
+
if (shownKey !== imageKey) {
|
|
204
|
+
setShownKey(imageKey);
|
|
205
|
+
setCurrent(0);
|
|
206
|
+
}
|
|
207
|
+
const group = useId();
|
|
208
|
+
const Heading = headingTag(headingLevel);
|
|
209
|
+
const shown = images[current] ?? images[0];
|
|
210
|
+
return (
|
|
211
|
+
<div
|
|
212
|
+
id={id}
|
|
213
|
+
{...componentRoot("ProductOverview")}
|
|
214
|
+
className={cx("m-product-overview", className)}
|
|
215
|
+
>
|
|
216
|
+
{gallery === "grid" ? (
|
|
217
|
+
<div className="m-product-gallery" data-gallery="grid">
|
|
218
|
+
{images.map((image) => (
|
|
219
|
+
<div key={image.src} className="m-product-image">
|
|
220
|
+
<img src={image.src} alt={image.alt} />
|
|
221
|
+
</div>
|
|
222
|
+
))}
|
|
223
|
+
</div>
|
|
224
|
+
) : (
|
|
225
|
+
<div className="m-product-gallery">
|
|
226
|
+
{shown && (
|
|
227
|
+
<div className="m-product-image">
|
|
228
|
+
<img src={shown.src} alt={shown.alt} />
|
|
229
|
+
</div>
|
|
230
|
+
)}
|
|
231
|
+
{images.length > 1 && (
|
|
232
|
+
<div
|
|
233
|
+
className="m-product-thumbs"
|
|
234
|
+
role="group"
|
|
235
|
+
aria-label="Product images"
|
|
236
|
+
>
|
|
237
|
+
{images.map((image, index) => (
|
|
238
|
+
<button
|
|
239
|
+
key={image.src}
|
|
240
|
+
type="button"
|
|
241
|
+
aria-pressed={index === current}
|
|
242
|
+
aria-label={`Show image ${index + 1} of ${images.length}: ${image.alt}`}
|
|
243
|
+
onClick={() => setCurrent(index)}
|
|
244
|
+
>
|
|
245
|
+
<img src={image.src} alt="" />
|
|
246
|
+
</button>
|
|
247
|
+
))}
|
|
248
|
+
</div>
|
|
249
|
+
)}
|
|
250
|
+
</div>
|
|
251
|
+
)}
|
|
252
|
+
<div className="m-product-details">
|
|
253
|
+
{!hideName && <Heading className="m-product-name">{name}</Heading>}
|
|
254
|
+
<p className="m-product-price">{price}</p>
|
|
255
|
+
{rating && (
|
|
256
|
+
<div className="m-rating">
|
|
257
|
+
<Stars
|
|
258
|
+
value={rating.value}
|
|
259
|
+
label={`Rated ${rating.value} out of 5 from ${rating.count} reviews`}
|
|
260
|
+
/>
|
|
261
|
+
{rating.href ? (
|
|
262
|
+
<KitLink href={rating.href}>{rating.count} reviews</KitLink>
|
|
263
|
+
) : (
|
|
264
|
+
<span aria-hidden="true">{rating.count} reviews</span>
|
|
265
|
+
)}
|
|
266
|
+
</div>
|
|
267
|
+
)}
|
|
268
|
+
{description && (
|
|
269
|
+
<div className="m-product-description">{description}</div>
|
|
270
|
+
)}
|
|
271
|
+
<form
|
|
272
|
+
className="m-product-form"
|
|
273
|
+
onSubmit={(event) => {
|
|
274
|
+
event.preventDefault();
|
|
275
|
+
if (!loading) onAdd?.();
|
|
276
|
+
}}
|
|
277
|
+
>
|
|
278
|
+
{options.map((option) => (
|
|
279
|
+
<fieldset
|
|
280
|
+
key={option.id}
|
|
281
|
+
className="m-product-option"
|
|
282
|
+
data-swatches={option.values.some((v) => v.swatch) || undefined}
|
|
283
|
+
>
|
|
284
|
+
<legend>{option.label}</legend>
|
|
285
|
+
<div>
|
|
286
|
+
{option.values.map((value) => (
|
|
287
|
+
<label
|
|
288
|
+
key={value.value}
|
|
289
|
+
data-disabled={value.disabled || undefined}
|
|
290
|
+
>
|
|
291
|
+
<input
|
|
292
|
+
type="radio"
|
|
293
|
+
name={`${group}-${option.id}`}
|
|
294
|
+
value={value.value}
|
|
295
|
+
checked={selected[option.id] === value.value}
|
|
296
|
+
disabled={value.disabled}
|
|
297
|
+
onChange={() => onSelect?.(option.id, value.value)}
|
|
298
|
+
/>
|
|
299
|
+
{value.swatch ? (
|
|
300
|
+
<Swatch color={value.swatch} label={value.label} />
|
|
301
|
+
) : (
|
|
302
|
+
<span>{value.label}</span>
|
|
303
|
+
)}
|
|
304
|
+
</label>
|
|
305
|
+
))}
|
|
306
|
+
</div>
|
|
307
|
+
</fieldset>
|
|
308
|
+
))}
|
|
309
|
+
<Button type="submit" loading={loading}>
|
|
310
|
+
{addLabel}
|
|
311
|
+
</Button>
|
|
312
|
+
</form>
|
|
313
|
+
{details.length > 0 && (
|
|
314
|
+
<ul className="m-product-highlights">
|
|
315
|
+
{details.map((detail) => (
|
|
316
|
+
<li key={detail}>{detail}</li>
|
|
317
|
+
))}
|
|
318
|
+
</ul>
|
|
319
|
+
)}
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export interface CartItem {
|
|
326
|
+
id: string;
|
|
327
|
+
name: string;
|
|
328
|
+
href: string;
|
|
329
|
+
price: string;
|
|
330
|
+
quantity: number;
|
|
331
|
+
image: ProductImage;
|
|
332
|
+
/** Chosen options, e.g. "Natural · Small". */
|
|
333
|
+
meta?: string;
|
|
334
|
+
/** Availability note, e.g. "In stock" or "Ships in 3 weeks". */
|
|
335
|
+
stock?: string;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Content of an empty state: a title, an optional description and action. */
|
|
339
|
+
export interface EmptyContent {
|
|
340
|
+
title: string;
|
|
341
|
+
description?: string;
|
|
342
|
+
action?: ReactNode;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export interface ShoppingCartProps {
|
|
346
|
+
items: CartItem[];
|
|
347
|
+
onQuantityChange: (id: string, quantity: number) => void;
|
|
348
|
+
onRemove: (id: string) => void;
|
|
349
|
+
/** Accessible name of the list of lines. */
|
|
350
|
+
label?: string;
|
|
351
|
+
maxQuantity?: number;
|
|
352
|
+
/** Level of each line's product name, and of the empty state's title (default 3). */
|
|
353
|
+
headingLevel?: HeadingLevel;
|
|
354
|
+
/** Shown when there are no items. */
|
|
355
|
+
empty?: EmptyContent;
|
|
356
|
+
/**
|
|
357
|
+
* Beside the lines when wide and below them when narrow, e.g. an
|
|
358
|
+
* OrderSummary with shipping and tax estimates and the checkout action.
|
|
359
|
+
*/
|
|
360
|
+
summary?: ReactNode;
|
|
361
|
+
/**
|
|
362
|
+
* Products to suggest under the bag, shown as a ProductList with a heading
|
|
363
|
+
* (default level 2; product names one level below). Also shown when the
|
|
364
|
+
* bag is empty.
|
|
365
|
+
*/
|
|
366
|
+
related?: {
|
|
367
|
+
title: string;
|
|
368
|
+
products: ProductCard[];
|
|
369
|
+
headingLevel?: HeadingLevel;
|
|
370
|
+
};
|
|
371
|
+
id?: string;
|
|
372
|
+
className?: string;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Bag contents with per-line quantity and remove controls. After a removal,
|
|
377
|
+
* focus moves to the next line's Remove button (the previous one after the
|
|
378
|
+
* last line), or to the empty state once the bag is empty.
|
|
379
|
+
*/
|
|
380
|
+
export function ShoppingCart({
|
|
381
|
+
summary,
|
|
382
|
+
related,
|
|
383
|
+
id,
|
|
384
|
+
className,
|
|
385
|
+
...props
|
|
386
|
+
}: ShoppingCartProps) {
|
|
387
|
+
if (!summary && !related)
|
|
388
|
+
return <CartLines {...props} id={id} className={className} root />;
|
|
389
|
+
return (
|
|
390
|
+
<div
|
|
391
|
+
id={id}
|
|
392
|
+
{...componentRoot("ShoppingCart")}
|
|
393
|
+
className={cx("m-cart-layout", className)}
|
|
394
|
+
data-summary={summary ? true : undefined}
|
|
395
|
+
>
|
|
396
|
+
<CartLines {...props} />
|
|
397
|
+
{summary && <div className="m-cart-summary">{summary}</div>}
|
|
398
|
+
{related && <CartRelated {...related} />}
|
|
399
|
+
</div>
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function CartRelated({
|
|
404
|
+
title,
|
|
405
|
+
products,
|
|
406
|
+
headingLevel = 2,
|
|
407
|
+
}: NonNullable<ShoppingCartProps["related"]>) {
|
|
408
|
+
const titleId = useId();
|
|
409
|
+
const Heading = headingTag(headingLevel);
|
|
410
|
+
return (
|
|
411
|
+
<section className="m-cart-related" aria-labelledby={titleId}>
|
|
412
|
+
<Heading id={titleId} className="m-cart-related-title">
|
|
413
|
+
{title}
|
|
414
|
+
</Heading>
|
|
415
|
+
<ProductList
|
|
416
|
+
label={title}
|
|
417
|
+
products={products}
|
|
418
|
+
itemHeadingLevel={childLevel(headingLevel)}
|
|
419
|
+
/>
|
|
420
|
+
</section>
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function CartLines({
|
|
425
|
+
items,
|
|
426
|
+
onQuantityChange,
|
|
427
|
+
onRemove,
|
|
428
|
+
label = "Items in your bag",
|
|
429
|
+
maxQuantity = 8,
|
|
430
|
+
headingLevel = 3,
|
|
431
|
+
empty = {
|
|
432
|
+
title: "Your bag is empty",
|
|
433
|
+
description: "Items you add will appear here.",
|
|
434
|
+
},
|
|
435
|
+
id,
|
|
436
|
+
className,
|
|
437
|
+
root: isRoot = false,
|
|
438
|
+
}: Omit<ShoppingCartProps, "summary" | "related"> & {
|
|
439
|
+
/** The lines are ShoppingCart's root (no summary or related products). */
|
|
440
|
+
root?: boolean;
|
|
441
|
+
}) {
|
|
442
|
+
const base = useId();
|
|
443
|
+
const Heading = headingTag(headingLevel);
|
|
444
|
+
const root = useRef<HTMLElement>(null);
|
|
445
|
+
// The line whose Remove button was pressed, until the consumer drops it.
|
|
446
|
+
const removing = useRef<{ index: number; id: string } | null>(null);
|
|
447
|
+
useEffect(() => {
|
|
448
|
+
const pending = removing.current;
|
|
449
|
+
if (!pending || items.some((item) => item.id === pending.id)) return;
|
|
450
|
+
removing.current = null;
|
|
451
|
+
const buttons =
|
|
452
|
+
root.current?.querySelectorAll<HTMLButtonElement>(".m-cart-remove");
|
|
453
|
+
if (buttons?.length)
|
|
454
|
+
buttons[Math.min(pending.index, buttons.length - 1)].focus();
|
|
455
|
+
else root.current?.focus();
|
|
456
|
+
}, [items]);
|
|
457
|
+
if (!items.length)
|
|
458
|
+
return (
|
|
459
|
+
<div
|
|
460
|
+
id={id}
|
|
461
|
+
{...componentRoot("ShoppingCart", isRoot)}
|
|
462
|
+
className={cx("m-cart-empty", className)}
|
|
463
|
+
ref={root as RefObject<HTMLDivElement>}
|
|
464
|
+
tabIndex={-1}
|
|
465
|
+
>
|
|
466
|
+
<EmptyState
|
|
467
|
+
title={empty.title}
|
|
468
|
+
description={empty.description}
|
|
469
|
+
action={empty.action}
|
|
470
|
+
headingLevel={headingLevel}
|
|
471
|
+
/>
|
|
472
|
+
</div>
|
|
473
|
+
);
|
|
474
|
+
return (
|
|
475
|
+
<ul
|
|
476
|
+
id={id}
|
|
477
|
+
{...componentRoot("ShoppingCart", isRoot)}
|
|
478
|
+
className={cx("m-cart", className)}
|
|
479
|
+
aria-label={label}
|
|
480
|
+
ref={root as RefObject<HTMLUListElement>}
|
|
481
|
+
>
|
|
482
|
+
{items.map((item, index) => {
|
|
483
|
+
const quantityId = `${base}-${item.id}`;
|
|
484
|
+
const choices = Array.from(
|
|
485
|
+
{ length: Math.max(maxQuantity, item.quantity) },
|
|
486
|
+
(_, i) => i + 1,
|
|
487
|
+
);
|
|
488
|
+
return (
|
|
489
|
+
<li key={item.id}>
|
|
490
|
+
<div className="m-product-image">
|
|
491
|
+
<img src={item.image.src} alt={item.image.alt} loading="lazy" />
|
|
492
|
+
</div>
|
|
493
|
+
<div className="m-cart-info">
|
|
494
|
+
<Heading className="m-cart-item-title">
|
|
495
|
+
<KitLink href={item.href}>{item.name}</KitLink>
|
|
496
|
+
</Heading>
|
|
497
|
+
{item.meta && <p className="m-product-meta">{item.meta}</p>}
|
|
498
|
+
<p className="m-product-price">{item.price}</p>
|
|
499
|
+
{item.stock && <p className="m-cart-stock">{item.stock}</p>}
|
|
500
|
+
</div>
|
|
501
|
+
<div className="m-cart-controls">
|
|
502
|
+
<label className="m-sr-only" htmlFor={quantityId}>
|
|
503
|
+
Quantity, {item.name}
|
|
504
|
+
</label>
|
|
505
|
+
<div className="m-select-control">
|
|
506
|
+
<select
|
|
507
|
+
id={quantityId}
|
|
508
|
+
value={item.quantity}
|
|
509
|
+
onChange={(event) =>
|
|
510
|
+
onQuantityChange(item.id, Number(event.target.value))
|
|
511
|
+
}
|
|
512
|
+
>
|
|
513
|
+
{choices.map((n) => (
|
|
514
|
+
<option key={n} value={n}>
|
|
515
|
+
{n}
|
|
516
|
+
</option>
|
|
517
|
+
))}
|
|
518
|
+
</select>
|
|
519
|
+
<ChevronDown />
|
|
520
|
+
</div>
|
|
521
|
+
<button
|
|
522
|
+
type="button"
|
|
523
|
+
className="m-cart-remove"
|
|
524
|
+
onClick={() => {
|
|
525
|
+
removing.current = { index, id: item.id };
|
|
526
|
+
onRemove(item.id);
|
|
527
|
+
}}
|
|
528
|
+
>
|
|
529
|
+
Remove<span className="m-sr-only"> {item.name}</span>
|
|
530
|
+
</button>
|
|
531
|
+
</div>
|
|
532
|
+
</li>
|
|
533
|
+
);
|
|
534
|
+
})}
|
|
535
|
+
</ul>
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Cost breakdown with a distinguished total and a checkout action. */
|
|
540
|
+
export function OrderSummary({
|
|
541
|
+
title = "Order summary",
|
|
542
|
+
lines,
|
|
543
|
+
total,
|
|
544
|
+
action,
|
|
545
|
+
headingLevel = 2,
|
|
546
|
+
children,
|
|
547
|
+
id,
|
|
548
|
+
className,
|
|
549
|
+
}: {
|
|
550
|
+
title?: string;
|
|
551
|
+
/**
|
|
552
|
+
* Cost lines. `hint` explains an estimate under its label, e.g. "Calculated
|
|
553
|
+
* at checkout" or a link to how shipping is worked out.
|
|
554
|
+
*/
|
|
555
|
+
lines: { label: string; value: string; hint?: ReactNode }[];
|
|
556
|
+
total: { label: string; value: string };
|
|
557
|
+
action?: ReactNode;
|
|
558
|
+
headingLevel?: HeadingLevel;
|
|
559
|
+
/** Notes such as a discount code field or delivery estimate. */
|
|
560
|
+
children?: ReactNode;
|
|
561
|
+
id?: string;
|
|
562
|
+
className?: string;
|
|
563
|
+
}) {
|
|
564
|
+
const titleId = useId();
|
|
565
|
+
const Heading = headingTag(headingLevel);
|
|
566
|
+
return (
|
|
567
|
+
<section
|
|
568
|
+
id={id}
|
|
569
|
+
{...componentRoot("OrderSummary")}
|
|
570
|
+
className={cx("m-order-summary", className)}
|
|
571
|
+
aria-labelledby={titleId}
|
|
572
|
+
>
|
|
573
|
+
<Heading id={titleId} className="m-order-summary-title">
|
|
574
|
+
{title}
|
|
575
|
+
</Heading>
|
|
576
|
+
<TermList
|
|
577
|
+
items={lines.map((line) => ({
|
|
578
|
+
key: line.label,
|
|
579
|
+
term: line.hint ? (
|
|
580
|
+
<>
|
|
581
|
+
{line.label}
|
|
582
|
+
<span className="m-order-hint">{line.hint}</span>
|
|
583
|
+
</>
|
|
584
|
+
) : (
|
|
585
|
+
line.label
|
|
586
|
+
),
|
|
587
|
+
details: line.value,
|
|
588
|
+
}))}
|
|
589
|
+
>
|
|
590
|
+
<div className="m-order-total">
|
|
591
|
+
<dt>{total.label}</dt>
|
|
592
|
+
<dd>{total.value}</dd>
|
|
593
|
+
</div>
|
|
594
|
+
</TermList>
|
|
595
|
+
{children}
|
|
596
|
+
{action && <div className="m-order-action">{action}</div>}
|
|
597
|
+
</section>
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Product overview in a dialog, with a link to the full product page. */
|
|
602
|
+
export function ProductQuickView({
|
|
603
|
+
open,
|
|
604
|
+
onClose,
|
|
605
|
+
href,
|
|
606
|
+
detailsLabel = "View full details",
|
|
607
|
+
className,
|
|
608
|
+
...product
|
|
609
|
+
}: Omit<
|
|
610
|
+
ProductOverviewProps,
|
|
611
|
+
"headingLevel" | "hideName" | "id" | "className"
|
|
612
|
+
> & {
|
|
613
|
+
open: boolean;
|
|
614
|
+
onClose: () => void;
|
|
615
|
+
/** Full product page. */
|
|
616
|
+
href: string;
|
|
617
|
+
detailsLabel?: string;
|
|
618
|
+
/** Class on the dialog's content wrapper. */
|
|
619
|
+
className?: string;
|
|
620
|
+
}) {
|
|
621
|
+
return (
|
|
622
|
+
<AsComponentRoot name="ProductQuickView" of="Modal">
|
|
623
|
+
<Modal open={open} onClose={onClose} title={product.name} size="lg">
|
|
624
|
+
<div className={cx("m-quick-view", className)}>
|
|
625
|
+
<ProductOverview {...product} hideName />
|
|
626
|
+
<ActionLink label={detailsLabel} href={href} variant="ghost" />
|
|
627
|
+
</div>
|
|
628
|
+
</Modal>
|
|
629
|
+
</AsComponentRoot>
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
export interface FilterFacet {
|
|
634
|
+
id: string;
|
|
635
|
+
label: string;
|
|
636
|
+
options: { value: string; label: string; count?: number }[];
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Collapsible checkbox facets with counts, plus removable chips for active
|
|
641
|
+
* filters. Every change, Clear all included, calls `onChange` once with the
|
|
642
|
+
* complete next selection.
|
|
643
|
+
*/
|
|
644
|
+
export function ProductFilters({
|
|
645
|
+
facets,
|
|
646
|
+
selected,
|
|
647
|
+
onChange,
|
|
648
|
+
label = "Filters",
|
|
649
|
+
id,
|
|
650
|
+
className,
|
|
651
|
+
}: {
|
|
652
|
+
facets: FilterFacet[];
|
|
653
|
+
/** Selected values per facet id. */
|
|
654
|
+
selected: Record<string, string[]>;
|
|
655
|
+
/** The complete next selection, keyed by facet id. */
|
|
656
|
+
onChange: (selected: Record<string, string[]>) => void;
|
|
657
|
+
label?: string;
|
|
658
|
+
id?: string;
|
|
659
|
+
className?: string;
|
|
660
|
+
}) {
|
|
661
|
+
const root = useRef<HTMLElement>(null);
|
|
662
|
+
const active = facets.flatMap((facet) =>
|
|
663
|
+
(selected[facet.id] || []).map((value) => ({
|
|
664
|
+
facet,
|
|
665
|
+
value,
|
|
666
|
+
label:
|
|
667
|
+
facet.options.find((option) => option.value === value)?.label ?? value,
|
|
668
|
+
})),
|
|
669
|
+
);
|
|
670
|
+
const toggle = (facet: string, value: string, on: boolean) => {
|
|
671
|
+
const current = selected[facet] || [];
|
|
672
|
+
onChange({
|
|
673
|
+
...selected,
|
|
674
|
+
[facet]: on ? [...current, value] : current.filter((v) => v !== value),
|
|
675
|
+
});
|
|
676
|
+
};
|
|
677
|
+
const clearAll = () => {
|
|
678
|
+
onChange(
|
|
679
|
+
Object.fromEntries(
|
|
680
|
+
[...Object.keys(selected), ...facets.map((facet) => facet.id)].map(
|
|
681
|
+
(facet) => [facet, []],
|
|
682
|
+
),
|
|
683
|
+
),
|
|
684
|
+
);
|
|
685
|
+
// The chips and this button disappear: continue from the first facet.
|
|
686
|
+
root.current?.querySelector("summary")?.focus();
|
|
687
|
+
};
|
|
688
|
+
// A removed chip takes its button with it: focus the chip that takes its
|
|
689
|
+
// place (or the previous one), else the first facet.
|
|
690
|
+
const removedChip = useRef<number | null>(null);
|
|
691
|
+
const activeKey = active
|
|
692
|
+
.map((item) => `${item.facet.id}:${item.value}`)
|
|
693
|
+
.join();
|
|
694
|
+
useEffect(() => {
|
|
695
|
+
const index = removedChip.current;
|
|
696
|
+
if (index === null) return;
|
|
697
|
+
removedChip.current = null;
|
|
698
|
+
const buttons = root.current?.querySelectorAll<HTMLButtonElement>(
|
|
699
|
+
".m-filter-chips .m-chip button",
|
|
700
|
+
);
|
|
701
|
+
if (buttons?.length) buttons[Math.min(index, buttons.length - 1)].focus();
|
|
702
|
+
else root.current?.querySelector("summary")?.focus();
|
|
703
|
+
}, [activeKey]);
|
|
704
|
+
return (
|
|
705
|
+
<section
|
|
706
|
+
id={id}
|
|
707
|
+
ref={root}
|
|
708
|
+
{...componentRoot("ProductFilters")}
|
|
709
|
+
className={cx("m-filters", className)}
|
|
710
|
+
aria-label={label}
|
|
711
|
+
>
|
|
712
|
+
{active.length > 0 && (
|
|
713
|
+
<div
|
|
714
|
+
className="m-filter-chips"
|
|
715
|
+
role="group"
|
|
716
|
+
aria-label="Active filters"
|
|
717
|
+
>
|
|
718
|
+
{active.map((item, index) => (
|
|
719
|
+
<FilterChip
|
|
720
|
+
key={`${item.facet.id}:${item.value}`}
|
|
721
|
+
label={item.label}
|
|
722
|
+
onRemove={() => {
|
|
723
|
+
removedChip.current = index;
|
|
724
|
+
toggle(item.facet.id, item.value, false);
|
|
725
|
+
}}
|
|
726
|
+
/>
|
|
727
|
+
))}
|
|
728
|
+
<button type="button" className="m-filter-clear" onClick={clearAll}>
|
|
729
|
+
Clear all
|
|
730
|
+
</button>
|
|
731
|
+
</div>
|
|
732
|
+
)}
|
|
733
|
+
{facets.map((facet) => {
|
|
734
|
+
const count = (selected[facet.id] || []).length;
|
|
735
|
+
return (
|
|
736
|
+
<details key={facet.id} open>
|
|
737
|
+
<summary>
|
|
738
|
+
{facet.label}
|
|
739
|
+
{count > 0 && <span className="m-filter-active">{count}</span>}
|
|
740
|
+
</summary>
|
|
741
|
+
<fieldset>
|
|
742
|
+
<legend className="m-sr-only">{facet.label}</legend>
|
|
743
|
+
{facet.options.map((option) => (
|
|
744
|
+
<Checkbox
|
|
745
|
+
key={option.value}
|
|
746
|
+
checked={(selected[facet.id] || []).includes(option.value)}
|
|
747
|
+
onChange={(event) =>
|
|
748
|
+
toggle(facet.id, option.value, event.target.checked)
|
|
749
|
+
}
|
|
750
|
+
label={
|
|
751
|
+
<>
|
|
752
|
+
{option.label}
|
|
753
|
+
{option.count !== undefined && (
|
|
754
|
+
<span className="m-filter-count">({option.count})</span>
|
|
755
|
+
)}
|
|
756
|
+
</>
|
|
757
|
+
}
|
|
758
|
+
/>
|
|
759
|
+
))}
|
|
760
|
+
</fieldset>
|
|
761
|
+
</details>
|
|
762
|
+
);
|
|
763
|
+
})}
|
|
764
|
+
</section>
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function Stars({ value, label }: { value: number; label: string }) {
|
|
769
|
+
return (
|
|
770
|
+
<div className="m-stars" role="img" aria-label={label}>
|
|
771
|
+
{[1, 2, 3, 4, 5].map((n) => (
|
|
772
|
+
<span key={n} data-filled={value >= n - 0.25 || undefined}>
|
|
773
|
+
<Star />
|
|
774
|
+
</span>
|
|
775
|
+
))}
|
|
776
|
+
</div>
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
export interface Review {
|
|
781
|
+
id: string;
|
|
782
|
+
author: string;
|
|
783
|
+
rating: number;
|
|
784
|
+
date: string;
|
|
785
|
+
title: string;
|
|
786
|
+
body: ReactNode;
|
|
787
|
+
/** Reviewer's photo, shown with `avatars`; without it the Avatar shows initials. */
|
|
788
|
+
avatar?: string;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** Rating summary with a star distribution, followed by individual reviews. */
|
|
792
|
+
export function ProductReviews({
|
|
793
|
+
title = "Customer reviews",
|
|
794
|
+
average,
|
|
795
|
+
count,
|
|
796
|
+
distribution,
|
|
797
|
+
reviews,
|
|
798
|
+
action,
|
|
799
|
+
avatars = false,
|
|
800
|
+
headingLevel = 2,
|
|
801
|
+
id,
|
|
802
|
+
className,
|
|
803
|
+
}: {
|
|
804
|
+
title?: string;
|
|
805
|
+
average: number;
|
|
806
|
+
count: number;
|
|
807
|
+
distribution: { stars: number; count: number }[];
|
|
808
|
+
reviews: Review[];
|
|
809
|
+
/** E.g. a "Write a review" button. */
|
|
810
|
+
action?: ReactNode;
|
|
811
|
+
/** Shows each reviewer's Avatar (their `avatar` photo or initials) beside their name. */
|
|
812
|
+
avatars?: boolean;
|
|
813
|
+
/** Level of the title; each review's title is one level below. */
|
|
814
|
+
headingLevel?: HeadingLevel;
|
|
815
|
+
id?: string;
|
|
816
|
+
className?: string;
|
|
817
|
+
}) {
|
|
818
|
+
const base = useId();
|
|
819
|
+
const Heading = headingTag(headingLevel);
|
|
820
|
+
const ReviewHeading = headingTag(childLevel(headingLevel));
|
|
821
|
+
return (
|
|
822
|
+
<section
|
|
823
|
+
id={id}
|
|
824
|
+
{...componentRoot("ProductReviews")}
|
|
825
|
+
className={cx("m-reviews", className)}
|
|
826
|
+
aria-labelledby={base}
|
|
827
|
+
data-avatars={avatars || undefined}
|
|
828
|
+
>
|
|
829
|
+
<div className="m-reviews-summary">
|
|
830
|
+
<Heading id={base} className="m-reviews-title">
|
|
831
|
+
{title}
|
|
832
|
+
</Heading>
|
|
833
|
+
<div className="m-rating">
|
|
834
|
+
<Stars
|
|
835
|
+
value={average}
|
|
836
|
+
label={`Rated ${average} out of 5 from ${count} reviews`}
|
|
837
|
+
/>
|
|
838
|
+
<span aria-hidden="true">Based on {count} reviews</span>
|
|
839
|
+
</div>
|
|
840
|
+
<TermList
|
|
841
|
+
className="m-reviews-distribution"
|
|
842
|
+
items={distribution.map((row) => ({
|
|
843
|
+
key: String(row.stars),
|
|
844
|
+
term: (
|
|
845
|
+
<>
|
|
846
|
+
{row.stars} {row.stars === 1 ? "star" : "stars"}
|
|
847
|
+
</>
|
|
848
|
+
),
|
|
849
|
+
details: (
|
|
850
|
+
<>
|
|
851
|
+
<meter
|
|
852
|
+
min={0}
|
|
853
|
+
max={count}
|
|
854
|
+
value={row.count}
|
|
855
|
+
aria-label={`${row.stars} ${row.stars === 1 ? "star" : "stars"}: ${row.count} ${row.count === 1 ? "review" : "reviews"}`}
|
|
856
|
+
/>
|
|
857
|
+
<span>{row.count}</span>
|
|
858
|
+
</>
|
|
859
|
+
),
|
|
860
|
+
}))}
|
|
861
|
+
/>
|
|
862
|
+
{action}
|
|
863
|
+
</div>
|
|
864
|
+
<div className="m-review-list">
|
|
865
|
+
{reviews.map((review) => (
|
|
866
|
+
<article
|
|
867
|
+
key={review.id}
|
|
868
|
+
className="m-review"
|
|
869
|
+
aria-labelledby={`${base}-${review.id}`}
|
|
870
|
+
>
|
|
871
|
+
<header>
|
|
872
|
+
{avatars && (
|
|
873
|
+
<Avatar
|
|
874
|
+
className="m-review-avatar"
|
|
875
|
+
name={review.author}
|
|
876
|
+
src={review.avatar}
|
|
877
|
+
decorative
|
|
878
|
+
/>
|
|
879
|
+
)}
|
|
880
|
+
<Stars
|
|
881
|
+
value={review.rating}
|
|
882
|
+
label={`Rated ${review.rating} out of 5`}
|
|
883
|
+
/>
|
|
884
|
+
<ReviewHeading
|
|
885
|
+
id={`${base}-${review.id}`}
|
|
886
|
+
className="m-review-title"
|
|
887
|
+
>
|
|
888
|
+
{review.title}
|
|
889
|
+
</ReviewHeading>
|
|
890
|
+
<p className="m-review-meta">
|
|
891
|
+
{review.author} · {review.date}
|
|
892
|
+
</p>
|
|
893
|
+
</header>
|
|
894
|
+
<div className="m-review-body">{review.body}</div>
|
|
895
|
+
</article>
|
|
896
|
+
))}
|
|
897
|
+
</div>
|
|
898
|
+
</section>
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
export interface Order {
|
|
903
|
+
id: string;
|
|
904
|
+
number: string;
|
|
905
|
+
date: string;
|
|
906
|
+
total: string;
|
|
907
|
+
status: string;
|
|
908
|
+
href: string;
|
|
909
|
+
/** Order-level controls in the header, e.g. "View invoice" or an actions menu. */
|
|
910
|
+
actions?: ReactNode;
|
|
911
|
+
/**
|
|
912
|
+
* Delivery stages shown as a tracker under the status, e.g. Order placed,
|
|
913
|
+
* Processing, Shipped and Delivered, with the latest stage `current`.
|
|
914
|
+
*/
|
|
915
|
+
progress?: ProgressStep[];
|
|
916
|
+
items: {
|
|
917
|
+
id: string;
|
|
918
|
+
name: string;
|
|
919
|
+
image: ProductImage;
|
|
920
|
+
quantity: number;
|
|
921
|
+
price: string;
|
|
922
|
+
href?: string;
|
|
923
|
+
/** Per-item controls, e.g. "Buy again" and "View product". */
|
|
924
|
+
actions?: ReactNode;
|
|
925
|
+
}[];
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/** Past orders, each with its summary, status and line items. */
|
|
929
|
+
export function OrderHistory({
|
|
930
|
+
orders,
|
|
931
|
+
headingLevel = 2,
|
|
932
|
+
empty = {
|
|
933
|
+
title: "No orders yet",
|
|
934
|
+
description: "Orders you place will appear here.",
|
|
935
|
+
},
|
|
936
|
+
id,
|
|
937
|
+
className,
|
|
938
|
+
}: {
|
|
939
|
+
orders: Order[];
|
|
940
|
+
/** Level of each order's heading, and of the empty state's title (default 2). */
|
|
941
|
+
headingLevel?: HeadingLevel;
|
|
942
|
+
/** Shown when there are no orders. */
|
|
943
|
+
empty?: EmptyContent;
|
|
944
|
+
id?: string;
|
|
945
|
+
className?: string;
|
|
946
|
+
}) {
|
|
947
|
+
const base = useId();
|
|
948
|
+
if (!orders.length)
|
|
949
|
+
return (
|
|
950
|
+
<div
|
|
951
|
+
id={id}
|
|
952
|
+
{...componentRoot("OrderHistory")}
|
|
953
|
+
className={cx("m-order-history", className)}
|
|
954
|
+
>
|
|
955
|
+
<EmptyState
|
|
956
|
+
title={empty.title}
|
|
957
|
+
description={empty.description}
|
|
958
|
+
action={empty.action}
|
|
959
|
+
headingLevel={headingLevel}
|
|
960
|
+
/>
|
|
961
|
+
</div>
|
|
962
|
+
);
|
|
963
|
+
const Heading = headingTag(headingLevel);
|
|
964
|
+
return (
|
|
965
|
+
<div
|
|
966
|
+
id={id}
|
|
967
|
+
{...componentRoot("OrderHistory")}
|
|
968
|
+
className={cx("m-order-history", className)}
|
|
969
|
+
>
|
|
970
|
+
{orders.map((order) => {
|
|
971
|
+
const titleId = `${base}-${order.id}`;
|
|
972
|
+
return (
|
|
973
|
+
<section key={order.id} className="m-order" aria-labelledby={titleId}>
|
|
974
|
+
<header className="m-order-header">
|
|
975
|
+
<Heading id={titleId} className="m-order-title">
|
|
976
|
+
Order {order.number}
|
|
977
|
+
</Heading>
|
|
978
|
+
<TermList
|
|
979
|
+
items={[
|
|
980
|
+
{ term: "Date placed", details: order.date },
|
|
981
|
+
{ term: "Total", details: order.total },
|
|
982
|
+
]}
|
|
983
|
+
/>
|
|
984
|
+
<KitLink href={order.href}>
|
|
985
|
+
View order<span className="m-sr-only"> {order.number}</span>
|
|
986
|
+
</KitLink>
|
|
987
|
+
{order.actions && (
|
|
988
|
+
<div className="m-order-actions">{order.actions}</div>
|
|
989
|
+
)}
|
|
990
|
+
</header>
|
|
991
|
+
<p className="m-order-status">{order.status}</p>
|
|
992
|
+
{order.progress && order.progress.length > 0 && (
|
|
993
|
+
<ProgressSteps
|
|
994
|
+
className="m-order-progress"
|
|
995
|
+
steps={order.progress}
|
|
996
|
+
variant="bar"
|
|
997
|
+
navigation={false}
|
|
998
|
+
label={`Order ${order.number} progress`}
|
|
999
|
+
/>
|
|
1000
|
+
)}
|
|
1001
|
+
<ul className="m-order-items">
|
|
1002
|
+
{order.items.map((item) => (
|
|
1003
|
+
<li key={item.id}>
|
|
1004
|
+
<div className="m-product-image">
|
|
1005
|
+
<img
|
|
1006
|
+
src={item.image.src}
|
|
1007
|
+
alt={item.image.alt}
|
|
1008
|
+
loading="lazy"
|
|
1009
|
+
/>
|
|
1010
|
+
</div>
|
|
1011
|
+
<div>
|
|
1012
|
+
<p className="m-order-item-name">
|
|
1013
|
+
{item.href ? (
|
|
1014
|
+
<KitLink href={item.href}>{item.name}</KitLink>
|
|
1015
|
+
) : (
|
|
1016
|
+
item.name
|
|
1017
|
+
)}
|
|
1018
|
+
</p>
|
|
1019
|
+
<p className="m-product-meta">Qty {item.quantity}</p>
|
|
1020
|
+
</div>
|
|
1021
|
+
<p className="m-product-price">{item.price}</p>
|
|
1022
|
+
{item.actions && (
|
|
1023
|
+
<div className="m-order-item-actions">{item.actions}</div>
|
|
1024
|
+
)}
|
|
1025
|
+
</li>
|
|
1026
|
+
))}
|
|
1027
|
+
</ul>
|
|
1028
|
+
</section>
|
|
1029
|
+
);
|
|
1030
|
+
})}
|
|
1031
|
+
</div>
|
|
1032
|
+
);
|
|
1033
|
+
}
|