@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.
Files changed (124) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +625 -0
  3. package/examples/basic/aeon.config.ts +39 -0
  4. package/examples/basic/components/Cursor.tsx +86 -0
  5. package/examples/basic/components/OfflineIndicator.tsx +103 -0
  6. package/examples/basic/components/PresenceBar.tsx +77 -0
  7. package/examples/basic/package.json +20 -0
  8. package/examples/basic/pages/index.tsx +80 -0
  9. package/package.json +101 -0
  10. package/packages/analytics/README.md +309 -0
  11. package/packages/analytics/build.ts +35 -0
  12. package/packages/analytics/package.json +50 -0
  13. package/packages/analytics/src/click-tracker.ts +368 -0
  14. package/packages/analytics/src/context-bridge.ts +319 -0
  15. package/packages/analytics/src/data-layer.ts +302 -0
  16. package/packages/analytics/src/gtm-loader.ts +239 -0
  17. package/packages/analytics/src/index.ts +230 -0
  18. package/packages/analytics/src/merkle-tree.ts +489 -0
  19. package/packages/analytics/src/provider.tsx +300 -0
  20. package/packages/analytics/src/types.ts +320 -0
  21. package/packages/analytics/src/use-analytics.ts +296 -0
  22. package/packages/analytics/tsconfig.json +19 -0
  23. package/packages/benchmarks/src/benchmark.test.ts +691 -0
  24. package/packages/cli/dist/index.js +61899 -0
  25. package/packages/cli/package.json +43 -0
  26. package/packages/cli/src/commands/build.test.ts +682 -0
  27. package/packages/cli/src/commands/build.ts +890 -0
  28. package/packages/cli/src/commands/dev.ts +473 -0
  29. package/packages/cli/src/commands/init.ts +409 -0
  30. package/packages/cli/src/commands/start.ts +297 -0
  31. package/packages/cli/src/index.ts +105 -0
  32. package/packages/directives/src/use-aeon.ts +272 -0
  33. package/packages/mcp-server/package.json +51 -0
  34. package/packages/mcp-server/src/index.ts +178 -0
  35. package/packages/mcp-server/src/resources.ts +346 -0
  36. package/packages/mcp-server/src/tools/index.ts +36 -0
  37. package/packages/mcp-server/src/tools/navigation.ts +545 -0
  38. package/packages/mcp-server/tsconfig.json +21 -0
  39. package/packages/react/package.json +40 -0
  40. package/packages/react/src/Link.tsx +388 -0
  41. package/packages/react/src/components/InstallPrompt.tsx +286 -0
  42. package/packages/react/src/components/OfflineDiagnostics.tsx +677 -0
  43. package/packages/react/src/components/PushNotifications.tsx +453 -0
  44. package/packages/react/src/hooks/useAeonNavigation.ts +219 -0
  45. package/packages/react/src/hooks/useConflicts.ts +277 -0
  46. package/packages/react/src/hooks/useNetworkState.ts +209 -0
  47. package/packages/react/src/hooks/usePilotNavigation.ts +254 -0
  48. package/packages/react/src/hooks/useServiceWorker.ts +278 -0
  49. package/packages/react/src/hooks.ts +195 -0
  50. package/packages/react/src/index.ts +151 -0
  51. package/packages/react/src/provider.tsx +467 -0
  52. package/packages/react/tsconfig.json +19 -0
  53. package/packages/runtime/README.md +399 -0
  54. package/packages/runtime/build.ts +48 -0
  55. package/packages/runtime/package.json +71 -0
  56. package/packages/runtime/schema.sql +40 -0
  57. package/packages/runtime/src/api-routes.ts +465 -0
  58. package/packages/runtime/src/benchmark.ts +171 -0
  59. package/packages/runtime/src/cache.ts +479 -0
  60. package/packages/runtime/src/durable-object.ts +1341 -0
  61. package/packages/runtime/src/index.ts +360 -0
  62. package/packages/runtime/src/navigation.test.ts +421 -0
  63. package/packages/runtime/src/navigation.ts +422 -0
  64. package/packages/runtime/src/nextjs-adapter.ts +272 -0
  65. package/packages/runtime/src/offline/encrypted-queue.test.ts +607 -0
  66. package/packages/runtime/src/offline/encrypted-queue.ts +478 -0
  67. package/packages/runtime/src/offline/encryption.test.ts +412 -0
  68. package/packages/runtime/src/offline/encryption.ts +397 -0
  69. package/packages/runtime/src/offline/types.ts +465 -0
  70. package/packages/runtime/src/predictor.ts +371 -0
  71. package/packages/runtime/src/registry.ts +351 -0
  72. package/packages/runtime/src/router/context-extractor.ts +661 -0
  73. package/packages/runtime/src/router/esi-control-react.tsx +2053 -0
  74. package/packages/runtime/src/router/esi-control.ts +541 -0
  75. package/packages/runtime/src/router/esi-cyrano.ts +779 -0
  76. package/packages/runtime/src/router/esi-format-react.tsx +1744 -0
  77. package/packages/runtime/src/router/esi-react.tsx +1065 -0
  78. package/packages/runtime/src/router/esi-translate-observer.ts +476 -0
  79. package/packages/runtime/src/router/esi-translate-react.tsx +556 -0
  80. package/packages/runtime/src/router/esi-translate.ts +503 -0
  81. package/packages/runtime/src/router/esi.ts +666 -0
  82. package/packages/runtime/src/router/heuristic-adapter.test.ts +295 -0
  83. package/packages/runtime/src/router/heuristic-adapter.ts +557 -0
  84. package/packages/runtime/src/router/index.ts +298 -0
  85. package/packages/runtime/src/router/merkle-capability.ts +473 -0
  86. package/packages/runtime/src/router/speculation.ts +451 -0
  87. package/packages/runtime/src/router/types.ts +630 -0
  88. package/packages/runtime/src/router.test.ts +470 -0
  89. package/packages/runtime/src/router.ts +302 -0
  90. package/packages/runtime/src/server.ts +481 -0
  91. package/packages/runtime/src/service-worker-push.ts +319 -0
  92. package/packages/runtime/src/service-worker.ts +553 -0
  93. package/packages/runtime/src/skeleton-hydrate.ts +237 -0
  94. package/packages/runtime/src/speculation.test.ts +389 -0
  95. package/packages/runtime/src/speculation.ts +486 -0
  96. package/packages/runtime/src/storage.test.ts +1297 -0
  97. package/packages/runtime/src/storage.ts +1048 -0
  98. package/packages/runtime/src/sync/conflict-resolver.test.ts +528 -0
  99. package/packages/runtime/src/sync/conflict-resolver.ts +565 -0
  100. package/packages/runtime/src/sync/coordinator.test.ts +608 -0
  101. package/packages/runtime/src/sync/coordinator.ts +596 -0
  102. package/packages/runtime/src/tree-compiler.ts +295 -0
  103. package/packages/runtime/src/types.ts +728 -0
  104. package/packages/runtime/src/worker.ts +327 -0
  105. package/packages/runtime/tsconfig.json +20 -0
  106. package/packages/runtime/wasm/aeon_pages_runtime.d.ts +504 -0
  107. package/packages/runtime/wasm/aeon_pages_runtime.js +1657 -0
  108. package/packages/runtime/wasm/aeon_pages_runtime_bg.wasm +0 -0
  109. package/packages/runtime/wasm/aeon_pages_runtime_bg.wasm.d.ts +196 -0
  110. package/packages/runtime/wasm/package.json +21 -0
  111. package/packages/runtime/wrangler.toml +41 -0
  112. package/packages/runtime-wasm/Cargo.lock +436 -0
  113. package/packages/runtime-wasm/Cargo.toml +29 -0
  114. package/packages/runtime-wasm/pkg/aeon_pages_runtime.d.ts +480 -0
  115. package/packages/runtime-wasm/pkg/aeon_pages_runtime.js +1568 -0
  116. package/packages/runtime-wasm/pkg/aeon_pages_runtime_bg.wasm +0 -0
  117. package/packages/runtime-wasm/pkg/aeon_pages_runtime_bg.wasm.d.ts +192 -0
  118. package/packages/runtime-wasm/pkg/package.json +21 -0
  119. package/packages/runtime-wasm/src/hydrate.rs +352 -0
  120. package/packages/runtime-wasm/src/lib.rs +191 -0
  121. package/packages/runtime-wasm/src/render.rs +629 -0
  122. package/packages/runtime-wasm/src/router.rs +298 -0
  123. package/packages/runtime-wasm/src/skeleton.rs +430 -0
  124. 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
+ }