From f5ec7b381293cbd880f5a18a05cd9760b1c20e66 Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Thu, 18 Dec 2025 18:44:33 -0800 Subject: [PATCH] conversion: explicit event params, removes any in PixelControl --- src/lib/conversion/client.ts | 3 +- src/lib/conversion/control.ts | 88 ++++++++++++++++++------------ src/lib/conversion/server.ts | 36 +++++++----- src/lib/metapixel/pixel-control.ts | 29 +++++++++- src/lib/types/conversion.d.ts | 43 ++++++++++++++- src/lib/types/fbq.d.ts | 2 +- 6 files changed, 146 insertions(+), 55 deletions(-) diff --git a/src/lib/conversion/client.ts b/src/lib/conversion/client.ts index b583c0a..6a5cc48 100644 --- a/src/lib/conversion/client.ts +++ b/src/lib/conversion/client.ts @@ -1,6 +1,7 @@ import type { TrackingManager } from '$lib/tracking.svelte'; import type { ConversionErrorResponseBody, + ConversionEventParams, ConversionRequestBody, ConversionResponseBody, ConversionUserData @@ -37,7 +38,7 @@ export class ConversionClient { options: { eventID: string; user?: Omit; - customData?: Record; + customData?: ConversionEventParams; } ): Promise { // Extract user data diff --git a/src/lib/conversion/control.ts b/src/lib/conversion/control.ts index e1d822e..fcfa5f0 100644 --- a/src/lib/conversion/control.ts +++ b/src/lib/conversion/control.ts @@ -1,6 +1,17 @@ -import { CustomData, EventRequest, ServerEvent, UserData } from 'facebook-nodejs-business-sdk'; +import { + Content, + CustomData, + EventRequest, + ServerEvent, + UserData +} from 'facebook-nodejs-business-sdk'; import type { StandardEventName } from '../types/fbq.js'; -import type { ConversionResponseBody, ConversionUserData } from '$lib/types/conversion.js'; +import type { + ConversionEventDetails, + ConversionEventParams, + ConversionResponseBody, + ConversionUserData +} from '$lib/types/conversion.js'; import { dev } from '$app/environment'; import log from 'loglevel'; @@ -10,7 +21,7 @@ import log from 'loglevel'; * @param data - The user data to include. * @returns The constructed UserData object. */ -export const buildConversionUserData = (data: ConversionUserData): UserData => { +const buildUserData = (data: ConversionUserData): UserData => { const userData = new UserData(); if (data.email) userData.setEmail(data.email); if (data.phone) userData.setPhone(data.phone); @@ -30,40 +41,41 @@ export const buildConversionUserData = (data: ConversionUserData): UserData => { * @param params - The custom data parameters. * @returns The constructed CustomData object. */ -export const buildCustomData = (params: Record): CustomData => { +const buildCustomData = (params: ConversionEventParams): CustomData => { const c = new CustomData(); - // Map known fields safely + if (params.value) c.setValue(params.value); + if (params.net_revenue) c.setNetRevenue(params.net_revenue); if (params.currency) c.setCurrency(params.currency); - if (typeof params.value === 'number') c.setValue(params.value); - if (Array.isArray(params.contents)) c.setContents(params.contents); + if (params.content_name) c.setContentName(params.content_name); + if (params.content_category) c.setContentCategory(params.content_category); + if (params.content_ids) c.setContentIds(params.content_ids); + if (params.contents) { + const contents: Content[] = params.contents.map((content) => { + const c = new Content(); + if (content.id) c.setId(content.id.toString()); + if (content.quantity) c.setQuantity(content.quantity); + if (content.item_price) c.setItemPrice(content.item_price); + if (content.title) c.setTitle(content.title); + if (content.description) c.setDescription(content.description); + if (content.category) c.setCategory(content.category); + if (content.brand) c.setBrand(content.brand); + if (content.delivery_category) c.setDeliveryCategory(content.delivery_category); + return c; + }); + c.setContents(contents); + } if (params.content_type) c.setContentType(params.content_type); - if (Array.isArray(params.content_ids)) c.setContentIds(params.content_ids); - if (typeof params.num_items === 'number') c.setNumItems(params.num_items); + if (params.order_id) c.setOrderId(params.order_id); + if (params.predicted_ltv) c.setPredictedLtv(params.predicted_ltv); + if (params.num_items) c.setNumItems(params.num_items); if (params.search_string) c.setSearchString(params.search_string); - // Attach anything else as custom_properties - const extras = { ...params }; - delete extras.currency; - delete extras.value; - delete extras.contents; - delete extras.content_type; - delete extras.content_ids; - delete extras.num_items; - delete extras.search_string; - if (Object.keys(extras).length) c.setCustomProperties(extras); + if (params.status) c.setStatus(params.status); + if (params.item_number) c.setItemNumber(params.item_number); + if (params.delivery_category) c.setDeliveryCategory(params.delivery_category); + if (params.custom_properties) c.setCustomProperties(params.custom_properties); return c; }; -/** - * Parameters for sending a conversion event to Meta Pixel. - */ -export type ConversionEventOptions = { - eventID: string; - actionSource: 'website' | 'app' | 'offline' | 'other'; - userData: UserData; - customData?: CustomData; - eventSourceURL?: string; -}; - /** * Control class for sending Conversion API events to Meta Pixel. */ @@ -95,17 +107,21 @@ export class ConversionControl { */ async trackEvent( eventName: StandardEventName, - options: ConversionEventOptions + details: ConversionEventDetails, + params?: ConversionEventParams ): Promise { const event = new ServerEvent() .setEventName(eventName) .setEventTime(Math.floor(Date.now() / 1000)) - .setEventId(options.eventID) - .setUserData(options.userData) - .setActionSource(options.actionSource); + .setEventId(details.eventID) + .setUserData(buildUserData(details.userData)) + .setActionSource(details.actionSource); - if (options?.eventSourceURL) event.setEventSourceUrl(options.eventSourceURL); - if (options?.customData) event.setCustomData(options.customData); + if (details?.eventSourceURL) event.setEventSourceUrl(details.eventSourceURL); + if (params) { + const customData = buildCustomData(params); + event.setCustomData(customData); + } // If we're in dev mode and missing a test event code, log and exit if (dev && !this._testEventCode) { diff --git a/src/lib/conversion/server.ts b/src/lib/conversion/server.ts index 7d10f59..4d89fb5 100644 --- a/src/lib/conversion/server.ts +++ b/src/lib/conversion/server.ts @@ -1,6 +1,11 @@ import { json, type RequestHandler } from '@sveltejs/kit'; -import { buildConversionUserData, buildCustomData, ConversionControl } from './control.ts'; -import type { ConversionErrorResponseBody, ConversionRequestBody } from '$lib/types/conversion.js'; +import { ConversionControl } from './control.ts'; +import type { + ConversionErrorResponseBody, + ConversionEventParams, + ConversionRequestBody, + ConversionUserData +} from '$lib/types/conversion.js'; import { StatusCodes } from 'http-status-codes'; @@ -29,32 +34,33 @@ export const createConversionRequestHandler: (control: ConversionControl) => Req const ip = getEventIP(request, getClientAddress); const ua = request.headers.get('user-agent'); - const userData = buildConversionUserData({ + const userData: ConversionUserData = { ...body.user, ip, ua: ua ?? body.user?.ua ?? undefined - }); + }; // Build custom data with UTM params if applicable - let rawCustomData = body.customData ?? {}; + let customData: ConversionEventParams = body.customData ?? {}; if (body.eventName === 'PageView' && body.utms) { // For PageView events, automatically include UTM params if provided - rawCustomData = { - ...rawCustomData, + customData = { + ...customData, ...body.utms }; } - const customData = buildCustomData(rawCustomData); - // Send the event via the control - const response = await control.trackEvent(body.eventName, { - eventID: body.eventID, - eventSourceURL: body.eventSourceURL, - actionSource: 'website', - userData, + const response = await control.trackEvent( + body.eventName, + { + eventID: body.eventID, + eventSourceURL: body.eventSourceURL, + actionSource: 'website', + userData + }, customData - }); + ); return json(response, { status: StatusCodes.OK }); } catch (e) { diff --git a/src/lib/metapixel/pixel-control.ts b/src/lib/metapixel/pixel-control.ts index 59c2794..a95e365 100644 --- a/src/lib/metapixel/pixel-control.ts +++ b/src/lib/metapixel/pixel-control.ts @@ -12,7 +12,34 @@ import { resolveGetter, type MaybeGetter } from '../util/getter.ts'; import log from 'loglevel'; import { dev } from '$app/environment'; import { ConversionClient } from '../conversion/client.ts'; +import type { ConversionEventParams } from '$lib/types/conversion.js'; +const pixelParamsToCustomData = (params: CommonParams & CustomParams): ConversionEventParams => { + const customData: ConversionEventParams = {}; + 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: ConversionEventParams['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 @@ -229,7 +256,7 @@ export class PixelControl { if (this._conversionClient) { eventID = eventID ?? crypto.randomUUID(); this._conversionClient - .trackEvent(event, { eventID: eventID, customData: params as any }) + .trackEvent(event, { eventID: eventID, customData: pixelParamsToCustomData(params ?? {}) }) .then((response) => { log.debug( `Meta Pixel [${this._pixelID}] ${event} event sent to Conversion API with Event ID: ${eventID}, Response: ${JSON.stringify( diff --git a/src/lib/types/conversion.d.ts b/src/lib/types/conversion.d.ts index 2a0590d..bb731f3 100644 --- a/src/lib/types/conversion.d.ts +++ b/src/lib/types/conversion.d.ts @@ -16,6 +16,47 @@ export type ConversionUserData = { externalId?: string; }; +/** + * Supported custom data fields for conversion events. + */ +export type ConversionEventParams = { + value?: number; + net_revenue?: number; + currency?: string; + content_name?: string; + content_category?: string; + content_ids?: string[]; + contents?: { + id?: string; + quantity?: number; + item_price?: number; + title?: string; + description?: string; + category?: string; + brand?: string; + delivery_category?: string; + }[]; + content_type?: string; + order_id?: string; + predicted_ltv?: number; + num_items?: number; + search_string?: string; + status?: string; + item_number?: string; + delivery_category?: string; + custom_properties?: Record; +}; + +/** + * Parameters for sending a conversion event to Meta Pixel. + */ +export type ConversionEventDetails = { + eventID: string; + actionSource: 'website' | 'app' | 'offline' | 'other'; + userData: ConversionUserData; + eventSourceURL?: string; +}; + /** * Request body for conversion event tracking. */ @@ -28,7 +69,7 @@ export type ConversionRequestBody = { Record<'utm_source' | 'utm_medium' | 'utm_campaign' | 'utm_content' | 'utm_term', string> >; user?: Omit; - customData?: Record; + customData?: ConversionEventParams; }; /** diff --git a/src/lib/types/fbq.d.ts b/src/lib/types/fbq.d.ts index 31cf35a..b0a9c26 100644 --- a/src/lib/types/fbq.d.ts +++ b/src/lib/types/fbq.d.ts @@ -39,7 +39,7 @@ export type CommonParams = Partial<{ /** * 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.