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:
1 parent
a6f0c39c35
commit
31426fccba
11 files changed
+498
-4
No files matched your search
@@ -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 })
|
||||
}, [])
|
||||
}
|
||||
Reference in new issue
Block a user