/** * Privacy Manager for ConversionIQ SDK * Handles GDPR/CCPA compliance with progressive consent management */ import { ConversionIQConfig, PrivacySettings, DataType } from '../types/index'; import { ShadowDOMManager } from './ShadowDOMManager'; interface ConsentData { version: string; timestamp: number; settings: PrivacySettings; jurisdiction: string; expiresAt: number; } export class PrivacyManager { private config: ConversionIQConfig; private shadowDOM?: ShadowDOMManager; private consentData: ConsentData | null = null; private readonly STORAGE_KEY = 'conversioniq_consent'; private readonly CONSENT_VERSION = '1.0'; private readonly CONSENT_DURATION = 13 * 30 * 24 * 60 * 60 * 1000; // 13 months // Default consent levels private readonly DEFAULT_CONSENT: PrivacySettings = { essential: true, // Always allowed functional: false, // Requires consent analytics: false, // Requires consent marketing: false // Requires consent }; constructor(config: ConversionIQConfig) { this.config = config; // Load existing consent data immediately so canTrack() works before init() this.loadConsentData(); } /** * Initialize privacy manager */ public async init(): Promise { try { // Load existing consent this.loadConsentData(); // Check if we need to show consent UI if (this.needsConsent()) { await this.showConsentUI(); } // Clean up expired consent data this.cleanupExpiredData(); } catch (error) { console.warn('ConversionIQ: Privacy manager initialization failed:', error); // Fall back to essential cookies only this.consentData = { version: this.CONSENT_VERSION, timestamp: Date.now(), settings: { ...this.DEFAULT_CONSENT }, jurisdiction: 'unknown', expiresAt: Date.now() + this.CONSENT_DURATION }; } } /** * Check if tracking is allowed for a specific data type */ public canTrack(dataType: DataType): boolean { if (!this.consentData) { // If no consent data, only allow essential return dataType === 'essential'; } // Check if consent has expired if (Date.now() > this.consentData.expiresAt) { this.clearConsentData(); return dataType === 'essential'; } return this.consentData.settings[dataType] || false; } /** * Update consent settings */ public updateConsent(settings: Partial): void { if (!this.consentData) { this.consentData = { version: this.CONSENT_VERSION, timestamp: Date.now(), settings: { ...this.DEFAULT_CONSENT }, jurisdiction: this.detectJurisdiction(), expiresAt: Date.now() + this.CONSENT_DURATION }; } // Update settings this.consentData.settings = { ...this.consentData.settings, ...settings, essential: true // Essential is always true }; this.consentData.timestamp = Date.now(); this.saveConsentData(); // Hide consent UI if it's visible if (this.shadowDOM) { this.hideConsentUI(); } } /** * Get current consent status */ public getConsentStatus(): PrivacySettings { return this.consentData?.settings || { ...this.DEFAULT_CONSENT }; } /** * Check if consent UI needs to be shown */ public needsConsent(): boolean { // No consent data exists if (!this.consentData) { return this.requiresConsent(); } // Consent has expired if (Date.now() > this.consentData.expiresAt) { return this.requiresConsent(); } // Version has changed if (this.consentData.version !== this.CONSENT_VERSION) { return this.requiresConsent(); } return false; } /** * Check if jurisdiction requires consent */ private requiresConsent(): boolean { const jurisdiction = this.detectJurisdiction(); // EU countries require GDPR consent const gdprCountries = [ 'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', 'IS', 'LI', 'NO', 'GB' // UK has GDPR-equivalent laws (UK GDPR) ]; // California requires CCPA consent const ccpaRegions = ['CA', 'US-CA']; return gdprCountries.includes(jurisdiction) || jurisdiction === 'EU' || // Generic EU detection ccpaRegions.includes(jurisdiction) || this.config.enablePrivacyMode === true; } /** * Detect user's jurisdiction for privacy compliance */ private detectJurisdiction(): string { // Try to detect from timezone try { const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; // European timezones (simplified) if (timezone.startsWith('Europe/')) { const country = timezone.split('/')[1]; const countryMap: Record = { 'London': 'GB', 'Berlin': 'DE', 'Paris': 'FR', 'Rome': 'IT', 'Madrid': 'ES', 'Amsterdam': 'NL', 'Vienna': 'AT', 'Brussels': 'BE', 'Prague': 'CZ', 'Warsaw': 'PL', 'Stockholm': 'SE', 'Oslo': 'NO' }; return countryMap[country] || 'EU'; } // US timezones if (timezone.includes('Los_Angeles') || timezone.includes('San_Francisco')) { return 'US-CA'; } if (timezone.startsWith('America/')) { return 'US'; } } catch (error) { // Fallback detection methods } // Try to detect from language const language = navigator.language || navigator.languages?.[0] || ''; if (language.startsWith('en-GB') || language.startsWith('en-EU')) { return 'EU'; } if (language.startsWith('en-US')) { return 'US'; } return 'unknown'; } /** * Show consent UI */ private async showConsentUI(): Promise { if (!this.shadowDOM) { this.shadowDOM = new ShadowDOMManager(); } const jurisdiction = this.detectJurisdiction(); const isGDPR = jurisdiction.startsWith('EU') || jurisdiction === 'GB'; const isCCPA = jurisdiction === 'US-CA'; const consentModal = this.shadowDOM.createModal({ title: isGDPR ? 'Cookie Preferences' : 'Privacy Preferences', content: this.createConsentContent(isGDPR, isCCPA), persistent: true, className: 'consent-modal', allowHTML: true // Consent UI contains safe internal HTML }); // Add event listeners this.attachConsentHandlers(consentModal, isGDPR, isCCPA); // Show modal this.shadowDOM.show(consentModal); } /** * Create consent UI content */ private createConsentContent(isGDPR: boolean, isCCPA: boolean): string { const title = isGDPR ? 'We value your privacy' : 'Your Privacy Choices'; const description = isGDPR ? 'We use cookies and similar technologies to provide, protect and improve our services. You can choose which types of cookies you allow.' : 'We collect and use personal information to provide and improve our services. You can control how we use this information.'; return ` `; } /** * Attach event handlers to consent UI */ private attachConsentHandlers(modal: HTMLElement, isGDPR: boolean, isCCPA: boolean): void { const shadowRoot = modal.getRootNode() as ShadowRoot; // Accept all button const acceptAllBtn = shadowRoot.getElementById('accept-all'); acceptAllBtn?.addEventListener('click', () => { this.updateConsent({ essential: true, functional: true, analytics: true, marketing: true }); }); // Reject all button (GDPR only) if (isGDPR) { const rejectAllBtn = shadowRoot.getElementById('reject-all'); rejectAllBtn?.addEventListener('click', () => { this.updateConsent({ essential: true, functional: false, analytics: false, marketing: false }); }); } // Save selected preferences const saveBtn = shadowRoot.getElementById('accept-selected'); saveBtn?.addEventListener('click', () => { const functional = (shadowRoot.getElementById('functional') as HTMLInputElement)?.checked || false; const analytics = (shadowRoot.getElementById('analytics') as HTMLInputElement)?.checked || false; const marketing = (shadowRoot.getElementById('marketing') as HTMLInputElement)?.checked || false; this.updateConsent({ essential: true, functional, analytics, marketing }); }); } /** * Hide consent UI */ private hideConsentUI(): void { if (this.shadowDOM) { this.shadowDOM.hideAll(); } } /** * Load consent data from storage */ private loadConsentData(): void { try { const stored = localStorage.getItem(this.STORAGE_KEY); if (stored) { const data = JSON.parse(stored) as ConsentData; // Validate data structure if (data.version && data.timestamp && data.settings && data.expiresAt) { this.consentData = data; } } } catch (error) { // Invalid data in storage, ignore } } /** * Save consent data to storage */ private saveConsentData(): void { if (!this.consentData) { return; } try { localStorage.setItem(this.STORAGE_KEY, JSON.stringify(this.consentData)); } catch (error) { // Storage might be full or disabled, continue without persistence console.warn('ConversionIQ: Could not save consent data:', error); } } /** * Clear consent data */ private clearConsentData(): void { this.consentData = null; try { localStorage.removeItem(this.STORAGE_KEY); } catch (error) { // Ignore storage errors } } /** * Clean up expired consent data */ private cleanupExpiredData(): void { try { const keys = Object.keys(localStorage); const now = Date.now(); keys.forEach(key => { if (key.startsWith('conversioniq_') && key.includes('_consent_')) { try { const data = JSON.parse(localStorage.getItem(key) || '{}'); if (data.expiresAt && now > data.expiresAt) { localStorage.removeItem(key); } } catch (error) { // Invalid data, remove it localStorage.removeItem(key); } } }); } catch (error) { // Ignore cleanup errors } } /** * Get privacy-compliant user ID */ public getPrivacyCompliantUserId(): string | null { if (!this.canTrack('analytics')) { return null; } // Generate or retrieve anonymous user ID const key = 'conversioniq_user_id'; try { let userId = localStorage.getItem(key); if (!userId) { userId = 'ciq_' + Date.now().toString(36) + Math.random().toString(36).substring(2); localStorage.setItem(key, userId); } return userId; } catch (error) { // Generate session-only ID if storage fails return 'ciq_session_' + Date.now().toString(36) + Math.random().toString(36).substring(2); } } /** * Clean up privacy manager */ public destroy(): void { this.hideConsentUI(); if (this.shadowDOM) { this.shadowDOM.destroy(); } } }