UNPKG

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
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