@clianta/sdk 1.4.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * Clianta SDK v1.4.0
2
+ * Clianta SDK v1.5.1
3
3
  * (c) 2026 Clianta
4
4
  * Released under the MIT License.
5
5
  */
@@ -9,15 +9,22 @@
9
9
  */
10
10
  /** SDK Version */
11
11
  const SDK_VERSION = '1.4.0';
12
- /** Default API endpoint based on environment */
12
+ /** Default API endpoint reads from env or falls back to localhost */
13
13
  const getDefaultApiEndpoint = () => {
14
- if (typeof window === 'undefined')
15
- return 'https://api.clianta.online';
16
- const hostname = window.location.hostname;
17
- if (hostname.includes('localhost') || hostname.includes('127.0.0.1')) {
18
- return 'http://localhost:5000';
14
+ // Build-time env var (works with Next.js, Vite, CRA, etc.)
15
+ if (typeof process !== 'undefined' && process.env?.NEXT_PUBLIC_CLIANTA_API_ENDPOINT) {
16
+ return process.env.NEXT_PUBLIC_CLIANTA_API_ENDPOINT;
17
+ }
18
+ if (typeof process !== 'undefined' && process.env?.VITE_CLIANTA_API_ENDPOINT) {
19
+ return process.env.VITE_CLIANTA_API_ENDPOINT;
20
+ }
21
+ if (typeof process !== 'undefined' && process.env?.REACT_APP_CLIANTA_API_ENDPOINT) {
22
+ return process.env.REACT_APP_CLIANTA_API_ENDPOINT;
19
23
  }
20
- return 'https://api.clianta.online';
24
+ if (typeof process !== 'undefined' && process.env?.CLIANTA_API_ENDPOINT) {
25
+ return process.env.CLIANTA_API_ENDPOINT;
26
+ }
27
+ return 'http://localhost:5000';
21
28
  };
22
29
  /** Core plugins enabled by default */
23
30
  const DEFAULT_PLUGINS = [
@@ -50,6 +57,7 @@ const DEFAULT_CONFIG = {
50
57
  cookieDomain: '',
51
58
  useCookies: false,
52
59
  cookielessMode: false,
60
+ persistMode: 'session',
53
61
  };
54
62
  /** Storage keys */
55
63
  const STORAGE_KEYS = {
@@ -243,6 +251,39 @@ class Transport {
243
251
  return false;
244
252
  }
245
253
  }
254
+ /**
255
+ * Fetch data from the tracking API (GET request)
256
+ * Used for read-back APIs (visitor profile, activity, etc.)
257
+ */
258
+ async fetchData(path, params) {
259
+ const url = new URL(`${this.config.apiEndpoint}${path}`);
260
+ if (params) {
261
+ Object.entries(params).forEach(([key, value]) => {
262
+ if (value !== undefined && value !== null) {
263
+ url.searchParams.set(key, value);
264
+ }
265
+ });
266
+ }
267
+ try {
268
+ const response = await this.fetchWithTimeout(url.toString(), {
269
+ method: 'GET',
270
+ headers: {
271
+ 'Accept': 'application/json',
272
+ },
273
+ });
274
+ if (response.ok) {
275
+ const body = await response.json();
276
+ logger.debug('Fetch successful:', path);
277
+ return { success: true, data: body.data ?? body, status: response.status };
278
+ }
279
+ logger.error(`Fetch failed with status ${response.status}`);
280
+ return { success: false, status: response.status };
281
+ }
282
+ catch (error) {
283
+ logger.error('Fetch request failed:', error);
284
+ return { success: false, error: error };
285
+ }
286
+ }
246
287
  /**
247
288
  * Internal send with retry logic
248
289
  */
@@ -407,7 +448,9 @@ function cookie(name, value, days) {
407
448
  date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
408
449
  expires = '; expires=' + date.toUTCString();
409
450
  }
410
- document.cookie = name + '=' + value + expires + '; path=/; SameSite=Lax';
451
+ // Add Secure flag on HTTPS to prevent cookie leakage over plaintext
452
+ const secure = typeof location !== 'undefined' && location.protocol === 'https:' ? '; Secure' : '';
453
+ document.cookie = name + '=' + value + expires + '; path=/; SameSite=Lax' + secure;
411
454
  return value;
412
455
  }
413
456
  // ============================================
@@ -571,6 +614,17 @@ function isMobile() {
571
614
  return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
572
615
  }
573
616
  // ============================================
617
+ // VALIDATION UTILITIES
618
+ // ============================================
619
+ /**
620
+ * Validate email format
621
+ */
622
+ function isValidEmail(email) {
623
+ if (typeof email !== 'string' || !email)
624
+ return false;
625
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
626
+ }
627
+ // ============================================
574
628
  // DEVICE INFO
575
629
  // ============================================
576
630
  /**
@@ -624,6 +678,7 @@ class EventQueue {
624
678
  maxQueueSize: config.maxQueueSize ?? MAX_QUEUE_SIZE,
625
679
  storageKey: config.storageKey ?? STORAGE_KEYS.EVENT_QUEUE,
626
680
  };
681
+ this.persistMode = config.persistMode || 'session';
627
682
  // Restore persisted queue
628
683
  this.restoreQueue();
629
684
  // Start auto-flush timer
@@ -729,6 +784,13 @@ class EventQueue {
729
784
  clear() {
730
785
  this.queue = [];
731
786
  this.persistQueue([]);
787
+ // Also clear localStorage if used
788
+ if (this.persistMode === 'local' && typeof localStorage !== 'undefined') {
789
+ try {
790
+ localStorage.removeItem(this.config.storageKey);
791
+ }
792
+ catch { /* ignore */ }
793
+ }
732
794
  }
