diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/index.ts b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/index.ts index 6a7853442c3..f7dfe5a20a4 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/index.ts +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/index.ts @@ -21,6 +21,8 @@ export const destination: BrowserDestinationDefinition = { pixelId: { description: 'The Pixel ID associated with your Facebook Pixel.', label: 'Pixel ID', + minimum: 15, + maximum: 15, type: 'string', required: true }, diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/fields.ts b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/fields.ts index 8031020fef0..f425dfb7d96 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/fields.ts +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/fields.ts @@ -244,7 +244,7 @@ export const value: InputField = { label: 'Value', description: 'A numeric value associated with this event. This could be a monetary value or a value in some other metric.', type: 'number', - default: { '@path': '$.properties.currency' }, + default: { '@path': '$.properties.total' }, depends_on: getDependenciesFor('value'), required: { match: 'all', @@ -268,7 +268,7 @@ export const custom_data: InputField = { export const eventID: InputField = { label: 'Event ID', - description: 'This ID can be any unique string. Event ID is used to deduplicate events sent by both Facebook Pixel and Conversions API.', + description: 'This ID can be any unique string. Event ID is used to deduplicate events sent both the server side Conversions API and the browser Pixel.', type: 'string', default: { '@path': '$.messageId' } } @@ -315,7 +315,7 @@ export const userData: InputField = { }, ph: { label: 'Phone Number', - description: 'Phone number of the user', + description: 'Phone number of the user. Make sure to include the country code. For example, "15551234567" for a US number.', type: 'string' }, fn: { @@ -354,7 +354,7 @@ export const userData: InputField = { }, zp: { label: 'ZIP/Postal Code', - description: 'ZIP or postal code of the user. For example, "94025" for Menlo Park, CA, or "10001" for New York City.', + description: 'ZIP or postal code of the user. For example, U.S zip code: 94035, Australia zip code: 1987, France zip code: 75018, UK zip code: m11ae', type: 'string' }, country: { diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/generated-types.ts b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/generated-types.ts index 6a4e8672f9d..e32b089d25a 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/generated-types.ts +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/generated-types.ts @@ -87,7 +87,7 @@ export interface Payload { [k: string]: unknown } /** - * This ID can be any unique string. Event ID is used to deduplicate events sent by both Facebook Pixel and Conversions API. + * This ID can be any unique string. Event ID is used to deduplicate events sent both the server side Conversions API and the browser Pixel. */ eventID?: string /** @@ -111,7 +111,7 @@ export interface Payload { */ em?: string /** - * Phone number of the user + * Phone number of the user. Make sure to include the country code. For example, "15551234567" for a US number. */ ph?: string /** @@ -139,7 +139,7 @@ export interface Payload { */ st?: string /** - * ZIP or postal code of the user. For example, "94025" for Menlo Park, CA, or "10001" for New York City. + * ZIP or postal code of the user. For example, U.S zip code: 94035, Australia zip code: 1987, France zip code: 75018, UK zip code: m11ae */ zp?: string /** diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/index.ts b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/index.ts index d29420f46be..1f35fa4f5b6 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/index.ts +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/index.ts @@ -2,62 +2,17 @@ import type { BrowserActionDefinition } from '@segment/browser-destination-runti import type { Settings } from '../generated-types' import type { Payload } from './generated-types' import { AllFields } from './fields' -import type { FBClient, FBStandardEventType, FBNonStandardEventType } from '../types' -import { buildOptions } from './utils' -import { getNotVisibleForEvent } from './depends-on' -import { validate } from './validate' +import type { FBClient } from '../types' +import { send } from './utils' const action: BrowserActionDefinition = { title: 'Send Event', description: 'Send a Standard or Custom Event to Facebook Conversions API.', platform: 'web', + defaultSubscription: 'type = "track"', fields: AllFields, perform: (client, { payload, settings }) => { - const { pixelId } = settings - const { - event_config: { custom_event_name, show_fields, event_name } = {}, - eventID, - eventSourceUrl, - actionSource, - userData, - ...rest - } = payload - - const isCustom = event_name === 'CustomEvent' ? true : false - - if(isCustom){ - validate(payload) - } - - if(show_fields === false){ - // If show_fields is false we delete values for fields which are hidden in the UI. - const fieldsToDelete = getNotVisibleForEvent(event_name as FBStandardEventType | FBNonStandardEventType) - fieldsToDelete.forEach(field => { - if (field in rest) { - delete rest[field as keyof typeof rest] - } - }) - } - - const options = buildOptions(payload) - - if(isCustom){ - client( - 'trackSingleCustom', - pixelId, - custom_event_name as string, - { ...rest }, - options - ) - } else { - client( - 'trackSingle', - pixelId, - event_name as FBStandardEventType, - { ...rest }, - options - ) - } + send(client, payload, settings) } } diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/utils.ts b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/utils.ts index 7a3ded4ad71..47b850738a8 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/utils.ts +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/utils.ts @@ -1,6 +1,70 @@ -import { LDU, UserData, Options, ActionSource } from '../types' +import { LDU, UserData, Options, ActionSource, FBClient, FBStandardEventType, FBNonStandardEventType } from '../types' import { US_STATE_CODES, COUNTRY_CODES} from './constants' import { Payload } from './generated-types' +import { Settings } from '../generated-types' +import { getNotVisibleForEvent } from './depends-on' +import { omit } from '@segment/actions-core' + +export function send(client: FBClient, payload: Payload, settings: Settings) { + const { pixelId } = settings + const { + event_config: { custom_event_name, show_fields, event_name } = {}, + ...rest + } = omit(payload, ['eventID', 'eventSourceUrl', 'actionSource', 'userData']) + + const isCustom = event_name === 'CustomEvent' ? true : false + + if(isCustom && !validate(payload)){ + return + } + + if(show_fields === false){ + // If show_fields is false we delete values for fields which are hidden in the UI. + const fieldsToDelete = getNotVisibleForEvent(event_name as FBStandardEventType | FBNonStandardEventType) + fieldsToDelete.forEach(field => { + if (field in rest) { + delete rest[field as keyof typeof rest] + } + }) + } + + const options = buildOptions(payload) + + if(isCustom){ + client( + 'trackSingleCustom', + pixelId, + custom_event_name as string, + { ...rest }, + options + ) + } else { + client( + 'trackSingle', + pixelId, + event_name as FBStandardEventType, + { ...rest }, + options + ) + } +} + +export function validate(payload: Payload): boolean { + const { + event_config: { event_name }, + content_ids, + contents + } = payload + + if(['AddToCart', 'Purchase', 'ViewContent'].includes(event_name)){ + if(content_ids?.length === 0 || contents?.length === 0) { + console.warn(`content_ids or contents are required for event ${event_name}`) + return false + } + } + + return true +} export function getLDU(ldu: keyof typeof LDU) { const lduObj = LDU[ldu] @@ -10,11 +74,13 @@ export function getLDU(ldu: keyof typeof LDU) { export function buildOptions(payload: Payload): Options | undefined { const { eventID, eventSourceUrl, actionSource, userData } = payload + const sanitizedUserData = santizeUserData(userData as UserData) + const options: Options = { eventID, eventSourceUrl, - actionSource: actionSource as ActionSource | undefined, - userData: santizeUserData(userData as UserData) + ...(actionSource ? { actionSource: actionSource as ActionSource } : {}), + ...(sanitizedUserData ? { userData: sanitizedUserData } : {}) } return Object.values(options).some(Boolean) ? options : undefined @@ -24,17 +90,36 @@ export function santizeUserData(userData: UserData): UserData | undefined { if(!userData){ return undefined } - userData.em = typeof userData.em === 'string' ? userData.em.toLowerCase().trim() : undefined // lowercase and trim whitespace - userData.ph = userData.ph ? userData.ph.replace(/\D/g, '') : undefined // remove non-numeric characters - userData.fn = userData.fn ? userData.fn.toLowerCase().trim() : undefined // lowercase and trim whitespace - userData.ln = userData.ln ? userData.ln.toLowerCase().trim() : undefined // lowercase and trim whitespace - userData.ge = userData.ge ? userData.ge.toLowerCase().trim() : undefined // lowercase and trim whitespace - userData.db = formatDate(userData.db) // format date to YYYYMMDD - userData.ct = userData.ct ? userData.ct.toLowerCase().replace(/\s+/g, '') : undefined // lowercase and replace any whitespace - userData.st = sanitizeState(userData.st) // lowercase 2 character state code - userData.zp = userData.zp ? userData.zp.trim() : undefined - userData.country = sanitizeCountry(userData.country) // lowercase 2 character country code - return userData + + const { external_id, em, ph, fn, ln, ge, db, ct, st, zp, country } = userData + + const formattedExternalId = external_id ?? undefined + const formattedEm = typeof em === 'string' ? em.toLowerCase().trim() : undefined + const formattedPh = ph ? ph.replace(/\D/g, '') : undefined + const formattedFn = fn ? sanitiseUtf8String(fn) : undefined + const formattedLn = ln ? sanitiseUtf8String(ln) : undefined + const formattedGe = ge === 'f' || ge === 'm' ? ge : undefined + const formattedDb = formatDate(db) + const formattedCt = ct ? sanitiseUtf8String(ct) : undefined + const formattedSt = st ? sanitizeState(st) : undefined + const formattedZp = zp ? sanitiseUtf8String(zp) : undefined + const formattedCountry = country ? sanitizeCountry(country) : undefined + + const ud: UserData = { + ...(formattedExternalId ? {external_id: formattedExternalId} : {}), + ...(formattedEm ? {em: formattedEm} : {}), + ...(formattedPh ? {ph: formattedPh} : {}), + ...(formattedFn ? {fn: formattedFn} : {}), + ...(formattedLn ? {ln: formattedLn} : {}), + ...(formattedGe ? {ge: formattedGe} : {}), + ...(formattedDb ? {db: formattedDb} : {}), + ...(formattedCt ? {ct: formattedCt} : {}), + ...(formattedSt ? {st: formattedSt} : {}), + ...(formattedZp ? {zp: formattedZp} : {}), + ...(formattedCountry ? {country: formattedCountry} : {}) + } + + return ud } function formatDate(isoDate?: string): string | undefined { @@ -56,18 +141,19 @@ function sanitizeState(state?: string): string | undefined { return undefined } - const normalized = state.replace(/\s+/g, '').toLowerCase() + const normalized = sanitiseUtf8String(state) + + if (!normalized) { + return undefined + } const abbreviation = US_STATE_CODES.get(normalized) if (abbreviation) { return abbreviation + } else + { + return normalized } - - if (/^[a-z]{2}$/i.test(normalized)) { - return normalized.toLowerCase() - } - - return undefined } function sanitizeCountry(country?: string): string | undefined { @@ -75,16 +161,26 @@ function sanitizeCountry(country?: string): string | undefined { return undefined } - const normalized = country.replace(/\s+/g, '').toUpperCase() + const normalized = sanitiseUtf8String(country) + + if(!normalized){ + return undefined + } const abbreviation = COUNTRY_CODES.get(normalized) if (abbreviation) { return abbreviation + } else { + return normalized } +} - if (/^[A-Z]{2}$/.test(normalized)) { - return normalized.toLowerCase() +function sanitiseUtf8String(stringValue?: string): string | undefined { + if (!stringValue) { + return undefined } - - return undefined + // Remove all non-letter characters using Unicode property escapes + return stringValue + .toLowerCase() + .replace(/[^\p{L}]/gu, '') } \ No newline at end of file diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/validate.ts b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/validate.ts index b343df91b51..1fe5375ffb9 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/validate.ts +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/validate.ts @@ -1,15 +1 @@ import { Payload } from './generated-types' - -export function validate(payload: Payload) { - const { - event_config: { event_name }, - content_ids, - contents - } = payload - - if(['AddToCart', 'Purchase', 'ViewContent'].includes(event_name)){ - if(content_ids?.length === 0 || contents?.length === 0) { - throw new Error(`content_ids or contents are required for event ${event_name}`) - } - } -} \ No newline at end of file diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/types.ts b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/types.ts index 81f8fcedef5..d91603fd900 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/types.ts +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/types.ts @@ -89,7 +89,7 @@ export type UserData = { ph?: string // Phone number (SHA-256) fn?: string // First name (SHA-256) ln?: string // Last name (SHA-256) - ge?: string // Gender (SHA-256) + ge?: 'f' | 'm' // Gender (SHA-256) db?: string // Date of birth (SHA-256) - format: YYYYMMDD ct?: string // City (SHA-256) st?: string // State (SHA-256)