@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
text/typescript
/**
* 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();
}
}
}