@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.
- package/CHANGELOG.md +7 -31
- package/package.json +4 -4
- package/src/__tests__/__snapshots__/facet-filters-block.test.tsx.snap +11 -0
- package/src/__tests__/custom-css.test.ts +181 -0
- package/src/__tests__/designer-chrome-clipping.test.tsx +497 -0
- package/src/__tests__/facet-filters-block.test.tsx +707 -0
- package/src/__tests__/item-template.test.tsx +184 -0
- package/src/__tests__/listing-controls.test.tsx +961 -0
- package/src/__tests__/part-style-scope.test.tsx +229 -0
- package/src/__tests__/part-styles.test.ts +311 -0
- package/src/__tests__/registry-parity.test.ts +42 -0
- package/src/__tests__/row-visibility.test.ts +672 -0
- package/src/__tests__/sort-select-page-cursor.test.tsx +336 -0
- package/src/__tests__/theme-block-html-id.test.tsx +167 -0
- package/src/blocks/__tests__/__snapshots__/product-gallery-block.test.tsx.snap +15 -0
- package/src/blocks/__tests__/accordion-block-state.test.tsx +201 -0
- package/src/blocks/__tests__/facet-filters-block.test.tsx +190 -0
- package/src/blocks/__tests__/listing-context-card-model.test.tsx +230 -0
- package/src/blocks/__tests__/listing-lookup-hardening.test.ts +80 -0
- package/src/blocks/__tests__/product-gallery-block.test.tsx +974 -0
- package/src/blocks/__tests__/product-grid-composition.test.tsx +570 -0
- package/src/blocks/__tests__/product-grid-lookups.test.tsx +230 -0
- package/src/blocks/__tests__/product-price-block.test.tsx +248 -0
- package/src/blocks/__tests__/product-recommendations-block.test.tsx +702 -0
- package/src/blocks/__tests__/share-block.test.tsx +392 -0
- package/src/blocks/__tests__/store-locator-block.test.tsx +691 -0
- package/src/blocks/__tests__/variant-selector-block.test.tsx +817 -0
- package/src/blocks/__tests__/variant-selector-golden.json +20 -0
- package/src/blocks/account-menu-block.tsx +264 -0
- package/src/blocks/add-to-cart-block.tsx +417 -0
- package/src/blocks/address-list-block.tsx +348 -0
- package/src/blocks/applied-filters-block.tsx +288 -0
- package/src/blocks/auth-form-block.tsx +295 -0
- package/src/blocks/basket-items-block.tsx +457 -0
- package/src/blocks/basket-summary-block.tsx +404 -0
- package/src/blocks/block-renderer-registry.tsx +94 -0
- package/src/blocks/change-password-form-block.tsx +240 -0
- package/src/blocks/checkout-steps-block.tsx +288 -0
- package/src/blocks/checkout-summary-block.tsx +449 -0
- package/src/blocks/countdown-block.tsx +182 -0
- package/src/blocks/coupon-list-block.tsx +292 -0
- package/src/blocks/facet-filters-block.tsx +1686 -0
- package/src/blocks/faq-accordion-block.tsx +358 -0
- package/src/blocks/favorite-button-block.tsx +252 -0
- package/src/blocks/favourites-grid-block.tsx +510 -0
- package/src/blocks/forgot-password-block.tsx +155 -0
- package/src/blocks/item-template-utils.ts +131 -0
- package/src/blocks/item-template.tsx +164 -0
- package/src/blocks/listing-breadcrumb-block.tsx +175 -0
- package/src/blocks/listing-context.ts +850 -0
- package/src/blocks/listing-pagination-block.tsx +923 -0
- package/src/blocks/listing-result-count-block.tsx +126 -0
- package/src/blocks/listing-title-block.tsx +115 -0
- package/src/blocks/oauth-buttons-block.tsx +205 -0
- package/src/blocks/order-detail-block.tsx +474 -0
- package/src/blocks/order-list-block.tsx +274 -0
- package/src/blocks/order-success-block.tsx +641 -0
- package/src/blocks/order-tracking-block.tsx +533 -0
- package/src/blocks/product-attributes-block.tsx +307 -0
- package/src/blocks/product-block-utils.tsx +639 -0
- package/src/blocks/product-breadcrumb-block.tsx +215 -0
- package/src/blocks/product-description-block.tsx +169 -0
- package/src/blocks/product-gallery-block.tsx +1523 -0
- package/src/blocks/product-grid-block.tsx +966 -0
- package/src/blocks/product-price-block.tsx +373 -0
- package/src/blocks/product-recommendations-block.tsx +1433 -0
- package/src/blocks/product-title-block.tsx +129 -0
- package/src/blocks/profile-form-block.tsx +297 -0
- package/src/blocks/recently-viewed-store.ts +201 -0
- package/src/blocks/register-form-block.tsx +362 -0
- package/src/blocks/share-block.tsx +667 -0
- package/src/blocks/size-guide-block.tsx +313 -0
- package/src/blocks/sort-select-block.tsx +636 -0
- package/src/blocks/stock-status-block.tsx +168 -0
- package/src/blocks/store-locator-block.tsx +1184 -0
- package/src/blocks/variant-selector-block.tsx +1110 -0
- package/src/sections/__tests__/tablet-raw-bucket-readers.test.tsx +186 -0
- 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;
|