@clianta/sdk 1.4.0 → 1.5.1

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