Publish Container / build (push) Successful in 17m3s
- 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>
157 lines
5.2 KiB
TypeScript
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 })
|
|
}, [])
|
|
}
|
|
|