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