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