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
14 changes: 14 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
88 changes: 88 additions & 0 deletions app/api/reddit-conversion/route.ts
Original file line number Diff line number Diff line change
@@ -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 = {

Check warning on line 34 in app/api/reddit-conversion/route.ts

View workflow job for this annotation

GitHub Actions / build (20.x)

Unexpected any. Specify a different type
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 = {};

Check warning on line 43 in app/api/reddit-conversion/route.ts

View workflow job for this annotation

GitHub Actions / build (20.x)

Unexpected any. Specify a different type
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 }
);
}
}
10 changes: 10 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,16 @@ export default function RootLayout({
});
`}
</Script>
{/* Reddit Pixel */}
{process.env.NEXT_PUBLIC_REDDIT_PIXEL_ID && (
<Script id="reddit-pixel" strategy="afterInteractive">
{`
!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);
rdt('init','${process.env.NEXT_PUBLIC_REDDIT_PIXEL_ID}');
rdt('track', 'PageVisit');
`}
</Script>
)}
</head>
<body>
<AuthProvider>
Expand Down
23 changes: 21 additions & 2 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
trackSearch,
trackRunAllStarted,
trackQueryRun,
trackExploreTabViewed,
} from "@/lib/analytics";

type SortOption = "relevance" | "power" | "recent" | "alphabetical";
Expand Down Expand Up @@ -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);
}, []);
Expand All @@ -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()) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
123 changes: 119 additions & 4 deletions 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
// Extend Window interface to include gtag and rdt
declare global {
interface Window {
gtag?: (
Expand All @@ -14,6 +14,7 @@ declare global {
params?: Record<string, any>
) => void;
dataLayer?: any[];
rdt?: (command: string, ...args: any[]) => void;
}
}

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

/**
* Safely send an event to Reddit Pixel
*/
function trackRedditEvent(eventName: string, metadata?: Record<string, any>) {
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<string, any>
) {
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)
// ============================================================================
Expand Down Expand Up @@ -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');
}
}

/**
Expand All @@ -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,
});
}

Expand Down Expand Up @@ -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,
});
}

/**
Expand Down Expand Up @@ -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,
});
}

/**
Expand All @@ -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,
});
}

/**
Expand Down Expand Up @@ -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 */
Expand Down
Loading