From 22e559228de478f576e1bf0a4e0749b7048fcf11 Mon Sep 17 00:00:00 2001 From: Tsuki <976499226@qq.com> Date: Mon, 20 Apr 2026 01:12:01 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=84=20style:=20add=20x=20ads=20analyti?= =?UTF-8?q?cs=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config.ts | 7 + src/index.ts | 3 + src/providers/xads.test.ts | 235 ++++++++++++++++++++++++++++++ src/providers/xads.ts | 285 +++++++++++++++++++++++++++++++++++++ src/types.ts | 12 ++ 5 files changed, 542 insertions(+) create mode 100644 src/providers/xads.test.ts create mode 100644 src/providers/xads.ts diff --git a/src/config.ts b/src/config.ts index 4357bcf..4893cca 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,7 @@ import { AnalyticsManager } from './manager'; import { GoogleAnalyticsProvider } from './providers/ga4'; import { PostHogAnalyticsProvider } from './providers/posthog'; +import { XAdsAnalyticsProvider } from './providers/xads'; import type { AnalyticsConfig } from './types'; /** @@ -48,6 +49,12 @@ export function createAnalytics(config: AnalyticsConfig): AnalyticsManager { manager.registerProvider('ga4', provider); } + // Register X Ads if enabled + if (config.providers.xAds?.enabled) { + const provider = new XAdsAnalyticsProvider(config.providers.xAds, config.business); + manager.registerProvider('xAds', provider); + } + // Note: posthogNode provider is not available in the client entry point // Use '@lobehub/analytics/server' for server-side analytics if (config.providers.posthogNode?.enabled) { diff --git a/src/index.ts b/src/index.ts index ff3c9e5..4f261a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ export { AnalyticsManager } from './manager'; // Providers export { GoogleAnalyticsProvider } from './providers/ga4'; export { PostHogAnalyticsProvider } from './providers/posthog'; +export { XAdsAnalyticsProvider } from './providers/xads'; // Note: PostHogNodeAnalyticsProvider is available in '@lobehub/analytics/server' // Configuration @@ -39,6 +40,8 @@ export type { PredefinedEvents, ProviderConfig, UmamiProviderAnalyticsConfig, + XAdsEventIdMap, + XAdsProviderAnalyticsConfig, } from './types'; // Default export diff --git a/src/providers/xads.test.ts b/src/providers/xads.test.ts new file mode 100644 index 0000000..e0e72a7 --- /dev/null +++ b/src/providers/xads.test.ts @@ -0,0 +1,235 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { XAdsAnalyticsProvider } from './xads'; + +const getQueuedCommands = () => { + return ((window.twq?.queue ?? []) as unknown[][]).map((args) => [...args]); +}; + +describe('XAdsAnalyticsProvider', () => { + beforeEach(() => { + document.head.innerHTML = ''; + vi.restoreAllMocks(); + + delete window.twq; + delete window.__LOBE_ANALYTICS_X_ADS_STATE__; + }); + + it('should initialize X Ads only once', async () => { + const provider = new XAdsAnalyticsProvider( + { + debug: true, + enabled: true, + pixelId: 'tw-pixel_123', + purchaseEventId: 'tw-pixel_123-purchase_456', + }, + 'test', + ); + + await provider.initialize(); + await provider.initialize(); + + const scripts = document.querySelectorAll( + 'script[src="https://static.ads-twitter.com/uwt.js"]', + ); + const queuedCommands = getQueuedCommands(); + const configCommands = queuedCommands.filter(([command]) => command === 'config'); + + expect(scripts).toHaveLength(1); + expect(configCommands).toEqual([['config', 'tw-pixel_123']]); + }); + + it('should track purchase events with mapped parameters', async () => { + const provider = new XAdsAnalyticsProvider( + { + enabled: true, + pixelId: 'tw-pixel_123', + purchaseEventId: 'tw-pixel_123-purchase_456', + }, + 'test', + ); + + await provider.initialize(); + await provider.track({ + name: 'purchase', + properties: { + currency: 'USD', + items: [ + { + item_id: 'plan_pro', + item_name: 'Pro Plan', + price: 29.9, + quantity: 1, + }, + ], + transaction_id: 'sub_123', + value: 29.9, + }, + }); + + const queuedCommands = getQueuedCommands(); + const purchaseCommand = queuedCommands.find( + ([command, eventId]) => command === 'event' && eventId === 'tw-pixel_123-purchase_456', + ); + + expect(purchaseCommand).toEqual([ + 'event', + 'tw-pixel_123-purchase_456', + { + contents: [ + { + content_id: 'plan_pro', + content_name: 'Pro Plan', + content_price: 29.9, + num_items: 1, + }, + ], + conversion_id: 'sub_123', + currency: 'USD', + value: 29.9, + }, + ]); + }); + + it('should normalize currency codes before tracking purchase events', async () => { + const provider = new XAdsAnalyticsProvider( + { + enabled: true, + pixelId: 'tw-pixel_123', + purchaseEventId: 'tw-pixel_123-purchase_456', + }, + 'test', + ); + + await provider.initialize(); + await provider.track({ + name: 'purchase', + properties: { + currency: 'usd', + transaction_id: 'sub_123', + value: 29.9, + }, + }); + + const queuedCommands = getQueuedCommands(); + const purchaseCommand = queuedCommands.find( + ([command, eventId]) => command === 'event' && eventId === 'tw-pixel_123-purchase_456', + ); + + expect(purchaseCommand).toEqual([ + 'event', + 'tw-pixel_123-purchase_456', + { + conversion_id: 'sub_123', + currency: 'USD', + value: 29.9, + }, + ]); + }); + + it('should skip purchase tracking when purchaseEventId is missing', async () => { + const provider = new XAdsAnalyticsProvider( + { + enabled: true, + pixelId: 'tw-pixel_123', + }, + 'test', + ); + + await provider.initialize(); + await provider.track({ + name: 'purchase', + properties: { + currency: 'USD', + transaction_id: 'sub_123', + value: 29.9, + }, + }); + + const queuedCommands = getQueuedCommands(); + const purchaseCommands = queuedCommands.filter(([command]) => command === 'event'); + + expect(purchaseCommands).toHaveLength(0); + }); + + it('should track configured custom events', async () => { + const provider = new XAdsAnalyticsProvider( + { + eventIds: { + login_or_signup_clicked: 'tw-pixel_123-custom_789', + main_page_view: 'tw-pixel_123-custom_999', + }, + enabled: true, + pixelId: 'tw-pixel_123', + }, + 'test', + ); + + await provider.initialize(); + await provider.track({ + name: 'main_page_view', + properties: { + spm: 'main_page.interface.view', + }, + }); + await provider.track({ + name: 'login_or_signup_clicked', + properties: { + spm: 'homepage.login_or_signup.click', + }, + }); + + const queuedCommands = getQueuedCommands(); + const eventCommands = queuedCommands.filter(([command]) => command === 'event'); + + expect(eventCommands).toEqual([ + ['event', 'tw-pixel_123-custom_999'], + ['event', 'tw-pixel_123-custom_789'], + ]); + }); + + it('should skip unconfigured custom events', async () => { + const provider = new XAdsAnalyticsProvider( + { + enabled: true, + pixelId: 'tw-pixel_123', + purchaseEventId: 'tw-pixel_123-purchase_456', + }, + 'test', + ); + + await provider.initialize(); + await provider.track({ + name: 'main_page_view', + properties: { + spm: 'main_page.interface.view', + }, + }); + + const queuedCommands = getQueuedCommands(); + const eventCommands = queuedCommands.filter(([command]) => command === 'event'); + + expect(eventCommands).toHaveLength(0); + }); + + it('should no-op when pixelId is missing', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const provider = new XAdsAnalyticsProvider( + { + enabled: true, + pixelId: '', + purchaseEventId: 'tw-pixel_123-purchase_456', + }, + 'test', + ); + + await provider.initialize(); + + expect( + document.querySelector('script[src="https://static.ads-twitter.com/uwt.js"]'), + ).toBeNull(); + expect(window.twq).toBeUndefined(); + expect(errorSpy).toHaveBeenCalledWith('[X Ads] X Ads pixelId is required', ''); + }); +}); diff --git a/src/providers/xads.ts b/src/providers/xads.ts new file mode 100644 index 0000000..622e8f9 --- /dev/null +++ b/src/providers/xads.ts @@ -0,0 +1,285 @@ +import { BaseAnalytics } from '@/base'; +import type { AnalyticsEvent, XAdsProviderAnalyticsConfig } from '@/types'; + +const X_ADS_SCRIPT_SELECTOR = 'script[src="https://static.ads-twitter.com/uwt.js"]'; +const X_ADS_SCRIPT_SRC = 'https://static.ads-twitter.com/uwt.js'; + +interface XAdsContentItem { + content_id?: string; + content_name?: string; + content_price?: number; + num_items?: number; +} + +interface XAdsEventParameters { + contents?: XAdsContentItem[]; + conversion_id?: string; + currency?: string; + value?: number; +} + +interface XAdsFunction { + (...args: unknown[]): void; + exe?: (...args: unknown[]) => void; + queue?: unknown[][]; + version?: string; +} + +interface XAdsGlobalState { + configuredPixelIds: Set; + scriptRequested: boolean; +} + +declare global { + interface Window { + __LOBE_ANALYTICS_X_ADS_STATE__?: XAdsGlobalState; + twq?: XAdsFunction; + } +} + +/** + * X Ads Analytics Provider + * Uses X Pixel (twq + uwt.js) for client-side conversion tracking + */ +export class XAdsAnalyticsProvider extends BaseAnalytics { + private readonly config: XAdsProviderAnalyticsConfig; + private initialized = false; + + constructor(config: XAdsProviderAnalyticsConfig, business: string) { + super({ business, debug: config.debug, enabled: config.enabled }); + this.config = config; + } + + getProviderName(): string { + return 'X Ads'; + } + + async initialize(): Promise { + if (!this.isEnabled() || this.initialized) { + return; + } + + if (typeof window === 'undefined') { + this.logError('X Ads provider requires browser environment'); + return; + } + + if (!this.config.pixelId) { + this.logError('X Ads pixelId is required'); + return; + } + + try { + const twq = this.ensureTwq(); + const state = this.getGlobalState(); + + this.ensureScript(state); + + if (!state.configuredPixelIds.has(this.config.pixelId)) { + twq('config', this.config.pixelId); + state.configuredPixelIds.add(this.config.pixelId); + } + + this.initialized = true; + this.log('X Ads initialized successfully'); + this.log(`Pixel ID: ${this.config.pixelId}`); + } catch (error) { + this.logError('Failed to initialize X Ads', error); + throw error; + } + } + + async track(event: AnalyticsEvent): Promise { + if (!this.isEnabled() || !this.initialized || !this.validateEvent(event)) { + return; + } + + const eventId = this.getEventId(event.name); + if (!eventId) { + this.log(`Skipping event: ${event.name} is not configured for X Ads`); + return; + } + + const twq = window.twq; + if (!twq) { + this.logError('twq function not available'); + return; + } + + try { + const eventParams = this.getEventParameters(event); + + if (eventParams) { + twq('event', eventId, eventParams); + } else { + twq('event', eventId); + } + + this.log(`Tracked event: ${event.name}`, { + event: event.name, + eventId, + ...eventParams, + }); + } catch (error) { + this.logError(`Failed to track event: ${event.name}`, error); + } + } + + async identify(): Promise { + this.log('Identify is not supported in X Ads provider'); + } + + async trackPageView(): Promise { + this.log('Page views are handled by the X Pixel base code'); + } + + async reset(): Promise { + this.log('Reset is not supported in X Ads provider'); + } + + private ensureScript(state: XAdsGlobalState) { + if (state.scriptRequested || document.querySelector(X_ADS_SCRIPT_SELECTOR)) { + state.scriptRequested = true; + return; + } + + const script = document.createElement('script'); + script.async = true; + script.src = X_ADS_SCRIPT_SRC; + document.head.append(script); + + state.scriptRequested = true; + } + + private ensureTwq(): XAdsFunction { + if (window.twq) { + return window.twq; + } + + const twq: XAdsFunction = (...args: unknown[]) => { + if (twq.exe) { + twq.exe(...args); + return; + } + + const queue = twq.queue || []; + queue.push(args); + twq.queue = queue; + }; + + twq.version = '1.1'; + twq.queue = []; + + window.twq = twq; + + return twq; + } + + private getGlobalState(): XAdsGlobalState { + if (!window.__LOBE_ANALYTICS_X_ADS_STATE__) { + window.__LOBE_ANALYTICS_X_ADS_STATE__ = { + configuredPixelIds: new Set(), + scriptRequested: false, + }; + } + + return window.__LOBE_ANALYTICS_X_ADS_STATE__; + } + + private getEventId(eventName: string): string | undefined { + if (eventName === 'purchase') { + return this.config.purchaseEventId ?? this.config.eventIds?.[eventName]; + } + + return this.config.eventIds?.[eventName]; + } + + private getEventParameters(event: AnalyticsEvent): XAdsEventParameters | undefined { + if (event.name === 'purchase') { + return this.mapPurchaseEventParameters(event.properties); + } + + return undefined; + } + + private mapPurchaseEventParameters(properties?: Record): XAdsEventParameters { + const eventParams: XAdsEventParameters = {}; + + const currency = properties?.currency; + if (typeof currency === 'string' && currency.trim()) { + eventParams.currency = currency.trim().toUpperCase(); + } + + const value = this.toFiniteNumber(properties?.value); + if (value !== undefined) { + eventParams.value = value; + } + + const transactionId = properties?.transaction_id; + if (typeof transactionId === 'string' || typeof transactionId === 'number') { + eventParams.conversion_id = String(transactionId); + } + + const contents = this.mapContents(properties?.items); + if (contents.length > 0) { + eventParams.contents = contents; + } + + return eventParams; + } + + private mapContents(items: unknown): XAdsContentItem[] { + if (!Array.isArray(items)) { + return []; + } + + const contents = items + .map((item) => { + if (!item || typeof item !== 'object') { + return null; + } + + const content = item as Record; + const contentItem: XAdsContentItem = {}; + const contentPrice = this.toFiniteNumber(content.price); + const numItems = this.toFiniteNumber(content.quantity); + + if (typeof content.item_id === 'string' && content.item_id.trim()) { + contentItem.content_id = content.item_id; + } + + if (typeof content.item_name === 'string' && content.item_name.trim()) { + contentItem.content_name = content.item_name; + } + + if (contentPrice !== undefined) { + contentItem.content_price = contentPrice; + } + + if (numItems !== undefined) { + contentItem.num_items = numItems; + } + + return Object.keys(contentItem).length > 0 ? contentItem : null; + }) + .filter((content): content is XAdsContentItem => content !== null); + + return contents; + } + + private toFiniteNumber(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value); + + if (Number.isFinite(parsed)) { + return parsed; + } + } + + return undefined; + } +} diff --git a/src/types.ts b/src/types.ts index 65fd329..08c0ba1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -91,6 +91,16 @@ export interface GoogleAnalyticsProviderConfig extends ProviderConfig { measurementId: string; } +export interface XAdsEventIdMap { + [eventName: string]: string | undefined; +} + +export interface XAdsProviderAnalyticsConfig extends ProviderConfig { + eventIds?: XAdsEventIdMap; + pixelId: string; + purchaseEventId?: string; +} + // Main analytics configuration export interface AnalyticsConfig { business: string; @@ -100,6 +110,7 @@ export interface AnalyticsConfig { posthog?: PostHogProviderAnalyticsConfig; posthogNode?: PostHogNodeProviderAnalyticsConfig; umami?: UmamiProviderAnalyticsConfig; + xAds?: XAdsProviderAnalyticsConfig; // add more providers here }; } @@ -109,6 +120,7 @@ export interface ProviderTypeMap { ga4: import('./providers/ga4').GoogleAnalyticsProvider; posthog: import('./providers/posthog').PostHogAnalyticsProvider; posthogNode: import('./providers/posthog-node').PostHogNodeAnalyticsProvider; + xAds: import('./providers/xads').XAdsAnalyticsProvider; // Add more providers as they are implemented // umami: import('./providers/umami').UmamiAnalyticsProvider; // ga: import('./providers/ga').GoogleAnalyticsProvider;