@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,215 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import NextLink from 'next/link';
5
+ import { getResponsiveValue, partAttrs } from '../utils';
6
+ import { useThemePageContext } from '../theme-page-context';
7
+ import { BlockRendererProps } from './block-renderer-registry';
8
+ import {
9
+ DesignerPlaceholder,
10
+ getProductName,
11
+ pickProductField,
12
+ readToggle,
13
+ useThemeProduct
14
+ } from './product-block-utils';
15
+
16
+ interface ProductCrumb {
17
+ label: string;
18
+ url: string;
19
+ }
20
+
21
+ const toRecord = (value: unknown): Record<string, unknown> =>
22
+ value && typeof value === 'object' && !Array.isArray(value)
23
+ ? (value as Record<string, unknown>)
24
+ : {};
25
+
26
+ const toText = (value: unknown): string =>
27
+ typeof value === 'string'
28
+ ? value
29
+ : typeof value === 'number' && Number.isFinite(value)
30
+ ? String(value)
31
+ : '';
32
+
33
+ const normalizeCrumb = (entry: unknown): ProductCrumb | null => {
34
+ // Category payloads are sometimes a bare name with no URL behind it.
35
+ if (typeof entry === 'string') {
36
+ return entry.trim() ? { label: entry.trim(), url: '' } : null;
37
+ }
38
+
39
+ const crumb = toRecord(entry);
40
+ const label =
41
+ toText(crumb.label) || toText(crumb.name) || toText(crumb.text) || '';
42
+ if (!label) return null;
43
+
44
+ return {
45
+ label,
46
+ url: toText(crumb.url) || toText(crumb.absolute_url)
47
+ };
48
+ };
49
+
50
+ const collectCrumbs = (entries: unknown): ProductCrumb[] =>
51
+ Array.isArray(entries)
52
+ ? entries
53
+ .map((entry) => normalizeCrumb(entry))
54
+ .filter((crumb): crumb is ProductCrumb => crumb !== null)
55
+ : [];
56
+
57
+ /**
58
+ * Functional PDP block: the breadcrumb trail of the product page. Unlike the
59
+ * listing page context, `buildProductPageContext()` publishes no breadcrumb
60
+ * array, so the trail is assembled from whatever the payload does carry —
61
+ * an explicit trail when a brand adds one, otherwise the product categories —
62
+ * and the product itself always closes the trail as the current page.
63
+ * Properties: separator, ariaLabel, gap, showHome, homeLabel, homeHref,
64
+ * showCurrent, maxLabelLength.
65
+ */
66
+ const ProductBreadcrumbBlock = ({
67
+ block,
68
+ currentBreakpoint = 'desktop',
69
+ isDesigner = false
70
+ }: BlockRendererProps) => {
71
+ const product = useThemeProduct();
72
+ const pageContext = useThemePageContext();
73
+
74
+ const prop = (key: string, fallback: string) =>
75
+ String(
76
+ getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
77
+ );
78
+
79
+ const separator = prop('separator', '/');
80
+ const ariaLabel = prop('ariaLabel', 'Breadcrumb');
81
+ const homeLabel = prop('homeLabel', 'Home');
82
+ const homeHref = prop('homeHref', '/');
83
+ const showHome = readToggle(
84
+ block.properties?.showHome,
85
+ currentBreakpoint,
86
+ false
87
+ );
88
+ const showCurrent = readToggle(
89
+ block.properties?.showCurrent,
90
+ currentBreakpoint,
91
+ true
92
+ );
93
+ const gapValue = Number(
94
+ getResponsiveValue(block.properties?.gap, currentBreakpoint, 8)
95
+ );
96
+ const gap = Number.isFinite(gapValue) && gapValue >= 0 ? gapValue : 8;
97
+ const maxLabelLengthValue = Number(
98
+ getResponsiveValue(block.properties?.maxLabelLength, currentBreakpoint, 0)
99
+ );
100
+ const maxLabelLength =
101
+ Number.isFinite(maxLabelLengthValue) && maxLabelLengthValue > 0
102
+ ? Math.round(maxLabelLengthValue)
103
+ : 0;
104
+
105
+ const title = getProductName(product);
106
+
107
+ // Probed in order of specificity: an explicit trail on the product wins over
108
+ // a page-level one, and categories are only a fallback shape.
109
+ const trail = collectCrumbs(pickProductField(product, 'breadcrumbs'));
110
+ const contextTrail = collectCrumbs(
111
+ (pageContext as Record<string, unknown> | null)?.breadcrumbs
112
+ );
113
+ const categoryTrail = collectCrumbs(pickProductField(product, 'categories'));
114
+
115
+ const resolvedParents = trail.length
116
+ ? trail
117
+ : contextTrail.length
118
+ ? contextTrail
119
+ : categoryTrail;
120
+
121
+ const parents = showHome
122
+ ? [{ label: homeLabel, url: homeHref }].concat(resolvedParents)
123
+ : resolvedParents;
124
+
125
+ if (!product || (!title && parents.length === 0)) {
126
+ return isDesigner ? (
127
+ <DesignerPlaceholder
128
+ label="Product breadcrumb (no product data)"
129
+ attribute="data-product-breadcrumb"
130
+ />
131
+ ) : null;
132
+ }
133
+
134
+ // The product is the current page, so it must not be duplicated when the
135
+ // resolved trail already ends on it.
136
+ const lastLabel = parents.length ? parents[parents.length - 1].label : '';
137
+ const items =
138
+ showCurrent && title && lastLabel !== title
139
+ ? parents.concat({ label: title, url: '' })
140
+ : parents;
141
+
142
+ const displayLabel = (label: string) =>
143
+ maxLabelLength > 0 && label.length > maxLabelLength
144
+ ? `${label.slice(0, maxLabelLength).trimEnd()}…`
145
+ : label;
146
+
147
+ return (
148
+ <nav data-product-breadcrumb="" aria-label={ariaLabel}>
149
+ <ol
150
+ {...partAttrs('list')}
151
+ style={{
152
+ listStyle: 'none',
153
+ margin: 0,
154
+ padding: 0,
155
+ display: 'flex',
156
+ alignItems: 'center',
157
+ flexWrap: 'wrap',
158
+ gap: `var(--product-breadcrumb-gap, ${gap}px)`
159
+ }}
160
+ >
161
+ {items.map((item, index) => {
162
+ const isLast = index === items.length - 1;
163
+
164
+ return (
165
+ <li
166
+ key={`${item.label}-${index}`}
167
+ data-breadcrumb-item={isLast ? 'current' : 'link'}
168
+ {...partAttrs('item', { selected: isLast })}
169
+ style={{
170
+ display: 'inline-flex',
171
+ alignItems: 'center',
172
+ gap: '8px'
173
+ }}
174
+ >
175
+ {isLast || !item.url ? (
176
+ <span
177
+ {...partAttrs('label', { selected: isLast })}
178
+ aria-current={isLast ? 'page' : undefined}
179
+ >
180
+ {displayLabel(item.label)}
181
+ </span>
182
+ ) : (
183
+ <NextLink
184
+ // NextLink warns when href resolves to undefined, so a crumb
185
+ // whose url the payload omitted still gets a resolved value.
186
+ href={isDesigner ? '#' : item.url || '#'}
187
+ {...partAttrs('link')}
188
+ // Navigating inside the designer iframe would drop the author
189
+ // out of the page they are editing, so crumbs are inert there.
190
+ onClick={(event) => {
191
+ if (isDesigner) event.preventDefault();
192
+ }}
193
+ style={{ color: 'inherit', textDecoration: 'none' }}
194
+ >
195
+ {displayLabel(item.label)}
196
+ </NextLink>
197
+ )}
198
+ {!isLast && (
199
+ <span
200
+ data-breadcrumb-separator=""
201
+ {...partAttrs('separator')}
202
+ aria-hidden="true"
203
+ >
204
+ {separator}
205
+ </span>
206
+ )}
207
+ </li>
208
+ );
209
+ })}
210
+ </ol>
211
+ </nav>
212
+ );
213
+ };
214
+
215
+ export default ProductBreadcrumbBlock;
@@ -0,0 +1,169 @@
1
+ 'use client';
2
+
3
+ import React, { useState } from 'react';
4
+ import { getResponsiveValue, partAttrs } from '../utils';
5
+ import { BlockRendererProps } from './block-renderer-registry';
6
+ import {
7
+ DesignerPlaceholder,
8
+ pickProductField,
9
+ readToggle,
10
+ stripHtml,
11
+ useThemeProduct
12
+ } from './product-block-utils';
13
+
14
+ /**
15
+ * Same word-boundary rule as the shared helper, with the ellipsis authored
16
+ * instead of baked in — the character differs per language and per brand.
17
+ */
18
+ const truncate = (text: string, maxLength: number, suffix: string): string => {
19
+ if (!text || text.length <= maxLength) return text;
20
+
21
+ const sliced = text.slice(0, maxLength);
22
+ const lastSpace = sliced.lastIndexOf(' ');
23
+ const safe = lastSpace > maxLength * 0.6 ? sliced.slice(0, lastSpace) : sliced;
24
+
25
+ return `${safe.trimEnd()}${suffix}`;
26
+ };
27
+
28
+ /**
29
+ * Functional PDP block: the product description coming from commerce. Full
30
+ * mode keeps the merchandiser's markup, excerpt mode flattens it to plain
31
+ * text so a teaser never leaks half-open tags. The editor owns mode, length,
32
+ * the optional read-more toggle and — through named parts — the styling.
33
+ * Properties: mode, excerptLength, ellipsis, showToggle, collapsedLines,
34
+ * moreLabel, lessLabel.
35
+ */
36
+ const ProductDescriptionBlock = ({
37
+ block,
38
+ currentBreakpoint = 'desktop',
39
+ isDesigner = false
40
+ }: BlockRendererProps) => {
41
+ const product = useThemeProduct();
42
+ const [isExpanded, setIsExpanded] = useState(false);
43
+
44
+ const prop = (key: string, fallback: string) =>
45
+ String(
46
+ getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
47
+ );
48
+
49
+ const modeValue = String(
50
+ getResponsiveValue(block.properties?.mode, currentBreakpoint, 'full')
51
+ );
52
+ const mode = modeValue === 'excerpt' ? 'excerpt' : 'full';
53
+ const excerptLength = Number(
54
+ getResponsiveValue(block.properties?.excerptLength, currentBreakpoint, 160)
55
+ );
56
+ const ellipsis = prop('ellipsis', '…');
57
+ const showToggle = readToggle(
58
+ block.properties?.showToggle,
59
+ currentBreakpoint,
60
+ false
61
+ );
62
+ const moreLabel = prop('moreLabel', 'Read more');
63
+ const lessLabel = prop('lessLabel', 'Read less');
64
+ const collapsedLinesValue = Number(
65
+ getResponsiveValue(block.properties?.collapsedLines, currentBreakpoint, 4)
66
+ );
67
+ const collapsedLines =
68
+ Number.isFinite(collapsedLinesValue) && collapsedLinesValue > 0
69
+ ? Math.round(collapsedLinesValue)
70
+ : 4;
71
+
72
+ const description = String(
73
+ pickProductField<string>(product, 'description') || ''
74
+ ).trim();
75
+
76
+ if (!description) {
77
+ return isDesigner ? (
78
+ <DesignerPlaceholder
79
+ label="Product description (empty)"
80
+ attribute="data-product-description"
81
+ />
82
+ ) : null;
83
+ }
84
+
85
+ const toggleNode = showToggle ? (
86
+ <button
87
+ type="button"
88
+ data-product-description-toggle=""
89
+ {...partAttrs('toggle', { open: isExpanded })}
90
+ aria-expanded={isExpanded}
91
+ onClick={() => setIsExpanded((current) => !current)}
92
+ style={{
93
+ display: 'inline-flex',
94
+ alignItems: 'center',
95
+ marginTop: '8px',
96
+ padding: 0,
97
+ border: 'none',
98
+ background: 'transparent',
99
+ color: 'inherit',
100
+ font: 'inherit',
101
+ textDecoration: 'underline',
102
+ cursor: 'pointer'
103
+ }}
104
+ >
105
+ {isExpanded ? lessLabel : moreLabel}
106
+ </button>
107
+ ) : null;
108
+
109
+ if (mode === 'excerpt') {
110
+ const safeLength =
111
+ Number.isFinite(excerptLength) && excerptLength > 0 ? excerptLength : 160;
112
+ const plain = stripHtml(description);
113
+ // With the toggle on, "expanded" means the whole text — truncating an
114
+ // expanded excerpt would make the control look broken.
115
+ const excerpt =
116
+ showToggle && isExpanded ? plain : truncate(plain, safeLength, ellipsis);
117
+
118
+ if (!excerpt) {
119
+ return isDesigner ? (
120
+ <DesignerPlaceholder
121
+ label="Product description (empty)"
122
+ attribute="data-product-description"
123
+ />
124
+ ) : null;
125
+ }
126
+
127
+ return (
128
+ <>
129
+ <div
130
+ data-product-description=""
131
+ data-product-description-mode="excerpt"
132
+ {...partAttrs('content', { open: showToggle && isExpanded })}
133
+ >
134
+ {excerpt}
135
+ </div>
136
+ {toggleNode}
137
+ </>
138
+ );
139
+ }
140
+
141
+ // The clamp is only added once a brand asked for a toggle, so an unclamped
142
+ // description keeps exactly the box it renders today.
143
+ const clampStyle: React.CSSProperties =
144
+ showToggle && !isExpanded
145
+ ? {
146
+ display: '-webkit-box',
147
+ WebkitBoxOrient: 'vertical',
148
+ WebkitLineClamp: collapsedLines,
149
+ overflow: 'hidden'
150
+ }
151
+ : {};
152
+
153
+ return (
154
+ <>
155
+ <div
156
+ data-product-description=""
157
+ data-product-description-mode="full"
158
+ {...partAttrs('content', { open: showToggle && isExpanded })}
159
+ {...(showToggle ? { style: clampStyle } : {})}
160
+ // Same trust boundary as text-block: the markup is merchandiser-authored
161
+ // commerce content, not shopper input.
162
+ dangerouslySetInnerHTML={{ __html: description }}
163
+ />
164
+ {toggleNode}
165
+ </>
166
+ );
167
+ };
168
+
169
+ export default ProductDescriptionBlock;