@akinon/pz-theme 2.0.37-rc.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 +7 -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,307 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { getResponsiveValue, partAttrs } from '../utils';
5
+ import { BlockRendererProps } from './block-renderer-registry';
6
+ import { hasItemTemplate, ItemTemplateList } from './item-template';
7
+ import {
8
+ DesignerPlaceholder,
9
+ pickProductField,
10
+ readToggle,
11
+ useThemeProduct
12
+ } from './product-block-utils';
13
+
14
+ interface ProductAttributeRow {
15
+ key: string;
16
+ label: string;
17
+ value: string;
18
+ /**
19
+ * What `data-product-attribute` has always carried. The commerce key only
20
+ * became available when the page context stopped dropping it, and swapping
21
+ * the emitted value would rename an attribute brands may already style.
22
+ */
23
+ domKey: string;
24
+ }
25
+
26
+ const ALLOWED_SORTS = ['default', 'label-asc', 'label-desc'];
27
+
28
+ const humanizeKey = (key: string) =>
29
+ key
30
+ .replace(/[_-]+/g, ' ')
31
+ .trim()
32
+ .replace(/^./, (first) => first.toUpperCase());
33
+
34
+ const toText = (value: unknown): string => {
35
+ if (typeof value === 'string') return value.trim();
36
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value);
37
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No';
38
+ return '';
39
+ };
40
+
41
+ /**
42
+ * The page context normalizes `attributes_kwargs` into `[{ label, value }]`,
43
+ * but a brand that publishes its own product context can still hand over the
44
+ * raw commerce record (`{ key: { label, value } }` or `{ key: value }`).
45
+ * Both shapes are accepted so the block does not depend on which builder ran.
46
+ */
47
+ const toRows = (raw: unknown): ProductAttributeRow[] => {
48
+ if (Array.isArray(raw)) {
49
+ return raw
50
+ .map((entry, index) => {
51
+ if (!entry || typeof entry !== 'object') return null;
52
+
53
+ const record = entry as Record<string, unknown>;
54
+ const label = toText(record.label) || toText(record.name);
55
+ if (!label) return null;
56
+
57
+ return {
58
+ key: toText(record.key) || `attribute-${index}`,
59
+ label,
60
+ value: toText(record.value),
61
+ domKey: `attribute-${index}`
62
+ };
63
+ })
64
+ .filter((row): row is ProductAttributeRow => row !== null);
65
+ }
66
+
67
+ if (raw && typeof raw === 'object') {
68
+ return Object.entries(raw as Record<string, unknown>)
69
+ .map(([key, entry]) => {
70
+ if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
71
+ const record = entry as Record<string, unknown>;
72
+ const label = toText(record.label) || toText(record.name);
73
+
74
+ return {
75
+ key,
76
+ label: label || humanizeKey(key),
77
+ value: toText(record.value),
78
+ // A keyed record was always emitted under its own key.
79
+ domKey: key
80
+ };
81
+ }
82
+
83
+ const value = toText(entry);
84
+ // Nested/opaque values (objects, arrays) flatten to '' and are treated
85
+ // exactly like an empty attribute rather than printed as [object].
86
+ return { key, label: humanizeKey(key), value, domKey: key };
87
+ })
88
+ .filter((row) => Boolean(row.label));
89
+ }
90
+
91
+ return [];
92
+ };
93
+
94
+ /** Comma/space separated attribute keys, lowercased for a forgiving match. */
95
+ const parseKeyList = (raw: string): string[] =>
96
+ raw
97
+ .split(/[,\s]+/)
98
+ .map((entry) => entry.trim().toLowerCase())
99
+ .filter(Boolean);
100
+
101
+ /**
102
+ * Functional PDP block: the product attribute table (material, colour code,
103
+ * fabric, …). Attribute CONTENT is commerce-driven — it comes from the product
104
+ * on the page context — and the editor owns the layout, which attributes are
105
+ * printed, their order, an optional custom row layout and the styling.
106
+ * Properties: layout, hideEmpty, showLabel, separator, limit, sort,
107
+ * includeKeys, excludeKeys, gap.
108
+ */
109
+ const ProductAttributesBlock = ({
110
+ block,
111
+ placeholderId,
112
+ sectionId,
113
+ selectedBlockId = null,
114
+ currentBreakpoint = 'desktop',
115
+ isDesigner = false
116
+ }: BlockRendererProps) => {
117
+ const product = useThemeProduct();
118
+
119
+ const prop = (key: string, fallback: string) =>
120
+ String(
121
+ getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
122
+ );
123
+
124
+ const layoutValue = String(
125
+ getResponsiveValue(block.properties?.layout, currentBreakpoint, 'table')
126
+ );
127
+ const layout = layoutValue === 'list' ? 'list' : 'table';
128
+ const hideEmpty = readToggle(
129
+ block.properties?.hideEmpty,
130
+ currentBreakpoint,
131
+ true
132
+ );
133
+ const showLabel = readToggle(
134
+ block.properties?.showLabel,
135
+ currentBreakpoint,
136
+ true
137
+ );
138
+ const separator = prop('separator', '');
139
+ const sortValue = prop('sort', 'default');
140
+ const sort = ALLOWED_SORTS.includes(sortValue) ? sortValue : 'default';
141
+ const includeKeys = parseKeyList(prop('includeKeys', ''));
142
+ const excludeKeys = parseKeyList(prop('excludeKeys', ''));
143
+ const limitValue = Number(
144
+ getResponsiveValue(block.properties?.limit, currentBreakpoint, 0)
145
+ );
146
+ const limit =
147
+ Number.isFinite(limitValue) && limitValue > 0 ? Math.round(limitValue) : 0;
148
+ const gapValue = Number(
149
+ getResponsiveValue(block.properties?.gap, currentBreakpoint, 8)
150
+ );
151
+ const gap = Number.isFinite(gapValue) && gapValue >= 0 ? gapValue : 8;
152
+
153
+ const allRows = toRows(pickProductField(product, 'attributes')).filter((row) =>
154
+ hideEmpty ? Boolean(row.value) : true
155
+ );
156
+
157
+ const filteredRows = allRows.filter((row) => {
158
+ const key = row.key.toLowerCase();
159
+ if (includeKeys.length > 0 && !includeKeys.includes(key)) return false;
160
+ return !excludeKeys.includes(key);
161
+ });
162
+
163
+ // Compared without a locale on purpose: a locale-aware collation resolves
164
+ // differently on the server and in the browser, which would reorder the
165
+ // rows during hydration.
166
+ const sortedRows =
167
+ sort === 'default'
168
+ ? filteredRows
169
+ : filteredRows.slice().sort((a, b) => {
170
+ const left = a.label.toLowerCase();
171
+ const right = b.label.toLowerCase();
172
+ if (left === right) return 0;
173
+ const ascending = left < right ? -1 : 1;
174
+ return sort === 'label-desc' ? -ascending : ascending;
175
+ });
176
+
177
+ const rows = limit > 0 ? sortedRows.slice(0, limit) : sortedRows;
178
+
179
+ const hasCustomRows = hasItemTemplate(block, currentBreakpoint);
180
+ // Concatenated rather than rendered as two children: React's SSR puts a
181
+ // `<!-- -->` marker between adjacent text nodes, which would change the
182
+ // markup of every composition that leaves the separator empty.
183
+ const labelText = (label: string) =>
184
+ separator ? `${label}${separator}` : label;
185
+
186
+ if (rows.length === 0 && !hasCustomRows) {
187
+ return isDesigner ? (
188
+ <DesignerPlaceholder
189
+ label="Product attributes (no attribute data)"
190
+ attribute="data-product-attributes"
191
+ />
192
+ ) : null;
193
+ }
194
+
195
+ // A designer-authored row cannot live inside <table>/<dl> markup, so the
196
+ // custom layout gets a plain flow container of its own.
197
+ if (hasCustomRows) {
198
+ return (
199
+ <div
200
+ data-product-attributes=""
201
+ data-product-attributes-layout="custom"
202
+ {...partAttrs('list')}
203
+ style={{
204
+ display: 'flex',
205
+ flexDirection: 'column',
206
+ gap: `var(--product-attributes-gap, ${gap}px)`
207
+ }}
208
+ >
209
+ <ItemTemplateList
210
+ block={block}
211
+ items={rows.map((row) => ({
212
+ key: row.key,
213
+ label: row.label,
214
+ // `name` is the binding a designer reaches for first; it mirrors
215
+ // the label rather than introducing a second vocabulary.
216
+ name: row.label,
217
+ value: row.value
218
+ }))}
219
+ placeholderId={placeholderId}
220
+ sectionId={sectionId}
221
+ isDesigner={isDesigner}
222
+ selectedBlockId={selectedBlockId}
223
+ currentBreakpoint={currentBreakpoint}
224
+ />
225
+ </div>
226
+ );
227
+ }
228
+
229
+ if (layout === 'list') {
230
+ return (
231
+ <dl
232
+ data-product-attributes=""
233
+ data-product-attributes-layout="list"
234
+ {...partAttrs('list')}
235
+ style={{
236
+ margin: 0,
237
+ display: 'flex',
238
+ flexDirection: 'column',
239
+ gap: `var(--product-attributes-gap, ${gap}px)`
240
+ }}
241
+ >
242
+ {rows.map((row) => (
243
+ <div
244
+ key={row.key}
245
+ data-product-attribute={row.domKey}
246
+ {...partAttrs('row')}
247
+ style={{ display: 'flex', gap: '8px' }}
248
+ >
249
+ {showLabel && (
250
+ <dt {...partAttrs('label')} style={{ opacity: 0.7 }}>
251
+ {labelText(row.label)}
252
+ </dt>
253
+ )}
254
+ <dd {...partAttrs('value')} style={{ margin: 0 }}>
255
+ {row.value}
256
+ </dd>
257
+ </div>
258
+ ))}
259
+ </dl>
260
+ );
261
+ }
262
+
263
+ return (
264
+ <table
265
+ data-product-attributes=""
266
+ data-product-attributes-layout="table"
267
+ {...partAttrs('list')}
268
+ style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left' }}
269
+ >
270
+ <tbody>
271
+ {rows.map((row) => (
272
+ <tr
273
+ key={row.key}
274
+ data-product-attribute={row.domKey}
275
+ {...partAttrs('row')}
276
+ >
277
+ {showLabel && (
278
+ <th
279
+ scope="row"
280
+ {...partAttrs('label')}
281
+ style={{
282
+ padding: 'var(--product-attributes-cell-padding, 6px 0)',
283
+ fontWeight: 'inherit',
284
+ opacity: 0.7,
285
+ verticalAlign: 'top'
286
+ }}
287
+ >
288
+ {labelText(row.label)}
289
+ </th>
290
+ )}
291
+ <td
292
+ {...partAttrs('value')}
293
+ style={{
294
+ padding: 'var(--product-attributes-cell-padding, 6px 0)',
295
+ verticalAlign: 'top'
296
+ }}
297
+ >
298
+ {row.value}
299
+ </td>
300
+ </tr>
301
+ ))}
302
+ </tbody>
303
+ </table>
304
+ );
305
+ };
306
+
307
+ export default ProductAttributesBlock;