@grainql/analytics-web 2.7.1 → 2.9.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/README.md +36 -3
- package/dist/cjs/consent.d.ts +38 -7
- package/dist/cjs/consent.d.ts.map +1 -1
- package/dist/cjs/consent.js +82 -23
- package/dist/cjs/consent.js.map +1 -1
- package/dist/cjs/debug-agent.d.ts +171 -0
- package/dist/cjs/debug-agent.d.ts.map +1 -0
- package/dist/cjs/debug-agent.js +1219 -0
- package/dist/cjs/debug-agent.js.map +1 -0
- package/dist/cjs/id-manager.d.ts +66 -0
- package/dist/cjs/id-manager.d.ts.map +1 -0
- package/dist/cjs/id-manager.js +212 -0
- package/dist/cjs/id-manager.js.map +1 -0
- package/dist/cjs/index.d.ts +26 -8
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/interaction-tracking.d.ts +6 -0
- package/dist/cjs/interaction-tracking.d.ts.map +1 -1
- package/dist/cjs/interaction-tracking.js +55 -5
- package/dist/cjs/interaction-tracking.js.map +1 -1
- package/dist/cjs/page-tracking.d.ts +6 -0
- package/dist/cjs/page-tracking.d.ts.map +1 -1
- package/dist/cjs/page-tracking.js +23 -2
- package/dist/cjs/page-tracking.js.map +1 -1
- package/dist/cjs/react/hooks/useConsent.d.ts +18 -2
- package/dist/cjs/react/hooks/useConsent.d.ts.map +1 -1
- package/dist/cjs/react/hooks/useConsent.js +52 -1
- package/dist/cjs/react/hooks/useConsent.js.map +1 -1
- package/dist/consent.d.ts +38 -7
- package/dist/consent.d.ts.map +1 -1
- package/dist/consent.js +82 -23
- package/dist/debug-agent.d.ts +171 -0
- package/dist/debug-agent.d.ts.map +1 -0
- package/dist/debug-agent.js +1219 -0
- package/dist/esm/consent.d.ts +38 -7
- package/dist/esm/consent.d.ts.map +1 -1
- package/dist/esm/consent.js +82 -23
- package/dist/esm/consent.js.map +1 -1
- package/dist/esm/debug-agent.d.ts +171 -0
- package/dist/esm/debug-agent.d.ts.map +1 -0
- package/dist/esm/debug-agent.js +1215 -0
- package/dist/esm/debug-agent.js.map +1 -0
- package/dist/esm/id-manager.d.ts +66 -0
- package/dist/esm/id-manager.d.ts.map +1 -0
- package/dist/esm/id-manager.js +208 -0
- package/dist/esm/id-manager.js.map +1 -0
- package/dist/esm/index.d.ts +26 -8
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/interaction-tracking.d.ts +6 -0
- package/dist/esm/interaction-tracking.d.ts.map +1 -1
- package/dist/esm/interaction-tracking.js +55 -5
- package/dist/esm/interaction-tracking.js.map +1 -1
- package/dist/esm/page-tracking.d.ts +6 -0
- package/dist/esm/page-tracking.d.ts.map +1 -1
- package/dist/esm/page-tracking.js +23 -2
- package/dist/esm/page-tracking.js.map +1 -1
- package/dist/esm/react/hooks/useConsent.d.ts +18 -2
- package/dist/esm/react/hooks/useConsent.d.ts.map +1 -1
- package/dist/esm/react/hooks/useConsent.js +49 -1
- package/dist/esm/react/hooks/useConsent.js.map +1 -1
- package/dist/id-manager.d.ts +66 -0
- package/dist/id-manager.d.ts.map +1 -0
- package/dist/id-manager.js +212 -0
- package/dist/index.d.ts +26 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.global.dev.js +1635 -86
- package/dist/index.global.dev.js.map +4 -4
- package/dist/index.global.js +506 -2
- package/dist/index.global.js.map +4 -4
- package/dist/index.js +171 -44
- package/dist/index.mjs +172 -45
- package/dist/interaction-tracking.d.ts +6 -0
- package/dist/interaction-tracking.d.ts.map +1 -1
- package/dist/interaction-tracking.js +55 -5
- package/dist/page-tracking.d.ts +6 -0
- package/dist/page-tracking.d.ts.map +1 -1
- package/dist/page-tracking.js +23 -2
- package/dist/react/hooks/useConsent.d.ts +18 -2
- package/dist/react/hooks/useConsent.d.ts.map +1 -1
- package/dist/react/hooks/useConsent.js +52 -1
- package/dist/react/hooks/useConsent.mjs +49 -1
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
* A lightweight, dependency-free TypeScript SDK for sending analytics events to Grain's REST API
|
|
4
4
|
*/
|
|
5
5
|
import { ConsentManager } from './consent.js';
|
|
6
|
-
import { setCookie, getCookie
|
|
6
|
+
import { setCookie, getCookie } from './cookies.js';
|
|
7
7
|
import { ActivityDetector } from './activity.js';
|
|
8
8
|
import { HeartbeatManager } from './heartbeat.js';
|
|
9
9
|
import { PageTrackingManager } from './page-tracking.js';
|
|
10
|
+
import { IdManager } from './id-manager.js';
|
|
10
11
|
import { categorizeReferrer, parseUTMParameters, getOrCreateFirstTouchAttribution, getSessionUTMParameters, getFirstTouchAttribution, } from './attribution.js';
|
|
11
12
|
export { categorizeReferrer, parseUTMParameters };
|
|
12
13
|
// Re-export timezone-country utilities
|
|
@@ -18,13 +19,13 @@ export class GrainAnalytics {
|
|
|
18
19
|
this.flushTimer = null;
|
|
19
20
|
this.isDestroyed = false;
|
|
20
21
|
this.globalUserId = null;
|
|
21
|
-
this.persistentAnonymousUserId = null;
|
|
22
|
+
this.persistentAnonymousUserId = null; // Deprecated: use idManager instead
|
|
22
23
|
// Remote Config properties
|
|
23
24
|
this.configCache = null;
|
|
24
25
|
this.configRefreshTimer = null;
|
|
25
26
|
this.configChangeListeners = [];
|
|
26
27
|
this.configFetchPromise = null;
|
|
27
|
-
this.cookiesEnabled = false;
|
|
28
|
+
this.cookiesEnabled = false; // Deprecated: cookies no longer used for IDs
|
|
28
29
|
// Automatic Tracking properties
|
|
29
30
|
this.activityDetector = null;
|
|
30
31
|
this.heartbeatManager = null;
|
|
@@ -38,6 +39,9 @@ export class GrainAnalytics {
|
|
|
38
39
|
// Session tracking
|
|
39
40
|
this.sessionStartTime = Date.now();
|
|
40
41
|
this.sessionEventCount = 0;
|
|
42
|
+
// Debug mode properties
|
|
43
|
+
this.debugAgent = null;
|
|
44
|
+
this.isDebugMode = false;
|
|
41
45
|
this.config = {
|
|
42
46
|
apiUrl: 'https://api.grainql.com',
|
|
43
47
|
authStrategy: 'NONE',
|
|
@@ -52,38 +56,37 @@ export class GrainAnalytics {
|
|
|
52
56
|
configCacheKey: 'grain_config',
|
|
53
57
|
configRefreshInterval: 300000, // 5 minutes
|
|
54
58
|
enableConfigCache: true,
|
|
55
|
-
// Privacy defaults
|
|
56
|
-
consentMode: '
|
|
59
|
+
// Privacy defaults (v2.0)
|
|
60
|
+
consentMode: 'cookieless', // Default: privacy-first, no permanent tracking
|
|
57
61
|
waitForConsent: false,
|
|
58
|
-
enableCookies: false,
|
|
59
|
-
anonymizeIP: false,
|
|
60
62
|
disableAutoProperties: false,
|
|
61
63
|
// Automatic Tracking defaults
|
|
62
64
|
enableHeartbeat: true,
|
|
63
65
|
heartbeatActiveInterval: 120000, // 2 minutes
|
|
64
66
|
heartbeatInactiveInterval: 300000, // 5 minutes
|
|
65
67
|
enableAutoPageView: true,
|
|
66
|
-
stripQueryParams: true,
|
|
68
|
+
stripQueryParams: true, // Privacy-first: strip by default
|
|
69
|
+
stripHash: false,
|
|
67
70
|
// Heatmap Tracking defaults
|
|
68
71
|
enableHeatmapTracking: true,
|
|
69
72
|
...config,
|
|
70
73
|
tenantId: config.tenantId,
|
|
71
74
|
};
|
|
72
|
-
// Initialize consent manager
|
|
75
|
+
// Initialize consent manager (v2.0)
|
|
73
76
|
this.consentManager = new ConsentManager(this.config.tenantId, this.config.consentMode);
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
77
|
+
// Initialize ID manager (v2.0)
|
|
78
|
+
const idMode = this.consentManager.getIdMode();
|
|
79
|
+
this.idManager = new IdManager({
|
|
80
|
+
mode: idMode,
|
|
81
|
+
tenantId: this.config.tenantId,
|
|
82
|
+
useLocalStorage: true, // For permanent IDs when consented
|
|
83
|
+
});
|
|
81
84
|
// Set global userId if provided in config
|
|
82
85
|
if (config.userId) {
|
|
83
86
|
this.globalUserId = config.userId;
|
|
84
87
|
}
|
|
85
88
|
this.validateConfig();
|
|
86
|
-
|
|
89
|
+
// Deprecated: initializePersistentAnonymousUserId() - now handled by IdManager
|
|
87
90
|
this.setupBeforeUnload();
|
|
88
91
|
this.startFlushTimer();
|
|
89
92
|
this.initializeConfigCache();
|
|
@@ -91,6 +94,8 @@ export class GrainAnalytics {
|
|
|
91
94
|
this.ephemeralSessionId = this.generateUUID();
|
|
92
95
|
// Initialize automatic tracking (browser only)
|
|
93
96
|
if (typeof window !== 'undefined') {
|
|
97
|
+
// Check for debug mode before initializing tracking
|
|
98
|
+
this.checkAndInitializeDebugMode();
|
|
94
99
|
this.initializeAutomaticTracking();
|
|
95
100
|
// Track session start
|
|
96
101
|
this.trackSessionStart();
|
|
@@ -99,8 +104,11 @@ export class GrainAnalytics {
|
|
|
99
104
|
this.initializeHeatmapTracking();
|
|
100
105
|
}
|
|
101
106
|
}
|
|
102
|
-
// Set up consent change listener to flush waiting events
|
|
107
|
+
// Set up consent change listener to sync IdManager and flush waiting events (v2.0)
|
|
103
108
|
this.consentManager.addListener((state) => {
|
|
109
|
+
// Sync IdManager with consent state
|
|
110
|
+
const idMode = this.consentManager.getIdMode();
|
|
111
|
+
this.idManager.setMode(idMode);
|
|
104
112
|
if (state.granted) {
|
|
105
113
|
this.handleConsentGranted();
|
|
106
114
|
}
|
|
@@ -142,11 +150,14 @@ export class GrainAnalytics {
|
|
|
142
150
|
*/
|
|
143
151
|
shouldAllowPersistentStorage() {
|
|
144
152
|
const hasConsent = this.consentManager.hasConsent('analytics');
|
|
145
|
-
const
|
|
153
|
+
const isCookieless = this.config.consentMode === 'cookieless';
|
|
146
154
|
const userExplicitlyIdentified = !!this.globalUserId;
|
|
147
155
|
const isJWTAuth = this.config.authStrategy === 'JWT';
|
|
156
|
+
// Never allow persistent storage in cookieless mode
|
|
157
|
+
if (isCookieless)
|
|
158
|
+
return false;
|
|
148
159
|
// Allow persistent storage if any of these conditions are met
|
|
149
|
-
return hasConsent ||
|
|
160
|
+
return hasConsent || userExplicitlyIdentified || isJWTAuth;
|
|
150
161
|
}
|
|
151
162
|
/**
|
|
152
163
|
* Generate a proper UUIDv4 identifier for anonymous user ID
|
|
@@ -240,23 +251,21 @@ export class GrainAnalytics {
|
|
|
240
251
|
}
|
|
241
252
|
}
|
|
242
253
|
/**
|
|
243
|
-
* Get the effective user ID (
|
|
254
|
+
* Get the effective user ID (v2.0)
|
|
244
255
|
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
256
|
+
* Privacy-first implementation:
|
|
257
|
+
* - Returns global userId if explicitly set (via identify/login)
|
|
258
|
+
* - Otherwise uses IdManager to generate:
|
|
259
|
+
* - Daily rotating ID (cookieless mode)
|
|
260
|
+
* - Permanent ID (with consent)
|
|
247
261
|
*/
|
|
248
262
|
getEffectiveUserIdInternal() {
|
|
263
|
+
// Explicit user identification always takes precedence
|
|
249
264
|
if (this.globalUserId) {
|
|
250
265
|
return this.globalUserId;
|
|
251
266
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
}
|
|
255
|
-
// Generate a new UUIDv4 identifier as fallback
|
|
256
|
-
this.persistentAnonymousUserId = this.generateAnonymousUserId();
|
|
257
|
-
// Try to persist it (will be skipped in opt-in mode without consent)
|
|
258
|
-
this.savePersistentAnonymousUserId(this.persistentAnonymousUserId);
|
|
259
|
-
return this.persistentAnonymousUserId;
|
|
267
|
+
// Use IdManager to generate appropriate ID based on consent
|
|
268
|
+
return this.idManager.getCurrentUserId();
|
|
260
269
|
}
|
|
261
270
|
log(...args) {
|
|
262
271
|
if (this.config.debug) {
|
|
@@ -627,6 +636,7 @@ export class GrainAnalytics {
|
|
|
627
636
|
try {
|
|
628
637
|
this.pageTrackingManager = new PageTrackingManager(this, {
|
|
629
638
|
stripQueryParams: this.config.stripQueryParams,
|
|
639
|
+
stripHash: this.config.stripHash,
|
|
630
640
|
debug: this.config.debug,
|
|
631
641
|
tenantId: this.config.tenantId,
|
|
632
642
|
});
|
|
@@ -719,6 +729,8 @@ export class GrainAnalytics {
|
|
|
719
729
|
debug: this.config.debug,
|
|
720
730
|
enableMutationObserver: true,
|
|
721
731
|
mutationDebounceDelay: 500,
|
|
732
|
+
tenantId: this.config.tenantId,
|
|
733
|
+
apiUrl: this.config.apiUrl,
|
|
722
734
|
});
|
|
723
735
|
this.log('Interaction tracking initialized');
|
|
724
736
|
}
|
|
@@ -922,12 +934,13 @@ export class GrainAnalytics {
|
|
|
922
934
|
return;
|
|
923
935
|
const hasConsent = this.consentManager.hasConsent('analytics');
|
|
924
936
|
// Create event with appropriate user ID
|
|
937
|
+
// v2.0: Always use IdManager which returns daily rotating ID or permanent ID based on consent
|
|
925
938
|
const event = {
|
|
926
939
|
eventName,
|
|
927
|
-
userId:
|
|
940
|
+
userId: this.getEffectiveUserId(), // IdManager handles daily vs permanent based on consent
|
|
928
941
|
properties: {
|
|
929
942
|
...properties,
|
|
930
|
-
_minimal: !hasConsent, // Flag to indicate minimal tracking
|
|
943
|
+
_minimal: !hasConsent, // Flag to indicate minimal tracking (daily rotating ID)
|
|
931
944
|
_consent_status: hasConsent ? 'granted' : 'pending',
|
|
932
945
|
},
|
|
933
946
|
};
|
|
@@ -1024,17 +1037,22 @@ export class GrainAnalytics {
|
|
|
1024
1037
|
event.properties = filtered;
|
|
1025
1038
|
}
|
|
1026
1039
|
const formattedEvent = this.formatEvent(event);
|
|
1027
|
-
// Check consent
|
|
1040
|
+
// Check if we should wait for consent (only if explicitly configured)
|
|
1028
1041
|
if (this.consentManager.shouldWaitForConsent() && this.config.waitForConsent) {
|
|
1029
1042
|
// Queue event until consent is granted
|
|
1030
1043
|
this.waitingForConsentQueue.push(formattedEvent);
|
|
1031
1044
|
this.log(`Event waiting for consent: ${event.eventName}`, event.properties);
|
|
1032
1045
|
return;
|
|
1033
1046
|
}
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1047
|
+
// v2.0: GDPR Strict falls back to cookie-less mode (daily rotating IDs)
|
|
1048
|
+
// Events are never blocked - IdManager already provides correct ID (daily or permanent)
|
|
1049
|
+
const hasConsent = this.consentManager.hasConsent('analytics');
|
|
1050
|
+
// Add tracking flags to indicate consent status
|
|
1051
|
+
formattedEvent.properties = {
|
|
1052
|
+
...formattedEvent.properties,
|
|
1053
|
+
_minimal: !hasConsent, // Flag: true = daily rotating ID, false = permanent ID
|
|
1054
|
+
_consent_status: hasConsent ? 'granted' : 'pending',
|
|
1055
|
+
};
|
|
1038
1056
|
this.eventQueue.push(formattedEvent);
|
|
1039
1057
|
this.eventCountSinceLastHeartbeat++;
|
|
1040
1058
|
this.sessionEventCount++;
|
|
@@ -1722,13 +1740,24 @@ export class GrainAnalytics {
|
|
|
1722
1740
|
}
|
|
1723
1741
|
// Privacy & Consent Methods
|
|
1724
1742
|
/**
|
|
1725
|
-
* Grant consent for tracking
|
|
1743
|
+
* Grant consent for tracking (v2.0)
|
|
1744
|
+
* Switches from cookie-less mode to permanent IDs
|
|
1726
1745
|
* @param categories - Optional array of consent categories (e.g., ['analytics', 'functional'])
|
|
1727
1746
|
*/
|
|
1728
1747
|
grantConsent(categories) {
|
|
1729
1748
|
try {
|
|
1730
1749
|
this.consentManager.grantConsent(categories);
|
|
1731
|
-
|
|
1750
|
+
// Sync ID manager with consent state
|
|
1751
|
+
const idMode = this.consentManager.getIdMode();
|
|
1752
|
+
this.idManager.setMode(idMode);
|
|
1753
|
+
this.log('Consent granted, switched to permanent IDs', categories);
|
|
1754
|
+
// Process any queued events waiting for consent
|
|
1755
|
+
if (this.waitingForConsentQueue.length > 0) {
|
|
1756
|
+
this.log(`Processing ${this.waitingForConsentQueue.length} queued events`);
|
|
1757
|
+
this.eventQueue.push(...this.waitingForConsentQueue);
|
|
1758
|
+
this.waitingForConsentQueue = [];
|
|
1759
|
+
this.flush();
|
|
1760
|
+
}
|
|
1732
1761
|
}
|
|
1733
1762
|
catch (error) {
|
|
1734
1763
|
const formattedError = this.formatError(error, 'grantConsent');
|
|
@@ -1736,16 +1765,22 @@ export class GrainAnalytics {
|
|
|
1736
1765
|
}
|
|
1737
1766
|
}
|
|
1738
1767
|
/**
|
|
1739
|
-
* Revoke consent for tracking (
|
|
1768
|
+
* Revoke consent for tracking (v2.0)
|
|
1769
|
+
* Switches from permanent IDs to cookie-less mode
|
|
1740
1770
|
* @param categories - Optional array of categories to revoke (if not provided, revokes all)
|
|
1741
1771
|
*/
|
|
1742
1772
|
revokeConsent(categories) {
|
|
1743
1773
|
try {
|
|
1744
1774
|
this.consentManager.revokeConsent(categories);
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
this.
|
|
1748
|
-
this.
|
|
1775
|
+
// Sync ID manager with consent state
|
|
1776
|
+
const idMode = this.consentManager.getIdMode();
|
|
1777
|
+
this.idManager.setMode(idMode);
|
|
1778
|
+
this.log('Consent revoked, switched to cookie-less mode', categories);
|
|
1779
|
+
// Clear queued events when consent is fully revoked
|
|
1780
|
+
if (!this.consentManager.hasConsent()) {
|
|
1781
|
+
this.eventQueue = [];
|
|
1782
|
+
this.waitingForConsentQueue = [];
|
|
1783
|
+
}
|
|
1749
1784
|
}
|
|
1750
1785
|
catch (error) {
|
|
1751
1786
|
const formattedError = this.formatError(error, 'revokeConsent');
|
|
@@ -1777,6 +1812,93 @@ export class GrainAnalytics {
|
|
|
1777
1812
|
offConsentChange(listener) {
|
|
1778
1813
|
this.consentManager.removeListener(listener);
|
|
1779
1814
|
}
|
|
1815
|
+
/**
|
|
1816
|
+
* Check for debug mode parameters and initialize debug agent if valid
|
|
1817
|
+
*/
|
|
1818
|
+
checkAndInitializeDebugMode() {
|
|
1819
|
+
if (typeof window === 'undefined')
|
|
1820
|
+
return;
|
|
1821
|
+
try {
|
|
1822
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
1823
|
+
const isDebug = urlParams.get('grain_debug') === '1';
|
|
1824
|
+
const sessionId = urlParams.get('grain_session');
|
|
1825
|
+
if (!isDebug || !sessionId) {
|
|
1826
|
+
return;
|
|
1827
|
+
}
|
|
1828
|
+
this.log('Debug mode detected, verifying session:', sessionId);
|
|
1829
|
+
// Verify session with API
|
|
1830
|
+
this.verifyDebugSession(sessionId, window.location.hostname)
|
|
1831
|
+
.then((valid) => {
|
|
1832
|
+
if (valid) {
|
|
1833
|
+
this.log('Debug session verified, initializing debug agent');
|
|
1834
|
+
this.isDebugMode = true;
|
|
1835
|
+
this.initializeDebugAgent(sessionId);
|
|
1836
|
+
}
|
|
1837
|
+
else {
|
|
1838
|
+
this.log('Debug session verification failed');
|
|
1839
|
+
}
|
|
1840
|
+
})
|
|
1841
|
+
.catch((error) => {
|
|
1842
|
+
this.log('Failed to verify debug session:', error);
|
|
1843
|
+
});
|
|
1844
|
+
}
|
|
1845
|
+
catch (error) {
|
|
1846
|
+
this.log('Error checking debug mode:', error);
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
/**
|
|
1850
|
+
* Verify debug session with API
|
|
1851
|
+
*/
|
|
1852
|
+
async verifyDebugSession(sessionId, domain) {
|
|
1853
|
+
try {
|
|
1854
|
+
const url = `${this.config.apiUrl}/v1/tenant/${encodeURIComponent(this.config.tenantId)}/debug-sessions/verify`;
|
|
1855
|
+
const response = await fetch(url, {
|
|
1856
|
+
method: 'POST',
|
|
1857
|
+
headers: {
|
|
1858
|
+
'Content-Type': 'application/json',
|
|
1859
|
+
},
|
|
1860
|
+
body: JSON.stringify({
|
|
1861
|
+
sessionId,
|
|
1862
|
+
domain,
|
|
1863
|
+
}),
|
|
1864
|
+
});
|
|
1865
|
+
if (!response.ok) {
|
|
1866
|
+
return false;
|
|
1867
|
+
}
|
|
1868
|
+
const result = await response.json();
|
|
1869
|
+
return result.valid === true;
|
|
1870
|
+
}
|
|
1871
|
+
catch (error) {
|
|
1872
|
+
this.log('Debug session verification error:', error);
|
|
1873
|
+
return false;
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
/**
|
|
1877
|
+
* Initialize debug agent
|
|
1878
|
+
*/
|
|
1879
|
+
initializeDebugAgent(sessionId) {
|
|
1880
|
+
if (typeof window === 'undefined')
|
|
1881
|
+
return;
|
|
1882
|
+
try {
|
|
1883
|
+
this.log('Loading debug agent module');
|
|
1884
|
+
import('./debug-agent').then(({ DebugAgent }) => {
|
|
1885
|
+
try {
|
|
1886
|
+
this.debugAgent = new DebugAgent(this, sessionId, this.config.tenantId, this.config.apiUrl, {
|
|
1887
|
+
debug: this.config.debug,
|
|
1888
|
+
});
|
|
1889
|
+
this.log('Debug agent initialized');
|
|
1890
|
+
}
|
|
1891
|
+
catch (error) {
|
|
1892
|
+
this.log('Failed to initialize debug agent:', error);
|
|
1893
|
+
}
|
|
1894
|
+
}).catch((error) => {
|
|
1895
|
+
this.log('Failed to load debug agent module:', error);
|
|
1896
|
+
});
|
|
1897
|
+
}
|
|
1898
|
+
catch (error) {
|
|
1899
|
+
this.log('Error initializing debug agent:', error);
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1780
1902
|
/**
|
|
1781
1903
|
* Destroy the client and clean up resources
|
|
1782
1904
|
*/
|
|
@@ -1816,6 +1938,11 @@ export class GrainAnalytics {
|
|
|
1816
1938
|
this.heatmapTrackingManager.destroy();
|
|
1817
1939
|
this.heatmapTrackingManager = null;
|
|
1818
1940
|
}
|
|
1941
|
+
// Destroy debug agent
|
|
1942
|
+
if (this.debugAgent) {
|
|
1943
|
+
this.debugAgent.destroy();
|
|
1944
|
+
this.debugAgent = null;
|
|
1945
|
+
}
|
|
1819
1946
|
// Send any remaining events (in chunks if necessary)
|
|
1820
1947
|
if (this.eventQueue.length > 0) {
|
|
1821
1948
|
const eventsToSend = [...this.eventQueue];
|
|
@@ -15,6 +15,8 @@ export interface InteractionTrackingConfig {
|
|
|
15
15
|
debug?: boolean;
|
|
16
16
|
enableMutationObserver?: boolean;
|
|
17
17
|
mutationDebounceDelay?: number;
|
|
18
|
+
tenantId?: string;
|
|
19
|
+
apiUrl?: string;
|
|
18
20
|
}
|
|
19
21
|
export declare class InteractionTrackingManager {
|
|
20
22
|
private tracker;
|
|
@@ -26,6 +28,10 @@ export declare class InteractionTrackingManager {
|
|
|
26
28
|
private mutationObserver;
|
|
27
29
|
private mutationDebounceTimer;
|
|
28
30
|
constructor(tracker: InteractionTracker, interactions: InteractionConfig[], config?: InteractionTrackingConfig);
|
|
31
|
+
/**
|
|
32
|
+
* Fetch trackers from API and merge with existing interactions
|
|
33
|
+
*/
|
|
34
|
+
private fetchAndMergeTrackers;
|
|
29
35
|
/**
|
|
30
36
|
* Attach listeners to all configured interactions
|
|
31
37
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"interaction-tracking.d.ts","sourceRoot":"","sources":["../src/interaction-tracking.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAG/D,MAAM,WAAW,gBAAgB;IAC/B,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,kBAAkB;IACjC,KAAK,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACjH,UAAU,CAAC,QAAQ,EAAE,WAAW,GAAG,WAAW,GAAG,YAAY,GAAG,OAAO,CAAC;IACxE,GAAG,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;CAC/B;AAED,MAAM,WAAW,yBAAyB;IACxC,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,sBAAsB,CAAC,EAAE,OAAO,CAAC;IACjC,qBAAqB,CAAC,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"interaction-tracking.d.ts","sourceRoot":"","sources":["../src/interaction-tracking.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAG/D,MAAM,WAAW,gBAAgB;IAC/B,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,kBAAkB;IACjC,KAAK,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACjH,UAAU,CAAC,QAAQ,EAAE,WAAW,GAAG,WAAW,GAAG,YAAY,GAAG,OAAO,CAAC;IACxE,GAAG,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;CAC/B;AAED,MAAM,WAAW,yBAAyB;IACxC,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,sBAAsB,CAAC,EAAE,OAAO,CAAC;IACjC,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,qBAAa,0BAA0B;IACrC,OAAO,CAAC,OAAO,CAAqB;IACpC,OAAO,CAAC,YAAY,CAAsB;IAC1C,OAAO,CAAC,MAAM,CAA4B;IAC1C,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,iBAAiB,CAA6E;IACtG,OAAO,CAAC,UAAU,CAA0C;IAC5D,OAAO,CAAC,gBAAgB,CAAiC;IACzD,OAAO,CAAC,qBAAqB,CAAuB;gBAGlD,OAAO,EAAE,kBAAkB,EAC3B,YAAY,EAAE,iBAAiB,EAAE,EACjC,MAAM,GAAE,yBAA8B;IAmCxC;;OAEG;YACW,qBAAqB;IA6CnC;;OAEG;IACH,OAAO,CAAC,kBAAkB;IAU1B;;OAEG;IACH,OAAO,CAAC,yBAAyB;IAiCjC;;OAEG;IACH,OAAO,CAAC,sBAAsB;IAiC9B;;OAEG;IACH,OAAO,CAAC,sBAAsB;IAkB9B;;OAEG;IACH,OAAO,CAAC,kBAAkB;IAyC1B;;OAEG;IACH,OAAO,CAAC,qBAAqB;IA0B7B;;OAEG;IACH,OAAO,CAAC,eAAe;IA+BvB;;OAEG;IACH,OAAO,CAAC,eAAe;IAWvB;;OAEG;IACH,OAAO,CAAC,GAAG;IAMX;;OAEG;IACH,kBAAkB,CAAC,YAAY,EAAE,iBAAiB,EAAE,GAAG,IAAI;IAoB3D;;OAEG;IACH,OAAO,IAAI,IAAI;CA4BhB"}
|
|
@@ -19,15 +19,25 @@ class InteractionTrackingManager {
|
|
|
19
19
|
debug: config.debug ?? false,
|
|
20
20
|
enableMutationObserver: config.enableMutationObserver ?? true,
|
|
21
21
|
mutationDebounceDelay: config.mutationDebounceDelay ?? 500,
|
|
22
|
+
tenantId: config.tenantId,
|
|
23
|
+
apiUrl: config.apiUrl,
|
|
22
24
|
};
|
|
23
25
|
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
|
24
|
-
//
|
|
25
|
-
if (
|
|
26
|
-
|
|
26
|
+
// Fetch and merge trackers if configured
|
|
27
|
+
if (this.config.tenantId && this.config.apiUrl) {
|
|
28
|
+
this.fetchAndMergeTrackers().then(() => {
|
|
29
|
+
this.attachAllListeners();
|
|
30
|
+
});
|
|
27
31
|
}
|
|
28
32
|
else {
|
|
29
|
-
// DOM
|
|
30
|
-
|
|
33
|
+
// Attach listeners after DOM is ready
|
|
34
|
+
if (document.readyState === 'loading') {
|
|
35
|
+
document.addEventListener('DOMContentLoaded', () => this.attachAllListeners());
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
// DOM already loaded
|
|
39
|
+
setTimeout(() => this.attachAllListeners(), 0);
|
|
40
|
+
}
|
|
31
41
|
}
|
|
32
42
|
// Setup mutation observer for dynamic content
|
|
33
43
|
if (this.config.enableMutationObserver) {
|
|
@@ -35,6 +45,46 @@ class InteractionTrackingManager {
|
|
|
35
45
|
}
|
|
36
46
|
}
|
|
37
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Fetch trackers from API and merge with existing interactions
|
|
50
|
+
*/
|
|
51
|
+
async fetchAndMergeTrackers() {
|
|
52
|
+
if (!this.config.tenantId || !this.config.apiUrl)
|
|
53
|
+
return;
|
|
54
|
+
try {
|
|
55
|
+
const currentUrl = typeof window !== 'undefined' ? window.location.href : '';
|
|
56
|
+
const url = `${this.config.apiUrl}/v1/client/${encodeURIComponent(this.config.tenantId)}/trackers?url=${encodeURIComponent(currentUrl)}`;
|
|
57
|
+
this.log('Fetching trackers from:', url);
|
|
58
|
+
const response = await fetch(url, {
|
|
59
|
+
method: 'GET',
|
|
60
|
+
headers: {
|
|
61
|
+
'Content-Type': 'application/json',
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
if (!response.ok) {
|
|
65
|
+
this.log('Failed to fetch trackers:', response.status);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const result = await response.json();
|
|
69
|
+
if (result.trackers && Array.isArray(result.trackers)) {
|
|
70
|
+
this.log('Fetched', result.trackers.length, 'trackers');
|
|
71
|
+
// Convert trackers to InteractionConfig format
|
|
72
|
+
const trackerInteractions = result.trackers.map((tracker) => ({
|
|
73
|
+
eventName: tracker.eventName,
|
|
74
|
+
selector: tracker.selector,
|
|
75
|
+
priority: 5, // High priority for manually created trackers
|
|
76
|
+
label: tracker.eventName,
|
|
77
|
+
description: `Tracker: ${tracker.eventName}`,
|
|
78
|
+
}));
|
|
79
|
+
// Merge with existing interactions (trackers take precedence)
|
|
80
|
+
this.interactions = [...trackerInteractions, ...this.interactions];
|
|
81
|
+
this.log('Merged trackers, total interactions:', this.interactions.length);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
this.log('Error fetching trackers:', error);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
38
88
|
/**
|
|
39
89
|
* Attach listeners to all configured interactions
|
|
40
90
|
*/
|
package/dist/page-tracking.d.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export interface PageTrackingConfig {
|
|
6
6
|
stripQueryParams: boolean;
|
|
7
|
+
stripHash?: boolean;
|
|
7
8
|
debug?: boolean;
|
|
8
9
|
tenantId: string;
|
|
9
10
|
}
|
|
@@ -63,8 +64,13 @@ export declare class PageTrackingManager {
|
|
|
63
64
|
private getDeviceType;
|
|
64
65
|
/**
|
|
65
66
|
* Extract path from URL, optionally stripping query parameters
|
|
67
|
+
* Privacy-first: strips query params by default
|
|
66
68
|
*/
|
|
67
69
|
private extractPath;
|
|
70
|
+
/**
|
|
71
|
+
* Clean URL for privacy (strip query params based on config)
|
|
72
|
+
*/
|
|
73
|
+
private cleanUrl;
|
|
68
74
|
/**
|
|
69
75
|
* Get the current page path
|
|
70
76
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"page-tracking.d.ts","sourceRoot":"","sources":["../src/page-tracking.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAaH,MAAM,WAAW,kBAAkB;IACjC,gBAAgB,EAAE,OAAO,CAAC;IAC1B,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,WAAW;IAC1B,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IAC/E,UAAU,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;IACvC,kBAAkB,IAAI,MAAM,CAAC;IAC7B,qBAAqB,IAAI,MAAM,CAAC;IAChC,YAAY,IAAI,MAAM,CAAC;CACxB;AAED,qBAAa,mBAAmB;IAC9B,OAAO,CAAC,MAAM,CAAqB;IACnC,OAAO,CAAC,OAAO,CAAc;IAC7B,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,WAAW,CAAuB;IAC1C,OAAO,CAAC,iBAAiB,CAAyC;IAClE,OAAO,CAAC,oBAAoB,CAA4C;IACxE,OAAO,CAAC,YAAY,CAAuB;IAC3C,OAAO,CAAC,WAAW,CAAuB;IAC1C,OAAO,CAAC,aAAa,CAAK;gBAEd,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,kBAAkB;IAY5D;;OAEG;IACH,OAAO,CAAC,qBAAqB;IAqB7B;;OAEG;IACH,OAAO,CAAC,uBAAuB;IAK/B;;OAEG;IACH,OAAO,CAAC,cAAc,CAGpB;IAEF;;OAEG;IACH,OAAO,CAAC,gBAAgB,CAGtB;IAEF;;OAEG;IACH,OAAO,CAAC,gBAAgB;IA4GxB;;OAEG;IACH,OAAO,CAAC,aAAa;IASrB;;OAEG;IACH,OAAO,CAAC,UAAU;IAUlB;;OAEG;IACH,OAAO,CAAC,KAAK;IAUb;;OAEG;IACH,OAAO,CAAC,aAAa;IAwBrB;;OAEG;IACH,OAAO,CAAC,
|
|
1
|
+
{"version":3,"file":"page-tracking.d.ts","sourceRoot":"","sources":["../src/page-tracking.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAaH,MAAM,WAAW,kBAAkB;IACjC,gBAAgB,EAAE,OAAO,CAAC;IAC1B,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,WAAW;IAC1B,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IAC/E,UAAU,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;IACvC,kBAAkB,IAAI,MAAM,CAAC;IAC7B,qBAAqB,IAAI,MAAM,CAAC;IAChC,YAAY,IAAI,MAAM,CAAC;CACxB;AAED,qBAAa,mBAAmB;IAC9B,OAAO,CAAC,MAAM,CAAqB;IACnC,OAAO,CAAC,OAAO,CAAc;IAC7B,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,WAAW,CAAuB;IAC1C,OAAO,CAAC,iBAAiB,CAAyC;IAClE,OAAO,CAAC,oBAAoB,CAA4C;IACxE,OAAO,CAAC,YAAY,CAAuB;IAC3C,OAAO,CAAC,WAAW,CAAuB;IAC1C,OAAO,CAAC,aAAa,CAAK;gBAEd,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,kBAAkB;IAY5D;;OAEG;IACH,OAAO,CAAC,qBAAqB;IAqB7B;;OAEG;IACH,OAAO,CAAC,uBAAuB;IAK/B;;OAEG;IACH,OAAO,CAAC,cAAc,CAGpB;IAEF;;OAEG;IACH,OAAO,CAAC,gBAAgB,CAGtB;IAEF;;OAEG;IACH,OAAO,CAAC,gBAAgB;IA4GxB;;OAEG;IACH,OAAO,CAAC,aAAa;IASrB;;OAEG;IACH,OAAO,CAAC,UAAU;IAUlB;;OAEG;IACH,OAAO,CAAC,KAAK;IAUb;;OAEG;IACH,OAAO,CAAC,aAAa;IAwBrB;;;OAGG;IACH,OAAO,CAAC,WAAW;IAyBnB;;OAEG;IACH,OAAO,CAAC,QAAQ;IAahB;;OAEG;IACH,cAAc,IAAI,MAAM,GAAG,IAAI;IAI/B;;OAEG;IACH,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;IAqCnE;;OAEG;IACH,gBAAgB,IAAI,MAAM;IAI1B;;OAEG;IACH,OAAO,IAAI,IAAI;CAqBhB"}
|
package/dist/page-tracking.js
CHANGED
|
@@ -113,7 +113,7 @@ class PageTrackingManager {
|
|
|
113
113
|
// Enhanced properties when consent is granted
|
|
114
114
|
if (hasConsent) {
|
|
115
115
|
properties.title = document.title || '';
|
|
116
|
-
properties.full_url = currentUrl;
|
|
116
|
+
properties.full_url = this.cleanUrl(currentUrl); // Clean URL based on privacy settings
|
|
117
117
|
properties.session_id = this.tracker.getSessionId();
|
|
118
118
|
// Add referrer info
|
|
119
119
|
if (referrer) {
|
|
@@ -231,14 +231,20 @@ class PageTrackingManager {
|
|
|
231
231
|
}
|
|
232
232
|
/**
|
|
233
233
|
* Extract path from URL, optionally stripping query parameters
|
|
234
|
+
* Privacy-first: strips query params by default
|
|
234
235
|
*/
|
|
235
236
|
extractPath(url) {
|
|
236
237
|
try {
|
|
237
238
|
const urlObj = new URL(url);
|
|
238
|
-
let path = urlObj.pathname
|
|
239
|
+
let path = urlObj.pathname;
|
|
240
|
+
// Include query params only if not stripping
|
|
239
241
|
if (!this.config.stripQueryParams && urlObj.search) {
|
|
240
242
|
path += urlObj.search;
|
|
241
243
|
}
|
|
244
|
+
// Include hash only if not stripping
|
|
245
|
+
if (!this.config.stripHash && urlObj.hash) {
|
|
246
|
+
path += urlObj.hash;
|
|
247
|
+
}
|
|
242
248
|
return path;
|
|
243
249
|
}
|
|
244
250
|
catch (error) {
|
|
@@ -249,6 +255,21 @@ class PageTrackingManager {
|
|
|
249
255
|
return url;
|
|
250
256
|
}
|
|
251
257
|
}
|
|
258
|
+
/**
|
|
259
|
+
* Clean URL for privacy (strip query params based on config)
|
|
260
|
+
*/
|
|
261
|
+
cleanUrl(url) {
|
|
262
|
+
if (!this.config.stripQueryParams) {
|
|
263
|
+
return url;
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
const urlObj = new URL(url);
|
|
267
|
+
return `${urlObj.origin}${urlObj.pathname}${this.config.stripHash ? '' : urlObj.hash}`;
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
return url;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
252
273
|
/**
|
|
253
274
|
* Get the current page path
|
|
254
275
|
*/
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* useConsent - Hook for managing user consent
|
|
2
|
+
* useConsent - Hook for managing user consent (v2.0)
|
|
3
|
+
* Updated for new consent modes: cookieless, gdpr-strict, gdpr-opt-out
|
|
3
4
|
*/
|
|
4
|
-
import type { ConsentState } from '../../consent';
|
|
5
|
+
import type { ConsentState, ConsentMode } from '../../consent';
|
|
5
6
|
export declare function useConsent(): {
|
|
6
7
|
consentState: ConsentState;
|
|
7
8
|
grantConsent: (categories?: string[]) => void;
|
|
@@ -10,4 +11,19 @@ export declare function useConsent(): {
|
|
|
10
11
|
isGranted: boolean;
|
|
11
12
|
categories: string[];
|
|
12
13
|
};
|
|
14
|
+
/**
|
|
15
|
+
* useConsentMode - Hook to get current consent mode
|
|
16
|
+
* v2.0: Returns 'cookieless' | 'gdpr-strict' | 'gdpr-opt-out'
|
|
17
|
+
*/
|
|
18
|
+
export declare function useConsentMode(): ConsentMode | null;
|
|
19
|
+
/**
|
|
20
|
+
* useTrackingId - Hook to get current tracking ID
|
|
21
|
+
* v2.0: Returns daily rotating ID or permanent ID based on consent
|
|
22
|
+
*/
|
|
23
|
+
export declare function useTrackingId(): string | null;
|
|
24
|
+
/**
|
|
25
|
+
* useCanTrack - Hook to check if tracking is allowed
|
|
26
|
+
* v2.0: Always returns true (even cookieless mode allows basic tracking)
|
|
27
|
+
*/
|
|
28
|
+
export declare function useCanTrack(): boolean;
|
|
13
29
|
//# sourceMappingURL=useConsent.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useConsent.d.ts","sourceRoot":"","sources":["../../../../src/react/hooks/useConsent.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"useConsent.d.ts","sourceRoot":"","sources":["../../../../src/react/hooks/useConsent.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAIH,OAAO,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAE/D,wBAAgB,UAAU;;gCAwBR,MAAM,EAAE;iCASR,MAAM,EAAE;4BASV,MAAM;;;EAerB;AAED;;;GAGG;AACH,wBAAgB,cAAc,IAAI,WAAW,GAAG,IAAI,CAanD;AAED;;;GAGG;AACH,wBAAgB,aAAa,IAAI,MAAM,GAAG,IAAI,CAiB7C;AAED;;;GAGG;AACH,wBAAgB,WAAW,IAAI,OAAO,CAKrC"}
|