@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
  */
@@ -50,6 +50,7 @@ const DEFAULT_CONFIG = {
50
50
  cookieDomain: '',
51
51
  useCookies: false,
52
52
  cookielessMode: false,
53
+ persistMode: 'session',
53
54
  };
54
55
  /** Storage keys */
55
56
  const STORAGE_KEYS = {
@@ -243,6 +244,39 @@ class Transport {
243
244
  return false;
244
245
  }
245
246
  }
247
+ /**
248
+ * Fetch data from the tracking API (GET request)
249
+ * Used for read-back APIs (visitor profile, activity, etc.)
250
+ */
251
+ async fetchData(path, params) {
252
+ const url = new URL(`${this.config.apiEndpoint}${path}`);
253
+ if (params) {
254
+ Object.entries(params).forEach(([key, value]) => {
255
+ if (value !== undefined && value !== null) {
256
+ url.searchParams.set(key, value);
257
+ }
258
+ });
259
+ }
260
+ try {
261
+ const response = await this.fetchWithTimeout(url.toString(), {
262
+ method: 'GET',
263
+ headers: {
264
+ 'Accept': 'application/json',
265
+ },
266
+ });
267
+ if (response.ok) {
268
+ const body = await response.json();
269
+ logger.debug('Fetch successful:', path);
270
+ return { success: true, data: body.data ?? body, status: response.status };
271
+ }
272
+ logger.error(`Fetch failed with status ${response.status}`);
273
+ return { success: false, status: response.status };
274
+ }
275
+ catch (error) {
276
+ logger.error('Fetch request failed:', error);
277
+ return { success: false, error: error };
278
+ }
279
+ }
246
280
  /**
247
281
  * Internal send with retry logic
248
282
  */
@@ -407,7 +441,9 @@ function cookie(name, value, days) {
407
441
  date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
408
442
  expires = '; expires=' + date.toUTCString();
409
443
  }
410
- document.cookie = name + '=' + value + expires + '; path=/; SameSite=Lax';
444
+ // Add Secure flag on HTTPS to prevent cookie leakage over plaintext
445
+ const secure = typeof location !== 'undefined' && location.protocol === 'https:' ? '; Secure' : '';
446
+ document.cookie = name + '=' + value + expires + '; path=/; SameSite=Lax' + secure;
411
447
  return value;
412
448
  }
413
449
  // ============================================
@@ -571,6 +607,17 @@ function isMobile() {
571
607
  return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
572
608
  }
573
609
  // ============================================
610
+ // VALIDATION UTILITIES
611
+ // ============================================
612
+ /**
613
+ * Validate email format
614
+ */
615
+ function isValidEmail(email) {
616
+ if (typeof email !== 'string' || !email)
617
+ return false;
618
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
619
+ }
620
+ // ============================================
574
621
  // DEVICE INFO
575
622
  // ============================================
576
623
  /**
@@ -624,6 +671,7 @@ class EventQueue {
624
671
  maxQueueSize: config.maxQueueSize ?? MAX_QUEUE_SIZE,
625
672
  storageKey: config.storageKey ?? STORAGE_KEYS.EVENT_QUEUE,
626
673
  };
674
+ this.persistMode = config.persistMode || 'session';
627
675
  // Restore persisted queue
628
676
  this.restoreQueue();
629
677
  // Start auto-flush timer
@@ -729,6 +777,13 @@ class EventQueue {
729
777
  clear() {
730
778
  this.queue = [];
731
779
  this.persistQueue([]);
780
+ // Also clear localStorage if used
781
+ if (this.persistMode === 'local' && typeof localStorage !== 'undefined') {
782
+ try {
783
+ localStorage.removeItem(this.config.storageKey);
784
+ }
785
+ catch { /* ignore */ }
786
+ }
732
787
  }
733
788
  /**
734
789
  * Stop the flush timer and cleanup handlers
@@ -783,22 +838,44 @@ class EventQueue {
783
838
  window.addEventListener('pagehide', this.boundPageHide);
784
839
  }
785
840
  /**
786
- * Persist queue to localStorage
841
+ * Persist queue to storage based on persistMode
787
842
  */
788
843
  persistQueue(events) {
844
+ if (this.persistMode === 'none')
845
+ return;
789
846
  try {
790
- setLocalStorage(this.config.storageKey, JSON.stringify(events));
847
+ const serialized = JSON.stringify(events);
848
+ if (this.persistMode === 'local' && typeof localStorage !== 'undefined') {
849
+ try {
850
+ localStorage.setItem(this.config.storageKey, serialized);
851
+ }
852
+ catch {
853
+ // localStorage quota exceeded — fallback to sessionStorage
854
+ setSessionStorage(this.config.storageKey, serialized);
855
+ }
856
+ }
857
+ else {
858
+ setSessionStorage(this.config.storageKey, serialized);
859
+ }
791
860
  }
792
861
  catch {
793
862
  // Ignore storage errors
794
863
  }
795
864
  }
