diff --git a/.env.local.example b/.env.local.example index ec78d73..d081a52 100644 --- a/.env.local.example +++ b/.env.local.example @@ -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 diff --git a/README.md b/README.md index 8ed38cb..3ec867e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/api/debug-env/route.ts b/app/api/debug-env/route.ts new file mode 100644 index 0000000..bb1ce75 --- /dev/null +++ b/app/api/debug-env/route.ts @@ -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', + }); +} diff --git a/app/api/x-conversion/route.ts b/app/api/x-conversion/route.ts new file mode 100644 index 0000000..0a334bc --- /dev/null +++ b/app/api/x-conversion/route.ts @@ -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 } + ); + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 241eb28..185d6fa 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -83,6 +83,17 @@ export default function RootLayout({ `} )} + {/* X (Twitter) Pixel */} + {process.env.NEXT_PUBLIC_X_PIXEL_ID && ( + + )} diff --git a/lib/analytics.ts b/lib/analytics.ts index 7211029..bfd86cf 100644 --- a/lib/analytics.ts +++ b/lib/analytics.ts @@ -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?: ( @@ -15,6 +15,7 @@ declare global { ) => void; dataLayer?: any[]; rdt?: (command: string, ...args: any[]) => void; + twq?: (command: string, ...args: any[]) => void; } } @@ -73,6 +74,48 @@ async function trackRedditConversion( } } +/** + * Safely send an event to X (Twitter) Pixel + */ +function trackXEvent(eventId: string, metadata?: Record) { + 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 +) { + 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) // ============================================================================ @@ -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'); } /** @@ -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'); } } @@ -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, + }); } /** @@ -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, + }); } /** @@ -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', + }); } /** @@ -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', + }); } /**