@akinon/pz-theme 2.0.144-rc.0 → 2.0.145-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
CHANGED
|
@@ -1,40 +1,20 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.
|
|
4
|
-
|
|
5
|
-
### Patch Changes
|
|
6
|
-
|
|
7
|
-
- Updated dependencies [
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
- Updated dependencies [bfafa3f49]
|
|
19
|
-
- Updated dependencies [57d7eb305]
|
|
20
|
-
- Updated dependencies [d99a6a7d5]
|
|
21
|
-
- Updated dependencies [9db81a714]
|
|
22
|
-
- Updated dependencies [591e345e1]
|
|
23
|
-
- Updated dependencies [4de5303c5]
|
|
24
|
-
- Updated dependencies [95b139dc1]
|
|
25
|
-
- Updated dependencies [1d00f2d06]
|
|
26
|
-
- Updated dependencies [4ac7b2a1e]
|
|
27
|
-
- Updated dependencies [e9598c71d]
|
|
28
|
-
- Updated dependencies [4998a9631]
|
|
29
|
-
- Updated dependencies [804d2bd6c]
|
|
30
|
-
- Updated dependencies [3909d3224]
|
|
31
|
-
- Updated dependencies [6a3d8a631]
|
|
32
|
-
- Updated dependencies [e18836b20]
|
|
33
|
-
- Updated dependencies [b1111d7b1]
|
|
34
|
-
- Updated dependencies [1595d458e]
|
|
35
|
-
- Updated dependencies [dca128112]
|
|
36
|
-
- @akinon/next@2.0.144-rc.0
|
|
37
|
-
- @akinon/theme-contract@2.0.144-rc.0
|
|
3
|
+
## 2.0.145-beta.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [cbbbfd757]
|
|
8
|
+
- @akinon/next@2.0.145-beta.0
|
|
9
|
+
- @akinon/theme-contract@2.0.145-beta.0
|
|
10
|
+
|
|
11
|
+
## 2.0.144
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- fc64466: ZERO-5222: Honour the merchant's section tracking settings in the renderer
|
|
16
|
+
- @akinon/next@2.0.144
|
|
17
|
+
- @akinon/theme-contract@2.0.144
|
|
38
18
|
|
|
39
19
|
## 2.0.143
|
|
40
20
|
|
package/package.json
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akinon/pz-theme",
|
|
3
3
|
"description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
|
|
4
|
-
"version": "2.0.
|
|
4
|
+
"version": "2.0.145-beta.0",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"test": "jest"
|
|
9
9
|
},
|
|
10
10
|
"peerDependencies": {
|
|
11
|
-
"@akinon/next": "2.0.
|
|
11
|
+
"@akinon/next": "2.0.145-beta.0",
|
|
12
12
|
"react": "^18.0.0 || ^19.0.0",
|
|
13
13
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@akinon/theme-contract": "2.0.
|
|
16
|
+
"@akinon/theme-contract": "2.0.145-beta.0",
|
|
17
17
|
"clsx": "2.1.1",
|
|
18
18
|
"lottie-web": "^5.13.0",
|
|
19
19
|
"react-multi-carousel": "2.8.4",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"zod": "4.4.1"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@akinon/next": "2.0.
|
|
24
|
+
"@akinon/next": "2.0.145-beta.0",
|
|
25
25
|
"@testing-library/jest-dom": "5.16.4",
|
|
26
26
|
"@testing-library/react": "16.3.2",
|
|
27
27
|
"@testing-library/user-event": "14.1.1",
|
|
@@ -8,9 +8,11 @@ jest.mock('next/navigation', () => ({
|
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
10
|
FRAMED_SECTION_SEEN_DWELL_MS,
|
|
11
|
-
SECTION_SEEN_DWELL_MS,
|
|
12
11
|
useSectionSeen
|
|
13
12
|
} from '../use-section-seen';
|
|
13
|
+
import { DEFAULT_SECTION_TRACKING } from '../../utils/section-tracking';
|
|
14
|
+
|
|
15
|
+
const SECTION_SEEN_DWELL_MS = DEFAULT_SECTION_TRACKING.dwellMs;
|
|
14
16
|
import {
|
|
15
17
|
__resetThemeSignalsForTests,
|
|
16
18
|
subscribeToThemeSignals,
|
|
@@ -89,7 +91,12 @@ const Section = ({
|
|
|
89
91
|
section = DEFAULT_SECTION
|
|
90
92
|
}: {
|
|
91
93
|
placeholderId?: string;
|
|
92
|
-
section?: {
|
|
94
|
+
section?: {
|
|
95
|
+
id: string;
|
|
96
|
+
type: string;
|
|
97
|
+
order?: number;
|
|
98
|
+
properties?: Record<string, unknown>;
|
|
99
|
+
};
|
|
93
100
|
}) => {
|
|
94
101
|
const ref = useRef<HTMLDivElement>(null);
|
|
95
102
|
useSectionSeen({ ref, section: section as never, placeholderId });
|
|
@@ -144,7 +151,10 @@ describe('section.seen (ZERO-5164)', () => {
|
|
|
144
151
|
placeholderId: 'home-body',
|
|
145
152
|
sectionId: 'sec_hero',
|
|
146
153
|
sectionType: 'custom',
|
|
147
|
-
order: 2
|
|
154
|
+
order: 2,
|
|
155
|
+
// Every report says why it was made, so a brand bridge can gate
|
|
156
|
+
// consent on one field rather than on a list of section ids.
|
|
157
|
+
purpose: 'analytics'
|
|
148
158
|
}
|
|
149
159
|
]);
|
|
150
160
|
});
|
|
@@ -305,6 +315,119 @@ describe('section.seen (ZERO-5164)', () => {
|
|
|
305
315
|
});
|
|
306
316
|
});
|
|
307
317
|
|
|
318
|
+
describe('what the merchant authored (ZERO-5222)', () => {
|
|
319
|
+
const tracked = (viewTracking: Record<string, unknown>) => ({
|
|
320
|
+
id: 'sec_hero',
|
|
321
|
+
type: 'custom',
|
|
322
|
+
order: 2,
|
|
323
|
+
properties: { viewTracking }
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('carries the authored name and purpose', () => {
|
|
327
|
+
render(
|
|
328
|
+
<Section
|
|
329
|
+
section={tracked({ name: 'home_hero_summer', purpose: 'marketing' })}
|
|
330
|
+
/>
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
latest().cross(true);
|
|
334
|
+
tick(SECTION_SEEN_DWELL_MS);
|
|
335
|
+
|
|
336
|
+
expect(received[0]).toMatchObject({
|
|
337
|
+
sectionName: 'home_hero_summer',
|
|
338
|
+
purpose: 'marketing'
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('reports a name the merchant could not have typed as unnamed', () => {
|
|
343
|
+
// The same composition is written by the API and the AI generator, so
|
|
344
|
+
// the pattern is enforced on read rather than trusted from the panel.
|
|
345
|
+
render(<Section section={tracked({ name: 'Yaz İndirimi!' })} />);
|
|
346
|
+
|
|
347
|
+
latest().cross(true);
|
|
348
|
+
tick(SECTION_SEEN_DWELL_MS);
|
|
349
|
+
|
|
350
|
+
expect(received[0]).not.toHaveProperty('sectionName');
|
|
351
|
+
expect(received).toHaveLength(1);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('watches from the first pixel when asked to', () => {
|
|
355
|
+
render(<Section section={tracked({ seenWhen: 'appears' })} />);
|
|
356
|
+
|
|
357
|
+
expect(latest().options).toEqual({ threshold: 0, rootMargin: '0px' });
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('honours an authored dwell', () => {
|
|
361
|
+
render(<Section section={tracked({ dwellMs: 2000 })} />);
|
|
362
|
+
|
|
363
|
+
latest().cross(true);
|
|
364
|
+
tick(1900);
|
|
365
|
+
expect(received).toEqual([]);
|
|
366
|
+
|
|
367
|
+
tick(100);
|
|
368
|
+
expect(received).toHaveLength(1);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('treats the framed dwell as a floor, not a default', () => {
|
|
372
|
+
// A merchant typing 0 does not make the editor handshake arrive sooner.
|
|
373
|
+
Object.defineProperty(window, 'top', { configurable: true, value: {} });
|
|
374
|
+
|
|
375
|
+
render(<Section section={tracked({ dwellMs: 0 })} />);
|
|
376
|
+
latest().cross(true);
|
|
377
|
+
tick(FRAMED_SECTION_SEEN_DWELL_MS - 1);
|
|
378
|
+
expect(received).toEqual([]);
|
|
379
|
+
|
|
380
|
+
tick(1);
|
|
381
|
+
expect(received).toHaveLength(1);
|
|
382
|
+
|
|
383
|
+
Object.defineProperty(window, 'top', {
|
|
384
|
+
configurable: true,
|
|
385
|
+
value: window
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('reports every time the section comes back when asked to', () => {
|
|
390
|
+
render(<Section section={tracked({ frequency: 'every-view' })} />);
|
|
391
|
+
|
|
392
|
+
const observer = latest();
|
|
393
|
+
observer.cross(true);
|
|
394
|
+
tick(SECTION_SEEN_DWELL_MS);
|
|
395
|
+
observer.cross(false);
|
|
396
|
+
observer.cross(true);
|
|
397
|
+
tick(SECTION_SEEN_DWELL_MS);
|
|
398
|
+
|
|
399
|
+
expect(received).toHaveLength(2);
|
|
400
|
+
// Still watching, because there may be a third time.
|
|
401
|
+
expect(observer.disconnected).toBe(false);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('does not report twice for one uninterrupted stay', () => {
|
|
405
|
+
render(<Section section={tracked({ frequency: 'every-view' })} />);
|
|
406
|
+
|
|
407
|
+
const observer = latest();
|
|
408
|
+
observer.cross(true);
|
|
409
|
+
tick(SECTION_SEEN_DWELL_MS);
|
|
410
|
+
// A ratio change while it is still on screen is the same stay.
|
|
411
|
+
observer.cross(true, 0.9);
|
|
412
|
+
tick(SECTION_SEEN_DWELL_MS);
|
|
413
|
+
|
|
414
|
+
expect(received).toHaveLength(1);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('reads settings the editor bucketed by breakpoint', () => {
|
|
418
|
+
render(
|
|
419
|
+
<Section
|
|
420
|
+
section={tracked({ desktop: { name: 'bucketed_hero' } } as never)}
|
|
421
|
+
/>
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
latest().cross(true);
|
|
425
|
+
tick(SECTION_SEEN_DWELL_MS);
|
|
426
|
+
|
|
427
|
+
expect(received[0].sectionName).toBe('bucketed_hero');
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
|
|
308
431
|
it('does nothing where the browser has no IntersectionObserver', () => {
|
|
309
432
|
delete (window as unknown as Record<string, unknown>).IntersectionObserver;
|
|
310
433
|
|
|
@@ -4,10 +4,15 @@ import { useEffect, useRef } from 'react';
|
|
|
4
4
|
import { usePathname } from 'next/navigation';
|
|
5
5
|
|
|
6
6
|
import { useThemeSignal } from './use-theme-signal';
|
|
7
|
+
import {
|
|
8
|
+
normalizeSectionTracking,
|
|
9
|
+
SECTION_TRACKING_PROPERTY_KEY
|
|
10
|
+
} from '../utils/section-tracking';
|
|
7
11
|
import type { Section } from '../theme-section';
|
|
8
12
|
|
|
9
13
|
/**
|
|
10
|
-
* Report a composed section becoming visible to the shopper (ZERO-5164)
|
|
14
|
+
* Report a composed section becoming visible to the shopper (ZERO-5164),
|
|
15
|
+
* the way the merchant asked for it (ZERO-5222).
|
|
11
16
|
*
|
|
12
17
|
* The anchor of the reporting feature: a merchant adds a campaign band and
|
|
13
18
|
* asks whether anyone saw it. Nothing in this package observed a section
|
|
@@ -22,32 +27,25 @@ import type { Section } from '../theme-section';
|
|
|
22
27
|
* changes.
|
|
23
28
|
*/
|
|
24
29
|
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
* viewport can never reach 0.5 and a three-screen listing can never reach any
|
|
31
|
-
* meaningful ratio at all: a fixed ratio would silently never report on
|
|
32
|
-
* exactly the sections this exists for. A band asks a height-independent
|
|
33
|
-
* question — did this section cross the middle of the screen — and is the
|
|
34
|
-
* shape `chrome/scroll-state.tsx` already uses for the header.
|
|
35
|
-
*/
|
|
36
|
-
const SEEN_BAND = { threshold: 0, rootMargin: '-50% 0px -50% 0px' };
|
|
30
|
+
/** A zero-height band across the middle of the viewport. */
|
|
31
|
+
const BAND: IntersectionObserverInit = {
|
|
32
|
+
threshold: 0,
|
|
33
|
+
rootMargin: '-50% 0px -50% 0px'
|
|
34
|
+
};
|
|
37
35
|
|
|
38
|
-
/**
|
|
39
|
-
|
|
36
|
+
/** From the first pixel, for a section the merchant wants counted early. */
|
|
37
|
+
const ANY_PART: IntersectionObserverInit = { threshold: 0, rootMargin: '0px' };
|
|
40
38
|
|
|
41
39
|
/**
|
|
42
|
-
* The dwell
|
|
40
|
+
* The dwell an unauthored section waits, and the floor for an authored one
|
|
41
|
+
* while the storefront is framed.
|
|
43
42
|
*
|
|
44
43
|
* `window.__themeEditorDesigner` is only set once the editor's handshake
|
|
45
44
|
* arrives, and an IntersectionObserver delivers its first callback as soon as
|
|
46
|
-
* it observes a visible target — so an immediate report from the editor
|
|
47
|
-
* would count a designer as a shopper before the flag that mutes them
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* later, so the longer dwell is the cheap way to close the race for both.
|
|
45
|
+
* it observes a visible target — so an immediate report from the editor
|
|
46
|
+
* canvas would count a designer as a shopper before the flag that mutes them
|
|
47
|
+
* exists. That race does not go away because a merchant typed 0, which is why
|
|
48
|
+
* the framed value is a floor rather than a default.
|
|
51
49
|
*/
|
|
52
50
|
export const FRAMED_SECTION_SEEN_DWELL_MS = 1500;
|
|
53
51
|
|
|
@@ -62,7 +60,7 @@ const isFramed = (): boolean => {
|
|
|
62
60
|
|
|
63
61
|
export interface UseSectionSeenOptions {
|
|
64
62
|
ref: React.RefObject<HTMLElement>;
|
|
65
|
-
section: Pick<Section, 'id' | 'type' | 'order'>;
|
|
63
|
+
section: Pick<Section, 'id' | 'type' | 'order' | 'properties'>;
|
|
66
64
|
placeholderId: string;
|
|
67
65
|
}
|
|
68
66
|
|
|
@@ -79,7 +77,7 @@ export const useSectionSeen = ({
|
|
|
79
77
|
});
|
|
80
78
|
|
|
81
79
|
/**
|
|
82
|
-
* The last key actually reported.
|
|
80
|
+
* The last key actually reported, for a section counted once per page.
|
|
83
81
|
*
|
|
84
82
|
* Keyed on the route rather than the mount: `ChromeSlot` mounts the header
|
|
85
83
|
* and footer in the root layout, and the app router does not unmount a
|
|
@@ -92,6 +90,8 @@ export const useSectionSeen = ({
|
|
|
92
90
|
const reported = useRef<string | null>(null);
|
|
93
91
|
|
|
94
92
|
const order = section.order;
|
|
93
|
+
const { name, purpose, seenWhen, dwellMs, frequency } =
|
|
94
|
+
normalizeSectionTracking(section.properties?.[SECTION_TRACKING_PROPERTY_KEY]);
|
|
95
95
|
|
|
96
96
|
useEffect(() => {
|
|
97
97
|
const node = ref.current;
|
|
@@ -99,12 +99,18 @@ export const useSectionSeen = ({
|
|
|
99
99
|
if (typeof IntersectionObserver === 'undefined') return;
|
|
100
100
|
|
|
101
101
|
const latchKey = `${pathname}:${placeholderId}:${section.id}`;
|
|
102
|
-
|
|
102
|
+
const countsOnce = frequency === 'once-per-view';
|
|
103
|
+
if (countsOnce && reported.current === latchKey) return;
|
|
103
104
|
|
|
104
105
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
106
|
+
// Whether this uninterrupted stay on screen has already been reported.
|
|
107
|
+
// Without it a second callback for the same entry — a ratio change, a
|
|
108
|
+
// resize — would start a fresh dwell and report the same stay twice.
|
|
109
|
+
let reportedThisEntry = false;
|
|
110
|
+
|
|
105
111
|
const dwell = isFramed()
|
|
106
|
-
? FRAMED_SECTION_SEEN_DWELL_MS
|
|
107
|
-
:
|
|
112
|
+
? Math.max(dwellMs, FRAMED_SECTION_SEEN_DWELL_MS)
|
|
113
|
+
: dwellMs;
|
|
108
114
|
|
|
109
115
|
const clear = () => {
|
|
110
116
|
if (timer === undefined) return;
|
|
@@ -116,38 +122,54 @@ export const useSectionSeen = ({
|
|
|
116
122
|
const entry = entries[entries.length - 1];
|
|
117
123
|
if (!entry) return;
|
|
118
124
|
|
|
119
|
-
// Left
|
|
125
|
+
// Left before the dwell elapsed: scrolled past, not seen. Leaving also
|
|
126
|
+
// ends the stay, so a section the merchant asked to count every time
|
|
127
|
+
// can count the next one.
|
|
120
128
|
if (!entry.isIntersecting) {
|
|
121
129
|
clear();
|
|
130
|
+
reportedThisEntry = false;
|
|
122
131
|
return;
|
|
123
132
|
}
|
|
124
133
|
|
|
125
|
-
if (timer !== undefined) return;
|
|
134
|
+
if (timer !== undefined || reportedThisEntry) return;
|
|
126
135
|
|
|
127
136
|
timer = setTimeout(() => {
|
|
128
137
|
timer = undefined;
|
|
129
|
-
if (reported.current === latchKey) return;
|
|
138
|
+
if (countsOnce && reported.current === latchKey) return;
|
|
130
139
|
|
|
131
140
|
const sent = signal(
|
|
132
141
|
'section.seen',
|
|
133
142
|
{
|
|
143
|
+
purpose,
|
|
144
|
+
// `sectionName`, not `name`: the emitter owns `name` as the
|
|
145
|
+
// moment's own identity and ignores a fact that tries to use it,
|
|
146
|
+
// so a merchant label sent under that key would vanish without a
|
|
147
|
+
// word. It also reads better beside `sectionId` and `sectionType`.
|
|
148
|
+
...(name ? { sectionName: name } : null),
|
|
134
149
|
...(typeof order === 'number' && Number.isFinite(order)
|
|
135
150
|
? { order }
|
|
136
151
|
: null)
|
|
137
152
|
},
|
|
138
|
-
|
|
153
|
+
// `every-view` deliberately does not take the module latch, so it
|
|
154
|
+
// falls back on the ordinary per-key ceiling: a section entering
|
|
155
|
+
// and leaving twenty times still cannot flood the route budget.
|
|
156
|
+
countsOnce ? { key: latchKey, once: true } : { key: latchKey }
|
|
139
157
|
);
|
|
140
158
|
|
|
141
159
|
// Latched only on a report that actually left. Writing the latch
|
|
142
160
|
// first and emitting second is how a muted render — the editor
|
|
143
161
|
// canvas, or a consent gate that has not answered yet — silences a
|
|
144
162
|
// section for the rest of the page with nothing to show for it.
|
|
145
|
-
if (sent)
|
|
163
|
+
if (!sent) return;
|
|
164
|
+
|
|
165
|
+
reportedThisEntry = true;
|
|
166
|
+
|
|
167
|
+
if (countsOnce) {
|
|
146
168
|
reported.current = latchKey;
|
|
147
169
|
observer.disconnect();
|
|
148
170
|
}
|
|
149
171
|
}, dwell);
|
|
150
|
-
},
|
|
172
|
+
}, seenWhen === 'appears' ? ANY_PART : BAND);
|
|
151
173
|
|
|
152
174
|
observer.observe(node);
|
|
153
175
|
|
|
@@ -155,7 +177,19 @@ export const useSectionSeen = ({
|
|
|
155
177
|
clear();
|
|
156
178
|
observer.disconnect();
|
|
157
179
|
};
|
|
158
|
-
}, [
|
|
180
|
+
}, [
|
|
181
|
+
ref,
|
|
182
|
+
pathname,
|
|
183
|
+
placeholderId,
|
|
184
|
+
section.id,
|
|
185
|
+
order,
|
|
186
|
+
signal,
|
|
187
|
+
name,
|
|
188
|
+
purpose,
|
|
189
|
+
seenWhen,
|
|
190
|
+
dwellMs,
|
|
191
|
+
frequency
|
|
192
|
+
]);
|
|
159
193
|
};
|
|
160
194
|
|
|
161
195
|
export default useSectionSeen;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_SECTION_TRACKING,
|
|
3
|
+
MAX_SECTION_DWELL_MS,
|
|
4
|
+
normalizeSectionTracking,
|
|
5
|
+
sanitizeSectionTrackingName
|
|
6
|
+
} from '../section-tracking';
|
|
7
|
+
|
|
8
|
+
describe('section tracking settings (ZERO-5222)', () => {
|
|
9
|
+
describe('the name', () => {
|
|
10
|
+
it('takes a well-formed one as authored', () => {
|
|
11
|
+
expect(sanitizeSectionTrackingName('home_hero_summer')).toBe(
|
|
12
|
+
'home_hero_summer'
|
|
13
|
+
);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('lowercases and trims what the merchant typed', () => {
|
|
17
|
+
expect(sanitizeSectionTrackingName(' Home_Hero ')).toBe('home_hero');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it.each([
|
|
21
|
+
['2026_campaign', 'starts with a digit'],
|
|
22
|
+
['home hero', 'has a space'],
|
|
23
|
+
['home-hero', 'has a hyphen'],
|
|
24
|
+
['müşteri_bandı', 'is not ascii'],
|
|
25
|
+
['a'.repeat(41), 'is too long'],
|
|
26
|
+
['', 'is empty'],
|
|
27
|
+
['__proto__', 'starts with an underscore']
|
|
28
|
+
])('drops %s, which %s', (value) => {
|
|
29
|
+
expect(sanitizeSectionTrackingName(value)).toBe('');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('drops anything that is not a string', () => {
|
|
33
|
+
expect(sanitizeSectionTrackingName({ name: 'hero' })).toBe('');
|
|
34
|
+
expect(sanitizeSectionTrackingName(42)).toBe('');
|
|
35
|
+
expect(sanitizeSectionTrackingName(null)).toBe('');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('refuses a huge string without working through it', () => {
|
|
39
|
+
// Sliced before the lowercase and the match, so a stored megabyte is
|
|
40
|
+
// rejected on its first 96 characters.
|
|
41
|
+
expect(sanitizeSectionTrackingName('a'.repeat(1_000_000))).toBe('');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('the whole object', () => {
|
|
46
|
+
it('falls back to the defaults when nothing is authored', () => {
|
|
47
|
+
expect(normalizeSectionTracking(undefined)).toEqual(
|
|
48
|
+
DEFAULT_SECTION_TRACKING
|
|
49
|
+
);
|
|
50
|
+
expect(normalizeSectionTracking(null)).toEqual(DEFAULT_SECTION_TRACKING);
|
|
51
|
+
expect(normalizeSectionTracking('tracked')).toEqual(
|
|
52
|
+
DEFAULT_SECTION_TRACKING
|
|
53
|
+
);
|
|
54
|
+
expect(normalizeSectionTracking([])).toEqual(DEFAULT_SECTION_TRACKING);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('reads what the merchant authored', () => {
|
|
58
|
+
expect(
|
|
59
|
+
normalizeSectionTracking({
|
|
60
|
+
name: 'home_hero_summer',
|
|
61
|
+
purpose: 'marketing',
|
|
62
|
+
seenWhen: 'appears',
|
|
63
|
+
dwellMs: 2000,
|
|
64
|
+
frequency: 'every-view'
|
|
65
|
+
})
|
|
66
|
+
).toEqual({
|
|
67
|
+
name: 'home_hero_summer',
|
|
68
|
+
purpose: 'marketing',
|
|
69
|
+
seenWhen: 'appears',
|
|
70
|
+
dwellMs: 2000,
|
|
71
|
+
frequency: 'every-view'
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('falls back field by field rather than dropping the lot', () => {
|
|
76
|
+
// An unparseable setting must not cost the impression it describes.
|
|
77
|
+
expect(
|
|
78
|
+
normalizeSectionTracking({
|
|
79
|
+
name: 'campaign_band',
|
|
80
|
+
purpose: 'profiling',
|
|
81
|
+
seenWhen: 'halfway',
|
|
82
|
+
dwellMs: 'soon',
|
|
83
|
+
frequency: 'always'
|
|
84
|
+
})
|
|
85
|
+
).toEqual({
|
|
86
|
+
...DEFAULT_SECTION_TRACKING,
|
|
87
|
+
name: 'campaign_band'
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('clamps a dwell nobody meant', () => {
|
|
92
|
+
expect(normalizeSectionTracking({ dwellMs: -500 }).dwellMs).toBe(0);
|
|
93
|
+
expect(normalizeSectionTracking({ dwellMs: 60_000 }).dwellMs).toBe(
|
|
94
|
+
MAX_SECTION_DWELL_MS
|
|
95
|
+
);
|
|
96
|
+
expect(normalizeSectionTracking({ dwellMs: 1200.6 }).dwellMs).toBe(1201);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('reads a value the editor bucketed by breakpoint', () => {
|
|
100
|
+
// The editor wraps anything it does not know to be primitive into a
|
|
101
|
+
// breakpoint bucket, and the API and the AI generator write the same
|
|
102
|
+
// row. Tracking that differed by viewport would be meaningless, so the
|
|
103
|
+
// bucket is read as the value rather than honoured as one.
|
|
104
|
+
expect(
|
|
105
|
+
normalizeSectionTracking({
|
|
106
|
+
desktop: { name: 'home_hero', purpose: 'marketing' }
|
|
107
|
+
})
|
|
108
|
+
).toEqual({
|
|
109
|
+
...DEFAULT_SECTION_TRACKING,
|
|
110
|
+
name: 'home_hero',
|
|
111
|
+
purpose: 'marketing'
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('prefers the flat value when a stray bucket key sits beside it', () => {
|
|
116
|
+
expect(
|
|
117
|
+
normalizeSectionTracking({
|
|
118
|
+
name: 'flat_name',
|
|
119
|
+
desktop: { name: 'bucketed_name' }
|
|
120
|
+
}).name
|
|
121
|
+
).toBe('flat_name');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('does not recurse forever on an empty object', () => {
|
|
125
|
+
expect(normalizeSectionTracking({})).toEqual(DEFAULT_SECTION_TRACKING);
|
|
126
|
+
expect(normalizeSectionTracking({ desktop: {} })).toEqual(
|
|
127
|
+
DEFAULT_SECTION_TRACKING
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a merchant authored about reporting a section (ZERO-5222).
|
|
3
|
+
*
|
|
4
|
+
* `section.seen` already reports every composed section with what the
|
|
5
|
+
* renderer knows by itself — the section id, its type and its authored order.
|
|
6
|
+
* A report that says `sec_8f2c` is of limited use to a marketer, and those
|
|
7
|
+
* ids are re-minted by "Reset to code", so a brand cannot reliably map them
|
|
8
|
+
* by hand either. This is the vocabulary behind the panel that lets a
|
|
9
|
+
* merchant name one.
|
|
10
|
+
*
|
|
11
|
+
* Deliberately NOT an on/off switch. Reporting already happens for every
|
|
12
|
+
* section: a toggle that turned it off would take away what shipped, and one
|
|
13
|
+
* that did not would be a lie. The panel names a section; a brand that wants
|
|
14
|
+
* one out of its reports filters it in its own bridge or container.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Where the authored object lives on the section. */
|
|
18
|
+
export const SECTION_TRACKING_PROPERTY_KEY = 'viewTracking';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Why this section is reported — the brand's bridge gates consent on it.
|
|
22
|
+
*
|
|
23
|
+
* `analytics` is the default because it is the narrowest claim: a section
|
|
24
|
+
* nobody has spoken about is counted, not profiled.
|
|
25
|
+
*/
|
|
26
|
+
export type SectionTrackingPurpose =
|
|
27
|
+
| 'analytics'
|
|
28
|
+
| 'marketing'
|
|
29
|
+
| 'personalization';
|
|
30
|
+
|
|
31
|
+
/** Which geometry counts as seen. */
|
|
32
|
+
export type SectionSeenWhen = 'appears' | 'middle';
|
|
33
|
+
|
|
34
|
+
/** Whether the section may be reported more than once on one page. */
|
|
35
|
+
export type SectionTrackingFrequency = 'once-per-view' | 'every-view';
|
|
36
|
+
|
|
37
|
+
export interface SectionTracking {
|
|
38
|
+
/** Empty when the merchant has not named this section. */
|
|
39
|
+
name: string;
|
|
40
|
+
purpose: SectionTrackingPurpose;
|
|
41
|
+
seenWhen: SectionSeenWhen;
|
|
42
|
+
dwellMs: number;
|
|
43
|
+
frequency: SectionTrackingFrequency;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* What an unauthored section reports, i.e. what ZERO-5164 already does.
|
|
48
|
+
*
|
|
49
|
+
* Changing one of these changes every section on every storefront that has
|
|
50
|
+
* never opened the panel, so they are the conservative end of each choice:
|
|
51
|
+
* the band rather than the first pixel, a dwell long enough to exclude a
|
|
52
|
+
* section scrolled straight past, and once per page.
|
|
53
|
+
*/
|
|
54
|
+
export const DEFAULT_SECTION_TRACKING: SectionTracking = {
|
|
55
|
+
name: '',
|
|
56
|
+
purpose: 'analytics',
|
|
57
|
+
seenWhen: 'middle',
|
|
58
|
+
dwellMs: 500,
|
|
59
|
+
frequency: 'once-per-view'
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Longest a merchant may ask a section to be held before it counts. */
|
|
63
|
+
export const MAX_SECTION_DWELL_MS = 5000;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Lowercase, underscore-separated, starts with a letter.
|
|
67
|
+
*
|
|
68
|
+
* The same shape GA4 and every tag manager treat as an ordinary dimension
|
|
69
|
+
* value, so a name travels from the panel to a report without anyone having
|
|
70
|
+
* to quote or escape it.
|
|
71
|
+
*/
|
|
72
|
+
export const SECTION_TRACKING_NAME_PATTERN = /^[a-z][a-z0-9_]{0,39}$/;
|
|
73
|
+
|
|
74
|
+
const PURPOSES: readonly SectionTrackingPurpose[] = [
|
|
75
|
+
'analytics',
|
|
76
|
+
'marketing',
|
|
77
|
+
'personalization'
|
|
78
|
+
];
|
|
79
|
+
const SEEN_WHEN: readonly SectionSeenWhen[] = ['appears', 'middle'];
|
|
80
|
+
const FREQUENCIES: readonly SectionTrackingFrequency[] = [
|
|
81
|
+
'once-per-view',
|
|
82
|
+
'every-view'
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A name, or nothing.
|
|
87
|
+
*
|
|
88
|
+
* Sanitized on READ rather than trusted from the panel: the same composition
|
|
89
|
+
* is written by the editor, by the Omnitron API and by the AI generator, and
|
|
90
|
+
* only one of those three has a text field with a pattern on it. A name that
|
|
91
|
+
* does not survive this is dropped and the section reports unnamed — losing
|
|
92
|
+
* the label is a smaller failure than putting an unconstrained merchant
|
|
93
|
+
* string on the wire.
|
|
94
|
+
*/
|
|
95
|
+
export const sanitizeSectionTrackingName = (value: unknown): string => {
|
|
96
|
+
if (typeof value !== 'string') return '';
|
|
97
|
+
|
|
98
|
+
// Sliced before the work, not after: a stored 10 MB string must not be
|
|
99
|
+
// lowercased and matched in full to be rejected.
|
|
100
|
+
const name = value.slice(0, 96).trim().toLowerCase();
|
|
101
|
+
|
|
102
|
+
return SECTION_TRACKING_NAME_PATTERN.test(name) ? name : '';
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const readNumber = (value: unknown): number | null => {
|
|
106
|
+
const parsed = typeof value === 'number' ? value : Number(value);
|
|
107
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The authored settings, or the defaults.
|
|
112
|
+
*
|
|
113
|
+
* Every field is re-validated and a bad one falls back on its own rather than
|
|
114
|
+
* failing the whole object: an unparseable setting must not cost the
|
|
115
|
+
* impression it was describing.
|
|
116
|
+
*
|
|
117
|
+
* The `{ desktop: … }` unwrap mirrors `normalizePublishWindowValue`. The
|
|
118
|
+
* editor wraps any value it does not know to be primitive into a breakpoint
|
|
119
|
+
* bucket, and a composition written before the key was declared primitive —
|
|
120
|
+
* or by hand, or by the API — arrives in that shape. Tracking that differed
|
|
121
|
+
* by viewport would be meaningless, so a bucket is read as the value rather
|
|
122
|
+
* than honoured as one.
|
|
123
|
+
*/
|
|
124
|
+
export const normalizeSectionTracking = (value: unknown): SectionTracking => {
|
|
125
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
126
|
+
return DEFAULT_SECTION_TRACKING;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const record = value as Record<string, unknown>;
|
|
130
|
+
|
|
131
|
+
const hasOwnFields =
|
|
132
|
+
'name' in record ||
|
|
133
|
+
'purpose' in record ||
|
|
134
|
+
'seenWhen' in record ||
|
|
135
|
+
'dwellMs' in record ||
|
|
136
|
+
'frequency' in record;
|
|
137
|
+
|
|
138
|
+
if (!hasOwnFields) {
|
|
139
|
+
const bucketed = record.desktop ?? record.tablet ?? record.mobile;
|
|
140
|
+
if (bucketed && bucketed !== value) {
|
|
141
|
+
return normalizeSectionTracking(bucketed);
|
|
142
|
+
}
|
|
143
|
+
return DEFAULT_SECTION_TRACKING;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const purpose = PURPOSES.includes(record.purpose as SectionTrackingPurpose)
|
|
147
|
+
? (record.purpose as SectionTrackingPurpose)
|
|
148
|
+
: DEFAULT_SECTION_TRACKING.purpose;
|
|
149
|
+
|
|
150
|
+
const seenWhen = SEEN_WHEN.includes(record.seenWhen as SectionSeenWhen)
|
|
151
|
+
? (record.seenWhen as SectionSeenWhen)
|
|
152
|
+
: DEFAULT_SECTION_TRACKING.seenWhen;
|
|
153
|
+
|
|
154
|
+
const frequency = FREQUENCIES.includes(
|
|
155
|
+
record.frequency as SectionTrackingFrequency
|
|
156
|
+
)
|
|
157
|
+
? (record.frequency as SectionTrackingFrequency)
|
|
158
|
+
: DEFAULT_SECTION_TRACKING.frequency;
|
|
159
|
+
|
|
160
|
+
const dwell = readNumber(record.dwellMs);
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
name: sanitizeSectionTrackingName(record.name),
|
|
164
|
+
purpose,
|
|
165
|
+
seenWhen,
|
|
166
|
+
dwellMs:
|
|
167
|
+
dwell === null
|
|
168
|
+
? DEFAULT_SECTION_TRACKING.dwellMs
|
|
169
|
+
: Math.min(Math.max(Math.round(dwell), 0), MAX_SECTION_DWELL_MS),
|
|
170
|
+
frequency
|
|
171
|
+
};
|
|
172
|
+
};
|