@akinon/pz-theme 2.0.163-beta.0 → 2.0.163
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 +4 -4
- package/package.json +4 -4
- package/src/__tests__/commerce-channel-frozen.test.ts +12 -1
- package/src/__tests__/section-list-identity.test.tsx +113 -0
- package/src/blocks/__tests__/favourites-grid-impression.test.tsx +217 -0
- package/src/blocks/__tests__/group-block-list-click.test.tsx +90 -0
- package/src/blocks/favourites-grid-block.tsx +16 -18
- package/src/blocks/group-block.tsx +14 -2
- package/src/blocks/item-template.tsx +1 -0
- package/src/blocks/product-block-utils.tsx +5 -1
- package/src/blocks/product-recommendations-block.tsx +45 -13
- package/src/hooks/__tests__/use-list-impression.test.tsx +228 -0
- package/src/hooks/__tests__/use-section-seen.test.tsx +55 -2
- package/src/hooks/use-list-impression.ts +116 -0
- package/src/hooks/use-section-seen.ts +22 -2
- package/src/sections/__tests__/posts-slider-select-item.test.tsx +205 -0
- package/src/sections/__tests__/section-wrapper-list.test.tsx +148 -0
- package/src/sections/posts-slider-section.tsx +62 -2
- package/src/sections/section-wrapper.tsx +32 -2
- package/src/theme-block.tsx +6 -0
- package/src/theme-section-data-context.tsx +33 -0
- package/src/theme-section.tsx +60 -2
- package/src/utils/__tests__/section-impressions.test.ts +68 -0
- package/src/utils/iterator-price-shapes.test.ts +13 -0
- package/src/utils/iterator-utils.ts +2 -1
- package/src/utils/section-impressions.ts +37 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.163
|
|
3
|
+
## 2.0.163
|
|
4
4
|
|
|
5
5
|
### Patch Changes
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
- @akinon/next@2.0.163
|
|
9
|
-
- @akinon/theme-contract@2.0.163
|
|
7
|
+
- 2c4fae70: ZERO-5305: Report product lists as GA4 lists on the commerce channel. Collection sections, product sliders and recommendation strips send `view_item_list` once seen and `select_item` with the list id, list name and the card's position; the favourites grid reports when it is seen rather than on mount; `section.seen` carries the section label and its product count. Nothing is emitted inside the editor.
|
|
8
|
+
- @akinon/next@2.0.163
|
|
9
|
+
- @akinon/theme-contract@2.0.163
|
|
10
10
|
|
|
11
11
|
## 2.0.162
|
|
12
12
|
|
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.163
|
|
4
|
+
"version": "2.0.163",
|
|
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.163
|
|
11
|
+
"@akinon/next": "2.0.163",
|
|
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.163
|
|
16
|
+
"@akinon/theme-contract": "2.0.163",
|
|
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.163
|
|
24
|
+
"@akinon/next": "2.0.163",
|
|
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",
|
|
@@ -30,11 +30,22 @@ const FROZEN_COMMERCE_CALL_SITES: Record<string, number> = {
|
|
|
30
30
|
'blocks/auth-form-block.tsx': 3,
|
|
31
31
|
'blocks/basket-items-block.tsx': 1,
|
|
32
32
|
'blocks/favorite-button-block.tsx': 1,
|
|
33
|
-
|
|
33
|
+
// view_item_list moved to hooks/use-list-impression.ts (ZERO-5305): the
|
|
34
|
+
// grid reports when it is seen, as every other list does.
|
|
35
|
+
'blocks/favourites-grid-block.tsx': 2,
|
|
34
36
|
'blocks/group-block.tsx': 1,
|
|
35
37
|
'blocks/product-grid-block.tsx': 2,
|
|
36
38
|
'blocks/product-recommendations-block.tsx': 1,
|
|
37
39
|
'blocks/search-box-block.tsx': 1,
|
|
40
|
+
// view_item_list for the product rows that are not a listing grid — a
|
|
41
|
+
// collection carousel, a recommendations strip (ZERO-5305). An existing GA4
|
|
42
|
+
// verb, not a new moment: a storefront bridge maps it as it maps the
|
|
43
|
+
// listing grid's.
|
|
44
|
+
'hooks/use-list-impression.ts': 1,
|
|
45
|
+
// select_item for a product slider's card (ZERO-5305): the same verb the
|
|
46
|
+
// composed card's anchor reports from group-block, for the slides the
|
|
47
|
+
// slider draws itself.
|
|
48
|
+
'sections/posts-slider-section.tsx': 1,
|
|
38
49
|
// One call, four page-scope actions behind it (view_item, view_cart,
|
|
39
50
|
// begin_checkout, purchase) — the action is a variable there.
|
|
40
51
|
'theme-placeholder-wrapper.tsx': 1
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
jest.mock('next/navigation', () => ({
|
|
5
|
+
usePathname: () => '/'
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
jest.mock('react-multi-carousel', () => ({
|
|
9
|
+
__esModule: true,
|
|
10
|
+
default: ({ children }: { children?: React.ReactNode }) => (
|
|
11
|
+
<div>{children}</div>
|
|
12
|
+
)
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
const wrapperProps: Array<Record<string, unknown>> = [];
|
|
16
|
+
jest.mock('../sections/section-wrapper', () => ({
|
|
17
|
+
__esModule: true,
|
|
18
|
+
default: (
|
|
19
|
+
props: Record<string, unknown> & { children?: React.ReactNode }
|
|
20
|
+
) => {
|
|
21
|
+
wrapperProps.push(props);
|
|
22
|
+
return (
|
|
23
|
+
<section data-section-id={(props.section as { id: string }).id}>
|
|
24
|
+
{props.children}
|
|
25
|
+
</section>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
import ThemeSection from '../theme-section';
|
|
31
|
+
import { ThemeSettingsProvider } from '../theme-settings-context';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A section with a renderer of its own (a product slider) is drawn through
|
|
35
|
+
* `SectionWrapper`, not the generic branch's <section>. ThemeSection hands the
|
|
36
|
+
* wrapper what it needs to report the section as the generic branch does
|
|
37
|
+
* (ZERO-5305): the product count, so a product row is never taken for a
|
|
38
|
+
* banner, and the rows with the list they are — the collection, named after
|
|
39
|
+
* the section's label.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
const ROWS = [
|
|
43
|
+
{ pk: 1, base_code: 'B1', name: 'Polo', price: '1499' },
|
|
44
|
+
{ pk: 2, base_code: 'B2', name: 'Shirt', price: '2499' }
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const slider = (blocks: unknown[] = []) =>
|
|
48
|
+
({
|
|
49
|
+
id: 'section-1',
|
|
50
|
+
type: 'flash-deals-slider',
|
|
51
|
+
name: 'section-1',
|
|
52
|
+
label: 'Picks Slider',
|
|
53
|
+
order: 3,
|
|
54
|
+
hidden: false,
|
|
55
|
+
properties: {},
|
|
56
|
+
styles: {},
|
|
57
|
+
dataSourceId: 'datasource-1',
|
|
58
|
+
dataSource: {
|
|
59
|
+
id: 'datasource-1',
|
|
60
|
+
type: 'collection',
|
|
61
|
+
details: { collection: { data: { products: ROWS } } }
|
|
62
|
+
},
|
|
63
|
+
blocks
|
|
64
|
+
} as never);
|
|
65
|
+
|
|
66
|
+
const renderSection = (section: never) =>
|
|
67
|
+
render(
|
|
68
|
+
<ThemeSettingsProvider value={null}>
|
|
69
|
+
<ThemeSection
|
|
70
|
+
section={section}
|
|
71
|
+
placeholderId="home-body"
|
|
72
|
+
currentBreakpoint="desktop"
|
|
73
|
+
isDesigner={false}
|
|
74
|
+
/>
|
|
75
|
+
</ThemeSettingsProvider>
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
describe('a section drawn through SectionWrapper (ZERO-5305)', () => {
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
wrapperProps.length = 0;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('is handed its product count and its rows as a list', () => {
|
|
84
|
+
renderSection(slider());
|
|
85
|
+
|
|
86
|
+
const props = wrapperProps[wrapperProps.length - 1];
|
|
87
|
+
expect(props.productCount).toBe(2);
|
|
88
|
+
expect(props.listProducts).toEqual(ROWS);
|
|
89
|
+
expect(props.listId).toBe('datasource-1');
|
|
90
|
+
expect(props.listName).toBe('Picks Slider');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('lists nothing when a grid inside reports its own impressions', () => {
|
|
94
|
+
renderSection(
|
|
95
|
+
slider([
|
|
96
|
+
{
|
|
97
|
+
id: 'grid',
|
|
98
|
+
type: 'product-grid',
|
|
99
|
+
label: 'Grid',
|
|
100
|
+
properties: {},
|
|
101
|
+
styles: {},
|
|
102
|
+
order: 0,
|
|
103
|
+
hidden: false,
|
|
104
|
+
blocks: []
|
|
105
|
+
}
|
|
106
|
+
])
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const props = wrapperProps[wrapperProps.length - 1];
|
|
110
|
+
expect(props.listProducts).toBeNull();
|
|
111
|
+
expect(props.productCount).toBe(2);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { act, render } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
import FavouritesGridBlock from '../favourites-grid-block';
|
|
5
|
+
import { COMMERCE_EVENT_NAME } from '../../utils/analytics';
|
|
6
|
+
import { LIST_IMPRESSION_DWELL_MS } from '../../hooks/use-list-impression';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The favourites grid reports its favourites as a list when the grid is seen
|
|
10
|
+
* (ZERO-5305), as every other list does — not when it mounts. A grid kept on
|
|
11
|
+
* a page only for its empty state, its list laid out with `display: none`
|
|
12
|
+
* beside a favourites strip, reported the favourites a second time.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
jest.mock('next-auth/react', () => ({
|
|
16
|
+
useSession: () => ({ data: null, status: 'authenticated' })
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
jest.mock('@akinon/next/hooks', () => ({
|
|
20
|
+
__esModule: true,
|
|
21
|
+
useLocalization: () => ({ locale: 'tr', currency: 'try' })
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
jest.mock('@akinon/next/redux/hooks', () => ({
|
|
25
|
+
__esModule: true,
|
|
26
|
+
useAppSelector: () => undefined,
|
|
27
|
+
useAppDispatch: () => jest.fn()
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
const favourites = {
|
|
31
|
+
results: [
|
|
32
|
+
{
|
|
33
|
+
pk: 11,
|
|
34
|
+
product: {
|
|
35
|
+
pk: 101,
|
|
36
|
+
base_code: 'B101',
|
|
37
|
+
name: 'Shirt',
|
|
38
|
+
absolute_url: '/gomlek/',
|
|
39
|
+
price: '3999.90',
|
|
40
|
+
currency_type: 'try'
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
pk: 12,
|
|
45
|
+
product: {
|
|
46
|
+
pk: 102,
|
|
47
|
+
base_code: 'B102',
|
|
48
|
+
name: 'Polo',
|
|
49
|
+
absolute_url: '/polo/',
|
|
50
|
+
price: '2499.00',
|
|
51
|
+
currency_type: 'try'
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
]
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Every `@akinon/next/data/client/*` subpath maps to the same mock file.
|
|
58
|
+
jest.mock('@akinon/next/data/client/account', () => ({
|
|
59
|
+
__esModule: true,
|
|
60
|
+
useGetFavoritesQuery: () => ({ data: mockFavourites(), isLoading: false }),
|
|
61
|
+
useRemoveFavoriteMutation: () => [jest.fn(), { isLoading: false }],
|
|
62
|
+
useAddProductMutation: () => [jest.fn(), { isLoading: false }],
|
|
63
|
+
basketApi: { util: { invalidateTags: jest.fn() } }
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
let current = favourites;
|
|
67
|
+
const mockFavourites = () => current;
|
|
68
|
+
|
|
69
|
+
class RecordingObserver {
|
|
70
|
+
static instances: RecordingObserver[] = [];
|
|
71
|
+
callback: IntersectionObserverCallback;
|
|
72
|
+
observed: Element[] = [];
|
|
73
|
+
|
|
74
|
+
constructor(callback: IntersectionObserverCallback) {
|
|
75
|
+
this.callback = callback;
|
|
76
|
+
RecordingObserver.instances.push(this);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
observe(node: Element) {
|
|
80
|
+
this.observed.push(node);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
unobserve() {}
|
|
84
|
+
disconnect() {}
|
|
85
|
+
takeRecords() {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
cross() {
|
|
90
|
+
act(() => {
|
|
91
|
+
this.callback(
|
|
92
|
+
[
|
|
93
|
+
{
|
|
94
|
+
isIntersecting: true,
|
|
95
|
+
intersectionRatio: 1,
|
|
96
|
+
target: this.observed[0]
|
|
97
|
+
} as unknown as IntersectionObserverEntry
|
|
98
|
+
],
|
|
99
|
+
this as unknown as IntersectionObserver
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const block = {
|
|
106
|
+
id: 'favourites-grid-1',
|
|
107
|
+
type: 'favourites-grid',
|
|
108
|
+
label: 'Favourites Grid',
|
|
109
|
+
order: 0,
|
|
110
|
+
hidden: false,
|
|
111
|
+
properties: {},
|
|
112
|
+
styles: {},
|
|
113
|
+
blocks: []
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
describe('favourites-grid list impression (ZERO-5305)', () => {
|
|
117
|
+
let received: Array<Record<string, unknown>>;
|
|
118
|
+
let originalObserver: unknown;
|
|
119
|
+
const onCommerce = (event: Event) =>
|
|
120
|
+
received.push((event as CustomEvent<Record<string, unknown>>).detail);
|
|
121
|
+
const lists = () => received.filter((d) => d.action === 'view_item_list');
|
|
122
|
+
const latest = () =>
|
|
123
|
+
RecordingObserver.instances[RecordingObserver.instances.length - 1];
|
|
124
|
+
|
|
125
|
+
beforeEach(() => {
|
|
126
|
+
jest.useFakeTimers();
|
|
127
|
+
current = favourites;
|
|
128
|
+
received = [];
|
|
129
|
+
window.addEventListener(COMMERCE_EVENT_NAME, onCommerce);
|
|
130
|
+
RecordingObserver.instances = [];
|
|
131
|
+
originalObserver = (window as unknown as Record<string, unknown>)
|
|
132
|
+
.IntersectionObserver;
|
|
133
|
+
(window as unknown as Record<string, unknown>).IntersectionObserver =
|
|
134
|
+
RecordingObserver;
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
afterEach(() => {
|
|
138
|
+
window.removeEventListener(COMMERCE_EVENT_NAME, onCommerce);
|
|
139
|
+
(window as unknown as Record<string, unknown>).IntersectionObserver =
|
|
140
|
+
originalObserver;
|
|
141
|
+
jest.useRealTimers();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('reports nothing until the grid is seen, then the favourites as one list', () => {
|
|
145
|
+
const { container } = render(
|
|
146
|
+
<FavouritesGridBlock
|
|
147
|
+
block={block}
|
|
148
|
+
placeholderId="p1"
|
|
149
|
+
sectionId="s1"
|
|
150
|
+
isDesigner={false}
|
|
151
|
+
/>
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
expect(lists()).toEqual([]);
|
|
155
|
+
expect(latest().observed[0]).toBe(
|
|
156
|
+
container.querySelector('[data-favourites-grid]')
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
latest().cross();
|
|
160
|
+
act(() => {
|
|
161
|
+
jest.advanceTimersByTime(LIST_IMPRESSION_DWELL_MS);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(lists()).toHaveLength(1);
|
|
165
|
+
expect(lists()[0]).toMatchObject({ currency: 'try' });
|
|
166
|
+
expect(lists()[0].items).toEqual([
|
|
167
|
+
expect.objectContaining({
|
|
168
|
+
id: '101',
|
|
169
|
+
name: 'Shirt',
|
|
170
|
+
index: 0,
|
|
171
|
+
listId: 'favourites',
|
|
172
|
+
listName: 'Favourites'
|
|
173
|
+
}),
|
|
174
|
+
expect.objectContaining({
|
|
175
|
+
id: '102',
|
|
176
|
+
name: 'Polo',
|
|
177
|
+
index: 1,
|
|
178
|
+
listId: 'favourites',
|
|
179
|
+
listName: 'Favourites'
|
|
180
|
+
})
|
|
181
|
+
]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('reports the new list once a favourite is removed', () => {
|
|
185
|
+
const { rerender } = render(
|
|
186
|
+
<FavouritesGridBlock
|
|
187
|
+
block={block}
|
|
188
|
+
placeholderId="p1"
|
|
189
|
+
sectionId="s1"
|
|
190
|
+
isDesigner={false}
|
|
191
|
+
/>
|
|
192
|
+
);
|
|
193
|
+
latest().cross();
|
|
194
|
+
act(() => {
|
|
195
|
+
jest.advanceTimersByTime(LIST_IMPRESSION_DWELL_MS);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
current = { results: [favourites.results[1]] };
|
|
199
|
+
rerender(
|
|
200
|
+
<FavouritesGridBlock
|
|
201
|
+
block={block}
|
|
202
|
+
placeholderId="p1"
|
|
203
|
+
sectionId="s1"
|
|
204
|
+
isDesigner={false}
|
|
205
|
+
/>
|
|
206
|
+
);
|
|
207
|
+
latest().cross();
|
|
208
|
+
act(() => {
|
|
209
|
+
jest.advanceTimersByTime(LIST_IMPRESSION_DWELL_MS);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
expect(lists()).toHaveLength(2);
|
|
213
|
+
expect(lists()[1].items).toEqual([
|
|
214
|
+
expect.objectContaining({ id: '102', index: 0 })
|
|
215
|
+
]);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { fireEvent, render } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
jest.mock('../../theme-block', () => ({
|
|
5
|
+
__esModule: true,
|
|
6
|
+
default: () => null
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
import GroupBlock from '../group-block';
|
|
10
|
+
import { ThemeItemScopeProvider } from '../product-block-utils';
|
|
11
|
+
import { ThemeSectionListProvider } from '../../theme-section-data-context';
|
|
12
|
+
import { COMMERCE_EVENT_NAME } from '../../utils/analytics';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A product card's click names the list the card sits in — the section's
|
|
16
|
+
* carousel, not the product's catalogue group — and its place in it
|
|
17
|
+
* (ZERO-5305), the identity the row's view_item_list carries.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const card = () =>
|
|
21
|
+
({
|
|
22
|
+
block: {
|
|
23
|
+
id: 'card',
|
|
24
|
+
type: 'group',
|
|
25
|
+
label: 'card',
|
|
26
|
+
value: '',
|
|
27
|
+
properties: { tag: 'a', href: '#product' },
|
|
28
|
+
styles: {},
|
|
29
|
+
order: 0,
|
|
30
|
+
hidden: false,
|
|
31
|
+
blocks: []
|
|
32
|
+
},
|
|
33
|
+
placeholderId: 'home-body',
|
|
34
|
+
sectionId: 'sec_new_in',
|
|
35
|
+
isDesigner: false
|
|
36
|
+
} as any);
|
|
37
|
+
|
|
38
|
+
const PRODUCT = { pk: 7, base_code: 'B7', name: 'Polo', price: '1499' };
|
|
39
|
+
|
|
40
|
+
describe('a product card click (ZERO-5305)', () => {
|
|
41
|
+
let received: Array<Record<string, unknown>>;
|
|
42
|
+
const onCommerce = (event: Event) =>
|
|
43
|
+
received.push((event as CustomEvent<Record<string, unknown>>).detail);
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
received = [];
|
|
47
|
+
window.addEventListener(COMMERCE_EVENT_NAME, onCommerce);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
window.removeEventListener(COMMERCE_EVENT_NAME, onCommerce);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('reports the section’s list and the card’s place in it', () => {
|
|
55
|
+
const { container } = render(
|
|
56
|
+
<ThemeSectionListProvider listId="collection-12" listName="New Arrivals">
|
|
57
|
+
<ThemeItemScopeProvider product={PRODUCT as never} index={3}>
|
|
58
|
+
<GroupBlock {...card()} />
|
|
59
|
+
</ThemeItemScopeProvider>
|
|
60
|
+
</ThemeSectionListProvider>
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
fireEvent.click(container.querySelector('a') as HTMLElement);
|
|
64
|
+
|
|
65
|
+
expect(received).toHaveLength(1);
|
|
66
|
+
expect(received[0]).toMatchObject({ action: 'select_item' });
|
|
67
|
+
expect(received[0].items).toEqual([
|
|
68
|
+
expect.objectContaining({
|
|
69
|
+
baseCode: 'B7',
|
|
70
|
+
index: 3,
|
|
71
|
+
listId: 'collection-12',
|
|
72
|
+
listName: 'New Arrivals'
|
|
73
|
+
})
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('still reports the product outside a section list', () => {
|
|
78
|
+
const { container } = render(
|
|
79
|
+
<ThemeItemScopeProvider product={PRODUCT as never}>
|
|
80
|
+
<GroupBlock {...card()} />
|
|
81
|
+
</ThemeItemScopeProvider>
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
fireEvent.click(container.querySelector('a') as HTMLElement);
|
|
85
|
+
|
|
86
|
+
expect(received[0].items).toEqual([
|
|
87
|
+
expect.not.objectContaining({ listId: expect.anything() })
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
readItemsCurrency
|
|
26
26
|
} from '../utils/analytics';
|
|
27
27
|
import { hasItemTemplate, ItemTemplateList } from './item-template';
|
|
28
|
+
import { useListImpression } from '../hooks/use-list-impression';
|
|
28
29
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
29
30
|
import { DesignerPlaceholder, readToggle } from './product-block-utils';
|
|
30
31
|
|
|
@@ -283,25 +284,21 @@ const FavouritesGridBlock = ({
|
|
|
283
284
|
|
|
284
285
|
// GA4 view_item_list (ZERO-4869), once per distinct wishlist content: the
|
|
285
286
|
// favourites grid IS the list impression on this screen. Keyed by row keys
|
|
286
|
-
// so a remove refires with the new list, a mere re-render does not.
|
|
287
|
-
//
|
|
287
|
+
// so a remove refires with the new list, a mere re-render does not. Sent
|
|
288
|
+
// when the grid is seen, as every other list is (ZERO-5305): a grid kept
|
|
289
|
+
// only for its empty state, its list laid out with `display: none` next to
|
|
290
|
+
// a favourites strip, used to report the favourites a second time.
|
|
288
291
|
const rowsKey = rows.map((row) => row.key).join(',');
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
listName: 'Favourites'
|
|
300
|
-
})
|
|
301
|
-
)
|
|
302
|
-
});
|
|
303
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
304
|
-
}, [rowsKey, isDesigner]);
|
|
292
|
+
const listRef = React.useRef<HTMLDivElement>(null);
|
|
293
|
+
useListImpression({
|
|
294
|
+
ref: listRef,
|
|
295
|
+
products: rows as Array<Record<string, unknown>>,
|
|
296
|
+
listId: 'favourites',
|
|
297
|
+
listName: 'Favourites',
|
|
298
|
+
enabled: !isDesigner,
|
|
299
|
+
refreshKey: rowsKey,
|
|
300
|
+
currency: localeCurrency
|
|
301
|
+
});
|
|
305
302
|
|
|
306
303
|
// A designer who marked a child as the card layout gets that layout repeated
|
|
307
304
|
// per saved product; otherwise the built-in card renders unchanged.
|
|
@@ -402,6 +399,7 @@ const FavouritesGridBlock = ({
|
|
|
402
399
|
|
|
403
400
|
return (
|
|
404
401
|
<div
|
|
402
|
+
ref={listRef}
|
|
405
403
|
data-favourites-grid=""
|
|
406
404
|
data-favourites-grid-id={gridId}
|
|
407
405
|
aria-busy={isBusy}
|
|
@@ -17,6 +17,7 @@ import { DesignerPlaceholder, useThemeItemScope } from './product-block-utils';
|
|
|
17
17
|
import { renderComposedChildren } from './render-composed-children';
|
|
18
18
|
import { CardListSlider, readListGap } from '../components/card-list-slider';
|
|
19
19
|
import { CardListViews } from '../components/card-list-views';
|
|
20
|
+
import { useThemeSectionList } from '../theme-section-data-context';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Composed anchors to the storefront's own routes navigate client-side. A
|
|
@@ -59,17 +60,28 @@ const GroupBlock = ({
|
|
|
59
60
|
const Tag = tag as keyof React.JSX.IntrinsicElements;
|
|
60
61
|
const href = tag === 'a' ? block.properties?.href : undefined;
|
|
61
62
|
const itemScope = useThemeItemScope();
|
|
63
|
+
const sectionList = useThemeSectionList();
|
|
62
64
|
|
|
63
65
|
// GA4 select_item (ZERO-4869): a composed anchor inside a repeated product
|
|
64
66
|
// row IS the product card's click. The row scope carries the product, so no
|
|
65
|
-
// per-card wiring is needed — and outside a row this stays silent.
|
|
67
|
+
// per-card wiring is needed — and outside a row this stays silent. The card
|
|
68
|
+
// reports the list it sits in — the section's — and its place in it
|
|
69
|
+
// (ZERO-5305), the same identity the row's view_item_list carries.
|
|
66
70
|
const handleAnchorAnalytics = () => {
|
|
67
71
|
if (isDesigner) return;
|
|
68
72
|
const scopedProduct = itemScope?.product;
|
|
69
73
|
if (!scopedProduct) return;
|
|
70
74
|
emitCommerceEvent('select_item', {
|
|
71
75
|
currency: readItemsCurrency(scopedProduct as Record<string, unknown>),
|
|
72
|
-
items: [
|
|
76
|
+
items: [
|
|
77
|
+
buildCommerceItem(scopedProduct as Record<string, unknown>, {
|
|
78
|
+
...(typeof itemScope?.index === 'number'
|
|
79
|
+
? { index: itemScope.index }
|
|
80
|
+
: null),
|
|
81
|
+
...(sectionList?.listId ? { listId: sectionList.listId } : null),
|
|
82
|
+
...(sectionList?.listName ? { listName: sectionList.listName } : null)
|
|
83
|
+
})
|
|
84
|
+
]
|
|
73
85
|
});
|
|
74
86
|
};
|
|
75
87
|
|
|
@@ -99,6 +99,8 @@ export interface ThemeProduct {
|
|
|
99
99
|
*/
|
|
100
100
|
interface ThemeItemScopeValue {
|
|
101
101
|
product: ThemeProduct | null;
|
|
102
|
+
/** The row's place in its list (0-based), when the repeat knows it. */
|
|
103
|
+
index?: number;
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
const ThemeItemScopeContext = React.createContext<ThemeItemScopeValue | null>(
|
|
@@ -107,12 +109,14 @@ const ThemeItemScopeContext = React.createContext<ThemeItemScopeValue | null>(
|
|
|
107
109
|
|
|
108
110
|
export const ThemeItemScopeProvider = ({
|
|
109
111
|
product,
|
|
112
|
+
index,
|
|
110
113
|
children
|
|
111
114
|
}: {
|
|
112
115
|
product: ThemeProduct | null;
|
|
116
|
+
index?: number;
|
|
113
117
|
children: React.ReactNode;
|
|
114
118
|
}) => {
|
|
115
|
-
const value = React.useMemo(() => ({ product }), [product]);
|
|
119
|
+
const value = React.useMemo(() => ({ product, index }), [product, index]);
|
|
116
120
|
|
|
117
121
|
return (
|
|
118
122
|
<ThemeItemScopeContext.Provider value={value}>
|