@clianta/sdk 1.4.0 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/README.md +56 -1
- package/dist/angular.cjs.js +4432 -0
- package/dist/angular.cjs.js.map +1 -0
- package/dist/angular.d.ts +364 -0
- package/dist/angular.esm.js +4428 -0
- package/dist/angular.esm.js.map +1 -0
- package/dist/clianta.cjs.js +557 -99
- package/dist/clianta.cjs.js.map +1 -1
- package/dist/clianta.esm.js +557 -99
- package/dist/clianta.esm.js.map +1 -1
- package/dist/clianta.umd.js +557 -99
- package/dist/clianta.umd.js.map +1 -1
- package/dist/clianta.umd.min.js +2 -2
- package/dist/clianta.umd.min.js.map +1 -1
- package/dist/index.d.ts +315 -7
- package/dist/react.cjs.js +571 -105
- package/dist/react.cjs.js.map +1 -1
- package/dist/react.d.ts +155 -1
- package/dist/react.esm.js +572 -106
- package/dist/react.esm.js.map +1 -1
- package/dist/svelte.cjs.js +4464 -0
- package/dist/svelte.cjs.js.map +1 -0
- package/dist/svelte.d.ts +374 -0
- package/dist/svelte.esm.js +4461 -0
- package/dist/svelte.esm.js.map +1 -0
- package/dist/vue.cjs.js +558 -100
- package/dist/vue.cjs.js.map +1 -1
- package/dist/vue.d.ts +155 -1
- package/dist/vue.esm.js +558 -100
- package/dist/vue.esm.js.map +1 -1
- package/package.json +21 -2
package/dist/react.esm.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* Clianta SDK v1.
|
|
2
|
+
* Clianta SDK v1.5.1
|
|
3
3
|
* (c) 2026 Clianta
|
|
4
4
|
* Released under the MIT License.
|
|
5
5
|
*/
|
|
6
6
|
import { jsx } from 'react/jsx-runtime';
|
|
7
|
-
import { createContext, useRef, useEffect, useContext } from 'react';
|
|
7
|
+
import { createContext, useState, useRef, useEffect, useContext } from 'react';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Clianta SDK - Configuration
|
|
@@ -12,15 +12,22 @@ import { createContext, useRef, useEffect, useContext } from 'react';
|
|
|
12
12
|
*/
|
|
13
13
|
/** SDK Version */
|
|
14
14
|
const SDK_VERSION = '1.4.0';
|
|
15
|
-
/** Default API endpoint
|
|
15
|
+
/** Default API endpoint — reads from env or falls back to localhost */
|
|
16
16
|
const getDefaultApiEndpoint = () => {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
17
|
+
// Build-time env var (works with Next.js, Vite, CRA, etc.)
|
|
18
|
+
if (typeof process !== 'undefined' && process.env?.NEXT_PUBLIC_CLIANTA_API_ENDPOINT) {
|
|
19
|
+
return process.env.NEXT_PUBLIC_CLIANTA_API_ENDPOINT;
|
|
20
|
+
}
|
|
21
|
+
if (typeof process !== 'undefined' && process.env?.VITE_CLIANTA_API_ENDPOINT) {
|
|
22
|
+
return process.env.VITE_CLIANTA_API_ENDPOINT;
|
|
23
|
+
}
|
|
24
|
+
if (typeof process !== 'undefined' && process.env?.REACT_APP_CLIANTA_API_ENDPOINT) {
|
|
25
|
+
return process.env.REACT_APP_CLIANTA_API_ENDPOINT;
|
|
22
26
|
}
|
|
23
|
-
|
|
27
|
+
if (typeof process !== 'undefined' && process.env?.CLIANTA_API_ENDPOINT) {
|
|
28
|
+
return process.env.CLIANTA_API_ENDPOINT;
|
|
29
|
+
}
|
|
30
|
+
return 'http://localhost:5000';
|
|
24
31
|
};
|
|
25
32
|
/** Core plugins enabled by default */
|
|
26
33
|
const DEFAULT_PLUGINS = [
|
|
@@ -53,6 +60,7 @@ const DEFAULT_CONFIG = {
|
|
|
53
60
|
cookieDomain: '',
|
|
54
61
|
useCookies: false,
|
|
55
62
|
cookielessMode: false,
|
|
63
|
+
persistMode: 'session',
|
|
56
64
|
};
|
|
57
65
|
/** Storage keys */
|
|
58
66
|
const STORAGE_KEYS = {
|
|
@@ -246,6 +254,39 @@ class Transport {
|
|
|
246
254
|
return false;
|
|
247
255
|
}
|
|
248
256
|
}
|
|
257
|
+
/**
|
|
258
|
+
* Fetch data from the tracking API (GET request)
|
|
259
|
+
* Used for read-back APIs (visitor profile, activity, etc.)
|
|
260
|
+
*/
|
|
261
|
+
async fetchData(path, params) {
|
|
262
|
+
const url = new URL(`${this.config.apiEndpoint}${path}`);
|
|
263
|
+
if (params) {
|
|
264
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
265
|
+
if (value !== undefined && value !== null) {
|
|
266
|
+
url.searchParams.set(key, value);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
try {
|
|
271
|
+
const response = await this.fetchWithTimeout(url.toString(), {
|
|
272
|
+
method: 'GET',
|
|
273
|
+
headers: {
|
|
274
|
+
'Accept': 'application/json',
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
if (response.ok) {
|
|
278
|
+
const body = await response.json();
|
|
279
|
+
logger.debug('Fetch successful:', path);
|
|
280
|
+
return { success: true, data: body.data ?? body, status: response.status };
|
|
281
|
+
}
|
|
282
|
+
logger.error(`Fetch failed with status ${response.status}`);
|
|
283
|
+
return { success: false, status: response.status };
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
logger.error('Fetch request failed:', error);
|
|
287
|
+
return { success: false, error: error };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
249
290
|
/**
|
|
250
291
|
* Internal send with retry logic
|
|
251
292
|
*/
|
|
@@ -410,7 +451,9 @@ function cookie(name, value, days) {
|
|
|
410
451
|
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
|
|
411
452
|
expires = '; expires=' + date.toUTCString();
|
|
412
453
|
}
|
|
413
|
-
|
|
454
|
+
// Add Secure flag on HTTPS to prevent cookie leakage over plaintext
|
|
455
|
+
const secure = typeof location !== 'undefined' && location.protocol === 'https:' ? '; Secure' : '';
|
|
456
|
+
document.cookie = name + '=' + value + expires + '; path=/; SameSite=Lax' + secure;
|
|
414
457
|
return value;
|
|
415
458
|
}
|
|
416
459
|
// ============================================
|
|
@@ -574,6 +617,17 @@ function isMobile() {
|
|
|
574
617
|
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
|
575
618
|
}
|
|
576
619
|
// ============================================
|
|
620
|
+
// VALIDATION UTILITIES
|
|
621
|
+
// ============================================
|
|
622
|
+
/**
|
|
623
|
+
* Validate email format
|
|
624
|
+
*/
|
|
625
|
+
function isValidEmail(email) {
|
|
626
|
+
if (typeof email !== 'string' || !email)
|
|
627
|
+
return false;
|
|
628
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
|
629
|
+
}
|
|
630
|
+
// ============================================
|
|
577
631
|
// DEVICE INFO
|
|
578
632
|
// ============================================
|
|
579
633
|
/**
|
|
@@ -627,6 +681,7 @@ class EventQueue {
|
|
|
627
681
|
maxQueueSize: config.maxQueueSize ?? MAX_QUEUE_SIZE,
|
|
628
682
|
storageKey: config.storageKey ?? STORAGE_KEYS.EVENT_QUEUE,
|
|
629
683
|
};
|
|
684
|
+
this.persistMode = config.persistMode || 'session';
|
|
630
685
|
// Restore persisted queue
|
|
631
686
|
this.restoreQueue();
|
|
632
687
|
// Start auto-flush timer
|
|
@@ -732,6 +787,13 @@ class EventQueue {
|
|
|
732
787
|
clear() {
|
|
733
788
|
this.queue = [];
|
|
734
789
|
this.persistQueue([]);
|
|
790
|
+
// Also clear localStorage if used
|
|
791
|
+
if (this.persistMode === 'local' && typeof localStorage !== 'undefined') {
|
|
792
|
+
try {
|
|
793
|
+
localStorage.removeItem(this.config.storageKey);
|
|
794
|
+
}
|
|
795
|
+
catch { /* ignore */ }
|
|
796
|
+
}
|
|
735
797
|
}
|
|
736
798
|
/**
|
|
737
799
|
* Stop the flush timer and cleanup handlers
|
|
@@ -786,22 +848,44 @@ class EventQueue {
|
|
|
786
848
|
window.addEventListener('pagehide', this.boundPageHide);
|
|
787
849
|
}
|
|
788
850
|
/**
|
|
789
|
-
* Persist queue to
|
|
851
|
+
* Persist queue to storage based on persistMode
|
|
790
852
|
*/
|
|
791
853
|
persistQueue(events) {
|
|
854
|
+
if (this.persistMode === 'none')
|
|
855
|
+
return;
|
|
792
856
|
try {
|
|
793
|
-
|
|
857
|
+
const serialized = JSON.stringify(events);
|
|
858
|
+
if (this.persistMode === 'local' && typeof localStorage !== 'undefined') {
|
|
859
|
+
try {
|
|
860
|
+
localStorage.setItem(this.config.storageKey, serialized);
|
|
861
|
+
}
|
|
862
|
+
catch {
|
|
863
|
+
// localStorage quota exceeded — fallback to sessionStorage
|
|
864
|
+
setSessionStorage(this.config.storageKey, serialized);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
else {
|
|
868
|
+
setSessionStorage(this.config.storageKey, serialized);
|
|
869
|
+
}
|
|
794
870
|
}
|
|
795
871
|
catch {
|
|
796
872
|
// Ignore storage errors
|
|
797
873
|
}
|
|
798
874
|
}
|
|
799
875
|
/**
|
|
800
|
-
* Restore queue from
|
|
876
|
+
* Restore queue from storage
|
|
801
877
|
*/
|
|
802
878
|
restoreQueue() {
|
|
803
879
|
try {
|
|
804
|
-
|
|
880
|
+
let stored = null;
|
|
881
|
+
// Check localStorage first (cross-session persistence)
|
|
882
|
+
if (this.persistMode === 'local' && typeof localStorage !== 'undefined') {
|
|
883
|
+
stored = localStorage.getItem(this.config.storageKey);
|
|
884
|
+
}
|
|
885
|
+
// Fall back to sessionStorage
|
|
886
|
+
if (!stored) {
|
|
887
|
+
stored = getSessionStorage(this.config.storageKey);
|
|
888
|
+
}
|
|
805
889
|
if (stored) {
|
|
806
890
|
const events = JSON.parse(stored);
|
|
807
891
|
if (Array.isArray(events) && events.length > 0) {
|
|
@@ -869,10 +953,13 @@ class PageViewPlugin extends BasePlugin {
|
|
|
869
953
|
history.pushState = function (...args) {
|
|
870
954
|
self.originalPushState.apply(history, args);
|
|
871
955
|
self.trackPageView();
|
|
956
|
+
// Notify other plugins (e.g. ScrollPlugin) about navigation
|
|
957
|
+
window.dispatchEvent(new Event('clianta:navigation'));
|
|
872
958
|
};
|
|
873
959
|
history.replaceState = function (...args) {
|
|
874
960
|
self.originalReplaceState.apply(history, args);
|
|
875
961
|
self.trackPageView();
|
|
962
|
+
window.dispatchEvent(new Event('clianta:navigation'));
|
|
876
963
|
};
|
|
877
964
|
// Handle back/forward navigation
|
|
878
965
|
this.popstateHandler = () => this.trackPageView();
|
|
@@ -926,9 +1013,8 @@ class ScrollPlugin extends BasePlugin {
|
|
|
926
1013
|
this.pageLoadTime = 0;
|
|
927
1014
|
this.scrollTimeout = null;
|
|
928
1015
|
this.boundHandler = null;
|
|
929
|
-
/** SPA navigation
|
|
930
|
-
this.
|
|
931
|
-
this.originalReplaceState = null;
|
|
1016
|
+
/** SPA navigation — listen for PageViewPlugin's custom event instead of patching history */
|
|
1017
|
+
this.navigationHandler = null;
|
|
932
1018
|
this.popstateHandler = null;
|
|
933
1019
|
}
|
|
934
1020
|
init(tracker) {
|
|
@@ -937,8 +1023,13 @@ class ScrollPlugin extends BasePlugin {
|
|
|
937
1023
|
if (typeof window !== 'undefined') {
|
|
938
1024
|
this.boundHandler = this.handleScroll.bind(this);
|
|
939
1025
|
window.addEventListener('scroll', this.boundHandler, { passive: true });
|
|
940
|
-
//
|
|
941
|
-
|
|
1026
|
+
// Listen for navigation events dispatched by PageViewPlugin
|
|
1027
|
+
// instead of independently monkey-patching history.pushState
|
|
1028
|
+
this.navigationHandler = () => this.resetForNavigation();
|
|
1029
|
+
window.addEventListener('clianta:navigation', this.navigationHandler);
|
|
1030
|
+
// Handle back/forward navigation
|
|
1031
|
+
this.popstateHandler = () => this.resetForNavigation();
|
|
1032
|
+
window.addEventListener('popstate', this.popstateHandler);
|
|
942
1033
|
}
|
|
943
1034
|
}
|
|
944
1035
|
destroy() {
|
|
@@ -948,16 +1039,10 @@ class ScrollPlugin extends BasePlugin {
|
|
|
948
1039
|
if (this.scrollTimeout) {
|
|
949
1040
|
clearTimeout(this.scrollTimeout);
|
|
950
1041
|
}
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
this.originalPushState = null;
|
|
955
|
-
}
|
|
956
|
-
if (this.originalReplaceState) {
|
|
957
|
-
history.replaceState = this.originalReplaceState;
|
|
958
|
-
this.originalReplaceState = null;
|
|
1042
|
+
if (this.navigationHandler && typeof window !== 'undefined') {
|
|
1043
|
+
window.removeEventListener('clianta:navigation', this.navigationHandler);
|
|
1044
|
+
this.navigationHandler = null;
|
|
959
1045
|
}
|
|
960
|
-
// Remove popstate listener
|
|
961
1046
|
if (this.popstateHandler && typeof window !== 'undefined') {
|
|
962
1047
|
window.removeEventListener('popstate', this.popstateHandler);
|
|
963
1048
|
this.popstateHandler = null;
|
|
@@ -972,29 +1057,6 @@ class ScrollPlugin extends BasePlugin {
|
|
|
972
1057
|
this.maxScrollDepth = 0;
|
|
973
1058
|
this.pageLoadTime = Date.now();
|
|
974
1059
|
}
|
|
975
|
-
/**
|
|
976
|
-
* Setup History API interception for SPA navigation
|
|
977
|
-
*/
|
|
978
|
-
setupNavigationReset() {
|
|
979
|
-
if (typeof window === 'undefined')
|
|
980
|
-
return;
|
|
981
|
-
// Store originals for cleanup
|
|
982
|
-
this.originalPushState = history.pushState;
|
|
983
|
-
this.originalReplaceState = history.replaceState;
|
|
984
|
-
// Intercept pushState and replaceState
|
|
985
|
-
const self = this;
|
|
986
|
-
history.pushState = function (...args) {
|
|
987
|
-
self.originalPushState.apply(history, args);
|
|
988
|
-
self.resetForNavigation();
|
|
989
|
-
};
|
|
990
|
-
history.replaceState = function (...args) {
|
|
991
|
-
self.originalReplaceState.apply(history, args);
|
|
992
|
-
self.resetForNavigation();
|
|
993
|
-
};
|
|
994
|
-
// Handle back/forward navigation
|
|
995
|
-
this.popstateHandler = () => this.resetForNavigation();
|
|
996
|
-
window.addEventListener('popstate', this.popstateHandler);
|
|
997
|
-
}
|
|
998
1060
|
handleScroll() {
|
|
999
1061
|
// Debounce scroll tracking
|
|
1000
1062
|
if (this.scrollTimeout) {
|
|
@@ -1194,6 +1256,10 @@ class ClicksPlugin extends BasePlugin {
|
|
|
1194
1256
|
elementId: elementInfo.id,
|
|
1195
1257
|
elementClass: elementInfo.className,
|
|
1196
1258
|
href: target.href || undefined,
|
|
1259
|
+
x: Math.round((e.clientX / window.innerWidth) * 100),
|
|
1260
|
+
y: Math.round((e.clientY / window.innerHeight) * 100),
|
|
1261
|
+
viewportWidth: window.innerWidth,
|
|
1262
|
+
viewportHeight: window.innerHeight,
|
|
1197
1263
|
});
|
|
1198
1264
|
}
|
|
1199
1265
|
}
|
|
@@ -1216,6 +1282,9 @@ class EngagementPlugin extends BasePlugin {
|
|
|
1216
1282
|
this.boundMarkEngaged = null;
|
|
1217
1283
|
this.boundTrackTimeOnPage = null;
|
|
1218
1284
|
this.boundVisibilityHandler = null;
|
|
1285
|
+
/** SPA navigation — listen for PageViewPlugin's custom event instead of patching history */
|
|
1286
|
+
this.navigationHandler = null;
|
|
1287
|
+
this.popstateHandler = null;
|
|
1219
1288
|
}
|
|
1220
1289
|
init(tracker) {
|
|
1221
1290
|
super.init(tracker);
|
|
@@ -1241,6 +1310,13 @@ class EngagementPlugin extends BasePlugin {
|
|
|
1241
1310
|
// Track time on page before unload
|
|
1242
1311
|
window.addEventListener('beforeunload', this.boundTrackTimeOnPage);
|
|
1243
1312
|
document.addEventListener('visibilitychange', this.boundVisibilityHandler);
|
|
1313
|
+
// Listen for navigation events dispatched by PageViewPlugin
|
|
1314
|
+
// instead of independently monkey-patching history.pushState
|
|
1315
|
+
this.navigationHandler = () => this.resetForNavigation();
|
|
1316
|
+
window.addEventListener('clianta:navigation', this.navigationHandler);
|
|
1317
|
+
// Handle back/forward navigation
|
|
1318
|
+
this.popstateHandler = () => this.resetForNavigation();
|
|
1319
|
+
window.addEventListener('popstate', this.popstateHandler);
|
|
1244
1320
|
}
|
|
1245
1321
|
destroy() {
|
|
1246
1322
|
if (this.boundMarkEngaged && typeof document !== 'undefined') {
|
|
@@ -1254,11 +1330,28 @@ class EngagementPlugin extends BasePlugin {
|
|
|
1254
1330
|
if (this.boundVisibilityHandler && typeof document !== 'undefined') {
|
|
1255
1331
|
document.removeEventListener('visibilitychange', this.boundVisibilityHandler);
|
|
1256
1332
|
}
|
|
1333
|
+
if (this.navigationHandler && typeof window !== 'undefined') {
|
|
1334
|
+
window.removeEventListener('clianta:navigation', this.navigationHandler);
|
|
1335
|
+
this.navigationHandler = null;
|
|
1336
|
+
}
|
|
1337
|
+
if (this.popstateHandler && typeof window !== 'undefined') {
|
|
1338
|
+
window.removeEventListener('popstate', this.popstateHandler);
|
|
1339
|
+
this.popstateHandler = null;
|
|
1340
|
+
}
|
|
1257
1341
|
if (this.engagementTimeout) {
|
|
1258
1342
|
clearTimeout(this.engagementTimeout);
|
|
1259
1343
|
}
|
|
1260
1344
|
super.destroy();
|
|
1261
1345
|
}
|
|
1346
|
+
resetForNavigation() {
|
|
1347
|
+
this.pageLoadTime = Date.now();
|
|
1348
|
+
this.engagementStartTime = Date.now();
|
|
1349
|
+
this.isEngaged = false;
|
|
1350
|
+
if (this.engagementTimeout) {
|
|
1351
|
+
clearTimeout(this.engagementTimeout);
|
|
1352
|
+
this.engagementTimeout = null;
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1262
1355
|
markEngaged() {
|
|
1263
1356
|
if (!this.isEngaged) {
|
|
1264
1357
|
this.isEngaged = true;
|
|
@@ -1298,9 +1391,8 @@ class DownloadsPlugin extends BasePlugin {
|
|
|
1298
1391
|
this.name = 'downloads';
|
|
1299
1392
|
this.trackedDownloads = new Set();
|
|
1300
1393
|
this.boundHandler = null;
|
|
1301
|
-
/** SPA navigation
|
|
1302
|
-
this.
|
|
1303
|
-
this.originalReplaceState = null;
|
|
1394
|
+
/** SPA navigation — listen for PageViewPlugin's custom event instead of patching history */
|
|
1395
|
+
this.navigationHandler = null;
|
|
1304
1396
|
this.popstateHandler = null;
|
|
1305
1397
|
}
|
|
1306
1398
|
init(tracker) {
|
|
@@ -1308,24 +1400,25 @@ class DownloadsPlugin extends BasePlugin {
|
|
|
1308
1400
|
if (typeof document !== 'undefined') {
|
|
1309
1401
|
this.boundHandler = this.handleClick.bind(this);
|
|
1310
1402
|
document.addEventListener('click', this.boundHandler, true);
|
|
1311
|
-
|
|
1312
|
-
|
|
1403
|
+
}
|
|
1404
|
+
if (typeof window !== 'undefined') {
|
|
1405
|
+
// Listen for navigation events dispatched by PageViewPlugin
|
|
1406
|
+
// instead of independently monkey-patching history.pushState
|
|
1407
|
+
this.navigationHandler = () => this.resetForNavigation();
|
|
1408
|
+
window.addEventListener('clianta:navigation', this.navigationHandler);
|
|
1409
|
+
// Handle back/forward navigation
|
|
1410
|
+
this.popstateHandler = () => this.resetForNavigation();
|
|
1411
|
+
window.addEventListener('popstate', this.popstateHandler);
|
|
1313
1412
|
}
|
|
1314
1413
|
}
|
|
1315
1414
|
destroy() {
|
|
1316
1415
|
if (this.boundHandler && typeof document !== 'undefined') {
|
|
1317
1416
|
document.removeEventListener('click', this.boundHandler, true);
|
|
1318
1417
|
}
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
this.originalPushState = null;
|
|
1418
|
+
if (this.navigationHandler && typeof window !== 'undefined') {
|
|
1419
|
+
window.removeEventListener('clianta:navigation', this.navigationHandler);
|
|
1420
|
+
this.navigationHandler = null;
|
|
1323
1421
|
}
|
|
1324
|
-
if (this.originalReplaceState) {
|
|
1325
|
-
history.replaceState = this.originalReplaceState;
|
|
1326
|
-
this.originalReplaceState = null;
|
|
1327
|
-
}
|
|
1328
|
-
// Remove popstate listener
|
|
1329
1422
|
if (this.popstateHandler && typeof window !== 'undefined') {
|
|
1330
1423
|
window.removeEventListener('popstate', this.popstateHandler);
|
|
1331
1424
|
this.popstateHandler = null;
|
|
@@ -1338,29 +1431,6 @@ class DownloadsPlugin extends BasePlugin {
|
|
|
1338
1431
|
resetForNavigation() {
|
|
1339
1432
|
this.trackedDownloads.clear();
|
|
1340
1433
|
}
|
|
1341
|
-
/**
|
|
1342
|
-
* Setup History API interception for SPA navigation
|
|
1343
|
-
*/
|
|
1344
|
-
setupNavigationReset() {
|
|
1345
|
-
if (typeof window === 'undefined')
|
|
1346
|
-
return;
|
|
1347
|
-
// Store originals for cleanup
|
|
1348
|
-
this.originalPushState = history.pushState;
|
|
1349
|
-
this.originalReplaceState = history.replaceState;
|
|
1350
|
-
// Intercept pushState and replaceState
|
|
1351
|
-
const self = this;
|
|
1352
|
-
history.pushState = function (...args) {
|
|
1353
|
-
self.originalPushState.apply(history, args);
|
|
1354
|
-
self.resetForNavigation();
|
|
1355
|
-
};
|
|
1356
|
-
history.replaceState = function (...args) {
|
|
1357
|
-
self.originalReplaceState.apply(history, args);
|
|
1358
|
-
self.resetForNavigation();
|
|
1359
|
-
};
|
|
1360
|
-
// Handle back/forward navigation
|
|
1361
|
-
this.popstateHandler = () => this.resetForNavigation();
|
|
1362
|
-
window.addEventListener('popstate', this.popstateHandler);
|
|
1363
|
-
}
|
|
1364
1434
|
handleClick(e) {
|
|
1365
1435
|
const link = e.target.closest('a');
|
|
1366
1436
|
if (!link || !link.href)
|
|
@@ -1396,6 +1466,9 @@ class ExitIntentPlugin extends BasePlugin {
|
|
|
1396
1466
|
this.exitIntentShown = false;
|
|
1397
1467
|
this.pageLoadTime = 0;
|
|
1398
1468
|
this.boundHandler = null;
|
|
1469
|
+
/** SPA navigation — listen for PageViewPlugin's custom event instead of patching history */
|
|
1470
|
+
this.navigationHandler = null;
|
|
1471
|
+
this.popstateHandler = null;
|
|
1399
1472
|
}
|
|
1400
1473
|
init(tracker) {
|
|
1401
1474
|
super.init(tracker);
|
|
@@ -1407,13 +1480,34 @@ class ExitIntentPlugin extends BasePlugin {
|
|
|
1407
1480
|
this.boundHandler = this.handleMouseLeave.bind(this);
|
|
1408
1481
|
document.addEventListener('mouseleave', this.boundHandler);
|
|
1409
1482
|
}
|
|
1483
|
+
if (typeof window !== 'undefined') {
|
|
1484
|
+
// Listen for navigation events dispatched by PageViewPlugin
|
|
1485
|
+
// instead of independently monkey-patching history.pushState
|
|
1486
|
+
this.navigationHandler = () => this.resetForNavigation();
|
|
1487
|
+
window.addEventListener('clianta:navigation', this.navigationHandler);
|
|
1488
|
+
// Handle back/forward navigation
|
|
1489
|
+
this.popstateHandler = () => this.resetForNavigation();
|
|
1490
|
+
window.addEventListener('popstate', this.popstateHandler);
|
|
1491
|
+
}
|
|
1410
1492
|
}
|
|
1411
1493
|
destroy() {
|
|
1412
1494
|
if (this.boundHandler && typeof document !== 'undefined') {
|
|
1413
1495
|
document.removeEventListener('mouseleave', this.boundHandler);
|
|
1414
1496
|
}
|
|
1497
|
+
if (this.navigationHandler && typeof window !== 'undefined') {
|
|
1498
|
+
window.removeEventListener('clianta:navigation', this.navigationHandler);
|
|
1499
|
+
this.navigationHandler = null;
|
|
1500
|
+
}
|
|
1501
|
+
if (this.popstateHandler && typeof window !== 'undefined') {
|
|
1502
|
+
window.removeEventListener('popstate', this.popstateHandler);
|
|
1503
|
+
this.popstateHandler = null;
|
|
1504
|
+
}
|
|
1415
1505
|
super.destroy();
|
|
1416
1506
|
}
|
|
1507
|
+
resetForNavigation() {
|
|
1508
|
+
this.exitIntentShown = false;
|
|
1509
|
+
this.pageLoadTime = Date.now();
|
|
1510
|
+
}
|
|
1417
1511
|
handleMouseLeave(e) {
|
|
1418
1512
|
// Only trigger when mouse leaves from the top of the page
|
|
1419
1513
|
if (e.clientY > 0 || this.exitIntentShown)
|
|
@@ -1641,6 +1735,8 @@ class PopupFormsPlugin extends BasePlugin {
|
|
|
1641
1735
|
this.shownForms = new Set();
|
|
1642
1736
|
this.scrollHandler = null;
|
|
1643
1737
|
this.exitHandler = null;
|
|
1738
|
+
this.delayTimers = [];
|
|
1739
|
+
this.clickTriggerListeners = [];
|
|
1644
1740
|
}
|
|
1645
1741
|
async init(tracker) {
|
|
1646
1742
|
super.init(tracker);
|
|
@@ -1655,6 +1751,14 @@ class PopupFormsPlugin extends BasePlugin {
|
|
|
1655
1751
|
}
|
|
1656
1752
|
destroy() {
|
|
1657
1753
|
this.removeTriggers();
|
|
1754
|
+
for (const timer of this.delayTimers) {
|
|
1755
|
+
clearTimeout(timer);
|
|
1756
|
+
}
|
|
1757
|
+
this.delayTimers = [];
|
|
1758
|
+
for (const { element, handler } of this.clickTriggerListeners) {
|
|
1759
|
+
element.removeEventListener('click', handler);
|
|
1760
|
+
}
|
|
1761
|
+
this.clickTriggerListeners = [];
|
|
1658
1762
|
super.destroy();
|
|
1659
1763
|
}
|
|
1660
1764
|
loadShownForms() {
|
|
@@ -1685,7 +1789,7 @@ class PopupFormsPlugin extends BasePlugin {
|
|
|
1685
1789
|
return;
|
|
1686
1790
|
const config = this.tracker.getConfig();
|
|
1687
1791
|
const workspaceId = this.tracker.getWorkspaceId();
|
|
1688
|
-
const apiEndpoint = config.apiEndpoint || '
|
|
1792
|
+
const apiEndpoint = config.apiEndpoint || 'http://localhost:5000';
|
|
1689
1793
|
try {
|
|
1690
1794
|
const url = encodeURIComponent(window.location.href);
|
|
1691
1795
|
const response = await fetch(`${apiEndpoint}/api/public/lead-forms/${workspaceId}?url=${url}`);
|
|
@@ -1717,7 +1821,7 @@ class PopupFormsPlugin extends BasePlugin {
|
|
|
1717
1821
|
this.forms.forEach(form => {
|
|
1718
1822
|
switch (form.trigger.type) {
|
|
1719
1823
|
case 'delay':
|
|
1720
|
-
setTimeout(() => this.showForm(form), (form.trigger.value || 5) * 1000);
|
|
1824
|
+
this.delayTimers.push(setTimeout(() => this.showForm(form), (form.trigger.value || 5) * 1000));
|
|
1721
1825
|
break;
|
|
1722
1826
|
case 'scroll':
|
|
1723
1827
|
this.setupScrollTrigger(form);
|
|
@@ -1760,7 +1864,9 @@ class PopupFormsPlugin extends BasePlugin {
|
|
|
1760
1864
|
return;
|
|
1761
1865
|
const elements = document.querySelectorAll(form.trigger.selector);
|
|
1762
1866
|
elements.forEach(el => {
|
|
1763
|
-
|
|
1867
|
+
const handler = () => this.showForm(form);
|
|
1868
|
+
el.addEventListener('click', handler);
|
|
1869
|
+
this.clickTriggerListeners.push({ element: el, handler });
|
|
1764
1870
|
});
|
|
1765
1871
|
}
|
|
1766
1872
|
removeTriggers() {
|
|
@@ -1788,7 +1894,7 @@ class PopupFormsPlugin extends BasePlugin {
|
|
|
1788
1894
|
if (!this.tracker)
|
|
1789
1895
|
return;
|
|
1790
1896
|
const config = this.tracker.getConfig();
|
|
1791
|
-
const apiEndpoint = config.apiEndpoint || '
|
|
1897
|
+
const apiEndpoint = config.apiEndpoint || 'http://localhost:5000';
|
|
1792
1898
|
try {
|
|
1793
1899
|
await fetch(`${apiEndpoint}/api/public/lead-forms/${formId}/view`, {
|
|
1794
1900
|
method: 'POST',
|
|
@@ -2035,7 +2141,7 @@ class PopupFormsPlugin extends BasePlugin {
|
|
|
2035
2141
|
if (!this.tracker)
|
|
2036
2142
|
return;
|
|
2037
2143
|
const config = this.tracker.getConfig();
|
|
2038
|
-
const apiEndpoint = config.apiEndpoint || '
|
|
2144
|
+
const apiEndpoint = config.apiEndpoint || 'http://localhost:5000';
|
|
2039
2145
|
const visitorId = this.tracker.getVisitorId();
|
|
2040
2146
|
// Collect form data
|
|
2041
2147
|
const formData = new FormData(formElement);
|
|
@@ -2047,7 +2153,7 @@ class PopupFormsPlugin extends BasePlugin {
|
|
|
2047
2153
|
const submitBtn = formElement.querySelector('button[type="submit"]');
|
|
2048
2154
|
if (submitBtn) {
|
|
2049
2155
|
submitBtn.disabled = true;
|
|
2050
|
-
submitBtn.
|
|
2156
|
+
submitBtn.textContent = 'Submitting...';
|
|
2051
2157
|
}
|
|
2052
2158
|
try {
|
|
2053
2159
|
const response = await fetch(`${apiEndpoint}/api/public/lead-forms/${form._id}/submit`, {
|
|
@@ -2088,11 +2194,24 @@ class PopupFormsPlugin extends BasePlugin {
|
|
|
2088
2194
|
if (data.email) {
|
|
2089
2195
|
this.tracker?.identify(data.email, data);
|
|
2090
2196
|
}
|
|
2091
|
-
// Redirect if configured
|
|
2197
|
+
// Redirect if configured (validate URL to prevent open redirect)
|
|
2092
2198
|
if (form.redirectUrl) {
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2199
|
+
try {
|
|
2200
|
+
const redirect = new URL(form.redirectUrl, window.location.origin);
|
|
2201
|
+
const isSameOrigin = redirect.origin === window.location.origin;
|
|
2202
|
+
const isSafeProtocol = redirect.protocol === 'https:' || redirect.protocol === 'http:';
|
|
2203
|
+
if (isSameOrigin || isSafeProtocol) {
|
|
2204
|
+
setTimeout(() => {
|
|
2205
|
+
window.location.href = redirect.href;
|
|
2206
|
+
}, 1500);
|
|
2207
|
+
}
|
|
2208
|
+
else {
|
|
2209
|
+
console.warn('[Clianta] Blocked unsafe redirect URL:', form.redirectUrl);
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
catch {
|
|
2213
|
+
console.warn('[Clianta] Invalid redirect URL:', form.redirectUrl);
|
|
2214
|
+
}
|
|
2096
2215
|
}
|
|
2097
2216
|
// Close after delay
|
|
2098
2217
|
setTimeout(() => {
|
|
@@ -2107,7 +2226,7 @@ class PopupFormsPlugin extends BasePlugin {
|
|
|
2107
2226
|
console.error('[Clianta] Form submit error:', error);
|
|
2108
2227
|
if (submitBtn) {
|
|
2109
2228
|
submitBtn.disabled = false;
|
|
2110
|
-
submitBtn.
|
|
2229
|
+
submitBtn.textContent = form.submitButtonText || 'Subscribe';
|
|
2111
2230
|
}
|
|
2112
2231
|
}
|
|
2113
2232
|
}
|
|
@@ -2917,7 +3036,7 @@ class CRMClient {
|
|
|
2917
3036
|
* The contact is upserted in the CRM and matching workflow automations fire automatically.
|
|
2918
3037
|
*
|
|
2919
3038
|
* @example
|
|
2920
|
-
* const crm = new CRMClient('
|
|
3039
|
+
* const crm = new CRMClient('http://localhost:5000', 'WORKSPACE_ID');
|
|
2921
3040
|
* crm.setApiKey('mm_live_...');
|
|
2922
3041
|
*
|
|
2923
3042
|
* await crm.sendEvent({
|
|
@@ -3438,6 +3557,114 @@ class CRMClient {
|
|
|
3438
3557
|
});
|
|
3439
3558
|
}
|
|
3440
3559
|
// ============================================
|
|
3560
|
+
// READ-BACK / DATA RETRIEVAL API
|
|
3561
|
+
// ============================================
|
|
3562
|
+
/**
|
|
3563
|
+
* Get a contact by email address.
|
|
3564
|
+
* Returns the first matching contact from a search query.
|
|
3565
|
+
*/
|
|
3566
|
+
async getContactByEmail(email) {
|
|
3567
|
+
this.validateRequired('email', email, 'getContactByEmail');
|
|
3568
|
+
const queryParams = new URLSearchParams({ search: email, limit: '1' });
|
|
3569
|
+
return this.request(`/api/workspaces/${this.workspaceId}/contacts?${queryParams.toString()}`);
|
|
3570
|
+
}
|
|
3571
|
+
/**
|
|
3572
|
+
* Get activity timeline for a contact
|
|
3573
|
+
*/
|
|
3574
|
+
async getContactActivity(contactId, params) {
|
|
3575
|
+
this.validateRequired('contactId', contactId, 'getContactActivity');
|
|
3576
|
+
const queryParams = new URLSearchParams();
|
|
3577
|
+
if (params?.page)
|
|
3578
|
+
queryParams.set('page', params.page.toString());
|
|
3579
|
+
if (params?.limit)
|
|
3580
|
+
queryParams.set('limit', params.limit.toString());
|
|
3581
|
+
if (params?.type)
|
|
3582
|
+
queryParams.set('type', params.type);
|
|
3583
|
+
if (params?.startDate)
|
|
3584
|
+
queryParams.set('startDate', params.startDate);
|
|
3585
|
+
if (params?.endDate)
|
|
3586
|
+
queryParams.set('endDate', params.endDate);
|
|
3587
|
+
const query = queryParams.toString();
|
|
3588
|
+
const endpoint = `/api/workspaces/${this.workspaceId}/contacts/${contactId}/activities${query ? `?${query}` : ''}`;
|
|
3589
|
+
return this.request(endpoint);
|
|
3590
|
+
}
|
|
3591
|
+
/**
|
|
3592
|
+
* Get engagement metrics for a contact (via their linked visitor data)
|
|
3593
|
+
*/
|
|
3594
|
+
async getContactEngagement(contactId) {
|
|
3595
|
+
this.validateRequired('contactId', contactId, 'getContactEngagement');
|
|
3596
|
+
return this.request(`/api/workspaces/${this.workspaceId}/contacts/${contactId}/engagement`);
|
|
3597
|
+
}
|
|
3598
|
+
/**
|
|
3599
|
+
* Get a full timeline for a contact including events, activities, and opportunities
|
|
3600
|
+
*/
|
|
3601
|
+
async getContactTimeline(contactId, params) {
|
|
3602
|
+
this.validateRequired('contactId', contactId, 'getContactTimeline');
|
|
3603
|
+
const queryParams = new URLSearchParams();
|
|
3604
|
+
if (params?.page)
|
|
3605
|
+
queryParams.set('page', params.page.toString());
|
|
3606
|
+
if (params?.limit)
|
|
3607
|
+
queryParams.set('limit', params.limit.toString());
|
|
3608
|
+
const query = queryParams.toString();
|
|
3609
|
+
const endpoint = `/api/workspaces/${this.workspaceId}/contacts/${contactId}/timeline${query ? `?${query}` : ''}`;
|
|
3610
|
+
return this.request(endpoint);
|
|
3611
|
+
}
|
|
3612
|
+
/**
|
|
3613
|
+
* Search contacts with advanced filters
|
|
3614
|
+
*/
|
|
3615
|
+
async searchContacts(query, filters) {
|
|
3616
|
+
const queryParams = new URLSearchParams();
|
|
3617
|
+
queryParams.set('search', query);
|
|
3618
|
+
if (filters?.status)
|
|
3619
|
+
queryParams.set('status', filters.status);
|
|
3620
|
+
if (filters?.lifecycleStage)
|
|
3621
|
+
queryParams.set('lifecycleStage', filters.lifecycleStage);
|
|
3622
|
+
if (filters?.source)
|
|
3623
|
+
queryParams.set('source', filters.source);
|
|
3624
|
+
if (filters?.tags)
|
|
3625
|
+
queryParams.set('tags', filters.tags.join(','));
|
|
3626
|
+
if (filters?.page)
|
|
3627
|
+
queryParams.set('page', filters.page.toString());
|
|
3628
|
+
if (filters?.limit)
|
|
3629
|
+
queryParams.set('limit', filters.limit.toString());
|
|
3630
|
+
const qs = queryParams.toString();
|
|
3631
|
+
const endpoint = `/api/workspaces/${this.workspaceId}/contacts${qs ? `?${qs}` : ''}`;
|
|
3632
|
+
return this.request(endpoint);
|
|
3633
|
+
}
|
|
3634
|
+
// ============================================
|
|
3635
|
+
// WEBHOOK MANAGEMENT API
|
|
3636
|
+
// ============================================
|
|
3637
|
+
/**
|
|
3638
|
+
* List all webhook subscriptions
|
|
3639
|
+
*/
|
|
3640
|
+
async listWebhooks(params) {
|
|
3641
|
+
const queryParams = new URLSearchParams();
|
|
3642
|
+
if (params?.page)
|
|
3643
|
+
queryParams.set('page', params.page.toString());
|
|
3644
|
+
if (params?.limit)
|
|
3645
|
+
queryParams.set('limit', params.limit.toString());
|
|
3646
|
+
const query = queryParams.toString();
|
|
3647
|
+
return this.request(`/api/workspaces/${this.workspaceId}/webhooks${query ? `?${query}` : ''}`);
|
|
3648
|
+
}
|
|
3649
|
+
/**
|
|
3650
|
+
* Create a new webhook subscription
|
|
3651
|
+
*/
|
|
3652
|
+
async createWebhook(data) {
|
|
3653
|
+
return this.request(`/api/workspaces/${this.workspaceId}/webhooks`, {
|
|
3654
|
+
method: 'POST',
|
|
3655
|
+
body: JSON.stringify(data),
|
|
3656
|
+
});
|
|
3657
|
+
}
|
|
3658
|
+
/**
|
|
3659
|
+
* Delete a webhook subscription
|
|
3660
|
+
*/
|
|
3661
|
+
async deleteWebhook(webhookId) {
|
|
3662
|
+
this.validateRequired('webhookId', webhookId, 'deleteWebhook');
|
|
3663
|
+
return this.request(`/api/workspaces/${this.workspaceId}/webhooks/${webhookId}`, {
|
|
3664
|
+
method: 'DELETE',
|
|
3665
|
+
});
|
|
3666
|
+
}
|
|
3667
|
+
// ============================================
|
|
3441
3668
|
// EVENT TRIGGERS API (delegated to triggers manager)
|
|
3442
3669
|
// ============================================
|
|
3443
3670
|
/**
|
|
@@ -3481,6 +3708,8 @@ class Tracker {
|
|
|
3481
3708
|
this.contactId = null;
|
|
3482
3709
|
/** Pending identify retry on next flush */
|
|
3483
3710
|
this.pendingIdentify = null;
|
|
3711
|
+
/** Registered event schemas for validation */
|
|
3712
|
+
this.eventSchemas = new Map();
|
|
3484
3713
|
if (!workspaceId) {
|
|
3485
3714
|
throw new Error('[Clianta] Workspace ID is required');
|
|
3486
3715
|
}
|
|
@@ -3506,6 +3735,16 @@ class Tracker {
|
|
|
3506
3735
|
this.visitorId = this.createVisitorId();
|
|
3507
3736
|
this.sessionId = this.createSessionId();
|
|
3508
3737
|
logger.debug('IDs created', { visitorId: this.visitorId, sessionId: this.sessionId });
|
|
3738
|
+
// Security warnings
|
|
3739
|
+
if (this.config.apiEndpoint.startsWith('http://') &&
|
|
3740
|
+
typeof window !== 'undefined' &&
|
|
3741
|
+
!window.location.hostname.includes('localhost') &&
|
|
3742
|
+
!window.location.hostname.includes('127.0.0.1')) {
|
|
3743
|
+
logger.warn('apiEndpoint uses HTTP — events and visitor data will be sent unencrypted. Use HTTPS in production.');
|
|
3744
|
+
}
|
|
3745
|
+
if (this.config.apiKey && typeof window !== 'undefined') {
|
|
3746
|
+
logger.warn('API key is exposed in client-side code. Use API keys only in server-side (Node.js) environments.');
|
|
3747
|
+
}
|
|
3509
3748
|
// Initialize plugins
|
|
3510
3749
|
this.initPlugins();
|
|
3511
3750
|
this.isInitialized = true;
|
|
@@ -3611,6 +3850,7 @@ class Tracker {
|
|
|
3611
3850
|
properties: {
|
|
3612
3851
|
...properties,
|
|
3613
3852
|
eventId: generateUUID(), // Unique ID for deduplication on retry
|
|
3853
|
+
websiteDomain: typeof window !== 'undefined' ? window.location.hostname : undefined,
|
|
3614
3854
|
},
|
|
3615
3855
|
device: getDeviceInfo(),
|
|
3616
3856
|
...getUTMParams(),
|
|
@@ -3621,6 +3861,8 @@ class Tracker {
|
|
|
3621
3861
|
if (this.contactId) {
|
|
3622
3862
|
event.contactId = this.contactId;
|
|
3623
3863
|
}
|
|
3864
|
+
// Validate event against registered schema (debug mode only)
|
|
3865
|
+
this.validateEventSchema(eventType, properties);
|
|
3624
3866
|
// Check consent before tracking
|
|
3625
3867
|
if (!this.consentManager.canTrack()) {
|
|
3626
3868
|
// Buffer event for later if waitForConsent is enabled
|
|
@@ -3655,6 +3897,10 @@ class Tracker {
|
|
|
3655
3897
|
logger.warn('Email is required for identification');
|
|
3656
3898
|
return null;
|
|
3657
3899
|
}
|
|
3900
|
+
if (!isValidEmail(email)) {
|
|
3901
|
+
logger.warn('Invalid email format, identification skipped:', email);
|
|
3902
|
+
return null;
|
|
3903
|
+
}
|
|
3658
3904
|
logger.info('Identifying visitor:', email);
|
|
3659
3905
|
const result = await this.transport.sendIdentify({
|
|
3660
3906
|
workspaceId: this.workspaceId,
|
|
@@ -3689,6 +3935,83 @@ class Tracker {
|
|
|
3689
3935
|
const client = new CRMClient(this.config.apiEndpoint, this.workspaceId, undefined, apiKey);
|
|
3690
3936
|
return client.sendEvent(payload);
|
|
3691
3937
|
}
|
|
3938
|
+
/**
|
|
3939
|
+
* Get the current visitor's profile from the CRM.
|
|
3940
|
+
* Returns visitor data and linked contact info if identified.
|
|
3941
|
+
* Only returns data for the current visitor (privacy-safe for frontend).
|
|
3942
|
+
*/
|
|
3943
|
+
async getVisitorProfile() {
|
|
3944
|
+
if (!this.isInitialized) {
|
|
3945
|
+
logger.warn('SDK not initialized');
|
|
3946
|
+
return null;
|
|
3947
|
+
}
|
|
3948
|
+
const result = await this.transport.fetchData(`/api/public/track/visitor/${this.workspaceId}/${this.visitorId}/profile`);
|
|
3949
|
+
if (result.success && result.data) {
|
|
3950
|
+
logger.debug('Visitor profile fetched:', result.data);
|
|
3951
|
+
return result.data;
|
|
3952
|
+
}
|
|
3953
|
+
logger.warn('Failed to fetch visitor profile:', result.error);
|
|
3954
|
+
return null;
|
|
3955
|
+
}
|
|
3956
|
+
/**
|
|
3957
|
+
* Get the current visitor's recent activity/events.
|
|
3958
|
+
* Returns paginated list of tracking events for this visitor.
|
|
3959
|
+
*/
|
|
3960
|
+
async getVisitorActivity(options) {
|
|
3961
|
+
if (!this.isInitialized) {
|
|
3962
|
+
logger.warn('SDK not initialized');
|
|
3963
|
+
return null;
|
|
3964
|
+
}
|
|
3965
|
+
const params = {};
|
|
3966
|
+
if (options?.page)
|
|
3967
|
+
params.page = options.page.toString();
|
|
3968
|
+
if (options?.limit)
|
|
3969
|
+
params.limit = options.limit.toString();
|
|
3970
|
+
if (options?.eventType)
|
|
3971
|
+
params.eventType = options.eventType;
|
|
3972
|
+
if (options?.startDate)
|
|
3973
|
+
params.startDate = options.startDate;
|
|
3974
|
+
if (options?.endDate)
|
|
3975
|
+
params.endDate = options.endDate;
|
|
3976
|
+
const result = await this.transport.fetchData(`/api/public/track/visitor/${this.workspaceId}/${this.visitorId}/activity`, params);
|
|
3977
|
+
if (result.success && result.data) {
|
|
3978
|
+
return result.data;
|
|
3979
|
+
}
|
|
3980
|
+
logger.warn('Failed to fetch visitor activity:', result.error);
|
|
3981
|
+
return null;
|
|
3982
|
+
}
|
|
3983
|
+
/**
|
|
3984
|
+
* Get a summarized journey timeline for the current visitor.
|
|
3985
|
+
* Includes top pages, sessions, time spent, and recent activities.
|
|
3986
|
+
*/
|
|
3987
|
+
async getVisitorTimeline() {
|
|
3988
|
+
if (!this.isInitialized) {
|
|
3989
|
+
logger.warn('SDK not initialized');
|
|
3990
|
+
return null;
|
|
3991
|
+
}
|
|
3992
|
+
const result = await this.transport.fetchData(`/api/public/track/visitor/${this.workspaceId}/${this.visitorId}/timeline`);
|
|
3993
|
+
if (result.success && result.data) {
|
|
3994
|
+
return result.data;
|
|
3995
|
+
}
|
|
3996
|
+
logger.warn('Failed to fetch visitor timeline:', result.error);
|
|
3997
|
+
return null;
|
|
3998
|
+
}
|
|
3999
|
+
/**
|
|
4000
|
+
* Get engagement metrics for the current visitor.
|
|
4001
|
+
* Includes time on site, page views, bounce rate, and engagement score.
|
|
4002
|
+
*/
|
|
4003
|
+
async getVisitorEngagement() {
|
|
4004
|
+
if (!this.isInitialized) {
|
|
4005
|
+
logger.warn('SDK not initialized');
|
|
4006
|
+
return null;
|
|
4007
|
+
}
|
|
4008
|
+
const result = await this.transport.fetchData(`/api/public/track/visitor/${this.workspaceId}/${this.visitorId}/engagement`);
|
|
4009
|
+
if (result.success && result.data) {
|
|
4010
|
+
return result.data;
|
|
4011
|
+
}
|
|
4012
|
+
logger.warn('Failed to fetch visitor engagement:', result.error);
|
|
4013
|
+
return null;
|
|
4014
|
+
}
|
|
3692
4015
|
/**
|
|
3693
4016
|
* Retry pending identify call
|
|
3694
4017
|
*/
|
|
@@ -3718,6 +4041,59 @@ class Tracker {
|
|
|
3718
4041
|
logger.enabled = enabled;
|
|
3719
4042
|
logger.info(`Debug mode ${enabled ? 'enabled' : 'disabled'}`);
|
|
3720
4043
|
}
|
|
4044
|
+
/**
|
|
4045
|
+
* Register a schema for event validation.
|
|
4046
|
+
* When debug mode is enabled, events will be validated against registered schemas.
|
|
4047
|
+
*
|
|
4048
|
+
* @example
|
|
4049
|
+
* tracker.registerEventSchema('purchase', {
|
|
4050
|
+
* productId: 'string',
|
|
4051
|
+
* price: 'number',
|
|
4052
|
+
* quantity: 'number',
|
|
4053
|
+
* });
|
|
4054
|
+
*/
|
|
4055
|
+
registerEventSchema(eventType, schema) {
|
|
4056
|
+
this.eventSchemas.set(eventType, schema);
|
|
4057
|
+
logger.debug('Event schema registered:', eventType);
|
|
4058
|
+
}
|
|
4059
|
+
/**
|
|
4060
|
+
* Validate event properties against a registered schema (debug mode only)
|
|
4061
|
+
*/
|
|
4062
|
+
validateEventSchema(eventType, properties) {
|
|
4063
|
+
if (!this.config.debug)
|
|
4064
|
+
return;
|
|
4065
|
+
const schema = this.eventSchemas.get(eventType);
|
|
4066
|
+
if (!schema)
|
|
4067
|
+
return;
|
|
4068
|
+
for (const [key, expectedType] of Object.entries(schema)) {
|
|
4069
|
+
const value = properties[key];
|
|
4070
|
+
if (value === undefined) {
|
|
4071
|
+
logger.warn(`[Schema] Missing property "${key}" for event type "${eventType}"`);
|
|
4072
|
+
continue;
|
|
4073
|
+
}
|
|
4074
|
+
let valid = false;
|
|
4075
|
+
switch (expectedType) {
|
|
4076
|
+
case 'string':
|
|
4077
|
+
valid = typeof value === 'string';
|
|
4078
|
+
break;
|
|
4079
|
+
case 'number':
|
|
4080
|
+
valid = typeof value === 'number';
|
|
4081
|
+
break;
|
|
4082
|
+
case 'boolean':
|
|
4083
|
+
valid = typeof value === 'boolean';
|
|
4084
|
+
break;
|
|
4085
|
+
case 'object':
|
|
4086
|
+
valid = typeof value === 'object' && !Array.isArray(value);
|
|
4087
|
+
break;
|
|
4088
|
+
case 'array':
|
|
4089
|
+
valid = Array.isArray(value);
|
|
4090
|
+
break;
|
|
4091
|
+
}
|
|
4092
|
+
if (!valid) {
|
|
4093
|
+
logger.warn(`[Schema] Property "${key}" for event "${eventType}" expected ${expectedType}, got ${typeof value}`);
|
|
4094
|
+
}
|
|
4095
|
+
}
|
|
4096
|
+
}
|
|
3721
4097
|
/**
|
|
3722
4098
|
* Get visitor ID
|
|
3723
4099
|
*/
|
|
@@ -3757,6 +4133,8 @@ class Tracker {
|
|
|
3757
4133
|
resetIds(this.config.useCookies);
|
|
3758
4134
|
this.visitorId = this.createVisitorId();
|
|
3759
4135
|
this.sessionId = this.createSessionId();
|
|
4136
|
+
this.contactId = null;
|
|
4137
|
+
this.pendingIdentify = null;
|
|
3760
4138
|
this.queue.clear();
|
|
3761
4139
|
}
|
|
3762
4140
|
/**
|
|
@@ -3798,6 +4176,86 @@ class Tracker {
|
|
|
3798
4176
|
this.sessionId = this.createSessionId();
|
|
3799
4177
|
logger.info('All user data deleted');
|
|
3800
4178
|
}
|
|
4179
|
+
// ============================================
|
|
4180
|
+
// PUBLIC CRM METHODS (no API key required)
|
|
4181
|
+
// ============================================
|
|
4182
|
+
/**
|
|
4183
|
+
* Create or update a contact by email (upsert).
|
|
4184
|
+
* Secured by domain whitelist — no API key needed.
|
|
4185
|
+
*/
|
|
4186
|
+
async createContact(data) {
|
|
4187
|
+
return this.publicCrmRequest('/api/public/crm/contacts', 'POST', {
|
|
4188
|
+
workspaceId: this.workspaceId,
|
|
4189
|
+
...data,
|
|
4190
|
+
});
|
|
4191
|
+
}
|
|
4192
|
+
/**
|
|
4193
|
+
* Update an existing contact by ID (limited fields only).
|
|
4194
|
+
*/
|
|
4195
|
+
async updateContact(contactId, data) {
|
|
4196
|
+
return this.publicCrmRequest(`/api/public/crm/contacts/${contactId}`, 'PUT', {
|
|
4197
|
+
workspaceId: this.workspaceId,
|
|
4198
|
+
...data,
|
|
4199
|
+
});
|
|
4200
|
+
}
|
|
4201
|
+
/**
|
|
4202
|
+
* Submit a form — creates/updates contact from form data.
|
|
4203
|
+
*/
|
|
4204
|
+
async submitForm(formId, data) {
|
|
4205
|
+
const payload = {
|
|
4206
|
+
...data,
|
|
4207
|
+
metadata: {
|
|
4208
|
+
...data.metadata,
|
|
4209
|
+
visitorId: this.visitorId,
|
|
4210
|
+
sessionId: this.sessionId,
|
|
4211
|
+
pageUrl: typeof window !== 'undefined' ? window.location.href : undefined,
|
|
4212
|
+
referrer: typeof document !== 'undefined' ? document.referrer || undefined : undefined,
|
|
4213
|
+
},
|
|
4214
|
+
};
|
|
4215
|
+
return this.publicCrmRequest(`/api/public/crm/forms/${formId}/submit`, 'POST', payload);
|
|
4216
|
+
}
|
|
4217
|
+
/**
|
|
4218
|
+
* Log an activity linked to a contact (append-only).
|
|
4219
|
+
*/
|
|
4220
|
+
async logActivity(data) {
|
|
4221
|
+
return this.publicCrmRequest('/api/public/crm/activities', 'POST', {
|
|
4222
|
+
workspaceId: this.workspaceId,
|
|
4223
|
+
...data,
|
|
4224
|
+
});
|
|
4225
|
+
}
|
|
4226
|
+
/**
|
|
4227
|
+
* Create an opportunity (e.g., from "Request Demo" forms).
|
|
4228
|
+
*/
|
|
4229
|
+
async createOpportunity(data) {
|
|
4230
|
+
return this.publicCrmRequest('/api/public/crm/opportunities', 'POST', {
|
|
4231
|
+
workspaceId: this.workspaceId,
|
|
4232
|
+
...data,
|
|
4233
|
+
});
|
|
4234
|
+
}
|
|
4235
|
+
/**
|
|
4236
|
+
* Internal helper for public CRM API calls.
|
|
4237
|
+
*/
|
|
4238
|
+
async publicCrmRequest(path, method, body) {
|
|
4239
|
+
const url = `${this.config.apiEndpoint}${path}`;
|
|
4240
|
+
try {
|
|
4241
|
+
const response = await fetch(url, {
|
|
4242
|
+
method,
|
|
4243
|
+
headers: { 'Content-Type': 'application/json' },
|
|
4244
|
+
body: JSON.stringify(body),
|
|
4245
|
+
});
|
|
4246
|
+
const data = await response.json().catch(() => ({}));
|
|
4247
|
+
if (response.ok) {
|
|
4248
|
+
logger.debug(`Public CRM ${method} ${path} succeeded`);
|
|
4249
|
+
return { success: true, data: data.data ?? data, status: response.status };
|
|
4250
|
+
}
|
|
4251
|
+
logger.error(`Public CRM ${method} ${path} failed (${response.status}):`, data.message);
|
|
4252
|
+
return { success: false, error: data.message, status: response.status };
|
|
4253
|
+
}
|
|
4254
|
+
catch (error) {
|
|
4255
|
+
logger.error(`Public CRM ${method} ${path} error:`, error);
|
|
4256
|
+
return { success: false, error: error.message };
|
|
4257
|
+
}
|
|
4258
|
+
}
|
|
3801
4259
|
/**
|
|
3802
4260
|
* Destroy tracker and cleanup
|
|
3803
4261
|
*/
|
|
@@ -3891,7 +4349,7 @@ const CliantaContext = createContext(null);
|
|
|
3891
4349
|
*
|
|
3892
4350
|
* const config: CliantaConfig = {
|
|
3893
4351
|
* projectId: 'your-project-id',
|
|
3894
|
-
* apiEndpoint:
|
|
4352
|
+
* apiEndpoint: process.env.NEXT_PUBLIC_CLIANTA_API_ENDPOINT || 'http://localhost:5000',
|
|
3895
4353
|
* debug: process.env.NODE_ENV === 'development',
|
|
3896
4354
|
* };
|
|
3897
4355
|
*
|
|
@@ -3906,7 +4364,9 @@ const CliantaContext = createContext(null);
|
|
|
3906
4364
|
* </CliantaProvider>
|
|
3907
4365
|
*/
|
|
3908
4366
|
function CliantaProvider({ config, children }) {
|
|
3909
|
-
const
|
|
4367
|
+
const [tracker, setTracker] = useState(null);
|
|
4368
|
+
// Stable ref to projectId — the only value that truly identifies the tracker
|
|
4369
|
+
const projectIdRef = useRef(config.projectId);
|
|
3910
4370
|
useEffect(() => {
|
|
3911
4371
|
// Initialize tracker with config
|
|
3912
4372
|
const projectId = config.projectId;
|
|
@@ -3914,15 +4374,21 @@ function CliantaProvider({ config, children }) {
|
|
|
3914
4374
|
console.error('[Clianta] Missing projectId in config. Please add projectId to your clianta.config.ts');
|
|
3915
4375
|
return;
|
|
3916
4376
|
}
|
|
4377
|
+
// Only re-initialize if projectId actually changed
|
|
4378
|
+
if (projectIdRef.current !== projectId) {
|
|
4379
|
+
projectIdRef.current = projectId;
|
|
4380
|
+
}
|
|
3917
4381
|
// Extract projectId (handled separately) and pass rest as options
|
|
3918
4382
|
const { projectId: _, ...options } = config;
|
|
3919
|
-
|
|
4383
|
+
const instance = clianta(projectId, options);
|
|
4384
|
+
setTracker(instance);
|
|
3920
4385
|
// Cleanup: flush pending events on unmount
|
|
3921
4386
|
return () => {
|
|
3922
|
-
|
|
4387
|
+
instance?.flush();
|
|
3923
4388
|
};
|
|
3924
|
-
|
|
3925
|
-
|
|
4389
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
4390
|
+
}, [config.projectId]);
|
|
4391
|
+
return (jsx(CliantaContext.Provider, { value: tracker, children: children }));
|
|
3926
4392
|
}
|
|
3927
4393
|
/**
|
|
3928
4394
|
* useClianta - Hook to access tracker in any component
|