From bdd293e210c7e5ec46a1c167e5f5058699a3e301 Mon Sep 17 00:00:00 2001 From: BC-AdamWard <95659751+BC-AdamWard@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:22:54 -0600 Subject: [PATCH] feat(analytics): add Meta Pixel analytics provider Adds a Meta Pixel (Facebook Pixel) provider to the Catalyst analytics framework, following the GoogleAnalyticsProvider architecture. Merchants who configure a Meta Pixel (surfaced via webAnalytics.metaPixel.pixelId in the GraphQL Storefront API) get standard e-commerce event tracking alongside GA4. - MetaPixelProvider implementing AnalyticsProvider, typed with @types/facebook-pixel (no 'any'). - Meta Consent Mode: fbq('consent', ...) applied before init so the pixel stays dormant until marketing consent is granted. - Standard events (ViewContent, AddToCart) use fbq('track', ...); events with no Meta standard equivalent (ViewCart, RemoveFromCart, ViewCategory) use fbq('trackCustom', ...). Each carries an eventID for browser <-> Conversions API deduplication. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../add-meta-pixel-analytics-provider.md | 20 ++ core/components/analytics/fragment.ts | 3 + core/components/analytics/provider.tsx | 45 ++-- .../analytics/providers/meta-pixel/index.ts | 221 ++++++++++++++++++ core/package.json | 1 + pnpm-lock.yaml | 12 +- 6 files changed, 287 insertions(+), 15 deletions(-) create mode 100644 .changeset/add-meta-pixel-analytics-provider.md create mode 100644 core/lib/analytics/providers/meta-pixel/index.ts diff --git a/.changeset/add-meta-pixel-analytics-provider.md b/.changeset/add-meta-pixel-analytics-provider.md new file mode 100644 index 0000000000..3a2b3a5aad --- /dev/null +++ b/.changeset/add-meta-pixel-analytics-provider.md @@ -0,0 +1,20 @@ +--- +"@bigcommerce/catalyst-core": minor +--- + +Add Meta Pixel analytics provider support + +Adds a Meta Pixel (Facebook Pixel) provider to the Catalyst analytics +framework, following the same architecture as the Google Analytics provider. +Merchants who configure a Meta Pixel in their store settings (surfaced via +`webAnalytics.metaPixel.pixelId` in the GraphQL Storefront API) get standard +e-commerce event tracking alongside or instead of GA4. + +- `MetaPixelProvider` implementing the `AnalyticsProvider` interface, typed with + `@types/facebook-pixel` (no `any`). +- Meta Consent Mode: consent is applied via `fbq('consent', …)` before init so + the pixel stays dormant until marketing consent is granted. +- Standard events (`ViewContent`, `AddToCart`) use `fbq('track', …)`; events + without a Meta standard equivalent (`ViewCart`, `RemoveFromCart`, + `ViewCategory`) use `fbq('trackCustom', …)`. +- Each event carries an `eventID` for browser ↔ Conversions API deduplication. diff --git a/core/components/analytics/fragment.ts b/core/components/analytics/fragment.ts index 1ef3ad6b57..5161532ffe 100644 --- a/core/components/analytics/fragment.ts +++ b/core/components/analytics/fragment.ts @@ -6,6 +6,9 @@ export const WebAnalyticsFragment = graphql(` ga4 { tagId } + metaPixel { + pixelId + } } } `); diff --git a/core/components/analytics/provider.tsx b/core/components/analytics/provider.tsx index f52b93e8c5..eb05a4d3dc 100644 --- a/core/components/analytics/provider.tsx +++ b/core/components/analytics/provider.tsx @@ -6,7 +6,9 @@ import { PropsWithChildren, useEffect, useRef } from 'react'; import { FragmentOf } from '~/client/graphql'; import { Analytics } from '~/lib/analytics'; import { GoogleAnalyticsProvider } from '~/lib/analytics/providers/google-analytics'; +import { MetaPixelProvider } from '~/lib/analytics/providers/meta-pixel'; import { AnalyticsProvider as AnalyticsProviderLib } from '~/lib/analytics/react'; +import { AnalyticsProvider as AnalyticsProviderType } from '~/lib/analytics/types'; import { getConsentCookie } from '~/lib/consent-manager/cookies/client'; import { WebAnalyticsFragment } from './fragment'; @@ -33,21 +35,38 @@ const getConsent = () => { }; const getAnalytics = ({ channelId, isCookieConsentEnabled, settings }: Props): Analytics | null => { - if (settings?.webAnalytics?.ga4?.tagId && channelId) { - const googleAnalytics = new GoogleAnalyticsProvider({ - gaId: settings.webAnalytics.ga4.tagId, - consentModeEnabled: isCookieConsentEnabled, - developerId: 'dMjk3Nj', - getConsent, - }); - - return new Analytics({ - channelId, - providers: [googleAnalytics], - }); + if (!channelId) { + return null; + } + + const providers: AnalyticsProviderType[] = []; + + if (settings?.webAnalytics?.ga4?.tagId) { + providers.push( + new GoogleAnalyticsProvider({ + gaId: settings.webAnalytics.ga4.tagId, + consentModeEnabled: isCookieConsentEnabled, + developerId: 'dMjk3Nj', + getConsent, + }), + ); + } + + if (settings?.webAnalytics?.metaPixel?.pixelId) { + providers.push( + new MetaPixelProvider({ + pixelId: settings.webAnalytics.metaPixel.pixelId, + consentModeEnabled: isCookieConsentEnabled, + getConsent, + }), + ); + } + + if (providers.length === 0) { + return null; } - return null; + return new Analytics({ channelId, providers }); }; export function AnalyticsProvider({ diff --git a/core/lib/analytics/providers/meta-pixel/index.ts b/core/lib/analytics/providers/meta-pixel/index.ts new file mode 100644 index 0000000000..b99bbd6207 --- /dev/null +++ b/core/lib/analytics/providers/meta-pixel/index.ts @@ -0,0 +1,221 @@ +import { AnalyticsProvider } from '~/lib/analytics/types'; + +export interface MetaPixelConfig { + pixelId: string; + consentModeEnabled?: boolean; + nonce?: string; + getConsent?: () => Analytics.Consent.ConsentValues | null; +} + +/** + * Meta Pixel (Facebook Pixel) Analytics Provider + * + * Implements the AnalyticsProvider interface to track e-commerce events using + * Meta Pixel. Mirrors the GoogleAnalyticsProvider architecture: the base code + * is injected once (defining the global `fbq`), consent is applied via Meta's + * Consent Mode, and events are forwarded through `fbq`. + * + * Standard Meta events are sent with `fbq('track', …)`; events without a Meta + * standard equivalent (ViewCart, RemoveFromCart, ViewCategory) are sent with + * `fbq('trackCustom', …)`. Each event carries an `eventID` for browser ↔ + * Conversions API deduplication. + * + * @example + * ```typescript + * const metaPixel = new MetaPixelProvider({ + * pixelId: '1234567890', + * consentModeEnabled: true, + * getConsent: () => getConsentCookie(), + * }); + * metaPixel.initialize(); + * ``` + */ +export class MetaPixelProvider implements AnalyticsProvider { + static #instance: MetaPixelProvider | null = null; + + readonly cart = this.getCartEvents(); + readonly navigation = this.getNavigationEvents(); + readonly consent = this.getConsentEvents(); + + private readonly pixelScriptId = 'meta-pixel-script'; + + constructor(private readonly config: MetaPixelConfig) { + this.validateConfig(); + + if (MetaPixelProvider.#instance) { + return MetaPixelProvider.#instance; + } + + MetaPixelProvider.#instance = this; + } + + initialize() { + if (typeof window === 'undefined') { + throw new Error('Meta Pixel is only available in the browser environment'); + } + + // Order matters: define `fbq`, apply consent, then init. Meta's Consent + // Mode requires `fbq('consent', 'revoke')` to run *before* `fbq('init')` + // so no events fire until marketing consent is granted. + this.injectBaseCode(); + this.initializeConsent(); + this.trackPageView(); + } + + private validateConfig() { + if (!this.config.pixelId) { + throw new Error('Meta Pixel requires a Pixel ID'); + } + + // Pixel IDs are numeric strings. + if (!/^\d+$/.test(this.config.pixelId)) { + throw new Error('Meta Pixel ID must be a numeric string'); + } + } + + // Inject the Meta Pixel base code. This defines the global `fbq` function + // (which queues calls until fbevents.js loads) and loads the script. It does + // NOT call `fbq('init')` — that happens in `trackPageView`, after consent. + private injectBaseCode() { + if (document.getElementById(this.pixelScriptId)) { + return; + } + + const script = document.createElement('script'); + + script.id = this.pixelScriptId; + script.type = 'text/javascript'; + script.nonce = this.config.nonce; + script.innerHTML = ` + !function(f,b,e,v,n,t,s) + {if(f.fbq)return;n=f.fbq=function(){n.callMethod? + n.callMethod.apply(n,arguments):n.queue.push(arguments)}; + if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0'; + n.queue=[];t=b.createElement(e);t.async=!0; + t.src=v;s=b.getElementsByTagName(e)[0]; + s.parentNode.insertBefore(t,s)}(window, document,'script', + 'https://connect.facebook.net/en_US/fbevents.js'); + `; + + document.head.appendChild(script); + } + + // Apply Meta Consent Mode based on the user's marketing preference. Defaults + // to revoked unless marketing consent is explicitly granted. Runs before + // `fbq('init')` so the pixel stays dormant until consent. + private initializeConsent() { + if (!this.config.consentModeEnabled || !this.config.getConsent) { + return; + } + + const consent = this.config.getConsent(); + + fbq('consent', consent?.marketing ? 'grant' : 'revoke'); + } + + private trackPageView() { + fbq('init', this.config.pixelId); + fbq('track', 'PageView'); + } + + private getContentIds(items: Analytics.Product[]): string[] { + return items.map((item) => item.sku ?? item.id); + } + + private getContents(items: Analytics.Product[]) { + return items.map((item) => ({ + id: item.sku ?? item.id, + quantity: item.quantity ?? 1, + item_price: item.price, + })); + } + + private getCartEvents() { + return { + // No Meta standard event for cart view — sent as a custom event. + cartViewed: (payload, metadata) => { + fbq( + 'trackCustom', + 'ViewCart', + { + content_ids: this.getContentIds(payload.items), + contents: this.getContents(payload.items), + content_type: 'product', + currency: payload.currency, + value: payload.value, + }, + { eventID: metadata.eventUuid }, + ); + }, + productAdded: (payload, metadata) => { + fbq( + 'track', + 'AddToCart', + { + content_ids: this.getContentIds(payload.items), + content_type: 'product', + currency: payload.currency, + value: payload.value, + }, + { eventID: metadata.eventUuid }, + ); + }, + // No Meta standard event for cart removal — sent as a custom event. + productRemoved: (payload, metadata) => { + fbq( + 'trackCustom', + 'RemoveFromCart', + { + content_ids: this.getContentIds(payload.items), + contents: this.getContents(payload.items), + content_type: 'product', + currency: payload.currency, + value: payload.value, + }, + { eventID: metadata.eventUuid }, + ); + }, + } satisfies Analytics.Cart.ProviderEvents; + } + + private getNavigationEvents() { + return { + // No Meta standard event for category view — sent as a custom event. + categoryViewed: (payload, metadata) => { + fbq( + 'trackCustom', + 'ViewCategory', + { + content_category: payload.name, + content_ids: this.getContentIds(payload.items), + contents: this.getContents(payload.items), + content_type: 'product', + currency: payload.currency, + }, + { eventID: metadata.eventUuid }, + ); + }, + productViewed: (payload, metadata) => { + fbq( + 'track', + 'ViewContent', + { + content_ids: this.getContentIds(payload.items), + content_type: 'product', + currency: payload.currency, + value: payload.value, + }, + { eventID: metadata.eventUuid }, + ); + }, + } satisfies Analytics.Navigation.ProviderEvents; + } + + private getConsentEvents() { + return { + consentUpdated: (consent) => { + fbq('consent', consent.marketing ? 'grant' : 'revoke'); + }, + } satisfies Analytics.Consent.ProviderEvents; + } +} diff --git a/core/package.json b/core/package.json index 417bc4311b..e0254699ba 100644 --- a/core/package.json +++ b/core/package.json @@ -94,6 +94,7 @@ "@playwright/test": "^1.52.0", "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/typography": "^0.5.16", + "@types/facebook-pixel": "^0.0.31", "@types/gtag.js": "^0.0.20", "@types/lodash.debounce": "^4.0.9", "@types/node": "^22.15.30", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 029e4aedb4..65898bcec6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -252,6 +252,9 @@ importers: '@tailwindcss/typography': specifier: ^0.5.16 version: 0.5.16(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3))) + '@types/facebook-pixel': + specifier: ^0.0.31 + version: 0.0.31 '@types/gtag.js': specifier: ^0.0.20 version: 0.0.20 @@ -4160,6 +4163,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/facebook-pixel@0.0.31': + resolution: {integrity: sha512-NyfutSDY9Cabk7IUsEMy2pcVPGVVQjsFI3caZkeZvJ9lffz/CZjJA84mUhluTRM/iHjZ96VwBMPORpDSioNeyg==} + '@types/fs-extra@11.0.4': resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==} @@ -14393,6 +14399,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/facebook-pixel@0.0.31': {} + '@types/fs-extra@11.0.4': dependencies: '@types/jsonfile': 6.1.4 @@ -16329,7 +16337,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.9.1)(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: @@ -16361,7 +16369,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.9.1)(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3