@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,457 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import NextLink from 'next/link';
5
+ import {
6
+ useGetBasketQuery,
7
+ useUpdateQuantityMutation
8
+ } from '@akinon/next/data/client/basket';
9
+ import { useLocalization } from '@akinon/next/hooks';
10
+ import { getResponsiveValue, partAttrs } from '../utils';
11
+ import { useThemePageContext } from '../theme-page-context';
12
+ import { formatMoney } from './commerce-format';
13
+ import { hasItemTemplate, ItemTemplateList } from './item-template';
14
+ import { readToggle } from './product-block-utils';
15
+ import { BlockRendererProps } from './block-renderer-registry';
16
+
17
+ interface BasketLineLike {
18
+ id?: number;
19
+ quantity?: number;
20
+ image?: string;
21
+ price?: string;
22
+ unit_price?: string;
23
+ total_amount?: string;
24
+ currency_type?: string;
25
+ attributes?: unknown;
26
+ product?: {
27
+ pk?: number;
28
+ name?: string;
29
+ absolute_url?: string;
30
+ image?: string;
31
+ } | null;
32
+ }
33
+
34
+ /**
35
+ * The basket line's variant choices as one line ("M / Red"). Commerce sends
36
+ * them as a plain object, so the values are joined in payload order — the keys
37
+ * are attribute slugs and are not shopper-facing copy.
38
+ */
39
+ const formatVariant = (attributes: unknown, separator: string): string => {
40
+ if (!attributes || typeof attributes !== 'object') return '';
41
+
42
+ return Object.values(attributes as Record<string, unknown>)
43
+ .map((value) =>
44
+ value === null || value === undefined ? '' : String(value).trim()
45
+ )
46
+ .filter(Boolean)
47
+ .join(separator);
48
+ };
49
+
50
+ const readCount = (value: unknown, fallback: number): number => {
51
+ const numeric = Math.round(Number(value));
52
+
53
+ return Number.isFinite(numeric) && numeric >= 0 ? numeric : fallback;
54
+ };
55
+
56
+ const RemoveGlyph = ({ icon }: { icon: string }) =>
57
+ icon === 'trash' ? (
58
+ <svg
59
+ width="18"
60
+ height="18"
61
+ viewBox="0 0 24 24"
62
+ fill="none"
63
+ stroke="currentColor"
64
+ strokeWidth="1.6"
65
+ strokeLinecap="round"
66
+ >
67
+ <path d="M4 7h16M9 7V5h6v2M7 7l1 13h8l1-13" />
68
+ </svg>
69
+ ) : (
70
+ <svg
71
+ width="18"
72
+ height="18"
73
+ viewBox="0 0 24 24"
74
+ fill="none"
75
+ stroke="currentColor"
76
+ strokeWidth="1.6"
77
+ strokeLinecap="round"
78
+ >
79
+ <path d="M6 6l12 12M18 6L6 18" />
80
+ </svg>
81
+ );
82
+
83
+ /**
84
+ * Functional commerce block: editable basket lines. Basket CONTENT stays
85
+ * commerce-driven (basket service); the editor owns which affordances are
86
+ * exposed, every shopper-facing string and the styling.
87
+ *
88
+ * Parts: root, item, image, content, name, variant, price, quantity,
89
+ * quantity-button, quantity-input, actions, line-total, remove, empty.
90
+ * A child marked as the item template replaces the built-in row.
91
+ */
92
+ const BasketItemsBlock = ({
93
+ block,
94
+ placeholderId,
95
+ sectionId,
96
+ selectedBlockId = null,
97
+ currentBreakpoint = 'desktop',
98
+ isDesigner = false
99
+ }: BlockRendererProps) => {
100
+ const prop = (key: string, fallback: string) =>
101
+ String(
102
+ getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
103
+ );
104
+
105
+ const boolProp = (key: string, fallback: boolean) =>
106
+ readToggle(block.properties?.[key], currentBreakpoint, fallback);
107
+
108
+ const showImage = boolProp('showImage', true);
109
+ const allowQuantityChange = boolProp('allowQuantityChange', true);
110
+ const allowRemove = boolProp('allowRemove', true);
111
+ const showPrice = boolProp('showPrice', true);
112
+ const showTotal = boolProp('showTotal', true);
113
+ const showVariant = boolProp('showVariant', false);
114
+ const showDivider = boolProp('showDivider', true);
115
+
116
+ const variantSeparator = prop('variantSeparator', ' / ');
117
+ const decreaseLabel = prop('decreaseLabel', '–');
118
+ const increaseLabel = prop('increaseLabel', '+');
119
+ const decreaseAriaLabel = prop('decreaseAriaLabel', 'Decrease quantity');
120
+ const increaseAriaLabel = prop('increaseAriaLabel', 'Increase quantity');
121
+ const removeAriaLabel = prop('removeAriaLabel', 'Remove item');
122
+ const removeIcon = prop('removeIcon', 'cross');
123
+ const removeLabel = prop('removeLabel', 'Remove');
124
+ const emptyLabel = prop('emptyLabel', '').trim();
125
+ const loadingLabel = prop('loadingLabel', '').trim();
126
+
127
+ const imageWidth = readCount(
128
+ getResponsiveValue(block.properties?.imageWidth, currentBreakpoint, 72),
129
+ 72
130
+ );
131
+ const imageHeight = readCount(
132
+ getResponsiveValue(block.properties?.imageHeight, currentBreakpoint, 96),
133
+ 96
134
+ );
135
+ const nameLines = readCount(
136
+ getResponsiveValue(block.properties?.nameLines, currentBreakpoint, 0),
137
+ 0
138
+ );
139
+ const maxItems = readCount(
140
+ getResponsiveValue(block.properties?.maxItems, currentBreakpoint, 0),
141
+ 0
142
+ );
143
+
144
+ const pageContext = useThemePageContext();
145
+ const { locale, currency: localeCurrency } = useLocalization();
146
+ const { data: basket, isLoading } = useGetBasketQuery(undefined as never);
147
+ const [updateQuantity, { isLoading: isUpdating }] =
148
+ useUpdateQuantityMutation();
149
+
150
+ const contextBasket = (pageContext?.basket ?? null) as {
151
+ items?: BasketLineLike[];
152
+ } | null;
153
+
154
+ // The live query owns the post-mutation state; the SSR page context is only
155
+ // a first-paint fallback so the list is not empty before the query resolves.
156
+ const allItems: BasketLineLike[] =
157
+ (basket as { basketitem_set?: BasketLineLike[] } | undefined)
158
+ ?.basketitem_set ??
159
+ contextBasket?.items ??
160
+ [];
161
+
162
+ const items = maxItems > 0 ? allItems.slice(0, maxItems) : allItems;
163
+
164
+ const changeQuantity = (item: BasketLineLike, nextQuantity: number) => {
165
+ const productPk = item.product?.pk;
166
+
167
+ if (isDesigner || productPk === undefined || nextQuantity < 0) return;
168
+
169
+ updateQuantity({
170
+ product: productPk,
171
+ quantity: nextQuantity,
172
+ attributes: (item.attributes as Record<string, unknown>) || {}
173
+ });
174
+ };
175
+
176
+ // Navigating inside the designer iframe would drop the author out of the
177
+ // page they are editing, so the product links are inert there.
178
+ const guard = (event: React.MouseEvent) => {
179
+ if (isDesigner) event.preventDefault();
180
+ };
181
+
182
+ // Plain objects so a designer binds item.name / item.price / item.image /
183
+ // item.url / item.quantity / item.total / item.variant in their own row.
184
+ const templateItems = items.map((item, index) => {
185
+ const currency = item.currency_type || localeCurrency;
186
+
187
+ return {
188
+ key: String(item.id ?? item.product?.pk ?? index),
189
+ name: item.product?.name || '',
190
+ image: item.image || item.product?.image || '',
191
+ url: item.product?.absolute_url || '',
192
+ quantity: Number(item.quantity ?? 0),
193
+ variant: formatVariant(item.attributes, variantSeparator),
194
+ price: formatMoney(item.unit_price || item.price, { locale, currency }),
195
+ total: formatMoney(item.total_amount, { locale, currency }),
196
+ currency
197
+ };
198
+ });
199
+
200
+ // A designer who marked a child as the row layout gets that layout repeated
201
+ // per line; otherwise the built-in row renders unchanged.
202
+ const customRows = hasItemTemplate(block, currentBreakpoint) ? (
203
+ <ItemTemplateList
204
+ block={block}
205
+ items={templateItems as unknown as Record<string, unknown>[]}
206
+ placeholderId={placeholderId}
207
+ sectionId={sectionId}
208
+ isDesigner={isDesigner}
209
+ selectedBlockId={selectedBlockId}
210
+ currentBreakpoint={currentBreakpoint}
211
+ />
212
+ ) : null;
213
+
214
+ if (items.length === 0 && !customRows) {
215
+ const authoredLabel = isLoading ? loadingLabel : emptyLabel;
216
+
217
+ // Authored empty copy is the one case the block speaks to the shopper; with
218
+ // none set the storefront stays silent exactly as before.
219
+ if (!isDesigner && !authoredLabel) return null;
220
+
221
+ const designerLabel = isLoading
222
+ ? 'Basket items (loading)'
223
+ : 'Basket items (empty)';
224
+
225
+ return (
226
+ <div
227
+ data-basket-items=""
228
+ data-basket-items-state="empty"
229
+ {...partAttrs('empty', { empty: true, loading: isLoading })}
230
+ >
231
+ {authoredLabel || (isDesigner ? designerLabel : '')}
232
+ </div>
233
+ );
234
+ }
235
+
236
+ const nameStyle: React.CSSProperties = {
237
+ color: 'inherit',
238
+ textDecoration: 'none',
239
+ ...(nameLines > 0
240
+ ? {
241
+ display: '-webkit-box',
242
+ WebkitBoxOrient: 'vertical',
243
+ WebkitLineClamp: nameLines,
244
+ overflow: 'hidden'
245
+ }
246
+ : null)
247
+ };
248
+
249
+ return (
250
+ <ul
251
+ data-basket-items=""
252
+ {...partAttrs('root', { loading: isUpdating })}
253
+ style={{
254
+ display: 'flex',
255
+ flexDirection: 'column',
256
+ gap: 'var(--basket-items-gap, 16px)',
257
+ listStyle: 'none',
258
+ margin: 0,
259
+ padding: 0
260
+ }}
261
+ >
262
+ {customRows ??
263
+ items.map((item, index) => {
264
+ // The line reports the currency it was priced in; the storefront
265
+ // currency is only the fallback when commerce omits it.
266
+ const currency = item.currency_type || localeCurrency;
267
+ const image = item.image || item.product?.image || '';
268
+ const name = item.product?.name || '';
269
+ const href = item.product?.absolute_url || '';
270
+ const linkHref = isDesigner ? '#' : href || '#';
271
+ const quantity = Number(item.quantity ?? 0);
272
+ const variant = showVariant
273
+ ? formatVariant(item.attributes, variantSeparator)
274
+ : '';
275
+
276
+ return (
277
+ <li
278
+ key={item.id ?? item.product?.pk ?? index}
279
+ data-basket-item=""
280
+ {...partAttrs('item')}
281
+ style={{
282
+ display: 'flex',
283
+ alignItems: 'flex-start',
284
+ gap: '12px',
285
+ paddingBottom: '16px',
286
+ ...(showDivider
287
+ ? {
288
+ borderBottom:
289
+ '1px solid var(--basket-items-border, #e5e7eb)'
290
+ }
291
+ : null)
292
+ }}
293
+ >
294
+ {showImage && image && (
295
+ <NextLink
296
+ href={linkHref}
297
+ onClick={guard}
298
+ style={{ flex: '0 0 auto', display: 'block' }}
299
+ >
300
+ {/* Plain img: basket line thumbnails come from arbitrary
301
+ commerce CDNs that the brand next.config may not allow. */}
302
+ {/* eslint-disable-next-line @next/next/no-img-element */}
303
+ <img
304
+ src={image}
305
+ alt={name}
306
+ width={imageWidth}
307
+ height={imageHeight}
308
+ {...partAttrs('image')}
309
+ style={{ objectFit: 'cover', display: 'block' }}
310
+ />
311
+ </NextLink>
312
+ )}
313
+
314
+ <div
315
+ {...partAttrs('content')}
316
+ style={{ flex: '1 1 auto', minWidth: 0 }}
317
+ >
318
+ <NextLink
319
+ href={linkHref}
320
+ onClick={guard}
321
+ data-basket-item-name=""
322
+ {...partAttrs('name')}
323
+ style={nameStyle}
324
+ >
325
+ {name}
326
+ </NextLink>
327
+
328
+ {variant && (
329
+ <div
330
+ data-basket-item-variant=""
331
+ {...partAttrs('variant')}
332
+ style={{ marginTop: '4px', opacity: 0.8 }}
333
+ >
334
+ {variant}
335
+ </div>
336
+ )}
337
+
338
+ {showPrice && (
339
+ <div
340
+ data-basket-item-price=""
341
+ {...partAttrs('price')}
342
+ style={{ marginTop: '4px', opacity: 0.8 }}
343
+ >
344
+ {formatMoney(item.unit_price || item.price, {
345
+ locale,
346
+ currency
347
+ })}
348
+ </div>
349
+ )}
350
+
351
+ {allowQuantityChange && (
352
+ <div
353
+ data-basket-item-quantity=""
354
+ {...partAttrs('quantity', { loading: isUpdating })}
355
+ style={{
356
+ display: 'inline-flex',
357
+ alignItems: 'center',
358
+ gap: '8px',
359
+ marginTop: '8px',
360
+ border: '1px solid var(--basket-items-border, #e5e7eb)',
361
+ borderRadius: '4px',
362
+ padding: '2px 6px'
363
+ }}
364
+ >
365
+ <button
366
+ type="button"
367
+ aria-label={decreaseAriaLabel}
368
+ disabled={isUpdating || quantity <= 1}
369
+ onClick={() => changeQuantity(item, quantity - 1)}
370
+ {...partAttrs('quantity-button', {
371
+ disabled: isUpdating || quantity <= 1
372
+ })}
373
+ style={{
374
+ background: 'none',
375
+ border: 'none',
376
+ cursor: 'pointer',
377
+ color: 'inherit',
378
+ padding: '2px 6px'
379
+ }}
380
+ >
381
+ {decreaseLabel}
382
+ </button>
383
+ <span {...partAttrs('quantity-input')}>{quantity}</span>
384
+ <button
385
+ type="button"
386
+ aria-label={increaseAriaLabel}
387
+ disabled={isUpdating}
388
+ onClick={() => changeQuantity(item, quantity + 1)}
389
+ {...partAttrs('quantity-button', {
390
+ disabled: isUpdating
391
+ })}
392
+ style={{
393
+ background: 'none',
394
+ border: 'none',
395
+ cursor: 'pointer',
396
+ color: 'inherit',
397
+ padding: '2px 6px'
398
+ }}
399
+ >
400
+ {increaseLabel}
401
+ </button>
402
+ </div>
403
+ )}
404
+ </div>
405
+
406
+ <div
407
+ {...partAttrs('actions')}
408
+ style={{
409
+ flex: '0 0 auto',
410
+ display: 'flex',
411
+ flexDirection: 'column',
412
+ alignItems: 'flex-end',
413
+ gap: '8px'
414
+ }}
415
+ >
416
+ {showTotal && (
417
+ <span
418
+ data-basket-item-total=""
419
+ {...partAttrs('line-total')}
420
+ >
421
+ {formatMoney(item.total_amount, { locale, currency })}
422
+ </span>
423
+ )}
424
+
425
+ {allowRemove && (
426
+ <button
427
+ type="button"
428
+ data-basket-item-remove=""
429
+ aria-label={removeAriaLabel}
430
+ disabled={isUpdating}
431
+ // Quantity 0 is how the commerce basket endpoint deletes a line.
432
+ onClick={() => changeQuantity(item, 0)}
433
+ {...partAttrs('remove', { disabled: isUpdating })}
434
+ style={{
435
+ background: 'none',
436
+ border: 'none',
437
+ cursor: 'pointer',
438
+ color: 'inherit',
439
+ padding: 0
440
+ }}
441
+ >
442
+ {removeIcon === 'text' ? (
443
+ removeLabel
444
+ ) : (
445
+ <RemoveGlyph icon={removeIcon} />
446
+ )}
447
+ </button>
448
+ )}
449
+ </div>
450
+ </li>
451
+ );
452
+ })}
453
+ </ul>
454
+ );
455
+ };
456
+
457
+ export default BasketItemsBlock;