mentiq-sdk
Version:
A powerful analytics SDK for React and Next.js with heatmap tracking, session monitoring, and performance analytics
1,286 lines (1,283 loc) • 104 kB
JavaScript
import { jsx, Fragment } from 'react/jsx-runtime';
import React, { createContext, useEffect, useCallback, useRef, useContext, useState } from 'react';
function generateId() {
return Math.random().toString(36).substring(2) + Date.now().toString(36);
}
function getSessionId() {
const sessionKey = "mentiq_session_id";
const sessionTimeout = 30 * 60 * 1000; // 30 minutes
if (typeof window === "undefined") {
return generateId();
}
const stored = sessionStorage.getItem(sessionKey);
const lastActivity = localStorage.getItem("mentiq_last_activity");
const now = Date.now();
const isExpired = lastActivity && now - parseInt(lastActivity, 10) > sessionTimeout;
if (!stored || isExpired) {
const newSessionId = generateId();
sessionStorage.setItem(sessionKey, newSessionId);
localStorage.setItem("mentiq_last_activity", now.toString());
return newSessionId;
}
localStorage.setItem("mentiq_last_activity", now.toString());
return stored;
}
function getAnonymousId() {
const key = "mentiq_anonymous_id";
if (typeof window === "undefined") {
return generateId();
}
let anonymousId = localStorage.getItem(key);
if (!anonymousId) {
anonymousId = generateId();
localStorage.setItem(key, anonymousId);
}
return anonymousId;
}
function getUserId() {
if (typeof window === "undefined") {
return null;
}
return localStorage.getItem("mentiq_user_id");
}
function setUserId(userId) {
if (typeof window === "undefined") {
return;
}
localStorage.setItem("mentiq_user_id", userId);
}
function clearUserId() {
if (typeof window === "undefined") {
return;
}
localStorage.removeItem("mentiq_user_id");
}
function getContext$1() {
const context = {
library: {
name: "mentiq-sdk",
version: "1.0.0",
},
};
if (typeof window !== "undefined") {
context.page = {
title: document.title,
url: window.location.href,
path: window.location.pathname,
referrer: document.referrer || undefined,
search: window.location.search || undefined,
};
context.userAgent = navigator.userAgent;
context.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
context.locale = navigator.language;
context.screen = {
width: window.screen.width,
height: window.screen.height,
};
}
return context;
}
function createEvent(type, event, properties) {
// Enrich properties with channel and email if available
const enrichedProperties = { ...(properties || {}) };
// Add channel if not already present
if (!enrichedProperties.channel && typeof window !== "undefined") {
enrichedProperties.channel = detectChannel();
}
// Add email if available and not already present
if (!enrichedProperties.email) {
const email = getUserEmail();
if (email) {
enrichedProperties.email = email;
}
}
return {
id: generateId(),
timestamp: Date.now(),
type,
event,
properties: enrichedProperties,
userId: getUserId() || undefined,
anonymousId: getAnonymousId(),
sessionId: getSessionId(),
context: getContext$1(),
};
}
function debounce(func, wait) {
let timeout;
return ((...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(null, args), wait);
});
}
function throttle(func, limit) {
let inThrottle;
return ((...args) => {
if (!inThrottle) {
func.apply(null, args);
inThrottle = true;
setTimeout(() => (inThrottle = false), limit);
}
});
}
/**
* Detect acquisition channel from URL parameters and referrer
*/
function detectChannel() {
if (typeof window === "undefined") {
return "direct";
}
const url = new URL(window.location.href);
const referrer = document.referrer;
// Check for UTM parameters (highest priority)
const utmSource = url.searchParams.get("utm_source");
const utmMedium = url.searchParams.get("utm_medium");
const utmCampaign = url.searchParams.get("utm_campaign");
if (utmSource) {
// Store UTM parameters for future reference
try {
localStorage.setItem("mentiq_utm_source", utmSource);
if (utmMedium)
localStorage.setItem("mentiq_utm_medium", utmMedium);
if (utmCampaign)
localStorage.setItem("mentiq_utm_campaign", utmCampaign);
}
catch (e) {
console.warn("Failed to store UTM parameters", e);
}
return mapUtmToChannel(utmSource, utmMedium);
}
// Check for custom tracking parameter (e.g., ?ref=facebook-ad)
const refParam = url.searchParams.get("ref") || url.searchParams.get("referral");
if (refParam) {
try {
localStorage.setItem("mentiq_ref_param", refParam);
}
catch (e) {
console.warn("Failed to store ref parameter", e);
}
return refParam.toLowerCase().replace(/-/g, "_");
}
// Check stored UTM parameters (persisted from previous visit)
const storedUtmSource = localStorage.getItem("mentiq_utm_source");
if (storedUtmSource) {
const storedUtmMedium = localStorage.getItem("mentiq_utm_medium");
return mapUtmToChannel(storedUtmSource, storedUtmMedium);
}
// Analyze referrer
if (referrer && referrer !== "") {
const channel = getChannelFromReferrer(referrer);
if (channel !== "direct") {
try {
localStorage.setItem("mentiq_channel", channel);
}
catch (e) {
console.warn("Failed to store channel", e);
}
return channel;
}
}
// Check for stored channel (persisted from previous visit)
const storedChannel = localStorage.getItem("mentiq_channel");
if (storedChannel) {
return storedChannel;
}
return "direct";
}
/**
* Map UTM parameters to channel names
*/
function mapUtmToChannel(source, medium) {
const sourceLower = source.toLowerCase();
const mediumLower = (medium === null || medium === void 0 ? void 0 : medium.toLowerCase()) || "";
// Social media channels
if (sourceLower.includes("facebook") || sourceLower.includes("meta")) {
return mediumLower.includes("paid") || mediumLower.includes("cpc")
? "paid_social_facebook"
: "organic_social_facebook";
}
if (sourceLower.includes("instagram")) {
return mediumLower.includes("paid") || mediumLower.includes("cpc")
? "paid_social_instagram"
: "organic_social_instagram";
}
if (sourceLower.includes("twitter") || sourceLower.includes("x.com")) {
return mediumLower.includes("paid") || mediumLower.includes("cpc")
? "paid_social_twitter"
: "organic_social_twitter";
}
if (sourceLower.includes("linkedin")) {
return mediumLower.includes("paid") || mediumLower.includes("cpc")
? "paid_social_linkedin"
: "organic_social_linkedin";
}
if (sourceLower.includes("tiktok")) {
return mediumLower.includes("paid") || mediumLower.includes("cpc")
? "paid_social_tiktok"
: "organic_social_tiktok";
}
// Search engines
if (sourceLower.includes("google")) {
return mediumLower.includes("cpc") || mediumLower.includes("paid")
? "paid_search_google"
: "organic_search_google";
}
if (sourceLower.includes("bing")) {
return mediumLower.includes("cpc") || mediumLower.includes("paid")
? "paid_search_bing"
: "organic_search_bing";
}
// Email campaigns
if (mediumLower.includes("email") || sourceLower.includes("email")) {
return "email_campaign";
}
// Display/Banner ads
if (mediumLower.includes("display") || mediumLower.includes("banner")) {
return "display_ads";
}
// Affiliate
if (mediumLower.includes("affiliate") || sourceLower.includes("affiliate")) {
return "affiliate";
}
// Referral
if (mediumLower.includes("referral")) {
return "referral";
}
// Generic paid
if (mediumLower.includes("cpc") ||
mediumLower.includes("paid") ||
mediumLower.includes("ppc")) {
return `paid_${sourceLower}`;
}
return sourceLower;
}
/**
* Extract channel from referrer URL
*/
function getChannelFromReferrer(referrer) {
try {
const url = new URL(referrer);
const hostname = url.hostname.toLowerCase();
// Social media
if (hostname.includes("facebook.com") || hostname.includes("fb.com"))
return "organic_social_facebook";
if (hostname.includes("instagram.com"))
return "organic_social_instagram";
if (hostname.includes("twitter.com") || hostname.includes("t.co"))
return "organic_social_twitter";
if (hostname.includes("linkedin.com"))
return "organic_social_linkedin";
if (hostname.includes("tiktok.com"))
return "organic_social_tiktok";
if (hostname.includes("youtube.com"))
return "organic_social_youtube";
if (hostname.includes("reddit.com"))
return "organic_social_reddit";
if (hostname.includes("pinterest.com"))
return "organic_social_pinterest";
// Search engines
if (hostname.includes("google."))
return "organic_search_google";
if (hostname.includes("bing.com"))
return "organic_search_bing";
if (hostname.includes("yahoo.com"))
return "organic_search_yahoo";
if (hostname.includes("duckduckgo.com"))
return "organic_search_duckduckgo";
if (hostname.includes("baidu.com"))
return "organic_search_baidu";
// If referrer exists but not recognized, it's a referral
return "referral";
}
catch (e) {
return "direct";
}
}
/**
* Get channel from URL parameters (for manual checking)
*/
function getChannelFromUrl(url) {
if (typeof window === "undefined" && !url) {
return null;
}
const urlToCheck = url || window.location.href;
const parsedUrl = new URL(urlToCheck);
const utmSource = parsedUrl.searchParams.get("utm_source");
const utmMedium = parsedUrl.searchParams.get("utm_medium");
const refParam = parsedUrl.searchParams.get("ref") || parsedUrl.searchParams.get("referral");
if (utmSource) {
return mapUtmToChannel(utmSource, utmMedium);
}
if (refParam) {
return refParam.toLowerCase().replace(/-/g, "_");
}
return null;
}
/**
* Get user email from storage or auth session
* Checks multiple sources in order of priority:
* 1. Mentiq-specific storage
* 2. NextAuth/Auth.js session
* 3. Supabase auth
* 4. Firebase auth
* 5. Clerk auth
* 6. Custom auth patterns
*/
function getUserEmail() {
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
if (typeof window === "undefined") {
return null;
}
// 1. Check Mentiq-specific storage first (highest priority)
const mentiqEmail = localStorage.getItem("mentiq_user_email");
if (mentiqEmail) {
return mentiqEmail;
}
// 2. Check NextAuth/Auth.js session (common in Next.js apps)
try {
const nextAuthSession = localStorage.getItem("next-auth.session-token") ||
sessionStorage.getItem("next-auth.session-token");
if (nextAuthSession) {
// Try to extract from session storage
const sessionData = sessionStorage.getItem("__next_auth_session__");
if (sessionData) {
const parsed = JSON.parse(sessionData);
if ((_a = parsed === null || parsed === void 0 ? void 0 : parsed.user) === null || _a === void 0 ? void 0 : _a.email) {
return parsed.user.email;
}
}
}
}
catch (e) {
// Silent fail, continue to next check
}
// 3. Check Supabase auth
try {
const supabaseKeys = Object.keys(localStorage).filter((key) => key.startsWith("sb-") && key.includes("-auth-token"));
for (const key of supabaseKeys) {
const token = localStorage.getItem(key);
if (token) {
const parsed = JSON.parse(token);
if ((_b = parsed === null || parsed === void 0 ? void 0 : parsed.user) === null || _b === void 0 ? void 0 : _b.email) {
return parsed.user.email;
}
}
}
}
catch (e) {
// Silent fail, continue to next check
}
// 4. Check Firebase auth
try {
const firebaseKeys = Object.keys(localStorage).filter((key) => key.startsWith("firebase:authUser:"));
for (const key of firebaseKeys) {
const userData = localStorage.getItem(key);
if (userData) {
const parsed = JSON.parse(userData);
if (parsed === null || parsed === void 0 ? void 0 : parsed.email) {
return parsed.email;
}
}
}
}
catch (e) {
// Silent fail, continue to next check
}
// 5. Check Clerk auth
try {
const clerkSession = sessionStorage.getItem("__clerk_client_uat") ||
localStorage.getItem("__clerk_client_uat");
if (clerkSession) {
// Clerk stores user data separately
const clerkKeys = Object.keys(sessionStorage).filter((key) => key.includes("clerk") && key.includes("user"));
for (const key of clerkKeys) {
const userData = sessionStorage.getItem(key);
if (userData) {
const parsed = JSON.parse(userData);
if ((_c = parsed === null || parsed === void 0 ? void 0 : parsed.primaryEmailAddress) === null || _c === void 0 ? void 0 : _c.emailAddress) {
return parsed.primaryEmailAddress.emailAddress;
}
if ((_e = (_d = parsed === null || parsed === void 0 ? void 0 : parsed.emailAddresses) === null || _d === void 0 ? void 0 : _d[0]) === null || _e === void 0 ? void 0 : _e.emailAddress) {
return parsed.emailAddresses[0].emailAddress;
}
}
}
}
}
catch (e) {
// Silent fail, continue to next check
}
// 6. Check Auth0
try {
const auth0Keys = Object.keys(localStorage).filter((key) => key.startsWith("@@auth0") || key.includes("auth0"));
for (const key of auth0Keys) {
const authData = localStorage.getItem(key);
if (authData) {
const parsed = JSON.parse(authData);
if ((_h = (_g = (_f = parsed === null || parsed === void 0 ? void 0 : parsed.body) === null || _f === void 0 ? void 0 : _f.decodedToken) === null || _g === void 0 ? void 0 : _g.user) === null || _h === void 0 ? void 0 : _h.email) {
return parsed.body.decodedToken.user.email;
}
if ((_j = parsed === null || parsed === void 0 ? void 0 : parsed.user) === null || _j === void 0 ? void 0 : _j.email) {
return parsed.user.email;
}
}
}
}
catch (e) {
// Silent fail, continue to next check
}
// 7. Check for common user object patterns in localStorage
try {
const commonKeys = ["user", "currentUser", "auth", "session", "userData"];
for (const key of commonKeys) {
const data = localStorage.getItem(key) || sessionStorage.getItem(key);
if (data) {
const parsed = JSON.parse(data);
if (parsed === null || parsed === void 0 ? void 0 : parsed.email) {
return parsed.email;
}
if ((_k = parsed === null || parsed === void 0 ? void 0 : parsed.user) === null || _k === void 0 ? void 0 : _k.email) {
return parsed.user.email;
}
}
}
}
catch (e) {
// Silent fail
}
// 8. Check cookies as last resort
try {
if (typeof document !== "undefined" && document.cookie) {
// Look for email in cookies
const emailMatch = document.cookie.match(/email=([^;]+)/);
if (emailMatch && emailMatch[1]) {
const decodedEmail = decodeURIComponent(emailMatch[1]);
// Basic email validation
if (decodedEmail.includes("@") && decodedEmail.includes(".")) {
return decodedEmail;
}
}
}
}
catch (e) {
// Silent fail
}
return null;
}
class SessionRecorder {
constructor(config, sessionId, recordingConfig) {
this.events = [];
this.isRecording = false;
this.config = config;
this.sessionId = sessionId;
this.recordingConfig = {
maxDuration: (recordingConfig === null || recordingConfig === void 0 ? void 0 : recordingConfig.maxDuration) || 5 * 60 * 1000, // 5 minutes
checkoutEveryNms: (recordingConfig === null || recordingConfig === void 0 ? void 0 : recordingConfig.checkoutEveryNms) || 30 * 1000, // 30 seconds
blockClass: (recordingConfig === null || recordingConfig === void 0 ? void 0 : recordingConfig.blockClass) || "mentiq-block",
ignoreClass: (recordingConfig === null || recordingConfig === void 0 ? void 0 : recordingConfig.ignoreClass) || "mentiq-ignore",
maskAllInputs: (recordingConfig === null || recordingConfig === void 0 ? void 0 : recordingConfig.maskAllInputs) !== undefined
? recordingConfig.maskAllInputs
: true,
maskTextClass: (recordingConfig === null || recordingConfig === void 0 ? void 0 : recordingConfig.maskTextClass) || "mentiq-mask",
inlineStylesheet: (recordingConfig === null || recordingConfig === void 0 ? void 0 : recordingConfig.inlineStylesheet) !== undefined
? recordingConfig.inlineStylesheet
: true,
collectFonts: (recordingConfig === null || recordingConfig === void 0 ? void 0 : recordingConfig.collectFonts) !== undefined
? recordingConfig.collectFonts
: true,
sampling: (recordingConfig === null || recordingConfig === void 0 ? void 0 : recordingConfig.sampling) || {
mousemove: 50, // Sample every 50ms
mouseInteraction: true,
scroll: 150, // Sample every 150ms
input: "last", // Only record last input value
},
};
}
start() {
if (this.isRecording) {
if (this.config.debug) {
console.warn("Session recording is already active");
}
return;
}
if (typeof window === "undefined") {
if (this.config.debug) {
console.warn("Session recording is only available in browser environments");
}
return;
}
try {
this.events = [];
this.recordingStartTime = Date.now();
this.isRecording = true;
// Dynamically import rrweb
import('rrweb')
.then(({ record }) => {
// Start recording with rrweb
this.stopRecording = record({
emit: (event) => {
this.events.push(event);
// Check if we've exceeded max duration
if (this.recordingStartTime &&
Date.now() - this.recordingStartTime >
(this.recordingConfig.maxDuration || 300000)) {
this.stop();
}
},
checkoutEveryNms: this.recordingConfig.checkoutEveryNms,
blockClass: this.recordingConfig.blockClass,
ignoreClass: this.recordingConfig.ignoreClass,
maskAllInputs: this.recordingConfig.maskAllInputs,
maskTextClass: this.recordingConfig.maskTextClass,
inlineStylesheet: this.recordingConfig.inlineStylesheet,
collectFonts: this.recordingConfig.collectFonts,
sampling: this.recordingConfig.sampling,
});
// Setup periodic upload every 10 seconds
this.uploadInterval = setInterval(() => {
this.uploadRecording();
}, 10000);
if (this.config.debug) {
console.log("Session recording started", {
sessionId: this.sessionId,
});
}
})
.catch((error) => {
this.isRecording = false;
if (this.config.debug) {
console.error("Failed to load rrweb:", error);
console.warn("Please install rrweb: npm install rrweb");
}
});
}
catch (error) {
this.isRecording = false;
if (this.config.debug) {
console.error("Failed to start session recording:", error);
}
}
}
stop() {
if (!this.isRecording) {
return;
}
try {
// Stop the recording
if (this.stopRecording) {
this.stopRecording();
this.stopRecording = undefined;
}
// Clear upload interval
if (this.uploadInterval) {
clearInterval(this.uploadInterval);
this.uploadInterval = undefined;
}
// Upload remaining events
this.uploadRecording(true);
this.isRecording = false;
this.recordingStartTime = undefined;
if (this.config.debug) {
console.log("Session recording stopped", { sessionId: this.sessionId });
}
}
catch (error) {
if (this.config.debug) {
console.error("Failed to stop session recording:", error);
}
}
}
async uploadRecording(isFinal = false) {
if (this.events.length === 0) {
return;
}
// Get events to upload
const eventsToUpload = [...this.events];
this.events = [];
try {
const endpoint = `${this.config.endpoint || "https://api.mentiq.io"}/api/v1/sessions/${this.sessionId}/recordings`;
// Calculate duration in seconds (backend expects seconds)
const durationMs = this.recordingStartTime
? Date.now() - this.recordingStartTime
: 0;
const duration = Math.floor(durationMs / 1000);
// Get start URL (current or initial page)
const startUrl = typeof window !== "undefined" ? window.location.href : "";
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `ApiKey ${this.config.apiKey}`,
"X-Project-ID": this.config.projectId,
},
body: JSON.stringify({
events: eventsToUpload,
duration: duration,
start_url: startUrl,
user_id: this.config.userId || null,
}),
});
if (!response.ok) {
// If upload fails, put events back for retry
this.events = [...eventsToUpload, ...this.events];
if (this.config.debug) {
console.error("Failed to upload recording:", response.statusText);
}
}
else {
if (this.config.debug) {
console.log("Recording uploaded successfully", {
eventCount: eventsToUpload.length,
isFinal,
});
}
}
}
catch (error) {
// If upload fails, put events back for retry
this.events = [...eventsToUpload, ...this.events];
if (this.config.debug) {
console.error("Error uploading recording:", error);
}
}
}
pause() {
if (!this.isRecording) {
return;
}
if (this.stopRecording) {
this.stopRecording();
this.stopRecording = undefined;
}
if (this.uploadInterval) {
clearInterval(this.uploadInterval);
this.uploadInterval = undefined;
}
this.isRecording = false;
if (this.config.debug) {
console.log("Session recording paused");
}
}
resume() {
if (this.isRecording) {
return;
}
this.start();
if (this.config.debug) {
console.log("Session recording resumed");
}
}
isActive() {
return this.isRecording;
}
getEventCount() {
return this.events.length;
}
clearEvents() {
this.events = [];
}
}
class Analytics {
constructor(config) {
this.eventQueue = [];
this.providers = [];
this.isInitialized = false;
this.heatmapListeners = [];
this.errorListeners = [];
this.funnelState = new Map();
this.funnelAbandonmentTimer = new Map();
this.config = {
...config,
endpoint: config.endpoint || "https://api.mentiq.io",
debug: config.debug || false,
sessionTimeout: config.sessionTimeout || 30 * 60 * 1000, // 30 minutes
batchSize: config.batchSize || 20,
flushInterval: config.flushInterval || 10000, // 10 seconds
enableAutoPageTracking: config.enableAutoPageTracking !== false,
enablePerformanceTracking: config.enablePerformanceTracking || false,
enableHeatmapTracking: config.enableHeatmapTracking || false,
enableSessionRecording: config.enableSessionRecording || false,
enableErrorTracking: config.enableErrorTracking || false,
maxQueueSize: config.maxQueueSize || 1000,
retryAttempts: config.retryAttempts || 3,
retryDelay: config.retryDelay || 1000,
};
this.sessionData = this.initializeSession();
this.initialize();
}
initializeSession() {
const channel = typeof window !== "undefined" ? detectChannel() : "direct";
return {
startTime: Date.now(),
pageViews: 0,
clicks: 0,
scrollDepth: 0,
maxScrollDepth: 0,
isActive: true,
events: [],
scrollEvents: 0,
clickEvents: 0,
pageChanges: 0,
engagementScore: 0,
bounceLikelihood: 0,
channel: channel,
};
}
initialize() {
if (this.isInitialized)
return;
// Set initial user ID if provided
if (this.config.userId) {
setUserId(this.config.userId);
}
// Auto-detect email from auth session if available
if (typeof window !== "undefined") {
const detectedEmail = getUserEmail();
if (detectedEmail && !localStorage.getItem("mentiq_user_email")) {
// Store detected email for future use
try {
localStorage.setItem("mentiq_user_email", detectedEmail);
if (this.config.debug) {
console.log("MentiQ: Auto-detected user email from auth session:", detectedEmail);
}
}
catch (e) {
console.warn("Failed to store auto-detected email", e);
}
}
}
// Add default provider
this.addProvider({
name: "default",
track: this.sendEvent.bind(this),
});
// Setup auto flush
this.setupAutoFlush();
// Setup session tracking
this.setupSessionTracking();
// Setup auto page tracking
if (this.config.enableAutoPageTracking && typeof window !== "undefined") {
this.setupAutoPageTracking();
}
// Setup performance tracking
if (this.config.enablePerformanceTracking &&
typeof window !== "undefined") {
this.setupPerformanceTracking();
}
// Setup heatmap tracking
if (this.config.enableHeatmapTracking && typeof window !== "undefined") {
this.setupHeatmapTracking();
}
// Setup error tracking
if (this.config.enableErrorTracking && typeof window !== "undefined") {
this.setupErrorTracking();
}
// Setup session recording
if (this.config.enableSessionRecording && typeof window !== "undefined") {
this.setupSessionRecording();
}
this.isInitialized = true;
if (this.config.debug) {
console.log("MentiQ Analytics initialized", this.config);
}
}
setupSessionTracking() {
if (typeof window === "undefined")
return;
// Track session activity
const updateSession = () => {
this.sessionData.isActive = true;
this.sessionData.endTime = Date.now();
this.sessionData.duration =
this.sessionData.endTime - this.sessionData.startTime;
// Reset session timeout
if (this.sessionTimer) {
clearTimeout(this.sessionTimer);
}
this.sessionTimer = setTimeout(() => {
this.endSession();
}, this.config.sessionTimeout);
};
// Track user activity
const events = [
"mousedown",
"mousemove",
"keypress",
"scroll",
"touchstart",
];
events.forEach((event) => {
window.addEventListener(event, updateSession, { passive: true });
});
// Track scroll depth and scroll events
const trackScrollDepth = debounce(() => {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
const scrollDepth = Math.round(((scrollTop + windowHeight) / documentHeight) * 100);
this.sessionData.scrollDepth = scrollDepth;
this.sessionData.maxScrollDepth = Math.max(this.sessionData.maxScrollDepth, scrollDepth);
// Increment scroll events counter
this.sessionData.scrollEvents = (this.sessionData.scrollEvents || 0) + 1;
}, 1000);
// Track click events for detailed metrics
const trackClicks = (event) => {
this.sessionData.clickEvents = (this.sessionData.clickEvents || 0) + 1;
this.sessionData.clicks = this.sessionData.clickEvents;
};
window.addEventListener("scroll", trackScrollDepth, { passive: true });
window.addEventListener("click", trackClicks, { passive: true });
// Initialize session timer
updateSession();
}
setupHeatmapTracking() {
if (typeof window === "undefined")
return;
const trackClick = (event) => {
var _a, _b;
const heatmapData = {
x: event.clientX,
y: event.clientY,
element: (_b = (_a = event.target) === null || _a === void 0 ? void 0 : _a.tagName) === null || _b === void 0 ? void 0 : _b.toLowerCase(),
selector: this.getElementSelector(event.target),
action: "click",
viewport: {
width: window.innerWidth,
height: window.innerHeight,
},
};
const analyticsEvent = createEvent("heatmap", "click", {
heatmap: heatmapData,
});
this.enqueueEvent(analyticsEvent);
this.sessionData.clicks++;
};
const trackMouseMove = debounce((event) => {
var _a, _b;
const heatmapData = {
x: event.clientX,
y: event.clientY,
element: (_b = (_a = event.target) === null || _a === void 0 ? void 0 : _a.tagName) === null || _b === void 0 ? void 0 : _b.toLowerCase(),
selector: this.getElementSelector(event.target),
action: "move",
viewport: {
width: window.innerWidth,
height: window.innerHeight,
},
};
const analyticsEvent = createEvent("heatmap", "mouse_move", {
heatmap: heatmapData,
});
this.enqueueEvent(analyticsEvent);
}, 500);
const trackScroll = debounce(() => {
const heatmapData = {
x: window.pageXOffset,
y: window.pageYOffset,
action: "scroll",
viewport: {
width: window.innerWidth,
height: window.innerHeight,
},
};
const analyticsEvent = createEvent("heatmap", "scroll", {
heatmap: heatmapData,
});
this.enqueueEvent(analyticsEvent);
}, 1000);
window.addEventListener("click", trackClick);
window.addEventListener("mousemove", trackMouseMove, { passive: true });
window.addEventListener("scroll", trackScroll, { passive: true });
// Store listeners for cleanup
this.heatmapListeners.push(() => window.removeEventListener("click", trackClick), () => window.removeEventListener("mousemove", trackMouseMove), () => window.removeEventListener("scroll", trackScroll));
}
setupErrorTracking() {
if (typeof window === "undefined")
return;
const trackJavaScriptError = (event) => {
var _a;
const errorData = {
message: event.message,
stack: (_a = event.error) === null || _a === void 0 ? void 0 : _a.stack,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
type: "javascript",
};
const analyticsEvent = createEvent("error", "javascript_error", {
error: errorData,
});
this.enqueueEvent(analyticsEvent);
};
const trackUnhandledRejection = (event) => {
var _a, _b;
const errorData = {
message: ((_a = event.reason) === null || _a === void 0 ? void 0 : _a.message) || String(event.reason),
stack: (_b = event.reason) === null || _b === void 0 ? void 0 : _b.stack,
type: "unhandledrejection",
};
const analyticsEvent = createEvent("error", "unhandled_rejection", {
error: errorData,
});
this.enqueueEvent(analyticsEvent);
};
window.addEventListener("error", trackJavaScriptError);
window.addEventListener("unhandledrejection", trackUnhandledRejection);
// Store listeners for cleanup
this.errorListeners.push(() => window.removeEventListener("error", trackJavaScriptError), () => window.removeEventListener("unhandledrejection", trackUnhandledRejection));
}
getElementSelector(element) {
if (!element)
return "";
const id = element.id ? `#${element.id}` : "";
const className = element.className
? `.${element.className.split(" ").join(".")}`
: "";
const tagName = element.tagName.toLowerCase();
return `${tagName}${id}${className}`;
}
endSession() {
this.sessionData.isActive = false;
this.sessionData.endTime = Date.now();
this.sessionData.duration =
this.sessionData.endTime - this.sessionData.startTime;
// Send session data
const analyticsEvent = createEvent("session", "session_end", {
session: this.sessionData,
});
this.enqueueEvent(analyticsEvent);
// Start new session
this.sessionData = this.initializeSession();
}
setupAutoFlush() {
this.flushTimer = setInterval(() => {
if (this.eventQueue.length > 0) {
this.flush();
}
}, this.config.flushInterval);
}
setupAutoPageTracking() {
// Track initial page load
this.page();
// Track pushState/replaceState navigation (SPA)
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;
history.pushState = (...args) => {
originalPushState.apply(history, args);
setTimeout(() => this.page(), 0);
};
history.replaceState = (...args) => {
originalReplaceState.apply(history, args);
setTimeout(() => this.page(), 0);
};
// Track popstate (back/forward navigation)
window.addEventListener("popstate", () => {
setTimeout(() => this.page(), 0);
});
}
setupPerformanceTracking() {
if ("performance" in window && "getEntriesByType" in performance) {
window.addEventListener("load", () => {
setTimeout(() => {
const navigation = performance.getEntriesByType("navigation")[0];
if (navigation) {
this.track("page_performance", {
load_time: navigation.loadEventEnd - navigation.fetchStart,
dom_ready: navigation.domContentLoadedEventEnd - navigation.fetchStart,
first_byte: navigation.responseStart - navigation.fetchStart,
dns_lookup: navigation.domainLookupEnd - navigation.domainLookupStart,
});
}
}, 0);
});
}
}
addProvider(provider) {
this.providers.push(provider);
}
track(event, properties) {
const analyticsEvent = createEvent("track", event, properties);
this.enqueueEvent(analyticsEvent);
}
page(properties) {
const analyticsEvent = createEvent("page", undefined, properties);
this.enqueueEvent(analyticsEvent);
// Update session page tracking
this.sessionData.pageViews++;
this.sessionData.pageChanges = (this.sessionData.pageChanges || 0) + 1;
}
identify(userId, traits) {
setUserId(userId);
// Store email if provided
if ((traits === null || traits === void 0 ? void 0 : traits.email) && typeof window !== "undefined") {
try {
localStorage.setItem("mentiq_user_email", traits.email);
}
catch (e) {
console.warn("Failed to store user email", e);
}
}
const analyticsEvent = createEvent("identify", undefined, traits);
this.enqueueEvent(analyticsEvent);
}
alias(newId, previousId) {
const analyticsEvent = createEvent("alias", undefined, {
newId,
previousId: previousId || getUserId(),
});
this.enqueueEvent(analyticsEvent);
}
reset() {
clearUserId();
this.eventQueue = [];
if (this.config.debug) {
console.log("MentiQ Analytics reset");
}
}
async flush() {
if (this.eventQueue.length === 0)
return;
const events = [...this.eventQueue];
this.eventQueue = [];
const batches = this.createBatches(events);
try {
await Promise.all(batches === null || batches === void 0 ? void 0 : batches.map((batch) => {
var _a;
return Promise.all((_a = this.providers) === null || _a === void 0 ? void 0 : _a.map((provider) => this.sendBatch(provider, batch)));
}));
if (this.config.debug) {
console.log("MentiQ Analytics flushed", events.length, "events");
}
}
catch (error) {
// Re-queue events on failure (they're already handled in sendBatch)
if (this.config.debug) {
console.error("MentiQ Analytics flush failed:", error);
}
throw error;
}
}
setUserId(userId) {
setUserId(userId);
}
getUserId() {
return getUserId();
}
getAnonymousId() {
return getAnonymousId();
}
getSessionId() {
return getSessionId();
}
getSessionData() {
return { ...this.sessionData };
}
getActiveSession() {
return this.calculateDetailedSessionMetrics();
}
calculateEngagementScore() {
const { clicks, scrollDepth, duration, pageViews, clickEvents, scrollEvents, } = this.sessionData;
// Calculate session duration in minutes
const sessionDuration = duration
? duration / 1000 / 60
: (Date.now() - this.sessionData.startTime) / 1000 / 60;
// Weighted engagement score calculation
let score = 0;
// Click engagement (up to 25 points)
const clickScore = Math.min((clickEvents || clicks) * 2, 25);
score += clickScore;
// Scroll engagement (up to 20 points)
const scrollScore = Math.min(scrollDepth || 0, 20);
score += scrollScore;
// Time engagement (up to 30 points) - diminishing returns after 10 minutes
const timeScore = Math.min(sessionDuration * 3, 30);
score += timeScore;
// Page view engagement (up to 20 points)
const pageScore = Math.min((pageViews || 0) * 4, 20);
score += pageScore;
// Scroll event engagement (up to 5 points)
const scrollEventScore = Math.min((scrollEvents || 0) * 0.5, 5);
score += scrollEventScore;
// Normalize to 0-100 scale
const finalScore = Math.min(score, 100);
// Update session data
this.sessionData.engagementScore = finalScore;
return finalScore;
}
calculateDetailedSessionMetrics() {
const currentTime = Date.now();
const duration = currentTime - this.sessionData.startTime;
// Update detailed metrics
const updatedSessionData = {
...this.sessionData,
duration,
endTime: this.sessionData.isActive ? undefined : currentTime,
engagementScore: this.calculateEngagementScore(),
bounceLikelihood: this.calculateBounceLikelihood(),
};
return updatedSessionData;
}
calculateBounceLikelihood() {
const { pageViews, clickEvents, scrollEvents, scrollDepth } = this.sessionData;
const sessionDuration = (Date.now() - this.sessionData.startTime) / 1000; // in seconds
// Factors that reduce bounce likelihood
let bounceScore = 100; // Start with 100% bounce likelihood
// Reduce bounce likelihood based on engagement
if (pageViews > 1)
bounceScore -= 30; // Multiple pages viewed
if ((clickEvents || 0) > 3)
bounceScore -= 20; // Multiple clicks
if ((scrollEvents || 0) > 5)
bounceScore -= 15; // Active scrolling
if ((scrollDepth || 0) > 50)
bounceScore -= 15; // Scrolled past 50%
if (sessionDuration > 30)
bounceScore -= 10; // Stayed more than 30 seconds
if (sessionDuration > 120)
bounceScore -= 10; // Stayed more than 2 minutes
// Ensure bounce score is between 0 and 100
bounceScore = Math.max(0, Math.min(100, bounceScore));
// Update session data
this.sessionData.bounceLikelihood = bounceScore;
return bounceScore;
}
trackCustomError(error, properties) {
const errorData = {
message: typeof error === "string" ? error : error.message,
stack: typeof error === "object" ? error.stack : undefined,
type: "custom",
};
const analyticsEvent = createEvent("error", "custom_error", {
error: errorData,
...properties,
});
this.enqueueEvent(analyticsEvent);
}
trackPerformance(performanceData) {
const analyticsEvent = createEvent("track", "performance", {
performance: performanceData,
});
this.enqueueEvent(analyticsEvent);
}
trackFeatureUsage(featureName, properties) {
this.track("feature_used", {
feature_name: featureName,
...properties,
});
}
trackFunnelStep(funnelName, stepName, stepIndex, properties) {
this.track("funnel_step", {
funnel_name: funnelName,
step_name: stepName,
step_index: stepIndex,
...properties,
});
}
completeFunnel(funnelName, properties) {
this.track("funnel_completed", {
funnel_name: funnelName,
...properties,
});
// Clear funnel state on completion
this.funnelState.delete(funnelName);
const timer = this.funnelAbandonmentTimer.get(funnelName);
if (timer) {
clearTimeout(timer);
this.funnelAbandonmentTimer.delete(funnelName);
}
}
startFunnel(funnelName, properties) {
// Clear any existing funnel state
this.clearFunnelState(funnelName);
this.funnelState.set(funnelName, {
currentStep: 0,
startTime: Date.now(),
steps: [],
});
this.trackFunnelStep(funnelName, "start", 0, properties);
// Set abandonment timer (5 minutes default)
const abandonmentTimeout = setTimeout(() => {
this.abandonFunnel(funnelName, "timeout");
}, 5 * 60 * 1000);
this.funnelAbandonmentTimer.set(funnelName, abandonmentTimeout);
if (this.config.debug) {
console.log(`MentiQ Analytics: Funnel "${funnelName}" started`);
}
}
advanceFunnel(funnelName, stepName, properties) {
const state = this.funnelState.get(funnelName);
if (!state) {
if (this.config.debug) {
console.warn(`Funnel ${funnelName} not started`);
}
return;
}
state.currentStep++;
const timeInFunnel = Date.now() - state.startTime;
// Add step to history
state.steps.push(stepName);
this.trackFunnelStep(funnelName, stepName, state.currentStep, {
time_in_funnel: timeInFunnel,
previous_step: state.steps[state.steps.length - 2] || "start",
total_steps_completed: state.currentStep,
...properties,
});
// Reset abandonment timer
this.resetAbandonmentTimer(funnelName);
if (this.config.debug) {
console.log(`MentiQ Analytics: Funnel "${funnelName}" advanced to step ${state.currentStep}: ${stepName}`);
}
}
abandonFunnel(funnelName, reason, properties) {
const state = this.funnelState.get(funnelName);
if (!state)
return;
const timeBeforeAbandon = Date.now() - state.startTime;
this.track("funnel_abandoned", {
funnel_name: funnelName,
abandoned_at_step: state.currentStep,
abandoned_step_name: state.steps[state.steps.length - 1] || "start",
time_before_abandon: timeBeforeAbandon,
abandon_reason: reason || "unknown",
steps_completed_count: state.steps.length,
steps_completed_names: state.steps.join(","),
completion_percentage: this.calculateFunnelCompletion(funnelName, state.currentStep),
...properties,
});
this.clearFunnelState(funnelName);
if (this.config.debug) {
console.log(`MentiQ Analytics: Funnel "${funnelName}" abandoned at step ${state.currentStep}, reason: ${reason}`);
}
}
getFunnelState(funnelName) {
const state = this.funnelState.get(funnelName);
if (!state)
return undefined;
return {
funnelName,
currentStep: state.currentStep,
startTime: state.startTime,
steps: [...state.steps],
isActive: true,
timeInFunnel: Date.now() - state.startTime,
};
}
clearFunnelState(funnelName) {
this.funnelState.delete(funnelName);
const timer = this.funnelAbandonmentTimer.get(funnelName);
if (timer) {
clearTimeout(timer);
this.funnelAbandonmentTimer.delete(funnelName);
}
}
resetAbandonmentTimer(funnelName) {
const timer = this.funnelAbandonmentTimer.get(funnelName);
if (timer) {
clearTimeout(timer);
}
const newTimer = setTimeout(() => {
this.abandonFunnel(funnelName, "timeout");
}, 5 * 60 * 1000);
this.funnelAbandonmentTimer.set(funnelName, newTimer);
}
calculateFunnelCompletion(funnelNam