@clianta/sdk 1.2.0 → 1.4.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.4.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.4.0';
18
18
  /** Default API endpoint based on environment */
19
19
  const getDefaultApiEndpoint = () => {
20
20
  if (typeof window === 'undefined')
@@ -34,13 +34,13 @@
34
34
  'engagement',
35
35
  'downloads',
36
36
  'exitIntent',
37
- 'popupForms',
38
37
  ];
39
38
  /** Default configuration values */
40
39
  const DEFAULT_CONFIG = {
41
40
  projectId: '',
42
41
  apiEndpoint: getDefaultApiEndpoint(),
43
42
  authToken: '',
43
+ apiKey: '',
44
44
  debug: false,
45
45
  autoPageView: true,
46
46
  plugins: DEFAULT_PLUGINS,
@@ -188,12 +188,39 @@
188
188
  return this.send(url, payload);
189
189
  }
190
190
  /**
191
- * Send identify request
191
+ * Send identify request.
192
+ * Returns contactId from the server response so the Tracker can store it.
192
193
  */
193
194
  async sendIdentify(data) {
194
195
  const url = `${this.config.apiEndpoint}/api/public/track/identify`;
195
- const payload = JSON.stringify(data);
196
- return this.send(url, payload);
196
+ try {
197
+ const response = await this.fetchWithTimeout(url, {
198
+ method: 'POST',
199
+ headers: { 'Content-Type': 'application/json' },
200
+ body: JSON.stringify(data),
201
+ keepalive: true,
202
+ });
203
+ const body = await response.json().catch(() => ({}));
204
+ if (response.ok) {
205
+ logger.debug('Identify successful, contactId:', body.contactId);
206
+ return {
207
+ success: true,
208
+ status: response.status,
209
+ contactId: body.contactId ?? undefined,
210
+ };
211
+ }
212
+ if (response.status >= 500) {
213
+ logger.warn(`Identify server error (${response.status})`);
214
+ }
215
+ else {
216
+ logger.error(`Identify failed with status ${response.status}:`, body.message);
217
+ }
218
+ return { success: false, status: response.status };
219
+ }
220
+ catch (error) {
221
+ logger.error('Identify request failed:', error);
222
+ return { success: false, error: error };
223
+ }
197
224
  }
198
225
  /**
199
226
  * Send events synchronously (for page unload)
@@ -592,6 +619,10 @@
592
619
  this.isFlushing = false;
593
620
  /** Rate limiting: timestamps of recent events */
594
621
  this.eventTimestamps = [];
622
+ /** Unload handler references for cleanup */
623
+ this.boundBeforeUnload = null;
624
+ this.boundVisibilityChange = null;
625
+ this.boundPageHide = null;
595
626
  this.transport = transport;
596
627
  this.config = {
597
628
  batchSize: config.batchSize ?? 10,
@@ -706,13 +737,25 @@
706
737
  this.persistQueue([]);
707
738
  }
708
739
  /**
709
- * Stop the flush timer
740
+ * Stop the flush timer and cleanup handlers
710
741
  */
711
742
  destroy() {
712
743
  if (this.flushTimer) {
713
744
  clearInterval(this.flushTimer);
714
745
  this.flushTimer = null;
715
746
  }
747
+ // Remove unload handlers
748
+ if (typeof window !== 'undefined') {
749
+ if (this.boundBeforeUnload) {
750
+ window.removeEventListener('beforeunload', this.boundBeforeUnload);
751
+ }
752
+ if (this.boundVisibilityChange) {
753
+ window.removeEventListener('visibilitychange', this.boundVisibilityChange);
754
+ }
755
+ if (this.boundPageHide) {
756
+ window.removeEventListener('pagehide', this.boundPageHide);
757
+ }
758
+ }
716
759
  }
717
760
  /**
718
761
  * Start auto-flush timer
@@ -732,19 +775,18 @@
732
775
  if (typeof window === 'undefined')
733
776
  return;
734
777
  // Flush on page unload
735
- window.addEventListener('beforeunload', () => {
736
- this.flushSync();
737
- });
778
+ this.boundBeforeUnload = () => this.flushSync();
779
+ window.addEventListener('beforeunload', this.boundBeforeUnload);
738
780
  // Flush when page becomes hidden
739
- window.addEventListener('visibilitychange', () => {
781
+ this.boundVisibilityChange = () => {
740
782
  if (document.visibilityState === 'hidden') {
741
783
  this.flushSync();
742
784
  }
743
- });
785
+ };
786
+ window.addEventListener('visibilitychange', this.boundVisibilityChange);
744
787
  // Flush on page hide (iOS Safari)
745
- window.addEventListener('pagehide', () => {
746
- this.flushSync();
747
- });
788
+ this.boundPageHide = () => this.flushSync();
789
+ window.addEventListener('pagehide', this.boundPageHide);
748
790
  }
749
791
  /**
750
792
  * Persist queue to localStorage
@@ -887,6 +929,10 @@
887
929
  this.pageLoadTime = 0;
888
930
  this.scrollTimeout = null;
889
931
  this.boundHandler = null;
932
+ /** SPA navigation support */
933
+ this.originalPushState = null;
934
+ this.originalReplaceState = null;
935
+ this.popstateHandler = null;
890
936
  }
891
937
  init(tracker) {
892
938
  super.init(tracker);
@@ -894,6 +940,8 @@
894
940
  if (typeof window !== 'undefined') {
895
941
  this.boundHandler = this.handleScroll.bind(this);
896
942
  window.addEventListener('scroll', this.boundHandler, { passive: true });
943
+ // Setup SPA navigation reset
944
+ this.setupNavigationReset();
897
945
  }
898
946
  }
899
947
  destroy() {
@@ -903,8 +951,53 @@
903
951
  if (this.scrollTimeout) {
904
952
  clearTimeout(this.scrollTimeout);
905
953
  }
954
+ // Restore original history methods
955
+ if (this.originalPushState) {
956
+ history.pushState = this.originalPushState;
957
+ this.originalPushState = null;
958
+ }
959
+ if (this.originalReplaceState) {
960
+ history.replaceState = this.originalReplaceState;
961
+ this.originalReplaceState = null;
962
+ }
963
+ // Remove popstate listener
964
+ if (this.popstateHandler && typeof window !== 'undefined') {
965
+ window.removeEventListener('popstate', this.popstateHandler);
966
+ this.popstateHandler = null;
967
+ }
906
968
  super.destroy();
907
969
  }
970
+ /**
971
+ * Reset scroll tracking for SPA navigation
972
+ */
973
+ resetForNavigation() {
974
+ this.milestonesReached.clear();
975
+ this.maxScrollDepth = 0;
976
+ this.pageLoadTime = Date.now();
977
+ }
978
+ /**
979
+ * Setup History API interception for SPA navigation
980
+ */
981
+ setupNavigationReset() {
982
+ if (typeof window === 'undefined')
983
+ return;
984
+ // Store originals for cleanup
985
+ this.originalPushState = history.pushState;
986
+ this.originalReplaceState = history.replaceState;
987
+ // Intercept pushState and replaceState
988
+ const self = this;
989
+ history.pushState = function (...args) {
990
+ self.originalPushState.apply(history, args);
991
+ self.resetForNavigation();
992
+ };
993
+ history.replaceState = function (...args) {
994
+ self.originalReplaceState.apply(history, args);
995
+ self.resetForNavigation();
996
+ };
997
+ // Handle back/forward navigation
998
+ this.popstateHandler = () => this.resetForNavigation();
999
+ window.addEventListener('popstate', this.popstateHandler);
1000
+ }
908
1001
  handleScroll() {
909
1002
  // Debounce scroll tracking
910
1003
  if (this.scrollTimeout) {
@@ -957,6 +1050,7 @@
957
1050
  this.trackedForms = new WeakSet();
958
1051
  this.formInteractions = new Set();
959
1052
  this.observer = null;
1053
+ this.listeners = [];
960
1054
  }
961
1055
  init(tracker) {
962
1056
  super.init(tracker);
@@ -975,8 +1069,20 @@
975
1069
  this.observer.disconnect();
976
1070
  this.observer = null;
977
1071
  }
1072
+ // Remove all tracked event listeners
1073
+ for (const { element, event, handler } of this.listeners) {
1074
+ element.removeEventListener(event, handler);
1075
+ }
1076
+ this.listeners = [];
978
1077
  super.destroy();
979
1078
  }
