@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.144-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [86187b1c2]
8
- - Updated dependencies [0cf9ea239]
9
- - Updated dependencies [324f97d55]
10
- - Updated dependencies [51ea06888]
11
- - Updated dependencies
12
- - Updated dependencies [2fea41430]
13
- - Updated dependencies [b55acb768]
14
- - Updated dependencies [760258c1c]
15
- - Updated dependencies [143be2b9d]
16
- - Updated dependencies [7889b08fe]
17
- - Updated dependencies [9f8cd3bc5]
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.144-rc.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.144-rc.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.144-rc.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.144-rc.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?: { id: string; type: string; order?: number };
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
- * A zero-height band across the middle of the viewport, rather than a share
27
- * of the section.
28
- *
29
- * `intersectionRatio` is relative to the TARGET, so a hero taller than the
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
- /** How long a section must hold the band before it counts as seen. */
39
- export const SECTION_SEEN_DWELL_MS = 500;
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 used while the storefront is inside an iframe.
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 canvas
47
- * would count a designer as a shopper before the flag that mutes them exists.
48
- * A production storefront is legitimately framed too (payment providers,
49
- * marketplace previews) and loses nothing by reporting an impression a second
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
- if (reported.current === latchKey) return;
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
- : SECTION_SEEN_DWELL_MS;
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 the band before the dwell elapsed: scrolled past, not seen.
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
- { key: latchKey, once: true }
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
- }, SEEN_BAND);
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
- }, [ref, pathname, placeholderId, section.id, order, signal]);
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
+ };