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; private _conversionClient?: CAPIClient = undefined; private static _baseLoaded: boolean = false; private static _registeredPixels: Record = {}; /** 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, 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, 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( 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.` ); } }