@akinon/pz-theme 2.0.37-beta.0 → 2.0.37

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.
Files changed (78) hide show
  1. package/CHANGELOG.md +4 -4
  2. package/package.json +4 -4
  3. package/src/__tests__/__snapshots__/facet-filters-block.test.tsx.snap +11 -0
  4. package/src/__tests__/custom-css.test.ts +181 -0
  5. package/src/__tests__/designer-chrome-clipping.test.tsx +497 -0
  6. package/src/__tests__/facet-filters-block.test.tsx +707 -0
  7. package/src/__tests__/item-template.test.tsx +184 -0
  8. package/src/__tests__/listing-controls.test.tsx +961 -0
  9. package/src/__tests__/part-style-scope.test.tsx +229 -0
  10. package/src/__tests__/part-styles.test.ts +311 -0
  11. package/src/__tests__/registry-parity.test.ts +42 -0
  12. package/src/__tests__/row-visibility.test.ts +672 -0
  13. package/src/__tests__/sort-select-page-cursor.test.tsx +336 -0
  14. package/src/__tests__/theme-block-html-id.test.tsx +167 -0
  15. package/src/blocks/__tests__/__snapshots__/product-gallery-block.test.tsx.snap +15 -0
  16. package/src/blocks/__tests__/accordion-block-state.test.tsx +201 -0
  17. package/src/blocks/__tests__/facet-filters-block.test.tsx +190 -0
  18. package/src/blocks/__tests__/listing-context-card-model.test.tsx +230 -0
  19. package/src/blocks/__tests__/listing-lookup-hardening.test.ts +80 -0
  20. package/src/blocks/__tests__/product-gallery-block.test.tsx +974 -0
  21. package/src/blocks/__tests__/product-grid-composition.test.tsx +570 -0
  22. package/src/blocks/__tests__/product-grid-lookups.test.tsx +230 -0
  23. package/src/blocks/__tests__/product-price-block.test.tsx +248 -0
  24. package/src/blocks/__tests__/product-recommendations-block.test.tsx +702 -0
  25. package/src/blocks/__tests__/share-block.test.tsx +392 -0
  26. package/src/blocks/__tests__/store-locator-block.test.tsx +691 -0
  27. package/src/blocks/__tests__/variant-selector-block.test.tsx +817 -0
  28. package/src/blocks/__tests__/variant-selector-golden.json +20 -0
  29. package/src/blocks/account-menu-block.tsx +264 -0
  30. package/src/blocks/add-to-cart-block.tsx +417 -0
  31. package/src/blocks/address-list-block.tsx +348 -0
  32. package/src/blocks/applied-filters-block.tsx +288 -0
  33. package/src/blocks/auth-form-block.tsx +295 -0
  34. package/src/blocks/basket-items-block.tsx +457 -0
  35. package/src/blocks/basket-summary-block.tsx +404 -0
  36. package/src/blocks/block-renderer-registry.tsx +94 -0
  37. package/src/blocks/change-password-form-block.tsx +240 -0
  38. package/src/blocks/checkout-steps-block.tsx +288 -0
  39. package/src/blocks/checkout-summary-block.tsx +449 -0
  40. package/src/blocks/countdown-block.tsx +182 -0
  41. package/src/blocks/coupon-list-block.tsx +292 -0
  42. package/src/blocks/facet-filters-block.tsx +1686 -0
  43. package/src/blocks/faq-accordion-block.tsx +358 -0
  44. package/src/blocks/favorite-button-block.tsx +252 -0
  45. package/src/blocks/favourites-grid-block.tsx +510 -0
  46. package/src/blocks/forgot-password-block.tsx +155 -0
  47. package/src/blocks/item-template-utils.ts +131 -0
  48. package/src/blocks/item-template.tsx +164 -0
  49. package/src/blocks/listing-breadcrumb-block.tsx +175 -0
  50. package/src/blocks/listing-context.ts +850 -0
  51. package/src/blocks/listing-pagination-block.tsx +923 -0
  52. package/src/blocks/listing-result-count-block.tsx +126 -0
  53. package/src/blocks/listing-title-block.tsx +115 -0
  54. package/src/blocks/oauth-buttons-block.tsx +205 -0
  55. package/src/blocks/order-detail-block.tsx +474 -0
  56. package/src/blocks/order-list-block.tsx +274 -0
  57. package/src/blocks/order-success-block.tsx +641 -0
  58. package/src/blocks/order-tracking-block.tsx +533 -0
  59. package/src/blocks/product-attributes-block.tsx +307 -0
  60. package/src/blocks/product-block-utils.tsx +639 -0
  61. package/src/blocks/product-breadcrumb-block.tsx +215 -0
  62. package/src/blocks/product-description-block.tsx +169 -0
  63. package/src/blocks/product-gallery-block.tsx +1523 -0
  64. package/src/blocks/product-grid-block.tsx +966 -0
  65. package/src/blocks/product-price-block.tsx +373 -0
  66. package/src/blocks/product-recommendations-block.tsx +1433 -0
  67. package/src/blocks/product-title-block.tsx +129 -0
  68. package/src/blocks/profile-form-block.tsx +297 -0
  69. package/src/blocks/recently-viewed-store.ts +201 -0
  70. package/src/blocks/register-form-block.tsx +362 -0
  71. package/src/blocks/share-block.tsx +667 -0
  72. package/src/blocks/size-guide-block.tsx +313 -0
  73. package/src/blocks/sort-select-block.tsx +636 -0
  74. package/src/blocks/stock-status-block.tsx +168 -0
  75. package/src/blocks/store-locator-block.tsx +1184 -0
  76. package/src/blocks/variant-selector-block.tsx +1110 -0
  77. package/src/sections/__tests__/tablet-raw-bucket-readers.test.tsx +186 -0
  78. package/src/utils/__tests__/part-ownership-guard.test.tsx +29 -4
