Skip to content

Commit 71e5119

Browse files
authored
Reddit Ads Integration (#35)
* Initial Reddit Ads Integration * Fixed env var * Updated README to reflect Reddit Ads integration. * Refactor Reddit conversion payload to exclude undefined fields and simplify user data handling. * Add Reddit analytics tracking for Explore tab and improve search event tracking - Introduced `trackExploreTabViewed` to monitor Explore tab views. - Enhanced `trackQueryRun` to support optional Reddit tracking. - Updated search tracking to ensure only user-initiated searches are logged. - Changed Reddit conversion event from `SignUp` to `Lead` for DNA uploads. - Refined handling of search filters and user actions to improve analytics accuracy.
1 parent 5e5fb4b commit 71e5119

6 files changed

Lines changed: 256 additions & 6 deletions

File tree

.env.local.example

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,17 @@ NEXT_PUBLIC_SUBSCRIPTION_CACHE_HOURS=1
105105
# ANALYTICS (optional)
106106
# =============================================================================
107107
# NEXT_PUBLIC_PLAUSIBLE_DOMAIN=your_domain.com
108+
109+
# =============================================================================
110+
# REDDIT ADS - Pixel & Conversions API
111+
# =============================================================================
112+
# Reddit Pixel ID (used in both client-side pixel and server-side Conversions API)
113+
# Get this from: https://ads.reddit.com → Pixels & Events
114+
# Format: a2_...
115+
# NEXT_PUBLIC_ prefix required for browser access
116+
NEXT_PUBLIC_REDDIT_PIXEL_ID=a2_iom6tk9tutrs
117+
118+
# Reddit Conversions API Access Token (server-side only)
119+
# Get this from: https://ads.reddit.com → Settings → API
120+
# Format: Bearer token
121+
REDDIT_CONVERSIONS_API_TOKEN=your_reddit_api_token_here

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,10 @@ npm start
149149
- `NEXT_PUBLIC_EVM_PAYMENT_WALLET_ADDRESS`: EVM wallet address where users send ETH/USDC payments
150150
- `NEXT_PUBLIC_SUBSCRIPTION_CACHE_HOURS`: Cache duration in hours (default: 1)
151151

152+
**Reddit Ads (Optional):**
153+
- `NEXT_PUBLIC_REDDIT_PIXEL_ID`: Reddit Pixel ID for tracking conversions (client + server)
154+
- `REDDIT_CONVERSIONS_API_TOKEN`: Reddit Conversions API access token (server-side only)
155+
152156
See `.env.local.example` for complete configuration details.
153157

154158
### Database Schema

app/api/reddit-conversion/route.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* Reddit Conversions API endpoint
3+
*
4+
* Server-side conversion tracking for Reddit Ads
5+
* Sends conversion events with match keys for improved attribution
6+
*/
7+
8+
import { NextRequest, NextResponse } from 'next/server';
9+
10+
export async function POST(request: NextRequest) {
11+
try {
12+
const { eventType, metadata } = await request.json();
13+
14+
// Get Reddit API credentials from environment
15+
const pixelId = process.env.NEXT_PUBLIC_REDDIT_PIXEL_ID;
16+
const accessToken = process.env.REDDIT_CONVERSIONS_API_TOKEN;
17+
18+
if (!pixelId || !accessToken) {
19+
console.warn('[Reddit Conversion] Reddit API not configured');
20+
return NextResponse.json(
21+
{ error: 'Reddit Conversions API not configured' },
22+
{ status: 500 }
23+
);
24+
}
25+
26+
// Extract match keys from request
27+
const ip = request.headers.get('x-forwarded-for')?.split(',')[0] ||
28+
request.headers.get('x-real-ip') ||
29+
'unknown';
30+
const userAgent = request.headers.get('user-agent') || 'unknown';
31+
32+
// Build conversion event payload
33+
// Remove undefined fields as Reddit API may reject them
34+
const event: any = {
35+
event_at: Date.now(),
36+
action_source: 'WEBSITE',
37+
type: {
38+
tracking_type: eventType, // 'SignUp' or 'Purchase'
39+
},
40+
};
41+
42+
// Add optional fields only if they have values
43+
const user: any = {};
44+
if (ip !== 'unknown') user.ip_address = ip;
45+
if (userAgent !== 'unknown') user.user_agent = userAgent;
46+
if (Object.keys(user).length > 0) event.user = user;
47+
48+
if (metadata) event.metadata = metadata;
49+
50+
const payload = {
51+
data: {
52+
events: [event],
53+
},
54+
};
55+
56+
// Send to Reddit Conversions API
57+
const response = await fetch(
58+
`https://ads-api.reddit.com/api/v3/pixels/${pixelId}/conversion_events`,
59+
{
60+
method: 'POST',
61+
headers: {
62+
'Authorization': `Bearer ${accessToken}`,
63+
'Content-Type': 'application/json',
64+
},
65+
body: JSON.stringify(payload),
66+
}
67+
);
68+
69+
if (!response.ok) {
70+
const errorText = await response.text();
71+
console.error('[Reddit Conversion] API error:', response.status, errorText);
72+
return NextResponse.json(
73+
{ error: 'Failed to send conversion event' },
74+
{ status: response.status }
75+
);
76+
}
77+
78+
const result = await response.json();
79+
return NextResponse.json({ success: true, result });
80+
81+
} catch (error) {
82+
console.error('[Reddit Conversion] Error:', error);
83+
return NextResponse.json(
84+
{ error: 'Internal server error' },
85+
{ status: 500 }
86+
);
87+
}
88+
}

app/layout.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,16 @@ export default function RootLayout({
7373
});
7474
`}
7575
</Script>
76+
{/* Reddit Pixel */}
77+
{process.env.NEXT_PUBLIC_REDDIT_PIXEL_ID && (
78+
<Script id="reddit-pixel" strategy="afterInteractive">
79+
{`
80+
!function(w,d){if(!w.rdt){var p=w.rdt=function(){p.sendEvent?p.sendEvent.apply(p,arguments):p.callQueue.push(arguments)};p.callQueue=[];var t=d.createElement("script");t.src="https://www.redditstatic.com/ads/pixel.js?pixel_id=${process.env.NEXT_PUBLIC_REDDIT_PIXEL_ID}",t.async=!0;var s=d.getElementsByTagName("script")[0];s.parentNode.insertBefore(t,s)}}(window,document);
81+
rdt('init','${process.env.NEXT_PUBLIC_REDDIT_PIXEL_ID}');
82+
rdt('track', 'PageVisit');
83+
`}
84+
</Script>
85+
)}
7686
</head>
7787
<body>
7888
<AuthProvider>

app/page.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
trackSearch,
2727
trackRunAllStarted,
2828
trackQueryRun,
29+
trackExploreTabViewed,
2930
} from "@/lib/analytics";
3031

3132
type SortOption = "relevance" | "power" | "recent" | "alphabetical";
@@ -212,6 +213,10 @@ function MainContent() {
212213
// Track client-side mounting to prevent hydration errors
213214
const [mounted, setMounted] = useState(false);
214215

216+
// Track if search change is user-initiated (for Reddit analytics)
217+
const userInitiatedSearchRef = useRef(false);
218+
const previousSearchRef = useRef('');
219+
215220
useEffect(() => {
216221
setMounted(true);
217222
}, []);
@@ -238,6 +243,13 @@ function MainContent() {
238243
}
239244
}, []);
240245

246+
// Track Explore tab view
247+
useEffect(() => {
248+
if (activeTab === 'explore' && mounted) {
249+
trackExploreTabViewed();
250+
}
251+
}, [activeTab, mounted]);
252+
241253
// Persist active tab in dev-mode
242254
useEffect(() => {
243255
if (isDevModeEnabled()) {
@@ -340,6 +352,12 @@ function MainContent() {
340352

341353
// Debounce search input to avoid excessive API calls
342354
useEffect(() => {
355+
// Check if this is a user-initiated change
356+
if (filters.search !== previousSearchRef.current) {
357+
userInitiatedSearchRef.current = true;
358+
previousSearchRef.current = filters.search;
359+
}
360+
343361
const timer = setTimeout(() => {
344362
setDebouncedSearch(filters.search);
345363
}, 400);
@@ -453,9 +471,10 @@ function MainContent() {
453471
const totalLoadTime = endTime - startTime;
454472
setLoadTime(totalLoadTime);
455473

456-
// Track search if there's a search query
457-
if (debouncedSearch.trim()) {
474+
// Track search if there's a search query and it was user-initiated
475+
if (debouncedSearch.trim() && userInitiatedSearchRef.current) {
458476
trackSearch(debouncedSearch, filteredData.length, totalLoadTime);
477+
userInitiatedSearchRef.current = false; // Reset flag after tracking
459478
}
460479

461480
// Append results if offset > 0 (Load More), otherwise replace

lib/analytics.ts

Lines changed: 119 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* Privacy-compliant with no PII tracking.
66
*/
77

8-
// Extend Window interface to include gtag
8+
// Extend Window interface to include gtag and rdt
99
declare global {
1010
interface Window {
1111
gtag?: (
@@ -14,6 +14,7 @@ declare global {
1414
params?: Record<string, any>
1515
) => void;
1616
dataLayer?: any[];
17+
rdt?: (command: string, ...args: any[]) => void;
1718
}
1819
}
1920

@@ -30,6 +31,48 @@ function trackEvent(eventName: string, params?: Record<string, any>) {
3031
}
3132
}
3233

34+
/**
35+
* Safely send an event to Reddit Pixel
36+
*/
37+
function trackRedditEvent(eventName: string, metadata?: Record<string, any>) {
38+
if (typeof window !== 'undefined' && window.rdt) {
39+
try {
40+
if (metadata) {
41+
window.rdt('track', eventName, metadata);
42+
} else {
43+
window.rdt('track', eventName);
44+
}
45+
} catch (error) {
46+
console.warn('Reddit Pixel tracking failed:', error);
47+
}
48+
}
49+
}
50+
51+
/**
52+
* Send event to Reddit Conversions API (server-side)
53+
*/
54+
async function trackRedditConversion(
55+
eventType: string,
56+
metadata?: Record<string, any>
57+
) {
58+
if (typeof window !== 'undefined') {
59+
try {
60+
await fetch('/api/reddit-conversion', {
61+
method: 'POST',
62+
headers: {
63+
'Content-Type': 'application/json',
64+
},
65+
body: JSON.stringify({
66+
eventType,
67+
metadata,
68+
}),
69+
});
70+
} catch (error) {
71+
console.warn('Reddit Conversions API tracking failed:', error);
72+
}
73+
}
74+
}
75+
3376
// ============================================================================
3477
// CORE USER JOURNEY EVENTS (12 total)
3578
// ============================================================================
@@ -66,13 +109,30 @@ export function trackOnboardingStepViewed(step: string) {
66109
});
67110
}
68111

112+
/**
113+
* User viewed the Explore tab
114+
*/
115+
export function trackExploreTabViewed() {
116+
trackEvent('explore_tab_viewed');
117+
118+
// Track as ViewContent event on Reddit
119+
trackRedditEvent('ViewContent');
120+
trackRedditConversion('ViewContent');
121+
}
122+
69123
/**
70124
* User ran a search query to find studies
71125
*/
72-
export function trackQueryRun(resultCount: number) {
126+
export function trackQueryRun(resultCount: number, shouldTrackReddit: boolean = false) {
73127
trackEvent('query_run', {
74128
result_count: resultCount,
75129
});
130+
131+
// Only track as Search event on Reddit if explicitly requested (user-initiated search)
132+
if (shouldTrackReddit) {
133+
trackRedditEvent('Search');
134+
trackRedditConversion('Search');
135+
}
76136
}
77137

78138
/**
@@ -96,9 +156,20 @@ export function trackAIAnalysisRun() {
96156
* User loaded a genotype file (DNA data)
97157
*/
98158
export function trackGenotypeFileLoaded(fileSize: number, variantCount: number) {
99-
trackEvent('genotype_file_loaded', {
159+
const metadata = {
100160
file_size_kb: Math.round(fileSize / 1024),
101161
variant_count: variantCount,
162+
};
163+
164+
trackEvent('genotype_file_loaded', metadata);
165+
166+
// Track as Lead event on Reddit (DNA upload is a lead generation action)
167+
const conversionId = `dna_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
168+
trackRedditEvent('Lead', {
169+
conversionId,
170+
});
171+
trackRedditConversion('Lead', {
172+
conversion_id: conversionId,
102173
});
103174
}
104175