1079
+ /**
1080
+ * Track event listener for cleanup
1081
+ */
1082
+ addListener(element, event, handler) {
1083
+ element.addEventListener(event, handler);
1084
+ this.listeners.push({ element, event, handler });
1085
+ }
980
1086
  trackAllForms() {
981
1087
  document.querySelectorAll('form').forEach((form) => {
982
1088
  this.setupFormTracking(form);
@@ -1002,7 +1108,7 @@
1002
1108
  if (!field.name || field.type === 'submit' || field.type === 'button')
1003
1109
  return;
1004
1110
  ['focus', 'blur', 'change'].forEach((eventType) => {
1005
- field.addEventListener(eventType, () => {
1111
+ const handler = () => {
1006
1112
  const key = `${formId}-${field.name}-${eventType}`;
1007
1113
  if (!this.formInteractions.has(key)) {
1008
1114
  this.formInteractions.add(key);
@@ -1013,12 +1119,13 @@
1013
1119
  interactionType: eventType,
1014
1120
  });
1015
1121
  }
1016
- });
1122
+ };
1123
+ this.addListener(field, eventType, handler);
1017
1124
  });
1018
1125
  }
1019
1126
  });
1020
1127
  // Track form submission
1021
- form.addEventListener('submit', () => {
1128
+ const submitHandler = () => {
1022
1129
  this.track('form_submit', 'Form Submitted', {
1023
1130
  formId,
1024
1131
  action: form.action,
@@ -1026,7 +1133,8 @@
1026
1133
  });
1027
1134
  // Auto-identify if email field found
1028
1135
  this.autoIdentify(form);
1029
- });
1136
+ };
1137
+ this.addListener(form, 'submit', submitHandler);
1030
1138
  }
1031
1139
  autoIdentify(form) {
1032
1140
  const emailField = form.querySelector('input[type="email"], input[name*="email"]');
@@ -1110,6 +1218,7 @@
1110
1218
  this.engagementTimeout = null;
1111
1219
  this.boundMarkEngaged = null;
1112
1220
  this.boundTrackTimeOnPage = null;
1221
+ this.boundVisibilityHandler = null;
1113
1222
  }
1114
1223
  init(tracker) {
1115
1224
  super.init(tracker);
@@ -1120,12 +1229,7 @@
1120
1229
  // Setup engagement detection
1121
1230
  this.boundMarkEngaged = this.markEngaged.bind(this);
1122
1231
  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', () => {
1232
+ this.boundVisibilityHandler = () => {
1129
1233
  if (document.visibilityState === 'hidden') {
1130
1234
  this.trackTimeOnPage();
1131
1235
  }
@@ -1133,7 +1237,13 @@
1133
1237
  // Reset engagement timer when page becomes visible again
1134
1238
  this.engagementStartTime = Date.now();
1135
1239
  }
1240
+ };
1241
+ ['mousemove', 'keydown', 'touchstart', 'scroll'].forEach((event) => {
1242
+ document.addEventListener(event, this.boundMarkEngaged, { passive: true });
1136
1243
  });
1244
+ // Track time on page before unload
1245
+ window.addEventListener('beforeunload', this.boundTrackTimeOnPage);
1246
+ document.addEventListener('visibilitychange', this.boundVisibilityHandler);
1137
1247
  }
1138
1248
  destroy() {
1139
1249
  if (this.boundMarkEngaged && typeof document !== 'undefined') {
@@ -1144,6 +1254,9 @@
1144
1254
  if (this.boundTrackTimeOnPage && typeof window !== 'undefined') {
1145
1255
  window.removeEventListener('beforeunload', this.boundTrackTimeOnPage);
1146
1256
  }
1257
+ if (this.boundVisibilityHandler && typeof document !== 'undefined') {
1258
+ document.removeEventListener('visibilitychange', this.boundVisibilityHandler);
1259
+ }
1147
1260
  if (this.engagementTimeout) {
1148
1261
  clearTimeout(this.engagementTimeout);
1149
1262
  }
@@ -1188,20 +1301,69 @@
1188
1301
  this.name = 'downloads';
1189
1302
  this.trackedDownloads = new Set();
1190
1303
  this.boundHandler = null;
1304
+ /** SPA navigation support */
1305
+ this.originalPushState = null;
1306
+ this.originalReplaceState = null;
1307
+ this.popstateHandler = null;
1191
1308
  }
1192
1309
  init(tracker) {
1193
1310
  super.init(tracker);
1194
1311
  if (typeof document !== 'undefined') {
1195
1312
  this.boundHandler = this.handleClick.bind(this);
1196
1313
  document.addEventListener('click', this.boundHandler, true);
1314
+ // Setup SPA navigation reset
1315
+ this.setupNavigationReset();
1197
1316
  }
1198
1317
  }
1199
1318
  destroy() {
1200
1319
  if (this.boundHandler && typeof document !== 'undefined') {
1201
1320
  document.removeEventListener('click', this.boundHandler, true);
1202
1321
  }
1322
+ // Restore original history methods
1323
+ if (this.originalPushState) {
1324
+ history.pushState = this.originalPushState;
1325
+ this.originalPushState = null;
1326
+ }
1327
+ if (this.originalReplaceState) {
1328
+ history.replaceState = this.originalReplaceState;
1329
+ this.originalReplaceState = null;
1330
+ }
1331
+ // Remove popstate listener
1332
+ if (this.popstateHandler && typeof window !== 'undefined') {
1333
+ window.removeEventListener('popstate', this.popstateHandler);
1334
+ this.popstateHandler = null;
1335
+ }
1203
1336
  super.destroy();
1204
1337
  }
1338
+ /**
1339
+ * Reset download tracking for SPA navigation
1340
+ */
1341
+ resetForNavigation() {
1342
+ this.trackedDownloads.clear();
1343
+ }
1344
+ /**
1345
+ * Setup History API interception for SPA navigation
1346
+ */
1347
+ setupNavigationReset() {
1348
+ if (typeof window === 'undefined')
1349
+ return;
1350
+ // Store originals for cleanup
1351
+ this.originalPushState = history.pushState;
1352
+ this.originalReplaceState = history.replaceState;
1353
+ // Intercept pushState and replaceState
1354
+ const self = this;
1355
+ history.pushState = function (...args) {
1356
+ self.originalPushState.apply(history, args);
1357
+ self.resetForNavigation();
1358
+ };
1359
+ history.replaceState = function (...args) {
1360
+ self.originalReplaceState.apply(history, args);
1361
+ self.resetForNavigation();
1362
+ };
1363
+ // Handle back/forward navigation
1364
+ this.popstateHandler = () => this.resetForNavigation();
1365
+ window.addEventListener('popstate', this.popstateHandler);
1366
+ }
1205
1367
  handleClick(e) {
1206
1368
  const link = e.target.closest('a');
1207
1369
  if (!link || !link.href)
@@ -1327,16 +1489,33 @@
1327
1489
  constructor() {
1328
1490
  super(...arguments);
1329
1491
  this.name = 'performance';
1492
+ this.boundLoadHandler = null;
1493
+ this.observers = [];
1494
+ this.boundClsVisibilityHandler = null;
1330
1495
  }
1331
1496
  init(tracker) {
1332
1497
  super.init(tracker);
1333
1498
  if (typeof window !== 'undefined') {
1334
1499
  // Track performance after page load
1335
- window.addEventListener('load', () => {
1500
+ this.boundLoadHandler = () => {
1336
1501
  // Delay to ensure all metrics are available
1337
1502
  setTimeout(() => this.trackPerformance(), 100);
1338
- });
1503
+ };
1504
+ window.addEventListener('load', this.boundLoadHandler);
1505
+ }
1506
+ }
1507
+ destroy() {
1508
+ if (this.boundLoadHandler && typeof window !== 'undefined') {
1509
+ window.removeEventListener('load', this.boundLoadHandler);
1339
1510
  }
1511
+ for (const observer of this.observers) {
1512
+ observer.disconnect();
1513
+ }
1514
+ this.observers = [];
1515
+ if (this.boundClsVisibilityHandler && typeof window !== 'undefined') {
1516
+ window.removeEventListener('visibilitychange', this.boundClsVisibilityHandler);
1517
+ }
1518
+ super.destroy();
1340
1519
  }
1341
1520
  trackPerformance() {
1342
1521
  if (typeof performance === 'undefined')
@@ -1394,6 +1573,7 @@
1394
1573
  }
1395
1574
  });
1396
1575
  lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
1576
+ this.observers.push(lcpObserver);
1397
1577
  }
1398
1578
  catch {
1399
1579
  // LCP not supported
@@ -1411,6 +1591,7 @@
1411
1591
  }
1412
1592
  });
1413
1593
  fidObserver.observe({ type: 'first-input', buffered: true });
1594
+ this.observers.push(fidObserver);
1414
1595
  }
1415
1596
  catch {
1416
1597
  // FID not supported
@@ -1427,15 +1608,17 @@
1427
1608
  });
1428
1609
  });
1429
1610
  clsObserver.observe({ type: 'layout-shift', buffered: true });
1611
+ this.observers.push(clsObserver);
1430
1612
  // Report CLS after page is hidden
