@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
|
@@ -11,7 +11,12 @@ import {
|
|
|
11
11
|
} from '@akinon/next/data/client/product';
|
|
12
12
|
import { useLocalization } from '@akinon/next/hooks';
|
|
13
13
|
import { useOverlay } from '../hooks/use-overlay';
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
ThemeSectionListProvider,
|
|
16
|
+
useThemeSectionData,
|
|
17
|
+
useThemeSectionList
|
|
18
|
+
} from '../theme-section-data-context';
|
|
19
|
+
import { useListImpression } from '../hooks/use-list-impression';
|
|
15
20
|
import { getResponsiveValue, partAttrs } from '../utils';
|
|
16
21
|
import {
|
|
17
22
|
buildCommerceItem,
|
|
@@ -285,6 +290,7 @@ const ProductRecommendationsBlock = ({
|
|
|
285
290
|
}: BlockRendererProps) => {
|
|
286
291
|
const { locale, currency: localeCurrency } = useLocalization();
|
|
287
292
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
293
|
+
const sectionList = useThemeSectionList();
|
|
288
294
|
const trackRef = useRef<HTMLDivElement>(null);
|
|
289
295
|
// The ambient product: on a PDP this is what the shopper is looking at, and
|
|
290
296
|
// it is both what gets recorded and what is excluded from its own strip.
|
|
@@ -863,6 +869,25 @@ const ProductRecommendationsBlock = ({
|
|
|
863
869
|
? favouriteCards
|
|
864
870
|
: collectionProducts;
|
|
865
871
|
|
|
872
|
+
// The list this strip is (ZERO-5305): named after its title ("You may also
|
|
873
|
+
// like"), else the section's label; identified by the
|
|
874
|
+
// collection it shows, else the section.
|
|
875
|
+
const listIdentity = {
|
|
876
|
+
listId:
|
|
877
|
+
(!isRecentlyViewed && !isFavourites && !isSectionSource && collectionId) ||
|
|
878
|
+
sectionList?.listId ||
|
|
879
|
+
'product_recommendations',
|
|
880
|
+
listName: title || sectionList?.listName || 'Product Recommendations'
|
|
881
|
+
};
|
|
882
|
+
|
|
883
|
+
useListImpression({
|
|
884
|
+
ref: rootRef,
|
|
885
|
+
products: products as unknown as Record<string, unknown>[],
|
|
886
|
+
listId: listIdentity.listId,
|
|
887
|
+
listName: listIdentity.listName,
|
|
888
|
+
enabled: !isDesigner
|
|
889
|
+
});
|
|
890
|
+
|
|
866
891
|
/**
|
|
867
892
|
* The designer has no browsing history behind the canvas, so a recently
|
|
868
893
|
* viewed strip would collapse to nothing and stop being selectable, sizeable
|
|
@@ -891,17 +916,24 @@ const ProductRecommendationsBlock = ({
|
|
|
891
916
|
|
|
892
917
|
// A designer who marked a child as the card layout gets that layout repeated
|
|
893
918
|
// per product; otherwise the built-in card renders unchanged.
|
|
919
|
+
// The strip's own list identity rides over its cards, so an authored card's
|
|
920
|
+
// click reports this strip — not the section around it — as its list.
|
|
894
921
|
const customCards = hasItemTemplate(block, currentBreakpoint) ? (
|
|
895
|
-
<
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
922
|
+
<ThemeSectionListProvider
|
|
923
|
+
listId={listIdentity.listId}
|
|
924
|
+
listName={listIdentity.listName}
|
|
925
|
+
>
|
|
926
|
+
<ItemTemplateList
|
|
927
|
+
block={block}
|
|
928
|
+
items={products as unknown as Record<string, unknown>[]}
|
|
929
|
+
placeholderId={placeholderId}
|
|
930
|
+
sectionId={sectionId}
|
|
931
|
+
isDesigner={isDesigner}
|
|
932
|
+
selectedBlockId={selectedBlockId}
|
|
933
|
+
currentBreakpoint={currentBreakpoint}
|
|
934
|
+
designerPlaceholderCount={4}
|
|
935
|
+
/>
|
|
936
|
+
</ThemeSectionListProvider>
|
|
905
937
|
) : null;
|
|
906
938
|
|
|
907
939
|
// An empty history is the normal state on a first visit, so the strip
|
|
@@ -1294,8 +1326,8 @@ const ProductRecommendationsBlock = ({
|
|
|
1294
1326
|
buildCommerceItem(
|
|
1295
1327
|
product as unknown as Record<string, unknown>,
|
|
1296
1328
|
{
|
|
1297
|
-
|
|
1298
|
-
|
|
1329
|
+
...listIdentity,
|
|
1330
|
+
index: products.indexOf(product)
|
|
1299
1331
|
}
|
|
1300
1332
|
)
|
|
1301
1333
|
]
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import React, { useRef } from 'react';
|
|
2
|
+
import { act, render } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
let pathname = '/';
|
|
5
|
+
jest.mock('next/navigation', () => ({
|
|
6
|
+
usePathname: () => pathname
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
LIST_IMPRESSION_DWELL_MS,
|
|
11
|
+
useListImpression
|
|
12
|
+
} from '../use-list-impression';
|
|
13
|
+
import { COMMERCE_EVENT_NAME } from '../../utils/analytics';
|
|
14
|
+
|
|
15
|
+
declare global {
|
|
16
|
+
interface Window {
|
|
17
|
+
__themeEditorDesigner?: boolean;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A product row that is not a listing grid — a collection carousel, a
|
|
23
|
+
* recommendations strip — reports its products as one view_item_list, named
|
|
24
|
+
* and identified as the list it is (ZERO-5305).
|
|
25
|
+
*
|
|
26
|
+
* The package's jsdom setup fills IntersectionObserver with a no-op, so every
|
|
27
|
+
* case is driven through this recording stand-in (as use-section-seen does).
|
|
28
|
+
*/
|
|
29
|
+
class RecordingObserver {
|
|
30
|
+
static instances: RecordingObserver[] = [];
|
|
31
|
+
|
|
32
|
+
callback: IntersectionObserverCallback;
|
|
33
|
+
options: IntersectionObserverInit | undefined;
|
|
34
|
+
observed: Element[] = [];
|
|
35
|
+
disconnected = false;
|
|
36
|
+
|
|
37
|
+
constructor(
|
|
38
|
+
callback: IntersectionObserverCallback,
|
|
39
|
+
options?: IntersectionObserverInit
|
|
40
|
+
) {
|
|
41
|
+
this.callback = callback;
|
|
42
|
+
this.options = options;
|
|
43
|
+
RecordingObserver.instances.push(this);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
observe(node: Element) {
|
|
47
|
+
this.observed.push(node);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
unobserve() {}
|
|
51
|
+
|
|
52
|
+
disconnect() {
|
|
53
|
+
this.disconnected = true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
takeRecords() {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
cross(isIntersecting: boolean) {
|
|
61
|
+
act(() => {
|
|
62
|
+
this.callback(
|
|
63
|
+
[
|
|
64
|
+
{
|
|
65
|
+
isIntersecting,
|
|
66
|
+
intersectionRatio: isIntersecting ? 1 : 0,
|
|
67
|
+
target: this.observed[0]
|
|
68
|
+
} as unknown as IntersectionObserverEntry
|
|
69
|
+
],
|
|
70
|
+
this as unknown as IntersectionObserver
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const latest = () =>
|
|
77
|
+
RecordingObserver.instances[RecordingObserver.instances.length - 1];
|
|
78
|
+
|
|
79
|
+
const PRODUCTS = [
|
|
80
|
+
{ pk: 1, base_code: 'B1', name: 'Polo', price: '1499' },
|
|
81
|
+
{ pk: 2, base_code: 'B2', name: 'Shirt', price: '2499' }
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
const Row = ({
|
|
85
|
+
products = PRODUCTS,
|
|
86
|
+
enabled = true,
|
|
87
|
+
refreshKey,
|
|
88
|
+
currency
|
|
89
|
+
}: {
|
|
90
|
+
products?: Record<string, unknown>[] | null;
|
|
91
|
+
enabled?: boolean;
|
|
92
|
+
refreshKey?: string;
|
|
93
|
+
currency?: string;
|
|
94
|
+
}) => {
|
|
95
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
96
|
+
useListImpression({
|
|
97
|
+
ref,
|
|
98
|
+
products,
|
|
99
|
+
listId: 'collection-12',
|
|
100
|
+
listName: 'New Arrivals',
|
|
101
|
+
enabled,
|
|
102
|
+
refreshKey,
|
|
103
|
+
currency
|
|
104
|
+
});
|
|
105
|
+
return <div ref={ref} />;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const tick = (ms: number) =>
|
|
109
|
+
act(() => {
|
|
110
|
+
jest.advanceTimersByTime(ms);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('useListImpression (ZERO-5305)', () => {
|
|
114
|
+
let received: Array<Record<string, unknown>>;
|
|
115
|
+
let originalObserver: unknown;
|
|
116
|
+
const onCommerce = (event: Event) =>
|
|
117
|
+
received.push((event as CustomEvent<Record<string, unknown>>).detail);
|
|
118
|
+
|
|
119
|
+
beforeEach(() => {
|
|
120
|
+
jest.useFakeTimers();
|
|
121
|
+
pathname = '/';
|
|
122
|
+
received = [];
|
|
123
|
+
delete window.__themeEditorDesigner;
|
|
124
|
+
window.addEventListener(COMMERCE_EVENT_NAME, onCommerce);
|
|
125
|
+
RecordingObserver.instances = [];
|
|
126
|
+
originalObserver = (window as unknown as Record<string, unknown>)
|
|
127
|
+
.IntersectionObserver;
|
|
128
|
+
(window as unknown as Record<string, unknown>).IntersectionObserver =
|
|
129
|
+
RecordingObserver;
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
afterEach(() => {
|
|
133
|
+
window.removeEventListener(COMMERCE_EVENT_NAME, onCommerce);
|
|
134
|
+
(window as unknown as Record<string, unknown>).IntersectionObserver =
|
|
135
|
+
originalObserver;
|
|
136
|
+
delete window.__themeEditorDesigner;
|
|
137
|
+
jest.useRealTimers();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('reports the row as one list once it has been on screen for the dwell', () => {
|
|
141
|
+
render(<Row />);
|
|
142
|
+
|
|
143
|
+
latest().cross(true);
|
|
144
|
+
expect(received).toEqual([]);
|
|
145
|
+
tick(LIST_IMPRESSION_DWELL_MS);
|
|
146
|
+
|
|
147
|
+
expect(received).toHaveLength(1);
|
|
148
|
+
expect(received[0]).toMatchObject({ action: 'view_item_list' });
|
|
149
|
+
expect(received[0].items).toEqual([
|
|
150
|
+
expect.objectContaining({
|
|
151
|
+
baseCode: 'B1',
|
|
152
|
+
index: 0,
|
|
153
|
+
listId: 'collection-12',
|
|
154
|
+
listName: 'New Arrivals'
|
|
155
|
+
}),
|
|
156
|
+
expect.objectContaining({
|
|
157
|
+
baseCode: 'B2',
|
|
158
|
+
index: 1,
|
|
159
|
+
listId: 'collection-12',
|
|
160
|
+
listName: 'New Arrivals'
|
|
161
|
+
})
|
|
162
|
+
]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('reports once per route, however often the row comes back into view', () => {
|
|
166
|
+
render(<Row />);
|
|
167
|
+
|
|
168
|
+
latest().cross(true);
|
|
169
|
+
tick(LIST_IMPRESSION_DWELL_MS);
|
|
170
|
+
latest().cross(false);
|
|
171
|
+
latest().cross(true);
|
|
172
|
+
tick(LIST_IMPRESSION_DWELL_MS);
|
|
173
|
+
|
|
174
|
+
expect(received).toHaveLength(1);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('reports nothing for a glance shorter than the dwell', () => {
|
|
178
|
+
render(<Row />);
|
|
179
|
+
|
|
180
|
+
latest().cross(true);
|
|
181
|
+
tick(LIST_IMPRESSION_DWELL_MS - 100);
|
|
182
|
+
latest().cross(false);
|
|
183
|
+
tick(LIST_IMPRESSION_DWELL_MS);
|
|
184
|
+
|
|
185
|
+
expect(received).toEqual([]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('watches nothing while there is nothing to list, or while disabled', () => {
|
|
189
|
+
render(<Row products={[]} />);
|
|
190
|
+
render(<Row products={null} />);
|
|
191
|
+
render(<Row enabled={false} />);
|
|
192
|
+
|
|
193
|
+
expect(RecordingObserver.instances).toHaveLength(0);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('reports again when a list keyed by its content changes', () => {
|
|
197
|
+
const { rerender } = render(<Row refreshKey="1,2" />);
|
|
198
|
+
latest().cross(true);
|
|
199
|
+
tick(LIST_IMPRESSION_DWELL_MS);
|
|
200
|
+
|
|
201
|
+
rerender(<Row products={[PRODUCTS[1]]} refreshKey="2" />);
|
|
202
|
+
latest().cross(true);
|
|
203
|
+
tick(LIST_IMPRESSION_DWELL_MS);
|
|
204
|
+
|
|
205
|
+
expect(received).toHaveLength(2);
|
|
206
|
+
expect(received[1].items).toEqual([
|
|
207
|
+
expect.objectContaining({ baseCode: 'B2', index: 0 })
|
|
208
|
+
]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('carries the currency the caller knows', () => {
|
|
212
|
+
render(<Row currency="try" />);
|
|
213
|
+
latest().cross(true);
|
|
214
|
+
tick(LIST_IMPRESSION_DWELL_MS);
|
|
215
|
+
|
|
216
|
+
expect(received[0]).toMatchObject({ currency: 'try' });
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('reports nothing inside the editor', () => {
|
|
220
|
+
window.__themeEditorDesigner = true;
|
|
221
|
+
render(<Row />);
|
|
222
|
+
|
|
223
|
+
latest().cross(true);
|
|
224
|
+
tick(LIST_IMPRESSION_DWELL_MS);
|
|
225
|
+
|
|
226
|
+
expect(received).toEqual([]);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
@@ -88,18 +88,26 @@ const DEFAULT_SECTION = { id: 'sec_hero', type: 'custom', order: 2 };
|
|
|
88
88
|
|
|
89
89
|
const Section = ({
|
|
90
90
|
placeholderId = 'home-body',
|
|
91
|
-
section = DEFAULT_SECTION
|
|
91
|
+
section = DEFAULT_SECTION,
|
|
92
|
+
productCount
|
|
92
93
|
}: {
|
|
93
94
|
placeholderId?: string;
|
|
94
95
|
section?: {
|
|
95
96
|
id: string;
|
|
96
97
|
type: string;
|
|
97
98
|
order?: number;
|
|
99
|
+
label?: string;
|
|
98
100
|
properties?: Record<string, unknown>;
|
|
99
101
|
};
|
|
102
|
+
productCount?: number;
|
|
100
103
|
}) => {
|
|
101
104
|
const ref = useRef<HTMLDivElement>(null);
|
|
102
|
-
useSectionSeen({
|
|
105
|
+
useSectionSeen({
|
|
106
|
+
ref,
|
|
107
|
+
section: section as never,
|
|
108
|
+
placeholderId,
|
|
109
|
+
productCount
|
|
110
|
+
});
|
|
103
111
|
|
|
104
112
|
return <div ref={ref} data-testid="section" />;
|
|
105
113
|
};
|
|
@@ -315,6 +323,51 @@ describe('section.seen (ZERO-5164)', () => {
|
|
|
315
323
|
});
|
|
316
324
|
});
|
|
317
325
|
|
|
326
|
+
describe('what the section is (ZERO-5305)', () => {
|
|
327
|
+
it('carries the label the merchant sees in the layer tree', () => {
|
|
328
|
+
render(
|
|
329
|
+
<Section
|
|
330
|
+
section={{ ...DEFAULT_SECTION, label: ' Summer Sale Hero ' }}
|
|
331
|
+
/>
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
latest().cross(true);
|
|
335
|
+
tick(SECTION_SEEN_DWELL_MS);
|
|
336
|
+
|
|
337
|
+
expect(received[0]).toMatchObject({ sectionLabel: 'Summer Sale Hero' });
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('caps a label at a display name’s length', () => {
|
|
341
|
+
render(
|
|
342
|
+
<Section section={{ ...DEFAULT_SECTION, label: 'x'.repeat(500) }} />
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
latest().cross(true);
|
|
346
|
+
tick(SECTION_SEEN_DWELL_MS);
|
|
347
|
+
|
|
348
|
+
expect(String(received[0].sectionLabel)).toHaveLength(120);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('says how many products the section lists, so a bridge can tell a row from a banner', () => {
|
|
352
|
+
render(<Section productCount={8} />);
|
|
353
|
+
|
|
354
|
+
latest().cross(true);
|
|
355
|
+
tick(SECTION_SEEN_DWELL_MS);
|
|
356
|
+
|
|
357
|
+
expect(received[0]).toMatchObject({ productCount: 8 });
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('carries neither for an unlabelled section without products', () => {
|
|
361
|
+
render(<Section productCount={0} />);
|
|
362
|
+
|
|
363
|
+
latest().cross(true);
|
|
364
|
+
tick(SECTION_SEEN_DWELL_MS);
|
|
365
|
+
|
|
366
|
+
expect(received[0]).not.toHaveProperty('sectionLabel');
|
|
367
|
+
expect(received[0]).not.toHaveProperty('productCount');
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
318
371
|
describe('what the merchant authored (ZERO-5222)', () => {
|
|
319
372
|
const tracked = (viewTracking: Record<string, unknown>) => ({
|
|
320
373
|
id: 'sec_hero',
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from 'react';
|
|
4
|
+
import { usePathname } from 'next/navigation';
|
|
5
|
+
|
|
6
|
+
import { buildCommerceItem, emitCommerceEvent } from '../utils/analytics';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* GA4 `view_item_list` for a product row that is not a listing grid (a
|
|
10
|
+
* collection carousel, a recommendations strip), ZERO-5305.
|
|
11
|
+
*
|
|
12
|
+
* The listing grid and the favourites grid already report their own
|
|
13
|
+
* impressions; a carousel built from a collection, or the recommendations a
|
|
14
|
+
* block fetches for itself, reported none, so a click on one of their cards
|
|
15
|
+
* (`select_item`) had no impression to belong to. Every list reports as the
|
|
16
|
+
* list it is: its name (the carousel's) and its id.
|
|
17
|
+
*
|
|
18
|
+
* Once per route, when a quarter of the row has been on screen for half a
|
|
19
|
+
* second — the products in the row's markup are its list, as a listing page's
|
|
20
|
+
* products are, whether or not a slide has scrolled each card into view yet.
|
|
21
|
+
* A row that is never on screen (laid out with `display: none`, say) never
|
|
22
|
+
* reports. Silent inside the editor (emitCommerceEvent checks), and silent
|
|
23
|
+
* with nothing to list.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export const LIST_IMPRESSION_DWELL_MS = 500;
|
|
27
|
+
|
|
28
|
+
const SEEN: IntersectionObserverInit = { threshold: 0.25 };
|
|
29
|
+
|
|
30
|
+
export interface UseListImpressionOptions {
|
|
31
|
+
ref: React.RefObject<HTMLElement>;
|
|
32
|
+
products: ReadonlyArray<Record<string, unknown>> | null | undefined;
|
|
33
|
+
listId: string;
|
|
34
|
+
listName: string;
|
|
35
|
+
/** False while the row has nothing real to list (loading, the designer). */
|
|
36
|
+
enabled?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* The list's content, for a list that reports again when it changes (the
|
|
39
|
+
* favourites grid after a removal); left out, once per route.
|
|
40
|
+
*/
|
|
41
|
+
refreshKey?: string;
|
|
42
|
+
/** The list's currency, when the caller knows it. */
|
|
43
|
+
currency?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const useListImpression = ({
|
|
47
|
+
ref,
|
|
48
|
+
products,
|
|
49
|
+
listId,
|
|
50
|
+
listName,
|
|
51
|
+
enabled = true,
|
|
52
|
+
refreshKey = '',
|
|
53
|
+
currency
|
|
54
|
+
}: UseListImpressionOptions): void => {
|
|
55
|
+
const pathname = usePathname();
|
|
56
|
+
const reportedFor = useRef<string | null>(null);
|
|
57
|
+
|
|
58
|
+
// The latest values, read when the observer fires rather than captured:
|
|
59
|
+
// the row's products arrive after the element mounts.
|
|
60
|
+
const latest = useRef({ products, listId, listName, currency });
|
|
61
|
+
latest.current = { products, listId, listName, currency };
|
|
62
|
+
|
|
63
|
+
const count = products?.length ?? 0;
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
const node = ref.current;
|
|
67
|
+
if (!node || !enabled || count === 0) return;
|
|
68
|
+
if (typeof IntersectionObserver === 'undefined') return;
|
|
69
|
+
|
|
70
|
+
const latchKey = `${pathname}:${listId}:${refreshKey}`;
|
|
71
|
+
if (reportedFor.current === latchKey) return;
|
|
72
|
+
|
|
73
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
74
|
+
|
|
75
|
+
const observer = new IntersectionObserver((entries) => {
|
|
76
|
+
const entry = entries[entries.length - 1];
|
|
77
|
+
if (!entry) return;
|
|
78
|
+
|
|
79
|
+
if (!entry.isIntersecting) {
|
|
80
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
81
|
+
timer = undefined;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (timer !== undefined) return;
|
|
86
|
+
timer = setTimeout(() => {
|
|
87
|
+
timer = undefined;
|
|
88
|
+
if (reportedFor.current === latchKey) return;
|
|
89
|
+
|
|
90
|
+
const current = latest.current;
|
|
91
|
+
const items = (current.products ?? []).map((product, index) =>
|
|
92
|
+
buildCommerceItem(product, {
|
|
93
|
+
index,
|
|
94
|
+
listId: current.listId,
|
|
95
|
+
listName: current.listName
|
|
96
|
+
})
|
|
97
|
+
);
|
|
98
|
+
if (items.every((item) => item === null)) return;
|
|
99
|
+
|
|
100
|
+
reportedFor.current = latchKey;
|
|
101
|
+
observer.disconnect();
|
|
102
|
+
emitCommerceEvent('view_item_list', {
|
|
103
|
+
...(current.currency ? { currency: current.currency } : null),
|
|
104
|
+
items
|
|
105
|
+
});
|
|
106
|
+
}, LIST_IMPRESSION_DWELL_MS);
|
|
107
|
+
}, SEEN);
|
|
108
|
+
|
|
109
|
+
observer.observe(node);
|
|
110
|
+
|
|
111
|
+
return () => {
|
|
112
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
113
|
+
observer.disconnect();
|
|
114
|
+
};
|
|
115
|
+
}, [ref, enabled, count, pathname, listId, refreshKey]);
|
|
116
|
+
};
|
|
@@ -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,
|