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