1431
- window.addEventListener('visibilitychange', () => {
1613
+ this.boundClsVisibilityHandler = () => {
1432
1614
  if (document.visibilityState === 'hidden' && clsValue > 0) {
1433
1615
  this.track('performance', 'Web Vital - CLS', {
1434
1616
  metric: 'CLS',
1435
1617
  value: Math.round(clsValue * 1000) / 1000,
1436
1618
  });
1437
1619
  }
1438
- }, { once: true });
1620
+ };
1621
+ window.addEventListener('visibilitychange', this.boundClsVisibilityHandler, { once: true });
1439
1622
  }
1440
1623
  catch {
1441
1624
  // CLS not supported
@@ -1746,7 +1929,7 @@
1746
1929
  label.appendChild(requiredMark);
1747
1930
  }
1748
1931
  fieldWrapper.appendChild(label);
1749
- // Input/Textarea
1932
+ // Input/Textarea/Select
1750
1933
  if (field.type === 'textarea') {
1751
1934
  const textarea = document.createElement('textarea');
1752
1935
  textarea.name = field.name;
@@ -1757,6 +1940,38 @@
1757
1940
  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
1941
  fieldWrapper.appendChild(textarea);
1759
1942
  }
1943
+ else if (field.type === 'select') {
1944
+ const select = document.createElement('select');
1945
+ select.name = field.name;
1946
+ if (field.required)
1947
+ select.required = true;
1948
+ 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;';
1949
+ // Add placeholder option
1950
+ if (field.placeholder) {
1951
+ const placeholderOption = document.createElement('option');
1952
+ placeholderOption.value = '';
1953
+ placeholderOption.textContent = field.placeholder;
1954
+ placeholderOption.disabled = true;
1955
+ placeholderOption.selected = true;
1956
+ select.appendChild(placeholderOption);
1957
+ }
1958
+ // Add options from field.options array if provided
1959
+ if (field.options && Array.isArray(field.options)) {
1960
+ field.options.forEach((opt) => {
1961
+ const option = document.createElement('option');
1962
+ if (typeof opt === 'string') {
1963
+ option.value = opt;
1964
+ option.textContent = opt;
1965
+ }
1966
+ else {
1967
+ option.value = opt.value;
1968
+ option.textContent = opt.label;
1969
+ }
1970
+ select.appendChild(option);
1971
+ });
1972
+ }
1973
+ fieldWrapper.appendChild(select);
1974
+ }
1760
1975
  else {
1761
1976
  const input = document.createElement('input');
1762
1977
  input.type = field.type;
@@ -1790,96 +2005,6 @@
1790
2005
  formElement.appendChild(submitBtn);
1791
2006
  container.appendChild(formElement);
1792
2007
  }
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
2008
  setupFormEvents(form, overlay, container) {
1884
2009
  // Close button
1885
2010
  const closeBtn = container.querySelector('#clianta-form-close');
@@ -2270,310 +2395,436 @@
2270
2395
  }
2271
2396
 
2272
2397
  /**
2273
- * Clianta SDK - Main Tracker Class
2274
- * @see SDK_VERSION in core/config.ts
2398
+ * Clianta SDK - Event Triggers Manager
2399
+ * Manages event-driven automation and email notifications
2275
2400
  */
2276
2401
  /**
2277
- * Main Clianta Tracker Class
2402
+ * Event Triggers Manager
2403
+ * Handles event-driven automation based on CRM actions
2404
+ *
2405
+ * Similar to:
2406
+ * - Salesforce: Process Builder, Flow Automation
2407
+ * - HubSpot: Workflows, Email Sequences
2408
+ * - Pipedrive: Workflow Automation
2278
2409
  */
