From c7210dd2c340c1d9f88d2b30f3784975683ba1cf Mon Sep 17 00:00:00 2001 From: Akshay Date: Thu, 13 Nov 2025 16:45:46 +0530 Subject: [PATCH] feat(analytics): Implement session-based view tracking for events - Add session-based deduplication for event view tracking - Create new `useViewTracking` hook with session management logic - Refactor event view tracking API route to use `AnalyticsService` - Implement client-side session tracking to prevent duplicate views - Add logging and error handling for view tracking process - Improve analytics tracking reliability and performance Enhances event view tracking by preventing multiple view counts from the same user session and providing more robust tracking mechanisms. --- app/api/events/[slug]/track-view/route.ts | 79 +++++++----------- app/events/[slug]/page.tsx | 2 + hooks/useAnalyticsTracking.ts | 98 +++++++++++++++++++++-- hooks/useViewTracking.ts | 63 +++++++++++++++ lib/services/analytics-service.ts | 48 ++++++++--- 5 files changed, 221 insertions(+), 69 deletions(-) create mode 100644 hooks/useViewTracking.ts diff --git a/app/api/events/[slug]/track-view/route.ts b/app/api/events/[slug]/track-view/route.ts index e801004f..455e113d 100644 --- a/app/api/events/[slug]/track-view/route.ts +++ b/app/api/events/[slug]/track-view/route.ts @@ -1,69 +1,50 @@ import { NextRequest, NextResponse } from 'next/server' -import { createClient } from '@/lib/supabase/server' +import { AnalyticsService } from '@/lib/services/analytics-service' +// Force Node.js runtime for API routes +export const runtime = 'nodejs' + +/** + * POST /api/events/[slug]/track-view + * Track a view for an event with session-based deduplication + */ export async function POST( request: NextRequest, { params }: { params: Promise<{ slug: string }> } ) { try { - const supabase = await createClient() const { slug } = await params - // Get event by slug - const { data: event, error: eventError } = await supabase - .from('events') - .select('id, company_id, views') - .eq('slug', slug) - .single() - - if (eventError || !event) { - return NextResponse.json( - { error: 'Event not found' }, - { status: 404 } - ) - } - - // Increment view count - const { error: updateError } = await supabase - .from('events') - .update({ views: (event.views || 0) + 1 }) - .eq('id', event.id) + // Get session ID from request body (generated on client) + const body = await request.json() + const sessionId = body.sessionId - if (updateError) { - console.error('Error updating view count:', updateError) + if (!sessionId) { return NextResponse.json( - { error: 'Failed to track view' }, - { status: 500 } + { error: 'Session ID is required' }, + { status: 400 } ) } - // Update company analytics if event has a company - if (event.company_id) { - const today = new Date().toISOString().split('T')[0] + // Check if this session has already viewed this event + // We rely on client-side sessionStorage to prevent duplicates + // The client will only send the request once per session + + // Track the view + await AnalyticsService.trackEventView(slug) - // Upsert daily analytics - const { error: analyticsError } = await supabase.rpc( - 'increment_company_analytics', - { - p_company_id: event.company_id, - p_date: today, - p_field: 'total_views', - p_increment: 1 - } - ) - - if (analyticsError) { - console.error('Error updating company analytics:', analyticsError) - // Don't fail the request if analytics update fails - } - } - - return NextResponse.json({ success: true }) + return NextResponse.json( + { success: true, message: 'View tracked successfully' }, + { status: 200 } + ) } catch (error) { - console.error('Error tracking view:', error) + console.error('Error tracking event view:', error) + + // Don't fail the request if view tracking fails + // Just log the error and return success return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 } + { success: true, message: 'View tracking skipped' }, + { status: 200 } ) } } diff --git a/app/events/[slug]/page.tsx b/app/events/[slug]/page.tsx index e45a42a7..e3327095 100644 --- a/app/events/[slug]/page.tsx +++ b/app/events/[slug]/page.tsx @@ -97,10 +97,12 @@ export default function EventPage() { } = useRegistrationStatus('event', event?.id?.toString() || '') // Track analytics + console.log('[EventPage] About to call useAnalyticsTracking with slug:', slug) const { trackClick } = useAnalyticsTracking({ eventSlug: slug, trackView: true, }) + console.log('[EventPage] useAnalyticsTracking returned:', { trackClick }) useEffect(() => { const fetchEvent = async () => { diff --git a/hooks/useAnalyticsTracking.ts b/hooks/useAnalyticsTracking.ts index d7eb0341..6dc6bbb9 100644 --- a/hooks/useAnalyticsTracking.ts +++ b/hooks/useAnalyticsTracking.ts @@ -7,6 +7,39 @@ interface UseAnalyticsTrackingOptions { trackClick?: boolean } +// Helper to get or create session ID +const getSessionId = () => { + if (typeof window === 'undefined') return null + + let sessionId = sessionStorage.getItem('analytics_session_id') + if (!sessionId) { + sessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` + sessionStorage.setItem('analytics_session_id', sessionId) + } + return sessionId +} + +// Helper to check if already viewed in this session +const hasViewedInSession = (slug: string, type: 'event' | 'hackathon'): boolean => { + if (typeof window === 'undefined') return false + + const key = `viewed_${type}s` + const viewed = JSON.parse(sessionStorage.getItem(key) || '[]') + return viewed.includes(slug) +} + +// Helper to mark as viewed in this session +const markAsViewed = (slug: string, type: 'event' | 'hackathon'): void => { + if (typeof window === 'undefined') return + + const key = `viewed_${type}s` + const viewed = JSON.parse(sessionStorage.getItem(key) || '[]') + if (!viewed.includes(slug)) { + viewed.push(slug) + sessionStorage.setItem(key, JSON.stringify(viewed)) + } +} + export function useAnalyticsTracking({ eventSlug, hackathonId, @@ -16,30 +49,79 @@ export function useAnalyticsTracking({ const viewTracked = useRef(false) const clickTracked = useRef(false) - // Track view on mount + console.log('[Analytics Hook] Initialized with:', { eventSlug, hackathonId, trackView, trackClick }) + + // Track view on mount with session-based deduplication useEffect(() => { - if (!trackView || viewTracked.current) return + console.log('[Analytics Hook] useEffect triggered', { trackView, viewTracked: viewTracked.current }) + if (!trackView || viewTracked.current) { + console.log('[Analytics Hook] Skipping - trackView:', trackView, 'viewTracked:', viewTracked.current) + return + } const trackViewAsync = async () => { try { + const sessionId = getSessionId() + if (!sessionId) { + console.log('[Analytics] No session ID available') + return + } + if (eventSlug) { - await fetch(`/api/events/${eventSlug}/track-view`, { + console.log('[Analytics] Tracking view for event:', eventSlug) + + // Check if already viewed in this session + if (hasViewedInSession(eventSlug, 'event')) { + console.log('[Analytics] Event already viewed in this session') + viewTracked.current = true + return + } + + console.log('[Analytics] Sending track-view request...') + const response = await fetch(`/api/events/${eventSlug}/track-view`, { method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ sessionId }), }) - viewTracked.current = true + + console.log('[Analytics] Track-view response:', response.status, response.ok) + + if (response.ok) { + markAsViewed(eventSlug, 'event') + viewTracked.current = true + console.log('[Analytics] View tracked successfully') + } else { + console.error('[Analytics] Failed to track view:', await response.text()) + } } else if (hackathonId) { - await fetch(`/api/hackathons/${hackathonId}/track-view`, { + // Check if already viewed in this session + if (hasViewedInSession(hackathonId, 'hackathon')) { + viewTracked.current = true + return + } + + const response = await fetch(`/api/hackathons/${hackathonId}/track-view`, { method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ sessionId }), }) - viewTracked.current = true + + if (response.ok) { + markAsViewed(hackathonId, 'hackathon') + viewTracked.current = true + } } } catch (error) { console.error('Error tracking view:', error) } } - // Track view after a short delay to avoid tracking bots - const timer = setTimeout(trackViewAsync, 1000) + // Track view after a short delay to avoid tracking bots and ensure real engagement + const timer = setTimeout(trackViewAsync, 2000) return () => clearTimeout(timer) }, [eventSlug, hackathonId, trackView]) diff --git a/hooks/useViewTracking.ts b/hooks/useViewTracking.ts new file mode 100644 index 00000000..61fc4969 --- /dev/null +++ b/hooks/useViewTracking.ts @@ -0,0 +1,63 @@ +import { useEffect, useRef } from 'react' + +/** + * Hook to track event views with session-based deduplication + * Only tracks once per session per event + */ +export function useViewTracking(eventSlug: string, enabled: boolean = true) { + const hasTracked = useRef(false) + + useEffect(() => { + // Don't track if disabled or already tracked + if (!enabled || hasTracked.current || !eventSlug) { + return + } + + // Generate or get session ID + const getSessionId = () => { + let sessionId = sessionStorage.getItem('view_session_id') + if (!sessionId) { + sessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` + sessionStorage.setItem('view_session_id', sessionId) + } + return sessionId + } + + // Check if this event was already viewed in this session + const viewedEvents = JSON.parse(sessionStorage.getItem('viewed_events') || '[]') + if (viewedEvents.includes(eventSlug)) { + hasTracked.current = true + return + } + + // Track the view + const trackView = async () => { + try { + const sessionId = getSessionId() + + const response = await fetch(`/api/events/${eventSlug}/track-view`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ sessionId }), + }) + + if (response.ok) { + // Mark as viewed in this session + viewedEvents.push(eventSlug) + sessionStorage.setItem('viewed_events', JSON.stringify(viewedEvents)) + hasTracked.current = true + } + } catch (error) { + console.error('Failed to track view:', error) + // Silently fail - don't disrupt user experience + } + } + + // Track after a short delay to ensure the user actually viewed the page + const timer = setTimeout(trackView, 2000) // 2 second delay + + return () => clearTimeout(timer) + }, [eventSlug, enabled]) +} diff --git a/lib/services/analytics-service.ts b/lib/services/analytics-service.ts index d781a09f..b059b5df 100644 --- a/lib/services/analytics-service.ts +++ b/lib/services/analytics-service.ts @@ -8,29 +8,53 @@ export class AnalyticsService { static async trackEventView(eventSlug: string): Promise { const supabase = await createClient() + // First, try to get the event (only approved events should be viewable publicly) const { data: event, error: eventError } = await supabase .from('events') - .select('id, company_id, views') + .select('id, company_id, views, approval_status') .eq('slug', eventSlug) + .eq('approval_status', 'approved') // Only track views for approved events .single() - if (eventError || !event) { + if (eventError) { + console.error('Error fetching event for view tracking:', eventError) throw new Error('Event not found') } - // Increment view count - await supabase - .from('events') - .update({ views: (event.views || 0) + 1 }) - .eq('id', event.id) + if (!event) { + throw new Error('Event not found') + } + + // Increment view count using RPC function if available, otherwise direct update + const { error: rpcError } = await supabase.rpc('increment_event_views', { + event_id: event.id, + }) + + if (rpcError) { + // Fallback to direct update if RPC doesn't exist + const { error: updateError } = await supabase + .from('events') + .update({ views: (event.views || 0) + 1 }) + .eq('id', event.id) + + if (updateError) { + console.error('Error updating event views:', updateError) + // Don't throw - we don't want to fail the request if view tracking fails + } + } // Update company analytics if event has a company if (event.company_id) { - await this.incrementCompanyAnalytics( - event.company_id, - 'total_views', - 1 - ) + try { + await this.incrementCompanyAnalytics( + event.company_id, + 'total_views', + 1 + ) + } catch (error) { + console.error('Error updating company analytics:', error) + // Don't throw - we don't want to fail the request + } } }