Compare commits
9 Commits
v0.0.2
...
99c1f003c6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99c1f003c6 | ||
|
|
97497db8e4 | ||
|
|
674663b027 | ||
|
|
e7b12f50b1 | ||
|
|
927b02d30e | ||
|
|
45a6bda53e | ||
|
|
9a72280737 | ||
|
|
095462c80d | ||
|
|
bb92e25485 |
@@ -4,7 +4,7 @@
|
|||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://gitea.auvem.com/svelte-toolkit/spectator.git"
|
"url": "https://gitea.auvem.com/svelte-toolkit/spectator.git"
|
||||||
},
|
},
|
||||||
"version": "0.0.2",
|
"version": "0.0.5",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@sveltejs/kit": "^2.0.0",
|
"@sveltejs/kit": "^2.0.0",
|
||||||
"svelte": "^5.0.0"
|
"svelte": "^5.40.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^1.4.0",
|
"@eslint/compat": "^1.4.0",
|
||||||
|
|||||||
@@ -1,11 +1,36 @@
|
|||||||
|
<!-- @component
|
||||||
|
MetaPixel integrates the Meta (Facebook) Pixel into your Svelte application,
|
||||||
|
allowing you to track page views and custom events while respecting user consent
|
||||||
|
for tracking. The component manages the lifecycle of the Meta Pixel script and
|
||||||
|
PixelControl interface.
|
||||||
|
|
||||||
|
The PixelControl class also allows you to directly manage multiple Pixel
|
||||||
|
instances and handle event tracking with optional test event codes without
|
||||||
|
using the MetaPixel component.
|
||||||
|
-->
|
||||||
|
|
||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
|
export type PixelControlOptions = {
|
||||||
|
/**
|
||||||
|
* if provided, events fired will always have this code attached
|
||||||
|
* to prevent them from polluting real analytics data.
|
||||||
|
*/
|
||||||
|
testEventCode?: string;
|
||||||
|
/** Advanced matching data */
|
||||||
|
advancedMatching?: AdvancedMatching;
|
||||||
|
/** Initialization options */
|
||||||
|
initOptions?: InitOptions;
|
||||||
|
};
|
||||||
|
|
||||||
export class PixelControl {
|
export class PixelControl {
|
||||||
private _pixelID: string;
|
private _pixelID: string;
|
||||||
private _testEventCode?: string = undefined;
|
private _testEventCode?: string = undefined;
|
||||||
private _trackingManager: MaybeGetter<TrackingManager | undefined>;
|
private _trackingManager: MaybeGetter<TrackingManager | undefined>;
|
||||||
|
|
||||||
private static _baseLoaded: boolean = false;
|
private static _baseLoaded: boolean = false;
|
||||||
|
private static _registeredPixels: Record<string, PixelControl> = {};
|
||||||
|
|
||||||
|
/** Indicates whether the Meta Pixel base script has been loaded. */
|
||||||
static get baseLoaded(): boolean {
|
static get baseLoaded(): boolean {
|
||||||
return this._baseLoaded;
|
return this._baseLoaded;
|
||||||
}
|
}
|
||||||
@@ -16,7 +41,7 @@
|
|||||||
* @throws Error if the Meta Pixel API is not loaded.
|
* @throws Error if the Meta Pixel API is not loaded.
|
||||||
*/
|
*/
|
||||||
static loadGuard(): void {
|
static loadGuard(): void {
|
||||||
if (!this._baseLoaded || !window._fbq) {
|
if (!this._baseLoaded || !window.fbq) {
|
||||||
throw new Error('Meta Pixel API has not been loaded. Call PixelControl.load() first.');
|
throw new Error('Meta Pixel API has not been loaded. Call PixelControl.load() first.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -24,19 +49,22 @@
|
|||||||
private constructor(
|
private constructor(
|
||||||
trackingManager: MaybeGetter<TrackingManager | undefined>,
|
trackingManager: MaybeGetter<TrackingManager | undefined>,
|
||||||
pixelID: string,
|
pixelID: string,
|
||||||
testEventCode?: string
|
options?: PixelControlOptions
|
||||||
) {
|
) {
|
||||||
this._trackingManager = trackingManager;
|
this._trackingManager = trackingManager;
|
||||||
this._pixelID = pixelID;
|
this._pixelID = pixelID;
|
||||||
this._testEventCode = testEventCode;
|
this._testEventCode = options?.testEventCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Loads the Meta Pixel base script. */
|
/** Loads the Meta Pixel base script. */
|
||||||
static load() {
|
static async load() {
|
||||||
if (this._baseLoaded && window._fbq) return;
|
if (this._baseLoaded && !!window.fbq) return;
|
||||||
if (!window._fbq) {
|
if (!window.fbq) {
|
||||||
PixelControl.revokeConsent(); // Initialize without consent
|
try {
|
||||||
loadMetaPixel(); // Load the Meta Pixel script
|
await loadMetaPixel(); // Load the Meta Pixel script
|
||||||
|
} catch (e) {
|
||||||
|
log.warn('Failed to load Meta Pixel script, all events will be queued.', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this._baseLoaded = true;
|
this._baseLoaded = true;
|
||||||
log.debug('Meta Pixel base script loaded.');
|
log.debug('Meta Pixel base script loaded.');
|
||||||
@@ -57,43 +85,55 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a PixelControl instance for the given Meta Pixel ID. If
|
* Registers a PixelControl instance for the given Meta Pixel ID. If
|
||||||
* the base Meta Pixel script has not been loaded yet, it will be
|
* the base Meta Pixel script has not been loaded yet, it will be
|
||||||
* loaded automatically. Optionally sets a test event code for the Pixel.
|
* loaded automatically. Optionally sets a test event code for the Pixel.
|
||||||
* Does NOT initialize the Pixel; call `fireInit()` on the returned instance
|
* Should only be called once for each Pixel ID, use PixelControl.get()
|
||||||
* before tracking events.
|
* to retrieve existing instances.
|
||||||
* @param trackingManager Tracking manager to handle user consent for tracking
|
* @param trackingManager Tracking manager to handle user consent for tracking
|
||||||
* @param pixelID Meta Pixel ID
|
* @param pixelID Meta Pixel ID
|
||||||
* @param options Optional settings
|
* @param options Optional settings
|
||||||
* @returns PixelControl instance
|
* @returns PixelControl instance
|
||||||
*/
|
*/
|
||||||
static for(
|
static initialize(
|
||||||
trackingManager: MaybeGetter<TrackingManager | undefined>,
|
trackingManager: MaybeGetter<TrackingManager | undefined>,
|
||||||
pixelID: string,
|
pixelID: string,
|
||||||
options?: {
|
options?: PixelControlOptions
|
||||||
/**
|
|
||||||
* if provided, events fired will always have this code attached
|
|
||||||
* to prevent them from polluting real analytics data.
|
|
||||||
*/
|
|
||||||
testEventCode?: string;
|
|
||||||
}
|
|
||||||
): PixelControl {
|
): PixelControl {
|
||||||
|
// Load the base script if not already loaded
|
||||||
PixelControl.load();
|
PixelControl.load();
|
||||||
return new PixelControl(trackingManager, pixelID, options?.testEventCode);
|
|
||||||
|
// Check for existing PixelControl instance
|
||||||
|
if (this._registeredPixels[pixelID]) {
|
||||||
|
log.warn(
|
||||||
|
`PixelControl instance for Meta Pixel ID: ${pixelID} already exists. Returning existing instance.`
|
||||||
|
);
|
||||||
|
return this._registeredPixels[pixelID];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and register the PixelControl instance
|
||||||
|
const pixel = new PixelControl(trackingManager, pixelID, options);
|
||||||
|
this._registeredPixels[pixelID] = pixel;
|
||||||
|
|
||||||
|
// Fire initialization
|
||||||
|
window.fbq('init', pixel._pixelID, options?.advancedMatching, options?.initOptions);
|
||||||
|
log.debug(`Meta Pixel [${pixel._pixelID}] initialized.`);
|
||||||
|
|
||||||
|
return pixel;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes this pixel with the Meta Pixel API including any advanced
|
* Returns an existing PixelControl instance for the given Meta Pixel ID.
|
||||||
* matching data and options.
|
* @param pixelID Meta Pixel ID
|
||||||
* @param advancedMatching Advanced matching data
|
* @returns PixelControl instance
|
||||||
* @param initOptions Initialization options
|
* @throws Error if no PixelControl instance is found for the given ID.
|
||||||
* @returns this PixelControl instance
|
|
||||||
*/
|
*/
|
||||||
fireInit(advancedMatching?: AdvancedMatching, initOptions?: InitOptions): PixelControl {
|
static get(pixelID: string): PixelControl {
|
||||||
PixelControl.loadGuard();
|
const pixel = this._registeredPixels[pixelID];
|
||||||
window.fbq('init', this._pixelID, advancedMatching, initOptions);
|
if (!pixel) {
|
||||||
log.debug(`Meta Pixel [${this._pixelID}] initialized.`);
|
throw new Error(`No PixelControl instance found for Meta Pixel ID: ${pixelID}`);
|
||||||
return this;
|
}
|
||||||
|
return pixel;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -114,10 +154,17 @@
|
|||||||
*/
|
*/
|
||||||
pageView() {
|
pageView() {
|
||||||
if (!this.consentGuard()) return;
|
if (!this.consentGuard()) return;
|
||||||
window.fbq('track', 'PageView', undefined, { test_event_code: this._testEventCode });
|
// Send the PageView event
|
||||||
log.debug(
|
if (!dev || this._testEventCode) {
|
||||||
`Meta Pixel [${this._pixelID}] PageView event sent (test code: ${this._testEventCode}).`
|
window.fbq('track', 'PageView', undefined, { test_event_code: this._testEventCode });
|
||||||
);
|
log.debug(
|
||||||
|
`Meta Pixel [${this._pixelID}] PageView event sent${dev && ` (test code: ${this._testEventCode})`}.`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log.info(
|
||||||
|
`Meta Pixel [${this._pixelID}] PageView event not sent in development mode without a test event code.`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -126,13 +173,19 @@
|
|||||||
*/
|
*/
|
||||||
track<K extends StandardEventName>(event: K, params?: EventParamsByName[K], eventID?: string) {
|
track<K extends StandardEventName>(event: K, params?: EventParamsByName[K], eventID?: string) {
|
||||||
if (!this.consentGuard()) return;
|
if (!this.consentGuard()) return;
|
||||||
window.fbq('trackSingle', this._pixelID, event, params, {
|
if (!dev || this._testEventCode) {
|
||||||
eventID,
|
window.fbq('trackSingle', this._pixelID, event, params, {
|
||||||
test_event_code: this._testEventCode
|
eventID,
|
||||||
});
|
test_event_code: this._testEventCode
|
||||||
log.debug(
|
});
|
||||||
`Meta Pixel [${this._pixelID}] ${event} event sent (test code: ${this._testEventCode}).`
|
log.debug(
|
||||||
);
|
`Meta Pixel [${this._pixelID}] ${event} event sent${dev && ` (test code: ${this._testEventCode})`}.`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log.info(
|
||||||
|
`Meta Pixel [${this._pixelID}] ${event} event not sent in development mode without a test event code.`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -141,13 +194,19 @@
|
|||||||
*/
|
*/
|
||||||
trackCustom(event: string, params?: CommonParams & CustomParams, eventID?: string) {
|
trackCustom(event: string, params?: CommonParams & CustomParams, eventID?: string) {
|
||||||
if (!this.consentGuard()) return;
|
if (!this.consentGuard()) return;
|
||||||
window.fbq('trackSingleCustom', this._pixelID, event, params, {
|
if (!dev || this._testEventCode) {
|
||||||
eventID,
|
window.fbq('trackSingleCustom', this._pixelID, event, params, {
|
||||||
test_event_code: this._testEventCode
|
eventID,
|
||||||
});
|
test_event_code: this._testEventCode
|
||||||
log.debug(
|
});
|
||||||
`Meta Pixel [${this._pixelID}] ${event} custom event sent (test code: ${this._testEventCode}).`
|
log.debug(
|
||||||
);
|
`Meta Pixel [${this._pixelID}] ${event} custom event sent (test code: ${this._testEventCode}).`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log.info(
|
||||||
|
`Meta Pixel [${this._pixelID}] ${event} custom event not sent in development mode without a test event code.`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -168,32 +227,30 @@
|
|||||||
import { onNavigate } from '$app/navigation';
|
import { onNavigate } from '$app/navigation';
|
||||||
import { resolveGetter, type MaybeGetter } from './util/getter.ts';
|
import { resolveGetter, type MaybeGetter } from './util/getter.ts';
|
||||||
import log from 'loglevel';
|
import log from 'loglevel';
|
||||||
|
import { dev } from '$app/environment';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Meta Pixel ID */
|
|
||||||
pixelID: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* If a test event code is available, events fired will always have this
|
|
||||||
* code attached to prevent them from polluting real analytics data.
|
|
||||||
*/
|
|
||||||
testEventCode?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Controls whether page views are automatically tracked by this
|
|
||||||
* component (default: true).
|
|
||||||
*/
|
|
||||||
autoPageView?: boolean;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tracking manager to handle user consent for tracking. If omitted
|
* Tracking manager to handle user consent for tracking. If omitted
|
||||||
* tracking is disabled by default until consent is granted via
|
* tracking is disabled by default until consent is granted via
|
||||||
* PixelControl.grantConsent().
|
* PixelControl.grantConsent().
|
||||||
*/
|
*/
|
||||||
trackingManager?: TrackingManager;
|
trackingManager?: TrackingManager;
|
||||||
|
|
||||||
|
/** Meta Pixel ID */
|
||||||
|
pixelID: string;
|
||||||
|
|
||||||
|
/** Meta Pixel Options */
|
||||||
|
pixelOptions?: PixelControlOptions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controls whether page views are automatically tracked by this
|
||||||
|
* component (default: true).
|
||||||
|
*/
|
||||||
|
autoPageView?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { pixelID, testEventCode, autoPageView = true, trackingManager }: Props = $props();
|
let { pixelID, pixelOptions, autoPageView = true, trackingManager }: Props = $props();
|
||||||
|
|
||||||
let pixel = $state<PixelControl | null>(null);
|
let pixel = $state<PixelControl | null>(null);
|
||||||
|
|
||||||
@@ -202,7 +259,7 @@
|
|||||||
throw new Error('MetaPixel component requires a TrackingManager to manage consent.');
|
throw new Error('MetaPixel component requires a TrackingManager to manage consent.');
|
||||||
}
|
}
|
||||||
PixelControl.load();
|
PixelControl.load();
|
||||||
pixel = PixelControl.for(trackingManager, pixelID, { testEventCode }).fireInit();
|
pixel = PixelControl.initialize(trackingManager, pixelID, pixelOptions);
|
||||||
|
|
||||||
trackingManager.runWithConsent(() => {
|
trackingManager.runWithConsent(() => {
|
||||||
if (autoPageView && pixel) {
|
if (autoPageView && pixel) {
|
||||||
@@ -218,4 +275,11 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getPixelControl = (): PixelControl => {
|
||||||
|
if (!pixel) {
|
||||||
|
throw new Error('MetaPixel component has not been initialized yet, wait for onMount.');
|
||||||
|
}
|
||||||
|
return pixel;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { setContext, getContext, onDestroy } from 'svelte';
|
import { browser } from '$app/environment';
|
||||||
|
import log from 'loglevel';
|
||||||
|
import { onDestroy, onMount, createContext } from 'svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for initializing the TrackingManager.
|
* Options for initializing the TrackingManager.
|
||||||
@@ -36,17 +38,69 @@ type InternalService<T> = Service<T> & {
|
|||||||
* Manages user tracking preferences and services that require consent.
|
* Manages user tracking preferences and services that require consent.
|
||||||
*/
|
*/
|
||||||
export class TrackingManager {
|
export class TrackingManager {
|
||||||
|
/** tracking consent, persisted to localStorage by saveOpts */
|
||||||
private _consent: boolean | null = $state(null);
|
private _consent: boolean | null = $state(null);
|
||||||
private _services: Record<string, InternalService<unknown>> = {};
|
private _services: Record<string, InternalService<unknown>> = {};
|
||||||
private _changeCallbacks: Array<(consent: boolean | null) => void> = [];
|
private _changeCallbacks: Array<(consent: boolean | null) => void> = [];
|
||||||
private _consentQueue: Array<() => 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) {
|
constructor(opts?: TrackingManagerOpts) {
|
||||||
if (opts) {
|
if (opts) {
|
||||||
if (opts.consent !== undefined) this._consent = opts.consent;
|
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. */
|
/** Indicates whether tracking is currently allowed. */
|
||||||
get consent() {
|
get consent() {
|
||||||
return this._consent;
|
return this._consent;
|
||||||
@@ -78,7 +132,7 @@ export class TrackingManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets whether tracking is consented. If set to true, all queued callbacks
|
* Sets whether tracking is consented. If set to true, all queued callbacks
|
||||||
* will be executed.
|
* will be executed. Automatically persists to localStorage if available.
|
||||||
*/
|
*/
|
||||||
setConsent(value: boolean) {
|
setConsent(value: boolean) {
|
||||||
if (this._consent === value) return;
|
if (this._consent === value) return;
|
||||||
@@ -95,6 +149,7 @@ export class TrackingManager {
|
|||||||
this._changeCallbacks.forEach((cb) => {
|
this._changeCallbacks.forEach((cb) => {
|
||||||
cb(this._consent);
|
cb(this._consent);
|
||||||
});
|
});
|
||||||
|
this.saveOpts();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,16 +168,23 @@ export class TrackingManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs callback immediately if we have consent already or queues it for later.
|
* 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.
|
* @param callback The function to run when consent is granted.
|
||||||
*/
|
*/
|
||||||
runWithConsent(callback: () => void) {
|
runWithConsent(callback: () => void) {
|
||||||
if (this._consent) {
|
if (this._consent) callback();
|
||||||
callback();
|
else this._consentQueue.push(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(() => {
|
onDestroy(() => {
|
||||||
this._consentQueue = this._consentQueue.filter((cb) => cb !== callback);
|
this._consentQueue = this._consentQueue.filter((cb) => cb !== callback);
|
||||||
});
|
});
|
||||||
@@ -192,19 +254,31 @@ export class TrackingManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const trackingManagerKey = Symbol();
|
const [getTrackingContext, setTrackingContext] = createContext<TrackingManager>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the TrackingManager from context, or creates one if it doesn't exist.
|
* Gets the TrackingManager from context, or creates one if it doesn't exist.
|
||||||
* @param initializer Optional initializer function to customize the TrackingManager.
|
* If called from the browser, attempts to load saved state from localStorage.
|
||||||
* @returns The TrackingManager instance.
|
* @returns The TrackingManager instance.
|
||||||
*/
|
*/
|
||||||
export const getTrackingManager = (): TrackingManager => {
|
export const getTrackingManager = (): TrackingManager => {
|
||||||
const saved = getContext<TrackingManager>(trackingManagerKey);
|
try {
|
||||||
if (saved) return saved;
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
console.debug('initializing a new TrackingManager');
|
|
||||||
const manager = new TrackingManager();
|
|
||||||
setContext(trackingManagerKey, manager);
|
|
||||||
return manager;
|
return manager;
|
||||||
};
|
};
|
||||||
|
|||||||
5
src/lib/types/fbq.d.ts
vendored
5
src/lib/types/fbq.d.ts
vendored
@@ -340,6 +340,11 @@ export interface FBQ {
|
|||||||
// consent and LDU
|
// consent and LDU
|
||||||
(cmd: 'consent', state: 'grant' | 'revoke'): void;
|
(cmd: 'consent', state: 'grant' | 'revoke'): void;
|
||||||
(cmd: 'dataProcessingOptions', options: string[], countryCode?: number, stateCode?: number): void;
|
(cmd: 'dataProcessingOptions', options: string[], countryCode?: number, stateCode?: number): void;
|
||||||
|
|
||||||
|
/** Prevent automatic listening to history.pushState/popstate */
|
||||||
|
disablePushState?: boolean;
|
||||||
|
/** Allow duplicate page view events (legacy / undocumented behavior) */
|
||||||
|
allowDuplicatePageViews?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
import log from 'loglevel';
|
||||||
|
|
||||||
const SCRIPT_SRC = 'https://connect.facebook.net/en_US/fbevents.js';
|
const SCRIPT_SRC = 'https://connect.facebook.net/en_US/fbevents.js';
|
||||||
|
|
||||||
@@ -8,22 +9,60 @@ type QueuedFBQ = ((...args: unknown[]) => void) & {
|
|||||||
loaded?: boolean;
|
loaded?: boolean;
|
||||||
version?: string;
|
version?: string;
|
||||||
push?: unknown;
|
push?: unknown;
|
||||||
|
disablePushState?: boolean;
|
||||||
|
allowDuplicatePageViews?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads the Meta Pixel script and configures the `fbq` function to queue
|
* Loads the Meta Pixel script and configures the `fbq` function to queue
|
||||||
* commands until the script is fully loaded. You may optionally await the
|
* commands until the script is fully loaded. You may optionally await the
|
||||||
* returned Promise to ensure the script has loaded before proceeding.
|
* returned Promise to ensure the script has loaded before proceeding.
|
||||||
|
*
|
||||||
|
* Options:
|
||||||
|
* - `disablePushState` (default: true) — when true, sets
|
||||||
|
* `window.fbq.disablePushState = true` before the pixel script loads so the
|
||||||
|
* pixel does not auto-listen to `history.pushState`/`popstate` (recommended
|
||||||
|
* for SPA frameworks like Svelte).
|
||||||
|
* - `allowDuplicatePageViews` (default: false) — when true, sets
|
||||||
|
* `window.fbq.allowDuplicatePageViews = true` on the stub.
|
||||||
*/
|
*/
|
||||||
export const loadMetaPixel = (): Promise<void> => {
|
export const loadMetaPixel = (opts?: {
|
||||||
|
disablePushState?: boolean;
|
||||||
|
allowDuplicatePageViews?: boolean;
|
||||||
|
}): Promise<void> => {
|
||||||
// Make sure we're using the browser
|
// Make sure we're using the browser
|
||||||
if (!browser || !window) {
|
if (!browser || !window) {
|
||||||
return Promise.reject(new Error('Window is undefined'));
|
return Promise.reject(new Error(`Not in browser, can't access window`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default behavior: disable pushState handling since Svelte apps manage
|
||||||
|
// navigation themselves and Meta's auto-patching of history APIs can
|
||||||
|
// cause duplicate/incorrect pageview events. Consumers can pass
|
||||||
|
// `opts.disablePushState = false` to opt out.
|
||||||
|
const disablePushState = opts?.disablePushState ?? true;
|
||||||
|
const allowDuplicatePageViews = opts?.allowDuplicatePageViews ?? false;
|
||||||
|
|
||||||
// If fbq is already defined, resolve immediately
|
// If fbq is already defined, resolve immediately
|
||||||
const existing = window.fbq as QueuedFBQ | undefined;
|
const existing = window.fbq as QueuedFBQ | undefined;
|
||||||
if (existing && existing.loaded) {
|
if (existing) {
|
||||||
|
// If the existing stub is present but hasn't set these flags yet, set
|
||||||
|
// them now so the loaded library (if it inspects them) sees intended
|
||||||
|
// behavior. Setting these is a no-op if initialization already
|
||||||
|
// completed.
|
||||||
|
if (disablePushState) existing.disablePushState = true;
|
||||||
|
if (allowDuplicatePageViews) existing.allowDuplicatePageViews = true;
|
||||||
|
const existingScript = getExistingScript();
|
||||||
|
if (existingScript) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
attachToScript(existingScript, resolve, reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug(
|
||||||
|
'Meta Pixel fbq already present, skipping injection',
|
||||||
|
existing.version,
|
||||||
|
existing.queue
|
||||||
|
);
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,18 +79,18 @@ export const loadMetaPixel = (): Promise<void> => {
|
|||||||
q.push = q;
|
q.push = q;
|
||||||
q.loaded = true;
|
q.loaded = true;
|
||||||
q.version = '2.0';
|
q.version = '2.0';
|
||||||
|
// set control flags on the stub before the meta script runs
|
||||||
|
if (disablePushState) q.disablePushState = true;
|
||||||
|
if (allowDuplicatePageViews) q.allowDuplicatePageViews = true;
|
||||||
window.fbq = q;
|
window.fbq = q;
|
||||||
|
window._fbq = q;
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// Avoid adding the same script twice
|
// Avoid adding the same script twice
|
||||||
const existingScript = document.querySelector(
|
const existingScript = getExistingScript();
|
||||||
`script[src="${SCRIPT_SRC}"]`
|
|
||||||
) as HTMLScriptElement | null;
|
|
||||||
if (existingScript) {
|
if (existingScript) {
|
||||||
existingScript.addEventListener('load', () => resolve());
|
attachToScript(existingScript, resolve, reject);
|
||||||
existingScript.addEventListener('error', () =>
|
log.debug('Meta Pixel script already present, waiting for load');
|
||||||
reject(new Error('Failed to load Meta Pixel script'))
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,8 +98,23 @@ export const loadMetaPixel = (): Promise<void> => {
|
|||||||
const script = document.createElement('script');
|
const script = document.createElement('script');
|
||||||
script.src = SCRIPT_SRC;
|
script.src = SCRIPT_SRC;
|
||||||
script.async = true;
|
script.async = true;
|
||||||
script.addEventListener('load', () => resolve());
|
attachToScript(script, resolve, reject);
|
||||||
script.addEventListener('error', () => reject(new Error('Failed to load Meta Pixel script')));
|
|
||||||
document.head.appendChild(script);
|
document.head.appendChild(script);
|
||||||
|
log.debug('Meta Pixel script added to document');
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getExistingScript = (): HTMLScriptElement | null => {
|
||||||
|
return document.querySelector(
|
||||||
|
`script[src*="connect.facebook.net"][src*="fbevents.js"]`
|
||||||
|
) as HTMLScriptElement | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachToScript = (
|
||||||
|
el: HTMLScriptElement,
|
||||||
|
resolve: () => void,
|
||||||
|
reject: (err: Error) => void
|
||||||
|
) => {
|
||||||
|
el.addEventListener('load', () => resolve());
|
||||||
|
el.addEventListener('error', () => reject(new Error('Failed to load Meta Pixel script')));
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user