diff --git a/.env.local.example b/.env.local.example index 175bc91..ec78d73 100644 --- a/.env.local.example +++ b/.env.local.example @@ -105,3 +105,17 @@ NEXT_PUBLIC_SUBSCRIPTION_CACHE_HOURS=1 # ANALYTICS (optional) # ============================================================================= # NEXT_PUBLIC_PLAUSIBLE_DOMAIN=your_domain.com + +# ============================================================================= +# REDDIT ADS - Pixel & Conversions API +# ============================================================================= +# Reddit Pixel ID (used in both client-side pixel and server-side Conversions API) +# Get this from: https://ads.reddit.com → Pixels & Events +# Format: a2_... +# NEXT_PUBLIC_ prefix required for browser access +NEXT_PUBLIC_REDDIT_PIXEL_ID=a2_iom6tk9tutrs + +# Reddit Conversions API Access Token (server-side only) +# Get this from: https://ads.reddit.com → Settings → API +# Format: Bearer token +REDDIT_CONVERSIONS_API_TOKEN=your_reddit_api_token_here diff --git a/README.md b/README.md index 15ea6ba..8ed38cb 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,10 @@ npm start - `NEXT_PUBLIC_EVM_PAYMENT_WALLET_ADDRESS`: EVM wallet address where users send ETH/USDC payments - `NEXT_PUBLIC_SUBSCRIPTION_CACHE_HOURS`: Cache duration in hours (default: 1) +**Reddit Ads (Optional):** +- `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) + See `.env.local.example` for complete configuration details. ### Database Schema diff --git a/app/api/reddit-conversion/route.ts b/app/api/reddit-conversion/route.ts new file mode 100644 index 0000000..5c0c9b0 --- /dev/null +++ b/app/api/reddit-conversion/route.ts @@ -0,0 +1,88 @@ +/** + * Reddit Conversions API endpoint + * + * Server-side conversion tracking for Reddit 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 Reddit API credentials from environment + const pixelId = process.env.NEXT_PUBLIC_REDDIT_PIXEL_ID; + const accessToken = process.env.REDDIT_CONVERSIONS_API_TOKEN; + + if (!pixelId || !accessToken) { + console.warn('[Reddit Conversion] Reddit API not configured'); + return NextResponse.json( + { error: 'Reddit 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 + // Remove undefined fields as Reddit API may reject them + const event: any = { + event_at: Date.now(), + action_source: 'WEBSITE', + type: { + tracking_type: eventType, // 'SignUp' or 'Purchase' + }, + }; + + // Add optional fields only if they have values + const user: any = {}; + if (ip !== 'unknown') user.ip_address = ip; + if (userAgent !== 'unknown') user.user_agent = userAgent; + if (Object.keys(user).length > 0) event.user = user; + + if (metadata) event.metadata = metadata; + + const payload = { + data: { + events: [event], + }, + }; + + // Send to Reddit Conversions API + const response = await fetch( + `https://ads-api.reddit.com/api/v3/pixels/${pixelId}/conversion_events`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error('[Reddit 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('[Reddit Conversion] Error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 2ddb51a..241eb28 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -73,6 +73,16 @@ export default function RootLayout({ }); `} + {/* Reddit Pixel */} + {process.env.NEXT_PUBLIC_REDDIT_PIXEL_ID && ( + + )} diff --git a/app/page.tsx b/app/page.tsx index 9ab2f52..635bf7d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -26,6 +26,7 @@ import { trackSearch, trackRunAllStarted, trackQueryRun, + trackExploreTabViewed, } from "@/lib/analytics"; type SortOption = "relevance" | "power" | "recent" | "alphabetical"; @@ -212,6 +213,10 @@ function MainContent() { // Track client-side mounting to prevent hydration errors const [mounted, setMounted] = useState(false); + // Track if search change is user-initiated (for Reddit analytics) + const userInitiatedSearchRef = useRef(false); + const previousSearchRef = useRef(''); + useEffect(() => { setMounted(true); }, []); @@ -238,6 +243,13 @@ function MainContent() { } }, []); + // Track Explore tab view + useEffect(() => { + if (activeTab === 'explore' && mounted) { + trackExploreTabViewed(); + } + }, [activeTab, mounted]); + // Persist active tab in dev-mode useEffect(() => { if (isDevModeEnabled()) { @@ -340,6 +352,12 @@ function MainContent() { // Debounce search input to avoid excessive API calls useEffect(() => { + // Check if this is a user-initiated change + if (filters.search !== previousSearchRef.current) { + userInitiatedSearchRef.current = true; + previousSearchRef.current = filters.search; + } + const timer = setTimeout(() => { setDebouncedSearch(filters.search); }, 400); @@ -453,9 +471,10 @@ function MainContent() { const totalLoadTime = endTime - startTime; setLoadTime(totalLoadTime); - // Track search if there's a search query - if (debouncedSearch.trim()) { + // Track search if there's a search query and it was user-initiated + if (debouncedSearch.trim() && userInitiatedSearchRef.current) { trackSearch(debouncedSearch, filteredData.length, totalLoadTime); + userInitiatedSearchRef.current = false; // Reset flag after tracking } // Append results if offset > 0 (Load More), otherwise replace diff --git a/lib/analytics.ts b/lib/analytics.ts index b9fcfed..7211029 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 +// Extend Window interface to include gtag and rdt declare global { interface Window { gtag?: ( @@ -14,6 +14,7 @@ declare global { params?: Record ) => void; dataLayer?: any[]; + rdt?: (command: string, ...args: any[]) => void; } } @@ -30,6 +31,48 @@ function trackEvent(eventName: string, params?: Record) { } } +/** + * Safely send an event to Reddit Pixel + */ +function trackRedditEvent(eventName: string, metadata?: Record) { + if (typeof window !== 'undefined' && window.rdt) { + try { + if (metadata) { + window.rdt('track', eventName, metadata); + } else { + window.rdt('track', eventName); + } + } catch (error) { + console.warn('Reddit Pixel tracking failed:', error); + } + } +} + +/** + * Send event to Reddit Conversions API (server-side) + */ +async function trackRedditConversion( + eventType: string, + metadata?: Record +) { + if (typeof window !== 'undefined') { + try { + await fetch('/api/reddit-conversion', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + eventType, + metadata, + }), + }); + } catch (error) { + console.warn('Reddit Conversions API tracking failed:', error); + } + } +} + // ============================================================================ // CORE USER JOURNEY EVENTS (12 total) // ============================================================================ @@ -66,13 +109,30 @@ export function trackOnboardingStepViewed(step: string) { }); } +/** + * User viewed the Explore tab + */ +export function trackExploreTabViewed() { + trackEvent('explore_tab_viewed'); + + // Track as ViewContent event on Reddit + trackRedditEvent('ViewContent'); + trackRedditConversion('ViewContent'); +} + /** * User ran a search query to find studies */ -export function trackQueryRun(resultCount: number) { +export function trackQueryRun(resultCount: number, shouldTrackReddit: boolean = false) { trackEvent('query_run', { result_count: resultCount, }); + + // Only track as Search event on Reddit if explicitly requested (user-initiated search) + if (shouldTrackReddit) { + trackRedditEvent('Search'); + trackRedditConversion('Search'); + } } /** @@ -96,9 +156,20 @@ export function trackAIAnalysisRun() { * User loaded a genotype file (DNA data) */ export function trackGenotypeFileLoaded(fileSize: number, variantCount: number) { - trackEvent('genotype_file_loaded', { + const metadata = { file_size_kb: Math.round(fileSize / 1024), variant_count: variantCount, + }; + + trackEvent('genotype_file_loaded', metadata); + + // Track as Lead event on Reddit (DNA upload is a lead generation action) + const conversionId = `dna_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + trackRedditEvent('Lead', { + conversionId, + }); + trackRedditConversion('Lead', { + conversion_id: conversionId, }); } @@ -132,6 +203,15 @@ export function trackPremiumSectionViewed() { */ export function trackUserLoggedIn() { trackEvent('user_logged_in'); + + // Track as SignUp event on Reddit + const conversionId = `login_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + trackRedditEvent('SignUp', { + conversionId, + }); + trackRedditConversion('SignUp', { + conversion_id: conversionId, + }); } /** @@ -175,6 +255,23 @@ export function trackSubscribedWithCreditCard(durationDays: number) { trackEvent('subscribed_credit_card', { duration_days: durationDays, }); + + // Track as Purchase event on Reddit + const conversionId = `sub_cc_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const value = (durationDays / 30) * 4.99; // Monthly price is $4.99 + + trackRedditEvent('Purchase', { + conversionId, + currency: 'USD', + value: value, + item_count: 1, + }); + trackRedditConversion('Purchase', { + conversion_id: conversionId, + currency: 'USD', + value: value, + item_count: 1, + }); } /** @@ -184,6 +281,23 @@ export function trackSubscribedWithStablecoin(durationDays: number) { trackEvent('subscribed_stablecoin', { duration_days: durationDays, }); + + // Track as Purchase event on Reddit + const conversionId = `sub_crypto_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const value = (durationDays / 30) * 4.99; // Monthly price is $4.99 + + trackRedditEvent('Purchase', { + conversionId, + currency: 'USD', + value: value, + item_count: 1, + }); + trackRedditConversion('Purchase', { + conversion_id: conversionId, + currency: 'USD', + value: value, + item_count: 1, + }); } /** @@ -214,7 +328,8 @@ export function trackFileUploadSuccess(fileSize: number, variantCount: number) { /** @deprecated Use trackQueryRun instead */ export function trackSearch(query: string, resultCount: number, loadTime: number) { - trackQueryRun(resultCount); + // Pass true to indicate this is a user-initiated search (should track on Reddit) + trackQueryRun(resultCount, true); } /** @deprecated Use trackMatchRevealed instead */