UNPKG

@tinytapanalytics/sdk

Version:

Behavioral psychology platform that detects visitor frustration, predicts abandonment, and helps you save at-risk conversions in real-time

421 lines (353 loc) 11.7 kB
/** * Auto-tracking features for automatic event collection * Dynamically imported to reduce core bundle size */ import { TinyTapAnalyticsConfig } from '../types/index'; export class AutoTracking { private config: TinyTapAnalyticsConfig; private sdk: any; private observers: Set<MutationObserver | IntersectionObserver> = new Set(); private listeners: Set<{ element: EventTarget; event: string; handler: EventListener }> = new Set(); private isActive = false; constructor(config: TinyTapAnalyticsConfig, sdk: any) { this.config = config; this.sdk = sdk; } /** * Start auto-tracking */ public start(): void { if (this.isActive) { return; } this.isActive = true; this.setupClickTracking(); this.setupFormTracking(); this.setupScrollTracking(); this.setupElementVisibilityTracking(); this.setupPageEngagementTracking(); this.setupErrorTracking(); if (this.config.debug) { console.log('TinyTapAnalytics: Auto-tracking started'); } } /** * Stop auto-tracking */ public stop(): void { if (!this.isActive) { return; } this.isActive = false; // Clean up observers this.observers.forEach(observer => observer.disconnect()); this.observers.clear(); // Clean up event listeners this.listeners.forEach(({ element, event, handler }) => { element.removeEventListener(event, handler); }); this.listeners.clear(); if (this.config.debug) { console.log('TinyTapAnalytics: Auto-tracking stopped'); } } /** * Setup automatic click tracking */ private setupClickTracking(): void { const clickHandler = (event: Event) => { const target = event.target as Element; if (this.shouldTrackClick(target)) { this.sdk.trackClick(target, { auto_tracked: true, coordinates: { x: (event as MouseEvent).clientX, y: (event as MouseEvent).clientY }, timestamp: Date.now() }); } }; document.addEventListener('click', clickHandler, true); this.listeners.add({ element: document, event: 'click', handler: clickHandler }); } /** * Setup automatic form tracking */ private setupFormTracking(): void { // Track form submissions const submitHandler = (event: Event) => { const form = event.target as HTMLFormElement; this.sdk.track('form_submit', { form_id: form.id || null, form_action: form.action || null, form_method: form.method || 'get', form_fields: this.getFormFields(form), auto_tracked: true }); }; // Track form field interactions const fieldHandler = (event: Event) => { const field = event.target as HTMLInputElement; const form = field.closest('form'); if (form && this.shouldTrackFormField(field)) { this.sdk.track('form_field_interaction', { field_name: field.name || null, field_type: field.type || null, field_id: field.id || null, form_id: form.id || null, event_type: event.type, auto_tracked: true }); } }; document.addEventListener('submit', submitHandler, true); document.addEventListener('focus', fieldHandler, true); document.addEventListener('blur', fieldHandler, true); this.listeners.add({ element: document, event: 'submit', handler: submitHandler }); this.listeners.add({ element: document, event: 'focus', handler: fieldHandler }); this.listeners.add({ element: document, event: 'blur', handler: fieldHandler }); } /** * Setup scroll depth tracking */ private setupScrollTracking(): void { let maxScrollDepth = 0; let scrollTimeout: number; const scrollHandler = () => { clearTimeout(scrollTimeout); scrollTimeout = window.setTimeout(() => { const scrollDepth = this.getScrollDepth(); if (scrollDepth > maxScrollDepth) { const previousDepth = maxScrollDepth; maxScrollDepth = scrollDepth; // Track scroll milestones const milestones = [25, 50, 75, 90]; const crossedMilestone = milestones.find( milestone => previousDepth < milestone && scrollDepth >= milestone ); if (crossedMilestone) { this.sdk.track('scroll_depth', { depth: crossedMilestone, auto_tracked: true, page_height: document.documentElement.scrollHeight, viewport_height: window.innerHeight }); } } }, 250); }; window.addEventListener('scroll', scrollHandler, { passive: true }); this.listeners.add({ element: window, event: 'scroll', handler: scrollHandler }); } /** * Setup element visibility tracking */ private setupElementVisibilityTracking(): void { if (!('IntersectionObserver' in window)) { return; } const observer = new IntersectionObserver( (entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const element = entry.target; const selector = this.getElementSelector(element); this.sdk.track('element_view', { element: selector, element_type: element.tagName.toLowerCase(), visibility_ratio: entry.intersectionRatio, auto_tracked: true }); } }); }, { threshold: [0.5] } ); // Observe trackable elements const trackableElements = document.querySelectorAll('[data-track-view]'); trackableElements.forEach(element => observer.observe(element)); this.observers.add(observer); // Watch for new elements this.setupDynamicElementTracking(observer); } /** * Setup page engagement tracking */ private setupPageEngagementTracking(): void { let startTime = Date.now(); let isVisible = !document.hidden; let totalEngagementTime = 0; const visibilityHandler = () => { const now = Date.now(); if (document.hidden) { if (isVisible) { totalEngagementTime += now - startTime; isVisible = false; } } else { if (!isVisible) { startTime = now; isVisible = true; } } }; const beforeUnloadHandler = () => { const now = Date.now(); if (isVisible) { totalEngagementTime += now - startTime; } this.sdk.track('page_engagement', { total_time: totalEngagementTime, page_url: window.location.href, auto_tracked: true }); }; document.addEventListener('visibilitychange', visibilityHandler); window.addEventListener('beforeunload', beforeUnloadHandler); this.listeners.add({ element: document, event: 'visibilitychange', handler: visibilityHandler }); this.listeners.add({ element: window, event: 'beforeunload', handler: beforeUnloadHandler }); } /** * Setup error tracking */ private setupErrorTracking(): void { const errorHandler = (event: Event) => { const errorEvent = event as ErrorEvent; this.sdk.track('javascript_error', { message: errorEvent.message, filename: errorEvent.filename, lineno: errorEvent.lineno, colno: errorEvent.colno, stack: errorEvent.error?.stack, auto_tracked: true }); }; const rejectionHandler = (event: Event) => { const rejectionEvent = event as PromiseRejectionEvent; this.sdk.track('promise_rejection', { reason: rejectionEvent.reason?.toString() || 'Unknown', auto_tracked: true }); }; window.addEventListener('error', errorHandler); window.addEventListener('unhandledrejection', rejectionHandler); this.listeners.add({ element: window, event: 'error', handler: errorHandler }); this.listeners.add({ element: window, event: 'unhandledrejection', handler: rejectionHandler }); } /** * Setup tracking for dynamically added elements */ private setupDynamicElementTracking(intersectionObserver: IntersectionObserver): void { if (!('MutationObserver' in window)) { return; } const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { if (node.nodeType === Node.ELEMENT_NODE) { const element = node as Element; // Track new elements with data-track-view if (element.hasAttribute('data-track-view')) { intersectionObserver.observe(element); } // Check child elements const trackableChildren = element.querySelectorAll('[data-track-view]'); trackableChildren.forEach(child => intersectionObserver.observe(child)); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); this.observers.add(observer); } /** * Determine if click should be tracked */ private shouldTrackClick(element: Element): boolean { const tagName = element.tagName.toLowerCase(); // Always track buttons and links if (tagName === 'button' || tagName === 'a') { return true; } // Track elements with data-track attribute if (element.hasAttribute('data-track') || element.hasAttribute('data-track-click')) { return true; } // Track elements with common CTA classes const classList = Array.from(element.classList); const ctaKeywords = ['btn', 'button', 'cta', 'submit', 'checkout', 'buy', 'purchase', 'download']; return ctaKeywords.some(keyword => classList.some(className => className.toLowerCase().includes(keyword)) ); } /** * Determine if form field should be tracked */ private shouldTrackFormField(field: HTMLInputElement): boolean { // Don't track password fields for privacy if (field.type === 'password') { return false; } // Don't track if explicitly disabled if (field.hasAttribute('data-track-disable')) { return false; } return true; } /** * Get form field data */ private getFormFields(form: HTMLFormElement): string[] { const fields: string[] = []; const formData = new FormData(form); for (const [name] of formData.entries()) { if (!fields.includes(name)) { fields.push(name); } } return fields; } /** * Get scroll depth percentage */ private getScrollDepth(): number { const windowHeight = window.innerHeight; const documentHeight = document.documentElement.scrollHeight; const scrollTop = window.pageYOffset || document.documentElement.scrollTop; return Math.round((scrollTop / (documentHeight - windowHeight)) * 100); } /** * Get element selector */ private getElementSelector(element: Element): string { if (element.id) { return `#${element.id}`; } if (element.className) { const classes = Array.from(element.classList).join('.'); return `.${classes}`; } const parent = element.parentElement; if (parent) { const siblings = Array.from(parent.children); const index = siblings.indexOf(element) + 1; return `${element.tagName.toLowerCase()}:nth-child(${index})`; } return element.tagName.toLowerCase(); } /** * Get current auto-tracking statistics */ public getStats(): { isActive: boolean; observers: number; listeners: number; } { return { isActive: this.isActive, observers: this.observers.size, listeners: this.listeners.size }; } }