import { browser } from '$app/environment'; import log from 'loglevel'; import { onDestroy, onMount, createContext } from 'svelte'; /** * Options for initializing the TrackingManager. */ export type TrackingManagerOpts = { consent?: boolean | null; }; /** Definition of a service callback function. */ export type ServiceCallback = (context?: T) => boolean; /** * Definition of a tracking service with consent and revoke callbacks. */ export type Service = { context?: T; /** * Called when the user consents to tracking or immediately if tracking is * already allowed. Return true to mark the service as mounted. */ onConsent: ServiceCallback; /** * Called when the user revokes consent to tracking. Return true to mark * the service as unmounted. */ onRevoke: ServiceCallback; }; type InternalService = Service & { mounted: boolean; sid: string; }; /** * Manages user tracking preferences and services that require consent. */ export class TrackingManager { /** tracking consent, persisted to localStorage by saveOpts */ private _consent: boolean | null = $state(null); private _services: Record> = {}; private _changeCallbacks: Array<(consent: boolean | null) => void> = []; private _consentQueue: Array<() => void> = []; /** * Saves consent state to localStorage if browser storage is available. * Automatically called after updating consent. * @throws Error if storage is not available. */ saveOpts(): TrackingManager { if (!browser || !window?.localStorage) { throw new Error('Cannot access localStorage to save tracking state'); } window.localStorage.setItem( 'trackingOpts', JSON.stringify({ consent: this._consent } as TrackingManagerOpts) ); return this; } /** * Loads tracking options from localStorage if available. Suitable to call * after initialization, e.g. in onMount after a TrackingManager is created. * @throws Error if storage is not available. */ loadOpts(): TrackingManager { if (!browser || !window?.localStorage) { throw new Error('Cannot access localStorage to load tracking state'); } const raw = window.localStorage.getItem('trackingOpts'); if (raw) { const opts = JSON.parse(raw) as TrackingManagerOpts; if (opts.consent !== undefined && opts.consent !== null) { this.setConsent(opts.consent); } log.debug('[TrackingManager] Loaded tracking options from storage:', opts); } return this; } /** * Creates a TrackingManager instance. * @param opts Optional initial options. */ constructor(opts?: TrackingManagerOpts) { if (opts) { if (opts.consent !== undefined) this._consent = opts.consent; } } /** * Creates a TrackingManager instance from localStorage data. * @throws Error if storage is not available. */ static fromLocalStorage(): TrackingManager { return new TrackingManager().loadOpts(); } /** Indicates whether tracking is currently allowed. */ get consent() { return this._consent; } /** Indicates whether tracking permission has been explicitly set. */ haveUserDecision() { return this._consent !== null; } /** * Checks whether tracking consent has been explicitly granted. * @returns True if consent is granted, false if revoked or not set. */ haveUserConsent() { return this._consent === true; } /** * Checks whether tracking is currently allowed. * @throws Error if the user has not made a decision yet or has revoked consent. */ requireConsent() { if (this._consent === null) { throw new Error('Tracking permission has not been set yet.'); } if (this._consent === false) { throw new Error('Tracking permission has been revoked by the user.'); } } /** * Sets whether tracking is consented. If set to true, all queued callbacks * will be executed. Automatically persists to localStorage if available. */ setConsent(value: boolean) { if (this._consent === value) return; this._consent = value; if (this._consent) { this.mountServices(); this._consentQueue.forEach((cb) => cb()); this._consentQueue = []; } else { this.destroyServices(); } this._changeCallbacks.forEach((cb) => { cb(this._consent); }); this.saveOpts(); } /** * Registers a callback to be notified when the tracking permission changes. */ notifyChange(callback: (consent: boolean | null) => void) { this._changeCallbacks.push(callback); } /** * Unregisters a previously registered change callback. */ unnotifyChange(callback: () => void) { this._changeCallbacks = this._changeCallbacks.filter((cb) => cb !== callback); } /** * Runs callback immediately if we have consent already or queues it for later. * @param callback The function to run when consent is granted. */ runWithConsent(callback: () => void) { if (this._consent) callback(); else this._consentQueue.push(callback); } /** * Runs callback onMount if we have consent already or queues it for later. * Removes the callback from the queue onDestroy. * @param callback The function to run when consent is granted. */ lifecycleWithConsent(callback: () => void) { onMount(() => { if (this._consent) callback(); else this._consentQueue.push(callback); }); onDestroy(() => { this._consentQueue = this._consentQueue.filter((cb) => cb !== callback); }); } /** * Records a service to be executed once tracking is allowed. Use this to * defer tracking-related actions until the user has granted permission. * @param service The service definition. * @return The unique identifier for the registered service. */ createService(service: Service): string { const s: InternalService = { ...service, mounted: false, sid: crypto.randomUUID() }; if (this._consent) { s.mounted = s.onConsent(); } this._services[s.sid] = s as InternalService; return s.sid; } /** * Gets the typed context for a registered service. * @param sid The unique identifier of the service. * @returns The context of the service, or undefined if not found. */ getServiceContext(sid: string): T | undefined { const service = this._services[sid]; if (service) { return service.context as T; } return undefined; } /** * Removes a registered service by its unique identifier. Calls the * destroy function if the service was mounted. */ removeService(sid: string) { const service = this._services[sid]; if (service) { if (service.mounted) { service.onRevoke(service.context); } delete this._services[sid]; } } private mountServices() { Object.values(this._services).forEach((service) => { if (!service.mounted) { service.mounted = service.onConsent(service.context); } }); } private destroyServices() { Object.values(this._services).forEach((service) => { if (service.mounted) { service.mounted = !service.onRevoke(service.context); } }); } } const [getTrackingContext, setTrackingContext] = createContext(); /** * Gets the TrackingManager from context, or creates one if it doesn't exist. * If called from the browser, attempts to load saved state from localStorage. * @returns The TrackingManager instance. */ export const getTrackingManager = (): TrackingManager => { try { const saved = getTrackingContext(); if (saved) { log.debug('[TrackingManager] Using existing instance from context'); return saved; } } catch { // ignore missing context, we'll create a new one } log.debug('[TrackingManager] Creating new instance'); const manager = $state(new TrackingManager()); setTrackingContext(manager); if (browser) { manager.loadOpts(); } return manager; };