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