@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.
package/dist/vue.cjs.js CHANGED
@@ -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
  */
@@ -13,15 +13,22 @@ var vue = require('vue');
13
13
  */
14
14
  /** SDK Version */
15
15
  const SDK_VERSION = '1.4.0';
16
- /** Default API endpoint based on environment */
16
+ /** Default API endpoint reads from env or falls back to localhost */
17
17
  const getDefaultApiEndpoint = () => {
18
- if (typeof window === 'undefined')
19
- return 'https://api.clianta.online';
20
- const hostname = window.location.hostname;
21
- if (hostname.includes('localhost') || hostname.includes('127.0.0.1')) {
22
- return 'http://localhost:5000';
18
+ // Build-time env var (works with Next.js, Vite, CRA, etc.)
19
+ if (typeof process !== 'undefined' && process.env?.NEXT_PUBLIC_CLIANTA_API_ENDPOINT) {
20
+ return process.env.NEXT_PUBLIC_CLIANTA_API_ENDPOINT;
21
+ }
22
+ if (typeof process !== 'undefined' && process.env?.VITE_CLIANTA_API_ENDPOINT) {
23
+ return process.env.VITE_CLIANTA_API_ENDPOINT;
24
+ }
25
+ if (typeof process !== 'undefined' && process.env?.REACT_APP_CLIANTA_API_ENDPOINT) {
26
+ return process.env.REACT_APP_CLIANTA_API_ENDPOINT;
23
27
  }
24
- return 'https://api.clianta.online';
28
+ if (typeof process !== 'undefined' && process.env?.CLIANTA_API_ENDPOINT) {
29
+ return process.env.CLIANTA_API_ENDPOINT;
30
+ }
31
+ return 'http://localhost:5000';
25
32
  };
26
33
  /** Core plugins enabled by default */
27
34
  const DEFAULT_PLUGINS = [
@@ -54,6 +61,7 @@ const DEFAULT_CONFIG = {
54
61
  cookieDomain: '',
55
62
  useCookies: false,
56
63
  cookielessMode: false,
64
+ persistMode: 'session',
57
65
  };
58
66
  /** Storage keys */
59
67
  const STORAGE_KEYS = {
@@ -247,6 +255,39 @@ class Transport {
247
255
  return false;
248
256
  }
249
257
  }
258
+ /**
259
+ * Fetch data from the tracking API (GET request)
260
+ * Used for read-back APIs (visitor profile, activity, etc.)
261
+ */
262
+ async fetchData(path, params) {
263
+ const url = new URL(`${this.config.apiEndpoint}${path}`);
264
+ if (params) {
265
+ Object.entries(params).forEach(([key, value]) => {
266
+ if (value !== undefined && value !== null) {
267
+ url.searchParams.set(key, value);
268
+ }
269
+ });
270
+ }
271
+ try {
272
+ const response = await this.fetchWithTimeout(url.toString(), {
273
+ method: 'GET',
274
+ headers: {
275
+ 'Accept': 'application/json',
276
+ },
277
+ });
278
+ if (response.ok) {
279
+ const body = await response.json();
280
+ logger.debug('Fetch successful:', path);
281
+ return { success: true, data: body.data ?? body, status: response.status };
282
+ }
283
+ logger.error(`Fetch failed with status ${response.status}`);
284
+ return { success: false, status: response.status };
285
+ }
286
+ catch (error) {
287
+ logger.error('Fetch request failed:', error);
288
+ return { success: false, error: error };
289
+ }
290
+ }
250
291
  /**
251
292
  * Internal send with retry logic
252
293
  */
@@ -411,7 +452,9 @@ function cookie(name, value, days) {
411
452
  date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
412
453
  expires = '; expires=' + date.toUTCString();
413
454
  }
414
- document.cookie = name + '=' + value + expires + '; path=/; SameSite=Lax';
455
+ // Add Secure flag on HTTPS to prevent cookie leakage over plaintext
456
+ const secure = typeof location !== 'undefined' && location.protocol === 'https:' ? '; Secure' : '';
457
+ document.cookie = name + '=' + value + expires + '; path=/; SameSite=Lax' + secure;
415
458
  return value;
416
459
  }
417
460
  // ============================================
@@ -575,6 +618,17 @@ function isMobile() {
575
618
  return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
576
619
  }
577
620
  // ============================================
621
+ // VALIDATION UTILITIES
622
+ // ============================================
623
+ /**
624
+ * Validate email format
625
+ */
626
+ function isValidEmail(email) {
627
+ if (typeof email !== 'string' || !email)
628
+ return false;
629
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
630
+ }
631
+ // ============================================
578
632
  // DEVICE INFO
579
633
  // ============================================
580
634
  /**
@@ -628,6 +682,7 @@ class EventQueue {
628
682
  maxQueueSize: config.maxQueueSize ?? MAX_QUEUE_SIZE,
629
683
  storageKey: config.storageKey ?? STORAGE_KEYS.EVENT_QUEUE,
630
684
  };
685
+ this.persistMode = config.persistMode || 'session';
631
686
  // Restore persisted queue
632
687
  this.restoreQueue();
633
688
  // Start auto-flush timer
@@ -733,6 +788,13 @@ class EventQueue {
733
788
  clear() {
734
789
  this.queue = [];
735
790
  this.persistQueue([]);
791
+ // Also clear localStorage if used
792
+ if (this.persistMode === 'local' && typeof localStorage !== 'undefined') {
793
+ try {
794
+ localStorage.removeItem(this.config.storageKey);
795
+ }
796
+ catch { /* ignore */ }
797
+ }
736
798
  }
