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
15 changes: 15 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,18 @@ NEXT_PUBLIC_REDDIT_PIXEL_ID=a2_iom6tk9tutrs
# Get this from: https://ads.reddit.com → Settings → API
# Format: Bearer token
REDDIT_CONVERSIONS_API_TOKEN=your_reddit_api_token_here

# =============================================================================
# X ADS (TWITTER) - Pixel & Conversions API
# =============================================================================
# X Pixel ID (used in both client-side pixel and server-side Conversions API)
# Get this from: https://ads.x.com → Events Manager → Your Pixel
# Format: tw-xxxxx-xxxxxx (appears in pixel code)
# NEXT_PUBLIC_ prefix required for browser access
NEXT_PUBLIC_X_PIXEL_ID=r9lkr

# X Conversions API Access Token (server-side only)
# Get this from: https://ads.x.com → Events Manager → Your Pixel → Settings → Conversions API → Generate Access Token
# Format: Long OAuth 2.0 Bearer token (e.g., AAAAAAAAAAAAAAAAAAAAAMLheAAAAAAA0%2BuSeid...)
# IMPORTANT: This is different from your main X API token - it's specifically for Conversions API
X_CONVERSIONS_API_TOKEN=your_x_api_token_here
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,11 @@ npm start
- `NEXT_PUBLIC_REDDIT_PIXEL_ID`: Reddit Pixel ID for tracking conversions (client + server)
- `REDDIT_CONVERSIONS_API_TOKEN`: Reddit Conversions API access token (server-side only)

**X Ads (Optional):**
- `NEXT_PUBLIC_X_PIXEL_ID`: X (Twitter) Pixel ID for tracking conversions (client + server)
- `X_CONVERSIONS_API_TOKEN`: X Conversions API OAuth 2.0 access token (server-side only)
- Get from: X Ads Manager → Events Manager → Your Pixel → Settings → Conversions API → Generate Access Token

See `.env.local.example` for complete configuration details.

### Database Schema
Expand Down
10 changes: 10 additions & 0 deletions app/api/debug-env/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { NextResponse } from 'next/server';

export async function GET() {
return NextResponse.json({
X_PIXEL_ID: process.env.NEXT_PUBLIC_X_PIXEL_ID,
X_TOKEN_EXISTS: !!process.env.X_CONVERSIONS_API_TOKEN,
X_TOKEN_LENGTH: process.env.X_CONVERSIONS_API_TOKEN?.length || 0,
X_TOKEN_PREFIX: process.env.X_CONVERSIONS_API_TOKEN?.substring(0, 20) || 'MISSING',
});
}
109 changes: 109 additions & 0 deletions app/api/x-conversion/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* X (Twitter) Conversions API endpoint
*
* Server-side conversion tracking for X Ads
* Sends conversion events with match keys for improved attribution
*/

import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
try {
const { eventType, metadata } = await request.json();

// Get X API credentials from environment
const pixelId = process.env.NEXT_PUBLIC_X_PIXEL_ID;
const accessToken = process.env.X_CONVERSIONS_API_TOKEN;

// Debug: Log env var status
console.log('[X Conversion] Env check:', {
pixelId,
hasToken: !!accessToken,
tokenLength: accessToken?.length,
});

if (!pixelId || !accessToken) {
console.warn('[X Conversion] X API not configured');
return NextResponse.json(
{ error: 'X Conversions API not configured' },
{ status: 500 }
);
}

// Extract match keys from request
const ip = request.headers.get('x-forwarded-for')?.split(',')[0] ||
request.headers.get('x-real-ip') ||
'unknown';
const userAgent = request.headers.get('user-agent') || 'unknown';

// Build conversion event payload according to X API spec
const event: any = {
conversion_time: new Date().toISOString(),
event_id: metadata?.conversion_id || `${eventType}_${Date.now()}`,
identifiers: [],
};

// Add match keys (identifiers)
if (ip !== 'unknown') {
event.identifiers.push({
hashed_ip_address: ip, // X will hash this server-side
});
}

// Add conversion metadata
if (eventType === 'Purchase' && metadata?.value) {
event.conversion_value = metadata.value;
event.currency = metadata.currency || 'USD';
}

const payload = {
conversions: [
{
conversion_event: eventType,
...event,
},
],
};

// Debug logging
console.log('[X Conversion] Sending request:', {
url: `https://ads-api.x.com/12/measurement/conversions/${pixelId}`,
eventType,
hasAccessToken: !!accessToken,
accessTokenPrefix: accessToken.substring(0, 20) + '...',
payload: JSON.stringify(payload, null, 2),
});

// Send to X Conversions API
const response = await fetch(
`https://ads-api.x.com/12/measurement/conversions/${pixelId}`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
}
);

if (!response.ok) {
const errorText = await response.text();
console.error('[X Conversion] API error:', response.status, errorText);
return NextResponse.json(
{ error: 'Failed to send conversion event' },
{ status: response.status }
);
}

