@akinon/pz-theme 2.0.103-beta.0 → 2.0.103

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 CHANGED
@@ -1,12 +1,13 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.103-beta.0
3
+ ## 2.0.103
4
4
 
5
5
  ### Patch Changes
6
6
 
7
- - Updated dependencies [cbbbfd757]
8
- - @akinon/next@2.0.103-beta.0
9
- - @akinon/theme-contract@2.0.103-beta.0
7
+ - 64407cc: ZERO-4829: add tests for iterator price shapes and enhance price formatting logic
8
+ - af4e3ba: ZERO-4289: enhance group block logic to handle sized shapes and update property switch behavior
9
+ - @akinon/next@2.0.103
10
+ - @akinon/theme-contract@2.0.103
10
11
 
11
12
  ## 2.0.102
12
13
 
package/package.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "name": "@akinon/pz-theme",
3
3
  "description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
4
- "version": "2.0.103-beta.0",
4
+ "version": "2.0.103",
5
5
  "license": "MIT",
6
6
  "main": "src/index.ts",
7
7
  "scripts": {
8
8
  "test": "jest"
9
9
  },
10
10
  "peerDependencies": {
11
- "@akinon/next": "2.0.103-beta.0",
11
+ "@akinon/next": "2.0.103",
12
12
  "react": "^18.0.0 || ^19.0.0",
13
13
  "react-dom": "^18.0.0 || ^19.0.0"
14
14
  },
15
15
  "dependencies": {
16
- "@akinon/theme-contract": "2.0.103-beta.0",
16
+ "@akinon/theme-contract": "2.0.103",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "react-multi-carousel": "2.8.4",
@@ -21,7 +21,7 @@
21
21
  "zod": "4.4.1"
22
22
  },
