@akinon/pz-theme 2.0.103-rc.0 → 2.0.104-beta.0
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 +16 -33
- package/package.json +4 -4
- package/src/__tests__/collection-row-scope.test.tsx +187 -0
- package/src/__tests__/group-row-styles.test.tsx +110 -0
- package/src/__tests__/theme-typography-vars.test.tsx +125 -0
- package/src/blocks/__tests__/listing-row-swatches.test.ts +111 -0
- package/src/blocks/group-block.tsx +35 -1
- package/src/blocks/item-template-utils.ts +34 -0
- package/src/blocks/item-template.tsx +6 -1
- package/src/blocks/listing-context.ts +17 -622
- package/src/blocks/listing-row.ts +692 -0
- package/src/components/with-designer-features.tsx +6 -0
- package/src/sections/__tests__/posts-slider-row-visibility.test.tsx +147 -0
- package/src/sections/posts-slider-section.tsx +23 -2
- package/src/theme-block.tsx +63 -2
- package/src/theme-placeholder-client.tsx +4 -3
- package/src/theme-placeholder-wrapper.tsx +61 -5
- package/src/utils/__tests__/property-switches.test.tsx +250 -0
- package/src/utils/index.ts +55 -3
- package/src/utils/iterator-price-shapes.test.ts +465 -0
- package/src/utils/iterator-utils.ts +371 -35
- package/src/utils/property-switches.ts +230 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,38 +1,21 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.
|
|
4
|
-
|
|
5
|
-
### Patch Changes
|
|
6
|
-
|
|
7
|
-
- Updated dependencies [
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
- Updated dependencies [57d7eb305]
|
|
20
|
-
- Updated dependencies [d99a6a7d5]
|
|
21
|
-
- Updated dependencies [9db81a714]
|
|
22
|
-
- Updated dependencies [591e345e1]
|
|
23
|
-
- Updated dependencies [4de5303c5]
|
|
24
|
-
- Updated dependencies [95b139dc1]
|
|
25
|
-
- Updated dependencies [1d00f2d06]
|
|
26
|
-
- Updated dependencies [4ac7b2a1e]
|
|
27
|
-
- Updated dependencies [e9598c71d]
|
|
28
|
-
- Updated dependencies [4998a9631]
|
|
29
|
-
- Updated dependencies [804d2bd6c]
|
|
30
|
-
- Updated dependencies [3909d3224]
|
|
31
|
-
- Updated dependencies [6a3d8a631]
|
|
32
|
-
- Updated dependencies [e18836b20]
|
|
33
|
-
- Updated dependencies [b1111d7b1]
|
|
34
|
-
- @akinon/next@2.0.103-rc.0
|
|
35
|
-
- @akinon/theme-contract@2.0.103-rc.0
|
|
3
|
+
## 2.0.104-beta.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [cbbbfd757]
|
|
8
|
+
- @akinon/next@2.0.104-beta.0
|
|
9
|
+
- @akinon/theme-contract@2.0.104-beta.0
|
|
10
|
+
|
|
11
|
+
## 2.0.103
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- 64407cc: ZERO-4829: add tests for iterator price shapes and enhance price formatting logic
|
|
16
|
+
- af4e3ba: ZERO-4289: enhance group block logic to handle sized shapes and update property switch behavior
|
|
17
|
+
- @akinon/next@2.0.103
|
|
18
|
+
- @akinon/theme-contract@2.0.103
|
|
36
19
|
|
|
37
20
|
## 2.0.102
|
|
38
21
|
|
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.
|
|
4
|
+
"version": "2.0.104-beta.0",
|
|
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.
|
|
11
|
+
"@akinon/next": "2.0.104-beta.0",
|
|
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.
|
|
16
|
+
"@akinon/theme-contract": "2.0.104-beta.0",
|
|
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.
|
|
24
|
+
"@akinon/next": "2.0.104-beta.0",
|
|
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' ||
|
|
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
|
}
|