@clianta/sdk 1.4.0 → 1.5.0

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