@clianta/sdk 1.2.0 → 1.3.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,5 +1,5 @@
1
1
  /*!
2
- * Clianta SDK v1.2.0
2
+ * Clianta SDK v1.3.0
3
3
  * (c) 2026 Clianta
4
4
  * Released under the MIT License.
5
5
  */
@@ -11,7 +11,7 @@ import { createContext, useRef, useEffect, useContext } from 'react';
11
11
  * @see SDK_VERSION in core/config.ts
12
12
  */
13
13
  /** SDK Version */
14
- const SDK_VERSION = '1.2.0';
14
+ const SDK_VERSION = '1.3.0';
15
15
  /** Default API endpoint based on environment */
16
16
  const getDefaultApiEndpoint = () => {
17
17
  if (typeof window === 'undefined')
@@ -31,7 +31,6 @@ const DEFAULT_PLUGINS = [
31
31
  'engagement',
32
32
  'downloads',
33
33
  'exitIntent',
34
- 'popupForms',
35
34
  ];
36
35
  /** Default configuration values */
37
36
  const DEFAULT_CONFIG = {
@@ -589,6 +588,10 @@ class EventQueue {
589
588
  this.isFlushing = false;
590
589
  /** Rate limiting: timestamps of recent events */
591
590
  this.eventTimestamps = [];
591
+ /** Unload handler references for cleanup */
592
+ this.boundBeforeUnload = null;
593
+ this.boundVisibilityChange = null;
594
+ this.boundPageHide = null;
592
595
  this.transport = transport;
593
596
  this.config = {
594
597
  batchSize: config.batchSize ?? 10,
@@ -703,13 +706,25 @@ class EventQueue {
703
706
  this.persistQueue([]);
704
707
  }
705
708
  /**
706
- * Stop the flush timer
709
+ * Stop the flush timer and cleanup handlers
707
710
  */
708
711
  destroy() {
709
712
  if (this.flushTimer) {
710
713
  clearInterval(this.flushTimer);
711
714
  this.flushTimer = null;
712
715
  }
716
+ // Remove unload handlers
717
+ if (typeof window !== 'undefined') {
718
+ if (this.boundBeforeUnload) {
719
+ window.removeEventListener('beforeunload', this.boundBeforeUnload);
720
+ }
721
+ if (this.boundVisibilityChange) {
722
+ window.removeEventListener('visibilitychange', this.boundVisibilityChange);
723
+ }
724
+ if (this.boundPageHide) {
725
+ window.removeEventListener('pagehide', this.boundPageHide);
726
+ }
727
+ }
713
728
  }
714
729
  /**
715
730
  * Start auto-flush timer
@@ -729,19 +744,18 @@ class EventQueue {
729
744
  if (typeof window === 'undefined')
730
745
  return;
731
746
  // Flush on page unload
732
- window.addEventListener('beforeunload', () => {
733
- this.flushSync();
734
- });
747
+ this.boundBeforeUnload = () => this.flushSync();
748
+ window.addEventListener('beforeunload', this.boundBeforeUnload);
735
749
  // Flush when page becomes hidden
736
- window.addEventListener('visibilitychange', () => {
750
+ this.boundVisibilityChange = () => {
737
751
  if (document.visibilityState === 'hidden') {
738
752
  this.flushSync();
739
753
  }
740
- });
754
+ };
755
+ window.addEventListener('visibilitychange', this.boundVisibilityChange);
741
756
  // Flush on page hide (iOS Safari)
742
- window.addEventListener('pagehide', () => {
743
- this.flushSync();
744
- });
757
+ this.boundPageHide = () => this.flushSync();
758
+ window.addEventListener('pagehide', this.boundPageHide);
745
759
  }
746
760
  /**
747
761
  * Persist queue to localStorage
@@ -884,6 +898,10 @@ class ScrollPlugin extends BasePlugin {
884
898
  this.pageLoadTime = 0;
885
899
  this.scrollTimeout = null;
886
900
  this.boundHandler = null;
901
+ /** SPA navigation support */
902
+ this.originalPushState = null;
903
+ this.originalReplaceState = null;
904
+ this.popstateHandler = null;
887
905
  }
888
906
  init(tracker) {
889
907
  super.init(tracker);
@@ -891,6 +909,8 @@ class ScrollPlugin extends BasePlugin {
891
909
  if (typeof window !== 'undefined') {
892
910
  this.boundHandler = this.handleScroll.bind(this);
893
911
  window.addEventListener('scroll', this.boundHandler, { passive: true });
912
+ // Setup SPA navigation reset
913
+ this.setupNavigationReset();
894
914
  }
895
915
  }
896
916
  destroy() {
@@ -900,8 +920,53 @@ class ScrollPlugin extends BasePlugin {
900
920
  if (this.scrollTimeout) {
901
921
  clearTimeout(this.scrollTimeout);
902
922
  }
923
+ // Restore original history methods
924
+ if (this.originalPushState) {
925
+ history.pushState = this.originalPushState;
926
+ this.originalPushState = null;
927
+ }
928
+ if (this.originalReplaceState) {
929
+ history.replaceState = this.originalReplaceState;
930
+ this.originalReplaceState = null;
931
+ }
932
+ // Remove popstate listener
933
+ if (this.popstateHandler && typeof window !== 'undefined') {
934
+ window.removeEventListener('popstate', this.popstateHandler);
935
+ this.popstateHandler = null;
936
+ }
903
937
  super.destroy();
904
938
  }
939
+ /**
940
+ * Reset scroll tracking for SPA navigation
941
+ */
942
+ resetForNavigation() {
943
+ this.milestonesReached.clear();
944
+ this.maxScrollDepth = 0;
945
+ this.pageLoadTime = Date.now();
946
+ }
947
+ /**
948
+ * Setup History API interception for SPA navigation
949
+ */
950
+ setupNavigationReset() {
951
+ if (typeof window === 'undefined')
952
+ return;
953
+ // Store originals for cleanup
954
+ this.originalPushState = history.pushState;
955
+ this.originalReplaceState = history.replaceState;
956
+ // Intercept pushState and replaceState
957
+ const self = this;
958
+ history.pushState = function (...args) {
959
+ self.originalPushState.apply(history, args);
960
+ self.resetForNavigation();
961
+ };
962
+ history.replaceState = function (...args) {
963
+ self.originalReplaceState.apply(history, args);
964
+ self.resetForNavigation();
965
+ };
966
+ // Handle back/forward navigation
967
+ this.popstateHandler = () => this.resetForNavigation();
968
+ window.addEventListener('popstate', this.popstateHandler);
969
+ }
905
970
  handleScroll() {
906
971
  // Debounce scroll tracking
907
972
  if (this.scrollTimeout) {
@@ -954,6 +1019,7 @@ class FormsPlugin extends BasePlugin {
954
1019
  this.trackedForms = new WeakSet();
955
1020
  this.formInteractions = new Set();
956
1021
  this.observer = null;
1022
+ this.listeners = [];
957
1023
  }
958
1024
  init(tracker) {
959
1025
  super.init(tracker);
@@ -972,8 +1038,20 @@ class FormsPlugin extends BasePlugin {
972
1038
  this.observer.disconnect();
973
1039
  this.observer = null;
974
1040
  }
1041
+ // Remove all tracked event listeners
1042
+ for (const { element, event, handler } of this.listeners) {
1043
+ element.removeEventListener(event, handler);
1044
+ }
1045
+ this.listeners = [];
975
1046
  super.destroy();
976
1047
  }
1048
+ /**
1049
+ * Track event listener for cleanup
1050
+ */
1051
+ addListener(element, event, handler) {
1052
+ element.addEventListener(event, handler);
1053
+ this.listeners.push({ element, event, handler });
1054
+ }
977
1055
  trackAllForms() {
978
1056
  document.querySelectorAll('form').forEach((form) => {
979
1057
  this.setupFormTracking(form);
@@ -999,7 +1077,7 @@ class FormsPlugin extends BasePlugin {
999
1077
  if (!field.name || field.type === 'submit' || field.type === 'button')
1000
1078
  return;
1001
1079
  ['focus', 'blur', 'change'].forEach((eventType) => {
1002
- field.addEventListener(eventType, () => {
1080
+ const handler = () => {
1003
1081
  const key = `${formId}-${field.name}-${eventType}`;
1004
1082
  if (!this.formInteractions.has(key)) {
1005
1083
  this.formInteractions.add(key);
@@ -1010,12 +1088,13 @@ class FormsPlugin extends BasePlugin {
1010
1088
  interactionType: eventType,
1011
1089
  });
1012
1090
  }
1013
- });
1091
+ };
1092
+ this.addListener(field, eventType, handler);
1014
1093
  });
1015
1094
  }
1016
1095
  });
1017
1096
  // Track form submission
1018
- form.addEventListener('submit', () => {
1097
+ const submitHandler = () => {
1019
1098
  this.track('form_submit', 'Form Submitted', {
1020
1099
  formId,
1021
1100
  action: form.action,
@@ -1023,7 +1102,8 @@ class FormsPlugin extends BasePlugin {
1023
1102
  });
1024
1103
  // Auto-identify if email field found
1025
1104
  this.autoIdentify(form);
1026
- });
1105
+ };
1106
+ this.addListener(form, 'submit', submitHandler);
1027
1107
  }
1028
1108
  autoIdentify(form) {
1029
1109
  const emailField = form.querySelector('input[type="email"], input[name*="email"]');
@@ -1107,6 +1187,7 @@ class EngagementPlugin extends BasePlugin {
1107
1187
  this.engagementTimeout = null;
1108
1188
  this.boundMarkEngaged = null;
1109
1189
  this.boundTrackTimeOnPage = null;
1190
+ this.boundVisibilityHandler = null;
1110
1191
  }
1111
1192
  init(tracker) {
1112
1193
  super.init(tracker);
@@ -1117,12 +1198,7 @@ class EngagementPlugin extends BasePlugin {
1117
1198
  // Setup engagement detection
1118
1199
  this.boundMarkEngaged = this.markEngaged.bind(this);
1119
1200
  this.boundTrackTimeOnPage = this.trackTimeOnPage.bind(this);
1120
- ['mousemove', 'keydown', 'touchstart', 'scroll'].forEach((event) => {
1121
- document.addEventListener(event, this.boundMarkEngaged, { passive: true });
1122
- });
1123
- // Track time on page before unload
1124
- window.addEventListener('beforeunload', this.boundTrackTimeOnPage);
1125
- window.addEventListener('visibilitychange', () => {
1201
+ this.boundVisibilityHandler = () => {
1126
1202
  if (document.visibilityState === 'hidden') {
1127
1203
  this.trackTimeOnPage();
1128
1204
  }
@@ -1130,7 +1206,13 @@ class EngagementPlugin extends BasePlugin {
1130
1206
  // Reset engagement timer when page becomes visible again
1131
1207
  this.engagementStartTime = Date.now();
1132
1208
  }
1209
+ };
1210
+ ['mousemove', 'keydown', 'touchstart', 'scroll'].forEach((event) => {
1211
+ document.addEventListener(event, this.boundMarkEngaged, { passive: true });
1133
1212
  });
1213
+ // Track time on page before unload
1214
+ window.addEventListener('beforeunload', this.boundTrackTimeOnPage);
1215
+ document.addEventListener('visibilitychange', this.boundVisibilityHandler);
1134
1216
  }
1135
1217
  destroy() {
1136
1218
  if (this.boundMarkEngaged && typeof document !== 'undefined') {
@@ -1141,6 +1223,9 @@ class EngagementPlugin extends BasePlugin {
1141
1223
  if (this.boundTrackTimeOnPage && typeof window !== 'undefined') {
1142
1224
  window.removeEventListener('beforeunload', this.boundTrackTimeOnPage);
1143
1225
  }
1226
+ if (this.boundVisibilityHandler && typeof document !== 'undefined') {
1227
+ document.removeEventListener('visibilitychange', this.boundVisibilityHandler);
1228
+ }
1144
1229
  if (this.engagementTimeout) {
1145
1230
  clearTimeout(this.engagementTimeout);
1146
1231
  }
@@ -1185,20 +1270,69 @@ class DownloadsPlugin extends BasePlugin {
1185
1270
  this.name = 'downloads';
1186
1271
  this.trackedDownloads = new Set();
1187
1272
  this.boundHandler = null;
1273
+ /** SPA navigation support */
1274
+ this.originalPushState = null;
1275
+ this.originalReplaceState = null;
1276
+ this.popstateHandler = null;
1188
1277
  }
1189
1278
  init(tracker) {
1190
1279
  super.init(tracker);
1191
1280
  if (typeof document !== 'undefined') {
1192
1281
  this.boundHandler = this.handleClick.bind(this);
1193
1282
  document.addEventListener('click', this.boundHandler, true);
1283
+ // Setup SPA navigation reset
1284
+ this.setupNavigationReset();
1194
1285
  }
1195
1286
  }
1196
1287
  destroy() {
1197
1288
  if (this.boundHandler && typeof document !== 'undefined') {
1198
1289
  document.removeEventListener('click', this.boundHandler, true);
1199
1290
  }
1291
+ // Restore original history methods
1292
+ if (this.originalPushState) {
1293
+ history.pushState = this.originalPushState;
1294
+ this.originalPushState = null;
1295
+ }
1296
+ if (this.originalReplaceState) {
1297
+ history.replaceState = this.originalReplaceState;
1298
+ this.originalReplaceState = null;
1299
+ }
1300
+ // Remove popstate listener
1301
+ if (this.popstateHandler && typeof window !== 'undefined') {
1302
+ window.removeEventListener('popstate', this.popstateHandler);
1303
+ this.popstateHandler = null;
1304
+ }
1200
1305
  super.destroy();
1201
1306
  }
1307
+ /**
1308
+ * Reset download tracking for SPA navigation
1309
+ */
1310
+ resetForNavigation() {
1311
+ this.trackedDownloads.clear();
1312
+ }
1313
+ /**
1314
+ * Setup History API interception for SPA navigation
1315
+ */
1316
+ setupNavigationReset() {
1317
+ if (typeof window === 'undefined')
1318
+ return;
1319
+ // Store originals for cleanup
1320
+ this.originalPushState = history.pushState;
1321
+ this.originalReplaceState = history.replaceState;
1322
+ // Intercept pushState and replaceState
1323
+ const self = this;
1324
+ history.pushState = function (...args) {
1325
+ self.originalPushState.apply(history, args);
1326
+ self.resetForNavigation();
1327
+ };
1328
+ history.replaceState = function (...args) {
1329
+ self.originalReplaceState.apply(history, args);
1330
+ self.resetForNavigation();
1331
+ };
1332
+ // Handle back/forward navigation
1333
+ this.popstateHandler = () => this.resetForNavigation();
1334
+ window.addEventListener('popstate', this.popstateHandler);
1335
+ }
1202
1336
  handleClick(e) {
1203
1337
  const link = e.target.closest('a');
1204
1338
  if (!link || !link.href)
@@ -1324,17 +1458,34 @@ class PerformancePlugin extends BasePlugin {
1324
1458
  constructor() {
1325
1459
  super(...arguments);
1326
1460
  this.name = 'performance';
1461
+ this.boundLoadHandler = null;
1462
+ this.observers = [];
1463
+ this.boundClsVisibilityHandler = null;
1327
1464
  }
1328
1465
  init(tracker) {
1329
1466
  super.init(tracker);
1330
1467
  if (typeof window !== 'undefined') {
1331
1468
  // Track performance after page load
1332
- window.addEventListener('load', () => {
1469
+ this.boundLoadHandler = () => {
1333
1470
  // Delay to ensure all metrics are available
1334
1471
  setTimeout(() => this.trackPerformance(), 100);
1335
- });
1472
+ };
1473
+ window.addEventListener('load', this.boundLoadHandler);
1336
1474
  }
1337
1475
  }
1476
+ destroy() {
1477
+ if (this.boundLoadHandler && typeof window !== 'undefined') {
1478
+ window.removeEventListener('load', this.boundLoadHandler);
1479
+ }
1480
+ for (const observer of this.observers) {
1481
+ observer.disconnect();
1482
+ }
1483
+ this.observers = [];
1484
+ if (this.boundClsVisibilityHandler && typeof window !== 'undefined') {
1485
+ window.removeEventListener('visibilitychange', this.boundClsVisibilityHandler);
1486
+ }
1487
+ super.destroy();
1488
+ }
1338
1489
  trackPerformance() {
1339
1490
  if (typeof performance === 'undefined')
1340
1491
  return;
@@ -1391,6 +1542,7 @@ class PerformancePlugin extends BasePlugin {
1391
1542
  }
1392
1543
  });
1393
1544
  lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
1545
+ this.observers.push(lcpObserver);
1394
1546
  }
1395
1547
  catch {
1396
1548
  // LCP not supported
@@ -1408,6 +1560,7 @@ class PerformancePlugin extends BasePlugin {
1408
1560
  }
1409
1561
  });
1410
1562
  fidObserver.observe({ type: 'first-input', buffered: true });
1563
+ this.observers.push(fidObserver);
1411
1564
  }
1412
1565
  catch {
1413
1566
  // FID not supported
@@ -1424,15 +1577,17 @@ class PerformancePlugin extends BasePlugin {
1424
1577
  });
1425
1578
  });
1426
1579
  clsObserver.observe({ type: 'layout-shift', buffered: true });
1580
+ this.observers.push(clsObserver);
1427
1581
  // Report CLS after page is hidden
1428
- window.addEventListener('visibilitychange', () => {
1582
+ this.boundClsVisibilityHandler = () => {
1429
1583
  if (document.visibilityState === 'hidden' && clsValue > 0) {
1430
1584
  this.track('performance', 'Web Vital - CLS', {
1431
1585
  metric: 'CLS',
1432
1586
  value: Math.round(clsValue * 1000) / 1000,
1433
1587
  });
1434
1588
  }
1435
- }, { once: true });
1589
+ };
1590
+ window.addEventListener('visibilitychange', this.boundClsVisibilityHandler, { once: true });
1436
1591
  }
1437
1592
  catch {
1438
1593
  // CLS not supported
@@ -1743,7 +1898,7 @@ class PopupFormsPlugin extends BasePlugin {
1743
1898
  label.appendChild(requiredMark);
1744
1899
  }
1745
1900
  fieldWrapper.appendChild(label);
1746
- // Input/Textarea
1901
+ // Input/Textarea/Select
1747
1902
  if (field.type === 'textarea') {
1748
1903
  const textarea = document.createElement('textarea');
1749
1904
  textarea.name = field.name;
@@ -1754,6 +1909,38 @@ class PopupFormsPlugin extends BasePlugin {
1754
1909
  textarea.style.cssText = 'width: 100%; padding: 8px 12px; border: 1px solid #E4E4E7; border-radius: 6px; font-size: 14px; resize: vertical; min-height: 80px; box-sizing: border-box;';
1755
1910
  fieldWrapper.appendChild(textarea);
1756
1911
  }
1912
+ else if (field.type === 'select') {
1913
+ const select = document.createElement('select');
1914
+ select.name = field.name;
1915
+ if (field.required)
1916
+ select.required = true;
1917
+ select.style.cssText = 'width: 100%; padding: 8px 12px; border: 1px solid #E4E4E7; border-radius: 6px; font-size: 14px; box-sizing: border-box; background: white; cursor: pointer;';
1918
+ // Add placeholder option
1919
+ if (field.placeholder) {
1920
+ const placeholderOption = document.createElement('option');
1921
+ placeholderOption.value = '';
1922
+ placeholderOption.textContent = field.placeholder;
1923
+ placeholderOption.disabled = true;
1924
+ placeholderOption.selected = true;
1925
+ select.appendChild(placeholderOption);
1926
+ }
1927
+ // Add options from field.options array if provided
1928
+ if (field.options && Array.isArray(field.options)) {
1929
+ field.options.forEach((opt) => {
1930
+ const option = document.createElement('option');
1931
+ if (typeof opt === 'string') {
1932
+ option.value = opt;
1933
+ option.textContent = opt;
1934
+ }
1935
+ else {
1936
+ option.value = opt.value;
1937
+ option.textContent = opt.label;
1938
+ }
1939
+ select.appendChild(option);
1940
+ });
1941
+ }
1942
+ fieldWrapper.appendChild(select);
1943
+ }
1757
1944
  else {
1758
1945
  const input = document.createElement('input');
1759
1946
  input.type = field.type;
@@ -1787,96 +1974,6 @@ class PopupFormsPlugin extends BasePlugin {
1787
1974
  formElement.appendChild(submitBtn);
1788
1975
  container.appendChild(formElement);
1789
1976
  }
1790
- buildFormHTML(form) {
1791
- const style = form.style || {};
1792
- const primaryColor = style.primaryColor || '#10B981';
1793
- const textColor = style.textColor || '#18181B';
1794
- let fieldsHTML = form.fields.map(field => {
1795
- const requiredMark = field.required ? '<span style="color: #EF4444;">*</span>' : '';
1796
- if (field.type === 'textarea') {
1797
- return `
1798
- <div style="margin-bottom: 12px;">
1799
- <label style="display: block; font-size: 14px; font-weight: 500; margin-bottom: 4px; color: ${textColor};">
1800
- ${field.label} ${requiredMark}
1801
- </label>
1802
- <textarea
1803
- name="${field.name}"
1804
- placeholder="${field.placeholder || ''}"
1805
- ${field.required ? 'required' : ''}
1806
- style="width: 100%; padding: 8px 12px; border: 1px solid #E4E4E7; border-radius: 6px; font-size: 14px; resize: vertical; min-height: 80px;"
1807
- ></textarea>
1808
- </div>
1809
- `;
1810
- }
1811
- else if (field.type === 'checkbox') {
1812
- return `
1813
- <div style="margin-bottom: 12px;">
1814
- <label style="display: flex; align-items: center; gap: 8px; font-size: 14px; color: ${textColor}; cursor: pointer;">
1815
- <input
1816
- type="checkbox"
1817
- name="${field.name}"
1818
- ${field.required ? 'required' : ''}
1819
- style="width: 16px; height: 16px;"
1820
- />
1821
- ${field.label} ${requiredMark}
1822
- </label>
1823
- </div>
1824
- `;
1825
- }
1826
- else {
1827
- return `
1828
- <div style="margin-bottom: 12px;">
1829
- <label style="display: block; font-size: 14px; font-weight: 500; margin-bottom: 4px; color: ${textColor};">
1830
- ${field.label} ${requiredMark}
1831
- </label>
1832
- <input
1833
- type="${field.type}"
1834
- name="${field.name}"
1835
- placeholder="${field.placeholder || ''}"
1836
- ${field.required ? 'required' : ''}
1837
- style="width: 100%; padding: 8px 12px; border: 1px solid #E4E4E7; border-radius: 6px; font-size: 14px; box-sizing: border-box;"
1838
- />
1839
- </div>
1840
- `;
1841
- }
1842
- }).join('');
1843
- return `
1844
- <button id="clianta-form-close" style="
1845
- position: absolute;
1846
- top: 12px;
1847
- right: 12px;
1848
- background: none;
1849
- border: none;
1850
- font-size: 20px;
1851
- cursor: pointer;
1852
- color: #71717A;
1853
- padding: 4px;
1854
- ">&times;</button>
1855
- <h2 style="font-size: 20px; font-weight: 700; margin-bottom: 8px; color: ${textColor};">
1856
- ${form.headline || 'Stay in touch'}
1857
- </h2>
1858
- <p style="font-size: 14px; color: #71717A; margin-bottom: 16px;">
1859
- ${form.subheadline || 'Get the latest updates'}
1860
- </p>
1861
- <form id="clianta-form-element">
1862
- ${fieldsHTML}
1863
- <button type="submit" style="
1864
- width: 100%;
1865
- padding: 10px 16px;
1866
- background: ${primaryColor};
1867
- color: white;
1868
- border: none;
1869
- border-radius: 6px;
1870
- font-size: 14px;
1871
- font-weight: 500;
1872
- cursor: pointer;
1873
- margin-top: 8px;
1874
- ">
1875
- ${form.submitButtonText || 'Subscribe'}
1876
- </button>
1877
- </form>
1878
- `;
1879
- }
1880
1977
  setupFormEvents(form, overlay, container) {
1881
1978
  // Close button
1882
1979
  const closeBtn = container.querySelector('#clianta-form-close');
@@ -2277,6 +2374,8 @@ class Tracker {
2277
2374
  constructor(workspaceId, userConfig = {}) {
2278
2375
  this.plugins = [];
2279
2376
  this.isInitialized = false;
2377
+ /** Pending identify retry on next flush */
2378
+ this.pendingIdentify = null;
2280
2379
  if (!workspaceId) {
2281
2380
  throw new Error('[Clianta] Workspace ID is required');
2282
2381
  }
@@ -2406,7 +2505,7 @@ class Tracker {
2406
2505
  referrer: typeof document !== 'undefined' ? document.referrer || undefined : undefined,
2407
2506
  properties,
2408
2507
  device: getDeviceInfo(),
2409
- utm: getUTMParams(),
2508
+ ...getUTMParams(),
2410
2509
  timestamp: new Date().toISOString(),
2411
2510
  sdkVersion: SDK_VERSION,
2412
2511
  };
@@ -2451,11 +2550,24 @@ class Tracker {
2451
2550
  });
2452
2551
  if (result.success) {
2453
2552
  logger.info('Visitor identified successfully');
2553
+ this.pendingIdentify = null;
2454
2554
  }
2455
2555
  else {
2456
2556
  logger.error('Failed to identify visitor:', result.error);
2557
+ // Store for retry on next flush
2558
+ this.pendingIdentify = { email, traits };
2457
2559
  }
2458
2560
  }
2561
+ /**
2562
+ * Retry pending identify call
2563
+ */
2564
+ async retryPendingIdentify() {
2565
+ if (!this.pendingIdentify)
2566
+ return;
2567
+ const { email, traits } = this.pendingIdentify;
2568
+ this.pendingIdentify = null;
2569
+ await this.identify(email, traits);
2570
+ }
2459
2571
  /**
2460
2572
  * Update consent state
2461
2573
  */
@@ -2503,6 +2615,7 @@ class Tracker {
2503
2615
  * Force flush event queue
2504
2616
  */
2505
2617
  async flush() {
2618
+ await this.retryPendingIdentify();
2506
2619
  await this.queue.flush();
2507
2620
  }
2508
2621
  /**
@@ -2574,6 +2687,440 @@ class Tracker {
2574
2687
  }
2575
2688
  }
2576
2689
 
2690
+ /**
2691
+ * Clianta SDK - Event Triggers Manager
2692
+ * Manages event-driven automation and email notifications
2693
+ */
2694
+ /**
2695
+ * Event Triggers Manager
2696
+ * Handles event-driven automation based on CRM actions
2697
+ *
2698
+ * Similar to:
2699
+ * - Salesforce: Process Builder, Flow Automation
2700
+ * - HubSpot: Workflows, Email Sequences
2701
+ * - Pipedrive: Workflow Automation
2702
+ */
2703
+ class EventTriggersManager {
2704
+ constructor(apiEndpoint, workspaceId, authToken) {
2705
+ this.triggers = new Map();
2706
+ this.listeners = new Map();
2707
+ this.apiEndpoint = apiEndpoint;
2708
+ this.workspaceId = workspaceId;
2709
+ this.authToken = authToken;
2710
+ }
2711
+ /**
2712
+ * Set authentication token
2713
+ */
2714
+ setAuthToken(token) {
2715
+ this.authToken = token;
2716
+ }
2717
+ /**
2718
+ * Make authenticated API request
2719
+ */
2720
+ async request(endpoint, options = {}) {
2721
+ const url = `${this.apiEndpoint}${endpoint}`;
2722
+ const headers = {
2723
+ 'Content-Type': 'application/json',
2724
+ ...(options.headers || {}),
2725
+ };
2726
+ if (this.authToken) {
2727
+ headers['Authorization'] = `Bearer ${this.authToken}`;
2728
+ }
2729
+ try {
2730
+ const response = await fetch(url, {
2731
+ ...options,
2732
+ headers,
2733
+ });
2734
+ const data = await response.json();
2735
+ if (!response.ok) {
2736
+ return {
2737
+ success: false,
2738
+ error: data.message || 'Request failed',
2739
+ status: response.status,
2740
+ };
2741
+ }
2742
+ return {
2743
+ success: true,
2744
+ data: data.data || data,
2745
+ status: response.status,
2746
+ };
2747
+ }
2748
+ catch (error) {
2749
+ return {
2750
+ success: false,
2751
+ error: error instanceof Error ? error.message : 'Network error',
2752
+ status: 0,
2753
+ };
2754
+ }
2755
+ }
2756
+ // ============================================
2757
+ // TRIGGER MANAGEMENT
2758
+ // ============================================
2759
+ /**
2760
+ * Get all event triggers
2761
+ */
2762
+ async getTriggers() {
2763
+ return this.request(`/api/workspaces/${this.workspaceId}/triggers`);
2764
+ }
2765
+ /**
2766
+ * Get a single trigger by ID
2767
+ */
2768
+ async getTrigger(triggerId) {
2769
+ return this.request(`/api/workspaces/${this.workspaceId}/triggers/${triggerId}`);
2770
+ }
2771
+ /**
2772
+ * Create a new event trigger
2773
+ */
2774
+ async createTrigger(trigger) {
2775
+ const result = await this.request(`/api/workspaces/${this.workspaceId}/triggers`, {
2776
+ method: 'POST',
2777
+ body: JSON.stringify(trigger),
2778
+ });
2779
+ // Cache the trigger locally if successful
2780
+ if (result.success && result.data?._id) {
2781
+ this.triggers.set(result.data._id, result.data);
2782
+ }
2783
+ return result;
2784
+ }
2785
+ /**
2786
+ * Update an existing trigger
2787
+ */
2788
+ async updateTrigger(triggerId, updates) {
2789
+ const result = await this.request(`/api/workspaces/${this.workspaceId}/triggers/${triggerId}`, {
2790
+ method: 'PUT',
2791
+ body: JSON.stringify(updates),
2792
+ });
2793
+ // Update cache if successful
2794
+ if (result.success && result.data?._id) {
2795
+ this.triggers.set(result.data._id, result.data);
2796
+ }
2797
+ return result;
2798
+ }
2799
+ /**
2800
+ * Delete a trigger
2801
+ */
2802
+ async deleteTrigger(triggerId) {
2803
+ const result = await this.request(`/api/workspaces/${this.workspaceId}/triggers/${triggerId}`, {
2804
+ method: 'DELETE',
2805
+ });
2806
+ // Remove from cache if successful
2807
+ if (result.success) {
2808
+ this.triggers.delete(triggerId);
2809
+ }
2810
+ return result;
2811
+ }
2812
+ /**
2813
+ * Activate a trigger
2814
+ */
2815
+ async activateTrigger(triggerId) {
2816
+ return this.updateTrigger(triggerId, { isActive: true });
2817
+ }
2818
+ /**
2819
+ * Deactivate a trigger
2820
+ */
2821
+ async deactivateTrigger(triggerId) {
2822
+ return this.updateTrigger(triggerId, { isActive: false });
2823
+ }
2824
+ // ============================================
2825
+ // EVENT HANDLING (CLIENT-SIDE)
2826
+ // ============================================
2827
+ /**
2828
+ * Register a local event listener for client-side triggers
2829
+ * This allows immediate client-side reactions to events
2830
+ */
2831
+ on(eventType, callback) {
2832
+ if (!this.listeners.has(eventType)) {
2833
+ this.listeners.set(eventType, new Set());
2834
+ }
2835
+ this.listeners.get(eventType).add(callback);
2836
+ logger.debug(`Event listener registered: ${eventType}`);
2837
+ }
2838
+ /**
2839
+ * Remove an event listener
2840
+ */
2841
+ off(eventType, callback) {
2842
+ const listeners = this.listeners.get(eventType);
2843
+ if (listeners) {
2844
+ listeners.delete(callback);
2845
+ }
2846
+ }
2847
+ /**
2848
+ * Emit an event (client-side only)
2849
+ * This will trigger any registered local listeners
2850
+ */
2851
+ emit(eventType, data) {
2852
+ logger.debug(`Event emitted: ${eventType}`, data);
2853
+ const listeners = this.listeners.get(eventType);
2854
+ if (listeners) {
2855
+ listeners.forEach(callback => {
2856
+ try {
2857
+ callback(data);
2858
+ }
2859
+ catch (error) {
2860
+ logger.error(`Error in event listener for ${eventType}:`, error);
2861
+ }
2862
+ });
2863
+ }
2864
+ }
2865
+ /**
2866
+ * Check if conditions are met for a trigger
2867
+ * Supports dynamic field evaluation including custom fields and nested paths
2868
+ */
2869
+ evaluateConditions(conditions, data) {
2870
+ if (!conditions || conditions.length === 0) {
2871
+ return true; // No conditions means always fire
2872
+ }
2873
+ return conditions.every(condition => {
2874
+ // Support dot notation for nested fields (e.g., 'customFields.industry')
2875
+ const fieldValue = condition.field.includes('.')
2876
+ ? this.getNestedValue(data, condition.field)
2877
+ : data[condition.field];
2878
+ const targetValue = condition.value;
2879
+ switch (condition.operator) {
2880
+ case 'equals':
2881
+ return fieldValue === targetValue;
2882
+ case 'not_equals':
2883
+ return fieldValue !== targetValue;
2884
+ case 'contains':
2885
+ return String(fieldValue).includes(String(targetValue));
2886
+ case 'greater_than':
2887
+ return Number(fieldValue) > Number(targetValue);
2888
+ case 'less_than':
2889
+ return Number(fieldValue) < Number(targetValue);
2890
+ case 'in':
2891
+ return Array.isArray(targetValue) && targetValue.includes(fieldValue);
2892
+ case 'not_in':
2893
+ return Array.isArray(targetValue) && !targetValue.includes(fieldValue);
2894
+ default:
2895
+ return false;
2896
+ }
2897
+ });
2898
+ }
2899
+ /**
2900
+ * Execute actions for a triggered event (client-side preview)
2901
+ * Note: Actual execution happens on the backend
2902
+ */
2903
+ async executeActions(trigger, data) {
2904
+ logger.info(`Executing actions for trigger: ${trigger.name}`);
2905
+ for (const action of trigger.actions) {
2906
+ try {
2907
+ await this.executeAction(action, data);
2908
+ }
2909
+ catch (error) {
2910
+ logger.error(`Failed to execute action:`, error);
2911
+ }
2912
+ }
2913
+ }
2914
+ /**
2915
+ * Execute a single action
2916
+ */
2917
+ async executeAction(action, data) {
2918
+ switch (action.type) {
2919
+ case 'send_email':
2920
+ await this.executeSendEmail(action, data);
2921
+ break;
2922
+ case 'webhook':
2923
+ await this.executeWebhook(action, data);
2924
+ break;
2925
+ case 'create_task':
2926
+ await this.executeCreateTask(action, data);
2927
+ break;
2928
+ case 'update_contact':
2929
+ await this.executeUpdateContact(action, data);
2930
+ break;
2931
+ default:
2932
+ logger.warn(`Unknown action type:`, action);
2933
+ }
2934
+ }
2935
+ /**
2936
+ * Execute send email action (via backend API)
2937
+ */
2938
+ async executeSendEmail(action, data) {
2939
+ logger.debug('Sending email:', action);
2940
+ const payload = {
2941
+ to: this.replaceVariables(action.to, data),
2942
+ subject: action.subject ? this.replaceVariables(action.subject, data) : undefined,
2943
+ body: action.body ? this.replaceVariables(action.body, data) : undefined,
2944
+ templateId: action.templateId,
2945
+ cc: action.cc,
2946
+ bcc: action.bcc,
2947
+ from: action.from,
2948
+ delayMinutes: action.delayMinutes,
2949
+ };
2950
+ await this.request(`/api/workspaces/${this.workspaceId}/emails/send`, {
2951
+ method: 'POST',
2952
+ body: JSON.stringify(payload),
2953
+ });
2954
+ }
2955
+ /**
2956
+ * Execute webhook action
2957
+ */
2958
+ async executeWebhook(action, data) {
2959
+ logger.debug('Calling webhook:', action.url);
2960
+ const body = action.body ? this.replaceVariables(action.body, data) : JSON.stringify(data);
2961
+ await fetch(action.url, {
2962
+ method: action.method,
2963
+ headers: {
2964
+ 'Content-Type': 'application/json',
2965
+ ...action.headers,
2966
+ },
2967
+ body,
2968
+ });
2969
+ }
2970
+ /**
2971
+ * Execute create task action
2972
+ */
2973
+ async executeCreateTask(action, data) {
2974
+ logger.debug('Creating task:', action.title);
2975
+ const dueDate = action.dueDays
2976
+ ? new Date(Date.now() + action.dueDays * 24 * 60 * 60 * 1000).toISOString()
2977
+ : undefined;
2978
+ await this.request(`/api/workspaces/${this.workspaceId}/tasks`, {
2979
+ method: 'POST',
2980
+ body: JSON.stringify({
2981
+ title: this.replaceVariables(action.title, data),
2982
+ description: action.description ? this.replaceVariables(action.description, data) : undefined,
2983
+ priority: action.priority,
2984
+ dueDate,
2985
+ assignedTo: action.assignedTo,
2986
+ relatedContactId: typeof data.contactId === 'string' ? data.contactId : undefined,
2987
+ }),
2988
+ });
2989
+ }
2990
+ /**
2991
+ * Execute update contact action
2992
+ */
2993
+ async executeUpdateContact(action, data) {
2994
+ const contactId = data.contactId || data._id;
2995
+ if (!contactId) {
2996
+ logger.warn('Cannot update contact: no contactId in data');
2997
+ return;
2998
+ }
2999
+ logger.debug('Updating contact:', contactId);
3000
+ await this.request(`/api/workspaces/${this.workspaceId}/contacts/${contactId}`, {
3001
+ method: 'PUT',
3002
+ body: JSON.stringify(action.updates),
3003
+ });
3004
+ }
3005
+ /**
3006
+ * Replace variables in a string template
3007
+ * Supports syntax like {{contact.email}}, {{opportunity.value}}
3008
+ */
3009
+ replaceVariables(template, data) {
3010
+ return template.replace(/\{\{([^}]+)\}\}/g, (match, path) => {
3011
+ const value = this.getNestedValue(data, path.trim());
3012
+ return value !== undefined ? String(value) : match;
3013
+ });
3014
+ }
3015
+ /**
3016
+ * Get nested value from object using dot notation
3017
+ * Supports dynamic field access including custom fields
3018
+ */
3019
+ getNestedValue(obj, path) {
3020
+ return path.split('.').reduce((current, key) => {
3021
+ return current !== null && current !== undefined && typeof current === 'object'
3022
+ ? current[key]
3023
+ : undefined;
3024
+ }, obj);
3025
+ }
3026
+ /**
3027
+ * Extract all available field paths from a data object
3028
+ * Useful for dynamic field discovery based on platform-specific attributes
3029
+ * @param obj - The data object to extract fields from
3030
+ * @param prefix - Internal use for nested paths
3031
+ * @param maxDepth - Maximum depth to traverse (default: 3)
3032
+ * @returns Array of field paths (e.g., ['email', 'contact.firstName', 'customFields.industry'])
3033
+ */
3034
+ extractAvailableFields(obj, prefix = '', maxDepth = 3) {
3035
+ if (maxDepth <= 0)
3036
+ return [];
3037
+ const fields = [];
3038
+ for (const key in obj) {
3039
+ if (!obj.hasOwnProperty(key))
3040
+ continue;
3041
+ const value = obj[key];
3042
+ const fieldPath = prefix ? `${prefix}.${key}` : key;
3043
+ fields.push(fieldPath);
3044
+ // Recursively traverse nested objects
3045
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
3046
+ const nestedFields = this.extractAvailableFields(value, fieldPath, maxDepth - 1);
3047
+ fields.push(...nestedFields);
3048
+ }
3049
+ }
3050
+ return fields;
3051
+ }
3052
+ /**
3053
+ * Get available fields from sample data
3054
+ * Helps with dynamic field detection for platform-specific attributes
3055
+ * @param sampleData - Sample data object to analyze
3056
+ * @returns Array of available field paths
3057
+ */
3058
+ getAvailableFields(sampleData) {
3059
+ return this.extractAvailableFields(sampleData);
3060
+ }
3061
+ // ============================================
3062
+ // HELPER METHODS FOR COMMON PATTERNS
3063
+ // ============================================
3064
+ /**
3065
+ * Create a simple email trigger
3066
+ * Helper method for common use case
3067
+ */
3068
+ async createEmailTrigger(config) {
3069
+ return this.createTrigger({
3070
+ name: config.name,
3071
+ eventType: config.eventType,
3072
+ conditions: config.conditions,
3073
+ actions: [
3074
+ {
3075
+ type: 'send_email',
3076
+ to: config.to,
3077
+ subject: config.subject,
3078
+ body: config.body,
3079
+ },
3080
+ ],
3081
+ isActive: true,
3082
+ });
3083
+ }
3084
+ /**
3085
+ * Create a task creation trigger
3086
+ */
3087
+ async createTaskTrigger(config) {
3088
+ return this.createTrigger({
3089
+ name: config.name,
3090
+ eventType: config.eventType,
3091
+ conditions: config.conditions,
3092
+ actions: [
3093
+ {
3094
+ type: 'create_task',
3095
+ title: config.taskTitle,
3096
+ description: config.taskDescription,
3097
+ priority: config.priority,
3098
+ dueDays: config.dueDays,
3099
+ },
3100
+ ],
3101
+ isActive: true,
3102
+ });
3103
+ }
3104
+ /**
3105
+ * Create a webhook trigger
3106
+ */
3107
+ async createWebhookTrigger(config) {
3108
+ return this.createTrigger({
3109
+ name: config.name,
3110
+ eventType: config.eventType,
3111
+ conditions: config.conditions,
3112
+ actions: [
3113
+ {
3114
+ type: 'webhook',
3115
+ url: config.webhookUrl,
3116
+ method: config.method || 'POST',
3117
+ },
3118
+ ],
3119
+ isActive: true,
3120
+ });
3121
+ }
3122
+ }
3123
+
2577
3124
  /**
2578
3125
  * Clianta SDK - CRM API Client
2579
3126
  * @see SDK_VERSION in core/config.ts
@@ -2586,12 +3133,23 @@ class CRMClient {
2586
3133
  this.apiEndpoint = apiEndpoint;
2587
3134
  this.workspaceId = workspaceId;
2588
3135
  this.authToken = authToken;
3136
+ this.triggers = new EventTriggersManager(apiEndpoint, workspaceId, authToken);
2589
3137
  }
2590
3138
  /**
2591
3139
  * Set authentication token for API requests
2592
3140
  */
2593
3141
  setAuthToken(token) {
2594
3142
  this.authToken = token;
3143
+ this.triggers.setAuthToken(token);
3144
+ }
3145
+ /**
3146
+ * Validate required parameter exists
3147
+ * @throws {Error} if value is null/undefined or empty string
3148
+ */
3149
+ validateRequired(param, value, methodName) {
3150
+ if (value === null || value === undefined || value === '') {
3151
+ throw new Error(`[CRMClient.${methodName}] ${param} is required`);
3152
+ }
2595
3153
  }
2596
3154
  /**
2597
3155
  * Make authenticated API request
@@ -2656,6 +3214,7 @@ class CRMClient {
2656
3214
  * Get a single contact by ID
2657
3215
  */
2658
3216
  async getContact(contactId) {
3217
+ this.validateRequired('contactId', contactId, 'getContact');
2659
3218
  return this.request(`/api/workspaces/${this.workspaceId}/contacts/${contactId}`);
2660
3219
  }
2661
3220
  /**
@@ -2671,6 +3230,7 @@ class CRMClient {
2671
3230
  * Update an existing contact
2672
3231
  */
2673
3232
  async updateContact(contactId, updates) {
3233
+ this.validateRequired('contactId', contactId, 'updateContact');
2674
3234
  return this.request(`/api/workspaces/${this.workspaceId}/contacts/${contactId}`, {
2675
3235
  method: 'PUT',
2676
3236
  body: JSON.stringify(updates),
@@ -2680,6 +3240,7 @@ class CRMClient {
2680
3240
  * Delete a contact
2681
3241
  */
2682
3242
  async deleteContact(contactId) {
3243
+ this.validateRequired('contactId', contactId, 'deleteContact');
2683
3244
  return this.request(`/api/workspaces/${this.workspaceId}/contacts/${contactId}`, {
2684
3245
  method: 'DELETE',
2685
3246
  });
@@ -3043,6 +3604,90 @@ class CRMClient {
3043
3604
  opportunityId: data.opportunityId,
3044
3605
  });
3045
3606
  }
3607
+ // ============================================
3608
+ // EMAIL TEMPLATES API
3609
+ // ============================================
3610
+ /**
3611
+ * Get all email templates
3612
+ */
3613
+ async getEmailTemplates(params) {
3614
+ const queryParams = new URLSearchParams();
3615
+ if (params?.page)
3616
+ queryParams.set('page', params.page.toString());
3617
+ if (params?.limit)
3618
+ queryParams.set('limit', params.limit.toString());
3619
+ const query = queryParams.toString();
3620
+ const endpoint = `/api/workspaces/${this.workspaceId}/email-templates${query ? `?${query}` : ''}`;
3621
+ return this.request(endpoint);
3622
+ }
3623
+ /**
3624
+ * Get a single email template by ID
3625
+ */
3626
+ async getEmailTemplate(templateId) {
3627
+ return this.request(`/api/workspaces/${this.workspaceId}/email-templates/${templateId}`);
3628
+ }
3629
+ /**
3630
+ * Create a new email template
3631
+ */
3632
+ async createEmailTemplate(template) {
3633
+ return this.request(`/api/workspaces/${this.workspaceId}/email-templates`, {
3634
+ method: 'POST',
3635
+ body: JSON.stringify(template),
3636
+ });
3637
+ }
3638
+ /**
3639
+ * Update an email template
3640
+ */
3641
+ async updateEmailTemplate(templateId, updates) {
3642
+ return this.request(`/api/workspaces/${this.workspaceId}/email-templates/${templateId}`, {
3643
+ method: 'PUT',
3644
+ body: JSON.stringify(updates),
3645
+ });
3646
+ }
3647
+ /**
3648
+ * Delete an email template
3649
+ */
3650
+ async deleteEmailTemplate(templateId) {
3651
+ return this.request(`/api/workspaces/${this.workspaceId}/email-templates/${templateId}`, {
3652
+ method: 'DELETE',
3653
+ });
3654
+ }
3655
+ /**
3656
+ * Send an email using a template
3657
+ */
3658
+ async sendEmail(data) {
3659
+ return this.request(`/api/workspaces/${this.workspaceId}/emails/send`, {
3660
+ method: 'POST',
3661
+ body: JSON.stringify(data),
3662
+ });
3663
+ }
3664
+ // ============================================
3665
+ // EVENT TRIGGERS API (delegated to triggers manager)
3666
+ // ============================================
3667
+ /**
3668
+ * Get all event triggers
3669
+ */
3670
+ async getEventTriggers() {
3671
+ return this.triggers.getTriggers();
3672
+ }
3673
+ /**
3674
+ * Create a new event trigger
3675
+ */
3676
+ async createEventTrigger(trigger) {
3677
+ return this.triggers.createTrigger(trigger);
3678
+ }
3679
+ /**
3680
+ * Update an event trigger
3681
+ */
3682
+ async updateEventTrigger(triggerId, updates) {
3683
+ return this.triggers.updateTrigger(triggerId, updates);
3684
+ }
3685
+ /**
3686
+ * Delete an event trigger
3687
+ */
3688
+ async deleteEventTrigger(triggerId) {
3689
+ return this.triggers.deleteTrigger(triggerId);
3690
+ }
3046
3691
  }
3047
3692
 
3048
3693
  /**
@@ -3097,6 +3742,7 @@ if (typeof window !== 'undefined') {
3097
3742
  Tracker,
3098
3743
  CRMClient,
3099
3744
  ConsentManager,
3745
+ EventTriggersManager,
3100
3746
  };
3101
3747
  }
3102
3748