@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,474 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import NextLink from 'next/link';
5
+ import { useParams, useSearchParams } from 'next/navigation';
6
+ import { useGetOrderQuery } from '@akinon/next/data/client/account';
7
+ import { useLocalization } from '@akinon/next/hooks';
8
+ import { getResponsiveValue, partAttrs } from '../utils';
9
+ import { formatDate, formatMoney } from './commerce-format';
10
+ import { hasItemTemplate, ItemTemplateList } from './item-template';
11
+ import { BlockRendererProps } from './block-renderer-registry';
12
+ import {
13
+ DesignerPlaceholder,
14
+ groupOrderItems,
15
+ readToggle
16
+ } from './product-block-utils';
17
+
18
+ interface OrderItemLike {
19
+ id?: number;
20
+ price?: string;
21
+ quantity?: string | number;
22
+ total_amount?: string;
23
+ status?: { value?: string; label?: string };
24
+ tracking_url?: string;
25
+ tracking_number?: string;
26
+ estimated_delivery_date?: string;
27
+ price_currency?: { value?: string; label?: string };
28
+ product?: {
29
+ pk?: number;
30
+ name?: string;
31
+ image?: string;
32
+ absolute_url?: string;
33
+ } | null;
34
+ }
35
+
36
+ interface OrderLike {
37
+ number?: string;
38
+ created_date?: string;
39
+ amount?: string;
40
+ amount_without_discount?: string;
41
+ discount_amount?: string;
42
+ shipping_amount?: string;
43
+ tracking_url?: string;
44
+ tracking_number?: string;
45
+ status?: { value?: string; label?: string };
46
+ currency?: { value?: string; label?: string };
47
+ orderitem_set?: OrderItemLike[];
48
+ }
49
+
50
+ /**
51
+ * The order key lives in the route on account pages (`/account/orders/[id]`)
52
+ * but in the query string on the anonymous tracking page, and brands name the
53
+ * segment differently. Both sources are probed so the block works wherever it
54
+ * is dropped, without asking the author to configure a data path.
55
+ *
56
+ * `pk` is in the list because it is the segment name pz uses for every other
57
+ * detail route (product/[pk], category/[pk], flat-page/[pk]), so an order route
58
+ * copied from those reads its key from `pk` rather than from `id`.
59
+ */
60
+ const ORDER_PARAM_KEYS = [
61
+ 'id',
62
+ 'pk',
63
+ 'orderId',
64
+ 'orderNumber',
65
+ 'number',
66
+ 'order'
67
+ ];
68
+
69
+ const firstString = (
70
+ read: (key: string) => unknown,
71
+ keys: string[]
72
+ ): string => {
73
+ for (const key of keys) {
74
+ const value = read(key);
75
+ const text = Array.isArray(value) ? value[0] : value;
76
+
77
+ if (typeof text === 'string' && text.trim()) return text.trim();
78
+ if (typeof text === 'number') return String(text);
79
+ }
80
+
81
+ return '';
82
+ };
83
+
84
+ /** Positive integers only; anything else falls back to the shipped size. */
85
+ const readSize = (value: unknown, fallback: number): number => {
86
+ const numeric = Math.round(Number(value));
87
+
88
+ return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback;
89
+ };
90
+
91
+ /**
92
+ * Functional account block: one order's lines, totals, status and shipment
93
+ * info. Order CONTENT stays commerce-driven (account order service) and the
94
+ * order key is read from the URL; the editor owns which sections are exposed,
95
+ * every caption and the styling.
96
+ * A designer can mark one child as the row layout for the order lines; without
97
+ * one the built-in line renders.
98
+ * Properties: showShipmentStatus, showItems, showImage, imageWidth,
99
+ * imageHeight, showQuantity, showTotals, quantityPrefix, subtotalLabel,
100
+ * discountLabel, shippingLabel, totalLabel, trackingLabel.
101
+ * Parts: detail, header, number, date, status, list, item, image, name,
102
+ * quantity, item-status, price, shipment, delivery, tracking, totals,
103
+ * total-row, total-label, total-value, grand-total.
104
+ */
105
+ const OrderDetailBlock = ({
106
+ block,
107
+ placeholderId,
108
+ sectionId,
109
+ selectedBlockId = null,
110
+ currentBreakpoint = 'desktop',
111
+ isDesigner = false
112
+ }: BlockRendererProps) => {
113
+ const showShipmentStatus = readToggle(
114
+ block.properties?.showShipmentStatus,
115
+ currentBreakpoint,
116
+ true
117
+ );
118
+ const showItems = readToggle(
119
+ block.properties?.showItems,
120
+ currentBreakpoint,
121
+ true
122
+ );
123
+ const showImage = readToggle(
124
+ block.properties?.showImage,
125
+ currentBreakpoint,
126
+ true
127
+ );
128
+ const showQuantity = readToggle(
129
+ block.properties?.showQuantity,
130
+ currentBreakpoint,
131
+ true
132
+ );
133
+ const showTotals = readToggle(
134
+ block.properties?.showTotals,
135
+ currentBreakpoint,
136
+ true
137
+ );
138
+ const imageWidth = readSize(
139
+ getResponsiveValue(block.properties?.imageWidth, currentBreakpoint, 72),
140
+ 72
141
+ );
142
+ const imageHeight = readSize(
143
+ getResponsiveValue(block.properties?.imageHeight, currentBreakpoint, 96),
144
+ 96
145
+ );
146
+ const text = (key: string, fallback: string) =>
147
+ String(
148
+ getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
149
+ );
150
+ const quantityPrefix = text('quantityPrefix', 'x');
151
+ const subtotalLabel = text('subtotalLabel', 'Subtotal');
152
+ const discountLabel = text('discountLabel', 'Discount');
153
+ const shippingLabel = text('shippingLabel', 'Shipping');
154
+ const totalLabel = text('totalLabel', 'Total');
155
+ const trackingLabel = text('trackingLabel', 'Track shipment');
156
+
157
+ const { locale, currency: localeCurrency } = useLocalization();
158
+ const params = useParams();
159
+ const searchParams = useSearchParams();
160
+
161
+ const orderKey =
162
+ firstString((key) => (params as Record<string, unknown>)?.[key], ORDER_PARAM_KEYS) ||
163
+ firstString((key) => searchParams?.get(key), ORDER_PARAM_KEYS);
164
+
165
+ // Not gated on the session: brands expose the same endpoint for anonymous
166
+ // order tracking. Without a key there is nothing to ask for at all.
167
+ const { data, isLoading } = useGetOrderQuery(orderKey, { skip: !orderKey });
168
+
169
+ const order = (data ?? null) as OrderLike | null;
170
+ const items = order?.orderitem_set ?? [];
171
+ // pz writes one order line per unit; the same shared grouping the thank-you
172
+ // page uses keeps a three-unit order from listing as three "x1" rows here.
173
+ // The raw lines are still used for the shipment section, which reads the
174
+ // per-line delivery estimate.
175
+ const groupedItems = groupOrderItems(items);
176
+
177
+ if (!order) {
178
+ return isDesigner ? (
179
+ <DesignerPlaceholder
180
+ label={
181
+ isLoading
182
+ ? 'Order detail (loading)'
183
+ : orderKey
184
+ ? 'Order detail (order not found)'
185
+ : 'Order detail (no order in URL)'
186
+ }
187
+ attribute="data-order-detail"
188
+ />
189
+ ) : null;
190
+ }
191
+
192
+ // The order carries the currency it was placed in; the storefront currency is
193
+ // only the fallback for payloads that omit it.
194
+ const currency = order.currency?.value || localeCurrency;
195
+ const money = (value: unknown) => formatMoney(value, { locale, currency });
196
+
197
+ // Navigating inside the designer iframe would drop the author out of the page
198
+ // they are editing, so every link is inert there.
199
+ const guard = (event: React.MouseEvent) => {
200
+ if (isDesigner) event.preventDefault();
201
+ };
202
+
203
+ const rowStyle: React.CSSProperties = {
204
+ display: 'flex',
205
+ alignItems: 'center',
206
+ justifyContent: 'space-between',
207
+ gap: '12px'
208
+ };
209
+
210
+ const lineRows = groupedItems.map((line) => {
211
+ const lineCurrency = line.item.price_currency?.value || currency;
212
+
213
+ return {
214
+ key: line.key,
215
+ name: line.name,
216
+ image: line.image,
217
+ url: line.href,
218
+ href: line.href,
219
+ quantity: line.quantity,
220
+ price: formatMoney(line.price, { locale, currency: lineCurrency }),
221
+ total: formatMoney(line.total, { locale, currency: lineCurrency }),
222
+ status: line.item.status?.label || '',
223
+ statusValue: line.item.status?.value || ''
224
+ };
225
+ });
226
+
227
+ // A designer who marked a child as the row layout gets that layout repeated
228
+ // per order line; otherwise the built-in line renders unchanged.
229
+ const customLines = hasItemTemplate(block, currentBreakpoint) ? (
230
+ <ItemTemplateList
231
+ block={block}
232
+ items={lineRows}
233
+ placeholderId={placeholderId}
234
+ sectionId={sectionId}
235
+ isDesigner={isDesigner}
236
+ selectedBlockId={selectedBlockId}
237
+ currentBreakpoint={currentBreakpoint}
238
+ />
239
+ ) : null;
240
+
241
+ const totalRow = (
242
+ label: string,
243
+ value: string,
244
+ attribute: string,
245
+ part = 'total-row'
246
+ ) =>
247
+ value ? (
248
+ <div {...{ [attribute]: '' }} {...partAttrs(part)} style={rowStyle}>
249
+ <span {...partAttrs('total-label')}>{label}</span>
250
+ <span {...partAttrs('total-value')}>{value}</span>
251
+ </div>
252
+ ) : null;
253
+
254
+ return (
255
+ <div
256
+ data-order-detail=""
257
+ {...partAttrs('detail')}
258
+ style={{
259
+ display: 'flex',
260
+ flexDirection: 'column',
261
+ gap: 'var(--order-detail-gap, 24px)'
262
+ }}
263
+ >
264
+ <header
265
+ data-order-detail-header=""
266
+ {...partAttrs('header')}
267
+ style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}
268
+ >
269
+ <span
270
+ data-order-detail-number=""
271
+ {...partAttrs('number')}
272
+ style={{ fontWeight: 600 }}
273
+ >
274
+ {order.number || orderKey}
275
+ </span>
276
+ <span
277
+ data-order-detail-date=""
278
+ {...partAttrs('date')}
279
+ style={{ opacity: 0.8 }}
280
+ >
281
+ {formatDate(order.created_date, locale)}
282
+ </span>
283
+ {order.status?.label && (
284
+ <span
285
+ data-order-detail-status=""
286
+ // The raw status code is published so scoped custom CSS can paint
287
+ // one status differently from the rest.
288
+ data-order-status-value={order.status.value}
289
+ {...partAttrs('status')}
290
+ >
291
+ {order.status.label}
292
+ </span>
293
+ )}
294
+ </header>
295
+
296
+ {showItems && (groupedItems.length > 0 || customLines) && (
297
+ <ul
298
+ data-order-detail-items=""
299
+ {...partAttrs('list')}
300
+ style={{
301
+ display: 'flex',
302
+ flexDirection: 'column',
303
+ gap: '16px',
304
+ listStyle: 'none',
305
+ margin: 0,
306
+ padding: 0
307
+ }}
308
+ >
309
+ {customLines ??
310
+ groupedItems.map((line) => {
311
+ // Status, currency and thumbnails are identical across the lines of
312
+ // a group, so the first line represents the whole row.
313
+ const item = line.item;
314
+ const name = line.name;
315
+ const href = line.href;
316
+ const lineCurrency = item.price_currency?.value || currency;
317
+
318
+ return (
319
+ <li
320
+ key={line.key}
321
+ data-order-detail-item=""
322
+ {...partAttrs('item')}
323
+ style={{
324
+ display: 'flex',
325
+ alignItems: 'flex-start',
326
+ gap: '12px',
327
+ paddingBottom: '16px',
328
+ borderBottom: '1px solid var(--order-detail-border, #e5e7eb)'
329
+ }}
330
+ >
331
+ {showImage && item.product?.image && (
332
+ <NextLink
333
+ href={isDesigner ? '#' : href || '#'}
334
+ onClick={guard}
335
+ style={{ flex: '0 0 auto', display: 'block' }}
336
+ >
337
+ {/* Plain img: order line thumbnails come from arbitrary
338
+ commerce CDNs that the brand next.config may not allow. */}
339
+ {/* eslint-disable-next-line @next/next/no-img-element */}
340
+ <img
341
+ src={item.product.image}
342
+ alt={name}
343
+ width={imageWidth}
344
+ height={imageHeight}
345
+ {...partAttrs('image')}
346
+ style={{ objectFit: 'cover', display: 'block' }}
347
+ />
348
+ </NextLink>
349
+ )}
350
+
351
+ <div style={{ flex: '1 1 auto', minWidth: 0 }}>
352
+ <NextLink
353
+ href={isDesigner ? '#' : href || '#'}
354
+ onClick={guard}
355
+ data-order-detail-item-name=""
356
+ {...partAttrs('name')}
357
+ style={{ color: 'inherit', textDecoration: 'none' }}
358
+ >
359
+ {name}
360
+ </NextLink>
361
+
362
+ {showQuantity && (
363
+ <div
364
+ data-order-detail-item-quantity=""
365
+ {...partAttrs('quantity')}
366
+ style={{ marginTop: '4px', opacity: 0.8 }}
367
+ >
368
+ {`${quantityPrefix}${line.quantity}`}
369
+ </div>
370
+ )}
371
+
372
+ {showShipmentStatus && item.status?.label && (
373
+ <div
374
+ data-order-detail-item-status=""
375
+ data-order-status-value={item.status.value}
376
+ {...partAttrs('item-status')}
377
+ style={{ marginTop: '4px', opacity: 0.8 }}
378
+ >
379
+ {item.status.label}
380
+ </div>
381
+ )}
382
+ </div>
383
+
384
+ <span data-order-detail-item-price="" {...partAttrs('price')}>
385
+ {/* The row now stands for every unit of the product, so the
386
+ group's summed line total is what it must price — the
387
+ single line's total_amount would understate an "x3" row.
388
+ For an ungrouped row this is the value printed before. */}
389
+ {formatMoney(line.total, {
390
+ locale,
391
+ currency: lineCurrency
392
+ })}
393
+ </span>
394
+ </li>
395
+ );
396
+ })}
397
+ </ul>
398
+ )}
399
+
400
+ {showShipmentStatus && (
401
+ <section
402
+ data-order-detail-shipment=""
403
+ {...partAttrs('shipment')}
404
+ style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}
405
+ >
406
+ {items.some((item) => item.estimated_delivery_date) && (
407
+ <span
408
+ data-order-detail-estimated-delivery=""
409
+ {...partAttrs('delivery')}
410
+ >
411
+ {formatDate(
412
+ items.find((item) => item.estimated_delivery_date)
413
+ ?.estimated_delivery_date,
414
+ locale
415
+ )}
416
+ </span>
417
+ )}
418
+
419
+ {(order.tracking_number || order.tracking_url) && (
420
+ <span data-order-detail-tracking="" {...partAttrs('tracking')}>
421
+ {order.tracking_url ? (
422
+ // Carrier tracking is an external URL, so it stays a plain
423
+ // anchor — but it is guarded exactly like the internal links.
424
+ <a
425
+ href={isDesigner ? '#' : order.tracking_url}
426
+ onClick={guard}
427
+ target={isDesigner ? undefined : '_blank'}
428
+ rel="noreferrer"
429
+ style={{ color: 'inherit' }}
430
+ >
431
+ {order.tracking_number || trackingLabel}
432
+ </a>
433
+ ) : (
434
+ order.tracking_number
435
+ )}
436
+ </span>
437
+ )}
438
+ </section>
439
+ )}
440
+
441
+ {showTotals && (
442
+ <section
443
+ data-order-detail-totals=""
444
+ {...partAttrs('totals')}
445
+ style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}
446
+ >
447
+ {totalRow(
448
+ subtotalLabel,
449
+ money(order.amount_without_discount),
450
+ 'data-order-detail-subtotal'
451
+ )}
452
+ {totalRow(
453
+ discountLabel,
454
+ money(order.discount_amount),
455
+ 'data-order-detail-discount'
456
+ )}
457
+ {totalRow(
458
+ shippingLabel,
459
+ money(order.shipping_amount),
460
+ 'data-order-detail-shipping'
461
+ )}
462
+ {totalRow(
463
+ totalLabel,
464
+ money(order.amount),
465
+ 'data-order-detail-total',
466
+ 'grand-total'
467
+ )}
468
+ </section>
469
+ )}
470
+ </div>
471
+ );
472
+ };
473
+
474
+ export default OrderDetailBlock;