@affectively/aeon-pages 1.3.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 +112 -0
- package/README.md +625 -0
- package/examples/basic/aeon.config.ts +39 -0
- package/examples/basic/components/Cursor.tsx +86 -0
- package/examples/basic/components/OfflineIndicator.tsx +103 -0
- package/examples/basic/components/PresenceBar.tsx +77 -0
- package/examples/basic/package.json +20 -0
- package/examples/basic/pages/index.tsx +80 -0
- package/package.json +101 -0
- package/packages/analytics/README.md +309 -0
- package/packages/analytics/build.ts +35 -0
- package/packages/analytics/package.json +50 -0
- package/packages/analytics/src/click-tracker.ts +368 -0
- package/packages/analytics/src/context-bridge.ts +319 -0
- package/packages/analytics/src/data-layer.ts +302 -0
- package/packages/analytics/src/gtm-loader.ts +239 -0
- package/packages/analytics/src/index.ts +230 -0
- package/packages/analytics/src/merkle-tree.ts +489 -0
- package/packages/analytics/src/provider.tsx +300 -0
- package/packages/analytics/src/types.ts +320 -0
- package/packages/analytics/src/use-analytics.ts +296 -0
- package/packages/analytics/tsconfig.json +19 -0
- package/packages/benchmarks/src/benchmark.test.ts +691 -0
- package/packages/cli/dist/index.js +61899 -0
- package/packages/cli/package.json +43 -0
- package/packages/cli/src/commands/build.test.ts +682 -0
- package/packages/cli/src/commands/build.ts +890 -0
- package/packages/cli/src/commands/dev.ts +473 -0
- package/packages/cli/src/commands/init.ts +409 -0
- package/packages/cli/src/commands/start.ts +297 -0
- package/packages/cli/src/index.ts +105 -0
- package/packages/directives/src/use-aeon.ts +272 -0
- package/packages/mcp-server/package.json +51 -0
- package/packages/mcp-server/src/index.ts +178 -0
- package/packages/mcp-server/src/resources.ts +346 -0
- package/packages/mcp-server/src/tools/index.ts +36 -0
- package/packages/mcp-server/src/tools/navigation.ts +545 -0
- package/packages/mcp-server/tsconfig.json +21 -0
- package/packages/react/package.json +40 -0
- package/packages/react/src/Link.tsx +388 -0
- package/packages/react/src/components/InstallPrompt.tsx +286 -0
- package/packages/react/src/components/OfflineDiagnostics.tsx +677 -0
- package/packages/react/src/components/PushNotifications.tsx +453 -0
- package/packages/react/src/hooks/useAeonNavigation.ts +219 -0
- package/packages/react/src/hooks/useConflicts.ts +277 -0
- package/packages/react/src/hooks/useNetworkState.ts +209 -0
- package/packages/react/src/hooks/usePilotNavigation.ts +254 -0
- package/packages/react/src/hooks/useServiceWorker.ts +278 -0
- package/packages/react/src/hooks.ts +195 -0
- package/packages/react/src/index.ts +151 -0
- package/packages/react/src/provider.tsx +467 -0
- package/packages/react/tsconfig.json +19 -0
- package/packages/runtime/README.md +399 -0
- package/packages/runtime/build.ts +48 -0
- package/packages/runtime/package.json +71 -0
- package/packages/runtime/schema.sql +40 -0
- package/packages/runtime/src/api-routes.ts +465 -0
- package/packages/runtime/src/benchmark.ts +171 -0
- package/packages/runtime/src/cache.ts +479 -0
- package/packages/runtime/src/durable-object.ts +1341 -0
- package/packages/runtime/src/index.ts +360 -0
- package/packages/runtime/src/navigation.test.ts +421 -0
- package/packages/runtime/src/navigation.ts +422 -0
- package/packages/runtime/src/nextjs-adapter.ts +272 -0
- package/packages/runtime/src/offline/encrypted-queue.test.ts +607 -0
- package/packages/runtime/src/offline/encrypted-queue.ts +478 -0
- package/packages/runtime/src/offline/encryption.test.ts +412 -0
- package/packages/runtime/src/offline/encryption.ts +397 -0
- package/packages/runtime/src/offline/types.ts +465 -0
- package/packages/runtime/src/predictor.ts +371 -0
- package/packages/runtime/src/registry.ts +351 -0
- package/packages/runtime/src/router/context-extractor.ts +661 -0
- package/packages/runtime/src/router/esi-control-react.tsx +2053 -0
- package/packages/runtime/src/router/esi-control.ts +541 -0
- package/packages/runtime/src/router/esi-cyrano.ts +779 -0
- package/packages/runtime/src/router/esi-format-react.tsx +1744 -0
- package/packages/runtime/src/router/esi-react.tsx +1065 -0
- package/packages/runtime/src/router/esi-translate-observer.ts +476 -0
- package/packages/runtime/src/router/esi-translate-react.tsx +556 -0
- package/packages/runtime/src/router/esi-translate.ts +503 -0
- package/packages/runtime/src/router/esi.ts +666 -0
- package/packages/runtime/src/router/heuristic-adapter.test.ts +295 -0
- package/packages/runtime/src/router/heuristic-adapter.ts +557 -0
- package/packages/runtime/src/router/index.ts +298 -0
- package/packages/runtime/src/router/merkle-capability.ts +473 -0
- package/packages/runtime/src/router/speculation.ts +451 -0
- package/packages/runtime/src/router/types.ts +630 -0
- package/packages/runtime/src/router.test.ts +470 -0
- package/packages/runtime/src/router.ts +302 -0
- package/packages/runtime/src/server.ts +481 -0
- package/packages/runtime/src/service-worker-push.ts +319 -0
- package/packages/runtime/src/service-worker.ts +553 -0
- package/packages/runtime/src/skeleton-hydrate.ts +237 -0
- package/packages/runtime/src/speculation.test.ts +389 -0
- package/packages/runtime/src/speculation.ts +486 -0
- package/packages/runtime/src/storage.test.ts +1297 -0
- package/packages/runtime/src/storage.ts +1048 -0
- package/packages/runtime/src/sync/conflict-resolver.test.ts +528 -0
- package/packages/runtime/src/sync/conflict-resolver.ts +565 -0
- package/packages/runtime/src/sync/coordinator.test.ts +608 -0
- package/packages/runtime/src/sync/coordinator.ts +596 -0
- package/packages/runtime/src/tree-compiler.ts +295 -0
- package/packages/runtime/src/types.ts +728 -0
- package/packages/runtime/src/worker.ts +327 -0
- package/packages/runtime/tsconfig.json +20 -0
- package/packages/runtime/wasm/aeon_pages_runtime.d.ts +504 -0
- package/packages/runtime/wasm/aeon_pages_runtime.js +1657 -0
- package/packages/runtime/wasm/aeon_pages_runtime_bg.wasm +0 -0
- package/packages/runtime/wasm/aeon_pages_runtime_bg.wasm.d.ts +196 -0
- package/packages/runtime/wasm/package.json +21 -0
- package/packages/runtime/wrangler.toml +41 -0
- package/packages/runtime-wasm/Cargo.lock +436 -0
- package/packages/runtime-wasm/Cargo.toml +29 -0
- package/packages/runtime-wasm/pkg/aeon_pages_runtime.d.ts +480 -0
- package/packages/runtime-wasm/pkg/aeon_pages_runtime.js +1568 -0
- package/packages/runtime-wasm/pkg/aeon_pages_runtime_bg.wasm +0 -0
- package/packages/runtime-wasm/pkg/aeon_pages_runtime_bg.wasm.d.ts +192 -0
- package/packages/runtime-wasm/pkg/package.json +21 -0
- package/packages/runtime-wasm/src/hydrate.rs +352 -0
- package/packages/runtime-wasm/src/lib.rs +191 -0
- package/packages/runtime-wasm/src/render.rs +629 -0
- package/packages/runtime-wasm/src/router.rs +298 -0
- package/packages/runtime-wasm/src/skeleton.rs +430 -0
- package/rfcs/RFC-001-ZERO-DEPENDENCY-RENDERING.md +1446 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automatic Click Tracker
|
|
3
|
+
*
|
|
4
|
+
* Intercepts all clicks using event delegation and automatically
|
|
5
|
+
* tracks them to GTM dataLayer with Merkle tree context.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Single delegated listener (no per-element handlers)
|
|
9
|
+
* - Walks up DOM to find Merkle-annotated ancestor
|
|
10
|
+
* - Captures full tree path and element metadata
|
|
11
|
+
* - Includes ESI context snapshot with each click
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { AnalyticsConfig, ClickTrackingOptions, ESIState } from './types';
|
|
15
|
+
import {
|
|
16
|
+
extractElementInfo,
|
|
17
|
+
extractPositionInfo,
|
|
18
|
+
pushClickEvent,
|
|
19
|
+
} from './data-layer';
|
|
20
|
+
import {
|
|
21
|
+
parseMerkleFromElement,
|
|
22
|
+
findNearestMerkleElement,
|
|
23
|
+
} from './merkle-tree';
|
|
24
|
+
import { getESIContextSnapshot } from './context-bridge';
|
|
25
|
+
|
|
26
|
+
// ============================================================================
|
|
27
|
+
// Types
|
|
28
|
+
// ============================================================================
|
|
29
|
+
|
|
30
|
+
interface ClickHandler {
|
|
31
|
+
listener: (event: MouseEvent) => void;
|
|
32
|
+
cleanup: () => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ============================================================================
|
|
36
|
+
// State
|
|
37
|
+
// ============================================================================
|
|
38
|
+
|
|
39
|
+
/** Active click handler */
|
|
40
|
+
let activeHandler: ClickHandler | null = null;
|
|
41
|
+
|
|
42
|
+
/** Debounce timer */
|
|
43
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
44
|
+
|
|
45
|
+
/** Last click timestamp for debouncing */
|
|
46
|
+
let lastClickTime = 0;
|
|
47
|
+
|
|
48
|
+
// ============================================================================
|
|
49
|
+
// Click Handler
|
|
50
|
+
// ============================================================================
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Check if element should be excluded from tracking
|
|
54
|
+
*/
|
|
55
|
+
function shouldExclude(
|
|
56
|
+
element: HTMLElement,
|
|
57
|
+
excludeSelectors: string[],
|
|
58
|
+
): boolean {
|
|
59
|
+
for (const selector of excludeSelectors) {
|
|
60
|
+
if (element.matches(selector) || element.closest(selector)) {
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Create click event handler
|
|
69
|
+
*/
|
|
70
|
+
function createClickHandler(
|
|
71
|
+
config: AnalyticsConfig,
|
|
72
|
+
options: ClickTrackingOptions,
|
|
73
|
+
): (event: MouseEvent) => void {
|
|
74
|
+
return (event: MouseEvent) => {
|
|
75
|
+
const target = event.target as HTMLElement;
|
|
76
|
+
|
|
77
|
+
if (!target || !(target instanceof HTMLElement)) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Check exclusions
|
|
82
|
+
if (options.excludeSelectors?.length) {
|
|
83
|
+
if (shouldExclude(target, options.excludeSelectors)) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Debounce if configured
|
|
89
|
+
if (options.debounceMs && options.debounceMs > 0) {
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
if (now - lastClickTime < options.debounceMs) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
lastClickTime = now;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Find nearest element with Merkle attributes
|
|
98
|
+
const merkleElement = findNearestMerkleElement(target);
|
|
99
|
+
|
|
100
|
+
// Parse Merkle info
|
|
101
|
+
let merkleHash = 'unknown';
|
|
102
|
+
let treePath: string[] = [];
|
|
103
|
+
let treePathHashes: string[] = [];
|
|
104
|
+
|
|
105
|
+
if (merkleElement) {
|
|
106
|
+
const merkleInfo = parseMerkleFromElement(merkleElement);
|
|
107
|
+
if (merkleInfo) {
|
|
108
|
+
merkleHash = merkleInfo.hash;
|
|
109
|
+
treePath = options.includeTreePath !== false ? merkleInfo.path : [];
|
|
110
|
+
treePathHashes =
|
|
111
|
+
options.includeTreePath !== false ? merkleInfo.pathHashes : [];
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
// No Merkle element found - generate path from DOM structure
|
|
115
|
+
treePath = generateDOMPath(target);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Extract element info (from clicked element, not Merkle ancestor)
|
|
119
|
+
const elementInfo = extractElementInfo(target, options.maxTextLength);
|
|
120
|
+
|
|
121
|
+
// Extract position if enabled
|
|
122
|
+
const position =
|
|
123
|
+
options.includePosition !== false
|
|
124
|
+
? extractPositionInfo(event)
|
|
125
|
+
: { x: 0, y: 0, viewportX: 0, viewportY: 0 };
|
|
126
|
+
|
|
127
|
+
// Get ESI context snapshot
|
|
128
|
+
const context = getESIContextSnapshot();
|
|
129
|
+
|
|
130
|
+
// Push to dataLayer
|
|
131
|
+
pushClickEvent(
|
|
132
|
+
merkleHash,
|
|
133
|
+
treePath,
|
|
134
|
+
treePathHashes,
|
|
135
|
+
elementInfo,
|
|
136
|
+
position,
|
|
137
|
+
context,
|
|
138
|
+
{
|
|
139
|
+
dataLayerName: config.dataLayerName,
|
|
140
|
+
eventPrefix: config.eventPrefix,
|
|
141
|
+
},
|
|
142
|
+
);
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Generate DOM path for elements without Merkle attributes
|
|
148
|
+
*/
|
|
149
|
+
function generateDOMPath(element: HTMLElement): string[] {
|
|
150
|
+
const path: string[] = [];
|
|
151
|
+
let current: HTMLElement | null = element;
|
|
152
|
+
|
|
153
|
+
while (current && current !== document.body) {
|
|
154
|
+
let identifier = current.tagName.toLowerCase();
|
|
155
|
+
|
|
156
|
+
// Add ID if present
|
|
157
|
+
if (current.id) {
|
|
158
|
+
identifier += `#${current.id}`;
|
|
159
|
+
}
|
|
160
|
+
// Or first meaningful class
|
|
161
|
+
else if (current.className && typeof current.className === 'string') {
|
|
162
|
+
const classes = current.className.split(' ').filter(Boolean);
|
|
163
|
+
const meaningfulClass = classes.find(
|
|
164
|
+
(c) => !c.startsWith('_') && !c.match(/^[a-z]{1,3}\d+/),
|
|
165
|
+
);
|
|
166
|
+
if (meaningfulClass) {
|
|
167
|
+
identifier += `.${meaningfulClass}`;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
path.unshift(identifier);
|
|
172
|
+
current = current.parentElement;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Limit depth
|
|
176
|
+
if (path.length > 10) {
|
|
177
|
+
return ['...', ...path.slice(-9)];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return path;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ============================================================================
|
|
184
|
+
// Initialization
|
|
185
|
+
// ============================================================================
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Initialize click tracking
|
|
189
|
+
*/
|
|
190
|
+
export function initClickTracker(config: AnalyticsConfig): () => void {
|
|
191
|
+
// Skip if disabled
|
|
192
|
+
if (config.trackClicks === false) {
|
|
193
|
+
return () => {};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Clean up existing handler
|
|
197
|
+
if (activeHandler) {
|
|
198
|
+
activeHandler.cleanup();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const options: ClickTrackingOptions = {
|
|
202
|
+
debounceMs: 0,
|
|
203
|
+
maxTextLength: 100,
|
|
204
|
+
excludeSelectors: [],
|
|
205
|
+
includePosition: true,
|
|
206
|
+
includeTreePath: true,
|
|
207
|
+
...config.clickOptions,
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
// Create handler
|
|
211
|
+
const listener = createClickHandler(config, options);
|
|
212
|
+
|
|
213
|
+
// Add event listener with capture for earliest interception
|
|
214
|
+
document.addEventListener('click', listener, {
|
|
215
|
+
capture: true,
|
|
216
|
+
passive: true,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// Cleanup function
|
|
220
|
+
const cleanup = () => {
|
|
221
|
+
document.removeEventListener('click', listener, { capture: true });
|
|
222
|
+
|
|
223
|
+
if (debounceTimer) {
|
|
224
|
+
clearTimeout(debounceTimer);
|
|
225
|
+
debounceTimer = null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
activeHandler = null;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
activeHandler = { listener, cleanup };
|
|
232
|
+
|
|
233
|
+
return cleanup;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Stop click tracking
|
|
238
|
+
*/
|
|
239
|
+
export function stopClickTracker(): void {
|
|
240
|
+
if (activeHandler) {
|
|
241
|
+
activeHandler.cleanup();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Check if click tracking is active
|
|
247
|
+
*/
|
|
248
|
+
export function isClickTrackerActive(): boolean {
|
|
249
|
+
return activeHandler !== null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ============================================================================
|
|
253
|
+
// Manual Tracking
|
|
254
|
+
// ============================================================================
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Manually track a click event
|
|
258
|
+
* Useful for custom elements or programmatic clicks
|
|
259
|
+
*/
|
|
260
|
+
export function trackClick(
|
|
261
|
+
element: HTMLElement,
|
|
262
|
+
event?: MouseEvent,
|
|
263
|
+
config?: Pick<
|
|
264
|
+
AnalyticsConfig,
|
|
265
|
+
'dataLayerName' | 'eventPrefix' | 'clickOptions'
|
|
266
|
+
>,
|
|
267
|
+
): void {
|
|
268
|
+
const options = config?.clickOptions || {};
|
|
269
|
+
|
|
270
|
+
// Find Merkle element
|
|
271
|
+
const merkleElement = findNearestMerkleElement(element);
|
|
272
|
+
|
|
273
|
+
let merkleHash = 'unknown';
|
|
274
|
+
let treePath: string[] = [];
|
|
275
|
+
let treePathHashes: string[] = [];
|
|
276
|
+
|
|
277
|
+
if (merkleElement) {
|
|
278
|
+
const merkleInfo = parseMerkleFromElement(merkleElement);
|
|
279
|
+
if (merkleInfo) {
|
|
280
|
+
merkleHash = merkleInfo.hash;
|
|
281
|
+
treePath = merkleInfo.path;
|
|
282
|
+
treePathHashes = merkleInfo.pathHashes;
|
|
283
|
+
}
|
|
284
|
+
} else {
|
|
285
|
+
treePath = generateDOMPath(element);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Extract element info
|
|
289
|
+
const elementInfo = extractElementInfo(element, options.maxTextLength || 100);
|
|
290
|
+
|
|
291
|
+
// Extract position if event provided
|
|
292
|
+
const position = event
|
|
293
|
+
? extractPositionInfo(event)
|
|
294
|
+
: { x: 0, y: 0, viewportX: 0, viewportY: 0 };
|
|
295
|
+
|
|
296
|
+
// Get context
|
|
297
|
+
const context = getESIContextSnapshot();
|
|
298
|
+
|
|
299
|
+
// Push to dataLayer
|
|
300
|
+
pushClickEvent(
|
|
301
|
+
merkleHash,
|
|
302
|
+
treePath,
|
|
303
|
+
treePathHashes,
|
|
304
|
+
elementInfo,
|
|
305
|
+
position,
|
|
306
|
+
context,
|
|
307
|
+
{
|
|
308
|
+
dataLayerName: config?.dataLayerName || 'dataLayer',
|
|
309
|
+
eventPrefix: config?.eventPrefix || 'aeon',
|
|
310
|
+
},
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ============================================================================
|
|
315
|
+
// Custom Event Tracking
|
|
316
|
+
// ============================================================================
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Track a custom interaction (not necessarily a click)
|
|
320
|
+
*/
|
|
321
|
+
export function trackInteraction(
|
|
322
|
+
name: string,
|
|
323
|
+
data: Record<string, unknown>,
|
|
324
|
+
element?: HTMLElement,
|
|
325
|
+
config?: Pick<AnalyticsConfig, 'dataLayerName' | 'eventPrefix'>,
|
|
326
|
+
): void {
|
|
327
|
+
const context = getESIContextSnapshot();
|
|
328
|
+
|
|
329
|
+
let treePath: string[] = [];
|
|
330
|
+
let merkleHash = 'none';
|
|
331
|
+
|
|
332
|
+
if (element) {
|
|
333
|
+
const merkleElement = findNearestMerkleElement(element);
|
|
334
|
+
if (merkleElement) {
|
|
335
|
+
const merkleInfo = parseMerkleFromElement(merkleElement);
|
|
336
|
+
if (merkleInfo) {
|
|
337
|
+
merkleHash = merkleInfo.hash;
|
|
338
|
+
treePath = merkleInfo.path;
|
|
339
|
+
}
|
|
340
|
+
} else {
|
|
341
|
+
treePath = generateDOMPath(element);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const dataLayerName = config?.dataLayerName || 'dataLayer';
|
|
346
|
+
const eventPrefix = config?.eventPrefix || 'aeon';
|
|
347
|
+
|
|
348
|
+
const event = {
|
|
349
|
+
event: `${eventPrefix}.interaction`,
|
|
350
|
+
aeon: {
|
|
351
|
+
version: '1.0.0',
|
|
352
|
+
timestamp: Date.now(),
|
|
353
|
+
},
|
|
354
|
+
interaction: {
|
|
355
|
+
name,
|
|
356
|
+
merkleHash,
|
|
357
|
+
treePath,
|
|
358
|
+
data,
|
|
359
|
+
},
|
|
360
|
+
context,
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const w = window as unknown as Record<string, unknown[]>;
|
|
364
|
+
const dataLayer = w[dataLayerName];
|
|
365
|
+
if (dataLayer) {
|
|
366
|
+
dataLayer.push(event);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESI Context Bridge
|
|
3
|
+
*
|
|
4
|
+
* Syncs ESI state from window.__AEON_ESI_STATE__ to GTM dataLayer.
|
|
5
|
+
* Handles initial sync and subscribes to state changes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { AnalyticsConfig, ESIState } from './types';
|
|
9
|
+
import { pushContextEvent, pushPageViewEvent } from './data-layer';
|
|
10
|
+
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// ESI State Access
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Get current ESI state from window
|
|
17
|
+
*/
|
|
18
|
+
export function getESIState(): ESIState | null {
|
|
19
|
+
return window.__AEON_ESI_STATE__ || null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Check if ESI state is available
|
|
24
|
+
*/
|
|
25
|
+
export function hasESIState(): boolean {
|
|
26
|
+
return !!window.__AEON_ESI_STATE__;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get specific ESI state property
|
|
31
|
+
*/
|
|
32
|
+
export function getESIProperty<K extends keyof ESIState>(
|
|
33
|
+
key: K,
|
|
34
|
+
): ESIState[K] | undefined {
|
|
35
|
+
const state = getESIState();
|
|
36
|
+
return state ? state[key] : undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ============================================================================
|
|
40
|
+
// ESI State Subscription
|
|
41
|
+
// ============================================================================
|
|
42
|
+
|
|
43
|
+
/** Active subscription cleanup function */
|
|
44
|
+
let unsubscribe: (() => void) | null = null;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Subscribe to ESI state changes
|
|
48
|
+
*/
|
|
49
|
+
export function subscribeToESIChanges(
|
|
50
|
+
callback: (state: ESIState) => void,
|
|
51
|
+
): () => void {
|
|
52
|
+
const state = window.__AEON_ESI_STATE__;
|
|
53
|
+
|
|
54
|
+
if (state?.subscribe) {
|
|
55
|
+
return state.subscribe(callback);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// No subscribe method available - return no-op
|
|
59
|
+
return () => {};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ============================================================================
|
|
63
|
+
// DataLayer Sync
|
|
64
|
+
// ============================================================================
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Sync current ESI state to dataLayer
|
|
68
|
+
*/
|
|
69
|
+
export function syncESIToDataLayer(
|
|
70
|
+
config: Pick<AnalyticsConfig, 'dataLayerName' | 'eventPrefix'>,
|
|
71
|
+
): boolean {
|
|
72
|
+
const esiState = getESIState();
|
|
73
|
+
|
|
74
|
+
if (!esiState) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
pushContextEvent(esiState, config);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Push page view with current ESI context
|
|
84
|
+
*/
|
|
85
|
+
export function pushPageView(
|
|
86
|
+
config: Pick<AnalyticsConfig, 'dataLayerName' | 'eventPrefix'>,
|
|
87
|
+
merkleRoot = '',
|
|
88
|
+
): boolean {
|
|
89
|
+
const esiState = getESIState();
|
|
90
|
+
|
|
91
|
+
if (!esiState) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
pushPageViewEvent(
|
|
96
|
+
window.location.pathname,
|
|
97
|
+
document.title,
|
|
98
|
+
merkleRoot,
|
|
99
|
+
esiState,
|
|
100
|
+
config,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ============================================================================
|
|
107
|
+
// Watch Mode
|
|
108
|
+
// ============================================================================
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Start watching for ESI state changes and sync to dataLayer
|
|
112
|
+
*/
|
|
113
|
+
export function watchESIChanges(
|
|
114
|
+
config: Pick<AnalyticsConfig, 'dataLayerName' | 'eventPrefix'>,
|
|
115
|
+
): () => void {
|
|
116
|
+
// Clean up any existing subscription
|
|
117
|
+
if (unsubscribe) {
|
|
118
|
+
unsubscribe();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Subscribe to changes
|
|
122
|
+
unsubscribe = subscribeToESIChanges(() => {
|
|
123
|
+
syncESIToDataLayer(config);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return () => {
|
|
127
|
+
if (unsubscribe) {
|
|
128
|
+
unsubscribe();
|
|
129
|
+
unsubscribe = null;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ============================================================================
|
|
135
|
+
// Initialization
|
|
136
|
+
// ============================================================================
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Initialize context bridge with full sync
|
|
140
|
+
*/
|
|
141
|
+
export function initContextBridge(
|
|
142
|
+
config: Pick<
|
|
143
|
+
AnalyticsConfig,
|
|
144
|
+
'dataLayerName' | 'eventPrefix' | 'syncESIContext'
|
|
145
|
+
>,
|
|
146
|
+
): () => void {
|
|
147
|
+
if (config.syncESIContext === false) {
|
|
148
|
+
return () => {};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Initial sync
|
|
152
|
+
syncESIToDataLayer(config);
|
|
153
|
+
|
|
154
|
+
// Watch for changes
|
|
155
|
+
return watchESIChanges(config);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ============================================================================
|
|
159
|
+
// Retry Logic
|
|
160
|
+
// ============================================================================
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Wait for ESI state to be available
|
|
164
|
+
*/
|
|
165
|
+
export function waitForESIState(timeout = 5000): Promise<ESIState | null> {
|
|
166
|
+
return new Promise((resolve) => {
|
|
167
|
+
// Check immediately
|
|
168
|
+
const state = getESIState();
|
|
169
|
+
if (state) {
|
|
170
|
+
resolve(state);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const startTime = Date.now();
|
|
175
|
+
|
|
176
|
+
const check = () => {
|
|
177
|
+
const state = getESIState();
|
|
178
|
+
if (state) {
|
|
179
|
+
resolve(state);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (Date.now() - startTime > timeout) {
|
|
184
|
+
resolve(null);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
requestAnimationFrame(check);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
check();
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Initialize context bridge with retry
|
|
197
|
+
*/
|
|
198
|
+
export async function initContextBridgeWithRetry(
|
|
199
|
+
config: Pick<
|
|
200
|
+
AnalyticsConfig,
|
|
201
|
+
'dataLayerName' | 'eventPrefix' | 'syncESIContext'
|
|
202
|
+
>,
|
|
203
|
+
timeout = 5000,
|
|
204
|
+
): Promise<() => void> {
|
|
205
|
+
if (config.syncESIContext === false) {
|
|
206
|
+
return () => {};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Wait for ESI state
|
|
210
|
+
const state = await waitForESIState(timeout);
|
|
211
|
+
|
|
212
|
+
if (!state) {
|
|
213
|
+
console.warn('[Aeon Analytics] ESI state not available after timeout');
|
|
214
|
+
return () => {};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Now initialize
|
|
218
|
+
return initContextBridge(config);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ============================================================================
|
|
222
|
+
// Utilities
|
|
223
|
+
// ============================================================================
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Get partial ESI state for click events
|
|
227
|
+
*/
|
|
228
|
+
export function getESIContextSnapshot(): Partial<ESIState> {
|
|
229
|
+
const state = getESIState();
|
|
230
|
+
|
|
231
|
+
if (!state) {
|
|
232
|
+
return {};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Return relevant context for click tracking
|
|
236
|
+
return {
|
|
237
|
+
userTier: state.userTier,
|
|
238
|
+
isAdmin: state.isAdmin,
|
|
239
|
+
userId: state.userId,
|
|
240
|
+
sessionId: state.sessionId,
|
|
241
|
+
isNewSession: state.isNewSession,
|
|
242
|
+
emotionState: state.emotionState,
|
|
243
|
+
features: state.features,
|
|
244
|
+
viewport: state.viewport,
|
|
245
|
+
connection: state.connection,
|
|
246
|
+
localHour: state.localHour,
|
|
247
|
+
timezone: state.timezone,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Check if current user is an admin
|
|
253
|
+
* Admins bypass all tier restrictions
|
|
254
|
+
*/
|
|
255
|
+
export function isAdmin(): boolean {
|
|
256
|
+
const state = getESIState();
|
|
257
|
+
return state?.isAdmin === true || state?.userTier === 'admin';
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Check if user has specific feature enabled
|
|
262
|
+
* Admins always have access to all features
|
|
263
|
+
*/
|
|
264
|
+
export function hasFeature(feature: keyof ESIState['features']): boolean {
|
|
265
|
+
const state = getESIState();
|
|
266
|
+
|
|
267
|
+
// Admins bypass all tier restrictions
|
|
268
|
+
if (state?.isAdmin === true || state?.userTier === 'admin') {
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return state?.features?.[feature] ?? false;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Check if user meets minimum tier requirement
|
|
277
|
+
* Admins always meet any tier requirement
|
|
278
|
+
*/
|
|
279
|
+
export function meetsTierRequirement(
|
|
280
|
+
requiredTier: ESIState['userTier'],
|
|
281
|
+
): boolean {
|
|
282
|
+
const state = getESIState();
|
|
283
|
+
|
|
284
|
+
if (!state) {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Admins bypass all tier restrictions
|
|
289
|
+
if (state.isAdmin === true || state.userTier === 'admin') {
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Tier hierarchy: free < starter < pro < enterprise
|
|
294
|
+
const tierOrder: ESIState['userTier'][] = [
|
|
295
|
+
'free',
|
|
296
|
+
'starter',
|
|
297
|
+
'pro',
|
|
298
|
+
'enterprise',
|
|
299
|
+
'admin',
|
|
300
|
+
];
|
|
301
|
+
const userTierIndex = tierOrder.indexOf(state.userTier);
|
|
302
|
+
const requiredTierIndex = tierOrder.indexOf(requiredTier);
|
|
303
|
+
|
|
304
|
+
return userTierIndex >= requiredTierIndex;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Get user tier
|
|
309
|
+
*/
|
|
310
|
+
export function getUserTier(): ESIState['userTier'] | null {
|
|
311
|
+
return getESIProperty('userTier') ?? null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Get emotion state
|
|
316
|
+
*/
|
|
317
|
+
export function getEmotionState(): ESIState['emotionState'] | null {
|
|
318
|
+
return getESIProperty('emotionState') ?? null;
|
|
319
|
+
}
|