@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,131 @@
1
+ import type { Block } from '../theme-block';
2
+ import { getResponsiveValue } from '../utils';
3
+ import type { ThemeProduct } from './product-block-utils';
4
+
5
+ /**
6
+ * The marker and lookup for custom row layouts, kept free of React so a
7
+ * renderer (or a test) can ask "did the designer author their own row?"
8
+ * without pulling the whole block tree in.
9
+ */
10
+ export const ITEM_TEMPLATE_ROLE = 'item-template';
11
+
12
+ const readRole = (block: Block, currentBreakpoint = 'desktop'): string =>
13
+ String(
14
+ getResponsiveValue(block.properties?.role, currentBreakpoint, '') ?? ''
15
+ );
16
+
17
+ /** The child a designer marked as the row layout, if any. */
18
+ export const findItemTemplate = (
19
+ block: Block,
20
+ currentBreakpoint = 'desktop'
21
+ ): Block | null =>
22
+ (block.blocks || []).find(
23
+ child => readRole(child, currentBreakpoint) === ITEM_TEMPLATE_ROLE
24
+ ) ?? null;
25
+
26
+ export const hasItemTemplate = (
27
+ block: Block,
28
+ currentBreakpoint = 'desktop'
29
+ ): boolean => findItemTemplate(block, currentBreakpoint) !== null;
30
+
31
+ /**
32
+ * The lists whose rows ARE products.
33
+ *
34
+ * Which rows carry a product has to be declared per list, never sniffed off
35
+ * the row: an address row and a coupon row both carry `pk` and a `name`, and a
36
+ * heuristic that accepted them would point a wishlist toggle at an address id.
37
+ * A list not named here keeps behaving exactly as it does today — the blocks
38
+ * inside its rows go on reading the page's product.
39
+ */
40
+ const PRODUCT_ITEM_LIST_TYPES = new Set([
41
+ 'product-grid',
42
+ 'favourites-grid',
43
+ 'product-recommendations'
44
+ ]);
45
+
46
+ export const publishesProductItems = (blockType?: string): boolean =>
47
+ typeof blockType === 'string' && PRODUCT_ITEM_LIST_TYPES.has(blockType);
48
+
49
+ /**
50
+ * Where a row keeps the id of the product it shows. `productPk` is read first
51
+ * because a favourites row carries BOTH: `pk` is the favourite entry's own id
52
+ * (what removing it needs) and `productPk` is the product behind it. Reading
53
+ * `pk` there would favourite a wishlist entry id.
54
+ */
55
+ const PRODUCT_PK_FIELDS = ['productPk', 'pk'];
56
+
57
+ const readProductPk = (
58
+ item: Record<string, unknown>
59
+ ): number | undefined => {
60
+ for (const field of PRODUCT_PK_FIELDS) {
61
+ const value = item[field];
62
+ if (value === undefined || value === null || value === '') continue;
63
+
64
+ const numeric = Number(value);
65
+ // Commerce ids are positive integers; a row that key-fell-back to
66
+ // "product-3" must not resolve to a pk at all.
67
+ if (Number.isFinite(numeric) && numeric > 0) return numeric;
68
+ }
69
+
70
+ return undefined;
71
+ };
72
+
73
+ /**
74
+ * A row seen as the ambient product, or null when this list publishes no
75
+ * products or the row is one of the empty placeholders the designer draws
76
+ * against. `pk` is rewritten to the PRODUCT's id so a mutation started from
77
+ * inside the row (wishlist, add to basket) addresses the right thing; the
78
+ * row itself is left untouched, so `item.*` bindings still resolve as before.
79
+ */
80
+ export const resolveItemProduct = (
81
+ blockType: string | undefined,
82
+ item: Record<string, unknown> | null | undefined
83
+ ): ThemeProduct | null => {
84
+ if (!publishesProductItems(blockType)) return null;
85
+ if (!item || Object.keys(item).length === 0) return null;
86
+
87
+ const pk = readProductPk(item);
88
+
89
+ // No id, no scope: a wishlist toggle or an add-to-basket started from this
90
+ // row would have nothing to address, and claiming the scope would only stop
91
+ // the block reading the page product it reads today.
92
+ if (pk === undefined) return null;
93
+
94
+ return { ...item, pk } as ThemeProduct;
95
+ };
96
+
97
+ /**
98
+ * What a product row shows in the editor when the page has no live data.
99
+ *
100
+ * Without it a designer laying out a card sees "Add to cart (no product)" and
101
+ * "Favourite button (no product)" boxes where the controls belong — different
102
+ * size, different weight — so the card cannot be judged as a card. The sample
103
+ * is designer-only and never reaches a storefront; mutations stay blocked by
104
+ * the `isDesigner` guard each block already has, and the pk is deliberately
105
+ * not a real one so nothing can be addressed with it.
106
+ */
107
+ export const DESIGNER_SAMPLE_PRODUCT: ThemeProduct = {
108
+ pk: -1,
109
+ name: 'Sample product',
110
+ title: 'Sample product',
111
+ price: 499,
112
+ retailPrice: 799,
113
+ in_stock: true,
114
+ stock: 12,
115
+ // Enough shape for the optional pieces to draw themselves: a size row on a
116
+ // quick-add card, and the price-history caption. Without them a designer
117
+ // switches the feature on and the canvas answers with a hint instead of the
118
+ // element they are trying to place.
119
+ price_extra_field: { previous_price: 549 },
120
+ variants: [
121
+ {
122
+ attribute_key: 'size',
123
+ attribute_name: 'Size',
124
+ options: [
125
+ { label: 'S', value: 's', in_stock: true, is_selectable: true },
126
+ { label: 'M', value: 'm', in_stock: true, is_selectable: true, is_selected: true },
127
+ { label: 'L', value: 'l', in_stock: false, is_selectable: false }
128
+ ]
129
+ }
130
+ ]
131
+ } as ThemeProduct;
@@ -0,0 +1,164 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import ThemeBlock, { Block } from '../theme-block';
5
+ import {
6
+ buildIteratorBlock,
7
+ sliceIteratorItems
8
+ } from '../utils/iterator-utils';
9
+ import {
10
+ DESIGNER_SAMPLE_PRODUCT,
11
+ findItemTemplate,
12
+ ITEM_TEMPLATE_ROLE,
13
+ publishesProductItems,
14
+ resolveItemProduct
15
+ } from './item-template-utils';
16
+ import { ThemeItemScopeProvider } from './product-block-utils';
17
+
18
+ export {
19
+ findItemTemplate,
20
+ hasItemTemplate,
21
+ ITEM_TEMPLATE_ROLE,
22
+ publishesProductItems,
23
+ resolveItemProduct
24
+ } from './item-template-utils';
25
+
26
+ /**
27
+ * Custom item layouts for functional lists.
28
+ *
29
+ * Every functional block that shows a list — the product grid, basket rows,
30
+ * order rows, address cards, store results — used to draw its row in TSX.
31
+ * A brand could change spacing around it and nothing inside it: no reordering
32
+ * the price above the title, no extra field, no removing an element. The
33
+ * editor's repeat mechanism existed but was offered for group blocks only.
34
+ *
35
+ * A list block can now carry one child marked as its item template. When it is
36
+ * there, the block hands its own data to the same iterator machinery the group
37
+ * block uses, and the designer's layout is repeated once per item with
38
+ * `item.*` bindings resolved. When it is not, the block draws its built-in row
39
+ * exactly as before — which is what keeps every published composition working.
40
+ *
41
+ * A PRODUCT row is also a SCOPE. Blocks like the favourite toggle or
42
+ * add-to-cart used to read the product off the page context, which on a
43
+ * listing is nothing and on a PDP is the wrong product for a recommendation
44
+ * card. Rows of a list that publishes products are wrapped in the item scope,
45
+ * so those blocks act on the row's own product; every other list is left
46
+ * alone, because its rows are not products and its blocks read the page.
47
+ */
48
+
49
+ interface ItemTemplateListProps {
50
+ /** The functional block that owns the list. */
51
+ block: Block;
52
+ /** The rows to draw, already normalised by the block. */
53
+ items: Record<string, unknown>[];
54
+ placeholderId: string;
55
+ sectionId: string;
56
+ isDesigner?: boolean;
57
+ isSelected?: boolean;
58
+ selectedBlockId?: string | null;
59
+ currentBreakpoint?: string;
60
+ /**
61
+ * How many rows to draw in the editor when the page has no live data —
62
+ * an empty grid is impossible to design against.
63
+ */
64
+ designerPlaceholderCount?: number;
65
+ }
66
+
67
+ /**
68
+ * Renders the designer's row layout once per item. Returns null when there is
69
+ * no template, so a caller can fall back to its own markup with a plain
70
+ * `?? builtInRows`.
71
+ */
72
+ export const ItemTemplateList = ({
73
+ block,
74
+ items,
75
+ placeholderId,
76
+ sectionId,
77
+ isDesigner = false,
78
+ selectedBlockId = null,
79
+ currentBreakpoint = 'desktop',
80
+ designerPlaceholderCount = 3
81
+ }: ItemTemplateListProps): React.ReactElement | null => {
82
+ const template = findItemTemplate(block, currentBreakpoint);
83
+ if (!template) return null;
84
+
85
+ const rows =
86
+ items.length > 0
87
+ ? items
88
+ : isDesigner
89
+ ? Array.from({ length: designerPlaceholderCount }, () => ({}))
90
+ : [];
91
+
92
+ if (rows.length === 0) return null;
93
+
94
+ // The iterator is given the rows directly: a functional block fetched them
95
+ // itself (RTK, page context), they are not on the section's data source.
96
+ // The iterator repeats `blocks[0]` of whatever it is given, so the template
97
+ // is wrapped rather than passed directly — passing it directly would repeat
98
+ // only its first child and drop the rest of the card.
99
+ const expanded = buildIteratorBlock({
100
+ iteratorBlock: {
101
+ ...block,
102
+ id: `${block.id}-items`,
103
+ isIterator: true,
104
+ blocks: [template]
105
+ },
106
+ items: rows,
107
+ isDesigner
108
+ });
109
+
110
+ const clones = expanded.blocks || [];
111
+ // Only lists whose rows ARE products open a scope. An attribute row or a
112
+ // gallery thumbnail row must keep falling through to the page's product:
113
+ // those templates live on a PDP, and a scope there — even an empty one —
114
+ // would silence blocks that render correctly today.
115
+ const scopesProducts = publishesProductItems(block.type);
116
+ // The same slice the iterator applied, so clone N is handed the row it
117
+ // was built from rather than row N.
118
+ const scopedRows = scopesProducts
119
+ ? sliceIteratorItems(block, rows).items
120
+ : rows;
121
+
122
+ return (
123
+ <>
124
+ {clones.map((clone, index) => {
125
+ const key = clone.id || `${template.id}-item-${index}`;
126
+ const row = (
127
+ <ThemeBlock
128
+ // Keyed here so the non-scoped branch is the element it always
129
+ // was: wrapping it in anything, even a fragment, would change the
130
+ // element type at this position and remount every existing row.
131
+ key={key}
132
+ block={clone}
133
+ placeholderId={placeholderId}
134
+ sectionId={sectionId}
135
+ isDesigner={isDesigner}
136
+ isSelected={selectedBlockId === clone.id}
137
+ selectedBlockId={selectedBlockId}
138
+ currentBreakpoint={currentBreakpoint}
139
+ />
140
+ );
141
+
142
+ if (!scopesProducts) return row;
143
+
144
+ const scoped = resolveItemProduct(block.type, scopedRows[index]);
145
+
146
+ // Context only — the provider renders no element, so the markup a
147
+ // published row produces is unchanged.
148
+ return (
149
+ <ThemeItemScopeProvider
150
+ key={key}
151
+ product={
152
+ scoped ?? (isDesigner ? DESIGNER_SAMPLE_PRODUCT : null)
153
+ }
154
+ >
155
+ {row}
156
+ </ThemeItemScopeProvider>
157
+ );
158
+ })}
159
+ </>
160
+ );
161
+ };
162
+
163
+ /** Alias kept for call sites that read better as a marker than a role. */
164
+ export const ITEM_TEMPLATE_MARKER = ITEM_TEMPLATE_ROLE;
@@ -0,0 +1,175 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import NextLink from 'next/link';
5
+ import { getResponsiveValue, partAttrs } from '../utils';
6
+ import { BlockRendererProps } from './block-renderer-registry';
7
+ import {
8
+ listingPlaceholderStyle,
9
+ readToggle,
10
+ useListingContext
11
+ } from './listing-context';
12
+
13
+ interface BreadcrumbEntry {
14
+ label: string;
15
+ url: string;
16
+ /** A stand-in for the crumbs dropped by the max-items cap. */
17
+ collapsed?: boolean;
18
+ }
19
+
20
+ /**
21
+ * Functional listing block: the breadcrumb trail. Trail CONTENT is
22
+ * commerce-driven (the breadcrumb payload resolved server side); the editor
23
+ * owns the separator, the optional home entry, how long the trail may get
24
+ * and styling.
25
+ *
26
+ * Parts: list, item, link, current, separator, ellipsis.
27
+ * Properties: separator, showHome, homeLabel, homeHref, ariaLabel, maxItems,
28
+ * collapseLabel, maxLabelLength, ellipsis.
29
+ */
30
+ const ListingBreadcrumbBlock = ({
31
+ block,
32
+ currentBreakpoint = 'desktop',
33
+ isDesigner = false
34
+ }: BlockRendererProps) => {
35
+ const listing = useListingContext();
36
+
37
+ const separator = String(
38
+ getResponsiveValue(block.properties?.separator, currentBreakpoint, '/')
39
+ );
40
+ const showHome = readToggle(
41
+ block.properties?.showHome,
42
+ currentBreakpoint,
43
+ true
44
+ );
45
+ const homeLabel = String(
46
+ getResponsiveValue(block.properties?.homeLabel, currentBreakpoint, 'Home')
47
+ );
48
+ const homeHref = String(
49
+ getResponsiveValue(block.properties?.homeHref, currentBreakpoint, '/')
50
+ );
51
+ const ariaLabel = String(
52
+ getResponsiveValue(block.properties?.ariaLabel, currentBreakpoint, 'Breadcrumb')
53
+ );
54
+ const maxItemsRaw = Math.round(
55
+ Number(getResponsiveValue(block.properties?.maxItems, currentBreakpoint, 0))
56
+ );
57
+ // 0 keeps the whole trail, which is what every published composition shows.
58
+ const maxItems =
59
+ Number.isFinite(maxItemsRaw) && maxItemsRaw >= 2 ? maxItemsRaw : 0;
60
+ const collapseLabel = String(
61
+ getResponsiveValue(block.properties?.collapseLabel, currentBreakpoint, '…')
62
+ );
63
+ const maxLabelLengthRaw = Math.round(
64
+ Number(
65
+ getResponsiveValue(block.properties?.maxLabelLength, currentBreakpoint, 0)
66
+ )
67
+ );
68
+ const maxLabelLength =
69
+ Number.isFinite(maxLabelLengthRaw) && maxLabelLengthRaw > 0
70
+ ? maxLabelLengthRaw
71
+ : 0;
72
+ const ellipsis = String(
73
+ getResponsiveValue(block.properties?.ellipsis, currentBreakpoint, '…')
74
+ );
75
+
76
+ const crumbs = listing?.breadcrumbs || [];
77
+
78
+ if (crumbs.length === 0) {
79
+ return isDesigner ? (
80
+ <div data-listing-breadcrumb="" style={listingPlaceholderStyle}>
81
+ Breadcrumb (no trail data)
82
+ </div>
83
+ ) : null;
84
+ }
85
+
86
+ const fullTrail: BreadcrumbEntry[] = showHome
87
+ ? [{ label: homeLabel, url: homeHref }].concat(crumbs)
88
+ : crumbs.slice();
89
+
90
+ // A long trail is folded from the middle: the root and the deepest crumbs
91
+ // are the ones a shopper navigates with.
92
+ const items: BreadcrumbEntry[] =
93
+ maxItems > 0 && fullTrail.length > maxItems
94
+ ? [
95
+ fullTrail[0],
96
+ { label: collapseLabel, url: '', collapsed: true },
97
+ ...fullTrail.slice(fullTrail.length - (maxItems - 1))
98
+ ]
99
+ : fullTrail;
100
+
101
+ const shorten = (label: string) =>
102
+ maxLabelLength > 0 && label.length > maxLabelLength
103
+ ? `${label.slice(0, maxLabelLength).trimEnd()}${ellipsis}`
104
+ : label;
105
+
106
+ return (
107
+ <nav data-listing-breadcrumb="" aria-label={ariaLabel}>
108
+ <ol
109
+ {...partAttrs('list')}
110
+ style={{
111
+ listStyle: 'none',
112
+ margin: 0,
113
+ padding: 0,
114
+ display: 'flex',
115
+ alignItems: 'center',
116
+ flexWrap: 'wrap',
117
+ gap: 'var(--listing-breadcrumb-gap, 8px)'
118
+ }}
119
+ >
120
+ {items.map((item, index) => {
121
+ const isLast = index === items.length - 1;
122
+ const label = item.collapsed ? item.label : shorten(item.label);
123
+
124
+ return (
125
+ <li
126
+ key={`${item.label}-${index}`}
127
+ data-breadcrumb-item={isLast ? 'current' : 'link'}
128
+ {...partAttrs('item', { selected: isLast })}
129
+ style={{ display: 'inline-flex', alignItems: 'center', gap: '8px' }}
130
+ >
131
+ {item.collapsed ? (
132
+ <span data-breadcrumb-collapsed="" {...partAttrs('ellipsis')}>
133
+ {label}
134
+ </span>
135
+ ) : isLast || !item.url ? (
136
+ <span
137
+ aria-current={isLast ? 'page' : undefined}
138
+ {...partAttrs(isLast ? 'current' : 'link', {
139
+ selected: isLast
140
+ })}
141
+ >
142
+ {label}
143
+ </span>
144
+ ) : (
145
+ <NextLink
146
+ href={item.url}
147
+ {...partAttrs('link')}
148
+ // Navigating inside the designer iframe would drop the author
149
+ // out of the page they are editing, so crumbs are inert there.
150
+ onClick={(event) => {
151
+ if (isDesigner) event.preventDefault();
152
+ }}
153
+ style={{ color: 'inherit', textDecoration: 'none' }}
154
+ >
155
+ {label}
156
+ </NextLink>
157
+ )}
158
+ {!isLast && (
159
+ <span
160
+ data-breadcrumb-separator=""
161
+ aria-hidden="true"
162
+ {...partAttrs('separator')}
163
+ >
164
+ {separator}
165
+ </span>
166
+ )}
167
+ </li>
168
+ );
169
+ })}
170
+ </ol>
171
+ </nav>
172
+ );
173
+ };
174
+
175
+ export default ListingBreadcrumbBlock;