@clianta/sdk 1.3.0 → 1.5.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.3.0
2
+ * Clianta SDK v1.5.0
3
3
  * (c) 2026 Clianta
4
4
  * Released under the MIT License.
5
5
  */
@@ -8,7 +8,7 @@
8
8
  * @see SDK_VERSION in core/config.ts
9
9
  */
10
10
  /** SDK Version */
11
- const SDK_VERSION = '1.3.0';
11
+ const SDK_VERSION = '1.4.0';
12
12
  /** Default API endpoint based on environment */
13
13
  const getDefaultApiEndpoint = () => {
14
14
  if (typeof window === 'undefined')
@@ -34,6 +34,7 @@ const DEFAULT_CONFIG = {
34
34
  projectId: '',
35
35
  apiEndpoint: getDefaultApiEndpoint(),
36
36
  authToken: '',
37
+ apiKey: '',
37
38
  debug: false,
38
39
  autoPageView: true,
39
40
  plugins: DEFAULT_PLUGINS,
@@ -49,6 +50,7 @@ const DEFAULT_CONFIG = {
49
50
  cookieDomain: '',
50
51
  useCookies: false,
51
52
  cookielessMode: false,
53
+ persistMode: 'session',
52
54
  };
53
55
  /** Storage keys */
54
56
  const STORAGE_KEYS = {
@@ -181,12 +183,39 @@ class Transport {
181
183
  return this.send(url, payload);
182
184
  }
183
185
  /**
184
- * Send identify request
186
+ * Send identify request.
187
+ * Returns contactId from the server response so the Tracker can store it.
185
188
  */
186
189
  async sendIdentify(data) {
187
190
  const url = `${this.config.apiEndpoint}/api/public/track/identify`;
188
- const payload = JSON.stringify(data);
189
- return this.send(url, payload);
191
+ try {
192
+ const response = await this.fetchWithTimeout(url, {
193
+ method: 'POST',
194
+ headers: { 'Content-Type': 'application/json' },
195
+ body: JSON.stringify(data),
196
+ keepalive: true,
197
+ });
198
+ const body = await response.json().catch(() => ({}));
199
+ if (response.ok) {
200
+ logger.debug('Identify successful, contactId:', body.contactId);
201
+ return {
202
+ success: true,
203
+ status: response.status,
204
+ contactId: body.contactId ?? undefined,
205
+ };
206
+ }
207
+ if (response.status >= 500) {
208
+ logger.warn(`Identify server error (${response.status})`);
209
+ }
210
+ else {
211
+ logger.error(`Identify failed with status ${response.status}:`, body.message);
212
+ }
213
+ return { success: false, status: response.status };
214
+ }
215
+ catch (error) {
216
+ logger.error('Identify request failed:', error);
217
+ return { success: false, error: error };
218
+ }
190
219
  }
191
220
  /**
192
221
  * Send events synchronously (for page unload)
@@ -215,6 +244,39 @@ class Transport {
215
244
  return false;
216
245
  }
217
246
  }
247
+ /**
248
+ * Fetch data from the tracking API (GET request)
249
+ * Used for read-back APIs (visitor profile, activity, etc.)
250
+ */
251
+ async fetchData(path, params) {
252
+ const url = new URL(`${this.config.apiEndpoint}${path}`);
253
+ if (params) {
254
+ Object.entries(params).forEach(([key, value]) => {
255
+ if (value !== undefined && value !== null) {
256
+ url.searchParams.set(key, value);
257
+ }
258
+ });
259
+ }
260
+ try {
261
+ const response = await this.fetchWithTimeout(url.toString(), {
262
+ method: 'GET',
263
+ headers: {
264
+ 'Accept': 'application/json',
265
+ },
266
+ });
267
+ if (response.ok) {
268
+ const body = await response.json();
269
+ logger.debug('Fetch successful:', path);
270
+ return { success: true, data: body.data ?? body, status: response.status };
271
+ }
272
+ logger.error(`Fetch failed with status ${response.status}`);
273
+ return { success: false, status: response.status };
274
+ }
275
+ catch (error) {
276
+ logger.error('Fetch request failed:', error);
277
+ return { success: false, error: error };
278
+ }
279
+ }
218
280
  /**
219
281
  * Internal send with retry logic
220
282
  */
@@ -379,7 +441,9 @@ function cookie(name, value, days) {
379
441
  date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
380
442
  expires = '; expires=' + date.toUTCString();
381
443
  }
382
- document.cookie = name + '=' + value + expires + '; path=/; SameSite=Lax';
444
+ // Add Secure flag on HTTPS to prevent cookie leakage over plaintext
445
+ const secure = typeof location !== 'undefined' && location.protocol === 'https:' ? '; Secure' : '';
446
+ document.cookie = name + '=' + value + expires + '; path=/; SameSite=Lax' + secure;
383
447
  return value;
384
448
  }
385
449
  // ============================================
@@ -543,6 +607,17 @@ function isMobile() {
543
607
  return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
544
608
  }
545
609
  // ============================================
610
+ // VALIDATION UTILITIES
611
+ // ============================================
612
+ /**
613
+ * Validate email format
614
+ */
615
+ function isValidEmail(email) {
616
+ if (typeof email !== 'string' || !email)
617
+ return false;
618
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
619
+ }
620
+ // ============================================
546
621
  // DEVICE INFO
547
622
  // ============================================
548
623
  /**
@@ -596,6 +671,7 @@ class EventQueue {
596
671
  maxQueueSize: config.maxQueueSize ?? MAX_QUEUE_SIZE,
597
672
  storageKey: config.storageKey ?? STORAGE_KEYS.EVENT_QUEUE,
598
673
  };
674
+ this.persistMode = config.persistMode || 'session';
599
675
  // Restore persisted queue
600
676
  this.restoreQueue();
601
677
  // Start auto-flush timer
@@ -701,6 +777,13 @@ class EventQueue {
701
777
  clear() {
702
778
  this.queue = [];
703
779
  this.persistQueue([]);
780
+ // Also clear localStorage if used
781
+ if (this.persistMode === 'local' && typeof localStorage !== 'undefined') {
782
+ try {
783
+ localStorage.removeItem(this.config.storageKey);
784
+ }
785
+ catch { /* ignore */ }
786
+ }
704
787
  }
705
788
  /**
706
789
  * Stop the flush timer and cleanup handlers
@@ -755,22 +838,44 @@ class EventQueue {
755
838
  window.addEventListener('pagehide', this.boundPageHide);
756
839
  }
757
840
  /**
758
- * Persist queue to localStorage
841
+ * Persist queue to storage based on persistMode
759
842
  */
760
843
  persistQueue(events) {
844
+ if (this.persistMode === 'none')
845
+ return;
761
846
  try {
762
- setLocalStorage(this.config.storageKey, JSON.stringify(events));
847
+ const serialized = JSON.stringify(events);
848
+ if (this.persistMode === 'local' && typeof localStorage !== 'undefined') {
849
+ try {
850
+ localStorage.setItem(this.config.storageKey, serialized);
851
+ }
852
+ catch {
853
+ // localStorage quota exceeded — fallback to sessionStorage
854
+ setSessionStorage(this.config.storageKey, serialized);
855
+ }
856
+ }
857
+ else {
858
+ setSessionStorage(this.config.storageKey, serialized);
859
+ }
763
860
  }
764
861
  catch {
765
862
  // Ignore storage errors
766
863
  }
767
864
  }
768
865
  /**
769
- * Restore queue from localStorage
866
+ * Restore queue from storage
770
867
  */
