@clianta/sdk 1.3.0 → 1.5.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 +46 -0
- package/README.md +56 -1
- package/dist/angular.cjs.js +4345 -0
- package/dist/angular.cjs.js.map +1 -0
- package/dist/angular.d.ts +298 -0
- package/dist/angular.esm.js +4341 -0
- package/dist/angular.esm.js.map +1 -0
- package/dist/clianta.cjs.js +1504 -1005
- package/dist/clianta.cjs.js.map +1 -1
- package/dist/clianta.esm.js +1504 -1005
- package/dist/clianta.esm.js.map +1 -1
- package/dist/clianta.umd.js +1504 -1005
- package/dist/clianta.umd.js.map +1 -1
- package/dist/clianta.umd.min.js +2 -2
- package/dist/clianta.umd.min.js.map +1 -1
- package/dist/index.d.ts +1068 -791
- package/dist/react.cjs.js +1517 -1010
- package/dist/react.cjs.js.map +1 -1
- package/dist/react.d.ts +125 -3
- package/dist/react.esm.js +1518 -1011
- package/dist/react.esm.js.map +1 -1
- package/dist/svelte.cjs.js +4377 -0
- package/dist/svelte.cjs.js.map +1 -0
- package/dist/svelte.d.ts +308 -0
- package/dist/svelte.esm.js +4374 -0
- package/dist/svelte.esm.js.map +1 -0
- package/dist/vue.cjs.js +1504 -1005
- package/dist/vue.cjs.js.map +1 -1
- package/dist/vue.d.ts +125 -3
- package/dist/vue.esm.js +1504 -1005
- package/dist/vue.esm.js.map +1 -1
- package/package.json +21 -2
|
@@ -0,0 +1,4345 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Clianta SDK v1.5.0
|
|
3
|
+
* (c) 2026 Clianta
|
|
4
|
+
* Released under the MIT License.
|
|
5
|
+
*/
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Clianta SDK - Configuration
|
|
10
|
+
* @see SDK_VERSION in core/config.ts
|
|
11
|
+
*/
|
|
12
|
+
/** SDK Version */
|
|
13
|
+
const SDK_VERSION = '1.4.0';
|
|
14
|
+
/** Default API endpoint based on environment */
|
|
15
|
+
const getDefaultApiEndpoint = () => {
|
|
16
|
+
if (typeof window === 'undefined')
|
|
17
|
+
return 'https://api.clianta.online';
|
|
18
|
+
const hostname = window.location.hostname;
|
|
19
|
+
if (hostname.includes('localhost') || hostname.includes('127.0.0.1')) {
|
|
20
|
+
return 'http://localhost:5000';
|
|
21
|
+
}
|
|
22
|
+
return 'https://api.clianta.online';
|
|
23
|
+
};
|
|
24
|
+
/** Core plugins enabled by default */
|
|
25
|
+
const DEFAULT_PLUGINS = [
|
|
26
|
+
'pageView',
|
|
27
|
+
'forms',
|
|
28
|
+
'scroll',
|
|
29
|
+
'clicks',
|
|
30
|
+
'engagement',
|
|
31
|
+
'downloads',
|
|
32
|
+
'exitIntent',
|
|
33
|
+
];
|
|
34
|
+
/** Default configuration values */
|
|
35
|
+
const DEFAULT_CONFIG = {
|
|
36
|
+
projectId: '',
|
|
37
|
+
apiEndpoint: getDefaultApiEndpoint(),
|
|
38
|
+
authToken: '',
|
|
39
|
+
apiKey: '',
|
|
40
|
+
debug: false,
|
|
41
|
+
autoPageView: true,
|
|
42
|
+
plugins: DEFAULT_PLUGINS,
|
|
43
|
+
sessionTimeout: 30 * 60 * 1000, // 30 minutes
|
|
44
|
+
batchSize: 10,
|
|
45
|
+
flushInterval: 5000, // 5 seconds
|
|
46
|
+
consent: {
|
|
47
|
+
defaultConsent: { analytics: true, marketing: false, personalization: false },
|
|
48
|
+
waitForConsent: false,
|
|
49
|
+
storageKey: 'mb_consent',
|
|
50
|
+
anonymousMode: false,
|
|
51
|
+
},
|
|
52
|
+
cookieDomain: '',
|
|
53
|
+
useCookies: false,
|
|
54
|
+
cookielessMode: false,
|
|
55
|
+
persistMode: 'session',
|
|
56
|
+
};
|
|
57
|
+
/** Storage keys */
|
|
58
|
+
const STORAGE_KEYS = {
|
|
59
|
+
VISITOR_ID: 'mb_vid',
|
|
60
|
+
SESSION_ID: 'mb_sid',
|
|
61
|
+
SESSION_TIMESTAMP: 'mb_st',
|
|
62
|
+
CONSENT: 'mb_consent',
|
|
63
|
+
EVENT_QUEUE: 'mb_queue',
|
|
64
|
+
};
|
|
65
|
+
/** Scroll depth milestones to track */
|
|
66
|
+
const SCROLL_MILESTONES = [25, 50, 75, 100];
|
|
67
|
+
/** File extensions to track as downloads */
|
|
68
|
+
const DOWNLOAD_EXTENSIONS = [
|
|
69
|
+
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
|
70
|
+
'.zip', '.rar', '.tar', '.gz', '.7z',
|
|
71
|
+
'.csv', '.txt', '.json', '.xml',
|
|
72
|
+
'.mp3', '.mp4', '.wav', '.avi', '.mov',
|
|
73
|
+
];
|
|
74
|
+
/**
|
|
75
|
+
* Merge user config with defaults
|
|
76
|
+
*/
|
|
77
|
+
function mergeConfig(userConfig = {}) {
|
|
78
|
+
return {
|
|
79
|
+
...DEFAULT_CONFIG,
|
|
80
|
+
...userConfig,
|
|
81
|
+
consent: {
|
|
82
|
+
...DEFAULT_CONFIG.consent,
|
|
83
|
+
...userConfig.consent,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Clianta SDK - Debug Logger
|
|
90
|
+
* @see SDK_VERSION in core/config.ts
|
|
91
|
+
*/
|
|
92
|
+
const LOG_PREFIX = '[Clianta]';
|
|
93
|
+
const LOG_STYLES = {
|
|
94
|
+
debug: 'color: #6b7280; font-weight: normal;',
|
|
95
|
+
info: 'color: #3b82f6; font-weight: normal;',
|
|
96
|
+
warn: 'color: #f59e0b; font-weight: bold;',
|
|
97
|
+
error: 'color: #ef4444; font-weight: bold;',
|
|
98
|
+
};
|
|
99
|
+
const LOG_LEVELS = {
|
|
100
|
+
debug: 0,
|
|
101
|
+
info: 1,
|
|
102
|
+
warn: 2,
|
|
103
|
+
error: 3,
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* Create a logger instance
|
|
107
|
+
*/
|
|
108
|
+
function createLogger(enabled = false) {
|
|
109
|
+
let currentLevel = 'debug';
|
|
110
|
+
let isEnabled = enabled;
|
|
111
|
+
const shouldLog = (level) => {
|
|
112
|
+
if (!isEnabled)
|
|
113
|
+
return false;
|
|
114
|
+
return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel];
|
|
115
|
+
};
|
|
116
|
+
const formatArgs = (level, args) => {
|
|
117
|
+
if (typeof console !== 'undefined' && typeof window !== 'undefined') {
|
|
118
|
+
// Browser with styled console
|
|
119
|
+
return [`%c${LOG_PREFIX}`, LOG_STYLES[level], ...args];
|
|
120
|
+
}
|
|
121
|
+
// Node.js or basic console
|
|
122
|
+
return [`${LOG_PREFIX} [${level.toUpperCase()}]`, ...args];
|
|
123
|
+
};
|
|
124
|
+
return {
|
|
125
|
+
get enabled() {
|
|
126
|
+
return isEnabled;
|
|
127
|
+
},
|
|
128
|
+
set enabled(value) {
|
|
129
|
+
isEnabled = value;
|
|
130
|
+
},
|
|
131
|
+
debug(...args) {
|
|
132
|
+
if (shouldLog('debug') && typeof console !== 'undefined') {
|
|
133
|
+
console.log(...formatArgs('debug', args));
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
info(...args) {
|
|
137
|
+
if (shouldLog('info') && typeof console !== 'undefined') {
|
|
138
|
+
console.info(...formatArgs('info', args));
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
warn(...args) {
|
|
142
|
+
if (shouldLog('warn') && typeof console !== 'undefined') {
|
|
143
|
+
console.warn(...formatArgs('warn', args));
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
error(...args) {
|
|
147
|
+
if (shouldLog('error') && typeof console !== 'undefined') {
|
|
148
|
+
console.error(...formatArgs('error', args));
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
setLevel(level) {
|
|
152
|
+
currentLevel = level;
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/** Global logger instance */
|
|
157
|
+
const logger = createLogger(false);
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Clianta SDK - Transport Layer
|
|
161
|
+
* Handles sending events to the backend with retry logic
|
|
162
|
+
* @see SDK_VERSION in core/config.ts
|
|
163
|
+
*/
|
|
164
|
+
const DEFAULT_TIMEOUT = 10000; // 10 seconds
|
|
165
|
+
const DEFAULT_MAX_RETRIES = 3;
|
|
166
|
+
const DEFAULT_RETRY_DELAY = 1000; // 1 second
|
|
167
|
+
/**
|
|
168
|
+
* Transport class for sending data to the backend
|
|
169
|
+
*/
|
|
170
|
+
class Transport {
|
|
171
|
+
constructor(config) {
|
|
172
|
+
this.config = {
|
|
173
|
+
apiEndpoint: config.apiEndpoint,
|
|
174
|
+
maxRetries: config.maxRetries ?? DEFAULT_MAX_RETRIES,
|
|
175
|
+
retryDelay: config.retryDelay ?? DEFAULT_RETRY_DELAY,
|
|
176
|
+
timeout: config.timeout ?? DEFAULT_TIMEOUT,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Send events to the tracking endpoint
|
|
181
|
+
*/
|
|
182
|
+
async sendEvents(events) {
|
|
183
|
+
const url = `${this.config.apiEndpoint}/api/public/track/event`;
|
|
184
|
+
const payload = JSON.stringify({ events });
|
|
185
|
+
return this.send(url, payload);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Send identify request.
|
|
189
|
+
* Returns contactId from the server response so the Tracker can store it.
|
|
190
|
+
*/
|
|
191
|
+
async sendIdentify(data) {
|
|
192
|
+
const url = `${this.config.apiEndpoint}/api/public/track/identify`;
|
|
193
|
+
try {
|
|
194
|
+
const response = await this.fetchWithTimeout(url, {
|
|
195
|
+
method: 'POST',
|
|
196
|
+
headers: { 'Content-Type': 'application/json' },
|
|
197
|
+
body: JSON.stringify(data),
|
|
198
|
+
keepalive: true,
|
|
199
|
+
});
|
|
200
|
+
const body = await response.json().catch(() => ({}));
|
|
201
|
+
if (response.ok) {
|
|
202
|
+
logger.debug('Identify successful, contactId:', body.contactId);
|
|
203
|
+
return {
|
|
204
|
+
success: true,
|
|
205
|
+
status: response.status,
|
|
206
|
+
contactId: body.contactId ?? undefined,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
if (response.status >= 500) {
|
|
210
|
+
logger.warn(`Identify server error (${response.status})`);
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
logger.error(`Identify failed with status ${response.status}:`, body.message);
|
|
214
|
+
}
|
|
215
|
+
return { success: false, status: response.status };
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
logger.error('Identify request failed:', error);
|
|
219
|
+
return { success: false, error: error };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Send events synchronously (for page unload)
|
|
224
|
+
* Uses navigator.sendBeacon for reliability
|
|
225
|
+
*/
|
|
226
|
+
sendBeacon(events) {
|
|
227
|
+
if (typeof navigator === 'undefined' || !navigator.sendBeacon) {
|
|
228
|
+
logger.warn('sendBeacon not available, events may be lost');
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
const url = `${this.config.apiEndpoint}/api/public/track/event`;
|
|
232
|
+
const payload = JSON.stringify({ events });
|
|
233
|
+
const blob = new Blob([payload], { type: 'application/json' });
|
|
234
|
+
try {
|
|
235
|
+
const success = navigator.sendBeacon(url, blob);
|
|
236
|
+
if (success) {
|
|
237
|
+
logger.debug(`Beacon sent ${events.length} events`);
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
logger.warn('sendBeacon returned false');
|
|
241
|
+
}
|
|
242
|
+
return success;
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
logger.error('sendBeacon error:', error);
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Fetch data from the tracking API (GET request)
|
|
251
|
+
* Used for read-back APIs (visitor profile, activity, etc.)
|
|
252
|
+
*/
|
|
253
|
+
async fetchData(path, params) {
|
|
254
|
+
const url = new URL(`${this.config.apiEndpoint}${path}`);
|
|
255
|
+
if (params) {
|
|
256
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
257
|
+
if (value !== undefined && value !== null) {
|
|
258
|
+
url.searchParams.set(key, value);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
const response = await this.fetchWithTimeout(url.toString(), {
|
|
264
|
+
method: 'GET',
|
|
265
|
+
headers: {
|
|
266
|
+
'Accept': 'application/json',
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
if (response.ok) {
|
|
270
|
+
const body = await response.json();
|
|
271
|
+
logger.debug('Fetch successful:', path);
|
|
272
|
+
return { success: true, data: body.data ?? body, status: response.status };
|
|
273
|
+
}
|
|
274
|
+
logger.error(`Fetch failed with status ${response.status}`);
|
|
275
|
+
return { success: false, status: response.status };
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
logger.error('Fetch request failed:', error);
|
|
279
|
+
return { success: false, error: error };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Internal send with retry logic
|
|
284
|
+
*/
|
|
285
|
+
async send(url, payload, attempt = 1) {
|
|
286
|
+
try {
|
|
287
|
+
const response = await this.fetchWithTimeout(url, {
|
|
288
|
+
method: 'POST',
|
|
289
|
+
headers: {
|
|
290
|
+
'Content-Type': 'application/json',
|
|
291
|
+
},
|
|
292
|
+
body: payload,
|
|
293
|
+
keepalive: true,
|
|
294
|
+
});
|
|
295
|
+
if (response.ok) {
|
|
296
|
+
logger.debug('Request successful:', url);
|
|
297
|
+
return { success: true, status: response.status };
|
|
298
|
+
}
|
|
299
|
+
// Server error - may retry
|
|
300
|
+
if (response.status >= 500 && attempt < this.config.maxRetries) {
|
|
301
|
+
logger.warn(`Server error (${response.status}), retrying...`);
|
|
302
|
+
await this.delay(this.config.retryDelay * attempt);
|
|
303
|
+
return this.send(url, payload, attempt + 1);
|
|
304
|
+
}
|
|
305
|
+
// Client error - don't retry
|
|
306
|
+
logger.error(`Request failed with status ${response.status}`);
|
|
307
|
+
return { success: false, status: response.status };
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
// Network error - retry if possible
|
|
311
|
+
if (attempt < this.config.maxRetries) {
|
|
312
|
+
logger.warn(`Network error, retrying (${attempt}/${this.config.maxRetries})...`);
|
|
313
|
+
await this.delay(this.config.retryDelay * attempt);
|
|
314
|
+
return this.send(url, payload, attempt + 1);
|
|
315
|
+
}
|
|
316
|
+
logger.error('Request failed after retries:', error);
|
|
317
|
+
return { success: false, error: error };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Fetch with timeout
|
|
322
|
+
*/
|
|
323
|
+
async fetchWithTimeout(url, options) {
|
|
324
|
+
const controller = new AbortController();
|
|
325
|
+
const timeout = setTimeout(() => controller.abort(), this.config.timeout);
|
|
326
|
+
try {
|
|
327
|
+
const response = await fetch(url, {
|
|
328
|
+
...options,
|
|
329
|
+
signal: controller.signal,
|
|
330
|
+
});
|
|
331
|
+
return response;
|
|
332
|
+
}
|
|
333
|
+
finally {
|
|
334
|
+
clearTimeout(timeout);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Delay helper
|
|
339
|
+
*/
|
|
340
|
+
delay(ms) {
|
|
341
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Clianta SDK - Utility Functions
|
|
347
|
+
* @see SDK_VERSION in core/config.ts
|
|
348
|
+
*/
|
|
349
|
+
// ============================================
|
|
350
|
+
// UUID GENERATION
|
|
351
|
+
// ============================================
|
|
352
|
+
/**
|
|
353
|
+
* Generate a UUID v4
|
|
354
|
+
*/
|
|
355
|
+
function generateUUID() {
|
|
356
|
+
// Use crypto.randomUUID if available (modern browsers)
|
|
357
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
358
|
+
return crypto.randomUUID();
|
|
359
|
+
}
|
|
360
|
+
// Fallback to manual generation
|
|
361
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
362
|
+
const r = (Math.random() * 16) | 0;
|
|
363
|
+
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
364
|
+
return v.toString(16);
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
// ============================================
|
|
368
|
+
// STORAGE UTILITIES
|
|
369
|
+
// ============================================
|
|
370
|
+
/**
|
|
371
|
+
* Safely get from localStorage
|
|
372
|
+
*/
|
|
373
|
+
function getLocalStorage(key) {
|
|
374
|
+
try {
|
|
375
|
+
if (typeof localStorage !== 'undefined') {
|
|
376
|
+
return localStorage.getItem(key);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
// localStorage not available or blocked
|
|
381
|
+
}
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Safely set to localStorage
|
|
386
|
+
*/
|
|
387
|
+
function setLocalStorage(key, value) {
|
|
388
|
+
try {
|
|
389
|
+
if (typeof localStorage !== 'undefined') {
|
|
390
|
+
localStorage.setItem(key, value);
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
// localStorage not available or blocked
|
|
396
|
+
}
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Safely get from sessionStorage
|
|
401
|
+
*/
|
|
402
|
+
function getSessionStorage(key) {
|
|
403
|
+
try {
|
|
404
|
+
if (typeof sessionStorage !== 'undefined') {
|
|
405
|
+
return sessionStorage.getItem(key);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
// sessionStorage not available or blocked
|
|
410
|
+
}
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Safely set to sessionStorage
|
|
415
|
+
*/
|
|
416
|
+
function setSessionStorage(key, value) {
|
|
417
|
+
try {
|
|
418
|
+
if (typeof sessionStorage !== 'undefined') {
|
|
419
|
+
sessionStorage.setItem(key, value);
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
// sessionStorage not available or blocked
|
|
425
|
+
}
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Get or set a cookie
|
|
430
|
+
*/
|
|
431
|
+
function cookie(name, value, days) {
|
|
432
|
+
if (typeof document === 'undefined')
|
|
433
|
+
return null;
|
|
434
|
+
// Get cookie
|
|
435
|
+
if (value === undefined) {
|
|
436
|
+
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
|
|
437
|
+
return match ? match[2] : null;
|
|
438
|
+
}
|
|
439
|
+
// Set cookie
|
|
440
|
+
let expires = '';
|
|
441
|
+
if (days) {
|
|
442
|
+
const date = new Date();
|
|
443
|
+
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
|
|
444
|
+
expires = '; expires=' + date.toUTCString();
|
|
445
|
+
}
|
|
446
|
+
// Add Secure flag on HTTPS to prevent cookie leakage over plaintext
|
|
447
|
+
const secure = typeof location !== 'undefined' && location.protocol === 'https:' ? '; Secure' : '';
|
|
448
|
+
document.cookie = name + '=' + value + expires + '; path=/; SameSite=Lax' + secure;
|
|
449
|
+
return value;
|
|
450
|
+
}
|
|
451
|
+
// ============================================
|
|
452
|
+
// VISITOR & SESSION MANAGEMENT
|
|
453
|
+
// ============================================
|
|
454
|
+
/**
|
|
455
|
+
* Get or create a persistent visitor ID
|
|
456
|
+
*/
|
|
457
|
+
function getOrCreateVisitorId(useCookies = false) {
|
|
458
|
+
const key = STORAGE_KEYS.VISITOR_ID;
|
|
459
|
+
// Try to get existing ID
|
|
460
|
+
let visitorId = null;
|
|
461
|
+
if (useCookies) {
|
|
462
|
+
visitorId = cookie(key);
|
|
463
|
+
}
|
|
464
|
+
else {
|
|
465
|
+
visitorId = getLocalStorage(key);
|
|
466
|
+
}
|
|
467
|
+
// Create new ID if not found
|
|
468
|
+
if (!visitorId) {
|
|
469
|
+
visitorId = generateUUID();
|
|
470
|
+
if (useCookies) {
|
|
471
|
+
cookie(key, visitorId, 365); // 1 year
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
setLocalStorage(key, visitorId);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return visitorId;
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Get or create a session ID (expires after timeout)
|
|
481
|
+
*/
|
|
482
|
+
function getOrCreateSessionId(timeout) {
|
|
483
|
+
const sidKey = STORAGE_KEYS.SESSION_ID;
|
|
484
|
+
const tsKey = STORAGE_KEYS.SESSION_TIMESTAMP;
|
|
485
|
+
let sessionId = getSessionStorage(sidKey);
|
|
486
|
+
const lastActivity = parseInt(getSessionStorage(tsKey) || '0', 10);
|
|
487
|
+
const now = Date.now();
|
|
488
|
+
// Check if session expired
|
|
489
|
+
if (!sessionId || now - lastActivity > timeout) {
|
|
490
|
+
sessionId = generateUUID();
|
|
491
|
+
setSessionStorage(sidKey, sessionId);
|
|
492
|
+
}
|
|
493
|
+
// Update last activity
|
|
494
|
+
setSessionStorage(tsKey, now.toString());
|
|
495
|
+
return sessionId;
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Reset visitor and session IDs
|
|
499
|
+
*/
|
|
500
|
+
function resetIds(useCookies = false) {
|
|
501
|
+
const visitorKey = STORAGE_KEYS.VISITOR_ID;
|
|
502
|
+
if (useCookies) {
|
|
503
|
+
cookie(visitorKey, '', -1); // Delete cookie
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
try {
|
|
507
|
+
localStorage.removeItem(visitorKey);
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
// Ignore
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
try {
|
|
514
|
+
sessionStorage.removeItem(STORAGE_KEYS.SESSION_ID);
|
|
515
|
+
sessionStorage.removeItem(STORAGE_KEYS.SESSION_TIMESTAMP);
|
|
516
|
+
}
|
|
517
|
+
catch {
|
|
518
|
+
// Ignore
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
// ============================================
|
|
522
|
+
// URL UTILITIES
|
|
523
|
+
// ============================================
|
|
524
|
+
/**
|
|
525
|
+
* Extract UTM parameters from URL
|
|
526
|
+
*/
|
|
527
|
+
function getUTMParams() {
|
|
528
|
+
if (typeof window === 'undefined')
|
|
529
|
+
return {};
|
|
530
|
+
try {
|
|
531
|
+
const params = new URLSearchParams(window.location.search);
|
|
532
|
+
return {
|
|
533
|
+
utmSource: params.get('utm_source') || undefined,
|
|
534
|
+
utmMedium: params.get('utm_medium') || undefined,
|
|
535
|
+
utmCampaign: params.get('utm_campaign') || undefined,
|
|
536
|
+
utmTerm: params.get('utm_term') || undefined,
|
|
537
|
+
utmContent: params.get('utm_content') || undefined,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
catch {
|
|
541
|
+
return {};
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Check if URL is a download link
|
|
546
|
+
*/
|
|
547
|
+
function isDownloadUrl(url) {
|
|
548
|
+
const lowerUrl = url.toLowerCase();
|
|
549
|
+
return DOWNLOAD_EXTENSIONS.some((ext) => lowerUrl.includes(ext));
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Extract filename from URL
|
|
553
|
+
*/
|
|
554
|
+
function getFilenameFromUrl(url) {
|
|
555
|
+
try {
|
|
556
|
+
return url.split('/').pop()?.split('?')[0] || 'unknown';
|
|
557
|
+
}
|
|
558
|
+
catch {
|
|
559
|
+
return 'unknown';
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Extract file extension from URL
|
|
564
|
+
*/
|
|
565
|
+
function getFileExtension(url) {
|
|
566
|
+
const filename = getFilenameFromUrl(url);
|
|
567
|
+
const parts = filename.split('.');
|
|
568
|
+
return parts.length > 1 ? parts.pop() || 'unknown' : 'unknown';
|
|
569
|
+
}
|
|
570
|
+
// ============================================
|
|
571
|
+
// DOM UTILITIES
|
|
572
|
+
// ============================================
|
|
573
|
+
/**
|
|
574
|
+
* Get text content from element (truncated)
|
|
575
|
+
*/
|
|
576
|
+
function getElementText(element, maxLength = 100) {
|
|
577
|
+
const text = element.innerText ||
|
|
578
|
+
element.textContent ||
|
|
579
|
+
element.value ||
|
|
580
|
+
'';
|
|
581
|
+
return text.trim().substring(0, maxLength);
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Get element identification info
|
|
585
|
+
*/
|
|
586
|
+
function getElementInfo(element) {
|
|
587
|
+
return {
|
|
588
|
+
tag: element.tagName?.toLowerCase() || 'unknown',
|
|
589
|
+
id: element.id || '',
|
|
590
|
+
className: element.className || '',
|
|
591
|
+
text: getElementText(element, 50),
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Check if element is a trackable click target
|
|
596
|
+
*/
|
|
597
|
+
function isTrackableClickElement(element) {
|
|
598
|
+
const trackableTags = ['BUTTON', 'A', 'INPUT'];
|
|
599
|
+
return (trackableTags.includes(element.tagName) ||
|
|
600
|
+
element.hasAttribute('data-track-click') ||
|
|
601
|
+
element.classList.contains('track-click'));
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Check if device is mobile
|
|
605
|
+
*/
|
|
606
|
+
function isMobile() {
|
|
607
|
+
if (typeof navigator === 'undefined')
|
|
608
|
+
return false;
|
|
609
|
+
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
|
610
|
+
}
|
|
611
|
+
// ============================================
|
|
612
|
+
// VALIDATION UTILITIES
|
|
613
|
+
// ============================================
|
|
614
|
+
/**
|
|
615
|
+
* Validate email format
|
|
616
|
+
*/
|
|
617
|
+
function isValidEmail(email) {
|
|
618
|
+
if (typeof email !== 'string' || !email)
|
|
619
|
+
return false;
|
|
620
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
|
621
|
+
}
|
|
622
|
+
// ============================================
|
|
623
|
+
// DEVICE INFO
|
|
624
|
+
// ============================================
|
|
625
|
+
/**
|
|
626
|
+
* Get current device information
|
|
627
|
+
*/
|
|
628
|
+
function getDeviceInfo() {
|
|
629
|
+
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
|
|
630
|
+
return {
|
|
631
|
+
userAgent: 'unknown',
|
|
632
|
+
screen: 'unknown',
|
|
633
|
+
language: 'unknown',
|
|
634
|
+
timezone: 'unknown',
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
return {
|
|
638
|
+
userAgent: navigator.userAgent,
|
|
639
|
+
screen: `${screen.width}x${screen.height}`,
|
|
640
|
+
language: navigator.language,
|
|
641
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'unknown',
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Clianta SDK - Event Queue
|
|
647
|
+
* Handles batching and flushing of events
|
|
648
|
+
* @see SDK_VERSION in core/config.ts
|
|
649
|
+
*/
|
|
650
|
+
const MAX_QUEUE_SIZE = 1000;
|
|
651
|
+
/** Rate limit: max events per window */
|
|
652
|
+
const RATE_LIMIT_MAX_EVENTS = 100;
|
|
653
|
+
/** Rate limit window in ms (1 minute) */
|
|
654
|
+
const RATE_LIMIT_WINDOW_MS = 60000;
|
|
655
|
+
/**
|
|
656
|
+
* Event queue with batching, persistence, rate limiting, and auto-flush
|
|
657
|
+
*/
|
|
658
|
+
class EventQueue {
|
|
659
|
+
constructor(transport, config = {}) {
|
|
660
|
+
this.queue = [];
|
|
661
|
+
this.flushTimer = null;
|
|
662
|
+
this.isFlushing = false;
|
|
663
|
+
/** Rate limiting: timestamps of recent events */
|
|
664
|
+
this.eventTimestamps = [];
|
|
665
|
+
/** Unload handler references for cleanup */
|
|
666
|
+
this.boundBeforeUnload = null;
|
|
667
|
+
this.boundVisibilityChange = null;
|
|
668
|
+
this.boundPageHide = null;
|
|
669
|
+
this.transport = transport;
|
|
670
|
+
this.config = {
|
|
671
|
+
batchSize: config.batchSize ?? 10,
|
|
672
|
+
flushInterval: config.flushInterval ?? 5000,
|
|
673
|
+
maxQueueSize: config.maxQueueSize ?? MAX_QUEUE_SIZE,
|
|
674
|
+
storageKey: config.storageKey ?? STORAGE_KEYS.EVENT_QUEUE,
|
|
675
|
+
};
|
|
676
|
+
this.persistMode = config.persistMode || 'session';
|
|
677
|
+
// Restore persisted queue
|
|
678
|
+
this.restoreQueue();
|
|
679
|
+
// Start auto-flush timer
|
|
680
|
+
this.startFlushTimer();
|
|
681
|
+
// Setup unload handlers
|
|
682
|
+
this.setupUnloadHandlers();
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Add an event to the queue
|
|
686
|
+
*/
|
|
687
|
+
push(event) {
|
|
688
|
+
// Rate limiting check
|
|
689
|
+
if (!this.checkRateLimit()) {
|
|
690
|
+
logger.warn('Rate limit exceeded, event dropped:', event.eventName);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
// Don't exceed max queue size
|
|
694
|
+
if (this.queue.length >= this.config.maxQueueSize) {
|
|
695
|
+
logger.warn('Queue full, dropping oldest event');
|
|
696
|
+
this.queue.shift();
|
|
697
|
+
}
|
|
698
|
+
this.queue.push(event);
|
|
699
|
+
logger.debug('Event queued:', event.eventName, `(${this.queue.length} in queue)`);
|
|
700
|
+
// Flush if batch size reached
|
|
701
|
+
if (this.queue.length >= this.config.batchSize) {
|
|
702
|
+
this.flush();
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Check and enforce rate limiting
|
|
707
|
+
* @returns true if event is allowed, false if rate limited
|
|
708
|
+
*/
|
|
709
|
+
checkRateLimit() {
|
|
710
|
+
const now = Date.now();
|
|
711
|
+
// Remove timestamps outside the window
|
|
712
|
+
this.eventTimestamps = this.eventTimestamps.filter(ts => now - ts < RATE_LIMIT_WINDOW_MS);
|
|
713
|
+
// Check if under limit
|
|
714
|
+
if (this.eventTimestamps.length >= RATE_LIMIT_MAX_EVENTS) {
|
|
715
|
+
return false;
|
|
716
|
+
}
|
|
717
|
+
// Record this event
|
|
718
|
+
this.eventTimestamps.push(now);
|
|
719
|
+
return true;
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Flush the queue (send all events)
|
|
723
|
+
*/
|
|
724
|
+
async flush() {
|
|
725
|
+
if (this.isFlushing || this.queue.length === 0) {
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
this.isFlushing = true;
|
|
729
|
+
// Atomically take snapshot of current queue length to avoid race condition
|
|
730
|
+
const count = this.queue.length;
|
|
731
|
+
const events = this.queue.splice(0, count);
|
|
732
|
+
try {
|
|
733
|
+
logger.debug(`Flushing ${events.length} events`);
|
|
734
|
+
// Clear persisted queue
|
|
735
|
+
this.persistQueue([]);
|
|
736
|
+
// Send to backend
|
|
737
|
+
const result = await this.transport.sendEvents(events);
|
|
738
|
+
if (!result.success) {
|
|
739
|
+
// Re-queue events on failure (at the front)
|
|
740
|
+
logger.warn('Flush failed, re-queuing events');
|
|
741
|
+
this.queue.unshift(...events);
|
|
742
|
+
this.persistQueue(this.queue);
|
|
743
|
+
}
|
|
744
|
+
else {
|
|
745
|
+
logger.debug('Flush successful');
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
catch (error) {
|
|
749
|
+
logger.error('Flush error:', error);
|
|
750
|
+
}
|
|
751
|
+
finally {
|
|
752
|
+
this.isFlushing = false;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Flush synchronously using sendBeacon (for page unload)
|
|
757
|
+
*/
|
|
758
|
+
flushSync() {
|
|
759
|
+
if (this.queue.length === 0)
|
|
760
|
+
return;
|
|
761
|
+
const events = this.queue.splice(0, this.queue.length);
|
|
762
|
+
logger.debug(`Sync flushing ${events.length} events via beacon`);
|
|
763
|
+
const success = this.transport.sendBeacon(events);
|
|
764
|
+
if (!success) {
|
|
765
|
+
// Re-queue and persist for next page load
|
|
766
|
+
this.queue.unshift(...events);
|
|
767
|
+
this.persistQueue(this.queue);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Get current queue length
|
|
772
|
+
*/
|
|
773
|
+
get length() {
|
|
774
|
+
return this.queue.length;
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Clear the queue
|
|
778
|
+
*/
|
|
779
|
+
clear() {
|
|
780
|
+
this.queue = [];
|
|
781
|
+
this.persistQueue([]);
|
|
782
|
+
// Also clear localStorage if used
|
|
783
|
+
if (this.persistMode === 'local' && typeof localStorage !== 'undefined') {
|
|
784
|
+
try {
|
|
785
|
+
localStorage.removeItem(this.config.storageKey);
|
|
786
|
+
}
|
|
787
|
+
catch { /* ignore */ }
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* Stop the flush timer and cleanup handlers
|
|
792
|
+
*/
|
|
793
|
+
destroy() {
|
|
794
|
+
if (this.flushTimer) {
|
|
795
|
+
clearInterval(this.flushTimer);
|
|
796
|
+
this.flushTimer = null;
|
|
797
|
+
}
|
|
798
|
+
// Remove unload handlers
|
|
799
|
+
if (typeof window !== 'undefined') {
|
|
800
|
+
if (this.boundBeforeUnload) {
|
|
801
|
+
window.removeEventListener('beforeunload', this.boundBeforeUnload);
|
|
802
|
+
}
|
|
803
|
+
if (this.boundVisibilityChange) {
|
|
804
|
+
window.removeEventListener('visibilitychange', this.boundVisibilityChange);
|
|
805
|
+
}
|
|
806
|
+
if (this.boundPageHide) {
|
|
807
|
+
window.removeEventListener('pagehide', this.boundPageHide);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Start auto-flush timer
|
|
813
|
+
*/
|
|
814
|
+
startFlushTimer() {
|
|
815
|
+
if (this.flushTimer) {
|
|
816
|
+
clearInterval(this.flushTimer);
|
|
817
|
+
}
|
|
818
|
+
this.flushTimer = setInterval(() => {
|
|
819
|
+
this.flush();
|
|
820
|
+
}, this.config.flushInterval);
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* Setup page unload handlers
|
|
824
|
+
*/
|
|
825
|
+
setupUnloadHandlers() {
|
|
826
|
+
if (typeof window === 'undefined')
|
|
827
|
+
return;
|
|
828
|
+
// Flush on page unload
|
|
829
|
+
this.boundBeforeUnload = () => this.flushSync();
|
|
830
|
+
window.addEventListener('beforeunload', this.boundBeforeUnload);
|
|
831
|
+
// Flush when page becomes hidden
|
|
832
|
+
this.boundVisibilityChange = () => {
|
|
833
|
+
if (document.visibilityState === 'hidden') {
|
|
834
|
+
this.flushSync();
|
|
835
|
+
}
|
|
836
|
+
};
|
|
837
|
+
window.addEventListener('visibilitychange', this.boundVisibilityChange);
|
|
838
|
+
// Flush on page hide (iOS Safari)
|
|
839
|
+
this.boundPageHide = () => this.flushSync();
|
|
840
|
+
window.addEventListener('pagehide', this.boundPageHide);
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Persist queue to storage based on persistMode
|
|
844
|
+
*/
|
|
845
|
+
persistQueue(events) {
|
|
846
|
+
if (this.persistMode === 'none')
|
|
847
|
+
return;
|
|
848
|
+
try {
|
|
849
|
+
const serialized = JSON.stringify(events);
|
|
850
|
+
if (this.persistMode === 'local' && typeof localStorage !== 'undefined') {
|
|
851
|
+
try {
|
|
852
|
+
localStorage.setItem(this.config.storageKey, serialized);
|
|
853
|
+
}
|
|
854
|
+
catch {
|
|
855
|
+
// localStorage quota exceeded — fallback to sessionStorage
|
|
856
|
+
setSessionStorage(this.config.storageKey, serialized);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
else {
|
|
860
|
+
setSessionStorage(this.config.storageKey, serialized);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
catch {
|
|
864
|
+
// Ignore storage errors
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Restore queue from storage
|
|
869
|
+
*/
|
|
870
|
+
restoreQueue() {
|
|
871
|
+
try {
|
|
872
|
+
let stored = null;
|
|
873
|
+
// Check localStorage first (cross-session persistence)
|
|
874
|
+
if (this.persistMode === 'local' && typeof localStorage !== 'undefined') {
|
|
875
|
+
stored = localStorage.getItem(this.config.storageKey);
|
|
876
|
+
}
|
|
877
|
+
// Fall back to sessionStorage
|
|
878
|
+
if (!stored) {
|
|
879
|
+
stored = getSessionStorage(this.config.storageKey);
|
|
880
|
+
}
|
|
881
|
+
if (stored) {
|
|
882
|
+
const events = JSON.parse(stored);
|
|
883
|
+
if (Array.isArray(events) && events.length > 0) {
|
|
884
|
+
this.queue = events;
|
|
885
|
+
logger.debug(`Restored ${events.length} events from storage`);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
catch {
|
|
890
|
+
// Ignore parse errors
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Clianta SDK - Plugin Base
|
|
897
|
+
* @see SDK_VERSION in core/config.ts
|
|
898
|
+
*/
|
|
899
|
+
/**
|
|
900
|
+
* Base class for plugins
|
|
901
|
+
*/
|
|
902
|
+
class BasePlugin {
|
|
903
|
+
constructor() {
|
|
904
|
+
this.tracker = null;
|
|
905
|
+
}
|
|
906
|
+
init(tracker) {
|
|
907
|
+
this.tracker = tracker;
|
|
908
|
+
}
|
|
909
|
+
destroy() {
|
|
910
|
+
this.tracker = null;
|
|
911
|
+
}
|
|
912
|
+
track(eventType, eventName, properties) {
|
|
913
|
+
if (this.tracker) {
|
|
914
|
+
this.tracker.track(eventType, eventName, properties);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Clianta SDK - Page View Plugin
|
|
921
|
+
* @see SDK_VERSION in core/config.ts
|
|
922
|
+
*/
|
|
923
|
+
/**
|
|
924
|
+
* Page View Plugin - Tracks page views
|
|
925
|
+
*/
|
|
926
|
+
class PageViewPlugin extends BasePlugin {
|
|
927
|
+
constructor() {
|
|
928
|
+
super(...arguments);
|
|
929
|
+
this.name = 'pageView';
|
|
930
|
+
this.originalPushState = null;
|
|
931
|
+
this.originalReplaceState = null;
|
|
932
|
+
this.popstateHandler = null;
|
|
933
|
+
}
|
|
934
|
+
init(tracker) {
|
|
935
|
+
super.init(tracker);
|
|
936
|
+
// Track initial page view
|
|
937
|
+
this.trackPageView();
|
|
938
|
+
// Track SPA navigation (History API)
|
|
939
|
+
if (typeof window !== 'undefined') {
|
|
940
|
+
// Store originals for cleanup
|
|
941
|
+
this.originalPushState = history.pushState;
|
|
942
|
+
this.originalReplaceState = history.replaceState;
|
|
943
|
+
// Intercept pushState and replaceState
|
|
944
|
+
const self = this;
|
|
945
|
+
history.pushState = function (...args) {
|
|
946
|
+
self.originalPushState.apply(history, args);
|
|
947
|
+
self.trackPageView();
|
|
948
|
+
// Notify other plugins (e.g. ScrollPlugin) about navigation
|
|
949
|
+
window.dispatchEvent(new Event('clianta:navigation'));
|
|
950
|
+
};
|
|
951
|
+
history.replaceState = function (...args) {
|
|
952
|
+
self.originalReplaceState.apply(history, args);
|
|
953
|
+
self.trackPageView();
|
|
954
|
+
window.dispatchEvent(new Event('clianta:navigation'));
|
|
955
|
+
};
|
|
956
|
+
// Handle back/forward navigation
|
|
957
|
+
this.popstateHandler = () => this.trackPageView();
|
|
958
|
+
window.addEventListener('popstate', this.popstateHandler);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
destroy() {
|
|
962
|
+
// Restore original history methods
|
|
963
|
+
if (this.originalPushState) {
|
|
964
|
+
history.pushState = this.originalPushState;
|
|
965
|
+
this.originalPushState = null;
|
|
966
|
+
}
|
|
967
|
+
if (this.originalReplaceState) {
|
|
968
|
+
history.replaceState = this.originalReplaceState;
|
|
969
|
+
this.originalReplaceState = null;
|
|
970
|
+
}
|
|
971
|
+
// Remove popstate listener
|
|
972
|
+
if (this.popstateHandler && typeof window !== 'undefined') {
|
|
973
|
+
window.removeEventListener('popstate', this.popstateHandler);
|
|
974
|
+
this.popstateHandler = null;
|
|
975
|
+
}
|
|
976
|
+
super.destroy();
|
|
977
|
+
}
|
|
978
|
+
trackPageView() {
|
|
979
|
+
if (typeof window === 'undefined' || typeof document === 'undefined')
|
|
980
|
+
return;
|
|
981
|
+
this.track('page_view', 'Page Viewed', {
|
|
982
|
+
title: document.title,
|
|
983
|
+
path: window.location.pathname,
|
|
984
|
+
search: window.location.search,
|
|
985
|
+
hash: window.location.hash,
|
|
986
|
+
referrer: document.referrer || 'direct',
|
|
987
|
+
viewport: `${window.innerWidth}x${window.innerHeight}`,
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* Clianta SDK - Scroll Depth Plugin
|
|
994
|
+
* @see SDK_VERSION in core/config.ts
|
|
995
|
+
*/
|
|
996
|
+
/**
|
|
997
|
+
* Scroll Depth Plugin - Tracks scroll milestones
|
|
998
|
+
*/
|
|
999
|
+
class ScrollPlugin extends BasePlugin {
|
|
1000
|
+
constructor() {
|
|
1001
|
+
super(...arguments);
|
|
1002
|
+
this.name = 'scroll';
|
|
1003
|
+
this.milestonesReached = new Set();
|
|
1004
|
+
this.maxScrollDepth = 0;
|
|
1005
|
+
this.pageLoadTime = 0;
|
|
1006
|
+
this.scrollTimeout = null;
|
|
1007
|
+
this.boundHandler = null;
|
|
1008
|
+
/** SPA navigation — listen for PageViewPlugin's custom event instead of patching history */
|
|
1009
|
+
this.navigationHandler = null;
|
|
1010
|
+
this.popstateHandler = null;
|
|
1011
|
+
}
|
|
1012
|
+
init(tracker) {
|
|
1013
|
+
super.init(tracker);
|
|
1014
|
+
this.pageLoadTime = Date.now();
|
|
1015
|
+
if (typeof window !== 'undefined') {
|
|
1016
|
+
this.boundHandler = this.handleScroll.bind(this);
|
|
1017
|
+
window.addEventListener('scroll', this.boundHandler, { passive: true });
|
|
1018
|
+
// Listen for navigation events dispatched by PageViewPlugin
|
|
1019
|
+
// instead of independently monkey-patching history.pushState
|
|
1020
|
+
this.navigationHandler = () => this.resetForNavigation();
|
|
1021
|
+
window.addEventListener('clianta:navigation', this.navigationHandler);
|
|
1022
|
+
// Handle back/forward navigation
|
|
1023
|
+
this.popstateHandler = () => this.resetForNavigation();
|
|
1024
|
+
window.addEventListener('popstate', this.popstateHandler);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
destroy() {
|
|
1028
|
+
if (this.boundHandler && typeof window !== 'undefined') {
|
|
1029
|
+
window.removeEventListener('scroll', this.boundHandler);
|
|
1030
|
+
}
|
|
1031
|
+
if (this.scrollTimeout) {
|
|
1032
|
+
clearTimeout(this.scrollTimeout);
|
|
1033
|
+
}
|
|
1034
|
+
if (this.navigationHandler && typeof window !== 'undefined') {
|
|
1035
|
+
window.removeEventListener('clianta:navigation', this.navigationHandler);
|
|
1036
|
+
this.navigationHandler = null;
|
|
1037
|
+
}
|
|
1038
|
+
if (this.popstateHandler && typeof window !== 'undefined') {
|
|
1039
|
+
window.removeEventListener('popstate', this.popstateHandler);
|
|
1040
|
+
this.popstateHandler = null;
|
|
1041
|
+
}
|
|
1042
|
+
super.destroy();
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Reset scroll tracking for SPA navigation
|
|
1046
|
+
*/
|
|
1047
|
+
resetForNavigation() {
|
|
1048
|
+
this.milestonesReached.clear();
|
|
1049
|
+
this.maxScrollDepth = 0;
|
|
1050
|
+
this.pageLoadTime = Date.now();
|
|
1051
|
+
}
|
|
1052
|
+
handleScroll() {
|
|
1053
|
+
// Debounce scroll tracking
|
|
1054
|
+
if (this.scrollTimeout) {
|
|
1055
|
+
clearTimeout(this.scrollTimeout);
|
|
1056
|
+
}
|
|
1057
|
+
this.scrollTimeout = setTimeout(() => this.trackScrollDepth(), 150);
|
|
1058
|
+
}
|
|
1059
|
+
trackScrollDepth() {
|
|
1060
|
+
if (typeof window === 'undefined' || typeof document === 'undefined')
|
|
1061
|
+
return;
|
|
1062
|
+
const windowHeight = window.innerHeight;
|
|
1063
|
+
const documentHeight = document.documentElement.scrollHeight;
|
|
1064
|
+
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
|
1065
|
+
const scrollableHeight = documentHeight - windowHeight;
|
|
1066
|
+
// Guard against divide-by-zero on short pages
|
|
1067
|
+
if (scrollableHeight <= 0)
|
|
1068
|
+
return;
|
|
1069
|
+
const scrollPercent = Math.floor((scrollTop / scrollableHeight) * 100);
|
|
1070
|
+
// Clamp to valid range
|
|
1071
|
+
const clampedPercent = Math.max(0, Math.min(100, scrollPercent));
|
|
1072
|
+
// Update max scroll depth
|
|
1073
|
+
if (clampedPercent > this.maxScrollDepth) {
|
|
1074
|
+
this.maxScrollDepth = clampedPercent;
|
|
1075
|
+
}
|
|
1076
|
+
// Track milestones
|
|
1077
|
+
for (const milestone of SCROLL_MILESTONES) {
|
|
1078
|
+
if (clampedPercent >= milestone && !this.milestonesReached.has(milestone)) {
|
|
1079
|
+
this.milestonesReached.add(milestone);
|
|
1080
|
+
this.track('scroll_depth', `Scrolled ${milestone}%`, {
|
|
1081
|
+
depth: milestone,
|
|
1082
|
+
maxDepth: this.maxScrollDepth,
|
|
1083
|
+
timeToReach: Date.now() - this.pageLoadTime,
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* Clianta SDK - Form Tracking Plugin
|
|
1092
|
+
* @see SDK_VERSION in core/config.ts
|
|
1093
|
+
*/
|
|
1094
|
+
/**
|
|
1095
|
+
* Form Tracking Plugin - Auto-tracks form views, interactions, and submissions
|
|
1096
|
+
*/
|
|
1097
|
+
class FormsPlugin extends BasePlugin {
|
|
1098
|
+
constructor() {
|
|
1099
|
+
super(...arguments);
|
|
1100
|
+
this.name = 'forms';
|
|
1101
|
+
this.trackedForms = new WeakSet();
|
|
1102
|
+
this.formInteractions = new Set();
|
|
1103
|
+
this.observer = null;
|
|
1104
|
+
this.listeners = [];
|
|
1105
|
+
}
|
|
1106
|
+
init(tracker) {
|
|
1107
|
+
super.init(tracker);
|
|
1108
|
+
if (typeof document === 'undefined')
|
|
1109
|
+
return;
|
|
1110
|
+
// Track existing forms
|
|
1111
|
+
this.trackAllForms();
|
|
1112
|
+
// Watch for dynamically added forms
|
|
1113
|
+
if (typeof MutationObserver !== 'undefined') {
|
|
1114
|
+
this.observer = new MutationObserver(() => this.trackAllForms());
|
|
1115
|
+
this.observer.observe(document.body, { childList: true, subtree: true });
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
destroy() {
|
|
1119
|
+
if (this.observer) {
|
|
1120
|
+
this.observer.disconnect();
|
|
1121
|
+
this.observer = null;
|
|
1122
|
+
}
|
|
1123
|
+
// Remove all tracked event listeners
|
|
1124
|
+
for (const { element, event, handler } of this.listeners) {
|
|
1125
|
+
element.removeEventListener(event, handler);
|
|
1126
|
+
}
|
|
1127
|
+
this.listeners = [];
|
|
1128
|
+
super.destroy();
|
|
1129
|
+
}
|
|
1130
|
+
/**
|
|
1131
|
+
* Track event listener for cleanup
|
|
1132
|
+
*/
|
|
1133
|
+
addListener(element, event, handler) {
|
|
1134
|
+
element.addEventListener(event, handler);
|
|
1135
|
+
this.listeners.push({ element, event, handler });
|
|
1136
|
+
}
|
|
1137
|
+
trackAllForms() {
|
|
1138
|
+
document.querySelectorAll('form').forEach((form) => {
|
|
1139
|
+
this.setupFormTracking(form);
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
setupFormTracking(form) {
|
|
1143
|
+
if (this.trackedForms.has(form))
|
|
1144
|
+
return;
|
|
1145
|
+
this.trackedForms.add(form);
|
|
1146
|
+
const formId = form.id || form.name || `form-${Math.random().toString(36).substr(2, 9)}`;
|
|
1147
|
+
// Track form view
|
|
1148
|
+
this.track('form_view', 'Form Viewed', {
|
|
1149
|
+
formId,
|
|
1150
|
+
action: form.action,
|
|
1151
|
+
method: form.method,
|
|
1152
|
+
fieldCount: form.elements.length,
|
|
1153
|
+
});
|
|
1154
|
+
// Track field interactions
|
|
1155
|
+
Array.from(form.elements).forEach((field) => {
|
|
1156
|
+
if (field instanceof HTMLInputElement ||
|
|
1157
|
+
field instanceof HTMLSelectElement ||
|
|
1158
|
+
field instanceof HTMLTextAreaElement) {
|
|
1159
|
+
if (!field.name || field.type === 'submit' || field.type === 'button')
|
|
1160
|
+
return;
|
|
1161
|
+
['focus', 'blur', 'change'].forEach((eventType) => {
|
|
1162
|
+
const handler = () => {
|
|
1163
|
+
const key = `${formId}-${field.name}-${eventType}`;
|
|
1164
|
+
if (!this.formInteractions.has(key)) {
|
|
1165
|
+
this.formInteractions.add(key);
|
|
1166
|
+
this.track('form_interaction', 'Form Field Interaction', {
|
|
1167
|
+
formId,
|
|
1168
|
+
fieldName: field.name,
|
|
1169
|
+
fieldType: field.type,
|
|
1170
|
+
interactionType: eventType,
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
};
|
|
1174
|
+
this.addListener(field, eventType, handler);
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
});
|
|
1178
|
+
// Track form submission
|
|
1179
|
+
const submitHandler = () => {
|
|
1180
|
+
this.track('form_submit', 'Form Submitted', {
|
|
1181
|
+
formId,
|
|
1182
|
+
action: form.action,
|
|
1183
|
+
method: form.method,
|
|
1184
|
+
});
|
|
1185
|
+
// Auto-identify if email field found
|
|
1186
|
+
this.autoIdentify(form);
|
|
1187
|
+
};
|
|
1188
|
+
this.addListener(form, 'submit', submitHandler);
|
|
1189
|
+
}
|
|
1190
|
+
autoIdentify(form) {
|
|
1191
|
+
const emailField = form.querySelector('input[type="email"], input[name*="email"]');
|
|
1192
|
+
if (!emailField?.value || !this.tracker)
|
|
1193
|
+
return;
|
|
1194
|
+
const email = emailField.value;
|
|
1195
|
+
const traits = {};
|
|
1196
|
+
// Capture common fields
|
|
1197
|
+
const firstNameField = form.querySelector('[name*="first"], [name*="fname"]');
|
|
1198
|
+
const lastNameField = form.querySelector('[name*="last"], [name*="lname"]');
|
|
1199
|
+
const companyField = form.querySelector('[name*="company"], [name*="organization"]');
|
|
1200
|
+
const phoneField = form.querySelector('[type="tel"], [name*="phone"]');
|
|
1201
|
+
if (firstNameField?.value)
|
|
1202
|
+
traits.firstName = firstNameField.value;
|
|
1203
|
+
if (lastNameField?.value)
|
|
1204
|
+
traits.lastName = lastNameField.value;
|
|
1205
|
+
if (companyField?.value)
|
|
1206
|
+
traits.company = companyField.value;
|
|
1207
|
+
if (phoneField?.value)
|
|
1208
|
+
traits.phone = phoneField.value;
|
|
1209
|
+
this.tracker.identify(email, traits);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* Clianta SDK - Click Tracking Plugin
|
|
1215
|
+
* @see SDK_VERSION in core/config.ts
|
|
1216
|
+
*/
|
|
1217
|
+
/**
|
|
1218
|
+
* Click Tracking Plugin - Tracks button and CTA clicks
|
|
1219
|
+
*/
|
|
1220
|
+
class ClicksPlugin extends BasePlugin {
|
|
1221
|
+
constructor() {
|
|
1222
|
+
super(...arguments);
|
|
1223
|
+
this.name = 'clicks';
|
|
1224
|
+
this.boundHandler = null;
|
|
1225
|
+
}
|
|
1226
|
+
init(tracker) {
|
|
1227
|
+
super.init(tracker);
|
|
1228
|
+
if (typeof document !== 'undefined') {
|
|
1229
|
+
this.boundHandler = this.handleClick.bind(this);
|
|
1230
|
+
document.addEventListener('click', this.boundHandler, true);
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
destroy() {
|
|
1234
|
+
if (this.boundHandler && typeof document !== 'undefined') {
|
|
1235
|
+
document.removeEventListener('click', this.boundHandler, true);
|
|
1236
|
+
}
|
|
1237
|
+
super.destroy();
|
|
1238
|
+
}
|
|
1239
|
+
handleClick(e) {
|
|
1240
|
+
const target = e.target;
|
|
1241
|
+
if (!target || !isTrackableClickElement(target))
|
|
1242
|
+
return;
|
|
1243
|
+
const buttonText = getElementText(target, 100);
|
|
1244
|
+
const elementInfo = getElementInfo(target);
|
|
1245
|
+
this.track('button_click', 'Button Clicked', {
|
|
1246
|
+
buttonText,
|
|
1247
|
+
elementType: target.tagName.toLowerCase(),
|
|
1248
|
+
elementId: elementInfo.id,
|
|
1249
|
+
elementClass: elementInfo.className,
|
|
1250
|
+
href: target.href || undefined,
|
|
1251
|
+
x: Math.round((e.clientX / window.innerWidth) * 100),
|
|
1252
|
+
y: Math.round((e.clientY / window.innerHeight) * 100),
|
|
1253
|
+
viewportWidth: window.innerWidth,
|
|
1254
|
+
viewportHeight: window.innerHeight,
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* Clianta SDK - Engagement Plugin
|
|
1261
|
+
* @see SDK_VERSION in core/config.ts
|
|
1262
|
+
*/
|
|
1263
|
+
/**
|
|
1264
|
+
* Engagement Plugin - Tracks user engagement and time on page
|
|
1265
|
+
*/
|
|
1266
|
+
class EngagementPlugin extends BasePlugin {
|
|
1267
|
+
constructor() {
|
|
1268
|
+
super(...arguments);
|
|
1269
|
+
this.name = 'engagement';
|
|
1270
|
+
this.pageLoadTime = 0;
|
|
1271
|
+
this.engagementStartTime = 0;
|
|
1272
|
+
this.isEngaged = false;
|
|
1273
|
+
this.engagementTimeout = null;
|
|
1274
|
+
this.boundMarkEngaged = null;
|
|
1275
|
+
this.boundTrackTimeOnPage = null;
|
|
1276
|
+
this.boundVisibilityHandler = null;
|
|
1277
|
+
/** SPA navigation — listen for PageViewPlugin's custom event instead of patching history */
|
|
1278
|
+
this.navigationHandler = null;
|
|
1279
|
+
this.popstateHandler = null;
|
|
1280
|
+
}
|
|
1281
|
+
init(tracker) {
|
|
1282
|
+
super.init(tracker);
|
|
1283
|
+
this.pageLoadTime = Date.now();
|
|
1284
|
+
this.engagementStartTime = Date.now();
|
|
1285
|
+
if (typeof document === 'undefined' || typeof window === 'undefined')
|
|
1286
|
+
return;
|
|
1287
|
+
// Setup engagement detection
|
|
1288
|
+
this.boundMarkEngaged = this.markEngaged.bind(this);
|
|
1289
|
+
this.boundTrackTimeOnPage = this.trackTimeOnPage.bind(this);
|
|
1290
|
+
this.boundVisibilityHandler = () => {
|
|
1291
|
+
if (document.visibilityState === 'hidden') {
|
|
1292
|
+
this.trackTimeOnPage();
|
|
1293
|
+
}
|
|
1294
|
+
else {
|
|
1295
|
+
// Reset engagement timer when page becomes visible again
|
|
1296
|
+
this.engagementStartTime = Date.now();
|
|
1297
|
+
}
|
|
1298
|
+
};
|
|
1299
|
+
['mousemove', 'keydown', 'touchstart', 'scroll'].forEach((event) => {
|
|
1300
|
+
document.addEventListener(event, this.boundMarkEngaged, { passive: true });
|
|
1301
|
+
});
|
|
1302
|
+
// Track time on page before unload
|
|
1303
|
+
window.addEventListener('beforeunload', this.boundTrackTimeOnPage);
|
|
1304
|
+
document.addEventListener('visibilitychange', this.boundVisibilityHandler);
|
|
1305
|
+
// Listen for navigation events dispatched by PageViewPlugin
|
|
1306
|
+
// instead of independently monkey-patching history.pushState
|
|
1307
|
+
this.navigationHandler = () => this.resetForNavigation();
|
|
1308
|
+
window.addEventListener('clianta:navigation', this.navigationHandler);
|
|
1309
|
+
// Handle back/forward navigation
|
|
1310
|
+
this.popstateHandler = () => this.resetForNavigation();
|
|
1311
|
+
window.addEventListener('popstate', this.popstateHandler);
|
|
1312
|
+
}
|
|
1313
|
+
destroy() {
|
|
1314
|
+
if (this.boundMarkEngaged && typeof document !== 'undefined') {
|
|
1315
|
+
['mousemove', 'keydown', 'touchstart', 'scroll'].forEach((event) => {
|
|
1316
|
+
document.removeEventListener(event, this.boundMarkEngaged);
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
if (this.boundTrackTimeOnPage && typeof window !== 'undefined') {
|
|
1320
|
+
window.removeEventListener('beforeunload', this.boundTrackTimeOnPage);
|
|
1321
|
+
}
|
|
1322
|
+
if (this.boundVisibilityHandler && typeof document !== 'undefined') {
|
|
1323
|
+
document.removeEventListener('visibilitychange', this.boundVisibilityHandler);
|
|
1324
|
+
}
|
|
1325
|
+
if (this.navigationHandler && typeof window !== 'undefined') {
|
|
1326
|
+
window.removeEventListener('clianta:navigation', this.navigationHandler);
|
|
1327
|
+
this.navigationHandler = null;
|
|
1328
|
+
}
|
|
1329
|
+
if (this.popstateHandler && typeof window !== 'undefined') {
|
|
1330
|
+
window.removeEventListener('popstate', this.popstateHandler);
|
|
1331
|
+
this.popstateHandler = null;
|
|
1332
|
+
}
|
|
1333
|
+
if (this.engagementTimeout) {
|
|
1334
|
+
clearTimeout(this.engagementTimeout);
|
|
1335
|
+
}
|
|
1336
|
+
super.destroy();
|
|
1337
|
+
}
|
|
1338
|
+
resetForNavigation() {
|
|
1339
|
+
this.pageLoadTime = Date.now();
|
|
1340
|
+
this.engagementStartTime = Date.now();
|
|
1341
|
+
this.isEngaged = false;
|
|
1342
|
+
if (this.engagementTimeout) {
|
|
1343
|
+
clearTimeout(this.engagementTimeout);
|
|
1344
|
+
this.engagementTimeout = null;
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
markEngaged() {
|
|
1348
|
+
if (!this.isEngaged) {
|
|
1349
|
+
this.isEngaged = true;
|
|
1350
|
+
this.track('engagement', 'User Engaged', {
|
|
1351
|
+
timeToEngage: Date.now() - this.pageLoadTime,
|
|
1352
|
+
});
|
|
1353
|
+
}
|
|
1354
|
+
// Reset engagement timeout
|
|
1355
|
+
if (this.engagementTimeout) {
|
|
1356
|
+
clearTimeout(this.engagementTimeout);
|
|
1357
|
+
}
|
|
1358
|
+
this.engagementTimeout = setTimeout(() => {
|
|
1359
|
+
this.isEngaged = false;
|
|
1360
|
+
}, 30000); // 30 seconds of inactivity
|
|
1361
|
+
}
|
|
1362
|
+
trackTimeOnPage() {
|
|
1363
|
+
const timeSpent = Math.floor((Date.now() - this.engagementStartTime) / 1000);
|
|
1364
|
+
if (timeSpent > 0) {
|
|
1365
|
+
this.track('time_on_page', 'Time Spent', {
|
|
1366
|
+
seconds: timeSpent,
|
|
1367
|
+
engaged: this.isEngaged,
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
/**
|
|
1374
|
+
* Clianta SDK - Downloads Plugin
|
|
1375
|
+
* @see SDK_VERSION in core/config.ts
|
|
1376
|
+
*/
|
|
1377
|
+
/**
|
|
1378
|
+
* Downloads Plugin - Tracks file downloads
|
|
1379
|
+
*/
|
|
1380
|
+
class DownloadsPlugin extends BasePlugin {
|
|
1381
|
+
constructor() {
|
|
1382
|
+
super(...arguments);
|
|
1383
|
+
this.name = 'downloads';
|
|
1384
|
+
this.trackedDownloads = new Set();
|
|
1385
|
+
this.boundHandler = null;
|
|
1386
|
+
/** SPA navigation — listen for PageViewPlugin's custom event instead of patching history */
|
|
1387
|
+
this.navigationHandler = null;
|
|
1388
|
+
this.popstateHandler = null;
|
|
1389
|
+
}
|
|
1390
|
+
init(tracker) {
|
|
1391
|
+
super.init(tracker);
|
|
1392
|
+
if (typeof document !== 'undefined') {
|
|
1393
|
+
this.boundHandler = this.handleClick.bind(this);
|
|
1394
|
+
document.addEventListener('click', this.boundHandler, true);
|
|
1395
|
+
}
|
|
1396
|
+
if (typeof window !== 'undefined') {
|
|
1397
|
+
// Listen for navigation events dispatched by PageViewPlugin
|
|
1398
|
+
// instead of independently monkey-patching history.pushState
|
|
1399
|
+
this.navigationHandler = () => this.resetForNavigation();
|
|
1400
|
+
window.addEventListener('clianta:navigation', this.navigationHandler);
|
|
1401
|
+
// Handle back/forward navigation
|
|
1402
|
+
this.popstateHandler = () => this.resetForNavigation();
|
|
1403
|
+
window.addEventListener('popstate', this.popstateHandler);
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
destroy() {
|
|
1407
|
+
if (this.boundHandler && typeof document !== 'undefined') {
|
|
1408
|
+
document.removeEventListener('click', this.boundHandler, true);
|
|
1409
|
+
}
|
|
1410
|
+
if (this.navigationHandler && typeof window !== 'undefined') {
|
|
1411
|
+
window.removeEventListener('clianta:navigation', this.navigationHandler);
|
|
1412
|
+
this.navigationHandler = null;
|
|
1413
|
+
}
|
|
1414
|
+
if (this.popstateHandler && typeof window !== 'undefined') {
|
|
1415
|
+
window.removeEventListener('popstate', this.popstateHandler);
|
|
1416
|
+
this.popstateHandler = null;
|
|
1417
|
+
}
|
|
1418
|
+
super.destroy();
|
|
1419
|
+
}
|
|
1420
|
+
/**
|
|
1421
|
+
* Reset download tracking for SPA navigation
|
|
1422
|
+
*/
|
|
1423
|
+
resetForNavigation() {
|
|
1424
|
+
this.trackedDownloads.clear();
|
|
1425
|
+
}
|
|
1426
|
+
handleClick(e) {
|
|
1427
|
+
const link = e.target.closest('a');
|
|
1428
|
+
if (!link || !link.href)
|
|
1429
|
+
return;
|
|
1430
|
+
const url = link.href;
|
|
1431
|
+
// Check if it's a download link
|
|
1432
|
+
if (!isDownloadUrl(url))
|
|
1433
|
+
return;
|
|
1434
|
+
// Avoid tracking the same download multiple times
|
|
1435
|
+
if (this.trackedDownloads.has(url))
|
|
1436
|
+
return;
|
|
1437
|
+
this.trackedDownloads.add(url);
|
|
1438
|
+
this.track('download', 'File Download', {
|
|
1439
|
+
url,
|
|
1440
|
+
filename: getFilenameFromUrl(url),
|
|
1441
|
+
fileType: getFileExtension(url),
|
|
1442
|
+
linkText: getElementText(link, 100),
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
/**
|
|
1448
|
+
* Clianta SDK - Exit Intent Plugin
|
|
1449
|
+
* @see SDK_VERSION in core/config.ts
|
|
1450
|
+
*/
|
|
1451
|
+
/**
|
|
1452
|
+
* Exit Intent Plugin - Detects when user intends to leave the page
|
|
1453
|
+
*/
|
|
1454
|
+
class ExitIntentPlugin extends BasePlugin {
|
|
1455
|
+
constructor() {
|
|
1456
|
+
super(...arguments);
|
|
1457
|
+
this.name = 'exitIntent';
|
|
1458
|
+
this.exitIntentShown = false;
|
|
1459
|
+
this.pageLoadTime = 0;
|
|
1460
|
+
this.boundHandler = null;
|
|
1461
|
+
/** SPA navigation — listen for PageViewPlugin's custom event instead of patching history */
|
|
1462
|
+
this.navigationHandler = null;
|
|
1463
|
+
this.popstateHandler = null;
|
|
1464
|
+
}
|
|
1465
|
+
init(tracker) {
|
|
1466
|
+
super.init(tracker);
|
|
1467
|
+
this.pageLoadTime = Date.now();
|
|
1468
|
+
// Skip on mobile (no mouse events)
|
|
1469
|
+
if (isMobile())
|
|
1470
|
+
return;
|
|
1471
|
+
if (typeof document !== 'undefined') {
|
|
1472
|
+
this.boundHandler = this.handleMouseLeave.bind(this);
|
|
1473
|
+
document.addEventListener('mouseleave', this.boundHandler);
|
|
1474
|
+
}
|
|
1475
|
+
if (typeof window !== 'undefined') {
|
|
1476
|
+
// Listen for navigation events dispatched by PageViewPlugin
|
|
1477
|
+
// instead of independently monkey-patching history.pushState
|
|
1478
|
+
this.navigationHandler = () => this.resetForNavigation();
|
|
1479
|
+
window.addEventListener('clianta:navigation', this.navigationHandler);
|
|
1480
|
+
// Handle back/forward navigation
|
|
1481
|
+
this.popstateHandler = () => this.resetForNavigation();
|
|
1482
|
+
window.addEventListener('popstate', this.popstateHandler);
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
destroy() {
|
|
1486
|
+
if (this.boundHandler && typeof document !== 'undefined') {
|
|
1487
|
+
document.removeEventListener('mouseleave', this.boundHandler);
|
|
1488
|
+
}
|
|
1489
|
+
if (this.navigationHandler && typeof window !== 'undefined') {
|
|
1490
|
+
window.removeEventListener('clianta:navigation', this.navigationHandler);
|
|
1491
|
+
this.navigationHandler = null;
|
|
1492
|
+
}
|
|
1493
|
+
if (this.popstateHandler && typeof window !== 'undefined') {
|
|
1494
|
+
window.removeEventListener('popstate', this.popstateHandler);
|
|
1495
|
+
this.popstateHandler = null;
|
|
1496
|
+
}
|
|
1497
|
+
super.destroy();
|
|
1498
|
+
}
|
|
1499
|
+
resetForNavigation() {
|
|
1500
|
+
this.exitIntentShown = false;
|
|
1501
|
+
this.pageLoadTime = Date.now();
|
|
1502
|
+
}
|
|
1503
|
+
handleMouseLeave(e) {
|
|
1504
|
+
// Only trigger when mouse leaves from the top of the page
|
|
1505
|
+
if (e.clientY > 0 || this.exitIntentShown)
|
|
1506
|
+
return;
|
|
1507
|
+
this.exitIntentShown = true;
|
|
1508
|
+
this.track('exit_intent', 'Exit Intent Detected', {
|
|
1509
|
+
timeOnPage: Date.now() - this.pageLoadTime,
|
|
1510
|
+
});
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
/**
|
|
1515
|
+
* Clianta SDK - Error Tracking Plugin
|
|
1516
|
+
* @see SDK_VERSION in core/config.ts
|
|
1517
|
+
*/
|
|
1518
|
+
/**
|
|
1519
|
+
* Error Tracking Plugin - Tracks JavaScript errors
|
|
1520
|
+
*/
|
|
1521
|
+
class ErrorsPlugin extends BasePlugin {
|
|
1522
|
+
constructor() {
|
|
1523
|
+
super(...arguments);
|
|
1524
|
+
this.name = 'errors';
|
|
1525
|
+
this.boundErrorHandler = null;
|
|
1526
|
+
this.boundRejectionHandler = null;
|
|
1527
|
+
}
|
|
1528
|
+
init(tracker) {
|
|
1529
|
+
super.init(tracker);
|
|
1530
|
+
if (typeof window !== 'undefined') {
|
|
1531
|
+
this.boundErrorHandler = this.handleError.bind(this);
|
|
1532
|
+
this.boundRejectionHandler = this.handleRejection.bind(this);
|
|
1533
|
+
window.addEventListener('error', this.boundErrorHandler);
|
|
1534
|
+
window.addEventListener('unhandledrejection', this.boundRejectionHandler);
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
destroy() {
|
|
1538
|
+
if (typeof window !== 'undefined') {
|
|
1539
|
+
if (this.boundErrorHandler) {
|
|
1540
|
+
window.removeEventListener('error', this.boundErrorHandler);
|
|
1541
|
+
}
|
|
1542
|
+
if (this.boundRejectionHandler) {
|
|
1543
|
+
window.removeEventListener('unhandledrejection', this.boundRejectionHandler);
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
super.destroy();
|
|
1547
|
+
}
|
|
1548
|
+
handleError(e) {
|
|
1549
|
+
this.track('error', 'JavaScript Error', {
|
|
1550
|
+
message: e.message,
|
|
1551
|
+
filename: e.filename,
|
|
1552
|
+
line: e.lineno,
|
|
1553
|
+
column: e.colno,
|
|
1554
|
+
stack: e.error?.stack?.substring(0, 500),
|
|
1555
|
+
});
|
|
1556
|
+
}
|
|
1557
|
+
handleRejection(e) {
|
|
1558
|
+
this.track('error', 'Unhandled Promise Rejection', {
|
|
1559
|
+
reason: String(e.reason).substring(0, 200),
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
/**
|
|
1565
|
+
* Clianta SDK - Performance Plugin
|
|
1566
|
+
* @see SDK_VERSION in core/config.ts
|
|
1567
|
+
*/
|
|
1568
|
+
/**
|
|
1569
|
+
* Performance Plugin - Tracks page performance and Web Vitals
|
|
1570
|
+
*/
|
|
1571
|
+
class PerformancePlugin extends BasePlugin {
|
|
1572
|
+
constructor() {
|
|
1573
|
+
super(...arguments);
|
|
1574
|
+
this.name = 'performance';
|
|
1575
|
+
this.boundLoadHandler = null;
|
|
1576
|
+
this.observers = [];
|
|
1577
|
+
this.boundClsVisibilityHandler = null;
|
|
1578
|
+
}
|
|
1579
|
+
init(tracker) {
|
|
1580
|
+
super.init(tracker);
|
|
1581
|
+
if (typeof window !== 'undefined') {
|
|
1582
|
+
// Track performance after page load
|
|
1583
|
+
this.boundLoadHandler = () => {
|
|
1584
|
+
// Delay to ensure all metrics are available
|
|
1585
|
+
setTimeout(() => this.trackPerformance(), 100);
|
|
1586
|
+
};
|
|
1587
|
+
window.addEventListener('load', this.boundLoadHandler);
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
destroy() {
|
|
1591
|
+
if (this.boundLoadHandler && typeof window !== 'undefined') {
|
|
1592
|
+
window.removeEventListener('load', this.boundLoadHandler);
|
|
1593
|
+
}
|
|
1594
|
+
for (const observer of this.observers) {
|
|
1595
|
+
observer.disconnect();
|
|
1596
|
+
}
|
|
1597
|
+
this.observers = [];
|
|
1598
|
+
if (this.boundClsVisibilityHandler && typeof window !== 'undefined') {
|
|
1599
|
+
window.removeEventListener('visibilitychange', this.boundClsVisibilityHandler);
|
|
1600
|
+
}
|
|
1601
|
+
super.destroy();
|
|
1602
|
+
}
|
|
1603
|
+
trackPerformance() {
|
|
1604
|
+
if (typeof performance === 'undefined')
|
|
1605
|
+
return;
|
|
1606
|
+
// Use modern Navigation Timing API (PerformanceNavigationTiming)
|
|
1607
|
+
const entries = performance.getEntriesByType('navigation');
|
|
1608
|
+
if (entries.length > 0) {
|
|
1609
|
+
const navTiming = entries[0];
|
|
1610
|
+
const loadTime = Math.round(navTiming.loadEventEnd - navTiming.startTime);
|
|
1611
|
+
const domReady = Math.round(navTiming.domContentLoadedEventEnd - navTiming.startTime);
|
|
1612
|
+
const ttfb = Math.round(navTiming.responseStart - navTiming.requestStart);
|
|
1613
|
+
const domInteractive = Math.round(navTiming.domInteractive - navTiming.startTime);
|
|
1614
|
+
this.track('performance', 'Page Performance', {
|
|
1615
|
+
loadTime,
|
|
1616
|
+
domReady,
|
|
1617
|
+
ttfb, // Time to First Byte
|
|
1618
|
+
domInteractive,
|
|
1619
|
+
// Additional modern metrics
|
|
1620
|
+
dns: Math.round(navTiming.domainLookupEnd - navTiming.domainLookupStart),
|
|
1621
|
+
connection: Math.round(navTiming.connectEnd - navTiming.connectStart),
|
|
1622
|
+
transferSize: navTiming.transferSize,
|
|
1623
|
+
});
|
|
1624
|
+
}
|
|
1625
|
+
else {
|
|
1626
|
+
// Fallback for older browsers using deprecated API
|
|
1627
|
+
const timing = performance.timing;
|
|
1628
|
+
if (!timing)
|
|
1629
|
+
return;
|
|
1630
|
+
const loadTime = timing.loadEventEnd - timing.navigationStart;
|
|
1631
|
+
const domReady = timing.domContentLoadedEventEnd - timing.navigationStart;
|
|
1632
|
+
const ttfb = timing.responseStart - timing.navigationStart;
|
|
1633
|
+
const domInteractive = timing.domInteractive - timing.navigationStart;
|
|
1634
|
+
this.track('performance', 'Page Performance', {
|
|
1635
|
+
loadTime,
|
|
1636
|
+
domReady,
|
|
1637
|
+
ttfb,
|
|
1638
|
+
domInteractive,
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
// Track Web Vitals if available
|
|
1642
|
+
this.trackWebVitals();
|
|
1643
|
+
}
|
|
1644
|
+
trackWebVitals() {
|
|
1645
|
+
// LCP (Largest Contentful Paint)
|
|
1646
|
+
if ('PerformanceObserver' in window) {
|
|
1647
|
+
try {
|
|
1648
|
+
const lcpObserver = new PerformanceObserver((entryList) => {
|
|
1649
|
+
const entries = entryList.getEntries();
|
|
1650
|
+
const lastEntry = entries[entries.length - 1];
|
|
1651
|
+
if (lastEntry) {
|
|
1652
|
+
this.track('performance', 'Web Vital - LCP', {
|
|
1653
|
+
metric: 'LCP',
|
|
1654
|
+
value: Math.round(lastEntry.startTime),
|
|
1655
|
+
});
|
|
1656
|
+
}
|
|
1657
|
+
});
|
|
1658
|
+
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
|
|
1659
|
+
this.observers.push(lcpObserver);
|
|
1660
|
+
}
|
|
1661
|
+
catch {
|
|
1662
|
+
// LCP not supported
|
|
1663
|
+
}
|
|
1664
|
+
// FID (First Input Delay)
|
|
1665
|
+
try {
|
|
1666
|
+
const fidObserver = new PerformanceObserver((entryList) => {
|
|
1667
|
+
const entries = entryList.getEntries();
|
|
1668
|
+
const firstEntry = entries[0];
|
|
1669
|
+
if (firstEntry) {
|
|
1670
|
+
this.track('performance', 'Web Vital - FID', {
|
|
1671
|
+
metric: 'FID',
|
|
1672
|
+
value: Math.round(firstEntry.processingStart - firstEntry.startTime),
|
|
1673
|
+
});
|
|
1674
|
+
}
|
|
1675
|
+
});
|
|
1676
|
+
fidObserver.observe({ type: 'first-input', buffered: true });
|
|
1677
|
+
this.observers.push(fidObserver);
|
|
1678
|
+
}
|
|
1679
|
+
catch {
|
|
1680
|
+
// FID not supported
|
|
1681
|
+
}
|
|
1682
|
+
// CLS (Cumulative Layout Shift)
|
|
1683
|
+
try {
|
|
1684
|
+
let clsValue = 0;
|
|
1685
|
+
const clsObserver = new PerformanceObserver((entryList) => {
|
|
1686
|
+
const entries = entryList.getEntries();
|
|
1687
|
+
entries.forEach((entry) => {
|
|
1688
|
+
if (!entry.hadRecentInput) {
|
|
1689
|
+
clsValue += entry.value || 0;
|
|
1690
|
+
}
|
|
1691
|
+
});
|
|
1692
|
+
});
|
|
1693
|
+
clsObserver.observe({ type: 'layout-shift', buffered: true });
|
|
1694
|
+
this.observers.push(clsObserver);
|
|
1695
|
+
// Report CLS after page is hidden
|
|
1696
|
+
this.boundClsVisibilityHandler = () => {
|
|
1697
|
+
if (document.visibilityState === 'hidden' && clsValue > 0) {
|
|
1698
|
+
this.track('performance', 'Web Vital - CLS', {
|
|
1699
|
+
metric: 'CLS',
|
|
1700
|
+
value: Math.round(clsValue * 1000) / 1000,
|
|
1701
|
+
});
|
|
1702
|
+
}
|
|
1703
|
+
};
|
|
1704
|
+
window.addEventListener('visibilitychange', this.boundClsVisibilityHandler, { once: true });
|
|
1705
|
+
}
|
|
1706
|
+
catch {
|
|
1707
|
+
// CLS not supported
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
/**
|
|
1714
|
+
* Clianta Tracking SDK - Popup Forms Plugin
|
|
1715
|
+
* @see SDK_VERSION in core/config.ts
|
|
1716
|
+
*
|
|
1717
|
+
* Auto-loads and displays lead capture popups based on triggers
|
|
1718
|
+
*/
|
|
1719
|
+
/**
|
|
1720
|
+
* Popup Forms Plugin - Fetches and displays lead capture forms
|
|
1721
|
+
*/
|
|
1722
|
+
class PopupFormsPlugin extends BasePlugin {
|
|
1723
|
+
constructor() {
|
|
1724
|
+
super(...arguments);
|
|
1725
|
+
this.name = 'popupForms';
|
|
1726
|
+
this.forms = [];
|
|
1727
|
+
this.shownForms = new Set();
|
|
1728
|
+
this.scrollHandler = null;
|
|
1729
|
+
this.exitHandler = null;
|
|
1730
|
+
this.delayTimers = [];
|
|
1731
|
+
this.clickTriggerListeners = [];
|
|
1732
|
+
}
|
|
1733
|
+
async init(tracker) {
|
|
1734
|
+
super.init(tracker);
|
|
1735
|
+
if (typeof window === 'undefined')
|
|
1736
|
+
return;
|
|
1737
|
+
// Load shown forms from storage
|
|
1738
|
+
this.loadShownForms();
|
|
1739
|
+
// Fetch active forms
|
|
1740
|
+
await this.fetchForms();
|
|
1741
|
+
// Setup triggers
|
|
1742
|
+
this.setupTriggers();
|
|
1743
|
+
}
|
|
1744
|
+
destroy() {
|
|
1745
|
+
this.removeTriggers();
|
|
1746
|
+
for (const timer of this.delayTimers) {
|
|
1747
|
+
clearTimeout(timer);
|
|
1748
|
+
}
|
|
1749
|
+
this.delayTimers = [];
|
|
1750
|
+
for (const { element, handler } of this.clickTriggerListeners) {
|
|
1751
|
+
element.removeEventListener('click', handler);
|
|
1752
|
+
}
|
|
1753
|
+
this.clickTriggerListeners = [];
|
|
1754
|
+
super.destroy();
|
|
1755
|
+
}
|
|
1756
|
+
loadShownForms() {
|
|
1757
|
+
try {
|
|
1758
|
+
const stored = localStorage.getItem('clianta_shown_forms');
|
|
1759
|
+
if (stored) {
|
|
1760
|
+
const data = JSON.parse(stored);
|
|
1761
|
+
this.shownForms = new Set(data.forms || []);
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
catch (e) {
|
|
1765
|
+
// Ignore storage errors
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
saveShownForms() {
|
|
1769
|
+
try {
|
|
1770
|
+
localStorage.setItem('clianta_shown_forms', JSON.stringify({
|
|
1771
|
+
forms: Array.from(this.shownForms),
|
|
1772
|
+
timestamp: Date.now(),
|
|
1773
|
+
}));
|
|
1774
|
+
}
|
|
1775
|
+
catch (e) {
|
|
1776
|
+
// Ignore storage errors
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
async fetchForms() {
|
|
1780
|
+
if (!this.tracker)
|
|
1781
|
+
return;
|
|
1782
|
+
const config = this.tracker.getConfig();
|
|
1783
|
+
const workspaceId = this.tracker.getWorkspaceId();
|
|
1784
|
+
const apiEndpoint = config.apiEndpoint || 'https://api.clianta.online';
|
|
1785
|
+
try {
|
|
1786
|
+
const url = encodeURIComponent(window.location.href);
|
|
1787
|
+
const response = await fetch(`${apiEndpoint}/api/public/lead-forms/${workspaceId}?url=${url}`);
|
|
1788
|
+
if (!response.ok)
|
|
1789
|
+
return;
|
|
1790
|
+
const data = await response.json();
|
|
1791
|
+
if (data.success && Array.isArray(data.data)) {
|
|
1792
|
+
this.forms = data.data.filter((form) => this.shouldShowForm(form));
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
catch (error) {
|
|
1796
|
+
console.error('[Clianta] Failed to fetch forms:', error);
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
shouldShowForm(form) {
|
|
1800
|
+
// Check show frequency
|
|
1801
|
+
if (form.showFrequency === 'once_per_visitor') {
|
|
1802
|
+
if (this.shownForms.has(form._id))
|
|
1803
|
+
return false;
|
|
1804
|
+
}
|
|
1805
|
+
else if (form.showFrequency === 'once_per_session') {
|
|
1806
|
+
const sessionKey = `clianta_form_${form._id}_shown`;
|
|
1807
|
+
if (sessionStorage.getItem(sessionKey))
|
|
1808
|
+
return false;
|
|
1809
|
+
}
|
|
1810
|
+
return true;
|
|
1811
|
+
}
|
|
1812
|
+
setupTriggers() {
|
|
1813
|
+
this.forms.forEach(form => {
|
|
1814
|
+
switch (form.trigger.type) {
|
|
1815
|
+
case 'delay':
|
|
1816
|
+
this.delayTimers.push(setTimeout(() => this.showForm(form), (form.trigger.value || 5) * 1000));
|
|
1817
|
+
break;
|
|
1818
|
+
case 'scroll':
|
|
1819
|
+
this.setupScrollTrigger(form);
|
|
1820
|
+
break;
|
|
1821
|
+
case 'exit_intent':
|
|
1822
|
+
this.setupExitIntentTrigger(form);
|
|
1823
|
+
break;
|
|
1824
|
+
case 'click':
|
|
1825
|
+
this.setupClickTrigger(form);
|
|
1826
|
+
break;
|
|
1827
|
+
}
|
|
1828
|
+
});
|
|
1829
|
+
}
|
|
1830
|
+
setupScrollTrigger(form) {
|
|
1831
|
+
const threshold = form.trigger.value || 50;
|
|
1832
|
+
this.scrollHandler = () => {
|
|
1833
|
+
const scrollPercent = (window.scrollY / (document.documentElement.scrollHeight - window.innerHeight)) * 100;
|
|
1834
|
+
if (scrollPercent >= threshold) {
|
|
1835
|
+
this.showForm(form);
|
|
1836
|
+
if (this.scrollHandler) {
|
|
1837
|
+
window.removeEventListener('scroll', this.scrollHandler);
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
};
|
|
1841
|
+
window.addEventListener('scroll', this.scrollHandler, { passive: true });
|
|
1842
|
+
}
|
|
1843
|
+
setupExitIntentTrigger(form) {
|
|
1844
|
+
this.exitHandler = (e) => {
|
|
1845
|
+
if (e.clientY <= 0) {
|
|
1846
|
+
this.showForm(form);
|
|
1847
|
+
if (this.exitHandler) {
|
|
1848
|
+
document.removeEventListener('mouseout', this.exitHandler);
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
};
|
|
1852
|
+
document.addEventListener('mouseout', this.exitHandler);
|
|
1853
|
+
}
|
|
1854
|
+
setupClickTrigger(form) {
|
|
1855
|
+
if (!form.trigger.selector)
|
|
1856
|
+
return;
|
|
1857
|
+
const elements = document.querySelectorAll(form.trigger.selector);
|
|
1858
|
+
elements.forEach(el => {
|
|
1859
|
+
const handler = () => this.showForm(form);
|
|
1860
|
+
el.addEventListener('click', handler);
|
|
1861
|
+
this.clickTriggerListeners.push({ element: el, handler });
|
|
1862
|
+
});
|
|
1863
|
+
}
|
|
1864
|
+
removeTriggers() {
|
|
1865
|
+
if (this.scrollHandler) {
|
|
1866
|
+
window.removeEventListener('scroll', this.scrollHandler);
|
|
1867
|
+
}
|
|
1868
|
+
if (this.exitHandler) {
|
|
1869
|
+
document.removeEventListener('mouseout', this.exitHandler);
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
async showForm(form) {
|
|
1873
|
+
// Check if already shown in this session
|
|
1874
|
+
if (!this.shouldShowForm(form))
|
|
1875
|
+
return;
|
|
1876
|
+
// Mark as shown
|
|
1877
|
+
this.shownForms.add(form._id);
|
|
1878
|
+
this.saveShownForms();
|
|
1879
|
+
sessionStorage.setItem(`clianta_form_${form._id}_shown`, 'true');
|
|
1880
|
+
// Track view
|
|
1881
|
+
await this.trackFormView(form._id);
|
|
1882
|
+
// Render form
|
|
1883
|
+
this.renderForm(form);
|
|
1884
|
+
}
|
|
1885
|
+
async trackFormView(formId) {
|
|
1886
|
+
if (!this.tracker)
|
|
1887
|
+
return;
|
|
1888
|
+
const config = this.tracker.getConfig();
|
|
1889
|
+
const apiEndpoint = config.apiEndpoint || 'https://api.clianta.online';
|
|
1890
|
+
try {
|
|
1891
|
+
await fetch(`${apiEndpoint}/api/public/lead-forms/${formId}/view`, {
|
|
1892
|
+
method: 'POST',
|
|
1893
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1894
|
+
});
|
|
1895
|
+
}
|
|
1896
|
+
catch (e) {
|
|
1897
|
+
// Ignore tracking errors
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
renderForm(form) {
|
|
1901
|
+
// Create overlay
|
|
1902
|
+
const overlay = document.createElement('div');
|
|
1903
|
+
overlay.id = `clianta-form-overlay-${form._id}`;
|
|
1904
|
+
overlay.style.cssText = `
|
|
1905
|
+
position: fixed;
|
|
1906
|
+
top: 0;
|
|
1907
|
+
left: 0;
|
|
1908
|
+
right: 0;
|
|
1909
|
+
bottom: 0;
|
|
1910
|
+
background: rgba(0, 0, 0, 0.5);
|
|
1911
|
+
z-index: 999998;
|
|
1912
|
+
display: flex;
|
|
1913
|
+
align-items: center;
|
|
1914
|
+
justify-content: center;
|
|
1915
|
+
opacity: 0;
|
|
1916
|
+
transition: opacity 0.3s ease;
|
|
1917
|
+
`;
|
|
1918
|
+
// Create form container
|
|
1919
|
+
const container = document.createElement('div');
|
|
1920
|
+
container.id = `clianta-form-${form._id}`;
|
|
1921
|
+
const style = form.style || {};
|
|
1922
|
+
container.style.cssText = `
|
|
1923
|
+
background: ${style.backgroundColor || '#FFFFFF'};
|
|
1924
|
+
border-radius: ${style.borderRadius || 12}px;
|
|
1925
|
+
padding: 24px;
|
|
1926
|
+
max-width: 400px;
|
|
1927
|
+
width: 90%;
|
|
1928
|
+
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
|
1929
|
+
transform: translateY(20px);
|
|
1930
|
+
opacity: 0;
|
|
1931
|
+
transition: all 0.3s ease;
|
|
1932
|
+
`;
|
|
1933
|
+
// Build form using safe DOM APIs (no innerHTML for user content)
|
|
1934
|
+
this.buildFormDOM(form, container);
|
|
1935
|
+
overlay.appendChild(container);
|
|
1936
|
+
document.body.appendChild(overlay);
|
|
1937
|
+
// Animate in
|
|
1938
|
+
requestAnimationFrame(() => {
|
|
1939
|
+
overlay.style.opacity = '1';
|
|
1940
|
+
container.style.transform = 'translateY(0)';
|
|
1941
|
+
container.style.opacity = '1';
|
|
1942
|
+
});
|
|
1943
|
+
// Setup event listeners
|
|
1944
|
+
this.setupFormEvents(form, overlay, container);
|
|
1945
|
+
}
|
|
1946
|
+
/**
|
|
1947
|
+
* Escape HTML to prevent XSS - used only for static structure
|
|
1948
|
+
*/
|
|
1949
|
+
escapeHTML(str) {
|
|
1950
|
+
const div = document.createElement('div');
|
|
1951
|
+
div.textContent = str;
|
|
1952
|
+
return div.innerHTML;
|
|
1953
|
+
}
|
|
1954
|
+
/**
|
|
1955
|
+
* Build form using safe DOM APIs (prevents XSS)
|
|
1956
|
+
*/
|
|
1957
|
+
buildFormDOM(form, container) {
|
|
1958
|
+
const style = form.style || {};
|
|
1959
|
+
const primaryColor = style.primaryColor || '#10B981';
|
|
1960
|
+
const textColor = style.textColor || '#18181B';
|
|
1961
|
+
// Close button
|
|
1962
|
+
const closeBtn = document.createElement('button');
|
|
1963
|
+
closeBtn.id = 'clianta-form-close';
|
|
1964
|
+
closeBtn.style.cssText = `
|
|
1965
|
+
position: absolute;
|
|
1966
|
+
top: 12px;
|
|
1967
|
+
right: 12px;
|
|
1968
|
+
background: none;
|
|
1969
|
+
border: none;
|
|
1970
|
+
font-size: 20px;
|
|
1971
|
+
cursor: pointer;
|
|
1972
|
+
color: #71717A;
|
|
1973
|
+
padding: 4px;
|
|
1974
|
+
`;
|
|
1975
|
+
closeBtn.textContent = '×';
|
|
1976
|
+
container.appendChild(closeBtn);
|
|
1977
|
+
// Headline
|
|
1978
|
+
const headline = document.createElement('h2');
|
|
1979
|
+
headline.style.cssText = `font-size: 20px; font-weight: 700; margin-bottom: 8px; color: ${this.escapeHTML(textColor)};`;
|
|
1980
|
+
headline.textContent = form.headline || 'Stay in touch';
|
|
1981
|
+
container.appendChild(headline);
|
|
1982
|
+
// Subheadline
|
|
1983
|
+
const subheadline = document.createElement('p');
|
|
1984
|
+
subheadline.style.cssText = 'font-size: 14px; color: #71717A; margin-bottom: 16px;';
|
|
1985
|
+
subheadline.textContent = form.subheadline || 'Get the latest updates';
|
|
1986
|
+
container.appendChild(subheadline);
|
|
1987
|
+
// Form element
|
|
1988
|
+
const formElement = document.createElement('form');
|
|
1989
|
+
formElement.id = 'clianta-form-element';
|
|
1990
|
+
// Build fields
|
|
1991
|
+
form.fields.forEach(field => {
|
|
1992
|
+
const fieldWrapper = document.createElement('div');
|
|
1993
|
+
fieldWrapper.style.marginBottom = '12px';
|
|
1994
|
+
if (field.type === 'checkbox') {
|
|
1995
|
+
// Checkbox layout
|
|
1996
|
+
const label = document.createElement('label');
|
|
1997
|
+
label.style.cssText = `display: flex; align-items: center; gap: 8px; font-size: 14px; color: ${this.escapeHTML(textColor)}; cursor: pointer;`;
|
|
1998
|
+
const input = document.createElement('input');
|
|
1999
|
+
input.type = 'checkbox';
|
|
2000
|
+
input.name = field.name;
|
|
2001
|
+
if (field.required)
|
|
2002
|
+
input.required = true;
|
|
2003
|
+
input.style.cssText = 'width: 16px; height: 16px;';
|
|
2004
|
+
label.appendChild(input);
|
|
2005
|
+
const labelText = document.createTextNode(field.label + ' ');
|
|
2006
|
+
label.appendChild(labelText);
|
|
2007
|
+
if (field.required) {
|
|
2008
|
+
const requiredMark = document.createElement('span');
|
|
2009
|
+
requiredMark.style.color = '#EF4444';
|
|
2010
|
+
requiredMark.textContent = '*';
|
|
2011
|
+
label.appendChild(requiredMark);
|
|
2012
|
+
}
|
|
2013
|
+
fieldWrapper.appendChild(label);
|
|
2014
|
+
}
|
|
2015
|
+
else {
|
|
2016
|
+
// Label
|
|
2017
|
+
const label = document.createElement('label');
|
|
2018
|
+
label.style.cssText = `display: block; font-size: 14px; font-weight: 500; margin-bottom: 4px; color: ${this.escapeHTML(textColor)};`;
|
|
2019
|
+
label.textContent = field.label + ' ';
|
|
2020
|
+
if (field.required) {
|
|
2021
|
+
const requiredMark = document.createElement('span');
|
|
2022
|
+
requiredMark.style.color = '#EF4444';
|
|
2023
|
+
requiredMark.textContent = '*';
|
|
2024
|
+
label.appendChild(requiredMark);
|
|
2025
|
+
}
|
|
2026
|
+
fieldWrapper.appendChild(label);
|
|
2027
|
+
// Input/Textarea/Select
|
|
2028
|
+
if (field.type === 'textarea') {
|
|
2029
|
+
const textarea = document.createElement('textarea');
|
|
2030
|
+
textarea.name = field.name;
|
|
2031
|
+
if (field.placeholder)
|
|
2032
|
+
textarea.placeholder = field.placeholder;
|
|
2033
|
+
if (field.required)
|
|
2034
|
+
textarea.required = true;
|
|
2035
|
+
textarea.style.cssText = 'width: 100%; padding: 8px 12px; border: 1px solid #E4E4E7; border-radius: 6px; font-size: 14px; resize: vertical; min-height: 80px; box-sizing: border-box;';
|
|
2036
|
+
fieldWrapper.appendChild(textarea);
|
|
2037
|
+
}
|
|
2038
|
+
else if (field.type === 'select') {
|
|
2039
|
+
const select = document.createElement('select');
|
|
2040
|
+
select.name = field.name;
|
|
2041
|
+
if (field.required)
|
|
2042
|
+
select.required = true;
|
|
2043
|
+
select.style.cssText = 'width: 100%; padding: 8px 12px; border: 1px solid #E4E4E7; border-radius: 6px; font-size: 14px; box-sizing: border-box; background: white; cursor: pointer;';
|
|
2044
|
+
// Add placeholder option
|
|
2045
|
+
if (field.placeholder) {
|
|
2046
|
+
const placeholderOption = document.createElement('option');
|
|
2047
|
+
placeholderOption.value = '';
|
|
2048
|
+
placeholderOption.textContent = field.placeholder;
|
|
2049
|
+
placeholderOption.disabled = true;
|
|
2050
|
+
placeholderOption.selected = true;
|
|
2051
|
+
select.appendChild(placeholderOption);
|
|
2052
|
+
}
|
|
2053
|
+
// Add options from field.options array if provided
|
|
2054
|
+
if (field.options && Array.isArray(field.options)) {
|
|
2055
|
+
field.options.forEach((opt) => {
|
|
2056
|
+
const option = document.createElement('option');
|
|
2057
|
+
if (typeof opt === 'string') {
|
|
2058
|
+
option.value = opt;
|
|
2059
|
+
option.textContent = opt;
|
|
2060
|
+
}
|
|
2061
|
+
else {
|
|
2062
|
+
option.value = opt.value;
|
|
2063
|
+
option.textContent = opt.label;
|
|
2064
|
+
}
|
|
2065
|
+
select.appendChild(option);
|
|
2066
|
+
});
|
|
2067
|
+
}
|
|
2068
|
+
fieldWrapper.appendChild(select);
|
|
2069
|
+
}
|
|
2070
|
+
else {
|
|
2071
|
+
const input = document.createElement('input');
|
|
2072
|
+
input.type = field.type;
|
|
2073
|
+
input.name = field.name;
|
|
2074
|
+
if (field.placeholder)
|
|
2075
|
+
input.placeholder = field.placeholder;
|
|
2076
|
+
if (field.required)
|
|
2077
|
+
input.required = true;
|
|
2078
|
+
input.style.cssText = 'width: 100%; padding: 8px 12px; border: 1px solid #E4E4E7; border-radius: 6px; font-size: 14px; box-sizing: border-box;';
|
|
2079
|
+
fieldWrapper.appendChild(input);
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
formElement.appendChild(fieldWrapper);
|
|
2083
|
+
});
|
|
2084
|
+
// Submit button
|
|
2085
|
+
const submitBtn = document.createElement('button');
|
|
2086
|
+
submitBtn.type = 'submit';
|
|
2087
|
+
submitBtn.style.cssText = `
|
|
2088
|
+
width: 100%;
|
|
2089
|
+
padding: 10px 16px;
|
|
2090
|
+
background: ${this.escapeHTML(primaryColor)};
|
|
2091
|
+
color: white;
|
|
2092
|
+
border: none;
|
|
2093
|
+
border-radius: 6px;
|
|
2094
|
+
font-size: 14px;
|
|
2095
|
+
font-weight: 500;
|
|
2096
|
+
cursor: pointer;
|
|
2097
|
+
margin-top: 8px;
|
|
2098
|
+
`;
|
|
2099
|
+
submitBtn.textContent = form.submitButtonText || 'Subscribe';
|
|
2100
|
+
formElement.appendChild(submitBtn);
|
|
2101
|
+
container.appendChild(formElement);
|
|
2102
|
+
}
|
|
2103
|
+
setupFormEvents(form, overlay, container) {
|
|
2104
|
+
// Close button
|
|
2105
|
+
const closeBtn = container.querySelector('#clianta-form-close');
|
|
2106
|
+
if (closeBtn) {
|
|
2107
|
+
closeBtn.addEventListener('click', () => this.closeForm(form._id, overlay, container));
|
|
2108
|
+
}
|
|
2109
|
+
// Overlay click
|
|
2110
|
+
overlay.addEventListener('click', (e) => {
|
|
2111
|
+
if (e.target === overlay) {
|
|
2112
|
+
this.closeForm(form._id, overlay, container);
|
|
2113
|
+
}
|
|
2114
|
+
});
|
|
2115
|
+
// Form submit
|
|
2116
|
+
const formElement = container.querySelector('#clianta-form-element');
|
|
2117
|
+
if (formElement) {
|
|
2118
|
+
formElement.addEventListener('submit', async (e) => {
|
|
2119
|
+
e.preventDefault();
|
|
2120
|
+
await this.handleSubmit(form, formElement, container);
|
|
2121
|
+
});
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
closeForm(formId, overlay, container) {
|
|
2125
|
+
container.style.transform = 'translateY(20px)';
|
|
2126
|
+
container.style.opacity = '0';
|
|
2127
|
+
overlay.style.opacity = '0';
|
|
2128
|
+
setTimeout(() => {
|
|
2129
|
+
overlay.remove();
|
|
2130
|
+
}, 300);
|
|
2131
|
+
}
|
|
2132
|
+
async handleSubmit(form, formElement, container) {
|
|
2133
|
+
if (!this.tracker)
|
|
2134
|
+
return;
|
|
2135
|
+
const config = this.tracker.getConfig();
|
|
2136
|
+
const apiEndpoint = config.apiEndpoint || 'https://api.clianta.online';
|
|
2137
|
+
const visitorId = this.tracker.getVisitorId();
|
|
2138
|
+
// Collect form data
|
|
2139
|
+
const formData = new FormData(formElement);
|
|
2140
|
+
const data = {};
|
|
2141
|
+
formData.forEach((value, key) => {
|
|
2142
|
+
data[key] = value;
|
|
2143
|
+
});
|
|
2144
|
+
// Disable submit button
|
|
2145
|
+
const submitBtn = formElement.querySelector('button[type="submit"]');
|
|
2146
|
+
if (submitBtn) {
|
|
2147
|
+
submitBtn.disabled = true;
|
|
2148
|
+
submitBtn.textContent = 'Submitting...';
|
|
2149
|
+
}
|
|
2150
|
+
try {
|
|
2151
|
+
const response = await fetch(`${apiEndpoint}/api/public/lead-forms/${form._id}/submit`, {
|
|
2152
|
+
method: 'POST',
|
|
2153
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2154
|
+
body: JSON.stringify({
|
|
2155
|
+
visitorId,
|
|
2156
|
+
data,
|
|
2157
|
+
url: window.location.href,
|
|
2158
|
+
}),
|
|
2159
|
+
});
|
|
2160
|
+
const result = await response.json();
|
|
2161
|
+
if (result.success) {
|
|
2162
|
+
// Show success message using safe DOM APIs
|
|
2163
|
+
container.innerHTML = '';
|
|
2164
|
+
const successWrapper = document.createElement('div');
|
|
2165
|
+
successWrapper.style.cssText = 'text-align: center; padding: 20px;';
|
|
2166
|
+
const iconWrapper = document.createElement('div');
|
|
2167
|
+
iconWrapper.style.cssText = 'width: 48px; height: 48px; background: #10B981; border-radius: 50%; margin: 0 auto 16px; display: flex; align-items: center; justify-content: center;';
|
|
2168
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
2169
|
+
svg.setAttribute('width', '24');
|
|
2170
|
+
svg.setAttribute('height', '24');
|
|
2171
|
+
svg.setAttribute('viewBox', '0 0 24 24');
|
|
2172
|
+
svg.setAttribute('fill', 'none');
|
|
2173
|
+
svg.setAttribute('stroke', 'white');
|
|
2174
|
+
svg.setAttribute('stroke-width', '2');
|
|
2175
|
+
const polyline = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
|
|
2176
|
+
polyline.setAttribute('points', '20 6 9 17 4 12');
|
|
2177
|
+
svg.appendChild(polyline);
|
|
2178
|
+
iconWrapper.appendChild(svg);
|
|
2179
|
+
const message = document.createElement('p');
|
|
2180
|
+
message.style.cssText = 'font-size: 16px; font-weight: 500; color: #18181B;';
|
|
2181
|
+
message.textContent = form.successMessage || 'Thank you!';
|
|
2182
|
+
successWrapper.appendChild(iconWrapper);
|
|
2183
|
+
successWrapper.appendChild(message);
|
|
2184
|
+
container.appendChild(successWrapper);
|
|
2185
|
+
// Track identify
|
|
2186
|
+
if (data.email) {
|
|
2187
|
+
this.tracker?.identify(data.email, data);
|
|
2188
|
+
}
|
|
2189
|
+
// Redirect if configured (validate URL to prevent open redirect)
|
|
2190
|
+
if (form.redirectUrl) {
|
|
2191
|
+
try {
|
|
2192
|
+
const redirect = new URL(form.redirectUrl, window.location.origin);
|
|
2193
|
+
const isSameOrigin = redirect.origin === window.location.origin;
|
|
2194
|
+
const isSafeProtocol = redirect.protocol === 'https:' || redirect.protocol === 'http:';
|
|
2195
|
+
if (isSameOrigin || isSafeProtocol) {
|
|
2196
|
+
setTimeout(() => {
|
|
2197
|
+
window.location.href = redirect.href;
|
|
2198
|
+
}, 1500);
|
|
2199
|
+
}
|
|
2200
|
+
else {
|
|
2201
|
+
console.warn('[Clianta] Blocked unsafe redirect URL:', form.redirectUrl);
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
catch {
|
|
2205
|
+
console.warn('[Clianta] Invalid redirect URL:', form.redirectUrl);
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
// Close after delay
|
|
2209
|
+
setTimeout(() => {
|
|
2210
|
+
const overlay = document.getElementById(`clianta-form-overlay-${form._id}`);
|
|
2211
|
+
if (overlay) {
|
|
2212
|
+
this.closeForm(form._id, overlay, container);
|
|
2213
|
+
}
|
|
2214
|
+
}, 2000);
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
catch (error) {
|
|
2218
|
+
console.error('[Clianta] Form submit error:', error);
|
|
2219
|
+
if (submitBtn) {
|
|
2220
|
+
submitBtn.disabled = false;
|
|
2221
|
+
submitBtn.textContent = form.submitButtonText || 'Subscribe';
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
/**
|
|
2228
|
+
* Clianta SDK - Plugins Index
|
|
2229
|
+
* Version is defined in core/config.ts as SDK_VERSION
|
|
2230
|
+
*/
|
|
2231
|
+
/**
|
|
2232
|
+
* Get plugin instance by name
|
|
2233
|
+
*/
|
|
2234
|
+
function getPlugin(name) {
|
|
2235
|
+
switch (name) {
|
|
2236
|
+
case 'pageView':
|
|
2237
|
+
return new PageViewPlugin();
|
|
2238
|
+
case 'scroll':
|
|
2239
|
+
return new ScrollPlugin();
|
|
2240
|
+
case 'forms':
|
|
2241
|
+
return new FormsPlugin();
|
|
2242
|
+
case 'clicks':
|
|
2243
|
+
return new ClicksPlugin();
|
|
2244
|
+
case 'engagement':
|
|
2245
|
+
return new EngagementPlugin();
|
|
2246
|
+
case 'downloads':
|
|
2247
|
+
return new DownloadsPlugin();
|
|
2248
|
+
case 'exitIntent':
|
|
2249
|
+
return new ExitIntentPlugin();
|
|
2250
|
+
case 'errors':
|
|
2251
|
+
return new ErrorsPlugin();
|
|
2252
|
+
case 'performance':
|
|
2253
|
+
return new PerformancePlugin();
|
|
2254
|
+
case 'popupForms':
|
|
2255
|
+
return new PopupFormsPlugin();
|
|
2256
|
+
default:
|
|
2257
|
+
throw new Error(`Unknown plugin: ${name}`);
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
/**
|
|
2262
|
+
* Clianta SDK - Consent Storage
|
|
2263
|
+
* Handles persistence of consent state
|
|
2264
|
+
* @see SDK_VERSION in core/config.ts
|
|
2265
|
+
*/
|
|
2266
|
+
const CONSENT_VERSION = 1;
|
|
2267
|
+
/**
|
|
2268
|
+
* Save consent state to storage
|
|
2269
|
+
*/
|
|
2270
|
+
function saveConsent(state) {
|
|
2271
|
+
try {
|
|
2272
|
+
if (typeof localStorage === 'undefined')
|
|
2273
|
+
return false;
|
|
2274
|
+
const stored = {
|
|
2275
|
+
state,
|
|
2276
|
+
timestamp: Date.now(),
|
|
2277
|
+
version: CONSENT_VERSION,
|
|
2278
|
+
};
|
|
2279
|
+
localStorage.setItem(STORAGE_KEYS.CONSENT, JSON.stringify(stored));
|
|
2280
|
+
return true;
|
|
2281
|
+
}
|
|
2282
|
+
catch {
|
|
2283
|
+
return false;
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
/**
|
|
2287
|
+
* Load consent state from storage
|
|
2288
|
+
*/
|
|
2289
|
+
function loadConsent() {
|
|
2290
|
+
try {
|
|
2291
|
+
if (typeof localStorage === 'undefined')
|
|
2292
|
+
return null;
|
|
2293
|
+
const stored = localStorage.getItem(STORAGE_KEYS.CONSENT);
|
|
2294
|
+
if (!stored)
|
|
2295
|
+
return null;
|
|
2296
|
+
const parsed = JSON.parse(stored);
|
|
2297
|
+
// Validate version
|
|
2298
|
+
if (parsed.version !== CONSENT_VERSION) {
|
|
2299
|
+
clearConsent();
|
|
2300
|
+
return null;
|
|
2301
|
+
}
|
|
2302
|
+
return parsed;
|
|
2303
|
+
}
|
|
2304
|
+
catch {
|
|
2305
|
+
return null;
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
/**
|
|
2309
|
+
* Clear consent state from storage
|
|
2310
|
+
*/
|
|
2311
|
+
function clearConsent() {
|
|
2312
|
+
try {
|
|
2313
|
+
if (typeof localStorage === 'undefined')
|
|
2314
|
+
return false;
|
|
2315
|
+
localStorage.removeItem(STORAGE_KEYS.CONSENT);
|
|
2316
|
+
return true;
|
|
2317
|
+
}
|
|
2318
|
+
catch {
|
|
2319
|
+
return false;
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
/**
|
|
2323
|
+
* Check if consent has been explicitly set
|
|
2324
|
+
*/
|
|
2325
|
+
function hasStoredConsent() {
|
|
2326
|
+
return loadConsent() !== null;
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
/**
|
|
2330
|
+
* Clianta SDK - Consent Manager
|
|
2331
|
+
* Manages consent state and event buffering for GDPR/CCPA compliance
|
|
2332
|
+
* @see SDK_VERSION in core/config.ts
|
|
2333
|
+
*/
|
|
2334
|
+
/** Maximum events to buffer while waiting for consent */
|
|
2335
|
+
const MAX_BUFFER_SIZE = 100;
|
|
2336
|
+
/**
|
|
2337
|
+
* Manages user consent state for tracking
|
|
2338
|
+
*/
|
|
2339
|
+
class ConsentManager {
|
|
2340
|
+
constructor(config = {}) {
|
|
2341
|
+
this.eventBuffer = [];
|
|
2342
|
+
this.callbacks = [];
|
|
2343
|
+
this.hasExplicitConsent = false;
|
|
2344
|
+
this.config = {
|
|
2345
|
+
defaultConsent: { analytics: true, marketing: false, personalization: false },
|
|
2346
|
+
waitForConsent: false,
|
|
2347
|
+
storageKey: 'mb_consent',
|
|
2348
|
+
...config,
|
|
2349
|
+
};
|
|
2350
|
+
// Load stored consent or use default
|
|
2351
|
+
const stored = loadConsent();
|
|
2352
|
+
if (stored) {
|
|
2353
|
+
this.state = stored.state;
|
|
2354
|
+
this.hasExplicitConsent = true;
|
|
2355
|
+
logger.debug('Loaded stored consent:', this.state);
|
|
2356
|
+
}
|
|
2357
|
+
else {
|
|
2358
|
+
this.state = this.config.defaultConsent || { analytics: true };
|
|
2359
|
+
this.hasExplicitConsent = false;
|
|
2360
|
+
logger.debug('Using default consent:', this.state);
|
|
2361
|
+
}
|
|
2362
|
+
// Register callback if provided
|
|
2363
|
+
if (config.onConsentChange) {
|
|
2364
|
+
this.callbacks.push(config.onConsentChange);
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
/**
|
|
2368
|
+
* Grant consent for specified categories
|
|
2369
|
+
*/
|
|
2370
|
+
grant(categories) {
|
|
2371
|
+
const previous = { ...this.state };
|
|
2372
|
+
this.state = { ...this.state, ...categories };
|
|
2373
|
+
this.hasExplicitConsent = true;
|
|
2374
|
+
saveConsent(this.state);
|
|
2375
|
+
logger.info('Consent granted:', categories);
|
|
2376
|
+
this.notifyChange(previous);
|
|
2377
|
+
}
|
|
2378
|
+
/**
|
|
2379
|
+
* Revoke consent for specified categories
|
|
2380
|
+
*/
|
|
2381
|
+
revoke(categories) {
|
|
2382
|
+
const previous = { ...this.state };
|
|
2383
|
+
for (const category of categories) {
|
|
2384
|
+
this.state[category] = false;
|
|
2385
|
+
}
|
|
2386
|
+
this.hasExplicitConsent = true;
|
|
2387
|
+
saveConsent(this.state);
|
|
2388
|
+
logger.info('Consent revoked:', categories);
|
|
2389
|
+
this.notifyChange(previous);
|
|
2390
|
+
}
|
|
2391
|
+
/**
|
|
2392
|
+
* Update entire consent state
|
|
2393
|
+
*/
|
|
2394
|
+
update(state) {
|
|
2395
|
+
const previous = { ...this.state };
|
|
2396
|
+
this.state = { ...state };
|
|
2397
|
+
this.hasExplicitConsent = true;
|
|
2398
|
+
saveConsent(this.state);
|
|
2399
|
+
logger.info('Consent updated:', this.state);
|
|
2400
|
+
this.notifyChange(previous);
|
|
2401
|
+
}
|
|
2402
|
+
/**
|
|
2403
|
+
* Reset consent to default (clear stored consent)
|
|
2404
|
+
*/
|
|
2405
|
+
reset() {
|
|
2406
|
+
const previous = { ...this.state };
|
|
2407
|
+
this.state = this.config.defaultConsent || { analytics: true };
|
|
2408
|
+
this.hasExplicitConsent = false;
|
|
2409
|
+
this.eventBuffer = [];
|
|
2410
|
+
clearConsent();
|
|
2411
|
+
logger.info('Consent reset to defaults');
|
|
2412
|
+
this.notifyChange(previous);
|
|
2413
|
+
}
|
|
2414
|
+
/**
|
|
2415
|
+
* Get current consent state
|
|
2416
|
+
*/
|
|
2417
|
+
getState() {
|
|
2418
|
+
return { ...this.state };
|
|
2419
|
+
}
|
|
2420
|
+
/**
|
|
2421
|
+
* Check if a specific consent category is granted
|
|
2422
|
+
*/
|
|
2423
|
+
hasConsent(category) {
|
|
2424
|
+
return this.state[category] === true;
|
|
2425
|
+
}
|
|
2426
|
+
/**
|
|
2427
|
+
* Check if analytics consent is granted (most common check)
|
|
2428
|
+
*/
|
|
2429
|
+
canTrack() {
|
|
2430
|
+
// If waiting for consent and no explicit consent given, cannot track
|
|
2431
|
+
if (this.config.waitForConsent && !this.hasExplicitConsent) {
|
|
2432
|
+
return false;
|
|
2433
|
+
}
|
|
2434
|
+
return this.state.analytics === true;
|
|
2435
|
+
}
|
|
2436
|
+
/**
|
|
2437
|
+
* Check if explicit consent has been given
|
|
2438
|
+
*/
|
|
2439
|
+
hasExplicit() {
|
|
2440
|
+
return this.hasExplicitConsent;
|
|
2441
|
+
}
|
|
2442
|
+
/**
|
|
2443
|
+
* Check if there's stored consent
|
|
2444
|
+
*/
|
|
2445
|
+
hasStored() {
|
|
2446
|
+
return hasStoredConsent();
|
|
2447
|
+
}
|
|
2448
|
+
/**
|
|
2449
|
+
* Buffer an event (for waitForConsent mode)
|
|
2450
|
+
*/
|
|
2451
|
+
bufferEvent(event) {
|
|
2452
|
+
// Prevent unbounded buffer growth
|
|
2453
|
+
if (this.eventBuffer.length >= MAX_BUFFER_SIZE) {
|
|
2454
|
+
logger.warn('Consent event buffer full, dropping oldest event');
|
|
2455
|
+
this.eventBuffer.shift();
|
|
2456
|
+
}
|
|
2457
|
+
this.eventBuffer.push(event);
|
|
2458
|
+
logger.debug('Event buffered (waiting for consent):', event.eventName);
|
|
2459
|
+
}
|
|
2460
|
+
/**
|
|
2461
|
+
* Get and clear buffered events
|
|
2462
|
+
*/
|
|
2463
|
+
flushBuffer() {
|
|
2464
|
+
const events = [...this.eventBuffer];
|
|
2465
|
+
this.eventBuffer = [];
|
|
2466
|
+
if (events.length > 0) {
|
|
2467
|
+
logger.debug(`Flushing ${events.length} buffered events`);
|
|
2468
|
+
}
|
|
2469
|
+
return events;
|
|
2470
|
+
}
|
|
2471
|
+
/**
|
|
2472
|
+
* Get buffered event count
|
|
2473
|
+
*/
|
|
2474
|
+
getBufferSize() {
|
|
2475
|
+
return this.eventBuffer.length;
|
|
2476
|
+
}
|
|
2477
|
+
/**
|
|
2478
|
+
* Register a consent change callback
|
|
2479
|
+
*/
|
|
2480
|
+
onChange(callback) {
|
|
2481
|
+
this.callbacks.push(callback);
|
|
2482
|
+
// Return unsubscribe function
|
|
2483
|
+
return () => {
|
|
2484
|
+
const index = this.callbacks.indexOf(callback);
|
|
2485
|
+
if (index > -1) {
|
|
2486
|
+
this.callbacks.splice(index, 1);
|
|
2487
|
+
}
|
|
2488
|
+
};
|
|
2489
|
+
}
|
|
2490
|
+
/**
|
|
2491
|
+
* Notify all callbacks of consent change
|
|
2492
|
+
*/
|
|
2493
|
+
notifyChange(previous) {
|
|
2494
|
+
for (const callback of this.callbacks) {
|
|
2495
|
+
try {
|
|
2496
|
+
callback(this.state, previous);
|
|
2497
|
+
}
|
|
2498
|
+
catch (error) {
|
|
2499
|
+
logger.error('Consent change callback error:', error);
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
/**
|
|
2506
|
+
* Clianta SDK - Event Triggers Manager
|
|
2507
|
+
* Manages event-driven automation and email notifications
|
|
2508
|
+
*/
|
|
2509
|
+
/**
|
|
2510
|
+
* Event Triggers Manager
|
|
2511
|
+
* Handles event-driven automation based on CRM actions
|
|
2512
|
+
*
|
|
2513
|
+
* Similar to:
|
|
2514
|
+
* - Salesforce: Process Builder, Flow Automation
|
|
2515
|
+
* - HubSpot: Workflows, Email Sequences
|
|
2516
|
+
* - Pipedrive: Workflow Automation
|
|
2517
|
+
*/
|
|
2518
|
+
class EventTriggersManager {
|
|
2519
|
+
constructor(apiEndpoint, workspaceId, authToken) {
|
|
2520
|
+
this.triggers = new Map();
|
|
2521
|
+
this.listeners = new Map();
|
|
2522
|
+
this.apiEndpoint = apiEndpoint;
|
|
2523
|
+
this.workspaceId = workspaceId;
|
|
2524
|
+
this.authToken = authToken;
|
|
2525
|
+
}
|
|
2526
|
+
/**
|
|
2527
|
+
* Set authentication token
|
|
2528
|
+
*/
|
|
2529
|
+
setAuthToken(token) {
|
|
2530
|
+
this.authToken = token;
|
|
2531
|
+
}
|
|
2532
|
+
/**
|
|
2533
|
+
* Make authenticated API request
|
|
2534
|
+
*/
|
|
2535
|
+
async request(endpoint, options = {}) {
|
|
2536
|
+
const url = `${this.apiEndpoint}${endpoint}`;
|
|
2537
|
+
const headers = {
|
|
2538
|
+
'Content-Type': 'application/json',
|
|
2539
|
+
...(options.headers || {}),
|
|
2540
|
+
};
|
|
2541
|
+
if (this.authToken) {
|
|
2542
|
+
headers['Authorization'] = `Bearer ${this.authToken}`;
|
|
2543
|
+
}
|
|
2544
|
+
try {
|
|
2545
|
+
const response = await fetch(url, {
|
|
2546
|
+
...options,
|
|
2547
|
+
headers,
|
|
2548
|
+
});
|
|
2549
|
+
const data = await response.json();
|
|
2550
|
+
if (!response.ok) {
|
|
2551
|
+
return {
|
|
2552
|
+
success: false,
|
|
2553
|
+
error: data.message || 'Request failed',
|
|
2554
|
+
status: response.status,
|
|
2555
|
+
};
|
|
2556
|
+
}
|
|
2557
|
+
return {
|
|
2558
|
+
success: true,
|
|
2559
|
+
data: data.data || data,
|
|
2560
|
+
status: response.status,
|
|
2561
|
+
};
|
|
2562
|
+
}
|
|
2563
|
+
catch (error) {
|
|
2564
|
+
return {
|
|
2565
|
+
success: false,
|
|
2566
|
+
error: error instanceof Error ? error.message : 'Network error',
|
|
2567
|
+
status: 0,
|
|
2568
|
+
};
|
|
2569
|
+
}
|
|
2570
|
+
}
|
|
2571
|
+
// ============================================
|
|
2572
|
+
// TRIGGER MANAGEMENT
|
|
2573
|
+
// ============================================
|
|
2574
|
+
/**
|
|
2575
|
+
* Get all event triggers
|
|
2576
|
+
*/
|
|
2577
|
+
async getTriggers() {
|
|
2578
|
+
return this.request(`/api/workspaces/${this.workspaceId}/triggers`);
|
|
2579
|
+
}
|
|
2580
|
+
/**
|
|
2581
|
+
* Get a single trigger by ID
|
|
2582
|
+
*/
|
|
2583
|
+
async getTrigger(triggerId) {
|
|
2584
|
+
return this.request(`/api/workspaces/${this.workspaceId}/triggers/${triggerId}`);
|
|
2585
|
+
}
|
|
2586
|
+
/**
|
|
2587
|
+
* Create a new event trigger
|
|
2588
|
+
*/
|
|
2589
|
+
async createTrigger(trigger) {
|
|
2590
|
+
const result = await this.request(`/api/workspaces/${this.workspaceId}/triggers`, {
|
|
2591
|
+
method: 'POST',
|
|
2592
|
+
body: JSON.stringify(trigger),
|
|
2593
|
+
});
|
|
2594
|
+
// Cache the trigger locally if successful
|
|
2595
|
+
if (result.success && result.data?._id) {
|
|
2596
|
+
this.triggers.set(result.data._id, result.data);
|
|
2597
|
+
}
|
|
2598
|
+
return result;
|
|
2599
|
+
}
|
|
2600
|
+
/**
|
|
2601
|
+
* Update an existing trigger
|
|
2602
|
+
*/
|
|
2603
|
+
async updateTrigger(triggerId, updates) {
|
|
2604
|
+
const result = await this.request(`/api/workspaces/${this.workspaceId}/triggers/${triggerId}`, {
|
|
2605
|
+
method: 'PUT',
|
|
2606
|
+
body: JSON.stringify(updates),
|
|
2607
|
+
});
|
|
2608
|
+
// Update cache if successful
|
|
2609
|
+
if (result.success && result.data?._id) {
|
|
2610
|
+
this.triggers.set(result.data._id, result.data);
|
|
2611
|
+
}
|
|
2612
|
+
return result;
|
|
2613
|
+
}
|
|
2614
|
+
/**
|
|
2615
|
+
* Delete a trigger
|
|
2616
|
+
*/
|
|
2617
|
+
async deleteTrigger(triggerId) {
|
|
2618
|
+
const result = await this.request(`/api/workspaces/${this.workspaceId}/triggers/${triggerId}`, {
|
|
2619
|
+
method: 'DELETE',
|
|
2620
|
+
});
|
|
2621
|
+
// Remove from cache if successful
|
|
2622
|
+
if (result.success) {
|
|
2623
|
+
this.triggers.delete(triggerId);
|
|
2624
|
+
}
|
|
2625
|
+
return result;
|
|
2626
|
+
}
|
|
2627
|
+
/**
|
|
2628
|
+
* Activate a trigger
|
|
2629
|
+
*/
|
|
2630
|
+
async activateTrigger(triggerId) {
|
|
2631
|
+
return this.updateTrigger(triggerId, { isActive: true });
|
|
2632
|
+
}
|
|
2633
|
+
/**
|
|
2634
|
+
* Deactivate a trigger
|
|
2635
|
+
*/
|
|
2636
|
+
async deactivateTrigger(triggerId) {
|
|
2637
|
+
return this.updateTrigger(triggerId, { isActive: false });
|
|
2638
|
+
}
|
|
2639
|
+
// ============================================
|
|
2640
|
+
// EVENT HANDLING (CLIENT-SIDE)
|
|
2641
|
+
// ============================================
|
|
2642
|
+
/**
|
|
2643
|
+
* Register a local event listener for client-side triggers
|
|
2644
|
+
* This allows immediate client-side reactions to events
|
|
2645
|
+
*/
|
|
2646
|
+
on(eventType, callback) {
|
|
2647
|
+
if (!this.listeners.has(eventType)) {
|
|
2648
|
+
this.listeners.set(eventType, new Set());
|
|
2649
|
+
}
|
|
2650
|
+
this.listeners.get(eventType).add(callback);
|
|
2651
|
+
logger.debug(`Event listener registered: ${eventType}`);
|
|
2652
|
+
}
|
|
2653
|
+
/**
|
|
2654
|
+
* Remove an event listener
|
|
2655
|
+
*/
|
|
2656
|
+
off(eventType, callback) {
|
|
2657
|
+
const listeners = this.listeners.get(eventType);
|
|
2658
|
+
if (listeners) {
|
|
2659
|
+
listeners.delete(callback);
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
/**
|
|
2663
|
+
* Emit an event (client-side only)
|
|
2664
|
+
* This will trigger any registered local listeners
|
|
2665
|
+
*/
|
|
2666
|
+
emit(eventType, data) {
|
|
2667
|
+
logger.debug(`Event emitted: ${eventType}`, data);
|
|
2668
|
+
const listeners = this.listeners.get(eventType);
|
|
2669
|
+
if (listeners) {
|
|
2670
|
+
listeners.forEach(callback => {
|
|
2671
|
+
try {
|
|
2672
|
+
callback(data);
|
|
2673
|
+
}
|
|
2674
|
+
catch (error) {
|
|
2675
|
+
logger.error(`Error in event listener for ${eventType}:`, error);
|
|
2676
|
+
}
|
|
2677
|
+
});
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
/**
|
|
2681
|
+
* Check if conditions are met for a trigger
|
|
2682
|
+
* Supports dynamic field evaluation including custom fields and nested paths
|
|
2683
|
+
*/
|
|
2684
|
+
evaluateConditions(conditions, data) {
|
|
2685
|
+
if (!conditions || conditions.length === 0) {
|
|
2686
|
+
return true; // No conditions means always fire
|
|
2687
|
+
}
|
|
2688
|
+
return conditions.every(condition => {
|
|
2689
|
+
// Support dot notation for nested fields (e.g., 'customFields.industry')
|
|
2690
|
+
const fieldValue = condition.field.includes('.')
|
|
2691
|
+
? this.getNestedValue(data, condition.field)
|
|
2692
|
+
: data[condition.field];
|
|
2693
|
+
const targetValue = condition.value;
|
|
2694
|
+
switch (condition.operator) {
|
|
2695
|
+
case 'equals':
|
|
2696
|
+
return fieldValue === targetValue;
|
|
2697
|
+
case 'not_equals':
|
|
2698
|
+
return fieldValue !== targetValue;
|
|
2699
|
+
case 'contains':
|
|
2700
|
+
return String(fieldValue).includes(String(targetValue));
|
|
2701
|
+
case 'greater_than':
|
|
2702
|
+
return Number(fieldValue) > Number(targetValue);
|
|
2703
|
+
case 'less_than':
|
|
2704
|
+
return Number(fieldValue) < Number(targetValue);
|
|
2705
|
+
case 'in':
|
|
2706
|
+
return Array.isArray(targetValue) && targetValue.includes(fieldValue);
|
|
2707
|
+
case 'not_in':
|
|
2708
|
+
return Array.isArray(targetValue) && !targetValue.includes(fieldValue);
|
|
2709
|
+
default:
|
|
2710
|
+
return false;
|
|
2711
|
+
}
|
|
2712
|
+
});
|
|
2713
|
+
}
|
|
2714
|
+
/**
|
|
2715
|
+
* Execute actions for a triggered event (client-side preview)
|
|
2716
|
+
* Note: Actual execution happens on the backend
|
|
2717
|
+
*/
|
|
2718
|
+
async executeActions(trigger, data) {
|
|
2719
|
+
logger.info(`Executing actions for trigger: ${trigger.name}`);
|
|
2720
|
+
for (const action of trigger.actions) {
|
|
2721
|
+
try {
|
|
2722
|
+
await this.executeAction(action, data);
|
|
2723
|
+
}
|
|
2724
|
+
catch (error) {
|
|
2725
|
+
logger.error(`Failed to execute action:`, error);
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
/**
|
|
2730
|
+
* Execute a single action
|
|
2731
|
+
*/
|
|
2732
|
+
async executeAction(action, data) {
|
|
2733
|
+
switch (action.type) {
|
|
2734
|
+
case 'send_email':
|
|
2735
|
+
await this.executeSendEmail(action, data);
|
|
2736
|
+
break;
|
|
2737
|
+
case 'webhook':
|
|
2738
|
+
await this.executeWebhook(action, data);
|
|
2739
|
+
break;
|
|
2740
|
+
case 'create_task':
|
|
2741
|
+
await this.executeCreateTask(action, data);
|
|
2742
|
+
break;
|
|
2743
|
+
case 'update_contact':
|
|
2744
|
+
await this.executeUpdateContact(action, data);
|
|
2745
|
+
break;
|
|
2746
|
+
default:
|
|
2747
|
+
logger.warn(`Unknown action type:`, action);
|
|
2748
|
+
}
|
|
2749
|
+
}
|
|
2750
|
+
/**
|
|
2751
|
+
* Execute send email action (via backend API)
|
|
2752
|
+
*/
|
|
2753
|
+
async executeSendEmail(action, data) {
|
|
2754
|
+
logger.debug('Sending email:', action);
|
|
2755
|
+
const payload = {
|
|
2756
|
+
to: this.replaceVariables(action.to, data),
|
|
2757
|
+
subject: action.subject ? this.replaceVariables(action.subject, data) : undefined,
|
|
2758
|
+
body: action.body ? this.replaceVariables(action.body, data) : undefined,
|
|
2759
|
+
templateId: action.templateId,
|
|
2760
|
+
cc: action.cc,
|
|
2761
|
+
bcc: action.bcc,
|
|
2762
|
+
from: action.from,
|
|
2763
|
+
delayMinutes: action.delayMinutes,
|
|
2764
|
+
};
|
|
2765
|
+
await this.request(`/api/workspaces/${this.workspaceId}/emails/send`, {
|
|
2766
|
+
method: 'POST',
|
|
2767
|
+
body: JSON.stringify(payload),
|
|
2768
|
+
});
|
|
2769
|
+
}
|
|
2770
|
+
/**
|
|
2771
|
+
* Execute webhook action
|
|
2772
|
+
*/
|
|
2773
|
+
async executeWebhook(action, data) {
|
|
2774
|
+
logger.debug('Calling webhook:', action.url);
|
|
2775
|
+
const body = action.body ? this.replaceVariables(action.body, data) : JSON.stringify(data);
|
|
2776
|
+
await fetch(action.url, {
|
|
2777
|
+
method: action.method,
|
|
2778
|
+
headers: {
|
|
2779
|
+
'Content-Type': 'application/json',
|
|
2780
|
+
...action.headers,
|
|
2781
|
+
},
|
|
2782
|
+
body,
|
|
2783
|
+
});
|
|
2784
|
+
}
|
|
2785
|
+
/**
|
|
2786
|
+
* Execute create task action
|
|
2787
|
+
*/
|
|
2788
|
+
async executeCreateTask(action, data) {
|
|
2789
|
+
logger.debug('Creating task:', action.title);
|
|
2790
|
+
const dueDate = action.dueDays
|
|
2791
|
+
? new Date(Date.now() + action.dueDays * 24 * 60 * 60 * 1000).toISOString()
|
|
2792
|
+
: undefined;
|
|
2793
|
+
await this.request(`/api/workspaces/${this.workspaceId}/tasks`, {
|
|
2794
|
+
method: 'POST',
|
|
2795
|
+
body: JSON.stringify({
|
|
2796
|
+
title: this.replaceVariables(action.title, data),
|
|
2797
|
+
description: action.description ? this.replaceVariables(action.description, data) : undefined,
|
|
2798
|
+
priority: action.priority,
|
|
2799
|
+
dueDate,
|
|
2800
|
+
assignedTo: action.assignedTo,
|
|
2801
|
+
relatedContactId: typeof data.contactId === 'string' ? data.contactId : undefined,
|
|
2802
|
+
}),
|
|
2803
|
+
});
|
|
2804
|
+
}
|
|
2805
|
+
/**
|
|
2806
|
+
* Execute update contact action
|
|
2807
|
+
*/
|
|
2808
|
+
async executeUpdateContact(action, data) {
|
|
2809
|
+
const contactId = data.contactId || data._id;
|
|
2810
|
+
if (!contactId) {
|
|
2811
|
+
logger.warn('Cannot update contact: no contactId in data');
|
|
2812
|
+
return;
|
|
2813
|
+
}
|
|
2814
|
+
logger.debug('Updating contact:', contactId);
|
|
2815
|
+
await this.request(`/api/workspaces/${this.workspaceId}/contacts/${contactId}`, {
|
|
2816
|
+
method: 'PUT',
|
|
2817
|
+
body: JSON.stringify(action.updates),
|
|
2818
|
+
});
|
|
2819
|
+
}
|
|
2820
|
+
/**
|
|
2821
|
+
* Replace variables in a string template
|
|
2822
|
+
* Supports syntax like {{contact.email}}, {{opportunity.value}}
|
|
2823
|
+
*/
|
|
2824
|
+
replaceVariables(template, data) {
|
|
2825
|
+
return template.replace(/\{\{([^}]+)\}\}/g, (match, path) => {
|
|
2826
|
+
const value = this.getNestedValue(data, path.trim());
|
|
2827
|
+
return value !== undefined ? String(value) : match;
|
|
2828
|
+
});
|
|
2829
|
+
}
|
|
2830
|
+
/**
|
|
2831
|
+
* Get nested value from object using dot notation
|
|
2832
|
+
* Supports dynamic field access including custom fields
|
|
2833
|
+
*/
|
|
2834
|
+
getNestedValue(obj, path) {
|
|
2835
|
+
return path.split('.').reduce((current, key) => {
|
|
2836
|
+
return current !== null && current !== undefined && typeof current === 'object'
|
|
2837
|
+
? current[key]
|
|
2838
|
+
: undefined;
|
|
2839
|
+
}, obj);
|
|
2840
|
+
}
|
|
2841
|
+
/**
|
|
2842
|
+
* Extract all available field paths from a data object
|
|
2843
|
+
* Useful for dynamic field discovery based on platform-specific attributes
|
|
2844
|
+
* @param obj - The data object to extract fields from
|
|
2845
|
+
* @param prefix - Internal use for nested paths
|
|
2846
|
+
* @param maxDepth - Maximum depth to traverse (default: 3)
|
|
2847
|
+
* @returns Array of field paths (e.g., ['email', 'contact.firstName', 'customFields.industry'])
|
|
2848
|
+
*/
|
|
2849
|
+
extractAvailableFields(obj, prefix = '', maxDepth = 3) {
|
|
2850
|
+
if (maxDepth <= 0)
|
|
2851
|
+
return [];
|
|
2852
|
+
const fields = [];
|
|
2853
|
+
for (const key in obj) {
|
|
2854
|
+
if (!obj.hasOwnProperty(key))
|
|
2855
|
+
continue;
|
|
2856
|
+
const value = obj[key];
|
|
2857
|
+
const fieldPath = prefix ? `${prefix}.${key}` : key;
|
|
2858
|
+
fields.push(fieldPath);
|
|
2859
|
+
// Recursively traverse nested objects
|
|
2860
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
2861
|
+
const nestedFields = this.extractAvailableFields(value, fieldPath, maxDepth - 1);
|
|
2862
|
+
fields.push(...nestedFields);
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2865
|
+
return fields;
|
|
2866
|
+
}
|
|
2867
|
+
/**
|
|
2868
|
+
* Get available fields from sample data
|
|
2869
|
+
* Helps with dynamic field detection for platform-specific attributes
|
|
2870
|
+
* @param sampleData - Sample data object to analyze
|
|
2871
|
+
* @returns Array of available field paths
|
|
2872
|
+
*/
|
|
2873
|
+
getAvailableFields(sampleData) {
|
|
2874
|
+
return this.extractAvailableFields(sampleData);
|
|
2875
|
+
}
|
|
2876
|
+
// ============================================
|
|
2877
|
+
// HELPER METHODS FOR COMMON PATTERNS
|
|
2878
|
+
// ============================================
|
|
2879
|
+
/**
|
|
2880
|
+
* Create a simple email trigger
|
|
2881
|
+
* Helper method for common use case
|
|
2882
|
+
*/
|
|
2883
|
+
async createEmailTrigger(config) {
|
|
2884
|
+
return this.createTrigger({
|
|
2885
|
+
name: config.name,
|
|
2886
|
+
eventType: config.eventType,
|
|
2887
|
+
conditions: config.conditions,
|
|
2888
|
+
actions: [
|
|
2889
|
+
{
|
|
2890
|
+
type: 'send_email',
|
|
2891
|
+
to: config.to,
|
|
2892
|
+
subject: config.subject,
|
|
2893
|
+
body: config.body,
|
|
2894
|
+
},
|
|
2895
|
+
],
|
|
2896
|
+
isActive: true,
|
|
2897
|
+
});
|
|
2898
|
+
}
|
|
2899
|
+
/**
|
|
2900
|
+
* Create a task creation trigger
|
|
2901
|
+
*/
|
|
2902
|
+
async createTaskTrigger(config) {
|
|
2903
|
+
return this.createTrigger({
|
|
2904
|
+
name: config.name,
|
|
2905
|
+
eventType: config.eventType,
|
|
2906
|
+
conditions: config.conditions,
|
|
2907
|
+
actions: [
|
|
2908
|
+
{
|
|
2909
|
+
type: 'create_task',
|
|
2910
|
+
title: config.taskTitle,
|
|
2911
|
+
description: config.taskDescription,
|
|
2912
|
+
priority: config.priority,
|
|
2913
|
+
dueDays: config.dueDays,
|
|
2914
|
+
},
|
|
2915
|
+
],
|
|
2916
|
+
isActive: true,
|
|
2917
|
+
});
|
|
2918
|
+
}
|
|
2919
|
+
/**
|
|
2920
|
+
* Create a webhook trigger
|
|
2921
|
+
*/
|
|
2922
|
+
async createWebhookTrigger(config) {
|
|
2923
|
+
return this.createTrigger({
|
|
2924
|
+
name: config.name,
|
|
2925
|
+
eventType: config.eventType,
|
|
2926
|
+
conditions: config.conditions,
|
|
2927
|
+
actions: [
|
|
2928
|
+
{
|
|
2929
|
+
type: 'webhook',
|
|
2930
|
+
url: config.webhookUrl,
|
|
2931
|
+
method: config.method || 'POST',
|
|
2932
|
+
},
|
|
2933
|
+
],
|
|
2934
|
+
isActive: true,
|
|
2935
|
+
});
|
|
2936
|
+
}
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
/**
|
|
2940
|
+
* Clianta SDK - CRM API Client
|
|
2941
|
+
* @see SDK_VERSION in core/config.ts
|
|
2942
|
+
*/
|
|
2943
|
+
/**
|
|
2944
|
+
* CRM API Client for managing contacts and opportunities
|
|
2945
|
+
*/
|
|
2946
|
+
class CRMClient {
|
|
2947
|
+
constructor(apiEndpoint, workspaceId, authToken, apiKey) {
|
|
2948
|
+
this.apiEndpoint = apiEndpoint;
|
|
2949
|
+
this.workspaceId = workspaceId;
|
|
2950
|
+
this.authToken = authToken;
|
|
2951
|
+
this.apiKey = apiKey;
|
|
2952
|
+
this.triggers = new EventTriggersManager(apiEndpoint, workspaceId, authToken);
|
|
2953
|
+
}
|
|
2954
|
+
/**
|
|
2955
|
+
* Set authentication token for API requests (user JWT)
|
|
2956
|
+
*/
|
|
2957
|
+
setAuthToken(token) {
|
|
2958
|
+
this.authToken = token;
|
|
2959
|
+
this.apiKey = undefined;
|
|
2960
|
+
this.triggers.setAuthToken(token);
|
|
2961
|
+
}
|
|
2962
|
+
/**
|
|
2963
|
+
* Set workspace API key for server-to-server requests.
|
|
2964
|
+
* Use this instead of setAuthToken when integrating from an external app.
|
|
2965
|
+
*/
|
|
2966
|
+
setApiKey(key) {
|
|
2967
|
+
this.apiKey = key;
|
|
2968
|
+
this.authToken = undefined;
|
|
2969
|
+
}
|
|
2970
|
+
/**
|
|
2971
|
+
* Validate required parameter exists
|
|
2972
|
+
* @throws {Error} if value is null/undefined or empty string
|
|
2973
|
+
*/
|
|
2974
|
+
validateRequired(param, value, methodName) {
|
|
2975
|
+
if (value === null || value === undefined || value === '') {
|
|
2976
|
+
throw new Error(`[CRMClient.${methodName}] ${param} is required`);
|
|
2977
|
+
}
|
|
2978
|
+
}
|
|
2979
|
+
/**
|
|
2980
|
+
* Make authenticated API request
|
|
2981
|
+
*/
|
|
2982
|
+
async request(endpoint, options = {}) {
|
|
2983
|
+
const url = `${this.apiEndpoint}${endpoint}`;
|
|
2984
|
+
const headers = {
|
|
2985
|
+
'Content-Type': 'application/json',
|
|
2986
|
+
...(options.headers || {}),
|
|
2987
|
+
};
|
|
2988
|
+
if (this.apiKey) {
|
|
2989
|
+
headers['X-Api-Key'] = this.apiKey;
|
|
2990
|
+
}
|
|
2991
|
+
else if (this.authToken) {
|
|
2992
|
+
headers['Authorization'] = `Bearer ${this.authToken}`;
|
|
2993
|
+
}
|
|
2994
|
+
try {
|
|
2995
|
+
const response = await fetch(url, {
|
|
2996
|
+
...options,
|
|
2997
|
+
headers,
|
|
2998
|
+
});
|
|
2999
|
+
const data = await response.json();
|
|
3000
|
+
if (!response.ok) {
|
|
3001
|
+
return {
|
|
3002
|
+
success: false,
|
|
3003
|
+
error: data.message || 'Request failed',
|
|
3004
|
+
status: response.status,
|
|
3005
|
+
};
|
|
3006
|
+
}
|
|
3007
|
+
return {
|
|
3008
|
+
success: true,
|
|
3009
|
+
data: data.data || data,
|
|
3010
|
+
status: response.status,
|
|
3011
|
+
};
|
|
3012
|
+
}
|
|
3013
|
+
catch (error) {
|
|
3014
|
+
return {
|
|
3015
|
+
success: false,
|
|
3016
|
+
error: error instanceof Error ? error.message : 'Network error',
|
|
3017
|
+
status: 0,
|
|
3018
|
+
};
|
|
3019
|
+
}
|
|
3020
|
+
}
|
|
3021
|
+
// ============================================
|
|
3022
|
+
// INBOUND EVENTS API (API-key authenticated)
|
|
3023
|
+
// ============================================
|
|
3024
|
+
/**
|
|
3025
|
+
* Send an inbound event from an external app (e.g. user signup on client website).
|
|
3026
|
+
* Requires the client to be initialized with an API key via setApiKey() or the constructor.
|
|
3027
|
+
*
|
|
3028
|
+
* The contact is upserted in the CRM and matching workflow automations fire automatically.
|
|
3029
|
+
*
|
|
3030
|
+
* @example
|
|
3031
|
+
* const crm = new CRMClient('https://api.clianta.online', 'WORKSPACE_ID');
|
|
3032
|
+
* crm.setApiKey('mm_live_...');
|
|
3033
|
+
*
|
|
3034
|
+
* await crm.sendEvent({
|
|
3035
|
+
* event: 'user.registered',
|
|
3036
|
+
* contact: { email: 'alice@example.com', firstName: 'Alice' },
|
|
3037
|
+
* data: { plan: 'free', signupSource: 'homepage' },
|
|
3038
|
+
* });
|
|
3039
|
+
*/
|
|
3040
|
+
async sendEvent(payload) {
|
|
3041
|
+
const url = `${this.apiEndpoint}/api/public/events`;
|
|
3042
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
3043
|
+
if (this.apiKey) {
|
|
3044
|
+
headers['X-Api-Key'] = this.apiKey;
|
|
3045
|
+
}
|
|
3046
|
+
else if (this.authToken) {
|
|
3047
|
+
headers['Authorization'] = `Bearer ${this.authToken}`;
|
|
3048
|
+
}
|
|
3049
|
+
try {
|
|
3050
|
+
const response = await fetch(url, {
|
|
3051
|
+
method: 'POST',
|
|
3052
|
+
headers,
|
|
3053
|
+
body: JSON.stringify(payload),
|
|
3054
|
+
});
|
|
3055
|
+
const data = await response.json();
|
|
3056
|
+
if (!response.ok) {
|
|
3057
|
+
return {
|
|
3058
|
+
success: false,
|
|
3059
|
+
contactCreated: false,
|
|
3060
|
+
event: payload.event,
|
|
3061
|
+
error: data.error || 'Request failed',
|
|
3062
|
+
};
|
|
3063
|
+
}
|
|
3064
|
+
return {
|
|
3065
|
+
success: data.success,
|
|
3066
|
+
contactCreated: data.contactCreated,
|
|
3067
|
+
contactId: data.contactId,
|
|
3068
|
+
event: data.event,
|
|
3069
|
+
};
|
|
3070
|
+
}
|
|
3071
|
+
catch (error) {
|
|
3072
|
+
return {
|
|
3073
|
+
success: false,
|
|
3074
|
+
contactCreated: false,
|
|
3075
|
+
event: payload.event,
|
|
3076
|
+
error: error instanceof Error ? error.message : 'Network error',
|
|
3077
|
+
};
|
|
3078
|
+
}
|
|
3079
|
+
}
|
|
3080
|
+
// ============================================
|
|
3081
|
+
// CONTACTS API
|
|
3082
|
+
// ============================================
|
|
3083
|
+
/**
|
|
3084
|
+
* Get all contacts with pagination
|
|
3085
|
+
*/
|
|
3086
|
+
async getContacts(params) {
|
|
3087
|
+
const queryParams = new URLSearchParams();
|
|
3088
|
+
if (params?.page)
|
|
3089
|
+
queryParams.set('page', params.page.toString());
|
|
3090
|
+
if (params?.limit)
|
|
3091
|
+
queryParams.set('limit', params.limit.toString());
|
|
3092
|
+
if (params?.search)
|
|
3093
|
+
queryParams.set('search', params.search);
|
|
3094
|
+
if (params?.status)
|
|
3095
|
+
queryParams.set('status', params.status);
|
|
3096
|
+
const query = queryParams.toString();
|
|
3097
|
+
const endpoint = `/api/workspaces/${this.workspaceId}/contacts${query ? `?${query}` : ''}`;
|
|
3098
|
+
return this.request(endpoint);
|
|
3099
|
+
}
|
|
3100
|
+
/**
|
|
3101
|
+
* Get a single contact by ID
|
|
3102
|
+
*/
|
|
3103
|
+
async getContact(contactId) {
|
|
3104
|
+
this.validateRequired('contactId', contactId, 'getContact');
|
|
3105
|
+
return this.request(`/api/workspaces/${this.workspaceId}/contacts/${contactId}`);
|
|
3106
|
+
}
|
|
3107
|
+
/**
|
|
3108
|
+
* Create a new contact
|
|
3109
|
+
*/
|
|
3110
|
+
async createContact(contact) {
|
|
3111
|
+
return this.request(`/api/workspaces/${this.workspaceId}/contacts`, {
|
|
3112
|
+
method: 'POST',
|
|
3113
|
+
body: JSON.stringify(contact),
|
|
3114
|
+
});
|
|
3115
|
+
}
|
|
3116
|
+
/**
|
|
3117
|
+
* Update an existing contact
|
|
3118
|
+
*/
|
|
3119
|
+
async updateContact(contactId, updates) {
|
|
3120
|
+
this.validateRequired('contactId', contactId, 'updateContact');
|
|
3121
|
+
return this.request(`/api/workspaces/${this.workspaceId}/contacts/${contactId}`, {
|
|
3122
|
+
method: 'PUT',
|
|
3123
|
+
body: JSON.stringify(updates),
|
|
3124
|
+
});
|
|
3125
|
+
}
|
|
3126
|
+
/**
|
|
3127
|
+
* Delete a contact
|
|
3128
|
+
*/
|
|
3129
|
+
async deleteContact(contactId) {
|
|
3130
|
+
this.validateRequired('contactId', contactId, 'deleteContact');
|
|
3131
|
+
return this.request(`/api/workspaces/${this.workspaceId}/contacts/${contactId}`, {
|
|
3132
|
+
method: 'DELETE',
|
|
3133
|
+
});
|
|
3134
|
+
}
|
|
3135
|
+
// ============================================
|
|
3136
|
+
// OPPORTUNITIES API
|
|
3137
|
+
// ============================================
|
|
3138
|
+
/**
|
|
3139
|
+
* Get all opportunities with pagination
|
|
3140
|
+
*/
|
|
3141
|
+
async getOpportunities(params) {
|
|
3142
|
+
const queryParams = new URLSearchParams();
|
|
3143
|
+
if (params?.page)
|
|
3144
|
+
queryParams.set('page', params.page.toString());
|
|
3145
|
+
if (params?.limit)
|
|
3146
|
+
queryParams.set('limit', params.limit.toString());
|
|
3147
|
+
if (params?.pipelineId)
|
|
3148
|
+
queryParams.set('pipelineId', params.pipelineId);
|
|
3149
|
+
if (params?.stageId)
|
|
3150
|
+
queryParams.set('stageId', params.stageId);
|
|
3151
|
+
const query = queryParams.toString();
|
|
3152
|
+
const endpoint = `/api/workspaces/${this.workspaceId}/opportunities${query ? `?${query}` : ''}`;
|
|
3153
|
+
return this.request(endpoint);
|
|
3154
|
+
}
|
|
3155
|
+
/**
|
|
3156
|
+
* Get a single opportunity by ID
|
|
3157
|
+
*/
|
|
3158
|
+
async getOpportunity(opportunityId) {
|
|
3159
|
+
return this.request(`/api/workspaces/${this.workspaceId}/opportunities/${opportunityId}`);
|
|
3160
|
+
}
|
|
3161
|
+
/**
|
|
3162
|
+
* Create a new opportunity
|
|
3163
|
+
*/
|
|
3164
|
+
async createOpportunity(opportunity) {
|
|
3165
|
+
return this.request(`/api/workspaces/${this.workspaceId}/opportunities`, {
|
|
3166
|
+
method: 'POST',
|
|
3167
|
+
body: JSON.stringify(opportunity),
|
|
3168
|
+
});
|
|
3169
|
+
}
|
|
3170
|
+
/**
|
|
3171
|
+
* Update an existing opportunity
|
|
3172
|
+
*/
|
|
3173
|
+
async updateOpportunity(opportunityId, updates) {
|
|
3174
|
+
return this.request(`/api/workspaces/${this.workspaceId}/opportunities/${opportunityId}`, {
|
|
3175
|
+
method: 'PUT',
|
|
3176
|
+
body: JSON.stringify(updates),
|
|
3177
|
+
});
|
|
3178
|
+
}
|
|
3179
|
+
/**
|
|
3180
|
+
* Delete an opportunity
|
|
3181
|
+
*/
|
|
3182
|
+
async deleteOpportunity(opportunityId) {
|
|
3183
|
+
return this.request(`/api/workspaces/${this.workspaceId}/opportunities/${opportunityId}`, {
|
|
3184
|
+
method: 'DELETE',
|
|
3185
|
+
});
|
|
3186
|
+
}
|
|
3187
|
+
/**
|
|
3188
|
+
* Move opportunity to a different stage
|
|
3189
|
+
*/
|
|
3190
|
+
async moveOpportunity(opportunityId, stageId) {
|
|
3191
|
+
return this.request(`/api/workspaces/${this.workspaceId}/opportunities/${opportunityId}/move`, {
|
|
3192
|
+
method: 'POST',
|
|
3193
|
+
body: JSON.stringify({ stageId }),
|
|
3194
|
+
});
|
|
3195
|
+
}
|
|
3196
|
+
// ============================================
|
|
3197
|
+
// COMPANIES API
|
|
3198
|
+
// ============================================
|
|
3199
|
+
/**
|
|
3200
|
+
* Get all companies with pagination
|
|
3201
|
+
*/
|
|
3202
|
+
async getCompanies(params) {
|
|
3203
|
+
const queryParams = new URLSearchParams();
|
|
3204
|
+
if (params?.page)
|
|
3205
|
+
queryParams.set('page', params.page.toString());
|
|
3206
|
+
if (params?.limit)
|
|
3207
|
+
queryParams.set('limit', params.limit.toString());
|
|
3208
|
+
if (params?.search)
|
|
3209
|
+
queryParams.set('search', params.search);
|
|
3210
|
+
if (params?.status)
|
|
3211
|
+
queryParams.set('status', params.status);
|
|
3212
|
+
if (params?.industry)
|
|
3213
|
+
queryParams.set('industry', params.industry);
|
|
3214
|
+
const query = queryParams.toString();
|
|
3215
|
+
const endpoint = `/api/workspaces/${this.workspaceId}/companies${query ? `?${query}` : ''}`;
|
|
3216
|
+
return this.request(endpoint);
|
|
3217
|
+
}
|
|
3218
|
+
/**
|
|
3219
|
+
* Get a single company by ID
|
|
3220
|
+
*/
|
|
3221
|
+
async getCompany(companyId) {
|
|
3222
|
+
return this.request(`/api/workspaces/${this.workspaceId}/companies/${companyId}`);
|
|
3223
|
+
}
|
|
3224
|
+
/**
|
|
3225
|
+
* Create a new company
|
|
3226
|
+
*/
|
|
3227
|
+
async createCompany(company) {
|
|
3228
|
+
return this.request(`/api/workspaces/${this.workspaceId}/companies`, {
|
|
3229
|
+
method: 'POST',
|
|
3230
|
+
body: JSON.stringify(company),
|
|
3231
|
+
});
|
|
3232
|
+
}
|
|
3233
|
+
/**
|
|
3234
|
+
* Update an existing company
|
|
3235
|
+
*/
|
|
3236
|
+
async updateCompany(companyId, updates) {
|
|
3237
|
+
return this.request(`/api/workspaces/${this.workspaceId}/companies/${companyId}`, {
|
|
3238
|
+
method: 'PUT',
|
|
3239
|
+
body: JSON.stringify(updates),
|
|
3240
|
+
});
|
|
3241
|
+
}
|
|
3242
|
+
/**
|
|
3243
|
+
* Delete a company
|
|
3244
|
+
*/
|
|
3245
|
+
async deleteCompany(companyId) {
|
|
3246
|
+
return this.request(`/api/workspaces/${this.workspaceId}/companies/${companyId}`, {
|
|
3247
|
+
method: 'DELETE',
|
|
3248
|
+
});
|
|
3249
|
+
}
|
|
3250
|
+
/**
|
|
3251
|
+
* Get contacts belonging to a company
|
|
3252
|
+
*/
|
|
3253
|
+
async getCompanyContacts(companyId, params) {
|
|
3254
|
+
const queryParams = new URLSearchParams();
|
|
3255
|
+
if (params?.page)
|
|
3256
|
+
queryParams.set('page', params.page.toString());
|
|
3257
|
+
if (params?.limit)
|
|
3258
|
+
queryParams.set('limit', params.limit.toString());
|
|
3259
|
+
const query = queryParams.toString();
|
|
3260
|
+
const endpoint = `/api/workspaces/${this.workspaceId}/companies/${companyId}/contacts${query ? `?${query}` : ''}`;
|
|
3261
|
+
return this.request(endpoint);
|
|
3262
|
+
}
|
|
3263
|
+
/**
|
|
3264
|
+
* Get deals/opportunities belonging to a company
|
|
3265
|
+
*/
|
|
3266
|
+
async getCompanyDeals(companyId, params) {
|
|
3267
|
+
const queryParams = new URLSearchParams();
|
|
3268
|
+
if (params?.page)
|
|
3269
|
+
queryParams.set('page', params.page.toString());
|
|
3270
|
+
if (params?.limit)
|
|
3271
|
+
queryParams.set('limit', params.limit.toString());
|
|
3272
|
+
const query = queryParams.toString();
|
|
3273
|
+
const endpoint = `/api/workspaces/${this.workspaceId}/companies/${companyId}/deals${query ? `?${query}` : ''}`;
|
|
3274
|
+
return this.request(endpoint);
|
|
3275
|
+
}
|
|
3276
|
+
// ============================================
|
|
3277
|
+
// PIPELINES API
|
|
3278
|
+
// ============================================
|
|
3279
|
+
/**
|
|
3280
|
+
* Get all pipelines
|
|
3281
|
+
*/
|
|
3282
|
+
async getPipelines() {
|
|
3283
|
+
return this.request(`/api/workspaces/${this.workspaceId}/pipelines`);
|
|
3284
|
+
}
|
|
3285
|
+
/**
|
|
3286
|
+
* Get a single pipeline by ID
|
|
3287
|
+
*/
|
|
3288
|
+
async getPipeline(pipelineId) {
|
|
3289
|
+
return this.request(`/api/workspaces/${this.workspaceId}/pipelines/${pipelineId}`);
|
|
3290
|
+
}
|
|
3291
|
+
/**
|
|
3292
|
+
* Create a new pipeline
|
|
3293
|
+
*/
|
|
3294
|
+
async createPipeline(pipeline) {
|
|
3295
|
+
return this.request(`/api/workspaces/${this.workspaceId}/pipelines`, {
|
|
3296
|
+
method: 'POST',
|
|
3297
|
+
body: JSON.stringify(pipeline),
|
|
3298
|
+
});
|
|
3299
|
+
}
|
|
3300
|
+
/**
|
|
3301
|
+
* Update an existing pipeline
|
|
3302
|
+
*/
|
|
3303
|
+
async updatePipeline(pipelineId, updates) {
|
|
3304
|
+
return this.request(`/api/workspaces/${this.workspaceId}/pipelines/${pipelineId}`, {
|
|
3305
|
+
method: 'PUT',
|
|
3306
|
+
body: JSON.stringify(updates),
|
|
3307
|
+
});
|
|
3308
|
+
}
|
|
3309
|
+
/**
|
|
3310
|
+
* Delete a pipeline
|
|
3311
|
+
*/
|
|
3312
|
+
async deletePipeline(pipelineId) {
|
|
3313
|
+
return this.request(`/api/workspaces/${this.workspaceId}/pipelines/${pipelineId}`, {
|
|
3314
|
+
method: 'DELETE',
|
|
3315
|
+
});
|
|
3316
|
+
}
|
|
3317
|
+
// ============================================
|
|
3318
|
+
// TASKS API
|
|
3319
|
+
// ============================================
|
|
3320
|
+
/**
|
|
3321
|
+
* Get all tasks with pagination
|
|
3322
|
+
*/
|
|
3323
|
+
async getTasks(params) {
|
|
3324
|
+
const queryParams = new URLSearchParams();
|
|
3325
|
+
if (params?.page)
|
|
3326
|
+
queryParams.set('page', params.page.toString());
|
|
3327
|
+
if (params?.limit)
|
|
3328
|
+
queryParams.set('limit', params.limit.toString());
|
|
3329
|
+
if (params?.status)
|
|
3330
|
+
queryParams.set('status', params.status);
|
|
3331
|
+
if (params?.priority)
|
|
3332
|
+
queryParams.set('priority', params.priority);
|
|
3333
|
+
if (params?.contactId)
|
|
3334
|
+
queryParams.set('contactId', params.contactId);
|
|
3335
|
+
if (params?.companyId)
|
|
3336
|
+
queryParams.set('companyId', params.companyId);
|
|
3337
|
+
if (params?.opportunityId)
|
|
3338
|
+
queryParams.set('opportunityId', params.opportunityId);
|
|
3339
|
+
const query = queryParams.toString();
|
|
3340
|
+
const endpoint = `/api/workspaces/${this.workspaceId}/tasks${query ? `?${query}` : ''}`;
|
|
3341
|
+
return this.request(endpoint);
|
|
3342
|
+
}
|
|
3343
|
+
/**
|
|
3344
|
+
* Get a single task by ID
|
|
3345
|
+
*/
|
|
3346
|
+
async getTask(taskId) {
|
|
3347
|
+
return this.request(`/api/workspaces/${this.workspaceId}/tasks/${taskId}`);
|
|
3348
|
+
}
|
|
3349
|
+
/**
|
|
3350
|
+
* Create a new task
|
|
3351
|
+
*/
|
|
3352
|
+
async createTask(task) {
|
|
3353
|
+
return this.request(`/api/workspaces/${this.workspaceId}/tasks`, {
|
|
3354
|
+
method: 'POST',
|
|
3355
|
+
body: JSON.stringify(task),
|
|
3356
|
+
});
|
|
3357
|
+
}
|
|
3358
|
+
/**
|
|
3359
|
+
* Update an existing task
|
|
3360
|
+
*/
|
|
3361
|
+
async updateTask(taskId, updates) {
|
|
3362
|
+
return this.request(`/api/workspaces/${this.workspaceId}/tasks/${taskId}`, {
|
|
3363
|
+
method: 'PUT',
|
|
3364
|
+
body: JSON.stringify(updates),
|
|
3365
|
+
});
|
|
3366
|
+
}
|
|
3367
|
+
/**
|
|
3368
|
+
* Mark a task as completed
|
|
3369
|
+
*/
|
|
3370
|
+
async completeTask(taskId) {
|
|
3371
|
+
return this.request(`/api/workspaces/${this.workspaceId}/tasks/${taskId}/complete`, {
|
|
3372
|
+
method: 'PATCH',
|
|
3373
|
+
});
|
|
3374
|
+
}
|
|
3375
|
+
/**
|
|
3376
|
+
* Delete a task
|
|
3377
|
+
*/
|
|
3378
|
+
async deleteTask(taskId) {
|
|
3379
|
+
return this.request(`/api/workspaces/${this.workspaceId}/tasks/${taskId}`, {
|
|
3380
|
+
method: 'DELETE',
|
|
3381
|
+
});
|
|
3382
|
+
}
|
|
3383
|
+
// ============================================
|
|
3384
|
+
// ACTIVITIES API
|
|
3385
|
+
// ============================================
|
|
3386
|
+
/**
|
|
3387
|
+
* Get activities for a contact
|
|
3388
|
+
*/
|
|
3389
|
+
async getContactActivities(contactId, params) {
|
|
3390
|
+
const queryParams = new URLSearchParams();
|
|
3391
|
+
if (params?.page)
|
|
3392
|
+
queryParams.set('page', params.page.toString());
|
|
3393
|
+
if (params?.limit)
|
|
3394
|
+
queryParams.set('limit', params.limit.toString());
|
|
3395
|
+
if (params?.type)
|
|
3396
|
+
queryParams.set('type', params.type);
|
|
3397
|
+
const query = queryParams.toString();
|
|
3398
|
+
const endpoint = `/api/workspaces/${this.workspaceId}/contacts/${contactId}/activities${query ? `?${query}` : ''}`;
|
|
3399
|
+
return this.request(endpoint);
|
|
3400
|
+
}
|
|
3401
|
+
/**
|
|
3402
|
+
* Get activities for an opportunity/deal
|
|
3403
|
+
*/
|
|
3404
|
+
async getOpportunityActivities(opportunityId, params) {
|
|
3405
|
+
const queryParams = new URLSearchParams();
|
|
3406
|
+
if (params?.page)
|
|
3407
|
+
queryParams.set('page', params.page.toString());
|
|
3408
|
+
if (params?.limit)
|
|
3409
|
+
queryParams.set('limit', params.limit.toString());
|
|
3410
|
+
if (params?.type)
|
|
3411
|
+
queryParams.set('type', params.type);
|
|
3412
|
+
const query = queryParams.toString();
|
|
3413
|
+
const endpoint = `/api/workspaces/${this.workspaceId}/opportunities/${opportunityId}/activities${query ? `?${query}` : ''}`;
|
|
3414
|
+
return this.request(endpoint);
|
|
3415
|
+
}
|
|
3416
|
+
/**
|
|
3417
|
+
* Create a new activity
|
|
3418
|
+
*/
|
|
3419
|
+
async createActivity(activity) {
|
|
3420
|
+
// Determine the correct endpoint based on related entity
|
|
3421
|
+
let endpoint;
|
|
3422
|
+
if (activity.opportunityId) {
|
|
3423
|
+
endpoint = `/api/workspaces/${this.workspaceId}/opportunities/${activity.opportunityId}/activities`;
|
|
3424
|
+
}
|
|
3425
|
+
else if (activity.contactId) {
|
|
3426
|
+
endpoint = `/api/workspaces/${this.workspaceId}/contacts/${activity.contactId}/activities`;
|
|
3427
|
+
}
|
|
3428
|
+
else {
|
|
3429
|
+
endpoint = `/api/workspaces/${this.workspaceId}/activities`;
|
|
3430
|
+
}
|
|
3431
|
+
return this.request(endpoint, {
|
|
3432
|
+
method: 'POST',
|
|
3433
|
+
body: JSON.stringify(activity),
|
|
3434
|
+
});
|
|
3435
|
+
}
|
|
3436
|
+
/**
|
|
3437
|
+
* Update an existing activity
|
|
3438
|
+
*/
|
|
3439
|
+
async updateActivity(activityId, updates) {
|
|
3440
|
+
return this.request(`/api/workspaces/${this.workspaceId}/activities/${activityId}`, {
|
|
3441
|
+
method: 'PATCH',
|
|
3442
|
+
body: JSON.stringify(updates),
|
|
3443
|
+
});
|
|
3444
|
+
}
|
|
3445
|
+
/**
|
|
3446
|
+
* Delete an activity
|
|
3447
|
+
*/
|
|
3448
|
+
async deleteActivity(activityId) {
|
|
3449
|
+
return this.request(`/api/workspaces/${this.workspaceId}/activities/${activityId}`, {
|
|
3450
|
+
method: 'DELETE',
|
|
3451
|
+
});
|
|
3452
|
+
}
|
|
3453
|
+
/**
|
|
3454
|
+
* Log a call activity
|
|
3455
|
+
*/
|
|
3456
|
+
async logCall(data) {
|
|
3457
|
+
return this.createActivity({
|
|
3458
|
+
type: 'call',
|
|
3459
|
+
title: `${data.direction === 'inbound' ? 'Inbound' : 'Outbound'} Call`,
|
|
3460
|
+
direction: data.direction,
|
|
3461
|
+
duration: data.duration,
|
|
3462
|
+
outcome: data.outcome,
|
|
3463
|
+
description: data.notes,
|
|
3464
|
+
contactId: data.contactId,
|
|
3465
|
+
opportunityId: data.opportunityId,
|
|
3466
|
+
});
|
|
3467
|
+
}
|
|
3468
|
+
/**
|
|
3469
|
+
* Log a meeting activity
|
|
3470
|
+
*/
|
|
3471
|
+
async logMeeting(data) {
|
|
3472
|
+
return this.createActivity({
|
|
3473
|
+
type: 'meeting',
|
|
3474
|
+
title: data.title,
|
|
3475
|
+
duration: data.duration,
|
|
3476
|
+
outcome: data.outcome,
|
|
3477
|
+
description: data.notes,
|
|
3478
|
+
contactId: data.contactId,
|
|
3479
|
+
opportunityId: data.opportunityId,
|
|
3480
|
+
});
|
|
3481
|
+
}
|
|
3482
|
+
/**
|
|
3483
|
+
* Add a note to a contact or opportunity
|
|
3484
|
+
*/
|
|
3485
|
+
async addNote(data) {
|
|
3486
|
+
return this.createActivity({
|
|
3487
|
+
type: 'note',
|
|
3488
|
+
title: 'Note',
|
|
3489
|
+
description: data.content,
|
|
3490
|
+
contactId: data.contactId,
|
|
3491
|
+
opportunityId: data.opportunityId,
|
|
3492
|
+
});
|
|
3493
|
+
}
|
|
3494
|
+
// ============================================
|
|
3495
|
+
// EMAIL TEMPLATES API
|
|
3496
|
+
// ============================================
|
|
3497
|
+
/**
|
|
3498
|
+
* Get all email templates
|
|
3499
|
+
*/
|
|
3500
|
+
async getEmailTemplates(params) {
|
|
3501
|
+
const queryParams = new URLSearchParams();
|
|
3502
|
+
if (params?.page)
|
|
3503
|
+
queryParams.set('page', params.page.toString());
|
|
3504
|
+
if (params?.limit)
|
|
3505
|
+
queryParams.set('limit', params.limit.toString());
|
|
3506
|
+
const query = queryParams.toString();
|
|
3507
|
+
const endpoint = `/api/workspaces/${this.workspaceId}/email-templates${query ? `?${query}` : ''}`;
|
|
3508
|
+
return this.request(endpoint);
|
|
3509
|
+
}
|
|
3510
|
+
/**
|
|
3511
|
+
* Get a single email template by ID
|
|
3512
|
+
*/
|
|
3513
|
+
async getEmailTemplate(templateId) {
|
|
3514
|
+
return this.request(`/api/workspaces/${this.workspaceId}/email-templates/${templateId}`);
|
|
3515
|
+
}
|
|
3516
|
+
/**
|
|
3517
|
+
* Create a new email template
|
|
3518
|
+
*/
|
|
3519
|
+
async createEmailTemplate(template) {
|
|
3520
|
+
return this.request(`/api/workspaces/${this.workspaceId}/email-templates`, {
|
|
3521
|
+
method: 'POST',
|
|
3522
|
+
body: JSON.stringify(template),
|
|
3523
|
+
});
|
|
3524
|
+
}
|
|
3525
|
+
/**
|
|
3526
|
+
* Update an email template
|
|
3527
|
+
*/
|
|
3528
|
+
async updateEmailTemplate(templateId, updates) {
|
|
3529
|
+
return this.request(`/api/workspaces/${this.workspaceId}/email-templates/${templateId}`, {
|
|
3530
|
+
method: 'PUT',
|
|
3531
|
+
body: JSON.stringify(updates),
|
|
3532
|
+
});
|
|
3533
|
+
}
|
|
3534
|
+
/**
|
|
3535
|
+
* Delete an email template
|
|
3536
|
+
*/
|
|
3537
|
+
async deleteEmailTemplate(templateId) {
|
|
3538
|
+
return this.request(`/api/workspaces/${this.workspaceId}/email-templates/${templateId}`, {
|
|
3539
|
+
method: 'DELETE',
|
|
3540
|
+
});
|
|
3541
|
+
}
|
|
3542
|
+
/**
|
|
3543
|
+
* Send an email using a template
|
|
3544
|
+
*/
|
|
3545
|
+
async sendEmail(data) {
|
|
3546
|
+
return this.request(`/api/workspaces/${this.workspaceId}/emails/send`, {
|
|
3547
|
+
method: 'POST',
|
|
3548
|
+
body: JSON.stringify(data),
|
|
3549
|
+
});
|
|
3550
|
+
}
|
|
3551
|
+
// ============================================
|
|
3552
|
+
// READ-BACK / DATA RETRIEVAL API
|
|
3553
|
+
// ============================================
|
|
3554
|
+
/**
|
|
3555
|
+
* Get a contact by email address.
|
|
3556
|
+
* Returns the first matching contact from a search query.
|
|
3557
|
+
*/
|
|
3558
|
+
async getContactByEmail(email) {
|
|
3559
|
+
this.validateRequired('email', email, 'getContactByEmail');
|
|
3560
|
+
const queryParams = new URLSearchParams({ search: email, limit: '1' });
|
|
3561
|
+
return this.request(`/api/workspaces/${this.workspaceId}/contacts?${queryParams.toString()}`);
|
|
3562
|
+
}
|
|
3563
|
+
/**
|
|
3564
|
+
* Get activity timeline for a contact
|
|
3565
|
+
*/
|
|
3566
|
+
async getContactActivity(contactId, params) {
|
|
3567
|
+
this.validateRequired('contactId', contactId, 'getContactActivity');
|
|
3568
|
+
const queryParams = new URLSearchParams();
|
|
3569
|
+
if (params?.page)
|
|
3570
|
+
queryParams.set('page', params.page.toString());
|
|
3571
|
+
if (params?.limit)
|
|
3572
|
+
queryParams.set('limit', params.limit.toString());
|
|
3573
|
+
if (params?.type)
|
|
3574
|
+
queryParams.set('type', params.type);
|
|
3575
|
+
if (params?.startDate)
|
|
3576
|
+
queryParams.set('startDate', params.startDate);
|
|
3577
|
+
if (params?.endDate)
|
|
3578
|
+
queryParams.set('endDate', params.endDate);
|
|
3579
|
+
const query = queryParams.toString();
|
|
3580
|
+
const endpoint = `/api/workspaces/${this.workspaceId}/contacts/${contactId}/activities${query ? `?${query}` : ''}`;
|
|
3581
|
+
return this.request(endpoint);
|
|
3582
|
+
}
|
|
3583
|
+
/**
|
|
3584
|
+
* Get engagement metrics for a contact (via their linked visitor data)
|
|
3585
|
+
*/
|
|
3586
|
+
async getContactEngagement(contactId) {
|
|
3587
|
+
this.validateRequired('contactId', contactId, 'getContactEngagement');
|
|
3588
|
+
return this.request(`/api/workspaces/${this.workspaceId}/contacts/${contactId}/engagement`);
|
|
3589
|
+
}
|
|
3590
|
+
/**
|
|
3591
|
+
* Get a full timeline for a contact including events, activities, and opportunities
|
|
3592
|
+
*/
|
|
3593
|
+
async getContactTimeline(contactId, params) {
|
|
3594
|
+
this.validateRequired('contactId', contactId, 'getContactTimeline');
|
|
3595
|
+
const queryParams = new URLSearchParams();
|
|
3596
|
+
if (params?.page)
|
|
3597
|
+
queryParams.set('page', params.page.toString());
|
|
3598
|
+
if (params?.limit)
|
|
3599
|
+
queryParams.set('limit', params.limit.toString());
|
|
3600
|
+
const query = queryParams.toString();
|
|
3601
|
+
const endpoint = `/api/workspaces/${this.workspaceId}/contacts/${contactId}/timeline${query ? `?${query}` : ''}`;
|
|
3602
|
+
return this.request(endpoint);
|
|
3603
|
+
}
|
|
3604
|
+
/**
|
|
3605
|
+
* Search contacts with advanced filters
|
|
3606
|
+
*/
|
|
3607
|
+
async searchContacts(query, filters) {
|
|
3608
|
+
const queryParams = new URLSearchParams();
|
|
3609
|
+
queryParams.set('search', query);
|
|
3610
|
+
if (filters?.status)
|
|
3611
|
+
queryParams.set('status', filters.status);
|
|
3612
|
+
if (filters?.lifecycleStage)
|
|
3613
|
+
queryParams.set('lifecycleStage', filters.lifecycleStage);
|
|
3614
|
+
if (filters?.source)
|
|
3615
|
+
queryParams.set('source', filters.source);
|
|
3616
|
+
if (filters?.tags)
|
|
3617
|
+
queryParams.set('tags', filters.tags.join(','));
|
|
3618
|
+
if (filters?.page)
|
|
3619
|
+
queryParams.set('page', filters.page.toString());
|
|
3620
|
+
if (filters?.limit)
|
|
3621
|
+
queryParams.set('limit', filters.limit.toString());
|
|
3622
|
+
const qs = queryParams.toString();
|
|
3623
|
+
const endpoint = `/api/workspaces/${this.workspaceId}/contacts${qs ? `?${qs}` : ''}`;
|
|
3624
|
+
return this.request(endpoint);
|
|
3625
|
+
}
|
|
3626
|
+
// ============================================
|
|
3627
|
+
// WEBHOOK MANAGEMENT API
|
|
3628
|
+
// ============================================
|
|
3629
|
+
/**
|
|
3630
|
+
* List all webhook subscriptions
|
|
3631
|
+
*/
|
|
3632
|
+
async listWebhooks(params) {
|
|
3633
|
+
const queryParams = new URLSearchParams();
|
|
3634
|
+
if (params?.page)
|
|
3635
|
+
queryParams.set('page', params.page.toString());
|
|
3636
|
+
if (params?.limit)
|
|
3637
|
+
queryParams.set('limit', params.limit.toString());
|
|
3638
|
+
const query = queryParams.toString();
|
|
3639
|
+
return this.request(`/api/workspaces/${this.workspaceId}/webhooks${query ? `?${query}` : ''}`);
|
|
3640
|
+
}
|
|
3641
|
+
/**
|
|
3642
|
+
* Create a new webhook subscription
|
|
3643
|
+
*/
|
|
3644
|
+
async createWebhook(data) {
|
|
3645
|
+
return this.request(`/api/workspaces/${this.workspaceId}/webhooks`, {
|
|
3646
|
+
method: 'POST',
|
|
3647
|
+
body: JSON.stringify(data),
|
|
3648
|
+
});
|
|
3649
|
+
}
|
|
3650
|
+
/**
|
|
3651
|
+
* Delete a webhook subscription
|
|
3652
|
+
*/
|
|
3653
|
+
async deleteWebhook(webhookId) {
|
|
3654
|
+
this.validateRequired('webhookId', webhookId, 'deleteWebhook');
|
|
3655
|
+
return this.request(`/api/workspaces/${this.workspaceId}/webhooks/${webhookId}`, {
|
|
3656
|
+
method: 'DELETE',
|
|
3657
|
+
});
|
|
3658
|
+
}
|
|
3659
|
+
// ============================================
|
|
3660
|
+
// EVENT TRIGGERS API (delegated to triggers manager)
|
|
3661
|
+
// ============================================
|
|
3662
|
+
/**
|
|
3663
|
+
* Get all event triggers
|
|
3664
|
+
*/
|
|
3665
|
+
async getEventTriggers() {
|
|
3666
|
+
return this.triggers.getTriggers();
|
|
3667
|
+
}
|
|
3668
|
+
/**
|
|
3669
|
+
* Create a new event trigger
|
|
3670
|
+
*/
|
|
3671
|
+
async createEventTrigger(trigger) {
|
|
3672
|
+
return this.triggers.createTrigger(trigger);
|
|
3673
|
+
}
|
|
3674
|
+
/**
|
|
3675
|
+
* Update an event trigger
|
|
3676
|
+
*/
|
|
3677
|
+
async updateEventTrigger(triggerId, updates) {
|
|
3678
|
+
return this.triggers.updateTrigger(triggerId, updates);
|
|
3679
|
+
}
|
|
3680
|
+
/**
|
|
3681
|
+
* Delete an event trigger
|
|
3682
|
+
*/
|
|
3683
|
+
async deleteEventTrigger(triggerId) {
|
|
3684
|
+
return this.triggers.deleteTrigger(triggerId);
|
|
3685
|
+
}
|
|
3686
|
+
}
|
|
3687
|
+
|
|
3688
|
+
/**
|
|
3689
|
+
* Clianta SDK - Main Tracker Class
|
|
3690
|
+
* @see SDK_VERSION in core/config.ts
|
|
3691
|
+
*/
|
|
3692
|
+
/**
|
|
3693
|
+
* Main Clianta Tracker Class
|
|
3694
|
+
*/
|
|
3695
|
+
class Tracker {
|
|
3696
|
+
constructor(workspaceId, userConfig = {}) {
|
|
3697
|
+
this.plugins = [];
|
|
3698
|
+
this.isInitialized = false;
|
|
3699
|
+
/** contactId after a successful identify() call */
|
|
3700
|
+
this.contactId = null;
|
|
3701
|
+
/** Pending identify retry on next flush */
|
|
3702
|
+
this.pendingIdentify = null;
|
|
3703
|
+
/** Registered event schemas for validation */
|
|
3704
|
+
this.eventSchemas = new Map();
|
|
3705
|
+
if (!workspaceId) {
|
|
3706
|
+
throw new Error('[Clianta] Workspace ID is required');
|
|
3707
|
+
}
|
|
3708
|
+
this.workspaceId = workspaceId;
|
|
3709
|
+
this.config = mergeConfig(userConfig);
|
|
3710
|
+
// Setup debug mode
|
|
3711
|
+
logger.enabled = this.config.debug;
|
|
3712
|
+
logger.info(`Initializing SDK v${SDK_VERSION}`, { workspaceId });
|
|
3713
|
+
// Initialize consent manager
|
|
3714
|
+
this.consentManager = new ConsentManager({
|
|
3715
|
+
...this.config.consent,
|
|
3716
|
+
onConsentChange: (state, previous) => {
|
|
3717
|
+
this.onConsentChange(state, previous);
|
|
3718
|
+
},
|
|
3719
|
+
});
|
|
3720
|
+
// Initialize transport and queue
|
|
3721
|
+
this.transport = new Transport({ apiEndpoint: this.config.apiEndpoint });
|
|
3722
|
+
this.queue = new EventQueue(this.transport, {
|
|
3723
|
+
batchSize: this.config.batchSize,
|
|
3724
|
+
flushInterval: this.config.flushInterval,
|
|
3725
|
+
});
|
|
3726
|
+
// Get or create visitor and session IDs based on mode
|
|
3727
|
+
this.visitorId = this.createVisitorId();
|
|
3728
|
+
this.sessionId = this.createSessionId();
|
|
3729
|
+
logger.debug('IDs created', { visitorId: this.visitorId, sessionId: this.sessionId });
|
|
3730
|
+
// Security warnings
|
|
3731
|
+
if (this.config.apiEndpoint.startsWith('http://') &&
|
|
3732
|
+
typeof window !== 'undefined' &&
|
|
3733
|
+
!window.location.hostname.includes('localhost') &&
|
|
3734
|
+
!window.location.hostname.includes('127.0.0.1')) {
|
|
3735
|
+
logger.warn('apiEndpoint uses HTTP — events and visitor data will be sent unencrypted. Use HTTPS in production.');
|
|
3736
|
+
}
|
|
3737
|
+
if (this.config.apiKey && typeof window !== 'undefined') {
|
|
3738
|
+
logger.warn('API key is exposed in client-side code. Use API keys only in server-side (Node.js) environments.');
|
|
3739
|
+
}
|
|
3740
|
+
// Initialize plugins
|
|
3741
|
+
this.initPlugins();
|
|
3742
|
+
this.isInitialized = true;
|
|
3743
|
+
logger.info('SDK initialized successfully');
|
|
3744
|
+
}
|
|
3745
|
+
/**
|
|
3746
|
+
* Create visitor ID based on storage mode
|
|
3747
|
+
*/
|
|
3748
|
+
createVisitorId() {
|
|
3749
|
+
// Anonymous mode: use temporary ID until consent
|
|
3750
|
+
if (this.config.consent.anonymousMode && !this.consentManager.hasExplicit()) {
|
|
3751
|
+
const key = STORAGE_KEYS.VISITOR_ID + '_anon';
|
|
3752
|
+
let anonId = getSessionStorage(key);
|
|
3753
|
+
if (!anonId) {
|
|
3754
|
+
anonId = 'anon_' + generateUUID();
|
|
3755
|
+
setSessionStorage(key, anonId);
|
|
3756
|
+
}
|
|
3757
|
+
return anonId;
|
|
3758
|
+
}
|
|
3759
|
+
// Cookie-less mode: use sessionStorage only
|
|
3760
|
+
if (this.config.cookielessMode) {
|
|
3761
|
+
let visitorId = getSessionStorage(STORAGE_KEYS.VISITOR_ID);
|
|
3762
|
+
if (!visitorId) {
|
|
3763
|
+
visitorId = generateUUID();
|
|
3764
|
+
setSessionStorage(STORAGE_KEYS.VISITOR_ID, visitorId);
|
|
3765
|
+
}
|
|
3766
|
+
return visitorId;
|
|
3767
|
+
}
|
|
3768
|
+
// Normal mode
|
|
3769
|
+
return getOrCreateVisitorId(this.config.useCookies);
|
|
3770
|
+
}
|
|
3771
|
+
/**
|
|
3772
|
+
* Create session ID
|
|
3773
|
+
*/
|
|
3774
|
+
createSessionId() {
|
|
3775
|
+
return getOrCreateSessionId(this.config.sessionTimeout);
|
|
3776
|
+
}
|
|
3777
|
+
/**
|
|
3778
|
+
* Handle consent state changes
|
|
3779
|
+
*/
|
|
3780
|
+
onConsentChange(state, previous) {
|
|
3781
|
+
logger.debug('Consent changed:', { from: previous, to: state });
|
|
3782
|
+
// If analytics consent was just granted
|
|
3783
|
+
if (state.analytics && !previous.analytics) {
|
|
3784
|
+
// Upgrade from anonymous ID to persistent ID
|
|
3785
|
+
if (this.config.consent.anonymousMode) {
|
|
3786
|
+
this.visitorId = getOrCreateVisitorId(this.config.useCookies);
|
|
3787
|
+
logger.info('Upgraded from anonymous to persistent visitor ID');
|
|
3788
|
+
}
|
|
3789
|
+
// Flush buffered events
|
|
3790
|
+
const buffered = this.consentManager.flushBuffer();
|
|
3791
|
+
for (const event of buffered) {
|
|
3792
|
+
// Update event with new visitor ID
|
|
3793
|
+
event.visitorId = this.visitorId;
|
|
3794
|
+
this.queue.push(event);
|
|
3795
|
+
}
|
|
3796
|
+
}
|
|
3797
|
+
}
|
|
3798
|
+
/**
|
|
3799
|
+
* Initialize enabled plugins
|
|
3800
|
+
* Handles both sync and async plugin init methods
|
|
3801
|
+
*/
|
|
3802
|
+
initPlugins() {
|
|
3803
|
+
const pluginsToLoad = this.config.plugins;
|
|
3804
|
+
// Skip pageView plugin if autoPageView is disabled
|
|
3805
|
+
const filteredPlugins = this.config.autoPageView
|
|
3806
|
+
? pluginsToLoad
|
|
3807
|
+
: pluginsToLoad.filter((p) => p !== 'pageView');
|
|
3808
|
+
for (const pluginName of filteredPlugins) {
|
|
3809
|
+
try {
|
|
3810
|
+
const plugin = getPlugin(pluginName);
|
|
3811
|
+
// Handle both sync and async init (fire-and-forget for async)
|
|
3812
|
+
const result = plugin.init(this);
|
|
3813
|
+
if (result instanceof Promise) {
|
|
3814
|
+
result.catch((error) => {
|
|
3815
|
+
logger.error(`Async plugin init failed: ${pluginName}`, error);
|
|
3816
|
+
});
|
|
3817
|
+
}
|
|
3818
|
+
this.plugins.push(plugin);
|
|
3819
|
+
logger.debug(`Plugin loaded: ${pluginName}`);
|
|
3820
|
+
}
|
|
3821
|
+
catch (error) {
|
|
3822
|
+
logger.error(`Failed to load plugin: ${pluginName}`, error);
|
|
3823
|
+
}
|
|
3824
|
+
}
|
|
3825
|
+
}
|
|
3826
|
+
/**
|
|
3827
|
+
* Track a custom event
|
|
3828
|
+
*/
|
|
3829
|
+
track(eventType, eventName, properties = {}) {
|
|
3830
|
+
if (!this.isInitialized) {
|
|
3831
|
+
logger.warn('SDK not initialized, event dropped');
|
|
3832
|
+
return;
|
|
3833
|
+
}
|
|
3834
|
+
const event = {
|
|
3835
|
+
workspaceId: this.workspaceId,
|
|
3836
|
+
visitorId: this.visitorId,
|
|
3837
|
+
sessionId: this.sessionId,
|
|
3838
|
+
eventType: eventType,
|
|
3839
|
+
eventName,
|
|
3840
|
+
url: typeof window !== 'undefined' ? window.location.href : '',
|
|
3841
|
+
referrer: typeof document !== 'undefined' ? document.referrer || undefined : undefined,
|
|
3842
|
+
properties: {
|
|
3843
|
+
...properties,
|
|
3844
|
+
eventId: generateUUID(), // Unique ID for deduplication on retry
|
|
3845
|
+
websiteDomain: typeof window !== 'undefined' ? window.location.hostname : undefined,
|
|
3846
|
+
},
|
|
3847
|
+
device: getDeviceInfo(),
|
|
3848
|
+
...getUTMParams(),
|
|
3849
|
+
timestamp: new Date().toISOString(),
|
|
3850
|
+
sdkVersion: SDK_VERSION,
|
|
3851
|
+
};
|
|
3852
|
+
// Attach contactId if known (from a prior identify() call)
|
|
3853
|
+
if (this.contactId) {
|
|
3854
|
+
event.contactId = this.contactId;
|
|
3855
|
+
}
|
|
3856
|
+
// Validate event against registered schema (debug mode only)
|
|
3857
|
+
this.validateEventSchema(eventType, properties);
|
|
3858
|
+
// Check consent before tracking
|
|
3859
|
+
if (!this.consentManager.canTrack()) {
|
|
3860
|
+
// Buffer event for later if waitForConsent is enabled
|
|
3861
|
+
if (this.config.consent.waitForConsent) {
|
|
3862
|
+
this.consentManager.bufferEvent(event);
|
|
3863
|
+
return;
|
|
3864
|
+
}
|
|
3865
|
+
// Otherwise drop the event
|
|
3866
|
+
logger.debug('Event dropped (no consent):', eventName);
|
|
3867
|
+
return;
|
|
3868
|
+
}
|
|
3869
|
+
this.queue.push(event);
|
|
3870
|
+
logger.debug('Event tracked:', eventName, properties);
|
|
3871
|
+
}
|
|
3872
|
+
/**
|
|
3873
|
+
* Track a page view
|
|
3874
|
+
*/
|
|
3875
|
+
page(name, properties = {}) {
|
|
3876
|
+
const pageName = name || (typeof document !== 'undefined' ? document.title : 'Page View');
|
|
3877
|
+
this.track('page_view', pageName, {
|
|
3878
|
+
...properties,
|
|
3879
|
+
path: typeof window !== 'undefined' ? window.location.pathname : '',
|
|
3880
|
+
});
|
|
3881
|
+
}
|
|
3882
|
+
/**
|
|
3883
|
+
* Identify a visitor.
|
|
3884
|
+
* Links the anonymous visitorId to a CRM contact and returns the contactId.
|
|
3885
|
+
* All subsequent track() calls will include the contactId automatically.
|
|
3886
|
+
*/
|
|
3887
|
+
async identify(email, traits = {}) {
|
|
3888
|
+
if (!email) {
|
|
3889
|
+
logger.warn('Email is required for identification');
|
|
3890
|
+
return null;
|
|
3891
|
+
}
|
|
3892
|
+
if (!isValidEmail(email)) {
|
|
3893
|
+
logger.warn('Invalid email format, identification skipped:', email);
|
|
3894
|
+
return null;
|
|
3895
|
+
}
|
|
3896
|
+
logger.info('Identifying visitor:', email);
|
|
3897
|
+
const result = await this.transport.sendIdentify({
|
|
3898
|
+
workspaceId: this.workspaceId,
|
|
3899
|
+
visitorId: this.visitorId,
|
|
3900
|
+
email,
|
|
3901
|
+
properties: traits,
|
|
3902
|
+
});
|
|
3903
|
+
if (result.success) {
|
|
3904
|
+
logger.info('Visitor identified successfully, contactId:', result.contactId);
|
|
3905
|
+
// Store contactId so all future track() calls include it
|
|
3906
|
+
this.contactId = result.contactId ?? null;
|
|
3907
|
+
this.pendingIdentify = null;
|
|
3908
|
+
return this.contactId;
|
|
3909
|
+
}
|
|
3910
|
+
else {
|
|
3911
|
+
logger.error('Failed to identify visitor:', result.error);
|
|
3912
|
+
// Store for retry on next flush
|
|
3913
|
+
this.pendingIdentify = { email, traits };
|
|
3914
|
+
return null;
|
|
3915
|
+
}
|
|
3916
|
+
}
|
|
3917
|
+
/**
|
|
3918
|
+
* Send a server-side inbound event via the API key endpoint.
|
|
3919
|
+
* Convenience proxy to CRMClient.sendEvent() — requires apiKey in config.
|
|
3920
|
+
*/
|
|
3921
|
+
async sendEvent(payload) {
|
|
3922
|
+
const apiKey = this.config.apiKey;
|
|
3923
|
+
if (!apiKey) {
|
|
3924
|
+
logger.error('sendEvent() requires an apiKey in the SDK config');
|
|
3925
|
+
return { success: false, contactCreated: false, event: payload.event, error: 'No API key configured' };
|
|
3926
|
+
}
|
|
3927
|
+
const client = new CRMClient(this.config.apiEndpoint, this.workspaceId, undefined, apiKey);
|
|
3928
|
+
return client.sendEvent(payload);
|
|
3929
|
+
}
|
|
3930
|
+
/**
|
|
3931
|
+
* Get the current visitor's profile from the CRM.
|
|
3932
|
+
* Returns visitor data and linked contact info if identified.
|
|
3933
|
+
* Only returns data for the current visitor (privacy-safe for frontend).
|
|
3934
|
+
*/
|
|
3935
|
+
async getVisitorProfile() {
|
|
3936
|
+
if (!this.isInitialized) {
|
|
3937
|
+
logger.warn('SDK not initialized');
|
|
3938
|
+
return null;
|
|
3939
|
+
}
|
|
3940
|
+
const result = await this.transport.fetchData(`/api/public/track/visitor/${this.workspaceId}/${this.visitorId}/profile`);
|
|
3941
|
+
if (result.success && result.data) {
|
|
3942
|
+
logger.debug('Visitor profile fetched:', result.data);
|
|
3943
|
+
return result.data;
|
|
3944
|
+
}
|
|
3945
|
+
logger.warn('Failed to fetch visitor profile:', result.error);
|
|
3946
|
+
return null;
|
|
3947
|
+
}
|
|
3948
|
+
/**
|
|
3949
|
+
* Get the current visitor's recent activity/events.
|
|
3950
|
+
* Returns paginated list of tracking events for this visitor.
|
|
3951
|
+
*/
|
|
3952
|
+
async getVisitorActivity(options) {
|
|
3953
|
+
if (!this.isInitialized) {
|
|
3954
|
+
logger.warn('SDK not initialized');
|
|
3955
|
+
return null;
|
|
3956
|
+
}
|
|
3957
|
+
const params = {};
|
|
3958
|
+
if (options?.page)
|
|
3959
|
+
params.page = options.page.toString();
|
|
3960
|
+
if (options?.limit)
|
|
3961
|
+
params.limit = options.limit.toString();
|
|
3962
|
+
if (options?.eventType)
|
|
3963
|
+
params.eventType = options.eventType;
|
|
3964
|
+
if (options?.startDate)
|
|
3965
|
+
params.startDate = options.startDate;
|
|
3966
|
+
if (options?.endDate)
|
|
3967
|
+
params.endDate = options.endDate;
|
|
3968
|
+
const result = await this.transport.fetchData(`/api/public/track/visitor/${this.workspaceId}/${this.visitorId}/activity`, params);
|
|
3969
|
+
if (result.success && result.data) {
|
|
3970
|
+
return result.data;
|
|
3971
|
+
}
|
|
3972
|
+
logger.warn('Failed to fetch visitor activity:', result.error);
|
|
3973
|
+
return null;
|
|
3974
|
+
}
|
|
3975
|
+
/**
|
|
3976
|
+
* Get a summarized journey timeline for the current visitor.
|
|
3977
|
+
* Includes top pages, sessions, time spent, and recent activities.
|
|
3978
|
+
*/
|
|
3979
|
+
async getVisitorTimeline() {
|
|
3980
|
+
if (!this.isInitialized) {
|
|
3981
|
+
logger.warn('SDK not initialized');
|
|
3982
|
+
return null;
|
|
3983
|
+
}
|
|
3984
|
+
const result = await this.transport.fetchData(`/api/public/track/visitor/${this.workspaceId}/${this.visitorId}/timeline`);
|
|
3985
|
+
if (result.success && result.data) {
|
|
3986
|
+
return result.data;
|
|
3987
|
+
}
|
|
3988
|
+
logger.warn('Failed to fetch visitor timeline:', result.error);
|
|
3989
|
+
return null;
|
|
3990
|
+
}
|
|
3991
|
+
/**
|
|
3992
|
+
* Get engagement metrics for the current visitor.
|
|
3993
|
+
* Includes time on site, page views, bounce rate, and engagement score.
|
|
3994
|
+
*/
|
|
3995
|
+
async getVisitorEngagement() {
|
|
3996
|
+
if (!this.isInitialized) {
|
|
3997
|
+
logger.warn('SDK not initialized');
|
|
3998
|
+
return null;
|
|
3999
|
+
}
|
|
4000
|
+
const result = await this.transport.fetchData(`/api/public/track/visitor/${this.workspaceId}/${this.visitorId}/engagement`);
|
|
4001
|
+
if (result.success && result.data) {
|
|
4002
|
+
return result.data;
|
|
4003
|
+
}
|
|
4004
|
+
logger.warn('Failed to fetch visitor engagement:', result.error);
|
|
4005
|
+
return null;
|
|
4006
|
+
}
|
|
4007
|
+
/**
|
|
4008
|
+
* Retry pending identify call
|
|
4009
|
+
*/
|
|
4010
|
+
async retryPendingIdentify() {
|
|
4011
|
+
if (!this.pendingIdentify)
|
|
4012
|
+
return;
|
|
4013
|
+
const { email, traits } = this.pendingIdentify;
|
|
4014
|
+
this.pendingIdentify = null;
|
|
4015
|
+
await this.identify(email, traits);
|
|
4016
|
+
}
|
|
4017
|
+
/**
|
|
4018
|
+
* Update consent state
|
|
4019
|
+
*/
|
|
4020
|
+
consent(state) {
|
|
4021
|
+
this.consentManager.update(state);
|
|
4022
|
+
}
|
|
4023
|
+
/**
|
|
4024
|
+
* Get current consent state
|
|
4025
|
+
*/
|
|
4026
|
+
getConsentState() {
|
|
4027
|
+
return this.consentManager.getState();
|
|
4028
|
+
}
|
|
4029
|
+
/**
|
|
4030
|
+
* Toggle debug mode
|
|
4031
|
+
*/
|
|
4032
|
+
debug(enabled) {
|
|
4033
|
+
logger.enabled = enabled;
|
|
4034
|
+
logger.info(`Debug mode ${enabled ? 'enabled' : 'disabled'}`);
|
|
4035
|
+
}
|
|
4036
|
+
/**
|
|
4037
|
+
* Register a schema for event validation.
|
|
4038
|
+
* When debug mode is enabled, events will be validated against registered schemas.
|
|
4039
|
+
*
|
|
4040
|
+
* @example
|
|
4041
|
+
* tracker.registerEventSchema('purchase', {
|
|
4042
|
+
* productId: 'string',
|
|
4043
|
+
* price: 'number',
|
|
4044
|
+
* quantity: 'number',
|
|
4045
|
+
* });
|
|
4046
|
+
*/
|
|
4047
|
+
registerEventSchema(eventType, schema) {
|
|
4048
|
+
this.eventSchemas.set(eventType, schema);
|
|
4049
|
+
logger.debug('Event schema registered:', eventType);
|
|
4050
|
+
}
|
|
4051
|
+
/**
|
|
4052
|
+
* Validate event properties against a registered schema (debug mode only)
|
|
4053
|
+
*/
|
|
4054
|
+
validateEventSchema(eventType, properties) {
|
|
4055
|
+
if (!this.config.debug)
|
|
4056
|
+
return;
|
|
4057
|
+
const schema = this.eventSchemas.get(eventType);
|
|
4058
|
+
if (!schema)
|
|
4059
|
+
return;
|
|
4060
|
+
for (const [key, expectedType] of Object.entries(schema)) {
|
|
4061
|
+
const value = properties[key];
|
|
4062
|
+
if (value === undefined) {
|
|
4063
|
+
logger.warn(`[Schema] Missing property "${key}" for event type "${eventType}"`);
|
|
4064
|
+
continue;
|
|
4065
|
+
}
|
|
4066
|
+
let valid = false;
|
|
4067
|
+
switch (expectedType) {
|
|
4068
|
+
case 'string':
|
|
4069
|
+
valid = typeof value === 'string';
|
|
4070
|
+
break;
|
|
4071
|
+
case 'number':
|
|
4072
|
+
valid = typeof value === 'number';
|
|
4073
|
+
break;
|
|
4074
|
+
case 'boolean':
|
|
4075
|
+
valid = typeof value === 'boolean';
|
|
4076
|
+
break;
|
|
4077
|
+
case 'object':
|
|
4078
|
+
valid = typeof value === 'object' && !Array.isArray(value);
|
|
4079
|
+
break;
|
|
4080
|
+
case 'array':
|
|
4081
|
+
valid = Array.isArray(value);
|
|
4082
|
+
break;
|
|
4083
|
+
}
|
|
4084
|
+
if (!valid) {
|
|
4085
|
+
logger.warn(`[Schema] Property "${key}" for event "${eventType}" expected ${expectedType}, got ${typeof value}`);
|
|
4086
|
+
}
|
|
4087
|
+
}
|
|
4088
|
+
}
|
|
4089
|
+
/**
|
|
4090
|
+
* Get visitor ID
|
|
4091
|
+
*/
|
|
4092
|
+
getVisitorId() {
|
|
4093
|
+
return this.visitorId;
|
|
4094
|
+
}
|
|
4095
|
+
/**
|
|
4096
|
+
* Get session ID
|
|
4097
|
+
*/
|
|
4098
|
+
getSessionId() {
|
|
4099
|
+
return this.sessionId;
|
|
4100
|
+
}
|
|
4101
|
+
/**
|
|
4102
|
+
* Get workspace ID
|
|
4103
|
+
*/
|
|
4104
|
+
getWorkspaceId() {
|
|
4105
|
+
return this.workspaceId;
|
|
4106
|
+
}
|
|
4107
|
+
/**
|
|
4108
|
+
* Get current configuration
|
|
4109
|
+
*/
|
|
4110
|
+
getConfig() {
|
|
4111
|
+
return { ...this.config };
|
|
4112
|
+
}
|
|
4113
|
+
/**
|
|
4114
|
+
* Force flush event queue
|
|
4115
|
+
*/
|
|
4116
|
+
async flush() {
|
|
4117
|
+
await this.retryPendingIdentify();
|
|
4118
|
+
await this.queue.flush();
|
|
4119
|
+
}
|
|
4120
|
+
/**
|
|
4121
|
+
* Reset visitor and session (for logout)
|
|
4122
|
+
*/
|
|
4123
|
+
reset() {
|
|
4124
|
+
logger.info('Resetting visitor data');
|
|
4125
|
+
resetIds(this.config.useCookies);
|
|
4126
|
+
this.visitorId = this.createVisitorId();
|
|
4127
|
+
this.sessionId = this.createSessionId();
|
|
4128
|
+
this.contactId = null;
|
|
4129
|
+
this.pendingIdentify = null;
|
|
4130
|
+
this.queue.clear();
|
|
4131
|
+
}
|
|
4132
|
+
/**
|
|
4133
|
+
* Delete all stored user data (GDPR right-to-erasure)
|
|
4134
|
+
*/
|
|
4135
|
+
deleteData() {
|
|
4136
|
+
logger.info('Deleting all user data (GDPR request)');
|
|
4137
|
+
// Clear queue
|
|
4138
|
+
this.queue.clear();
|
|
4139
|
+
// Reset consent
|
|
4140
|
+
this.consentManager.reset();
|
|
4141
|
+
// Clear all stored IDs
|
|
4142
|
+
resetIds(this.config.useCookies);
|
|
4143
|
+
// Clear session storage items
|
|
4144
|
+
if (typeof sessionStorage !== 'undefined') {
|
|
4145
|
+
try {
|
|
4146
|
+
sessionStorage.removeItem(STORAGE_KEYS.VISITOR_ID);
|
|
4147
|
+
sessionStorage.removeItem(STORAGE_KEYS.VISITOR_ID + '_anon');
|
|
4148
|
+
sessionStorage.removeItem(STORAGE_KEYS.SESSION_ID);
|
|
4149
|
+
sessionStorage.removeItem(STORAGE_KEYS.SESSION_TIMESTAMP);
|
|
4150
|
+
}
|
|
4151
|
+
catch {
|
|
4152
|
+
// Ignore errors
|
|
4153
|
+
}
|
|
4154
|
+
}
|
|
4155
|
+
// Clear localStorage items
|
|
4156
|
+
if (typeof localStorage !== 'undefined') {
|
|
4157
|
+
try {
|
|
4158
|
+
localStorage.removeItem(STORAGE_KEYS.VISITOR_ID);
|
|
4159
|
+
localStorage.removeItem(STORAGE_KEYS.CONSENT);
|
|
4160
|
+
localStorage.removeItem(STORAGE_KEYS.EVENT_QUEUE);
|
|
4161
|
+
}
|
|
4162
|
+
catch {
|
|
4163
|
+
// Ignore errors
|
|
4164
|
+
}
|
|
4165
|
+
}
|
|
4166
|
+
// Generate new IDs
|
|
4167
|
+
this.visitorId = this.createVisitorId();
|
|
4168
|
+
this.sessionId = this.createSessionId();
|
|
4169
|
+
logger.info('All user data deleted');
|
|
4170
|
+
}
|
|
4171
|
+
/**
|
|
4172
|
+
* Destroy tracker and cleanup
|
|
4173
|
+
*/
|
|
4174
|
+
async destroy() {
|
|
4175
|
+
logger.info('Destroying tracker');
|
|
4176
|
+
// Flush any remaining events (await to ensure completion)
|
|
4177
|
+
await this.queue.flush();
|
|
4178
|
+
// Destroy plugins
|
|
4179
|
+
for (const plugin of this.plugins) {
|
|
4180
|
+
if (plugin.destroy) {
|
|
4181
|
+
plugin.destroy();
|
|
4182
|
+
}
|
|
4183
|
+
}
|
|
4184
|
+
this.plugins = [];
|
|
4185
|
+
// Destroy queue
|
|
4186
|
+
this.queue.destroy();
|
|
4187
|
+
this.isInitialized = false;
|
|
4188
|
+
}
|
|
4189
|
+
}
|
|
4190
|
+
|
|
4191
|
+
/**
|
|
4192
|
+
* Clianta SDK
|
|
4193
|
+
* Professional CRM and tracking SDK for lead generation
|
|
4194
|
+
* @see SDK_VERSION in core/config.ts
|
|
4195
|
+
*/
|
|
4196
|
+
// Global instance cache
|
|
4197
|
+
let globalInstance = null;
|
|
4198
|
+
/**
|
|
4199
|
+
* Initialize or get the Clianta tracker instance
|
|
4200
|
+
*
|
|
4201
|
+
* @example
|
|
4202
|
+
* // Simple initialization
|
|
4203
|
+
* const tracker = clianta('your-workspace-id');
|
|
4204
|
+
*
|
|
4205
|
+
* @example
|
|
4206
|
+
* // With configuration
|
|
4207
|
+
* const tracker = clianta('your-workspace-id', {
|
|
4208
|
+
* debug: true,
|
|
4209
|
+
* plugins: ['pageView', 'forms', 'scroll'],
|
|
4210
|
+
* });
|
|
4211
|
+
*
|
|
4212
|
+
* @example
|
|
4213
|
+
* // With consent configuration
|
|
4214
|
+
* const tracker = clianta('your-workspace-id', {
|
|
4215
|
+
* consent: {
|
|
4216
|
+
* waitForConsent: true,
|
|
4217
|
+
* anonymousMode: true,
|
|
4218
|
+
* },
|
|
4219
|
+
* cookielessMode: true, // GDPR-friendly mode
|
|
4220
|
+
* });
|
|
4221
|
+
*/
|
|
4222
|
+
function clianta(workspaceId, config) {
|
|
4223
|
+
// Return existing instance if same workspace
|
|
4224
|
+
if (globalInstance && globalInstance.getWorkspaceId() === workspaceId) {
|
|
4225
|
+
return globalInstance;
|
|
4226
|
+
}
|
|
4227
|
+
// Destroy existing instance if workspace changed
|
|
4228
|
+
if (globalInstance) {
|
|
4229
|
+
globalInstance.destroy();
|
|
4230
|
+
}
|
|
4231
|
+
// Create new instance
|
|
4232
|
+
globalInstance = new Tracker(workspaceId, config);
|
|
4233
|
+
return globalInstance;
|
|
4234
|
+
}
|
|
4235
|
+
// Attach to window for <script> usage
|
|
4236
|
+
if (typeof window !== 'undefined') {
|
|
4237
|
+
window.clianta = clianta;
|
|
4238
|
+
window.Clianta = {
|
|
4239
|
+
clianta,
|
|
4240
|
+
Tracker,
|
|
4241
|
+
CRMClient,
|
|
4242
|
+
ConsentManager,
|
|
4243
|
+
EventTriggersManager,
|
|
4244
|
+
};
|
|
4245
|
+
}
|
|
4246
|
+
|
|
4247
|
+
/**
|
|
4248
|
+
* Clianta SDK - Angular Integration
|
|
4249
|
+
*
|
|
4250
|
+
* Provides helpers for Angular 16+ integration.
|
|
4251
|
+
* Since Angular uses decorators and DI that require @angular/core as a dependency,
|
|
4252
|
+
* this module provides a factory pattern that Angular users wrap in their own service.
|
|
4253
|
+
*
|
|
4254
|
+
* @example
|
|
4255
|
+
* // In your Angular service:
|
|
4256
|
+
* import { Injectable, OnDestroy } from '@angular/core';
|
|
4257
|
+
* import { createCliantaTracker, type CliantaTrackerInstance } from '@clianta/sdk/angular';
|
|
4258
|
+
*
|
|
4259
|
+
* @Injectable({ providedIn: 'root' })
|
|
4260
|
+
* export class CliantaService implements OnDestroy {
|
|
4261
|
+
* private instance: CliantaTrackerInstance;
|
|
4262
|
+
*
|
|
4263
|
+
* constructor() {
|
|
4264
|
+
* this.instance = createCliantaTracker({
|
|
4265
|
+
* projectId: environment.cliantaProjectId,
|
|
4266
|
+
* apiEndpoint: environment.cliantaApiEndpoint,
|
|
4267
|
+
* debug: !environment.production,
|
|
4268
|
+
* });
|
|
4269
|
+
* }
|
|
4270
|
+
*
|
|
4271
|
+
* get tracker() { return this.instance.tracker; }
|
|
4272
|
+
*
|
|
4273
|
+
* track(eventType: string, eventName: string, properties?: Record<string, unknown>) {
|
|
4274
|
+
* this.instance.tracker?.track(eventType, eventName, properties);
|
|
4275
|
+
* }
|
|
4276
|
+
*
|
|
4277
|
+
* identify(email: string, traits?: Record<string, unknown>) {
|
|
4278
|
+
* return this.instance.tracker?.identify(email, traits);
|
|
4279
|
+
* }
|
|
4280
|
+
*
|
|
4281
|
+
* ngOnDestroy() {
|
|
4282
|
+
* this.instance.destroy();
|
|
4283
|
+
* }
|
|
4284
|
+
* }
|
|
4285
|
+
*/
|
|
4286
|
+
/**
|
|
4287
|
+
* Create a Clianta tracker instance for use in Angular services.
|
|
4288
|
+
*
|
|
4289
|
+
* @param config - Configuration including projectId
|
|
4290
|
+
* @returns Object with tracker instance and destroy method
|
|
4291
|
+
*
|
|
4292
|
+
* @example
|
|
4293
|
+
* const instance = createCliantaTracker({
|
|
4294
|
+
* projectId: 'your-project-id',
|
|
4295
|
+
* apiEndpoint: 'https://api.clianta.online',
|
|
4296
|
+
* });
|
|
4297
|
+
*
|
|
4298
|
+
* instance.tracker?.track('page_view', 'Home Page');
|
|
4299
|
+
* // On cleanup:
|
|
4300
|
+
* instance.destroy();
|
|
4301
|
+
*/
|
|
4302
|
+
function createCliantaTracker(config) {
|
|
4303
|
+
if (!config.projectId) {
|
|
4304
|
+
console.error('[Clianta] Missing projectId in Angular config');
|
|
4305
|
+
return { tracker: null, destroy: () => { } };
|
|
4306
|
+
}
|
|
4307
|
+
const { projectId, ...options } = config;
|
|
4308
|
+
const tracker = clianta(projectId, options);
|
|
4309
|
+
return {
|
|
4310
|
+
tracker,
|
|
4311
|
+
destroy: async () => {
|
|
4312
|
+
await tracker.destroy();
|
|
4313
|
+
},
|
|
4314
|
+
};
|
|
4315
|
+
}
|
|
4316
|
+
/**
|
|
4317
|
+
* Create a track function bound to a tracker instance.
|
|
4318
|
+
* Useful as a shorthand in Angular components.
|
|
4319
|
+
*
|
|
4320
|
+
* @example
|
|
4321
|
+
* const track = createTrackFn(instance.tracker);
|
|
4322
|
+
* track('button_click', 'CTA Button', { location: 'header' });
|
|
4323
|
+
*/
|
|
4324
|
+
function createTrackFn(tracker) {
|
|
4325
|
+
return (eventType, eventName, properties) => {
|
|
4326
|
+
tracker?.track(eventType, eventName, properties);
|
|
4327
|
+
};
|
|
4328
|
+
}
|
|
4329
|
+
/**
|
|
4330
|
+
* Create an identify function bound to a tracker instance.
|
|
4331
|
+
*
|
|
4332
|
+
* @example
|
|
4333
|
+
* const identify = createIdentifyFn(instance.tracker);
|
|
4334
|
+
* identify('user@example.com', { firstName: 'John' });
|
|
4335
|
+
*/
|
|
4336
|
+
function createIdentifyFn(tracker) {
|
|
4337
|
+
return (email, traits) => {
|
|
4338
|
+
return tracker?.identify(email, traits);
|
|
4339
|
+
};
|
|
4340
|
+
}
|
|
4341
|
+
|
|
4342
|
+
exports.createCliantaTracker = createCliantaTracker;
|
|
4343
|
+
exports.createIdentifyFn = createIdentifyFn;
|
|
4344
|
+
exports.createTrackFn = createTrackFn;
|
|
4345
|
+
//# sourceMappingURL=angular.cjs.js.map
|