@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +15 -50
  2. package/package.json +4 -4
  3. package/src/__tests__/commerce-channel-frozen.test.ts +12 -1
  4. package/src/__tests__/editor-locale-bridge.test.tsx +1 -32
  5. package/src/__tests__/section-list-identity.test.tsx +113 -0
  6. package/src/blocks/__tests__/favourites-grid-impression.test.tsx +217 -0
  7. package/src/blocks/__tests__/group-block-list-click.test.tsx +90 -0
  8. package/src/blocks/__tests__/text-block.test.tsx +6 -34
  9. package/src/blocks/favourites-grid-block.tsx +16 -18
  10. package/src/blocks/group-block.tsx +14 -2
  11. package/src/blocks/item-template.tsx +1 -0
  12. package/src/blocks/order-detail-block.tsx +95 -113
  13. package/src/blocks/product-block-utils.tsx +5 -1
  14. package/src/blocks/product-recommendations-block.tsx +45 -13
  15. package/src/blocks/text-block.tsx +2 -6
  16. package/src/custom/README.md +219 -100
  17. package/src/custom/__tests__/use-theme-setting.test.tsx +0 -97
  18. package/src/custom/collect.ts +9 -4
  19. package/src/custom/property-types.ts +15 -15
  20. package/src/custom/use-theme-setting.ts +5 -21
  21. package/src/editor-locale-bridge.tsx +11 -39
  22. package/src/hooks/__tests__/use-list-impression.test.tsx +228 -0
  23. package/src/hooks/__tests__/use-section-seen.test.tsx +55 -2
  24. package/src/hooks/use-list-impression.ts +116 -0
  25. package/src/hooks/use-section-seen.ts +22 -2
  26. package/src/sections/__tests__/posts-slider-select-item.test.tsx +205 -0
  27. package/src/sections/__tests__/section-wrapper-list.test.tsx +148 -0
  28. package/src/sections/posts-slider-section.tsx +62 -2
  29. package/src/sections/section-wrapper.tsx +32 -2
  30. package/src/theme-block.tsx +6 -0
  31. package/src/theme-placeholder-wrapper.tsx +0 -3
  32. package/src/theme-section-data-context.tsx +33 -0
  33. package/src/theme-section.tsx +60 -2
  34. package/src/utils/__tests__/section-impressions.test.ts +68 -0
  35. package/src/utils/iterator-price-shapes.test.ts +13 -0
  36. package/src/utils/iterator-utils.ts +2 -1
  37. package/src/utils/section-impressions.ts +37 -0
  38. package/src/blocks/__tests__/order-line-actions.test.tsx +0 -54
  39. package/src/blocks/order-line-actions.ts +0 -45
  40. package/src/utils/__tests__/sanitize-rich-text.test.ts +0 -80
  41. 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
- {...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>
@@ -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);