@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
|
@@ -33,20 +33,19 @@ export type PropertyType = (typeof CUSTOM_PROPERTY_TYPES)[number];
|
|
|
33
33
|
* The subset whose value provably survives the full round trip — panel write,
|
|
34
34
|
* persistence, and resolution back into a prop.
|
|
35
35
|
*
|
|
36
|
-
* The
|
|
37
|
-
* guessed:
|
|
38
|
-
* (`theme-basic-properties.tsx:1547-1548` passes arrays and locale objects
|
|
39
|
-
* through without the per-breakpoint wrapper). `getResponsiveValue` then
|
|
40
|
-
* rejects an array with a devWarn and returns the fallback, and a locale
|
|
41
|
-
* object has no `desktop` key so it does too. They can never deliver a
|
|
42
|
-
* value until a per-type resolver exists.
|
|
36
|
+
* The three excluded members are excluded for reasons that were verified, not
|
|
37
|
+
* guessed:
|
|
43
38
|
*
|
|
44
|
-
* `
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
39
|
+
* - `multiselect` and `localetext` are stored UNBUCKETED by the editor
|
|
40
|
+
* (`theme-basic-properties.tsx:1547-1548` passes arrays and locale objects
|
|
41
|
+
* through without the per-breakpoint wrapper). `getResponsiveValue` then
|
|
42
|
+
* rejects an array with a devWarn and returns the fallback, and a locale
|
|
43
|
+
* object has no `desktop` key so it does too. They can never deliver a
|
|
44
|
+
* value until a per-type resolver exists.
|
|
45
|
+
* - `richtext` has no safe renderer here. The allowlist sanitizer this repo
|
|
46
|
+
* already owns parses by assigning to `innerHTML` on a live element, so the
|
|
47
|
+
* sink is its first act — and pz-theme renders server-side, where there is
|
|
48
|
+
* no `document` at all.
|
|
50
49
|
*
|
|
51
50
|
* Declaring one of these throws at define time rather than shipping a control
|
|
52
51
|
* that silently shows the default forever.
|
|
@@ -60,11 +59,12 @@ export const SUPPORTED_PROPERTY_TYPES: readonly PropertyType[] = [
|
|
|
60
59
|
'color',
|
|
61
60
|
'image',
|
|
62
61
|
'file',
|
|
63
|
-
'richtext',
|
|
64
62
|
'typography',
|
|
65
63
|
'icon'
|
|
66
64
|
];
|
|
67
65
|
|
|
68
|
-
export const isSupportedPropertyType = (
|
|
66
|
+
export const isSupportedPropertyType = (
|
|
67
|
+
type: unknown
|
|
68
|
+
): type is PropertyType =>
|
|
69
69
|
typeof type === 'string' &&
|
|
70
70
|
(SUPPORTED_PROPERTY_TYPES as readonly string[]).includes(type);
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { getResponsiveValue } from '../utils';
|
|
4
|
-
import { sanitizeRichText } from '../utils/sanitize-rich-text';
|
|
5
4
|
import { devWarn } from '../utils/dev-warn';
|
|
6
5
|
import {
|
|
7
6
|
isLocalizedValue,
|
|
@@ -239,11 +238,6 @@ export function useThemeSetting<
|
|
|
239
238
|
|
|
240
239
|
const defaults = splitDefault(declaration, valueLocale);
|
|
241
240
|
|
|
242
|
-
const finish = (value: unknown): TValue =>
|
|
243
|
-
(declaration.type === 'richtext'
|
|
244
|
-
? sanitizeRichText(typeof value === 'string' ? value : '')
|
|
245
|
-
: value) as TValue;
|
|
246
|
-
|
|
247
241
|
if (!context) {
|
|
248
242
|
// A missing block context is never an error on its own: a developer
|
|
249
243
|
// previewing the component in a storybook or a unit test should see the
|
|
@@ -260,7 +254,7 @@ export function useThemeSetting<
|
|
|
260
254
|
`unreachable and the declared default is used instead. Render this ` +
|
|
261
255
|
`component through a block registered with registerCustomBlocks().`
|
|
262
256
|
);
|
|
263
|
-
return
|
|
257
|
+
return defaults.page as TValue;
|
|
264
258
|
}
|
|
265
259
|
|
|
266
260
|
// Reported on every render rather than once from an effect: the collection
|
|
@@ -277,17 +271,7 @@ export function useThemeSetting<
|
|
|
277
271
|
// attribute keys that were never declared. `node` rather than `block` so a
|
|
278
272
|
// setting declared inside a custom SECTION reads the section's own
|
|
279
273
|
// properties, which is where the editor writes it.
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
// The rich text control writes per language — `{ tr: '<p>…</p>', en: … }` —
|
|
283
|
-
// and the editor stores that map as it is, with no breakpoint key: the same
|
|
284
|
-
// shape that keeps `localetext` out (see `property-types.ts`). Read the
|
|
285
|
-
// page's language out of it first; what is left is a string or a breakpoint
|
|
286
|
-
// map, which the responsive read below already understands.
|
|
287
|
-
const stored =
|
|
288
|
-
declaration.type === 'richtext'
|
|
289
|
-
? resolveLocalizedValue(authored, valueLocale)
|
|
290
|
-
: authored;
|
|
274
|
+
const stored = context.node.properties?.[key];
|
|
291
275
|
|
|
292
276
|
// The sentinel again, for the same reason: handing `getResponsiveValue` an
|
|
293
277
|
// undefined fallback would resolve an undeclared default to `''`, so a
|
|
@@ -296,15 +280,15 @@ export function useThemeSetting<
|
|
|
296
280
|
const resolved = getResponsiveValue(stored, breakpoint, NOT_AUTHORED);
|
|
297
281
|
|
|
298
282
|
if (resolved === NOT_AUTHORED) {
|
|
299
|
-
return
|
|
283
|
+
return resolveAuthoredDefault(defaults.page, breakpoint) as TValue;
|
|
300
284
|
}
|
|
301
285
|
|
|
302
286
|
// The default-language text as the editor seeded it when the block was
|
|
303
287
|
// placed, never changed since: still the author's copy, so it follows the
|
|
304
288
|
// page's language. Anything the merchant typed is theirs and shows as typed.
|
|
305
289
|
if (defaults.perLocale && resolved === defaults.base) {
|
|
306
|
-
return
|
|
290
|
+
return defaults.page as TValue;
|
|
307
291
|
}
|
|
308
292
|
|
|
309
|
-
return
|
|
293
|
+
return resolved as TValue;
|
|
310
294
|
}
|
|
@@ -4,28 +4,14 @@ import { useEffect } from 'react';
|
|
|
4
4
|
import { useLocalization } from '@akinon/next/hooks';
|
|
5
5
|
|
|
6
6
|
import { isTrustedEditorEvent, postToEditor } from './editor-bridge';
|
|
7
|
-
import { useValueLocale } from './utils/localized-value';
|
|
8
7
|
|
|
9
8
|
/**
|
|
10
9
|
* The storefront half of the editor's Language dropdown.
|
|
11
10
|
*
|
|
12
11
|
* The editor starts that dropdown with English only and fills it from the
|
|
13
|
-
* `LOCALE_UPDATE` frame the preview posts (`{ locale, locales
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* The frame carries the storefront's default language as well. The editor's
|
|
18
|
-
* per-language text controls store a plain string in that language and a map
|
|
19
|
-
* in every other, so an editor that never heard it assumed English and gave
|
|
20
|
-
* every Turkish-default store a stray `en` key (ZERO-5296).
|
|
21
|
-
*
|
|
22
|
-
* It is posted BEFORE the document reports ready (`IFRAME_READY`): the bridge
|
|
23
|
-
* is a child of the placeholder wrapper, whose own effect posts that frame,
|
|
24
|
-
* and React runs a child's effects first. The editor relies on the order — it
|
|
25
|
-
* holds every per-language write until the preview is ready, and takes
|
|
26
|
-
* "ready without languages" to mean a storefront that will never send them.
|
|
27
|
-
*
|
|
28
|
-
* For a long time the only code answering either direction
|
|
12
|
+
* `LOCALE_UPDATE` frame the preview posts (`{ locale, locales }`); when the
|
|
13
|
+
* merchant picks another language it posts `LOCALE_UPDATE` back and expects the
|
|
14
|
+
* preview to follow. For a long time the only code answering either direction
|
|
29
15
|
* was the app-template's `LanguageSelect` — brand code. A brand whose header no
|
|
30
16
|
* longer renders that component (a header composed through ChromeSlot, a
|
|
31
17
|
* custom header section, a project older than the template) left the editor
|
|
@@ -68,7 +54,6 @@ export interface EditorLocaleOption {
|
|
|
68
54
|
interface LocaleState {
|
|
69
55
|
locale: string;
|
|
70
56
|
locales: EditorLocaleOption[];
|
|
71
|
-
defaultLocale?: string;
|
|
72
57
|
setLocale: (locale: string) => void;
|
|
73
58
|
}
|
|
74
59
|
|
|
@@ -101,22 +86,15 @@ const toEditorLocales = (locales: unknown): EditorLocaleOption[] =>
|
|
|
101
86
|
: entry.value
|
|
102
87
|
}));
|
|
103
88
|
|
|
104
|
-
const announcementKey = (
|
|
105
|
-
locale,
|
|
106
|
-
locales,
|
|
107
|
-
defaultLocale
|
|
108
|
-
}: Omit<LocaleState, 'setLocale'>) =>
|
|
109
|
-
JSON.stringify([locale, locales, defaultLocale ?? null]);
|
|
89
|
+
const announcementKey = (locale: string, locales: EditorLocaleOption[]) =>
|
|
90
|
+
JSON.stringify([locale, locales]);
|
|
110
91
|
|
|
111
|
-
const announce = ({ locale, locales
|
|
112
|
-
const key = announcementKey(
|
|
92
|
+
const announce = ({ locale, locales }: LocaleState) => {
|
|
93
|
+
const key = announcementKey(locale, locales);
|
|
113
94
|
if (key === lastAnnounced) return;
|
|
114
95
|
lastAnnounced = key;
|
|
115
96
|
|
|
116
|
-
postToEditor({
|
|
117
|
-
type: 'LOCALE_UPDATE',
|
|
118
|
-
data: { locale, locales, defaultLocale }
|
|
119
|
-
});
|
|
97
|
+
postToEditor({ type: 'LOCALE_UPDATE', data: { locale, locales } });
|
|
120
98
|
};
|
|
121
99
|
|
|
122
100
|
const handleEditorMessage = (event: MessageEvent) => {
|
|
@@ -137,7 +115,7 @@ const handleEditorMessage = (event: MessageEvent) => {
|
|
|
137
115
|
|
|
138
116
|
// The editor is already showing `requested`. Recording it as announced keeps
|
|
139
117
|
// the switch from being echoed back when the new locale reaches the page.
|
|
140
|
-
lastAnnounced = announcementKey(
|
|
118
|
+
lastAnnounced = announcementKey(requested, current.locales);
|
|
141
119
|
current.setLocale(requested);
|
|
142
120
|
};
|
|
143
121
|
|
|
@@ -148,7 +126,6 @@ const handleEditorMessage = (event: MessageEvent) => {
|
|
|
148
126
|
*/
|
|
149
127
|
export function EditorLocaleBridge(): null {
|
|
150
128
|
const { locale, locales, setLocale } = useLocalization();
|
|
151
|
-
const { defaultLocale } = useValueLocale();
|
|
152
129
|
|
|
153
130
|
useEffect(() => {
|
|
154
131
|
if (!isFramed()) return;
|
|
@@ -170,14 +147,9 @@ export function EditorLocaleBridge(): null {
|
|
|
170
147
|
useEffect(() => {
|
|
171
148
|
if (!isFramed() || !locale) return;
|
|
172
149
|
|
|
173
|
-
current = {
|
|
174
|
-
locale,
|
|
175
|
-
locales: toEditorLocales(locales),
|
|
176
|
-
defaultLocale,
|
|
177
|
-
setLocale
|
|
178
|
-
};
|
|
150
|
+
current = { locale, locales: toEditorLocales(locales), setLocale };
|
|
179
151
|
announce(current);
|
|
180
|
-
}, [locale, locales,
|
|
152
|
+
}, [locale, locales, setLocale]);
|
|
181
153
|
|
|
182
154
|
return null;
|
|
183
155
|
}
|
|
@@ -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
|
+
};
|