@@ -132,6 +203,15 @@ export function trackPremiumSectionViewed() {
132203
*/
133204
export function trackUserLoggedIn() {
134205
trackEvent('user_logged_in');
206+
207+
// Track as SignUp event on Reddit
208+
const conversionId = `login_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
209+
trackRedditEvent('SignUp', {
210+
conversionId,
211+
});
212+
trackRedditConversion('SignUp', {
213+
conversion_id: conversionId,
214+
});
135215
}
136216

137217
/**
@@ -175,6 +255,23 @@ export function trackSubscribedWithCreditCard(durationDays: number) {
175255
trackEvent('subscribed_credit_card', {
176256
duration_days: durationDays,
177257
});
258+
259+
// Track as Purchase event on Reddit
260+
const conversionId = `sub_cc_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
261+
const value = (durationDays / 30) * 4.99; // Monthly price is $4.99
262+
263+
trackRedditEvent('Purchase', {
264+
conversionId,
265+
currency: 'USD',
266+
value: value,
267+
item_count: 1,
268+
});
269+
trackRedditConversion('Purchase', {
270+
conversion_id: conversionId,
271+
currency: 'USD',
272+
value: value,
273+
item_count: 1,
274+
});
178275
}
179276

180277
/**
@@ -184,6 +281,23 @@ export function trackSubscribedWithStablecoin(durationDays: number) {
184281
trackEvent('subscribed_stablecoin', {
185282
duration_days: durationDays,
186283
});
284+
285+
// Track as Purchase event on Reddit
286+
const conversionId = `sub_crypto_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
287+
const value = (durationDays / 30) * 4.99; // Monthly price is $4.99
288+
289+
trackRedditEvent('Purchase', {
290+
conversionId,
291+
currency: 'USD',
292+
value: value,
293+
item_count: 1,
294+
});
295+
trackRedditConversion('Purchase', {
296+
conversion_id: conversionId,
297+
currency: 'USD',
298+
value: value,
299+
item_count: 1,
300+
});
187301
}
188302

189303
/**
@@ -214,7 +328,8 @@ export function trackFileUploadSuccess(fileSize: number, variantCount: number) {
214328

215329
/** @deprecated Use trackQueryRun instead */
216330
export function trackSearch(query: string, resultCount: number, loadTime: number) {
217-
trackQueryRun(resultCount);
331+
// Pass true to indicate this is a user-initiated search (should track on Reddit)
332+
trackQueryRun(resultCount, true);
218333
}
219334

220335
/** @deprecated Use trackMatchRevealed instead */

0 commit comments

Comments
 (0)