@@ -0,0 +1,641 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import NextLink from 'next/link';
5
+ import { usePathname, useSearchParams } from 'next/navigation';
6
+ import { useFetchCheckoutResultQuery } from '@akinon/next/data/client/checkout';
7
+ import { useLocalization } from '@akinon/next/hooks';
8
+ import { getResponsiveValue, partAttrs } from '../utils';
9
+ import { useThemePageContext } from '../theme-page-context';
10
+ import { formatDate, formatMoney, parseAmount } from './commerce-format';
11
+ import { hasItemTemplate, ItemTemplateList } from './item-template';
12
+ import { groupOrderItems, readToggle } from './product-block-utils';
13
+ import { BlockRendererProps } from './block-renderer-registry';
14
+
15
+ interface OrderAddressLike {
16
+ title?: string;
17
+ line?: string;
18
+ postcode?: string;
19
+ first_name?: string;
20
+ last_name?: string;
21
+ phone_number?: string;
22
+ city?: { name?: string };
23
+ township?: { name?: string };
24
+ district?: { name?: string };
25
+ }
26
+
27
+ interface OrderItemLike {
28
+ id?: number;
29
+ price?: string;
30
+ status?: { label?: string };
31
+ product?: {
32
+ pk?: number;
33
+ name?: string;
34
+ image?: string;
35
+ absolute_url?: string;
36
+ } | null;
37
+ }
38
+
39
+ interface OrderLike {
40
+ id?: number;
41
+ number?: string;
42
+ amount?: string;
43
+ shipping_amount?: string;
44
+ created_date?: string;
45
+ user_email?: string;
46
+ currency?: { value?: string };
47
+ status?: { label?: string };
48
+ shipping_address?: OrderAddressLike;
49
+ orderitem_set?: OrderItemLike[];
50
+ discountitem_set?: Array<{ name?: string; amount?: string }>;
51
+ }
52
+
53
+ interface RecapRow {
54
+ key: string;
55
+ label: string;
56
+ value: string;
57
+ type: string;
58
+ }
59
+
60
+ /**
61
+ * Route words that can sit in the last path segment when no token is present
62
+ * (/orders/completed with a trailing slash, for instance). Treating one of
63
+ * them as a token would fire a guaranteed 404 against commerce.
64
+ */
65
+ const RESERVED_SEGMENTS = new Set([
66
+ 'completed',
67
+ 'complete',
68
+ 'success',
69
+ 'order',
70
+ 'orders',
71
+ 'checkout'
72
+ ]);
73
+
74
+ /** Order tokens are long opaque hashes; anything short is a route segment. */
75
+ const MIN_TOKEN_LENGTH = 8;
76
+
77
+ const resolveToken = (pathname: string, tokenParam: string | null): string => {
78
+ const fromQuery = (tokenParam || '').trim();
79
+ if (fromQuery) return fromQuery;
80
+
81
+ const segments = String(pathname || '')
82
+ .split('/')
83
+ .filter(Boolean);
84
+ const last = segments[segments.length - 1] || '';
85
+
86
+ if (!last || last.length < MIN_TOKEN_LENGTH) return '';
87
+ if (RESERVED_SEGMENTS.has(last.toLowerCase())) return '';
88
+
89
+ return last;
90
+ };
91
+
92
+ /** Replaces the {number} / {email} placeholders an author may use in copy. */
93
+ const fillTemplate = (
94
+ template: string,
95
+ values: { number: string; email: string }
96
+ ) =>
97
+ template
98
+ .replace(/\{number\}/g, values.number)
99
+ .replace(/\{email\}/g, values.email);
100
+
101
+ const formatAddress = (address?: OrderAddressLike): string[] => {
102
+ if (!address) return [];
103
+
104
+ const locality = [
105
+ address.district?.name,
106
+ address.township?.name,
107
+ address.city?.name
108
+ ]
109
+ .filter(Boolean)
110
+ .join(' / ');
111
+
112
+ return [
113
+ address.title,
114
+ [address.line, address.postcode].filter(Boolean).join(' '),
115
+ locality,
116
+ [address.first_name, address.last_name].filter(Boolean).join(' '),
117
+ address.phone_number
118
+ ].filter((entry): entry is string => Boolean(entry && entry.trim()));
119
+ };
120
+
121
+ const readCount = (value: unknown, fallback: number): number => {
122
+ const numeric = Math.round(Number(value));
123
+
124
+ return Number.isFinite(numeric) && numeric >= 0 ? numeric : fallback;
125
+ };
126
+
127
+ const SuccessGlyph = ({ icon }: { icon: string }) =>
128
+ icon === 'circle-check' ? (
129
+ <svg
130
+ width="40"
131
+ height="40"
132
+ viewBox="0 0 24 24"
133
+ fill="none"
134
+ stroke="currentColor"
135
+ strokeWidth="1.6"
136
+ strokeLinecap="round"
137
+ strokeLinejoin="round"
138
+ >
139
+ <circle cx="12" cy="12" r="9" />
140
+ <path d="M8 12.5l2.5 2.5L16 9.5" />
141
+ </svg>
142
+ ) : (
143
+ <svg
144
+ width="40"
145
+ height="40"
146
+ viewBox="0 0 24 24"
147
+ fill="none"
148
+ stroke="currentColor"
149
+ strokeWidth="2"
150
+ strokeLinecap="round"
151
+ strokeLinejoin="round"
152
+ >
153
+ <path d="M4 12.5l5 5L20 6.5" />
154
+ </svg>
155
+ );
156
+
157
+ /**
158
+ * Thank-you recap for the order completion page. Purely a read of an order
159
+ * that already exists: the order is fetched by the token pz puts in the
160
+ * completion URL (/orders/completed/<token>), which is the same read the
161
+ * brand's own completed page performs. Nothing here can create, mutate or
162
+ * re-submit an order.
163
+ *
164
+ * Parts: root, header, icon, title, subtitle, meta, row, row-label, row-value,
165
+ * number, list, item, image, content, name, quantity, line-total, totals,
166
+ * total, total-label, total-value, address, address-title, address-line, cta,
167
+ * empty. A child marked as the item template replaces the line rows, the
168
+ * totals rows or the meta rows (`itemTemplateTarget`).
169
+ *
170
+ * `title` and `subtitle` accept {number} and {email} placeholders.
171
+ */
172
+ const OrderSuccessBlock = ({
173
+ block,
174
+ placeholderId,
175
+ sectionId,
176
+ selectedBlockId = null,
177
+ currentBreakpoint = 'desktop',
178
+ isDesigner = false
179
+ }: BlockRendererProps) => {
180
+ const prop = (key: string, fallback: string) =>
181
+ String(
182
+ getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
183
+ );
184
+
185
+ const boolProp = (key: string, fallback: boolean) =>
186
+ readToggle(block.properties?.[key], currentBreakpoint, fallback);
187
+
188
+ const titleTemplate = prop('title', 'Thank you for your order');
189
+ const subtitleTemplate = prop(
190
+ 'subtitle',
191
+ 'We sent the confirmation to {email}.'
192
+ );
193
+ const continueHref = prop('continueHref', '/').trim();
194
+ const continueLabel = prop('continueLabel', 'Continue shopping').trim();
195
+ const showLineItems = boolProp('showLineItems', true);
196
+ const showIcon = boolProp('showIcon', false);
197
+ const successIcon = prop('successIcon', 'check');
198
+ const showMeta = boolProp('showMeta', true);
199
+ const showOrderNumber = boolProp('showOrderNumber', true);
200
+ const showOrderDate = boolProp('showOrderDate', true);
201
+ const showEmail = boolProp('showEmail', true);
202
+ const showTotals = boolProp('showTotals', true);
203
+ const showSubtotal = boolProp('showSubtotal', true);
204
+ const showDiscounts = boolProp('showDiscounts', true);
205
+ const showShipping = boolProp('showShipping', true);
206
+ const showAddress = boolProp('showAddress', true);
207
+ const showItemImage = boolProp('showItemImage', true);
208
+ const showItemQuantity = boolProp('showItemQuantity', true);
209
+
210
+ const numberLabel = prop('numberLabel', 'Order number');
211
+ const dateLabel = prop('dateLabel', 'Order date');
212
+ const emailLabel = prop('emailLabel', 'E-mail');
213
+ const subtotalLabel = prop('subtotalLabel', 'Subtotal');
214
+ const discountLabel = prop('discountLabel', 'Discount');
215
+ const shippingLabel = prop('shippingLabel', 'Shipping');
216
+ const totalLabel = prop('totalLabel', 'Total');
217
+ const addressLabel = prop('addressLabel', 'Shipping address');
218
+ const quantityPrefix = prop('quantityPrefix', '×');
219
+ const discountPrefix = prop('discountPrefix', '-');
220
+ const emptyLabel = prop('emptyLabel', '').trim();
221
+ const loadingLabel = prop('loadingLabel', '').trim();
222
+ const templateTarget = prop('itemTemplateTarget', 'items');
223
+
224
+ const imageWidth = readCount(
225
+ getResponsiveValue(block.properties?.imageWidth, currentBreakpoint, 56),
226
+ 56
227
+ );
228
+ const imageHeight = readCount(
229
+ getResponsiveValue(block.properties?.imageHeight, currentBreakpoint, 75),
230
+ 75
231
+ );
232
+ const maxItems = readCount(
233
+ getResponsiveValue(block.properties?.maxItems, currentBreakpoint, 0),
234
+ 0
235
+ );
236
+
237
+ const pathname = usePathname();
238
+ const searchParams = useSearchParams();
239
+ const pageContext = useThemePageContext();
240
+ const { locale, currency: localeCurrency } = useLocalization();
241
+
242
+ // No page supplies an order page context yet, so this read is inert for now;
243
+ // it is kept so the block switches over without a code change once it is.
244
+ const contextOrder = (pageContext?.order ?? null) as OrderLike | null;
245
+ const token = resolveToken(pathname || '', searchParams?.get('token') ?? null);
246
+
247
+ const { data, isLoading } = useFetchCheckoutResultQuery(token, {
248
+ skip: !token || Boolean(contextOrder)
249
+ });
250
+
251
+ const order =
252
+ contextOrder ??
253
+ ((data as { order?: OrderLike } | undefined)?.order || null);
254
+
255
+ if (!order) {
256
+ const authoredLabel = isLoading ? loadingLabel : emptyLabel;
257
+
258
+ if (!isDesigner && !authoredLabel) return null;
259
+
260
+ const designerLabel = isLoading
261
+ ? 'Order recap (loading)'
262
+ : 'Order recap (no order token in URL)';
263
+
264
+ return (
265
+ <div
266
+ data-order-success=""
267
+ data-order-success-state="empty"
268
+ {...partAttrs('empty', { empty: true, loading: isLoading })}
269
+ >
270
+ {authoredLabel || (isDesigner ? designerLabel : '')}
271
+ </div>
272
+ );
273
+ }
274
+
275
+ // The order carries the currency it was placed in; the storefront currency
276
+ // is only the fallback.
277
+ const currency = order.currency?.value || localeCurrency || '';
278
+ const money = (value?: string | number) =>
279
+ formatMoney(value, { locale, currency });
280
+
281
+ const items = Array.isArray(order.orderitem_set) ? order.orderitem_set : [];
282
+
283
+ // pz writes one order line per unit, so identical products are collapsed and
284
+ // counted — otherwise a quantity of three reads as three separate rows. The
285
+ // grouping is shared with the account order detail block so both read the
286
+ // same payload the same way.
287
+ const allGroupedItems = groupOrderItems(items);
288
+ const groupedItems =
289
+ maxItems > 0 ? allGroupedItems.slice(0, maxItems) : allGroupedItems;
290
+
291
+ // Commerce does not send an order subtotal, so it is summed from the lines
292
+ // with the shared parser: Number() returns NaN for "1.299,90".
293
+ const subtotal = items.reduce(
294
+ (total, item) => total + (parseAmount(item.price) ?? 0),
295
+ 0
296
+ );
297
+
298
+ const discounts = (order.discountitem_set || []).filter(
299
+ (discount) => (parseAmount(discount?.amount) ?? 0) > 0
300
+ );
301
+
302
+ const orderNumber = order.number || (order.id ? `#${order.id}` : '');
303
+ const email = order.user_email || '';
304
+ const addressLines = formatAddress(order.shipping_address);
305
+
306
+ const metaRows: RecapRow[] = [];
307
+
308
+ if (showOrderNumber && orderNumber) {
309
+ metaRows.push({
310
+ key: 'number',
311
+ label: numberLabel,
312
+ value: orderNumber,
313
+ type: 'number'
314
+ });
315
+ }
316
+
317
+ if (showOrderDate && order.created_date) {
318
+ metaRows.push({
319
+ key: 'date',
320
+ label: dateLabel,
321
+ value: formatDate(order.created_date, locale),
322
+ type: 'date'
323
+ });
324
+ }
325
+
326
+ if (showEmail && email) {
327
+ metaRows.push({
328
+ key: 'email',
329
+ label: emailLabel,
330
+ value: email,
331
+ type: 'email'
332
+ });
333
+ }
334
+
335
+ const totalsRows: RecapRow[] = [];
336
+
337
+ if (showSubtotal && subtotal > 0) {
338
+ totalsRows.push({
339
+ key: 'subtotal',
340
+ label: subtotalLabel,
341
+ value: money(subtotal),
342
+ type: 'subtotal'
343
+ });
344
+ }
345
+
346
+ if (showDiscounts) {
347
+ discounts.forEach((discount, index) => {
348
+ totalsRows.push({
349
+ key: `${discount.name || 'discount'}-${index}`,
350
+ label: discount.name || discountLabel,
351
+ value: `${discountPrefix}${money(discount.amount)}`,
352
+ type: 'discount'
353
+ });
354
+ });
355
+ }
356
+
357
+ if (showShipping && order.shipping_amount !== undefined) {
358
+ totalsRows.push({
359
+ key: 'shipping',
360
+ label: shippingLabel,
361
+ value: money(order.shipping_amount),
362
+ type: 'shipping'
363
+ });
364
+ }
365
+
366
+ totalsRows.push({
367
+ key: 'total',
368
+ label: totalLabel,
369
+ value: money(order.amount),
370
+ type: 'total'
371
+ });
372
+
373
+ // Plain objects so a designer binds item.name / item.image / item.quantity /
374
+ // item.price / item.url in their own row layout.
375
+ const templateItems = groupedItems.map((item) => ({
376
+ key: item.key,
377
+ name: item.name,
378
+ image: item.image,
379
+ url: item.href,
380
+ quantity: item.quantity,
381
+ price: money(item.price),
382
+ total: item.total === null ? '' : money(item.total)
383
+ }));
384
+
385
+ const hasTemplate = hasItemTemplate(block, currentBreakpoint);
386
+
387
+ const templateList = (rows: Record<string, unknown>[]) => (
388
+ <ItemTemplateList
389
+ block={block}
390
+ items={rows}
391
+ placeholderId={placeholderId}
392
+ sectionId={sectionId}
393
+ isDesigner={isDesigner}
394
+ selectedBlockId={selectedBlockId}
395
+ currentBreakpoint={currentBreakpoint}
396
+ />
397
+ );
398
+
399
+ const customItems =
400
+ hasTemplate && templateTarget === 'items'
401
+ ? templateList(templateItems as unknown as Record<string, unknown>[])
402
+ : null;
403
+ const customTotals =
404
+ hasTemplate && templateTarget === 'totals'
405
+ ? templateList(totalsRows as unknown as Record<string, unknown>[])
406
+ : null;
407
+ const customMeta =
408
+ hasTemplate && templateTarget === 'meta'
409
+ ? templateList(metaRows as unknown as Record<string, unknown>[])
410
+ : null;
411
+
412
+ const rowStyle: React.CSSProperties = {
413
+ display: 'flex',
414
+ justifyContent: 'space-between',
415
+ gap: '12px',
416
+ padding: '4px 0'
417
+ };
418
+
419
+ const renderRow = (row: RecapRow) =>
420
+ row.type === 'total' ? (
421
+ <div
422
+ key={row.key}
423
+ style={{ ...rowStyle, fontWeight: 600 }}
424
+ data-order-success-row="total"
425
+ {...partAttrs('total')}
426
+ >
427
+ <span {...partAttrs('total-label')}>{row.label}</span>
428
+ <span data-order-success-total="" {...partAttrs('total-value')}>
429
+ {row.value}
430
+ </span>
431
+ </div>
432
+ ) : (
433
+ <div
434
+ key={row.key}
435
+ style={rowStyle}
436
+ data-order-success-row={row.type}
437
+ {...partAttrs('row')}
438
+ >
439
+ <span {...partAttrs('row-label')}>{row.label}</span>
440
+ <span
441
+ {...(row.type === 'number'
442
+ ? { 'data-order-success-number': '' }
443
+ : null)}
444
+ {...(row.type === 'email' ? { 'data-order-success-email': '' } : null)}
445
+ {...partAttrs(row.type === 'number' ? 'number' : 'row-value')}
446
+ >
447
+ {row.value}
448
+ </span>
449
+ </div>
450
+ );
451
+
452
+ return (
453
+ <div
454
+ data-order-success=""
455
+ {...partAttrs('root')}
456
+ style={{
457
+ display: 'flex',
458
+ flexDirection: 'column',
459
+ gap: 'var(--order-success-gap, 20px)'
460
+ }}
461
+ >
462
+ <div data-order-success-header="" {...partAttrs('header')}>
463
+ {showIcon && (
464
+ <div
465
+ data-order-success-icon=""
466
+ aria-hidden="true"
467
+ {...partAttrs('icon')}
468
+ style={{ marginBottom: '8px' }}
469
+ >
470
+ <SuccessGlyph icon={successIcon} />
471
+ </div>
472
+ )}
473
+
474
+ <div
475
+ data-order-success-title=""
476
+ {...partAttrs('title')}
477
+ style={{ fontWeight: 600 }}
478
+ >
479
+ {fillTemplate(titleTemplate, { number: orderNumber, email })}
480
+ </div>
481
+
482
+ {subtitleTemplate && (
483
+ <div
484
+ data-order-success-subtitle=""
485
+ {...partAttrs('subtitle')}
486
+ style={{ marginTop: '6px' }}
487
+ >
488
+ {fillTemplate(subtitleTemplate, { number: orderNumber, email })}
489
+ </div>
490
+ )}
491
+ </div>
492
+
493
+ {showMeta && (
494
+ <div data-order-success-meta="" {...partAttrs('meta')}>
495
+ {customMeta ?? metaRows.map(renderRow)}
496
+ </div>
497
+ )}
498
+
499
+ {showLineItems && (groupedItems.length > 0 || customItems) && (
500
+ <ul
501
+ data-order-success-items=""
502
+ {...partAttrs('list')}
503
+ style={{
504
+ display: 'flex',
505
+ flexDirection: 'column',
506
+ gap: '12px',
507
+ listStyle: 'none',
508
+ margin: 0,
509
+ padding: 0
510
+ }}
511
+ >
512
+ {customItems ??
513
+ groupedItems.map((item) => (
514
+ <li
515
+ key={item.key}
516
+ data-order-success-item=""
517
+ {...partAttrs('item')}
518
+ style={{
519
+ display: 'flex',
520
+ alignItems: 'flex-start',
521
+ gap: '12px'
522
+ }}
523
+ >
524
+ {showItemImage && item.image && (
525
+ // Plain img: order thumbnails come from arbitrary commerce
526
+ // CDNs that the brand next.config may not allow.
527
+ // eslint-disable-next-line @next/next/no-img-element
528
+ <img
529
+ src={item.image}
530
+ alt={item.name}
531
+ width={imageWidth}
532
+ height={imageHeight}
533
+ {...partAttrs('image')}
534
+ style={{
535
+ objectFit: 'cover',
536
+ display: 'block',
537
+ flex: '0 0 auto'
538
+ }}
539
+ />
540
+ )}
541
+
542
+ <div
543
+ {...partAttrs('content')}
544
+ style={{ flex: '1 1 auto', minWidth: 0 }}
545
+ >
546
+ {item.href ? (
547
+ <NextLink
548
+ // NextLink warns when href resolves to undefined, so the
549
+ // order line url is resolved here as well as guarded above.
550
+ href={isDesigner ? '#' : item.href || '#'}
551
+ data-order-success-item-name=""
552
+ {...partAttrs('name')}
553
+ // Navigating inside the designer iframe would drop the
554
+ // author out of the page they are editing.
555
+ onClick={(event) => {
556
+ if (isDesigner) event.preventDefault();
557
+ }}
558
+ style={{ color: 'inherit', textDecoration: 'none' }}
559
+ >
560
+ {item.name}
561
+ </NextLink>
562
+ ) : (
563
+ <span data-order-success-item-name="" {...partAttrs('name')}>
564
+ {item.name}
565
+ </span>
566
+ )}
567
+
568
+ {showItemQuantity && (
569
+ <div
570
+ {...partAttrs('quantity')}
571
+ style={{ marginTop: '2px', opacity: 0.8 }}
572
+ >
573
+ {quantityPrefix}
574
+ {item.quantity}
575
+ </div>
576
+ )}
577
+ </div>
578
+
579
+ <span
580
+ data-order-success-item-price=""
581
+ {...partAttrs('line-total')}
582
+ style={{ flex: '0 0 auto' }}
583
+ >
584
+ {money(item.price)}
585
+ </span>
586
+ </li>
587
+ ))}
588
+ </ul>
589
+ )}
590
+
591
+ {showTotals && (
592
+ <div data-order-success-totals="" {...partAttrs('totals')}>
593
+ {customTotals ?? totalsRows.map(renderRow)}
594
+ </div>
595
+ )}
596
+
597
+ {showAddress && addressLines.length > 0 && (
598
+ <div data-order-success-address="" {...partAttrs('address')}>
599
+ <div
600
+ {...partAttrs('address-title')}
601
+ style={{ fontWeight: 600, marginBottom: '6px' }}
602
+ >
603
+ {addressLabel}
604
+ </div>
605
+ {addressLines.map((line, index) => (
606
+ <div key={`address-line-${index}`} {...partAttrs('address-line')}>
607
+ {line}
608
+ </div>
609
+ ))}
610
+ </div>
611
+ )}
612
+
613
+ {continueHref && continueLabel && (
614
+ <NextLink
615
+ // NextLink warns when href resolves to undefined, so the continue CTA
616
+ // target is resolved here as well as guarded above.
617
+ href={isDesigner ? '#' : continueHref || '#'}
618
+ data-order-success-continue=""
619
+ {...partAttrs('cta')}
620
+ // Navigating inside the designer iframe would drop the author out of
621
+ // the page they are editing, so the CTA is inert there.
622
+ onClick={(event) => {
623
+ if (isDesigner) event.preventDefault();
624
+ }}
625
+ style={{
626
+ display: 'block',
627
+ textAlign: 'center',
628
+ padding: '12px 20px',
629
+ background: 'var(--order-success-cta-bg, #111827)',
630
+ color: 'var(--order-success-cta-color, #ffffff)',
631
+ textDecoration: 'none'
632
+ }}
633
+ >
634
+ {continueLabel}
635
+ </NextLink>
636
+ )}
637
+ </div>
638
+ );
639
+ };
640
+
641
+ export default OrderSuccessBlock;