2279
- class Tracker {
2280
- constructor(workspaceId, userConfig = {}) {
2281
- this.plugins = [];
2282
- this.isInitialized = false;
2283
- if (!workspaceId) {
2284
- throw new Error('[Clianta] Workspace ID is required');
2285
- }
2410
+ class EventTriggersManager {
2411
+ constructor(apiEndpoint, workspaceId, authToken) {
2412
+ this.triggers = new Map();
2413
+ this.listeners = new Map();
2414
+ this.apiEndpoint = apiEndpoint;
2286
2415
  this.workspaceId = workspaceId;
2287
- this.config = mergeConfig(userConfig);
2288
- // Setup debug mode
2289
- logger.enabled = this.config.debug;
2290
- logger.info(`Initializing SDK v${SDK_VERSION}`, { workspaceId });
2291
- // Initialize consent manager
2292
- this.consentManager = new ConsentManager({
2293
- ...this.config.consent,
2294
- onConsentChange: (state, previous) => {
2295
- this.onConsentChange(state, previous);
2296
- },
2297
- });
2298
- // Initialize transport and queue
2299
- this.transport = new Transport({ apiEndpoint: this.config.apiEndpoint });
2300
- this.queue = new EventQueue(this.transport, {
2301
- batchSize: this.config.batchSize,
2302
- flushInterval: this.config.flushInterval,
2303
- });
2304
- // Get or create visitor and session IDs based on mode
2305
- this.visitorId = this.createVisitorId();
2306
- this.sessionId = this.createSessionId();
2307
- logger.debug('IDs created', { visitorId: this.visitorId, sessionId: this.sessionId });
2308
- // Initialize plugins
2309
- this.initPlugins();
2310
- this.isInitialized = true;
2311
- logger.info('SDK initialized successfully');
2416
+ this.authToken = authToken;
2312
2417
  }
2313
2418
  /**
2314
- * Create visitor ID based on storage mode
2419
+ * Set authentication token
2315
2420
  */
2316
- createVisitorId() {
2317
- // Anonymous mode: use temporary ID until consent
2318
- if (this.config.consent.anonymousMode && !this.consentManager.hasExplicit()) {
2319
- const key = STORAGE_KEYS.VISITOR_ID + '_anon';
2320
- let anonId = getSessionStorage(key);
2321
- if (!anonId) {
2322
- anonId = 'anon_' + generateUUID();
2323
- setSessionStorage(key, anonId);
2324
- }
2325
- return anonId;
2421
+ setAuthToken(token) {
2422
+ this.authToken = token;
2423
+ }
2424
+ /**
2425
+ * Make authenticated API request
2426
+ */
2427
+ async request(endpoint, options = {}) {
2428
+ const url = `${this.apiEndpoint}${endpoint}`;
2429
+ const headers = {
2430
+ 'Content-Type': 'application/json',
2431
+ ...(options.headers || {}),
2432
+ };
2433
+ if (this.authToken) {
2434
+ headers['Authorization'] = `Bearer ${this.authToken}`;
2326
2435
  }
2327
- // Cookie-less mode: use sessionStorage only
2328
- if (this.config.cookielessMode) {
2329
- let visitorId = getSessionStorage(STORAGE_KEYS.VISITOR_ID);
2330
- if (!visitorId) {
2331
- visitorId = generateUUID();
2332
- setSessionStorage(STORAGE_KEYS.VISITOR_ID, visitorId);
2436
+ try {
2437
+ const response = await fetch(url, {
2438
+ ...options,
2439
+ headers,
2440
+ });
2441
+ const data = await response.json();
2442
+ if (!response.ok) {
2443
+ return {
2444
+ success: false,
2445
+ error: data.message || 'Request failed',
2446
+ status: response.status,
2447
+ };
2333
2448
  }
2334
- return visitorId;
2449
+ return {
2450
+ success: true,
2451
+ data: data.data || data,
2452
+ status: response.status,
2453
+ };
2335
2454
  }
2336
- // Normal mode
2337
- return getOrCreateVisitorId(this.config.useCookies);
2455
+ catch (error) {
2456
+ return {
2457
+ success: false,
2458
+ error: error instanceof Error ? error.message : 'Network error',
2459
+ status: 0,
2460
+ };
2461
+ }
2462
+ }
2463
+ // ============================================
2464
+ // TRIGGER MANAGEMENT
2465
+ // ============================================
2466
+ /**
2467
+ * Get all event triggers
2468
+ */
2469
+ async getTriggers() {
2470
+ return this.request(`/api/workspaces/${this.workspaceId}/triggers`);
2338
2471
  }
2339
2472
  /**
2340
- * Create session ID
2473
+ * Get a single trigger by ID
2341
2474
  */
2342
- createSessionId() {
2343
- return getOrCreateSessionId(this.config.sessionTimeout);
2475
+ async getTrigger(triggerId) {
2476
+ return this.request(`/api/workspaces/${this.workspaceId}/triggers/${triggerId}`);
2344
2477
  }
2345
2478
  /**
2346
- * Handle consent state changes
2479
+ * Create a new event trigger
2347
2480
  */
2348
- onConsentChange(state, previous) {
2349
- logger.debug('Consent changed:', { from: previous, to: state });
2350
- // If analytics consent was just granted
2351
- if (state.analytics && !previous.analytics) {
2352
- // Upgrade from anonymous ID to persistent ID
2353
- if (this.config.consent.anonymousMode) {
2354
- this.visitorId = getOrCreateVisitorId(this.config.useCookies);
2355
- logger.info('Upgraded from anonymous to persistent visitor ID');
2356
- }
2357
- // Flush buffered events
2358
- const buffered = this.consentManager.flushBuffer();
2359
- for (const event of buffered) {
2360
- // Update event with new visitor ID
2361
- event.visitorId = this.visitorId;
2362
- this.queue.push(event);
2363
- }
2481
+ async createTrigger(trigger) {
2482
+ const result = await this.request(`/api/workspaces/${this.workspaceId}/triggers`, {
2483
+ method: 'POST',
2484
+ body: JSON.stringify(trigger),
2485
+ });
2486
+ // Cache the trigger locally if successful
2487
+ if (result.success && result.data?._id) {
2488
+ this.triggers.set(result.data._id, result.data);
2364
2489
  }
2490
+ return result;
2365
2491
  }
2366
2492
  /**
2367
- * Initialize enabled plugins
2368
- * Handles both sync and async plugin init methods
2493
+ * Update an existing trigger
2369
2494
  */
2370
- initPlugins() {
2371
- const pluginsToLoad = this.config.plugins;
2372
- // Skip pageView plugin if autoPageView is disabled
2373
- const filteredPlugins = this.config.autoPageView
2374
- ? pluginsToLoad
2375
- : pluginsToLoad.filter((p) => p !== 'pageView');
2376
- for (const pluginName of filteredPlugins) {
2377
- try {
2378
- const plugin = getPlugin(pluginName);
2379
- // Handle both sync and async init (fire-and-forget for async)
2380
- const result = plugin.init(this);
2381
- if (result instanceof Promise) {
2382
- result.catch((error) => {
2383
- logger.error(`Async plugin init failed: ${pluginName}`, error);
2384
- });
2385
- }
2386
- this.plugins.push(plugin);
2387
- logger.debug(`Plugin loaded: ${pluginName}`);
2388
- }
2389
- catch (error) {
2390
- logger.error(`Failed to load plugin: ${pluginName}`, error);
2391
- }
2495
+ async updateTrigger(triggerId, updates) {
2496
+ const result = await this.request(`/api/workspaces/${this.workspaceId}/triggers/${triggerId}`, {
2497
+ method: 'PUT',
2498
+ body: JSON.stringify(updates),
2499
+ });
2500
+ // Update cache if successful
2501
+ if (result.success && result.data?._id) {
2502
+ this.triggers.set(result.data._id, result.data);
2392
2503
  }
2504
+ return result;
2393
2505
  }
2394
2506
  /**
2395
- * Track a custom event
2507
+ * Delete a trigger
2396
2508
  */
2397
- track(eventType, eventName, properties = {}) {
2398
- if (!this.isInitialized) {
2399
- logger.warn('SDK not initialized, event dropped');
2400
- return;
2401
- }
2402
- const event = {
2403
- workspaceId: this.workspaceId,
2404
- visitorId: this.visitorId,
2405
- sessionId: this.sessionId,
2406
- eventType: eventType,
2407
- eventName,
2408
- url: typeof window !== 'undefined' ? window.location.href : '',
2409
- referrer: typeof document !== 'undefined' ? document.referrer || undefined : undefined,
2410
- properties,
2411
- device: getDeviceInfo(),
2412
- utm: getUTMParams(),
2413
- timestamp: new Date().toISOString(),
2414
- sdkVersion: SDK_VERSION,
2415
- };
2416
- // Check consent before tracking
2417
- if (!this.consentManager.canTrack()) {
2418
- // Buffer event for later if waitForConsent is enabled
2419
- if (this.config.consent.waitForConsent) {
2420
- this.consentManager.bufferEvent(event);
2421
- return;
2422
- }
2423
- // Otherwise drop the event
2424
- logger.debug('Event dropped (no consent):', eventName);
2425
- return;
2509
+ async deleteTrigger(triggerId) {
2510
+ const result = await this.request(`/api/workspaces/${this.workspaceId}/triggers/${triggerId}`, {
2511
+ method: 'DELETE',
2512
+ });
2513
+ // Remove from cache if successful
2514
+ if (result.success) {
2515
+ this.triggers.delete(triggerId);
2426
2516
  }
2427
- this.queue.push(event);
2428
- logger.debug('Event tracked:', eventName, properties);
2517
+ return result;
2429
2518
  }
2430
2519
  /**
2431
- * Track a page view
2520
+ * Activate a trigger
2432
2521
  */
2433
- page(name, properties = {}) {
2434
- const pageName = name || (typeof document !== 'undefined' ? document.title : 'Page View');
2435
- this.track('page_view', pageName, {
2436
- ...properties,
2437
- path: typeof window !== 'undefined' ? window.location.pathname : '',
2438
- });
2522
+ async activateTrigger(triggerId) {
2523
+ return this.updateTrigger(triggerId, { isActive: true });
2439
2524
  }
2440
2525
  /**
2441
- * Identify a visitor
2526
+ * Deactivate a trigger
2442
2527
  */
2443
- async identify(email, traits = {}) {
2444
- if (!email) {
2445
- logger.warn('Email is required for identification');
2446
- return;
2447
- }
2448
- logger.info('Identifying visitor:', email);
2449
- const result = await this.transport.sendIdentify({
2450
- workspaceId: this.workspaceId,
2451
- visitorId: this.visitorId,
2452
- email,
2453
- properties: traits,
2454
- });
2455
- if (result.success) {
2456
- logger.info('Visitor identified successfully');
2457
- }
2458
- else {
2459
- logger.error('Failed to identify visitor:', result.error);
2460
- }
2528
+ async deactivateTrigger(triggerId) {
2529
+ return this.updateTrigger(triggerId, { isActive: false });
2461
2530
  }
2531
+ // ============================================
2532
+ // EVENT HANDLING (CLIENT-SIDE)
2533
+ // ============================================
2462
2534
  /**
2463
- * Update consent state
2535
+ * Register a local event listener for client-side triggers
2536
+ * This allows immediate client-side reactions to events
2464
2537
  */
2465
- consent(state) {
2466
- this.consentManager.update(state);
2538
+ on(eventType, callback) {
2539
+ if (!this.listeners.has(eventType)) {
2540
+ this.listeners.set(eventType, new Set());
2541
+ }
2542
+ this.listeners.get(eventType).add(callback);
2543
+ logger.debug(`Event listener registered: ${eventType}`);
2467
2544
  }
2468
2545
  /**
2469
- * Get current consent state
2546
+ * Remove an event listener
2470
2547
  */
2471
- getConsentState() {
2472
- return this.consentManager.getState();
2548
+ off(eventType, callback) {
2549
+ const listeners = this.listeners.get(eventType);
2550
+ if (listeners) {
2551
+ listeners.delete(callback);
2552
+ }
2473
2553
  }
2474
2554
  /**
2475
- * Toggle debug mode
2555
+ * Emit an event (client-side only)
2556
+ * This will trigger any registered local listeners
2476
2557
  */
2477
- debug(enabled) {
2478
- logger.enabled = enabled;
2479
- logger.info(`Debug mode ${enabled ? 'enabled' : 'disabled'}`);
2558
+ emit(eventType, data) {
2559
+ logger.debug(`Event emitted: ${eventType}`, data);
2560
+ const listeners = this.listeners.get(eventType);
2561
+ if (listeners) {
2562
+ listeners.forEach(callback => {
2563
+ try {
2564
+ callback(data);
2565
+ }
2566
+ catch (error) {
2567
+ logger.error(`Error in event listener for ${eventType}:`, error);
2568
+ }
2569
+ });
2570
+ }
2480
2571
  }
2481
2572
  /**
2482
- * Get visitor ID
2483
- */
2484
- getVisitorId() {
2485
- return this.visitorId;
2573
+ * Check if conditions are met for a trigger
2574
+ * Supports dynamic field evaluation including custom fields and nested paths
2575
+ */
2576
+ evaluateConditions(conditions, data) {
2577
+ if (!conditions || conditions.length === 0) {
2578
+ return true; // No conditions means always fire
2579
+ }
2580
+ return conditions.every(condition => {
2581
+ // Support dot notation for nested fields (e.g., 'customFields.industry')
2582
+ const fieldValue = condition.field.includes('.')
2583
+ ? this.getNestedValue(data, condition.field)
2584
+ : data[condition.field];
2585
+ const targetValue = condition.value;
2586
+ switch (condition.operator) {
2587
+ case 'equals':
2588
+ return fieldValue === targetValue;
2589
+ case 'not_equals':
2590
+ return fieldValue !== targetValue;
2591
+ case 'contains':
2592
+ return String(fieldValue).includes(String(targetValue));
2593
+ case 'greater_than':
2594
+ return Number(fieldValue) > Number(targetValue);
2595
+ case 'less_than':
2596
+ return Number(fieldValue) < Number(targetValue);
2597
+ case 'in':
2598
+ return Array.isArray(targetValue) && targetValue.includes(fieldValue);
2599
+ case 'not_in':
2600
+ return Array.isArray(targetValue) && !targetValue.includes(fieldValue);
2601
+ default:
2602
+ return false;
2603
+ }
2604
+ });
2486
2605
  }
2487
2606
  /**
2488
- * Get session ID
2607
+ * Execute actions for a triggered event (client-side preview)
2608
+ * Note: Actual execution happens on the backend
2489
2609
  */
2490
- getSessionId() {
2491
- return this.sessionId;
2610
+ async executeActions(trigger, data) {
2611
+ logger.info(`Executing actions for trigger: ${trigger.name}`);
2612
+ for (const action of trigger.actions) {
2613
+ try {
2614
+ await this.executeAction(action, data);
2615
+ }
2616
+ catch (error) {
2617
+ logger.error(`Failed to execute action:`, error);
2618
+ }
2619
+ }
2492
2620
  }
2493
2621
  /**
2494
- * Get workspace ID
2495
- */
2496
- getWorkspaceId() {
2497
- return this.workspaceId;
2622
+ * Execute a single action
2623
+ */
2624
+ async executeAction(action, data) {
2625
+ switch (action.type) {
2626
+ case 'send_email':
2627
+ await this.executeSendEmail(action, data);
2628
+ break;
2629
+ case 'webhook':
2630
+ await this.executeWebhook(action, data);
2631
+ break;
2632
+ case 'create_task':
2633
+ await this.executeCreateTask(action, data);
2634
+ break;
2635
+ case 'update_contact':
2636
+ await this.executeUpdateContact(action, data);
2637
+ break;
2638
+ default:
2639
+ logger.warn(`Unknown action type:`, action);
2640
+ }
2641
+ }
2642
+ /**
2643
+ * Execute send email action (via backend API)
2644
+ */
2645
+ async executeSendEmail(action, data) {
2646
+ logger.debug('Sending email:', action);
2647
+ const payload = {
2648
+ to: this.replaceVariables(action.to, data),
2649
+ subject: action.subject ? this.replaceVariables(action.subject, data) : undefined,
2650
+ body: action.body ? this.replaceVariables(action.body, data) : undefined,
2651
+ templateId: action.templateId,
2652
+ cc: action.cc,
2653
+ bcc: action.bcc,
2654
+ from: action.from,
2655
+ delayMinutes: action.delayMinutes,
2656
+ };
2657
+ await this.request(`/api/workspaces/${this.workspaceId}/emails/send`, {
2658
+ method: 'POST',
2659
+ body: JSON.stringify(payload),
2660
+ });
2498
2661
  }
2499
2662
  /**
2500
- * Get current configuration
2663
+ * Execute webhook action
2501
2664
  */
2502
- getConfig() {
2503
- return { ...this.config };
2665
+ async executeWebhook(action, data) {
2666
+ logger.debug('Calling webhook:', action.url);
2667
+ const body = action.body ? this.replaceVariables(action.body, data) : JSON.stringify(data);
2668
+ await fetch(action.url, {
2669
+ method: action.method,
2670
+ headers: {
2671
+ 'Content-Type': 'application/json',
2672
+ ...action.headers,
2673
+ },
2674
+ body,
2675
+ });
2504
2676
  }
2505
2677
  /**
2506
- * Force flush event queue
2678
+ * Execute create task action
2507
2679
  */
2508
- async flush() {
2509
- await this.queue.flush();
2680
+ async executeCreateTask(action, data) {
2681
+ logger.debug('Creating task:', action.title);
2682
+ const dueDate = action.dueDays
2683
+ ? new Date(Date.now() + action.dueDays * 24 * 60 * 60 * 1000).toISOString()
2684
+ : undefined;
2685
+ await this.request(`/api/workspaces/${this.workspaceId}/tasks`, {
2686
+ method: 'POST',
2687
+ body: JSON.stringify({
2688
+ title: this.replaceVariables(action.title, data),
2689
+ description: action.description ? this.replaceVariables(action.description, data) : undefined,
2690
+ priority: action.priority,
2691
+ dueDate,
2692
+ assignedTo: action.assignedTo,
2693
+ relatedContactId: typeof data.contactId === 'string' ? data.contactId : undefined,
2694
+ }),
2695
+ });
2510
2696
  }
2511
2697
  /**
2512
- * Reset visitor and session (for logout)
2698
+ * Execute update contact action
2513
2699
  */
2514
- reset() {
2515
- logger.info('Resetting visitor data');
2516
- resetIds(this.config.useCookies);
2517
- this.visitorId = this.createVisitorId();
2518
- this.sessionId = this.createSessionId();
2519
- this.queue.clear();
2700
+ async executeUpdateContact(action, data) {
2701
+ const contactId = data.contactId || data._id;
2702
+ if (!contactId) {
2703
+ logger.warn('Cannot update contact: no contactId in data');
2704
+ return;
2705
+ }
2706
+ logger.debug('Updating contact:', contactId);
2707
+ await this.request(`/api/workspaces/${this.workspaceId}/contacts/${contactId}`, {
2708
+ method: 'PUT',
2709
+ body: JSON.stringify(action.updates),
2710
+ });
2520
2711
  }
2521
2712
  /**
2522
- * Delete all stored user data (GDPR right-to-erasure)
2713
+ * Replace variables in a string template
2714
+ * Supports syntax like {{contact.email}}, {{opportunity.value}}
2523
2715
  */
2524
- deleteData() {
2525
- logger.info('Deleting all user data (GDPR request)');
2526
- // Clear queue
2527
- this.queue.clear();
2528
- // Reset consent
2529
- this.consentManager.reset();
2530
- // Clear all stored IDs
2531
- resetIds(this.config.useCookies);
2532
- // Clear session storage items
2533
- if (typeof sessionStorage !== 'undefined') {
2534
- try {
2535
- sessionStorage.removeItem(STORAGE_KEYS.VISITOR_ID);
2536
- sessionStorage.removeItem(STORAGE_KEYS.VISITOR_ID + '_anon');
2537
- sessionStorage.removeItem(STORAGE_KEYS.SESSION_ID);
2538
- sessionStorage.removeItem(STORAGE_KEYS.SESSION_TIMESTAMP);
2539
- }
2540
- catch {
2541
- // Ignore errors
2542
- }
2543
- }
2544
- // Clear localStorage items
2545
- if (typeof localStorage !== 'undefined') {
2546
- try {
2547
- localStorage.removeItem(STORAGE_KEYS.VISITOR_ID);
2548
- localStorage.removeItem(STORAGE_KEYS.CONSENT);
2549
- localStorage.removeItem(STORAGE_KEYS.EVENT_QUEUE);
2550
- }
2551
- catch {
2552
- // Ignore errors
2716
+ replaceVariables(template, data) {
2717
+ return template.replace(/\{\{([^}]+)\}\}/g, (match, path) => {
2718
+ const value = this.getNestedValue(data, path.trim());
2719
+ return value !== undefined ? String(value) : match;
2720
+ });
2721
+ }
2722
+ /**
2723
+ * Get nested value from object using dot notation
2724
+ * Supports dynamic field access including custom fields
2725
+ */
2726
+ getNestedValue(obj, path) {
2727
+ return path.split('.').reduce((current, key) => {
2728
+ return current !== null && current !== undefined && typeof current === 'object'
2729
+ ? current[key]
2730
+ : undefined;
2731
+ }, obj);
2732
+ }
2733
+ /**
2734
+ * Extract all available field paths from a data object
2735
+ * Useful for dynamic field discovery based on platform-specific attributes
2736
+ * @param obj - The data object to extract fields from
2737
+ * @param prefix - Internal use for nested paths
2738
+ * @param maxDepth - Maximum depth to traverse (default: 3)
2739
+ * @returns Array of field paths (e.g., ['email', 'contact.firstName', 'customFields.industry'])
2740
+ */
2741
+ extractAvailableFields(obj, prefix = '', maxDepth = 3) {
2742
+ if (maxDepth <= 0)
2743
+ return [];
2744
+ const fields = [];
2745
+ for (const key in obj) {
2746
+ if (!obj.hasOwnProperty(key))
2747
+ continue;
2748
+ const value = obj[key];
2749
+ const fieldPath = prefix ? `${prefix}.${key}` : key;
2750
+ fields.push(fieldPath);
2751
+ // Recursively traverse nested objects
2752
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
2753
+ const nestedFields = this.extractAvailableFields(value, fieldPath, maxDepth - 1);
2754
+ fields.push(...nestedFields);
2553
2755
  }
2554
2756
  }
2555
- // Generate new IDs
2556
- this.visitorId = this.createVisitorId();
2557
- this.sessionId = this.createSessionId();
2558
- logger.info('All user data deleted');
2757
+ return fields;
2559
2758
  }
2560
2759
  /**
2561
- * Destroy tracker and cleanup
2760
+ * Get available fields from sample data
2761
+ * Helps with dynamic field detection for platform-specific attributes
2762
+ * @param sampleData - Sample data object to analyze
2763
+ * @returns Array of available field paths
2562
2764
  */
2563
- async destroy() {
2564
- logger.info('Destroying tracker');
2565
- // Flush any remaining events (await to ensure completion)
2566
- await this.queue.flush();
2567
- // Destroy plugins
2568
- for (const plugin of this.plugins) {
2569
- if (plugin.destroy) {
2570
- plugin.destroy();
2571
- }
2572
- }
2573
- this.plugins = [];
2574
- // Destroy queue
2575
- this.queue.destroy();
2576
- this.isInitialized = false;
2765
+ getAvailableFields(sampleData) {
2766
+ return this.extractAvailableFields(sampleData);
2767
+ }
2768
+ // ============================================
2769
+ // HELPER METHODS FOR COMMON PATTERNS
2770
+ // ============================================
2771
+ /**
2772
+ * Create a simple email trigger
2773
+ * Helper method for common use case
2774
+ */
2775
+ async createEmailTrigger(config) {
2776
+ return this.createTrigger({
2777
+ name: config.name,
2778
+ eventType: config.eventType,
2779
+ conditions: config.conditions,
2780
+ actions: [
2781
+ {
2782
+ type: 'send_email',
2783
+ to: config.to,
2784
+ subject: config.subject,
2785
+ body: config.body,
2786
+ },
2787
+ ],
2788
+ isActive: true,
2789
+ });
2790
+ }
2791
+ /**
2792
+ * Create a task creation trigger
2793
+ */
2794
+ async createTaskTrigger(config) {
2795
+ return this.createTrigger({
2796
+ name: config.name,
2797
+ eventType: config.eventType,
2798
+ conditions: config.conditions,
2799
+ actions: [
2800
+ {
2801
+ type: 'create_task',
2802
+ title: config.taskTitle,
2803
+ description: config.taskDescription,
2804
+ priority: config.priority,
2805
+ dueDays: config.dueDays,
2806
+ },
2807
+ ],
2808
+ isActive: true,
2809
+ });
2810
+ }
2811
+ /**
2812
+ * Create a webhook trigger
2813
+ */
2814
+ async createWebhookTrigger(config) {
2815
+ return this.createTrigger({
2816
+ name: config.name,
2817
+ eventType: config.eventType,
2818
+ conditions: config.conditions,
2819
+ actions: [
2820
+ {
2821
+ type: 'webhook',
2822
+ url: config.webhookUrl,
2823
+ method: config.method || 'POST',
2824
+ },
2825
+ ],
2826
+ isActive: true,
2827
+ });
2577
2828
  }
2578
2829
  }
2579
2830
 
@@ -2585,16 +2836,37 @@
2585
2836
  * CRM API Client for managing contacts and opportunities
2586
2837
  */
2587
2838
  class CRMClient {
2588
- constructor(apiEndpoint, workspaceId, authToken) {
2839
+ constructor(apiEndpoint, workspaceId, authToken, apiKey) {
2589
2840
  this.apiEndpoint = apiEndpoint;
2590
2841
  this.workspaceId = workspaceId;
2591
2842
  this.authToken = authToken;
2843
+ this.apiKey = apiKey;
2844
+ this.triggers = new EventTriggersManager(apiEndpoint, workspaceId, authToken);
2592
2845
  }
2593
2846
  /**
2594
- * Set authentication token for API requests
2847
+ * Set authentication token for API requests (user JWT)
2595
2848
  */
2596
2849
  setAuthToken(token) {
2597
2850
  this.authToken = token;
2851
+ this.apiKey = undefined;
2852
+ this.triggers.setAuthToken(token);
2853
+ }
2854
+ /**
2855
+ * Set workspace API key for server-to-server requests.
2856
+ * Use this instead of setAuthToken when integrating from an external app.
2857
+ */
2858
+ setApiKey(key) {
2859
+ this.apiKey = key;
2860
+ this.authToken = undefined;
2861
+ }
2862
+ /**
2863
+ * Validate required parameter exists
2864
+ * @throws {Error} if value is null/undefined or empty string
2865
+ */
2866
+ validateRequired(param, value, methodName) {
2867
+ if (value === null || value === undefined || value === '') {
2868
+ throw new Error(`[CRMClient.${methodName}] ${param} is required`);
2869
+ }
2598
2870
  }
2599
2871
  /**
2600
2872
  * Make authenticated API request
@@ -2605,7 +2877,10 @@
2605
2877
  'Content-Type': 'application/json',
2606
2878
  ...(options.headers || {}),
2607
2879
  };
2608
- if (this.authToken) {
2880
+ if (this.apiKey) {
2881
+ headers['X-Api-Key'] = this.apiKey;
2882
+ }
2883
+ else if (this.authToken) {
2609
2884
  headers['Authorization'] = `Bearer ${this.authToken}`;
2610
2885
  }
2611
2886
  try {
@@ -2636,6 +2911,65 @@
2636
2911
  }
2637
2912
  }
2638
2913
  // ============================================
2914
+ // INBOUND EVENTS API (API-key authenticated)
2915
+ // ============================================
2916
+ /**
2917
+ * Send an inbound event from an external app (e.g. user signup on client website).
2918
+ * Requires the client to be initialized with an API key via setApiKey() or the constructor.
2919
+ *
2920
+ * The contact is upserted in the CRM and matching workflow automations fire automatically.
2921
+ *
2922
+ * @example
2923
+ * const crm = new CRMClient('https://api.clianta.online', 'WORKSPACE_ID');
2924
+ * crm.setApiKey('mm_live_...');
2925
+ *
2926
+ * await crm.sendEvent({
2927
+ * event: 'user.registered',
2928
+ * contact: { email: 'alice@example.com', firstName: 'Alice' },
2929
+ * data: { plan: 'free', signupSource: 'homepage' },
2930
+ * });
2931
+ */
2932
+ async sendEvent(payload) {
2933
+ const url = `${this.apiEndpoint}/api/public/events`;
2934
+ const headers = { 'Content-Type': 'application/json' };
2935
+ if (this.apiKey) {
2936
+ headers['X-Api-Key'] = this.apiKey;
2937
+ }
2938
+ else if (this.authToken) {
2939
+ headers['Authorization'] = `Bearer ${this.authToken}`;
2940
+ }
2941
+ try {
2942
+ const response = await fetch(url, {
2943
+ method: 'POST',
2944
+ headers,
2945
+ body: JSON.stringify(payload),
2946
+ });
2947
+ const data = await response.json();
2948
+ if (!response.ok) {
2949
+ return {
2950
+ success: false,
2951
+ contactCreated: false,
2952
+ event: payload.event,
2953
+ error: data.error || 'Request failed',
2954
+ };
2955
+ }
2956
+ return {
2957
+ success: data.success,
2958
+ contactCreated: data.contactCreated,
2959
+ contactId: data.contactId,
2960
+ event: data.event,
2961
+ };
2962
+ }
2963
+ catch (error) {
2964
+ return {
2965
+ success: false,
2966
+ contactCreated: false,
2967
+ event: payload.event,
2968
+ error: error instanceof Error ? error.message : 'Network error',
2969
+ };
2970
+ }
2971
+ }
2972
+ // ============================================
2639
2973
  // CONTACTS API
2640
2974
  // ============================================
2641
2975
  /**
@@ -2659,6 +2993,7 @@
2659
2993
  * Get a single contact by ID
2660
2994
  */
2661
2995
  async getContact(contactId) {
2996
+ this.validateRequired('contactId', contactId, 'getContact');
2662
2997
  return this.request(`/api/workspaces/${this.workspaceId}/contacts/${contactId}`);
2663
2998
  }
2664
2999
  /**
@@ -2674,6 +3009,7 @@
2674
3009
  * Update an existing contact
2675
3010
  */
2676
3011
  async updateContact(contactId, updates) {
3012
+ this.validateRequired('contactId', contactId, 'updateContact');
2677
3013
  return this.request(`/api/workspaces/${this.workspaceId}/contacts/${contactId}`, {
2678
3014
  method: 'PUT',
2679
3015
  body: JSON.stringify(updates),
@@ -2683,6 +3019,7 @@
2683
3019
  * Delete a contact
2684
3020
  */
2685
3021
  async deleteContact(contactId) {
3022
+ this.validateRequired('contactId', contactId, 'deleteContact');
2686
3023
  return this.request(`/api/workspaces/${this.workspaceId}/contacts/${contactId}`, {
2687
3024
  method: 'DELETE',
2688
3025
  });
@@ -3046,6 +3383,442 @@
3046
3383
  opportunityId: data.opportunityId,
3047
3384
  });
3048
3385
  }
3386
+ // ============================================
3387
+ // EMAIL TEMPLATES API
3388
+ // ============================================
3389
+ /**
3390
+ * Get all email templates
3391
+ */
3392
+ async getEmailTemplates(params) {
3393
+ const queryParams = new URLSearchParams();
3394
+ if (params?.page)
3395
+ queryParams.set('page', params.page.toString());
3396
+ if (params?.limit)
3397
+ queryParams.set('limit', params.limit.toString());
3398
+ const query = queryParams.toString();
3399
+ const endpoint = `/api/workspaces/${this.workspaceId}/email-templates${query ? `?${query}` : ''}`;
3400
+ return this.request(endpoint);
3401
+ }
3402
+ /**
3403
+ * Get a single email template by ID
3404
+ */
3405
+ async getEmailTemplate(templateId) {
3406
+ return this.request(`/api/workspaces/${this.workspaceId}/email-templates/${templateId}`);
3407
+ }
3408
+ /**
3409
+ * Create a new email template
3410
+ */
3411
+ async createEmailTemplate(template) {
3412
+ return this.request(`/api/workspaces/${this.workspaceId}/email-templates`, {
3413
+ method: 'POST',
3414
+ body: JSON.stringify(template),
3415
+ });
3416
+ }
3417
+ /**
3418
+ * Update an email template
3419
+ */
3420
+ async updateEmailTemplate(templateId, updates) {
3421
+ return this.request(`/api/workspaces/${this.workspaceId}/email-templates/${templateId}`, {
3422
+ method: 'PUT',
3423
+ body: JSON.stringify(updates),
3424
+ });
3425
+ }
3426
+ /**
3427
+ * Delete an email template
3428
+ */
3429
+ async deleteEmailTemplate(templateId) {
3430
+ return this.request(`/api/workspaces/${this.workspaceId}/email-templates/${templateId}`, {
3431
+ method: 'DELETE',
3432
+ });
3433
+ }
3434
+ /**
3435
+ * Send an email using a template
3436
+ */
3437
+ async sendEmail(data) {
3438
+ return this.request(`/api/workspaces/${this.workspaceId}/emails/send`, {
3439
+ method: 'POST',
3440
+ body: JSON.stringify(data),
3441
+ });
3442
+ }
3443
+ // ============================================
3444
+ // EVENT TRIGGERS API (delegated to triggers manager)
3445
+ // ============================================
3446
+ /**
3447
+ * Get all event triggers
3448
+ */
3449
+ async getEventTriggers() {
3450
+ return this.triggers.getTriggers();
3451
+ }
3452
+ /**
3453
+ * Create a new event trigger
3454
+ */
3455
+ async createEventTrigger(trigger) {
3456
+ return this.triggers.createTrigger(trigger);
3457
+ }
3458
+ /**
3459
+ * Update an event trigger
3460
+ */
3461
+ async updateEventTrigger(triggerId, updates) {
3462
+ return this.triggers.updateTrigger(triggerId, updates);
3463
+ }
3464
+ /**
3465
+ * Delete an event trigger
3466
+ */
3467
+ async deleteEventTrigger(triggerId) {
3468
+ return this.triggers.deleteTrigger(triggerId);
3469
+ }
3470
+ }
3471
+
3472
+ /**
3473
+ * Clianta SDK - Main Tracker Class
3474
+ * @see SDK_VERSION in core/config.ts
3475
+ */
3476
+ /**
3477
+ * Main Clianta Tracker Class
3478
+ */
3479
+ class Tracker {
3480
+ constructor(workspaceId, userConfig = {}) {
3481
+ this.plugins = [];
3482
+ this.isInitialized = false;
3483
+ /** contactId after a successful identify() call */
3484
+ this.contactId = null;
3485
+ /** Pending identify retry on next flush */
3486
+ this.pendingIdentify = null;
3487
+ if (!workspaceId) {
3488
+ throw new Error('[Clianta] Workspace ID is required');
3489
+ }
3490
+ this.workspaceId = workspaceId;
3491
+ this.config = mergeConfig(userConfig);
3492
+ // Setup debug mode
3493
+ logger.enabled = this.config.debug;
3494
+ logger.info(`Initializing SDK v${SDK_VERSION}`, { workspaceId });
3495
+ // Initialize consent manager
3496
+ this.consentManager = new ConsentManager({
3497
+ ...this.config.consent,
3498
+ onConsentChange: (state, previous) => {
3499
+ this.onConsentChange(state, previous);
3500
+ },
3501
+ });
3502
+ // Initialize transport and queue
3503
+ this.transport = new Transport({ apiEndpoint: this.config.apiEndpoint });
3504
+ this.queue = new EventQueue(this.transport, {
3505
+ batchSize: this.config.batchSize,
3506
+ flushInterval: this.config.flushInterval,
3507
+ });
3508
+ // Get or create visitor and session IDs based on mode
3509
+ this.visitorId = this.createVisitorId();
3510
+ this.sessionId = this.createSessionId();
3511
+ logger.debug('IDs created', { visitorId: this.visitorId, sessionId: this.sessionId });
3512
+ // Initialize plugins
3513
+ this.initPlugins();
3514
+ this.isInitialized = true;
3515
+ logger.info('SDK initialized successfully');
3516
+ }
3517
+ /**
3518
+ * Create visitor ID based on storage mode
3519
+ */
3520
+ createVisitorId() {
3521
+ // Anonymous mode: use temporary ID until consent
3522
+ if (this.config.consent.anonymousMode && !this.consentManager.hasExplicit()) {
3523
+ const key = STORAGE_KEYS.VISITOR_ID + '_anon';
3524
+ let anonId = getSessionStorage(key);
3525
+ if (!anonId) {
3526
+ anonId = 'anon_' + generateUUID();
3527
+ setSessionStorage(key, anonId);
3528
+ }
3529
+ return anonId;
3530
+ }
3531
+ // Cookie-less mode: use sessionStorage only
3532
+ if (this.config.cookielessMode) {
3533
+ let visitorId = getSessionStorage(STORAGE_KEYS.VISITOR_ID);
3534
+ if (!visitorId) {
3535
+ visitorId = generateUUID();
3536
+ setSessionStorage(STORAGE_KEYS.VISITOR_ID, visitorId);
3537
+ }
3538
+ return visitorId;
3539
+ }
3540
+ // Normal mode
3541
+ return getOrCreateVisitorId(this.config.useCookies);
3542
+ }
3543
+ /**
3544
+ * Create session ID
3545
+ */
3546
+ createSessionId() {
3547
+ return getOrCreateSessionId(this.config.sessionTimeout);
3548
+ }
3549
+ /**
3550
+ * Handle consent state changes
3551
+ */
3552
+ onConsentChange(state, previous) {
3553
+ logger.debug('Consent changed:', { from: previous, to: state });
3554
+ // If analytics consent was just granted
3555
+ if (state.analytics && !previous.analytics) {
3556
+ // Upgrade from anonymous ID to persistent ID
3557
+ if (this.config.consent.anonymousMode) {
3558
+ this.visitorId = getOrCreateVisitorId(this.config.useCookies);
3559
+ logger.info('Upgraded from anonymous to persistent visitor ID');
3560
+ }
3561
+ // Flush buffered events
3562
+ const buffered = this.consentManager.flushBuffer();
3563
+ for (const event of buffered) {
3564
+ // Update event with new visitor ID
3565
+ event.visitorId = this.visitorId;
3566
+ this.queue.push(event);
3567
+ }
3568
+ }
3569
+ }
3570
+ /**
3571
+ * Initialize enabled plugins
3572
+ * Handles both sync and async plugin init methods
3573
+ */
3574
+ initPlugins() {
3575
+ const pluginsToLoad = this.config.plugins;
3576
+ // Skip pageView plugin if autoPageView is disabled
3577
+ const filteredPlugins = this.config.autoPageView
3578
+ ? pluginsToLoad
3579
+ : pluginsToLoad.filter((p) => p !== 'pageView');
3580
+ for (const pluginName of filteredPlugins) {
3581
+ try {
3582
+ const plugin = getPlugin(pluginName);
3583
+ // Handle both sync and async init (fire-and-forget for async)
3584
+ const result = plugin.init(this);
3585
+ if (result instanceof Promise) {
3586
+ result.catch((error) => {
3587
+ logger.error(`Async plugin init failed: ${pluginName}`, error);
3588
+ });
3589
+ }
3590
+ this.plugins.push(plugin);
3591
+ logger.debug(`Plugin loaded: ${pluginName}`);
3592
+ }
3593
+ catch (error) {
3594
+ logger.error(`Failed to load plugin: ${pluginName}`, error);
3595
+ }
3596
+ }
3597
+ }
3598
+ /**
3599
+ * Track a custom event
3600
+ */
3601
+ track(eventType, eventName, properties = {}) {
3602
+ if (!this.isInitialized) {
3603
+ logger.warn('SDK not initialized, event dropped');
3604
+ return;
3605
+ }
3606
+ const event = {
3607
+ workspaceId: this.workspaceId,
3608
+ visitorId: this.visitorId,
3609
+ sessionId: this.sessionId,
3610
+ eventType: eventType,
3611
+ eventName,
3612
+ url: typeof window !== 'undefined' ? window.location.href : '',
3613
+ referrer: typeof document !== 'undefined' ? document.referrer || undefined : undefined,
3614
+ properties: {
3615
+ ...properties,
3616
+ eventId: generateUUID(), // Unique ID for deduplication on retry
3617
+ },
3618
+ device: getDeviceInfo(),
3619
+ ...getUTMParams(),
3620
+ timestamp: new Date().toISOString(),
3621
+ sdkVersion: SDK_VERSION,
3622
+ };
3623
+ // Attach contactId if known (from a prior identify() call)
3624
+ if (this.contactId) {
3625
+ event.contactId = this.contactId;
3626
+ }
3627
+ // Check consent before tracking
3628
+ if (!this.consentManager.canTrack()) {
3629
+ // Buffer event for later if waitForConsent is enabled
3630
+ if (this.config.consent.waitForConsent) {
3631
+ this.consentManager.bufferEvent(event);
3632
+ return;
3633
+ }
3634
+ // Otherwise drop the event
3635
+ logger.debug('Event dropped (no consent):', eventName);
3636
+ return;
3637
+ }
3638
+ this.queue.push(event);
3639
+ logger.debug('Event tracked:', eventName, properties);
3640
+ }
3641
+ /**
3642
+ * Track a page view
3643
+ */
3644
+ page(name, properties = {}) {
3645
+ const pageName = name || (typeof document !== 'undefined' ? document.title : 'Page View');
3646
+ this.track('page_view', pageName, {
3647
+ ...properties,
3648
+ path: typeof window !== 'undefined' ? window.location.pathname : '',
3649
+ });
3650
+ }
3651
+ /**
3652
+ * Identify a visitor.
3653
+ * Links the anonymous visitorId to a CRM contact and returns the contactId.
3654
+ * All subsequent track() calls will include the contactId automatically.
3655
+ */
3656
+ async identify(email, traits = {}) {
3657
+ if (!email) {
3658
+ logger.warn('Email is required for identification');
3659
+ return null;
3660
+ }
3661
+ logger.info('Identifying visitor:', email);
3662
+ const result = await this.transport.sendIdentify({
3663
+ workspaceId: this.workspaceId,
3664
+ visitorId: this.visitorId,
3665
+ email,
3666
+ properties: traits,
3667
+ });
3668
+ if (result.success) {
3669
+ logger.info('Visitor identified successfully, contactId:', result.contactId);
3670
+ // Store contactId so all future track() calls include it
3671
+ this.contactId = result.contactId ?? null;
3672
+ this.pendingIdentify = null;
3673
+ return this.contactId;
3674
+ }
3675
+ else {
3676
+ logger.error('Failed to identify visitor:', result.error);
3677
+ // Store for retry on next flush
3678
+ this.pendingIdentify = { email, traits };
3679
+ return null;
3680
+ }
3681
+ }
3682
+ /**
3683
+ * Send a server-side inbound event via the API key endpoint.
3684
+ * Convenience proxy to CRMClient.sendEvent() — requires apiKey in config.
3685
+ */
3686
+ async sendEvent(payload) {
3687
+ const apiKey = this.config.apiKey;
3688
+ if (!apiKey) {
3689
+ logger.error('sendEvent() requires an apiKey in the SDK config');
3690
+ return { success: false, contactCreated: false, event: payload.event, error: 'No API key configured' };
3691
+ }
3692
+ const client = new CRMClient(this.config.apiEndpoint, this.workspaceId, undefined, apiKey);
3693
+ return client.sendEvent(payload);
3694
+ }
3695
+ /**
3696
+ * Retry pending identify call
3697
+ */
3698
+ async retryPendingIdentify() {
3699
+ if (!this.pendingIdentify)
3700
+ return;
3701
+ const { email, traits } = this.pendingIdentify;
3702
+ this.pendingIdentify = null;
3703
+ await this.identify(email, traits);
3704
+ }
3705
+ /**
3706
+ * Update consent state
3707
+ */
3708
+ consent(state) {
3709
+ this.consentManager.update(state);
3710
+ }
3711
+ /**
3712
+ * Get current consent state
3713
+ */
3714
+ getConsentState() {
3715
+ return this.consentManager.getState();
3716
+ }
3717
+ /**
3718
+ * Toggle debug mode
3719
+ */
3720
+ debug(enabled) {
3721
+ logger.enabled = enabled;
3722
+ logger.info(`Debug mode ${enabled ? 'enabled' : 'disabled'}`);
3723
+ }
3724
+ /**
3725
+ * Get visitor ID
3726
+ */
3727
+ getVisitorId() {
3728
+ return this.visitorId;
3729
+ }
3730
+ /**
3731
+ * Get session ID
3732
+ */
3733
+ getSessionId() {
3734
+ return this.sessionId;
3735
+ }
3736
+ /**
3737
+ * Get workspace ID
3738
+ */
3739
+ getWorkspaceId() {
3740
+ return this.workspaceId;
3741
+ }
3742
+ /**
3743
+ * Get current configuration
3744
+ */
3745
+ getConfig() {
3746
+ return { ...this.config };
3747
+ }
3748
+ /**
3749
+ * Force flush event queue
3750
+ */
3751
+ async flush() {
3752
+ await this.retryPendingIdentify();
3753
+ await this.queue.flush();
3754
+ }
3755
+ /**
3756
+ * Reset visitor and session (for logout)
3757
+ */
3758
+ reset() {
3759
+ logger.info('Resetting visitor data');
3760
+ resetIds(this.config.useCookies);
3761
+ this.visitorId = this.createVisitorId();
3762
+ this.sessionId = this.createSessionId();
3763
+ this.queue.clear();
3764
+ }
3765
+ /**
3766
+ * Delete all stored user data (GDPR right-to-erasure)
3767
+ */
3768
+ deleteData() {
3769
+ logger.info('Deleting all user data (GDPR request)');
3770
+ // Clear queue
3771
+ this.queue.clear();
3772
+ // Reset consent
3773
+ this.consentManager.reset();
3774
+ // Clear all stored IDs
3775
+ resetIds(this.config.useCookies);
3776
+ // Clear session storage items
3777
+ if (typeof sessionStorage !== 'undefined') {
3778
+ try {
3779
+ sessionStorage.removeItem(STORAGE_KEYS.VISITOR_ID);
3780
+ sessionStorage.removeItem(STORAGE_KEYS.VISITOR_ID + '_anon');
3781
+ sessionStorage.removeItem(STORAGE_KEYS.SESSION_ID);
3782
+ sessionStorage.removeItem(STORAGE_KEYS.SESSION_TIMESTAMP);
3783
+ }
3784
+ catch {
3785
+ // Ignore errors
3786
+ }
3787
+ }
3788
+ // Clear localStorage items
3789
+ if (typeof localStorage !== 'undefined') {
3790
+ try {
3791
+ localStorage.removeItem(STORAGE_KEYS.VISITOR_ID);
3792
+ localStorage.removeItem(STORAGE_KEYS.CONSENT);
3793
+ localStorage.removeItem(STORAGE_KEYS.EVENT_QUEUE);
3794
+ }
3795
+ catch {
3796
+ // Ignore errors
3797
+ }
3798
+ }
3799
+ // Generate new IDs
3800
+ this.visitorId = this.createVisitorId();
3801
+ this.sessionId = this.createSessionId();
3802
+ logger.info('All user data deleted');
3803
+ }
3804
+ /**
3805
+ * Destroy tracker and cleanup
3806
+ */
3807
+ async destroy() {
3808
+ logger.info('Destroying tracker');
3809
+ // Flush any remaining events (await to ensure completion)
3810
+ await this.queue.flush();
3811
+ // Destroy plugins
3812
+ for (const plugin of this.plugins) {
3813
+ if (plugin.destroy) {
3814
+ plugin.destroy();
3815
+ }
3816
+ }
3817
+ this.plugins = [];
3818
+ // Destroy queue
3819
+ this.queue.destroy();
3820
+ this.isInitialized = false;
3821
+ }
3049
3822
  }
3050
3823
 
3051
3824
  /**
@@ -3100,11 +3873,13 @@
3100
3873
  Tracker,
3101
3874
  CRMClient,
3102
3875
  ConsentManager,
3876
+ EventTriggersManager,
3103
3877
  };
3104
3878
  }
3105
3879
 
3106
3880
  exports.CRMClient = CRMClient;
3107
3881
  exports.ConsentManager = ConsentManager;
3882
+ exports.EventTriggersManager = EventTriggersManager;
3108
3883
  exports.SDK_VERSION = SDK_VERSION;
3109
3884
  exports.Tracker = Tracker;
3110
3885
  exports.clianta = clianta;