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