737
799
  /**
738
800
  * Stop the flush timer and cleanup handlers
@@ -787,22 +849,44 @@ class EventQueue {
787
849
  window.addEventListener('pagehide', this.boundPageHide);
788
850
  }
789
851
  /**
790
- * Persist queue to localStorage
852
+ * Persist queue to storage based on persistMode
791
853
  */
792
854
  persistQueue(events) {
855
+ if (this.persistMode === 'none')
856
+ return;
793
857
  try {
794
- setLocalStorage(this.config.storageKey, JSON.stringify(events));
858
+ const serialized = JSON.stringify(events);
859
+ if (this.persistMode === 'local' && typeof localStorage !== 'undefined') {
860
+ try {
861
+ localStorage.setItem(this.config.storageKey, serialized);
862
+ }
863
+ catch {
864
+ // localStorage quota exceeded — fallback to sessionStorage
865
+ setSessionStorage(this.config.storageKey, serialized);
866
+ }
867
+ }
868
+ else {
869
+ setSessionStorage(this.config.storageKey, serialized);
870
+ }
795
871
  }
796
872
  catch {
797
873
  // Ignore storage errors
798
874
  }
799
875
  }
800
876
  /**
801
- * Restore queue from localStorage
877
+ * Restore queue from storage
802
878
  */
