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 */