Files
Siteforge/src/analytics.ts
T
nmemmertandClaude Sonnet 5.5 8a796964b9
Publish Container / build (push) Successful in 17m3s
Security & hardening pass: scrypt password hashing, persisted admin sessions, upvote/analytics rate limits, dependency fixes, healthcheck, lint cleanup; v2.10.0
- Study passwords: salted scrypt; legacy SHA-256 hashes verified and upgraded on login
- Admin sessions persisted (hashed tokens) so deploys no longer log the admin out
- Upvotes: rate limit + one per client per question, approved questions only; rate limit on analytics play/event
- npm audit: 46 -> 4 vulnerabilities (tiptap bumped to 3.31.4)
- Untrack per-user data (study users/notes/progress/etc.) from git
- Dockerfile HEALTHCHECK; node --test suite for password hashing
- Fix email template cards remounting (lost input focus) on each keystroke
- Lint: 34 errors -> 0

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 08:45:39 -04:00

157 lines
5.2 KiB
TypeScript

import { useEffect, useRef } from 'react'
import { useLocation } from 'react-router-dom'
const CONSENT_KEY = 'vbn_analytics_consent_choice'
const UTM_PENDING_KEY = 'vbn_pending_utm'
function hasConsent(): boolean {
return localStorage.getItem(CONSENT_KEY) === 'accepted'
}
// Capture UTM params immediately on module load, before consent is given.
// They're held in sessionStorage so they survive a consent-banner accept.
;(function captureUtmEarly() {
try {
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) {
sessionStorage.setItem(UTM_PENDING_KEY, JSON.stringify({ path: window.location.pathname, ...utm }))
}
} catch { /* sessionStorage unavailable */ }
})()
// The client gates on a localStorage flag; the server gates on a cookie.
// If the cookie is lost (cleared separately, dropped by the browser on a
// plain-HTTP deployment, etc.) while localStorage still says "accepted",
// the client would otherwise keep believing tracking is on and silently
// lose every event forever. Self-heal by clearing the stale flag so the
// consent banner reappears instead of leaking data indefinitely.
export async function checkAnalyticsConsentResponse(res: Response): Promise<void> {
try {
const data = await res.json()
if (data?.reason === 'no-consent') localStorage.removeItem(CONSENT_KEY)
} catch { /* ignore */ }
}
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,
}).then(checkAnalyticsConsentResponse).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(0)
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 ---
// UTM params are captured into sessionStorage before consent (see top of file).
// This hook drains sessionStorage and sends the event once consent is present.
// Running without a dep array so it retries after the consent banner triggers a re-render.
export function useUTMCapture() {
useEffect(() => {
if (!hasConsent()) return
try {
const raw = sessionStorage.getItem(UTM_PENDING_KEY)
if (!raw) return
const utm = JSON.parse(raw) as Record<string, string>
sessionStorage.removeItem(UTM_PENDING_KEY)
sendEvent('utm', utm)
} catch { /* ignore */ }
})
}
// --- All link clicks (internal + external) ---
export function useLinkClickTracking() {
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 || href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:')) return
let url: string
let internal: boolean
if (href.startsWith('http') || href.startsWith('//')) {
try {
const parsed = new URL(href)
if (parsed.hostname === window.location.hostname) {
url = parsed.pathname
internal = true
} else {
url = href
internal = false
}
} catch { return }
} else if (href.startsWith('/')) {
url = href.split('?')[0]
internal = true
} else {
return
}
sendEvent('link_click', { url: url.slice(0, 500), internal })
}
document.addEventListener('click', onClick, { capture: true })
return () => document.removeEventListener('click', onClick, { capture: true })
}, [])
}