352 lines
11 KiB
TypeScript
352 lines
11 KiB
TypeScript
import type { TrackingManager } from '../tracking.svelte.ts';
|
|
import type {
|
|
EventParamsByName,
|
|
StandardEventName,
|
|
CommonParams,
|
|
CustomParams,
|
|
AdvancedMatching,
|
|
InitOptions
|
|
} from '../types/fbq.js';
|
|
import { loadMetaPixel } from '../util/meta-pixel-loader.ts';
|
|
import { resolveGetter, type MaybeGetter } from '../util/getter.ts';
|
|
import log from 'loglevel';
|
|
import { dev } from '$app/environment';
|
|
import { CAPIClient } from '../capi/client.ts';
|
|
import {
|
|
ActionSource,
|
|
CAPIEvent,
|
|
type CAPIEventOptions,
|
|
type CAPIStandardParams
|
|
} from '../capi/event.ts';
|
|
|
|
const pixelParamsToCustomData = (params: CommonParams & CustomParams): CAPIStandardParams => {
|
|
const customData: CAPIStandardParams = {};
|
|
if (params.value) customData.value = params.value;
|
|
if (params.currency) customData.currency = params.currency;
|
|
if (params.content_name) customData.content_name = params.content_name;
|
|
if (params.content_category) customData.content_category = params.content_category;
|
|
if (params.content_ids) customData.content_ids = params.content_ids;
|
|
if (params.contents) {
|
|
const acc: CAPIStandardParams['contents'] = [];
|
|
customData.contents = params.contents.reduce((acc, content) => {
|
|
acc.push({
|
|
id: content.id.toString(),
|
|
quantity: content.quantity
|
|
});
|
|
return acc;
|
|
}, acc);
|
|
}
|
|
if (params.num_items) customData.num_items = params.num_items;
|
|
if (params.search_string) customData.search_string = params.search_string;
|
|
if (params.predicted_ltv) customData.predicted_ltv = params.predicted_ltv;
|
|
return customData;
|
|
};
|
|
|
|
/**
|
|
* Options for configuring a PixelControl instance.
|
|
*/
|
|
export type PixelControlOptions = {
|
|
/**
|
|
* if provided, events fired will always have this code attached
|
|
* to prevent them from polluting real analytics data.
|
|
*/
|
|
testEventCode?: string;
|
|
/**
|
|
* if provided, all events fired will be passed to the server endpoint
|
|
* at this URL to be sent via the Conversion API. Any events sent
|
|
* without a event ID will be assigned a random one.
|
|
*/
|
|
conversionHref?: string;
|
|
/** Advanced matching data */
|
|
advancedMatching?: AdvancedMatching;
|
|
/** Initialization options */
|
|
initOptions?: InitOptions;
|
|
};
|
|
|
|
/**
|
|
* Manages multiple Meta Pixel instances and provides methods to
|
|
* interact with them, including consent management and event tracking.
|
|
*/
|
|
export class PixelControl {
|
|
private _pixelID: string;
|
|
private _testEventCode?: string = undefined;
|
|
private _trackingManager: MaybeGetter<TrackingManager | undefined>;
|
|
private _conversionClient?: CAPIClient = undefined;
|
|
|
|
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 {
|
|
return this._baseLoaded;
|
|
}
|
|
|
|
/**
|
|
* Ensures that the Meta Pixel base has been loaded before
|
|
* allowing further operations.
|
|
* @throws Error if the Meta Pixel API is not loaded.
|
|
*/
|
|
static loadGuard(): void {
|
|
if (!this._baseLoaded || !window.fbq) {
|
|
throw new Error('Meta Pixel API has not been loaded. Call PixelControl.load() first.');
|
|
}
|
|
}
|
|
|
|
private constructor(
|
|
trackingManager: MaybeGetter<TrackingManager | undefined>,
|
|
pixelID: string,
|
|
options?: PixelControlOptions
|
|
) {
|
|
this._trackingManager = trackingManager;
|
|
this._pixelID = pixelID;
|
|
this._testEventCode = options?.testEventCode;
|
|
|
|
const resolvedTrackingManager = resolveGetter(trackingManager);
|
|
if (options?.conversionHref && resolvedTrackingManager) {
|
|
this._conversionClient = new CAPIClient(options.conversionHref, resolvedTrackingManager);
|
|
} else if (options?.conversionHref) {
|
|
log.warn(
|
|
`${this.logPrefix} CAPI Client ${options.conversionHref} available but not initialized, TrackingManager is required for user consent.`
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Loads the Meta Pixel base script. */
|
|
static load() {
|
|
if (this._baseLoaded && !!window.fbq) return;
|
|
loadMetaPixel(); // Load the Meta Pixel script
|
|
this._baseLoaded = true;
|
|
log.debug('[PixelControl] Meta Pixel base script loaded.', this._baseLoaded);
|
|
}
|
|
|
|
/** Tells the Meta pixel that the user has given consent for tracking. */
|
|
static grantConsent() {
|
|
this.loadGuard();
|
|
window.fbq?.('consent', 'grant');
|
|
log.debug('[PixelControl] Pixel consent granted.');
|
|
}
|
|
|
|
/** Tells the Meta pixel that the user has revoked consent for tracking. */
|
|
static revokeConsent() {
|
|
this.loadGuard();
|
|
window.fbq?.('consent', 'revoke');
|
|
log.debug('[PixelControl] Pixel consent revoked.');
|
|
}
|
|
|
|
/**
|
|
* Registers a PixelControl instance for the given Meta Pixel ID. If
|
|
* the base Meta Pixel script has not been loaded yet, it will be
|
|
* loaded automatically. Optionally sets a test event code for the Pixel.
|
|
* Should only be called once for each Pixel ID, use PixelControl.get()
|
|
* to retrieve existing instances.
|
|
* @param trackingManager Tracking manager to handle user consent for tracking
|
|
* @param pixelID Meta Pixel ID
|
|
* @param options Optional settings
|
|
* @returns PixelControl instance
|
|
*/
|
|
static initialize(
|
|
trackingManager: MaybeGetter<TrackingManager | undefined>,
|
|
pixelID: string,
|
|
options?: PixelControlOptions
|
|
): PixelControl {
|
|
// Load the base script if not already loaded
|
|
PixelControl.load();
|
|
|
|
// Check for existing PixelControl instance
|
|
if (this._registeredPixels[pixelID]) {
|
|
log.warn(
|
|
`${this._registeredPixels[pixelID].logPrefix} Instance 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(`${pixel.logPrefix} initialized.`);
|
|
|
|
return pixel;
|
|
}
|
|
|
|
/**
|
|
* Returns an existing PixelControl instance for the given Meta Pixel ID.
|
|
* @param pixelID Meta Pixel ID
|
|
* @returns PixelControl instance
|
|
* @throws Error if no PixelControl instance is found for the given ID.
|
|
*/
|
|
static get(pixelID: string): PixelControl {
|
|
const pixel = this._registeredPixels[pixelID];
|
|
if (!pixel) {
|
|
throw new Error(`No PixelControl instance found for Meta Pixel ID: ${pixelID}`);
|
|
}
|
|
return pixel;
|
|
}
|
|
|
|
/**
|
|
* Checks if the Meta Pixel has consent to track user data
|
|
* and if the Pixel has been loaded.
|
|
* @returns true if tracking is allowed, false otherwise.
|
|
* @throws Error if the Meta Pixel is not loaded.
|
|
*/
|
|
consentGuard(): boolean {
|
|
PixelControl.loadGuard();
|
|
const trackingManager = resolveGetter(this._trackingManager);
|
|
return trackingManager?.haveUserConsent() ?? false;
|
|
}
|
|
|
|
/** Warns if we're in dev mode and no test code is set */
|
|
private devModeWarn() {
|
|
if (dev && !this._testEventCode) {
|
|
log.warn(
|
|
`${this.logPrefix} Sending events in dev mode without a test event code. ` +
|
|
'Consider providing a test event code to avoid affecting real data.'
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Returns the log prefix including the pixel ID */
|
|
private get logPrefix(): string {
|
|
return `[PixelControl] [${this._pixelID}]`;
|
|
}
|
|
|
|
/**
|
|
* Shorthand utility to send a PageView event
|
|
* @param disableCAPI If true, disables sending this event to the Conversion API
|
|
* @throws Error if the Meta Pixel is not initialized.
|
|
*/
|
|
pageView(disableCAPI: boolean = false) {
|
|
this.track('PageView', undefined, undefined, disableCAPI);
|
|
}
|
|
|
|
/**
|
|
* Forwards an event to the Conversion API client if configured.
|
|
*
|
|
* @param event - The event name.
|
|
* @param params - The event parameters.
|
|
* @param eventID - Optional event ID for deduplication.
|
|
* @returns The event ID used, either provided or generated.
|
|
*/
|
|
private forwardToCAPI(
|
|
event: StandardEventName | string,
|
|
params?: CommonParams & CustomParams,
|
|
eventID?: string
|
|
): string | undefined {
|
|
if (!this._conversionClient) return eventID;
|
|
|
|
if (!eventID) {
|
|
eventID = crypto.randomUUID();
|
|
}
|
|
|
|
const opts: CAPIEventOptions = {
|
|
eventName: event,
|
|
eventID: eventID,
|
|
actionSource: ActionSource.Website,
|
|
eventSourceURL: window.location.href,
|
|
eventTime: new Date(),
|
|
userData: {
|
|
clientUserAgent: navigator.userAgent
|
|
},
|
|
customData: params ? pixelParamsToCustomData(params) : undefined
|
|
};
|
|
const ev = CAPIEvent.fromOpts(opts);
|
|
|
|
log.debug(
|
|
`${this.logPrefix} ${ev.readableName} forwarding to CAPI with body: ${JSON.stringify(ev.params, null, 2)}`
|
|
);
|
|
|
|
this._conversionClient
|
|
.trackEvent(ev)
|
|
.then((response) => {
|
|
log.debug(
|
|
`${this.logPrefix} ${ev.readableName} forwarded to CAPI, response: ${JSON.stringify(
|
|
response,
|
|
null,
|
|
2
|
|
)}`
|
|
);
|
|
})
|
|
.catch((error) => {
|
|
log.error(`${this.logPrefix} ${ev.readableName} failed to forward to CAPI. Error: `, error);
|
|
});
|
|
|
|
return eventID;
|
|
}
|
|
|
|
private getEventReadableName(
|
|
event: StandardEventName | string,
|
|
eventID?: string,
|
|
testCode?: string
|
|
): string {
|
|
return (
|
|
event + (eventID ? ` (${eventID})` : '') + (testCode ? ` [test_event_code: ${testCode}]` : '')
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Tracks a standard event for this pixel (uses `trackSingle` under the hood)
|
|
* @param event Standard event name
|
|
* @param params Event parameters
|
|
* @param eventID Optional event ID for deduplication with Conversion API
|
|
* @param disableCAPI If true, disables sending this event to the Conversion API
|
|
* @throws Error if the Meta Pixel is not initialized.
|
|
*/
|
|
track<K extends StandardEventName>(
|
|
event: K,
|
|
params?: EventParamsByName[K],
|
|
eventID?: string,
|
|
disableCAPI: boolean = false
|
|
) {
|
|
if (!this.consentGuard()) return;
|
|
this.devModeWarn();
|
|
|
|
// Optionally, send to conversion API endpoint
|
|
if (!disableCAPI) {
|
|
eventID = this.forwardToCAPI(event, params, eventID);
|
|
}
|
|
|
|
// Send the event to Meta via the pixel
|
|
window.fbq('trackSingle', this._pixelID, event, params, {
|
|
eventID,
|
|
test_event_code: this._testEventCode
|
|
});
|
|
log.debug(
|
|
`${this.logPrefix} ${this.getEventReadableName(event, eventID, this._testEventCode)} sent.`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Tracks a custom event for this pixel (uses `trackSingleCustom` under the hood)
|
|
* @param event Custom event name
|
|
* @param params Event parameters
|
|
* @param eventID Optional event ID for deduplication with Conversion API
|
|
* @param disableCAPI If true, disables sending this event to the Conversion API
|
|
* @throws Error if the Meta Pixel is not initialized.
|
|
*/
|
|
trackCustom(
|
|
event: string,
|
|
params?: CommonParams & CustomParams,
|
|
eventID?: string,
|
|
disableCAPI: boolean = false
|
|
) {
|
|
if (!this.consentGuard()) return;
|
|
this.devModeWarn();
|
|
|
|
// Optionally, send to conversion API endpoint
|
|
if (!disableCAPI) {
|
|
eventID = this.forwardToCAPI(event, params, eventID);
|
|
}
|
|
|
|
// Send the event to Meta via the pixel
|
|
window.fbq('trackSingleCustom', this._pixelID, event, params, {
|
|
eventID,
|
|
test_event_code: this._testEventCode
|
|
});
|
|
log.debug(
|
|
`${this.logPrefix} ${this.getEventReadableName(event, eventID, this._testEventCode)} sent.`
|
|
);
|
|
}
|
|
}
|