771
868
  restoreQueue() {
772
869
  try {
773
- const stored = getLocalStorage(this.config.storageKey);
870
+ let stored = null;
871
+ // Check localStorage first (cross-session persistence)
872
+ if (this.persistMode === 'local' && typeof localStorage !== 'undefined') {
873
+ stored = localStorage.getItem(this.config.storageKey);
874
+ }
875
+ // Fall back to sessionStorage
876
+ if (!stored) {
877
+ stored = getSessionStorage(this.config.storageKey);
878
+ }
774
879
  if (stored) {
775
880
  const events = JSON.parse(stored);
776
881
  if (Array.isArray(events) && events.length > 0) {
@@ -838,10 +943,13 @@ class PageViewPlugin extends BasePlugin {
838
943
  history.pushState = function (...args) {
839
944
  self.originalPushState.apply(history, args);
840
945
  self.trackPageView();
946
+ // Notify other plugins (e.g. ScrollPlugin) about navigation
947
+ window.dispatchEvent(new Event('clianta:navigation'));
841
948
  };
842
949
  history.replaceState = function (...args) {
843
950
  self.originalReplaceState.apply(history, args);
844
951
  self.trackPageView();
952
+ window.dispatchEvent(new Event('clianta:navigation'));
845
953
  };
846
954
  // Handle back/forward navigation
847
955
  this.popstateHandler = () => this.trackPageView();
@@ -895,9 +1003,8 @@ class ScrollPlugin extends BasePlugin {
895
1003
  this.pageLoadTime = 0;
896
1004
  this.scrollTimeout = null;
897
1005
  this.boundHandler = null;
898
- /** SPA navigation support */
899
- this.originalPushState = null;
900
- this.originalReplaceState = null;
1006
+ /** SPA navigation listen for PageViewPlugin's custom event instead of patching history */
1007
+ this.navigationHandler = null;
901
1008
  this.popstateHandler = null;
902
1009
  }
903
1010
  init(tracker) {
@@ -906,8 +1013,13 @@ class ScrollPlugin extends BasePlugin {
906
1013
  if (typeof window !== 'undefined') {
907
1014
  this.boundHandler = this.handleScroll.bind(this);
908
1015
  window.addEventListener('scroll', this.boundHandler, { passive: true });
909
- // Setup SPA navigation reset
910
- this.setupNavigationReset();
1016
+ // Listen for navigation events dispatched by PageViewPlugin
1017
+ // instead of independently monkey-patching history.pushState
1018
+ this.navigationHandler = () => this.resetForNavigation();
1019
+ window.addEventListener('clianta:navigation', this.navigationHandler);
1020
+ // Handle back/forward navigation
1021
+ this.popstateHandler = () => this.resetForNavigation();
1022
+ window.addEventListener('popstate', this.popstateHandler);
911
1023
  }
912
1024
  }
913
1025
  destroy() {
@@ -917,16 +1029,10 @@ class ScrollPlugin extends BasePlugin {
917
1029
  if (this.scrollTimeout) {
918
1030
  clearTimeout(this.scrollTimeout);
919
1031
  }
920
- // Restore original history methods
921
- if (this.originalPushState) {
922
- history.pushState = this.originalPushState;
923
- this.originalPushState = null;
924
- }
925
- if (this.originalReplaceState) {
926
- history.replaceState = this.originalReplaceState;
927
- this.originalReplaceState = null;
1032
+ if (this.navigationHandler && typeof window !== 'undefined') {
1033
+ window.removeEventListener('clianta:navigation', this.navigationHandler);
1034
+ this.navigationHandler = null;
928
1035
  }
929
- // Remove popstate listener
930
1036
  if (this.popstateHandler && typeof window !== 'undefined') {
931
1037
  window.removeEventListener('popstate', this.popstateHandler);
932
1038
  this.popstateHandler = null;
@@ -941,29 +1047,6 @@ class ScrollPlugin extends BasePlugin {
941
1047
  this.maxScrollDepth = 0;
942
1048
  this.pageLoadTime = Date.now();
943
1049
  }
944
- /**
945
- * Setup History API interception for SPA navigation
946
- */
947
- setupNavigationReset() {
948
- if (typeof window === 'undefined')
949
- return;
950
- // Store originals for cleanup
951
- this.originalPushState = history.pushState;
952
- this.originalReplaceState = history.replaceState;
953
- // Intercept pushState and replaceState
954
- const self = this;
955
- history.pushState = function (...args) {
956
- self.originalPushState.apply(history, args);
957
- self.resetForNavigation();
958
- };
959
- history.replaceState = function (...args) {
960
- self.originalReplaceState.apply(history, args);
961
- self.resetForNavigation();
962
- };
963
- // Handle back/forward navigation
964
- this.popstateHandler = () => this.resetForNavigation();
965
- window.addEventListener('popstate', this.popstateHandler);
966
- }
967
1050
  handleScroll() {
968
1051
  // Debounce scroll tracking
969
1052
  if (this.scrollTimeout) {
@@ -1163,6 +1246,10 @@ class ClicksPlugin extends BasePlugin {
1163
1246
  elementId: elementInfo.id,
1164
1247
  elementClass: elementInfo.className,
1165
1248
  href: target.href || undefined,
1249
+ x: Math.round((e.clientX / window.innerWidth) * 100),
1250
+ y: Math.round((e.clientY / window.innerHeight) * 100),
1251
+ viewportWidth: window.innerWidth,
1252
+ viewportHeight: window.innerHeight,
1166
1253
  });
1167
1254
  }
1168
1255
  }
@@ -1185,6 +1272,9 @@ class EngagementPlugin extends BasePlugin {
1185
1272
  this.boundMarkEngaged = null;
1186
1273
  this.boundTrackTimeOnPage = null;
1187
1274
  this.boundVisibilityHandler = null;
1275
+ /** SPA navigation — listen for PageViewPlugin's custom event instead of patching history */
1276
+ this.navigationHandler = null;
1277
+ this.popstateHandler = null;
1188
1278
  }
1189
1279
  init(tracker) {
1190
1280
  super.init(tracker);
@@ -1210,6 +1300,13 @@ class EngagementPlugin extends BasePlugin {
1210
1300
  // Track time on page before unload
1211
1301
  window.addEventListener('beforeunload', this.boundTrackTimeOnPage);
1212
1302
  document.addEventListener('visibilitychange', this.boundVisibilityHandler);
1303
+ // Listen for navigation events dispatched by PageViewPlugin
1304
+ // instead of independently monkey-patching history.pushState
1305
+ this.navigationHandler = () => this.resetForNavigation();
1306
+ window.addEventListener('clianta:navigation', this.navigationHandler);
1307
+ // Handle back/forward navigation
1308
+ this.popstateHandler = () => this.resetForNavigation();
1309
+ window.addEventListener('popstate', this.popstateHandler);
1213
1310
  }
1214
1311
  destroy() {
1215
1312
  if (this.boundMarkEngaged && typeof document !== 'undefined') {
@@ -1223,11 +1320,28 @@ class EngagementPlugin extends BasePlugin {
1223
1320
  if (this.boundVisibilityHandler && typeof document !== 'undefined') {
1224
1321
  document.removeEventListener('visibilitychange', this.boundVisibilityHandler);
1225
1322
  }
1323
+ if (this.navigationHandler && typeof window !== 'undefined') {
1324
+ window.removeEventListener('clianta:navigation', this.navigationHandler);
1325
+ this.navigationHandler = null;
1326
+ }
1327
+ if (this.popstateHandler && typeof window !== 'undefined') {
1328
+ window.removeEventListener('popstate', this.popstateHandler);
1329
+ this.popstateHandler = null;
1330
+ }
1226
1331
  if (this.engagementTimeout) {
1227
1332
  clearTimeout(this.engagementTimeout);
1228
1333
  }
1229
1334
  super.destroy();
1230
1335
  }
1336
+ resetForNavigation() {
1337
+ this.pageLoadTime = Date.now();
1338
+ this.engagementStartTime = Date.now();
1339
+ this.isEngaged = false;
1340
+ if (this.engagementTimeout) {
1341
+ clearTimeout(this.engagementTimeout);
1342
+ this.engagementTimeout = null;
1343
+ }
1344
+ }
1231
1345
  markEngaged() {
1232
1346
  if (!this.isEngaged) {
1233
1347
  this.isEngaged = true;
@@ -1267,9 +1381,8 @@ class DownloadsPlugin extends BasePlugin {
1267
1381
  this.name = 'downloads';
1268
1382
  this.trackedDownloads = new Set();
1269
1383
  this.boundHandler = null;
1270
- /** SPA navigation support */
1271
- this.originalPushState = null;
1272
- this.originalReplaceState = null;
1384
+ /** SPA navigation listen for PageViewPlugin's custom event instead of patching history */
1385
+ this.navigationHandler = null;
1273
1386
  this.popstateHandler = null;
1274
1387
  }
1275
1388
  init(tracker) {
@@ -1277,24 +1390,25 @@ class DownloadsPlugin extends BasePlugin {
1277
1390
  if (typeof document !== 'undefined') {
1278
1391
  this.boundHandler = this.handleClick.bind(this);
1279
1392
  document.addEventListener('click', this.boundHandler, true);
1280
- // Setup SPA navigation reset
1281
- this.setupNavigationReset();
1393
+ }
1394
+ if (typeof window !== 'undefined') {
1395
+ // Listen for navigation events dispatched by PageViewPlugin
1396
+ // instead of independently monkey-patching history.pushState
1397
+ this.navigationHandler = () => this.resetForNavigation();
1398
+ window.addEventListener('clianta:navigation', this.navigationHandler);
1399
+ // Handle back/forward navigation
1400
+ this.popstateHandler = () => this.resetForNavigation();
1401
+ window.addEventListener('popstate', this.popstateHandler);
1282
1402
  }
1283
1403
  }
1284
1404
  destroy() {
1285
1405
  if (this.boundHandler && typeof document !== 'undefined') {
1286
1406
  document.removeEventListener('click', this.boundHandler, true);
1287
1407
  }
1288
- // Restore original history methods
1289
- if (this.originalPushState) {
1290
- history.pushState = this.originalPushState;
1291
- this.originalPushState = null;
1292
- }
1293
- if (this.originalReplaceState) {
1294
- history.replaceState = this.originalReplaceState;
1295
- this.originalReplaceState = null;
1408
+ if (this.navigationHandler && typeof window !== 'undefined') {
1409
+ window.removeEventListener('clianta:navigation', this.navigationHandler);
1410
+ this.navigationHandler = null;
1296
1411
  }
1297
- // Remove popstate listener
1298
1412
  if (this.popstateHandler && typeof window !== 'undefined') {
1299
1413
  window.removeEventListener('popstate', this.popstateHandler);
1300
1414
  this.popstateHandler = null;
@@ -1307,29 +1421,6 @@ class DownloadsPlugin extends BasePlugin {
1307
1421
  resetForNavigation() {
1308
1422
  this.trackedDownloads.clear();
1309
1423
  }
1310
- /**
1311
- * Setup History API interception for SPA navigation
1312
- */
1313
- setupNavigationReset() {
1314
- if (typeof window === 'undefined')
1315
- return;
1316
- // Store originals for cleanup
1317
- this.originalPushState = history.pushState;
1318
- this.originalReplaceState = history.replaceState;
1319
- // Intercept pushState and replaceState
1320
- const self = this;
1321
- history.pushState = function (...args) {
1322
- self.originalPushState.apply(history, args);
1323
- self.resetForNavigation();
1324
- };
1325
- history.replaceState = function (...args) {
1326
- self.originalReplaceState.apply(history, args);
1327
- self.resetForNavigation();
1328
- };
1329
- // Handle back/forward navigation
1330
- this.popstateHandler = () => this.resetForNavigation();
1331
- window.addEventListener('popstate', this.popstateHandler);
1332
- }
1333
1424
  handleClick(e) {
1334
1425
  const link = e.target.closest('a');
1335
1426
  if (!link || !link.href)
@@ -1365,6 +1456,9 @@ class ExitIntentPlugin extends BasePlugin {
1365
1456
  this.exitIntentShown = false;
1366
1457
  this.pageLoadTime = 0;
1367
1458
  this.boundHandler = null;
1459
+ /** SPA navigation — listen for PageViewPlugin's custom event instead of patching history */
1460
+ this.navigationHandler = null;
1461
+ this.popstateHandler = null;
1368
1462
  }
1369
1463
  init(tracker) {
1370
1464
  super.init(tracker);
@@ -1376,13 +1470,34 @@ class ExitIntentPlugin extends BasePlugin {
1376
1470
  this.boundHandler = this.handleMouseLeave.bind(this);
1377
1471
  document.addEventListener('mouseleave', this.boundHandler);
1378
1472
  }
1473
+ if (typeof window !== 'undefined') {
1474
+ // Listen for navigation events dispatched by PageViewPlugin
1475
+ // instead of independently monkey-patching history.pushState
1476
+ this.navigationHandler = () => this.resetForNavigation();
1477
+ window.addEventListener('clianta:navigation', this.navigationHandler);
1478
+ // Handle back/forward navigation
1479
+ this.popstateHandler = () => this.resetForNavigation();
1480
+ window.addEventListener('popstate', this.popstateHandler);
1481
+ }
1379
1482
  }
1380
1483
  destroy() {
1381
1484
  if (this.boundHandler && typeof document !== 'undefined') {
1382
1485
  document.removeEventListener('mouseleave', this.boundHandler);
1383
1486
  }
1487
+ if (this.navigationHandler && typeof window !== 'undefined') {
1488
+ window.removeEventListener('clianta:navigation', this.navigationHandler);
1489
+ this.navigationHandler = null;
1490
+ }
1491
+ if (this.popstateHandler && typeof window !== 'undefined') {
1492
+ window.removeEventListener('popstate', this.popstateHandler);
1493
+ this.popstateHandler = null;
1494
+ }
1384
1495
  super.destroy();
1385
1496
  }
1497
+ resetForNavigation() {
1498
+ this.exitIntentShown = false;
1499
+ this.pageLoadTime = Date.now();
1500
+ }
1386
1501
  handleMouseLeave(e) {
1387
1502
  // Only trigger when mouse leaves from the top of the page
1388
1503
  if (e.clientY > 0 || this.exitIntentShown)
@@ -1610,6 +1725,8 @@ class PopupFormsPlugin extends BasePlugin {
1610
1725
  this.shownForms = new Set();
1611
1726
  this.scrollHandler = null;
1612
1727
  this.exitHandler = null;
1728
+ this.delayTimers = [];
1729
+ this.clickTriggerListeners = [];
1613
1730
  }
1614
1731
  async init(tracker) {
1615
1732
  super.init(tracker);
@@ -1624,6 +1741,14 @@ class PopupFormsPlugin extends BasePlugin {
1624
1741
  }
1625
1742
  destroy() {
1626
1743
  this.removeTriggers();
1744
+ for (const timer of this.delayTimers) {
1745
+ clearTimeout(timer);
1746
+ }
1747
+ this.delayTimers = [];
1748
+ for (const { element, handler } of this.clickTriggerListeners) {
1749
+ element.removeEventListener('click', handler);
1750
+ }
1751
+ this.clickTriggerListeners = [];
1627
1752
  super.destroy();
1628
1753
  }
1629
1754
  loadShownForms() {
@@ -1686,7 +1811,7 @@ class PopupFormsPlugin extends BasePlugin {
1686
1811
  this.forms.forEach(form => {
1687
1812
  switch (form.trigger.type) {
1688
1813
  case 'delay':
1689
- setTimeout(() => this.showForm(form), (form.trigger.value || 5) * 1000);
1814
+ this.delayTimers.push(setTimeout(() => this.showForm(form), (form.trigger.value || 5) * 1000));
1690
1815
  break;
1691
1816
  case 'scroll':
1692
1817
  this.setupScrollTrigger(form);
@@ -1729,7 +1854,9 @@ class PopupFormsPlugin extends BasePlugin {
1729
1854
  return;
1730
1855
  const elements = document.querySelectorAll(form.trigger.selector);
1731
1856
  elements.forEach(el => {
1732
- el.addEventListener('click', () => this.showForm(form));
1857
+ const handler = () => this.showForm(form);
1858
+ el.addEventListener('click', handler);
1859
+ this.clickTriggerListeners.push({ element: el, handler });
1733
1860
  });
1734
1861
  }
1735
1862
  removeTriggers() {
@@ -2016,7 +2143,7 @@ class PopupFormsPlugin extends BasePlugin {
2016
2143
  const submitBtn = formElement.querySelector('button[type="submit"]');
2017
2144
  if (submitBtn) {
2018
2145
  submitBtn.disabled = true;
2019
- submitBtn.innerHTML = 'Submitting...';
2146
+ submitBtn.textContent = 'Submitting...';
2020
2147
  }
2021
2148
  try {
2022
2149
  const response = await fetch(`${apiEndpoint}/api/public/lead-forms/${form._id}/submit`, {
@@ -2057,11 +2184,24 @@ class PopupFormsPlugin extends BasePlugin {
2057
2184
  if (data.email) {
2058
2185
  this.tracker?.identify(data.email, data);
2059
2186
  }
2060
- // Redirect if configured
2187
+ // Redirect if configured (validate URL to prevent open redirect)
2061
2188
  if (form.redirectUrl) {
2062
- setTimeout(() => {
2063
- window.location.href = form.redirectUrl;
2064
- }, 1500);
2189
+ try {
2190
+ const redirect = new URL(form.redirectUrl, window.location.origin);
2191
+ const isSameOrigin = redirect.origin === window.location.origin;
2192
+ const isSafeProtocol = redirect.protocol === 'https:' || redirect.protocol === 'http:';
2193
+ if (isSameOrigin || isSafeProtocol) {
2194
+ setTimeout(() => {
2195
+ window.location.href = redirect.href;
2196
+ }, 1500);
2197
+ }
2198
+ else {
2199
+ console.warn('[Clianta] Blocked unsafe redirect URL:', form.redirectUrl);
2200
+ }
2201
+ }
2202
+ catch {
2203
+ console.warn('[Clianta] Invalid redirect URL:', form.redirectUrl);
2204
+ }
2065
2205
  }
2066
2206
  // Close after delay
2067
2207
  setTimeout(() => {
@@ -2076,7 +2216,7 @@ class PopupFormsPlugin extends BasePlugin {
2076
2216
  console.error('[Clianta] Form submit error:', error);
2077
2217
  if (submitBtn) {
2078
2218
  submitBtn.disabled = false;
2079
- submitBtn.innerHTML = form.submitButtonText || 'Subscribe';
2219
+ submitBtn.textContent = form.submitButtonText || 'Subscribe';
2080
2220
  }
2081
2221
  }
2082
2222
  }
@@ -2361,355 +2501,478 @@ class ConsentManager {
2361
2501
  }
2362
2502
 
2363
2503
  /**
2364
- * Clianta SDK - Main Tracker Class
2365
- * @see SDK_VERSION in core/config.ts
2504
+ * Clianta SDK - Event Triggers Manager
2505
+ * Manages event-driven automation and email notifications
2366
2506
  */
2367
2507
  /**
2368
- * Main Clianta Tracker Class
2508
+ * Event Triggers Manager
2509
+ * Handles event-driven automation based on CRM actions
2510
+ *
2511
+ * Similar to:
2512
+ * - Salesforce: Process Builder, Flow Automation
2513
+ * - HubSpot: Workflows, Email Sequences
2514
+ * - Pipedrive: Workflow Automation
2369
2515
  */
2370
- class Tracker {
2371
- constructor(workspaceId, userConfig = {}) {
2372
- this.plugins = [];
2373
- this.isInitialized = false;
2374
- /** Pending identify retry on next flush */
2375
- this.pendingIdentify = null;
2376
- if (!workspaceId) {
2377
- throw new Error('[Clianta] Workspace ID is required');
2378
- }
2516
+ class EventTriggersManager {
2517
+ constructor(apiEndpoint, workspaceId, authToken) {
2518
+ this.triggers = new Map();
2519
+ this.listeners = new Map();
2520
+ this.apiEndpoint = apiEndpoint;
2379
2521
  this.workspaceId = workspaceId;
2380
- this.config = mergeConfig(userConfig);
2381
- // Setup debug mode
2382
- logger.enabled = this.config.debug;
2383
- logger.info(`Initializing SDK v${SDK_VERSION}`, { workspaceId });
2384
- // Initialize consent manager
2385
- this.consentManager = new ConsentManager({
2386
- ...this.config.consent,
2387
- onConsentChange: (state, previous) => {
2388
- this.onConsentChange(state, previous);
2389
- },
2390
- });
2391
- // Initialize transport and queue
2392
- this.transport = new Transport({ apiEndpoint: this.config.apiEndpoint });
2393
- this.queue = new EventQueue(this.transport, {
2394
- batchSize: this.config.batchSize,
2395
- flushInterval: this.config.flushInterval,
2396
- });
2397
- // Get or create visitor and session IDs based on mode
2398
- this.visitorId = this.createVisitorId();
2399
- this.sessionId = this.createSessionId();
2400
- logger.debug('IDs created', { visitorId: this.visitorId, sessionId: this.sessionId });
2401
- // Initialize plugins
2402
- this.initPlugins();
2403
- this.isInitialized = true;
2404
- logger.info('SDK initialized successfully');
2522
+ this.authToken = authToken;
2405
2523
  }
2406
2524
  /**
2407
- * Create visitor ID based on storage mode
2525
+ * Set authentication token
2408
2526
  */
2409
- createVisitorId() {
2410
- // Anonymous mode: use temporary ID until consent
2411
- if (this.config.consent.anonymousMode && !this.consentManager.hasExplicit()) {
2412
- const key = STORAGE_KEYS.VISITOR_ID + '_anon';
2413
- let anonId = getSessionStorage(key);
2414
- if (!anonId) {
2415
- anonId = 'anon_' + generateUUID();
2416
- setSessionStorage(key, anonId);
2417
- }
2418
- return anonId;
2527
+ setAuthToken(token) {
2528
+ this.authToken = token;
2529
+ }
2530
+ /**
2531
+ * Make authenticated API request
2532
+ */
2533
+ async request(endpoint, options = {}) {
2534
+ const url = `${this.apiEndpoint}${endpoint}`;
2535
+ const headers = {
2536
+ 'Content-Type': 'application/json',
2537
+ ...(options.headers || {}),
2538
+ };
2539
+ if (this.authToken) {
2540
+ headers['Authorization'] = `Bearer ${this.authToken}`;
2419
2541
  }
2420
- // Cookie-less mode: use sessionStorage only
2421
- if (this.config.cookielessMode) {
2422
- let visitorId = getSessionStorage(STORAGE_KEYS.VISITOR_ID);
2423
- if (!visitorId) {
2424
- visitorId = generateUUID();
2425
- setSessionStorage(STORAGE_KEYS.VISITOR_ID, visitorId);
2542
+ try {
2543
+ const response = await fetch(url, {
2544
+ ...options,
2545
+ headers,
2546
+ });
2547
+ const data = await response.json();
2548
+ if (!response.ok) {
2549
+ return {
2550
+ success: false,
2551
+ error: data.message || 'Request failed',
2552
+ status: response.status,
2553
+ };
2426
2554
  }
2427
- return visitorId;
2555
+ return {
2556
+ success: true,
2557
+ data: data.data || data,
2558
+ status: response.status,
2559
+ };
2560
+ }
2561
+ catch (error) {
2562
+ return {
2563
+ success: false,
2564
+ error: error instanceof Error ? error.message : 'Network error',
2565
+ status: 0,
2566
+ };
2428
2567
  }
2429
- // Normal mode
2430
- return getOrCreateVisitorId(this.config.useCookies);
2431
2568
  }
2569
+ // ============================================
2570
+ // TRIGGER MANAGEMENT
2571
+ // ============================================
2432
2572
  /**
2433
- * Create session ID
2573
+ * Get all event triggers
2434
2574
  */
2435
- createSessionId() {
2436
- return getOrCreateSessionId(this.config.sessionTimeout);
2437
- }
2438
- /**
2439
- * Handle consent state changes
2440
- */
2441
- onConsentChange(state, previous) {
2442
- logger.debug('Consent changed:', { from: previous, to: state });
2443
- // If analytics consent was just granted
2444
- if (state.analytics && !previous.analytics) {
2445
- // Upgrade from anonymous ID to persistent ID
2446
- if (this.config.consent.anonymousMode) {
2447
- this.visitorId = getOrCreateVisitorId(this.config.useCookies);
2448
- logger.info('Upgraded from anonymous to persistent visitor ID');
2449
- }
2450
- // Flush buffered events
2451
- const buffered = this.consentManager.flushBuffer();
2452
- for (const event of buffered) {
2453
- // Update event with new visitor ID
2454
- event.visitorId = this.visitorId;
2455
- this.queue.push(event);
2456
- }
2457
- }
2575
+ async getTriggers() {
2576
+ return this.request(`/api/workspaces/${this.workspaceId}/triggers`);
2458
2577
  }
2459
2578
  /**
2460
- * Initialize enabled plugins
2461
- * Handles both sync and async plugin init methods
2579
+ * Get a single trigger by ID
2462
2580
  */
2463
- initPlugins() {
2464
- const pluginsToLoad = this.config.plugins;
2465
- // Skip pageView plugin if autoPageView is disabled
2466
- const filteredPlugins = this.config.autoPageView
2467
- ? pluginsToLoad
2468
- : pluginsToLoad.filter((p) => p !== 'pageView');
2469
- for (const pluginName of filteredPlugins) {
2470
- try {
2471
- const plugin = getPlugin(pluginName);
2472
- // Handle both sync and async init (fire-and-forget for async)
2473
- const result = plugin.init(this);
2474
- if (result instanceof Promise) {
2475
- result.catch((error) => {
2476
- logger.error(`Async plugin init failed: ${pluginName}`, error);
2477
- });
2478
- }
2479
- this.plugins.push(plugin);
2480
- logger.debug(`Plugin loaded: ${pluginName}`);
2481
- }
2482
- catch (error) {
2483
- logger.error(`Failed to load plugin: ${pluginName}`, error);
2484
- }
2485
- }
2581
+ async getTrigger(triggerId) {
2582
+ return this.request(`/api/workspaces/${this.workspaceId}/triggers/${triggerId}`);
2486
2583
  }
2487
2584
  /**
2488
- * Track a custom event
2585
+ * Create a new event trigger
2489
2586
  */
2490
- track(eventType, eventName, properties = {}) {
2491
- if (!this.isInitialized) {
2492
- logger.warn('SDK not initialized, event dropped');
2493
- return;
2494
- }
2495
- const event = {
2496
- workspaceId: this.workspaceId,
2497
- visitorId: this.visitorId,
2498
- sessionId: this.sessionId,
2499
- eventType: eventType,
2500
- eventName,
2501
- url: typeof window !== 'undefined' ? window.location.href : '',
2502
- referrer: typeof document !== 'undefined' ? document.referrer || undefined : undefined,
2503
- properties,
2504
- device: getDeviceInfo(),
2505
- ...getUTMParams(),
2506
- timestamp: new Date().toISOString(),
2507
- sdkVersion: SDK_VERSION,
2508
- };
2509
- // Check consent before tracking
2510
- if (!this.consentManager.canTrack()) {
2511
- // Buffer event for later if waitForConsent is enabled
2512
- if (this.config.consent.waitForConsent) {
2513
- this.consentManager.bufferEvent(event);
2514
- return;
2515
- }
2516
- // Otherwise drop the event
2517
- logger.debug('Event dropped (no consent):', eventName);
2518
- return;
2587
+ async createTrigger(trigger) {
2588
+ const result = await this.request(`/api/workspaces/${this.workspaceId}/triggers`, {
2589
+ method: 'POST',
2590
+ body: JSON.stringify(trigger),
2591
+ });
2592
+ // Cache the trigger locally if successful
2593
+ if (result.success && result.data?._id) {
2594
+ this.triggers.set(result.data._id, result.data);
2519
2595
  }
2520
- this.queue.push(event);
2521
- logger.debug('Event tracked:', eventName, properties);
2596
+ return result;
2522
2597
  }
2523
2598
  /**
2524
- * Track a page view
2599
+ * Update an existing trigger
2525
2600
  */
2526
- page(name, properties = {}) {
2527
- const pageName = name || (typeof document !== 'undefined' ? document.title : 'Page View');
2528
- this.track('page_view', pageName, {
2529
- ...properties,
2530
- path: typeof window !== 'undefined' ? window.location.pathname : '',
2601
+ async updateTrigger(triggerId, updates) {
2602
+ const result = await this.request(`/api/workspaces/${this.workspaceId}/triggers/${triggerId}`, {
2603
+ method: 'PUT',
2604
+ body: JSON.stringify(updates),
2531
2605
  });
2606
+ // Update cache if successful
2607
+ if (result.success && result.data?._id) {
2608
+ this.triggers.set(result.data._id, result.data);
2609
+ }
2610
+ return result;
2532
2611
  }
2533
2612
  /**
2534
- * Identify a visitor
2613
+ * Delete a trigger
2535
2614
  */
2536
- async identify(email, traits = {}) {
2537
- if (!email) {
2538
- logger.warn('Email is required for identification');
2539
- return;
2540
- }
2541
- logger.info('Identifying visitor:', email);
2542
- const result = await this.transport.sendIdentify({
2543
- workspaceId: this.workspaceId,
2544
- visitorId: this.visitorId,
2545
- email,
2546
- properties: traits,
2615
+ async deleteTrigger(triggerId) {
2616
+ const result = await this.request(`/api/workspaces/${this.workspaceId}/triggers/${triggerId}`, {
2617
+ method: 'DELETE',
2547
2618
  });
2619
+ // Remove from cache if successful
2548
2620
  if (result.success) {
2549
- logger.info('Visitor identified successfully');
2550
- this.pendingIdentify = null;
2551
- }
2552
- else {
2553
- logger.error('Failed to identify visitor:', result.error);
2554
- // Store for retry on next flush
2555
- this.pendingIdentify = { email, traits };
2621
+ this.triggers.delete(triggerId);
2556
2622
  }
2623
+ return result;
2557
2624
  }
2558
2625
  /**
2559
- * Retry pending identify call
2560
- */
2561
- async retryPendingIdentify() {
2562
- if (!this.pendingIdentify)
2563
- return;
2564
- const { email, traits } = this.pendingIdentify;
2565
- this.pendingIdentify = null;
2566
- await this.identify(email, traits);
2567
- }
2568
- /**
2569
- * Update consent state
2570
- */
2571
- consent(state) {
2572
- this.consentManager.update(state);
2573
- }
2574
- /**
2575
- * Get current consent state
2626
+ * Activate a trigger
2576
2627
  */
2577
- getConsentState() {
2578
- return this.consentManager.getState();
2628
+ async activateTrigger(triggerId) {
2629
+ return this.updateTrigger(triggerId, { isActive: true });
2579
2630
  }
2580
2631
  /**
2581
- * Toggle debug mode
2632
+ * Deactivate a trigger
2582
2633
  */
2583
- debug(enabled) {
2584
- logger.enabled = enabled;
2585
- logger.info(`Debug mode ${enabled ? 'enabled' : 'disabled'}`);
2634
+ async deactivateTrigger(triggerId) {
2635
+ return this.updateTrigger(triggerId, { isActive: false });
2586
2636
  }
2637
+ // ============================================
2638
+ // EVENT HANDLING (CLIENT-SIDE)
2639
+ // ============================================
2587
2640
  /**
2588
- * Get visitor ID
2641
+ * Register a local event listener for client-side triggers
2642
+ * This allows immediate client-side reactions to events
2589
2643
  */
2590
- getVisitorId() {
2591
- return this.visitorId;
2644
+ on(eventType, callback) {
2645
+ if (!this.listeners.has(eventType)) {
2646
+ this.listeners.set(eventType, new Set());
2647
+ }
2648
+ this.listeners.get(eventType).add(callback);
2649
+ logger.debug(`Event listener registered: ${eventType}`);
2592
2650
  }
2593
2651
  /**
2594
- * Get session ID
2652
+ * Remove an event listener
2595
2653
  */
2596
- getSessionId() {
2597
- return this.sessionId;
2654
+ off(eventType, callback) {
2655
+ const listeners = this.listeners.get(eventType);
2656
+ if (listeners) {
2657
+ listeners.delete(callback);
2658
+ }
2598
2659
  }
2599
2660
  /**
2600
- * Get workspace ID
2661
+ * Emit an event (client-side only)
2662
+ * This will trigger any registered local listeners
2601
2663
  */
2602
- getWorkspaceId() {
2603
- return this.workspaceId;
2664
+ emit(eventType, data) {
2665
+ logger.debug(`Event emitted: ${eventType}`, data);
2666
+ const listeners = this.listeners.get(eventType);
2667
+ if (listeners) {
2668
+ listeners.forEach(callback => {
2669
+ try {
2670
+ callback(data);
2671
+ }
2672
+ catch (error) {
2673
+ logger.error(`Error in event listener for ${eventType}:`, error);
2674
+ }
2675
+ });
2676
+ }
2604
2677
  }
2605
2678
  /**
2606
- * Get current configuration
2679
+ * Check if conditions are met for a trigger
2680
+ * Supports dynamic field evaluation including custom fields and nested paths
2607
2681
  */
2608
- getConfig() {
2609
- return { ...this.config };
2682
+ evaluateConditions(conditions, data) {
2683
+ if (!conditions || conditions.length === 0) {
2684
+ return true; // No conditions means always fire
2685
+ }
2686
+ return conditions.every(condition => {
2687
+ // Support dot notation for nested fields (e.g., 'customFields.industry')
2688
+ const fieldValue = condition.field.includes('.')
2689
+ ? this.getNestedValue(data, condition.field)
2690
+ : data[condition.field];
2691
+ const targetValue = condition.value;
2692
+ switch (condition.operator) {
2693
+ case 'equals':
2694
+ return fieldValue === targetValue;
2695
+ case 'not_equals':
2696
+ return fieldValue !== targetValue;
2697
+ case 'contains':
2698
+ return String(fieldValue).includes(String(targetValue));
2699
+ case 'greater_than':
2700
+ return Number(fieldValue) > Number(targetValue);
2701
+ case 'less_than':
2702
+ return Number(fieldValue) < Number(targetValue);
2703
+ case 'in':
2704
+ return Array.isArray(targetValue) && targetValue.includes(fieldValue);
2705
+ case 'not_in':
2706
+ return Array.isArray(targetValue) && !targetValue.includes(fieldValue);
2707
+ default:
2708
+ return false;
2709
+ }
2710
+ });
2610
2711
  }
2611
2712
  /**
2612
- * Force flush event queue
2713
+ * Execute actions for a triggered event (client-side preview)
2714
+ * Note: Actual execution happens on the backend
2613
2715
  */
2614
- async flush() {
2615
- await this.retryPendingIdentify();
2616
- await this.queue.flush();
2716
+ async executeActions(trigger, data) {
2717
+ logger.info(`Executing actions for trigger: ${trigger.name}`);
2718
+ for (const action of trigger.actions) {
2719
+ try {
2720
+ await this.executeAction(action, data);
2721
+ }
2722
+ catch (error) {
2723
+ logger.error(`Failed to execute action:`, error);
2724
+ }
2725
+ }
2617
2726
  }
2618
2727
  /**
2619
- * Reset visitor and session (for logout)
2728
+ * Execute a single action
2620
2729
  */
2621
- reset() {
2622
- logger.info('Resetting visitor data');
2623
- resetIds(this.config.useCookies);
2624
- this.visitorId = this.createVisitorId();
2625
- this.sessionId = this.createSessionId();
2626
- this.queue.clear();
2730
+ async executeAction(action, data) {
2731
+ switch (action.type) {
2732
+ case 'send_email':
2733
+ await this.executeSendEmail(action, data);
2734
+ break;
2735
+ case 'webhook':
2736
+ await this.executeWebhook(action, data);
2737
+ break;
2738
+ case 'create_task':
2739
+ await this.executeCreateTask(action, data);
2740
+ break;
2741
+ case 'update_contact':
2742
+ await this.executeUpdateContact(action, data);
2743
+ break;
2744
+ default:
2745
+ logger.warn(`Unknown action type:`, action);
2746
+ }
2627
2747
  }
2628
2748
  /**
2629
- * Delete all stored user data (GDPR right-to-erasure)
2749
+ * Execute send email action (via backend API)
2630
2750
  */
2631
- deleteData() {
2632
- logger.info('Deleting all user data (GDPR request)');
2633
- // Clear queue
2634
- this.queue.clear();
2635
- // Reset consent
2636
- this.consentManager.reset();
2637
- // Clear all stored IDs
2638
- resetIds(this.config.useCookies);
2639
- // Clear session storage items
2640
- if (typeof sessionStorage !== 'undefined') {
2641
- try {
2642
- sessionStorage.removeItem(STORAGE_KEYS.VISITOR_ID);
2643
- sessionStorage.removeItem(STORAGE_KEYS.VISITOR_ID + '_anon');
2644
- sessionStorage.removeItem(STORAGE_KEYS.SESSION_ID);
2645
- sessionStorage.removeItem(STORAGE_KEYS.SESSION_TIMESTAMP);
2646
- }
2647
- catch {
2648
- // Ignore errors
2649
- }
2650
- }
2651
- // Clear localStorage items
2652
- if (typeof localStorage !== 'undefined') {
2653
- try {
2654
- localStorage.removeItem(STORAGE_KEYS.VISITOR_ID);
2655
- localStorage.removeItem(STORAGE_KEYS.CONSENT);
2656
- localStorage.removeItem(STORAGE_KEYS.EVENT_QUEUE);
2657
- }
2658
- catch {
2659
- // Ignore errors
2660
- }
2751
+ async executeSendEmail(action, data) {
2752
+ logger.debug('Sending email:', action);
2753
+ const payload = {
2754
+ to: this.replaceVariables(action.to, data),
2755
+ subject: action.subject ? this.replaceVariables(action.subject, data) : undefined,
2756
+ body: action.body ? this.replaceVariables(action.body, data) : undefined,
2757
+ templateId: action.templateId,
2758
+ cc: action.cc,
2759
+ bcc: action.bcc,
2760
+ from: action.from,
2761
+ delayMinutes: action.delayMinutes,
2762
+ };
2763
+ await this.request(`/api/workspaces/${this.workspaceId}/emails/send`, {
2764
+ method: 'POST',
2765
+ body: JSON.stringify(payload),
2766
+ });
2767
+ }
2768
+ /**
2769
+ * Execute webhook action
2770
+ */
2771
+ async executeWebhook(action, data) {
2772
+ logger.debug('Calling webhook:', action.url);
2773
+ const body = action.body ? this.replaceVariables(action.body, data) : JSON.stringify(data);
2774
+ await fetch(action.url, {
2775
+ method: action.method,
2776
+ headers: {
2777
+ 'Content-Type': 'application/json',
2778
+ ...action.headers,
2779
+ },
2780
+ body,
2781
+ });
2782
+ }
2783
+ /**
2784
+ * Execute create task action
2785
+ */
2786
+ async executeCreateTask(action, data) {
2787
+ logger.debug('Creating task:', action.title);
2788
+ const dueDate = action.dueDays
2789
+ ? new Date(Date.now() + action.dueDays * 24 * 60 * 60 * 1000).toISOString()
2790
+ : undefined;
2791
+ await this.request(`/api/workspaces/${this.workspaceId}/tasks`, {
2792
+ method: 'POST',
2793
+ body: JSON.stringify({
2794
+ title: this.replaceVariables(action.title, data),
2795
+ description: action.description ? this.replaceVariables(action.description, data) : undefined,
2796
+ priority: action.priority,
2797
+ dueDate,
2798
+ assignedTo: action.assignedTo,
2799
+ relatedContactId: typeof data.contactId === 'string' ? data.contactId : undefined,
2800
+ }),
2801
+ });
2802
+ }
2803
+ /**
2804
+ * Execute update contact action
2805
+ */
2806
+ async executeUpdateContact(action, data) {
2807
+ const contactId = data.contactId || data._id;
2808
+ if (!contactId) {
2809
+ logger.warn('Cannot update contact: no contactId in data');
2810
+ return;
2661
2811
  }
2662
- // Generate new IDs
2663
- this.visitorId = this.createVisitorId();
2664
- this.sessionId = this.createSessionId();
2665
- logger.info('All user data deleted');
2812
+ logger.debug('Updating contact:', contactId);
2813
+ await this.request(`/api/workspaces/${this.workspaceId}/contacts/${contactId}`, {
2814
+ method: 'PUT',
2815
+ body: JSON.stringify(action.updates),
2816
+ });
2666
2817
  }
2667
2818
  /**
2668
- * Destroy tracker and cleanup
2819
+ * Replace variables in a string template
2820
+ * Supports syntax like {{contact.email}}, {{opportunity.value}}
2669
2821
  */
2670
- async destroy() {
2671
- logger.info('Destroying tracker');
2672
- // Flush any remaining events (await to ensure completion)
2673
- await this.queue.flush();
2674
- // Destroy plugins
2675
- for (const plugin of this.plugins) {
2676
- if (plugin.destroy) {
2677
- plugin.destroy();
2822
+ replaceVariables(template, data) {
2823
+ return template.replace(/\{\{([^}]+)\}\}/g, (match, path) => {
2824
+ const value = this.getNestedValue(data, path.trim());
2825
+ return value !== undefined ? String(value) : match;
2826
+ });
2827
+ }
2828
+ /**
2829
+ * Get nested value from object using dot notation
2830
+ * Supports dynamic field access including custom fields
2831
+ */
2832
+ getNestedValue(obj, path) {
2833
+ return path.split('.').reduce((current, key) => {
2834
+ return current !== null && current !== undefined && typeof current === 'object'
2835
+ ? current[key]
2836
+ : undefined;
2837
+ }, obj);
2838
+ }
2839
+ /**
2840
+ * Extract all available field paths from a data object
2841
+ * Useful for dynamic field discovery based on platform-specific attributes
2842
+ * @param obj - The data object to extract fields from
2843
+ * @param prefix - Internal use for nested paths
2844
+ * @param maxDepth - Maximum depth to traverse (default: 3)
2845
+ * @returns Array of field paths (e.g., ['email', 'contact.firstName', 'customFields.industry'])
2846
+ */
2847
+ extractAvailableFields(obj, prefix = '', maxDepth = 3) {
2848
+ if (maxDepth <= 0)
2849
+ return [];
2850
+ const fields = [];
2851
+ for (const key in obj) {
2852
+ if (!obj.hasOwnProperty(key))
2853
+ continue;
2854
+ const value = obj[key];
2855
+ const fieldPath = prefix ? `${prefix}.${key}` : key;
2856
+ fields.push(fieldPath);
2857
+ // Recursively traverse nested objects
2858
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
2859
+ const nestedFields = this.extractAvailableFields(value, fieldPath, maxDepth - 1);
2860
+ fields.push(...nestedFields);
2678
2861
  }
2679
2862
  }
2680
- this.plugins = [];
2681
- // Destroy queue
2682
- this.queue.destroy();
2683
- this.isInitialized = false;
2863
+ return fields;
2864
+ }
2865
+ /**
2866
+ * Get available fields from sample data
2867
+ * Helps with dynamic field detection for platform-specific attributes
2868
+ * @param sampleData - Sample data object to analyze
2869
+ * @returns Array of available field paths
2870
+ */
2871
+ getAvailableFields(sampleData) {
2872
+ return this.extractAvailableFields(sampleData);
2873
+ }
2874
+ // ============================================
2875
+ // HELPER METHODS FOR COMMON PATTERNS
2876
+ // ============================================
2877
+ /**
2878
+ * Create a simple email trigger
2879
+ * Helper method for common use case
2880
+ */
2881
+ async createEmailTrigger(config) {
2882
+ return this.createTrigger({
2883
+ name: config.name,
2884
+ eventType: config.eventType,
2885
+ conditions: config.conditions,
2886
+ actions: [
2887
+ {
2888
+ type: 'send_email',
2889
+ to: config.to,
2890
+ subject: config.subject,
2891
+ body: config.body,
2892
+ },
2893
+ ],
2894
+ isActive: true,
2895
+ });
2896
+ }
2897
+ /**
2898
+ * Create a task creation trigger
2899
+ */
2900
+ async createTaskTrigger(config) {
2901
+ return this.createTrigger({
2902
+ name: config.name,
2903
+ eventType: config.eventType,
2904
+ conditions: config.conditions,
2905
+ actions: [
2906
+ {
2907
+ type: 'create_task',
2908
+ title: config.taskTitle,
2909
+ description: config.taskDescription,
2910
+ priority: config.priority,
2911
+ dueDays: config.dueDays,
2912
+ },
2913
+ ],
2914
+ isActive: true,
2915
+ });
2916
+ }
2917
+ /**
2918
+ * Create a webhook trigger
2919
+ */
2920
+ async createWebhookTrigger(config) {
2921
+ return this.createTrigger({
2922
+ name: config.name,
2923
+ eventType: config.eventType,
2924
+ conditions: config.conditions,
2925
+ actions: [
2926
+ {
2927
+ type: 'webhook',
2928
+ url: config.webhookUrl,
2929
+ method: config.method || 'POST',
2930
+ },
2931
+ ],
2932
+ isActive: true,
2933
+ });
2684
2934
  }
2685
2935
  }
2686
2936
 
2687
2937
  /**
2688
- * Clianta SDK - Event Triggers Manager
2689
- * Manages event-driven automation and email notifications
2938
+ * Clianta SDK - CRM API Client
2939
+ * @see SDK_VERSION in core/config.ts
2690
2940
  */
2691
2941
  /**
2692
- * Event Triggers Manager
2693
- * Handles event-driven automation based on CRM actions
2694
- *
2695
- * Similar to:
2696
- * - Salesforce: Process Builder, Flow Automation
2697
- * - HubSpot: Workflows, Email Sequences
2698
- * - Pipedrive: Workflow Automation
2942
+ * CRM API Client for managing contacts and opportunities
2699
2943
  */
2700
- class EventTriggersManager {
2701
- constructor(apiEndpoint, workspaceId, authToken) {
2702
- this.triggers = new Map();
2703
- this.listeners = new Map();
2944
+ class CRMClient {
2945
+ constructor(apiEndpoint, workspaceId, authToken, apiKey) {
2704
2946
  this.apiEndpoint = apiEndpoint;
2705
2947
  this.workspaceId = workspaceId;
2706
2948
  this.authToken = authToken;
2949
+ this.apiKey = apiKey;
2950
+ this.triggers = new EventTriggersManager(apiEndpoint, workspaceId, authToken);
2707
2951
  }
2708
2952
  /**
2709
- * Set authentication token
2953
+ * Set authentication token for API requests (user JWT)
2710
2954
  */
2711
2955
  setAuthToken(token) {
2712
2956
  this.authToken = token;
2957
+ this.apiKey = undefined;
2958
+ this.triggers.setAuthToken(token);
2959
+ }
2960
+ /**
2961
+ * Set workspace API key for server-to-server requests.
2962
+ * Use this instead of setAuthToken when integrating from an external app.
2963
+ */
2964
+ setApiKey(key) {
2965
+ this.apiKey = key;
2966
+ this.authToken = undefined;
2967
+ }
2968
+ /**
2969
+ * Validate required parameter exists
2970
+ * @throws {Error} if value is null/undefined or empty string
2971
+ */
2972
+ validateRequired(param, value, methodName) {
2973
+ if (value === null || value === undefined || value === '') {
2974
+ throw new Error(`[CRMClient.${methodName}] ${param} is required`);
2975
+ }
2713
2976
  }
2714
2977
  /**
2715
2978
  * Make authenticated API request
@@ -2720,970 +2983,1206 @@ class EventTriggersManager {
2720
2983
  'Content-Type': 'application/json',
2721
2984
  ...(options.headers || {}),
2722
2985
  };
2723
- if (this.authToken) {
2986
+ if (this.apiKey) {
2987
+ headers['X-Api-Key'] = this.apiKey;
2988
+ }
2989
+ else if (this.authToken) {
2990
+ headers['Authorization'] = `Bearer ${this.authToken}`;
2991
+ }
2992
+ try {
2993
+ const response = await fetch(url, {
2994
+ ...options,
2995
+ headers,
2996
+ });
2997
+ const data = await response.json();
2998
+ if (!response.ok) {
2999
+ return {
3000
+ success: false,
3001
+ error: data.message || 'Request failed',
3002
+ status: response.status,
3003
+ };
3004
+ }
3005
+ return {
3006
+ success: true,
3007
+ data: data.data || data,
3008
+ status: response.status,
3009
+ };
3010
+ }
3011
+ catch (error) {
3012
+ return {
3013
+ success: false,
3014
+ error: error instanceof Error ? error.message : 'Network error',
3015
+ status: 0,
3016
+ };
3017
+ }
3018
+ }
3019
+ // ============================================
3020
+ // INBOUND EVENTS API (API-key authenticated)
3021
+ // ============================================
3022
+ /**
3023
+ * Send an inbound event from an external app (e.g. user signup on client website).
3024
+ * Requires the client to be initialized with an API key via setApiKey() or the constructor.
3025
+ *
3026
+ * The contact is upserted in the CRM and matching workflow automations fire automatically.
3027
+ *
3028
+ * @example
3029
+ * const crm = new CRMClient('https://api.clianta.online', 'WORKSPACE_ID');
3030
+ * crm.setApiKey('mm_live_...');
3031
+ *
3032
+ * await crm.sendEvent({
3033
+ * event: 'user.registered',
3034
+ * contact: { email: 'alice@example.com', firstName: 'Alice' },
3035
+ * data: { plan: 'free', signupSource: 'homepage' },
3036
+ * });
3037
+ */
3038
+ async sendEvent(payload) {
3039
+ const url = `${this.apiEndpoint}/api/public/events`;
3040
+ const headers = { 'Content-Type': 'application/json' };
3041
+ if (this.apiKey) {
3042
+ headers['X-Api-Key'] = this.apiKey;
3043
+ }
3044
+ else if (this.authToken) {
2724
3045
  headers['Authorization'] = `Bearer ${this.authToken}`;
2725
3046
  }
2726
3047
  try {
2727
3048
  const response = await fetch(url, {
2728
- ...options,
3049
+ method: 'POST',
2729
3050
  headers,
3051
+ body: JSON.stringify(payload),
2730
3052
  });
2731
3053
  const data = await response.json();
2732
3054
  if (!response.ok) {
2733
3055
  return {
2734
3056
  success: false,
2735
- error: data.message || 'Request failed',
2736
- status: response.status,
3057
+ contactCreated: false,
3058
+ event: payload.event,
3059
+ error: data.error || 'Request failed',
2737
3060
  };
2738
3061
  }
2739
3062
  return {
2740
- success: true,
2741
- data: data.data || data,
2742
- status: response.status,
3063
+ success: data.success,
3064
+ contactCreated: data.contactCreated,
3065
+ contactId: data.contactId,
3066
+ event: data.event,
2743
3067
  };
2744
3068
  }
2745
3069
  catch (error) {
2746
3070
  return {
2747
3071
  success: false,
3072
+ contactCreated: false,
3073
+ event: payload.event,
2748
3074
  error: error instanceof Error ? error.message : 'Network error',
2749
- status: 0,
2750
3075
  };
2751
3076
  }
2752
3077
  }
2753
3078
  // ============================================
2754
- // TRIGGER MANAGEMENT
3079
+ // CONTACTS API
2755
3080
  // ============================================
2756
3081
  /**
2757
- * Get all event triggers
3082
+ * Get all contacts with pagination
2758
3083
  */
2759
- async getTriggers() {
2760
- return this.request(`/api/workspaces/${this.workspaceId}/triggers`);
3084
+ async getContacts(params) {
3085
+ const queryParams = new URLSearchParams();
3086
+ if (params?.page)
3087
+ queryParams.set('page', params.page.toString());
3088
+ if (params?.limit)
3089
+ queryParams.set('limit', params.limit.toString());
3090
+ if (params?.search)
3091
+ queryParams.set('search', params.search);
3092
+ if (params?.status)
3093
+ queryParams.set('status', params.status);
3094
+ const query = queryParams.toString();
3095
+ const endpoint = `/api/workspaces/${this.workspaceId}/contacts${query ? `?${query}` : ''}`;
3096
+ return this.request(endpoint);
2761
3097
  }
2762
3098
  /**
2763
- * Get a single trigger by ID
3099
+ * Get a single contact by ID
2764
3100
  */
2765
- async getTrigger(triggerId) {
2766
- return this.request(`/api/workspaces/${this.workspaceId}/triggers/${triggerId}`);
3101
+ async getContact(contactId) {
3102
+ this.validateRequired('contactId', contactId, 'getContact');
3103
+ return this.request(`/api/workspaces/${this.workspaceId}/contacts/${contactId}`);
2767
3104
  }
2768
3105
  /**
2769
- * Create a new event trigger
3106
+ * Create a new contact
2770
3107
  */
2771
- async createTrigger(trigger) {
2772
- const result = await this.request(`/api/workspaces/${this.workspaceId}/triggers`, {
3108
+ async createContact(contact) {
3109
+ return this.request(`/api/workspaces/${this.workspaceId}/contacts`, {
2773
3110
  method: 'POST',
2774
- body: JSON.stringify(trigger),
3111
+ body: JSON.stringify(contact),
2775
3112
  });
2776
- // Cache the trigger locally if successful
2777
- if (result.success && result.data?._id) {
2778
- this.triggers.set(result.data._id, result.data);
2779
- }
2780
- return result;
2781
3113
  }
2782
3114
  /**
2783
- * Update an existing trigger
3115
+ * Update an existing contact
2784
3116
  */
2785
- async updateTrigger(triggerId, updates) {
2786
- const result = await this.request(`/api/workspaces/${this.workspaceId}/triggers/${triggerId}`, {
3117
+ async updateContact(contactId, updates) {
3118
+ this.validateRequired('contactId', contactId, 'updateContact');
3119
+ return this.request(`/api/workspaces/${this.workspaceId}/contacts/${contactId}`, {
2787
3120
  method: 'PUT',
2788
3121
  body: JSON.stringify(updates),
2789
3122
  });
2790
- // Update cache if successful
2791
- if (result.success && result.data?._id) {
2792
- this.triggers.set(result.data._id, result.data);
2793
- }
2794
- return result;
2795
3123
  }
2796
3124
  /**
2797
- * Delete a trigger
3125
+ * Delete a contact
2798
3126
  */
2799
- async deleteTrigger(triggerId) {
2800
- const result = await this.request(`/api/workspaces/${this.workspaceId}/triggers/${triggerId}`, {
3127
+ async deleteContact(contactId) {
3128
+ this.validateRequired('contactId', contactId, 'deleteContact');
3129
+ return this.request(`/api/workspaces/${this.workspaceId}/contacts/${contactId}`, {
2801
3130
  method: 'DELETE',
2802
3131
  });
2803
- // Remove from cache if successful
2804
- if (result.success) {
2805
- this.triggers.delete(triggerId);
2806
- }
2807
- return result;
2808
- }
2809
- /**
2810
- * Activate a trigger
2811
- */
2812
- async activateTrigger(triggerId) {
2813
- return this.updateTrigger(triggerId, { isActive: true });
2814
- }
2815
- /**
2816
- * Deactivate a trigger
2817
- */
2818
- async deactivateTrigger(triggerId) {
2819
- return this.updateTrigger(triggerId, { isActive: false });
2820
3132
  }
2821
3133
  // ============================================
2822
- // EVENT HANDLING (CLIENT-SIDE)
3134
+ // OPPORTUNITIES API
2823
3135
  // ============================================
2824
3136
  /**
2825
- * Register a local event listener for client-side triggers
2826
- * This allows immediate client-side reactions to events
2827
- */
2828
- on(eventType, callback) {
2829
- if (!this.listeners.has(eventType)) {
2830
- this.listeners.set(eventType, new Set());
2831
- }
2832
- this.listeners.get(eventType).add(callback);
2833
- logger.debug(`Event listener registered: ${eventType}`);
2834
- }
2835
- /**
2836
- * Remove an event listener
3137
+ * Get all opportunities with pagination
2837
3138
  */
2838
- off(eventType, callback) {
2839
- const listeners = this.listeners.get(eventType);
2840
- if (listeners) {
2841
- listeners.delete(callback);
2842
- }
3139
+ async getOpportunities(params) {
3140
+ const queryParams = new URLSearchParams();
3141
+ if (params?.page)
3142
+ queryParams.set('page', params.page.toString());
3143
+ if (params?.limit)
3144
+ queryParams.set('limit', params.limit.toString());
3145
+ if (params?.pipelineId)
3146
+ queryParams.set('pipelineId', params.pipelineId);
3147
+ if (params?.stageId)
3148
+ queryParams.set('stageId', params.stageId);
3149
+ const query = queryParams.toString();
3150
+ const endpoint = `/api/workspaces/${this.workspaceId}/opportunities${query ? `?${query}` : ''}`;
3151
+ return this.request(endpoint);
2843
3152
  }
2844
3153
  /**
2845
- * Emit an event (client-side only)
2846
- * This will trigger any registered local listeners
3154
+ * Get a single opportunity by ID
2847
3155
  */
2848
- emit(eventType, data) {
2849
- logger.debug(`Event emitted: ${eventType}`, data);
2850
- const listeners = this.listeners.get(eventType);
2851
- if (listeners) {
2852
- listeners.forEach(callback => {
2853
- try {
2854
- callback(data);
2855
- }
2856
- catch (error) {
2857
- logger.error(`Error in event listener for ${eventType}:`, error);
2858
- }
2859
- });
2860
- }
3156
+ async getOpportunity(opportunityId) {
3157
+ return this.request(`/api/workspaces/${this.workspaceId}/opportunities/${opportunityId}`);
2861
3158
  }
2862
3159
  /**
2863
- * Check if conditions are met for a trigger
2864
- * Supports dynamic field evaluation including custom fields and nested paths
3160
+ * Create a new opportunity
2865
3161
  */
2866
- evaluateConditions(conditions, data) {
2867
- if (!conditions || conditions.length === 0) {
2868
- return true; // No conditions means always fire
2869
- }
2870
- return conditions.every(condition => {
2871
- // Support dot notation for nested fields (e.g., 'customFields.industry')
2872
- const fieldValue = condition.field.includes('.')
2873
- ? this.getNestedValue(data, condition.field)
2874
- : data[condition.field];
2875
- const targetValue = condition.value;
2876
- switch (condition.operator) {
2877
- case 'equals':
2878
- return fieldValue === targetValue;
2879
- case 'not_equals':
2880
- return fieldValue !== targetValue;
2881
- case 'contains':
2882
- return String(fieldValue).includes(String(targetValue));
2883
- case 'greater_than':
2884
- return Number(fieldValue) > Number(targetValue);
2885
- case 'less_than':
2886
- return Number(fieldValue) < Number(targetValue);
2887
- case 'in':
2888
- return Array.isArray(targetValue) && targetValue.includes(fieldValue);
2889
- case 'not_in':
2890
- return Array.isArray(targetValue) && !targetValue.includes(fieldValue);
2891
- default:
2892
- return false;
2893
- }
3162
+ async createOpportunity(opportunity) {
3163
+ return this.request(`/api/workspaces/${this.workspaceId}/opportunities`, {
3164
+ method: 'POST',
3165
+ body: JSON.stringify(opportunity),
2894
3166
  });
2895
3167
  }
2896
3168
  /**
2897
- * Execute actions for a triggered event (client-side preview)
2898
- * Note: Actual execution happens on the backend
3169
+ * Update an existing opportunity
2899
3170
  */
2900
- async executeActions(trigger, data) {
2901
- logger.info(`Executing actions for trigger: ${trigger.name}`);
2902
- for (const action of trigger.actions) {
2903
- try {
2904
- await this.executeAction(action, data);
2905
- }
2906
- catch (error) {
2907
- logger.error(`Failed to execute action:`, error);
2908
- }
2909
- }
3171
+ async updateOpportunity(opportunityId, updates) {
3172
+ return this.request(`/api/workspaces/${this.workspaceId}/opportunities/${opportunityId}`, {
3173
+ method: 'PUT',
3174
+ body: JSON.stringify(updates),
3175
+ });
2910
3176
  }
2911
3177
  /**
2912
- * Execute a single action
3178
+ * Delete an opportunity
2913
3179
  */
2914
- async executeAction(action, data) {
2915
- switch (action.type) {
2916
- case 'send_email':
2917
- await this.executeSendEmail(action, data);
2918
- break;
2919
- case 'webhook':
2920
- await this.executeWebhook(action, data);
2921
- break;
2922
- case 'create_task':
2923
- await this.executeCreateTask(action, data);
2924
- break;
2925
- case 'update_contact':
2926
- await this.executeUpdateContact(action, data);
2927
- break;
2928
- default:
2929
- logger.warn(`Unknown action type:`, action);
2930
- }
3180
+ async deleteOpportunity(opportunityId) {
3181
+ return this.request(`/api/workspaces/${this.workspaceId}/opportunities/${opportunityId}`, {
3182
+ method: 'DELETE',
3183
+ });
2931
3184
  }
2932
3185
  /**
2933
- * Execute send email action (via backend API)
3186
+ * Move opportunity to a different stage
2934
3187
  */
2935
- async executeSendEmail(action, data) {
2936
- logger.debug('Sending email:', action);
2937
- const payload = {
2938
- to: this.replaceVariables(action.to, data),
2939
- subject: action.subject ? this.replaceVariables(action.subject, data) : undefined,
2940
- body: action.body ? this.replaceVariables(action.body, data) : undefined,
2941
- templateId: action.templateId,
2942
- cc: action.cc,
2943
- bcc: action.bcc,
2944
- from: action.from,
2945
- delayMinutes: action.delayMinutes,
2946
- };
2947
- await this.request(`/api/workspaces/${this.workspaceId}/emails/send`, {
3188
+ async moveOpportunity(opportunityId, stageId) {
3189
+ return this.request(`/api/workspaces/${this.workspaceId}/opportunities/${opportunityId}/move`, {
2948
3190
  method: 'POST',
2949
- body: JSON.stringify(payload),
3191
+ body: JSON.stringify({ stageId }),
2950
3192
  });
2951
3193
  }
3194
+ // ============================================
3195
+ // COMPANIES API
3196
+ // ============================================
2952
3197
  /**
2953
- * Execute webhook action
3198
+ * Get all companies with pagination
2954
3199
  */
2955
- async executeWebhook(action, data) {
2956
- logger.debug('Calling webhook:', action.url);
2957
- const body = action.body ? this.replaceVariables(action.body, data) : JSON.stringify(data);
2958
- await fetch(action.url, {
2959
- method: action.method,
2960
- headers: {
2961
- 'Content-Type': 'application/json',
2962
- ...action.headers,
2963
- },
2964
- body,
2965
- });
3200
+ async getCompanies(params) {
3201
+ const queryParams = new URLSearchParams();
3202
+ if (params?.page)
3203
+ queryParams.set('page', params.page.toString());
3204
+ if (params?.limit)
3205
+ queryParams.set('limit', params.limit.toString());
3206
+ if (params?.search)
3207
+ queryParams.set('search', params.search);
3208
+ if (params?.status)
3209
+ queryParams.set('status', params.status);
3210
+ if (params?.industry)
3211
+ queryParams.set('industry', params.industry);
3212
+ const query = queryParams.toString();
3213
+ const endpoint = `/api/workspaces/${this.workspaceId}/companies${query ? `?${query}` : ''}`;
3214
+ return this.request(endpoint);
2966
3215
  }
2967
3216
  /**
2968
- * Execute create task action
3217
+ * Get a single company by ID
2969
3218
  */
2970
- async executeCreateTask(action, data) {
2971
- logger.debug('Creating task:', action.title);
2972
- const dueDate = action.dueDays
2973
- ? new Date(Date.now() + action.dueDays * 24 * 60 * 60 * 1000).toISOString()
2974
- : undefined;
2975
- await this.request(`/api/workspaces/${this.workspaceId}/tasks`, {
2976
- method: 'POST',
2977
- body: JSON.stringify({
2978
- title: this.replaceVariables(action.title, data),
2979
- description: action.description ? this.replaceVariables(action.description, data) : undefined,
2980
- priority: action.priority,
2981
- dueDate,
2982
- assignedTo: action.assignedTo,
2983
- relatedContactId: typeof data.contactId === 'string' ? data.contactId : undefined,
2984
- }),
2985
- });
3219
+ async getCompany(companyId) {
3220
+ return this.request(`/api/workspaces/${this.workspaceId}/companies/${companyId}`);
2986
3221
  }
2987
3222
  /**
2988
- * Execute update contact action
3223
+ * Create a new company
2989
3224
  */
2990
- async executeUpdateContact(action, data) {
2991
- const contactId = data.contactId || data._id;
2992
- if (!contactId) {
2993
- logger.warn('Cannot update contact: no contactId in data');
2994
- return;
2995
- }
2996
- logger.debug('Updating contact:', contactId);
2997
- await this.request(`/api/workspaces/${this.workspaceId}/contacts/${contactId}`, {
2998
- method: 'PUT',
2999
- body: JSON.stringify(action.updates),
3225
+ async createCompany(company) {
3226
+ return this.request(`/api/workspaces/${this.workspaceId}/companies`, {
3227
+ method: 'POST',
3228
+ body: JSON.stringify(company),
3000
3229
  });
3001
3230
  }
3002
3231
  /**
3003
- * Replace variables in a string template
3004
- * Supports syntax like {{contact.email}}, {{opportunity.value}}
3232
+ * Update an existing company
3005
3233
  */
3006
- replaceVariables(template, data) {
3007
- return template.replace(/\{\{([^}]+)\}\}/g, (match, path) => {
3008
- const value = this.getNestedValue(data, path.trim());
3009
- return value !== undefined ? String(value) : match;
3234
+ async updateCompany(companyId, updates) {
3235
+ return this.request(`/api/workspaces/${this.workspaceId}/companies/${companyId}`, {
3236
+ method: 'PUT',
3237
+ body: JSON.stringify(updates),
3010
3238
  });
3011
3239
  }
3012
3240
  /**
3013
- * Get nested value from object using dot notation
3014
- * Supports dynamic field access including custom fields
3241
+ * Delete a company
3015
3242
  */
3016
- getNestedValue(obj, path) {
3017
- return path.split('.').reduce((current, key) => {
3018
- return current !== null && current !== undefined && typeof current === 'object'
3019
- ? current[key]
3020
- : undefined;
3021
- }, obj);
3243
+ async deleteCompany(companyId) {
3244
+ return this.request(`/api/workspaces/${this.workspaceId}/companies/${companyId}`, {
3245
+ method: 'DELETE',
3246
+ });
3022
3247
  }
3023
3248
  /**
3024
- * Extract all available field paths from a data object
3025
- * Useful for dynamic field discovery based on platform-specific attributes
3026
- * @param obj - The data object to extract fields from
3027
- * @param prefix - Internal use for nested paths
3028
- * @param maxDepth - Maximum depth to traverse (default: 3)
3029
- * @returns Array of field paths (e.g., ['email', 'contact.firstName', 'customFields.industry'])
3249
+ * Get contacts belonging to a company
3030
3250
  */
3031
- extractAvailableFields(obj, prefix = '', maxDepth = 3) {
3032
- if (maxDepth <= 0)
3033
- return [];
3034
- const fields = [];
3035
- for (const key in obj) {
3036
- if (!obj.hasOwnProperty(key))
3037
- continue;
3038
- const value = obj[key];
3039
- const fieldPath = prefix ? `${prefix}.${key}` : key;
3040
- fields.push(fieldPath);
3041
- // Recursively traverse nested objects
3042
- if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
3043
- const nestedFields = this.extractAvailableFields(value, fieldPath, maxDepth - 1);
3044
- fields.push(...nestedFields);
3045
- }
3046
- }
3047
- return fields;
3251
+ async getCompanyContacts(companyId, params) {
3252
+ const queryParams = new URLSearchParams();
3253
+ if (params?.page)
3254
+ queryParams.set('page', params.page.toString());
3255
+ if (params?.limit)
3256
+ queryParams.set('limit', params.limit.toString());
3257
+ const query = queryParams.toString();
3258
+ const endpoint = `/api/workspaces/${this.workspaceId}/companies/${companyId}/contacts${query ? `?${query}` : ''}`;
3259
+ return this.request(endpoint);
3048
3260
  }
3049
3261
  /**
3050
- * Get available fields from sample data
3051
- * Helps with dynamic field detection for platform-specific attributes
3052
- * @param sampleData - Sample data object to analyze
3053
- * @returns Array of available field paths
3262
+ * Get deals/opportunities belonging to a company
3054
3263
  */
3055
- getAvailableFields(sampleData) {
3056
- return this.extractAvailableFields(sampleData);
3264
+ async getCompanyDeals(companyId, params) {
3265
+ const queryParams = new URLSearchParams();
3266
+ if (params?.page)
3267
+ queryParams.set('page', params.page.toString());
3268
+ if (params?.limit)
3269
+ queryParams.set('limit', params.limit.toString());
3270
+ const query = queryParams.toString();
3271
+ const endpoint = `/api/workspaces/${this.workspaceId}/companies/${companyId}/deals${query ? `?${query}` : ''}`;
3272
+ return this.request(endpoint);
3057
3273
  }
3058
3274
  // ============================================
3059
- // HELPER METHODS FOR COMMON PATTERNS
3275
+ // PIPELINES API
3060
3276
  // ============================================
3061
3277
  /**
3062
- * Create a simple email trigger
3063
- * Helper method for common use case
3278
+ * Get all pipelines
3064
3279
  */
3065
- async createEmailTrigger(config) {
3066
- return this.createTrigger({
3067
- name: config.name,
3068
- eventType: config.eventType,
3069
- conditions: config.conditions,
3070
- actions: [
3071
- {
3072
- type: 'send_email',
3073
- to: config.to,
3074
- subject: config.subject,
3075
- body: config.body,
3076
- },
3077
- ],
3078
- isActive: true,
3079
- });
3280
+ async getPipelines() {
3281
+ return this.request(`/api/workspaces/${this.workspaceId}/pipelines`);
3080
3282
  }
3081
3283
  /**
3082
- * Create a task creation trigger
3284
+ * Get a single pipeline by ID
3083
3285
  */
3084
- async createTaskTrigger(config) {
3085
- return this.createTrigger({
3086
- name: config.name,
3087
- eventType: config.eventType,
3088
- conditions: config.conditions,
3089
- actions: [
3090
- {
3091
- type: 'create_task',
3092
- title: config.taskTitle,
3093
- description: config.taskDescription,
3094
- priority: config.priority,
3095
- dueDays: config.dueDays,
3096
- },
3097
- ],
3098
- isActive: true,
3099
- });
3286
+ async getPipeline(pipelineId) {
3287
+ return this.request(`/api/workspaces/${this.workspaceId}/pipelines/${pipelineId}`);
3100
3288
  }
3101
3289
  /**
3102
- * Create a webhook trigger
3290
+ * Create a new pipeline
3103
3291
  */
3104
- async createWebhookTrigger(config) {
3105
- return this.createTrigger({
3106
- name: config.name,
3107
- eventType: config.eventType,
3108
- conditions: config.conditions,
3109
- actions: [
3110
- {
3111
- type: 'webhook',
3112
- url: config.webhookUrl,
3113
- method: config.method || 'POST',
3114
- },
3115
- ],
3116
- isActive: true,
3292
+ async createPipeline(pipeline) {
3293
+ return this.request(`/api/workspaces/${this.workspaceId}/pipelines`, {
3294
+ method: 'POST',
3295
+ body: JSON.stringify(pipeline),
3117
3296
  });
3118
3297
  }
3119
- }
3120
-
3121
- /**
3122
- * Clianta SDK - CRM API Client
3123
- * @see SDK_VERSION in core/config.ts
3124
- */
3125
- /**
3126
- * CRM API Client for managing contacts and opportunities
3127
- */
3128
- class CRMClient {
3129
- constructor(apiEndpoint, workspaceId, authToken) {
3130
- this.apiEndpoint = apiEndpoint;
3131
- this.workspaceId = workspaceId;
3132
- this.authToken = authToken;
3133
- this.triggers = new EventTriggersManager(apiEndpoint, workspaceId, authToken);
3134
- }
3135
3298
  /**
3136
- * Set authentication token for API requests
3137
- */
3138
- setAuthToken(token) {
3139
- this.authToken = token;
3140
- this.triggers.setAuthToken(token);
3141
- }
3142
- /**
3143
- * Validate required parameter exists
3144
- * @throws {Error} if value is null/undefined or empty string
3299
+ * Update an existing pipeline
3145
3300
  */
3146
- validateRequired(param, value, methodName) {
3147
- if (value === null || value === undefined || value === '') {
3148
- throw new Error(`[CRMClient.${methodName}] ${param} is required`);
3149
- }
3301
+ async updatePipeline(pipelineId, updates) {
3302
+ return this.request(`/api/workspaces/${this.workspaceId}/pipelines/${pipelineId}`, {
3303
+ method: 'PUT',
3304
+ body: JSON.stringify(updates),
3305
+ });
3150
3306
  }
3151
3307
  /**
3152
- * Make authenticated API request
3308
+ * Delete a pipeline
3153
3309
  */
3154
- async request(endpoint, options = {}) {
3155
- const url = `${this.apiEndpoint}${endpoint}`;
3156
- const headers = {
3157
- 'Content-Type': 'application/json',
3158
- ...(options.headers || {}),
3159
- };
3160
- if (this.authToken) {
3161
- headers['Authorization'] = `Bearer ${this.authToken}`;
3162
- }
3163
- try {
3164
- const response = await fetch(url, {
3165
- ...options,
3166
- headers,
3167
- });
3168
- const data = await response.json();
3169
- if (!response.ok) {
3170
- return {
3171
- success: false,
3172
- error: data.message || 'Request failed',
3173
- status: response.status,
3174
- };
3175
- }
3176
- return {
3177
- success: true,
3178
- data: data.data || data,
3179
- status: response.status,
3180
- };
3181
- }
3182
- catch (error) {
3183
- return {
3184
- success: false,
3185
- error: error instanceof Error ? error.message : 'Network error',
3186
- status: 0,
3187
- };
3188
- }
3310
+ async deletePipeline(pipelineId) {
3311
+ return this.request(`/api/workspaces/${this.workspaceId}/pipelines/${pipelineId}`, {
3312
+ method: 'DELETE',
3313
+ });
3189
3314
  }
3190
3315
  // ============================================
3191
- // CONTACTS API
3316
+ // TASKS API
3192
3317
  // ============================================
3193
3318
  /**
3194
- * Get all contacts with pagination
3319
+ * Get all tasks with pagination
3195
3320
  */
3196
- async getContacts(params) {
3321
+ async getTasks(params) {
3197
3322
  const queryParams = new URLSearchParams();
3198
3323
  if (params?.page)
3199
3324
  queryParams.set('page', params.page.toString());
3200
3325
  if (params?.limit)
3201
3326
  queryParams.set('limit', params.limit.toString());
3202
- if (params?.search)
3203
- queryParams.set('search', params.search);
3204
3327
  if (params?.status)
3205
3328
  queryParams.set('status', params.status);
3329
+ if (params?.priority)
3330
+ queryParams.set('priority', params.priority);
3331
+ if (params?.contactId)
3332
+ queryParams.set('contactId', params.contactId);
3333
+ if (params?.companyId)
3334
+ queryParams.set('companyId', params.companyId);
3335
+ if (params?.opportunityId)
3336
+ queryParams.set('opportunityId', params.opportunityId);
3206
3337
  const query = queryParams.toString();
3207
- const endpoint = `/api/workspaces/${this.workspaceId}/contacts${query ? `?${query}` : ''}`;
3338
+ const endpoint = `/api/workspaces/${this.workspaceId}/tasks${query ? `?${query}` : ''}`;
3208
3339
  return this.request(endpoint);
3209
3340
  }
3210
3341
  /**
3211
- * Get a single contact by ID
3342
+ * Get a single task by ID
3212
3343
  */
3213
- async getContact(contactId) {
3214
- this.validateRequired('contactId', contactId, 'getContact');
3215
- return this.request(`/api/workspaces/${this.workspaceId}/contacts/${contactId}`);
3344
+ async getTask(taskId) {
3345
+ return this.request(`/api/workspaces/${this.workspaceId}/tasks/${taskId}`);
3216
3346
  }
3217
3347
  /**
3218
- * Create a new contact
3348
+ * Create a new task
3219
3349
  */
3220
- async createContact(contact) {
3221
- return this.request(`/api/workspaces/${this.workspaceId}/contacts`, {
3350
+ async createTask(task) {
3351
+ return this.request(`/api/workspaces/${this.workspaceId}/tasks`, {
3222
3352
  method: 'POST',
3223
- body: JSON.stringify(contact),
3353
+ body: JSON.stringify(task),
3224
3354
  });
3225
3355
  }
3226
3356
  /**
3227
- * Update an existing contact
3357
+ * Update an existing task
3228
3358
  */
3229
- async updateContact(contactId, updates) {
3230
- this.validateRequired('contactId', contactId, 'updateContact');
3231
- return this.request(`/api/workspaces/${this.workspaceId}/contacts/${contactId}`, {
3359
+ async updateTask(taskId, updates) {
3360
+ return this.request(`/api/workspaces/${this.workspaceId}/tasks/${taskId}`, {
3232
3361
  method: 'PUT',
3233
3362
  body: JSON.stringify(updates),
3234
3363
  });
3235
3364
  }
3236
3365
  /**
3237
- * Delete a contact
3366
+ * Mark a task as completed
3238
3367
  */
3239
- async deleteContact(contactId) {
3240
- this.validateRequired('contactId', contactId, 'deleteContact');
3241
- return this.request(`/api/workspaces/${this.workspaceId}/contacts/${contactId}`, {
3368
+ async completeTask(taskId) {
3369
+ return this.request(`/api/workspaces/${this.workspaceId}/tasks/${taskId}/complete`, {
3370
+ method: 'PATCH',
3371
+ });
3372
+ }
3373
+ /**
3374
+ * Delete a task
3375
+ */
3376
+ async deleteTask(taskId) {
3377
+ return this.request(`/api/workspaces/${this.workspaceId}/tasks/${taskId}`, {
3242
3378
  method: 'DELETE',
3243
3379
  });
3244
3380
  }
3245
3381
  // ============================================
3246
- // OPPORTUNITIES API
3382
+ // ACTIVITIES API
3247
3383
  // ============================================
3248
3384
  /**
3249
- * Get all opportunities with pagination
3385
+ * Get activities for a contact
3250
3386
  */
3251
- async getOpportunities(params) {
3387
+ async getContactActivities(contactId, params) {
3252
3388
  const queryParams = new URLSearchParams();
3253
3389
  if (params?.page)
3254
3390
  queryParams.set('page', params.page.toString());
3255
3391
  if (params?.limit)
3256
3392
  queryParams.set('limit', params.limit.toString());
3257
- if (params?.pipelineId)
3258
- queryParams.set('pipelineId', params.pipelineId);
3259
- if (params?.stageId)
3260
- queryParams.set('stageId', params.stageId);
3393
+ if (params?.type)
3394
+ queryParams.set('type', params.type);
3261
3395
  const query = queryParams.toString();
3262
- const endpoint = `/api/workspaces/${this.workspaceId}/opportunities${query ? `?${query}` : ''}`;
3396
+ const endpoint = `/api/workspaces/${this.workspaceId}/contacts/${contactId}/activities${query ? `?${query}` : ''}`;
3263
3397
  return this.request(endpoint);
3264
3398
  }
3265
3399
  /**
3266
- * Get a single opportunity by ID
3400
+ * Get activities for an opportunity/deal
3267
3401
  */
3268
- async getOpportunity(opportunityId) {
3269
- return this.request(`/api/workspaces/${this.workspaceId}/opportunities/${opportunityId}`);
3402
+ async getOpportunityActivities(opportunityId, params) {
3403
+ const queryParams = new URLSearchParams();
3404
+ if (params?.page)
3405
+ queryParams.set('page', params.page.toString());
3406
+ if (params?.limit)
3407
+ queryParams.set('limit', params.limit.toString());
3408
+ if (params?.type)
3409
+ queryParams.set('type', params.type);
3410
+ const query = queryParams.toString();
3411
+ const endpoint = `/api/workspaces/${this.workspaceId}/opportunities/${opportunityId}/activities${query ? `?${query}` : ''}`;
3412
+ return this.request(endpoint);
3270
3413
  }
3271
3414
  /**
3272
- * Create a new opportunity
3415
+ * Create a new activity
3273
3416
  */
3274
- async createOpportunity(opportunity) {
3275
- return this.request(`/api/workspaces/${this.workspaceId}/opportunities`, {
3417
+ async createActivity(activity) {
3418
+ // Determine the correct endpoint based on related entity
3419
+ let endpoint;
3420
+ if (activity.opportunityId) {
3421
+ endpoint = `/api/workspaces/${this.workspaceId}/opportunities/${activity.opportunityId}/activities`;
3422
+ }
3423
+ else if (activity.contactId) {
3424
+ endpoint = `/api/workspaces/${this.workspaceId}/contacts/${activity.contactId}/activities`;
3425
+ }
3426
+ else {
3427
+ endpoint = `/api/workspaces/${this.workspaceId}/activities`;
3428
+ }
3429
+ return this.request(endpoint, {
3276
3430
  method: 'POST',
3277
- body: JSON.stringify(opportunity),
3431
+ body: JSON.stringify(activity),
3278
3432
  });
3279
3433
  }
3280
3434
  /**
3281
- * Update an existing opportunity
3435
+ * Update an existing activity
3282
3436
  */
3283
- async updateOpportunity(opportunityId, updates) {
3284
- return this.request(`/api/workspaces/${this.workspaceId}/opportunities/${opportunityId}`, {
3285
- method: 'PUT',
3437
+ async updateActivity(activityId, updates) {
3438
+ return this.request(`/api/workspaces/${this.workspaceId}/activities/${activityId}`, {
3439
+ method: 'PATCH',
3286
3440
  body: JSON.stringify(updates),
3287
3441
  });
3288
3442
  }
3289
3443
  /**
3290
- * Delete an opportunity
3444
+ * Delete an activity
3291
3445
  */
3292
- async deleteOpportunity(opportunityId) {
3293
- return this.request(`/api/workspaces/${this.workspaceId}/opportunities/${opportunityId}`, {
3446
+ async deleteActivity(activityId) {
3447
+ return this.request(`/api/workspaces/${this.workspaceId}/activities/${activityId}`, {
3294
3448
  method: 'DELETE',
3295
3449
  });
3296
3450
  }
3297
3451
  /**
3298
- * Move opportunity to a different stage
3452
+ * Log a call activity
3299
3453
  */
3300
- async moveOpportunity(opportunityId, stageId) {
3301
- return this.request(`/api/workspaces/${this.workspaceId}/opportunities/${opportunityId}/move`, {
3302
- method: 'POST',
3303
- body: JSON.stringify({ stageId }),
3454
+ async logCall(data) {
3455
+ return this.createActivity({
3456
+ type: 'call',
3457
+ title: `${data.direction === 'inbound' ? 'Inbound' : 'Outbound'} Call`,
3458
+ direction: data.direction,
3459
+ duration: data.duration,
3460
+ outcome: data.outcome,
3461
+ description: data.notes,
3462
+ contactId: data.contactId,
3463
+ opportunityId: data.opportunityId,
3464
+ });
3465
+ }
3466
+ /**
3467
+ * Log a meeting activity
3468
+ */
3469
+ async logMeeting(data) {
3470
+ return this.createActivity({
3471
+ type: 'meeting',
3472
+ title: data.title,
3473
+ duration: data.duration,
3474
+ outcome: data.outcome,
3475
+ description: data.notes,
3476
+ contactId: data.contactId,
3477
+ opportunityId: data.opportunityId,
3478
+ });
3479
+ }
3480
+ /**
3481
+ * Add a note to a contact or opportunity
3482
+ */
3483
+ async addNote(data) {
3484
+ return this.createActivity({
3485
+ type: 'note',
3486
+ title: 'Note',
3487
+ description: data.content,
3488
+ contactId: data.contactId,
3489
+ opportunityId: data.opportunityId,
3304
3490
  });
3305
3491
  }
3306
3492
  // ============================================
3307
- // COMPANIES API
3493
+ // EMAIL TEMPLATES API
3308
3494
  // ============================================
3309
3495
  /**
3310
- * Get all companies with pagination
3496
+ * Get all email templates
3311
3497
  */
3312
- async getCompanies(params) {
3498
+ async getEmailTemplates(params) {
3313
3499
  const queryParams = new URLSearchParams();
3314
3500
  if (params?.page)
3315
3501
  queryParams.set('page', params.page.toString());
3316
3502
  if (params?.limit)
3317
3503
  queryParams.set('limit', params.limit.toString());
3318
- if (params?.search)
3319
- queryParams.set('search', params.search);
3320
- if (params?.status)
3321
- queryParams.set('status', params.status);
3322
- if (params?.industry)
3323
- queryParams.set('industry', params.industry);
3324
3504
  const query = queryParams.toString();
3325
- const endpoint = `/api/workspaces/${this.workspaceId}/companies${query ? `?${query}` : ''}`;
3505
+ const endpoint = `/api/workspaces/${this.workspaceId}/email-templates${query ? `?${query}` : ''}`;
3326
3506
  return this.request(endpoint);
3327
3507
  }
3328
3508
  /**
3329
- * Get a single company by ID
3509
+ * Get a single email template by ID
3330
3510
  */
3331
- async getCompany(companyId) {
3332
- return this.request(`/api/workspaces/${this.workspaceId}/companies/${companyId}`);
3511
+ async getEmailTemplate(templateId) {
3512
+ return this.request(`/api/workspaces/${this.workspaceId}/email-templates/${templateId}`);
3333
3513
  }
3334
3514
  /**
3335
- * Create a new company
3515
+ * Create a new email template
3336
3516
  */
3337
- async createCompany(company) {
3338
- return this.request(`/api/workspaces/${this.workspaceId}/companies`, {
3517
+ async createEmailTemplate(template) {
3518
+ return this.request(`/api/workspaces/${this.workspaceId}/email-templates`, {
3339
3519
  method: 'POST',
3340
- body: JSON.stringify(company),
3520
+ body: JSON.stringify(template),
3341
3521
  });
3342
3522
  }
3343
3523
  /**
3344
- * Update an existing company
3524
+ * Update an email template
3345
3525
  */
3346
- async updateCompany(companyId, updates) {
3347
- return this.request(`/api/workspaces/${this.workspaceId}/companies/${companyId}`, {
3526
+ async updateEmailTemplate(templateId, updates) {
3527
+ return this.request(`/api/workspaces/${this.workspaceId}/email-templates/${templateId}`, {
3348
3528
  method: 'PUT',
3349
3529
  body: JSON.stringify(updates),
3350
3530
  });
3351
3531
  }
3352
3532
  /**
3353
- * Delete a company
3533
+ * Delete an email template
3354
3534
  */
3355
- async deleteCompany(companyId) {
3356
- return this.request(`/api/workspaces/${this.workspaceId}/companies/${companyId}`, {
3535
+ async deleteEmailTemplate(templateId) {
3536
+ return this.request(`/api/workspaces/${this.workspaceId}/email-templates/${templateId}`, {
3357
3537
  method: 'DELETE',
3358
3538
  });
3359
3539
  }
3360
3540
  /**
3361
- * Get contacts belonging to a company
3541
+ * Send an email using a template
3362
3542
  */
3363
- async getCompanyContacts(companyId, params) {
3543
+ async sendEmail(data) {
3544
+ return this.request(`/api/workspaces/${this.workspaceId}/emails/send`, {
3545
+ method: 'POST',
3546
+ body: JSON.stringify(data),
3547
+ });
3548
+ }
3549
+ // ============================================
3550
+ // READ-BACK / DATA RETRIEVAL API
3551
+ // ============================================
3552
+ /**
3553
+ * Get a contact by email address.
3554
+ * Returns the first matching contact from a search query.
3555
+ */
3556
+ async getContactByEmail(email) {
3557
+ this.validateRequired('email', email, 'getContactByEmail');
3558
+ const queryParams = new URLSearchParams({ search: email, limit: '1' });
3559
+ return this.request(`/api/workspaces/${this.workspaceId}/contacts?${queryParams.toString()}`);
3560
+ }
3561
+ /**
3562
+ * Get activity timeline for a contact
3563
+ */
3564
+ async getContactActivity(contactId, params) {
3565
+ this.validateRequired('contactId', contactId, 'getContactActivity');
3364
3566
  const queryParams = new URLSearchParams();
3365
3567
  if (params?.page)
3366
3568
  queryParams.set('page', params.page.toString());
3367
3569
  if (params?.limit)
3368
3570
  queryParams.set('limit', params.limit.toString());
3571
+ if (params?.type)
3572
+ queryParams.set('type', params.type);
3573
+ if (params?.startDate)
3574
+ queryParams.set('startDate', params.startDate);
3575
+ if (params?.endDate)
3576
+ queryParams.set('endDate', params.endDate);
3369
3577
  const query = queryParams.toString();
3370
- const endpoint = `/api/workspaces/${this.workspaceId}/companies/${companyId}/contacts${query ? `?${query}` : ''}`;
3578
+ const endpoint = `/api/workspaces/${this.workspaceId}/contacts/${contactId}/activities${query ? `?${query}` : ''}`;
3371
3579
  return this.request(endpoint);
3372
3580
  }
3373
3581
  /**
3374
- * Get deals/opportunities belonging to a company
3582
+ * Get engagement metrics for a contact (via their linked visitor data)
3375
3583
  */
3376
- async getCompanyDeals(companyId, params) {
3584
+ async getContactEngagement(contactId) {
3585
+ this.validateRequired('contactId', contactId, 'getContactEngagement');
3586
+ return this.request(`/api/workspaces/${this.workspaceId}/contacts/${contactId}/engagement`);
3587
+ }
3588
+ /**
3589
+ * Get a full timeline for a contact including events, activities, and opportunities
3590
+ */
3591
+ async getContactTimeline(contactId, params) {
3592
+ this.validateRequired('contactId', contactId, 'getContactTimeline');
3377
3593
  const queryParams = new URLSearchParams();
3378
3594
  if (params?.page)
3379
3595
  queryParams.set('page', params.page.toString());
3380
3596
  if (params?.limit)
3381
3597
  queryParams.set('limit', params.limit.toString());
3382
3598
  const query = queryParams.toString();
3383
- const endpoint = `/api/workspaces/${this.workspaceId}/companies/${companyId}/deals${query ? `?${query}` : ''}`;
3599
+ const endpoint = `/api/workspaces/${this.workspaceId}/contacts/${contactId}/timeline${query ? `?${query}` : ''}`;
3600
+ return this.request(endpoint);
3601
+ }
3602
+ /**
3603
+ * Search contacts with advanced filters
3604
+ */
3605
+ async searchContacts(query, filters) {
3606
+ const queryParams = new URLSearchParams();
3607
+ queryParams.set('search', query);
3608
+ if (filters?.status)
3609
+ queryParams.set('status', filters.status);
3610
+ if (filters?.lifecycleStage)
3611
+ queryParams.set('lifecycleStage', filters.lifecycleStage);
3612
+ if (filters?.source)
3613
+ queryParams.set('source', filters.source);
3614
+ if (filters?.tags)
3615
+ queryParams.set('tags', filters.tags.join(','));
3616
+ if (filters?.page)
3617
+ queryParams.set('page', filters.page.toString());
3618
+ if (filters?.limit)
3619
+ queryParams.set('limit', filters.limit.toString());
3620
+ const qs = queryParams.toString();
3621
+ const endpoint = `/api/workspaces/${this.workspaceId}/contacts${qs ? `?${qs}` : ''}`;
3384
3622
  return this.request(endpoint);
3385
3623
  }
3386
3624
  // ============================================
3387
- // PIPELINES API
3625
+ // WEBHOOK MANAGEMENT API
3388
3626
  // ============================================
3389
3627
  /**
3390
- * Get all pipelines
3628
+ * List all webhook subscriptions
3391
3629
  */
3392
- async getPipelines() {
3393
- return this.request(`/api/workspaces/${this.workspaceId}/pipelines`);
3630
+ async listWebhooks(params) {
3631
+ const queryParams = new URLSearchParams();
3632
+ if (params?.page)
3633
+ queryParams.set('page', params.page.toString());
3634
+ if (params?.limit)
3635
+ queryParams.set('limit', params.limit.toString());
3636
+ const query = queryParams.toString();
3637
+ return this.request(`/api/workspaces/${this.workspaceId}/webhooks${query ? `?${query}` : ''}`);
3394
3638
  }
3395
3639
  /**
3396
- * Get a single pipeline by ID
3640
+ * Create a new webhook subscription
3397
3641
  */
3398
- async getPipeline(pipelineId) {
3399
- return this.request(`/api/workspaces/${this.workspaceId}/pipelines/${pipelineId}`);
3642
+ async createWebhook(data) {
3643
+ return this.request(`/api/workspaces/${this.workspaceId}/webhooks`, {
3644
+ method: 'POST',
3645
+ body: JSON.stringify(data),
3646
+ });
3400
3647
  }
3401
3648
  /**
3402
- * Create a new pipeline
3649
+ * Delete a webhook subscription
3403
3650
  */
3404
- async createPipeline(pipeline) {
3405
- return this.request(`/api/workspaces/${this.workspaceId}/pipelines`, {
3406
- method: 'POST',
3407
- body: JSON.stringify(pipeline),
3651
+ async deleteWebhook(webhookId) {
3652
+ this.validateRequired('webhookId', webhookId, 'deleteWebhook');
3653
+ return this.request(`/api/workspaces/${this.workspaceId}/webhooks/${webhookId}`, {
3654
+ method: 'DELETE',
3408
3655
  });
3409
3656
  }
3657
+ // ============================================
3658
+ // EVENT TRIGGERS API (delegated to triggers manager)
3659
+ // ============================================
3660
+ /**
3661
+ * Get all event triggers
3662
+ */
3663
+ async getEventTriggers() {
3664
+ return this.triggers.getTriggers();
3665
+ }
3410
3666
  /**
3411
- * Update an existing pipeline
3667
+ * Create a new event trigger
3412
3668
  */
3413
- async updatePipeline(pipelineId, updates) {
3414
- return this.request(`/api/workspaces/${this.workspaceId}/pipelines/${pipelineId}`, {
3415
- method: 'PUT',
3416
- body: JSON.stringify(updates),
3417
- });
3669
+ async createEventTrigger(trigger) {
3670
+ return this.triggers.createTrigger(trigger);
3418
3671
  }
3419
3672
  /**
3420
- * Delete a pipeline
3673
+ * Update an event trigger
3421
3674
  */
3422
- async deletePipeline(pipelineId) {
3423
- return this.request(`/api/workspaces/${this.workspaceId}/pipelines/${pipelineId}`, {
3424
- method: 'DELETE',
3675
+ async updateEventTrigger(triggerId, updates) {
3676
+ return this.triggers.updateTrigger(triggerId, updates);
3677
+ }
3678
+ /**
3679
+ * Delete an event trigger
3680
+ */
3681
+ async deleteEventTrigger(triggerId) {
3682
+ return this.triggers.deleteTrigger(triggerId);
3683
+ }
3684
+ }
3685
+
3686
+ /**
3687
+ * Clianta SDK - Main Tracker Class
3688
+ * @see SDK_VERSION in core/config.ts
3689
+ */
3690
+ /**
3691
+ * Main Clianta Tracker Class
3692
+ */
3693
+ class Tracker {
3694
+ constructor(workspaceId, userConfig = {}) {
3695
+ this.plugins = [];
3696
+ this.isInitialized = false;
3697
+ /** contactId after a successful identify() call */
3698
+ this.contactId = null;
3699
+ /** Pending identify retry on next flush */
3700
+ this.pendingIdentify = null;
3701
+ /** Registered event schemas for validation */
3702
+ this.eventSchemas = new Map();
3703
+ if (!workspaceId) {
3704
+ throw new Error('[Clianta] Workspace ID is required');
3705
+ }
3706
+ this.workspaceId = workspaceId;
3707
+ this.config = mergeConfig(userConfig);
3708
+ // Setup debug mode
3709
+ logger.enabled = this.config.debug;
3710
+ logger.info(`Initializing SDK v${SDK_VERSION}`, { workspaceId });
3711
+ // Initialize consent manager
3712
+ this.consentManager = new ConsentManager({
3713
+ ...this.config.consent,
3714
+ onConsentChange: (state, previous) => {
3715
+ this.onConsentChange(state, previous);
3716
+ },
3717
+ });
3718
+ // Initialize transport and queue
3719
+ this.transport = new Transport({ apiEndpoint: this.config.apiEndpoint });
3720
+ this.queue = new EventQueue(this.transport, {
3721
+ batchSize: this.config.batchSize,
3722
+ flushInterval: this.config.flushInterval,
3425
3723
  });
3724
+ // Get or create visitor and session IDs based on mode
3725
+ this.visitorId = this.createVisitorId();
3726
+ this.sessionId = this.createSessionId();
3727
+ logger.debug('IDs created', { visitorId: this.visitorId, sessionId: this.sessionId });
3728
+ // Security warnings
3729
+ if (this.config.apiEndpoint.startsWith('http://') &&
3730
+ typeof window !== 'undefined' &&
3731
+ !window.location.hostname.includes('localhost') &&
3732
+ !window.location.hostname.includes('127.0.0.1')) {
3733
+ logger.warn('apiEndpoint uses HTTP — events and visitor data will be sent unencrypted. Use HTTPS in production.');
3734
+ }
3735
+ if (this.config.apiKey && typeof window !== 'undefined') {
3736
+ logger.warn('API key is exposed in client-side code. Use API keys only in server-side (Node.js) environments.');
3737
+ }
3738
+ // Initialize plugins
3739
+ this.initPlugins();
3740
+ this.isInitialized = true;
3741
+ logger.info('SDK initialized successfully');
3426
3742
  }
3427
- // ============================================
3428
- // TASKS API
3429
- // ============================================
3430
3743
  /**
3431
- * Get all tasks with pagination
3744
+ * Create visitor ID based on storage mode
3432
3745
  */
3433
- async getTasks(params) {
3434
- const queryParams = new URLSearchParams();
3435
- if (params?.page)
3436
- queryParams.set('page', params.page.toString());
3437
- if (params?.limit)
3438
- queryParams.set('limit', params.limit.toString());
3439
- if (params?.status)
3440
- queryParams.set('status', params.status);
3441
- if (params?.priority)
3442
- queryParams.set('priority', params.priority);
3443
- if (params?.contactId)
3444
- queryParams.set('contactId', params.contactId);
3445
- if (params?.companyId)
3446
- queryParams.set('companyId', params.companyId);
3447
- if (params?.opportunityId)
3448
- queryParams.set('opportunityId', params.opportunityId);
3449
- const query = queryParams.toString();
3450
- const endpoint = `/api/workspaces/${this.workspaceId}/tasks${query ? `?${query}` : ''}`;
3451
- return this.request(endpoint);
3746
+ createVisitorId() {
3747
+ // Anonymous mode: use temporary ID until consent
3748
+ if (this.config.consent.anonymousMode && !this.consentManager.hasExplicit()) {
3749
+ const key = STORAGE_KEYS.VISITOR_ID + '_anon';
3750
+ let anonId = getSessionStorage(key);
3751
+ if (!anonId) {
3752
+ anonId = 'anon_' + generateUUID();
3753
+ setSessionStorage(key, anonId);
3754
+ }
3755
+ return anonId;
3756
+ }
3757
+ // Cookie-less mode: use sessionStorage only
3758
+ if (this.config.cookielessMode) {
3759
+ let visitorId = getSessionStorage(STORAGE_KEYS.VISITOR_ID);
3760
+ if (!visitorId) {
3761
+ visitorId = generateUUID();
3762
+ setSessionStorage(STORAGE_KEYS.VISITOR_ID, visitorId);
3763
+ }
3764
+ return visitorId;
3765
+ }
3766
+ // Normal mode
3767
+ return getOrCreateVisitorId(this.config.useCookies);
3452
3768
  }
3453
3769
  /**
3454
- * Get a single task by ID
3770
+ * Create session ID
3455
3771
  */
3456
- async getTask(taskId) {
3457
- return this.request(`/api/workspaces/${this.workspaceId}/tasks/${taskId}`);
3772
+ createSessionId() {
3773
+ return getOrCreateSessionId(this.config.sessionTimeout);
3458
3774
  }
3459
3775
  /**
3460
- * Create a new task
3776
+ * Handle consent state changes
3461
3777
  */
3462
- async createTask(task) {
3463
- return this.request(`/api/workspaces/${this.workspaceId}/tasks`, {
3464
- method: 'POST',
3465
- body: JSON.stringify(task),
3466
- });
3778
+ onConsentChange(state, previous) {
3779
+ logger.debug('Consent changed:', { from: previous, to: state });
3780
+ // If analytics consent was just granted
3781
+ if (state.analytics && !previous.analytics) {
3782
+ // Upgrade from anonymous ID to persistent ID
3783
+ if (this.config.consent.anonymousMode) {
3784
+ this.visitorId = getOrCreateVisitorId(this.config.useCookies);
3785
+ logger.info('Upgraded from anonymous to persistent visitor ID');
3786
+ }
3787
+ // Flush buffered events
3788
+ const buffered = this.consentManager.flushBuffer();
3789
+ for (const event of buffered) {
3790
+ // Update event with new visitor ID
3791
+ event.visitorId = this.visitorId;
3792
+ this.queue.push(event);
3793
+ }
3794
+ }
3467
3795
  }
3468
3796
  /**
3469
- * Update an existing task
3797
+ * Initialize enabled plugins
3798
+ * Handles both sync and async plugin init methods
3470
3799
  */
3471
- async updateTask(taskId, updates) {
3472
- return this.request(`/api/workspaces/${this.workspaceId}/tasks/${taskId}`, {
3473
- method: 'PUT',
3474
- body: JSON.stringify(updates),
3475
- });
3800
+ initPlugins() {
3801
+ const pluginsToLoad = this.config.plugins;
3802
+ // Skip pageView plugin if autoPageView is disabled
3803
+ const filteredPlugins = this.config.autoPageView
3804
+ ? pluginsToLoad
3805
+ : pluginsToLoad.filter((p) => p !== 'pageView');
3806
+ for (const pluginName of filteredPlugins) {
3807
+ try {
3808
+ const plugin = getPlugin(pluginName);
3809
+ // Handle both sync and async init (fire-and-forget for async)
3810
+ const result = plugin.init(this);
3811
+ if (result instanceof Promise) {
3812
+ result.catch((error) => {
3813
+ logger.error(`Async plugin init failed: ${pluginName}`, error);
3814
+ });
3815
+ }
3816
+ this.plugins.push(plugin);
3817
+ logger.debug(`Plugin loaded: ${pluginName}`);
3818
+ }
3819
+ catch (error) {
3820
+ logger.error(`Failed to load plugin: ${pluginName}`, error);
3821
+ }
3822
+ }
3476
3823
  }
3477
3824
  /**
3478
- * Mark a task as completed
3825
+ * Track a custom event
3479
3826
  */
3480
- async completeTask(taskId) {
3481
- return this.request(`/api/workspaces/${this.workspaceId}/tasks/${taskId}/complete`, {
3482
- method: 'PATCH',
3483
- });
3827
+ track(eventType, eventName, properties = {}) {
3828
+ if (!this.isInitialized) {
3829
+ logger.warn('SDK not initialized, event dropped');
3830
+ return;
3831
+ }
3832
+ const event = {
3833
+ workspaceId: this.workspaceId,
3834
+ visitorId: this.visitorId,
3835
+ sessionId: this.sessionId,
3836
+ eventType: eventType,
3837
+ eventName,
3838
+ url: typeof window !== 'undefined' ? window.location.href : '',
3839
+ referrer: typeof document !== 'undefined' ? document.referrer || undefined : undefined,
3840
+ properties: {
3841
+ ...properties,
3842
+ eventId: generateUUID(), // Unique ID for deduplication on retry
3843
+ websiteDomain: typeof window !== 'undefined' ? window.location.hostname : undefined,
3844
+ },
3845
+ device: getDeviceInfo(),
3846
+ ...getUTMParams(),
3847
+ timestamp: new Date().toISOString(),
3848
+ sdkVersion: SDK_VERSION,
3849
+ };
3850
+ // Attach contactId if known (from a prior identify() call)
3851
+ if (this.contactId) {
3852
+ event.contactId = this.contactId;
3853
+ }
3854
+ // Validate event against registered schema (debug mode only)
3855
+ this.validateEventSchema(eventType, properties);
3856
+ // Check consent before tracking
3857
+ if (!this.consentManager.canTrack()) {
3858
+ // Buffer event for later if waitForConsent is enabled
3859
+ if (this.config.consent.waitForConsent) {
3860
+ this.consentManager.bufferEvent(event);
3861
+ return;
3862
+ }
3863
+ // Otherwise drop the event
3864
+ logger.debug('Event dropped (no consent):', eventName);
3865
+ return;
3866
+ }
3867
+ this.queue.push(event);
3868
+ logger.debug('Event tracked:', eventName, properties);
3484
3869
  }
3485
3870
  /**
3486
- * Delete a task
3871
+ * Track a page view
3487
3872
  */
3488
- async deleteTask(taskId) {
3489
- return this.request(`/api/workspaces/${this.workspaceId}/tasks/${taskId}`, {
3490
- method: 'DELETE',
3873
+ page(name, properties = {}) {
3874
+ const pageName = name || (typeof document !== 'undefined' ? document.title : 'Page View');
3875
+ this.track('page_view', pageName, {
3876
+ ...properties,
3877
+ path: typeof window !== 'undefined' ? window.location.pathname : '',
3491
3878
  });
3492
3879
  }
3493
- // ============================================
3494
- // ACTIVITIES API
3495
- // ============================================
3496
3880
  /**
3497
- * Get activities for a contact
3881
+ * Identify a visitor.
3882
+ * Links the anonymous visitorId to a CRM contact and returns the contactId.
3883
+ * All subsequent track() calls will include the contactId automatically.
3498
3884
  */
3499
- async getContactActivities(contactId, params) {
3500
- const queryParams = new URLSearchParams();
3501
- if (params?.page)
3502
- queryParams.set('page', params.page.toString());
3503
- if (params?.limit)
3504
- queryParams.set('limit', params.limit.toString());
3505
- if (params?.type)
3506
- queryParams.set('type', params.type);
3507
- const query = queryParams.toString();
3508
- const endpoint = `/api/workspaces/${this.workspaceId}/contacts/${contactId}/activities${query ? `?${query}` : ''}`;
3509
- return this.request(endpoint);
3885
+ async identify(email, traits = {}) {
3886
+ if (!email) {
3887
+ logger.warn('Email is required for identification');
3888
+ return null;
3889
+ }
3890
+ if (!isValidEmail(email)) {
3891
+ logger.warn('Invalid email format, identification skipped:', email);
3892
+ return null;
3893
+ }
3894
+ logger.info('Identifying visitor:', email);
3895
+ const result = await this.transport.sendIdentify({
3896
+ workspaceId: this.workspaceId,
3897
+ visitorId: this.visitorId,
3898
+ email,
3899
+ properties: traits,
3900
+ });
3901
+ if (result.success) {
3902
+ logger.info('Visitor identified successfully, contactId:', result.contactId);
3903
+ // Store contactId so all future track() calls include it
3904
+ this.contactId = result.contactId ?? null;
3905
+ this.pendingIdentify = null;
3906
+ return this.contactId;
3907
+ }
3908
+ else {
3909
+ logger.error('Failed to identify visitor:', result.error);
3910
+ // Store for retry on next flush
3911
+ this.pendingIdentify = { email, traits };
3912
+ return null;
3913
+ }
3510
3914
  }
3511
3915
  /**
3512
- * Get activities for an opportunity/deal
3916
+ * Send a server-side inbound event via the API key endpoint.
3917
+ * Convenience proxy to CRMClient.sendEvent() — requires apiKey in config.
3513
3918
  */
3514
- async getOpportunityActivities(opportunityId, params) {
3515
- const queryParams = new URLSearchParams();
3516
- if (params?.page)
3517
- queryParams.set('page', params.page.toString());
3518
- if (params?.limit)
3519
- queryParams.set('limit', params.limit.toString());
3520
- if (params?.type)
3521
- queryParams.set('type', params.type);
3522
- const query = queryParams.toString();
3523
- const endpoint = `/api/workspaces/${this.workspaceId}/opportunities/${opportunityId}/activities${query ? `?${query}` : ''}`;
3524
- return this.request(endpoint);
3919
+ async sendEvent(payload) {
3920
+ const apiKey = this.config.apiKey;
3921
+ if (!apiKey) {
3922
+ logger.error('sendEvent() requires an apiKey in the SDK config');
3923
+ return { success: false, contactCreated: false, event: payload.event, error: 'No API key configured' };
3924
+ }
3925
+ const client = new CRMClient(this.config.apiEndpoint, this.workspaceId, undefined, apiKey);
3926
+ return client.sendEvent(payload);
3525
3927
  }
3526
3928
  /**
3527
- * Create a new activity
3929
+ * Get the current visitor's profile from the CRM.
3930
+ * Returns visitor data and linked contact info if identified.
3931
+ * Only returns data for the current visitor (privacy-safe for frontend).
3528
3932
  */
3529
- async createActivity(activity) {
3530
- // Determine the correct endpoint based on related entity
3531
- let endpoint;
3532
- if (activity.opportunityId) {
3533
- endpoint = `/api/workspaces/${this.workspaceId}/opportunities/${activity.opportunityId}/activities`;
3933
+ async getVisitorProfile() {
3934
+ if (!this.isInitialized) {
3935
+ logger.warn('SDK not initialized');
3936
+ return null;
3534
3937
  }
3535
- else if (activity.contactId) {
3536
- endpoint = `/api/workspaces/${this.workspaceId}/contacts/${activity.contactId}/activities`;
3938
+ const result = await this.transport.fetchData(`/api/public/track/visitor/${this.workspaceId}/${this.visitorId}/profile`);
3939
+ if (result.success && result.data) {
3940
+ logger.debug('Visitor profile fetched:', result.data);
3941
+ return result.data;
3537
3942
  }
3538
- else {
3539
- endpoint = `/api/workspaces/${this.workspaceId}/activities`;
3943
+ logger.warn('Failed to fetch visitor profile:', result.error);
3944
+ return null;
3945
+ }
3946
+ /**
3947
+ * Get the current visitor's recent activity/events.
3948
+ * Returns paginated list of tracking events for this visitor.
3949
+ */
3950
+ async getVisitorActivity(options) {
3951
+ if (!this.isInitialized) {
3952
+ logger.warn('SDK not initialized');
3953
+ return null;
3540
3954
  }
3541
- return this.request(endpoint, {
3542
- method: 'POST',
3543
- body: JSON.stringify(activity),
3544
- });
3955
+ const params = {};
3956
+ if (options?.page)
3957
+ params.page = options.page.toString();
3958
+ if (options?.limit)
3959
+ params.limit = options.limit.toString();
3960
+ if (options?.eventType)
3961
+ params.eventType = options.eventType;
3962
+ if (options?.startDate)
3963
+ params.startDate = options.startDate;
3964
+ if (options?.endDate)
3965
+ params.endDate = options.endDate;
3966
+ const result = await this.transport.fetchData(`/api/public/track/visitor/${this.workspaceId}/${this.visitorId}/activity`, params);
3967
+ if (result.success && result.data) {
3968
+ return result.data;
3969
+ }
3970
+ logger.warn('Failed to fetch visitor activity:', result.error);
3971
+ return null;
3545
3972
  }
3546
3973
  /**
3547
- * Update an existing activity
3974
+ * Get a summarized journey timeline for the current visitor.
3975
+ * Includes top pages, sessions, time spent, and recent activities.
3548
3976
  */
3549
- async updateActivity(activityId, updates) {
3550
- return this.request(`/api/workspaces/${this.workspaceId}/activities/${activityId}`, {
3551
- method: 'PATCH',
3552
- body: JSON.stringify(updates),
3553
- });
3977
+ async getVisitorTimeline() {
3978
+ if (!this.isInitialized) {
3979
+ logger.warn('SDK not initialized');
3980
+ return null;
3981
+ }
3982
+ const result = await this.transport.fetchData(`/api/public/track/visitor/${this.workspaceId}/${this.visitorId}/timeline`);
3983
+ if (result.success && result.data) {
3984
+ return result.data;
3985
+ }
3986
+ logger.warn('Failed to fetch visitor timeline:', result.error);
3987
+ return null;
3554
3988
  }
3555
3989
  /**
3556
- * Delete an activity
3990
+ * Get engagement metrics for the current visitor.
3991
+ * Includes time on site, page views, bounce rate, and engagement score.
3557
3992
  */
3558
- async deleteActivity(activityId) {
3559
- return this.request(`/api/workspaces/${this.workspaceId}/activities/${activityId}`, {
3560
- method: 'DELETE',
3561
- });
3993
+ async getVisitorEngagement() {
3994
+ if (!this.isInitialized) {
3995
+ logger.warn('SDK not initialized');
3996
+ return null;
3997
+ }
3998
+ const result = await this.transport.fetchData(`/api/public/track/visitor/${this.workspaceId}/${this.visitorId}/engagement`);
3999
+ if (result.success && result.data) {
4000
+ return result.data;
4001
+ }
4002
+ logger.warn('Failed to fetch visitor engagement:', result.error);
4003
+ return null;
3562
4004
  }
3563
4005
  /**
3564
- * Log a call activity
4006
+ * Retry pending identify call
3565
4007
  */
3566
- async logCall(data) {
3567
- return this.createActivity({
3568
- type: 'call',
3569
- title: `${data.direction === 'inbound' ? 'Inbound' : 'Outbound'} Call`,
3570
- direction: data.direction,
3571
- duration: data.duration,
3572
- outcome: data.outcome,
3573
- description: data.notes,
3574
- contactId: data.contactId,
3575
- opportunityId: data.opportunityId,
3576
- });
4008
+ async retryPendingIdentify() {
4009
+ if (!this.pendingIdentify)
4010
+ return;
4011
+ const { email, traits } = this.pendingIdentify;
4012
+ this.pendingIdentify = null;
4013
+ await this.identify(email, traits);
3577
4014
  }
3578
4015
  /**
3579
- * Log a meeting activity
4016
+ * Update consent state
3580
4017
  */
3581
- async logMeeting(data) {
3582
- return this.createActivity({
3583
- type: 'meeting',
3584
- title: data.title,
3585
- duration: data.duration,
3586
- outcome: data.outcome,
3587
- description: data.notes,
3588
- contactId: data.contactId,
3589
- opportunityId: data.opportunityId,
3590
- });
4018
+ consent(state) {
4019
+ this.consentManager.update(state);
3591
4020
  }
3592
4021
  /**
3593
- * Add a note to a contact or opportunity
4022
+ * Get current consent state
3594
4023
  */
3595
- async addNote(data) {
3596
- return this.createActivity({
3597
- type: 'note',
3598
- title: 'Note',
3599
- description: data.content,
3600
- contactId: data.contactId,
3601
- opportunityId: data.opportunityId,
3602
- });
4024
+ getConsentState() {
4025
+ return this.consentManager.getState();
3603
4026
  }
3604
- // ============================================
3605
- // EMAIL TEMPLATES API
3606
- // ============================================
3607
4027
  /**
3608
- * Get all email templates
4028
+ * Toggle debug mode
3609
4029
  */
3610
- async getEmailTemplates(params) {
3611
- const queryParams = new URLSearchParams();
3612
- if (params?.page)
3613
- queryParams.set('page', params.page.toString());
3614
- if (params?.limit)
3615
- queryParams.set('limit', params.limit.toString());
3616
- const query = queryParams.toString();
3617
- const endpoint = `/api/workspaces/${this.workspaceId}/email-templates${query ? `?${query}` : ''}`;
3618
- return this.request(endpoint);
4030
+ debug(enabled) {
4031
+ logger.enabled = enabled;
4032
+ logger.info(`Debug mode ${enabled ? 'enabled' : 'disabled'}`);
3619
4033
  }
3620
4034
  /**
3621
- * Get a single email template by ID
4035
+ * Register a schema for event validation.
4036
+ * When debug mode is enabled, events will be validated against registered schemas.
4037
+ *
4038
+ * @example
4039
+ * tracker.registerEventSchema('purchase', {
4040
+ * productId: 'string',
4041
+ * price: 'number',
4042
+ * quantity: 'number',
4043
+ * });
3622
4044
  */
3623
- async getEmailTemplate(templateId) {
3624
- return this.request(`/api/workspaces/${this.workspaceId}/email-templates/${templateId}`);
4045
+ registerEventSchema(eventType, schema) {
4046
+ this.eventSchemas.set(eventType, schema);
4047
+ logger.debug('Event schema registered:', eventType);
3625
4048
  }
3626
4049
  /**
3627
- * Create a new email template
4050
+ * Validate event properties against a registered schema (debug mode only)
3628
4051
  */
3629
- async createEmailTemplate(template) {
3630
- return this.request(`/api/workspaces/${this.workspaceId}/email-templates`, {
3631
- method: 'POST',
3632
- body: JSON.stringify(template),
3633
- });
4052
+ validateEventSchema(eventType, properties) {
4053
+ if (!this.config.debug)
4054
+ return;
4055
+ const schema = this.eventSchemas.get(eventType);
4056
+ if (!schema)
4057
+ return;
4058
+ for (const [key, expectedType] of Object.entries(schema)) {
4059
+ const value = properties[key];
4060
+ if (value === undefined) {
4061
+ logger.warn(`[Schema] Missing property "${key}" for event type "${eventType}"`);
4062
+ continue;
4063
+ }
4064
+ let valid = false;
4065
+ switch (expectedType) {
4066
+ case 'string':
4067
+ valid = typeof value === 'string';
4068
+ break;
4069
+ case 'number':
4070
+ valid = typeof value === 'number';
4071
+ break;
4072
+ case 'boolean':
4073
+ valid = typeof value === 'boolean';
4074
+ break;
4075
+ case 'object':
4076
+ valid = typeof value === 'object' && !Array.isArray(value);
4077
+ break;
4078
+ case 'array':
4079
+ valid = Array.isArray(value);
4080
+ break;
4081
+ }
4082
+ if (!valid) {
4083
+ logger.warn(`[Schema] Property "${key}" for event "${eventType}" expected ${expectedType}, got ${typeof value}`);
4084
+ }
4085
+ }
3634
4086
  }
3635
4087
  /**
3636
- * Update an email template
4088
+ * Get visitor ID
3637
4089
  */
3638
- async updateEmailTemplate(templateId, updates) {
3639
- return this.request(`/api/workspaces/${this.workspaceId}/email-templates/${templateId}`, {
3640
- method: 'PUT',
3641
- body: JSON.stringify(updates),
3642
- });
4090
+ getVisitorId() {
4091
+ return this.visitorId;
3643
4092
  }
3644
4093
  /**
3645
- * Delete an email template
4094
+ * Get session ID
3646
4095
  */
3647
- async deleteEmailTemplate(templateId) {
3648
- return this.request(`/api/workspaces/${this.workspaceId}/email-templates/${templateId}`, {
3649
- method: 'DELETE',
3650
- });
4096
+ getSessionId() {
4097
+ return this.sessionId;
3651
4098
  }
3652
4099
  /**
3653
- * Send an email using a template
4100
+ * Get workspace ID
3654
4101
  */
3655
- async sendEmail(data) {
3656
- return this.request(`/api/workspaces/${this.workspaceId}/emails/send`, {
3657
- method: 'POST',
3658
- body: JSON.stringify(data),
3659
- });
4102
+ getWorkspaceId() {
4103
+ return this.workspaceId;
3660
4104
  }
3661
- // ============================================
3662
- // EVENT TRIGGERS API (delegated to triggers manager)
3663
- // ============================================
3664
4105
  /**
3665
- * Get all event triggers
4106
+ * Get current configuration
3666
4107
  */
3667
- async getEventTriggers() {
3668
- return this.triggers.getTriggers();
4108
+ getConfig() {
4109
+ return { ...this.config };
3669
4110
  }
3670
4111
  /**
3671
- * Create a new event trigger
4112
+ * Force flush event queue
3672
4113
  */
3673
- async createEventTrigger(trigger) {
3674
- return this.triggers.createTrigger(trigger);
4114
+ async flush() {
4115
+ await this.retryPendingIdentify();
4116
+ await this.queue.flush();
3675
4117
  }
3676
4118
  /**
3677
- * Update an event trigger
4119
+ * Reset visitor and session (for logout)
3678
4120
  */
3679
- async updateEventTrigger(triggerId, updates) {
3680
- return this.triggers.updateTrigger(triggerId, updates);
4121
+ reset() {
4122
+ logger.info('Resetting visitor data');
4123
+ resetIds(this.config.useCookies);
4124
+ this.visitorId = this.createVisitorId();
4125
+ this.sessionId = this.createSessionId();
4126
+ this.contactId = null;
4127
+ this.pendingIdentify = null;
4128
+ this.queue.clear();
3681
4129
  }
3682
4130
  /**
3683
- * Delete an event trigger
4131
+ * Delete all stored user data (GDPR right-to-erasure)
3684
4132
  */
3685
- async deleteEventTrigger(triggerId) {
3686
- return this.triggers.deleteTrigger(triggerId);
4133
+ deleteData() {
4134
+ logger.info('Deleting all user data (GDPR request)');
4135
+ // Clear queue
4136
+ this.queue.clear();
4137
+ // Reset consent
4138
+ this.consentManager.reset();
4139
+ // Clear all stored IDs
4140
+ resetIds(this.config.useCookies);
4141
+ // Clear session storage items
4142
+ if (typeof sessionStorage !== 'undefined') {
4143
+ try {
4144
+ sessionStorage.removeItem(STORAGE_KEYS.VISITOR_ID);
4145
+ sessionStorage.removeItem(STORAGE_KEYS.VISITOR_ID + '_anon');
4146
+ sessionStorage.removeItem(STORAGE_KEYS.SESSION_ID);
4147
+ sessionStorage.removeItem(STORAGE_KEYS.SESSION_TIMESTAMP);
4148
+ }
4149
+ catch {
4150
+ // Ignore errors
4151
+ }
4152
+ }
4153
+ // Clear localStorage items
4154
+ if (typeof localStorage !== 'undefined') {
4155
+ try {
4156
+ localStorage.removeItem(STORAGE_KEYS.VISITOR_ID);
4157
+ localStorage.removeItem(STORAGE_KEYS.CONSENT);
4158
+ localStorage.removeItem(STORAGE_KEYS.EVENT_QUEUE);
4159
+ }
4160
+ catch {
4161
+ // Ignore errors
4162
+ }
4163
+ }
4164
+ // Generate new IDs
4165
+ this.visitorId = this.createVisitorId();
4166
+ this.sessionId = this.createSessionId();
4167
+ logger.info('All user data deleted');
4168
+ }
4169
+ /**
4170
+ * Destroy tracker and cleanup
4171
+ */
4172
+ async destroy() {
4173
+ logger.info('Destroying tracker');
4174
+ // Flush any remaining events (await to ensure completion)
4175
+ await this.queue.flush();
4176
+ // Destroy plugins
4177
+ for (const plugin of this.plugins) {
4178
+ if (plugin.destroy) {
4179
+ plugin.destroy();
4180
+ }
4181
+ }
4182
+ this.plugins = [];
4183
+ // Destroy queue
4184
+ this.queue.destroy();
4185
+ this.isInitialized = false;
3687
4186
  }
3688
4187
  }
3689
4188