From a716ab31ede2608ed0f620b6830189230d1f4d65 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 4 Aug 2026 14:37:04 +0100 Subject: [PATCH 01/14] Remove broken content_ids default in Facebook Conversions API Web MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The content_ids field defaulted to a Liquid template `{{ properties.products | map: 'product_id' }}`. This never worked: the `map` filter is on the mapping-kit disabledFilters list (it throws `filter "map" is disabled`), and @liquid can only return a string, never an array — so a multiple:true field always dropped it via the Array.isArray check in formatFBEvent. Presets relying on the default (Purchase, AddPaymentInfo, InitiateCheckout) sent no content_ids. An @arrayPath alternative was rejected: the app mapping editor has no @arrayPath render branch for scalar multiple:true fields, so it would display blank in the UI. Removing the default entirely — customers configure content_ids if they need it, matching the server-side facebook-conversions-api destination. Added a test covering the field definition (no default) and formatFBEvent handling. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../metadata.json | 43 +- .../src/send/__tests__/content-ids.test.ts | 62 ++ .../src/send/fields.ts | 618 +++++++++--------- 3 files changed, 389 insertions(+), 334 deletions(-) create mode 100644 packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/content-ids.test.ts diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/metadata.json b/packages/browser-destinations/destinations/facebook-conversions-api-web/metadata.json index 2ac166d479..f5a34615e2 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/metadata.json +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/metadata.json @@ -395,9 +395,7 @@ "multiple": true, "allowNull": false, "dynamic": false, - "default": { - "@liquid": "{{ properties.products | map: 'product_id' }}" - }, + "default": null, "choices": null, "placeholder": null, "properties": null, @@ -2379,9 +2377,6 @@ "content_category": { "@path": "$.properties.category" }, - "content_ids": { - "@liquid": "{{ properties.products | map: 'product_id' }}" - }, "content_name": { "@path": "$.properties.name" }, @@ -2483,9 +2478,6 @@ "content_category": { "@path": "$.properties.category" }, - "content_ids": { - "@path": "$.properties.product_id" - }, "content_name": { "@path": "$.properties.name" }, @@ -2565,6 +2557,9 @@ "client_ip_address": { "@path": "$.context.traits.client_ip_address" } + }, + "content_ids": { + "@path": "$.properties.product_id" } }, "eventSlug": null @@ -2582,9 +2577,6 @@ "content_category": { "@path": "$.properties.category" }, - "content_ids": { - "@path": "$.properties.product_id" - }, "content_name": { "@path": "$.properties.name" }, @@ -2664,6 +2656,9 @@ "client_ip_address": { "@path": "$.context.traits.client_ip_address" } + }, + "content_ids": { + "@path": "$.properties.product_id" } }, "eventSlug": null @@ -2681,9 +2676,6 @@ "content_category": { "@path": "$.properties.category" }, - "content_ids": { - "@liquid": "{{ properties.products | map: 'product_id' }}" - }, "content_name": { "@path": "$.properties.name" }, @@ -2785,9 +2777,6 @@ "content_category": { "@path": "$.properties.category" }, - "content_ids": { - "@liquid": "{{ properties.products | map: 'product_id' }}" - }, "content_name": { "@path": "$.properties.name" }, @@ -2889,9 +2878,6 @@ "content_category": { "@path": "$.category" }, - "content_ids": { - "@liquid": "{{ properties.products | map: 'product_id' }}" - }, "content_name": { "@path": "$.name" }, @@ -2993,9 +2979,6 @@ "content_category": { "@path": "$.properties.category" }, - "content_ids": { - "@liquid": "{{ properties.products | map: 'product_id' }}" - }, "content_name": { "@path": "$.properties.name" }, @@ -3102,9 +3085,6 @@ "content_category": { "@path": "$.properties.category" }, - "content_ids": { - "@path": "$.properties.product_id" - }, "content_name": { "@path": "$.properties.name" }, @@ -3184,6 +3164,9 @@ "client_ip_address": { "@path": "$.context.traits.client_ip_address" } + }, + "content_ids": { + "@path": "$.properties.product_id" } }, "eventSlug": null @@ -3201,9 +3184,6 @@ "content_category": { "@path": "$.properties.category" }, - "content_ids": { - "@path": "$.properties.product_id" - }, "content_name": { "@path": "$.properties.name" }, @@ -3283,6 +3263,9 @@ "client_ip_address": { "@path": "$.context.traits.client_ip_address" } + }, + "content_ids": { + "@path": "$.properties.product_id" } }, "eventSlug": null diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/content-ids.test.ts b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/content-ids.test.ts new file mode 100644 index 0000000000..fec7566bdb --- /dev/null +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/content-ids.test.ts @@ -0,0 +1,62 @@ +import { content_ids } from '../fields' +import { formatFBEvent } from '../functions' +import { Payload } from '../generated-types' + +describe('content_ids field', () => { + describe('field definition', () => { + it('is a multiple string field', () => { + expect(content_ids.type).toBe('string') + expect(content_ids.multiple).toBe(true) + }) + + // Regression guard: the field previously shipped a broken default that + // used the disabled Liquid `map` filter + // ({ '@liquid': "{{ properties.products | map: 'product_id' }}" }). + // That default threw / never resolved to an array, so content_ids was + // always dropped. We removed the default; customers configure it if + // needed. Do NOT reintroduce a default without verifying it resolves to + // an array of strings AND renders in the app mapping-editor UI. + it('has no default mapping', () => { + expect(content_ids.default).toBeUndefined() + }) + + it('is gated behind the relevant event dependencies', () => { + expect(content_ids.depends_on).toBeDefined() + }) + }) + + describe('formatFBEvent handling of content_ids', () => { + it('passes a provided array of ids through', () => { + const payload: Partial = { + event_config: { event_name: 'ViewContent', show_fields: true }, + content_ids: ['SKU-ABC-123', 'SKU-XYZ-789'] + } + + const result = formatFBEvent(payload as Payload) + + expect(result.content_ids).toEqual(['SKU-ABC-123', 'SKU-XYZ-789']) + }) + + it('omits content_ids when it is an empty array', () => { + const payload: Partial = { + event_config: { event_name: 'AddToCart', show_fields: true }, + content_ids: [] + } + + const result = formatFBEvent(payload as Payload) + + expect(result).not.toHaveProperty('content_ids') + }) + + it('omits content_ids when it is absent', () => { + const payload: Partial = { + event_config: { event_name: 'Purchase', show_fields: true }, + value: 45.97 + } + + const result = formatFBEvent(payload as Payload) + + expect(result).not.toHaveProperty('content_ids') + }) + }) +}) 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 b6e2ce5196..0930bf686f 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 @@ -3,366 +3,376 @@ import { getDependenciesFor } from './depends-on' import { CURRENCY_ISO_CODES } from '../constants' export const event_config: InputField = { - label: 'Event Configuration', - description: 'Specify the type of Facebook Conversions API event to send.', - type: 'object', - required: true, - additionalProperties: false, - defaultObjectUI: 'keyvalue', - properties: { - event_name: { - label: 'Event Name', - description: "Facebook Conversions API Event Name to send. Select 'Custom Event' to send a non standard event.", - type: 'string', - required: true, - choices: [ - { label: 'Custom Event', value: 'CustomEvent' }, - { label: 'Page View', value: 'PageView' }, - { label: 'Add Payment Info', value: 'AddPaymentInfo' }, - { label: 'Add To Cart', value: 'AddToCart' }, - { label: 'Add To Wishlist', value: 'AddToWishlist' }, - { label: 'Complete Registration', value: 'CompleteRegistration' }, - { label: 'Contact', value: 'Contact' }, - { label: 'Customize Product', value: 'CustomizeProduct' }, - { label: 'Donate', value: 'Donate' }, - { label: 'Find Location', value: 'FindLocation' }, - { label: 'Initiate Checkout', value: 'InitiateCheckout' }, - { label: 'Lead', value: 'Lead' }, - { label: 'Purchase', value: 'Purchase' }, - { label: 'Schedule', value: 'Schedule' }, - { label: 'Search', value: 'Search' }, - { label: 'Start Trial', value: 'StartTrial' }, - { label: 'Submit Application', value: 'SubmitApplication' }, - { label: 'Subscribe', value: 'Subscribe' }, - { label: 'View Content', value: 'ViewContent' } - ] - }, - custom_event_name:{ - label: 'Custom Event Name', - description: 'Custom event name to send to Facebook', - type: 'string', - depends_on: getDependenciesFor('custom_event_name') - }, - show_fields: { - label: 'Show all fields', - description: 'Show all fields, even those which are not relevant to the selected Event Name.', - type: 'boolean', - default: false - } + label: 'Event Configuration', + description: 'Specify the type of Facebook Conversions API event to send.', + type: 'object', + required: true, + additionalProperties: false, + defaultObjectUI: 'keyvalue', + properties: { + event_name: { + label: 'Event Name', + description: "Facebook Conversions API Event Name to send. Select 'Custom Event' to send a non standard event.", + type: 'string', + required: true, + choices: [ + { label: 'Custom Event', value: 'CustomEvent' }, + { label: 'Page View', value: 'PageView' }, + { label: 'Add Payment Info', value: 'AddPaymentInfo' }, + { label: 'Add To Cart', value: 'AddToCart' }, + { label: 'Add To Wishlist', value: 'AddToWishlist' }, + { label: 'Complete Registration', value: 'CompleteRegistration' }, + { label: 'Contact', value: 'Contact' }, + { label: 'Customize Product', value: 'CustomizeProduct' }, + { label: 'Donate', value: 'Donate' }, + { label: 'Find Location', value: 'FindLocation' }, + { label: 'Initiate Checkout', value: 'InitiateCheckout' }, + { label: 'Lead', value: 'Lead' }, + { label: 'Purchase', value: 'Purchase' }, + { label: 'Schedule', value: 'Schedule' }, + { label: 'Search', value: 'Search' }, + { label: 'Start Trial', value: 'StartTrial' }, + { label: 'Submit Application', value: 'SubmitApplication' }, + { label: 'Subscribe', value: 'Subscribe' }, + { label: 'View Content', value: 'ViewContent' } + ] }, - default: { - event_name: 'CustomEvent', - custom_event_name: {'@path': '$.event'}, - show_fields: false + custom_event_name: { + label: 'Custom Event Name', + description: 'Custom event name to send to Facebook', + type: 'string', + depends_on: getDependenciesFor('custom_event_name') + }, + show_fields: { + label: 'Show all fields', + description: 'Show all fields, even those which are not relevant to the selected Event Name.', + type: 'boolean', + default: false } + }, + default: { + event_name: 'CustomEvent', + custom_event_name: { '@path': '$.event' }, + show_fields: false + } } export const content_category: InputField = { - label: 'Content Category', - description: 'The category of the content associated with the event.', - type: 'string', - default: { '@path': '$.properties.category' }, - depends_on: getDependenciesFor('content_category') + label: 'Content Category', + description: 'The category of the content associated with the event.', + type: 'string', + default: { '@path': '$.properties.category' }, + depends_on: getDependenciesFor('content_category') } export const content_name: InputField = { - label: 'Content Name', - description: 'The name of the page or product associated with the event.', - type: 'string', - default: { '@path': '$.properties.name' }, - depends_on: getDependenciesFor('content_name') + label: 'Content Name', + description: 'The name of the page or product associated with the event.', + type: 'string', + default: { '@path': '$.properties.name' }, + depends_on: getDependenciesFor('content_name') } export const content_type: InputField = { - label: 'Content Type', - description: - 'If the IDs being passed in content_ids or contents parameter are IDs of products, then the value should be product. If product group IDs are being passed, then the value should be product_group. If no content_type is provided, Meta will match the event to every item that has the same ID, independent of its type.', - type: 'string', - choices: [ - { value: 'product', label: 'Product' }, - { value: 'product_group', label: 'Product Group' } - ], - default: 'product', - depends_on: getDependenciesFor('content_type') + label: 'Content Type', + description: + 'If the IDs being passed in content_ids or contents parameter are IDs of products, then the value should be product. If product group IDs are being passed, then the value should be product_group. If no content_type is provided, Meta will match the event to every item that has the same ID, independent of its type.', + type: 'string', + choices: [ + { value: 'product', label: 'Product' }, + { value: 'product_group', label: 'Product Group' } + ], + default: 'product', + depends_on: getDependenciesFor('content_type') } export const content_ids: InputField = { - label: 'Content IDs', - description: "Product IDs associated with the event, such as SKUs (e.g. ['ABC123', 'XYZ789']). Accepts a single string value or array of strings.", - type: 'string', - multiple: true, - default: { "@liquid": "{{ properties.products | map: 'product_id' }}"} - , - depends_on: getDependenciesFor('content_ids') + label: 'Content IDs', + description: + "Product IDs associated with the event, such as SKUs (e.g. ['ABC123', 'XYZ789']). Accepts a single string value or array of strings.", + type: 'string', + multiple: true, + depends_on: getDependenciesFor('content_ids') } export const contents: InputField = { - label: 'Contents', - description: 'A list of JSON objects that contain the product IDs associated with the event plus information about the products. ID and quantity are required fields.', - type: 'object', - multiple: true, - additionalProperties: true, - defaultObjectUI: 'keyvalue', - properties: { - id: { - label: 'ID', - description: 'The product ID of the purchased item.', - type: 'string', - required: true - }, - quantity: { - label: 'Quantity', - description: 'The number of items purchased.', - type: 'integer', - required: true - }, - item_price: { - label: 'Item Price', - description: 'The price of the item.', - type: 'number' - } + label: 'Contents', + description: + 'A list of JSON objects that contain the product IDs associated with the event plus information about the products. ID and quantity are required fields.', + type: 'object', + multiple: true, + additionalProperties: true, + defaultObjectUI: 'keyvalue', + properties: { + id: { + label: 'ID', + description: 'The product ID of the purchased item.', + type: 'string', + required: true }, - default: { - '@arrayPath': [ - '$.properties.products', - { - id: { '@path': '$.product_id' }, - quantity: { '@path': '$.quantity' }, - item_price: { '@path': '$.price' } - } - ] + quantity: { + label: 'Quantity', + description: 'The number of items purchased.', + type: 'integer', + required: true }, - depends_on: getDependenciesFor('contents') -} + item_price: { + label: 'Item Price', + description: 'The price of the item.', + type: 'number' + } + }, + default: { + '@arrayPath': [ + '$.properties.products', + { + id: { '@path': '$.product_id' }, + quantity: { '@path': '$.quantity' }, + item_price: { '@path': '$.price' } + } + ] + }, + depends_on: getDependenciesFor('contents') +} export const currency: InputField = { - label: 'Currency', - description: 'The currency for the value specified. Currency must be a valid ISO 4217 three-digit currency code.', - type: 'string', - default: { '@path': '$.properties.currency' }, - depends_on: getDependenciesFor('currency'), - choices:(() => { - return [...CURRENCY_ISO_CODES].map(code => ({ - value: code, - label: code - })) - })(), - required: { - match: 'all', - conditions: [ - { - fieldKey: 'event_config.event_name', - operator: 'is', - value: 'Purchase' - } - ] - } + label: 'Currency', + description: 'The currency for the value specified. Currency must be a valid ISO 4217 three-digit currency code.', + type: 'string', + default: { '@path': '$.properties.currency' }, + depends_on: getDependenciesFor('currency'), + choices: (() => { + return [...CURRENCY_ISO_CODES].map((code) => ({ + value: code, + label: code + })) + })(), + required: { + match: 'all', + conditions: [ + { + fieldKey: 'event_config.event_name', + operator: 'is', + value: 'Purchase' + } + ] + } } export const delivery_category: InputField = { - label: 'Delivery Category', - description: 'Category of the delivery', - type: 'string', - choices: [ - { value: 'in_store', label: 'In Store' }, - { value: 'curbside', label: 'Curbside' }, - { value: 'home_delivery', label: 'Home Delivery' } - ], - depends_on: getDependenciesFor('delivery_category') + label: 'Delivery Category', + description: 'Category of the delivery', + type: 'string', + choices: [ + { value: 'in_store', label: 'In Store' }, + { value: 'curbside', label: 'Curbside' }, + { value: 'home_delivery', label: 'Home Delivery' } + ], + depends_on: getDependenciesFor('delivery_category') } export const num_items: InputField = { - label: 'Number of Items', - description: 'The number of items when checkout was initiated.', - type: 'integer', - default: { '@path': '$.properties.num_items' }, - depends_on: getDependenciesFor('num_items') + label: 'Number of Items', + description: 'The number of items when checkout was initiated.', + type: 'integer', + default: { '@path': '$.properties.num_items' }, + depends_on: getDependenciesFor('num_items') } export const predicted_ltv: InputField = { - label: 'Predicted LTV', - description: 'Predicted lifetime value of a subscriber as defined by the advertiser and expressed as an exact value.', - type: 'number', - depends_on: getDependenciesFor('predicted_ltv') + label: 'Predicted LTV', + description: 'Predicted lifetime value of a subscriber as defined by the advertiser and expressed as an exact value.', + type: 'number', + depends_on: getDependenciesFor('predicted_ltv') } export const net_revenue: InputField = { - label: 'Net Revenue', - description: 'The net revenue associated with the purchase.', - type: 'number', - default: { '@path': '$.properties.net_revenue' }, - depends_on: getDependenciesFor('net_revenue') + label: 'Net Revenue', + description: 'The net revenue associated with the purchase.', + type: 'number', + default: { '@path': '$.properties.net_revenue' }, + depends_on: getDependenciesFor('net_revenue') } export const search_string: InputField = { - label: 'Search String', - description: 'The string entered by the user for the search.', - type: 'string', - default: { '@path': '$.properties.query' }, - depends_on: getDependenciesFor('search_string') + label: 'Search String', + description: 'The string entered by the user for the search.', + type: 'string', + default: { '@path': '$.properties.query' }, + depends_on: getDependenciesFor('search_string') } export const status: InputField = { - label: 'Registration Status', - description: 'The status of the registration. true for completed registrations, false otherwise.', - type: 'boolean', - depends_on: getDependenciesFor('status') + label: 'Registration Status', + description: 'The status of the registration. true for completed registrations, false otherwise.', + type: 'boolean', + depends_on: getDependenciesFor('status') } 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.value' }, - depends_on: getDependenciesFor('value'), - required: { - match: 'all', - conditions: [ - { - fieldKey: 'event_config.event_name', - operator: 'is', - value: 'Purchase' - } - ] - } + 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.value' }, + depends_on: getDependenciesFor('value'), + required: { + match: 'all', + conditions: [ + { + fieldKey: 'event_config.event_name', + operator: 'is', + value: 'Purchase' + } + ] + } } export const custom_data: InputField = { - label: 'Custom Data', - description: 'The custom data object can be used to pass custom properties.', - type: 'object', - additionalProperties: true, - defaultObjectUI: 'keyvalue' + label: 'Custom Data', + description: 'The custom data object can be used to pass custom properties.', + type: 'object', + additionalProperties: true, + defaultObjectUI: 'keyvalue' } export const eventID: InputField = { - label: 'Event ID', - 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' } + label: 'Event ID', + 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' } } export const eventSourceUrl: InputField = { - label: 'Event Source URL', - description: 'The URL of the page where the event occurred. Can be used to override the default URL taken from the current page.', - type: 'string', - default: { '@path': '$.context.page.url' } + label: 'Event Source URL', + description: + 'The URL of the page where the event occurred. Can be used to override the default URL taken from the current page.', + type: 'string', + default: { '@path': '$.context.page.url' } } export const userData: InputField = { - label: 'User Data', - description: 'User data to be sent with the event. This can include hashed identifiers like email, phone number, etc.', - type: 'object', - additionalProperties: false, - defaultObjectUI: 'keyvalue', - properties: { - external_id: { - label: 'External ID', - description: 'A unique identifier for the user from your system', - type: 'string' - }, - em: { - label: 'Email', - description: 'Email address of the user', - type: 'string', - format: 'email' - }, - ph: { - label: 'Phone Number', - description: 'Phone number of the user. Make sure to include the country code. For example, "15551234567" for a US number.', - type: 'string' - }, - fn: { - label: 'First Name', - description: 'First name of the user', - type: 'string' - }, - ln: { - label: 'Last Name', - description: 'Last name of the user', - type: 'string' - }, - ge: { - label: 'Gender', - description: 'Gender of the user. If unknown leave blank.', - type: 'string', - choices: [ - { label: 'Male', value: 'm' }, - { label: 'Female', value: 'f' } - ] - }, - db: { - label: 'Date of Birth', - description: 'Date of birth of the user', - type: 'string', - format: 'date' - }, - ct: { - label: 'City', - description: 'City of the user', - type: 'string' - }, - st: { - label: 'State', - description: 'State of the user. Facebook expects the 2-letter abbreviation for US states. For example, "CA" for California, or "NY" for New York.', - type: 'string' - }, - zp: { - label: 'ZIP/Postal Code', - 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: { - label: 'Country', - description: 'The country of the user. Facebook expects the 2-letter ISO 3166-1 alpha-2 country code. For example, "US" for the United States, or "GB" for the United Kingdom.', - type: 'string' - }, - fbp: { - label: 'FBP', - description: 'Use this field to pass the Facebook browser cookie value (_fbp) associated with the user. If the "Format User Data with Parameter Builder" setting is enabled, Segment will automatically capture this value from the _fbp cookie.', - type: 'string' - }, - fbc: { - label: 'FBC', - description: 'Use this field to pass The Facebook browser cookie value (_fbc) associated with the user. If the "Format User Data with Parameter Builder" setting is enabled, Segment will automatically capture this value from the _fbc cookie.', - type: 'string' - } + label: 'User Data', + description: + 'User data to be sent with the event. This can include hashed identifiers like email, phone number, etc.', + type: 'object', + additionalProperties: false, + defaultObjectUI: 'keyvalue', + properties: { + external_id: { + label: 'External ID', + description: 'A unique identifier for the user from your system', + type: 'string' + }, + em: { + label: 'Email', + description: 'Email address of the user', + type: 'string', + format: 'email' + }, + ph: { + label: 'Phone Number', + description: + 'Phone number of the user. Make sure to include the country code. For example, "15551234567" for a US number.', + type: 'string' + }, + fn: { + label: 'First Name', + description: 'First name of the user', + type: 'string' + }, + ln: { + label: 'Last Name', + description: 'Last name of the user', + type: 'string' }, - default: { - external_id: { '@path': '$.userId' }, - em: { '@path': '$.context.traits.email' }, - ph: { '@path': '$.context.traits.phone' }, - fn: { '@path': '$.context.traits.first_name' }, - ln: { '@path': '$.context.traits.last_name' }, - ge: { '@path': '$.context.traits.gender' }, - db: { '@path': '$.context.traits.birthday' }, - ct: { '@path': '$.context.traits.address.city' }, - st: { '@path': '$.context.traits.address.state' }, - zp: { '@path': '$.context.traits.address.postal_code' }, - country: { '@path': '$.context.traits.address.country' }, - fbp: { '@path': '$.context.traits.fbp' }, - fbc: { '@path': '$.context.traits.fbc' }, - client_ip_address: { '@path': '$.context.traits.client_ip_address' } + ge: { + label: 'Gender', + description: 'Gender of the user. If unknown leave blank.', + type: 'string', + choices: [ + { label: 'Male', value: 'm' }, + { label: 'Female', value: 'f' } + ] + }, + db: { + label: 'Date of Birth', + description: 'Date of birth of the user', + type: 'string', + format: 'date' + }, + ct: { + label: 'City', + description: 'City of the user', + type: 'string' + }, + st: { + label: 'State', + description: + 'State of the user. Facebook expects the 2-letter abbreviation for US states. For example, "CA" for California, or "NY" for New York.', + type: 'string' + }, + zp: { + label: 'ZIP/Postal Code', + 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: { + label: 'Country', + description: + 'The country of the user. Facebook expects the 2-letter ISO 3166-1 alpha-2 country code. For example, "US" for the United States, or "GB" for the United Kingdom.', + type: 'string' + }, + fbp: { + label: 'FBP', + description: + 'Use this field to pass the Facebook browser cookie value (_fbp) associated with the user. If the "Format User Data with Parameter Builder" setting is enabled, Segment will automatically capture this value from the _fbp cookie.', + type: 'string' + }, + fbc: { + label: 'FBC', + description: + 'Use this field to pass The Facebook browser cookie value (_fbc) associated with the user. If the "Format User Data with Parameter Builder" setting is enabled, Segment will automatically capture this value from the _fbc cookie.', + type: 'string' } + }, + default: { + external_id: { '@path': '$.userId' }, + em: { '@path': '$.context.traits.email' }, + ph: { '@path': '$.context.traits.phone' }, + fn: { '@path': '$.context.traits.first_name' }, + ln: { '@path': '$.context.traits.last_name' }, + ge: { '@path': '$.context.traits.gender' }, + db: { '@path': '$.context.traits.birthday' }, + ct: { '@path': '$.context.traits.address.city' }, + st: { '@path': '$.context.traits.address.state' }, + zp: { '@path': '$.context.traits.address.postal_code' }, + country: { '@path': '$.context.traits.address.country' }, + fbp: { '@path': '$.context.traits.fbp' }, + fbc: { '@path': '$.context.traits.fbc' }, + client_ip_address: { '@path': '$.context.traits.client_ip_address' } + } } export const AllFields = { - event_config, - content_category, - content_ids, - content_name, - content_type, - contents, - currency, - delivery_category, - num_items, - predicted_ltv, - net_revenue, - search_string, - status, - value, - custom_data, - eventID, - eventSourceUrl, - userData -} \ No newline at end of file + event_config, + content_category, + content_ids, + content_name, + content_type, + contents, + currency, + delivery_category, + num_items, + predicted_ltv, + net_revenue, + search_string, + status, + value, + custom_data, + eventID, + eventSourceUrl, + userData +} From 39a05e7be513fa2eb3d8fc1c68f9b48172332d45 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 4 Aug 2026 16:52:46 +0100 Subject: [PATCH 02/14] Coerce single content_ids/contents values to arrays in FB Pixel Web MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit content_ids and contents are multiple:true fields, but the browser device-mode runtime does not arrify single values the way the server runtime does. A single-product event (properties.product_id) maps these to a scalar/object, which formatFBEvent's Array.isArray gate silently dropped — so ViewContent/AddToCart could send no product identifiers. formatFBEvent now coerces both fields with a toArray helper before the empty-check. Added send-path tests covering a single content_ids string and a single contents object. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/send/__tests__/functions.test.ts | 182 ++++--- .../src/send/functions.ts | 474 +++++++++--------- 2 files changed, 337 insertions(+), 319 deletions(-) diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/functions.test.ts b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/functions.test.ts index 5ddc0029f1..0de1eb604b 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/functions.test.ts +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/functions.test.ts @@ -89,6 +89,63 @@ describe('Facebook Conversions API Web - Send Functions', () => { ) }) + it('should wrap a single content_ids string into an array', async () => { + // The browser device-mode runtime does not arrify multiple:true fields, + // so a single-product event maps content_ids to a scalar string. It + // should still be sent to fbq as an array, not dropped. + const payload = { + event_config: { + event_name: 'ViewContent', + show_fields: false + }, + content_ids: 'product-123', + value: 19.99 + } + + await send(mockFbq, mockClientParamBuilder, payload, defaultSettings, mockAnalytics) + + expect(mockFbq).toHaveBeenCalledWith( + 'trackSingle', + 'test-pixel-123', + 'ViewContent', + { + partner_agent: 'segment', + content_ids: ['product-123'], + value: 19.99 + }, + undefined + ) + }) + + it('should wrap a single contents object into an array', async () => { + // Likewise a single-product event maps contents to a scalar object; it + // should be sent to fbq wrapped in an array. + const payload = { + event_config: { + event_name: 'AddToCart', + show_fields: false + }, + contents: { id: 'product-123', quantity: 2, item_price: 49.99 }, + value: 99.98, + currency: 'USD' + } + + await send(mockFbq, mockClientParamBuilder, payload, defaultSettings, mockAnalytics) + + expect(mockFbq).toHaveBeenCalledWith( + 'trackSingle', + 'test-pixel-123', + 'AddToCart', + { + partner_agent: 'segment', + contents: [{ id: 'product-123', quantity: 2, item_price: 49.99 }], + value: 99.98, + currency: 'USD' + }, + undefined + ) + }) + it('should send ViewContent event', async () => { const payload = { event_config: { @@ -128,7 +185,13 @@ describe('Facebook Conversions API Web - Send Functions', () => { await send(mockFbq, mockClientParamBuilder, payload, defaultSettings, mockAnalytics) - expect(mockFbq).toHaveBeenCalledWith('trackSingle', 'test-pixel-123', 'PageView', { partner_agent: 'segment' }, undefined) + expect(mockFbq).toHaveBeenCalledWith( + 'trackSingle', + 'test-pixel-123', + 'PageView', + { partner_agent: 'segment' }, + undefined + ) }) }) @@ -265,15 +328,9 @@ describe('Facebook Conversions API Web - Send Functions', () => { await send(mockFbq, mockClientParamBuilder, payload, defaultSettings, mockAnalytics) - expect(mockFbq).toHaveBeenCalledWith( - 'trackSingle', - 'test-pixel-123', - 'Purchase', - expect.any(Object), - { - eventID: 'unique-event-id-123' - } - ) + expect(mockFbq).toHaveBeenCalledWith('trackSingle', 'test-pixel-123', 'Purchase', expect.any(Object), { + eventID: 'unique-event-id-123' + }) }) it('should include eventSourceUrl when provided', async () => { @@ -289,15 +346,9 @@ describe('Facebook Conversions API Web - Send Functions', () => { await send(mockFbq, mockClientParamBuilder, payload, defaultSettings, mockAnalytics) - expect(mockFbq).toHaveBeenCalledWith( - 'trackSingle', - 'test-pixel-123', - 'Purchase', - expect.any(Object), - { - eventSourceUrl: 'https://example.com/checkout' - } - ) + expect(mockFbq).toHaveBeenCalledWith('trackSingle', 'test-pixel-123', 'Purchase', expect.any(Object), { + eventSourceUrl: 'https://example.com/checkout' + }) }) it('should include both eventID and eventSourceUrl when provided', async () => { @@ -314,16 +365,10 @@ describe('Facebook Conversions API Web - Send Functions', () => { await send(mockFbq, mockClientParamBuilder, payload, defaultSettings, mockAnalytics) - expect(mockFbq).toHaveBeenCalledWith( - 'trackSingle', - 'test-pixel-123', - 'Purchase', - expect.any(Object), - { - eventID: 'unique-event-id-123', - eventSourceUrl: 'https://example.com/checkout' - } - ) + expect(mockFbq).toHaveBeenCalledWith('trackSingle', 'test-pixel-123', 'Purchase', expect.any(Object), { + eventID: 'unique-event-id-123', + eventSourceUrl: 'https://example.com/checkout' + }) }) }) @@ -465,7 +510,7 @@ describe('Facebook Conversions API Web - Send Functions', () => { await send(mockFbq, mockClientParamBuilder, payload, defaultSettings, mockAnalytics) - const eventData = (mockFbq).mock.calls[0][3] + const eventData = mockFbq.mock.calls[0][3] expect(eventData).toEqual({ partner_agent: 'segment' }) }) @@ -592,30 +637,29 @@ describe('Facebook Conversions API Web - Send Functions', () => { expect(mockClientParamBuilderInstance.getNormalizedAndHashedPII).toHaveBeenCalledWith('John', 'first_name') expect(mockClientParamBuilderInstance.getNormalizedAndHashedPII).toHaveBeenCalledWith('Doe', 'last_name') expect(mockClientParamBuilderInstance.getNormalizedAndHashedPII).toHaveBeenCalledWith('m', 'gender') - expect(mockClientParamBuilderInstance.getNormalizedAndHashedPII).toHaveBeenCalledWith('1990-05-15', 'date_of_birth') + expect(mockClientParamBuilderInstance.getNormalizedAndHashedPII).toHaveBeenCalledWith( + '1990-05-15', + 'date_of_birth' + ) expect(mockClientParamBuilderInstance.getNormalizedAndHashedPII).toHaveBeenCalledWith('San Francisco', 'city') expect(mockClientParamBuilderInstance.getNormalizedAndHashedPII).toHaveBeenCalledWith('CA', 'state') expect(mockClientParamBuilderInstance.getNormalizedAndHashedPII).toHaveBeenCalledWith('94102', 'zip_code') expect(mockClientParamBuilderInstance.getNormalizedAndHashedPII).toHaveBeenCalledWith('US', 'country') expect(mockClientParamBuilderInstance.getNormalizedAndHashedPII).toHaveBeenCalledWith('user-123', 'external_id') - expect(mockFbq).toHaveBeenCalledWith( - 'init', - 'test-pixel-123', - { - em: 'hashed_email_value', - ph: 'hashed_phone_value', - fn: 'hashed_first_name_value', - ln: 'hashed_last_name_value', - ge: 'hashed_gender_value', - db: 'hashed_date_of_birth_value', - ct: 'hashed_city_value', - st: 'hashed_state_value', - zp: 'hashed_zip_code_value', - country: 'hashed_country_value', - external_id: 'hashed_external_id_value' - } - ) + expect(mockFbq).toHaveBeenCalledWith('init', 'test-pixel-123', { + em: 'hashed_email_value', + ph: 'hashed_phone_value', + fn: 'hashed_first_name_value', + ln: 'hashed_last_name_value', + ge: 'hashed_gender_value', + db: 'hashed_date_of_birth_value', + ct: 'hashed_city_value', + st: 'hashed_state_value', + zp: 'hashed_zip_code_value', + country: 'hashed_country_value', + external_id: 'hashed_external_id_value' + }) }) it('should use clientParamBuilder getFbc and getFbp methods', async () => { @@ -645,15 +689,11 @@ describe('Facebook Conversions API Web - Send Functions', () => { expect(mockClientParamBuilderInstance.getFbp).toHaveBeenCalled() // ClientParamBuilder values should override payload values - expect(mockFbq).toHaveBeenCalledWith( - 'init', - 'test-pixel-123', - { - em: 'hashed_email', - fbc: 'fb.1.1234567890.ClientParamBuilderFbc', - fbp: 'fb.1.1234567890.ClientParamBuilderFbp' - } - ) + expect(mockFbq).toHaveBeenCalledWith('init', 'test-pixel-123', { + em: 'hashed_email', + fbc: 'fb.1.1234567890.ClientParamBuilderFbc', + fbp: 'fb.1.1234567890.ClientParamBuilderFbp' + }) }) it('should use payload fbc/fbp when clientParamBuilder methods return null', async () => { @@ -682,15 +722,11 @@ describe('Facebook Conversions API Web - Send Functions', () => { expect(mockClientParamBuilderInstance.getFbp).toHaveBeenCalled() // Should fall back to payload values when clientParamBuilder returns null - expect(mockFbq).toHaveBeenCalledWith( - 'init', - 'test-pixel-123', - { - em: 'hashed_email', - fbc: 'fb.1.1234567890.PayloadFbc', - fbp: 'fb.1.1234567890.PayloadFbp' - } - ) + expect(mockFbq).toHaveBeenCalledWith('init', 'test-pixel-123', { + em: 'hashed_email', + fbc: 'fb.1.1234567890.PayloadFbc', + fbp: 'fb.1.1234567890.PayloadFbp' + }) }) it('should fall back to default formatting when clientParamBuilder returns undefined', async () => { @@ -716,7 +752,7 @@ describe('Facebook Conversions API Web - Send Functions', () => { expect(mockClientParamBuilderInstance.getNormalizedAndHashedPII).toHaveBeenCalledWith('(555) 123-4567', 'phone') // When clientParamBuilder returns undefined, nothing should be sent (empty userData) - const initCalls = (mockFbq).mock.calls.filter((call) => call[0] === 'init') + const initCalls = mockFbq.mock.calls.filter((call) => call[0] === 'init') expect(initCalls.length).toBe(0) }) @@ -769,14 +805,10 @@ describe('Facebook Conversions API Web - Send Functions', () => { await send(mockFbq, undefined, payload, defaultSettings, mockAnalytics) // Should use default formatting (normalize and hash) - expect(mockFbq).toHaveBeenCalledWith( - 'init', - 'test-pixel-123', - { - em: '973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b', - ph: '3c95277da5fd0da6a1a44ee3fdf56d20af6c6d242695a40e18e6e90dc3c5872c' - } - ) + expect(mockFbq).toHaveBeenCalledWith('init', 'test-pixel-123', { + em: '973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b', + ph: '3c95277da5fd0da6a1a44ee3fdf56d20af6c6d242695a40e18e6e90dc3c5872c' + }) }) }) }) diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/functions.ts b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/functions.ts index 93b3458a6c..a4f8beca41 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/functions.ts +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/functions.ts @@ -1,292 +1,278 @@ -import { FBEvent, UserData, EventOptions, FBClient, FBStandardEventType, FBNonStandardEventType, FBClientParamBuilder, PIIType, PIIParamName } from '../types' +import { + FBEvent, + UserData, + EventOptions, + FBClient, + FBStandardEventType, + FBNonStandardEventType, + FBClientParamBuilder, + PIIType, + PIIParamName +} from '../types' import { Payload } from './generated-types' import { Settings } from '../generated-types' import { UniversalStorage, Analytics } from '@segment/analytics-next' -import {US_STATE_CODES, COUNTRY_CODES, MAX_INIT_COUNT, INIT_COUNT_KEY, USER_DATA_KEY } from '../constants' +import { US_STATE_CODES, COUNTRY_CODES, MAX_INIT_COUNT, INIT_COUNT_KEY, USER_DATA_KEY } from '../constants' import { storageFallback, setStorageInitCount } from '../functions' import { getNotVisibleForEvent } from './depends-on' -export async function send(client: FBClient, clientParamBuilder: FBClientParamBuilder | undefined, payload: Payload, settings: Settings, analytics: Analytics) { - const { pixelId } = settings - const { - event_config: { - custom_event_name, - event_name - } = {} - } = payload +export async function send( + client: FBClient, + clientParamBuilder: FBClientParamBuilder | undefined, + payload: Payload, + settings: Settings, + analytics: Analytics +) { + const { pixelId } = settings + const { event_config: { custom_event_name, event_name } = {} } = payload - const isCustom = event_name === 'CustomEvent' ? true : false + const isCustom = event_name === 'CustomEvent' ? true : false - const errorMessage = validate(payload) + const errorMessage = validate(payload) - if(errorMessage) { - console.warn(`${errorMessage}`) - return - } - - const fbEvent = formatFBEvent(payload) - - await maybeSendUserData(client, clientParamBuilder, payload, settings, analytics) - - const options = formatOptions(payload) - - if(isCustom){ - client( - 'trackSingleCustom', - pixelId, - custom_event_name as string, - { ...fbEvent }, - options - ) - } - else { - client( - 'trackSingle', - pixelId, - event_name as FBStandardEventType, - { ...fbEvent }, - options - ) - } + if (errorMessage) { + console.warn(`${errorMessage}`) + return + } + + const fbEvent = formatFBEvent(payload) + + await maybeSendUserData(client, clientParamBuilder, payload, settings, analytics) + + const options = formatOptions(payload) + + if (isCustom) { + client('trackSingleCustom', pixelId, custom_event_name as string, { ...fbEvent }, options) + } else { + client('trackSingle', pixelId, event_name as FBStandardEventType, { ...fbEvent }, options) + } } function validate(payload: Payload): string | undefined { - const { - event_config: { event_name }, - content_ids, - contents - } = payload - - if(['AddToCart', 'Purchase', 'ViewContent'].includes(event_name)){ - if ((!content_ids || (Array.isArray(content_ids) && content_ids.length === 0)) && (!contents || (Array.isArray(contents) && contents.length === 0))) { - return `At least one of content_ids or contents is required for the ${event_name} event.` - } + const { + event_config: { event_name }, + content_ids, + contents + } = payload + + if (['AddToCart', 'Purchase', 'ViewContent'].includes(event_name)) { + if ( + (!content_ids || (Array.isArray(content_ids) && content_ids.length === 0)) && + (!contents || (Array.isArray(contents) && contents.length === 0)) + ) { + return `At least one of content_ids or contents is required for the ${event_name} event.` } + } - return undefined + return undefined +} + +function toArray(value: T | T[] | undefined | null): T[] { + if (value === undefined || value === null) return [] + return Array.isArray(value) ? value : [value] } export function formatFBEvent(payload: Payload): FBEvent { - const { - content_category, - content_ids, - content_name, - content_type, - contents, - currency, - delivery_category, - num_items, - value, - predicted_ltv, - net_revenue, - custom_data, - event_config: { - event_name, - show_fields - } = {} - } = payload - - const fbEvent: FBEvent = { - partner_agent: 'segment', - ...(content_category ? { content_category } : {}), - ...(content_ids && Array.isArray(content_ids) && content_ids.length > 0 ? { content_ids } : {}), - ...(content_name ? { content_name } : {}), - ...(content_type ? { content_type } : {}), - ...(contents && Array.isArray(contents) && contents.length > 0 ? { contents } : {}), - ...(currency ? { currency } : {}), - ...(delivery_category ? { delivery_category } : {}), - ...(typeof num_items === 'number' ? { num_items } : {}), - ...(typeof value === 'number' ? { value } : {}), - ...(typeof predicted_ltv === 'number' ? { predicted_ltv } : {}), - ...(typeof net_revenue === 'number' ? { net_revenue } : {}), - ...(custom_data && Object.entries(custom_data).length > 0 ? { custom_data } : {}) - } + const { + content_category, + content_ids, + content_name, + content_type, + contents, + currency, + delivery_category, + num_items, + value, + predicted_ltv, + net_revenue, + custom_data, + event_config: { event_name, show_fields } = {} + } = 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 fbEvent) { - delete fbEvent[field as keyof typeof fbEvent] - } - }) - } + const contentIdsArr = toArray(content_ids) + const contentsArr = toArray(contents) + + const fbEvent: FBEvent = { + partner_agent: 'segment', + ...(content_category ? { content_category } : {}), + ...(contentIdsArr.length > 0 ? { content_ids: contentIdsArr } : {}), + ...(content_name ? { content_name } : {}), + ...(content_type ? { content_type } : {}), + ...(contentsArr.length > 0 ? { contents: contentsArr } : {}), + ...(currency ? { currency } : {}), + ...(delivery_category ? { delivery_category } : {}), + ...(typeof num_items === 'number' ? { num_items } : {}), + ...(typeof value === 'number' ? { value } : {}), + ...(typeof predicted_ltv === 'number' ? { predicted_ltv } : {}), + ...(typeof net_revenue === 'number' ? { net_revenue } : {}), + ...(custom_data && Object.entries(custom_data).length > 0 ? { custom_data } : {}) + } + + 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 fbEvent) { + delete fbEvent[field as keyof typeof fbEvent] + } + }) + } - return fbEvent + return fbEvent } function formatOptions(payload: Payload): EventOptions | undefined { - const { eventID, eventSourceUrl } = payload - const options: EventOptions = { - ...(eventID ? { eventID } : {}), - ...(eventSourceUrl ? { eventSourceUrl } : {}), - } - return Object.values(options).some(Boolean) ? options : undefined + const { eventID, eventSourceUrl } = payload + const options: EventOptions = { + ...(eventID ? { eventID } : {}), + ...(eventSourceUrl ? { eventSourceUrl } : {}) + } + return Object.values(options).some(Boolean) ? options : undefined } -async function maybeSendUserData(client: FBClient, clientParamBuilder: FBClientParamBuilder | undefined, payload: Payload, settings: Settings, analytics: Analytics) { - const { - pixelId - } = settings - const { - userData - } = payload - const userDataFormatted = await formatUserData(userData, clientParamBuilder) - - if(userDataFormatted) { - /* +async function maybeSendUserData( + client: FBClient, + clientParamBuilder: FBClientParamBuilder | undefined, + payload: Payload, + settings: Settings, + analytics: Analytics +) { + const { pixelId } = settings + const { userData } = payload + const userDataFormatted = await formatUserData(userData, clientParamBuilder) + + if (userDataFormatted) { + /* Facebook indicated that init should only trigger on a single page load up to max 2 times. When userData is created it gets added to storage and included in the next init call on page load. Facebook also advised to always send userData when it's available, even if it was collected via previous events. */ - const storage = (analytics.storage as UniversalStorage>) ?? storageFallback - const initCountFromStorage: string | null = storage.get(INIT_COUNT_KEY) - const initCount: number | undefined = (initCountFromStorage && !isNaN(Number(initCountFromStorage))) - ? parseInt(initCountFromStorage, 10) - : undefined - - if(typeof initCount === 'number' && initCount < MAX_INIT_COUNT) { - client('init', pixelId, userDataFormatted) - setStorageInitCount(analytics, initCount + 1) - } - - storage.set(USER_DATA_KEY, JSON.stringify(userDataFormatted)) - } -} + const storage = (analytics.storage as UniversalStorage>) ?? storageFallback + const initCountFromStorage: string | null = storage.get(INIT_COUNT_KEY) + const initCount: number | undefined = + initCountFromStorage && !isNaN(Number(initCountFromStorage)) ? parseInt(initCountFromStorage, 10) : undefined -export async function formatUserData(userData: Payload['userData'], clientParamBuilder: FBClientParamBuilder | undefined): Promise { - if(!userData){ - return undefined + if (typeof initCount === 'number' && initCount < MAX_INIT_COUNT) { + client('init', pixelId, userDataFormatted) + setStorageInitCount(analytics, initCount + 1) } - const { - external_id, - em, - ph, - fn, - ln, - ge, - db, - ct, - st, - zp, - country, - fbp, - fbc - } = userData - - let fbcValue = fbc ? fbc.trim() : undefined - let fbpValue = fbp ? fbp.trim() : undefined - - if(clientParamBuilder){ - clientParamBuilder.processAndCollectAllParams() - fbcValue = clientParamBuilder.getFbc() || fbcValue - fbpValue = clientParamBuilder.getFbp() || fbpValue - } + storage.set(USER_DATA_KEY, JSON.stringify(userDataFormatted)) + } +} + +export async function formatUserData( + userData: Payload['userData'], + clientParamBuilder: FBClientParamBuilder | undefined +): Promise { + if (!userData) { + return undefined + } + + const { external_id, em, ph, fn, ln, ge, db, ct, st, zp, country, fbp, fbc } = userData + + let fbcValue = fbc ? fbc.trim() : undefined + let fbpValue = fbp ? fbp.trim() : undefined - const [ - emData, - phData, - fnData, - lnData, - geData, - dbData, - ctData, - stData, - zpData, - countryData, - externalIdData - ] = await Promise.all([ - formatPII(em, 'email', 'em', clientParamBuilder, (s) => s.toLowerCase().trim()), - formatPII(ph, 'phone', 'ph', clientParamBuilder, (s) => s.replace(/\D/g, '')), - formatPII(fn, 'first_name', 'fn', clientParamBuilder, (s) => s.toLowerCase().trim()), - formatPII(ln, 'last_name', 'ln', clientParamBuilder, (s) => s.toLowerCase().trim()), - formatPII(ge, 'gender', 'ge', clientParamBuilder, (s) => (['m', 'f'].includes(s) ? s : undefined)), - formatPII(db, 'date_of_birth', 'db', clientParamBuilder, (s) => formatDate(s)), - formatPII(ct, 'city', 'ct', clientParamBuilder, (s) => s.toLowerCase().replace(/\s+/g, '')), - formatPII(st, 'state', 'st', clientParamBuilder, (s) => fromMap(US_STATE_CODES, s)), - formatPII(zp, 'zip_code', 'zp', clientParamBuilder, (s) => s.trim()), - formatPII(country, 'country', 'country', clientParamBuilder, (s) => fromMap(COUNTRY_CODES, s)), - formatPII(external_id, 'external_id', 'external_id', clientParamBuilder, (s) => s.trim()) + if (clientParamBuilder) { + clientParamBuilder.processAndCollectAllParams() + fbcValue = clientParamBuilder.getFbc() || fbcValue + fbpValue = clientParamBuilder.getFbp() || fbpValue + } + + const [emData, phData, fnData, lnData, geData, dbData, ctData, stData, zpData, countryData, externalIdData] = + await Promise.all([ + formatPII(em, 'email', 'em', clientParamBuilder, (s) => s.toLowerCase().trim()), + formatPII(ph, 'phone', 'ph', clientParamBuilder, (s) => s.replace(/\D/g, '')), + formatPII(fn, 'first_name', 'fn', clientParamBuilder, (s) => s.toLowerCase().trim()), + formatPII(ln, 'last_name', 'ln', clientParamBuilder, (s) => s.toLowerCase().trim()), + formatPII(ge, 'gender', 'ge', clientParamBuilder, (s) => (['m', 'f'].includes(s) ? s : undefined)), + formatPII(db, 'date_of_birth', 'db', clientParamBuilder, (s) => formatDate(s)), + formatPII(ct, 'city', 'ct', clientParamBuilder, (s) => s.toLowerCase().replace(/\s+/g, '')), + formatPII(st, 'state', 'st', clientParamBuilder, (s) => fromMap(US_STATE_CODES, s)), + formatPII(zp, 'zip_code', 'zp', clientParamBuilder, (s) => s.trim()), + formatPII(country, 'country', 'country', clientParamBuilder, (s) => fromMap(COUNTRY_CODES, s)), + formatPII(external_id, 'external_id', 'external_id', clientParamBuilder, (s) => s.trim()) ]) - const ud: UserData = { - ...emData, - ...phData, - ...fnData, - ...lnData, - ...(geData as { ge?: 'm' | 'f' }), - ...dbData, - ...ctData, - ...stData, - ...zpData, - ...countryData, - ...externalIdData, - ...(fbcValue ? { fbc: fbcValue } : {}), - ...(fbpValue ? { fbp: fbpValue } : {}) - } + const ud: UserData = { + ...emData, + ...phData, + ...fnData, + ...lnData, + ...(geData as { ge?: 'm' | 'f' }), + ...dbData, + ...ctData, + ...stData, + ...zpData, + ...countryData, + ...externalIdData, + ...(fbcValue ? { fbc: fbcValue } : {}), + ...(fbpValue ? { fbp: fbpValue } : {}) + } - if(Object.keys(ud).length === 0){ - return undefined - } - return ud + if (Object.keys(ud).length === 0) { + return undefined + } + return ud } async function formatPII( - value: string | undefined, - piiType: PIIType, - piiParamType: K, - clientParamBuilder: FBClientParamBuilder | undefined, - formatter: (s: string) => string | undefined -): Promise>> { - if(!value) { - return {} + value: string | undefined, + piiType: PIIType, + piiParamType: K, + clientParamBuilder: FBClientParamBuilder | undefined, + formatter: (s: string) => string | undefined +): Promise>> { + if (!value) { + return {} + } + if (clientParamBuilder) { + const val = clientParamBuilder.getNormalizedAndHashedPII(value, piiType) + return val ? ({ [piiParamType]: val as V } as Partial>) : {} + } else { + const val = formatter(value) + if (!val) { + return {} + } else { + const hashValue = await sha256Hash(val) + return { [piiParamType]: hashValue as V } as Partial> } - if(clientParamBuilder){ - const val = clientParamBuilder.getNormalizedAndHashedPII(value, piiType) - return val ? ({ [piiParamType]: val as V } as Partial>) : {} - } - else { - const val = formatter(value) - if(!val) { - return {} - } - else { - const hashValue = await sha256Hash(val) - return ({ [piiParamType]: hashValue as V }) as Partial> - } - } + } } function formatDate(isoDate?: string): string | undefined { - if (!isoDate || typeof isoDate !== 'string') { - return undefined - } - const date = new Date(isoDate) - if (isNaN(date.getTime())) { - return undefined - } - const year = date.getUTCFullYear() - const month = (date.getUTCMonth() + 1).toString().padStart(2, '0') - const day = date.getUTCDate().toString().padStart(2, '0') - return `${year}${month}${day}` + if (!isoDate || typeof isoDate !== 'string') { + return undefined + } + const date = new Date(isoDate) + if (isNaN(date.getTime())) { + return undefined + } + const year = date.getUTCFullYear() + const month = (date.getUTCMonth() + 1).toString().padStart(2, '0') + const day = date.getUTCDate().toString().padStart(2, '0') + return `${year}${month}${day}` } function fromMap(map: Map, value?: string): string | undefined { - const cleaned = value?.toLowerCase().replace(/[^a-z]/g, '').trim() - if (!cleaned) { - return undefined - } - if(cleaned.length === 2 && Array.from(map.values()).includes(cleaned)) { - return cleaned - } - return map.get(cleaned) || undefined + const cleaned = value + ?.toLowerCase() + .replace(/[^a-z]/g, '') + .trim() + if (!cleaned) { + return undefined + } + if (cleaned.length === 2 && Array.from(map.values()).includes(cleaned)) { + return cleaned + } + return map.get(cleaned) || undefined } export async function sha256Hash(value: string): Promise { - const encoder = new TextEncoder() - const data = encoder.encode(value) - const hashBuffer = await window.crypto.subtle.digest('SHA-256', data) - const hashArray = Array.from(new Uint8Array(hashBuffer)) - return hashArray.map(b => b.toString(16).padStart(2, '0')).join('') -} \ No newline at end of file + const encoder = new TextEncoder() + const data = encoder.encode(value) + const hashBuffer = await window.crypto.subtle.digest('SHA-256', data) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') +} From e0560abaca287bd6cbff9cab0dcad7ffe12ef2f4 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 4 Aug 2026 17:29:55 +0100 Subject: [PATCH 03/14] Emit search_string in FB Pixel Web Search events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The search_string field was defined and mapped in the Search preset but formatFBEvent never read it and it was absent from the FBEvent type, so it was silently dropped — Search events reached Facebook with no query string. Added search_string to the FBEvent type and emit it in formatFBEvent (top-level event property, per Meta Pixel reference). Added tests covering formatFBEvent and the send path. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/send/__tests__/formatFBEvent.test.ts | 45 ++++++++++---- .../src/send/__tests__/functions.test.ts | 31 ++++++++++ .../src/send/functions.ts | 2 + .../facebook-conversions-api-web/src/types.ts | 58 +++++++++++-------- 4 files changed, 103 insertions(+), 33 deletions(-) diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/formatFBEvent.test.ts b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/formatFBEvent.test.ts index 7bc5adcec4..f788d1327b 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/formatFBEvent.test.ts +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/formatFBEvent.test.ts @@ -160,11 +160,11 @@ describe('formatFBEvent', () => { show_fields: true }, contents: [ - { id: 'product-1', quantity: 2, item_price: 25.50 }, - { id: 'product-2', quantity: 1, item_price: 100.00 }, + { id: 'product-1', quantity: 2, item_price: 25.5 }, + { id: 'product-2', quantity: 1, item_price: 100.0 }, { id: 'product-3', quantity: 3 } ], - value: 151.00 + value: 151.0 } const result = formatFBEvent(payload as Payload) @@ -172,11 +172,11 @@ describe('formatFBEvent', () => { expect(result).toEqual({ partner_agent: 'segment', contents: [ - { id: 'product-1', quantity: 2, item_price: 25.50 }, - { id: 'product-2', quantity: 1, item_price: 100.00 }, + { id: 'product-1', quantity: 2, item_price: 25.5 }, + { id: 'product-2', quantity: 1, item_price: 100.0 }, { id: 'product-3', quantity: 3 } ], - value: 151.00 + value: 151.0 }) }) @@ -209,6 +209,31 @@ describe('formatFBEvent', () => { }) }) + it('should include search_string for a Search event', () => { + const payload: Partial = { + event_config: { + event_name: 'Search', + show_fields: true + }, + search_string: 'monopoly board game', + content_category: 'Games', + content_ids: ['product-123'], + currency: 'USD', + value: 19.99 + } + + const result = formatFBEvent(payload as Payload) + + expect(result).toEqual({ + partner_agent: 'segment', + search_string: 'monopoly board game', + content_category: 'Games', + content_ids: ['product-123'], + currency: 'USD', + value: 19.99 + }) + }) + it('should format Subscribe event with predicted_ltv', () => { const payload: Partial = { event_config: { @@ -237,9 +262,9 @@ describe('formatFBEvent', () => { show_fields: true }, content_ids: ['product-123'], - value: 100.00, + value: 100.0, currency: 'USD', - net_revenue: 85.00 + net_revenue: 85.0 } const result = formatFBEvent(payload as Payload) @@ -247,9 +272,9 @@ describe('formatFBEvent', () => { expect(result).toEqual({ partner_agent: 'segment', content_ids: ['product-123'], - value: 100.00, + value: 100.0, currency: 'USD', - net_revenue: 85.00 + net_revenue: 85.0 }) }) diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/functions.test.ts b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/functions.test.ts index 0de1eb604b..d2bba1a15b 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/functions.test.ts +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/functions.test.ts @@ -175,6 +175,37 @@ describe('Facebook Conversions API Web - Send Functions', () => { ) }) + it('should send Search event with search_string', async () => { + const payload = { + event_config: { + event_name: 'Search', + show_fields: false + }, + search_string: 'monopoly board game', + content_category: 'Games', + content_ids: ['product-123'], + currency: 'USD', + value: 19.99 + } + + await send(mockFbq, mockClientParamBuilder, payload, defaultSettings, mockAnalytics) + + expect(mockFbq).toHaveBeenCalledWith( + 'trackSingle', + 'test-pixel-123', + 'Search', + { + partner_agent: 'segment', + search_string: 'monopoly board game', + content_category: 'Games', + content_ids: ['product-123'], + currency: 'USD', + value: 19.99 + }, + undefined + ) + }) + it('should send PageView event', async () => { const payload = { event_config: { diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/functions.ts b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/functions.ts index a4f8beca41..8933d44989 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/functions.ts +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/functions.ts @@ -82,6 +82,7 @@ export function formatFBEvent(payload: Payload): FBEvent { currency, delivery_category, num_items, + search_string, value, predicted_ltv, net_revenue, @@ -102,6 +103,7 @@ export function formatFBEvent(payload: Payload): FBEvent { ...(currency ? { currency } : {}), ...(delivery_category ? { delivery_category } : {}), ...(typeof num_items === 'number' ? { num_items } : {}), + ...(search_string ? { search_string } : {}), ...(typeof value === 'number' ? { value } : {}), ...(typeof predicted_ltv === 'number' ? { predicted_ltv } : {}), ...(typeof net_revenue === 'number' ? { net_revenue } : {}), 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 43fdc42cb0..7f1c7c5dfd 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 @@ -1,6 +1,6 @@ export interface WindowWithOptionalFbq extends Omit { - fbq?: FBClient; - _fbq?: FBClient; + fbq?: FBClient + _fbq?: FBClient } export type FBStandardEventType = @@ -29,8 +29,8 @@ export type InitOptions = { agent?: string } -export type EventOptions = { - eventID?: string +export type EventOptions = { + eventID?: string eventSourceUrl?: string } @@ -66,7 +66,8 @@ export type FBEvent = { currency?: string delivery_category?: string num_items?: number - value?: number + search_string?: string + value?: number custom_data?: { [k: string]: unknown } @@ -81,36 +82,47 @@ export type FBClient = { callMethod?: (...args: unknown[]) => void (command: 'set', key: string, value: boolean, pixelId: string): void (command: 'dataProcessingOptions', options: string[], country?: number, state?: number): void - (command: 'init', pixelId: string, userData?: UserData, options?: InitOptions ): void + (command: 'init', pixelId: string, userData?: UserData, options?: InitOptions): void (command: 'trackSingle', pixelId: string, event: FBStandardEventType, params?: FBEvent, options?: EventOptions): void (command: 'trackSingleCustom', pixelId: string, event: string, params?: FBEvent, options?: EventOptions): void } export type FBClientParamBuilder = { getNormalizedAndHashedPII: (value: string, piiType: PIIType) => string | undefined - processAndCollectAllParams: () => void + processAndCollectAllParams: () => void getFbc: () => string | undefined getFbp: () => string | undefined } -export type PIIType = 'email' | 'phone' | 'first_name' | 'last_name' | 'gender' | 'date_of_birth' | 'city' | 'state' | 'zip_code' | 'country' | 'external_id' +export type PIIType = + | 'email' + | 'phone' + | 'first_name' + | 'last_name' + | 'gender' + | 'date_of_birth' + | 'city' + | 'state' + | 'zip_code' + | 'country' + | 'external_id' export type PIIParamName = 'em' | 'ph' | 'fn' | 'ln' | 'ge' | 'db' | 'ct' | 'st' | 'zp' | 'country' | 'external_id' export const LDU = { - Disabled: {key: 'Disabled', state: undefined, country: undefined}, - GeolocationLogic: {key: 'GeolocationLogic', state: 0, country: 0}, - California: {key: 'California', state: 1000, country: 1}, - Colorado: {key: 'Colorado', state: 1001, country: 1}, - Connecticut: {key: 'Connecticut', state: 1002, country: 1}, - Florida: {key: 'Florida', state: 1003, country: 1}, - Oregon: {key: 'Oregon', state: 1004, country: 1}, - Texas: {key: 'Texas', state: 1005, country: 1}, - Montana: {key: 'Montana', state: 1006, country: 1}, - Delaware: {key: 'Delaware', state: 1007, country: 1}, - Nebraska: {key: 'Nebraska', state: 1008, country: 1}, - NewHampshire: {key: 'NewHampshire', state: 1009, country: 1}, - NewJersey: {key: 'NewJersey', state: 1010, country: 1}, - Minnesota: {key: 'Minnesota', state: 1011, country: 1} + Disabled: { key: 'Disabled', state: undefined, country: undefined }, + GeolocationLogic: { key: 'GeolocationLogic', state: 0, country: 0 }, + California: { key: 'California', state: 1000, country: 1 }, + Colorado: { key: 'Colorado', state: 1001, country: 1 }, + Connecticut: { key: 'Connecticut', state: 1002, country: 1 }, + Florida: { key: 'Florida', state: 1003, country: 1 }, + Oregon: { key: 'Oregon', state: 1004, country: 1 }, + Texas: { key: 'Texas', state: 1005, country: 1 }, + Montana: { key: 'Montana', state: 1006, country: 1 }, + Delaware: { key: 'Delaware', state: 1007, country: 1 }, + Nebraska: { key: 'Nebraska', state: 1008, country: 1 }, + NewHampshire: { key: 'NewHampshire', state: 1009, country: 1 }, + NewJersey: { key: 'NewJersey', state: 1010, country: 1 }, + Minnesota: { key: 'Minnesota', state: 1011, country: 1 } } as const -export type LDU = typeof LDU[keyof typeof LDU] \ No newline at end of file +export type LDU = typeof LDU[keyof typeof LDU] From 5ddf090914f91b5972ce422263f5a137fbda9370 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 4 Aug 2026 17:38:35 +0100 Subject: [PATCH 04/14] Emit status in FB Pixel Web CompleteRegistration events The status field was defined and mapped but formatFBEvent never read it and it was absent from the FBEvent type, so it was silently dropped. Added status to the FBEvent type and emit it in formatFBEvent using a typeof boolean check so status: false (registration not completed) is preserved. Added tests covering true, the false edge case, and the send path. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/send/__tests__/formatFBEvent.test.ts | 38 +++++++++++++++++++ .../src/send/__tests__/functions.test.ts | 27 +++++++++++++ .../src/send/functions.ts | 2 + .../facebook-conversions-api-web/src/types.ts | 1 + 4 files changed, 68 insertions(+) diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/formatFBEvent.test.ts b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/formatFBEvent.test.ts index f788d1327b..5e04b7b07b 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/formatFBEvent.test.ts +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/formatFBEvent.test.ts @@ -234,6 +234,44 @@ describe('formatFBEvent', () => { }) }) + it('should include status for a CompleteRegistration event', () => { + const payload: Partial = { + event_config: { + event_name: 'CompleteRegistration', + show_fields: true + }, + status: true, + currency: 'USD', + value: 0 + } + + const result = formatFBEvent(payload as Payload) + + expect(result).toEqual({ + partner_agent: 'segment', + status: true, + currency: 'USD', + value: 0 + }) + }) + + it('should include status when false (registration not completed)', () => { + const payload: Partial = { + event_config: { + event_name: 'CompleteRegistration', + show_fields: true + }, + status: false + } + + const result = formatFBEvent(payload as Payload) + + expect(result).toEqual({ + partner_agent: 'segment', + status: false + }) + }) + it('should format Subscribe event with predicted_ltv', () => { const payload: Partial = { event_config: { diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/functions.test.ts b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/functions.test.ts index d2bba1a15b..c550e16d58 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/functions.test.ts +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/functions.test.ts @@ -206,6 +206,33 @@ describe('Facebook Conversions API Web - Send Functions', () => { ) }) + it('should send CompleteRegistration event with status', async () => { + const payload = { + event_config: { + event_name: 'CompleteRegistration', + show_fields: false + }, + status: true, + currency: 'USD', + value: 0 + } + + await send(mockFbq, mockClientParamBuilder, payload, defaultSettings, mockAnalytics) + + expect(mockFbq).toHaveBeenCalledWith( + 'trackSingle', + 'test-pixel-123', + 'CompleteRegistration', + { + partner_agent: 'segment', + status: true, + currency: 'USD', + value: 0 + }, + undefined + ) + }) + it('should send PageView event', async () => { const payload = { event_config: { diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/functions.ts b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/functions.ts index 8933d44989..9b5898a423 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/functions.ts +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/functions.ts @@ -83,6 +83,7 @@ export function formatFBEvent(payload: Payload): FBEvent { delivery_category, num_items, search_string, + status, value, predicted_ltv, net_revenue, @@ -104,6 +105,7 @@ export function formatFBEvent(payload: Payload): FBEvent { ...(delivery_category ? { delivery_category } : {}), ...(typeof num_items === 'number' ? { num_items } : {}), ...(search_string ? { search_string } : {}), + ...(typeof status === 'boolean' ? { status } : {}), ...(typeof value === 'number' ? { value } : {}), ...(typeof predicted_ltv === 'number' ? { predicted_ltv } : {}), ...(typeof net_revenue === 'number' ? { net_revenue } : {}), 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 7f1c7c5dfd..350038b17f 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 @@ -67,6 +67,7 @@ export type FBEvent = { delivery_category?: string num_items?: number search_string?: string + status?: boolean value?: number custom_data?: { [k: string]: unknown From cb74cc51c2567fc3b78dd06f7fb360a550e03c73 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 4 Aug 2026 19:06:45 +0100 Subject: [PATCH 05/14] Add Maryland and Rhode Island LDU options to FB Pixel Web Facebook's Limited Data Use supports state codes 1012 (Maryland, effective 2025-09-09) and 1013 (Rhode Island, effective 2025-11-17), but the destination's LDU options stopped at 1011 (Minnesota). Customers in those states could not enable LDU. Added both to the LDU map and the ldu setting choices. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../metadata.json | 8 +++ .../facebook-conversions-api-web/src/index.ts | 65 ++++++++++--------- .../facebook-conversions-api-web/src/types.ts | 4 +- 3 files changed, 44 insertions(+), 33 deletions(-) diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/metadata.json b/packages/browser-destinations/destinations/facebook-conversions-api-web/metadata.json index f5a34615e2..fb10c1b571 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/metadata.json +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/metadata.json @@ -118,6 +118,14 @@ { "label": "LDU enabled - Minnesota only", "value": "Minnesota" + }, + { + "label": "LDU enabled - Maryland only", + "value": "Maryland" + }, + { + "label": "LDU enabled - Rhode Island only", + "value": "RhodeIsland" } ], "default": "Disabled", 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 29aa6d805b..61de5bb6d9 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 @@ -8,13 +8,16 @@ import { defaultValues } from '@segment/actions-core' declare global { interface Window { - fbq: FBClient, + fbq: FBClient _fbq: FBClient clientParamBuilder: FBClientParamBuilder | undefined } } -export const destination: BrowserDestinationDefinition = { +export const destination: BrowserDestinationDefinition< + Settings, + { fbq: FBClient; clientParamBuilder: FBClientParamBuilder | undefined } +> = { name: 'Facebook Conversions Api Web', slug: 'actions-facebook-conversions-api-web', mode: 'device', @@ -24,22 +27,25 @@ export const destination: BrowserDestinationDefinition typeof window.fbq === 'function', 100) - if(formatUserDataWithParamBuilder){ + if (formatUserDataWithParamBuilder) { const script = `https://capi-automation.s3.us-east-2.amazonaws.com/public/client_js/capiParamBuilder/clientParamBuilder.bundle.js` await deps.loadScript(script) await deps.resolveWhen(() => typeof window.clientParamBuilder === 'object', 100) @@ -99,8 +108,7 @@ export const destination: BrowserDestinationDefinition Date: Wed, 5 Aug 2026 10:45:48 +0100 Subject: [PATCH 06/14] Normalize field values in FB Pixel Web (trim, currency, minimums) - Trim string fields (content_category/name/type, delivery_category, search_string) and each content_ids entry, dropping whitespace-only values so they aren't sent. - Normalize currency: trim + uppercase + validate against CURRENCY_ISO_CODES; drop invalid codes. - Add minimum: 0 to num_items, value, predicted_ltv, net_revenue. - Add a test exercising the normalizers over a full payload. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../metadata.json | 8 ++-- .../src/send/__tests__/formatFBEvent.test.ts | 41 +++++++++++++++++ .../src/send/fields.ts | 4 ++ .../src/send/functions.ts | 46 +++++++++++++++---- 4 files changed, 87 insertions(+), 12 deletions(-) diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/metadata.json b/packages/browser-destinations/destinations/facebook-conversions-api-web/metadata.json index fb10c1b571..65a2d1b03e 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/metadata.json +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/metadata.json @@ -1630,7 +1630,7 @@ }, "readOnly": null, "hidden": null, - "minimum": null, + "minimum": 0, "maximum": null, "defaultObjectUI": null, "disabledInputMethods": null, @@ -1693,7 +1693,7 @@ }, "readOnly": null, "hidden": null, - "minimum": null, + "minimum": 0, "maximum": null, "defaultObjectUI": null, "disabledInputMethods": null, @@ -1728,7 +1728,7 @@ }, "readOnly": null, "hidden": null, - "minimum": null, + "minimum": 0, "maximum": null, "defaultObjectUI": null, "disabledInputMethods": null, @@ -1895,7 +1895,7 @@ }, "readOnly": null, "hidden": null, - "minimum": null, + "minimum": 0, "maximum": null, "defaultObjectUI": null, "disabledInputMethods": null, diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/formatFBEvent.test.ts b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/formatFBEvent.test.ts index 5e04b7b07b..87cae58b0b 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/formatFBEvent.test.ts +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/formatFBEvent.test.ts @@ -2,6 +2,47 @@ import { formatFBEvent } from '../functions' import { Payload } from '../generated-types' describe('formatFBEvent', () => { + it('should normalize string, array, and currency fields', () => { + const payload = { + event_config: { event_name: 'Purchase', show_fields: true }, + content_category: ' Games ', + content_name: ' Monopoly ', + content_type: ' product ', + content_ids: [' SKU-ABC-123 ', ' ', 'SKU-XYZ-789'], + delivery_category: ' home_delivery ', + search_string: ' board game ', + currency: ' usd ', + value: 45.97 + } as unknown as Payload + + const result = formatFBEvent(payload) + + expect(result).toEqual({ + partner_agent: 'segment', + content_category: 'Games', + content_name: 'Monopoly', + content_type: 'product', + content_ids: ['SKU-ABC-123', 'SKU-XYZ-789'], // trimmed; whitespace-only entry dropped + delivery_category: 'home_delivery', + search_string: 'board game', + currency: 'USD', // trimmed + uppercased to a valid ISO code + value: 45.97 + }) + }) + + it('should drop an invalid currency code', () => { + const payload = { + event_config: { event_name: 'Purchase', show_fields: true }, + content_ids: ['SKU-ABC-123'], + currency: 'NOTACURRENCY', + value: 10 + } as unknown as Payload + + const result = formatFBEvent(payload) + + expect(result).not.toHaveProperty('currency') + }) + it('should format a complete Purchase event with all fields', () => { const payload: Partial = { event_config: { 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 0930bf686f..77405464c3 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 @@ -175,6 +175,7 @@ export const num_items: InputField = { label: 'Number of Items', description: 'The number of items when checkout was initiated.', type: 'integer', + minimum: 0, default: { '@path': '$.properties.num_items' }, depends_on: getDependenciesFor('num_items') } @@ -183,6 +184,7 @@ export const predicted_ltv: InputField = { label: 'Predicted LTV', description: 'Predicted lifetime value of a subscriber as defined by the advertiser and expressed as an exact value.', type: 'number', + minimum: 0, depends_on: getDependenciesFor('predicted_ltv') } @@ -190,6 +192,7 @@ export const net_revenue: InputField = { label: 'Net Revenue', description: 'The net revenue associated with the purchase.', type: 'number', + minimum: 0, default: { '@path': '$.properties.net_revenue' }, depends_on: getDependenciesFor('net_revenue') } @@ -214,6 +217,7 @@ export const value: InputField = { description: 'A numeric value associated with this event. This could be a monetary value or a value in some other metric.', type: 'number', + minimum: 0, default: { '@path': '$.properties.value' }, depends_on: getDependenciesFor('value'), required: { diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/functions.ts b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/functions.ts index 9b5898a423..d8219fc3a1 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/functions.ts +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/functions.ts @@ -12,7 +12,14 @@ import { import { Payload } from './generated-types' import { Settings } from '../generated-types' import { UniversalStorage, Analytics } from '@segment/analytics-next' -import { US_STATE_CODES, COUNTRY_CODES, MAX_INIT_COUNT, INIT_COUNT_KEY, USER_DATA_KEY } from '../constants' +import { + US_STATE_CODES, + COUNTRY_CODES, + MAX_INIT_COUNT, + INIT_COUNT_KEY, + USER_DATA_KEY, + CURRENCY_ISO_CODES +} from '../constants' import { storageFallback, setStorageInitCount } from '../functions' import { getNotVisibleForEvent } from './depends-on' @@ -72,6 +79,23 @@ function toArray(value: T | T[] | undefined | null): T[] { return Array.isArray(value) ? value : [value] } +function trimmed(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined + const t = value.trim() + return t.length > 0 ? t : undefined +} + +function trimmedArray(values: string[]): string[] { + return values.map((v) => trimmed(v)).filter((v): v is string => v !== undefined) +} + +function normalizeCurrency(value: unknown): string | undefined { + const t = trimmed(value) + if (!t) return undefined + const upper = t.toUpperCase() + return CURRENCY_ISO_CODES.has(upper) ? upper : undefined +} + export function formatFBEvent(payload: Payload): FBEvent { const { content_category, @@ -91,20 +115,26 @@ export function formatFBEvent(payload: Payload): FBEvent { event_config: { event_name, show_fields } = {} } = payload - const contentIdsArr = toArray(content_ids) + const contentIdsArr = trimmedArray(toArray(content_ids)) const contentsArr = toArray(contents) + const contentCategory = trimmed(content_category) + const contentName = trimmed(content_name) + const contentType = trimmed(content_type) + const deliveryCategory = trimmed(delivery_category) + const searchString = trimmed(search_string) + const currencyCode = normalizeCurrency(currency) const fbEvent: FBEvent = { partner_agent: 'segment', - ...(content_category ? { content_category } : {}), + ...(contentCategory ? { content_category: contentCategory } : {}), ...(contentIdsArr.length > 0 ? { content_ids: contentIdsArr } : {}), - ...(content_name ? { content_name } : {}), - ...(content_type ? { content_type } : {}), + ...(contentName ? { content_name: contentName } : {}), + ...(contentType ? { content_type: contentType } : {}), ...(contentsArr.length > 0 ? { contents: contentsArr } : {}), - ...(currency ? { currency } : {}), - ...(delivery_category ? { delivery_category } : {}), + ...(currencyCode ? { currency: currencyCode } : {}), + ...(deliveryCategory ? { delivery_category: deliveryCategory } : {}), ...(typeof num_items === 'number' ? { num_items } : {}), - ...(search_string ? { search_string } : {}), + ...(searchString ? { search_string: searchString } : {}), ...(typeof status === 'boolean' ? { status } : {}), ...(typeof value === 'number' ? { value } : {}), ...(typeof predicted_ltv === 'number' ? { predicted_ltv } : {}), From f6ad0f0822fd120d84c06637ef4279dce43453a7 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 5 Aug 2026 12:00:51 +0100 Subject: [PATCH 07/14] Split event_config into flat fields to fix app UI depends_on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The event_config object field held event_name/custom_event_name/show_fields as sub-properties, and depends_on referenced them via dotted fieldKeys (event_config.show_fields). The app mapping editor only resolves flat top-level fieldKeys, so those conditions never matched and dependent fields (predicted_ltv, net_revenue, etc.) never displayed. The show_fields condition also compared a boolean field against the string 'true'. Changes: - Split event_config into top-level event_name, custom_event_name, show_fields fields. - depends_on now uses flat fieldKeys and a boolean `true` for show_fields. - Fixed the getDependenciesFor guard (>1 -> >=1) so single-event fields (num_items, search_string, status, net_revenue, custom_event_name) gate on their event, not only show_fields. - custom_event_name is required when event_name is CustomEvent. - Rewired functions.ts, all 9 presets, generated-types, and tests; expanded depends-on tests. Note: this changes the mapping shape (event_config removed) — existing mappings referencing event_config will need re-saving. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../metadata.json | 568 +++++++++--------- .../facebook-conversions-api-web/src/index.ts | 54 +- .../src/send/__tests__/content-ids.test.ts | 9 +- .../src/send/__tests__/depends-on.test.ts | 69 ++- .../src/send/__tests__/formatFBEvent.test.ts | 90 +-- .../src/send/__tests__/functions.test.ts | 188 ++---- .../src/send/__tests__/init.test.ts | 202 +++---- .../src/send/depends-on.ts | 114 ++-- .../src/send/fields.ts | 103 ++-- .../src/send/functions.ts | 19 +- .../src/send/generated-types.ts | 25 +- 11 files changed, 675 insertions(+), 766 deletions(-) diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/metadata.json b/packages/browser-destinations/destinations/facebook-conversions-api-web/metadata.json index 65a2d1b03e..f5ff8cacd3 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/metadata.json +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/metadata.json @@ -156,190 +156,175 @@ "hooks": null, "dynamicFields": null, "fields": { - "event_config": { - "label": "Event Configuration", - "description": "Specify the type of Facebook Conversions API event to send.", - "type": "object", + "event_name": { + "label": "Event Name", + "description": "Facebook Conversions API Event Name to send. Select 'Custom Event' to send a non standard event.", + "type": "string", "required": true, "multiple": false, "allowNull": false, "dynamic": false, - "default": { - "event_name": "CustomEvent", - "custom_event_name": { - "@path": "$.event" + "default": "CustomEvent", + "choices": [ + { + "label": "Custom Event", + "value": "CustomEvent" + }, + { + "label": "Page View", + "value": "PageView" + }, + { + "label": "Add Payment Info", + "value": "AddPaymentInfo" + }, + { + "label": "Add To Cart", + "value": "AddToCart" + }, + { + "label": "Add To Wishlist", + "value": "AddToWishlist" + }, + { + "label": "Complete Registration", + "value": "CompleteRegistration" + }, + { + "label": "Contact", + "value": "Contact" + }, + { + "label": "Customize Product", + "value": "CustomizeProduct" + }, + { + "label": "Donate", + "value": "Donate" }, - "show_fields": false + { + "label": "Find Location", + "value": "FindLocation" + }, + { + "label": "Initiate Checkout", + "value": "InitiateCheckout" + }, + { + "label": "Lead", + "value": "Lead" + }, + { + "label": "Purchase", + "value": "Purchase" + }, + { + "label": "Schedule", + "value": "Schedule" + }, + { + "label": "Search", + "value": "Search" + }, + { + "label": "Start Trial", + "value": "StartTrial" + }, + { + "label": "Submit Application", + "value": "SubmitApplication" + }, + { + "label": "Subscribe", + "value": "Subscribe" + }, + { + "label": "View Content", + "value": "ViewContent" + } + ], + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "custom_event_name": { + "label": "Custom Event Name", + "description": "Custom event name to send to Facebook", + "type": "string", + "required": { + "match": "all", + "conditions": [ + { + "fieldKey": "event_name", + "operator": "is", + "value": "CustomEvent" + } + ] + }, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "@path": "$.event" }, "choices": null, "placeholder": null, - "properties": { - "event_name": { - "label": "Event Name", - "description": "Facebook Conversions API Event Name to send. Select 'Custom Event' to send a non standard event.", - "type": "string", - "required": true, - "multiple": false, - "allowNull": false, - "dynamic": false, - "default": null, - "choices": [ - { - "label": "Custom Event", - "value": "CustomEvent" - }, - { - "label": "Page View", - "value": "PageView" - }, - { - "label": "Add Payment Info", - "value": "AddPaymentInfo" - }, - { - "label": "Add To Cart", - "value": "AddToCart" - }, - { - "label": "Add To Wishlist", - "value": "AddToWishlist" - }, - { - "label": "Complete Registration", - "value": "CompleteRegistration" - }, - { - "label": "Contact", - "value": "Contact" - }, - { - "label": "Customize Product", - "value": "CustomizeProduct" - }, - { - "label": "Donate", - "value": "Donate" - }, - { - "label": "Find Location", - "value": "FindLocation" - }, - { - "label": "Initiate Checkout", - "value": "InitiateCheckout" - }, - { - "label": "Lead", - "value": "Lead" - }, - { - "label": "Purchase", - "value": "Purchase" - }, - { - "label": "Schedule", - "value": "Schedule" - }, - { - "label": "Search", - "value": "Search" - }, - { - "label": "Start Trial", - "value": "StartTrial" - }, - { - "label": "Submit Application", - "value": "SubmitApplication" - }, - { - "label": "Subscribe", - "value": "Subscribe" - }, - { - "label": "View Content", - "value": "ViewContent" - } - ], - "placeholder": null, - "properties": null, - "category": null, - "depends_on": null, - "readOnly": null, - "hidden": null, - "minimum": null, - "maximum": null, - "defaultObjectUI": null, - "disabledInputMethods": null, - "displayMode": null, - "format": null, - "additionalProperties": false - }, - "custom_event_name": { - "label": "Custom Event Name", - "description": "Custom event name to send to Facebook", - "type": "string", - "required": false, - "multiple": false, - "allowNull": false, - "dynamic": false, - "default": null, - "choices": null, - "placeholder": null, - "properties": null, - "category": null, - "depends_on": { - "match": "any", - "conditions": [ - { - "fieldKey": "event_config.show_fields", - "operator": "is", - "value": "true" - } - ] + "properties": null, + "category": null, + "depends_on": { + "match": "any", + "conditions": [ + { + "fieldKey": "show_fields", + "operator": "is", + "value": true }, - "readOnly": null, - "hidden": null, - "minimum": null, - "maximum": null, - "defaultObjectUI": null, - "disabledInputMethods": null, - "displayMode": null, - "format": null, - "additionalProperties": false - }, - "show_fields": { - "label": "Show all fields", - "description": "Show all fields, even those which are not relevant to the selected Event Name.", - "type": "boolean", - "required": false, - "multiple": false, - "allowNull": false, - "dynamic": false, - "default": false, - "choices": null, - "placeholder": null, - "properties": null, - "category": null, - "depends_on": null, - "readOnly": null, - "hidden": null, - "minimum": null, - "maximum": null, - "defaultObjectUI": null, - "disabledInputMethods": null, - "displayMode": null, - "format": null, - "additionalProperties": false - } + { + "fieldKey": "event_name", + "operator": "is", + "value": "CustomEvent" + } + ] }, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "show_fields": { + "label": "Show all fields", + "description": "Show all fields, even those which are not relevant to the selected Event Name.", + "type": "boolean", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": false, + "choices": null, + "placeholder": null, + "properties": null, "category": null, "depends_on": null, "readOnly": null, "hidden": null, "minimum": null, "maximum": null, - "defaultObjectUI": "keyvalue", + "defaultObjectUI": null, "disabledInputMethods": null, "displayMode": null, "format": null, @@ -364,22 +349,22 @@ "match": "any", "conditions": [ { - "fieldKey": "event_config.show_fields", + "fieldKey": "show_fields", "operator": "is", - "value": "true" + "value": true }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "PageView" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "ViewContent" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "Search" } @@ -412,42 +397,42 @@ "match": "any", "conditions": [ { - "fieldKey": "event_config.show_fields", + "fieldKey": "show_fields", "operator": "is", - "value": "true" + "value": true }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "AddPaymentInfo" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "AddToCart" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "AddToWishlist" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "InitiateCheckout" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "Purchase" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "Search" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "ViewContent" } @@ -482,22 +467,22 @@ "match": "any", "conditions": [ { - "fieldKey": "event_config.show_fields", + "fieldKey": "show_fields", "operator": "is", - "value": "true" + "value": true }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "PageView" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "ViewContent" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "Search" } @@ -539,27 +524,27 @@ "match": "any", "conditions": [ { - "fieldKey": "event_config.show_fields", + "fieldKey": "show_fields", "operator": "is", - "value": "true" + "value": true }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "AddToCart" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "Purchase" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "Search" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "ViewContent" } @@ -680,42 +665,42 @@ "match": "any", "conditions": [ { - "fieldKey": "event_config.show_fields", + "fieldKey": "show_fields", "operator": "is", - "value": "true" + "value": true }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "AddPaymentInfo" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "AddToCart" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "AddToWishlist" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "InitiateCheckout" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "Purchase" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "Search" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "ViewContent" } @@ -739,7 +724,7 @@ "match": "all", "conditions": [ { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "Purchase" } @@ -1476,62 +1461,62 @@ "match": "any", "conditions": [ { - "fieldKey": "event_config.show_fields", + "fieldKey": "show_fields", "operator": "is", - "value": "true" + "value": true }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "AddPaymentInfo" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "AddToCart" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "AddToWishlist" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "CompleteRegistration" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "InitiateCheckout" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "Lead" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "Purchase" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "Search" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "StartTrial" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "Subscribe" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "ViewContent" } @@ -1577,17 +1562,17 @@ "match": "any", "conditions": [ { - "fieldKey": "event_config.show_fields", + "fieldKey": "show_fields", "operator": "is", - "value": "true" + "value": true }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "Purchase" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "InitiateCheckout" } @@ -1622,9 +1607,14 @@ "match": "any", "conditions": [ { - "fieldKey": "event_config.show_fields", + "fieldKey": "show_fields", + "operator": "is", + "value": true + }, + { + "fieldKey": "event_name", "operator": "is", - "value": "true" + "value": "InitiateCheckout" } ] }, @@ -1655,37 +1645,37 @@ "match": "any", "conditions": [ { - "fieldKey": "event_config.show_fields", + "fieldKey": "show_fields", "operator": "is", - "value": "true" + "value": true }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "Purchase" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "Subscribe" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "StartTrial" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "CompleteRegistration" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "AddPaymentInfo" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "CustomEvent" } @@ -1720,9 +1710,14 @@ "match": "any", "conditions": [ { - "fieldKey": "event_config.show_fields", + "fieldKey": "show_fields", + "operator": "is", + "value": true + }, + { + "fieldKey": "event_name", "operator": "is", - "value": "true" + "value": "Purchase" } ] }, @@ -1755,9 +1750,14 @@ "match": "any", "conditions": [ { - "fieldKey": "event_config.show_fields", + "fieldKey": "show_fields", "operator": "is", - "value": "true" + "value": true + }, + { + "fieldKey": "event_name", + "operator": "is", + "value": "Search" } ] }, @@ -1788,9 +1788,14 @@ "match": "any", "conditions": [ { - "fieldKey": "event_config.show_fields", + "fieldKey": "show_fields", + "operator": "is", + "value": true + }, + { + "fieldKey": "event_name", "operator": "is", - "value": "true" + "value": "CompleteRegistration" } ] }, @@ -1812,7 +1817,7 @@ "match": "all", "conditions": [ { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "Purchase" } @@ -1832,62 +1837,62 @@ "match": "any", "conditions": [ { - "fieldKey": "event_config.show_fields", + "fieldKey": "show_fields", "operator": "is", - "value": "true" + "value": true }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "AddPaymentInfo" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "AddToCart" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "AddToWishlist" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "CompleteRegistration" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "InitiateCheckout" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "Lead" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "Purchase" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "Search" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "StartTrial" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "Subscribe" }, { - "fieldKey": "event_config.event_name", + "fieldKey": "event_name", "operator": "is", "value": "ViewContent" } @@ -2378,10 +2383,11 @@ "partnerAction": "send", "subscribe": "event = \"Payment Info Entered\"", "mapping": { - "event_config": { - "event_name": "AddPaymentInfo", - "show_fields": false + "event_name": "AddPaymentInfo", + "custom_event_name": { + "@path": "$.event" }, + "show_fields": false, "content_category": { "@path": "$.properties.category" }, @@ -2479,10 +2485,11 @@ "partnerAction": "send", "subscribe": "event = \"Product Added\"", "mapping": { - "event_config": { - "event_name": "AddToCart", - "show_fields": false + "event_name": "AddToCart", + "custom_event_name": { + "@path": "$.event" }, + "show_fields": false, "content_category": { "@path": "$.properties.category" }, @@ -2578,10 +2585,11 @@ "partnerAction": "send", "subscribe": "event = \"Product Added To Wishlist\"", "mapping": { - "event_config": { - "event_name": "AddToWishlist", - "show_fields": false + "event_name": "AddToWishlist", + "custom_event_name": { + "@path": "$.event" }, + "show_fields": false, "content_category": { "@path": "$.properties.category" }, @@ -2677,10 +2685,11 @@ "partnerAction": "send", "subscribe": "event = \"Signed Up\"", "mapping": { - "event_config": { - "event_name": "CompleteRegistration", - "show_fields": false + "event_name": "CompleteRegistration", + "custom_event_name": { + "@path": "$.event" }, + "show_fields": false, "content_category": { "@path": "$.properties.category" }, @@ -2778,10 +2787,11 @@ "partnerAction": "send", "subscribe": "event = \"Checkout Started\"", "mapping": { - "event_config": { - "event_name": "InitiateCheckout", - "show_fields": false + "event_name": "InitiateCheckout", + "custom_event_name": { + "@path": "$.event" }, + "show_fields": false, "content_category": { "@path": "$.properties.category" }, @@ -2879,10 +2889,11 @@ "partnerAction": "send", "subscribe": "type = \"page\"", "mapping": { - "event_config": { - "event_name": "PageView", - "show_fields": false + "event_name": "PageView", + "custom_event_name": { + "@path": "$.event" }, + "show_fields": false, "content_category": { "@path": "$.category" }, @@ -2980,10 +2991,11 @@ "partnerAction": "send", "subscribe": "event = \"Order Completed\"", "mapping": { - "event_config": { - "event_name": "Purchase", - "show_fields": false + "event_name": "Purchase", + "custom_event_name": { + "@path": "$.event" }, + "show_fields": false, "content_category": { "@path": "$.properties.category" }, @@ -3086,10 +3098,11 @@ "partnerAction": "send", "subscribe": "event = \"Products Searched\"", "mapping": { - "event_config": { - "event_name": "Search", - "show_fields": false + "event_name": "Search", + "custom_event_name": { + "@path": "$.event" }, + "show_fields": false, "content_category": { "@path": "$.properties.category" }, @@ -3185,10 +3198,11 @@ "partnerAction": "send", "subscribe": "event = \"Product Viewed\"", "mapping": { - "event_config": { - "event_name": "ViewContent", - "show_fields": false + "event_name": "ViewContent", + "custom_event_name": { + "@path": "$.event" }, + "show_fields": false, "content_category": { "@path": "$.properties.category" }, 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 61de5bb6d9..8b7483fa51 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 @@ -110,10 +110,8 @@ export const destination: BrowserDestinationDefinition< partnerAction: 'send', mapping: { ...defaultValues(send.fields), - event_config: { - event_name: 'AddPaymentInfo', - show_fields: false - } + event_name: 'AddPaymentInfo', + show_fields: false }, type: 'automatic' }, @@ -123,10 +121,8 @@ export const destination: BrowserDestinationDefinition< partnerAction: 'send', mapping: { ...defaultValues(send.fields), - event_config: { - event_name: 'AddToCart', - show_fields: false - }, + event_name: 'AddToCart', + show_fields: false, contents: { id: { '@path': '$.properties.product_id' }, quantity: { '@path': '$.properties.quantity' }, @@ -143,10 +139,8 @@ export const destination: BrowserDestinationDefinition< partnerAction: 'send', mapping: { ...defaultValues(send.fields), - event_config: { - event_name: 'AddToWishlist', - show_fields: false - }, + event_name: 'AddToWishlist', + show_fields: false, contents: { id: { '@path': '$.properties.product_id' }, quantity: { '@path': '$.properties.quantity' }, @@ -163,10 +157,8 @@ export const destination: BrowserDestinationDefinition< partnerAction: 'send', mapping: { ...defaultValues(send.fields), - event_config: { - event_name: 'CompleteRegistration', - show_fields: false - } + event_name: 'CompleteRegistration', + show_fields: false }, type: 'automatic' }, @@ -176,10 +168,8 @@ export const destination: BrowserDestinationDefinition< partnerAction: 'send', mapping: { ...defaultValues(send.fields), - event_config: { - event_name: 'InitiateCheckout', - show_fields: false - } + event_name: 'InitiateCheckout', + show_fields: false }, type: 'automatic' }, @@ -189,10 +179,8 @@ export const destination: BrowserDestinationDefinition< partnerAction: 'send', mapping: { ...defaultValues(send.fields), - event_config: { - event_name: 'PageView', - show_fields: false - }, + event_name: 'PageView', + show_fields: false, content_name: { '@path': '$.name' }, content_category: { '@path': '$.category' } }, @@ -204,10 +192,8 @@ export const destination: BrowserDestinationDefinition< partnerAction: 'send', mapping: { ...defaultValues(send.fields), - event_config: { - event_name: 'Purchase', - show_fields: false - }, + event_name: 'Purchase', + show_fields: false, value: { '@path': '$.properties.revenue' }, custom_data: { order_id: { '@path': '$.properties.order_id' } @@ -221,10 +207,8 @@ export const destination: BrowserDestinationDefinition< partnerAction: 'send', mapping: { ...defaultValues(send.fields), - event_config: { - event_name: 'Search', - show_fields: false - }, + event_name: 'Search', + show_fields: false, contents: { id: { '@path': '$.properties.product_id' }, quantity: { '@path': '$.properties.quantity' }, @@ -240,10 +224,8 @@ export const destination: BrowserDestinationDefinition< partnerAction: 'send', mapping: { ...defaultValues(send.fields), - event_config: { - event_name: 'ViewContent', - show_fields: false - }, + event_name: 'ViewContent', + show_fields: false, contents: { id: { '@path': '$.properties.product_id' }, quantity: { '@path': '$.properties.quantity' }, diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/content-ids.test.ts b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/content-ids.test.ts index fec7566bdb..9f8caa9641 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/content-ids.test.ts +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/content-ids.test.ts @@ -28,7 +28,8 @@ describe('content_ids field', () => { describe('formatFBEvent handling of content_ids', () => { it('passes a provided array of ids through', () => { const payload: Partial = { - event_config: { event_name: 'ViewContent', show_fields: true }, + event_name: 'ViewContent', + show_fields: true, content_ids: ['SKU-ABC-123', 'SKU-XYZ-789'] } @@ -39,7 +40,8 @@ describe('content_ids field', () => { it('omits content_ids when it is an empty array', () => { const payload: Partial = { - event_config: { event_name: 'AddToCart', show_fields: true }, + event_name: 'AddToCart', + show_fields: true, content_ids: [] } @@ -50,7 +52,8 @@ describe('content_ids field', () => { it('omits content_ids when it is absent', () => { const payload: Partial = { - event_config: { event_name: 'Purchase', show_fields: true }, + event_name: 'Purchase', + show_fields: true, value: 45.97 } diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/depends-on.test.ts b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/depends-on.test.ts index 0c96e506d2..5806698f10 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/depends-on.test.ts +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/depends-on.test.ts @@ -1,28 +1,53 @@ import { getDependenciesFor } from '../depends-on' describe('depends-on getDependenciesFor', () => { - test('returns correct depends_on rules', () => { + test('multi-event field includes show_fields plus one condition per event', () => { const result = getDependenciesFor('delivery_category') - expect(true).toBe(true) expect(result.match).toBe('any') - expect(result.conditions).toEqual( - [ - { - fieldKey: 'event_config.show_fields', - operator: 'is', - value: 'true' - }, - { - fieldKey: 'event_config.event_name', - operator: 'is', - value: 'Purchase' - }, - { - fieldKey: 'event_config.event_name', - operator: 'is', - value: 'InitiateCheckout' - } - ] - ) - }) + expect(result.conditions).toEqual([ + { + fieldKey: 'show_fields', + operator: 'is', + value: true + }, + { + fieldKey: 'event_name', + operator: 'is', + value: 'Purchase' + }, + { + fieldKey: 'event_name', + operator: 'is', + value: 'InitiateCheckout' + } + ]) + }) + + test('single-event field still includes its event condition', () => { + const result = getDependenciesFor('num_items') + expect(result.match).toBe('any') + expect(result.conditions).toEqual([ + { fieldKey: 'show_fields', operator: 'is', value: true }, + { fieldKey: 'event_name', operator: 'is', value: 'InitiateCheckout' } + ]) + }) + + test('custom_event_name gates on show_fields and the CustomEvent event', () => { + const result = getDependenciesFor('custom_event_name') + expect(result.conditions).toEqual([ + { fieldKey: 'show_fields', operator: 'is', value: true }, + { fieldKey: 'event_name', operator: 'is', value: 'CustomEvent' } + ]) + }) + + test('show_fields condition uses a boolean value, not the string "true"', () => { + const result = getDependenciesFor('status') + const showFieldsCondition = result.conditions.find((c) => 'fieldKey' in c && c.fieldKey === 'show_fields') + expect(showFieldsCondition).toEqual({ fieldKey: 'show_fields', operator: 'is', value: true }) + }) + + test('unknown field returns only the show_fields condition', () => { + const result = getDependenciesFor('not_a_real_field') + expect(result.conditions).toEqual([{ fieldKey: 'show_fields', operator: 'is', value: true }]) + }) }) diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/formatFBEvent.test.ts b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/formatFBEvent.test.ts index 87cae58b0b..c34d26154e 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/formatFBEvent.test.ts +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/formatFBEvent.test.ts @@ -4,7 +4,8 @@ import { Payload } from '../generated-types' describe('formatFBEvent', () => { it('should normalize string, array, and currency fields', () => { const payload = { - event_config: { event_name: 'Purchase', show_fields: true }, + event_name: 'Purchase', + show_fields: true, content_category: ' Games ', content_name: ' Monopoly ', content_type: ' product ', @@ -32,7 +33,8 @@ describe('formatFBEvent', () => { it('should drop an invalid currency code', () => { const payload = { - event_config: { event_name: 'Purchase', show_fields: true }, + event_name: 'Purchase', + show_fields: true, content_ids: ['SKU-ABC-123'], currency: 'NOTACURRENCY', value: 10 @@ -45,10 +47,8 @@ describe('formatFBEvent', () => { it('should format a complete Purchase event with all fields', () => { const payload: Partial = { - event_config: { - event_name: 'Purchase', - show_fields: true - }, + event_name: 'Purchase', + show_fields: true, content_ids: ['product-123', 'product-456'], content_name: 'Test Product', content_category: 'Electronics', @@ -96,10 +96,8 @@ describe('formatFBEvent', () => { it('should format minimal PageView event', () => { const payload: Partial = { - event_config: { - event_name: 'PageView', - show_fields: false - } + event_name: 'PageView', + show_fields: false } const result = formatFBEvent(payload as Payload) @@ -111,10 +109,8 @@ describe('formatFBEvent', () => { it('should include only provided fields', () => { const payload: Partial = { - event_config: { - event_name: 'ViewContent', - show_fields: true - }, + event_name: 'ViewContent', + show_fields: true, content_ids: ['product-789'], value: 149.99, currency: 'USD' @@ -132,10 +128,8 @@ describe('formatFBEvent', () => { it('should handle zero values for numeric fields', () => { const payload: Partial = { - event_config: { - event_name: 'Purchase', - show_fields: true - }, + event_name: 'Purchase', + show_fields: true, content_ids: ['product-123'], value: 0, num_items: 0, @@ -157,10 +151,8 @@ describe('formatFBEvent', () => { it('should not include empty arrays', () => { const payload: Partial = { - event_config: { - event_name: 'AddToCart', - show_fields: true - }, + event_name: 'AddToCart', + show_fields: true, content_ids: [], contents: [], value: 99.99 @@ -176,10 +168,8 @@ describe('formatFBEvent', () => { it('should not include empty custom_data object', () => { const payload: Partial = { - event_config: { - event_name: 'Purchase', - show_fields: true - }, + event_name: 'Purchase', + show_fields: true, content_ids: ['product-123'], value: 99.99, custom_data: {} @@ -196,10 +186,8 @@ describe('formatFBEvent', () => { it('should include contents array with all item properties', () => { const payload: Partial = { - event_config: { - event_name: 'Purchase', - show_fields: true - }, + event_name: 'Purchase', + show_fields: true, contents: [ { id: 'product-1', quantity: 2, item_price: 25.5 }, { id: 'product-2', quantity: 1, item_price: 100.0 }, @@ -223,10 +211,8 @@ describe('formatFBEvent', () => { it('should include all standard event fields', () => { const payload: Partial = { - event_config: { - event_name: 'InitiateCheckout', - show_fields: true - }, + event_name: 'InitiateCheckout', + show_fields: true, content_category: 'Apparel', content_ids: ['shirt-123'], content_name: 'Blue Shirt', @@ -252,10 +238,8 @@ describe('formatFBEvent', () => { it('should include search_string for a Search event', () => { const payload: Partial = { - event_config: { - event_name: 'Search', - show_fields: true - }, + event_name: 'Search', + show_fields: true, search_string: 'monopoly board game', content_category: 'Games', content_ids: ['product-123'], @@ -277,10 +261,8 @@ describe('formatFBEvent', () => { it('should include status for a CompleteRegistration event', () => { const payload: Partial = { - event_config: { - event_name: 'CompleteRegistration', - show_fields: true - }, + event_name: 'CompleteRegistration', + show_fields: true, status: true, currency: 'USD', value: 0 @@ -298,10 +280,8 @@ describe('formatFBEvent', () => { it('should include status when false (registration not completed)', () => { const payload: Partial = { - event_config: { - event_name: 'CompleteRegistration', - show_fields: true - }, + event_name: 'CompleteRegistration', + show_fields: true, status: false } @@ -315,10 +295,8 @@ describe('formatFBEvent', () => { it('should format Subscribe event with predicted_ltv', () => { const payload: Partial = { - event_config: { - event_name: 'Subscribe', - show_fields: true - }, + event_name: 'Subscribe', + show_fields: true, value: 9.99, currency: 'USD', predicted_ltv: 119.88 @@ -336,10 +314,8 @@ describe('formatFBEvent', () => { it('should format Purchase event with net_revenue', () => { const payload: Partial = { - event_config: { - event_name: 'Purchase', - show_fields: true - }, + event_name: 'Purchase', + show_fields: true, content_ids: ['product-123'], value: 100.0, currency: 'USD', @@ -359,10 +335,8 @@ describe('formatFBEvent', () => { it('should include custom_data when provided', () => { const payload: Partial = { - event_config: { - event_name: 'Lead', - show_fields: true - }, + event_name: 'Lead', + show_fields: true, value: 0, custom_data: { lead_source: 'facebook_ad', diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/functions.test.ts b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/functions.test.ts index c550e16d58..0f193afd4e 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/functions.test.ts +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/functions.test.ts @@ -31,10 +31,8 @@ describe('Facebook Conversions API Web - Send Functions', () => { describe('send - Standard Events', () => { it('should send Purchase event with required fields', async () => { const payload = { - event_config: { - event_name: 'Purchase', - show_fields: false - }, + event_name: 'Purchase', + show_fields: false, content_ids: ['product-123'], value: 99.99, currency: 'USD' @@ -58,10 +56,8 @@ describe('Facebook Conversions API Web - Send Functions', () => { it('should send AddToCart event with contents', async () => { const payload = { - event_config: { - event_name: 'AddToCart', - show_fields: false - }, + event_name: 'AddToCart', + show_fields: false, contents: [ { id: 'product-123', @@ -94,10 +90,8 @@ describe('Facebook Conversions API Web - Send Functions', () => { // so a single-product event maps content_ids to a scalar string. It // should still be sent to fbq as an array, not dropped. const payload = { - event_config: { - event_name: 'ViewContent', - show_fields: false - }, + event_name: 'ViewContent', + show_fields: false, content_ids: 'product-123', value: 19.99 } @@ -121,10 +115,8 @@ describe('Facebook Conversions API Web - Send Functions', () => { // Likewise a single-product event maps contents to a scalar object; it // should be sent to fbq wrapped in an array. const payload = { - event_config: { - event_name: 'AddToCart', - show_fields: false - }, + event_name: 'AddToCart', + show_fields: false, contents: { id: 'product-123', quantity: 2, item_price: 49.99 }, value: 99.98, currency: 'USD' @@ -148,10 +140,8 @@ describe('Facebook Conversions API Web - Send Functions', () => { it('should send ViewContent event', async () => { const payload = { - event_config: { - event_name: 'ViewContent', - show_fields: false - }, + event_name: 'ViewContent', + show_fields: false, content_ids: ['product-456'], content_name: 'Test Product', content_category: 'Electronics', @@ -177,10 +167,8 @@ describe('Facebook Conversions API Web - Send Functions', () => { it('should send Search event with search_string', async () => { const payload = { - event_config: { - event_name: 'Search', - show_fields: false - }, + event_name: 'Search', + show_fields: false, search_string: 'monopoly board game', content_category: 'Games', content_ids: ['product-123'], @@ -208,10 +196,8 @@ describe('Facebook Conversions API Web - Send Functions', () => { it('should send CompleteRegistration event with status', async () => { const payload = { - event_config: { - event_name: 'CompleteRegistration', - show_fields: false - }, + event_name: 'CompleteRegistration', + show_fields: false, status: true, currency: 'USD', value: 0 @@ -235,10 +221,8 @@ describe('Facebook Conversions API Web - Send Functions', () => { it('should send PageView event', async () => { const payload = { - event_config: { - event_name: 'PageView', - show_fields: false - } + event_name: 'PageView', + show_fields: false } await send(mockFbq, mockClientParamBuilder, payload, defaultSettings, mockAnalytics) @@ -256,11 +240,9 @@ describe('Facebook Conversions API Web - Send Functions', () => { describe('send - Custom Events', () => { it('should send custom event with custom event name', async () => { const payload = { - event_config: { - event_name: 'CustomEvent', - custom_event_name: 'MyCustomEvent', - show_fields: true - }, + event_name: 'CustomEvent', + custom_event_name: 'MyCustomEvent', + show_fields: true, value: 50.0, custom_data: { custom_field_1: 'value1', @@ -290,10 +272,8 @@ describe('Facebook Conversions API Web - Send Functions', () => { describe('send - Validation', () => { it('should warn if AddToCart is missing both content_ids and contents', async () => { const payload = { - event_config: { - event_name: 'AddToCart', - show_fields: false - }, + event_name: 'AddToCart', + show_fields: false, value: 99.99 } @@ -307,10 +287,8 @@ describe('Facebook Conversions API Web - Send Functions', () => { it('should warn if Purchase is missing both content_ids and contents', async () => { const payload = { - event_config: { - event_name: 'Purchase', - show_fields: false - }, + event_name: 'Purchase', + show_fields: false, value: 199.99, currency: 'USD' } @@ -325,10 +303,8 @@ describe('Facebook Conversions API Web - Send Functions', () => { it('should warn if ViewContent is missing both content_ids and contents', async () => { const payload = { - event_config: { - event_name: 'ViewContent', - show_fields: false - } + event_name: 'ViewContent', + show_fields: false } await send(mockFbq, mockClientParamBuilder, payload, defaultSettings, mockAnalytics) @@ -341,10 +317,8 @@ describe('Facebook Conversions API Web - Send Functions', () => { it('should not warn if AddToCart has content_ids', async () => { const payload = { - event_config: { - event_name: 'AddToCart', - show_fields: false - }, + event_name: 'AddToCart', + show_fields: false, content_ids: ['product-123'], value: 99.99 } @@ -357,10 +331,8 @@ describe('Facebook Conversions API Web - Send Functions', () => { it('should not warn if AddToCart has contents', async () => { const payload = { - event_config: { - event_name: 'AddToCart', - show_fields: false - }, + event_name: 'AddToCart', + show_fields: false, contents: [{ id: 'product-123', quantity: 1 }], value: 99.99 } @@ -375,10 +347,8 @@ describe('Facebook Conversions API Web - Send Functions', () => { describe('send - Event Options', () => { it('should include eventID when provided', async () => { const payload = { - event_config: { - event_name: 'Purchase', - show_fields: false - }, + event_name: 'Purchase', + show_fields: false, content_ids: ['product-123'], value: 99.99, eventID: 'unique-event-id-123' @@ -393,10 +363,8 @@ describe('Facebook Conversions API Web - Send Functions', () => { it('should include eventSourceUrl when provided', async () => { const payload = { - event_config: { - event_name: 'Purchase', - show_fields: false - }, + event_name: 'Purchase', + show_fields: false, content_ids: ['product-123'], value: 99.99, eventSourceUrl: 'https://example.com/checkout' @@ -411,10 +379,8 @@ describe('Facebook Conversions API Web - Send Functions', () => { it('should include both eventID and eventSourceUrl when provided', async () => { const payload = { - event_config: { - event_name: 'Purchase', - show_fields: false - }, + event_name: 'Purchase', + show_fields: false, content_ids: ['product-123'], value: 99.99, eventID: 'unique-event-id-123', @@ -433,10 +399,8 @@ describe('Facebook Conversions API Web - Send Functions', () => { describe('send - Event Data Fields', () => { it('should include all event fields when show_fields is true', async () => { const payload = { - event_config: { - event_name: 'Purchase', - show_fields: true - }, + event_name: 'Purchase', + show_fields: true, content_ids: ['product-123'], content_name: 'Test Product', content_category: 'Electronics', @@ -470,10 +434,8 @@ describe('Facebook Conversions API Web - Send Functions', () => { it('should include predicted_ltv for Subscribe event', async () => { const payload = { - event_config: { - event_name: 'Subscribe', - show_fields: false - }, + event_name: 'Subscribe', + show_fields: false, predicted_ltv: 500.0, value: 50.0 } @@ -495,10 +457,8 @@ describe('Facebook Conversions API Web - Send Functions', () => { it('should include net_revenue for Purchase event', async () => { const payload = { - event_config: { - event_name: 'Purchase', - show_fields: false - }, + event_name: 'Purchase', + show_fields: false, content_ids: ['product-123'], net_revenue: 450.0, value: 50.0 @@ -522,10 +482,8 @@ describe('Facebook Conversions API Web - Send Functions', () => { it('should include custom_data', async () => { const payload = { - event_config: { - event_name: 'Purchase', - show_fields: false - }, + event_name: 'Purchase', + show_fields: false, content_ids: ['product-123'], value: 99.99, custom_data: { @@ -557,10 +515,8 @@ describe('Facebook Conversions API Web - Send Functions', () => { it('should not include empty arrays or objects', async () => { const payload = { - event_config: { - event_name: 'PageView', - show_fields: false - }, + event_name: 'PageView', + show_fields: false, content_ids: [], contents: [], custom_data: {} @@ -574,10 +530,8 @@ describe('Facebook Conversions API Web - Send Functions', () => { it('should handle numeric values correctly including zero', async () => { const payload = { - event_config: { - event_name: 'InitiateCheckout', - show_fields: false - }, + event_name: 'InitiateCheckout', + show_fields: false, content_ids: ['product-123'], value: 0, num_items: 0 @@ -617,10 +571,8 @@ describe('Facebook Conversions API Web - Send Functions', () => { mockClientParamBuilderInstance.getNormalizedAndHashedPII.mockReturnValue('hashed_email_value') const payload = { - event_config: { - event_name: 'Purchase', - show_fields: false - }, + event_name: 'Purchase', + show_fields: false, content_ids: ['product-123'], value: 99.99, userData: { @@ -640,10 +592,8 @@ describe('Facebook Conversions API Web - Send Functions', () => { mockClientParamBuilderInstance.getNormalizedAndHashedPII.mockReturnValue('hashed_phone_value') const payload = { - event_config: { - event_name: 'Purchase', - show_fields: false - }, + event_name: 'Purchase', + show_fields: false, content_ids: ['product-123'], value: 99.99, userData: { @@ -667,10 +617,8 @@ describe('Facebook Conversions API Web - Send Functions', () => { }) const payload = { - event_config: { - event_name: 'Purchase', - show_fields: false - }, + event_name: 'Purchase', + show_fields: false, content_ids: ['product-123'], value: 99.99, userData: { @@ -727,10 +675,8 @@ describe('Facebook Conversions API Web - Send Functions', () => { mockClientParamBuilderInstance.getNormalizedAndHashedPII.mockReturnValue('hashed_email') const payload = { - event_config: { - event_name: 'Purchase', - show_fields: false - }, + event_name: 'Purchase', + show_fields: false, content_ids: ['product-123'], value: 99.99, userData: { @@ -761,10 +707,8 @@ describe('Facebook Conversions API Web - Send Functions', () => { mockClientParamBuilderInstance.getNormalizedAndHashedPII.mockReturnValue('hashed_email') const payload = { - event_config: { - event_name: 'Purchase', - show_fields: false - }, + event_name: 'Purchase', + show_fields: false, content_ids: ['product-123'], value: 99.99, userData: { @@ -792,10 +736,8 @@ describe('Facebook Conversions API Web - Send Functions', () => { mockClientParamBuilderInstance.getNormalizedAndHashedPII.mockReturnValue(undefined) const payload = { - event_config: { - event_name: 'Purchase', - show_fields: false - }, + event_name: 'Purchase', + show_fields: false, content_ids: ['product-123'], value: 99.99, userData: { @@ -821,10 +763,8 @@ describe('Facebook Conversions API Web - Send Functions', () => { mockClientParamBuilderInstance.getNormalizedAndHashedPII.mockReturnValue('hashed_email') const payload = { - event_config: { - event_name: 'Purchase', - show_fields: false - }, + event_name: 'Purchase', + show_fields: false, content_ids: ['product-123'], value: 99.99, userData: { @@ -847,10 +787,8 @@ describe('Facebook Conversions API Web - Send Functions', () => { mockAnalytics.storage.get.mockReturnValue('0') const payload = { - event_config: { - event_name: 'Purchase', - show_fields: false - }, + event_name: 'Purchase', + show_fields: false, content_ids: ['product-123'], value: 99.99, userData: { diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/init.test.ts b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/init.test.ts index 9e48c0a0df..563d88d8c7 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/init.test.ts +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/init.test.ts @@ -31,10 +31,8 @@ describe('Facebook Conversions API Web - Init with User Data', () => { mockAnalytics.storage.get.mockReturnValue('0') const payload = { - event_config: { - event_name: 'Purchase', - show_fields: false - }, + event_name: 'Purchase', + show_fields: false, content_ids: ['product-123'], value: 99.99, userData: { @@ -60,10 +58,8 @@ describe('Facebook Conversions API Web - Init with User Data', () => { mockAnalytics.storage.get.mockReturnValue('0') const payload = { - event_config: { - event_name: 'Purchase', - show_fields: false - }, + event_name: 'Purchase', + show_fields: false, content_ids: ['product-123'], value: 99.99, userData: { @@ -84,10 +80,8 @@ describe('Facebook Conversions API Web - Init with User Data', () => { mockAnalytics.storage.get.mockReturnValue('0') const payload = { - event_config: { - event_name: 'Purchase', - show_fields: false - }, + event_name: 'Purchase', + show_fields: false, content_ids: ['product-123'], value: 99.99, userData: { @@ -101,24 +95,18 @@ describe('Facebook Conversions API Web - Init with User Data', () => { // Names should be lowercased and trimmed, then hashed // 'john' -> '96d9632f363564cc3032521409cf22a852f2032eec099ed5967c0d000cec607a' // 'doe' -> '799ef92a11af918e3fb741df42934f3b568ed2d93ac1df74f1b8d41a27932a6f' - expect(mockFbq).toHaveBeenCalledWith( - 'init', - 'test-pixel-123', - { - fn: '96d9632f363564cc3032521409cf22a852f2032eec099ed5967c0d000cec607a', - ln: '799ef92a11af918e3fb741df42934f3b568ed2d93ac1df74f1b8d41a27932a6f' - } - ) + expect(mockFbq).toHaveBeenCalledWith('init', 'test-pixel-123', { + fn: '96d9632f363564cc3032521409cf22a852f2032eec099ed5967c0d000cec607a', + ln: '799ef92a11af918e3fb741df42934f3b568ed2d93ac1df74f1b8d41a27932a6f' + }) }) it('should format userData with gender', async () => { mockAnalytics.storage.get.mockReturnValue('0') const payload = { - event_config: { - event_name: 'Purchase', - show_fields: false - }, + event_name: 'Purchase', + show_fields: false, content_ids: ['product-123'], value: 99.99, userData: { @@ -138,10 +126,8 @@ describe('Facebook Conversions API Web - Init with User Data', () => { mockAnalytics.storage.get.mockReturnValue('0') const payload = { - event_config: { - event_name: 'Purchase', - show_fields: false - }, + event_name: 'Purchase', + show_fields: false, content_ids: ['product-123'], value: 99.99, userData: { @@ -162,10 +148,8 @@ describe('Facebook Conversions API Web - Init with User Data', () => { mockAnalytics.storage.get.mockReturnValue('0') const payload = { - event_config: { - event_name: 'Purchase', - show_fields: false - }, + event_name: 'Purchase', + show_fields: false, content_ids: ['product-123'], value: 99.99, userData: { @@ -186,10 +170,8 @@ describe('Facebook Conversions API Web - Init with User Data', () => { mockAnalytics.storage.get.mockReturnValue('0') const payload = { - event_config: { - event_name: 'Purchase', - show_fields: false - }, + event_name: 'Purchase', + show_fields: false, content_ids: ['product-123'], value: 99.99, userData: { @@ -210,10 +192,8 @@ describe('Facebook Conversions API Web - Init with User Data', () => { mockAnalytics.storage.get.mockReturnValue('0') const payload = { - event_config: { - event_name: 'Purchase', - show_fields: false - }, + event_name: 'Purchase', + show_fields: false, content_ids: ['product-123'], value: 99.99, userData: { @@ -234,10 +214,8 @@ describe('Facebook Conversions API Web - Init with User Data', () => { mockAnalytics.storage.get.mockReturnValue('0') const payload = { - event_config: { - event_name: 'Purchase', - show_fields: false - }, + event_name: 'Purchase', + show_fields: false, content_ids: ['product-123'], value: 99.99, userData: { @@ -258,10 +236,8 @@ describe('Facebook Conversions API Web - Init with User Data', () => { mockAnalytics.storage.get.mockReturnValue('0') const payload = { - event_config: { - event_name: 'Purchase', - show_fields: false - }, + event_name: 'Purchase', + show_fields: false, content_ids: ['product-123'], value: 99.99, userData: { @@ -282,10 +258,8 @@ describe('Facebook Conversions API Web - Init with User Data', () => { mockAnalytics.storage.get.mockReturnValue('0') const payload = { - event_config: { - event_name: 'Purchase', - show_fields: false - }, + event_name: 'Purchase', + show_fields: false, content_ids: ['product-123'], value: 99.99, userData: { @@ -306,10 +280,8 @@ describe('Facebook Conversions API Web - Init with User Data', () => { mockAnalytics.storage.get.mockReturnValue('0') const payload = { - event_config: { - event_name: 'Purchase', - show_fields: false - }, + event_name: 'Purchase', + show_fields: false, content_ids: ['product-123'], value: 99.99, userData: { @@ -330,10 +302,8 @@ describe('Facebook Conversions API Web - Init with User Data', () => { mockAnalytics.storage.get.mockReturnValue('0') const payload = { - event_config: { - event_name: 'Purchase', - show_fields: false - }, + event_name: 'Purchase', + show_fields: false, content_ids: ['product-123'], value: 99.99, userData: { @@ -345,24 +315,18 @@ describe('Facebook Conversions API Web - Init with User Data', () => { await send(mockFbq, mockClientParamBuilder, payload, defaultSettings, mockAnalytics) // FBP and FBC should be trimmed but NOT hashed - expect(mockFbq).toHaveBeenCalledWith( - 'init', - 'test-pixel-123', - { - fbp: 'fb.1.1234567890.1234567890', - fbc: 'fb.1.1234567890.AbCdEf123' - } - ) + expect(mockFbq).toHaveBeenCalledWith('init', 'test-pixel-123', { + fbp: 'fb.1.1234567890.1234567890', + fbc: 'fb.1.1234567890.AbCdEf123' + }) }) it('should format userData with all fields combined', async () => { mockAnalytics.storage.get.mockReturnValue('0') const payload = { - event_config: { - event_name: 'Purchase', - show_fields: false - }, + event_name: 'Purchase', + show_fields: false, content_ids: ['product-123'], value: 99.99, userData: { @@ -385,35 +349,29 @@ describe('Facebook Conversions API Web - Init with User Data', () => { await send(mockFbq, mockClientParamBuilder, payload, defaultSettings, mockAnalytics) // All PII fields should be normalized and hashed; fbc/fbp should NOT be hashed - expect(mockFbq).toHaveBeenCalledWith( - 'init', - 'test-pixel-123', - { - external_id: 'fcdec6df4d44dbc637c7c5b58efface52a7f8a88535423430255be0bb89bedd8', - em: '973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b', - ph: '3c95277da5fd0da6a1a44ee3fdf56d20af6c6d242695a40e18e6e90dc3c5872c', - fn: '96d9632f363564cc3032521409cf22a852f2032eec099ed5967c0d000cec607a', - ln: '799ef92a11af918e3fb741df42934f3b568ed2d93ac1df74f1b8d41a27932a6f', - ge: '62c66a7a5dd70c3146618063c344e531e6d4b59e379808443ce962b3abd63c5a', - db: '53058fbd6731774c37a6d838c09d25b337fa7b9b5007f82cc934d857d2596e0c', - ct: '1a6bd4d9d79dc0a79b53795c70d3349fa9e38968a3fbefbfe8783efb1d2b6aac', - st: '6959097001d10501ac7d54c0bdb8db61420f658f2922cc26e46d536119a31126', - zp: '8137c19c8f35f6b6a1cce99753226e1c7211eaaebd68528b789f973b0be95e31', - country: '79adb2a2fce5c6ba215fe5f27f532d4e7edbac4b6a5e09e1ef3a08084a904621', - fbp: 'fb.1.1234567890.1234567890', - fbc: 'fb.1.1234567890.AbCdEf123' - } - ) + expect(mockFbq).toHaveBeenCalledWith('init', 'test-pixel-123', { + external_id: 'fcdec6df4d44dbc637c7c5b58efface52a7f8a88535423430255be0bb89bedd8', + em: '973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b', + ph: '3c95277da5fd0da6a1a44ee3fdf56d20af6c6d242695a40e18e6e90dc3c5872c', + fn: '96d9632f363564cc3032521409cf22a852f2032eec099ed5967c0d000cec607a', + ln: '799ef92a11af918e3fb741df42934f3b568ed2d93ac1df74f1b8d41a27932a6f', + ge: '62c66a7a5dd70c3146618063c344e531e6d4b59e379808443ce962b3abd63c5a', + db: '53058fbd6731774c37a6d838c09d25b337fa7b9b5007f82cc934d857d2596e0c', + ct: '1a6bd4d9d79dc0a79b53795c70d3349fa9e38968a3fbefbfe8783efb1d2b6aac', + st: '6959097001d10501ac7d54c0bdb8db61420f658f2922cc26e46d536119a31126', + zp: '8137c19c8f35f6b6a1cce99753226e1c7211eaaebd68528b789f973b0be95e31', + country: '79adb2a2fce5c6ba215fe5f27f532d4e7edbac4b6a5e09e1ef3a08084a904621', + fbp: 'fb.1.1234567890.1234567890', + fbc: 'fb.1.1234567890.AbCdEf123' + }) }) it('should skip invalid gender values', async () => { mockAnalytics.storage.get.mockReturnValue('0') const payload = { - event_config: { - event_name: 'Purchase', - show_fields: false - }, + event_name: 'Purchase', + show_fields: false, content_ids: ['product-123'], value: 99.99, userData: { @@ -424,7 +382,7 @@ describe('Facebook Conversions API Web - Init with User Data', () => { await send(mockFbq, mockClientParamBuilder, payload, defaultSettings, mockAnalytics) // Should not call init if only invalid gender is provided - const initCalls = (mockFbq).mock.calls.filter((call) => call[0] === 'init') + const initCalls = mockFbq.mock.calls.filter((call) => call[0] === 'init') expect(initCalls.length).toBe(0) }) @@ -432,10 +390,8 @@ describe('Facebook Conversions API Web - Init with User Data', () => { mockAnalytics.storage.get.mockReturnValue('0') const payload = { - event_config: { - event_name: 'Purchase', - show_fields: false - }, + event_name: 'Purchase', + show_fields: false, content_ids: ['product-123'], value: 99.99, userData: { @@ -446,7 +402,7 @@ describe('Facebook Conversions API Web - Init with User Data', () => { await send(mockFbq, mockClientParamBuilder, payload, defaultSettings, mockAnalytics) // Should not call init if only invalid date is provided - const initCalls = (mockFbq).mock.calls.filter((call) => call[0] === 'init') + const initCalls = mockFbq.mock.calls.filter((call) => call[0] === 'init') expect(initCalls.length).toBe(0) }) }) @@ -456,10 +412,8 @@ describe('Facebook Conversions API Web - Init with User Data', () => { mockAnalytics.storage.get.mockReturnValue('2') const payload = { - event_config: { - event_name: 'Purchase', - show_fields: false - }, + event_name: 'Purchase', + show_fields: false, content_ids: ['product-123'], value: 99.99, userData: { @@ -470,7 +424,7 @@ describe('Facebook Conversions API Web - Init with User Data', () => { await send(mockFbq, mockClientParamBuilder, payload, defaultSettings, mockAnalytics) // Should not call init with user data when count is at max - const initCalls = (mockFbq).mock.calls.filter((call) => call[0] === 'init') + const initCalls = mockFbq.mock.calls.filter((call) => call[0] === 'init') expect(initCalls.length).toBe(0) }) @@ -490,10 +444,8 @@ describe('Facebook Conversions API Web - Init with User Data', () => { }) const payload = { - event_config: { - event_name: 'Purchase', - show_fields: false - }, + event_name: 'Purchase', + show_fields: false, content_ids: ['product-123'], value: 99.99, userData: { @@ -504,42 +456,40 @@ describe('Facebook Conversions API Web - Init with User Data', () => { // First track event - init should be called (count: 0 -> 1) await send(mockFbq, mockClientParamBuilder, payload, defaultSettings, mockAnalytics) - let initCalls = (mockFbq).mock.calls.filter((call) => call[0] === 'init') + let initCalls = mockFbq.mock.calls.filter((call) => call[0] === 'init') expect(initCalls.length).toBe(1) expect(currentInitCount).toBe(1) // Second track event - init should be called again (count: 1 -> 2) await send(mockFbq, mockClientParamBuilder, payload, defaultSettings, mockAnalytics) - initCalls = (mockFbq).mock.calls.filter((call) => call[0] === 'init') + initCalls = mockFbq.mock.calls.filter((call) => call[0] === 'init') expect(initCalls.length).toBe(2) expect(currentInitCount).toBe(2) // Third track event - init should NOT be called (count stays at 2) await send(mockFbq, mockClientParamBuilder, payload, defaultSettings, mockAnalytics) - initCalls = (mockFbq).mock.calls.filter((call) => call[0] === 'init') + initCalls = mockFbq.mock.calls.filter((call) => call[0] === 'init') expect(initCalls.length).toBe(2) // Still only 2 init calls expect(currentInitCount).toBe(2) // Count remains at max // Fourth track event - init should still NOT be called await send(mockFbq, mockClientParamBuilder, payload, defaultSettings, mockAnalytics) - initCalls = (mockFbq).mock.calls.filter((call) => call[0] === 'init') + initCalls = mockFbq.mock.calls.filter((call) => call[0] === 'init') expect(initCalls.length).toBe(2) // Still only 2 init calls expect(currentInitCount).toBe(2) // Verify user data storage was still updated on all calls - const userDataSetCalls = (mockAnalytics.storage.set).mock.calls.filter((call) => call[0] === 'fb_user_data') + const userDataSetCalls = mockAnalytics.storage.set.mock.calls.filter((call) => call[0] === 'fb_user_data') expect(userDataSetCalls.length).toBe(4) // User data stored on all 4 track events }) it('should allow init to fire when count is reset to 0 (simulating new page load)', async () => { const payload = { - event_config: { - event_name: 'Purchase', - show_fields: false - }, + event_name: 'Purchase', + show_fields: false, content_ids: ['product-123'], value: 99.99, userData: { @@ -553,7 +503,7 @@ describe('Facebook Conversions API Web - Init with User Data', () => { await send(mockFbq, mockClientParamBuilder, payload, defaultSettings, mockAnalytics) - let initCalls = (mockFbq).mock.calls.filter((call) => call[0] === 'init') + let initCalls = mockFbq.mock.calls.filter((call) => call[0] === 'init') expect(initCalls.length).toBe(0) // Init blocked when count at max // === New Page Load Scenario === @@ -565,11 +515,15 @@ describe('Facebook Conversions API Web - Init with User Data', () => { // First track event on new page await send(mockFbq, mockClientParamBuilder, payload, defaultSettings, mockAnalytics) - initCalls = (mockFbq).mock.calls.filter((call) => call[0] === 'init') + initCalls = mockFbq.mock.calls.filter((call) => call[0] === 'init') expect(initCalls.length).toBe(1) // Init fires when count is reset - expect(mockFbq).toHaveBeenCalledWith('init', 'test-pixel-123', expect.objectContaining({ - em: expect.any(String) - })) + expect(mockFbq).toHaveBeenCalledWith( + 'init', + 'test-pixel-123', + expect.objectContaining({ + em: expect.any(String) + }) + ) // Verify init count was incremented in storage expect(mockAnalytics.storage.set).toHaveBeenCalledWith('fb_pixel_init_count', '1') diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/depends-on.ts b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/depends-on.ts index 1b4f769aee..3e91fd8ac6 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/depends-on.ts +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/depends-on.ts @@ -2,51 +2,83 @@ import { DependsOnConditions, Condition } from '@segment/actions-core/destinatio import type { FBStandardEventType, FBNonStandardEventType } from '../types' export const fieldDependencies: Record = { - custom_event_name: ['CustomEvent'], - content_category: ['PageView', 'ViewContent', 'Search'], - content_ids: ['AddPaymentInfo','AddToCart','AddToWishlist','InitiateCheckout','Purchase','Search','ViewContent'], - content_name: ['PageView', 'ViewContent', 'Search'], - content_type: ['AddToCart', 'Purchase', 'Search', 'ViewContent'], - contents: ['AddPaymentInfo', 'AddToCart', 'AddToWishlist', 'InitiateCheckout', 'Purchase', 'Search', 'ViewContent'], - currency: ['AddPaymentInfo', 'AddToCart', 'AddToWishlist', 'CompleteRegistration', 'InitiateCheckout', 'Lead', 'Purchase', 'Search', 'StartTrial', 'Subscribe', 'ViewContent'], - delivery_category: ['Purchase', 'InitiateCheckout'], - num_items: ['InitiateCheckout'], - predicted_ltv: ['Purchase', 'Subscribe', 'StartTrial', 'CompleteRegistration', 'AddPaymentInfo', 'CustomEvent' ], - net_revenue: ['Purchase'], - search_string: ['Search'], - status: ['CompleteRegistration'], - value: ['AddPaymentInfo', 'AddToCart', 'AddToWishlist', 'CompleteRegistration', 'InitiateCheckout', 'Lead', 'Purchase', 'Search', 'StartTrial', 'Subscribe', 'ViewContent'], + custom_event_name: ['CustomEvent'], + content_category: ['PageView', 'ViewContent', 'Search'], + content_ids: [ + 'AddPaymentInfo', + 'AddToCart', + 'AddToWishlist', + 'InitiateCheckout', + 'Purchase', + 'Search', + 'ViewContent' + ], + content_name: ['PageView', 'ViewContent', 'Search'], + content_type: ['AddToCart', 'Purchase', 'Search', 'ViewContent'], + contents: ['AddPaymentInfo', 'AddToCart', 'AddToWishlist', 'InitiateCheckout', 'Purchase', 'Search', 'ViewContent'], + currency: [ + 'AddPaymentInfo', + 'AddToCart', + 'AddToWishlist', + 'CompleteRegistration', + 'InitiateCheckout', + 'Lead', + 'Purchase', + 'Search', + 'StartTrial', + 'Subscribe', + 'ViewContent' + ], + delivery_category: ['Purchase', 'InitiateCheckout'], + num_items: ['InitiateCheckout'], + predicted_ltv: ['Purchase', 'Subscribe', 'StartTrial', 'CompleteRegistration', 'AddPaymentInfo', 'CustomEvent'], + net_revenue: ['Purchase'], + search_string: ['Search'], + status: ['CompleteRegistration'], + value: [ + 'AddPaymentInfo', + 'AddToCart', + 'AddToWishlist', + 'CompleteRegistration', + 'InitiateCheckout', + 'Lead', + 'Purchase', + 'Search', + 'StartTrial', + 'Subscribe', + 'ViewContent' + ] } export function getDependenciesFor(fieldName: string): DependsOnConditions { - const conditions: Condition[] = [ - { - fieldKey: 'event_config.show_fields', - operator: 'is', - value: 'true' - } - ] - - const dependencies = fieldDependencies[fieldName] - - if(Array.isArray(dependencies) && dependencies.length > 1) { - dependencies.forEach(dep => { - conditions.push({ - fieldKey: 'event_config.event_name', - operator: 'is', - value: dep - }) - }) - } - - return { - match: 'any', - conditions + const conditions: Condition[] = [ + { + fieldKey: 'show_fields', + operator: 'is', + value: true } + ] + + const dependencies = fieldDependencies[fieldName] + + if (Array.isArray(dependencies) && dependencies.length >= 1) { + dependencies.forEach((dep) => { + conditions.push({ + fieldKey: 'event_name', + operator: 'is', + value: dep + }) + }) + } + + return { + match: 'any', + conditions + } } export function getNotVisibleForEvent(event: FBStandardEventType | FBNonStandardEventType): string[] { - return Object.entries(fieldDependencies) - .filter(([_, events]) => !events.includes(event)) - .map(([fieldName]) => fieldName) -} \ No newline at end of file + return Object.entries(fieldDependencies) + .filter(([_, events]) => !events.includes(event)) + .map(([fieldName]) => fieldName) +} 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 77405464c3..8dfa736c9e 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 @@ -2,59 +2,52 @@ import type { InputField } from '@segment/actions-core' import { getDependenciesFor } from './depends-on' import { CURRENCY_ISO_CODES } from '../constants' -export const event_config: InputField = { - label: 'Event Configuration', - description: 'Specify the type of Facebook Conversions API event to send.', - type: 'object', +export const event_name: InputField = { + label: 'Event Name', + description: "Facebook Conversions API Event Name to send. Select 'Custom Event' to send a non standard event.", + type: 'string', required: true, - additionalProperties: false, - defaultObjectUI: 'keyvalue', - properties: { - event_name: { - label: 'Event Name', - description: "Facebook Conversions API Event Name to send. Select 'Custom Event' to send a non standard event.", - type: 'string', - required: true, - choices: [ - { label: 'Custom Event', value: 'CustomEvent' }, - { label: 'Page View', value: 'PageView' }, - { label: 'Add Payment Info', value: 'AddPaymentInfo' }, - { label: 'Add To Cart', value: 'AddToCart' }, - { label: 'Add To Wishlist', value: 'AddToWishlist' }, - { label: 'Complete Registration', value: 'CompleteRegistration' }, - { label: 'Contact', value: 'Contact' }, - { label: 'Customize Product', value: 'CustomizeProduct' }, - { label: 'Donate', value: 'Donate' }, - { label: 'Find Location', value: 'FindLocation' }, - { label: 'Initiate Checkout', value: 'InitiateCheckout' }, - { label: 'Lead', value: 'Lead' }, - { label: 'Purchase', value: 'Purchase' }, - { label: 'Schedule', value: 'Schedule' }, - { label: 'Search', value: 'Search' }, - { label: 'Start Trial', value: 'StartTrial' }, - { label: 'Submit Application', value: 'SubmitApplication' }, - { label: 'Subscribe', value: 'Subscribe' }, - { label: 'View Content', value: 'ViewContent' } - ] - }, - custom_event_name: { - label: 'Custom Event Name', - description: 'Custom event name to send to Facebook', - type: 'string', - depends_on: getDependenciesFor('custom_event_name') - }, - show_fields: { - label: 'Show all fields', - description: 'Show all fields, even those which are not relevant to the selected Event Name.', - type: 'boolean', - default: false - } + choices: [ + { label: 'Custom Event', value: 'CustomEvent' }, + { label: 'Page View', value: 'PageView' }, + { label: 'Add Payment Info', value: 'AddPaymentInfo' }, + { label: 'Add To Cart', value: 'AddToCart' }, + { label: 'Add To Wishlist', value: 'AddToWishlist' }, + { label: 'Complete Registration', value: 'CompleteRegistration' }, + { label: 'Contact', value: 'Contact' }, + { label: 'Customize Product', value: 'CustomizeProduct' }, + { label: 'Donate', value: 'Donate' }, + { label: 'Find Location', value: 'FindLocation' }, + { label: 'Initiate Checkout', value: 'InitiateCheckout' }, + { label: 'Lead', value: 'Lead' }, + { label: 'Purchase', value: 'Purchase' }, + { label: 'Schedule', value: 'Schedule' }, + { label: 'Search', value: 'Search' }, + { label: 'Start Trial', value: 'StartTrial' }, + { label: 'Submit Application', value: 'SubmitApplication' }, + { label: 'Subscribe', value: 'Subscribe' }, + { label: 'View Content', value: 'ViewContent' } + ], + default: 'CustomEvent' +} + +export const custom_event_name: InputField = { + label: 'Custom Event Name', + description: 'Custom event name to send to Facebook', + type: 'string', + depends_on: getDependenciesFor('custom_event_name'), + required: { + match: 'all', + conditions: [{ fieldKey: 'event_name', operator: 'is', value: 'CustomEvent' }] }, - default: { - event_name: 'CustomEvent', - custom_event_name: { '@path': '$.event' }, - show_fields: false - } + default: { '@path': '$.event' } +} + +export const show_fields: InputField = { + label: 'Show all fields', + description: 'Show all fields, even those which are not relevant to the selected Event Name.', + type: 'boolean', + default: false } export const content_category: InputField = { @@ -151,7 +144,7 @@ export const currency: InputField = { match: 'all', conditions: [ { - fieldKey: 'event_config.event_name', + fieldKey: 'event_name', operator: 'is', value: 'Purchase' } @@ -224,7 +217,7 @@ export const value: InputField = { match: 'all', conditions: [ { - fieldKey: 'event_config.event_name', + fieldKey: 'event_name', operator: 'is', value: 'Purchase' } @@ -361,7 +354,9 @@ export const userData: InputField = { } export const AllFields = { - event_config, + event_name, + custom_event_name, + show_fields, content_category, content_ids, content_name, diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/functions.ts b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/functions.ts index d8219fc3a1..225e0c49ae 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/functions.ts +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/functions.ts @@ -31,7 +31,7 @@ export async function send( analytics: Analytics ) { const { pixelId } = settings - const { event_config: { custom_event_name, event_name } = {} } = payload + const { custom_event_name, event_name } = payload const isCustom = event_name === 'CustomEvent' ? true : false @@ -56,11 +56,7 @@ export async function send( } function validate(payload: Payload): string | undefined { - const { - event_config: { event_name }, - content_ids, - contents - } = payload + const { event_name, content_ids, contents } = payload if (['AddToCart', 'Purchase', 'ViewContent'].includes(event_name)) { if ( @@ -112,7 +108,8 @@ export function formatFBEvent(payload: Payload): FBEvent { predicted_ltv, net_revenue, custom_data, - event_config: { event_name, show_fields } = {} + event_name, + show_fields } = payload const contentIdsArr = trimmedArray(toArray(content_ids)) @@ -177,10 +174,10 @@ async function maybeSendUserData( if (userDataFormatted) { /* - Facebook indicated that init should only trigger on a single page load up to max 2 times. - When userData is created it gets added to storage and included in the next init call on page load. - Facebook also advised to always send userData when it's available, even if it was collected via previous events. - */ + Facebook indicated that init should only trigger on a single page load up to max 2 times. + When userData is created it gets added to storage and included in the next init call on page load. + Facebook also advised to always send userData when it's available, even if it was collected via previous events. + */ const storage = (analytics.storage as UniversalStorage>) ?? storageFallback const initCountFromStorage: string | null = storage.get(INIT_COUNT_KEY) const initCount: number | undefined = 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 7106763353..5c93c66a8a 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 @@ -2,22 +2,17 @@ export interface Payload { /** - * Specify the type of Facebook Conversions API event to send. + * Facebook Conversions API Event Name to send. Select 'Custom Event' to send a non standard event. */ - event_config: { - /** - * Facebook Conversions API Event Name to send. Select 'Custom Event' to send a non standard event. - */ - event_name: string - /** - * Custom event name to send to Facebook - */ - custom_event_name?: string - /** - * Show all fields, even those which are not relevant to the selected Event Name. - */ - show_fields?: boolean - } + event_name: string + /** + * Custom event name to send to Facebook + */ + custom_event_name?: string + /** + * Show all fields, even those which are not relevant to the selected Event Name. + */ + show_fields?: boolean /** * The category of the content associated with the event. */ From bfae20dea92c9c43b4f2e3536aa6d9e8824fe494 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 5 Aug 2026 12:11:31 +0100 Subject: [PATCH 08/14] updating to correct script file provided by FB --- .../destinations/facebook-conversions-api-web/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 8b7483fa51..1a39055dea 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 @@ -94,7 +94,7 @@ export const destination: BrowserDestinationDefinition< initScript(settings, analytics) await deps.resolveWhen(() => typeof window.fbq === 'function', 100) if (formatUserDataWithParamBuilder) { - const script = `https://capi-automation.s3.us-east-2.amazonaws.com/public/client_js/capiParamBuilder/clientParamBuilder.bundle.js` + const script = `https://unpkg.com/meta-capi-param-builder-clientjs/dist/clientParamBuilder.bundle.js` await deps.loadScript(script) await deps.resolveWhen(() => typeof window.clientParamBuilder === 'object', 100) } From 8ff7e9d46970bbdeefc87a0ba8438745cde1a72f Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 5 Aug 2026 12:37:26 +0100 Subject: [PATCH 09/14] Normalize word-form gender values in FB Pixel Web gender only accepted exact 'm'/'f', so a common trait like 'male' or 'female' was dropped on the local (non-param-builder) hashing path. Normalize male/female (any case) to m/f before hashing, and widen the gender field choices to match. Added a test covering word-form normalization and drop of unrecognized values. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../facebook-conversions-api-web/metadata.json | 14 +++++++++++--- .../src/send/__tests__/formatUserData.test.ts | 18 ++++++++++++++++++ .../src/send/fields.ts | 9 ++++++--- .../src/send/functions.ts | 7 ++++++- 4 files changed, 41 insertions(+), 7 deletions(-) diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/metadata.json b/packages/browser-destinations/destinations/facebook-conversions-api-web/metadata.json index f5ff8cacd3..502dc00819 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/metadata.json +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/metadata.json @@ -2161,7 +2161,7 @@ }, "ge": { "label": "Gender", - "description": "Gender of the user. If unknown leave blank.", + "description": "Gender of the user. acceptable values are \"m\" or \"male\" for male and \"f\" or \"female\" for female. If unknown leave blank.", "type": "string", "required": false, "multiple": false, @@ -2170,12 +2170,20 @@ "default": null, "choices": [ { - "label": "Male", + "label": "m", "value": "m" }, { - "label": "Female", + "label": "f", "value": "f" + }, + { + "label": "male", + "value": "male" + }, + { + "label": "female", + "value": "female" } ], "placeholder": null, diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/formatUserData.test.ts b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/formatUserData.test.ts index 34073cd7f1..fd8e7eb703 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/formatUserData.test.ts +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/formatUserData.test.ts @@ -61,6 +61,24 @@ describe('formatUserData', () => { }) }) + it('should normalize word-form gender before hashing', async () => { + // 'Female' should normalize to 'f' -> sha256('f') + const result = await formatUserData({ ge: 'Female' } as Payload['userData'], undefined) + expect(result).toEqual({ + ge: '252f10c83610ebca1a059c0bae8255eba2f95be4d1d7bcfa89d7248a82d9f111' + }) + + // 'male' should normalize to 'm' -> sha256('m') + const male = await formatUserData({ ge: 'male' } as Payload['userData'], undefined) + expect(male).toEqual({ + ge: '62c66a7a5dd70c3146618063c344e531e6d4b59e379808443ce962b3abd63c5a' + }) + + // an unrecognized value is dropped + const unknown = await formatUserData({ ge: 'nonbinary' } as Payload['userData'], undefined) + expect(unknown).toBeUndefined() + }) + it('should format date of birth as YYYYMMDD and hash', async () => { const userData: Payload['userData'] = { db: '1990-05-15T00:00:00.000Z' 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 8dfa736c9e..d16a090244 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 @@ -286,11 +286,14 @@ export const userData: InputField = { }, ge: { label: 'Gender', - description: 'Gender of the user. If unknown leave blank.', + description: + 'Gender of the user. acceptable values are "m" or "male" for male and "f" or "female" for female. If unknown leave blank.', type: 'string', choices: [ - { label: 'Male', value: 'm' }, - { label: 'Female', value: 'f' } + { label: 'm', value: 'm' }, + { label: 'f', value: 'f' }, + { label: 'male', value: 'male' }, + { label: 'female', value: 'female' } ] }, db: { diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/functions.ts b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/functions.ts index 225e0c49ae..717c7ab4f1 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/functions.ts +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/functions.ts @@ -217,7 +217,12 @@ export async function formatUserData( formatPII(ph, 'phone', 'ph', clientParamBuilder, (s) => s.replace(/\D/g, '')), formatPII(fn, 'first_name', 'fn', clientParamBuilder, (s) => s.toLowerCase().trim()), formatPII(ln, 'last_name', 'ln', clientParamBuilder, (s) => s.toLowerCase().trim()), - formatPII(ge, 'gender', 'ge', clientParamBuilder, (s) => (['m', 'f'].includes(s) ? s : undefined)), + formatPII(ge, 'gender', 'ge', clientParamBuilder, (s) => { + const g = s.toLowerCase().trim() + if (g === 'm' || g === 'male') return 'm' + if (g === 'f' || g === 'female') return 'f' + return undefined + }), formatPII(db, 'date_of_birth', 'db', clientParamBuilder, (s) => formatDate(s)), formatPII(ct, 'city', 'ct', clientParamBuilder, (s) => s.toLowerCase().replace(/\s+/g, '')), formatPII(st, 'state', 'st', clientParamBuilder, (s) => fromMap(US_STATE_CODES, s)), From aac4c07e292324a199cfc427e52995cfab180079 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 5 Aug 2026 12:39:21 +0100 Subject: [PATCH 10/14] updating types --- .../facebook-conversions-api-web/src/send/generated-types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 5c93c66a8a..227db3f5b5 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 @@ -118,7 +118,7 @@ export interface Payload { */ ln?: string /** - * Gender of the user. If unknown leave blank. + * Gender of the user. acceptable values are "m" or "male" for male and "f" or "female" for female. If unknown leave blank. */ ge?: string /** From d7dd1663a0f3c91d6672d81a5fe99d549a6fd4b6 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 5 Aug 2026 12:50:26 +0100 Subject: [PATCH 11/14] minor bug fix --- .../src/send/__tests__/depends-on.test.ts | 6 ++++-- .../facebook-conversions-api-web/src/send/depends-on.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/depends-on.test.ts b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/depends-on.test.ts index 5806698f10..34cbd5f250 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/depends-on.test.ts +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/depends-on.test.ts @@ -24,11 +24,13 @@ describe('depends-on getDependenciesFor', () => { }) test('single-event field still includes its event condition', () => { - const result = getDependenciesFor('num_items') + // status is relevant to exactly one event (CompleteRegistration); it must + // still gate on that event, not only on show_fields. + const result = getDependenciesFor('status') expect(result.match).toBe('any') expect(result.conditions).toEqual([ { fieldKey: 'show_fields', operator: 'is', value: true }, - { fieldKey: 'event_name', operator: 'is', value: 'InitiateCheckout' } + { fieldKey: 'event_name', operator: 'is', value: 'CompleteRegistration' } ]) }) diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/depends-on.ts b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/depends-on.ts index 3e91fd8ac6..6c57bbb86d 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/depends-on.ts +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/depends-on.ts @@ -30,7 +30,7 @@ export const fieldDependencies: Record Date: Wed, 5 Aug 2026 12:57:26 +0100 Subject: [PATCH 12/14] Align FB Pixel field-event mappings with Meta docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - num_items: add Purchase (Pixel reference lists num_items for Purchase). - content_category / content_name: allow on all events (removed from the event-dependency map and dropped their depends_on) — Meta documents them as general object properties without a per-event list. - predicted_ltv: unchanged; confirmed correct (Purchase, Subscribe, StartTrial, CompleteRegistration, AddPaymentInfo, CustomEvent) against the pLTV value-optimization guide. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../metadata.json | 57 +++---------------- .../src/send/depends-on.ts | 2 - .../src/send/fields.ts | 6 +- 3 files changed, 9 insertions(+), 56 deletions(-) diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/metadata.json b/packages/browser-destinations/destinations/facebook-conversions-api-web/metadata.json index 502dc00819..f20b60bceb 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/metadata.json +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/metadata.json @@ -345,31 +345,7 @@ "placeholder": null, "properties": null, "category": null, - "depends_on": { - "match": "any", - "conditions": [ - { - "fieldKey": "show_fields", - "operator": "is", - "value": true - }, - { - "fieldKey": "event_name", - "operator": "is", - "value": "PageView" - }, - { - "fieldKey": "event_name", - "operator": "is", - "value": "ViewContent" - }, - { - "fieldKey": "event_name", - "operator": "is", - "value": "Search" - } - ] - }, + "depends_on": null, "readOnly": null, "hidden": null, "minimum": null, @@ -463,31 +439,7 @@ "placeholder": null, "properties": null, "category": null, - "depends_on": { - "match": "any", - "conditions": [ - { - "fieldKey": "show_fields", - "operator": "is", - "value": true - }, - { - "fieldKey": "event_name", - "operator": "is", - "value": "PageView" - }, - { - "fieldKey": "event_name", - "operator": "is", - "value": "ViewContent" - }, - { - "fieldKey": "event_name", - "operator": "is", - "value": "Search" - } - ] - }, + "depends_on": null, "readOnly": null, "hidden": null, "minimum": null, @@ -1615,6 +1567,11 @@ "fieldKey": "event_name", "operator": "is", "value": "InitiateCheckout" + }, + { + "fieldKey": "event_name", + "operator": "is", + "value": "Purchase" } ] }, diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/depends-on.ts b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/depends-on.ts index 6c57bbb86d..5f8524c3c8 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/depends-on.ts +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/depends-on.ts @@ -3,7 +3,6 @@ import type { FBStandardEventType, FBNonStandardEventType } from '../types' export const fieldDependencies: Record = { custom_event_name: ['CustomEvent'], - content_category: ['PageView', 'ViewContent', 'Search'], content_ids: [ 'AddPaymentInfo', 'AddToCart', @@ -13,7 +12,6 @@ export const fieldDependencies: Record Date: Wed, 5 Aug 2026 13:28:54 +0100 Subject: [PATCH 13/14] Trim string values inside contents items in FB Pixel Web content_ids entries were trimmed but the id (and any other string field) inside contents objects was not, so a single-product event could send an untrimmed id in contents while content_ids was trimmed. trimContents now trims every string value in each contents item. Added a test. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/send/__tests__/formatFBEvent.test.ts | 19 +++++++++++++++++++ .../src/send/functions.ts | 16 +++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/formatFBEvent.test.ts b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/formatFBEvent.test.ts index c34d26154e..424a827a0a 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/formatFBEvent.test.ts +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/formatFBEvent.test.ts @@ -45,6 +45,25 @@ describe('formatFBEvent', () => { expect(result).not.toHaveProperty('currency') }) + it('should trim string values inside contents items', () => { + const payload = { + event_name: 'AddToCart', + show_fields: true, + contents: [ + { id: ' SKU-ABC-123 ', quantity: 2, item_price: 19.99 }, + { id: 'SKU-XYZ-789 ', quantity: 1 } + ] + } as unknown as Payload + + const result = formatFBEvent(payload) + + // string id trimmed; numeric quantity/item_price untouched + expect(result.contents).toEqual([ + { id: 'SKU-ABC-123', quantity: 2, item_price: 19.99 }, + { id: 'SKU-XYZ-789', quantity: 1 } + ]) + }) + it('should format a complete Purchase event with all fields', () => { const payload: Partial = { event_name: 'Purchase', diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/functions.ts b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/functions.ts index 717c7ab4f1..ac32aa1251 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/functions.ts +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/functions.ts @@ -92,6 +92,20 @@ function normalizeCurrency(value: unknown): string | undefined { return CURRENCY_ISO_CODES.has(upper) ? upper : undefined } +function trimContents(contents: Payload['contents']): NonNullable { + return (contents ?? []).map((item) => { + if (!item || typeof item !== 'object') return item + const trimmedItem = { ...item } + for (const key of Object.keys(trimmedItem)) { + const v = trimmedItem[key] + if (typeof v === 'string') { + trimmedItem[key] = v.trim() + } + } + return trimmedItem + }) +} + export function formatFBEvent(payload: Payload): FBEvent { const { content_category, @@ -113,7 +127,7 @@ export function formatFBEvent(payload: Payload): FBEvent { } = payload const contentIdsArr = trimmedArray(toArray(content_ids)) - const contentsArr = toArray(contents) + const contentsArr = trimContents(toArray(contents)) const contentCategory = trimmed(content_category) const contentName = trimmed(content_name) const contentType = trimmed(content_type) From b11ee2398b15f360473a9ee791f752badd6f2601 Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Wed, 5 Aug 2026 14:09:21 +0100 Subject: [PATCH 14/14] Harden validate(): whitespace content_ids + custom_event_name required - content_ids/contents are now trimmed and coerced to arrays in validate() (mirroring formatFBEvent), so a whitespace-only content_ids no longer passes validation and then gets dropped, sending an event with neither identifier. - Require a non-empty custom_event_name when event_name is CustomEvent, preventing fbq('trackSingleCustom', ..., undefined). Added tests for both. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/send/__tests__/functions.test.ts | 54 +++++++++++++++++++ .../src/send/functions.ts | 13 +++-- 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/functions.test.ts b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/functions.test.ts index 0f193afd4e..971b3a68e6 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/functions.test.ts +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/__tests__/functions.test.ts @@ -342,6 +342,60 @@ describe('Facebook Conversions API Web - Send Functions', () => { expect(consoleWarnSpy).not.toHaveBeenCalled() expect(mockFbq).toHaveBeenCalled() }) + + it('should warn if AddToCart content_ids is only whitespace', async () => { + // Whitespace-only content_ids is dropped by formatFBEvent, so validation + // must treat it as absent rather than letting the event through empty. + const payload = { + event_name: 'AddToCart', + show_fields: false, + content_ids: [' '], + value: 99.99 + } + + await send(mockFbq, mockClientParamBuilder, payload, defaultSettings, mockAnalytics) + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('At least one of content_ids or contents is required for the AddToCart event') + ) + expect(mockFbq).not.toHaveBeenCalled() + }) + + it('should warn if CustomEvent has no custom_event_name', async () => { + const payload = { + event_name: 'CustomEvent', + custom_event_name: ' ', + show_fields: false, + value: 10 + } + + await send(mockFbq, mockClientParamBuilder, payload, defaultSettings, mockAnalytics) + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('custom_event_name is required when event_name is CustomEvent') + ) + expect(mockFbq).not.toHaveBeenCalled() + }) + + it('should send CustomEvent when custom_event_name is provided', async () => { + const payload = { + event_name: 'CustomEvent', + custom_event_name: 'MyCustomEvent', + show_fields: false, + value: 10 + } + + await send(mockFbq, mockClientParamBuilder, payload, defaultSettings, mockAnalytics) + + expect(consoleWarnSpy).not.toHaveBeenCalled() + expect(mockFbq).toHaveBeenCalledWith( + 'trackSingleCustom', + 'test-pixel-123', + 'MyCustomEvent', + expect.any(Object), + undefined + ) + }) }) describe('send - Event Options', () => { diff --git a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/functions.ts b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/functions.ts index ac32aa1251..e5ff46692b 100644 --- a/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/functions.ts +++ b/packages/browser-destinations/destinations/facebook-conversions-api-web/src/send/functions.ts @@ -56,13 +56,16 @@ export async function send( } function validate(payload: Payload): string | undefined { - const { event_name, content_ids, contents } = payload + const { event_name, custom_event_name, content_ids, contents } = payload + + if (event_name === 'CustomEvent' && !trimmed(custom_event_name)) { + return `custom_event_name is required when event_name is CustomEvent.` + } if (['AddToCart', 'Purchase', 'ViewContent'].includes(event_name)) { - if ( - (!content_ids || (Array.isArray(content_ids) && content_ids.length === 0)) && - (!contents || (Array.isArray(contents) && contents.length === 0)) - ) { + const contentIds = trimmedArray(toArray(content_ids)) + const contentsArr = trimContents(toArray(contents)) + if (contentIds.length === 0 && contentsArr.length === 0) { return `At least one of content_ids or contents is required for the ${event_name} event.` } }