const result = await response.json();
return NextResponse.json({ success: true, result });

} catch (error) {
console.error('[X Conversion] Error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
11 changes: 11 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,17 @@ export default function RootLayout({
`}
</Script>
)}
{/* X (Twitter) Pixel */}
{process.env.NEXT_PUBLIC_X_PIXEL_ID && (
<Script id="x-pixel" strategy="afterInteractive">
{`
!function(e,t,n,s,u,a){e.twq||(s=e.twq=function(){s.exe?s.exe.apply(s,arguments):s.queue.push(arguments);
},s.version='1.1',s.queue=[],u=t.createElement(n),u.async=!0,u.src='https://static.ads-twitter.com/uwt.js',
a=t.getElementsByTagName(n)[0],a.parentNode.insertBefore(u,a))}(window,document,'script');
twq('config','${process.env.NEXT_PUBLIC_X_PIXEL_ID}');
`}
</Script>
)}
</head>
<body>
<AuthProvider>
Expand Down
93 changes: 92 additions & 1 deletion lib/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* Privacy-compliant with no PII tracking.
*/

// Extend Window interface to include gtag and rdt
// Extend Window interface to include gtag, rdt, and twq
declare global {
interface Window {
gtag?: (
Expand All @@ -15,6 +15,7 @@ declare global {
) => void;
dataLayer?: any[];
rdt?: (command: string, ...args: any[]) => void;
twq?: (command: string, ...args: any[]) => void;
}
}

Expand Down Expand Up @@ -73,6 +74,48 @@ async function trackRedditConversion(
}
}

/**
* Safely send an event to X (Twitter) Pixel
*/
function trackXEvent(eventId: string, metadata?: Record<string, any>) {
if (typeof window !== 'undefined' && window.twq) {
try {
if (metadata) {
window.twq('track', eventId, metadata);
} else {
window.twq('track', eventId);
}
} catch (error) {
console.warn('X Pixel tracking failed:', error);
}
}
}

/**
* Send event to X Conversions API (server-side)
*/
async function trackXConversion(
eventType: string,
metadata?: Record<string, any>
) {
if (typeof window !== 'undefined') {
try {
await fetch('/api/x-conversion', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
eventType,
metadata,
}),
});
} catch (error) {
console.warn('X Conversions API tracking failed:', error);
}
}
}

// ============================================================================
// CORE USER JOURNEY EVENTS (12 total)
// ============================================================================
Expand Down Expand Up @@ -118,6 +161,10 @@ export function trackExploreTabViewed() {
// Track as ViewContent event on Reddit
trackRedditEvent('ViewContent');
trackRedditConversion('ViewContent');

// Track as ViewContent event on X
trackXEvent('tw-r9lkr-rbs61');
trackXConversion('ViewContent');
}

/**
Expand All @@ -132,6 +179,10 @@ export function trackQueryRun(resultCount: number, shouldTrackReddit: boolean =
if (shouldTrackReddit) {
trackRedditEvent('Search');
trackRedditConversion('Search');

// Track as Search event on X
trackXEvent('tw-r9lkr-138c4u');
trackXConversion('Search');
}
}

Expand Down Expand Up @@ -171,6 +222,14 @@ export function trackGenotypeFileLoaded(fileSize: number, variantCount: number)
trackRedditConversion('Lead', {
conversion_id: conversionId,
});

// Track as Lead event on X
trackXEvent('tw-r9lkr-138c4w', {
conversion_id: conversionId,
});
trackXConversion('Lead', {
conversion_id: conversionId,
});
}

/**
Expand Down Expand Up @@ -212,6 +271,14 @@ export function trackUserLoggedIn() {
trackRedditConversion('SignUp', {
conversion_id: conversionId,
});

// Track as SignUp event on X
trackXEvent('tw-r9lkr-138c4x', {
conversion_id: conversionId,
});
trackXConversion('SignUp', {
conversion_id: conversionId,
});
}

/**
Expand Down Expand Up @@ -272,6 +339,18 @@ export function trackSubscribedWithCreditCard(durationDays: number) {
value: value,
item_count: 1,
});

// Track as Purchase event on X
trackXEvent('tw-r9lkr-138c4y', {
conversion_id: conversionId,
value: value.toFixed(2),
currency: 'USD',
});
trackXConversion('Purchase', {
conversion_id: conversionId,
value: value,
currency: 'USD',
});
}

/**
Expand All @@ -298,6 +377,18 @@ export function trackSubscribedWithStablecoin(durationDays: number) {
value: value,
item_count: 1,
});

// Track as Purchase event on X
trackXEvent('tw-r9lkr-138c4y', {
conversion_id: conversionId,
value: value.toFixed(2),
currency: 'USD',
});
trackXConversion('Purchase', {
conversion_id: conversionId,
value: value,
currency: 'USD',
});
}

/**
Expand Down
Loading