@akinon/pz-theme 2.0.133-rc.0 → 2.0.133

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,12 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.133-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 [1595d458]
35
- - Updated dependencies [dca12811]
36
- - @akinon/next@2.0.133-rc.0
37
- - @akinon/theme-contract@2.0.133-rc.0
3
+ ## 2.0.133
4
+
5
+ ### Patch Changes
6
+
7
+ - 114d857: ZERO-5164: Report a composed section becoming visible as section.seen
8
+ - @akinon/next@2.0.133
9
+ - @akinon/theme-contract@2.0.133
38
10
 
39
11
  ## 2.0.132
40
12
 
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.133-rc.0",
4
+ "version": "2.0.133",
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.133-rc.0",
11
+ "@akinon/next": "2.0.133",
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.133-rc.0",
16
+ "@akinon/theme-contract": "2.0.133",
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.133-rc.0",
24
+ "@akinon/next": "2.0.133",
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",
@@ -0,0 +1,316 @@
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
+ FRAMED_SECTION_SEEN_DWELL_MS,
11
+ SECTION_SEEN_DWELL_MS,
12
+ useSectionSeen
13
+ } from '../use-section-seen';
14
+ import {
15
+ __resetThemeSignalsForTests,
16
+ subscribeToThemeSignals,
17
+ type ThemeSignalDetail
18
+ } from '../../utils/theme-signal';
19
+
20
+ declare global {
21
+ interface Window {
22
+ __themeEditorDesigner?: boolean;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * A recording stand-in, in the shape `video-block.test.tsx` already uses.
28
+ *
29
+ * The package's jsdom setup fills `IntersectionObserver` with a no-op whose
30
+ * callback is never called, so a test that only asserts "no event" passes
31
+ * whether the code is right or wrong. Every negative case below is therefore
32
+ * paired with a positive one driven through this class.
33
+ */
34
+ class RecordingObserver {
35
+ static instances: RecordingObserver[] = [];
36
+
37
+ callback: IntersectionObserverCallback;
38
+ options: IntersectionObserverInit | undefined;
39
+ observed: Element[] = [];
40
+ disconnected = false;
41
+
42
+ constructor(
43
+ callback: IntersectionObserverCallback,
44
+ options?: IntersectionObserverInit
45
+ ) {
46
+ this.callback = callback;
47
+ this.options = options;
48
+ RecordingObserver.instances.push(this);
49
+ }
50
+
51
+ observe(node: Element) {
52
+ this.observed.push(node);
53
+ }
54
+
55
+ unobserve() {}
56
+
57
+ disconnect() {
58
+ this.disconnected = true;
59
+ }
60
+
61
+ takeRecords() {
62
+ return [];
63
+ }
64
+
65
+ /** Drive an intersection, the way the browser would. */
66
+ cross(isIntersecting: boolean, ratio = 1) {
67
+ act(() => {
68
+ this.callback(
69
+ [
70
+ {
71
+ isIntersecting,
72
+ intersectionRatio: ratio,
73
+ target: this.observed[0]
74
+ } as unknown as IntersectionObserverEntry
75
+ ],
76
+ this as unknown as IntersectionObserver
77
+ );
78
+ });
79
+ }
80
+ }
81
+
82
+ const latest = () =>
83
+ RecordingObserver.instances[RecordingObserver.instances.length - 1];
84
+
85
+ const DEFAULT_SECTION = { id: 'sec_hero', type: 'custom', order: 2 };
86
+
87
+ const Section = ({
88
+ placeholderId = 'home-body',
89
+ section = DEFAULT_SECTION
90
+ }: {
91
+ placeholderId?: string;
92
+ section?: { id: string; type: string; order?: number };
93
+ }) => {
94
+ const ref = useRef<HTMLDivElement>(null);
95
+ useSectionSeen({ ref, section: section as never, placeholderId });
96
+
97
+ return <div ref={ref} data-testid="section" />;
98
+ };
99
+
100
+ const tick = (ms: number) =>
101
+ act(() => {
102
+ jest.advanceTimersByTime(ms);
103
+ });
104
+
105
+ describe('section.seen (ZERO-5164)', () => {
106
+ let received: ThemeSignalDetail[];
107
+ let detach: () => void;
108
+ let originalObserver: unknown;
109
+
110
+ beforeEach(() => {
111
+ jest.useFakeTimers();
112
+ pathname = '/';
113
+ received = [];
114
+ detach = subscribeToThemeSignals((detail) => received.push(detail));
115
+ __resetThemeSignalsForTests();
116
+ delete window.__themeEditorDesigner;
117
+
118
+ RecordingObserver.instances = [];
119
+ originalObserver = (window as unknown as Record<string, unknown>)
120
+ .IntersectionObserver;
121
+ (window as unknown as Record<string, unknown>).IntersectionObserver =
122
+ RecordingObserver;
123
+ });
124
+
125
+ afterEach(() => {
126
+ detach();
127
+ (window as unknown as Record<string, unknown>).IntersectionObserver =
128
+ originalObserver;
129
+ delete window.__themeEditorDesigner;
130
+ jest.useRealTimers();
131
+ });
132
+
133
+ it('reports once the section has held the band for the dwell', () => {
134
+ render(<Section />);
135
+
136
+ latest().cross(true);
137
+ expect(received).toEqual([]);
138
+
139
+ tick(SECTION_SEEN_DWELL_MS);
140
+
141
+ expect(received).toEqual([
142
+ {
143
+ name: 'section.seen',
144
+ placeholderId: 'home-body',
145
+ sectionId: 'sec_hero',
146
+ sectionType: 'custom',
147
+ order: 2
148
+ }
149
+ ]);
150
+ });
151
+
152
+ it('watches a band across the middle of the viewport, not a share of the section', () => {
153
+ // A hero taller than the viewport can never reach a ratio threshold, so
154
+ // the question has to be height-independent.
155
+ render(<Section />);
156
+
157
+ expect(latest().options).toEqual({
158
+ threshold: 0,
159
+ rootMargin: '-50% 0px -50% 0px'
160
+ });
161
+ });
162
+
163
+ it('reports a section far taller than the viewport', () => {
164
+ render(<Section />);
165
+
166
+ latest().cross(true, 0.08);
167
+ tick(SECTION_SEEN_DWELL_MS);
168
+
169
+ expect(received).toHaveLength(1);
170
+ });
171
+
172
+ it('does not report a section scrolled straight past', () => {
173
+ render(<Section />);
174
+
175
+ latest().cross(true);
176
+ tick(SECTION_SEEN_DWELL_MS - 100);
177
+ latest().cross(false);
178
+ tick(SECTION_SEEN_DWELL_MS);
179
+
180
+ expect(received).toEqual([]);
181
+ });
182
+
183
+ it('reports once per route however often it crosses', () => {
184
+ render(<Section />);
185
+
186
+ latest().cross(true);
187
+ tick(SECTION_SEEN_DWELL_MS);
188
+ latest().cross(false);
189
+ latest().cross(true);
190
+ tick(SECTION_SEEN_DWELL_MS);
191
+
192
+ expect(received).toHaveLength(1);
193
+ });
194
+
195
+ it('stops observing once it has reported', () => {
196
+ render(<Section />);
197
+
198
+ const observer = latest();
199
+ observer.cross(true);
200
+ tick(SECTION_SEEN_DWELL_MS);
201
+
202
+ expect(observer.disconnected).toBe(true);
203
+ });
204
+
205
+ it('reports again after a client-side navigation', () => {
206
+ // ChromeSlot mounts the header in the root layout, which the app router
207
+ // never unmounts — so a per-mount latch would report it once per visit.
208
+ const { rerender } = render(<Section />);
209
+
210
+ latest().cross(true);
211
+ tick(SECTION_SEEN_DWELL_MS);
212
+
213
+ pathname = '/kadin';
214
+ rerender(<Section />);
215
+
216
+ // The rebuilt observer re-delivers the intersection for a header that
217
+ // never left the screen.
218
+ latest().cross(true);
219
+ tick(SECTION_SEEN_DWELL_MS);
220
+
221
+ expect(received).toHaveLength(2);
222
+ expect(received.every((d) => d.sectionId === 'sec_hero')).toBe(true);
223
+ });
224
+
225
+ it('counts the same section under two placeholders separately', () => {
226
+ render(
227
+ <>
228
+ <Section placeholderId="home-body" />
229
+ <Section placeholderId="footer" />
230
+ </>
231
+ );
232
+
233
+ RecordingObserver.instances.forEach((observer) => observer.cross(true));
234
+ tick(SECTION_SEEN_DWELL_MS);
235
+
236
+ expect(received.map((d) => d.placeholderId).sort()).toEqual([
237
+ 'footer',
238
+ 'home-body'
239
+ ]);
240
+ });
241
+
242
+ it('omits the order when the section has none', () => {
243
+ render(<Section section={{ id: 'sec_hero', type: 'custom' }} />);
244
+
245
+ latest().cross(true);
246
+ tick(SECTION_SEEN_DWELL_MS);
247
+
248
+ expect(received[0]).not.toHaveProperty('order');
249
+ });
250
+
251
+ describe('the editor', () => {
252
+ it('reports nothing while the editor is attached', () => {
253
+ window.__themeEditorDesigner = true;
254
+
255
+ render(<Section />);
256
+ latest().cross(true);
257
+ tick(FRAMED_SECTION_SEEN_DWELL_MS);
258
+
259
+ expect(received).toEqual([]);
260
+ });
261
+
262
+ it('does not latch on a muted attempt', () => {
263
+ // Latching first and emitting second would silence the section for the
264
+ // rest of the page on the strength of a render nobody saw.
265
+ window.__themeEditorDesigner = true;
266
+
267
+ render(<Section />);
268
+ latest().cross(true);
269
+ tick(FRAMED_SECTION_SEEN_DWELL_MS);
270
+
271
+ delete window.__themeEditorDesigner;
272
+ latest().cross(false);
273
+ latest().cross(true);
274
+ tick(FRAMED_SECTION_SEEN_DWELL_MS);
275
+
276
+ expect(received).toHaveLength(1);
277
+ });
278
+ });
279
+
280
+ describe('inside an iframe', () => {
281
+ const framed = () => {
282
+ Object.defineProperty(window, 'top', {
283
+ configurable: true,
284
+ value: {}
285
+ });
286
+ };
287
+
288
+ afterEach(() => {
289
+ Object.defineProperty(window, 'top', {
290
+ configurable: true,
291
+ value: window
292
+ });
293
+ });
294
+
295
+ it('waits longer, because the editor flag lands after the first callback', () => {
296
+ framed();
297
+ render(<Section />);
298
+
299
+ latest().cross(true);
300
+ tick(SECTION_SEEN_DWELL_MS);
301
+ expect(received).toEqual([]);
302
+
303
+ tick(FRAMED_SECTION_SEEN_DWELL_MS - SECTION_SEEN_DWELL_MS);
304
+ expect(received).toHaveLength(1);
305
+ });
306
+ });
307
+
308
+ it('does nothing where the browser has no IntersectionObserver', () => {
309
+ delete (window as unknown as Record<string, unknown>).IntersectionObserver;
310
+
311
+ expect(() => render(<Section />)).not.toThrow();
312
+ tick(FRAMED_SECTION_SEEN_DWELL_MS);
313
+
314
+ expect(received).toEqual([]);
315
+ });
316
+ });
@@ -0,0 +1,161 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef } from 'react';
4
+ import { usePathname } from 'next/navigation';
5
+
6
+ import { useThemeSignal } from './use-theme-signal';
7
+ import type { Section } from '../theme-section';
8
+
9
+ /**
10
+ * Report a composed section becoming visible to the shopper (ZERO-5164).
11
+ *
12
+ * The anchor of the reporting feature: a merchant adds a campaign band and
13
+ * asks whether anyone saw it. Nothing in this package observed a section
14
+ * before — the five existing IntersectionObservers all answer other questions
15
+ * (a pagination sentinel, a gallery's active image, a video's autoplay, the
16
+ * header's scroll state, a popup's scroll depth).
17
+ *
18
+ * One hook, called from the two places a section root is actually rendered:
19
+ * `SectionWrapper` for custom and registered-renderer sections, and the
20
+ * generic branch of `theme-section.tsx`. Both already hold a `sectionRef` for
21
+ * the editor's scroll-to, so nothing about either component's signature
22
+ * changes.
23
+ */
24
+
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' };
37
+
38
+ /** How long a section must hold the band before it counts as seen. */
39
+ export const SECTION_SEEN_DWELL_MS = 500;
40
+
41
+ /**
42
+ * The dwell used while the storefront is inside an iframe.
43
+ *
44
+ * `window.__themeEditorDesigner` is only set once the editor's handshake
45
+ * 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.
51
+ */
52
+ export const FRAMED_SECTION_SEEN_DWELL_MS = 1500;
53
+
54
+ const isFramed = (): boolean => {
55
+ try {
56
+ return window.self !== window.top;
57
+ } catch {
58
+ // A cross-origin parent throws on access, which is itself evidence of one.
59
+ return true;
60
+ }
61
+ };
62
+
63
+ export interface UseSectionSeenOptions {
64
+ ref: React.RefObject<HTMLElement>;
65
+ section: Pick<Section, 'id' | 'type' | 'order'>;
66
+ placeholderId: string;
67
+ }
68
+
69
+ export const useSectionSeen = ({
70
+ ref,
71
+ section,
72
+ placeholderId
73
+ }: UseSectionSeenOptions): void => {
74
+ const pathname = usePathname();
75
+ const signal = useThemeSignal({
76
+ placeholderId,
77
+ sectionId: section.id,
78
+ sectionType: section.type
79
+ });
80
+
81
+ /**
82
+ * The last key actually reported.
83
+ *
84
+ * Keyed on the route rather than the mount: `ChromeSlot` mounts the header
85
+ * and footer in the root layout, and the app router does not unmount a
86
+ * shared layout segment on a client-side navigation — so a per-mount latch
87
+ * would report those sections once per VISIT while every other section
88
+ * reports once per page. Rebuilding the observer on a pathname change
89
+ * re-delivers the intersection, which is what makes the re-report happen
90
+ * for a header that never left the screen.
91
+ */
92
+ const reported = useRef<string | null>(null);
93
+
94
+ const order = section.order;
95
+
96
+ useEffect(() => {
97
+ const node = ref.current;
98
+ if (!node) return;
99
+ if (typeof IntersectionObserver === 'undefined') return;
100
+
101
+ const latchKey = `${pathname}:${placeholderId}:${section.id}`;
102
+ if (reported.current === latchKey) return;
103
+
104
+ let timer: ReturnType<typeof setTimeout> | undefined;
105
+ const dwell = isFramed()
106
+ ? FRAMED_SECTION_SEEN_DWELL_MS
107
+ : SECTION_SEEN_DWELL_MS;
108
+
109
+ const clear = () => {
110
+ if (timer === undefined) return;
111
+ clearTimeout(timer);
112
+ timer = undefined;
113
+ };
114
+
115
+ const observer = new IntersectionObserver((entries) => {
116
+ const entry = entries[entries.length - 1];
117
+ if (!entry) return;
118
+
119
+ // Left the band before the dwell elapsed: scrolled past, not seen.
120
+ if (!entry.isIntersecting) {
121
+ clear();
122
+ return;
123
+ }
124
+
125
+ if (timer !== undefined) return;
126
+
127
+ timer = setTimeout(() => {
128
+ timer = undefined;
129
+ if (reported.current === latchKey) return;
130
+
131
+ const sent = signal(
132
+ 'section.seen',
133
+ {
134
+ ...(typeof order === 'number' && Number.isFinite(order)
135
+ ? { order }
136
+ : null)
137
+ },
138
+ { key: latchKey, once: true }
139
+ );
140
+
141
+ // Latched only on a report that actually left. Writing the latch
142
+ // first and emitting second is how a muted render — the editor
143
+ // canvas, or a consent gate that has not answered yet — silences a
144
+ // section for the rest of the page with nothing to show for it.
145
+ if (sent) {
146
+ reported.current = latchKey;
147
+ observer.disconnect();
148
+ }
149
+ }, dwell);
150
+ }, SEEN_BAND);
151
+
152
+ observer.observe(node);
153
+
154
+ return () => {
155
+ clear();
156
+ observer.disconnect();
157
+ };
158
+ }, [ref, pathname, placeholderId, section.id, order, signal]);
159
+ };
160
+
161
+ export default useSectionSeen;
@@ -9,6 +9,7 @@ import { SECTION_CONTENT_ATTRIBUTE } from '../utils/section-content-width';
9
9
 
10
10
  import { isTrustedEditorEvent, postToEditor } from '../editor-bridge';
11
11
  import { resolveSectionPartName } from '../hooks/use-designer-features';
12
+ import { useSectionSeen } from '../hooks/use-section-seen';
12
13
 
13
14
 
14
15
  interface SectionWrapperProps {
@@ -65,6 +66,12 @@ export default function SectionWrapper({
65
66
 
66
67
  const sectionRef = useRef<HTMLElement>(null);
67
68
 
69
+ // Report this section becoming visible (ZERO-5164). Inert in the editor:
70
+ // the emitter reads the editor handshake flag itself, so there is no
71
+ // `isDesigner` check here — that prop goes false in browse mode while the
72
+ // editor is still attached.
73
+ useSectionSeen({ ref: sectionRef, section, placeholderId });
74
+
68
75
  // Custom-renderer sections render through this wrapper instead of the
69
76
  // default <section> in theme-section, so the editor's SCROLL_TO_SECTION
70
77
  // must be handled here too.
@@ -29,6 +29,7 @@ import { getCustomSectionDefinition } from './custom/section-definitions';
29
29
  import { isCustomThemeType } from './custom/types';
30
30
  import { ThemeSectionDataProvider } from './theme-section-data-context';
31
31
  import { PreviewToEditorMessageType } from '@akinon/theme-contract';
32
+ import { useSectionSeen } from './hooks/use-section-seen';
32
33
  export interface Section {
33
34
  id: string;
34
35
  type: string;
@@ -137,6 +138,11 @@ export default function ThemeSection({
137
138
 
138
139
  const sectionRef = useRef<HTMLElement>(null);
139
140
 
141
+ // Report this section becoming visible (ZERO-5164). The generic branch
142
+ // below renders its own <section>; sections that go through SectionWrapper
143
+ // are reported there, so exactly one of the two runs per section.
144
+ useSectionSeen({ ref: sectionRef, section: effectiveSection, placeholderId });
145
+
140
146
  const constrainsContent = hasSectionContentWidth(effectiveSection.styles);
141
147
 
142
148
  // Bound prices are formatted deep inside the binding walk, which has no