Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3c880c17b | ||
|
|
01fd81f911 | ||
|
|
af1b66649b | ||
|
|
93e5c35d86 | ||
|
|
90a2063ad3 | ||
|
|
b6e0830209 | ||
|
|
a9a9f5ed30 | ||
|
|
0e1a449cb6 | ||
|
|
764da5db2e | ||
|
|
6692338b83 | ||
|
|
3561012fb9 | ||
|
|
b26f6160f8 | ||
|
|
1bb202ffa5 | ||
|
|
0cd3f10da6 | ||
|
|
f2d389ee64 | ||
|
|
f5ec7b3812 | ||
|
|
fc07bb057c | ||
|
|
9400e81aaa | ||
|
|
824fd262ed | ||
|
|
82cce84a4e | ||
|
|
2e12d281ef | ||
|
|
99c1f003c6 | ||
|
|
97497db8e4 | ||
|
|
674663b027 | ||
|
|
e7b12f50b1 |
@@ -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.3",
|
"version": "0.1.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
@@ -62,8 +62,11 @@
|
|||||||
"svelte"
|
"svelte"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/facebook-nodejs-business-sdk": "^23.0.0",
|
||||||
"@types/umami": "^2.10.1",
|
"@types/umami": "^2.10.1",
|
||||||
"loglevel": "^1.9.2"
|
"http-status-codes": "^2.3.0",
|
||||||
|
"loglevel": "^1.9.2",
|
||||||
|
"valibot": "^1.2.0"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"registry": "https://gitea.auvem.com/api/packages/svelte-toolkit/npm/"
|
"registry": "https://gitea.auvem.com/api/packages/svelte-toolkit/npm/"
|
||||||
|
|||||||
31
pnpm-lock.yaml
generated
31
pnpm-lock.yaml
generated
@@ -8,12 +8,21 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@types/facebook-nodejs-business-sdk':
|
||||||
|
specifier: ^23.0.0
|
||||||
|
version: 23.0.0
|
||||||
'@types/umami':
|
'@types/umami':
|
||||||
specifier: ^2.10.1
|
specifier: ^2.10.1
|
||||||
version: 2.10.1
|
version: 2.10.1
|
||||||
|
http-status-codes:
|
||||||
|
specifier: ^2.3.0
|
||||||
|
version: 2.3.0
|
||||||
loglevel:
|
loglevel:
|
||||||
specifier: ^1.9.2
|
specifier: ^1.9.2
|
||||||
version: 1.9.2
|
version: 1.9.2
|
||||||
|
valibot:
|
||||||
|
specifier: ^1.2.0
|
||||||
|
version: 1.2.0(typescript@5.9.3)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@eslint/compat':
|
'@eslint/compat':
|
||||||
specifier: ^1.4.0
|
specifier: ^1.4.0
|
||||||
@@ -473,6 +482,9 @@ packages:
|
|||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
|
'@types/facebook-nodejs-business-sdk@23.0.0':
|
||||||
|
resolution: {integrity: sha512-k9saLiTd4kvBCdBHEpTyDMYlqSsiDw0yHs2ePe5M1PTIldtdtNOGx73Gtu8dQqbTAlwBWbiG3VfI+9s45Cf88w==}
|
||||||
|
|
||||||
'@types/json-schema@7.0.15':
|
'@types/json-schema@7.0.15':
|
||||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||||
|
|
||||||
@@ -770,6 +782,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
http-status-codes@2.3.0:
|
||||||
|
resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==}
|
||||||
|
|
||||||
ignore@5.3.2:
|
ignore@5.3.2:
|
||||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
@@ -1086,6 +1101,14 @@ packages:
|
|||||||
util-deprecate@1.0.2:
|
util-deprecate@1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
|
valibot@1.2.0:
|
||||||
|
resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==}
|
||||||
|
peerDependencies:
|
||||||
|
typescript: '>=5'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
typescript:
|
||||||
|
optional: true
|
||||||
|
|
||||||
vite@7.3.0:
|
vite@7.3.0:
|
||||||
resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==}
|
resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -1447,6 +1470,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
|
'@types/facebook-nodejs-business-sdk@23.0.0': {}
|
||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
|
|
||||||
'@types/node@24.10.4':
|
'@types/node@24.10.4':
|
||||||
@@ -1791,6 +1816,8 @@ snapshots:
|
|||||||
|
|
||||||
has-flag@4.0.0: {}
|
has-flag@4.0.0: {}
|
||||||
|
|
||||||
|
http-status-codes@2.3.0: {}
|
||||||
|
|
||||||
ignore@5.3.2: {}
|
ignore@5.3.2: {}
|
||||||
|
|
||||||
ignore@7.0.5: {}
|
ignore@7.0.5: {}
|
||||||
@@ -2094,6 +2121,10 @@ snapshots:
|
|||||||
|
|
||||||
util-deprecate@1.0.2: {}
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
|
valibot@1.2.0(typescript@5.9.3):
|
||||||
|
optionalDependencies:
|
||||||
|
typescript: 5.9.3
|
||||||
|
|
||||||
vite@7.3.0(@types/node@24.10.4):
|
vite@7.3.0(@types/node@24.10.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.27.1
|
esbuild: 0.27.1
|
||||||
|
|||||||
@@ -1,224 +0,0 @@
|
|||||||
<script lang="ts" module>
|
|
||||||
export class PixelControl {
|
|
||||||
private _pixelID: string;
|
|
||||||
private _testEventCode?: string = undefined;
|
|
||||||
private _trackingManager: MaybeGetter<TrackingManager | undefined>;
|
|
||||||
|
|
||||||
private static _baseLoaded: boolean = false;
|
|
||||||
|
|
||||||
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,
|
|
||||||
testEventCode?: string
|
|
||||||
) {
|
|
||||||
this._trackingManager = trackingManager;
|
|
||||||
this._pixelID = pixelID;
|
|
||||||
this._testEventCode = testEventCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Loads the Meta Pixel base script. */
|
|
||||||
static async load() {
|
|
||||||
if (this._baseLoaded && !!window.fbq) return;
|
|
||||||
if (!window.fbq) {
|
|
||||||
try {
|
|
||||||
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;
|
|
||||||
log.debug('Meta Pixel base script loaded.');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Tells the Meta pixel that the user has given consent for tracking. */
|
|
||||||
static grantConsent() {
|
|
||||||
this.loadGuard();
|
|
||||||
window.fbq?.('consent', 'grant');
|
|
||||||
log.debug('Meta 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('Meta Pixel consent revoked.');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns 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.
|
|
||||||
* Does NOT initialize the Pixel; call `fireInit()` on the returned instance
|
|
||||||
* before tracking events.
|
|
||||||
* @param trackingManager Tracking manager to handle user consent for tracking
|
|
||||||
* @param pixelID Meta Pixel ID
|
|
||||||
* @param options Optional settings
|
|
||||||
* @returns PixelControl instance
|
|
||||||
*/
|
|
||||||
static for(
|
|
||||||
trackingManager: MaybeGetter<TrackingManager | undefined>,
|
|
||||||
pixelID: string,
|
|
||||||
options?: {
|
|
||||||
/**
|
|
||||||
* if provided, events fired will always have this code attached
|
|
||||||
* to prevent them from polluting real analytics data.
|
|
||||||
*/
|
|
||||||
testEventCode?: string;
|
|
||||||
}
|
|
||||||
): PixelControl {
|
|
||||||
PixelControl.load();
|
|
||||||
return new PixelControl(trackingManager, pixelID, options?.testEventCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes this pixel with the Meta Pixel API including any advanced
|
|
||||||
* matching data and options.
|
|
||||||
* @param advancedMatching Advanced matching data
|
|
||||||
* @param initOptions Initialization options
|
|
||||||
* @returns this PixelControl instance
|
|
||||||
*/
|
|
||||||
fireInit(advancedMatching?: AdvancedMatching, initOptions?: InitOptions): PixelControl {
|
|
||||||
PixelControl.loadGuard();
|
|
||||||
window.fbq('init', this._pixelID, advancedMatching, initOptions);
|
|
||||||
log.debug(`Meta Pixel [${this._pixelID}] initialized.`);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends a PageView event
|
|
||||||
* @throws Error if the Meta Pixel is not initialized.
|
|
||||||
*/
|
|
||||||
pageView() {
|
|
||||||
if (!this.consentGuard()) return;
|
|
||||||
window.fbq('track', 'PageView', undefined, { test_event_code: this._testEventCode });
|
|
||||||
log.debug(
|
|
||||||
`Meta Pixel [${this._pixelID}] PageView event sent (test code: ${this._testEventCode}).`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tracks a standard event for this pixel (uses `trackSingle` under the hood)
|
|
||||||
* @throws Error if the Meta Pixel is not initialized.
|
|
||||||
*/
|
|
||||||
track<K extends StandardEventName>(event: K, params?: EventParamsByName[K], eventID?: string) {
|
|
||||||
if (!this.consentGuard()) return;
|
|
||||||
window.fbq('trackSingle', this._pixelID, event, params, {
|
|
||||||
eventID,
|
|
||||||
test_event_code: this._testEventCode
|
|
||||||
});
|
|
||||||
log.debug(
|
|
||||||
`Meta Pixel [${this._pixelID}] ${event} event sent (test code: ${this._testEventCode}).`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tracks a custom event for this pixel (uses `trackSingleCustom` under the hood)
|
|
||||||
* @throws Error if the Meta Pixel is not initialized.
|
|
||||||
*/
|
|
||||||
trackCustom(event: string, params?: CommonParams & CustomParams, eventID?: string) {
|
|
||||||
if (!this.consentGuard()) return;
|
|
||||||
window.fbq('trackSingleCustom', this._pixelID, event, params, {
|
|
||||||
eventID,
|
|
||||||
test_event_code: this._testEventCode
|
|
||||||
});
|
|
||||||
log.debug(
|
|
||||||
`Meta Pixel [${this._pixelID}] ${event} custom event sent (test code: ${this._testEventCode}).`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
|
|
||||||
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 { onNavigate } from '$app/navigation';
|
|
||||||
import { resolveGetter, type MaybeGetter } from './util/getter.ts';
|
|
||||||
import log from 'loglevel';
|
|
||||||
|
|
||||||
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 is disabled by default until consent is granted via
|
|
||||||
* PixelControl.grantConsent().
|
|
||||||
*/
|
|
||||||
trackingManager?: TrackingManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { pixelID, testEventCode, autoPageView = true, trackingManager }: Props = $props();
|
|
||||||
|
|
||||||
let pixel = $state<PixelControl | null>(null);
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if (!trackingManager) {
|
|
||||||
throw new Error('MetaPixel component requires a TrackingManager to manage consent.');
|
|
||||||
}
|
|
||||||
PixelControl.load();
|
|
||||||
pixel = PixelControl.for(trackingManager, pixelID, { testEventCode }).fireInit();
|
|
||||||
|
|
||||||
trackingManager.runWithConsent(() => {
|
|
||||||
if (autoPageView && pixel) {
|
|
||||||
pixel.pageView();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
onNavigate(() => {
|
|
||||||
trackingManager?.runWithConsent(() => {
|
|
||||||
if (autoPageView && pixel) {
|
|
||||||
pixel.pageView();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -25,7 +25,9 @@
|
|||||||
const consentGranted = $derived(trackingManager ? trackingManager.consent === true : true);
|
const consentGranted = $derived(trackingManager ? trackingManager.consent === true : true);
|
||||||
|
|
||||||
// Development overrides to prevent dirty analytics
|
// Development overrides to prevent dirty analytics
|
||||||
const devConsoleTag = $derived(`[dev][consent: ${consentGranted ? 'granted' : 'revoked'}]`);
|
const devConsoleTag = $derived(
|
||||||
|
`[Umami] [dev][consent: ${consentGranted ? 'granted' : 'revoked'}]`
|
||||||
|
);
|
||||||
const devOverride = {
|
const devOverride = {
|
||||||
track: (...args: unknown[]): Promise<string> | undefined => {
|
track: (...args: unknown[]): Promise<string> | undefined => {
|
||||||
log.debug(`${devConsoleTag}: Track called with:`, ...args);
|
log.debug(`${devConsoleTag}: Track called with:`, ...args);
|
||||||
@@ -62,7 +64,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (dev) log.debug('[dev]: Umami tracking disabled');
|
if (dev) log.info('[Umami] [dev]: reporting disabled');
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (dev) {
|
if (dev) {
|
||||||
|
|||||||
101
src/lib/capi/client.ts
Normal file
101
src/lib/capi/client.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { getFbpFbc } from '../metapixel/fbc.ts';
|
||||||
|
import type { TrackingManager } from '$lib/tracking.svelte';
|
||||||
|
import {
|
||||||
|
capiErrorBodySchema,
|
||||||
|
capiResponseBodySchema,
|
||||||
|
type CAPIErrorBody,
|
||||||
|
type CAPIRequestBody,
|
||||||
|
type CAPIResponseBody
|
||||||
|
} from './handle.ts';
|
||||||
|
import type { CAPICustomerInfoParams, CAPIEvent } from './event.ts';
|
||||||
|
import * as v from 'valibot';
|
||||||
|
import { dev } from '$app/environment';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client abstracts HTTP communication with a spectator server endpoint for
|
||||||
|
* sending conversion events to Meta Conversions API.
|
||||||
|
*/
|
||||||
|
export class CAPIClient {
|
||||||
|
private _href: string;
|
||||||
|
private _trackingManager: TrackingManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new CAPIClient.
|
||||||
|
*
|
||||||
|
* @param serverHref - The spectator server endpoint URL.
|
||||||
|
* @param trackingManager - The tracking manager instance, used for consent status.
|
||||||
|
*/
|
||||||
|
constructor(serverHref: string, trackingManager: TrackingManager) {
|
||||||
|
this._href = serverHref;
|
||||||
|
this._trackingManager = trackingManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends CAPIEvents to the server endpoint. If no consent is given, no events
|
||||||
|
* are sent and a dummy response is returned.
|
||||||
|
* @param events - The array of CAPIEvents to send.
|
||||||
|
* @returns A promise that resolves to the server response.
|
||||||
|
* @throws Will throw an error if input or response shape validation fails.
|
||||||
|
*/
|
||||||
|
async sendEvents(events: CAPIEvent[]): Promise<CAPIResponseBody | CAPIErrorBody> {
|
||||||
|
// Respond with an empty response if consent is not given
|
||||||
|
if (!this._trackingManager.haveUserConsent()) {
|
||||||
|
if (dev) {
|
||||||
|
console.warn(`[CAPIClient] Consent not given. Skipping sending ${events.length} event(s).`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
fbtrace_id: '',
|
||||||
|
events_received: 0,
|
||||||
|
messages: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to build enriched user data
|
||||||
|
const { fbp, fbc } = getFbpFbc();
|
||||||
|
const enrichedUserData: Partial<CAPICustomerInfoParams> = { fbp, fbc };
|
||||||
|
|
||||||
|
// Build request body
|
||||||
|
const body: CAPIRequestBody = {
|
||||||
|
events: events.map((e) => {
|
||||||
|
e.enrichUserData(enrichedUserData);
|
||||||
|
return e.toObject();
|
||||||
|
})
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
v.parse(v.object({ events: v.array(v.any()) }), body); // Validate body shape
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`[CAPIClient] Invalid request body shape: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(this._href, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
const json = await response.json();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (response.ok) {
|
||||||
|
const parsed = v.parse(capiResponseBodySchema, json);
|
||||||
|
return parsed as CAPIResponseBody;
|
||||||
|
} else {
|
||||||
|
const parsed = v.parse(capiErrorBodySchema, json);
|
||||||
|
return parsed as CAPIErrorBody;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`[CAPIClient] Invalid response shape: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shorthand for sending a single CAPIEvent to the server endpoint.
|
||||||
|
* @param event - The CAPIEvent to send.
|
||||||
|
* @returns A promise that resolves to the server response.
|
||||||
|
* @throws Will throw an error if input or response shape validation fails.
|
||||||
|
*/
|
||||||
|
async trackEvent(event: CAPIEvent): Promise<CAPIResponseBody | CAPIErrorBody> {
|
||||||
|
return this.sendEvents([event]);
|
||||||
|
}
|
||||||
|
}
|
||||||
93
src/lib/capi/connector.ts
Normal file
93
src/lib/capi/connector.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { dev } from '$app/environment';
|
||||||
|
import log from 'loglevel';
|
||||||
|
import type { CAPIEvent } from './event.ts';
|
||||||
|
import * as v from 'valibot';
|
||||||
|
import { capiResponseBodySchema, type CAPIResponseBody } from './handle.ts';
|
||||||
|
|
||||||
|
const GRAPH_VERSION = 'v24.0';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connector class for Meta Conversion API (CAPI). Abstraction over direct HTTP
|
||||||
|
* requests to Meta's CAPI endpoint.
|
||||||
|
*
|
||||||
|
* See https://developers.facebook.com/docs/marketing-api/conversions-api/get-started
|
||||||
|
* for more information.
|
||||||
|
*/
|
||||||
|
export class CAPIConnector {
|
||||||
|
private _accessToken: string;
|
||||||
|
private _pixelID: string;
|
||||||
|
private _testEventCode?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new MCAPIControl instance.
|
||||||
|
*
|
||||||
|
* @param accessToken - Your Meta Pixel Conversion API access token.
|
||||||
|
* @param pixelID - Your Meta Pixel ID.
|
||||||
|
* @param testEventCode - Optional test event code used for all events if provided.
|
||||||
|
*/
|
||||||
|
constructor(accessToken: string, pixelID: string, testEventCode?: string) {
|
||||||
|
this._accessToken = accessToken;
|
||||||
|
this._pixelID = pixelID;
|
||||||
|
this._testEventCode = testEventCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends conversion events to the Meta Conversion API.
|
||||||
|
*
|
||||||
|
* @param events - Array of CAPIEvent instances to send.
|
||||||
|
* @returns The response from the Meta CAPI.
|
||||||
|
* @throws Will throw an error if the request fails or the API returns an error.
|
||||||
|
*/
|
||||||
|
async sendEvents(events: CAPIEvent[]): Promise<CAPIResponseBody> {
|
||||||
|
if (dev && !this._testEventCode) {
|
||||||
|
log.warn(
|
||||||
|
`[CAPIConnector] Sending ${events.length} event(s) in dev mode without a test event code. ` +
|
||||||
|
'Consider providing a test event code to avoid affecting real data.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `https://graph.facebook.com/${GRAPH_VERSION}/${this._pixelID}/events`;
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
data: events.map((e) => e.toObject()),
|
||||||
|
test_event_code: this._testEventCode
|
||||||
|
};
|
||||||
|
log.debug(
|
||||||
|
`[CAPIConnector] [${this._pixelID}] Sending ${events.length} event(s) to Meta CAPI at ${url} with body: ${JSON.stringify(body, null, 2)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const resp = await fetch(`${url}?access_token=${this._accessToken}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
const json = await resp.json();
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(`Meta CAPI error ${resp.status}: ${JSON.stringify(json, null, 2)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = v.parse(capiResponseBodySchema, json);
|
||||||
|
log.info(
|
||||||
|
`[CAPIConnector] [${this._pixelID}] Successfully sent ${events.length} event(s) to Meta CAPI.`
|
||||||
|
);
|
||||||
|
return parsed as CAPIResponseBody;
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`[CAPIConnector] Invalid response shape: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shorthand for sending a single event to the Meta Conversion API.
|
||||||
|
*
|
||||||
|
* @param event - The CAPIEvent instance to send.
|
||||||
|
* @returns The response from the Meta CAPI.
|
||||||
|
* @throws Will throw an error if the request fails or the API returns an error.
|
||||||
|
*/
|
||||||
|
async trackEvent(event: CAPIEvent): Promise<CAPIResponseBody> {
|
||||||
|
return this.sendEvents([event]);
|
||||||
|
}
|
||||||
|
}
|
||||||
329
src/lib/capi/event.ts
Normal file
329
src/lib/capi/event.ts
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
import type { StandardEventName } from '../types/fbq.js';
|
||||||
|
import * as v from 'valibot';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
const sha256 = (value: string): string => {
|
||||||
|
return crypto.createHash('sha256').update(value).digest('hex');
|
||||||
|
};
|
||||||
|
|
||||||
|
const normPhone = (s?: string | null) => {
|
||||||
|
// E.164-ish: keep digits, include leading + if present
|
||||||
|
if (!s) return undefined;
|
||||||
|
const plus = s.trim().startsWith('+');
|
||||||
|
const digits = s.replace(/[^\d]/g, '');
|
||||||
|
return plus ? `+${digits}` : digits;
|
||||||
|
};
|
||||||
|
|
||||||
|
const trimPunctuation = (s: string) => {
|
||||||
|
return s.replace(/^[\p{P}\p{S}]+|[\p{P}\p{S}]+$/gu, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported user data fields for conversion events.
|
||||||
|
*
|
||||||
|
* See https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/customer-information-parameters
|
||||||
|
* for an exhaustive list of Facebook's supported user data parameters. Mappings
|
||||||
|
* to Meta's expected keys are handled under the hood by CAPIEvent.
|
||||||
|
*/
|
||||||
|
export type CAPICustomerInfoParams = {
|
||||||
|
/** Email address (maps to `em`). */
|
||||||
|
email?: string;
|
||||||
|
/** Phone number (maps to `ph`). */
|
||||||
|
phone?: string;
|
||||||
|
/** First name (maps to `fn`). */
|
||||||
|
firstName?: string;
|
||||||
|
/** Last name (maps to `ln`). */
|
||||||
|
lastName?: string;
|
||||||
|
/** Client IP address (maps to `client_ip_address`). */
|
||||||
|
clientIP?: string;
|
||||||
|
/** Client user agent (maps to `client_user_agent`). */
|
||||||
|
clientUserAgent: string;
|
||||||
|
/** Facebook browser pixel ID (_fbp cookie). */
|
||||||
|
fbp?: string;
|
||||||
|
/** Facebook click ID (_fbc cookie). */
|
||||||
|
fbc?: string;
|
||||||
|
/** External ID (maps to `external_id`). */
|
||||||
|
externalID?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** valibot schema for meta's customer information parameters */
|
||||||
|
const metaCustomerInfoParamsSchema = v.object({
|
||||||
|
em: v.optional(v.string()),
|
||||||
|
ph: v.optional(v.string()),
|
||||||
|
fn: v.optional(v.string()),
|
||||||
|
ln: v.optional(v.string()),
|
||||||
|
client_ip_address: v.optional(v.string()),
|
||||||
|
client_user_agent: v.string(),
|
||||||
|
fbp: v.optional(v.string()),
|
||||||
|
fbc: v.optional(v.string()),
|
||||||
|
external_id: v.optional(v.string())
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Meta's customer information parameters with annoyingly abbreviated keys. */
|
||||||
|
type meta_customerInfoParams = v.InferOutput<typeof metaCustomerInfoParamsSchema>;
|
||||||
|
|
||||||
|
/** Maps our customer information parameters to Meta's abbreviated keys. */
|
||||||
|
const _meta_customInfoParams_Map: Record<
|
||||||
|
keyof CAPICustomerInfoParams,
|
||||||
|
keyof meta_customerInfoParams
|
||||||
|
> = {
|
||||||
|
email: 'em',
|
||||||
|
phone: 'ph',
|
||||||
|
firstName: 'fn',
|
||||||
|
lastName: 'ln',
|
||||||
|
clientIP: 'client_ip_address',
|
||||||
|
clientUserAgent: 'client_user_agent',
|
||||||
|
fbp: 'fbp',
|
||||||
|
fbc: 'fbc',
|
||||||
|
externalID: 'external_id'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps CAPICustomerInfoParams to meta_customerInfoParams by transforming keys.
|
||||||
|
* Transforms values as needed (e.g. hashing email/phone).
|
||||||
|
*
|
||||||
|
* WARNING: This function is unsafe and does not perform shape validation; ensure
|
||||||
|
* input shape is valid before consuming.
|
||||||
|
*
|
||||||
|
* @param data - The CAPICustomerInfoParams to map.
|
||||||
|
* @returns The mapped meta_customerInfoParams.
|
||||||
|
*/
|
||||||
|
const mapCustomerInfoToMeta = (data: Partial<CAPICustomerInfoParams>) => {
|
||||||
|
const dst = {} as meta_customerInfoParams; // unsafe
|
||||||
|
for (const key in data) {
|
||||||
|
const shortKey = _meta_customInfoParams_Map[key as keyof CAPICustomerInfoParams];
|
||||||
|
if (shortKey && data[key as keyof CAPICustomerInfoParams]) {
|
||||||
|
dst[shortKey] = data[key as keyof CAPICustomerInfoParams] as string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform values as needed
|
||||||
|
|
||||||
|
if (dst.em) {
|
||||||
|
dst.em = sha256(dst.em.trim().toLowerCase());
|
||||||
|
}
|
||||||
|
if (dst.ph) {
|
||||||
|
const normed = normPhone(dst.ph);
|
||||||
|
dst.ph = normed ? sha256(normed) : normed;
|
||||||
|
}
|
||||||
|
if (dst.fn) {
|
||||||
|
dst.fn = sha256(trimPunctuation(dst.fn.trim().toLowerCase()));
|
||||||
|
}
|
||||||
|
if (dst.ln) {
|
||||||
|
dst.ln = sha256(trimPunctuation(dst.ln.trim().toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return dst;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** valibot schema for standard parameters / custom data */
|
||||||
|
const standardParamsSchema = v.object({
|
||||||
|
value: v.optional(v.number()),
|
||||||
|
net_revenue: v.optional(v.number()),
|
||||||
|
currency: v.optional(v.string()),
|
||||||
|
content_name: v.optional(v.string()),
|
||||||
|
content_category: v.optional(v.string()),
|
||||||
|
content_ids: v.optional(v.array(v.string())),
|
||||||
|
contents: v.optional(
|
||||||
|
v.array(
|
||||||
|
v.object({
|
||||||
|
id: v.optional(v.string()),
|
||||||
|
quantity: v.optional(v.number()),
|
||||||
|
item_price: v.optional(v.number()),
|
||||||
|
title: v.optional(v.string()),
|
||||||
|
description: v.optional(v.string()),
|
||||||
|
category: v.optional(v.string()),
|
||||||
|
brand: v.optional(v.string()),
|
||||||
|
delivery_category: v.optional(v.string())
|
||||||
|
})
|
||||||
|
)
|
||||||
|
),
|
||||||
|
content_type: v.optional(v.string()),
|
||||||
|
order_id: v.optional(v.string()),
|
||||||
|
predicted_ltv: v.optional(v.number()),
|
||||||
|
num_items: v.optional(v.number()),
|
||||||
|
search_string: v.optional(v.string()),
|
||||||
|
status: v.optional(v.string()),
|
||||||
|
item_number: v.optional(v.string()),
|
||||||
|
delivery_category: v.optional(v.string()),
|
||||||
|
custom_properties: v.optional(v.record(v.string(), v.unknown()))
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported standard data fields typically used as custom data parameters
|
||||||
|
* for CAPI/Pixel events.
|
||||||
|
*
|
||||||
|
* See https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/custom-data
|
||||||
|
* for an exhaustive list of Facebook's supported standard event parameters.
|
||||||
|
*/
|
||||||
|
export type CAPIStandardParams = v.InferOutput<typeof standardParamsSchema>;
|
||||||
|
|
||||||
|
/** Supported action sources for conversion events. */
|
||||||
|
export enum ActionSource {
|
||||||
|
Email = 'email',
|
||||||
|
Website = 'website',
|
||||||
|
App = 'app',
|
||||||
|
PhoneCall = 'phone_call',
|
||||||
|
Chat = 'chat',
|
||||||
|
PhysicalStore = 'physical_store',
|
||||||
|
SystemGenerated = 'system_generated',
|
||||||
|
BusinessMessaging = 'business_messaging',
|
||||||
|
Other = 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** valibot schema for server event parameters, sent directly as request */
|
||||||
|
export const MetaServerEventParamsSchema = v.object({
|
||||||
|
event_name: v.string(),
|
||||||
|
event_time: v.number(),
|
||||||
|
user_data: metaCustomerInfoParamsSchema,
|
||||||
|
custom_data: v.optional(standardParamsSchema),
|
||||||
|
event_source_url: v.optional(v.string()),
|
||||||
|
opt_out: v.optional(v.boolean()),
|
||||||
|
event_id: v.optional(v.string()),
|
||||||
|
action_source: v.enum(ActionSource)
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal type representing validated server event parameters, suitable to
|
||||||
|
* POST directly to the Meta Conversions API.
|
||||||
|
*
|
||||||
|
* See https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/server-event
|
||||||
|
* for an exhaustive list of Facebook's supported server event parameters.
|
||||||
|
*/
|
||||||
|
export type MetaServerEventParams = v.InferOutput<typeof MetaServerEventParamsSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for creating a CAPIEvent. All parameters are validated and
|
||||||
|
* transformed to Meta's expected keys by CAPIEvent.
|
||||||
|
*/
|
||||||
|
export type CAPIEventOptions = {
|
||||||
|
eventName: StandardEventName | string;
|
||||||
|
/** Will be set to the current date and time if not provided */
|
||||||
|
eventTime?: Date;
|
||||||
|
userData: CAPICustomerInfoParams;
|
||||||
|
customData?: CAPIStandardParams;
|
||||||
|
/** Required if actionSource is set to 'website' */
|
||||||
|
eventSourceURL?: string;
|
||||||
|
optOut?: boolean;
|
||||||
|
eventID?: string;
|
||||||
|
actionSource: ActionSource;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a Meta Conversions API event with properly mapped parameters
|
||||||
|
* and input validation.
|
||||||
|
*/
|
||||||
|
export class CAPIEvent {
|
||||||
|
private _params?: MetaServerEventParams;
|
||||||
|
/** Returns Meta-compliant server event object. */
|
||||||
|
get params() {
|
||||||
|
return this._params;
|
||||||
|
}
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new CAPIEvent instance with the given options and generates a
|
||||||
|
* Meta-compliant event object.
|
||||||
|
*
|
||||||
|
* @param opts - The options for creating the CAPIEvent.
|
||||||
|
* @return The CAPIEvent instance.
|
||||||
|
* @throws Will throw an error if option shape validation fails.
|
||||||
|
*/
|
||||||
|
static fromOpts(opts: CAPIEventOptions) {
|
||||||
|
const event = new CAPIEvent();
|
||||||
|
|
||||||
|
// Transform our customer info params to Meta's expected keys
|
||||||
|
const meta_customerInfo = mapCustomerInfoToMeta(opts.userData);
|
||||||
|
|
||||||
|
// Build event params & validate
|
||||||
|
event._params = {
|
||||||
|
event_name: opts.eventName,
|
||||||
|
event_time: Math.floor((opts.eventTime ?? new Date()).getTime() / 1000),
|
||||||
|
user_data: meta_customerInfo,
|
||||||
|
custom_data: opts.customData,
|
||||||
|
event_source_url: opts.eventSourceURL,
|
||||||
|
opt_out: opts.optOut,
|
||||||
|
event_id: opts.eventID,
|
||||||
|
action_source: opts.actionSource
|
||||||
|
};
|
||||||
|
|
||||||
|
event._params = v.parse(MetaServerEventParamsSchema, event._params);
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unmashals a JSON string or object into a CAPIEvent instance and parses
|
||||||
|
* its parameters to match Meta's expected shape.
|
||||||
|
*
|
||||||
|
* @param src - The JSON string to unmarshal.
|
||||||
|
* @returns The CAPIEvent instance.
|
||||||
|
* @throws Will throw an error if the JSON is invalid or fails validation.
|
||||||
|
*/
|
||||||
|
private static fromSrc(src: string | object): CAPIEvent {
|
||||||
|
const obj = typeof src === 'string' ? JSON.parse(src) : src;
|
||||||
|
const parsed = v.parse(MetaServerEventParamsSchema, obj);
|
||||||
|
const event = new CAPIEvent();
|
||||||
|
event._params = parsed;
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unmarshals a JSON string into a CAPIEvent instance and parses its
|
||||||
|
* parameters to match Meta's expected shape.
|
||||||
|
*
|
||||||
|
* @param str - The JSON string to unmarshal.
|
||||||
|
* @returns The CAPIEvent instance.
|
||||||
|
* @throws Will throw an error if the JSON is invalid or fails validation.
|
||||||
|
*/
|
||||||
|
static fromJSON(str: string): CAPIEvent {
|
||||||
|
return CAPIEvent.fromSrc(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unmarshals a plain object into a CAPIEvent instance and parses its
|
||||||
|
* parameters to match Meta's expected shape.
|
||||||
|
*
|
||||||
|
* @param obj - The object to unmarshal.
|
||||||
|
* @returns The CAPIEvent instance.
|
||||||
|
* @throws Will throw an error if the object fails validation.
|
||||||
|
*/
|
||||||
|
static fromObject(obj: object): CAPIEvent {
|
||||||
|
return CAPIEvent.fromSrc(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marshals the CAPIEvent to a JSON string suitable for sending to the Meta CAPI.
|
||||||
|
* @returns The JSON string representation of the CAPIEvent.
|
||||||
|
*/
|
||||||
|
toJSON(): string {
|
||||||
|
return JSON.stringify(this._params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marshals the CAPIEvent to a plain object suitable for sending to the Meta CAPI.
|
||||||
|
* @returns The object representation of the CAPIEvent.
|
||||||
|
*/
|
||||||
|
toObject(): MetaServerEventParams {
|
||||||
|
return this._params as MetaServerEventParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enriches the CAPIEvent with additional user data.
|
||||||
|
* @param additionalData - Additional user data to merge.
|
||||||
|
* @throws Will throw an error if the enriched data fails validation.
|
||||||
|
*/
|
||||||
|
enrichUserData(additionalData: Partial<CAPICustomerInfoParams>) {
|
||||||
|
if (!this._params) return;
|
||||||
|
const additionalMetaData = mapCustomerInfoToMeta(additionalData);
|
||||||
|
|
||||||
|
// Merge additional data, only overwriting if no previous value exists
|
||||||
|
this._params.user_data = {
|
||||||
|
...additionalMetaData,
|
||||||
|
...this._params.user_data
|
||||||
|
};
|
||||||
|
|
||||||
|
// Re-validate after enrichment
|
||||||
|
this._params = v.parse(MetaServerEventParamsSchema, this._params);
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/lib/capi/handle.ts
Normal file
74
src/lib/capi/handle.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||||
|
import { StatusCodes } from 'http-status-codes';
|
||||||
|
import { getFbpFbcFromCookies } from '../metapixel/fbc.ts';
|
||||||
|
import type { CAPIConnector } from './connector.ts';
|
||||||
|
import { getRequestIP } from '../util/ip.ts';
|
||||||
|
import * as v from 'valibot';
|
||||||
|
import { CAPIEvent, MetaServerEventParamsSchema, type CAPICustomerInfoParams } from './event.ts';
|
||||||
|
|
||||||
|
export const capiRequestBodySchema = v.object({
|
||||||
|
events: v.array(MetaServerEventParamsSchema)
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Request body for conversion events */
|
||||||
|
export type CAPIRequestBody = v.InferOutput<typeof capiRequestBodySchema>;
|
||||||
|
|
||||||
|
export const capiErrorBodySchema = v.object({
|
||||||
|
error: v.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Returned by the conversion request handler in case of an error */
|
||||||
|
export type CAPIErrorBody = v.InferOutput<typeof capiErrorBodySchema>;
|
||||||
|
|
||||||
|
export const capiResponseBodySchema = v.object({
|
||||||
|
fbtrace_id: v.string(),
|
||||||
|
events_received: v.number(),
|
||||||
|
messages: v.array(v.string())
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Returned by the conversion request handler in case of a successful response */
|
||||||
|
export type CAPIResponseBody = v.InferOutput<typeof capiResponseBodySchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a SvelteKit request handler for processing conversion events.
|
||||||
|
*
|
||||||
|
* @param connector - The CAPIConnector instance to send events through.
|
||||||
|
* @returns A SvelteKit RequestHandler function.
|
||||||
|
*/
|
||||||
|
export const createCAPIHandler: (connector: CAPIConnector) => RequestHandler = (connector) => {
|
||||||
|
const handle: RequestHandler = async ({ request, getClientAddress, cookies }) => {
|
||||||
|
try {
|
||||||
|
const jsonBody = await request.json();
|
||||||
|
const parsed = v.parse(capiRequestBodySchema, jsonBody);
|
||||||
|
|
||||||
|
// Build enriched user data with IP, user agent, and fbp/fbc from cookies
|
||||||
|
const ip = getRequestIP(request, getClientAddress);
|
||||||
|
const ua = request.headers.get('user-agent') ?? undefined;
|
||||||
|
const { fbp, fbc } = getFbpFbcFromCookies(cookies);
|
||||||
|
|
||||||
|
const enrichedUserData: Partial<CAPICustomerInfoParams> = {
|
||||||
|
clientIP: ip,
|
||||||
|
clientUserAgent: ua,
|
||||||
|
fbp,
|
||||||
|
fbc
|
||||||
|
};
|
||||||
|
|
||||||
|
// Enrich each event's user data
|
||||||
|
const events: CAPIEvent[] = parsed.events.map((eventParams) => {
|
||||||
|
const event = CAPIEvent.fromObject(eventParams);
|
||||||
|
event.enrichUserData(enrichedUserData);
|
||||||
|
return event;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send the event via the control
|
||||||
|
const response = await connector.sendEvents(events);
|
||||||
|
return json(response, { status: StatusCodes.OK });
|
||||||
|
} catch (e) {
|
||||||
|
const response: CAPIErrorBody = { error: e instanceof Error ? e.message : String(e) };
|
||||||
|
return json(response, {
|
||||||
|
status: StatusCodes.INTERNAL_SERVER_ERROR
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return handle;
|
||||||
|
};
|
||||||
4
src/lib/capi/index.ts
Normal file
4
src/lib/capi/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './event.ts';
|
||||||
|
export * from './connector.ts';
|
||||||
|
export * from './handle.ts';
|
||||||
|
export * from './client.ts';
|
||||||
@@ -4,11 +4,16 @@ import { dev } from '$app/environment';
|
|||||||
import log from 'loglevel';
|
import log from 'loglevel';
|
||||||
|
|
||||||
export type * as fbq from './types/fbq.d.ts';
|
export type * as fbq from './types/fbq.d.ts';
|
||||||
export { default as MetaPixel, PixelControl } from './MetaPixel.svelte';
|
export * from './metapixel/index.ts';
|
||||||
export * from './tracking.svelte.ts';
|
export * from './tracking.svelte.ts';
|
||||||
export { default as Umami } from './Umami.svelte';
|
export { default as Umami } from './Umami.svelte';
|
||||||
|
export * from './capi/index.ts';
|
||||||
|
export * from './util/ip.ts';
|
||||||
|
|
||||||
// set log level to debug if we're in dev mode
|
// set log level to debug if we're in dev mode
|
||||||
if (dev) {
|
if (dev) {
|
||||||
log.setLevel('debug');
|
log.setLevel('debug');
|
||||||
|
log.debug('[spectator] Log level set to debug');
|
||||||
|
} else {
|
||||||
|
log.setLevel('warn');
|
||||||
}
|
}
|
||||||
|
|||||||
74
src/lib/metapixel/MetaPixel.svelte
Normal file
74
src/lib/metapixel/MetaPixel.svelte
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<!-- @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.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
import type { TrackingManager } from '../tracking.svelte.ts';
|
||||||
|
import { onNavigate } from '$app/navigation';
|
||||||
|
import { PixelControl, type PixelControlOptions } from './pixel-control.ts';
|
||||||
|
import { ensureFbc, type EnsureFbcOptions } from './fbc.ts';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/**
|
||||||
|
* Tracking manager to handle user consent for tracking. If omitted
|
||||||
|
* tracking is disabled by default until consent is granted via
|
||||||
|
* PixelControl.grantConsent().
|
||||||
|
*/
|
||||||
|
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, pixelOptions, autoPageView = true, trackingManager }: Props = $props();
|
||||||
|
|
||||||
|
let pixel = $state<PixelControl | null>(null);
|
||||||
|
|
||||||
|
const fbcOptions: EnsureFbcOptions = {
|
||||||
|
sameSite: 'Lax'
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!trackingManager) {
|
||||||
|
throw new Error('MetaPixel component requires a TrackingManager to manage consent.');
|
||||||
|
}
|
||||||
|
pixel = PixelControl.initialize(trackingManager, pixelID, pixelOptions);
|
||||||
|
|
||||||
|
trackingManager.runWithConsent(() => {
|
||||||
|
if (autoPageView && pixel) {
|
||||||
|
pixel.pageView();
|
||||||
|
ensureFbc(fbcOptions);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onNavigate(() => {
|
||||||
|
trackingManager?.runWithConsent(() => {
|
||||||
|
if (autoPageView && pixel) {
|
||||||
|
pixel.pageView();
|
||||||
|
ensureFbc(fbcOptions);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getPixelControl = (): PixelControl => {
|
||||||
|
if (!pixel) {
|
||||||
|
throw new Error('MetaPixel component has not been initialized yet, wait for onMount.');
|
||||||
|
}
|
||||||
|
return pixel;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
98
src/lib/metapixel/fbc.ts
Normal file
98
src/lib/metapixel/fbc.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import type { Cookies } from '@sveltejs/kit';
|
||||||
|
import log from 'loglevel';
|
||||||
|
|
||||||
|
export type EnsureFbcOptions = {
|
||||||
|
days?: number; // cookie lifetime, default 180
|
||||||
|
domain?: string; // optional cookie domain
|
||||||
|
sameSite?: 'Lax' | 'Strict' | 'None'; // default Lax
|
||||||
|
secure?: boolean; // default inferred from location.protocol === 'https:'
|
||||||
|
pixelLoaded?: boolean; // if true, skip manual set and let Pixel handle it
|
||||||
|
};
|
||||||
|
|
||||||
|
function getParam(name: string): string | undefined {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const v = params.get(name);
|
||||||
|
return v || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCookie(name: string): string | undefined {
|
||||||
|
return document.cookie
|
||||||
|
.split('; ')
|
||||||
|
.find((c) => c.startsWith(name + '='))
|
||||||
|
?.split('=')[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCookie(
|
||||||
|
name: string,
|
||||||
|
value: string,
|
||||||
|
{
|
||||||
|
days = 180,
|
||||||
|
domain,
|
||||||
|
sameSite = 'Lax',
|
||||||
|
secure
|
||||||
|
}: Pick<EnsureFbcOptions, 'days' | 'domain' | 'sameSite' | 'secure'> = {}
|
||||||
|
) {
|
||||||
|
const d = new Date();
|
||||||
|
d.setTime(d.getTime() + days * 864e5);
|
||||||
|
const parts = [
|
||||||
|
`${name}=${encodeURIComponent(value)}`,
|
||||||
|
`expires=${d.toUTCString()}`,
|
||||||
|
'path=/',
|
||||||
|
`SameSite=${sameSite}`
|
||||||
|
];
|
||||||
|
if (domain) parts.push(`domain=${domain}`);
|
||||||
|
const isSecure = secure ?? location.protocol === 'https:';
|
||||||
|
if (isSecure) parts.push('Secure');
|
||||||
|
document.cookie = parts.join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidFbc(value: string | undefined): boolean {
|
||||||
|
if (!value) return false;
|
||||||
|
// Expect "fb.1.<unix>.<fbclid>"
|
||||||
|
if (!value.startsWith('fb.1.')) return false;
|
||||||
|
const parts = value.split('.');
|
||||||
|
return parts.length >= 4 && /^\d+$/.test(parts[2]) && parts[3].length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure _fbc cookie exists when landing URL contains fbclid.
|
||||||
|
* Call after consent. If pixelLoaded is true, it skips manual setting.
|
||||||
|
*/
|
||||||
|
export function ensureFbc(options: EnsureFbcOptions = {}) {
|
||||||
|
try {
|
||||||
|
const { pixelLoaded = false, ...cookieOpts } = options;
|
||||||
|
|
||||||
|
if (pixelLoaded) throw new Error('Pixel loaded, skipping manual _fbc set'); // Let the Pixel set _fbc if it’s active
|
||||||
|
|
||||||
|
const fbclid = getParam('fbclid');
|
||||||
|
if (!fbclid) throw new Error('No fbclid param present');
|
||||||
|
|
||||||
|
const existing = getCookie('_fbc');
|
||||||
|
if (isValidFbc(existing)) throw new Error('_fbc cookie already present and valid');
|
||||||
|
|
||||||
|
const ts = Math.floor(Date.now() / 1000);
|
||||||
|
const fbc = `fb.1.${ts}.${fbclid}`;
|
||||||
|
setCookie('_fbc', fbc, cookieOpts);
|
||||||
|
log.debug('[ensureFbc] Set _fbc cookie:', fbc);
|
||||||
|
} catch (e) {
|
||||||
|
log.debug('[ensureFbc]', (e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to read both _fbp and _fbc for your CAPI payload from browser cookies.
|
||||||
|
*/
|
||||||
|
export function getFbpFbc(): { fbp?: string; fbc?: string } {
|
||||||
|
const fbp = getCookie('_fbp');
|
||||||
|
const fbc = getCookie('_fbc');
|
||||||
|
return { fbp, fbc };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to read both _fbp and _fbc for your CAPI payload from cookies object.
|
||||||
|
*/
|
||||||
|
export function getFbpFbcFromCookies(cookies: Cookies): { fbp?: string; fbc?: string } {
|
||||||
|
const fbp = cookies.get('_fbp') || undefined;
|
||||||
|
const fbc = cookies.get('_fbc') || undefined;
|
||||||
|
return { fbp, fbc };
|
||||||
|
}
|
||||||
4
src/lib/metapixel/index.ts
Normal file
4
src/lib/metapixel/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { default as MetaPixel } from './MetaPixel.svelte';
|
||||||
|
export { PixelControl, type PixelControlOptions } from './pixel-control.ts';
|
||||||
|
export * from './fbc.ts';
|
||||||
|
export * from './pixel-control.ts';
|
||||||
328
src/lib/metapixel/pixel-control.ts
Normal file
328
src/lib/metapixel/pixel-control.ts
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
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 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(
|
||||||
|
`[PixelControl] Conversion Client ${options.conversionHref} for Meta Pixel [${this._pixelID}] 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(
|
||||||
|
`[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(`[PixelControl] [${pixel._pixelID}] 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(
|
||||||
|
`[PixelControl] [${this._pixelID}] Sending events in dev mode without a test event code. ` +
|
||||||
|
'Consider providing a test event code to avoid affecting real data.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
this._conversionClient
|
||||||
|
.trackEvent(
|
||||||
|
CAPIEvent.fromOpts({
|
||||||
|
eventName: event,
|
||||||
|
eventID: eventID,
|
||||||
|
actionSource: ActionSource.Website,
|
||||||
|
eventTime: new Date(),
|
||||||
|
userData: {
|
||||||
|
clientUserAgent: navigator.userAgent
|
||||||
|
},
|
||||||
|
customData: params ? pixelParamsToCustomData(params) : undefined
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.then((response) => {
|
||||||
|
log.debug(
|
||||||
|
`[PixelControl] [${this._pixelID}] ${event} event forwarded to Conversion API with Event ID: ${eventID}, Response: ${JSON.stringify(
|
||||||
|
response,
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
log.error(
|
||||||
|
`[PixelControl] [${this._pixelID}] Failed to forward ${event} event to Conversion API with Event ID: ${eventID}`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return eventID;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(
|
||||||
|
`[PixelControl] [${this._pixelID}] ${event} event sent with event ID ${eventID} ${dev && ` (test code: ${this._testEventCode})`}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(
|
||||||
|
`[PixelControl] [${this._pixelID}] ${event} custom event sent with event ID ${eventID} ${dev && ` (test code: ${this._testEventCode})`}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,8 @@ export class TrackingManager {
|
|||||||
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 _loadCallbacks: Array<(consent: boolean | null) => void> = [];
|
||||||
|
private _loaded: boolean = false;
|
||||||
private _consentQueue: Array<() => void> = [];
|
private _consentQueue: Array<() => void> = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -80,6 +82,12 @@ export class TrackingManager {
|
|||||||
log.debug('[TrackingManager] Loaded tracking options from storage:', opts);
|
log.debug('[TrackingManager] Loaded tracking options from storage:', opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run load callbacks
|
||||||
|
this._loadCallbacks.forEach((cb) => {
|
||||||
|
cb(this._consent);
|
||||||
|
});
|
||||||
|
this._loaded = true;
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +160,16 @@ export class TrackingManager {
|
|||||||
this.saveOpts();
|
this.saveOpts();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a callback to be called only once the tracking state has been
|
||||||
|
* loaded from localStorage. Will be called immediately if already loaded
|
||||||
|
* and with each subsequent load.
|
||||||
|
*/
|
||||||
|
onceLoaded(callback: (consent: boolean | null) => void) {
|
||||||
|
this._loadCallbacks.push(callback);
|
||||||
|
if (this._loaded) callback(this.consent);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers a callback to be notified when the tracking permission changes.
|
* Registers a callback to be notified when the tracking permission changes.
|
||||||
*/
|
*/
|
||||||
|
|||||||
20
src/lib/types/fbq.d.ts
vendored
20
src/lib/types/fbq.d.ts
vendored
@@ -39,7 +39,7 @@ export type CommonParams = Partial<{
|
|||||||
/**
|
/**
|
||||||
* Product IDs associated with the event, such as SKUs (e.g. ["ABC123", "XYZ456"]).
|
* Product IDs associated with the event, such as SKUs (e.g. ["ABC123", "XYZ456"]).
|
||||||
*/
|
*/
|
||||||
content_ids: (string | number)[];
|
content_ids: string[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Name of the page/product.
|
* Name of the page/product.
|
||||||
@@ -230,27 +230,27 @@ export type StandardEventName =
|
|||||||
*/
|
*/
|
||||||
export type AdvancedMatching = {
|
export type AdvancedMatching = {
|
||||||
/** Primary contact email (or hashed email) */
|
/** Primary contact email (or hashed email) */
|
||||||
email?: string;
|
em?: string;
|
||||||
/** Phone number (E.164 or local) */
|
/** Phone number (E.164 or local) */
|
||||||
phone?: string;
|
ph?: string;
|
||||||
/** First name */
|
/** First name */
|
||||||
first_name?: string;
|
fn?: string;
|
||||||
/** Last name */
|
/** Last name */
|
||||||
last_name?: string;
|
ln?: string;
|
||||||
/** City */
|
/** City */
|
||||||
city?: string;
|
ct?: string;
|
||||||
/** State/region */
|
/** State/region */
|
||||||
state?: string;
|
st?: string;
|
||||||
/** Postal / ZIP code */
|
/** Postal / ZIP code */
|
||||||
zip?: string;
|
zp?: string;
|
||||||
/** Country code */
|
/** Country code */
|
||||||
country?: string;
|
country?: string;
|
||||||
/** External id to match users (optional) */
|
/** External id to match users (optional) */
|
||||||
external_id?: string;
|
external_id?: string;
|
||||||
/** Gender */
|
/** Gender */
|
||||||
gender?: string;
|
ge?: string;
|
||||||
/** Date of birth (ISO-like or YYYY-MM-DD) */
|
/** Date of birth (ISO-like or YYYY-MM-DD) */
|
||||||
date_of_birth?: string;
|
db?: string;
|
||||||
// allow additional provider-specific keys
|
// allow additional provider-specific keys
|
||||||
[key: string]: string | undefined;
|
[key: string]: string | undefined;
|
||||||
};
|
};
|
||||||
|
|||||||
20
src/lib/util/ip.ts
Normal file
20
src/lib/util/ip.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Extracts the client's IP address from the request headers or falls back to a provided function.
|
||||||
|
*
|
||||||
|
* @param request - The incoming Request object.
|
||||||
|
* @param getClientAddress - A function that returns the client's IP address as a fallback.
|
||||||
|
* @returns The client's IP address as a string.
|
||||||
|
*/
|
||||||
|
export const getRequestIP = (request: Request, getClientAddress: () => string) => {
|
||||||
|
return (
|
||||||
|
request.headers.get('x-forwarded-for') ||
|
||||||
|
request.headers.get('cf-connecting-ip') ||
|
||||||
|
request.headers.get('x-real-ip') ||
|
||||||
|
request.headers.get('x-client-ip') ||
|
||||||
|
request.headers.get('x-cluster-client-ip') ||
|
||||||
|
request.headers.get('x-original-forwarded-for') ||
|
||||||
|
request.headers.get('forwarded-for') ||
|
||||||
|
request.headers.get('forwarded') ||
|
||||||
|
getClientAddress()
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user