803
879
  restoreQueue() {
804
880
  try {
805
- const stored = getLocalStorage(this.config.storageKey);
881
+ let stored = null;
882
+ // Check localStorage first (cross-session persistence)
883
+ if (this.persistMode === 'local' && typeof localStorage !== 'undefined') {
884
+ stored = localStorage.getItem(this.config.storageKey);
885
+ }
886
+ // Fall back to sessionStorage
887
+ if (!stored) {
888
+ stored = getSessionStorage(this.config.storageKey);
889
+ }
806
890
  if (stored) {
807
891
  const events = JSON.parse(stored);
808
892
  if (Array.isArray(events) && events.length > 0) {
@@ -870,10 +954,13 @@ class PageViewPlugin extends BasePlugin {
870
954
  history.pushState = function (...args) {
871
955
  self.originalPushState.apply(history, args);
872
956
  self.trackPageView();
957
+ // Notify other plugins (e.g. ScrollPlugin) about navigation
958
+ window.dispatchEvent(new Event('clianta:navigation'));
873
959
  };
874
960
  history.replaceState = function (...args) {
875
961
  self.originalReplaceState.apply(history, args);
876
962
  self.trackPageView();
963
+ window.dispatchEvent(new Event('clianta:navigation'));
877
964
  };
878
965
  // Handle back/forward navigation
879
966
  this.popstateHandler = () => this.trackPageView();
@@ -927,9 +1014,8 @@ class ScrollPlugin extends BasePlugin {
927
1014
  this.pageLoadTime = 0;
928
1015
  this.scrollTimeout = null;
929
1016
  this.boundHandler = null;
930
- /** SPA navigation support */
931
- this.originalPushState = null;
932
- this.originalReplaceState = null;
1017
+ /** SPA navigation listen for PageViewPlugin's custom event instead of patching history */
1018
+ this.navigationHandler = null;
933
1019
  this.popstateHandler = null;
934
1020
  }
935
1021
  init(tracker) {
@@ -938,8 +1024,13 @@ class ScrollPlugin extends BasePlugin {
938
1024
  if (typeof window !== 'undefined') {
939
1025
  this.boundHandler = this.handleScroll.bind(this);
940
1026
  window.addEventListener('scroll', this.boundHandler, { passive: true });
941
- // Setup SPA navigation reset
942
- this.setupNavigationReset();
1027
+ // Listen for navigation events dispatched by PageViewPlugin
1028
+ // instead of independently monkey-patching history.pushState
1029
+ this.navigationHandler = () => this.resetForNavigation();
1030
+ window.addEventListener('clianta:navigation', this.navigationHandler);
1031
+ // Handle back/forward navigation
1032
+ this.popstateHandler = () => this.resetForNavigation();
1033
+ window.addEventListener('popstate', this.popstateHandler);
943
1034
  }
944
1035
  }
945
1036
  destroy() {
@@ -949,16 +1040,10 @@ class ScrollPlugin extends BasePlugin {
949
1040
  if (this.scrollTimeout) {
950
1041
  clearTimeout(this.scrollTimeout);
951
1042
  }
952
- // Restore original history methods
953
- if (this.originalPushState) {
954
- history.pushState = this.originalPushState;
955
- this.originalPushState = null;
956
- }
957
- if (this.originalReplaceState) {
958
- history.replaceState = this.originalReplaceState;
959
- this.originalReplaceState = null;
1043
+ if (this.navigationHandler && typeof window !== 'undefined') {
1044
+ window.removeEventListener('clianta:navigation', this.navigationHandler);
1045
+ this.navigationHandler = null;
960
1046
  }
961
- // Remove popstate listener
962
1047
  if (this.popstateHandler && typeof window !== 'undefined') {
963
1048
  window.removeEventListener('popstate', this.popstateHandler);
964
1049
  this.popstateHandler = null;
@@ -973,29 +1058,6 @@ class ScrollPlugin extends BasePlugin {
973
1058
  this.maxScrollDepth = 0;
974
1059
  this.pageLoadTime = Date.now();
975
1060
  }
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
1061
  handleScroll() {
1000
1062
  // Debounce scroll tracking
1001
1063
  if (this.scrollTimeout) {
@@ -1195,6 +1257,10 @@ class ClicksPlugin extends BasePlugin {
1195
1257
  elementId: elementInfo.id,
1196
1258
  elementClass: elementInfo.className,
1197
1259
  href: target.href || undefined,
1260
+ x: Math.round((e.clientX / window.innerWidth) * 100),
1261
+ y: Math.round((e.clientY / window.innerHeight) * 100),
1262
+ viewportWidth: window.innerWidth,
1263
+ viewportHeight: window.innerHeight,
1198
1264
  });
1199
1265
  }
1200
1266
  }
@@ -1217,6 +1283,9 @@ class EngagementPlugin extends BasePlugin {
1217
1283
  this.boundMarkEngaged = null;
1218
1284
  this.boundTrackTimeOnPage = null;
1219
1285
  this.boundVisibilityHandler = null;
1286
+ /** SPA navigation — listen for PageViewPlugin's custom event instead of patching history */
1287
+ this.navigationHandler = null;
1288
+ this.popstateHandler = null;
1220
1289
  }
1221
1290
  init(tracker) {
1222
1291
  super.init(tracker);
@@ -1242,6 +1311,13 @@ class EngagementPlugin extends BasePlugin {
1242
1311
  // Track time on page before unload
1243
1312
  window.addEventListener('beforeunload', this.boundTrackTimeOnPage);
1244
1313
  document.addEventListener('visibilitychange', this.boundVisibilityHandler);
1314
+ // Listen for navigation events dispatched by PageViewPlugin
1315
+ // instead of independently monkey-patching history.pushState
1316
+ this.navigationHandler = () => this.resetForNavigation();
1317
+ window.addEventListener('clianta:navigation', this.navigationHandler);
1318
+ // Handle back/forward navigation
1319
+ this.popstateHandler = () => this.resetForNavigation();
1320
+ window.addEventListener('popstate', this.popstateHandler);
1245
1321
  }
1246
1322
  destroy() {
1247
1323
  if (this.boundMarkEngaged && typeof document !== 'undefined') {
@@ -1255,11 +1331,28 @@ class EngagementPlugin extends BasePlugin {
1255
1331
  if (this.boundVisibilityHandler && typeof document !== 'undefined') {
1256
1332
  document.removeEventListener('visibilitychange', this.boundVisibilityHandler);
1257
1333
  }
1334
+ if (this.navigationHandler && typeof window !== 'undefined') {
1335
+ window.removeEventListener('clianta:navigation', this.navigationHandler);
1336
+ this.navigationHandler = null;
1337
+ }
1338
+ if (this.popstateHandler && typeof window !== 'undefined') {
1339
+ window.removeEventListener('popstate', this.popstateHandler);
1340
+ this.popstateHandler = null;
1341
+ }
1258
1342
  if (this.engagementTimeout) {
1259
1343
  clearTimeout(this.engagementTimeout);
1260
1344
  }
1261
1345
  super.destroy();
1262
1346
  }
1347
+ resetForNavigation() {
1348
+ this.pageLoadTime = Date.now();
1349
+ this.engagementStartTime = Date.now();
1350
+ this.isEngaged = false;
1351
+ if (this.engagementTimeout) {
1352
+ clearTimeout(this.engagementTimeout);
1353
+ this.engagementTimeout = null;
1354
+ }
1355
+ }
1263
1356
  markEngaged() {
1264
1357
  if (!this.isEngaged) {
1265
1358
  this.isEngaged = true;
@@ -1299,9 +1392,8 @@ class DownloadsPlugin extends BasePlugin {
1299
1392
  this.name = 'downloads';
1300
1393
  this.trackedDownloads = new Set();
1301
1394
  this.boundHandler = null;
1302
- /** SPA navigation support */
1303
- this.originalPushState = null;
1304
- this.originalReplaceState = null;
1395
+ /** SPA navigation listen for PageViewPlugin's custom event instead of patching history */
1396
+ this.navigationHandler = null;
1305
1397
  this.popstateHandler = null;
1306
1398
  }
1307
1399
  init(tracker) {
@@ -1309,24 +1401,25 @@ class DownloadsPlugin extends BasePlugin {
1309
1401
  if (typeof document !== 'undefined') {
1310
1402
  this.boundHandler = this.handleClick.bind(this);
1311
1403
  document.addEventListener('click', this.boundHandler, true);
1312
- // Setup SPA navigation reset
1313
- this.setupNavigationReset();
1404
+ }
1405
+ if (typeof window !== 'undefined') {
1406
+ // Listen for navigation events dispatched by PageViewPlugin
1407
+ // instead of independently monkey-patching history.pushState
1408
+ this.navigationHandler = () => this.resetForNavigation();
1409
+ window.addEventListener('clianta:navigation', this.navigationHandler);
1410
+ // Handle back/forward navigation
1411
+ this.popstateHandler = () => this.resetForNavigation();
1412
+ window.addEventListener('popstate', this.popstateHandler);
1314
1413
  }
1315
1414
  }
1316
1415
  destroy() {
1317
1416
  if (this.boundHandler && typeof document !== 'undefined') {
1318
1417
  document.removeEventListener('click', this.boundHandler, true);
1319
1418
  }
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;
1419
+ if (this.navigationHandler && typeof window !== 'undefined') {
1420
+ window.removeEventListener('clianta:navigation', this.navigationHandler);
1421
+ this.navigationHandler = null;
1328
1422
  }
1329
- // Remove popstate listener
1330
1423
  if (this.popstateHandler && typeof window !== 'undefined') {
1331
1424
  window.removeEventListener('popstate', this.popstateHandler);
1332
1425
  this.popstateHandler = null;
@@ -1339,29 +1432,6 @@ class DownloadsPlugin extends BasePlugin {
1339
1432
  resetForNavigation() {
1340
1433
  this.trackedDownloads.clear();
1341
1434
  }
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
1435
  handleClick(e) {
1366
1436
  const link = e.target.closest('a');
1367
1437
  if (!link || !link.href)
@@ -1397,6 +1467,9 @@ class ExitIntentPlugin extends BasePlugin {
1397
1467
  this.exitIntentShown = false;
1398
1468
  this.pageLoadTime = 0;
1399
1469
  this.boundHandler = null;
1470
+ /** SPA navigation — listen for PageViewPlugin's custom event instead of patching history */
1471
+ this.navigationHandler = null;
1472
+ this.popstateHandler = null;
1400
1473
  }
1401
1474
  init(tracker) {
1402
1475
  super.init(tracker);
@@ -1408,13 +1481,34 @@ class ExitIntentPlugin extends BasePlugin {
1408
1481
  this.boundHandler = this.handleMouseLeave.bind(this);
1409
1482
  document.addEventListener('mouseleave', this.boundHandler);
1410
1483
  }
1484
+ if (typeof window !== 'undefined') {
1485
+ // Listen for navigation events dispatched by PageViewPlugin
1486
+ // instead of independently monkey-patching history.pushState
1487
+ this.navigationHandler = () => this.resetForNavigation();
1488
+ window.addEventListener('clianta:navigation', this.navigationHandler);
1489
+ // Handle back/forward navigation
1490
+ this.popstateHandler = () => this.resetForNavigation();
1491
+ window.addEventListener('popstate', this.popstateHandler);
1492
+ }
1411
1493
  }
1412
1494
  destroy() {
1413
1495
  if (this.boundHandler && typeof document !== 'undefined') {
1414
1496
  document.removeEventListener('mouseleave', this.boundHandler);
1415
1497
  }
1498
+ if (this.navigationHandler && typeof window !== 'undefined') {
1499
+ window.removeEventListener('clianta:navigation', this.navigationHandler);
1500
+ this.navigationHandler = null;
1501
+ }
1502
+ if (this.popstateHandler && typeof window !== 'undefined') {
1503
+ window.removeEventListener('popstate', this.popstateHandler);
1504
+ this.popstateHandler = null;
1505
+ }
1416
1506
  super.destroy();
1417
1507
  }
1508
+ resetForNavigation() {
1509
+ this.exitIntentShown = false;
1510
+ this.pageLoadTime = Date.now();
1511
+ }
1418
1512
  handleMouseLeave(e) {
1419
1513
  // Only trigger when mouse leaves from the top of the page
1420
1514
  if (e.clientY > 0 || this.exitIntentShown)
@@ -1642,6 +1736,8 @@ class PopupFormsPlugin extends BasePlugin {
1642
1736
  this.shownForms = new Set();
1643
1737
  this.scrollHandler = null;
1644
1738
  this.exitHandler = null;
1739
+ this.delayTimers = [];
1740
+ this.clickTriggerListeners = [];
1645
1741
  }
1646
1742
  async init(tracker) {
1647
1743
  super.init(tracker);
@@ -1656,6 +1752,14 @@ class PopupFormsPlugin extends BasePlugin {
1656
1752
  }
1657
1753
  destroy() {
1658
1754
  this.removeTriggers();
1755
+ for (const timer of this.delayTimers) {
1756
+ clearTimeout(timer);
1757
+ }
1758
+ this.delayTimers = [];
1759
+ for (const { element, handler } of this.clickTriggerListeners) {
1760
+ element.removeEventListener('click', handler);
1761
+ }
1762
+ this.clickTriggerListeners = [];
1659
1763
  super.destroy();
1660
1764
  }
1661
1765
  loadShownForms() {
@@ -1686,7 +1790,7 @@ class PopupFormsPlugin extends BasePlugin {
1686
1790
  return;
1687
1791
  const config = this.tracker.getConfig();
1688
1792
  const workspaceId = this.tracker.getWorkspaceId();
1689
- const apiEndpoint = config.apiEndpoint || 'https://api.clianta.online';
1793
+ const apiEndpoint = config.apiEndpoint || 'http://localhost:5000';
1690
1794
  try {
1691
1795
  const url = encodeURIComponent(window.location.href);
1692
1796
  const response = await fetch(`${apiEndpoint}/api/public/lead-forms/${workspaceId}?url=${url}`);
@@ -1718,7 +1822,7 @@ class PopupFormsPlugin extends BasePlugin {
1718
1822
  this.forms.forEach(form => {
1719
1823
  switch (form.trigger.type) {
1720
1824
  case 'delay':
1721
- setTimeout(() => this.showForm(form), (form.trigger.value || 5) * 1000);
1825
+ this.delayTimers.push(setTimeout(() => this.showForm(form), (form.trigger.value || 5) * 1000));
1722
1826
  break;
1723
1827
  case 'scroll':
1724
1828
  this.setupScrollTrigger(form);
@@ -1761,7 +1865,9 @@ class PopupFormsPlugin extends BasePlugin {
1761
1865
  return;
1762
1866
  const elements = document.querySelectorAll(form.trigger.selector);
1763
1867
  elements.forEach(el => {
1764
- el.addEventListener('click', () => this.showForm(form));
1868
+ const handler = () => this.showForm(form);
1869
+ el.addEventListener('click', handler);
1870
+ this.clickTriggerListeners.push({ element: el, handler });
1765
1871
  });
1766
1872
  }
1767
1873
  removeTriggers() {
@@ -1789,7 +1895,7 @@ class PopupFormsPlugin extends BasePlugin {
1789
1895
  if (!this.tracker)
1790
1896
  return;
1791
1897
  const config = this.tracker.getConfig();
1792
- const apiEndpoint = config.apiEndpoint || 'https://api.clianta.online';
1898
+ const apiEndpoint = config.apiEndpoint || 'http://localhost:5000';
1793
1899
  try {
1794
1900
  await fetch(`${apiEndpoint}/api/public/lead-forms/${formId}/view`, {
1795
1901
  method: 'POST',
@@ -2036,7 +2142,7 @@ class PopupFormsPlugin extends BasePlugin {
2036
2142
  if (!this.tracker)
2037
2143
  return;
2038
2144
  const config = this.tracker.getConfig();
2039
- const apiEndpoint = config.apiEndpoint || 'https://api.clianta.online';
2145
+ const apiEndpoint = config.apiEndpoint || 'http://localhost:5000';
2040
2146
  const visitorId = this.tracker.getVisitorId();
2041
2147
  // Collect form data
2042
2148
  const formData = new FormData(formElement);
@@ -2048,7 +2154,7 @@ class PopupFormsPlugin extends BasePlugin {
2048
2154
  const submitBtn = formElement.querySelector('button[type="submit"]');
2049
2155
  if (submitBtn) {
2050
2156
  submitBtn.disabled = true;
2051
- submitBtn.innerHTML = 'Submitting...';
2157
+ submitBtn.textContent = 'Submitting...';
2052
2158
  }
2053
2159
  try {
2054
2160
  const response = await fetch(`${apiEndpoint}/api/public/lead-forms/${form._id}/submit`, {
@@ -2089,11 +2195,24 @@ class PopupFormsPlugin extends BasePlugin {
2089
2195
  if (data.email) {
2090
2196
  this.tracker?.identify(data.email, data);
2091
2197
  }
2092
- // Redirect if configured
2198
+ // Redirect if configured (validate URL to prevent open redirect)
2093
2199
  if (form.redirectUrl) {
2094
- setTimeout(() => {
2095
- window.location.href = form.redirectUrl;
2096
- }, 1500);
2200
+ try {
2201
+ const redirect = new URL(form.redirectUrl, window.location.origin);
2202
+ const isSameOrigin = redirect.origin === window.location.origin;
2203
+ const isSafeProtocol = redirect.protocol === 'https:' || redirect.protocol === 'http:';
2204
+ if (isSameOrigin || isSafeProtocol) {
2205
+ setTimeout(() => {
2206
+ window.location.href = redirect.href;
2207
+ }, 1500);
2208
+ }
2209
+ else {
2210
+ console.warn('[Clianta] Blocked unsafe redirect URL:', form.redirectUrl);
2211
+ }
2212
+ }
2213
+ catch {
2214
+ console.warn('[Clianta] Invalid redirect URL:', form.redirectUrl);
2215
+ }
2097
2216
  }
2098
2217
  // Close after delay
2099
2218
  setTimeout(() => {
@@ -2108,7 +2227,7 @@ class PopupFormsPlugin extends BasePlugin {
2108
2227
  console.error('[Clianta] Form submit error:', error);
2109
2228
  if (submitBtn) {
2110
2229
  submitBtn.disabled = false;
2111
- submitBtn.innerHTML = form.submitButtonText || 'Subscribe';
2230
+ submitBtn.textContent = form.submitButtonText || 'Subscribe';
2112
2231
  }
2113
2232
  }
2114
2233
  }
@@ -2918,7 +3037,7 @@ class CRMClient {
2918
3037
  * The contact is upserted in the CRM and matching workflow automations fire automatically.
2919
3038
  *
2920
3039
  * @example
2921
- * const crm = new CRMClient('https://api.clianta.online', 'WORKSPACE_ID');
3040
+ * const crm = new CRMClient('http://localhost:5000', 'WORKSPACE_ID');
2922
3041
  * crm.setApiKey('mm_live_...');
2923
3042
  *
2924
3043
  * await crm.sendEvent({
@@ -3439,6 +3558,114 @@ class CRMClient {
3439
3558
  });
3440
3559
  }
3441
3560
  // ============================================
3561
+ // READ-BACK / DATA RETRIEVAL API
3562
+ // ============================================
3563
+ /**
3564
+ * Get a contact by email address.
3565
+ * Returns the first matching contact from a search query.
3566
+ */
3567
+ async getContactByEmail(email) {
3568
+ this.validateRequired('email', email, 'getContactByEmail');
3569
+ const queryParams = new URLSearchParams({ search: email, limit: '1' });
3570
+ return this.request(`/api/workspaces/${this.workspaceId}/contacts?${queryParams.toString()}`);
3571
+ }
3572
+ /**
3573
+ * Get activity timeline for a contact
3574
+ */
3575
+ async getContactActivity(contactId, params) {
3576
+ this.validateRequired('contactId', contactId, 'getContactActivity');
3577
+ const queryParams = new URLSearchParams();
3578
+ if (params?.page)
3579
+ queryParams.set('page', params.page.toString());
3580
+ if (params?.limit)
3581
+ queryParams.set('limit', params.limit.toString());
3582
+ if (params?.type)
3583
+ queryParams.set('type', params.type);
3584
+ if (params?.startDate)
3585
+ queryParams.set('startDate', params.startDate);
3586
+ if (params?.endDate)
3587
+ queryParams.set('endDate', params.endDate);
3588
+ const query = queryParams.toString();
3589
+ const endpoint = `/api/workspaces/${this.workspaceId}/contacts/${contactId}/activities${query ? `?${query}` : ''}`;
3590
+ return this.request(endpoint);
3591
+ }
3592
+ /**
3593
+ * Get engagement metrics for a contact (via their linked visitor data)
3594
+ */
3595
+ async getContactEngagement(contactId) {
3596
+ this.validateRequired('contactId', contactId, 'getContactEngagement');
3597
+ return this.request(`/api/workspaces/${this.workspaceId}/contacts/${contactId}/engagement`);
3598
+ }
3599
+ /**
3600
+ * Get a full timeline for a contact including events, activities, and opportunities
3601
+ */
3602
+ async getContactTimeline(contactId, params) {
3603
+ this.validateRequired('contactId', contactId, 'getContactTimeline');
3604
+ const queryParams = new URLSearchParams();
3605
+ if (params?.page)
3606
+ queryParams.set('page', params.page.toString());
3607
+ if (params?.limit)
3608
+ queryParams.set('limit', params.limit.toString());
3609
+ const query = queryParams.toString();
3610
+ const endpoint = `/api/workspaces/${this.workspaceId}/contacts/${contactId}/timeline${query ? `?${query}` : ''}`;
3611
+ return this.request(endpoint);
3612
+ }
3613
+ /**
3614
+ * Search contacts with advanced filters
3615
+ */
3616
+ async searchContacts(query, filters) {
3617
+ const queryParams = new URLSearchParams();
3618
+ queryParams.set('search', query);
3619
+ if (filters?.status)
3620
+ queryParams.set('status', filters.status);
3621
+ if (filters?.lifecycleStage)
3622
+ queryParams.set('lifecycleStage', filters.lifecycleStage);
3623
+ if (filters?.source)
3624
+ queryParams.set('source', filters.source);
3625
+ if (filters?.tags)
3626
+ queryParams.set('tags', filters.tags.join(','));
3627
+ if (filters?.page)
3628
+ queryParams.set('page', filters.page.toString());
3629
+ if (filters?.limit)
3630
+ queryParams.set('limit', filters.limit.toString());
3631
+ const qs = queryParams.toString();
3632
+ const endpoint = `/api/workspaces/${this.workspaceId}/contacts${qs ? `?${qs}` : ''}`;
3633
+ return this.request(endpoint);
3634
+ }
3635
+ // ============================================
3636
+ // WEBHOOK MANAGEMENT API
3637
+ // ============================================
3638
+ /**
3639
+ * List all webhook subscriptions
3640
+ */
3641
+ async listWebhooks(params) {
3642
+ const queryParams = new URLSearchParams();
3643
+ if (params?.page)
3644
+ queryParams.set('page', params.page.toString());
3645
+ if (params?.limit)
3646
+ queryParams.set('limit', params.limit.toString());
3647
+ const query = queryParams.toString();
3648
+ return this.request(`/api/workspaces/${this.workspaceId}/webhooks${query ? `?${query}` : ''}`);
3649
+ }
3650
+ /**
3651
+ * Create a new webhook subscription
3652
+ */
3653
+ async createWebhook(data) {
3654
+ return this.request(`/api/workspaces/${this.workspaceId}/webhooks`, {
3655
+ method: 'POST',
3656
+ body: JSON.stringify(data),
3657
+ });
3658
+ }
3659
+ /**
3660
+ * Delete a webhook subscription
3661
+ */
3662
+ async deleteWebhook(webhookId) {
3663
+ this.validateRequired('webhookId', webhookId, 'deleteWebhook');
3664
+ return this.request(`/api/workspaces/${this.workspaceId}/webhooks/${webhookId}`, {
3665
+ method: 'DELETE',
3666
+ });
3667
+ }
3668
+ // ============================================
3442
3669
  // EVENT TRIGGERS API (delegated to triggers manager)
3443
3670
  // ============================================
3444
3671
  /**
@@ -3482,6 +3709,8 @@ class Tracker {
3482
3709
  this.contactId = null;
3483
3710
  /** Pending identify retry on next flush */
3484
3711
  this.pendingIdentify = null;
3712
+ /** Registered event schemas for validation */
3713
+ this.eventSchemas = new Map();
3485
3714
  if (!workspaceId) {
3486
3715
  throw new Error('[Clianta] Workspace ID is required');
3487
3716
  }
@@ -3507,6 +3736,16 @@ class Tracker {
3507
3736
  this.visitorId = this.createVisitorId();
3508
3737
  this.sessionId = this.createSessionId();
3509
3738
  logger.debug('IDs created', { visitorId: this.visitorId, sessionId: this.sessionId });
3739
+ // Security warnings
3740
+ if (this.config.apiEndpoint.startsWith('http://') &&
3741
+ typeof window !== 'undefined' &&
3742
+ !window.location.hostname.includes('localhost') &&
3743
+ !window.location.hostname.includes('127.0.0.1')) {
3744
+ logger.warn('apiEndpoint uses HTTP — events and visitor data will be sent unencrypted. Use HTTPS in production.');
3745
+ }
3746
+ if (this.config.apiKey && typeof window !== 'undefined') {
3747
+ logger.warn('API key is exposed in client-side code. Use API keys only in server-side (Node.js) environments.');
3748
+ }
3510
3749
  // Initialize plugins
3511
3750
  this.initPlugins();
3512
3751
  this.isInitialized = true;
@@ -3612,6 +3851,7 @@ class Tracker {
3612
3851
  properties: {
3613
3852
  ...properties,
3614
3853
  eventId: generateUUID(), // Unique ID for deduplication on retry
3854
+ websiteDomain: typeof window !== 'undefined' ? window.location.hostname : undefined,
3615
3855
  },
3616
3856
  device: getDeviceInfo(),
3617
3857
  ...getUTMParams(),
@@ -3622,6 +3862,8 @@ class Tracker {
3622
3862
  if (this.contactId) {
3623
3863
  event.contactId = this.contactId;
3624
3864
  }
3865
+ // Validate event against registered schema (debug mode only)
3866
+ this.validateEventSchema(eventType, properties);
3625
3867
  // Check consent before tracking
3626
3868
  if (!this.consentManager.canTrack()) {
3627
3869
  // Buffer event for later if waitForConsent is enabled
@@ -3656,6 +3898,10 @@ class Tracker {
3656
3898
  logger.warn('Email is required for identification');
3657
3899
  return null;
3658
3900
  }
3901
+ if (!isValidEmail(email)) {
3902
+ logger.warn('Invalid email format, identification skipped:', email);
3903
+ return null;
3904
+ }
3659
3905
  logger.info('Identifying visitor:', email);
3660
3906
  const result = await this.transport.sendIdentify({
3661
3907
  workspaceId: this.workspaceId,
@@ -3690,6 +3936,83 @@ class Tracker {
3690
3936
  const client = new CRMClient(this.config.apiEndpoint, this.workspaceId, undefined, apiKey);
3691
3937
  return client.sendEvent(payload);
3692
3938
  }
3939
+ /**
3940
+ * Get the current visitor's profile from the CRM.
3941
+ * Returns visitor data and linked contact info if identified.
3942
+ * Only returns data for the current visitor (privacy-safe for frontend).
3943
+ */
3944
+ async getVisitorProfile() {
3945
+ if (!this.isInitialized) {
3946
+ logger.warn('SDK not initialized');
3947
+ return null;
3948
+ }
3949
+ const result = await this.transport.fetchData(`/api/public/track/visitor/${this.workspaceId}/${this.visitorId}/profile`);
3950
+ if (result.success && result.data) {
3951
+ logger.debug('Visitor profile fetched:', result.data);
3952
+ return result.data;
3953
+ }
3954
+ logger.warn('Failed to fetch visitor profile:', result.error);
3955
+ return null;
3956
+ }
3957
+ /**
3958
+ * Get the current visitor's recent activity/events.
3959
+ * Returns paginated list of tracking events for this visitor.
3960
+ */
3961
+ async getVisitorActivity(options) {
3962
+ if (!this.isInitialized) {
3963
+ logger.warn('SDK not initialized');
3964
+ return null;
3965
+ }
3966
+ const params = {};
3967
+ if (options?.page)
3968
+ params.page = options.page.toString();
3969
+ if (options?.limit)
3970
+ params.limit = options.limit.toString();
3971
+ if (options?.eventType)
3972
+ params.eventType = options.eventType;
3973
+ if (options?.startDate)
3974
+ params.startDate = options.startDate;
3975
+ if (options?.endDate)
3976
+ params.endDate = options.endDate;
3977
+ const result = await this.transport.fetchData(`/api/public/track/visitor/${this.workspaceId}/${this.visitorId}/activity`, params);
3978
+ if (result.success && result.data) {
3979
+ return result.data;
3980
+ }
3981
+ logger.warn('Failed to fetch visitor activity:', result.error);
3982
+ return null;
3983
+ }
3984
+ /**
3985
+ * Get a summarized journey timeline for the current visitor.
3986
+ * Includes top pages, sessions, time spent, and recent activities.
3987
+ */
3988
+ async getVisitorTimeline() {
3989
+ if (!this.isInitialized) {
3990
+ logger.warn('SDK not initialized');
3991
+ return null;
3992
+ }
3993
+ const result = await this.transport.fetchData(`/api/public/track/visitor/${this.workspaceId}/${this.visitorId}/timeline`);
3994
+ if (result.success && result.data) {
3995
+ return result.data;
3996
+ }
3997
+ logger.warn('Failed to fetch visitor timeline:', result.error);
3998
+ return null;
3999
+ }
4000
+ /**
4001
+ * Get engagement metrics for the current visitor.
4002
+ * Includes time on site, page views, bounce rate, and engagement score.
4003
+ */
4004
+ async getVisitorEngagement() {
4005
+ if (!this.isInitialized) {
4006
+ logger.warn('SDK not initialized');
4007
+ return null;
4008
+ }
4009
+ const result = await this.transport.fetchData(`/api/public/track/visitor/${this.workspaceId}/${this.visitorId}/engagement`);
4010
+ if (result.success && result.data) {
4011
+ return result.data;
4012
+ }
4013
+ logger.warn('Failed to fetch visitor engagement:', result.error);
4014
+ return null;
4015
+ }
3693
4016
  /**
3694
4017
  * Retry pending identify call
3695
4018
  */
@@ -3719,6 +4042,59 @@ class Tracker {
3719
4042
  logger.enabled = enabled;
3720
4043
  logger.info(`Debug mode ${enabled ? 'enabled' : 'disabled'}`);
3721
4044
  }
4045
+ /**
4046
+ * Register a schema for event validation.
4047
+ * When debug mode is enabled, events will be validated against registered schemas.
4048
+ *
4049
+ * @example
4050
+ * tracker.registerEventSchema('purchase', {
4051
+ * productId: 'string',
4052
+ * price: 'number',
4053
+ * quantity: 'number',
4054
+ * });
4055
+ */
4056
+ registerEventSchema(eventType, schema) {
4057
+ this.eventSchemas.set(eventType, schema);
4058
+ logger.debug('Event schema registered:', eventType);
4059
+ }
4060
+ /**
4061
+ * Validate event properties against a registered schema (debug mode only)
4062
+ */
4063
+ validateEventSchema(eventType, properties) {
4064
+ if (!this.config.debug)
4065
+ return;
4066
+ const schema = this.eventSchemas.get(eventType);
4067
+ if (!schema)
4068
+ return;
4069
+ for (const [key, expectedType] of Object.entries(schema)) {
4070
+ const value = properties[key];
4071
+ if (value === undefined) {
4072
+ logger.warn(`[Schema] Missing property "${key}" for event type "${eventType}"`);
4073
+ continue;
4074
+ }
4075
+ let valid = false;
4076
+ switch (expectedType) {
4077
+ case 'string':
4078
+ valid = typeof value === 'string';
4079
+ break;
4080
+ case 'number':
4081
+ valid = typeof value === 'number';
4082
+ break;
4083
+ case 'boolean':
4084
+ valid = typeof value === 'boolean';
4085
+ break;
4086
+ case 'object':
4087
+ valid = typeof value === 'object' && !Array.isArray(value);
4088
+ break;
4089
+ case 'array':
4090
+ valid = Array.isArray(value);
4091
+ break;
4092
+ }
4093
+ if (!valid) {
4094
+ logger.warn(`[Schema] Property "${key}" for event "${eventType}" expected ${expectedType}, got ${typeof value}`);
4095
+ }
4096
+ }
4097
+ }
3722
4098
  /**
3723
4099
  * Get visitor ID
3724
4100
  */
@@ -3758,6 +4134,8 @@ class Tracker {
3758
4134
  resetIds(this.config.useCookies);
3759
4135
  this.visitorId = this.createVisitorId();
3760
4136
  this.sessionId = this.createSessionId();
4137
+ this.contactId = null;
4138
+ this.pendingIdentify = null;
3761
4139
  this.queue.clear();
3762
4140
  }
3763
4141
  /**
@@ -3799,6 +4177,86 @@ class Tracker {
3799
4177
  this.sessionId = this.createSessionId();
3800
4178
  logger.info('All user data deleted');
3801
4179
  }
4180
+ // ============================================
4181
+ // PUBLIC CRM METHODS (no API key required)
4182
+ // ============================================
4183
+ /**
4184
+ * Create or update a contact by email (upsert).
4185
+ * Secured by domain whitelist — no API key needed.
4186
+ */
4187
+ async createContact(data) {
4188
+ return this.publicCrmRequest('/api/public/crm/contacts', 'POST', {
4189
+ workspaceId: this.workspaceId,
4190
+ ...data,
4191
+ });
4192
+ }
4193
+ /**
4194
+ * Update an existing contact by ID (limited fields only).
4195
+ */
4196
+ async updateContact(contactId, data) {
4197
+ return this.publicCrmRequest(`/api/public/crm/contacts/${contactId}`, 'PUT', {
4198
+ workspaceId: this.workspaceId,
4199
+ ...data,
4200
+ });
4201
+ }
4202
+ /**
4203
+ * Submit a form — creates/updates contact from form data.
4204
+ */
4205
+ async submitForm(formId, data) {
4206
+ const payload = {
4207
+ ...data,
4208
+ metadata: {
4209
+ ...data.metadata,
4210
+ visitorId: this.visitorId,
4211
+ sessionId: this.sessionId,
4212
+ pageUrl: typeof window !== 'undefined' ? window.location.href : undefined,
4213
+ referrer: typeof document !== 'undefined' ? document.referrer || undefined : undefined,
4214
+ },
4215
+ };
4216
+ return this.publicCrmRequest(`/api/public/crm/forms/${formId}/submit`, 'POST', payload);
4217
+ }
4218
+ /**
4219
+ * Log an activity linked to a contact (append-only).
4220
+ */
4221
+ async logActivity(data) {
4222
+ return this.publicCrmRequest('/api/public/crm/activities', 'POST', {
4223
+ workspaceId: this.workspaceId,
4224
+ ...data,
4225
+ });
4226
+ }
4227
+ /**
4228
+ * Create an opportunity (e.g., from "Request Demo" forms).
4229
+ */
4230
+ async createOpportunity(data) {
4231
+ return this.publicCrmRequest('/api/public/crm/opportunities', 'POST', {
4232
+ workspaceId: this.workspaceId,
4233
+ ...data,
4234
+ });
4235
+ }
4236
+ /**
4237
+ * Internal helper for public CRM API calls.
4238
+ */
4239
+ async publicCrmRequest(path, method, body) {
4240
+ const url = `${this.config.apiEndpoint}${path}`;
4241
+ try {
4242
+ const response = await fetch(url, {
4243
+ method,
4244
+ headers: { 'Content-Type': 'application/json' },
4245
+ body: JSON.stringify(body),
4246
+ });
4247
+ const data = await response.json().catch(() => ({}));
4248
+ if (response.ok) {
4249
+ logger.debug(`Public CRM ${method} ${path} succeeded`);
4250
+ return { success: true, data: data.data ?? data, status: response.status };
4251
+ }
4252
+ logger.error(`Public CRM ${method} ${path} failed (${response.status}):`, data.message);
4253
+ return { success: false, error: data.message, status: response.status };
4254
+ }
4255
+ catch (error) {
4256
+ logger.error(`Public CRM ${method} ${path} error:`, error);
4257
+ return { success: false, error: error.message };
4258
+ }
4259
+ }
3802
4260
  /**
3803
4261
  * Destroy tracker and cleanup
3804
4262
  */
@@ -3894,7 +4352,7 @@ const CliantaKey = Symbol('clianta');
3894
4352
  * const app = createApp(App);
3895
4353
  * app.use(CliantaPlugin, {
3896
4354
  * projectId: 'your-project-id',
3897
- * apiEndpoint: 'https://api.clianta.online',
4355
+ * apiEndpoint: import.meta.env.VITE_CLIANTA_API_ENDPOINT || 'http://localhost:5000',
3898
4356
  * debug: import.meta.env.DEV,
3899
4357
  * });
3900
4358
  * app.mount('#app');