@akinon/pz-theme 2.0.163-rc.0 → 2.0.164-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 +15 -50
- package/package.json +4 -4
- package/src/__tests__/commerce-channel-frozen.test.ts +12 -1
- package/src/__tests__/editor-locale-bridge.test.tsx +1 -32
- 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/__tests__/text-block.test.tsx +6 -34
- 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/order-detail-block.tsx +95 -113
- package/src/blocks/product-block-utils.tsx +5 -1
- package/src/blocks/product-recommendations-block.tsx +45 -13
- package/src/blocks/text-block.tsx +2 -6
- package/src/custom/README.md +219 -100
- package/src/custom/__tests__/use-theme-setting.test.tsx +0 -97
- package/src/custom/collect.ts +9 -4
- package/src/custom/property-types.ts +15 -15
- package/src/custom/use-theme-setting.ts +5 -21
- package/src/editor-locale-bridge.tsx +11 -39
- 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-placeholder-wrapper.tsx +0 -3
- 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/src/blocks/__tests__/order-line-actions.test.tsx +0 -54
- package/src/blocks/order-line-actions.ts +0 -45
- package/src/utils/__tests__/sanitize-rich-text.test.ts +0 -80
- package/src/utils/sanitize-rich-text.ts +0 -215
|
@@ -60,14 +60,24 @@ const isFramed = (): boolean => {
|
|
|
60
60
|
|
|
61
61
|
export interface UseSectionSeenOptions {
|
|
62
62
|
ref: React.RefObject<HTMLElement>;
|
|
63
|
-
section: Pick<Section, 'id' | 'type' | 'order' | 'properties'
|
|
63
|
+
section: Pick<Section, 'id' | 'type' | 'order' | 'properties'> &
|
|
64
|
+
Partial<Pick<Section, 'label'>>;
|
|
64
65
|
placeholderId: string;
|
|
66
|
+
/**
|
|
67
|
+
* How many products the section lists (its collection's rows), so a bridge
|
|
68
|
+
* can tell a product row from a banner (ZERO-5305). 0 or absent: none.
|
|
69
|
+
*/
|
|
70
|
+
productCount?: number;
|
|
65
71
|
}
|
|
66
72
|
|
|
73
|
+
/** A label is a short display name; anything past this is not one. */
|
|
74
|
+
const MAX_SECTION_LABEL = 120;
|
|
75
|
+
|
|
67
76
|
export const useSectionSeen = ({
|
|
68
77
|
ref,
|
|
69
78
|
section,
|
|
70
|
-
placeholderId
|
|
79
|
+
placeholderId,
|
|
80
|
+
productCount = 0
|
|
71
81
|
}: UseSectionSeenOptions): void => {
|
|
72
82
|
const pathname = usePathname();
|
|
73
83
|
const signal = useThemeSignal({
|
|
@@ -90,6 +100,12 @@ export const useSectionSeen = ({
|
|
|
90
100
|
const reported = useRef<string | null>(null);
|
|
91
101
|
|
|
92
102
|
const order = section.order;
|
|
103
|
+
// The name the merchant sees and renames in the editor's layer tree: what a
|
|
104
|
+
// report shows a section as (ZERO-5305 — a promotion is read by its name).
|
|
105
|
+
const label =
|
|
106
|
+
typeof section.label === 'string'
|
|
107
|
+
? section.label.trim().slice(0, MAX_SECTION_LABEL)
|
|
108
|
+
: '';
|
|
93
109
|
const { name, purpose, seenWhen, dwellMs, frequency } =
|
|
94
110
|
normalizeSectionTracking(section.properties?.[SECTION_TRACKING_PROPERTY_KEY]);
|
|
95
111
|
|
|
@@ -146,6 +162,8 @@ export const useSectionSeen = ({
|
|
|
146
162
|
// so a merchant label sent under that key would vanish without a
|
|
147
163
|
// word. It also reads better beside `sectionId` and `sectionType`.
|
|
148
164
|
...(name ? { sectionName: name } : null),
|
|
165
|
+
...(label ? { sectionLabel: label } : null),
|
|
166
|
+
...(productCount > 0 ? { productCount } : null),
|
|
149
167
|
...(typeof order === 'number' && Number.isFinite(order)
|
|
150
168
|
? { order }
|
|
151
169
|
: null)
|
|
@@ -185,6 +203,8 @@ export const useSectionSeen = ({
|
|
|
185
203
|
order,
|
|
186
204
|
signal,
|
|
187
205
|
name,
|
|
206
|
+
label,
|
|
207
|
+
productCount,
|
|
188
208
|
purpose,
|
|
189
209
|
seenWhen,
|
|
190
210
|
dwellMs,
|
|
@@ -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>
|
|
@@ -1166,9 +1166,6 @@ export default function ThemePlaceholderWrapper({
|
|
|
1166
1166
|
Tells the editor which languages this storefront has and follows its
|
|
1167
1167
|
language switch. Every placeholder renders one; they share a single
|
|
1168
1168
|
listener and a single announcement (see editor-locale-bridge.tsx).
|
|
1169
|
-
A child of this wrapper on purpose: its effect, and so its frame,
|
|
1170
|
-
runs before the effect below that posts IFRAME_READY, and the editor
|
|
1171
|
-
counts on hearing the languages before the ready report.
|
|
1172
1169
|
*/}
|
|
1173
1170
|
<EditorLocaleBridge />
|
|
1174
1171
|
{themeBackgroundCSS && (
|
|
@@ -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);
|