@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.
@@ -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
- {...quickViewHandlers}
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
- {children}
224
+ <ThemeSectionListProvider listId={listId} listName={listName}>
225
+ {children}
226
+ </ThemeSectionListProvider>
197
227
  </div>
198
228
  </section>
199
229
  );
@@ -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);
@@ -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 { ThemeSectionDataProvider } from './theme-section-data-context';
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
- useSectionSeen({ ref: sectionRef, section: effectiveSection, placeholderId });
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
  }