796
865
  /**
797
- * Restore queue from localStorage
866
+ * Restore queue from storage
798
867
  */
799
868
  restoreQueue() {
800
869
  try {
801
- const stored = getLocalStorage(this.config.storageKey);
870
+ let stored = null;
871
+ // Check localStorage first (cross-session persistence)
872
+ if (this.persistMode === 'local' && typeof localStorage !== 'undefined') {
873
+ stored = localStorage.getItem(this.config.storageKey);
874
+ }
875
+ // Fall back to sessionStorage
876
+ if (!stored) {
877
+ stored = getSessionStorage(this.config.storageKey);
878
+ }
802
879
  if (stored) {
803
880
  const events = JSON.parse(stored);
804
881
  if (Array.isArray(events) && events.length > 0) {
@@ -866,10 +943,13 @@ class PageViewPlugin extends BasePlugin {
866
943
  history.pushState = function (...args) {
867
944
  self.originalPushState.apply(history, args);
868
945
  self.trackPageView();
946
+ // Notify other plugins (e.g. ScrollPlugin) about navigation
947
+ window.dispatchEvent(new Event('clianta:navigation'));
869
948
  };
870
949
  history.replaceState = function (...args) {
871
950
  self.originalReplaceState.apply(history, args);
872
951
  self.trackPageView();
952
+ window.dispatchEvent(new Event('clianta:navigation'));
873
953
  };
874
954
  // Handle back/forward navigation
875
955
  this.popstateHandler = () => this.trackPageView();
@@ -923,9 +1003,8 @@ class ScrollPlugin extends BasePlugin {
923
1003
  this.pageLoadTime = 0;
924
1004
  this.scrollTimeout = null;
925
1005
  this.boundHandler = null;
926
- /** SPA navigation support */
927
- this.originalPushState = null;
928
- this.originalReplaceState = null;
1006
+ /** SPA navigation listen for PageViewPlugin's custom event instead of patching history */
1007
+ this.navigationHandler = null;
929
1008
  this.popstateHandler = null;
930
1009
  }
931
1010
  init(tracker) {
@@ -934,8 +1013,13 @@ class ScrollPlugin extends BasePlugin {
934
1013
  if (typeof window !== 'undefined') {
935
1014
  this.boundHandler = this.handleScroll.bind(this);
936
1015
  window.addEventListener('scroll', this.boundHandler, { passive: true });
937
- // Setup SPA navigation reset
938
- this.setupNavigationReset();
1016
+ // Listen for navigation events dispatched by PageViewPlugin
1017
+ // instead of independently monkey-patching history.pushState
1018
+ this.navigationHandler = () => this.resetForNavigation();
1019
+ window.addEventListener('clianta:navigation', this.navigationHandler);
1020
+ // Handle back/forward navigation
1021
+ this.popstateHandler = () => this.resetForNavigation();
1022
+ window.addEventListener('popstate', this.popstateHandler);
939
1023
  }
940
1024
  }
941
1025
  destroy() {
@@ -945,16 +1029,10 @@ class ScrollPlugin extends BasePlugin {
945
1029
  if (this.scrollTimeout) {
946
1030
  clearTimeout(this.scrollTimeout);
947
1031
  }
948
- // Restore original history methods
949
- if (this.originalPushState) {
950
- history.pushState = this.originalPushState;
951
- this.originalPushState = null;
1032
+ if (this.navigationHandler && typeof window !== 'undefined') {
1033
+ window.removeEventListener('clianta:navigation', this.navigationHandler);
1034
+ this.navigationHandler = null;
952
1035
  }
953
- if (this.originalReplaceState) {
954
- history.replaceState = this.originalReplaceState;
955
- this.originalReplaceState = null;
956
- }
957
- // Remove popstate listener
958
1036
  if (this.popstateHandler && typeof window !== 'undefined') {
959
1037
  window.removeEventListener('popstate', this.popstateHandler);
960
1038
  this.popstateHandler = null;
@@ -969,29 +1047,6 @@ class ScrollPlugin extends BasePlugin {
969
1047
  this.maxScrollDepth = 0;
970
1048
  this.pageLoadTime = Date.now();
971
1049
  }
972
- /**
973
- * Setup History API interception for SPA navigation
974
- */
975
- setupNavigationReset() {
976
- if (typeof window === 'undefined')
977
- return;
978
- // Store originals for cleanup
979
- this.originalPushState = history.pushState;
980
- this.originalReplaceState = history.replaceState;
981
- // Intercept pushState and replaceState
982
- const self = this;
983
- history.pushState = function (...args) {
984
- self.originalPushState.apply(history, args);
985
- self.resetForNavigation();
986
- };
987
- history.replaceState = function (...args) {
988
- self.originalReplaceState.apply(history, args);
989
- self.resetForNavigation();
990
- };
991
- // Handle back/forward navigation
992
- this.popstateHandler = () => this.resetForNavigation();
993
- window.addEventListener('popstate', this.popstateHandler);
994
- }
995
1050
  handleScroll() {
996
1051
  // Debounce scroll tracking
997
1052
  if (this.scrollTimeout) {
@@ -1191,6 +1246,10 @@ class ClicksPlugin extends BasePlugin {
1191
1246
  elementId: elementInfo.id,
1192
1247
  elementClass: elementInfo.className,
1193
1248
  href: target.href || undefined,
1249
+ x: Math.round((e.clientX / window.innerWidth) * 100),
1250
+ y: Math.round((e.clientY / window.innerHeight) * 100),
1251
+ viewportWidth: window.innerWidth,
1252
+ viewportHeight: window.innerHeight,
1194
1253
  });
1195
1254
  }
1196
1255
  }
@@ -1213,6 +1272,9 @@ class EngagementPlugin extends BasePlugin {
1213
1272
  this.boundMarkEngaged = null;
1214
1273
  this.boundTrackTimeOnPage = null;
1215
1274
  this.boundVisibilityHandler = null;
1275
+ /** SPA navigation — listen for PageViewPlugin's custom event instead of patching history */
1276
+ this.navigationHandler = null;
1277
+ this.popstateHandler = null;
1216
1278
  }
1217
1279
  init(tracker) {
1218
1280
  super.init(tracker);
@@ -1238,6 +1300,13 @@ class EngagementPlugin extends BasePlugin {
1238
1300
  // Track time on page before unload
1239
1301
  window.addEventListener('beforeunload', this.boundTrackTimeOnPage);
1240
1302
  document.addEventListener('visibilitychange', this.boundVisibilityHandler);
1303
+ // Listen for navigation events dispatched by PageViewPlugin
1304
+ // instead of independently monkey-patching history.pushState
1305
+ this.navigationHandler = () => this.resetForNavigation();
1306
+ window.addEventListener('clianta:navigation', this.navigationHandler);
1307
+ // Handle back/forward navigation
1308
+ this.popstateHandler = () => this.resetForNavigation();
1309
+ window.addEventListener('popstate', this.popstateHandler);
1241
1310
  }
1242
1311
  destroy() {
1243
1312
  if (this.boundMarkEngaged && typeof document !== 'undefined') {
@@ -1251,11 +1320,28 @@ class EngagementPlugin extends BasePlugin {
1251
1320
  if (this.boundVisibilityHandler && typeof document !== 'undefined') {
1252
1321
  document.removeEventListener('visibilitychange', this.boundVisibilityHandler);
1253
1322
  }
1323
+ if (this.navigationHandler && typeof window !== 'undefined') {
1324
+ window.removeEventListener('clianta:navigation', this.navigationHandler);
1325
+ this.navigationHandler = null;
1326
+ }
1327
+ if (this.popstateHandler && typeof window !== 'undefined') {
1328
+ window.removeEventListener('popstate', this.popstateHandler);
1329
+ this.popstateHandler = null;
1330
+ }
1254
1331
  if (this.engagementTimeout) {
1255
1332
  clearTimeout(this.engagementTimeout);
1256
1333
  }
1257
1334
  super.destroy();
1258
1335
  }
1336
+ resetForNavigation() {
1337
+ this.pageLoadTime = Date.now();
1338
+ this.engagementStartTime = Date.now();
1339
+ this.isEngaged = false;
1340
+ if (this.engagementTimeout) {
1341
+ clearTimeout(this.engagementTimeout);
1342
+ this.engagementTimeout = null;
1343
+ }
1344
+ }
1259
1345
  markEngaged() {
1260
1346
  if (!this.isEngaged) {
1261
1347
  this.isEngaged = true;
@@ -1295,9 +1381,8 @@ class DownloadsPlugin extends BasePlugin {
1295
1381
  this.name = 'downloads';
1296
1382
  this.trackedDownloads = new Set();
1297
1383
  this.boundHandler = null;
1298
- /** SPA navigation support */
1299
- this.originalPushState = null;
1300
- this.originalReplaceState = null;
1384
+ /** SPA navigation listen for PageViewPlugin's custom event instead of patching history */
1385
+ this.navigationHandler = null;
1301
1386
  this.popstateHandler = null;
1302
1387
  }
1303
1388
  init(tracker) {
@@ -1305,24 +1390,25 @@ class DownloadsPlugin extends BasePlugin {
1305
1390
  if (typeof document !== 'undefined') {
1306
1391
  this.boundHandler = this.handleClick.bind(this);
1307
1392
  document.addEventListener('click', this.boundHandler, true);
1308
- // Setup SPA navigation reset
1309
- this.setupNavigationReset();
1393
+ }
1394
+ if (typeof window !== 'undefined') {
1395
+ // Listen for navigation events dispatched by PageViewPlugin
1396
+ // instead of independently monkey-patching history.pushState
1397
+ this.navigationHandler = () => this.resetForNavigation();
1398
+ window.addEventListener('clianta:navigation', this.navigationHandler);
1399
+ // Handle back/forward navigation
1400
+ this.popstateHandler = () => this.resetForNavigation();
1401
+ window.addEventListener('popstate', this.popstateHandler);
1310
1402
  }
1311
1403
  }
1312
1404
  destroy() {
1313
1405
  if (this.boundHandler && typeof document !== 'undefined') {
1314
1406
  document.removeEventListener('click', this.boundHandler, true);
1315
1407
  }
1316
- // Restore original history methods
1317
- if (this.originalPushState) {
1318
- history.pushState = this.originalPushState;
1319
- this.originalPushState = null;
1320
- }
1321
- if (this.originalReplaceState) {
1322
- history.replaceState = this.originalReplaceState;
1323
- this.originalReplaceState = null;
1408
+ if (this.navigationHandler && typeof window !== 'undefined') {
1409
+ window.removeEventListener('clianta:navigation', this.navigationHandler);
1410
+ this.navigationHandler = null;
1324
1411
  }
1325
- // Remove popstate listener
1326
1412
  if (this.popstateHandler && typeof window !== 'undefined') {
1327
1413
  window.removeEventListener('popstate', this.popstateHandler);
1328
1414
  this.popstateHandler = null;
@@ -1335,29 +1421,6 @@ class DownloadsPlugin extends BasePlugin {
1335
1421
  resetForNavigation() {
1336
1422
  this.trackedDownloads.clear();
1337
1423
  }
1338
- /**
1339
- * Setup History API interception for SPA navigation
1340
- */
1341
- setupNavigationReset() {
1342
- if (typeof window === 'undefined')
1343
- return;
1344
- // Store originals for cleanup
1345
- this.originalPushState = history.pushState;
1346
- this.originalReplaceState = history.replaceState;
1347
- // Intercept pushState and replaceState
1348
- const self = this;
1349
- history.pushState = function (...args) {
1350
- self.originalPushState.apply(history, args);
1351
- self.resetForNavigation();
1352
- };
1353
- history.replaceState = function (...args) {
1354
- self.originalReplaceState.apply(history, args);
1355
- self.resetForNavigation();
1356
- };
1357
- // Handle back/forward navigation
1358
- this.popstateHandler = () => this.resetForNavigation();
1359
- window.addEventListener('popstate', this.popstateHandler);
1360
- }
1361
1424
  handleClick(e) {
1362
1425
  const link = e.target.closest('a');
1363
1426
  if (!link || !link.href)
@@ -1393,6 +1456,9 @@ class ExitIntentPlugin extends BasePlugin {
1393
1456
  this.exitIntentShown = false;
1394
1457
  this.pageLoadTime = 0;
1395
1458
  this.boundHandler = null;
1459
+ /** SPA navigation — listen for PageViewPlugin's custom event instead of patching history */
1460
+ this.navigationHandler = null;
1461
+ this.popstateHandler = null;
1396
1462
  }
1397
1463
  init(tracker) {
1398
1464
  super.init(tracker);
@@ -1404,13 +1470,34 @@ class ExitIntentPlugin extends BasePlugin {
1404
1470
  this.boundHandler = this.handleMouseLeave.bind(this);
1405
1471
  document.addEventListener('mouseleave', this.boundHandler);
1406
1472
  }
1473
+ if (typeof window !== 'undefined') {
1474
+ // Listen for navigation events dispatched by PageViewPlugin
1475
+ // instead of independently monkey-patching history.pushState
1476
+ this.navigationHandler = () => this.resetForNavigation();
1477
+ window.addEventListener('clianta:navigation', this.navigationHandler);
1478
+ // Handle back/forward navigation
1479
+ this.popstateHandler = () => this.resetForNavigation();
1480
+ window.addEventListener('popstate', this.popstateHandler);
1481
+ }
1407
1482
  }
1408
1483
  destroy() {
1409
1484
  if (this.boundHandler && typeof document !== 'undefined') {
1410
1485
  document.removeEventListener('mouseleave', this.boundHandler);
1411
1486
  }
1487
+ if (this.navigationHandler && typeof window !== 'undefined') {
1488
+ window.removeEventListener('clianta:navigation', this.navigationHandler);
1489
+ this.navigationHandler = null;
1490
+ }
1491
+ if (this.popstateHandler && typeof window !== 'undefined') {
1492
+ window.removeEventListener('popstate', this.popstateHandler);
1493
+ this.popstateHandler = null;
1494
+ }
1412
1495
  super.destroy();
1413
1496
  }
1497
+ resetForNavigation() {
1498
+ this.exitIntentShown = false;
1499
+ this.pageLoadTime = Date.now();
1500
+ }
1414
1501
  handleMouseLeave(e) {
1415
1502
  // Only trigger when mouse leaves from the top of the page
1416
1503
  if (e.clientY > 0 || this.exitIntentShown)
@@ -1638,6 +1725,8 @@ class PopupFormsPlugin extends BasePlugin {
1638
1725
  this.shownForms = new Set();
1639
1726
  this.scrollHandler = null;
1640
1727
  this.exitHandler = null;
1728
+ this.delayTimers = [];
1729
+ this.clickTriggerListeners = [];
1641
1730
  }
1642
1731
  async init(tracker) {
1643
1732
  super.init(tracker);
@@ -1652,6 +1741,14 @@ class PopupFormsPlugin extends BasePlugin {
1652
1741
  }
1653
1742
  destroy() {
1654
1743
  this.removeTriggers();
1744
+ for (const timer of this.delayTimers) {
1745
+ clearTimeout(timer);
1746
+ }
1747
+ this.delayTimers = [];
1748
+ for (const { element, handler } of this.clickTriggerListeners) {
1749
+ element.removeEventListener('click', handler);
1750
+ }
1751
+ this.clickTriggerListeners = [];
1655
1752
  super.destroy();
1656
1753
  }
1657
1754
  loadShownForms() {
@@ -1714,7 +1811,7 @@ class PopupFormsPlugin extends BasePlugin {
1714
1811
  this.forms.forEach(form => {
1715
1812
  switch (form.trigger.type) {
1716
1813
  case 'delay':
1717
- setTimeout(() => this.showForm(form), (form.trigger.value || 5) * 1000);
1814
+ this.delayTimers.push(setTimeout(() => this.showForm(form), (form.trigger.value || 5) * 1000));
1718
1815
  break;
1719
1816
  case 'scroll':
1720
1817
  this.setupScrollTrigger(form);
@@ -1757,7 +1854,9 @@ class PopupFormsPlugin extends BasePlugin {
1757
1854
  return;
1758
1855
  const elements = document.querySelectorAll(form.trigger.selector);
1759
1856
  elements.forEach(el => {
1760
- el.addEventListener('click', () => this.showForm(form));
1857
+ const handler = () => this.showForm(form);
1858
+ el.addEventListener('click', handler);
1859
+ this.clickTriggerListeners.push({ element: el, handler });
1761
1860
  });
1762
1861
  }
1763
1862
  removeTriggers() {
@@ -2044,7 +2143,7 @@ class PopupFormsPlugin extends BasePlugin {
2044
2143
  const submitBtn = formElement.querySelector('button[type="submit"]');
2045
2144
  if (submitBtn) {
2046
2145
  submitBtn.disabled = true;
2047
- submitBtn.innerHTML = 'Submitting...';
2146
+ submitBtn.textContent = 'Submitting...';
2048
2147
  }
2049
2148
  try {
2050
2149
  const response = await fetch(`${apiEndpoint}/api/public/lead-forms/${form._id}/submit`, {
@@ -2085,11 +2184,24 @@ class PopupFormsPlugin extends BasePlugin {
2085
2184
  if (data.email) {
2086
2185
  this.tracker?.identify(data.email, data);
2087
2186
  }
2088
- // Redirect if configured
2187
+ // Redirect if configured (validate URL to prevent open redirect)
2089
2188
  if (form.redirectUrl) {
2090
- setTimeout(() => {
2091
- window.location.href = form.redirectUrl;
2092
- }, 1500);
2189
+ try {
2190
+ const redirect = new URL(form.redirectUrl, window.location.origin);
2191
+ const isSameOrigin = redirect.origin === window.location.origin;
2192
+ const isSafeProtocol = redirect.protocol === 'https:' || redirect.protocol === 'http:';
2193
+ if (isSameOrigin || isSafeProtocol) {
2194
+ setTimeout(() => {
2195
+ window.location.href = redirect.href;
2196
+ }, 1500);
2197
+ }
2198
+ else {
2199
+ console.warn('[Clianta] Blocked unsafe redirect URL:', form.redirectUrl);
2200
+ }
2201
+ }
2202
+ catch {
2203
+ console.warn('[Clianta] Invalid redirect URL:', form.redirectUrl);
2204
+ }
2093
2205
  }
2094
2206
  // Close after delay
2095
2207
  setTimeout(() => {
@@ -2104,7 +2216,7 @@ class PopupFormsPlugin extends BasePlugin {
2104
2216
  console.error('[Clianta] Form submit error:', error);
2105
2217
  if (submitBtn) {
2106
2218
  submitBtn.disabled = false;
2107
- submitBtn.innerHTML = form.submitButtonText || 'Subscribe';
2219
+ submitBtn.textContent = form.submitButtonText || 'Subscribe';
2108
2220
  }
2109
2221
  }
2110
2222
  }
@@ -3435,6 +3547,114 @@ class CRMClient {
3435
3547
  });
3436
3548
  }
3437
3549
  // ============================================
3550
+ // READ-BACK / DATA RETRIEVAL API
3551
+ // ============================================
3552
+ /**
3553
+ * Get a contact by email address.
3554
+ * Returns the first matching contact from a search query.
3555
+ */
3556
+ async getContactByEmail(email) {
3557
+ this.validateRequired('email', email, 'getContactByEmail');
3558
+ const queryParams = new URLSearchParams({ search: email, limit: '1' });
3559
+ return this.request(`/api/workspaces/${this.workspaceId}/contacts?${queryParams.toString()}`);
3560
+ }
3561
+ /**
3562
+ * Get activity timeline for a contact
3563
+ */
3564
+ async getContactActivity(contactId, params) {
3565
+ this.validateRequired('contactId', contactId, 'getContactActivity');
3566
+ const queryParams = new URLSearchParams();
3567
+ if (params?.page)
3568
+ queryParams.set('page', params.page.toString());
3569
+ if (params?.limit)
3570
+ queryParams.set('limit', params.limit.toString());
3571
+ if (params?.type)
3572
+ queryParams.set('type', params.type);
3573
+ if (params?.startDate)
3574
+ queryParams.set('startDate', params.startDate);
3575
+ if (params?.endDate)
3576
+ queryParams.set('endDate', params.endDate);
3577
+ const query = queryParams.toString();
3578
+ const endpoint = `/api/workspaces/${this.workspaceId}/contacts/${contactId}/activities${query ? `?${query}` : ''}`;
3579
+ return this.request(endpoint);
3580
+ }
3581
+ /**
3582
+ * Get engagement metrics for a contact (via their linked visitor data)
3583
+ */
3584
+ async getContactEngagement(contactId) {
3585
+ this.validateRequired('contactId', contactId, 'getContactEngagement');
3586
+ return this.request(`/api/workspaces/${this.workspaceId}/contacts/${contactId}/engagement`);
3587
+ }
3588
+ /**
3589
+ * Get a full timeline for a contact including events, activities, and opportunities
3590
+ */
3591
+ async getContactTimeline(contactId, params) {
3592
+ this.validateRequired('contactId', contactId, 'getContactTimeline');
3593
+ const queryParams = new URLSearchParams();
3594
+ if (params?.page)
3595
+ queryParams.set('page', params.page.toString());
3596
+ if (params?.limit)
3597
+ queryParams.set('limit', params.limit.toString());
3598
+ const query = queryParams.toString();
3599
+ const endpoint = `/api/workspaces/${this.workspaceId}/contacts/${contactId}/timeline${query ? `?${query}` : ''}`;
3600
+ return this.request(endpoint);
3601
+ }
3602
+ /**
3603
+ * Search contacts with advanced filters
3604
+ */
3605
+ async searchContacts(query, filters) {
3606
+ const queryParams = new URLSearchParams();
3607
+ queryParams.set('search', query);
3608
+ if (filters?.status)
3609
+ queryParams.set('status', filters.status);
3610
+ if (filters?.lifecycleStage)
3611
+ queryParams.set('lifecycleStage', filters.lifecycleStage);
3612
+ if (filters?.source)
3613
+ queryParams.set('source', filters.source);
3614
+ if (filters?.tags)
3615
+ queryParams.set('tags', filters.tags.join(','));
3616
+ if (filters?.page)
3617
+ queryParams.set('page', filters.page.toString());
3618
+ if (filters?.limit)
3619
+ queryParams.set('limit', filters.limit.toString());
3620
+ const qs = queryParams.toString();
3621
+ const endpoint = `/api/workspaces/${this.workspaceId}/contacts${qs ? `?${qs}` : ''}`;
3622
+ return this.request(endpoint);
3623
+ }
3624
+ // ============================================
3625
+ // WEBHOOK MANAGEMENT API
3626
+ // ============================================
3627
+ /**
3628
+ * List all webhook subscriptions
3629
+ */
3630
+ async listWebhooks(params) {
3631
+ const queryParams = new URLSearchParams();
3632
+ if (params?.page)
3633
+ queryParams.set('page', params.page.toString());
3634
+ if (params?.limit)
3635
+ queryParams.set('limit', params.limit.toString());
3636
+ const query = queryParams.toString();
3637
+ return this.request(`/api/workspaces/${this.workspaceId}/webhooks${query ? `?${query}` : ''}`);
3638
+ }
3639
+ /**
3640
+ * Create a new webhook subscription
3641
+ */
3642
+ async createWebhook(data) {
3643
+ return this.request(`/api/workspaces/${this.workspaceId}/webhooks`, {
3644
+ method: 'POST',
3645
+ body: JSON.stringify(data),
3646
+ });
3647
+ }
3648
+ /**
3649
+ * Delete a webhook subscription
3650
+ */
3651
+ async deleteWebhook(webhookId) {
3652
+ this.validateRequired('webhookId', webhookId, 'deleteWebhook');
3653
+ return this.request(`/api/workspaces/${this.workspaceId}/webhooks/${webhookId}`, {
3654
+ method: 'DELETE',
3655
+ });
3656
+ }
3657
+ // ============================================
3438
3658
  // EVENT TRIGGERS API (delegated to triggers manager)
3439
3659
  // ============================================
3440
3660
  /**
@@ -3478,6 +3698,8 @@ class Tracker {
3478
3698
  this.contactId = null;
3479
3699
  /** Pending identify retry on next flush */
3480
3700
  this.pendingIdentify = null;
3701
+ /** Registered event schemas for validation */
3702
+ this.eventSchemas = new Map();
3481
3703
  if (!workspaceId) {
3482
3704
  throw new Error('[Clianta] Workspace ID is required');
3483
3705
  }
@@ -3503,6 +3725,16 @@ class Tracker {
3503
3725
  this.visitorId = this.createVisitorId();
3504
3726
  this.sessionId = this.createSessionId();
3505
3727
  logger.debug('IDs created', { visitorId: this.visitorId, sessionId: this.sessionId });
3728
+ // Security warnings
3729
+ if (this.config.apiEndpoint.startsWith('http://') &&
3730
+ typeof window !== 'undefined' &&
3731
+ !window.location.hostname.includes('localhost') &&
3732
+ !window.location.hostname.includes('127.0.0.1')) {
3733
+ logger.warn('apiEndpoint uses HTTP — events and visitor data will be sent unencrypted. Use HTTPS in production.');
3734
+ }
3735
+ if (this.config.apiKey && typeof window !== 'undefined') {
3736
+ logger.warn('API key is exposed in client-side code. Use API keys only in server-side (Node.js) environments.');
3737
+ }
3506
3738
  // Initialize plugins
3507
3739
  this.initPlugins();
3508
3740
  this.isInitialized = true;
@@ -3608,6 +3840,7 @@ class Tracker {
3608
3840
  properties: {
3609
3841
  ...properties,
3610
3842
  eventId: generateUUID(), // Unique ID for deduplication on retry
3843
+ websiteDomain: typeof window !== 'undefined' ? window.location.hostname : undefined,
3611
3844
  },
3612
3845
  device: getDeviceInfo(),
3613
3846
  ...getUTMParams(),
@@ -3618,6 +3851,8 @@ class Tracker {
3618
3851
  if (this.contactId) {
3619
3852
  event.contactId = this.contactId;
3620
3853
  }
3854
+ // Validate event against registered schema (debug mode only)
3855
+ this.validateEventSchema(eventType, properties);
3621
3856
  // Check consent before tracking
3622
3857
  if (!this.consentManager.canTrack()) {
3623
3858
  // Buffer event for later if waitForConsent is enabled
@@ -3652,6 +3887,10 @@ class Tracker {
3652
3887
  logger.warn('Email is required for identification');
3653
3888
  return null;
3654
3889
  }
3890
+ if (!isValidEmail(email)) {
3891
+ logger.warn('Invalid email format, identification skipped:', email);
3892
+ return null;
3893
+ }
3655
3894
  logger.info('Identifying visitor:', email);
3656
3895
  const result = await this.transport.sendIdentify({
3657
3896
  workspaceId: this.workspaceId,
@@ -3686,6 +3925,83 @@ class Tracker {
3686
3925
  const client = new CRMClient(this.config.apiEndpoint, this.workspaceId, undefined, apiKey);
3687
3926
  return client.sendEvent(payload);
3688
3927
  }
3928
+ /**
3929
+ * Get the current visitor's profile from the CRM.
3930
+ * Returns visitor data and linked contact info if identified.
3931
+ * Only returns data for the current visitor (privacy-safe for frontend).
3932
+ */
3933
+ async getVisitorProfile() {
3934
+ if (!this.isInitialized) {
3935
+ logger.warn('SDK not initialized');
3936
+ return null;
3937
+ }
3938
+ const result = await this.transport.fetchData(`/api/public/track/visitor/${this.workspaceId}/${this.visitorId}/profile`);
3939
+ if (result.success && result.data) {
3940
+ logger.debug('Visitor profile fetched:', result.data);
3941
+ return result.data;
3942
+ }
3943
+ logger.warn('Failed to fetch visitor profile:', result.error);
3944
+ return null;
3945
+ }
3946
+ /**
3947
+ * Get the current visitor's recent activity/events.
3948
+ * Returns paginated list of tracking events for this visitor.
3949
+ */
3950
+ async getVisitorActivity(options) {
3951
+ if (!this.isInitialized) {
3952
+ logger.warn('SDK not initialized');
3953
+ return null;
3954
+ }
3955
+ const params = {};
3956
+ if (options?.page)
3957
+ params.page = options.page.toString();
3958
+ if (options?.limit)
3959
+ params.limit = options.limit.toString();
3960
+ if (options?.eventType)
3961
+ params.eventType = options.eventType;
3962
+ if (options?.startDate)
3963
+ params.startDate = options.startDate;
3964
+ if (options?.endDate)
3965
+ params.endDate = options.endDate;
3966
+ const result = await this.transport.fetchData(`/api/public/track/visitor/${this.workspaceId}/${this.visitorId}/activity`, params);
3967
+ if (result.success && result.data) {
3968
+ return result.data;
3969
+ }
3970
+ logger.warn('Failed to fetch visitor activity:', result.error);
3971
+ return null;
3972
+ }
3973
+ /**
3974
+ * Get a summarized journey timeline for the current visitor.
3975
+ * Includes top pages, sessions, time spent, and recent activities.
3976
+ */
3977
+ async getVisitorTimeline() {
3978
+ if (!this.isInitialized) {
3979
+ logger.warn('SDK not initialized');
3980
+ return null;
3981
+ }
3982
+ const result = await this.transport.fetchData(`/api/public/track/visitor/${this.workspaceId}/${this.visitorId}/timeline`);
3983
+ if (result.success && result.data) {
3984
+ return result.data;
3985
+ }
3986
+ logger.warn('Failed to fetch visitor timeline:', result.error);
3987
+ return null;
3988
+ }
3989
+ /**
3990
+ * Get engagement metrics for the current visitor.
3991
+ * Includes time on site, page views, bounce rate, and engagement score.
3992
+ */
3993
+ async getVisitorEngagement() {
3994
+ if (!this.isInitialized) {
3995
+ logger.warn('SDK not initialized');
3996
+ return null;
3997
+ }
3998
+ const result = await this.transport.fetchData(`/api/public/track/visitor/${this.workspaceId}/${this.visitorId}/engagement`);
3999
+ if (result.success && result.data) {
4000
+ return result.data;
4001
+ }
4002
+ logger.warn('Failed to fetch visitor engagement:', result.error);
4003
+ return null;
4004
+ }
3689
4005
  /**
3690
4006
  * Retry pending identify call
3691
4007
  */
@@ -3715,6 +4031,59 @@ class Tracker {
3715
4031
  logger.enabled = enabled;
3716
4032
  logger.info(`Debug mode ${enabled ? 'enabled' : 'disabled'}`);
3717
4033
  }
4034
+ /**
4035
+ * Register a schema for event validation.
4036
+ * When debug mode is enabled, events will be validated against registered schemas.
4037
+ *
4038
+ * @example
4039
+ * tracker.registerEventSchema('purchase', {
4040
+ * productId: 'string',
4041
+ * price: 'number',
4042
+ * quantity: 'number',
4043
+ * });
4044
+ */
4045
+ registerEventSchema(eventType, schema) {
4046
+ this.eventSchemas.set(eventType, schema);
4047
+ logger.debug('Event schema registered:', eventType);
4048
+ }
4049
+ /**
4050
+ * Validate event properties against a registered schema (debug mode only)
4051
+ */
4052
+ validateEventSchema(eventType, properties) {
4053
+ if (!this.config.debug)
4054
+ return;
4055
+ const schema = this.eventSchemas.get(eventType);
4056
+ if (!schema)
4057
+ return;
4058
+ for (const [key, expectedType] of Object.entries(schema)) {
4059
+ const value = properties[key];
4060
+ if (value === undefined) {
4061
+ logger.warn(`[Schema] Missing property "${key}" for event type "${eventType}"`);
4062
+ continue;
4063
+ }
4064
+ let valid = false;
4065
+ switch (expectedType) {
4066
+ case 'string':
4067
+ valid = typeof value === 'string';
4068
+ break;
4069
+ case 'number':
4070
+ valid = typeof value === 'number';
4071
+ break;
4072
+ case 'boolean':
4073
+ valid = typeof value === 'boolean';
4074
+ break;
4075
+ case 'object':
4076
+ valid = typeof value === 'object' && !Array.isArray(value);
4077
+ break;
4078
+ case 'array':
4079
+ valid = Array.isArray(value);
4080
+ break;
4081
+ }
4082
+ if (!valid) {
4083
+ logger.warn(`[Schema] Property "${key}" for event "${eventType}" expected ${expectedType}, got ${typeof value}`);
4084
+ }
4085
+ }
4086
+ }
3718
4087
  /**
3719
4088
  * Get visitor ID
3720
4089
  */
@@ -3754,6 +4123,8 @@ class Tracker {
3754
4123
  resetIds(this.config.useCookies);
3755
4124
  this.visitorId = this.createVisitorId();
3756
4125
  this.sessionId = this.createSessionId();
4126
+ this.contactId = null;
4127
+ this.pendingIdentify = null;
3757
4128
  this.queue.clear();
3758
4129
  }
3759
4130
  /**