diff --git a/.changeset/wild-ravens-swim.md b/.changeset/wild-ravens-swim.md new file mode 100644 index 00000000000..706426a8f90 --- /dev/null +++ b/.changeset/wild-ravens-swim.md @@ -0,0 +1,14 @@ +--- +"@builder.io/react": patch +"@builder.io/sdk": patch +"@builder.io/sdk-angular": patch +"@builder.io/sdk-react-nextjs": patch +"@builder.io/sdk-qwik": patch +"@builder.io/sdk-react": patch +"@builder.io/sdk-react-native": patch +"@builder.io/sdk-solid": patch +"@builder.io/sdk-svelte": patch +"@builder.io/sdk-vue": patch +--- + +fix: handle conversion tracking for gen1 and gen2 sdks diff --git a/package.json b/package.json index de785be691a..9b50aa40be3 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ "lint:fix": "prettier --write '**/*.{js,jsx,ts,tsx}'", "update-npm-dependency": "zx ./scripts/update-npm-dependency.mjs", "g:changeset": "changeset", - "g:nx": "cd $INIT_CWD && nx" + "g:nx": "cd $INIT_CWD && nx", + "watch:sdk": "npx watch \"yarn g:nx build $0\" packages/sdks/src packages/sdks/overrides" }, "engines": { "yarn": ">= 3.0.0" diff --git a/packages/core/src/builder.class.test.ts b/packages/core/src/builder.class.test.ts index 6c65b362fb1..2b6a3e434e5 100644 --- a/packages/core/src/builder.class.test.ts +++ b/packages/core/src/builder.class.test.ts @@ -1,4 +1,4 @@ -import { Builder, GetContentOptions } from './builder.class'; +import { Builder } from './builder.class'; import { BehaviorSubject } from './classes/observable.class'; import { BuilderContent } from './types/content'; @@ -1293,3 +1293,382 @@ describe('getAll', () => { ); }); }); + +describe('Builder.trackConversion', () => { + let builder: Builder; + let mockTrack: jest.SpyInstance; + let mockGetTestCookie: jest.SpyInstance; + + beforeEach(() => { + // Reset Builder static properties + Builder.isPreviewing = false; + + // Create a fresh Builder instance + builder = new Builder('test-api-key'); + + // Mock the track method + mockTrack = jest.spyOn(builder, 'track').mockImplementation(() => {}); + + // Mock the getTestCookie method + mockGetTestCookie = jest.spyOn(builder as any, 'getTestCookie').mockReturnValue(undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('early return conditions', () => { + it('should return early when Builder.isPreviewing is true', () => { + Builder.isPreviewing = true; + + (builder.trackConversion as any)(100, 'content-123'); + + expect(mockTrack).not.toHaveBeenCalled(); + }); + }); + + describe('parameter handling', () => { + it('should call track with all parameters provided', () => { + const amount = 100; + const contentId = 'content-123'; + const variationId = 'variation-456'; + const customProperties = { product: 'shoes' }; + const context = { userId: '789' }; + + (builder.trackConversion as any)(amount, contentId, variationId, customProperties, context); + + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: 100, + contentId: 'content-123', + variationId: 'variation-456', + meta: { product: 'shoes' }, + }, + { userId: '789' } + ); + }); + + it('should handle minimal parameters', () => { + builder.trackConversion(); + + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: undefined, + contentId: undefined, + variationId: undefined, + meta: undefined, + }, + undefined + ); + }); + + it('should handle two-parameter overload (amount and customProperties)', () => { + const customProperties = { category: 'electronics' }; + + builder.trackConversion(75, customProperties); + + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: 75, + contentId: undefined, + variationId: undefined, + meta: { category: 'electronics' }, + }, + undefined + ); + }); + }); + + describe('contentId logic', () => { + it('should use string contentId when provided', () => { + builder.trackConversion(100, 'explicit-content-id'); + + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: 100, + contentId: 'explicit-content-id', + variationId: undefined, + meta: undefined, + }, + undefined + ); + }); + + it('should handle contentId as object (legacy format)', () => { + const metaObject = { product: 'laptop', brand: 'Apple' }; + + (builder.trackConversion as any)(1500, metaObject, 'variation-123'); + + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: 1500, + contentId: undefined, + variationId: undefined, // undefined because contentId is undefined + meta: metaObject, + }, + undefined + ); + }); + + it('should fallback to this.contentId when no contentId provided', () => { + builder.contentId = 'builder-instance-content-id'; + + builder.trackConversion(200); + + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: 200, + contentId: 'builder-instance-content-id', + variationId: undefined, + meta: undefined, + }, + undefined + ); + }); + + it('should prioritize explicit contentId over this.contentId', () => { + builder.contentId = 'builder-instance-content-id'; + + (builder.trackConversion as any)(300, 'explicit-content-id'); + + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: 300, + contentId: 'explicit-content-id', + variationId: undefined, + meta: undefined, + }, + undefined + ); + }); + }); + + describe('variationId logic', () => { + it('should use provided variationId', () => { + (builder.trackConversion as any)(100, 'content-123', 'explicit-variation-456'); + + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: 100, + contentId: 'content-123', + variationId: 'explicit-variation-456', + meta: undefined, + }, + undefined + ); + }); + + it('should get variationId from test cookie when not provided', () => { + mockGetTestCookie.mockReturnValue('cookie-variation-789'); + + (builder.trackConversion as any)(100, 'content-123'); + + expect(mockGetTestCookie).toHaveBeenCalledWith('content-123'); + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: 100, + contentId: 'content-123', + variationId: 'cookie-variation-789', + meta: undefined, + }, + undefined + ); + }); + + it('should not get variationId from cookie when contentId is undefined', () => { + mockGetTestCookie.mockReturnValue('cookie-variation-789'); + + builder.trackConversion(100); + + expect(mockGetTestCookie).not.toHaveBeenCalled(); + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: 100, + contentId: undefined, + variationId: undefined, + meta: undefined, + }, + undefined + ); + }); + + it('should set variationId to undefined when it equals contentId', () => { + (builder.trackConversion as any)(100, 'content-123', 'content-123'); + + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: 100, + contentId: 'content-123', + variationId: undefined, + meta: undefined, + }, + undefined + ); + }); + + it('should set variationId to undefined when contentId is undefined', () => { + (builder.trackConversion as any)(100, undefined, 'variation-456'); + + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: 100, + contentId: undefined, + variationId: undefined, + meta: undefined, + }, + undefined + ); + }); + + it('should prioritize explicit variationId over cookie value', () => { + mockGetTestCookie.mockReturnValue('cookie-variation-789'); + + (builder.trackConversion as any)(100, 'content-123', 'explicit-variation-456'); + + expect(mockGetTestCookie).not.toHaveBeenCalled(); + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: 100, + contentId: 'content-123', + variationId: 'explicit-variation-456', + meta: undefined, + }, + undefined + ); + }); + }); + + describe('meta handling', () => { + it('should use customProperties as meta when contentId is string', () => { + const customProperties = { source: 'email', campaign: 'summer2023' }; + + (builder.trackConversion as any)(100, 'content-123', 'variation-456', customProperties); + + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: 100, + contentId: 'content-123', + variationId: 'variation-456', + meta: customProperties, + }, + undefined + ); + }); + + it('should use contentId as meta when contentId is object', () => { + const metaObject = { product: 'subscription', tier: 'premium' }; + + builder.trackConversion(100, metaObject); + + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: 100, + contentId: undefined, + variationId: undefined, + meta: metaObject, + }, + undefined + ); + }); + + it('should handle undefined meta', () => { + (builder.trackConversion as any)(100, 'content-123'); + + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: 100, + contentId: 'content-123', + variationId: undefined, + meta: undefined, + }, + undefined + ); + }); + }); + + describe('complex scenarios', () => { + it('should handle contentId from instance with cookie variationId', () => { + builder.contentId = 'instance-content-456'; + mockGetTestCookie.mockReturnValue('cookie-variation-789'); + + (builder.trackConversion as any)(250, undefined, undefined, { source: 'organic' }); + + expect(mockGetTestCookie).toHaveBeenCalledWith('instance-content-456'); + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: 250, + contentId: 'instance-content-456', + variationId: 'cookie-variation-789', + meta: { source: 'organic' }, + }, + undefined + ); + }); + + it('should handle all edge cases together', () => { + // Object contentId, no variationId, with context + const metaObject = { product: 'service', type: 'consultation' }; + const context = { referrer: 'google.com' }; + + (builder.trackConversion as any)(500, metaObject, undefined, undefined, context); + + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: 500, + contentId: undefined, + variationId: undefined, + meta: metaObject, + }, + context + ); + }); + + it('should handle zero amount', () => { + builder.trackConversion(0, 'content-123'); + + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: 0, + contentId: 'content-123', + variationId: undefined, + meta: undefined, + }, + undefined + ); + }); + + it('should handle negative amount', () => { + builder.trackConversion(-50, 'content-123'); + + expect(mockTrack).toHaveBeenCalledWith( + 'conversion', + { + amount: -50, + contentId: 'content-123', + variationId: undefined, + meta: undefined, + }, + undefined + ); + }); + }); +}); diff --git a/packages/core/src/builder.class.ts b/packages/core/src/builder.class.ts index 007425b794c..4296c0a2391 100644 --- a/packages/core/src/builder.class.ts +++ b/packages/core/src/builder.class.ts @@ -1373,6 +1373,7 @@ export class Builder { private hasOverriddenCanTrack = false; private apiKey$ = new BehaviorSubject(null); private authToken$ = new BehaviorSubject(null); + private contentId$ = new BehaviorSubject(null); userAttributesChanged = new BehaviorSubject(null); @@ -1601,9 +1602,30 @@ export class Builder { return; } const meta = typeof contentId === 'object' ? contentId : customProperties; - const useContentId = typeof contentId === 'string' ? contentId : undefined; + let useContentId = typeof contentId === 'string' ? contentId : undefined; - this.track('conversion', { amount, variationId, meta, contentId: useContentId }, context); + if (!useContentId && this.contentId) { + useContentId = this.contentId; + } + + let useVariationId = variationId; + if (!useVariationId && useContentId) { + useVariationId = this.getTestCookie(useContentId); + } + + this.track( + 'conversion', + { + amount, + variationId: + useVariationId && useContentId && useVariationId !== useContentId + ? useVariationId + : undefined, + meta, + contentId: useContentId, + }, + context + ); } autoTrack = !Builder.isBrowser @@ -1708,6 +1730,14 @@ export class Builder { this.apiKey$.next(key); } + get contentId() { + return this.contentId$.value; + } + + set contentId(id: string | null) { + this.contentId$.next(id); + } + get authToken() { return this.authToken$.value; } diff --git a/packages/react/src/components/builder-component.component.tsx b/packages/react/src/components/builder-component.component.tsx index 72e3aacdd74..91c018b4b94 100644 --- a/packages/react/src/components/builder-component.component.tsx +++ b/packages/react/src/components/builder-component.component.tsx @@ -1374,6 +1374,9 @@ export class BuilderComponent extends React.Component< } onContentLoaded = (data: any, content: Content) => { + if (content && content.id) { + this.builder.contentId = content.id; + } if (this.name === 'page' && Builder.isBrowser) { if (data) { const { title, pageTitle, description, pageDescription } = data; diff --git a/packages/react/src/components/builder-content.component.tsx b/packages/react/src/components/builder-content.component.tsx index e17e940c363..e00180b1ff9 100644 --- a/packages/react/src/components/builder-content.component.tsx +++ b/packages/react/src/components/builder-content.component.tsx @@ -183,6 +183,7 @@ export class BuilderContent extends React.Comp const contentData = this.options.initialContent[0]; // TODO: intersectionobserver like in subscribetocontent - reuse the logic if (contentData?.id) { + this.builder.contentId = contentData.id; this.builder.trackImpression(contentData.id, this.renderedVariantId, undefined, { content: contentData, }); diff --git a/packages/sdks-tests/src/e2e-tests/ab-test.spec.ts b/packages/sdks-tests/src/e2e-tests/ab-test.spec.ts index 3886cce944a..179e961acc7 100644 --- a/packages/sdks-tests/src/e2e-tests/ab-test.spec.ts +++ b/packages/sdks-tests/src/e2e-tests/ab-test.spec.ts @@ -10,7 +10,7 @@ import { CONTENT as AB_TEST_CONTENT } from '../specs/ab-test.js'; const SELECTOR = 'div[builder-content-id]'; -const createContextWithCookies = async ({ +export const createContextWithCookies = async ({ cookies, baseURL, browser, @@ -37,7 +37,7 @@ const createContextWithCookies = async ({ return context; }; -const initializeAbTest = async ( +export const initializeAbTest = async ( { page: _page, baseURL, diff --git a/packages/sdks-tests/src/e2e-tests/track-conversion.spec.ts b/packages/sdks-tests/src/e2e-tests/track-conversion.spec.ts new file mode 100644 index 00000000000..6f759225bc5 --- /dev/null +++ b/packages/sdks-tests/src/e2e-tests/track-conversion.spec.ts @@ -0,0 +1,485 @@ +import type { Browser } from '@playwright/test'; +import { expect } from '@playwright/test'; +import { excludeGen1, excludeRn, isSSRFramework, test } from '../helpers/index.js'; +import { CONVERSION_SYMBOL_CONTENT } from '../specs/symbol-with-conversion.js'; +import { CONVERSION_SECTION_CONTENT } from '../specs/section-with-conversion.js'; + +export const createContextWithCookies = async ({ + cookies, + baseURL, + browser, +}: { + browser: Browser; + baseURL: string; + cookies: { name: string; value: string }[]; +}) => { + const context = await browser.newContext({ + storageState: { + cookies: cookies.map(cookie => { + const newCookie = { + name: cookie.name, + value: cookie.value, + // this is valid but types seem to be mismatched. + url: baseURL, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + return newCookie; + }), + origins: [], + }, + }); + return context; +}; + +export const initializeAbTest = async ( + { + page: _page, + baseURL, + packageName, + browser, + }: Pick< + Parameters[2]>[0], + 'page' | 'baseURL' | 'packageName' | 'browser' + >, + { cookieName, cookieValue }: { cookieName: string; cookieValue: string } +) => { + if (!baseURL) throw new Error('Missing baseURL'); + + test.skip(isSSRFramework(packageName)); + + /** + * This test is flaky on `nextjs-sdk-next-app` and `qwik-city`. Most likely because it is the very first test that runs. + */ + test.slow(packageName === 'nextjs-sdk-next-app' || packageName === 'qwik-city'); + + const context = await createContextWithCookies({ + baseURL, + browser, + cookies: [{ name: cookieName, value: cookieValue }], + }); + + const page = await context.newPage(); + + return { page }; +}; + +const COOKIE_NAME = 'builder.tests.test-content-id'; +const CONTENT_ID = 'test-content-id'; +const VARIANT_ID = 'test-variation-id'; + +test.describe('Track Conversion', () => { + test.describe('Basic conversion tracking', () => { + test('should track basic conversion with amount', async ({ + page, + sdk, + baseURL, + packageName, + browser, + }) => { + test.skip(excludeGen1(sdk) || excludeRn(sdk)); + + const { page: testPage } = await initializeAbTest( + { + page, + baseURL, + packageName, + browser, + }, + { cookieName: COOKIE_NAME, cookieValue: CONTENT_ID } + ); + + const trackingRequestPromise = testPage.waitForRequest( + request => + request.url().includes('cdn.builder.io/api/v1/track') && + request.method() === 'POST' && + request.postDataJSON().events[0].type === 'conversion', + { timeout: 10000 } + ); + + await testPage.goto('/track-conversion', { waitUntil: 'networkidle' }); + + // Click the basic conversion button + await testPage.click('text=Track conversion with amount'); + + const trackingRequest = await trackingRequestPromise; + const data = trackingRequest.postDataJSON(); + + expect(data.events).toHaveLength(1); + expect(data.events[0].type).toBe('conversion'); + expect(data.events[0].data.amount).toBe(100); + expect(data.events[0].data.contentId).toBe('test-content-id'); + }); + + test('should track basic conversion without amount', async ({ + page, + sdk, + baseURL, + packageName, + browser, + }) => { + test.skip(excludeGen1(sdk) || excludeRn(sdk)); + + const { page: testPage } = await initializeAbTest( + { + page, + baseURL, + packageName, + browser, + }, + { cookieName: COOKIE_NAME, cookieValue: CONTENT_ID } + ); + + const trackingRequestPromise = testPage.waitForRequest( + request => + request.url().includes('cdn.builder.io/api/v1/track') && + request.method() === 'POST' && + request.postDataJSON().events[0].type === 'conversion', + { timeout: 10000 } + ); + + await testPage.goto('/track-conversion', { waitUntil: 'networkidle' }); + + // Click the basic conversion button + await testPage.click('text=Track conversion without amount'); + + const trackingRequest = await trackingRequestPromise; + const data = trackingRequest.postDataJSON(); + + expect(data.events).toHaveLength(1); + expect(data.events[0].type).toBe('conversion'); + expect(data.events[0].data.amount).toBe(undefined); + expect(data.events[0].data.contentId).toBe('test-content-id'); + }); + + test('should track conversion with all parameters', async ({ + page, + sdk, + baseURL, + packageName, + browser, + }) => { + test.skip(excludeGen1(sdk) || excludeRn(sdk)); + + const { page: testPage } = await initializeAbTest( + { + page, + baseURL, + packageName, + browser, + }, + { cookieName: COOKIE_NAME, cookieValue: CONTENT_ID } + ); + + const trackingRequestPromise = testPage.waitForRequest( + request => + request.url().includes('cdn.builder.io/api/v1/track') && + request.method() === 'POST' && + request.postDataJSON().events[0].type === 'conversion', + { timeout: 10000 } + ); + + await testPage.goto('/track-conversion', { waitUntil: 'networkidle' }); + + // Click the full parameters conversion button + await testPage.click('text=Track Conversion with All Parameters'); + + const trackingRequest = await trackingRequestPromise; + const data = trackingRequest.postDataJSON(); + + expect(data.events).toHaveLength(1); + expect(data.events[0].type).toBe('conversion'); + expect(data.events[0].data.amount).toBe(100); + expect(data.events[0].data.contentId).toBe('test-content-id'); + expect(data.events[0].data.variationId).toBe('test-variation-id'); + expect(data.events[0].data.meta).toEqual({ + product: 'premium-shoes', + }); + expect(data.events[0].data.context).toEqual({ + userId: 'user-123', + }); + }); + }); + + test.describe('Conversion tracking with A/B tests', () => { + test('should track conversion with variation ID from cookie', async ({ + page, + browser, + packageName, + baseURL, + sdk, + }) => { + test.skip(excludeGen1(sdk) || excludeRn(sdk)); + + if (!baseURL) throw new Error('Missing baseURL'); + + // Create context with A/B test cookie + const { page: testPage } = await initializeAbTest( + { + page, + baseURL, + packageName, + browser, + }, + { cookieName: COOKIE_NAME, cookieValue: VARIANT_ID } + ); + + const trackingRequestPromise = testPage.waitForRequest( + request => + request.url().includes('cdn.builder.io/api/v1/track') && + request.method() === 'POST' && + request.postDataJSON().events[0].type === 'conversion', + { timeout: 10000 } + ); + + await testPage.goto('/track-conversion', { waitUntil: 'networkidle' }); + + // Click the basic conversion button + await testPage.click('text=Track Basic Conversion - Variant'); + + const trackingRequest = await trackingRequestPromise; + const data = trackingRequest.postDataJSON(); + + expect(data.events).toHaveLength(1); + expect(data.events[0].type).toBe('conversion'); + expect(data.events[0].data.amount).toBe(100); + expect(data.events[0].data.contentId).toBe('test-content-id'); + expect(data.events[0].data.variationId).toBe(VARIANT_ID); + + await testPage.context().close(); + }); + + test('should not set variationId when it equals contentId', async ({ + page, + baseURL, + sdk, + packageName, + browser, + }) => { + test.skip(excludeGen1(sdk) || excludeRn(sdk)); + + const { page: testPage } = await initializeAbTest( + { + page, + baseURL, + packageName, + browser, + }, + { cookieName: COOKIE_NAME, cookieValue: CONTENT_ID } + ); + + const trackingRequestPromise = testPage.waitForRequest( + request => + request.url().includes('cdn.builder.io/api/v1/track') && + request.method() === 'POST' && + request.postDataJSON().events[0].type === 'conversion', + { timeout: 10000 } + ); + + await testPage.goto('/track-conversion', { waitUntil: 'networkidle' }); + + // Click the basic conversion button + await testPage.click('text=Track Basic Conversion - Default'); + + const trackingRequest = await trackingRequestPromise; + const data = trackingRequest.postDataJSON(); + + expect(data.events).toHaveLength(1); + expect(data.events[0].type).toBe('conversion'); + expect(data.events[0].data.amount).toBe(100); + expect(data.events[0].data.contentId).toBe('test-content-id'); + expect(data.events[0].data.variationId).toBeUndefined(); + expect(data.events[0].data.ownerId).toMatch(/abcd/); + + await testPage.context().close(); + }); + }); + + test.describe('Symbol Conversion Tracking', () => { + test('should track conversion from symbol', async ({ + page, + sdk, + packageName, + baseURL, + browser, + }) => { + test.skip(excludeGen1(sdk) || excludeRn(sdk)); + test.skip(isSSRFramework(packageName)); + + const { page: testPage } = await initializeAbTest( + { + page, + baseURL, + packageName, + browser, + }, + { cookieName: COOKIE_NAME, cookieValue: CONTENT_ID } + ); + + let symbolRequestCount = 0; + + await testPage.route(/.*cdn\.builder\.io\/api\/v3\/content\/symbol.*/, route => { + symbolRequestCount++; + return route.fulfill({ + status: 200, + json: { + results: [CONVERSION_SYMBOL_CONTENT], + }, + }); + }); + + const trackingRequestPromise = testPage.waitForRequest( + request => + request.url().includes('cdn.builder.io/api/v1/track') && + request.method() === 'POST' && + request.postDataJSON().events[0].type === 'conversion', + { timeout: 10000 } + ); + + await testPage.goto('/symbol-conversion', { waitUntil: 'networkidle' }); + + await testPage.click('text=Track Symbol Conversion'); + + const trackingRequest = await trackingRequestPromise; + const data = trackingRequest.postDataJSON(); + + expect(data.events).toHaveLength(1); + expect(data.events[0].type).toBe('conversion'); + expect(data.events[0].data.amount).toBeUndefined(); + expect(data.events[0].data.contentId).toBe('test-content-id'); + expect(data.events[0].data.ownerId).toMatch(/abcd/); + expect(data.events[0].data.sessionId).toMatch(/^[a-f0-9]{32}$/); + expect(data.events[0].data.visitorId).toMatch(/^[a-f0-9]{32}$/); + + expect(symbolRequestCount).toBeGreaterThanOrEqual(1); + + await testPage.context().close(); + }); + + test('should include correct headers when tracking from symbol', async ({ + page, + sdk, + packageName, + baseURL, + browser, + }) => { + test.skip(excludeGen1(sdk) || excludeRn(sdk)); + // Skip packages that fetch symbol content on the server + test.skip( + packageName === 'nextjs-sdk-next-app' || + packageName === 'gen1-next14-pages' || + packageName === 'gen1-next15-app' || + packageName === 'gen1-remix' + ); + + const { page: testPage } = await initializeAbTest( + { + page, + baseURL, + packageName, + browser, + }, + { cookieName: COOKIE_NAME, cookieValue: CONTENT_ID } + ); + + let symbolRequestCount = 0; + await testPage.route(/.*cdn\.builder\.io\/api\/v3\/content\/symbol.*/, route => { + symbolRequestCount++; + return route.fulfill({ + status: 200, + json: { + results: [CONVERSION_SYMBOL_CONTENT], + }, + }); + }); + + const trackingRequestPromise = testPage.waitForRequest( + request => + request.url().includes('cdn.builder.io/api/v1/track') && + request.method() === 'POST' && + request.postDataJSON().events[0].type === 'conversion', + { timeout: 10000 } + ); + + await testPage.goto('/symbol-conversion', { waitUntil: 'networkidle' }); + + // Click the symbol button + await testPage.click('text=Track Symbol Conversion'); + + const trackingRequest = await trackingRequestPromise; + const headers = trackingRequest.headers(); + + expect(headers['content-type']).toBe('application/json'); + expect(headers['x-builder-sdk']).toBeDefined(); + expect(headers['x-builder-sdk-gen']).toBeDefined(); + expect(headers['x-builder-sdk-version']).toMatch(/\d+\.\d+\.\d+/); + + expect(symbolRequestCount).toBeGreaterThanOrEqual(1); + + await testPage.context().close(); + }); + }); + + test.describe('Section Conversion Tracking', () => { + test('should track conversion from section button', async ({ + page, + sdk, + packageName, + baseURL, + browser, + }) => { + test.skip(excludeGen1(sdk) || excludeRn(sdk)); + test.skip(isSSRFramework(packageName)); + + const { page: testPage } = await initializeAbTest( + { + page, + baseURL, + packageName, + browser, + }, + { cookieName: COOKIE_NAME, cookieValue: CONTENT_ID } + ); + + let symbolRequestCount = 0; + await testPage.route( + /.*cdn\.builder\.io\/api\/v3\/content\/sample-section-model.*/, + route => { + symbolRequestCount++; + return route.fulfill({ + status: 200, + json: { + results: [CONVERSION_SECTION_CONTENT], + }, + }); + } + ); + + const trackingRequestPromise = testPage.waitForRequest( + request => + request.url().includes('cdn.builder.io/api/v1/track') && + request.method() === 'POST' && + request.postDataJSON().events[0].type === 'conversion', + { timeout: 10000 } + ); + + await testPage.goto('/section-conversion', { waitUntil: 'networkidle' }); + + await testPage.click('text=Section Button'); + + const trackingRequest = await trackingRequestPromise; + const data = trackingRequest.postDataJSON(); + + expect(data.events).toHaveLength(1); + expect(data.events[0].type).toBe('conversion'); + expect(data.events[0].data.amount).toBeUndefined(); + expect(data.events[0].data.contentId).toBe('test-content-id'); + expect(data.events[0].data.ownerId).toMatch(/abcd/); + expect(data.events[0].data.sessionId).toMatch(/^[a-f0-9]{32}$/); + expect(data.events[0].data.visitorId).toMatch(/^[a-f0-9]{32}$/); + + expect(symbolRequestCount).toBeGreaterThanOrEqual(1); + + await testPage.context().close(); + }); + }); +}); diff --git a/packages/sdks-tests/src/specs/index.ts b/packages/sdks-tests/src/specs/index.ts index 4e29eedeb9e..bc892fd05e8 100644 --- a/packages/sdks-tests/src/specs/index.ts +++ b/packages/sdks-tests/src/specs/index.ts @@ -62,6 +62,7 @@ import { ACCORDION_WITH_NO_DETAIL, } from './accordion.js'; import { SYMBOL_TRACKING } from './symbol-tracking.js'; +import { TRACK_CONVERSION_CONTENT } from './track-conversion.js'; import { COLUMNS_WITH_DIFFERENT_WIDTHS } from './columns-with-different-widths.js'; import { CUSTOM_COMPONENTS_MODELS_RESTRICTION } from './custom-components-models.js'; import { EDITING_BOX_TO_COLUMN_INNER_LAYOUT } from './editing-columns-inner-layout.js'; @@ -98,6 +99,8 @@ import { SECTION_CHILDREN } from './section-children.js'; import { MAIN_CONTENT as SYMBOL_UPDATE_ENTRIES } from './get-content-symbol-update-entry.js'; import { HTTP_REQUESTS_POST_API_CONTENT } from './http-requests-post-api.js'; import { HTTP_REQUESTS_GET_API_CONTENT } from './http-requests-get-api.js'; +import { CONTENT_WITH_SECTION_MODEL } from './section-with-conversion.js'; +import { CONTENT_WITH_SYMBOL } from './symbol-with-conversion.js'; function isBrowser(): boolean { return typeof window !== 'undefined' && typeof document !== 'undefined'; @@ -209,6 +212,9 @@ export const PAGES: Record = { '/accordion-grid': { content: ACCORDION_GRID }, '/accordion-no-detail': { content: ACCORDION_WITH_NO_DETAIL }, '/symbol-tracking': { content: SYMBOL_TRACKING }, + '/track-conversion': { content: TRACK_CONVERSION_CONTENT }, + '/symbol-conversion': { content: CONTENT_WITH_SYMBOL }, + '/section-conversion': { content: CONTENT_WITH_SECTION_MODEL }, '/columns-with-different-widths': { content: COLUMNS_WITH_DIFFERENT_WIDTHS }, '/custom-components-no-default-value': { content: CUSTOM_COMPONENT_NO_DEFAULT_VALUE }, '/custom-components-models-show': { diff --git a/packages/sdks-tests/src/specs/section-with-conversion.ts b/packages/sdks-tests/src/specs/section-with-conversion.ts new file mode 100644 index 00000000000..1caae1a1385 --- /dev/null +++ b/packages/sdks-tests/src/specs/section-with-conversion.ts @@ -0,0 +1,163 @@ +import type { BuilderContent } from './types.js'; + +export const CONVERSION_SECTION_CONTENT: BuilderContent = { + id: 'section-conversion-test-id', + data: { + title: 'Section Conversion Test', + inputs: [], + blocks: [ + { + '@type': '@builder.io/sdk:Element', + '@version': 2, + id: 'builder-cb4f82738ca74cfea35bf6b4e17aeb4b', + component: { + name: 'Core:Section', + options: { + maxWidth: 1200, + lazyLoad: false, + }, + isRSC: null, + }, + children: [ + { + '@type': '@builder.io/sdk:Element', + '@version': 2, + actions: { + click: 'builder.trackConversion()', + }, + code: { + actions: { + click: 'builder.trackConversion();\n', + }, + }, + id: 'builder-e5427a3b502d40c3a29de1e626fd1618', + meta: { + eventActions: { + '': [], + click: [ + { + '@type': '@builder.io/core:Action', + action: '@builder.io:trackEvent', + options: { + name: 'myEvent', + trackConversion: true, + }, + }, + ], + }, + }, + component: { + name: 'Core:Button', + options: { + text: 'Section Button', + openLinkInNewTab: false, + }, + isRSC: null, + }, + responsiveStyles: { + large: { + display: 'flex', + flexDirection: 'column', + position: 'relative', + flexShrink: '0', + boxSizing: 'border-box', + marginTop: '20px', + appearance: 'none', + paddingTop: '15px', + paddingBottom: '15px', + paddingLeft: '25px', + paddingRight: '25px', + backgroundColor: 'black', + color: 'white', + borderRadius: '4px', + textAlign: 'center', + cursor: 'pointer', + }, + }, + }, + ], + responsiveStyles: { + large: { + display: 'flex', + flexDirection: 'column', + position: 'relative', + flexShrink: '0', + boxSizing: 'border-box', + marginTop: '0px', + paddingLeft: '20px', + paddingRight: '20px', + paddingTop: '20px', + paddingBottom: '20px', + minHeight: '100px', + }, + }, + }, + ], + }, + meta: { + hasLinks: false, + kind: 'page', + }, +}; + +export const CONTENT_WITH_SECTION_MODEL = { + id: 'test-content-id', + data: { + title: 'Section Conversion Test', + inputs: [], + blocks: [ + { + '@type': '@builder.io/sdk:Element', + '@version': 2, + id: 'builder-text-header', + component: { + name: 'Text', + options: { + text: '

Section Conversion Tracking Test

', + }, + }, + responsiveStyles: { + large: { + display: 'flex', + flexDirection: 'column', + position: 'relative', + flexShrink: '0', + boxSizing: 'border-box', + marginTop: '20px', + lineHeight: 'normal', + height: 'auto', + textAlign: 'center', + }, + }, + }, + { + '@type': '@builder.io/sdk:Element', + '@version': 2, + id: 'builder-f2a4c0079d5149c3bc23e3c3032f88df', + component: { + name: 'Symbol', + options: { + symbol: { + model: 'sample-section-model', + entry: 'section-conversion-test-id', + }, + }, + }, + responsiveStyles: { + large: { + display: 'flex', + flexDirection: 'column', + position: 'relative', + flexShrink: '0', + boxSizing: 'border-box', + marginTop: '20px', + }, + }, + }, + ], + }, + meta: { + hasLinks: false, + kind: 'page', + }, +}; diff --git a/packages/sdks-tests/src/specs/symbol-with-conversion.ts b/packages/sdks-tests/src/specs/symbol-with-conversion.ts new file mode 100644 index 00000000000..119dfaf2c00 --- /dev/null +++ b/packages/sdks-tests/src/specs/symbol-with-conversion.ts @@ -0,0 +1,119 @@ +import type { BuilderContent } from './types.js'; + +export const CONVERSION_SYMBOL_CONTENT = { + createdDate: Date.now(), + createdBy: 'test-user', + variations: {}, + name: 'conversion tracking symbol', + published: 'published', + firstPublished: Date.now(), + testRatio: 1, + data: { + blocks: [ + { + '@type': '@builder.io/sdk:Element', + '@version': 2, + id: 'builder-conversion-symbol-button', + actions: { + click: 'builder.trackConversion();\n', + }, + code: { + actions: { + click: 'builder.trackConversion();\n', + }, + }, + component: { + name: 'Core:Button', + options: { + text: 'Track Symbol Conversion', + openLinkInNewTab: false, + }, + }, + responsiveStyles: { + large: { + display: 'flex', + flexDirection: 'column', + position: 'relative', + flexShrink: '0', + boxSizing: 'border-box', + marginTop: '20px', + appearance: 'none', + paddingTop: '15px', + paddingBottom: '15px', + paddingLeft: '25px', + paddingRight: '25px', + backgroundColor: 'blue', + color: 'white', + borderRadius: '4px', + textAlign: 'center', + cursor: 'pointer', + }, + }, + }, + ], + }, + id: 'conversion-symbol-id', + rev: 'test-rev', +} as const; + +export const CONTENT_WITH_SYMBOL: BuilderContent = { + id: 'test-content-id', + data: { + title: 'Symbol Conversion Test', + inputs: [], + blocks: [ + { + '@type': '@builder.io/sdk:Element', + '@version': 2, + id: 'builder-text-header', + component: { + name: 'Text', + options: { + text: '

Symbol Conversion Tracking Test

', + }, + }, + responsiveStyles: { + large: { + display: 'flex', + flexDirection: 'column', + position: 'relative', + flexShrink: '0', + boxSizing: 'border-box', + marginTop: '20px', + lineHeight: 'normal', + height: 'auto', + textAlign: 'center', + }, + }, + }, + { + '@type': '@builder.io/sdk:Element', + '@version': 2, + id: 'builder-symbol-with-conversion', + component: { + name: 'Symbol', + options: { + symbol: { + model: 'symbol', + entry: 'conversion-symbol-id', + }, + }, + }, + responsiveStyles: { + large: { + display: 'flex', + flexDirection: 'column', + position: 'relative', + flexShrink: '0', + boxSizing: 'border-box', + marginTop: '20px', + }, + }, + }, + ], + }, + meta: { + hasLinks: false, + kind: 'page', + }, +}; diff --git a/packages/sdks-tests/src/specs/track-conversion.ts b/packages/sdks-tests/src/specs/track-conversion.ts new file mode 100644 index 00000000000..b3312437230 --- /dev/null +++ b/packages/sdks-tests/src/specs/track-conversion.ts @@ -0,0 +1,468 @@ +export const TRACK_CONVERSION_CONTENT = { + published: 'published', + lastUpdatedBy: 'otrVluNzvNScbfBcM1pwuzPvX1o1', + modelId: '8b9bea507dd04be3a23d84c5d3824a48', + data: { + themeId: false, + inputs: [], + title: '[TEST] Insights Test', + blocks: [ + { + '@type': '@builder.io/sdk:Element', + '@version': 2, + id: 'builder-5e19c8bf75d243f2807ed6194bb1cfb3', + children: [ + { + '@type': '@builder.io/sdk:Element', + '@version': 2, + actions: { + click: 'builder.trackConversion(100);\n', + }, + code: { + actions: { + click: 'builder.trackConversion(100);\n', + }, + }, + id: 'builder-92b45ec0dbab4c4ba6fbe6d9f2557bff', + meta: { + eventActions: { + click: [ + { + '@type': '@builder.io/core:Action', + action: '@builder.io:trackEvent', + options: { + name: 'myEvent', + amount: 100, + trackConversion: true, + }, + }, + ], + }, + }, + component: { + name: 'Core:Button', + options: { + text: 'Track Basic Conversion - Default', + openLinkInNewTab: false, + }, + isRSC: null, + }, + responsiveStyles: { + large: { + display: 'flex', + flexDirection: 'column', + position: 'relative', + flexShrink: '0', + boxSizing: 'border-box', + marginTop: '20px', + appearance: 'none', + paddingTop: '15px', + paddingBottom: '15px', + paddingLeft: '25px', + paddingRight: '25px', + backgroundColor: 'black', + color: 'white', + borderRadius: '4px', + textAlign: 'center', + cursor: 'pointer', + }, + }, + }, + { + '@type': '@builder.io/sdk:Element', + '@version': 2, + id: 'builder-eaeaec2f9fcd435993dbf28ba5060c6c', + component: { + name: 'Text', + options: { + text: '

Default Variant

', + }, + isRSC: null, + }, + responsiveStyles: { + large: { + display: 'flex', + flexDirection: 'column', + position: 'relative', + flexShrink: '0', + boxSizing: 'border-box', + marginTop: '150px', + lineHeight: 'normal', + height: 'auto', + marginBottom: '10px', + paddingBottom: '0px', + }, + }, + }, + { + '@type': '@builder.io/sdk:Element', + '@version': 2, + actions: { + click: 'builder.trackConversion();\n', + }, + code: { + actions: { + click: 'builder.trackConversion();\n', + }, + }, + id: 'builder-3b4d00a8858c4ebc951bf0860543dc61', + meta: { + eventActions: { + click: [ + { + '@type': '@builder.io/core:Action', + action: '@builder.io:trackEvent', + options: { + name: 'myEvent', + trackConversion: true, + }, + }, + ], + }, + }, + component: { + name: 'Core:Button', + options: { + text: 'Track conversion without amount', + openLinkInNewTab: false, + }, + isRSC: null, + }, + responsiveStyles: { + large: { + display: 'flex', + flexDirection: 'column', + position: 'relative', + flexShrink: '0', + boxSizing: 'border-box', + marginTop: '20px', + appearance: 'none', + paddingTop: '15px', + paddingBottom: '15px', + paddingLeft: '25px', + paddingRight: '25px', + backgroundColor: 'black', + color: 'white', + borderRadius: '4px', + textAlign: 'center', + cursor: 'pointer', + }, + }, + }, + { + '@type': '@builder.io/sdk:Element', + '@version': 2, + actions: { + click: 'builder.trackConversion(100);\n', + }, + code: { + actions: { + click: 'builder.trackConversion(100);\n', + }, + }, + id: 'builder-3b4d00a8858c4ebc951bf0860543dc62', + meta: { + eventActions: { + '': [], + click: [ + { + '@type': '@builder.io/core:Action', + action: '@builder.io:trackEvent', + options: { + name: 'myEvent', + trackConversion: true, + }, + }, + ], + }, + }, + component: { + name: 'Core:Button', + options: { + text: 'Track conversion with amount', + openLinkInNewTab: false, + }, + isRSC: null, + }, + responsiveStyles: { + large: { + display: 'flex', + flexDirection: 'column', + position: 'relative', + flexShrink: '0', + boxSizing: 'border-box', + marginTop: '20px', + appearance: 'none', + paddingTop: '15px', + paddingBottom: '15px', + paddingLeft: '25px', + paddingRight: '25px', + backgroundColor: 'black', + color: 'white', + borderRadius: '4px', + textAlign: 'center', + cursor: 'pointer', + }, + }, + }, + { + '@type': '@builder.io/sdk:Element', + '@version': 2, + actions: { + click: + "builder.trackConversion(100, 'test-content-id', 'test-variation-id', { product: 'premium-shoes' }, { userId: 'user-123' });\n", + }, + code: { + actions: { + click: + "builder.trackConversion(100, 'test-content-id', 'test-variation-id', { product: 'premium-shoes' }, { userId: 'user-123' });\n", + }, + }, + id: 'builder-3b4d00a8858c4ebc951bf0860543dc63', + meta: { + eventActions: { + '': [], + click: [ + { + '@type': '@builder.io/core:Action', + action: '@builder.io:trackEvent', + options: { + name: 'myEvent', + trackConversion: true, + }, + }, + ], + }, + }, + component: { + name: 'Core:Button', + options: { + text: 'Track Conversion with All Parameters', + openLinkInNewTab: false, + }, + isRSC: null, + }, + responsiveStyles: { + large: { + display: 'flex', + flexDirection: 'column', + position: 'relative', + flexShrink: '0', + boxSizing: 'border-box', + marginTop: '20px', + appearance: 'none', + paddingTop: '15px', + paddingBottom: '15px', + paddingLeft: '25px', + paddingRight: '25px', + backgroundColor: 'black', + color: 'white', + borderRadius: '4px', + textAlign: 'center', + cursor: 'pointer', + }, + }, + }, + ], + responsiveStyles: { + large: { + display: 'flex', + flexDirection: 'column', + position: 'relative', + flexShrink: '0', + boxSizing: 'border-box', + marginTop: '20px', + height: 'auto', + paddingBottom: '0px', + marginRight: '0px', + justifyContent: 'center', + width: 'auto', + alignSelf: 'center', + flexGrow: '0', + }, + }, + }, + { + id: 'builder-pixel-11upajbeli8i', + '@type': '@builder.io/sdk:Element', + tagName: 'img', + properties: { + src: 'https://cdn.builder.io/api/v1/pixel?apiKey=75c6e293e39b4890ac75a37bbca0a447', + 'aria-hidden': 'true', + alt: '', + role: 'presentation', + width: '0', + height: '0', + }, + responsiveStyles: { + large: { + height: '0', + width: '0', + display: 'inline-block', + opacity: '0', + overflow: 'hidden', + pointerEvents: 'none', + }, + }, + }, + ], + url: '/track-conversion', + state: { + deviceSize: 'large', + location: { + path: '', + query: {}, + }, + }, + }, + createdBy: 'otrVluNzvNScbfBcM1pwuzPvX1o1', + firstPublished: 1758694322547, + meta: { + hasLinks: false, + kind: 'page', + }, + testRatio: 0.5, + folders: [], + name: '[TEST] Insights Test', + query: [ + { + '@type': '@builder.io/core:Query', + property: 'urlPath', + value: '/track-conversion', + operator: 'is', + }, + ], + createdDate: 1758694019198, + variations: { + 'test-variation-id': { + createdDate: 1758694157902, + testRatio: 0.5, + meta: {}, + name: 'Variation 1', + data: { + title: '[TEST] Insights Test', + inputs: [], + themeId: false, + blocks: [ + { + '@type': '@builder.io/sdk:Element', + '@version': 2, + id: 'builder-7b0c38da24fa4cf98d6d1e6a5b277a58', + meta: { + previousId: 'builder-5e19c8bf75d243f2807ed6194bb1cfb3', + }, + children: [ + { + '@type': '@builder.io/sdk:Element', + '@version': 2, + id: 'builder-e0dab9fa9dc6487baa20c9bb176417aa', + meta: { + previousId: 'builder-eaeaec2f9fcd435993dbf28ba5060c6c', + }, + component: { + name: 'Text', + options: { + text: '

Variation 1

', + }, + isRSC: null, + }, + responsiveStyles: { + large: { + display: 'flex', + flexDirection: 'column', + position: 'relative', + flexShrink: '0', + boxSizing: 'border-box', + marginTop: '150px', + lineHeight: 'normal', + height: 'auto', + marginBottom: '1px', + marginLeft: 'auto', + marginRight: 'auto', + }, + }, + }, + { + '@type': '@builder.io/sdk:Element', + '@version': 2, + actions: { + click: 'builder.trackConversion(100);\n', + }, + code: { + actions: { + click: 'builder.trackConversion(100);\n', + }, + }, + id: 'builder-92b45ec0dbab4c4ba6fbe6d9f2557bff', + meta: { + eventActions: { + click: [ + { + '@type': '@builder.io/core:Action', + action: '@builder.io:trackEvent', + options: { + name: 'myEvent', + amount: 100, + trackConversion: true, + }, + }, + ], + }, + }, + component: { + name: 'Core:Button', + options: { + text: 'Track Basic Conversion - Variant', + openLinkInNewTab: false, + }, + isRSC: null, + }, + responsiveStyles: { + large: { + display: 'flex', + flexDirection: 'column', + position: 'relative', + flexShrink: '0', + boxSizing: 'border-box', + marginTop: '20px', + appearance: 'none', + paddingTop: '15px', + paddingBottom: '15px', + paddingLeft: '25px', + paddingRight: '25px', + backgroundColor: 'black', + color: 'white', + borderRadius: '4px', + textAlign: 'center', + cursor: 'pointer', + }, + }, + }, + ], + responsiveStyles: { + large: { + display: 'flex', + flexDirection: 'column', + position: 'relative', + flexShrink: '0', + boxSizing: 'border-box', + marginTop: '20px', + height: 'auto', + paddingBottom: '0px', + marginRight: '0px', + justifyContent: 'center', + width: 'auto', + alignSelf: 'center', + flexGrow: '0', + }, + }, + }, + ], + }, + id: 'test-variation-id', + }, + }, + lastUpdated: 1758814072829, + id: 'test-content-id', + rev: 'lj38917wpgd', +}; diff --git a/packages/sdks/docs/DEVELOP.md b/packages/sdks/docs/DEVELOP.md index 2f56537493b..6a96f2c8d7a 100644 --- a/packages/sdks/docs/DEVELOP.md +++ b/packages/sdks/docs/DEVELOP.md @@ -78,6 +78,19 @@ You can fetch real data from the Builder API instead of using the JSON mock file To unlink the SDK from your project, all you have to do is run `npm install` in your project folder. That will clear all sym-links. +*Alternative Method* : + +- Build the SDK using `yarn g:nx build @builder.io/sdk-react` (replace `react` with the SDK you want to build) +- In your project temporarily replace the sdk dependency as follows: + + ``` + "@builder.io/sdk-react": "link:/packages/sdks/output/react", + ``` +- `yarn install` in your project folder +- If you want your sdk to refresh when making changes in the `packages/sdks/src` folder, run the command in root folder `yarn run watch:sdk @builder.io/sdk-react` (replace `react` with the SDK you want to build) + +This achieves the same result as the npm link method above, but uses yarn instead. + **NOTE: Testing React-Native SDK in iOS Simulator** One big caveat is that the iOS Simulator does not support sym-linked packages. To workaround this, you will have to copy the SDK folder. This means that you will need to manually do so every time you want a new change to be reflected. in the react-native example, there is a handy `yarn run cp-sdk` command to do that for you. diff --git a/packages/sdks/src/blocks/personalization-container/helpers.ts b/packages/sdks/src/blocks/personalization-container/helpers.ts index c3e6d27c229..6acae4d5068 100644 --- a/packages/sdks/src/blocks/personalization-container/helpers.ts +++ b/packages/sdks/src/blocks/personalization-container/helpers.ts @@ -15,6 +15,8 @@ export const DEFAULT_INDEX = 'default'; const FILTER_WITH_CUSTOM_TARGETING_SCRIPT_FN_NAME = 'filterWithCustomTargeting'; const BUILDER_IO_PERSONALIZATION_SCRIPT_FN_NAME = 'builderIoPersonalization'; const UPDATE_VARIANT_VISIBILITY_SCRIPT_FN_NAME = 'updateVisibilityStylesScript'; +const SETUP_GLOBAL_BUILDER_CONTEXT_SCRIPT_FN_NAME = + 'builderIoInitializeGlobalBuilderContext'; export type UserAttributes = { date?: string | Date; @@ -173,4 +175,8 @@ export const getUpdateVisibilityStylesScript = ( return `window.${UPDATE_VARIANT_VISIBILITY_SCRIPT_FN_NAME}(${JSON.stringify(variants)}, "${blockId}", ${isHydrationTarget}${locale ? `, "${locale}"` : ''})`; }; +export const getSetupGlobalBuilderContextScript = () => { + return `window.${SETUP_GLOBAL_BUILDER_CONTEXT_SCRIPT_FN_NAME}()`; +}; + export { filterWithCustomTargeting } from './helpers/inlined-fns.js'; diff --git a/packages/sdks/src/components/content-variants/helpers.ts b/packages/sdks/src/components/content-variants/helpers.ts index 8e93e66b58c..73bc73211a7 100644 --- a/packages/sdks/src/components/content-variants/helpers.ts +++ b/packages/sdks/src/components/content-variants/helpers.ts @@ -4,6 +4,7 @@ import type { Nullable } from '../../helpers/nullable.js'; import type { BuilderContent } from '../../types/builder-content.js'; import type { Target } from '../../types/targets.js'; import { + SETUP_GLOBAL_BUILDER_CONTEXT_SCRIPT, UPDATE_COOKIES_AND_STYLES_SCRIPT, UPDATE_VARIANT_VISIBILITY_SCRIPT, } from './inlined-fns.js'; @@ -16,6 +17,8 @@ import { */ const UPDATE_COOKIES_AND_STYLES_SCRIPT_NAME = 'builderIoAbTest'; const UPDATE_VARIANT_VISIBILITY_SCRIPT_FN_NAME = 'builderIoRenderContent'; +const SETUP_GLOBAL_BUILDER_CONTEXT_SCRIPT_FN_NAME = + 'builderIoInitializeGlobalBuilderContext'; export const getVariants = (content: Nullable) => Object.values(content?.variations || {}).map((variant) => ({ @@ -72,6 +75,7 @@ const isHydrationTarget = getIsHydrationTarget(TARGET); export const getInitVariantsFnsScriptString = () => ` window.${UPDATE_COOKIES_AND_STYLES_SCRIPT_NAME} = ${UPDATE_COOKIES_AND_STYLES_SCRIPT} window.${UPDATE_VARIANT_VISIBILITY_SCRIPT_FN_NAME} = ${UPDATE_VARIANT_VISIBILITY_SCRIPT} + window.${SETUP_GLOBAL_BUILDER_CONTEXT_SCRIPT_FN_NAME} = ${SETUP_GLOBAL_BUILDER_CONTEXT_SCRIPT} `; export const getUpdateCookieAndStylesScript = ( diff --git a/packages/sdks/src/components/content-variants/inlined-fns.ts b/packages/sdks/src/components/content-variants/inlined-fns.ts index f0524f835d8..f9a595d1faa 100644 --- a/packages/sdks/src/components/content-variants/inlined-fns.ts +++ b/packages/sdks/src/components/content-variants/inlined-fns.ts @@ -3,6 +3,108 @@ * They cannot import anything. */ +/** + * Global Builder context singleton to store and retrieve Builder configuration + * across the application without prop drilling. + */ + +export interface GlobalBuilderContext { + apiKey?: string; + apiHost?: string; + contentId?: string; +} + +// Define the global Builder object structure +interface GlobalBuilder { + globalContext: GlobalBuilderContext; + setContext: (context: GlobalBuilderContext) => void; + getContext: () => GlobalBuilderContext; + getValue: ( + key: K + ) => GlobalBuilderContext[K]; + clearContext: () => void; +} + +/** + * Initialize the GlobalBuilderContext on the global/window object + * This function sets up the Builder context functions globally + */ +export function initializeGlobalBuilderContext(): void { + // Detect environment and get the appropriate global object + const isServer = typeof window === 'undefined'; + const globalObject = isServer + ? typeof globalThis !== 'undefined' + ? globalThis + : (function () { + try { + return global; + } catch (e) { + return {}; + } + })() + : window; + + if ((globalObject as any).GlobalBuilderContext) { + // if already exists, don't re-initialize + return; + } + + /** + * Singleton instance to store the global Builder context + */ + const globalContext: GlobalBuilderContext = {}; + /** + * Set the global Builder context + * @param context - The context values to set + */ + function setGlobalBuilderContext( + this: any, + context: GlobalBuilderContext + ): void { + this.globalContext = { ...this.globalContext, ...context }; + } + + /** + * Get the global Builder context + * @returns The current global Builder context + */ + function getGlobalBuilderContext(this: any): GlobalBuilderContext { + return this.globalContext; + } + + /** + * Get a specific value from the global Builder context + * @param key - The key to retrieve + * @returns The value for the specified key + */ + function getGlobalBuilderValue( + this: any, + key: K + ): GlobalBuilderContext[K] { + return this.globalContext[key]; + } + + /** + * Clear the global Builder context + */ + function clearGlobalBuilderContext(this: any): void { + this.globalContext = {}; + } + + // Attach Builder functions to the global object + if (globalObject) { + (globalObject as any).GlobalBuilderContext = + (globalObject as any).GlobalBuilderContext || {}; + const globalBuilderContext = (globalObject as any) + .GlobalBuilderContext as GlobalBuilder; + globalBuilderContext.globalContext = globalContext; + + globalBuilderContext.setContext = setGlobalBuilderContext; + globalBuilderContext.getContext = getGlobalBuilderContext; + globalBuilderContext.getValue = getGlobalBuilderValue; + globalBuilderContext.clearContext = clearGlobalBuilderContext; + } +} type VariantData = { id: string; testRatio?: number; @@ -34,6 +136,48 @@ function updateCookiesAndStyles( '; path=/' + '; Secure; SameSite=None'; } + + function parseUrlParams(url: string): Map { + const result = new Map(); + + try { + const urlObj = new URL(url); + const params = urlObj.searchParams; + + for (const [key, value] of params) { + result.set(key, value); + } + } catch (error) { + console.debug('Error parsing URL parameters:', error); + } + + return result; + } + function getVariantIdFromUrl() { + if (typeof window === 'undefined') return; + const testCookiePrefix = 'builder.tests'; + try { + // Use native URL object to parse current page URL + const params = parseUrlParams(window.location.href); + + // Look for parameters that start with 'builder.tests.' + for (const [key, value] of params) { + if (key.startsWith(`${testCookiePrefix}.${contentId}`)) { + return [key, value]; + } + } + return; + } catch (e) { + console.debug('Error parsing tests from URL', e); + return; + } + } + const builderTestQueryParam = getVariantIdFromUrl(); + if (builderTestQueryParam) { + const [key, value] = builderTestQueryParam; + setCookie(key, value, 30); + } + function getCookie(name: string) { const nameEQ = name + '='; const ca = document.cookie.split(';'); @@ -187,3 +331,5 @@ export const UPDATE_COOKIES_AND_STYLES_SCRIPT = updateCookiesAndStyles export const UPDATE_VARIANT_VISIBILITY_SCRIPT = updateVariantVisibility .toString() .replace(/\s+/g, ' '); +export const SETUP_GLOBAL_BUILDER_CONTEXT_SCRIPT = + initializeGlobalBuilderContext.toString().replace(/\s+/g, ' '); diff --git a/packages/sdks/src/components/content/content.lite.tsx b/packages/sdks/src/components/content/content.lite.tsx index 81ef2c24817..e5938a14b58 100644 --- a/packages/sdks/src/components/content/content.lite.tsx +++ b/packages/sdks/src/components/content/content.lite.tsx @@ -7,6 +7,8 @@ import { useStore, useTarget, } from '@builder.io/mitosis'; +import { getSetupGlobalBuilderContextScript } from '../../blocks/personalization-container/helpers.js'; +import { initializeGlobalBuilderContext } from '../../components/content-variants/inlined-fns.js'; import { getDefaultRegisteredComponents } from '../../constants/builder-registered-components.js'; import { TARGET } from '../../constants/target.js'; import ComponentsContext from '../../context/components.context.lite.js'; @@ -16,6 +18,7 @@ import type { RegisteredComponents, } from '../../context/types.js'; import { evaluate } from '../../functions/evaluate/evaluate.js'; +import { getGlobalThis } from '../../functions/get-global-this.js'; import { serializeIncludingFunctions } from '../../functions/register-component.js'; import { logger } from '../../helpers/logger.js'; import type { ComponentInfo } from '../../types/components.js'; @@ -53,6 +56,16 @@ export default function ContentComponent(props: ContentProps) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-non-null-asserted-optional-chain contentId: props.content?.id!, }), + setGlobalContextScriptStr: props.isNestedRender + ? '' + : ` + ${getSetupGlobalBuilderContextScript()} + window.GlobalBuilderContext.setContext({ + apiKey: '${props.apiKey || ''}', + apiHost: '${props.apiHost || ''}', + contentId: '${props.content?.id || ''}', + }); + `, contentSetState: (newRootState: BuilderRenderState) => { builderContextSignal.value.rootState = newRootState; }, @@ -138,6 +151,14 @@ export default function ContentComponent(props: ContentProps) { 'No API key provided to `Content` component. This can cause issues. Please provide an API key using the `apiKey` prop.' ); } + initializeGlobalBuilderContext(); + if (!props.isNestedRender) { + (getGlobalThis() as any)?.GlobalBuilderContext?.setContext({ + apiKey: props.apiKey, + apiHost: props.apiHost, + contentId: props.content?.id, + }); + } // run any dynamic JS code attached to content const jsCode = builderContextSignal.value.content?.data?.jsCode; @@ -215,6 +236,12 @@ export default function ContentComponent(props: ContentProps) { nonce={props.nonce || ''} /> + + ({ + _track: vi.fn(), +})); + +vi.mock('../content-variants.js', () => ({ + getTestCookie: vi.fn(), +})); + +vi.mock('../../helpers/canTrack.js', () => ({ + getDefaultCanTrack: vi.fn(), +})); + +describe('getBuilderGlobals', () => { + beforeEach(() => { + vi.clearAllMocks(); + + (typeof window !== 'undefined' + ? window + : (global as any) + ).GlobalBuilderContext.globalContext = {}; + + vi.mocked(getDefaultCanTrack).mockReturnValue(true); + vi.mocked(getTestCookie).mockReturnValue(undefined); + }); + + describe('track function', () => { + it('should call _track with correct parameters', () => { + const builderGlobals = getBuilderGlobals(); + const mockContext = { + apiHost: 'https://test.builder.io', + apiKey: 'test-api-key', + }; + + (typeof window !== 'undefined' + ? window + : (global as any) + ).GlobalBuilderContext.setContext(mockContext); + + vi.mocked(getDefaultCanTrack).mockReturnValue(true); + + builderGlobals.track('click', { customProp: 'value' }, { userId: '123' }); + + expect(_track).toHaveBeenCalledWith({ + type: 'click', + customProp: 'value', + apiHost: 'https://test.builder.io', + apiKey: 'test-api-key', + context: { userId: '123' }, + canTrack: true, + }); + }); + + it('should use empty string for apiKey when not provided', () => { + const builderGlobals = getBuilderGlobals(); + (typeof window !== 'undefined' + ? window + : (global as any) + ).GlobalBuilderContext.setContext({ + apiHost: 'https://test.builder.io', + }); + + builderGlobals.track('pageview', {}); + + expect(_track).toHaveBeenCalledWith({ + type: 'pageview', + apiHost: 'https://test.builder.io', + apiKey: '', + context: undefined, + canTrack: true, + }); + }); + + it('should work with no properties provided', () => { + (typeof window !== 'undefined' + ? window + : (global as any) + ).GlobalBuilderContext.globalContext = {}; + const builderGlobals = getBuilderGlobals(); + + builderGlobals.track('custom-event', {}); + + expect(_track).toHaveBeenCalledWith({ + type: 'custom-event', + apiHost: undefined, + apiKey: '', + context: undefined, + canTrack: true, + }); + }); + }); + + describe('trackConversion function', () => { + it('should call _track with conversion type and all parameters', () => { + const builderGlobals = getBuilderGlobals(); + const mockContext = { + apiHost: 'https://test.builder.io', + apiKey: 'test-api-key', + contentId: 'content-123', + }; + + (typeof window !== 'undefined' + ? window + : (global as any) + ).GlobalBuilderContext.setContext(mockContext); + vi.mocked(getDefaultCanTrack).mockReturnValue(true); + + builderGlobals.trackConversion( + 100, + 'content-456', + 'variation-789', + { product: 'shoes' }, + { userId: '123' } + ); + + expect(_track).toHaveBeenCalledWith({ + type: 'conversion', + apiHost: 'https://test.builder.io', + apiKey: 'test-api-key', + amount: 100, + contentId: 'content-456', + variationId: 'variation-789', + meta: { product: 'shoes' }, + context: { userId: '123' }, + canTrack: true, + }); + }); + + it('should use contentId from global context when not provided', () => { + const builderGlobals = getBuilderGlobals(); + const mockContext = { + apiKey: 'test-api-key', + contentId: 'global-content-123', + }; + + (typeof window !== 'undefined' + ? window + : (global as any) + ).GlobalBuilderContext.setContext(mockContext); + + builderGlobals.trackConversion(50); + + expect(_track).toHaveBeenCalledWith({ + type: 'conversion', + apiHost: undefined, + apiKey: 'test-api-key', + amount: 50, + contentId: 'global-content-123', + variationId: undefined, + meta: undefined, + context: undefined, + canTrack: true, + }); + }); + + it('should handle contentId as object (legacy format)', () => { + const builderGlobals = getBuilderGlobals(); + const metaObject = { product: 'shoes', category: 'footwear' }; + + (typeof window !== 'undefined' + ? window + : (global as any) + ).GlobalBuilderContext.globalContext = { + apiKey: 'test-key', + }; + + builderGlobals.trackConversion(75, metaObject, 'variation-123'); + + expect(_track).toHaveBeenCalledWith({ + type: 'conversion', + apiHost: undefined, + apiKey: 'test-key', + amount: 75, + contentId: undefined, + variationId: undefined, // variationId is undefined because contentId is undefined + meta: metaObject, + context: undefined, + canTrack: true, + }); + }); + + it('should get variationId from test cookie when not provided', () => { + const builderGlobals = getBuilderGlobals(); + + (typeof window !== 'undefined' + ? window + : (global as any) + ).GlobalBuilderContext.globalContext = { + apiKey: 'test-key', + }; + vi.mocked(getTestCookie).mockReturnValue('cookie-variation-456'); + + builderGlobals.trackConversion(25, 'content-789'); + + expect(getTestCookie).toHaveBeenCalledWith('content-789'); + expect(_track).toHaveBeenCalledWith({ + type: 'conversion', + apiHost: undefined, + apiKey: 'test-key', + amount: 25, + contentId: 'content-789', + variationId: 'cookie-variation-456', + meta: undefined, + context: undefined, + canTrack: true, + }); + }); + + it('should not set variationId when it equals contentId', () => { + const builderGlobals = getBuilderGlobals(); + + (typeof window !== 'undefined' + ? window + : (global as any) + ).GlobalBuilderContext.setContext({ + apiKey: 'test-key', + }); + + builderGlobals.trackConversion(30, 'content-123', 'content-123'); + + expect(_track).toHaveBeenCalledWith({ + type: 'conversion', + apiHost: undefined, + apiKey: 'test-key', + amount: 30, + contentId: 'content-123', + variationId: undefined, + meta: undefined, + context: undefined, + canTrack: true, + }); + }); + + it('should handle all parameters as undefined', () => { + const builderGlobals = getBuilderGlobals(); + + (typeof window !== 'undefined' + ? window + : (global as any) + ).GlobalBuilderContext.globalContext = {}; + + builderGlobals.trackConversion(); + + expect(_track).toHaveBeenCalledWith({ + type: 'conversion', + apiHost: undefined, + apiKey: '', + amount: undefined, + contentId: undefined, + variationId: undefined, + meta: undefined, + context: undefined, + canTrack: true, + }); + }); + + it('should prioritize provided contentId over global context', () => { + const builderGlobals = getBuilderGlobals(); + const mockContext = { + apiKey: 'test-key', + contentId: 'global-content-123', + }; + + (typeof window !== 'undefined' + ? window + : (global as any) + ).GlobalBuilderContext.globalContext = mockContext; + + builderGlobals.trackConversion(100, 'explicit-content-456'); + + expect(_track).toHaveBeenCalledWith({ + type: 'conversion', + apiHost: undefined, + apiKey: 'test-key', + amount: 100, + contentId: 'explicit-content-456', + variationId: undefined, + meta: undefined, + context: undefined, + canTrack: true, + }); + }); + }); +}); describe('flatten state', () => { it('should behave normally when no PROTO_STATE', () => { diff --git a/packages/sdks/src/functions/evaluate/helpers.ts b/packages/sdks/src/functions/evaluate/helpers.ts index ce113bc328d..25e6f184d60 100644 --- a/packages/sdks/src/functions/evaluate/helpers.ts +++ b/packages/sdks/src/functions/evaluate/helpers.ts @@ -2,9 +2,13 @@ import type { BuilderContextInterface, BuilderRenderState, } from '../../context/types.js'; +import { getDefaultCanTrack } from '../../helpers/canTrack.js'; +import { getTestCookie } from '../content-variants.js'; import { isBrowser } from '../is-browser.js'; import { isEditing } from '../is-editing.js'; import { getUserAttributes } from '../track/helpers.js'; +import type { EventProps } from '../track/index.js'; +import { _track } from '../track/index.js'; export type EvaluatorArgs = Omit & { event?: Event; @@ -15,6 +19,18 @@ export type BuilderGlobals = { isBrowser: boolean | undefined; isServer: boolean | undefined; getUserAttributes: typeof getUserAttributes; + track: ( + eventName: string, + properties: Partial, + context?: any + ) => void; + trackConversion: ( + amount?: number, + contentId?: string | any, + variationId?: string, + customProperties?: any, + context?: any + ) => void; }; export type ExecutorArgs = Pick< @@ -52,6 +68,60 @@ export const getBuilderGlobals = (): BuilderGlobals => ({ isBrowser: isBrowser(), isServer: !isBrowser(), getUserAttributes: () => getUserAttributes(), + track: ( + eventName: string, + properties: Partial = {}, + context?: any + ) => { + const builderContext = ( + typeof window !== 'undefined' ? window : (global as any) + )?.GlobalBuilderContext?.getContext(); + _track({ + type: eventName, + ...properties, + apiHost: builderContext?.apiHost, + apiKey: builderContext?.apiKey || '', + context, + canTrack: getDefaultCanTrack(properties.canTrack), + }); + }, + trackConversion: ( + amount?: number, + contentId?: string, + variationId?: string, + customProperties?: any, + context?: any + ) => { + const meta = typeof contentId === 'object' ? contentId : customProperties; + let useContentId = typeof contentId === 'string' ? contentId : undefined; + const builderContext = ( + typeof window !== 'undefined' ? window : (global as any) + )?.GlobalBuilderContext?.getContext(); + + if (!useContentId && builderContext?.contentId) { + useContentId = builderContext.contentId; + } + + let useVariationId = variationId; + if (!useVariationId && useContentId) { + useVariationId = getTestCookie(useContentId) || undefined; + } + + _track({ + type: 'conversion', + apiHost: builderContext?.apiHost, + apiKey: builderContext?.apiKey || '', + amount: amount || undefined, + contentId: useContentId, + variationId: + useVariationId && useContentId && useVariationId !== useContentId + ? useVariationId + : undefined, + meta, + context: context || undefined, + canTrack: getDefaultCanTrack(), + }); + }, }); export const parseCode = (