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