Add comprehensive visitor engagement tracking and scroll-to-top on navigation

Tracks scroll depth (25/50/75/90%), time on page, UTM parameters, outbound
link clicks, search queries, audio pause/completion/listen time, and 404s.
Logged-in study users are now tied to their visitor record and surfaced in
the admin recent visits table. Scroll position resets on every route change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-06-17 14:06:21 -04:00
1 parent a6f0c39c35
commit 31426fccba
11 files changed
+498 -4

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
import { useEffect, useRef } from 'react'
import { useLocation } from 'react-router-dom'
const CONSENT_KEY = 'vbn_analytics_consent_choice'
function hasConsent(): boolean {
return localStorage.getItem(CONSENT_KEY) === 'accepted'
}
export function sendEvent(type: string, data: Record<string, unknown>): void {
if (!hasConsent()) return
fetch('/api/analytics/event', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type, ...data }),
keepalive: true,
}).catch(() => {})
}
// --- Scroll depth ---
export function useScrollDepthTracking() {
const location = useLocation()
const milestones = useRef(new Set<number>())
useEffect(() => {
milestones.current = new Set()
function onScroll() {
if (!hasConsent()) return
const el = document.documentElement
const pct = Math.round((el.scrollTop / (el.scrollHeight - el.clientHeight)) * 100)
for (const mark of [25, 50, 75, 90]) {
if (pct >= mark && !milestones.current.has(mark)) {
milestones.current.add(mark)
sendEvent('scroll_depth', { path: location.pathname, depth: mark })
}
}
}
window.addEventListener('scroll', onScroll, { passive: true })
return () => window.removeEventListener('scroll', onScroll)
}, [location.pathname])
}
// --- Time on page ---
export function useTimeOnPage() {
const location = useLocation()
const enteredAt = useRef(Date.now())
const path = useRef(location.pathname)
useEffect(() => {
enteredAt.current = Date.now()
path.current = location.pathname
function send() {
if (!hasConsent()) return
const seconds = Math.round((Date.now() - enteredAt.current) / 1000)
if (seconds < 3) return
sendEvent('time_on_page', { path: path.current, seconds })
}
const onVisibilityChange = () => { if (document.hidden) send() }
document.addEventListener('visibilitychange', onVisibilityChange)
return () => {
document.removeEventListener('visibilitychange', onVisibilityChange)
send()
}
}, [location.pathname])
}
// --- UTM capture (runs once per page load) ---
export function useUTMCapture() {
const captured = useRef(false)
useEffect(() => {
if (captured.current || !hasConsent()) return
const params = new URLSearchParams(window.location.search)
const utm: Record<string, string> = {}
for (const key of ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term']) {
const val = params.get(key)
if (val) utm[key] = val
}
if (Object.keys(utm).length === 0) return
captured.current = true
sendEvent('utm', { path: window.location.pathname, ...utm })
}, [])
}
// --- Outbound link clicks ---
export function useOutboundLinkTracking() {
useEffect(() => {
function onClick(e: MouseEvent) {
if (!hasConsent()) return
const target = (e.target as HTMLElement).closest('a')
if (!target) return
const href = target.getAttribute('href') ?? ''
if (!href.startsWith('http') && !href.startsWith('//')) return
try {
const url = new URL(href)
if (url.hostname === window.location.hostname) return
sendEvent('outbound_click', { url: href, text: target.textContent?.trim().slice(0, 100) ?? '' })
} catch { /* ignore malformed */ }
}
document.addEventListener('click', onClick, { capture: true })
return () => document.removeEventListener('click', onClick, { capture: true })
}, [])
}