/** * A/B Testing features for experimentation and optimization * Dynamically imported to reduce core bundle size */ import { ConversionIQConfig } from '../types/index'; interface ABTest { id: string; name: string; variants: ABVariant[]; allocation: Record; status: 'draft' | 'running' | 'paused' | 'completed'; targetingRules?: TargetingRule[]; conversionGoals: string[]; } interface ABVariant { id: string; name: string; control: boolean; traffic: number; changes: ABChange[]; } interface ABChange { type: 'css' | 'html' | 'javascript' | 'redirect'; selector?: string; property?: string; value: string; } interface TargetingRule { type: 'url' | 'device' | 'traffic' | 'attribute'; operator: 'equals' | 'contains' | 'matches' | 'greater' | 'less'; value: string | number; } interface TestResult { testId: string; variantId: string; userId: string; sessionId: string; assignedAt: number; conversions: Array<{ goal: string; timestamp: number; value?: number; }>; } export class ABTesting { private config: ConversionIQConfig; private sdk: any; private activeTests: Map = new Map(); private userAssignments: Map = new Map(); private testResults: Map = new Map(); private storageKey = 'conversioniq_ab_tests'; constructor(config: ConversionIQConfig, sdk: any) { this.config = config; this.sdk = sdk; this.loadStoredAssignments(); } /** * Initialize A/B testing */ public async init(): Promise { try { // Load active tests from API await this.loadActiveTests(); // Process tests for current page this.processActiveTests(); if (this.config.debug) { console.log('ConversionIQ: A/B Testing initialized', { activeTests: this.activeTests.size, assignments: this.userAssignments.size }); } } catch (error) { if (this.config.debug) { console.error('ConversionIQ: A/B Testing initialization failed', error); } } } /** * Create a new A/B test */ public createTest(test: Omit): string { const testId = this.generateTestId(); const newTest: ABTest = { ...test, id: testId, status: 'draft' }; this.activeTests.set(testId, newTest); if (this.config.debug) { console.log('ConversionIQ: A/B Test created', newTest); } return testId; } /** * Start an A/B test */ public startTest(testId: string): boolean { const test = this.activeTests.get(testId); if (!test) { return false; } test.status = 'running'; this.processTest(test); this.sdk.track('ab_test_started', { testId: test.id, testName: test.name, variants: test.variants.length }); if (this.config.debug) { console.log('ConversionIQ: A/B Test started', test.name); } return true; } /** * Stop an A/B test */ public stopTest(testId: string): boolean { const test = this.activeTests.get(testId); if (!test) { return false; } test.status = 'completed'; this.sdk.track('ab_test_stopped', { testId: test.id, testName: test.name }); if (this.config.debug) { console.log('ConversionIQ: A/B Test stopped', test.name); } return true; } /** * Get user's variant for a test */ public getVariant(testId: string): string | null { const test = this.activeTests.get(testId); if (!test || test.status !== 'running') { return null; } // Check if user is already assigned const existingAssignment = this.userAssignments.get(testId); if (existingAssignment) { return existingAssignment; } // Check targeting rules if (test.targetingRules && !this.matchesTargeting(test.targetingRules)) { return null; } // Assign user to variant const variantId = this.assignUserToVariant(test); if (variantId) { this.userAssignments.set(testId, variantId); this.storeAssignments(); // Track assignment this.sdk.track('ab_test_assignment', { testId: test.id, testName: test.name, variantId, variantName: test.variants.find(v => v.id === variantId)?.name }); // Create test result record this.testResults.set(testId, { testId, variantId, userId: this.sdk.userId || 'anonymous', sessionId: this.sdk.sessionId, assignedAt: Date.now(), conversions: [] }); } return variantId; } /** * Track conversion for A/B test */ public trackConversion(testId: string, goal: string, value?: number): void { const result = this.testResults.get(testId); if (!result) { return; } result.conversions.push({ goal, timestamp: Date.now(), value }); this.sdk.track('ab_test_conversion', { testId, variantId: result.variantId, goal, value, assignedAt: result.assignedAt, convertedAt: Date.now(), timeToConvert: Date.now() - result.assignedAt }); if (this.config.debug) { console.log('ConversionIQ: A/B Test conversion tracked', { testId, goal, value }); } } /** * Apply variant changes to the page */ public applyVariant(testId: string, variantId: string): void { const test = this.activeTests.get(testId); if (!test) { return; } const variant = test.variants.find(v => v.id === variantId); if (!variant || variant.control) { return; } variant.changes.forEach(change => { this.applyChange(change); }); if (this.config.debug) { console.log('ConversionIQ: Variant applied', { testId, variantId, changes: variant.changes.length }); } } /** * Get test results */ public getTestResults(testId: string): TestResult | null { return this.testResults.get(testId) || null; } /** * Get all active tests */ public getActiveTests(): ABTest[] { return Array.from(this.activeTests.values()).filter(test => test.status === 'running'); } /** * Load active tests from API */ private async loadActiveTests(): Promise { try { const response = await fetch(`${this.config.endpoint}/api/v1/ab-tests`, { headers: { 'Authorization': `Bearer ${this.config.apiKey}` } }); if (response.ok) { const tests = await response.json(); tests.forEach((test: ABTest) => { this.activeTests.set(test.id, test); }); } } catch (error) { if (this.config.debug) { console.warn('ConversionIQ: Failed to load A/B tests from API', error); } } } /** * Process all active tests */ private processActiveTests(): void { this.activeTests.forEach(test => { if (test.status === 'running') { this.processTest(test); } }); } /** * Process a single test */ private processTest(test: ABTest): void { const variantId = this.getVariant(test.id); if (variantId) { this.applyVariant(test.id, variantId); } } /** * Check if user matches targeting rules */ private matchesTargeting(rules: TargetingRule[]): boolean { return rules.every(rule => { switch (rule.type) { case 'url': return this.matchesUrlRule(rule); case 'device': return this.matchesDeviceRule(rule); case 'traffic': return this.matchesTrafficRule(rule); default: return true; } }); } /** * Check URL targeting rule */ private matchesUrlRule(rule: TargetingRule): boolean { const currentUrl = window.location.href; const value = rule.value as string; switch (rule.operator) { case 'equals': return currentUrl === value; case 'contains': return currentUrl.includes(value); case 'matches': return new RegExp(value).test(currentUrl); default: return false; } } /** * Check device targeting rule */ private matchesDeviceRule(rule: TargetingRule): boolean { const userAgent = navigator.userAgent.toLowerCase(); const value = (rule.value as string).toLowerCase(); switch (rule.operator) { case 'equals': case 'contains': return userAgent.includes(value); case 'matches': return new RegExp(value).test(userAgent); default: return false; } } /** * Check traffic targeting rule */ private matchesTrafficRule(rule: TargetingRule): boolean { const value = rule.value as number; const random = Math.random() * 100; switch (rule.operator) { case 'less': return random < value; case 'greater': return random > value; default: return false; } } /** * Assign user to variant based on traffic allocation */ private assignUserToVariant(test: ABTest): string | null { const userId = this.sdk.userId || this.generateAnonymousId(); const hash = this.hashString(userId + test.id); const random = (hash % 100) / 100; let cumulative = 0; for (const variant of test.variants) { cumulative += variant.traffic / 100; if (random <= cumulative) { return variant.id; } } return null; } /** * Apply a single change to the page */ private applyChange(change: ABChange): void { switch (change.type) { case 'css': this.applyCSSChange(change); break; case 'html': this.applyHTMLChange(change); break; case 'javascript': this.applyJavaScriptChange(change); break; case 'redirect': this.applyRedirect(change); break; } } /** * Apply CSS change */ private applyCSSChange(change: ABChange): void { if (!change.selector || !change.property) { return; } const elements = document.querySelectorAll(change.selector); elements.forEach(element => { (element as HTMLElement).style.setProperty(change.property!, change.value); }); } /** * Apply HTML change */ private applyHTMLChange(change: ABChange): void { if (!change.selector) { return; } const elements = document.querySelectorAll(change.selector); elements.forEach(element => { element.innerHTML = change.value; }); } /** * Apply JavaScript change */ private applyJavaScriptChange(change: ABChange): void { try { // Note: Function constructor is required for A/B testing dynamic code execution. // This code is controlled by authenticated administrators in the ConversionIQ dashboard, // not by end users, making it safe for this use case. const script = new Function(change.value); // eslint-disable-line no-new-func script(); } catch (error) { if (this.config.debug) { console.error('ConversionIQ: A/B Test JavaScript error', error); } } } /** * Apply redirect */ private applyRedirect(change: ABChange): void { window.location.href = change.value; } /** * Generate test ID */ private generateTestId(): string { return `test_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; } /** * Generate anonymous ID */ private generateAnonymousId(): string { return `anon_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; } /** * Hash string for consistent assignment */ private hashString(str: string): number { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // Convert to 32-bit integer } return Math.abs(hash); } /** * Store user assignments in localStorage */ private storeAssignments(): void { try { const assignments = Object.fromEntries(this.userAssignments); localStorage.setItem(this.storageKey, JSON.stringify(assignments)); } catch (error) { if (this.config.debug) { console.warn('ConversionIQ: Failed to store A/B test assignments', error); } } } /** * Load stored assignments from localStorage */ private loadStoredAssignments(): void { try { const stored = localStorage.getItem(this.storageKey); if (stored) { const assignments = JSON.parse(stored); this.userAssignments = new Map(Object.entries(assignments)); } } catch (error) { if (this.config.debug) { console.warn('ConversionIQ: Failed to load stored A/B test assignments', error); } } } }