@clianta/sdk 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * Clianta SDK v1.4.0
2
+ * Clianta SDK v1.5.0
3
3
  * (c) 2026 Clianta
4
4
  * Released under the MIT License.
5
5
  */
@@ -56,6 +56,7 @@
56
56
  cookieDomain: '',
57
57
  useCookies: false,
58
58
  cookielessMode: false,
59
+ persistMode: 'session',
59
60
  };
60
61
  /** Storage keys */
61
62
  const STORAGE_KEYS = {
@@ -249,6 +250,39 @@
249
250
  return false;
250
251
  }
251
252
  }
253
+ /**
254
+ * Fetch data from the tracking API (GET request)
255
+ * Used for read-back APIs (visitor profile, activity, etc.)
256
+ */
257
+ async fetchData(path, params) {
258
+ const url = new URL(`${this.config.apiEndpoint}${path}`);
259
+ if (params) {
260
+ Object.entries(params).forEach(([key, value]) => {
261
+ if (value !== undefined && value !== null) {
262
+ url.searchParams.set(key, value);
263
+ }
264
+ });
265
+ }
266
+ try {
267
+ const response = await this.fetchWithTimeout(url.toString(), {
268
+ method: 'GET',
269
+ headers: {
270
+ 'Accept': 'application/json',
271
+ },
272
+ });
273
+ if (response.ok) {
274
+ const body = await response.json();
275
+ logger.debug('Fetch successful:', path);
276
+ return { success: true, data: body.data ?? body, status: response.status };
277
+ }
278
+ logger.error(`Fetch failed with status ${response.status}`);
279
+ return { success: false, status: response.status };
280
+ }
281
+ catch (error) {
282
+ logger.error('Fetch request failed:', error);
283
+ return { success: false, error: error };
284
+ }
285
+ }
252
286
  /**
253
287
  * Internal send with retry logic
254
288
  */
@@ -413,7 +447,9 @@
413
447
  date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
414
448
  expires = '; expires=' + date.toUTCString();
415
449
  }
416
- document.cookie = name + '=' + value + expires + '; path=/; SameSite=Lax';
450
+ // Add Secure flag on HTTPS to prevent cookie leakage over plaintext
451
+ const secure = typeof location !== 'undefined' && location.protocol === 'https:' ? '; Secure' : '';
452
+ document.cookie = name + '=' + value + expires + '; path=/; SameSite=Lax' + secure;
417
453
  return value;
418
454
  }
419
455
  // ============================================
@@ -577,6 +613,17 @@
577
613
  return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
578
614
  }
579
615
  // ============================================
616
+ // VALIDATION UTILITIES
617
+ // ============================================
618
+ /**
619
+ * Validate email format
620
+ */
621
+ function isValidEmail(email) {
622
+ if (typeof email !== 'string' || !email)
623
+ return false;
624
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
625
+ }
626
+ // ============================================
580
627
  // DEVICE INFO
581
628
  // ============================================
582
629
  /**
@@ -630,6 +677,7 @@
630
677
  maxQueueSize: config.maxQueueSize ?? MAX_QUEUE_SIZE,
631
678
  storageKey: config.storageKey ?? STORAGE_KEYS.EVENT_QUEUE,
632
679
  };
680
+ this.persistMode = config.persistMode || 'session';
633
681
  // Restore persisted queue
634
682
  this.restoreQueue();
635
683
  // Start auto-flush timer
@@ -735,6 +783,13 @@
735
783
  clear() {
736
784
  this.queue = [];
737
785
  this.persistQueue([]);
786
+ // Also clear localStorage if used
787
+ if (this.persistMode === 'local' && typeof localStorage !== 'undefined') {
788
+ try {
789
+ localStorage.removeItem(this.config.storageKey);
790
+ }
791
+ catch { /* ignore */ }
792
+ }
738
793
  }
739
794
  /**
740
795
  * Stop the flush timer and cleanup handlers
@@ -789,22 +844,44 @@
789
844
  window.addEventListener('pagehide', this.boundPageHide);
790
845
  }
791
846
  /**
792
- * Persist queue to localStorage
847
+ * Persist queue to storage based on persistMode
793
848
  */
794
849
  persistQueue(events) {
850
+ if (this.persistMode === 'none')
851
+ return;
795
852
  try {
796
- setLocalStorage(this.config.storageKey, JSON.stringify(events));
853
+ const serialized = JSON.stringify(events);
854
+ if (this.persistMode === 'local' && typeof localStorage !== 'undefined') {
855
+ try {
856
+ localStorage.setItem(this.config.storageKey, serialized);
857
+ }
858
+ catch {
859
+ // localStorage quota exceeded — fallback to sessionStorage
860
+ setSessionStorage(this.config.storageKey, serialized);
861
+ }
862
+ }
863
+ else {
864
+ setSessionStorage(this.config.storageKey, serialized);
865
+ }
797
866
  }
798
867
  catch {
799
868
  // Ignore storage errors
800
869
  }
801
870
  }