733
795
  /**
734
796
  * Stop the flush timer and cleanup handlers
@@ -783,22 +845,44 @@ class EventQueue {
783
845
  window.addEventListener('pagehide', this.boundPageHide);
784
846
  }
785
847
  /**
786
- * Persist queue to localStorage
848
+ * Persist queue to storage based on persistMode
787
849
  */
788
850
  persistQueue(events) {
851
+ if (this.persistMode === 'none')
852
+ return;
789
853
  try {
790
- setLocalStorage(this.config.storageKey, JSON.stringify(events));
854
+ const serialized = JSON.stringify(events);
855
+ if (this.persistMode === 'local' && typeof localStorage !== 'undefined') {
856
+ try {
857
+ localStorage.setItem(this.config.storageKey, serialized);
858
+ }
859
+ catch {
860
+ // localStorage quota exceeded — fallback to sessionStorage
861
+ setSessionStorage(this.config.storageKey, serialized);
862
+ }
863
+ }
864
+ else {
865
+ setSessionStorage(this.config.storageKey, serialized);
866
+ }
791
867
  }
792
868
  catch {
793
869
  // Ignore storage errors
794
870
  }
795
871
  }
796
872
  /**
797
- * Restore queue from localStorage
873
+ * Restore queue from storage
798
874
  */
