UNPKG

@tinytapanalytics/sdk

Version:

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

576 lines (504 loc) 17.3 kB
/** * Privacy Manager for TinyTapAnalytics SDK * Handles GDPR/CCPA compliance with progressive consent management */ import { TinyTapAnalyticsConfig, 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: TinyTapAnalyticsConfig; private shadowDOM?: ShadowDOMManager; private consentData: ConsentData | null = null; private readonly STORAGE_KEY = 'tinytapanalytics_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: TinyTapAnalyticsConfig) { this.config = config; // Load existing consent data immediately so canTrack() works before init() this.loadConsentData(); } /** * Initialize privacy manager */ public async init(): Promise<void> { 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('TinyTapAnalytics: 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<PrivacySettings>): 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<string, string> = { '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 (as a banner) */ private async showConsentUI(): Promise<void> { if (!this.shadowDOM) { this.shadowDOM = new ShadowDOMManager(); } const jurisdiction = this.detectJurisdiction(); const isGDPR = jurisdiction.startsWith('EU') || jurisdiction === 'GB'; const consentBanner = this.shadowDOM.createBanner({ content: this.createConsentBannerContent(isGDPR), allowHTML: true // Banner contains safe internal HTML }); // Add event listeners this.attachBannerHandlers(consentBanner, isGDPR); // Show banner this.shadowDOM.show(consentBanner); } /** * Show detailed consent preferences modal */ private showConsentModal(): void { 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: false, // Allow dismissing the modal 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 banner content */ private createConsentBannerContent(isGDPR: boolean): string { const title = 'Cookie Notice'; const description = isGDPR ? 'We use cookies to enhance your browsing experience and analyze our traffic.' : 'We use cookies and similar technologies to improve your experience.'; return ` <button class="ciq-consent-banner-close" id="banner-close" aria-label="Close banner"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> </svg> </button> <div class="ciq-consent-banner-content"> <div class="ciq-consent-banner-text"> <h4>${title}</h4> <p>${description}</p> </div> <div class="ciq-consent-banner-actions"> ${isGDPR ? '<button id="banner-reject" class="ciq-btn ciq-btn-secondary ciq-btn-small">Reject All</button>' : ''} <button id="banner-manage" class="ciq-btn-link">Manage Preferences</button> <button id="banner-accept" class="ciq-btn ciq-btn-primary ciq-btn-small">Accept All</button> </div> </div> `; } /** * Attach event handlers to consent banner */ private attachBannerHandlers(banner: HTMLElement, isGDPR: boolean): void { const shadowRoot = banner.getRootNode() as ShadowRoot; // Close button - saves essential only and hides banner const closeBtn = shadowRoot.getElementById('banner-close'); closeBtn?.addEventListener('click', () => { this.updateConsent({ essential: true, functional: false, analytics: false, marketing: false }); }); // Accept all button const acceptBtn = shadowRoot.getElementById('banner-accept'); acceptBtn?.addEventListener('click', () => { this.updateConsent({ essential: true, functional: true, analytics: true, marketing: true }); }); // Reject all button (GDPR only) if (isGDPR) { const rejectBtn = shadowRoot.getElementById('banner-reject'); rejectBtn?.addEventListener('click', () => { this.updateConsent({ essential: true, functional: false, analytics: false, marketing: false }); }); } // Manage preferences button - opens detailed modal const manageBtn = shadowRoot.getElementById('banner-manage'); manageBtn?.addEventListener('click', () => { // Hide banner first if (this.shadowDOM) { this.shadowDOM.hide(banner); } // Show detailed modal this.showConsentModal(); }); } /** * 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 ` <div class="ciq-consent-content"> <h3>${title}</h3> <p>${description}</p> <div class="ciq-consent-options"> <div class="ciq-consent-option"> <label class="ciq-checkbox-label"> <input type="checkbox" id="essential" checked disabled> <span class="ciq-checkmark"></span> <div class="ciq-option-details"> <strong>Essential</strong> <p>Required for basic website functionality. Always active.</p> </div> </label> </div> <div class="ciq-consent-option"> <label class="ciq-checkbox-label"> <input type="checkbox" id="functional"> <span class="ciq-checkmark"></span> <div class="ciq-option-details"> <strong>Functional</strong> <p>Enables enhanced features like click tracking and user interface improvements.</p> </div> </label> </div> <div class="ciq-consent-option"> <label class="ciq-checkbox-label"> <input type="checkbox" id="analytics"> <span class="ciq-checkmark"></span> <div class="ciq-option-details"> <strong>Analytics</strong> <p>Helps us understand how you use our website to improve your experience.</p> </div> </label> </div> <div class="ciq-consent-option"> <label class="ciq-checkbox-label"> <input type="checkbox" id="marketing"> <span class="ciq-checkmark"></span> <div class="ciq-option-details"> <strong>Marketing</strong> <p>Allows us to measure the effectiveness of our optimization recommendations.</p> </div> </label> </div> </div> <div class="ciq-consent-actions"> ${isGDPR ? '<button id="reject-all" class="ciq-btn ciq-btn-secondary">Reject All</button>' : ''} <button id="accept-selected" class="ciq-btn ciq-btn-primary">Save Preferences</button> <button id="accept-all" class="ciq-btn ciq-btn-primary">Accept All</button> </div> <div class="ciq-consent-footer"> <p>You can change these settings at any time. For more information, see our privacy policy.</p> </div> </div> `; } /** * 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('TinyTapAnalytics: 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('tinytapanalytics_') && 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 = 'tinytapanalytics_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(); } } }