@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,302 @@
1
+ /**
2
+ * GTM dataLayer Integration
3
+ *
4
+ * Manages the dataLayer array and provides typed event pushing.
5
+ * Ensures dataLayer exists and handles event formatting.
6
+ */
7
+
8
+ import type {
9
+ AnalyticsConfig,
10
+ AeonEventBase,
11
+ ContextEvent,
12
+ PageViewEvent,
13
+ ClickEvent,
14
+ DataLayerEvent,
15
+ ESIState,
16
+ ElementInfo,
17
+ PositionInfo,
18
+ } from './types';
19
+
20
+ // ============================================================================
21
+ // Constants
22
+ // ============================================================================
23
+
24
+ /** Current analytics version */
25
+ export const ANALYTICS_VERSION = '1.0.0';
26
+
27
+ // ============================================================================
28
+ // dataLayer Management
29
+ // ============================================================================
30
+
31
+ /**
32
+ * Ensure dataLayer array exists on window
33
+ */
34
+ export function ensureDataLayer(name = 'dataLayer'): unknown[] {
35
+ const w = window as unknown as Record<string, unknown>;
36
+
37
+ if (!w[name]) {
38
+ w[name] = [];
39
+ }
40
+
41
+ return w[name] as unknown[];
42
+ }
43
+
44
+ /**
45
+ * Push event to dataLayer
46
+ */
47
+ export function pushToDataLayer(
48
+ event: DataLayerEvent,
49
+ dataLayerName = 'dataLayer',
50
+ ): void {
51
+ const dataLayer = ensureDataLayer(dataLayerName);
52
+ dataLayer.push(event);
53
+
54
+ // Debug logging
55
+ if (
56
+ (window as Window & { __AEON_ANALYTICS_DEBUG__?: boolean })
57
+ .__AEON_ANALYTICS_DEBUG__
58
+ ) {
59
+ console.log('[Aeon Analytics]', event.event, event);
60
+ }
61
+ }
62
+
63
+ // ============================================================================
64
+ // Event Builders
65
+ // ============================================================================
66
+
67
+ /**
68
+ * Create base event structure
69
+ */
70
+ function createBaseEvent(eventName: string, prefix = 'aeon'): AeonEventBase {
71
+ return {
72
+ event: prefix ? `${prefix}.${eventName}` : eventName,
73
+ aeon: {
74
+ version: ANALYTICS_VERSION,
75
+ timestamp: Date.now(),
76
+ },
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Build context event from ESI state
82
+ */
83
+ export function buildContextEvent(
84
+ esiState: ESIState,
85
+ prefix = 'aeon',
86
+ ): ContextEvent {
87
+ return {
88
+ ...createBaseEvent('context', prefix),
89
+ event: `${prefix}.context` as 'aeon.context',
90
+ user: {
91
+ tier: esiState.userTier,
92
+ id: esiState.userId,
93
+ sessionId: esiState.sessionId,
94
+ isNewSession: esiState.isNewSession,
95
+ },
96
+ emotion: esiState.emotionState,
97
+ preferences: esiState.preferences,
98
+ features: esiState.features,
99
+ device: {
100
+ viewport: esiState.viewport,
101
+ connection: esiState.connection,
102
+ },
103
+ time: {
104
+ localHour: esiState.localHour,
105
+ timezone: esiState.timezone,
106
+ },
107
+ recentPages: esiState.recentPages,
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Build page view event
113
+ */
114
+ export function buildPageViewEvent(
115
+ path: string,
116
+ title: string,
117
+ merkleRoot: string,
118
+ esiState: ESIState,
119
+ prefix = 'aeon',
120
+ ): PageViewEvent {
121
+ return {
122
+ ...createBaseEvent('pageview', prefix),
123
+ event: `${prefix}.pageview` as 'aeon.pageview',
124
+ page: {
125
+ path,
126
+ title,
127
+ merkleRoot,
128
+ },
129
+ user: {
130
+ tier: esiState.userTier,
131
+ id: esiState.userId,
132
+ sessionId: esiState.sessionId,
133
+ isNewSession: esiState.isNewSession,
134
+ },
135
+ emotion: esiState.emotionState,
136
+ features: esiState.features,
137
+ device: {
138
+ viewport: esiState.viewport,
139
+ connection: esiState.connection,
140
+ reducedMotion: esiState.preferences.reducedMotion,
141
+ },
142
+ time: {
143
+ localHour: esiState.localHour,
144
+ timezone: esiState.timezone,
145
+ },
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Build click event
151
+ */
152
+ export function buildClickEvent(
153
+ merkleHash: string,
154
+ treePath: string[],
155
+ treePathHashes: string[],
156
+ element: ElementInfo,
157
+ position: PositionInfo,
158
+ context: Partial<ESIState>,
159
+ prefix = 'aeon',
160
+ ): ClickEvent {
161
+ return {
162
+ ...createBaseEvent('click', prefix),
163
+ event: `${prefix}.click` as 'aeon.click',
164
+ click: {
165
+ merkleHash,
166
+ treePath,
167
+ treePathHashes,
168
+ element,
169
+ position,
170
+ },
171
+ context,
172
+ };
173
+ }
174
+
175
+ // ============================================================================
176
+ // Element Info Extraction
177
+ // ============================================================================
178
+
179
+ /**
180
+ * Extract element info for tracking
181
+ */
182
+ export function extractElementInfo(
183
+ element: HTMLElement,
184
+ maxTextLength = 100,
185
+ ): ElementInfo {
186
+ // Get text content, truncated
187
+ let text = element.innerText || element.textContent || '';
188
+ if (text.length > maxTextLength) {
189
+ text = text.slice(0, maxTextLength) + '...';
190
+ }
191
+ // Clean whitespace
192
+ text = text.replace(/\s+/g, ' ').trim();
193
+
194
+ return {
195
+ tagName: element.tagName,
196
+ text,
197
+ ariaLabel: element.getAttribute('aria-label') || undefined,
198
+ role: element.getAttribute('role') || undefined,
199
+ href: (element as HTMLAnchorElement).href || undefined,
200
+ id: element.id || undefined,
201
+ className: element.className || undefined,
202
+ };
203
+ }
204
+
205
+ /**
206
+ * Extract position info from mouse event
207
+ */
208
+ export function extractPositionInfo(event: MouseEvent): PositionInfo {
209
+ return {
210
+ x: event.pageX,
211
+ y: event.pageY,
212
+ viewportX: event.clientX,
213
+ viewportY: event.clientY,
214
+ };
215
+ }
216
+
217
+ // ============================================================================
218
+ // Push Helpers
219
+ // ============================================================================
220
+
221
+ /**
222
+ * Push context event to dataLayer
223
+ */
224
+ export function pushContextEvent(
225
+ esiState: ESIState,
226
+ config: Pick<AnalyticsConfig, 'dataLayerName' | 'eventPrefix'>,
227
+ ): void {
228
+ const event = buildContextEvent(esiState, config.eventPrefix);
229
+ pushToDataLayer(event, config.dataLayerName);
230
+ }
231
+
232
+ /**
233
+ * Push page view event to dataLayer
234
+ */
235
+ export function pushPageViewEvent(
236
+ path: string,
237
+ title: string,
238
+ merkleRoot: string,
239
+ esiState: ESIState,
240
+ config: Pick<AnalyticsConfig, 'dataLayerName' | 'eventPrefix'>,
241
+ ): void {
242
+ const event = buildPageViewEvent(
243
+ path,
244
+ title,
245
+ merkleRoot,
246
+ esiState,
247
+ config.eventPrefix,
248
+ );
249
+ pushToDataLayer(event, config.dataLayerName);
250
+ }
251
+
252
+ /**
253
+ * Push click event to dataLayer
254
+ */
255
+ export function pushClickEvent(
256
+ merkleHash: string,
257
+ treePath: string[],
258
+ treePathHashes: string[],
259
+ element: ElementInfo,
260
+ position: PositionInfo,
261
+ context: Partial<ESIState>,
262
+ config: Pick<AnalyticsConfig, 'dataLayerName' | 'eventPrefix'>,
263
+ ): void {
264
+ const event = buildClickEvent(
265
+ merkleHash,
266
+ treePath,
267
+ treePathHashes,
268
+ element,
269
+ position,
270
+ context,
271
+ config.eventPrefix,
272
+ );
273
+ pushToDataLayer(event, config.dataLayerName);
274
+ }
275
+
276
+ // ============================================================================
277
+ // Debug Mode
278
+ // ============================================================================
279
+
280
+ /**
281
+ * Enable/disable debug logging
282
+ */
283
+ export function setDebugMode(enabled: boolean): void {
284
+ (
285
+ window as Window & { __AEON_ANALYTICS_DEBUG__?: boolean }
286
+ ).__AEON_ANALYTICS_DEBUG__ = enabled;
287
+ }
288
+
289
+ /**
290
+ * Get current dataLayer contents (for debugging)
291
+ */
292
+ export function getDataLayer(name = 'dataLayer'): unknown[] {
293
+ return ensureDataLayer(name);
294
+ }
295
+
296
+ /**
297
+ * Clear dataLayer (for testing)
298
+ */
299
+ export function clearDataLayer(name = 'dataLayer'): void {
300
+ const w = window as unknown as Record<string, unknown>;
301
+ w[name] = [];
302
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * GTM Script Loader
3
+ *
4
+ * Injects Google Tag Manager script and noscript iframe.
5
+ * Handles async loading and ensures proper initialization order.
6
+ */
7
+
8
+ import type { GTMConfig } from './types';
9
+ import { ensureDataLayer } from './data-layer';
10
+
11
+ // ============================================================================
12
+ // GTM Script Injection
13
+ // ============================================================================
14
+
15
+ /** Track if GTM has been injected */
16
+ let gtmInjected = false;
17
+
18
+ /**
19
+ * Generate GTM script inline code
20
+ */
21
+ function generateGTMScript(containerId: string, dataLayerName: string): string {
22
+ return `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
23
+ new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
24
+ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
25
+ 'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
26
+ })(window,document,'script','${dataLayerName}','${containerId}');`;
27
+ }
28
+
29
+ /**
30
+ * Validate GTM container ID format
31
+ */
32
+ function validateContainerId(containerId: string): boolean {
33
+ // GTM container ID format: GTM-XXXXXX (6-8 alphanumeric characters)
34
+ return /^GTM-[A-Z0-9]{6,8}$/i.test(containerId);
35
+ }
36
+
37
+ /**
38
+ * Inject GTM script into document head
39
+ */
40
+ export function injectGTM(config: GTMConfig): boolean {
41
+ // Prevent double injection
42
+ if (gtmInjected) {
43
+ console.warn('[Aeon Analytics] GTM already injected');
44
+ return false;
45
+ }
46
+
47
+ // Validate container ID
48
+ if (!validateContainerId(config.containerId)) {
49
+ console.error(
50
+ `[Aeon Analytics] Invalid GTM container ID: ${config.containerId}. ` +
51
+ 'Expected format: GTM-XXXXXX',
52
+ );
53
+ return false;
54
+ }
55
+
56
+ // Ensure dataLayer exists before GTM loads
57
+ const dataLayerName = config.dataLayerName || 'dataLayer';
58
+ ensureDataLayer(dataLayerName);
59
+
60
+ // Create and inject script element
61
+ const script = document.createElement('script');
62
+ script.innerHTML = generateGTMScript(config.containerId, dataLayerName);
63
+
64
+ // Insert at beginning of head for earliest execution
65
+ if (document.head.firstChild) {
66
+ document.head.insertBefore(script, document.head.firstChild);
67
+ } else {
68
+ document.head.appendChild(script);
69
+ }
70
+
71
+ gtmInjected = true;
72
+ return true;
73
+ }
74
+
75
+ /**
76
+ * Inject GTM noscript iframe into document body
77
+ * For tracking when JavaScript is disabled
78
+ */
79
+ export function injectGTMNoScript(containerId: string): boolean {
80
+ // Validate container ID
81
+ if (!validateContainerId(containerId)) {
82
+ return false;
83
+ }
84
+
85
+ // Check if noscript already exists
86
+ const existingNoscript = document.querySelector(
87
+ `noscript iframe[src*="googletagmanager.com/ns.html?id=${containerId}"]`,
88
+ );
89
+ if (existingNoscript) {
90
+ return false;
91
+ }
92
+
93
+ // Create noscript element with iframe
94
+ const noscript = document.createElement('noscript');
95
+ const iframe = document.createElement('iframe');
96
+
97
+ iframe.src = `https://www.googletagmanager.com/ns.html?id=${containerId}`;
98
+ iframe.height = '0';
99
+ iframe.width = '0';
100
+ iframe.style.display = 'none';
101
+ iframe.style.visibility = 'hidden';
102
+
103
+ noscript.appendChild(iframe);
104
+
105
+ // Insert at beginning of body
106
+ if (document.body.firstChild) {
107
+ document.body.insertBefore(noscript, document.body.firstChild);
108
+ } else {
109
+ document.body.appendChild(noscript);
110
+ }
111
+
112
+ return true;
113
+ }
114
+
115
+ /**
116
+ * Initialize GTM with full configuration
117
+ */
118
+ export function initializeGTM(config: GTMConfig): boolean {
119
+ const scriptInjected = injectGTM(config);
120
+
121
+ // Only inject noscript if script was successfully injected
122
+ if (scriptInjected) {
123
+ injectGTMNoScript(config.containerId);
124
+ }
125
+
126
+ return scriptInjected;
127
+ }
128
+
129
+ // ============================================================================
130
+ // Server-Side Rendering Helpers
131
+ // ============================================================================
132
+
133
+ /**
134
+ * Generate GTM script tag for SSR
135
+ * Returns HTML string to inject into <head>
136
+ */
137
+ export function generateGTMScriptTag(config: GTMConfig): string {
138
+ if (!validateContainerId(config.containerId)) {
139
+ return '';
140
+ }
141
+
142
+ const dataLayerName = config.dataLayerName || 'dataLayer';
143
+
144
+ return `<script>${generateGTMScript(config.containerId, dataLayerName)}</script>`;
145
+ }
146
+
147
+ /**
148
+ * Generate GTM noscript tag for SSR
149
+ * Returns HTML string to inject at start of <body>
150
+ */
151
+ export function generateGTMNoScriptTag(containerId: string): string {
152
+ if (!validateContainerId(containerId)) {
153
+ return '';
154
+ }
155
+
156
+ return `<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=${containerId}" height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>`;
157
+ }
158
+
159
+ /**
160
+ * Generate dataLayer initialization script for SSR
161
+ * Use this before GTM script to pre-populate data
162
+ */
163
+ export function generateDataLayerScript(
164
+ initialData: Record<string, unknown>,
165
+ dataLayerName = 'dataLayer',
166
+ ): string {
167
+ const dataJson = JSON.stringify(initialData);
168
+ return `<script>window.${dataLayerName}=window.${dataLayerName}||[];window.${dataLayerName}.push(${dataJson});</script>`;
169
+ }
170
+
171
+ // ============================================================================
172
+ // Status Helpers
173
+ // ============================================================================
174
+
175
+ /**
176
+ * Check if GTM has been injected
177
+ */
178
+ export function isGTMInjected(): boolean {
179
+ return gtmInjected;
180
+ }
181
+
182
+ /**
183
+ * Check if GTM is loaded and ready
184
+ */
185
+ export function isGTMReady(): boolean {
186
+ if (!gtmInjected) return false;
187
+
188
+ // Check for gtm.js event in dataLayer
189
+ const dataLayer = (window as Window & { dataLayer?: unknown[] }).dataLayer;
190
+ if (!dataLayer) return false;
191
+
192
+ return dataLayer.some(
193
+ (item) =>
194
+ typeof item === 'object' &&
195
+ item !== null &&
196
+ (item as Record<string, unknown>).event === 'gtm.js',
197
+ );
198
+ }
199
+
200
+ /**
201
+ * Wait for GTM to be ready
202
+ */
203
+ export function waitForGTM(timeout = 5000): Promise<boolean> {
204
+ return new Promise((resolve) => {
205
+ if (isGTMReady()) {
206
+ resolve(true);
207
+ return;
208
+ }
209
+
210
+ const startTime = Date.now();
211
+
212
+ const check = () => {
213
+ if (isGTMReady()) {
214
+ resolve(true);
215
+ return;
216
+ }
217
+
218
+ if (Date.now() - startTime > timeout) {
219
+ resolve(false);
220
+ return;
221
+ }
222
+
223
+ requestAnimationFrame(check);
224
+ };
225
+
226
+ check();
227
+ });
228
+ }
229
+
230
+ // ============================================================================
231
+ // Reset (for testing)
232
+ // ============================================================================
233
+
234
+ /**
235
+ * Reset GTM injection state (for testing only)
236
+ */
237
+ export function resetGTMState(): void {
238
+ gtmInjected = false;
239
+ }