@akinon/pz-theme 2.0.37-rc.0 → 2.0.38-beta.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.
Files changed (78) hide show
  1. package/CHANGELOG.md +15 -31
  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,449 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { useGetBasketQuery } from '@akinon/next/data/client/basket';
5
+ import { useAppSelector } from '@akinon/next/redux/hooks';
6
+ import { useLocalization } from '@akinon/next/hooks';
7
+ import { getResponsiveValue, partAttrs } from '../utils';
8
+ import { useThemePageContext } from '../theme-page-context';
9
+ import { formatMoney, parseAmount } from './commerce-format';
10
+ import { hasItemTemplate, ItemTemplateList } from './item-template';
11
+ import { readToggle } from './product-block-utils';
12
+ import { BlockRendererProps } from './block-renderer-registry';
13
+
14
+ /** Live basket line, as `getBasket` returns it. */
15
+ interface BasketLineLike {
16
+ id?: number;
17
+ quantity?: number;
18
+ image?: string;
19
+ price?: string;
20
+ unit_price?: string;
21
+ total_amount?: string;
22
+ currency_type?: string;
23
+ product?: { pk?: number; name?: string; image?: string } | null;
24
+ }
25
+
26
+ interface BasketTotalsLike {
27
+ total_amount?: string;
28
+ total_product_amount?: string;
29
+ total_discount_amount?: string;
30
+ voucher_code?: string | null;
31
+ basketitem_set?: BasketLineLike[];
32
+ }
33
+
34
+ /** Basket line as `buildBasketPageContext()` normalises it (different keys). */
35
+ interface ContextLineLike {
36
+ name?: string;
37
+ title?: string;
38
+ image?: string;
39
+ quantity?: number;
40
+ price?: string;
41
+ unitPrice?: string;
42
+ totalAmount?: string;
43
+ currency?: string;
44
+ product?: { pk?: number } | null;
45
+ }
46
+
47
+ interface SummaryLine {
48
+ key: string;
49
+ name: string;
50
+ image: string;
51
+ quantity: number;
52
+ total: string;
53
+ currency: string;
54
+ }
55
+
56
+ interface TotalsRow {
57
+ key: string;
58
+ label: string;
59
+ value: string;
60
+ /** `total` is the grand total; it carries its own part and weight. */
61
+ type: 'subtotal' | 'shipping' | 'discount' | 'total';
62
+ }
63
+
64
+ const readCount = (value: unknown, fallback: number): number => {
65
+ const numeric = Math.round(Number(value));
66
+
67
+ return Number.isFinite(numeric) && numeric >= 0 ? numeric : fallback;
68
+ };
69
+
70
+ /**
71
+ * Read-only checkout sidebar recap. Deliberately has no affordances: no
72
+ * quantity steppers, no remove buttons, no coupon form and no product links.
73
+ * A shopper who leaves the checkout page mid-flow loses the pre-order the
74
+ * payment step is built on, so this block only ever displays.
75
+ *
76
+ * Totals come from the basket (`useGetBasketQuery`, the same source as
77
+ * basket-summary-block). Shipping is the one figure the basket payload does
78
+ * not carry — it only exists on the checkout pre-order — so it is read from
79
+ * the checkout redux slice, read-only, and the row is skipped entirely
80
+ * outside checkout where no pre-order exists.
81
+ *
82
+ * Parts: root, list, item, image, content, name, quantity, line-total, totals,
83
+ * row, row-label, row-value, total, total-label, total-value, coupon,
84
+ * coupon-label, coupon-code, empty. A child marked as the item template
85
+ * replaces either the line rows or the totals rows (`itemTemplateTarget`).
86
+ */
87
+ const CheckoutSummaryBlock = ({
88
+ block,
89
+ placeholderId,
90
+ sectionId,
91
+ selectedBlockId = null,
92
+ currentBreakpoint = 'desktop',
93
+ isDesigner = false
94
+ }: BlockRendererProps) => {
95
+ const prop = (key: string, fallback: string) =>
96
+ String(
97
+ getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
98
+ );
99
+
100
+ const boolProp = (key: string, fallback: boolean) =>
101
+ readToggle(block.properties?.[key], currentBreakpoint, fallback);
102
+
103
+ const showLineItems = boolProp('showLineItems', true);
104
+ const showCoupon = boolProp('showCoupon', true);
105
+ const showSubtotal = boolProp('showSubtotal', true);
106
+ const showShipping = boolProp('showShipping', true);
107
+ const showDiscount = boolProp('showDiscount', true);
108
+ const showItemImage = boolProp('showItemImage', true);
109
+ const showItemQuantity = boolProp('showItemQuantity', true);
110
+
111
+ const subtotalLabel = prop('subtotalLabel', 'Subtotal');
112
+ const shippingLabel = prop('shippingLabel', 'Shipping');
113
+ const discountLabel = prop('discountLabel', 'Discount');
114
+ const totalLabel = prop('totalLabel', 'Total');
115
+ const couponLabel = prop('couponLabel', 'Coupon');
116
+ const discountPrefix = prop('discountPrefix', '-');
117
+ const quantityPrefix = prop('quantityPrefix', '×');
118
+ const emptyLabel = prop('emptyLabel', '').trim();
119
+ const loadingLabel = prop('loadingLabel', '').trim();
120
+ const templateTarget = prop('itemTemplateTarget', 'items');
121
+
122
+ const imageWidth = readCount(
123
+ getResponsiveValue(block.properties?.imageWidth, currentBreakpoint, 56),
124
+ 56
125
+ );
126
+ const imageHeight = readCount(
127
+ getResponsiveValue(block.properties?.imageHeight, currentBreakpoint, 75),
128
+ 75
129
+ );
130
+ const maxItems = readCount(
131
+ getResponsiveValue(block.properties?.maxItems, currentBreakpoint, 0),
132
+ 0
133
+ );
134
+
135
+ const pageContext = useThemePageContext();
136
+ const { locale, currency: localeCurrency } = useLocalization();
137
+ const { data: basket, isLoading } = useGetBasketQuery(undefined as never);
138
+
139
+ // Read-only view of the checkout pre-order. Selecting a single primitive-ish
140
+ // branch keeps this from re-rendering on every unrelated checkout action.
141
+ const preOrder = useAppSelector(
142
+ (state: Record<string, any>) => state?.checkout?.preOrder
143
+ ) as
144
+ | { shipping_amount?: string; unpaid_amount?: string }
145
+ | null
146
+ | undefined;
147
+
148
+ const liveBasket = basket as BasketTotalsLike | undefined;
149
+ const contextBasket = (pageContext?.basket ?? null) as {
150
+ totalAmount?: string;
151
+ totalProductAmount?: string;
152
+ totalDiscountAmount?: string;
153
+ voucherCode?: string;
154
+ items?: ContextLineLike[];
155
+ } | null;
156
+
157
+ const liveLines = liveBasket?.basketitem_set;
158
+
159
+ // The live query owns the post-mutation state; the SSR page context is only
160
+ // a first-paint fallback so the sidebar is not blank before it resolves.
161
+ const allLines: SummaryLine[] = Array.isArray(liveLines)
162
+ ? liveLines.map((item, index) => ({
163
+ key: String(item.id ?? item.product?.pk ?? index),
164
+ name: item.product?.name || '',
165
+ image: item.image || item.product?.image || '',
166
+ quantity: Number(item.quantity ?? 1),
167
+ total: String(item.total_amount ?? item.price ?? ''),
168
+ // The line reports the currency it was priced in; the storefront
169
+ // currency is only the fallback when commerce omits it.
170
+ currency: item.currency_type || localeCurrency || ''
171
+ }))
172
+ : (contextBasket?.items || []).map((item, index) => ({
173
+ key: String(item.product?.pk ?? index),
174
+ name: item.name || item.title || '',
175
+ image: item.image || '',
176
+ quantity: Number(item.quantity ?? 1),
177
+ total: String(item.totalAmount ?? item.price ?? ''),
178
+ currency: item.currency || localeCurrency || ''
179
+ }));
180
+
181
+ const lines = maxItems > 0 ? allLines.slice(0, maxItems) : allLines;
182
+
183
+ const totals = {
184
+ subtotal:
185
+ liveBasket?.total_product_amount ?? contextBasket?.totalProductAmount,
186
+ discount:
187
+ liveBasket?.total_discount_amount ?? contextBasket?.totalDiscountAmount,
188
+ // The pre-order's unpaid amount is the payable figure (it already includes
189
+ // shipping and any installment interest); the basket total is the
190
+ // pre-checkout fallback.
191
+ total:
192
+ preOrder?.unpaid_amount ??
193
+ liveBasket?.total_amount ??
194
+ contextBasket?.totalAmount,
195
+ shipping: preOrder?.shipping_amount,
196
+ voucher: liveBasket?.voucher_code ?? contextBasket?.voucherCode ?? ''
197
+ };
198
+
199
+ const currency =
200
+ lines[0]?.currency ||
201
+ liveBasket?.basketitem_set?.[0]?.currency_type ||
202
+ localeCurrency ||
203
+ '';
204
+
205
+ const money = (value?: string | number) =>
206
+ formatMoney(value, { locale, currency });
207
+
208
+ const hasBasket = Boolean(liveBasket || contextBasket);
209
+
210
+ const rows: TotalsRow[] = [];
211
+
212
+ if (showSubtotal && totals.subtotal !== undefined) {
213
+ rows.push({
214
+ key: 'subtotal',
215
+ label: subtotalLabel,
216
+ value: money(totals.subtotal),
217
+ type: 'subtotal'
218
+ });
219
+ }
220
+
221
+ // Shipping only exists on the checkout pre-order, so the row is omitted
222
+ // rather than guessed at when there is none.
223
+ if (showShipping && totals.shipping !== undefined) {
224
+ rows.push({
225
+ key: 'shipping',
226
+ label: shippingLabel,
227
+ value: money(totals.shipping),
228
+ type: 'shipping'
229
+ });
230
+ }
231
+
232
+ if (showDiscount && (parseAmount(totals.discount) ?? 0) > 0) {
233
+ rows.push({
234
+ key: 'discount',
235
+ label: discountLabel,
236
+ value: `${discountPrefix}${money(totals.discount)}`,
237
+ type: 'discount'
238
+ });
239
+ }
240
+
241
+ rows.push({
242
+ key: 'total',
243
+ label: totalLabel,
244
+ value: money(totals.total),
245
+ type: 'total'
246
+ });
247
+
248
+ // Plain objects so a designer binds item.name / item.image / item.quantity /
249
+ // item.total in their own row layout.
250
+ const templateLines = lines.map((line) => ({
251
+ key: line.key,
252
+ name: line.name,
253
+ image: line.image,
254
+ quantity: line.quantity,
255
+ total: formatMoney(line.total, { locale, currency: line.currency }),
256
+ currency: line.currency
257
+ }));
258
+
259
+ const hasTemplate = hasItemTemplate(block, currentBreakpoint);
260
+
261
+ const customLines =
262
+ hasTemplate && templateTarget !== 'totals' ? (
263
+ <ItemTemplateList
264
+ block={block}
265
+ items={templateLines as unknown as Record<string, unknown>[]}
266
+ placeholderId={placeholderId}
267
+ sectionId={sectionId}
268
+ isDesigner={isDesigner}
269
+ selectedBlockId={selectedBlockId}
270
+ currentBreakpoint={currentBreakpoint}
271
+ />
272
+ ) : null;
273
+
274
+ const customTotals =
275
+ hasTemplate && templateTarget === 'totals' ? (
276
+ <ItemTemplateList
277
+ block={block}
278
+ items={rows as unknown as Record<string, unknown>[]}
279
+ placeholderId={placeholderId}
280
+ sectionId={sectionId}
281
+ isDesigner={isDesigner}
282
+ selectedBlockId={selectedBlockId}
283
+ currentBreakpoint={currentBreakpoint}
284
+ />
285
+ ) : null;
286
+
287
+ if (!hasBasket) {
288
+ const authoredLabel = isLoading ? loadingLabel : emptyLabel;
289
+
290
+ if (!isDesigner && !authoredLabel) return null;
291
+
292
+ const designerLabel = isLoading
293
+ ? 'Checkout summary (loading)'
294
+ : 'Checkout summary (no basket data)';
295
+
296
+ return (
297
+ <div
298
+ data-checkout-summary=""
299
+ data-checkout-summary-state="empty"
300
+ {...partAttrs('empty', { empty: true, loading: isLoading })}
301
+ >
302
+ {authoredLabel || (isDesigner ? designerLabel : '')}
303
+ </div>
304
+ );
305
+ }
306
+
307
+ const rowStyle: React.CSSProperties = {
308
+ display: 'flex',
309
+ justifyContent: 'space-between',
310
+ gap: '12px',
311
+ padding: '4px 0'
312
+ };
313
+
314
+ return (
315
+ <div
316
+ data-checkout-summary=""
317
+ {...partAttrs('root')}
318
+ style={{
319
+ display: 'flex',
320
+ flexDirection: 'column',
321
+ gap: 'var(--checkout-summary-gap, 12px)'
322
+ }}
323
+ >
324
+ {showLineItems && (lines.length > 0 || customLines) && (
325
+ <ul
326
+ data-checkout-summary-items=""
327
+ {...partAttrs('list')}
328
+ style={{
329
+ display: 'flex',
330
+ flexDirection: 'column',
331
+ gap: '12px',
332
+ listStyle: 'none',
333
+ margin: 0,
334
+ padding: 0
335
+ }}
336
+ >
337
+ {customLines ??
338
+ lines.map((line) => (
339
+ <li
340
+ key={line.key}
341
+ data-checkout-summary-item=""
342
+ {...partAttrs('item')}
343
+ style={{
344
+ display: 'flex',
345
+ alignItems: 'flex-start',
346
+ gap: '12px'
347
+ }}
348
+ >
349
+ {showItemImage && line.image && (
350
+ // Plain img: basket thumbnails come from arbitrary commerce
351
+ // CDNs that the brand next.config may not allow.
352
+ // eslint-disable-next-line @next/next/no-img-element
353
+ <img
354
+ src={line.image}
355
+ alt={line.name}
356
+ width={imageWidth}
357
+ height={imageHeight}
358
+ {...partAttrs('image')}
359
+ style={{
360
+ objectFit: 'cover',
361
+ display: 'block',
362
+ flex: '0 0 auto'
363
+ }}
364
+ />
365
+ )}
366
+
367
+ <div
368
+ {...partAttrs('content')}
369
+ style={{ flex: '1 1 auto', minWidth: 0 }}
370
+ >
371
+ <span
372
+ data-checkout-summary-item-name=""
373
+ {...partAttrs('name')}
374
+ >
375
+ {line.name}
376
+ </span>
377
+ {showItemQuantity && (
378
+ <div
379
+ data-checkout-summary-item-quantity=""
380
+ {...partAttrs('quantity')}
381
+ style={{ marginTop: '2px', opacity: 0.8 }}
382
+ >
383
+ {quantityPrefix}
384
+ {line.quantity}
385
+ </div>
386
+ )}
387
+ </div>
388
+
389
+ <span
390
+ data-checkout-summary-item-total=""
391
+ {...partAttrs('line-total')}
392
+ style={{ flex: '0 0 auto' }}
393
+ >
394
+ {formatMoney(line.total, { locale, currency: line.currency })}
395
+ </span>
396
+ </li>
397
+ ))}
398
+ </ul>
399
+ )}
400
+
401
+ <div data-checkout-summary-totals="" {...partAttrs('totals')}>
402
+ {customTotals ??
403
+ rows.map((row) =>
404
+ row.type === 'total' ? (
405
+ <div
406
+ key={row.key}
407
+ style={{ ...rowStyle, fontWeight: 600 }}
408
+ data-checkout-summary-row="total"
409
+ {...partAttrs('total')}
410
+ >
411
+ <span {...partAttrs('total-label')}>{row.label}</span>
412
+ <span {...partAttrs('total-value')}>{row.value}</span>
413
+ </div>
414
+ ) : (
415
+ <div
416
+ key={row.key}
417
+ style={rowStyle}
418
+ data-checkout-summary-row={row.type}
419
+ {...partAttrs('row')}
420
+ >
421
+ <span {...partAttrs('row-label')}>{row.label}</span>
422
+ <span {...partAttrs('row-value')}>{row.value}</span>
423
+ </div>
424
+ )
425
+ )}
426
+ </div>
427
+
428
+ {/* Display only: applying or removing a coupon mid-checkout recalculates
429
+ the pre-order, which is the payment step's job — not this block's. */}
430
+ {showCoupon && totals.voucher && (
431
+ <div
432
+ data-checkout-summary-coupon=""
433
+ {...partAttrs('coupon', { selected: true })}
434
+ style={{ display: 'flex', justifyContent: 'space-between', gap: '12px' }}
435
+ >
436
+ <span {...partAttrs('coupon-label')}>{couponLabel}</span>
437
+ <span
438
+ data-checkout-summary-coupon-code=""
439
+ {...partAttrs('coupon-code')}
440
+ >
441
+ {totals.voucher}
442
+ </span>
443
+ </div>
444
+ )}
445
+ </div>
446
+ );
447
+ };
448
+
449
+ export default CheckoutSummaryBlock;
@@ -0,0 +1,182 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect, useState } from 'react';
4
+ import { getResponsiveValue, partAttrs } from '../utils';
5
+ import { BlockRendererProps } from './block-renderer-registry';
6
+ import { DesignerPlaceholder, readToggle } from './product-block-utils';
7
+
8
+ const SECOND = 1000;
9
+ const MINUTE = 60 * SECOND;
10
+ const HOUR = 60 * MINUTE;
11
+ const DAY = 24 * HOUR;
12
+
13
+ const pad = (value: number) => String(value).padStart(2, '0');
14
+
15
+ /**
16
+ * Functional content block: a campaign countdown for landing pages.
17
+ *
18
+ * The remaining time is only computed after mount. Rendering it during SSR
19
+ * would bake the server clock into the HTML and hydrate against a different
20
+ * client clock — a guaranteed mismatch on a value that changes every second —
21
+ * so the first paint shows placeholder digits with the final markup shape.
22
+ *
23
+ * Parts: root, segment, value, label, separator, expired.
24
+ */
25
+ const CountdownBlock = ({
26
+ block,
27
+ currentBreakpoint = 'desktop',
28
+ isDesigner = false
29
+ }: BlockRendererProps) => {
30
+ const [now, setNow] = useState<number | null>(null);
31
+
32
+ useEffect(() => {
33
+ setNow(Date.now());
34
+
35
+ const timer = setInterval(() => setNow(Date.now()), SECOND);
36
+ return () => clearInterval(timer);
37
+ }, []);
38
+
39
+ const prop = (key: string, fallback: string) =>
40
+ String(
41
+ getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
42
+ );
43
+
44
+ const boolProp = (key: string, fallback: boolean) =>
45
+ readToggle(block.properties?.[key], currentBreakpoint, fallback);
46
+
47
+ const targetDate = prop('targetDate', '').trim();
48
+ const expiredLabel = prop('expiredLabel', 'Campaign ended');
49
+ const showLabels = boolProp('showLabels', true);
50
+ const padDigits = boolProp('padDigits', true);
51
+ const separator = prop('separator', '');
52
+ const placeholder = prop('placeholder', '--');
53
+
54
+ const units = [
55
+ {
56
+ key: 'days',
57
+ ms: DAY,
58
+ label: prop('daysLabel', 'Days'),
59
+ shown: boolProp('showDays', true)
60
+ },
61
+ {
62
+ key: 'hours',
63
+ ms: HOUR,
64
+ label: prop('hoursLabel', 'Hours'),
65
+ shown: boolProp('showHours', true)
66
+ },
67
+ {
68
+ key: 'minutes',
69
+ ms: MINUTE,
70
+ label: prop('minutesLabel', 'Minutes'),
71
+ shown: boolProp('showMinutes', true)
72
+ },
73
+ {
74
+ key: 'seconds',
75
+ ms: SECOND,
76
+ label: prop('secondsLabel', 'Seconds'),
77
+ shown: boolProp('showSeconds', true)
78
+ }
79
+ ];
80
+
81
+ const target = targetDate ? Date.parse(targetDate) : Number.NaN;
82
+
83
+ if (!Number.isFinite(target)) {
84
+ return isDesigner ? (
85
+ <DesignerPlaceholder
86
+ label="Countdown (set a target date, e.g. 2026-12-31T23:59:00)"
87
+ attribute="data-countdown"
88
+ />
89
+ ) : null;
90
+ }
91
+
92
+ const remaining = now === null ? null : Math.max(0, target - now);
93
+ const state =
94
+ remaining === null ? 'pending' : remaining === 0 ? 'expired' : 'running';
95
+
96
+ if (state === 'expired') {
97
+ return (
98
+ <div
99
+ data-countdown=""
100
+ data-countdown-state="expired"
101
+ {...partAttrs('expired', { empty: true })}
102
+ >
103
+ {expiredLabel}
104
+ </div>
105
+ );
106
+ }
107
+
108
+ // A hidden unit rolls into the next visible one, so switching days off shows
109
+ // the total hours rather than silently dropping a day from the total.
110
+ let rest = remaining;
111
+ const segments = units
112
+ .filter((unit) => unit.shown)
113
+ .map((unit) => {
114
+ if (rest === null) return { key: unit.key, label: unit.label, value: null };
115
+
116
+ const value = Math.floor(rest / unit.ms);
117
+ rest = rest % unit.ms;
118
+
119
+ return { key: unit.key, label: unit.label, value };
120
+ });
121
+
122
+ return (
123
+ <div
124
+ data-countdown=""
125
+ data-countdown-state={state}
126
+ {...partAttrs('root', { loading: remaining === null })}
127
+ style={{
128
+ display: 'flex',
129
+ alignItems: 'center',
130
+ gap: 'var(--countdown-gap, 12px)'
131
+ }}
132
+ >
133
+ {segments.map((segment, index) => (
134
+ <React.Fragment key={segment.key}>
135
+ <div
136
+ data-countdown-segment={segment.key}
137
+ {...partAttrs('segment')}
138
+ style={{
139
+ display: 'flex',
140
+ flexDirection: 'column',
141
+ alignItems: 'center',
142
+ lineHeight: 1.2
143
+ }}
144
+ >
145
+ <span
146
+ data-countdown-value=""
147
+ {...partAttrs('value')}
148
+ style={{ fontVariantNumeric: 'tabular-nums' }}
149
+ >
150
+ {segment.value === null
151
+ ? placeholder
152
+ : padDigits
153
+ ? pad(segment.value)
154
+ : String(segment.value)}
155
+ </span>
156
+ {showLabels && (
157
+ <span
158
+ data-countdown-label=""
159
+ {...partAttrs('label')}
160
+ style={{ fontSize: '0.75em', opacity: 0.7 }}
161
+ >
162
+ {segment.label}
163
+ </span>
164
+ )}
165
+ </div>
166
+
167
+ {separator && index < segments.length - 1 && (
168
+ <span
169
+ data-countdown-separator=""
170
+ aria-hidden="true"
171
+ {...partAttrs('separator')}
172
+ >
173
+ {separator}
174
+ </span>
175
+ )}
176
+ </React.Fragment>
177
+ ))}
178
+ </div>
179
+ );
180
+ };
181
+
182
+ export default CountdownBlock;