@akinon/pz-theme 2.0.133-rc.0 → 2.0.134-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 [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.134-beta.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [cbbbfd757]
|
|
8
|
+
- @akinon/next@2.0.134-beta.0
|
|
9
|
+
- @akinon/theme-contract@2.0.134-beta.0
|
|
10
|
+
|
|
11
|
+
## 2.0.133
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- 114d857: ZERO-5164: Report a composed section becoming visible as section.seen
|
|
16
|
+
- @akinon/next@2.0.133
|
|
17
|
+
- @akinon/theme-contract@2.0.133
|
|
38
18
|
|
|
39
19
|
## 2.0.132
|
|
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.134-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.134-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.134-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.134-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",
|
|
@@ -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.
|
package/src/theme-section.tsx
CHANGED
|
@@ -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
|