23
23
  "devDependencies": {
24
- "@akinon/next": "2.0.103-beta.0",
24
+ "@akinon/next": "2.0.103",
25
25
  "@testing-library/jest-dom": "5.16.4",
26
26
  "@testing-library/react": "16.3.2",
27
27
  "@testing-library/user-event": "14.1.1",
@@ -0,0 +1,187 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+
4
+ import ThemeBlock, { type Block } from '../theme-block';
5
+ import { buildIteratorBlock } from '../utils/iterator-utils';
6
+ import {
7
+ publishesProductRows,
8
+ resolveCollectionRowProduct
9
+ } from '../blocks/item-template-utils';
10
+
11
+ /**
12
+ * ZERO-4829 — the Product Card as a section for ANY page repeats over a
13
+ * collection data source. Its favourite and add-to-basket resolve the product
14
+ * through the item scope, and a custom section's repeat used to open none:
15
+ * every card rendered its name, price and picture and silently lost the heart.
16
+ *
17
+ * The rule is declared off the data source's type, not sniffed off the rows:
18
+ * a collection is a list of commerce products by definition, a static or API
19
+ * source says nothing about its rows and keeps behaving exactly as before.
20
+ */
21
+ const product = (pk: number, name: string) => ({
22
+ pk,
23
+ name,
24
+ price: '3499.00',
25
+ retail_price: '',
26
+ absolute_url: `/p-${pk}/`,
27
+ productimage_set: [{ image: `/img-${pk}.jpg` }]
28
+ });
29
+
30
+ const collection = (products: Record<string, unknown>[]) => ({
31
+ id: 'ds-1',
32
+ name: 'Erkek',
33
+ type: 'collection',
34
+ details: { collection: { pk: 1, productLimit: 4, products } }
35
+ });
36
+
37
+ const cardTemplate = (): Block => ({
38
+ id: 'card',
39
+ type: 'group',
40
+ label: 'Product Card',
41
+ properties: { tag: 'div' },
42
+ styles: {},
43
+ blocks: [
44
+ {
45
+ id: 'name',
46
+ type: 'text',
47
+ label: 'Name',
48
+ value: '',
49
+ properties: { dataBinding: 'item.name' },
50
+ styles: {},
51
+ blocks: []
52
+ },
53
+ {
54
+ id: 'fav',
55
+ type: 'favorite-button',
56
+ label: 'Favourite',
57
+ properties: { icon: 'heart', iconSize: 8, showLabel: false },
58
+ styles: {},
59
+ blocks: []
60
+ }
61
+ ]
62
+ });
63
+
64
+ const iterator = (): Block => ({
65
+ id: 'it',
66
+ type: 'group',
67
+ label: 'Iterator',
68
+ isIterator: true,
69
+ properties: { tag: 'div', iteratorDataPath: 'products', iteratorCount: 4 },
70
+ styles: {},
71
+ blocks: [cardTemplate()]
72
+ });
73
+
74
+ describe('a collection repeat publishes its rows as products', () => {
75
+ it('is declared off the data source type', () => {
76
+ expect(publishesProductRows({ type: 'collection' })).toBe(true);
77
+ expect(publishesProductRows({ type: 'static' })).toBe(false);
78
+ expect(publishesProductRows({ type: 'api' })).toBe(false);
79
+ expect(publishesProductRows(null)).toBe(false);
80
+ });
81
+
82
+ it('stamps every clone with its row as the product', () => {
83
+ const expanded = buildIteratorBlock({
84
+ iteratorBlock: iterator(),
85
+ sectionDataSource: collection([product(11, 'A'), product(12, 'B')]),
86
+ pageContext: null
87
+ } as never);
88
+
89
+ const scopes = (expanded.blocks || []).map(
90
+ (clone) => clone.itemScopeProduct
91
+ );
92
+ expect(scopes.map((scope) => scope && scope.pk)).toEqual([11, 12]);
93
+ expect(scopes[0]).toMatchObject({ name: 'A', absolute_url: '/p-11/' });
94
+ });
95
+
96
+ it('opens no scope for a static source, so those repeats are untouched', () => {
97
+ const expanded = buildIteratorBlock({
98
+ iteratorBlock: {
99
+ ...iterator(),
100
+ properties: { tag: 'div', iteratorDataPath: 'items', iteratorCount: 2 }
101
+ },
102
+ sectionDataSource: {
103
+ id: 'ds-2',
104
+ name: 'Static',
105
+ type: 'static',
106
+ details: { static: { name: 'x', data: [{ pk: 1, name: 'A' }] } }
107
+ },
108
+ pageContext: null
109
+ } as never);
110
+
111
+ for (const clone of expanded.blocks || []) {
112
+ expect('itemScopeProduct' in clone).toBe(false);
113
+ }
114
+ });
115
+
116
+ it('opens no second scope when the caller brought its own rows', () => {
117
+ // The product grid's ItemTemplateList wraps its clones itself.
118
+ const expanded = buildIteratorBlock({
119
+ iteratorBlock: iterator(),
120
+ sectionDataSource: collection([product(11, 'A')]),
121
+ pageContext: null,
122
+ items: [product(11, 'A')]
123
+ } as never);
124
+
125
+ for (const clone of expanded.blocks || []) {
126
+ expect('itemScopeProduct' in clone).toBe(false);
127
+ }
128
+ });
129
+
130
+ it('a padding row (fewer products than the count) carries no product', () => {
131
+ const expanded = buildIteratorBlock({
132
+ iteratorBlock: iterator(),
133
+ sectionDataSource: collection([product(11, 'A')]),
134
+ pageContext: null,
135
+ forceIteratorCount: 2
136
+ } as never);
137
+
138
+ // Whatever the count floor draws beyond the one real row is a padding
139
+ // row: it is stamped, but with null, so nothing inside it addresses a pk.
140
+ const padding = (expanded.blocks || []).slice(1);
141
+ for (const clone of padding) {
142
+ expect(clone.itemScopeProduct).toBeNull();
143
+ }
144
+ });
145
+
146
+ it('renders the favourite inside a scoped clone, for a guest as a login link', () => {
147
+ const expanded = buildIteratorBlock({
148
+ iteratorBlock: iterator(),
149
+ sectionDataSource: collection([product(11, 'A')]),
150
+ pageContext: null
151
+ } as never);
152
+ const [clone] = expanded.blocks || [];
153
+
154
+ const { container } = render(
155
+ <ThemeBlock block={clone} placeholderId="home" sectionId="s1" />
156
+ );
157
+
158
+ const favourite = container.querySelector('[data-favorite-button]');
159
+ expect(favourite).not.toBeNull();
160
+ expect(favourite?.getAttribute('data-favorite-state')).toBe('guest');
161
+ expect(container.textContent).toContain('A');
162
+ });
163
+
164
+ it('renders no favourite for the same clone without the scope', () => {
165
+ const expanded = buildIteratorBlock({
166
+ iteratorBlock: iterator(),
167
+ sectionDataSource: collection([product(11, 'A')]),
168
+ pageContext: null
169
+ } as never);
170
+ const [clone] = expanded.blocks || [];
171
+ const { itemScopeProduct: _scope, ...unscoped } = clone;
172
+
173
+ const { container } = render(
174
+ <ThemeBlock block={unscoped} placeholderId="home" sectionId="s1" />
175
+ );
176
+
177
+ expect(container.querySelector('[data-favorite-button]')).toBeNull();
178
+ });
179
+
180
+ it('resolves the row through the same pk rule as a grid row', () => {
181
+ expect(resolveCollectionRowProduct({ pk: '7', name: 'x' })).toMatchObject({
182
+ pk: 7
183
+ });
184
+ expect(resolveCollectionRowProduct({ name: 'no id' })).toBeNull();
185
+ expect(resolveCollectionRowProduct({})).toBeNull();
186
+ });
187
+ });
@@ -0,0 +1,110 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+
4
+ import ThemeBlock, { type Block } from '../theme-block';
5
+
6
+ /**
7
+ * ZERO-4829 — the Product Card's colour-swatch chip is an EMPTY `<a>` group
8
+ * whose only appearance is a background its row resolved
9
+ * (`linear-gradient({{item.color}}, …)`). Two things the renderer never had to
10
+ * do before: inline a row-resolved style on a GROUP (whose styles live on the
11
+ * wrapper, its own element being `display: contents`), and give an empty
12
+ * anchor its href when the router link is the branch that would carry it.
13
+ */
14
+ const chip = (overrides: Partial<Block> = {}): Block => ({
15
+ id: 'chip-clone-0',
16
+ styleSourceId: 'chip',
17
+ type: 'group',
18
+ label: 'Swatch',
19
+ properties: { tag: 'a', href: '/gant-erkek-bej-polo/' },
20
+ styles: {
21
+ width: { desktop: '8px' },
22
+ 'background-color': { desktop: '#d9c8a9' },
23
+ 'box-shadow': { desktop: 'inset 0 0 0 1px rgba(0, 0, 0, 0.08)' }
24
+ },
25
+ order: 0,
26
+ hidden: false,
27
+ blocks: [],
28
+ // The row resolved the colour (the template carried `{{item.color}}`);
29
+ // jsdom cannot hold a gradient in an inline style, so the test binds the
30
+ // plain colour the same way — the renderer does not care which property.
31
+ rowStyleKeys: ['background-color'],
32
+ ...overrides
33
+ });
34
+
35
+ describe('a group whose row resolved a style', () => {
36
+ it('inlines exactly the row-resolved keys on its wrapper', () => {
37
+ const { container } = render(
38
+ <ThemeBlock block={chip()} placeholderId="p" sectionId="s" isDesigner={false} />
39
+ );
40
+ const wrapper = container.querySelector('[data-block-id="chip"]') as HTMLElement;
41
+
42
+ expect(wrapper.style.backgroundColor).toBe('rgb(217, 200, 169)');
43
+ // Everything else stays on the shared rule.
44
+ expect(wrapper.style.width).toBe('');
45
+ expect(wrapper.style.boxShadow).toBe('');
46
+ });
47
+
48
+ it('inlines nothing on a group whose row resolved nothing', () => {
49
+ const { container } = render(
50
+ <ThemeBlock
51
+ block={chip({ rowStyleKeys: undefined })}
52
+ placeholderId="p"
53
+ sectionId="s"
54
+ isDesigner={false}
55
+ />
56
+ );
57
+ const wrapper = container.querySelector('[data-block-id="chip"]') as HTMLElement;
58
+ expect(wrapper.getAttribute('style')).toBeNull();
59
+ });
60
+ });
61
+
62
+ describe('an empty anchor group', () => {
63
+ it('keeps its internal href on the storefront — also when it is a sized shape', () => {
64
+ const { container } = render(
65
+ <ThemeBlock
66
+ block={chip({
67
+ styles: {
68
+ width: { desktop: '8px' },
69
+ height: { desktop: '12px' },
70
+ 'background-color': { desktop: '#d9c8a9' }
71
+ },
72
+ rowStyleKeys: undefined
73
+ })}
74
+ placeholderId="p"
75
+ sectionId="s"
76
+ isDesigner={false}
77
+ />
78
+ );
79
+ const anchor = container.querySelector('a') as HTMLAnchorElement;
80
+ expect(anchor).not.toBeNull();
81
+ expect(anchor.getAttribute('href')).toBe('/gant-erkek-bej-polo/');
82
+ expect(anchor.className).toContain('contents');
83
+ });
84
+ });
85
+
86
+ describe('an empty group on the canvas', () => {
87
+ const render_ = (styles: Record<string, unknown>) =>
88
+ render(
89
+ <ThemeBlock
90
+ block={chip({ styles, properties: { tag: 'a', href: '' }, rowStyleKeys: undefined })}
91
+ placeholderId="p"
92
+ sectionId="s"
93
+ isDesigner
94
+ />
95
+ );
96
+
97
+ it('draws no "drop blocks here" chip when it is a sized shape (a swatch)', () => {
98
+ const { container } = render_({
99
+ width: { desktop: '8px' },
100
+ height: { desktop: '12px' },
101
+ 'background-color': { desktop: '#c9c9c9' }
102
+ });
103
+ expect(container.querySelector('[data-group-placeholder]')).toBeNull();
104
+ });
105
+
106
+ it('still offers the drop target for an unsized empty group', () => {
107
+ const { container } = render_({ 'background-color': { desktop: '#c9c9c9' } });
108
+ expect(container.querySelector('[data-group-placeholder]')).not.toBeNull();
109
+ });
110
+ });
@@ -0,0 +1,125 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+ import { renderToStaticMarkup } from 'react-dom/server.node';
4
+
5
+ import ThemePlaceholderWrapper, {
6
+ buildThemeTypographyCss
7
+ } from '../theme-placeholder-wrapper';
8
+ import { readThemeTypographyFont, resolveThemeCssVariables } from '../utils';
9
+ import type { Section } from '../theme-section';
10
+
11
+ /**
12
+ * ZERO-4829 — a card dropped on a page whose body is not the brand face
13
+ * (the homepage body is Jost on GANT) rendered in that fallback, because a
14
+ * text block has no way to say "the theme's body face": the editor's
15
+ * typography settings only ever loaded fonts, never named them anywhere a
16
+ * template could point at.
17
+ *
18
+ * `--theme-font-<variant>` closes that. It is resolved from the settings on
19
+ * both sides of hydration, like the colour variables, so the server does not
20
+ * print the fallback while the client paints the brand face.
21
+ */
22
+ const SETTINGS = {
23
+ backgroundColor: '#ffffff',
24
+ typography: [
25
+ {
26
+ key: 'heading01',
27
+ fontFamily: 'GantSerif-MediumCondensed',
28
+ fontWeight: '500'
29
+ },
30
+ { key: 'body', fontFamily: 'GantModernV2-Regular', fontWeight: '400' },
31
+ { key: 'subheading', fontFamily: 'inherit' }
32
+ ]
33
+ };
34
+
35
+ const section = {
36
+ id: 's1',
37
+ type: 'generic-section',
38
+ name: 's1',
39
+ label: 'S1',
40
+ order: 0,
41
+ hidden: false,
42
+ properties: {},
43
+ styles: {},
44
+ blocks: []
45
+ } as unknown as Section;
46
+
47
+ describe('the theme font variables', () => {
48
+ it('resolve from the settings, fallback only when the variant names no face', () => {
49
+ expect(readThemeTypographyFont(SETTINGS, 'body')).toBe(
50
+ 'GantModernV2-Regular'
51
+ );
52
+ expect(readThemeTypographyFont(SETTINGS, 'subheading')).toBeUndefined();
53
+ expect(readThemeTypographyFont(SETTINGS, 'missing')).toBeUndefined();
54
+ expect(readThemeTypographyFont(null, 'body')).toBeUndefined();
55
+
56
+ expect(
57
+ resolveThemeCssVariables('var(--theme-font-body, inherit)', SETTINGS)
58
+ ).toBe('GantModernV2-Regular');
59
+ expect(
60
+ resolveThemeCssVariables(
61
+ 'var(--theme-font-subheading, inherit)',
62
+ SETTINGS
63
+ )
64
+ ).toBe('inherit');
65
+ // A settings-less render keeps the fallback, so nothing paints a var()
66
+ // the document never defined.
67
+ expect(
68
+ resolveThemeCssVariables('var(--theme-font-body, inherit)', null)
69
+ ).toBe('inherit');
70
+ });
71
+
72
+ it('are emitted on :root with the markup', () => {
73
+ expect(buildThemeTypographyCss(SETTINGS)).toBe(
74
+ ':root{--theme-font-heading01:GantSerif-MediumCondensed;--theme-font-body:GantModernV2-Regular}'
75
+ );
76
+ expect(buildThemeTypographyCss({ backgroundColor: '#fff' })).toBe('');
77
+ expect(buildThemeTypographyCss(null)).toBe('');
78
+
79
+ const html = renderToStaticMarkup(
80
+ <ThemePlaceholderWrapper
81
+ slug="theme-homepage-body"
82
+ initialSections={[section]}
83
+ initialPlaceholderId="theme-homepage-body"
84
+ isDesignMode={false}
85
+ initialThemeSettings={SETTINGS}
86
+ />
87
+ );
88
+ expect(html).toContain('--theme-font-body:GantModernV2-Regular');
89
+ });
90
+
91
+ it('cannot break out of the style tag through a font name', () => {
92
+ expect(
93
+ buildThemeTypographyCss({
94
+ typography: [{ key: 'body', fontFamily: 'Evil</style><script>' }]
95
+ })
96
+ ).not.toMatch(/<\/style/i);
97
+ // A key that is not a plain token is not turned into a variable name.
98
+ expect(
99
+ buildThemeTypographyCss({
100
+ typography: [{ key: 'body;}*{', fontFamily: 'X' }]
101
+ })
102
+ ).toBe('');
103
+ });
104
+
105
+ it('are kept live on the document by the settings effect', () => {
106
+ document.documentElement.style.removeProperty('--theme-font-body');
107
+ const { unmount } = render(
108
+ <ThemePlaceholderWrapper
109
+ slug="theme-homepage-body"
110
+ initialSections={[section]}
111
+ initialPlaceholderId="theme-homepage-body"
112
+ isDesignMode={false}
113
+ initialThemeSettings={SETTINGS}
114
+ />
115
+ );
116
+
117
+ expect(
118
+ document.documentElement.style.getPropertyValue('--theme-font-body')
119
+ ).toBe('GantModernV2-Regular');
120
+ expect(
121
+ document.documentElement.style.getPropertyValue('--theme-font-subheading')
122
+ ).toBe('');
123
+ unmount();
124
+ });
125
+ });
@@ -0,0 +1,111 @@
1
+ import { normalizeListingProduct, parseListingLookups } from '../listing-row';
2
+
3
+ /**
4
+ * ZERO-4829 — the colour swatches on the Product Card. GANT's listing payload
5
+ * nests the colours under `extra_data.variants[attribute_key=integration_renk]`
6
+ * and each option carries the colour CODE as its `label` — no `value`, no
7
+ * `is_selected`. The swatch table is authored against that code, so the
8
+ * lookup has to accept the label where the value is missing, and the current
9
+ * colour is the option whose product IS this row.
10
+ */
11
+ const gantProduct = (pk: number) => ({
12
+ pk,
13
+ name: 'GANT Erkek Mavi Polo',
14
+ absolute_url: `/p-${pk}/`,
15
+ price: '3499.00',
16
+ retail_price: '3999.00',
17
+ currency_type: 'try',
18
+ attributes: { integration_renk: '457', badge2: 'YENİ SEZON' },
19
+ extra_data: {
20
+ variants: [
21
+ {
22
+ attribute_key: 'integration_renk',
23
+ options: [
24
+ {
25
+ label: '457',
26
+ in_stock: true,
27
+ is_selectable: true,
28
+ product: {
29
+ pk,
30
+ absolute_url: `/p-${pk}/`,
31
+ productimage_set: [{ image: '/blue.jpg' }]
32
+ }
33
+ },
34
+ {
35
+ label: '200',
36
+ in_stock: true,
37
+ is_selectable: true,
38
+ product: {
39
+ pk: pk + 1,
40
+ absolute_url: `/p-${pk + 1}/`,
41
+ productimage_set: [{ image: '/beige.jpg' }]
42
+ }
43
+ },
44
+ {
45
+ label: '999',
46
+ in_stock: false,
47
+ is_selectable: false,
48
+ product: { pk: pk + 2, absolute_url: `/p-${pk + 2}/` }
49
+ }
50
+ ]
51
+ },
52
+ { attribute_key: 'integration_beden', options: [{ label: 'M' }] }
53
+ ]
54
+ }
55
+ });
56
+
57
+ describe('colour swatches on a listing row', () => {
58
+ it('maps the swatch colour by the option label when the option has no value', () => {
59
+ const row = normalizeListingProduct(gantProduct(1), 0, {
60
+ currency: 'TRY',
61
+ lookups: parseListingLookups('', '', '457|#2d5da1\n200|#d9c8a9')
62
+ });
63
+
64
+ expect(row?.colors?.map((color) => color.color)).toEqual([
65
+ '#2d5da1',
66
+ '#d9c8a9'
67
+ ]);
68
+ expect(row?.colors?.map((color) => color.label)).toEqual(['457', '200']);
69
+ expect(row?.colorCount).toBe(2);
70
+ });
71
+
72
+ it('marks the colour whose product is this row as selected', () => {
73
+ const row = normalizeListingProduct(gantProduct(1), 0, { currency: 'TRY' });
74
+
75
+ expect(row?.colors?.map((color) => color.isSelected)).toEqual([true, false]);
76
+ expect(row?.colors?.[1]).toMatchObject({
77
+ url: '/p-2/',
78
+ image: '/beige.jpg'
79
+ });
80
+ });
81
+
82
+ it('still keys the table by value where a payload carries one', () => {
83
+ const product = gantProduct(1);
84
+ const [group] = product.extra_data.variants;
85
+ (group.options[0] as Record<string, unknown>).value = 'mavi';
86
+
87
+ const row = normalizeListingProduct(product, 0, {
88
+ currency: 'TRY',
89
+ lookups: parseListingLookups('', '', 'mavi|#0000ff\n457|#2d5da1')
90
+ });
91
+
92
+ expect(row?.colors?.[0].color).toBe('#0000ff');
93
+ });
94
+
95
+ it('publishes the badge attribute keyed, for a text bound straight to it', () => {
96
+ const row = normalizeListingProduct(gantProduct(1), 0, { currency: 'TRY' });
97
+ expect(row?.attributes?.badge2).toBe('YENİ SEZON');
98
+ });
99
+
100
+ it('reads the keyed attributes the page context publishes beside its positional list', () => {
101
+ // What a listing's own rows look like after chrome/page-context.ts:
102
+ // `attributes` is the positional list, the keyed map is `attributesKeyed`.
103
+ const pageContextRow = {
104
+ ...gantProduct(1),
105
+ attributes: [{ key: 'badge2', label: 'Badge', value: 'YENİ SEZON' }],
106
+ attributesKeyed: { badge2: 'YENİ SEZON', integration_renk: '457' }
107
+ };
108
+ const row = normalizeListingProduct(pageContextRow, 0, { currency: 'TRY' });
109
+ expect(row?.attributes).toEqual({ badge2: 'YENİ SEZON', integration_renk: '457' });
110
+ });
111
+ });
@@ -20,6 +20,18 @@ import { renderComposedChildren } from './render-composed-children';
20
20
  */
21
21
  const isInternalHref = (href: unknown): href is string =>
22
22
  typeof href === 'string' && href.startsWith('/') && !href.startsWith('//');
23
+ const hasOwnLength = (value: unknown, breakpoint: string): boolean => {
24
+ const raw = String(getResponsiveValue(value, breakpoint, '')).trim();
25
+ return raw !== '' && raw !== 'auto' && raw !== '0' && raw !== '0px';
26
+ };
27
+
28
+ /** Both dimensions authored: the group draws a box whether or not it has children. */
29
+ const isSizedShape = (styles: unknown, breakpoint: string): boolean =>
30
+ Boolean(styles) &&
31
+ typeof styles === 'object' &&
32
+ hasOwnLength((styles as Record<string, unknown>).width, breakpoint) &&
33
+ hasOwnLength((styles as Record<string, unknown>).height, breakpoint);
34
+
23
35
  const GroupBlock = ({
24
36
  block,
25
37
  placeholderId,
@@ -75,10 +87,32 @@ const GroupBlock = ({
75
87
  const shippingRole = String(
76
88
  getResponsiveValue(block.properties?.shippingRole, currentBreakpoint, '')
77
89
  );
90
+ // A group with a width and a height of its own and nothing inside is a
91
+ // SHAPE — a colour-swatch chip, a progress marker — not a container waiting
92
+ // for children, so the canvas must not stamp "drop blocks here" over it
93
+ // (ZERO-4829: the chips of every card read "Group (drop blocks here)").
78
94
  const isVisualUtilityGroup =
79
- shippingRole === 'progress-fill' || shippingRole === 'progress-marker';
95
+ shippingRole === 'progress-fill' ||
96
+ shippingRole === 'progress-marker' ||
97
+ isSizedShape(block.styles, currentBreakpoint);
80
98
 
81
99
  if (!block.blocks || block.blocks.length === 0) {
100
+ // An empty anchor is a legitimate element too — a colour-swatch chip is
101
+ // a link with nothing inside it but its background. It navigates the same
102
+ // way a filled one does; the plain-<a> branches below have no href when
103
+ // the router link was chosen, which is exactly the case an internal href
104
+ // is — so this comes first, before the shape branch a sized chip takes.
105
+ if (useRouterLink) {
106
+ return (
107
+ <NextLink
108
+ href={href as string}
109
+ prefetch={false}
110
+ className="contents"
111
+ onClick={handleAnchorAnalytics}
112
+ />
113
+ );
114
+ }
115
+
82
116
  if (isVisualUtilityGroup) {
83
117
  return <Tag {...tagProps} />;
84
118
  }
@@ -134,6 +134,40 @@ export const resolveSliderRowProduct = (
134
134
  return { ...item, pk } as ThemeProduct;
135
135
  };
136
136
 
137
+ /**
138
+ * The section data sources whose rows are products.
139
+ *
140
+ * The same declared-not-sniffed rule as the two allow-lists above, applied to
141
+ * a section's own repeat: a custom section iterating a COLLECTION data source
142
+ * is, by the source's definition, a list of commerce products, so a card
143
+ * dropped into it (ZERO-4829's "Product Cards" on any page) gets the same
144
+ * product scope a product grid row gets — and its favourite and add-to-basket
145
+ * address the right product instead of rendering nothing for want of one.
146
+ * A static or API source says nothing about its rows and opens no scope.
147
+ */
148
+ const PRODUCT_ROW_DATA_SOURCE_TYPES = new Set(['collection']);
149
+
150
+ export const publishesProductRows = (
151
+ sectionDataSource: { type?: unknown } | null | undefined
152
+ ): boolean =>
153
+ typeof sectionDataSource?.type === 'string' &&
154
+ PRODUCT_ROW_DATA_SOURCE_TYPES.has(sectionDataSource.type);
155
+
156
+ /**
157
+ * A collection row seen as the ambient product, or null for the padding rows
158
+ * the count floor draws when the source has answered with fewer products.
159
+ */
160
+ export const resolveCollectionRowProduct = (
161
+ item: Record<string, unknown> | null | undefined
162
+ ): ThemeProduct | null => {
163
+ if (!item || Object.keys(item).length === 0) return null;
164
+
165
+ const pk = readProductPk(item);
166
+ if (pk === undefined) return null;
167
+
168
+ return { ...item, pk } as ThemeProduct;
169
+ };
170
+
137
171
  /**
138
172
  * What a product row shows in the editor when the page has no live data.
139
173
  *