799
875
  restoreQueue() {
800
876
  try {
801
- const stored = getLocalStorage(this.config.storageKey);
877
+ let stored = null;
878
+ // Check localStorage first (cross-session persistence)
879
+ if (this.persistMode === 'local' && typeof localStorage !== 'undefined') {
880
+ stored = localStorage.getItem(this.config.storageKey);
881
+ }
882
+ // Fall back to sessionStorage
883
+ if (!stored) {
884
+ stored = getSessionStorage(this.config.storageKey);
885
+ }
802
886
  if (stored) {
803
887
  const events = JSON.parse(stored);
804
888
  if (Array.isArray(events) && events.length > 0) {
@@ -866,10 +950,13 @@ class PageViewPlugin extends BasePlugin {
866
950
  history.pushState = function (...args) {
867
951
  self.originalPushState.apply(history, args);
868
952
  self.trackPageView();
953
+ // Notify other plugins (e.g. ScrollPlugin) about navigation
954
+ window.dispatchEvent(new Event('clianta:navigation'));
869
955
  };
870
956
  history.replaceState = function (...args) {
871
957
  self.originalReplaceState.apply(history, args);
872
958
  self.trackPageView();
959
+ window.dispatchEvent(new Event('clianta:navigation'));
873
960
  };
874
961
  // Handle back/forward navigation
875
962
  this.popstateHandler = () => this.trackPageView();
@@ -923,9 +1010,8 @@ class ScrollPlugin extends BasePlugin {
923
1010
  this.pageLoadTime = 0;
924
1011
  this.scrollTimeout = null;
925
1012
  this.boundHandler = null;
926
- /** SPA navigation support */
927
- this.originalPushState = null;
928
- this.originalReplaceState = null;
1013
+ /** SPA navigation listen for PageViewPlugin's custom event instead of patching history */
1014
+ this.navigationHandler = null;
929
1015
  this.popstateHandler = null;
930
1016
  }
931
1017
  init(tracker) {
@@ -934,8 +1020,13 @@ class ScrollPlugin extends BasePlugin {
934
1020
  if (typeof window !== 'undefined') {
935
1021
  this.boundHandler = this.handleScroll.bind(this);
936
1022
  window.addEventListener('scroll', this.boundHandler, { passive: true });
937
- // Setup SPA navigation reset
938
- this.setupNavigationReset();
1023
+ // Listen for navigation events dispatched by PageViewPlugin
1024
+ // instead of independently monkey-patching history.pushState
1025
+ this.navigationHandler = () => this.resetForNavigation();
1026
+ window.addEventListener('clianta:navigation', this.navigationHandler);
1027
+ // Handle back/forward navigation
1028
+ this.popstateHandler = () => this.resetForNavigation();
1029
+ window.addEventListener('popstate', this.popstateHandler);
939
1030
  }
940
1031
  }
941
1032
  destroy() {
@@ -945,16 +1036,10 @@ class ScrollPlugin extends BasePlugin {
945
1036
  if (this.scrollTimeout) {
946
1037
  clearTimeout(this.scrollTimeout);
947
1038
  }
948
- // Restore original history methods
949
- if (this.originalPushState) {
950
- history.pushState = this.originalPushState;
951
- this.originalPushState = null;
952
- }
953
- if (this.originalReplaceState) {
954
- history.replaceState = this.originalReplaceState;
955
- this.originalReplaceState = null;
1039
+ if (this.navigationHandler && typeof window !== 'undefined') {
1040
+ window.removeEventListener('clianta:navigation', this.navigationHandler);
1041
+ this.navigationHandler = null;
956
1042
  }
957
- // Remove popstate listener
958
1043
  if (this.popstateHandler && typeof window !== 'undefined') {
959
1044
  window.removeEventListener('popstate', this.popstateHandler);
960
1045
  this.popstateHandler = null;
@@ -969,29 +1054,6 @@ class ScrollPlugin extends BasePlugin {
969
1054
  this.maxScrollDepth = 0;
970
1055
  this.pageLoadTime = Date.now();
971
1056
  }
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
1057
  handleScroll() {
996
1058
  // Debounce scroll tracking
997
1059
  if (this.scrollTimeout) {
@@ -1191,6 +1253,10 @@ class ClicksPlugin extends BasePlugin {
1191
1253
  elementId: elementInfo.id,
1192
1254
  elementClass: elementInfo.className,
1193
1255
  href: target.href || undefined,
1256
+ x: Math.round((e.clientX / window.innerWidth) * 100),
1257
+ y: Math.round((e.clientY / window.innerHeight) * 100),
1258
+ viewportWidth: window.innerWidth,
1259
+ viewportHeight: window.innerHeight,
1194
1260
  });
1195
1261
  }
1196
1262
  }
@@ -1213,6 +1279,9 @@ class EngagementPlugin extends BasePlugin {
1213
1279
  this.boundMarkEngaged = null;
1214
1280
  this.boundTrackTimeOnPage = null;
1215
1281
  this.boundVisibilityHandler = null;
1282
+ /** SPA navigation — listen for PageViewPlugin's custom event instead of patching history */
1283
+ this.navigationHandler = null;
1284
+ this.popstateHandler = null;
1216
1285
  }
1217
1286
  init(tracker) {
1218
1287
  super.init(tracker);
@@ -1238,6 +1307,13 @@ class EngagementPlugin extends BasePlugin {
1238
1307
  // Track time on page before unload
1239
1308
  window.addEventListener('beforeunload', this.boundTrackTimeOnPage);
1240
1309
  document.addEventListener('visibilitychange', this.boundVisibilityHandler);
1310
+ // Listen for navigation events dispatched by PageViewPlugin
1311
+ // instead of independently monkey-patching history.pushState
1312
+ this.navigationHandler = () => this.resetForNavigation();
1313
+ window.addEventListener('clianta:navigation', this.navigationHandler);
1314
+ // Handle back/forward navigation
1315
+ this.popstateHandler = () => this.resetForNavigation();
1316
+ window.addEventListener('popstate', this.popstateHandler);
1241
1317
  }
1242
1318
  destroy() {
1243
1319
  if (this.boundMarkEngaged && typeof document !== 'undefined') {
@@ -1251,11 +1327,28 @@ class EngagementPlugin extends BasePlugin {
1251
1327
  if (this.boundVisibilityHandler && typeof document !== 'undefined') {
1252
1328
  document.removeEventListener('visibilitychange', this.boundVisibilityHandler);
1253
1329
  }
1330
+ if (this.navigationHandler && typeof window !== 'undefined') {
1331
+ window.removeEventListener('clianta:navigation', this.navigationHandler);
1332
+ this.navigationHandler = null;
1333
+ }
1334
+ if (this.popstateHandler && typeof window !== 'undefined') {
1335
+ window.removeEventListener('popstate', this.popstateHandler);
1336
+ this.popstateHandler = null;
1337
+ }
1254
1338
  if (this.engagementTimeout) {
1255
1339
  clearTimeout(this.engagementTimeout);
1256
1340
  }
1257
1341
  super.destroy();
1258
1342
  }
1343
+ resetForNavigation() {
1344
+ this.pageLoadTime = Date.now();
1345
+ this.engagementStartTime = Date.now();
1346
+ this.isEngaged = false;
1347
+ if (this.engagementTimeout) {
1348
+ clearTimeout(this.engagementTimeout);
1349
+ this.engagementTimeout = null;
1350
+ }
1351
+ }
1259
1352
  markEngaged() {
1260
1353
  if (!this.isEngaged) {
1261
1354
  this.isEngaged = true;
@@ -1295,9 +1388,8 @@ class DownloadsPlugin extends BasePlugin {
1295
1388
  this.name = 'downloads';
1296
1389
  this.trackedDownloads = new Set();
1297
1390
  this.boundHandler = null;
1298
- /** SPA navigation support */
1299
- this.originalPushState = null;
1300
- this.originalReplaceState = null;
1391
+ /** SPA navigation listen for PageViewPlugin's custom event instead of patching history */
1392
+ this.navigationHandler = null;
1301
1393
  this.popstateHandler = null;
1302
1394
  }
1303
1395
  init(tracker) {
@@ -1305,24 +1397,25 @@ class DownloadsPlugin extends BasePlugin {
1305
1397
  if (typeof document !== 'undefined') {
1306
1398
  this.boundHandler = this.handleClick.bind(this);
1307
1399
  document.addEventListener('click', this.boundHandler, true);
1308
- // Setup SPA navigation reset
1309
- this.setupNavigationReset();
1400
+ }
1401
+ if (typeof window !== 'undefined') {
1402
+ // Listen for navigation events dispatched by PageViewPlugin
1403
+ // instead of independently monkey-patching history.pushState
1404
+ this.navigationHandler = () => this.resetForNavigation();
1405
+ window.addEventListener('clianta:navigation', this.navigationHandler);
1406
+ // Handle back/forward navigation
1407
+ this.popstateHandler = () => this.resetForNavigation();
1408
+ window.addEventListener('popstate', this.popstateHandler);
1310
1409
  }
1311
1410
  }
1312
1411
  destroy() {
1313
1412
  if (this.boundHandler && typeof document !== 'undefined') {
1314
1413
  document.removeEventListener('click', this.boundHandler, true);
1315
1414
  }
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;
1415
+ if (this.navigationHandler && typeof window !== 'undefined') {
1416
+ window.removeEventListener('clianta:navigation', this.navigationHandler);
1417
+ this.navigationHandler = null;
1324
1418
  }
1325
- // Remove popstate listener
1326
1419
  if (this.popstateHandler && typeof window !== 'undefined') {
1327
1420
  window.removeEventListener('popstate', this.popstateHandler);
1328
1421
  this.popstateHandler = null;
@@ -1335,29 +1428,6 @@ class DownloadsPlugin extends BasePlugin {
1335
1428
  resetForNavigation() {
1336
1429
  this.trackedDownloads.clear();
1337
1430
  }
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
1431
  handleClick(e) {
1362
1432
  const link = e.target.closest('a');
1363
1433
  if (!link || !link.href)
@@ -1393,6 +1463,9 @@ class ExitIntentPlugin extends BasePlugin {
1393
1463
  this.exitIntentShown = false;
1394
1464
  this.pageLoadTime = 0;
1395
1465
  this.boundHandler = null;
1466
+ /** SPA navigation — listen for PageViewPlugin's custom event instead of patching history */
1467
+ this.navigationHandler = null;
1468
+ this.popstateHandler = null;
1396
1469
  }
1397
1470
  init(tracker) {
1398
1471
  super.init(tracker);
@@ -1404,13 +1477,34 @@ class ExitIntentPlugin extends BasePlugin {
1404
1477
  this.boundHandler = this.handleMouseLeave.bind(this);
1405
1478
  document.addEventListener('mouseleave', this.boundHandler);
1406
1479
  }
1480
+ if (typeof window !== 'undefined') {
1481
+ // Listen for navigation events dispatched by PageViewPlugin
1482
+ // instead of independently monkey-patching history.pushState
1483
+ this.navigationHandler = () => this.resetForNavigation();
1484
+ window.addEventListener('clianta:navigation', this.navigationHandler);
1485
+ // Handle back/forward navigation
1486
+ this.popstateHandler = () => this.resetForNavigation();
1487
+ window.addEventListener('popstate', this.popstateHandler);
1488
+ }
1407
1489
  }
1408
1490
  destroy() {
1409
1491
  if (this.boundHandler && typeof document !== 'undefined') {
1410
1492
  document.removeEventListener('mouseleave', this.boundHandler);
1411
1493
  }
1494
+ if (this.navigationHandler && typeof window !== 'undefined') {
1495
+ window.removeEventListener('clianta:navigation', this.navigationHandler);
1496
+ this.navigationHandler = null;
1497
+ }
1498
+ if (this.popstateHandler && typeof window !== 'undefined') {
1499
+ window.removeEventListener('popstate', this.popstateHandler);
1500
+ this.popstateHandler = null;
1501
+ }
1412
1502
  super.destroy();
1413
1503
  }
1504
+ resetForNavigation() {
1505
+ this.exitIntentShown = false;
1506
+ this.pageLoadTime = Date.now();
1507
+ }
1414
1508
  handleMouseLeave(e) {
1415
1509
  // Only trigger when mouse leaves from the top of the page
1416
1510
  if (e.clientY > 0 || this.exitIntentShown)
@@ -1638,6 +1732,8 @@ class PopupFormsPlugin extends BasePlugin {
1638
1732
  this.shownForms = new Set();
1639
1733
  this.scrollHandler = null;
1640
1734
  this.exitHandler = null;
1735
+ this.delayTimers = [];
1736
+ this.clickTriggerListeners = [];
1641
1737
  }
1642
1738
  async init(tracker) {
1643
1739
  super.init(tracker);
@@ -1652,6 +1748,14 @@ class PopupFormsPlugin extends BasePlugin {
1652
1748
  }
1653
1749
  destroy() {
1654
1750
  this.removeTriggers();
1751
+ for (const timer of this.delayTimers) {
1752
+ clearTimeout(timer);
1753
+ }
1754
+ this.delayTimers = [];
1755
+ for (const { element, handler } of this.clickTriggerListeners) {
1756
+ element.removeEventListener('click', handler);
1757
+ }
1758
+ this.clickTriggerListeners = [];
1655
1759
  super.destroy();
1656
1760
  }
1657
1761
  loadShownForms() {
@@ -1682,7 +1786,7 @@ class PopupFormsPlugin extends BasePlugin {
1682
1786
  return;
1683
1787
  const config = this.tracker.getConfig();
1684
1788
  const workspaceId = this.tracker.getWorkspaceId();
1685
- const apiEndpoint = config.apiEndpoint || 'https://api.clianta.online';
1789
+ const apiEndpoint = config.apiEndpoint || 'http://localhost:5000';
1686
1790
  try {
1687
1791
  const url = encodeURIComponent(window.location.href);
1688
1792
  const response = await fetch(`${apiEndpoint}/api/public/lead-forms/${workspaceId}?url=${url}`);
@@ -1714,7 +1818,7 @@ class PopupFormsPlugin extends BasePlugin {
1714
1818
  this.forms.forEach(form => {
1715
1819
  switch (form.trigger.type) {
1716
1820
  case 'delay':
1717
- setTimeout(() => this.showForm(form), (form.trigger.value || 5) * 1000);
1821
+ this.delayTimers.push(setTimeout(() => this.showForm(form), (form.trigger.value || 5) * 1000));
1718
1822
  break;
1719
1823
  case 'scroll':
1720
1824
  this.setupScrollTrigger(form);
@@ -1757,7 +1861,9 @@ class PopupFormsPlugin extends BasePlugin {
1757
1861
  return;
1758
1862
  const elements = document.querySelectorAll(form.trigger.selector);
1759
1863
  elements.forEach(el => {
1760
- el.addEventListener('click', () => this.showForm(form));
1864
+ const handler = () => this.showForm(form);
1865
+ el.addEventListener('click', handler);
1866
+ this.clickTriggerListeners.push({ element: el, handler });
1761
1867
  });
1762
1868
  }
1763
1869
  removeTriggers() {
@@ -1785,7 +1891,7 @@ class PopupFormsPlugin extends BasePlugin {
1785
1891
  if (!this.tracker)
1786
1892
  return;
1787
1893
  const config = this.tracker.getConfig();
1788
- const apiEndpoint = config.apiEndpoint || 'https://api.clianta.online';
1894
+ const apiEndpoint = config.apiEndpoint || 'http://localhost:5000';
1789
1895
  try {
1790
1896
  await fetch(`${apiEndpoint}/api/public/lead-forms/${formId}/view`, {
1791
1897
  method: 'POST',
@@ -2032,7 +2138,7 @@ class PopupFormsPlugin extends BasePlugin {
2032
2138
  if (!this.tracker)
2033
2139
  return;
2034
2140
  const config = this.tracker.getConfig();
2035
- const apiEndpoint = config.apiEndpoint || 'https://api.clianta.online';
2141
+ const apiEndpoint = config.apiEndpoint || 'http://localhost:5000';
2036
2142
  const visitorId = this.tracker.getVisitorId();
2037
2143
  // Collect form data
2038
2144
  const formData = new FormData(formElement);
@@ -2044,7 +2150,7 @@ class PopupFormsPlugin extends BasePlugin {
2044
2150
  const submitBtn = formElement.querySelector('button[type="submit"]');
2045
2151
  if (submitBtn) {
2046
2152
  submitBtn.disabled = true;
2047
- submitBtn.innerHTML = 'Submitting...';
2153
+ submitBtn.textContent = 'Submitting...';
2048
2154
  }
2049
2155
  try {
2050
2156
  const response = await fetch(`${apiEndpoint}/api/public/lead-forms/${form._id}/submit`, {
@@ -2085,11 +2191,24 @@ class PopupFormsPlugin extends BasePlugin {
2085
2191
  if (data.email) {
2086
2192
  this.tracker?.identify(data.email, data);
2087
2193
  }
2088
- // Redirect if configured
2194
+ // Redirect if configured (validate URL to prevent open redirect)
2089
2195
  if (form.redirectUrl) {
2090
- setTimeout(() => {
2091
- window.location.href = form.redirectUrl;
2092
- }, 1500);
2196
+ try {
2197
+ const redirect = new URL(form.redirectUrl, window.location.origin);
2198
+ const isSameOrigin = redirect.origin === window.location.origin;
2199
+ const isSafeProtocol = redirect.protocol === 'https:' || redirect.protocol === 'http:';
2200
+ if (isSameOrigin || isSafeProtocol) {
2201
+ setTimeout(() => {
2202
+ window.location.href = redirect.href;
2203
+ }, 1500);
2204
+ }
2205
+ else {
2206
+ console.warn('[Clianta] Blocked unsafe redirect URL:', form.redirectUrl);
2207
+ }
2208
+ }
2209
+ catch {
2210
+ console.warn('[Clianta] Invalid redirect URL:', form.redirectUrl);
2211
+ }
2093
2212
  }
2094
2213
  // Close after delay
2095
2214
  setTimeout(() => {
@@ -2104,7 +2223,7 @@ class PopupFormsPlugin extends BasePlugin {
2104
2223
  console.error('[Clianta] Form submit error:', error);
2105
2224
  if (submitBtn) {
2106
2225
  submitBtn.disabled = false;
2107
- submitBtn.innerHTML = form.submitButtonText || 'Subscribe';
2226
+ submitBtn.textContent = form.submitButtonText || 'Subscribe';
2108
2227
  }
2109
2228
  }
2110
2229
  }
@@ -2914,7 +3033,7 @@ class CRMClient {
2914
3033
  * The contact is upserted in the CRM and matching workflow automations fire automatically.
2915
3034
  *
2916
3035
  * @example
2917
- * const crm = new CRMClient('https://api.clianta.online', 'WORKSPACE_ID');
3036
+ * const crm = new CRMClient('http://localhost:5000', 'WORKSPACE_ID');
2918
3037
  * crm.setApiKey('mm_live_...');
2919
3038
  *
2920
3039
  * await crm.sendEvent({
@@ -3435,6 +3554,114 @@ class CRMClient {
3435
3554
  });
3436
3555
  }
3437
3556
  // ============================================
3557
+ // READ-BACK / DATA RETRIEVAL API
3558
+ // ============================================
3559
+ /**
3560
+ * Get a contact by email address.
3561
+ * Returns the first matching contact from a search query.
3562
+ */
3563
+ async getContactByEmail(email) {
3564
+ this.validateRequired('email', email, 'getContactByEmail');
3565
+ const queryParams = new URLSearchParams({ search: email, limit: '1' });
3566
+ return this.request(`/api/workspaces/${this.workspaceId}/contacts?${queryParams.toString()}`);
3567
+ }
3568
+ /**
3569
+ * Get activity timeline for a contact
3570
+ */
3571
+ async getContactActivity(contactId, params) {
3572
+ this.validateRequired('contactId', contactId, 'getContactActivity');
3573
+ const queryParams = new URLSearchParams();
3574
+ if (params?.page)
3575
+ queryParams.set('page', params.page.toString());
3576
+ if (params?.limit)
3577
+ queryParams.set('limit', params.limit.toString());
3578
+ if (params?.type)
3579
+ queryParams.set('type', params.type);
3580
+ if (params?.startDate)
3581
+ queryParams.set('startDate', params.startDate);
3582
+ if (params?.endDate)
3583
+ queryParams.set('endDate', params.endDate);
3584
+ const query = queryParams.toString();
3585
+ const endpoint = `/api/workspaces/${this.workspaceId}/contacts/${contactId}/activities${query ? `?${query}` : ''}`;
3586
+ return this.request(endpoint);
3587
+ }
3588
+ /**
3589
+ * Get engagement metrics for a contact (via their linked visitor data)
3590
+ */
3591
+ async getContactEngagement(contactId) {
3592
+ this.validateRequired('contactId', contactId, 'getContactEngagement');
3593
+ return this.request(`/api/workspaces/${this.workspaceId}/contacts/${contactId}/engagement`);
3594
+ }
3595
+ /**
3596
+ * Get a full timeline for a contact including events, activities, and opportunities
3597
+ */
3598
+ async getContactTimeline(contactId, params) {
3599
+ this.validateRequired('contactId', contactId, 'getContactTimeline');
3600
+ const queryParams = new URLSearchParams();
3601
+ if (params?.page)
3602
+ queryParams.set('page', params.page.toString());
3603
+ if (params?.limit)
3604
+ queryParams.set('limit', params.limit.toString());
3605
+ const query = queryParams.toString();
3606
+ const endpoint = `/api/workspaces/${this.workspaceId}/contacts/${contactId}/timeline${query ? `?${query}` : ''}`;
3607
+ return this.request(endpoint);
3608
+ }
3609
+ /**
3610
+ * Search contacts with advanced filters
3611
+ */
3612
+ async searchContacts(query, filters) {
3613
+ const queryParams = new URLSearchParams();
3614
+ queryParams.set('search', query);
3615
+ if (filters?.status)
3616
+ queryParams.set('status', filters.status);
3617
+ if (filters?.lifecycleStage)
3618
+ queryParams.set('lifecycleStage', filters.lifecycleStage);
3619
+ if (filters?.source)
3620
+ queryParams.set('source', filters.source);
3621
+ if (filters?.tags)
3622
+ queryParams.set('tags', filters.tags.join(','));
3623
+ if (filters?.page)
3624
+ queryParams.set('page', filters.page.toString());
3625
+ if (filters?.limit)
3626
+ queryParams.set('limit', filters.limit.toString());
3627
+ const qs = queryParams.toString();
3628
+ const endpoint = `/api/workspaces/${this.workspaceId}/contacts${qs ? `?${qs}` : ''}`;
3629
+ return this.request(endpoint);
3630
+ }
3631
+ // ============================================
3632
+ // WEBHOOK MANAGEMENT API
3633
+ // ============================================
3634
+ /**
3635
+ * List all webhook subscriptions
3636
+ */
3637
+ async listWebhooks(params) {
3638
+ const queryParams = new URLSearchParams();
3639
+ if (params?.page)
3640
+ queryParams.set('page', params.page.toString());
3641
+ if (params?.limit)
3642
+ queryParams.set('limit', params.limit.toString());
3643
+ const query = queryParams.toString();
3644
+ return this.request(`/api/workspaces/${this.workspaceId}/webhooks${query ? `?${query}` : ''}`);
3645
+ }
3646
+ /**
3647
+ * Create a new webhook subscription
3648
+ */
3649
+ async createWebhook(data) {
3650
+ return this.request(`/api/workspaces/${this.workspaceId}/webhooks`, {
3651
+ method: 'POST',
3652
+ body: JSON.stringify(data),
3653
+ });
3654
+ }
3655
+ /**
3656
+ * Delete a webhook subscription
3657
+ */
3658
+ async deleteWebhook(webhookId) {
3659
+ this.validateRequired('webhookId', webhookId, 'deleteWebhook');
3660
+ return this.request(`/api/workspaces/${this.workspaceId}/webhooks/${webhookId}`, {
3661
+ method: 'DELETE',
3662
+ });
3663
+ }
3664
+ // ============================================
3438
3665
  // EVENT TRIGGERS API (delegated to triggers manager)
3439
3666
  // ============================================
3440
3667
  /**
@@ -3478,6 +3705,8 @@ class Tracker {
3478
3705
  this.contactId = null;
3479
3706
  /** Pending identify retry on next flush */
3480
3707
  this.pendingIdentify = null;
3708
+ /** Registered event schemas for validation */
3709
+ this.eventSchemas = new Map();
3481
3710
  if (!workspaceId) {
3482
3711
  throw new Error('[Clianta] Workspace ID is required');
3483
3712
  }
@@ -3503,6 +3732,16 @@ class Tracker {
3503
3732
  this.visitorId = this.createVisitorId();
3504
3733
  this.sessionId = this.createSessionId();
3505
3734
  logger.debug('IDs created', { visitorId: this.visitorId, sessionId: this.sessionId });
3735
+ // Security warnings
3736
+ if (this.config.apiEndpoint.startsWith('http://') &&
3737
+ typeof window !== 'undefined' &&
3738
+ !window.location.hostname.includes('localhost') &&
3739
+ !window.location.hostname.includes('127.0.0.1')) {
3740
+ logger.warn('apiEndpoint uses HTTP — events and visitor data will be sent unencrypted. Use HTTPS in production.');
3741
+ }
3742
+ if (this.config.apiKey && typeof window !== 'undefined') {
3743
+ logger.warn('API key is exposed in client-side code. Use API keys only in server-side (Node.js) environments.');
3744
+ }
3506
3745
  // Initialize plugins
3507
3746
  this.initPlugins();
3508
3747
  this.isInitialized = true;
@@ -3608,6 +3847,7 @@ class Tracker {
3608
3847
  properties: {
3609
3848
  ...properties,
3610
3849
  eventId: generateUUID(), // Unique ID for deduplication on retry
3850
+ websiteDomain: typeof window !== 'undefined' ? window.location.hostname : undefined,
3611
3851
  },
3612
3852
  device: getDeviceInfo(),
3613
3853
  ...getUTMParams(),
@@ -3618,6 +3858,8 @@ class Tracker {
3618
3858
  if (this.contactId) {
3619
3859
  event.contactId = this.contactId;
3620
3860
  }
3861
+ // Validate event against registered schema (debug mode only)
3862
+ this.validateEventSchema(eventType, properties);
3621
3863
  // Check consent before tracking
3622
3864
  if (!this.consentManager.canTrack()) {
3623
3865
  // Buffer event for later if waitForConsent is enabled
@@ -3652,6 +3894,10 @@ class Tracker {
3652
3894
  logger.warn('Email is required for identification');
3653
3895
  return null;
3654
3896
  }
3897
+ if (!isValidEmail(email)) {
3898
+ logger.warn('Invalid email format, identification skipped:', email);
3899
+ return null;
3900
+ }
3655
3901
  logger.info('Identifying visitor:', email);
3656
3902
  const result = await this.transport.sendIdentify({
3657
3903
  workspaceId: this.workspaceId,
@@ -3686,6 +3932,83 @@ class Tracker {
3686
3932
  const client = new CRMClient(this.config.apiEndpoint, this.workspaceId, undefined, apiKey);
3687
3933
  return client.sendEvent(payload);
3688
3934
  }
3935
+ /**
3936
+ * Get the current visitor's profile from the CRM.
3937
+ * Returns visitor data and linked contact info if identified.
3938
+ * Only returns data for the current visitor (privacy-safe for frontend).
3939
+ */
3940
+ async getVisitorProfile() {
3941
+ if (!this.isInitialized) {
3942
+ logger.warn('SDK not initialized');
3943
+ return null;
3944
+ }
3945
+ const result = await this.transport.fetchData(`/api/public/track/visitor/${this.workspaceId}/${this.visitorId}/profile`);
3946
+ if (result.success && result.data) {
3947
+ logger.debug('Visitor profile fetched:', result.data);
3948
+ return result.data;
3949
+ }
3950
+ logger.warn('Failed to fetch visitor profile:', result.error);
3951
+ return null;
3952
+ }
3953
+ /**
3954
+ * Get the current visitor's recent activity/events.
3955
+ * Returns paginated list of tracking events for this visitor.
3956
+ */
3957
+ async getVisitorActivity(options) {
3958
+ if (!this.isInitialized) {
3959
+ logger.warn('SDK not initialized');
3960
+ return null;
3961
+ }
3962
+ const params = {};
3963
+ if (options?.page)
3964
+ params.page = options.page.toString();
3965
+ if (options?.limit)
3966
+ params.limit = options.limit.toString();
3967
+ if (options?.eventType)
3968
+ params.eventType = options.eventType;
3969
+ if (options?.startDate)
3970
+ params.startDate = options.startDate;
3971
+ if (options?.endDate)
3972
+ params.endDate = options.endDate;
3973
+ const result = await this.transport.fetchData(`/api/public/track/visitor/${this.workspaceId}/${this.visitorId}/activity`, params);
3974
+ if (result.success && result.data) {
3975
+ return result.data;
3976
+ }
3977
+ logger.warn('Failed to fetch visitor activity:', result.error);
3978
+ return null;
3979
+ }
3980
+ /**
3981
+ * Get a summarized journey timeline for the current visitor.
3982
+ * Includes top pages, sessions, time spent, and recent activities.
3983
+ */
3984
+ async getVisitorTimeline() {
3985
+ if (!this.isInitialized) {
3986
+ logger.warn('SDK not initialized');
3987
+ return null;
3988
+ }
3989
+ const result = await this.transport.fetchData(`/api/public/track/visitor/${this.workspaceId}/${this.visitorId}/timeline`);
3990
+ if (result.success && result.data) {
3991
+ return result.data;
3992
+ }
3993
+ logger.warn('Failed to fetch visitor timeline:', result.error);
3994
+ return null;
3995
+ }
3996
+ /**
3997
+ * Get engagement metrics for the current visitor.
3998
+ * Includes time on site, page views, bounce rate, and engagement score.
3999
+ */
4000
+ async getVisitorEngagement() {
4001
+ if (!this.isInitialized) {
4002
+ logger.warn('SDK not initialized');
4003
+ return null;
4004
+ }
4005
+ const result = await this.transport.fetchData(`/api/public/track/visitor/${this.workspaceId}/${this.visitorId}/engagement`);
4006
+ if (result.success && result.data) {
4007
+ return result.data;
4008
+ }
4009
+ logger.warn('Failed to fetch visitor engagement:', result.error);
4010
+ return null;
4011
+ }
3689
4012
  /**
3690
4013
  * Retry pending identify call
3691
4014
  */
@@ -3715,6 +4038,59 @@ class Tracker {
3715
4038
  logger.enabled = enabled;
3716
4039
  logger.info(`Debug mode ${enabled ? 'enabled' : 'disabled'}`);
3717
4040
  }
4041
+ /**
4042
+ * Register a schema for event validation.
4043
+ * When debug mode is enabled, events will be validated against registered schemas.
4044
+ *
4045
+ * @example
4046
+ * tracker.registerEventSchema('purchase', {
4047
+ * productId: 'string',
4048
+ * price: 'number',
4049
+ * quantity: 'number',
4050
+ * });
4051
+ */
4052
+ registerEventSchema(eventType, schema) {
4053
+ this.eventSchemas.set(eventType, schema);
4054
+ logger.debug('Event schema registered:', eventType);
4055
+ }
4056
+ /**
4057
+ * Validate event properties against a registered schema (debug mode only)
4058
+ */
4059
+ validateEventSchema(eventType, properties) {
4060
+ if (!this.config.debug)
4061
+ return;
4062
+ const schema = this.eventSchemas.get(eventType);
4063
+ if (!schema)
4064
+ return;
4065
+ for (const [key, expectedType] of Object.entries(schema)) {
4066
+ const value = properties[key];
4067
+ if (value === undefined) {
4068
+ logger.warn(`[Schema] Missing property "${key}" for event type "${eventType}"`);
4069
+ continue;
4070
+ }
4071
+ let valid = false;
4072
+ switch (expectedType) {
4073
+ case 'string':
4074
+ valid = typeof value === 'string';
4075
+ break;
4076
+ case 'number':
4077
+ valid = typeof value === 'number';
4078
+ break;
4079
+ case 'boolean':
4080
+ valid = typeof value === 'boolean';
4081
+ break;
4082
+ case 'object':
4083
+ valid = typeof value === 'object' && !Array.isArray(value);
4084
+ break;
4085
+ case 'array':
4086
+ valid = Array.isArray(value);
4087
+ break;
4088
+ }
4089
+ if (!valid) {
4090
+ logger.warn(`[Schema] Property "${key}" for event "${eventType}" expected ${expectedType}, got ${typeof value}`);
4091
+ }
4092
+ }
4093
+ }
3718
4094
  /**
3719
4095
  * Get visitor ID
3720
4096
  */
@@ -3754,6 +4130,8 @@ class Tracker {
3754
4130
  resetIds(this.config.useCookies);
3755
4131
  this.visitorId = this.createVisitorId();
3756
4132
  this.sessionId = this.createSessionId();
4133
+ this.contactId = null;
4134
+ this.pendingIdentify = null;
3757
4135
  this.queue.clear();
3758
4136
  }
3759
4137
  /**
@@ -3795,6 +4173,86 @@ class Tracker {
3795
4173
  this.sessionId = this.createSessionId();
3796
4174
  logger.info('All user data deleted');
3797
4175
  }
4176
+ // ============================================
4177
+ // PUBLIC CRM METHODS (no API key required)
4178
+ // ============================================
4179
+ /**
4180
+ * Create or update a contact by email (upsert).
4181
+ * Secured by domain whitelist — no API key needed.
4182
+ */
4183
+ async createContact(data) {
4184
+ return this.publicCrmRequest('/api/public/crm/contacts', 'POST', {
4185
+ workspaceId: this.workspaceId,
4186
+ ...data,
4187
+ });
4188
+ }
4189
+ /**
4190
+ * Update an existing contact by ID (limited fields only).
4191
+ */
4192
+ async updateContact(contactId, data) {
4193
+ return this.publicCrmRequest(`/api/public/crm/contacts/${contactId}`, 'PUT', {
4194
+ workspaceId: this.workspaceId,
4195
+ ...data,
4196
+ });
4197
+ }
4198
+ /**
4199
+ * Submit a form — creates/updates contact from form data.
4200
+ */
4201
+ async submitForm(formId, data) {
4202
+ const payload = {
4203
+ ...data,
4204
+ metadata: {
4205
+ ...data.metadata,
4206
+ visitorId: this.visitorId,
4207
+ sessionId: this.sessionId,
4208
+ pageUrl: typeof window !== 'undefined' ? window.location.href : undefined,
4209
+ referrer: typeof document !== 'undefined' ? document.referrer || undefined : undefined,
4210
+ },
4211
+ };
4212
+ return this.publicCrmRequest(`/api/public/crm/forms/${formId}/submit`, 'POST', payload);
4213
+ }
4214
+ /**
4215
+ * Log an activity linked to a contact (append-only).
4216
+ */
4217
+ async logActivity(data) {
4218
+ return this.publicCrmRequest('/api/public/crm/activities', 'POST', {
4219
+ workspaceId: this.workspaceId,
4220
+ ...data,
4221
+ });
4222
+ }
4223
+ /**
4224
+ * Create an opportunity (e.g., from "Request Demo" forms).
4225
+ */
4226
+ async createOpportunity(data) {
4227
+ return this.publicCrmRequest('/api/public/crm/opportunities', 'POST', {
4228
+ workspaceId: this.workspaceId,
4229
+ ...data,
4230
+ });
4231
+ }
4232
+ /**
4233
+ * Internal helper for public CRM API calls.
4234
+ */
4235
+ async publicCrmRequest(path, method, body) {
4236
+ const url = `${this.config.apiEndpoint}${path}`;
4237
+ try {
4238
+ const response = await fetch(url, {
4239
+ method,
4240
+ headers: { 'Content-Type': 'application/json' },
4241
+ body: JSON.stringify(body),
4242
+ });
4243
+ const data = await response.json().catch(() => ({}));
4244
+ if (response.ok) {
4245
+ logger.debug(`Public CRM ${method} ${path} succeeded`);
4246
+ return { success: true, data: data.data ?? data, status: response.status };
4247
+ }
4248
+ logger.error(`Public CRM ${method} ${path} failed (${response.status}):`, data.message);
4249
+ return { success: false, error: data.message, status: response.status };
4250
+ }
4251
+ catch (error) {
4252
+ logger.error(`Public CRM ${method} ${path} error:`, error);
4253
+ return { success: false, error: error.message };
4254
+ }
4255
+ }
3798
4256
  /**
3799
4257
  * Destroy tracker and cleanup
3800
4258
  */