Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -39,6 +40,8 @@ export type {
PredefinedEvents,
ProviderConfig,
UmamiProviderAnalyticsConfig,
XAdsEventIdMap,
XAdsProviderAnalyticsConfig,
} from './types';

// Default export
Expand Down
235 changes: 235 additions & 0 deletions src/providers/xads.test.ts
Original file line number Diff line number Diff line change
@@ -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', '');
});
});
Loading
Loading