@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
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { act, render, screen } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A deal card as it really is: one anchor to the product page, with a control
|
|
6
|
+
* a designer may drop inside it.
|
|
7
|
+
*/
|
|
8
|
+
jest.mock('../../theme-block', () => ({
|
|
9
|
+
__esModule: true,
|
|
10
|
+
default: ({ block }: { block: { id: string } }) => (
|
|
11
|
+
<a data-testid="card" data-id={block.id} href={`/product/${block.id}/`}>
|
|
12
|
+
<span data-testid="card-name">card</span>
|
|
13
|
+
<button type="button" data-testid="card-button">
|
|
14
|
+
add
|
|
15
|
+
</button>
|
|
16
|
+
</a>
|
|
17
|
+
)
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
/** The real carousel renders an empty track at jsdom's zero width. */
|
|
21
|
+
jest.mock('react-multi-carousel', () => ({
|
|
22
|
+
__esModule: true,
|
|
23
|
+
default: ({ children }: { children?: React.ReactNode }) => (
|
|
24
|
+
<div data-testid="carousel">{children}</div>
|
|
25
|
+
)
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
import PostsSliderSection from '../posts-slider-section';
|
|
29
|
+
import { ThemeSectionListProvider } from '../../theme-section-data-context';
|
|
30
|
+
import { COMMERCE_EVENT_NAME } from '../../utils/analytics';
|
|
31
|
+
|
|
32
|
+
declare global {
|
|
33
|
+
interface Window {
|
|
34
|
+
__themeEditorDesigner?: boolean;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A product slider's cards are a list (ZERO-5305): a click on one is a
|
|
40
|
+
* select_item naming the list it was picked from and its place in it, the
|
|
41
|
+
* identity the row's view_item_list carries — so the pick can follow the
|
|
42
|
+
* product to its page and into the basket.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
const product = (pk: number, name: string, price: number) => ({
|
|
46
|
+
pk,
|
|
47
|
+
base_code: `B${pk}`,
|
|
48
|
+
name,
|
|
49
|
+
price: String(price),
|
|
50
|
+
currency_type: 'try',
|
|
51
|
+
absolute_url: `/product/${pk}/`,
|
|
52
|
+
productimage_set: [{ image: `/img/${pk}.jpg` }],
|
|
53
|
+
active_price: { price, currency_type: 'TRY' }
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const ITERATOR = {
|
|
57
|
+
id: 'iter',
|
|
58
|
+
label: 'Iterator',
|
|
59
|
+
isIterator: true,
|
|
60
|
+
order: 0,
|
|
61
|
+
properties: {},
|
|
62
|
+
styles: {},
|
|
63
|
+
blocks: [
|
|
64
|
+
{
|
|
65
|
+
id: 'card',
|
|
66
|
+
type: 'group',
|
|
67
|
+
label: 'Product Card 1',
|
|
68
|
+
properties: { tag: 'a' },
|
|
69
|
+
styles: {},
|
|
70
|
+
blocks: []
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const renderSlider = ({
|
|
76
|
+
type = 'flash-deals-slider',
|
|
77
|
+
properties = {},
|
|
78
|
+
isDesigner = false,
|
|
79
|
+
list = { listId: 'datasource-1', listName: 'Picks Slider' } as {
|
|
80
|
+
listId: string;
|
|
81
|
+
listName: string;
|
|
82
|
+
} | null
|
|
83
|
+
} = {}) => {
|
|
84
|
+
const slider = (
|
|
85
|
+
<PostsSliderSection
|
|
86
|
+
{...({
|
|
87
|
+
section: {
|
|
88
|
+
id: 'sec',
|
|
89
|
+
type,
|
|
90
|
+
label: 'Picks Slider',
|
|
91
|
+
properties: { 'items-per-slide': 1, ...properties },
|
|
92
|
+
styles: {},
|
|
93
|
+
blocks: [ITERATOR],
|
|
94
|
+
dataSource: {
|
|
95
|
+
details: {
|
|
96
|
+
collection: {
|
|
97
|
+
products: [
|
|
98
|
+
product(11, 'Blue Stool', 2790),
|
|
99
|
+
product(22, 'Red Chair', 1490)
|
|
100
|
+
]
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
currentBreakpoint: 'desktop',
|
|
106
|
+
placeholderId: 'ph',
|
|
107
|
+
isDesigner
|
|
108
|
+
} as any)}
|
|
109
|
+
/>
|
|
110
|
+
);
|
|
111
|
+
return render(
|
|
112
|
+
list ? (
|
|
113
|
+
<ThemeSectionListProvider listId={list.listId} listName={list.listName}>
|
|
114
|
+
{slider}
|
|
115
|
+
</ThemeSectionListProvider>
|
|
116
|
+
) : (
|
|
117
|
+
slider
|
|
118
|
+
)
|
|
119
|
+
);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const click = (element: Element) =>
|
|
123
|
+
act(() => {
|
|
124
|
+
element.dispatchEvent(
|
|
125
|
+
new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 })
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('a product slider card click (ZERO-5305)', () => {
|
|
130
|
+
let received: Array<Record<string, any>>;
|
|
131
|
+
const onCommerce = (event: Event) =>
|
|
132
|
+
received.push((event as CustomEvent<Record<string, any>>).detail);
|
|
133
|
+
|
|
134
|
+
beforeEach(() => {
|
|
135
|
+
received = [];
|
|
136
|
+
delete window.__themeEditorDesigner;
|
|
137
|
+
window.addEventListener(COMMERCE_EVENT_NAME, onCommerce);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
afterEach(() => {
|
|
141
|
+
window.removeEventListener(COMMERCE_EVENT_NAME, onCommerce);
|
|
142
|
+
delete window.__themeEditorDesigner;
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const selects = () => received.filter((d) => d.action === 'select_item');
|
|
146
|
+
|
|
147
|
+
it('reports the pick with the list and the card’s place in it', () => {
|
|
148
|
+
renderSlider();
|
|
149
|
+
|
|
150
|
+
click(screen.getAllByTestId('card-name')[1]);
|
|
151
|
+
|
|
152
|
+
expect(selects()).toHaveLength(1);
|
|
153
|
+
expect(selects()[0].items).toEqual([
|
|
154
|
+
expect.objectContaining({
|
|
155
|
+
baseCode: 'B22',
|
|
156
|
+
name: 'Red Chair',
|
|
157
|
+
index: 1,
|
|
158
|
+
listId: 'datasource-1',
|
|
159
|
+
listName: 'Picks Slider'
|
|
160
|
+
})
|
|
161
|
+
]);
|
|
162
|
+
expect(selects()[0].currency).toBe('try');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('still reports the pick when the click opens the quick view', () => {
|
|
166
|
+
renderSlider({ properties: { 'show-quick-view': true } });
|
|
167
|
+
|
|
168
|
+
click(screen.getAllByTestId('card-name')[0]);
|
|
169
|
+
|
|
170
|
+
expect(selects()).toHaveLength(1);
|
|
171
|
+
expect(selects()[0].items[0]).toMatchObject({ baseCode: 'B11', index: 0 });
|
|
172
|
+
expect(
|
|
173
|
+
document.querySelector('[data-part="quick-view-panel"]')
|
|
174
|
+
).not.toBeNull();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('leaves a control inside the card to itself', () => {
|
|
178
|
+
renderSlider();
|
|
179
|
+
|
|
180
|
+
click(screen.getAllByTestId('card-button')[0]);
|
|
181
|
+
|
|
182
|
+
expect(selects()).toEqual([]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('reports the pick without a list outside one', () => {
|
|
186
|
+
renderSlider({ list: null });
|
|
187
|
+
|
|
188
|
+
click(screen.getAllByTestId('card-name')[0]);
|
|
189
|
+
|
|
190
|
+
expect(selects()[0].items[0]).toMatchObject({ baseCode: 'B11', index: 0 });
|
|
191
|
+
expect(selects()[0].items[0]).not.toHaveProperty('listId');
|
|
192
|
+
expect(selects()[0].items[0]).not.toHaveProperty('listName');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('is silent for a slider of posts, and in the designer', () => {
|
|
196
|
+
renderSlider({ type: 'posts-slider' });
|
|
197
|
+
click(screen.getAllByTestId('card-name')[0]);
|
|
198
|
+
expect(selects()).toEqual([]);
|
|
199
|
+
|
|
200
|
+
window.__themeEditorDesigner = true;
|
|
201
|
+
renderSlider();
|
|
202
|
+
click(screen.getAllByTestId('card-name')[0]);
|
|
203
|
+
expect(selects()).toEqual([]);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
import SectionWrapper from '../section-wrapper';
|
|
5
|
+
import type { Section } from '../../theme-section';
|
|
6
|
+
import { useThemeSectionList } from '../../theme-section-data-context';
|
|
7
|
+
import { useSectionSeen } from '../../hooks/use-section-seen';
|
|
8
|
+
import { useListImpression } from '../../hooks/use-list-impression';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A section drawn through `SectionWrapper` — a product slider, a developer's
|
|
12
|
+
* custom section — is reported as the generic branch reports its own
|
|
13
|
+
* (ZERO-5305): seen with its product count, so a product row is never taken
|
|
14
|
+
* for a banner; listed under its list identity as one view_item_list; and its
|
|
15
|
+
* cards told which list they are in. The two hooks are the wrapper's whole
|
|
16
|
+
* contract with the reports, so they are recorded here rather than driven.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
jest.mock('../../components/action-toolbar', () => ({
|
|
20
|
+
__esModule: true,
|
|
21
|
+
default: () => null
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
jest.mock('../../hooks/use-section-seen', () => ({
|
|
25
|
+
useSectionSeen: jest.fn()
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
jest.mock('../../hooks/use-list-impression', () => ({
|
|
29
|
+
useListImpression: jest.fn()
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
const seen = useSectionSeen as jest.Mock;
|
|
33
|
+
const impression = useListImpression as jest.Mock;
|
|
34
|
+
|
|
35
|
+
const section = {
|
|
36
|
+
id: 'sec-1',
|
|
37
|
+
type: 'flash-deals-slider',
|
|
38
|
+
name: 'sec-1',
|
|
39
|
+
label: 'Picks Slider',
|
|
40
|
+
properties: {},
|
|
41
|
+
styles: {},
|
|
42
|
+
blocks: [],
|
|
43
|
+
order: 2,
|
|
44
|
+
hidden: false
|
|
45
|
+
} as unknown as Section;
|
|
46
|
+
|
|
47
|
+
const ROWS = [
|
|
48
|
+
{ pk: 1, base_code: 'B1', name: 'Polo' },
|
|
49
|
+
{ pk: 2, base_code: 'B2', name: 'Shirt' }
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const ListReader = () => {
|
|
53
|
+
const list = useThemeSectionList();
|
|
54
|
+
return (
|
|
55
|
+
<span data-testid="list">
|
|
56
|
+
{list ? `${list.listId}|${list.listName}` : 'none'}
|
|
57
|
+
</span>
|
|
58
|
+
);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const lastCall = (mock: jest.Mock) =>
|
|
62
|
+
mock.mock.calls[mock.mock.calls.length - 1][0];
|
|
63
|
+
|
|
64
|
+
describe('SectionWrapper list reporting (ZERO-5305)', () => {
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
seen.mockClear();
|
|
67
|
+
impression.mockClear();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('reports the section seen with its product count', () => {
|
|
71
|
+
render(
|
|
72
|
+
<SectionWrapper section={section} placeholderId="ph" productCount={2}>
|
|
73
|
+
<span />
|
|
74
|
+
</SectionWrapper>
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
expect(lastCall(seen)).toMatchObject({
|
|
78
|
+
section,
|
|
79
|
+
placeholderId: 'ph',
|
|
80
|
+
productCount: 2
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('lists its rows under its list identity, from its own element', () => {
|
|
85
|
+
const { container } = render(
|
|
86
|
+
<SectionWrapper
|
|
87
|
+
section={section}
|
|
88
|
+
placeholderId="ph"
|
|
89
|
+
listProducts={ROWS}
|
|
90
|
+
listId="datasource-7"
|
|
91
|
+
listName="Picks Slider"
|
|
92
|
+
>
|
|
93
|
+
<span />
|
|
94
|
+
</SectionWrapper>
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const call = lastCall(impression);
|
|
98
|
+
expect(call).toMatchObject({
|
|
99
|
+
products: ROWS,
|
|
100
|
+
listId: 'datasource-7',
|
|
101
|
+
listName: 'Picks Slider',
|
|
102
|
+
enabled: true
|
|
103
|
+
});
|
|
104
|
+
expect(call.ref.current).toBe(
|
|
105
|
+
container.querySelector('[data-section-id="sec-1"]')
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('tells its cards which list they are in', () => {
|
|
110
|
+
render(
|
|
111
|
+
<SectionWrapper
|
|
112
|
+
section={section}
|
|
113
|
+
placeholderId="ph"
|
|
114
|
+
listId="datasource-7"
|
|
115
|
+
listName="Picks Slider"
|
|
116
|
+
>
|
|
117
|
+
<ListReader />
|
|
118
|
+
</SectionWrapper>
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
expect(screen.getByTestId('list')).toHaveTextContent(
|
|
122
|
+
'datasource-7|Picks Slider'
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('lists nothing when handed nothing, and nothing in the designer', () => {
|
|
127
|
+
render(
|
|
128
|
+
<SectionWrapper section={section} placeholderId="ph">
|
|
129
|
+
<span />
|
|
130
|
+
</SectionWrapper>
|
|
131
|
+
);
|
|
132
|
+
expect(lastCall(impression)).toMatchObject({ products: null, listId: '' });
|
|
133
|
+
|
|
134
|
+
render(
|
|
135
|
+
<SectionWrapper
|
|
136
|
+
section={section}
|
|
137
|
+
placeholderId="ph"
|
|
138
|
+
isDesigner
|
|
139
|
+
listProducts={ROWS}
|
|
140
|
+
listId="datasource-7"
|
|
141
|
+
listName="Picks Slider"
|
|
142
|
+
>
|
|
143
|
+
<span />
|
|
144
|
+
</SectionWrapper>
|
|
145
|
+
);
|
|
146
|
+
expect(lastCall(impression)).toMatchObject({ enabled: false });
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -53,6 +53,12 @@ import {
|
|
|
53
53
|
resolveSliderRowProduct
|
|
54
54
|
} from '../blocks/item-template-utils';
|
|
55
55
|
import type { ThemeProduct } from '../blocks/product-block-utils';
|
|
56
|
+
import { useThemeSectionList } from '../theme-section-data-context';
|
|
57
|
+
import {
|
|
58
|
+
buildCommerceItem,
|
|
59
|
+
emitCommerceEvent,
|
|
60
|
+
readItemsCurrency
|
|
61
|
+
} from '../utils/analytics';
|
|
56
62
|
|
|
57
63
|
import { postToEditor } from '../editor-bridge';
|
|
58
64
|
import { PreviewToEditorMessageType } from '@akinon/theme-contract';
|
|
@@ -391,7 +397,9 @@ const PostsSliderSection: React.FC<PostsSliderSectionProps> = ({
|
|
|
391
397
|
// products AND the data source actually returned one for this index.
|
|
392
398
|
// `products[index] || {}` above makes every clone truthy, so quick
|
|
393
399
|
// view cannot gate on the clone — it gates on this.
|
|
394
|
-
__qvProduct: resolveSliderRowProduct(section?.type, products[index])
|
|
400
|
+
__qvProduct: resolveSliderRowProduct(section?.type, products[index]),
|
|
401
|
+
// Its place in the list, for the position a card click reports.
|
|
402
|
+
__qvIndex: index
|
|
395
403
|
};
|
|
396
404
|
});
|
|
397
405
|
});
|
|
@@ -708,6 +716,8 @@ const PostsSliderSection: React.FC<PostsSliderSectionProps> = ({
|
|
|
708
716
|
);
|
|
709
717
|
const quickViewOpenerRef = React.useRef<HTMLElement | null>(null);
|
|
710
718
|
const bandRef = React.useRef<HTMLDivElement | null>(null);
|
|
719
|
+
// The list these slides are, as `SectionWrapper` publishes it (ZERO-5305).
|
|
720
|
+
const sectionList = useThemeSectionList();
|
|
711
721
|
|
|
712
722
|
const showQuickView =
|
|
713
723
|
getResponsiveValue(
|
|
@@ -1092,10 +1102,60 @@ const PostsSliderSection: React.FC<PostsSliderSectionProps> = ({
|
|
|
1092
1102
|
}
|
|
1093
1103
|
: null;
|
|
1094
1104
|
|
|
1105
|
+
/**
|
|
1106
|
+
* GA4 select_item (ZERO-5305): a click on a product slide's link
|
|
1107
|
+
* is a pick from this list, reported with the list and the card's
|
|
1108
|
+
* place in it — the identity the row's view_item_list carries.
|
|
1109
|
+
* Runs ahead of the quick view, which may stop the click; a
|
|
1110
|
+
* control dropped into the card owns its own click, and a slide
|
|
1111
|
+
* with no product row (a post, an empty designer pad) is silent.
|
|
1112
|
+
*/
|
|
1113
|
+
const reportSelectItem =
|
|
1114
|
+
!isDesigner && block.__qvProduct
|
|
1115
|
+
? (event: React.MouseEvent<HTMLDivElement>) => {
|
|
1116
|
+
const target = event.target as HTMLElement | null;
|
|
1117
|
+
if (!target?.closest?.('a[href]')) return;
|
|
1118
|
+
if (target.closest(INNER_CONTROL_SELECTOR)) return;
|
|
1119
|
+
|
|
1120
|
+
const product = block.__qvProduct as Record<
|
|
1121
|
+
string,
|
|
1122
|
+
unknown
|
|
1123
|
+
>;
|
|
1124
|
+
const item = buildCommerceItem(product, {
|
|
1125
|
+
...(typeof block.__qvIndex === 'number'
|
|
1126
|
+
? { index: block.__qvIndex }
|
|
1127
|
+
: null),
|
|
1128
|
+
...(sectionList?.listId
|
|
1129
|
+
? { listId: sectionList.listId }
|
|
1130
|
+
: null),
|
|
1131
|
+
...(sectionList?.listName
|
|
1132
|
+
? { listName: sectionList.listName }
|
|
1133
|
+
: null)
|
|
1134
|
+
});
|
|
1135
|
+
if (!item) return;
|
|
1136
|
+
emitCommerceEvent('select_item', {
|
|
1137
|
+
currency: readItemsCurrency(product),
|
|
1138
|
+
items: [item]
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
: null;
|
|
1142
|
+
|
|
1143
|
+
const slideClickHandlers =
|
|
1144
|
+
reportSelectItem || quickViewHandlers
|
|
1145
|
+
? {
|
|
1146
|
+
onClickCapture: (
|
|
1147
|
+
event: React.MouseEvent<HTMLDivElement>
|
|
1148
|
+
) => {
|
|
1149
|
+
reportSelectItem?.(event);
|
|
1150
|
+
quickViewHandlers?.onClickCapture(event);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
: null;
|
|
1154
|
+
|
|
1095
1155
|
return (
|
|
1096
1156
|
<div
|
|
1097
1157
|
key={block.id}
|
|
1098
|
-
{...
|
|
1158
|
+
{...slideClickHandlers}
|
|
1099
1159
|
style={
|
|
1100
1160
|
flushEdges
|
|
1101
1161
|
? {
|
|
@@ -10,6 +10,8 @@ import { SECTION_CONTENT_ATTRIBUTE } from '../utils/section-content-width';
|
|
|
10
10
|
import { isTrustedEditorEvent, postToEditor } from '../editor-bridge';
|
|
11
11
|
import { resolveSectionPartName } from '../hooks/use-designer-features';
|
|
12
12
|
import { useSectionSeen } from '../hooks/use-section-seen';
|
|
13
|
+
import { useListImpression } from '../hooks/use-list-impression';
|
|
14
|
+
import { ThemeSectionListProvider } from '../theme-section-data-context';
|
|
13
15
|
|
|
14
16
|
|
|
15
17
|
interface SectionWrapperProps {
|
|
@@ -24,6 +26,17 @@ interface SectionWrapperProps {
|
|
|
24
26
|
onToggleVisibility?: () => void;
|
|
25
27
|
onDelete?: () => void;
|
|
26
28
|
onRename?: (newLabel: string) => void;
|
|
29
|
+
/**
|
|
30
|
+
* The list this section's product rows are (ZERO-5305), worked out by
|
|
31
|
+
* `ThemeSection` as for its generic branch: a product slider renders
|
|
32
|
+
* through this wrapper, and its rows are a list as a carousel's are.
|
|
33
|
+
* `listProducts` is null when nothing here reports an impression (no
|
|
34
|
+
* collection, or a grid inside that reports its own).
|
|
35
|
+
*/
|
|
36
|
+
productCount?: number;
|
|
37
|
+
listProducts?: ReadonlyArray<Record<string, unknown>> | null;
|
|
38
|
+
listId?: string;
|
|
39
|
+
listName?: string;
|
|
27
40
|
children: React.ReactNode;
|
|
28
41
|
}
|
|
29
42
|
|
|
@@ -39,6 +52,10 @@ export default function SectionWrapper({
|
|
|
39
52
|
onToggleVisibility,
|
|
40
53
|
onDelete,
|
|
41
54
|
onRename,
|
|
55
|
+
productCount,
|
|
56
|
+
listProducts = null,
|
|
57
|
+
listId = '',
|
|
58
|
+
listName = '',
|
|
42
59
|
children
|
|
43
60
|
}: SectionWrapperProps) {
|
|
44
61
|
const readSectionString = (value: unknown): string => {
|
|
@@ -70,7 +87,18 @@ export default function SectionWrapper({
|
|
|
70
87
|
// the emitter reads the editor handshake flag itself, so there is no
|
|
71
88
|
// `isDesigner` check here — that prop goes false in browse mode while the
|
|
72
89
|
// editor is still attached.
|
|
73
|
-
useSectionSeen({ ref: sectionRef, section, placeholderId });
|
|
90
|
+
useSectionSeen({ ref: sectionRef, section, placeholderId, productCount });
|
|
91
|
+
|
|
92
|
+
// The section's product rows, reported as one view_item_list when it comes
|
|
93
|
+
// into view — the generic branch's own call never runs for a section drawn
|
|
94
|
+
// here, its ref being left unattached.
|
|
95
|
+
useListImpression({
|
|
96
|
+
ref: sectionRef,
|
|
97
|
+
products: listProducts,
|
|
98
|
+
listId,
|
|
99
|
+
listName,
|
|
100
|
+
enabled: !isDesigner
|
|
101
|
+
});
|
|
74
102
|
|
|
75
103
|
// Custom-renderer sections render through this wrapper instead of the
|
|
76
104
|
// default <section> in theme-section, so the editor's SCROLL_TO_SECTION
|
|
@@ -193,7 +221,9 @@ export default function SectionWrapper({
|
|
|
193
221
|
{...{ [SECTION_CONTENT_ATTRIBUTE]: '' }}
|
|
194
222
|
className={isDesigner ? 'relative z-10' : 'relative'}
|
|
195
223
|
>
|
|
196
|
-
{
|
|
224
|
+
<ThemeSectionListProvider listId={listId} listName={listName}>
|
|
225
|
+
{children}
|
|
226
|
+
</ThemeSectionListProvider>
|
|
197
227
|
</div>
|
|
198
228
|
</section>
|
|
199
229
|
);
|
package/src/theme-block.tsx
CHANGED
|
@@ -62,6 +62,11 @@ export interface Block {
|
|
|
62
62
|
* product scope the row's favourite / add-to-basket resolve against.
|
|
63
63
|
*/
|
|
64
64
|
itemScopeProduct?: ThemeProduct | null;
|
|
65
|
+
/**
|
|
66
|
+
* The row's place in its list (0-based), beside `itemScopeProduct`: the
|
|
67
|
+
* position a product card's click reports (ZERO-5305). Never authored.
|
|
68
|
+
*/
|
|
69
|
+
itemScopeIndex?: number;
|
|
65
70
|
iteratorDataPath?: string;
|
|
66
71
|
/**
|
|
67
72
|
* Written by the property-switch pass (utils/property-switches.ts), never
|
|
@@ -247,6 +252,7 @@ export default function ThemeBlock({
|
|
|
247
252
|
product={
|
|
248
253
|
block.itemScopeProduct ?? (isDesigner ? DESIGNER_SAMPLE_PRODUCT : null)
|
|
249
254
|
}
|
|
255
|
+
index={block.itemScopeIndex}
|
|
250
256
|
>
|
|
251
257
|
{rendered}
|
|
252
258
|
</ThemeItemScopeProvider>
|
|
@@ -30,3 +30,36 @@ export const ThemeSectionDataProvider = ({
|
|
|
30
30
|
};
|
|
31
31
|
|
|
32
32
|
export const useThemeSectionData = () => useContext(ThemeSectionDataContext);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The list a section's product rows are, for the commerce facts a row reports
|
|
36
|
+
* (ZERO-5305): a click on a card in a "New Arrivals" carousel names that
|
|
37
|
+
* carousel as the list, not the product's own catalogue group. The id is the
|
|
38
|
+
* section's data source (the collection), else the section; the name is the
|
|
39
|
+
* section's label — what the merchant calls it in the editor's layer tree.
|
|
40
|
+
*/
|
|
41
|
+
export interface ThemeSectionListValue {
|
|
42
|
+
listId: string;
|
|
43
|
+
listName: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const ThemeSectionListContext = createContext<ThemeSectionListValue | null>(
|
|
47
|
+
null
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
export const ThemeSectionListProvider = ({
|
|
51
|
+
listId,
|
|
52
|
+
listName,
|
|
53
|
+
children
|
|
54
|
+
}: PropsWithChildren<ThemeSectionListValue>) => {
|
|
55
|
+
const value = useMemo(() => ({ listId, listName }), [listId, listName]);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<ThemeSectionListContext.Provider value={value}>
|
|
59
|
+
{children}
|
|
60
|
+
</ThemeSectionListContext.Provider>
|
|
61
|
+
);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** The enclosing section's list identity; null outside a section. */
|
|
65
|
+
export const useThemeSectionList = () => useContext(ThemeSectionListContext);
|
package/src/theme-section.tsx
CHANGED
|
@@ -27,7 +27,12 @@ import { DesignerPlaceholder } from './blocks/product-block-utils';
|
|
|
27
27
|
import { CustomSectionHost } from './custom/section-host';
|
|
28
28
|
import { getCustomSectionDefinition } from './custom/section-definitions';
|
|
29
29
|
import { isCustomThemeType } from './custom/types';
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
ThemeSectionDataProvider,
|
|
32
|
+
ThemeSectionListProvider
|
|
33
|
+
} from './theme-section-data-context';
|
|
34
|
+
import { useListImpression } from './hooks/use-list-impression';
|
|
35
|
+
import { reportsOwnImpressions } from './utils/section-impressions';
|
|
31
36
|
import { PreviewToEditorMessageType } from '@akinon/theme-contract';
|
|
32
37
|
import { useSectionSeen } from './hooks/use-section-seen';
|
|
33
38
|
export interface Section {
|
|
@@ -52,6 +57,15 @@ export interface Section {
|
|
|
52
57
|
dataSourceSaved?: boolean;
|
|
53
58
|
}
|
|
54
59
|
|
|
60
|
+
/** How many products the section's collection holds; 0 without one. */
|
|
61
|
+
const sectionProductCount = (section: Section): number => {
|
|
62
|
+
const collection = section.dataSource?.details?.collection;
|
|
63
|
+
const rows = collection?.data ?? collection?.products;
|
|
64
|
+
if (Array.isArray(rows)) return rows.length;
|
|
65
|
+
if (Array.isArray(rows?.products)) return rows.products.length;
|
|
66
|
+
return 0;
|
|
67
|
+
};
|
|
68
|
+
|
|
55
69
|
interface ThemeSectionProps {
|
|
56
70
|
section: Section;
|
|
57
71
|
placeholderId: string;
|
|
@@ -141,7 +155,21 @@ export default function ThemeSection({
|
|
|
141
155
|
// Report this section becoming visible (ZERO-5164). The generic branch
|
|
142
156
|
// below renders its own <section>; sections that go through SectionWrapper
|
|
143
157
|
// are reported there, so exactly one of the two runs per section.
|
|
144
|
-
|
|
158
|
+
// The list this section's product rows are (ZERO-5305): the collection it
|
|
159
|
+
// is bound to, else the section; named after the section's label.
|
|
160
|
+
const sectionListId = String(
|
|
161
|
+
effectiveSection.dataSourceId || effectiveSection.id || ''
|
|
162
|
+
);
|
|
163
|
+
const sectionListName = String(
|
|
164
|
+
effectiveSection.label || effectiveSection.name || ''
|
|
165
|
+
).trim();
|
|
166
|
+
|
|
167
|
+
useSectionSeen({
|
|
168
|
+
ref: sectionRef,
|
|
169
|
+
section: effectiveSection,
|
|
170
|
+
placeholderId,
|
|
171
|
+
productCount: sectionProductCount(effectiveSection)
|
|
172
|
+
});
|
|
145
173
|
|
|
146
174
|
const constrainsContent = hasSectionContentWidth(effectiveSection.styles);
|
|
147
175
|
|
|
@@ -249,6 +277,32 @@ export default function ThemeSection({
|
|
|
249
277
|
[effectiveSection.dataSource, isDesigner]
|
|
250
278
|
);
|
|
251
279
|
|
|
280
|
+
// A section bound to a collection lists its products (a carousel, a strip):
|
|
281
|
+
// reported as one view_item_list when it comes into view. A section with a
|
|
282
|
+
// block that reports its products itself (the listing or favourites grid, a
|
|
283
|
+
// recommendations strip drawing the section's rows) is left to it.
|
|
284
|
+
// A section drawn through `SectionWrapper` reports there instead: this ref
|
|
285
|
+
// is attached only by the generic branch's own <section>.
|
|
286
|
+
const listProducts = reportsOwnImpressions(effectiveSection.blocks)
|
|
287
|
+
? null
|
|
288
|
+
: sectionDataItems;
|
|
289
|
+
useListImpression({
|
|
290
|
+
ref: sectionRef,
|
|
291
|
+
products: listProducts,
|
|
292
|
+
listId: sectionListId,
|
|
293
|
+
listName: sectionListName,
|
|
294
|
+
enabled: !isDesigner
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// What `SectionWrapper` needs to report this section as the generic branch
|
|
298
|
+
// does: seen with its product count, listed under its list identity.
|
|
299
|
+
const wrapperListProps = {
|
|
300
|
+
productCount: sectionProductCount(effectiveSection),
|
|
301
|
+
listProducts,
|
|
302
|
+
listId: sectionListId,
|
|
303
|
+
listName: sectionListName
|
|
304
|
+
};
|
|
305
|
+
|
|
252
306
|
const getIteratorRenderMeta = (iteratorBlock: Block) => {
|
|
253
307
|
const dataPath =
|
|
254
308
|
(iteratorBlock.iteratorDataPath as string | undefined) ||
|
|
@@ -598,6 +652,7 @@ export default function ThemeSection({
|
|
|
598
652
|
onToggleVisibility={onToggleVisibility}
|
|
599
653
|
onDelete={onDelete}
|
|
600
654
|
onRename={onRename}
|
|
655
|
+
{...wrapperListProps}
|
|
601
656
|
>
|
|
602
657
|
<CustomSectionHost
|
|
603
658
|
type={effectiveSection.type}
|
|
@@ -660,6 +715,7 @@ export default function ThemeSection({
|
|
|
660
715
|
onToggleVisibility={onToggleVisibility}
|
|
661
716
|
onDelete={onDelete}
|
|
662
717
|
onRename={onRename}
|
|
718
|
+
{...wrapperListProps}
|
|
663
719
|
>
|
|
664
720
|
<CustomSectionRenderer
|
|
665
721
|
section={effectiveSection}
|
|
@@ -1141,6 +1197,7 @@ export default function ThemeSection({
|
|
|
1141
1197
|
|
|
1142
1198
|
return (
|
|
1143
1199
|
<ThemeSectionDataProvider items={sectionDataItems}>
|
|
1200
|
+
<ThemeSectionListProvider listId={sectionListId} listName={sectionListName}>
|
|
1144
1201
|
<section
|
|
1145
1202
|
ref={sectionRef}
|
|
1146
1203
|
data-section-id={effectiveSection.id}
|
|
@@ -1473,6 +1530,7 @@ export default function ThemeSection({
|
|
|
1473
1530
|
})}
|
|
1474
1531
|
</div>
|
|
1475
1532
|
</section>
|
|
1533
|
+
</ThemeSectionListProvider>
|
|
1476
1534
|
</ThemeSectionDataProvider>
|
|
1477
1535
|
);
|
|
1478
1536
|
}
|