add tracking manager
This commit is contained in:
210
src/lib/tracking.svelte.ts
Normal file
210
src/lib/tracking.svelte.ts
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import { setContext, getContext, onDestroy } from 'svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for initializing the TrackingManager.
|
||||||
|
*/
|
||||||
|
export type TrackingManagerOpts = {
|
||||||
|
consent?: boolean | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Definition of a service callback function. */
|
||||||
|
export type ServiceCallback<T> = (context?: T) => boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Definition of a tracking service with consent and revoke callbacks.
|
||||||
|
*/
|
||||||
|
export type Service<T> = {
|
||||||
|
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<T>;
|
||||||
|
/**
|
||||||
|
* Called when the user revokes consent to tracking. Return true to mark
|
||||||
|
* the service as unmounted.
|
||||||
|
*/
|
||||||
|
onRevoke: ServiceCallback<T>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type InternalService<T> = Service<T> & {
|
||||||
|
mounted: boolean;
|
||||||
|
sid: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages user tracking preferences and services that require consent.
|
||||||
|
*/
|
||||||
|
export class TrackingManager {
|
||||||
|
private _consent: boolean | null = $state(null);
|
||||||
|
private _services: Record<string, InternalService<unknown>> = {};
|
||||||
|
private _changeCallbacks: Array<(consent: boolean | null) => void> = [];
|
||||||
|
private _consentQueue: Array<() => void> = [];
|
||||||
|
|
||||||
|
constructor(opts?: TrackingManagerOpts) {
|
||||||
|
if (opts) {
|
||||||
|
if (opts.consent !== undefined) this._consent = opts.consent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 allowed. If set to true, all queued callbacks
|
||||||
|
* will be executed.
|
||||||
|
*/
|
||||||
|
setAllowed(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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
* Removes callback from queue onDestroy.
|
||||||
|
* @param callback The function to run when consent is granted.
|
||||||
|
*/
|
||||||
|
runWithConsent(callback: () => void) {
|
||||||
|
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<T>(service: Service<T>): string {
|
||||||
|
const s: InternalService<T> = {
|
||||||
|
...service,
|
||||||
|
mounted: false,
|
||||||
|
sid: crypto.randomUUID()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this._consent) {
|
||||||
|
s.mounted = s.onConsent();
|
||||||
|
}
|
||||||
|
|
||||||
|
this._services[s.sid] = s as InternalService<unknown>;
|
||||||
|
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<T>(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 trackingManagerKey = Symbol();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the TrackingManager from context, or creates one if it doesn't exist.
|
||||||
|
* @param initializer Optional initializer function to customize the TrackingManager.
|
||||||
|
* @returns The TrackingManager instance.
|
||||||
|
*/
|
||||||
|
export const getTrackingManager = (): TrackingManager => {
|
||||||
|
const saved = getContext<TrackingManager>(trackingManagerKey);
|
||||||
|
if (saved) return saved;
|
||||||
|
|
||||||
|
console.debug('initializing a new TrackingManager');
|
||||||
|
const manager = new TrackingManager();
|
||||||
|
setContext(trackingManagerKey, manager);
|
||||||
|
return manager;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user