802
871
  /**
803
- * Restore queue from localStorage
872
+ * Restore queue from storage
804
873
  */
805
874
  restoreQueue() {
806
875
  try {
807
- const stored = getLocalStorage(this.config.storageKey);
876
+ let stored = null;
877
+ // Check localStorage first (cross-session persistence)
878
+ if (this.persistMode === 'local' && typeof localStorage !== 'undefined') {
879
+ stored = localStorage.getItem(this.config.storageKey);
880
+ }
881
+ // Fall back to sessionStorage
882
+ if (!stored) {
883
+ stored = getSessionStorage(this.config.storageKey);
884
+ }
808
885
  if (stored) {
809
886
  const events = JSON.parse(stored);
810
887
  if (Array.isArray(events) && events.length > 0) {
@@ -872,10 +949,13 @@
872
949
  history.pushState = function (...args) {
873
950
  self.originalPushState.apply(history, args);
874
951
  self.trackPageView();
952
+ // Notify other plugins (e.g. ScrollPlugin) about navigation
953
+ window.dispatchEvent(new Event('clianta:navigation'));
875
954
  };
876
955
  history.replaceState = function (...args) {
877
956
  self.originalReplaceState.apply(history, args);
878
957
  self.trackPageView();
958
+ window.dispatchEvent(new Event('clianta:navigation'));
879
959
  };
880
960
  // Handle back/forward navigation
881
961
  this.popstateHandler = () => this.trackPageView();
@@ -929,9 +1009,8 @@
929
1009
  this.pageLoadTime = 0;
930
1010
  this.scrollTimeout = null;
931
1011
  this.boundHandler = null;
932
- /** SPA navigation support */
933
- this.originalPushState = null;
934
- this.originalReplaceState = null;
1012
+ /** SPA navigation listen for PageViewPlugin's custom event instead of patching history */
1013
+ this.navigationHandler = null;
935
1014
  this.popstateHandler = null;
936
1015
  }
937
1016
  init(tracker) {
@@ -940,8 +1019,13 @@
940
1019
  if (typeof window !== 'undefined') {
941
1020
  this.boundHandler = this.handleScroll.bind(this);
942
1021
  window.addEventListener('scroll', this.boundHandler, { passive: true });
943
- // Setup SPA navigation reset
944
- this.setupNavigationReset();
1022
+ // Listen for navigation events dispatched by PageViewPlugin
1023
+ // instead of independently monkey-patching history.pushState
1024
+ this.navigationHandler = () => this.resetForNavigation();
1025
+ window.addEventListener('clianta:navigation', this.navigationHandler);
1026
+ // Handle back/forward navigation
1027
+ this.popstateHandler = () => this.resetForNavigation();
1028
+ window.addEventListener('popstate', this.popstateHandler);
945
1029
  }
946
1030
  }
947
1031
  destroy() {
@@ -951,16 +1035,10 @@
951
1035
  if (this.scrollTimeout) {
952
1036
  clearTimeout(this.scrollTimeout);
953
1037
  }
954
- // Restore original history methods
955
- if (this.originalPushState) {
956
- history.pushState = this.originalPushState;
957
- this.originalPushState = null;
1038
+ if (this.navigationHandler && typeof window !== 'undefined') {
1039
+ window.removeEventListener('clianta:navigation', this.navigationHandler);
1040
+ this.navigationHandler = null;
958
1041
  }
959
- if (this.originalReplaceState) {
960
- history.replaceState = this.originalReplaceState;
961
- this.originalReplaceState = null;
962
- }
963
- // Remove popstate listener
964
1042
  if (this.popstateHandler && typeof window !== 'undefined') {
965
1043
  window.removeEventListener('popstate', this.popstateHandler);
966
1044
  this.popstateHandler = null;
@@ -975,29 +1053,6 @@
975
1053
  this.maxScrollDepth = 0;
976
1054
  this.pageLoadTime = Date.now();
977
1055
  }
978
- /**
979
- * Setup History API interception for SPA navigation
980
- */
981
- setupNavigationReset() {
982
- if (typeof window === 'undefined')
983
- return;
984
- // Store originals for cleanup
985
- this.originalPushState = history.pushState;
986
- this.originalReplaceState = history.replaceState;
987
- // Intercept pushState and replaceState
988
- const self = this;
989
- history.pushState = function (...args) {
990
- self.originalPushState.apply(history, args);
991
- self.resetForNavigation();
992
- };
993
- history.replaceState = function (...args) {
994
- self.originalReplaceState.apply(history, args);
995
- self.resetForNavigation();
996
- };
997
- // Handle back/forward navigation
998
- this.popstateHandler = () => this.resetForNavigation();
999
- window.addEventListener('popstate', this.popstateHandler);
1000
- }
1001
1056
  handleScroll() {
1002
1057
  // Debounce scroll tracking
1003
1058
  if (this.scrollTimeout) {
@@ -1197,6 +1252,10 @@
1197
1252
  elementId: elementInfo.id,
1198
1253
  elementClass: elementInfo.className,
1199
1254
  href: target.href || undefined,
1255
+ x: Math.round((e.clientX / window.innerWidth) * 100),
1256
+ y: Math.round((e.clientY / window.innerHeight) * 100),
1257
+ viewportWidth: window.innerWidth,
1258
+ viewportHeight: window.innerHeight,
1200
1259
  });
1201
1260
  }
1202
1261
  }
@@ -1219,6 +1278,9 @@
1219
1278
  this.boundMarkEngaged = null;
1220
1279
  this.boundTrackTimeOnPage = null;
1221
1280
  this.boundVisibilityHandler = null;
1281
+ /** SPA navigation — listen for PageViewPlugin's custom event instead of patching history */
1282
+ this.navigationHandler = null;
1283
+ this.popstateHandler = null;
1222
1284
  }
1223
1285
  init(tracker) {
1224
1286
  super.init(tracker);
@@ -1244,6 +1306,13 @@
1244
1306
  // Track time on page before unload
1245
1307
  window.addEventListener('beforeunload', this.boundTrackTimeOnPage);
1246
1308
  document.addEventListener('visibilitychange', this.boundVisibilityHandler);
1309
+ // Listen for navigation events dispatched by PageViewPlugin
1310
+ // instead of independently monkey-patching history.pushState
1311
+ this.navigationHandler = () => this.resetForNavigation();
1312
+ window.addEventListener('clianta:navigation', this.navigationHandler);
1313
+ // Handle back/forward navigation
1314
+ this.popstateHandler = () => this.resetForNavigation();
1315
+ window.addEventListener('popstate', this.popstateHandler);
1247
1316
  }
1248
1317
  destroy() {
1249
1318
  if (this.boundMarkEngaged && typeof document !== 'undefined') {
@@ -1257,11 +1326,28 @@
1257
1326
  if (this.boundVisibilityHandler && typeof document !== 'undefined') {
1258
1327
  document.removeEventListener('visibilitychange', this.boundVisibilityHandler);
1259
1328
  }
1329
+ if (this.navigationHandler && typeof window !== 'undefined') {
1330
+ window.removeEventListener('clianta:navigation', this.navigationHandler);
1331
+ this.navigationHandler = null;
1332
+ }
1333
+ if (this.popstateHandler && typeof window !== 'undefined') {
1334
+ window.removeEventListener('popstate', this.popstateHandler);
1335
+ this.popstateHandler = null;
1336
+ }
1260
1337
  if (this.engagementTimeout) {
1261
1338
  clearTimeout(this.engagementTimeout);
1262
1339
  }
1263
1340
  super.destroy();
1264
1341
  }
1342
+ resetForNavigation() {
1343
+ this.pageLoadTime = Date.now();
1344
+ this.engagementStartTime = Date.now();
1345
+ this.isEngaged = false;
1346
+ if (this.engagementTimeout) {
1347
+ clearTimeout(this.engagementTimeout);
1348
+ this.engagementTimeout = null;
1349
+ }
1350
+ }
1265
1351
  markEngaged() {
1266
1352
  if (!this.isEngaged) {
1267
1353
  this.isEngaged = true;
@@ -1301,9 +1387,8 @@
1301
1387
  this.name = 'downloads';
1302
1388
  this.trackedDownloads = new Set();
1303
1389
  this.boundHandler = null;
1304
- /** SPA navigation support */
1305
- this.originalPushState = null;
1306
- this.originalReplaceState = null;
1390
+ /** SPA navigation listen for PageViewPlugin's custom event instead of patching history */
1391
+ this.navigationHandler = null;
1307
1392
  this.popstateHandler = null;
1308
1393
  }
1309
1394
  init(tracker) {
@@ -1311,24 +1396,25 @@
1311
1396
  if (typeof document !== 'undefined') {
1312
1397
  this.boundHandler = this.handleClick.bind(this);
1313
1398
  document.addEventListener('click', this.boundHandler, true);
1314
- // Setup SPA navigation reset
1315
- this.setupNavigationReset();
1399
+ }
1400
+ if (typeof window !== 'undefined') {
1401
+ // Listen for navigation events dispatched by PageViewPlugin
1402
+ // instead of independently monkey-patching history.pushState
1403
+ this.navigationHandler = () => this.resetForNavigation();
1404
+ window.addEventListener('clianta:navigation', this.navigationHandler);
1405
+ // Handle back/forward navigation
1406
+ this.popstateHandler = () => this.resetForNavigation();
1407
+ window.addEventListener('popstate', this.popstateHandler);
1316
1408
  }
1317
1409
  }
1318
1410
  destroy() {
1319
1411
  if (this.boundHandler && typeof document !== 'undefined') {
1320
1412
  document.removeEventListener('click', this.boundHandler, true);
1321
1413
  }
1322
- // Restore original history methods
1323
- if (this.originalPushState) {
1324
- history.pushState = this.originalPushState;
1325
- this.originalPushState = null;
1326
- }
1327
- if (this.originalReplaceState) {
1328
- history.replaceState = this.originalReplaceState;
1329
- this.originalReplaceState = null;
1414
+ if (this.navigationHandler && typeof window !== 'undefined') {
1415
+ window.removeEventListener('clianta:navigation', this.navigationHandler);
1416
+ this.navigationHandler = null;
1330
1417
  }
1331
- // Remove popstate listener
1332
1418
  if (this.popstateHandler && typeof window !== 'undefined') {
1333
1419
  window.removeEventListener('popstate', this.popstateHandler);
1334
1420
  this.popstateHandler = null;
@@ -1341,29 +1427,6 @@
1341
1427
  resetForNavigation() {
1342
1428
  this.trackedDownloads.clear();
1343
1429
  }
1344
- /**
1345
- * Setup History API interception for SPA navigation
1346
- */
1347
- setupNavigationReset() {
1348
- if (typeof window === 'undefined')
1349
- return;
1350
- // Store originals for cleanup
1351
- this.originalPushState = history.pushState;
1352
- this.originalReplaceState = history.replaceState;
1353
- // Intercept pushState and replaceState
1354
- const self = this;
1355
- history.pushState = function (...args) {
1356
- self.originalPushState.apply(history, args);
1357
- self.resetForNavigation();
1358
- };
1359
- history.replaceState = function (...args) {
1360
- self.originalReplaceState.apply(history, args);
1361
- self.resetForNavigation();
1362
- };
1363
- // Handle back/forward navigation
1364
- this.popstateHandler = () => this.resetForNavigation();
1365
- window.addEventListener('popstate', this.popstateHandler);
1366
- }
1367
1430
  handleClick(e) {
1368
1431
  const link = e.target.closest('a');
1369
1432
  if (!link || !link.href)
@@ -1399,6 +1462,9 @@
1399
1462
  this.exitIntentShown = false;
1400
1463
  this.pageLoadTime = 0;
1401
1464
  this.boundHandler = null;
1465
+ /** SPA navigation — listen for PageViewPlugin's custom event instead of patching history */
1466
+ this.navigationHandler = null;
1467
+ this.popstateHandler = null;
1402
1468
  }
1403
1469
  init(tracker) {
1404
1470
  super.init(tracker);
@@ -1410,13 +1476,34 @@
1410
1476
  this.boundHandler = this.handleMouseLeave.bind(this);
1411
1477
  document.addEventListener('mouseleave', this.boundHandler);
1412
1478
  }
1479
+ if (typeof window !== 'undefined') {
1480
+ // Listen for navigation events dispatched by PageViewPlugin
1481
+ // instead of independently monkey-patching history.pushState
1482
+ this.navigationHandler = () => this.resetForNavigation();
1483
+ window.addEventListener('clianta:navigation', this.navigationHandler);
1484
+ // Handle back/forward navigation
1485
+ this.popstateHandler = () => this.resetForNavigation();
1486
+ window.addEventListener('popstate', this.popstateHandler);
1487
+ }
1413
1488
  }
1414
1489
  destroy() {
1415
1490
  if (this.boundHandler && typeof document !== 'undefined') {
1416
1491
  document.removeEventListener('mouseleave', this.boundHandler);
1417
1492
  }
1493
+ if (this.navigationHandler && typeof window !== 'undefined') {
1494
+ window.removeEventListener('clianta:navigation', this.navigationHandler);
1495
+ this.navigationHandler = null;
1496
+ }
1497
+ if (this.popstateHandler && typeof window !== 'undefined') {
1498
+ window.removeEventListener('popstate', this.popstateHandler);
1499
+ this.popstateHandler = null;
1500
+ }
1418
1501
  super.destroy();
1419
1502
  }
1503
+ resetForNavigation() {
1504
+ this.exitIntentShown = false;
1505
+ this.pageLoadTime = Date.now();
1506
+ }
1420
1507
  handleMouseLeave(e) {
1421
1508
  // Only trigger when mouse leaves from the top of the page
1422
1509
  if (e.clientY > 0 || this.exitIntentShown)
@@ -1644,6 +1731,8 @@
1644
1731
  this.shownForms = new Set();
1645
1732
  this.scrollHandler = null;
1646
1733
  this.exitHandler = null;
1734
+ this.delayTimers = [];
1735
+ this.clickTriggerListeners = [];
1647
1736
  }
1648
1737
  async init(tracker) {
1649
1738
  super.init(tracker);
@@ -1658,6 +1747,14 @@
1658
1747
  }
1659
1748
  destroy() {
1660
1749
  this.removeTriggers();
1750
+ for (const timer of this.delayTimers) {
1751
+ clearTimeout(timer);
1752
+ }
1753
+ this.delayTimers = [];
1754
+ for (const { element, handler } of this.clickTriggerListeners) {
1755
+ element.removeEventListener('click', handler);
1756
+ }
1757
+ this.clickTriggerListeners = [];
1661
1758
  super.destroy();
1662
1759
  }
1663
1760
  loadShownForms() {
@@ -1720,7 +1817,7 @@
1720
1817
  this.forms.forEach(form => {
1721
1818
  switch (form.trigger.type) {
1722
1819
  case 'delay':
1723
- setTimeout(() => this.showForm(form), (form.trigger.value || 5) * 1000);
1820
+ this.delayTimers.push(setTimeout(() => this.showForm(form), (form.trigger.value || 5) * 1000));
1724
1821
  break;
1725
1822
  case 'scroll':
1726
1823
  this.setupScrollTrigger(form);
@@ -1763,7 +1860,9 @@
1763
1860
  return;
1764
1861
  const elements = document.querySelectorAll(form.trigger.selector);
1765
1862
  elements.forEach(el => {
1766
- el.addEventListener('click', () => this.showForm(form));
1863
+ const handler = () => this.showForm(form);
1864
+ el.addEventListener('click', handler);
1865
+ this.clickTriggerListeners.push({ element: el, handler });
1767
1866
  });
1768
1867
  }
1769
1868
  removeTriggers() {
@@ -2050,7 +2149,7 @@
2050
2149
  const submitBtn = formElement.querySelector('button[type="submit"]');
2051
2150
  if (submitBtn) {
2052
2151
  submitBtn.disabled = true;
2053
- submitBtn.innerHTML = 'Submitting...';
2152
+ submitBtn.textContent = 'Submitting...';
2054
2153
  }
2055
2154
  try {
2056
2155
  const response = await fetch(`${apiEndpoint}/api/public/lead-forms/${form._id}/submit`, {
@@ -2091,11 +2190,24 @@
2091
2190
  if (data.email) {
2092
2191
  this.tracker?.identify(data.email, data);
2093
2192
  }
2094
- // Redirect if configured
2193
+ // Redirect if configured (validate URL to prevent open redirect)
2095
2194
  if (form.redirectUrl) {
2096
- setTimeout(() => {
2097
- window.location.href = form.redirectUrl;
2098
- }, 1500);
2195
+ try {
2196
+ const redirect = new URL(form.redirectUrl, window.location.origin);
2197
+ const isSameOrigin = redirect.origin === window.location.origin;
2198
+ const isSafeProtocol = redirect.protocol === 'https:' || redirect.protocol === 'http:';
2199
+ if (isSameOrigin || isSafeProtocol) {
2200
+ setTimeout(() => {
2201
+ window.location.href = redirect.href;
2202
+ }, 1500);
2203
+ }
2204
+ else {
2205
+ console.warn('[Clianta] Blocked unsafe redirect URL:', form.redirectUrl);
2206
+ }
2207
+ }
2208
+ catch {
2209
+ console.warn('[Clianta] Invalid redirect URL:', form.redirectUrl);
2210
+ }
2099
2211
  }
2100
2212
  // Close after delay
2101
2213
  setTimeout(() => {
@@ -2110,7 +2222,7 @@
2110
2222
  console.error('[Clianta] Form submit error:', error);
2111
2223
  if (submitBtn) {
2112
2224
  submitBtn.disabled = false;
2113
- submitBtn.innerHTML = form.submitButtonText || 'Subscribe';
2225
+ submitBtn.textContent = form.submitButtonText || 'Subscribe';
2114
2226
  }
2115
2227
  }
2116
2228
  }
@@ -3441,6 +3553,114 @@
3441
3553
  });
3442
3554
  }
3443
3555
  // ============================================
3556
+ // READ-BACK / DATA RETRIEVAL API
3557
+ // ============================================
3558
+ /**
3559
+ * Get a contact by email address.
3560
+ * Returns the first matching contact from a search query.
3561
+ */
3562
+ async getContactByEmail(email) {
3563
+ this.validateRequired('email', email, 'getContactByEmail');
3564
+ const queryParams = new URLSearchParams({ search: email, limit: '1' });
3565
+ return this.request(`/api/workspaces/${this.workspaceId}/contacts?${queryParams.toString()}`);
3566
+ }
3567
+ /**
3568
+ * Get activity timeline for a contact
3569
+ */
3570
+ async getContactActivity(contactId, params) {
3571
+ this.validateRequired('contactId', contactId, 'getContactActivity');
3572
+ const queryParams = new URLSearchParams();
3573
+ if (params?.page)
3574
+ queryParams.set('page', params.page.toString());
3575
+ if (params?.limit)
3576
+ queryParams.set('limit', params.limit.toString());
3577
+ if (params?.type)
3578
+ queryParams.set('type', params.type);
3579
+ if (params?.startDate)
3580
+ queryParams.set('startDate', params.startDate);
3581
+ if (params?.endDate)
3582
+ queryParams.set('endDate', params.endDate);
3583
+ const query = queryParams.toString();
3584
+ const endpoint = `/api/workspaces/${this.workspaceId}/contacts/${contactId}/activities${query ? `?${query}` : ''}`;
3585
+ return this.request(endpoint);
3586
+ }
3587
+ /**
3588
+ * Get engagement metrics for a contact (via their linked visitor data)
3589
+ */
3590
+ async getContactEngagement(contactId) {
3591
+ this.validateRequired('contactId', contactId, 'getContactEngagement');
3592
+ return this.request(`/api/workspaces/${this.workspaceId}/contacts/${contactId}/engagement`);
3593
+ }
3594
+ /**
3595
+ * Get a full timeline for a contact including events, activities, and opportunities
3596
+ */
3597
+ async getContactTimeline(contactId, params) {
3598
+ this.validateRequired('contactId', contactId, 'getContactTimeline');
3599
+ const queryParams = new URLSearchParams();
3600
+ if (params?.page)
3601
+ queryParams.set('page', params.page.toString());
3602
+ if (params?.limit)
3603
+ queryParams.set('limit', params.limit.toString());
3604
+ const query = queryParams.toString();
3605
+ const endpoint = `/api/workspaces/${this.workspaceId}/contacts/${contactId}/timeline${query ? `?${query}` : ''}`;
3606
+ return this.request(endpoint);
3607
+ }
3608
+ /**
3609
+ * Search contacts with advanced filters
3610
+ */
3611
+ async searchContacts(query, filters) {
3612
+ const queryParams = new URLSearchParams();
3613
+ queryParams.set('search', query);
3614
+ if (filters?.status)
3615
+ queryParams.set('status', filters.status);
3616
+ if (filters?.lifecycleStage)
3617
+ queryParams.set('lifecycleStage', filters.lifecycleStage);
3618
+ if (filters?.source)
3619
+ queryParams.set('source', filters.source);
3620
+ if (filters?.tags)
3621
+ queryParams.set('tags', filters.tags.join(','));
3622
+ if (filters?.page)
3623
+ queryParams.set('page', filters.page.toString());
3624
+ if (filters?.limit)
3625
+ queryParams.set('limit', filters.limit.toString());
3626
+ const qs = queryParams.toString();
3627
+ const endpoint = `/api/workspaces/${this.workspaceId}/contacts${qs ? `?${qs}` : ''}`;
3628
+ return this.request(endpoint);
3629
+ }
3630
+ // ============================================
3631
+ // WEBHOOK MANAGEMENT API
3632
+ // ============================================
3633
+ /**
3634
+ * List all webhook subscriptions
3635
+ */
3636
+ async listWebhooks(params) {
3637
+ const queryParams = new URLSearchParams();
3638
+ if (params?.page)
3639
+ queryParams.set('page', params.page.toString());
3640
+ if (params?.limit)
3641
+ queryParams.set('limit', params.limit.toString());
3642
+ const query = queryParams.toString();
3643
+ return this.request(`/api/workspaces/${this.workspaceId}/webhooks${query ? `?${query}` : ''}`);
3644
+ }
3645
+ /**
3646
+ * Create a new webhook subscription
3647
+ */
3648
+ async createWebhook(data) {
3649
+ return this.request(`/api/workspaces/${this.workspaceId}/webhooks`, {
3650
+ method: 'POST',
3651
+ body: JSON.stringify(data),
3652
+ });
3653
+ }
3654
+ /**
3655
+ * Delete a webhook subscription
3656
+ */
3657
+ async deleteWebhook(webhookId) {
3658
+ this.validateRequired('webhookId', webhookId, 'deleteWebhook');
3659
+ return this.request(`/api/workspaces/${this.workspaceId}/webhooks/${webhookId}`, {
3660
+ method: 'DELETE',
3661
+ });
3662
+ }
3663
+ // ============================================
3444
3664
  // EVENT TRIGGERS API (delegated to triggers manager)
3445
3665
  // ============================================
3446
3666
  /**
@@ -3484,6 +3704,8 @@
3484
3704
  this.contactId = null;
3485
3705
  /** Pending identify retry on next flush */
3486
3706
  this.pendingIdentify = null;
3707
+ /** Registered event schemas for validation */
3708
+ this.eventSchemas = new Map();
3487
3709
  if (!workspaceId) {
3488
3710
  throw new Error('[Clianta] Workspace ID is required');
3489
3711
  }
@@ -3509,6 +3731,16 @@
3509
3731
  this.visitorId = this.createVisitorId();
3510
3732
  this.sessionId = this.createSessionId();
3511
3733
  logger.debug('IDs created', { visitorId: this.visitorId, sessionId: this.sessionId });
3734
+ // Security warnings
3735
+ if (this.config.apiEndpoint.startsWith('http://') &&
3736
+ typeof window !== 'undefined' &&
3737
+ !window.location.hostname.includes('localhost') &&
3738
+ !window.location.hostname.includes('127.0.0.1')) {
3739
+ logger.warn('apiEndpoint uses HTTP — events and visitor data will be sent unencrypted. Use HTTPS in production.');
3740
+ }
3741
+ if (this.config.apiKey && typeof window !== 'undefined') {
3742
+ logger.warn('API key is exposed in client-side code. Use API keys only in server-side (Node.js) environments.');
3743
+ }
3512
3744
  // Initialize plugins
3513
3745
  this.initPlugins();
3514
3746
  this.isInitialized = true;
@@ -3614,6 +3846,7 @@
3614
3846
  properties: {
3615
3847
  ...properties,
3616
3848
  eventId: generateUUID(), // Unique ID for deduplication on retry
3849
+ websiteDomain: typeof window !== 'undefined' ? window.location.hostname : undefined,
3617
3850
  },
3618
3851
  device: getDeviceInfo(),
3619
3852
  ...getUTMParams(),
@@ -3624,6 +3857,8 @@
3624
3857
  if (this.contactId) {
3625
3858
  event.contactId = this.contactId;
3626
3859
  }
3860
+ // Validate event against registered schema (debug mode only)
3861
+ this.validateEventSchema(eventType, properties);
3627
3862
  // Check consent before tracking
3628
3863
  if (!this.consentManager.canTrack()) {
3629
3864
  // Buffer event for later if waitForConsent is enabled
@@ -3658,6 +3893,10 @@
3658
3893
  logger.warn('Email is required for identification');
3659
3894
  return null;
3660
3895
  }
3896
+ if (!isValidEmail(email)) {
3897
+ logger.warn('Invalid email format, identification skipped:', email);
3898
+ return null;
3899
+ }
3661
3900
  logger.info('Identifying visitor:', email);
3662
3901
  const result = await this.transport.sendIdentify({
3663
3902
  workspaceId: this.workspaceId,
@@ -3692,6 +3931,83 @@
3692
3931
  const client = new CRMClient(this.config.apiEndpoint, this.workspaceId, undefined, apiKey);
3693
3932
  return client.sendEvent(payload);
3694
3933
  }
3934
+ /**
3935
+ * Get the current visitor's profile from the CRM.
3936
+ * Returns visitor data and linked contact info if identified.
3937
+ * Only returns data for the current visitor (privacy-safe for frontend).
3938
+ */
3939
+ async getVisitorProfile() {
3940
+ if (!this.isInitialized) {
3941
+ logger.warn('SDK not initialized');
3942
+ return null;
3943
+ }
3944
+ const result = await this.transport.fetchData(`/api/public/track/visitor/${this.workspaceId}/${this.visitorId}/profile`);
3945
+ if (result.success && result.data) {
3946
+ logger.debug('Visitor profile fetched:', result.data);
3947
+ return result.data;
3948
+ }
3949
+ logger.warn('Failed to fetch visitor profile:', result.error);
3950
+ return null;
3951
+ }
3952
+ /**
3953
+ * Get the current visitor's recent activity/events.
3954
+ * Returns paginated list of tracking events for this visitor.
3955
+ */
3956
+ async getVisitorActivity(options) {
3957
+ if (!this.isInitialized) {
3958
+ logger.warn('SDK not initialized');
3959
+ return null;
3960
+ }
3961
+ const params = {};
3962
+ if (options?.page)
3963
+ params.page = options.page.toString();
3964
+ if (options?.limit)
3965
+ params.limit = options.limit.toString();
3966
+ if (options?.eventType)
3967
+ params.eventType = options.eventType;
3968
+ if (options?.startDate)
3969
+ params.startDate = options.startDate;
3970
+ if (options?.endDate)
3971
+ params.endDate = options.endDate;
3972
+ const result = await this.transport.fetchData(`/api/public/track/visitor/${this.workspaceId}/${this.visitorId}/activity`, params);
3973
+ if (result.success && result.data) {
3974
+ return result.data;
3975
+ }
3976
+ logger.warn('Failed to fetch visitor activity:', result.error);
3977
+ return null;
3978
+ }
3979
+ /**
3980
+ * Get a summarized journey timeline for the current visitor.
3981
+ * Includes top pages, sessions, time spent, and recent activities.
3982
+ */
3983
+ async getVisitorTimeline() {
3984
+ if (!this.isInitialized) {
3985
+ logger.warn('SDK not initialized');
3986
+ return null;
3987
+ }
3988
+ const result = await this.transport.fetchData(`/api/public/track/visitor/${this.workspaceId}/${this.visitorId}/timeline`);
3989
+ if (result.success && result.data) {
3990
+ return result.data;
3991
+ }
3992
+ logger.warn('Failed to fetch visitor timeline:', result.error);
3993
+ return null;
3994
+ }
3995
+ /**
3996
+ * Get engagement metrics for the current visitor.
3997
+ * Includes time on site, page views, bounce rate, and engagement score.
3998
+ */
3999
+ async getVisitorEngagement() {
4000
+ if (!this.isInitialized) {
4001
+ logger.warn('SDK not initialized');
4002
+ return null;
4003
+ }
4004
+ const result = await this.transport.fetchData(`/api/public/track/visitor/${this.workspaceId}/${this.visitorId}/engagement`);
4005
+ if (result.success && result.data) {
4006
+ return result.data;
4007
+ }
4008
+ logger.warn('Failed to fetch visitor engagement:', result.error);
4009
+ return null;
4010
+ }
3695
4011
  /**
3696
4012
  * Retry pending identify call
3697
4013
  */
@@ -3721,6 +4037,59 @@
3721
4037
  logger.enabled = enabled;
3722
4038
  logger.info(`Debug mode ${enabled ? 'enabled' : 'disabled'}`);
3723
4039
  }
4040
+ /**
4041
+ * Register a schema for event validation.
4042
+ * When debug mode is enabled, events will be validated against registered schemas.
4043
+ *
4044
+ * @example
4045
+ * tracker.registerEventSchema('purchase', {
4046
+ * productId: 'string',
4047
+ * price: 'number',
4048
+ * quantity: 'number',
4049
+ * });
4050
+ */
4051
+ registerEventSchema(eventType, schema) {
4052
+ this.eventSchemas.set(eventType, schema);
4053
+ logger.debug('Event schema registered:', eventType);
4054
+ }
4055
+ /**
4056
+ * Validate event properties against a registered schema (debug mode only)
4057
+ */
4058
+ validateEventSchema(eventType, properties) {
4059
+ if (!this.config.debug)
4060
+ return;
4061
+ const schema = this.eventSchemas.get(eventType);
4062
+ if (!schema)
4063
+ return;
4064
+ for (const [key, expectedType] of Object.entries(schema)) {
4065
+ const value = properties[key];
4066
+ if (value === undefined) {
4067
+ logger.warn(`[Schema] Missing property "${key}" for event type "${eventType}"`);
4068
+ continue;
4069
+ }
4070
+ let valid = false;
4071
+ switch (expectedType) {
4072
+ case 'string':
4073
+ valid = typeof value === 'string';
4074
+ break;
4075
+ case 'number':
4076
+ valid = typeof value === 'number';
4077
+ break;
4078
+ case 'boolean':
4079
+ valid = typeof value === 'boolean';
4080
+ break;
4081
+ case 'object':
4082
+ valid = typeof value === 'object' && !Array.isArray(value);
4083
+ break;
4084
+ case 'array':
4085
+ valid = Array.isArray(value);
4086
+ break;
4087
+ }
4088
+ if (!valid) {
4089
+ logger.warn(`[Schema] Property "${key}" for event "${eventType}" expected ${expectedType}, got ${typeof value}`);
4090
+ }
4091
+ }
4092
+ }
3724
4093
  /**
3725
4094
  * Get visitor ID
3726
4095
  */
@@ -3760,6 +4129,8 @@
3760
4129
  resetIds(this.config.useCookies);
3761
4130
  this.visitorId = this.createVisitorId();
3762
4131
  this.sessionId = this.createSessionId();
4132
+ this.contactId = null;
4133
+ this.pendingIdentify = null;
3763
4134
  this.queue.clear();
3764
4135
  }
3765
4136
  /**