Skip to content

Commit 56ff944

Browse files
authored
Merge pull request #318 from codeunia-dev/feat/session-based-view-tracking
feat(analytics): Implement session-based view tracking for events
2 parents e4dd194 + c7210dd commit 56ff944

File tree

5 files changed

+221
-69
lines changed

5 files changed

+221
-69
lines changed
Lines changed: 30 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,50 @@
11
import { NextRequest, NextResponse } from 'next/server'
2-
import { createClient } from '@/lib/supabase/server'
2+
import { AnalyticsService } from '@/lib/services/analytics-service'
33

4+
// Force Node.js runtime for API routes
5+
export const runtime = 'nodejs'
6+
7+
/**
8+
* POST /api/events/[slug]/track-view
9+
* Track a view for an event with session-based deduplication
10+
*/
411
export async function POST(
512
request: NextRequest,
613
{ params }: { params: Promise<{ slug: string }> }
714
) {
815
try {
9-
const supabase = await createClient()
1016
const { slug } = await params
1117

12-
// Get event by slug
13-
const { data: event, error: eventError } = await supabase
14-
.from('events')
15-
.select('id, company_id, views')
16-
.eq('slug', slug)
17-
.single()
18-
19-
if (eventError || !event) {
20-
return NextResponse.json(
21-
{ error: 'Event not found' },
22-
{ status: 404 }
23-
)
24-
}
25-
26-
// Increment view count
27-
const { error: updateError } = await supabase
28-
.from('events')
29-
.update({ views: (event.views || 0) + 1 })
30-
.eq('id', event.id)
18+
// Get session ID from request body (generated on client)
19+
const body = await request.json()
20+
const sessionId = body.sessionId
3121

32-
if (updateError) {
33-
console.error('Error updating view count:', updateError)
22+
if (!sessionId) {
3423
return NextResponse.json(
35-
{ error: 'Failed to track view' },
36-
{ status: 500 }
24+
{ error: 'Session ID is required' },
25+
{ status: 400 }
3726
)
3827
}
3928

40-
// Update company analytics if event has a company
41-
if (event.company_id) {
42-
const today = new Date().toISOString().split('T')[0]
29+
// Check if this session has already viewed this event
30+
// We rely on client-side sessionStorage to prevent duplicates
31+
// The client will only send the request once per session
32+
33+
// Track the view
34+
await AnalyticsService.trackEventView(slug)
4335

44-
// Upsert daily analytics
45-
const { error: analyticsError } = await supabase.rpc(
46-
'increment_company_analytics',
47-
{
48-
p_company_id: event.company_id,
49-
p_date: today,
50-
p_field: 'total_views',
51-
p_increment: 1
52-
}
53-
)
54-
55-
if (analyticsError) {
56-
console.error('Error updating company analytics:', analyticsError)
57-
// Don't fail the request if analytics update fails
58-
}
59-
}
60-
61-
return NextResponse.json({ success: true })
36+
return NextResponse.json(
37+
{ success: true, message: 'View tracked successfully' },
38+
{ status: 200 }
39+
)
6240
} catch (error) {
63-
console.error('Error tracking view:', error)
41+
console.error('Error tracking event view:', error)
42+
43+
// Don't fail the request if view tracking fails
44+
// Just log the error and return success
6445
return NextResponse.json(
65-
{ error: 'Internal server error' },
66-
{ status: 500 }
46+
{ success: true, message: 'View tracking skipped' },
47+
{ status: 200 }
6748
)
6849
}
6950
}

app/events/[slug]/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,12 @@ export default function EventPage() {
9797
} = useRegistrationStatus('event', event?.id?.toString() || '')
9898

9999
// Track analytics
100+
console.log('[EventPage] About to call useAnalyticsTracking with slug:', slug)
100101
const { trackClick } = useAnalyticsTracking({
101102
eventSlug: slug,
102103
trackView: true,
103104
})
105+
console.log('[EventPage] useAnalyticsTracking returned:', { trackClick })
104106

105107
useEffect(() => {
106108
const fetchEvent = async () => {

hooks/useAnalyticsTracking.ts

Lines changed: 90 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,39 @@ interface UseAnalyticsTrackingOptions {
77
trackClick?: boolean
88
}
99

10+
// Helper to get or create session ID
11+
const getSessionId = () => {
12+
if (typeof window === 'undefined') return null
13+
14+
let sessionId = sessionStorage.getItem('analytics_session_id')
15+
if (!sessionId) {
16+
sessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
17+
sessionStorage.setItem('analytics_session_id', sessionId)
18+
}
19+
return sessionId
20+
}
21+
22+
// Helper to check if already viewed in this session
23+
const hasViewedInSession = (slug: string, type: 'event' | 'hackathon'): boolean => {
24+
if (typeof window === 'undefined') return false
25+
26+
const key = `viewed_${type}s`
27+
const viewed = JSON.parse(sessionStorage.getItem(key) || '[]')
28+
return viewed.includes(slug)
29+
}
30+
31+
// Helper to mark as viewed in this session
32+
const markAsViewed = (slug: string, type: 'event' | 'hackathon'): void => {
33+
if (typeof window === 'undefined') return
34+
35+
const key = `viewed_${type}s`
36+
const viewed = JSON.parse(sessionStorage.getItem(key) || '[]')
37+
if (!viewed.includes(slug)) {
38+
viewed.push(slug)
39+
sessionStorage.setItem(key, JSON.stringify(viewed))
40+
}
41+
}
42+
1043
export function useAnalyticsTracking({
1144
eventSlug,
1245
hackathonId,
@@ -16,30 +49,79 @@ export function useAnalyticsTracking({
1649
const viewTracked = useRef(false)
1750
const clickTracked = useRef(false)
1851

19-
// Track view on mount
52+
console.log('[Analytics Hook] Initialized with:', { eventSlug, hackathonId, trackView, trackClick })
53+
54+
// Track view on mount with session-based deduplication
2055
useEffect(() => {
21-
if (!trackView || viewTracked.current) return
56+
console.log('[Analytics Hook] useEffect triggered', { trackView, viewTracked: viewTracked.current })
57+
if (!trackView || viewTracked.current) {
58+
console.log('[Analytics Hook] Skipping - trackView:', trackView, 'viewTracked:', viewTracked.current)
59+
return
60+
}
2261

2362
const trackViewAsync = async () => {
2463
try {
64+
const sessionId = getSessionId()
65+
if (!sessionId) {
66+
console.log('[Analytics] No session ID available')
67+
return
68+
}
69+
2570
if (eventSlug) {
26-
await fetch(`/api/events/${eventSlug}/track-view`, {
71+
console.log('[Analytics] Tracking view for event:', eventSlug)
72+
73+
// Check if already viewed in this session
74+
if (hasViewedInSession(eventSlug, 'event')) {
75+
console.log('[Analytics] Event already viewed in this session')
76+
viewTracked.current = true
77+
return
78+
}
79+
80+
console.log('[Analytics] Sending track-view request...')
81+
const response = await fetch(`/api/events/${eventSlug}/track-view`, {
2782
method: 'POST',
83+
headers: {
84+
'Content-Type': 'application/json',
85+
},
86+
body: JSON.stringify({ sessionId }),
2887
})
29-
viewTracked.current = true
88+
89+
console.log('[Analytics] Track-view response:', response.status, response.ok)
90+
91+
if (response.ok) {
92+
markAsViewed(eventSlug, 'event')
93+
viewTracked.current = true
94+
console.log('[Analytics] View tracked successfully')
95+
} else {
96+
console.error('[Analytics] Failed to track view:', await response.text())
97+
}
3098
} else if (hackathonId) {
31-
await fetch(`/api/hackathons/${hackathonId}/track-view`, {
99+
// Check if already viewed in this session
100+
if (hasViewedInSession(hackathonId, 'hackathon')) {
101+
viewTracked.current = true
102+
return
103+
}
104+
105+
const response = await fetch(`/api/hackathons/${hackathonId}/track-view`, {
32106
method: 'POST',
107+
headers: {
108+
'Content-Type': 'application/json',
109+
},
110+
body: JSON.stringify({ sessionId }),
33111
})
34-
viewTracked.current = true
112+
113+
if (response.ok) {
114+
markAsViewed(hackathonId, 'hackathon')
115+
viewTracked.current = true
116+
}
35117
}
36118
} catch (error) {
37119
console.error('Error tracking view:', error)
38120
}
39121
}
40122

41-
// Track view after a short delay to avoid tracking bots
42-
const timer = setTimeout(trackViewAsync, 1000)
123+
// Track view after a short delay to avoid tracking bots and ensure real engagement
124+
const timer = setTimeout(trackViewAsync, 2000)
43125

44126
return () => clearTimeout(timer)
45127
}, [eventSlug, hackathonId, trackView])

hooks/useViewTracking.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { useEffect, useRef } from 'react'
2+
3+
/**
4+
* Hook to track event views with session-based deduplication
5+
* Only tracks once per session per event
6+
*/
7+
export function useViewTracking(eventSlug: string, enabled: boolean = true) {
8+
const hasTracked = useRef(false)
9+
10+
useEffect(() => {
11+
// Don't track if disabled or already tracked
12+
if (!enabled || hasTracked.current || !eventSlug) {
13+
return
14+
}
15+
16+
// Generate or get session ID
17+
const getSessionId = () => {
18+
let sessionId = sessionStorage.getItem('view_session_id')
19+
if (!sessionId) {
20+
sessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
21+
sessionStorage.setItem('view_session_id', sessionId)
22+
}
23+
return sessionId
24+
}
25+
26+
// Check if this event was already viewed in this session
27+
const viewedEvents = JSON.parse(sessionStorage.getItem('viewed_events') || '[]')
28+
if (viewedEvents.includes(eventSlug)) {
29+
hasTracked.current = true
30+
return
31+
}
32+
33+
// Track the view
34+
const trackView = async () => {
35+
try {
36+
const sessionId = getSessionId()
37+
38+
const response = await fetch(`/api/events/${eventSlug}/track-view`, {
39+
method: 'POST',
40+
headers: {
41+
'Content-Type': 'application/json',
42+
},
43+
body: JSON.stringify({ sessionId }),
44+
})
45+
46+
if (response.ok) {
47+
// Mark as viewed in this session
48+
viewedEvents.push(eventSlug)
49+
sessionStorage.setItem('viewed_events', JSON.stringify(viewedEvents))
50+
hasTracked.current = true
51+
}
52+
} catch (error) {
53+
console.error('Failed to track view:', error)
54+
// Silently fail - don't disrupt user experience
55+
}
56+
}
57+
58+
// Track after a short delay to ensure the user actually viewed the page
59+
const timer = setTimeout(trackView, 2000) // 2 second delay
60+
61+
return () => clearTimeout(timer)
62+
}, [eventSlug, enabled])
63+
}

lib/services/analytics-service.ts

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,29 +8,53 @@ export class AnalyticsService {
88
static async trackEventView(eventSlug: string): Promise<void> {
99
const supabase = await createClient()
1010

11+
// First, try to get the event (only approved events should be viewable publicly)
1112
const { data: event, error: eventError } = await supabase
1213
.from('events')
13-
.select('id, company_id, views')
14+
.select('id, company_id, views, approval_status')
1415
.eq('slug', eventSlug)
16+
.eq('approval_status', 'approved') // Only track views for approved events
1517
.single()
1618

17-
if (eventError || !event) {
19+
if (eventError) {
20+
console.error('Error fetching event for view tracking:', eventError)
1821
throw new Error('Event not found')
1922
}
2023

21-
// Increment view count
22-
await supabase
23-
.from('events')
24-
.update({ views: (event.views || 0) + 1 })
25-
.eq('id', event.id)
24+
if (!event) {
25+
throw new Error('Event not found')
26+
}
27+
28+
// Increment view count using RPC function if available, otherwise direct update
29+
const { error: rpcError } = await supabase.rpc('increment_event_views', {
30+
event_id: event.id,
31+
})
32+
33+
if (rpcError) {
34+
// Fallback to direct update if RPC doesn't exist
35+
const { error: updateError } = await supabase
36+
.from('events')
37+
.update({ views: (event.views || 0) + 1 })
38+
.eq('id', event.id)
39+
40+
if (updateError) {
41+
console.error('Error updating event views:', updateError)
42+
// Don't throw - we don't want to fail the request if view tracking fails
43+
}
44+
}
2645

2746
// Update company analytics if event has a company
2847
if (event.company_id) {
29-
await this.incrementCompanyAnalytics(
30-
event.company_id,
31-
'total_views',
32-
1
33-
)
48+
try {
49+
await this.incrementCompanyAnalytics(
50+
event.company_id,
51+
'total_views',
52+
1
53+
)
54+
} catch (error) {
55+
console.error('Error updating company analytics:', error)
56+
// Don't throw - we don't want to fail the request
57+
}
3458
}
3559
}
3660

0 commit comments

Comments
 (0)