@akinon/pz-theme 2.0.128-beta.0 → 2.0.128
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 +4 -4
- package/package.json +4 -4
- package/src/__tests__/commerce-channel-frozen.test.ts +118 -0
- package/src/custom/README-sections.md +13 -0
- package/src/custom/README.md +79 -0
- package/src/custom/__tests__/custom-entry-exports.test.tsx +36 -2
- package/src/custom/index.ts +38 -0
- package/src/hooks/__tests__/use-theme-signal.test.tsx +241 -0
- package/src/hooks/use-theme-signal.ts +144 -0
- package/src/overlays/__tests__/server-read-path-client-free.test.ts +8 -2
- package/src/utils/__tests__/theme-signal.test.ts +284 -0
- package/src/utils/theme-signal.ts +419 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { devWarn } from './dev-warn';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Neutral interaction signals for theme-composed sections and blocks
|
|
5
|
+
* (ZERO-5156).
|
|
6
|
+
*
|
|
7
|
+
* The sibling of `analytics.ts`, for the moments that are not commerce: a
|
|
8
|
+
* section scrolling into view, a slide changing, a modal opening, a promo
|
|
9
|
+
* being dismissed. Same doctrine — the theme reports a FACT in a neutral
|
|
10
|
+
* vocabulary and never knows GA4, Insider or any tag manager exists; a brand
|
|
11
|
+
* maps the fact to its own schema in its own repo, and a brand that listens
|
|
12
|
+
* to nothing gets exactly zero behaviour change.
|
|
13
|
+
*
|
|
14
|
+
* Why a second channel rather than a new action on `pz:commerce`: the one
|
|
15
|
+
* bridge that exists in the fleet ends in an unconditional flat dataLayer
|
|
16
|
+
* push, reached today only by `search`. The first UI moment named on that
|
|
17
|
+
* channel would start writing bare events into a live GA4 property the day
|
|
18
|
+
* the brand bumps this package. `pz:theme` cannot reach that listener at all,
|
|
19
|
+
* and `attachGa4DataLayerBridge` listens on the commerce name and never sees
|
|
20
|
+
* this one.
|
|
21
|
+
*
|
|
22
|
+
* This module is the ONLY place that dispatches the new channel, so the three
|
|
23
|
+
* rules every moment needs live here once instead of in thirty call sites:
|
|
24
|
+
* silence while the theme editor is attached, a latch so a repeat is a no-op,
|
|
25
|
+
* and a ceiling so an autoplaying carousel cannot bill a brand for a thousand
|
|
26
|
+
* hits an hour.
|
|
27
|
+
*
|
|
28
|
+
* It carries no `'use client'` and imports only `./dev-warn`, which imports
|
|
29
|
+
* nothing. That is deliberate and pinned by a test: a module reachable from a
|
|
30
|
+
* server render must not pull a client reference in, and the emitter has to be
|
|
31
|
+
* callable from plain modules (the overlay bus, a delegated DOM listener) as
|
|
32
|
+
* well as from components.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
export const THEME_SIGNAL_EVENT_NAME = 'pz:theme';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The moments this build can report.
|
|
39
|
+
*
|
|
40
|
+
* A closed union rather than `string`, for three reasons: a typo fails the
|
|
41
|
+
* build instead of producing a silent second event name in a brand's GA4
|
|
42
|
+
* property; the editor's capability announce can be derived from the frozen
|
|
43
|
+
* array below rather than maintained beside it; and a brand bridge can switch
|
|
44
|
+
* exhaustively.
|
|
45
|
+
*
|
|
46
|
+
* Dot-namespaced so a name can never collide with a GA4 reserved or
|
|
47
|
+
* recommended event (`share`, `video_start`, `select_promotion`). What a
|
|
48
|
+
* moment MEANS in analytics terms is the brand's decision, taken in its
|
|
49
|
+
* bridge or in its container — the theme only says what happened.
|
|
50
|
+
*/
|
|
51
|
+
export type KnownThemeSignalName =
|
|
52
|
+
| 'section.seen'
|
|
53
|
+
| 'slide.changed'
|
|
54
|
+
| 'slide.control'
|
|
55
|
+
| 'modal.opened'
|
|
56
|
+
| 'modal.closed'
|
|
57
|
+
| 'popup.triggered'
|
|
58
|
+
| 'popup.suppressed'
|
|
59
|
+
| 'panel.opened'
|
|
60
|
+
| 'drawer.opened'
|
|
61
|
+
| 'tab.changed'
|
|
62
|
+
| 'disclosure.toggled'
|
|
63
|
+
| 'nav.clicked'
|
|
64
|
+
| 'promo.rotated'
|
|
65
|
+
| 'promo.dismissed'
|
|
66
|
+
| 'promo.suppressed'
|
|
67
|
+
| 'coupon.applied'
|
|
68
|
+
| 'coupon.removed'
|
|
69
|
+
| 'sort.changed'
|
|
70
|
+
| 'filter.applied'
|
|
71
|
+
| 'filter.cleared'
|
|
72
|
+
| 'share.clicked'
|
|
73
|
+
| 'countdown.expired'
|
|
74
|
+
| 'video.played'
|
|
75
|
+
| 'video.paused';
|
|
76
|
+
|
|
77
|
+
export const THEME_SIGNAL_NAMES: readonly KnownThemeSignalName[] = [
|
|
78
|
+
'section.seen',
|
|
79
|
+
'slide.changed',
|
|
80
|
+
'slide.control',
|
|
81
|
+
'modal.opened',
|
|
82
|
+
'modal.closed',
|
|
83
|
+
'popup.triggered',
|
|
84
|
+
'popup.suppressed',
|
|
85
|
+
'panel.opened',
|
|
86
|
+
'drawer.opened',
|
|
87
|
+
'tab.changed',
|
|
88
|
+
'disclosure.toggled',
|
|
89
|
+
'nav.clicked',
|
|
90
|
+
'promo.rotated',
|
|
91
|
+
'promo.dismissed',
|
|
92
|
+
'promo.suppressed',
|
|
93
|
+
'coupon.applied',
|
|
94
|
+
'coupon.removed',
|
|
95
|
+
'sort.changed',
|
|
96
|
+
'filter.applied',
|
|
97
|
+
'filter.cleared',
|
|
98
|
+
'share.clicked',
|
|
99
|
+
'countdown.expired',
|
|
100
|
+
'video.played',
|
|
101
|
+
'video.paused'
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
const KNOWN_NAMES = new Set<string>(THEME_SIGNAL_NAMES);
|
|
105
|
+
|
|
106
|
+
/** The prefix a brand's own moment must carry. */
|
|
107
|
+
export const CUSTOM_SIGNAL_PREFIX = 'custom.';
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A brand-authored moment, namespaced so it can never collide with a name this
|
|
111
|
+
* package adds later and a bridge can tell the two apart at a glance.
|
|
112
|
+
*/
|
|
113
|
+
export type CustomThemeSignalName = `${typeof CUSTOM_SIGNAL_PREFIX}${string}`;
|
|
114
|
+
|
|
115
|
+
export type ThemeSignalName = KnownThemeSignalName | CustomThemeSignalName;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* What a fact may be.
|
|
119
|
+
*
|
|
120
|
+
* Closed to flat scalars on purpose — the one deliberate difference from
|
|
121
|
+
* `CommerceEventDetail`, which carries an open index signature and a product
|
|
122
|
+
* array because GA4 ecommerce demands one. Here there is no shape in which a
|
|
123
|
+
* basket line, a form state, a DOM node or a shopper's typed text can be
|
|
124
|
+
* attached "just for now", which is the no-PII rule expressed structurally
|
|
125
|
+
* rather than as a review convention. Non-scalars are dropped at emit, so a
|
|
126
|
+
* cast does not get one through either.
|
|
127
|
+
*/
|
|
128
|
+
export type ThemeSignalValue = string | number | boolean;
|
|
129
|
+
|
|
130
|
+
/** Where the moment happened. Every field optional: omit, never fabricate. */
|
|
131
|
+
export interface ThemeSignalIdentity {
|
|
132
|
+
placeholderId?: string;
|
|
133
|
+
sectionId?: string;
|
|
134
|
+
sectionType?: string;
|
|
135
|
+
blockId?: string;
|
|
136
|
+
blockType?: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* What a caller passes. `null` and `undefined` members are dropped rather than
|
|
141
|
+
* sent, so a caller can spread an optional value without a conditional.
|
|
142
|
+
*/
|
|
143
|
+
export interface ThemeSignalFacts extends ThemeSignalIdentity {
|
|
144
|
+
[key: string]: ThemeSignalValue | null | undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** What rides on the event. `name`, never `action` — see `emitThemeSignal`. */
|
|
148
|
+
export interface ThemeSignalDetail extends ThemeSignalIdentity {
|
|
149
|
+
name: ThemeSignalName;
|
|
150
|
+
[key: string]: ThemeSignalValue | undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface ThemeSignalOptions {
|
|
154
|
+
/**
|
|
155
|
+
* An extra dimension on the tally, composited as `name:key`, so two sections
|
|
156
|
+
* count separately while one section counts once. The composite shape is the
|
|
157
|
+
* one the page-scope emitter already uses for its own latch.
|
|
158
|
+
*/
|
|
159
|
+
key?: string;
|
|
160
|
+
/** Shorthand for `ceiling: 1` — the latch. */
|
|
161
|
+
once?: boolean;
|
|
162
|
+
/**
|
|
163
|
+
* How many times this composite key may fire before the counter resets.
|
|
164
|
+
* `Infinity` opts out. Defaults to `DEFAULT_SIGNAL_CEILING`.
|
|
165
|
+
*/
|
|
166
|
+
ceiling?: number;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Per-key flood guard.
|
|
171
|
+
*
|
|
172
|
+
* A six-slide hero at the default autoplay speed changes slide every three
|
|
173
|
+
* seconds — around 1200 `slide.changed` an hour on a tab left open, each one
|
|
174
|
+
* billed to whatever the brand forwards to. Twenty-four is far above any
|
|
175
|
+
* honest reading of a single page view and far below a runaway.
|
|
176
|
+
*/
|
|
177
|
+
export const DEFAULT_SIGNAL_CEILING = 24;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Total emits allowed before the counter resets, across every key.
|
|
181
|
+
*
|
|
182
|
+
* The per-key ceiling cannot catch a composition that is chatty in aggregate —
|
|
183
|
+
* forty tracked sections each emitting their legitimate once. This bounds the
|
|
184
|
+
* page as a whole.
|
|
185
|
+
*/
|
|
186
|
+
export const SIGNAL_BUDGET_PER_ROUTE = 200;
|
|
187
|
+
|
|
188
|
+
/** Longest string a fact may carry before it is truncated. */
|
|
189
|
+
const MAX_VALUE_LENGTH = 120;
|
|
190
|
+
|
|
191
|
+
/** Most facts one detail may carry, identity included. */
|
|
192
|
+
const MAX_FACT_COUNT = 24;
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The tally, and the route it belongs to.
|
|
196
|
+
*
|
|
197
|
+
* Reset when `location.pathname` changes, read at emit time — no router
|
|
198
|
+
* import, no navigation listener, nothing to unsubscribe. This is the module
|
|
199
|
+
* equivalent of the per-mount refs the page-scope emitter uses: a client-side
|
|
200
|
+
* visit to another page reports again, a re-render of the same page does not.
|
|
201
|
+
*/
|
|
202
|
+
let tally = new Map<string, number>();
|
|
203
|
+
let tallyPathname: string | null = null;
|
|
204
|
+
let spent = 0;
|
|
205
|
+
|
|
206
|
+
const currentPathname = (): string =>
|
|
207
|
+
typeof window === 'undefined' ? '' : (window.location?.pathname ?? '');
|
|
208
|
+
|
|
209
|
+
const rollTallyForRoute = (): void => {
|
|
210
|
+
const pathname = currentPathname();
|
|
211
|
+
if (pathname === tallyPathname) return;
|
|
212
|
+
|
|
213
|
+
tallyPathname = pathname;
|
|
214
|
+
tally = new Map();
|
|
215
|
+
spent = 0;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* True while the theme editor is attached — edit mode AND browse mode.
|
|
220
|
+
*
|
|
221
|
+
* Reads `window.__themeEditorDesigner` inline, the flag set only after a
|
|
222
|
+
* trusted SET_THEME_EDITOR_COOKIE arrives through the origin-guarded bridge.
|
|
223
|
+
* Four modules in this package read it exactly this way rather than importing
|
|
224
|
+
* it, so that the module holding it does not drag a client graph behind it.
|
|
225
|
+
*
|
|
226
|
+
* THE RAW FLAG, never an `isDesigner` a caller passes in. A block receives
|
|
227
|
+
* `isDesigner && !isBrowseMode`, so the moment a designer switches to browse
|
|
228
|
+
* mode that prop goes false while the editor is still attached — which is why
|
|
229
|
+
* the block-level commerce emits currently report a designer's clicks as a
|
|
230
|
+
* shopper's. Reading the flag here means no signal added through this module
|
|
231
|
+
* can inherit that defect, and no call site has to remember the distinction.
|
|
232
|
+
*
|
|
233
|
+
* Exported so a caller can skip expensive preparation (attaching an observer,
|
|
234
|
+
* measuring) rather than build a payload that would be thrown away.
|
|
235
|
+
*/
|
|
236
|
+
export const isThemeSignalMuted = (): boolean =>
|
|
237
|
+
typeof window !== 'undefined' &&
|
|
238
|
+
(window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner ===
|
|
239
|
+
true;
|
|
240
|
+
|
|
241
|
+
const isKnownName = (name: string): boolean =>
|
|
242
|
+
KNOWN_NAMES.has(name) ||
|
|
243
|
+
(name.startsWith(CUSTOM_SIGNAL_PREFIX) &&
|
|
244
|
+
name.length > CUSTOM_SIGNAL_PREFIX.length);
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* One fact, or nothing.
|
|
248
|
+
*
|
|
249
|
+
* Objects, arrays, functions and symbols are dropped rather than serialized:
|
|
250
|
+
* the payload contract is flat scalars, and the failure mode of "serialize it
|
|
251
|
+
* anyway" is a product object or a form state reaching a third-party tag.
|
|
252
|
+
*/
|
|
253
|
+
const sanitizeValue = (value: unknown): ThemeSignalValue | undefined => {
|
|
254
|
+
if (typeof value === 'string') {
|
|
255
|
+
const text = value.trim();
|
|
256
|
+
if (!text) return undefined;
|
|
257
|
+
return text.length > MAX_VALUE_LENGTH
|
|
258
|
+
? text.slice(0, MAX_VALUE_LENGTH)
|
|
259
|
+
: text;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (typeof value === 'number') {
|
|
263
|
+
return Number.isFinite(value) ? value : undefined;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (typeof value === 'boolean') return value;
|
|
267
|
+
|
|
268
|
+
return undefined;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const sanitizeFacts = (
|
|
272
|
+
name: string,
|
|
273
|
+
facts: ThemeSignalFacts
|
|
274
|
+
): Record<string, ThemeSignalValue> => {
|
|
275
|
+
const clean: Record<string, ThemeSignalValue> = Object.create(null);
|
|
276
|
+
let count = 0;
|
|
277
|
+
|
|
278
|
+
for (const [key, value] of Object.entries(facts)) {
|
|
279
|
+
// `name` is set by the emitter; a caller passing one would otherwise
|
|
280
|
+
// rename the moment from inside its own payload.
|
|
281
|
+
if (key === 'name') continue;
|
|
282
|
+
if (value === null || value === undefined) continue;
|
|
283
|
+
|
|
284
|
+
if (count >= MAX_FACT_COUNT) {
|
|
285
|
+
devWarn(
|
|
286
|
+
'themeSignal',
|
|
287
|
+
`"${name}" carries more than ${MAX_FACT_COUNT} facts; the rest are dropped`
|
|
288
|
+
);
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const scalar = sanitizeValue(value);
|
|
293
|
+
if (scalar === undefined) {
|
|
294
|
+
devWarn(
|
|
295
|
+
'themeSignal',
|
|
296
|
+
`"${name}" fact "${key}" is not a string, finite number or boolean; dropped`
|
|
297
|
+
);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
clean[key] = scalar;
|
|
302
|
+
count += 1;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return clean;
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Report one theme moment.
|
|
310
|
+
*
|
|
311
|
+
* Fire-and-forget by the same rule the commerce emitter follows: observability
|
|
312
|
+
* must never take a block down, so everything here is guarded and a failure is
|
|
313
|
+
* silent in production.
|
|
314
|
+
*
|
|
315
|
+
* The detail names the moment under `name`, not `action`. That is a deliberate
|
|
316
|
+
* difference from the commerce channel: a bridge accidentally pointed at the
|
|
317
|
+
* wrong channel reads `detail.action === undefined` and drops the frame,
|
|
318
|
+
* instead of pushing a nameless event into a live property.
|
|
319
|
+
*
|
|
320
|
+
* @returns whether it dispatched, so a caller can stop observing once a
|
|
321
|
+
* one-shot moment has been reported.
|
|
322
|
+
*/
|
|
323
|
+
export const emitThemeSignal = (
|
|
324
|
+
name: ThemeSignalName,
|
|
325
|
+
facts: ThemeSignalFacts = {},
|
|
326
|
+
options: ThemeSignalOptions = {}
|
|
327
|
+
): boolean => {
|
|
328
|
+
if (typeof window === 'undefined') return false;
|
|
329
|
+
|
|
330
|
+
// Before anything else: the editor canvas and browse mode report nothing.
|
|
331
|
+
if (isThemeSignalMuted()) return false;
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
if (!isKnownName(name)) {
|
|
335
|
+
devWarn(
|
|
336
|
+
'themeSignal',
|
|
337
|
+
`unknown signal name "${name}"; add it to THEME_SIGNAL_NAMES or prefix it with "${CUSTOM_SIGNAL_PREFIX}"`
|
|
338
|
+
);
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
rollTallyForRoute();
|
|
343
|
+
|
|
344
|
+
if (spent >= SIGNAL_BUDGET_PER_ROUTE) return false;
|
|
345
|
+
|
|
346
|
+
const ceiling = options.once
|
|
347
|
+
? 1
|
|
348
|
+
: (options.ceiling ?? DEFAULT_SIGNAL_CEILING);
|
|
349
|
+
|
|
350
|
+
if (ceiling !== Infinity) {
|
|
351
|
+
const tallyKey = options.key ? `${name}:${options.key}` : name;
|
|
352
|
+
const seen = tally.get(tallyKey) ?? 0;
|
|
353
|
+
if (seen >= ceiling) return false;
|
|
354
|
+
tally.set(tallyKey, seen + 1);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
spent += 1;
|
|
358
|
+
|
|
359
|
+
window.dispatchEvent(
|
|
360
|
+
new CustomEvent<ThemeSignalDetail>(THEME_SIGNAL_EVENT_NAME, {
|
|
361
|
+
detail: { name, ...sanitizeFacts(name, facts) }
|
|
362
|
+
})
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
return true;
|
|
366
|
+
} catch {
|
|
367
|
+
// Never let a reporting failure surface as a storefront error.
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
export interface ThemeSignalListenerOptions {
|
|
373
|
+
/** Only these moments reach the listener. */
|
|
374
|
+
names?: readonly ThemeSignalName[];
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Listen for theme moments.
|
|
379
|
+
*
|
|
380
|
+
* A typed wrapper over `addEventListener` so a brand does not hand-roll the
|
|
381
|
+
* CustomEvent cast, and so the event name lives in one place. Returns a detach
|
|
382
|
+
* function, which is what makes `useEffect(() => subscribeToThemeSignals(fn), [])`
|
|
383
|
+
* the whole integration on the brand side.
|
|
384
|
+
*
|
|
385
|
+
* No default GA4 bridge ships for this channel, on purpose: `add_to_cart` has
|
|
386
|
+
* an industry-standard mapping, "promo dismissed" does not, and shipping one
|
|
387
|
+
* would be this package choosing a brand's analytics schema.
|
|
388
|
+
*/
|
|
389
|
+
export const subscribeToThemeSignals = (
|
|
390
|
+
listener: (detail: ThemeSignalDetail) => void,
|
|
391
|
+
options: ThemeSignalListenerOptions = {}
|
|
392
|
+
): (() => void) => {
|
|
393
|
+
if (typeof window === 'undefined') return () => {};
|
|
394
|
+
|
|
395
|
+
const wanted = options.names ? new Set<string>(options.names) : null;
|
|
396
|
+
|
|
397
|
+
const handler = (event: Event) => {
|
|
398
|
+
const detail = (event as CustomEvent<ThemeSignalDetail>).detail;
|
|
399
|
+
if (!detail?.name) return;
|
|
400
|
+
if (wanted && !wanted.has(detail.name)) return;
|
|
401
|
+
|
|
402
|
+
try {
|
|
403
|
+
listener(detail);
|
|
404
|
+
} catch {
|
|
405
|
+
// A brand callback that throws must not break the dispatch for others.
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
window.addEventListener(THEME_SIGNAL_EVENT_NAME, handler);
|
|
410
|
+
|
|
411
|
+
return () => window.removeEventListener(THEME_SIGNAL_EVENT_NAME, handler);
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
/** Test-only: forget the route tally, matching the overlay bus reset helper. */
|
|
415
|
+
export const __resetThemeSignalsForTests = (): void => {
|
|
416
|
+
tally = new Map();
|
|
417
|
+
tallyPathname = null;
|
|
418
|
+
spent = 0;
|
|
419
|
+
};
|