From bf3154378b206d256f06a01f66d66028737bf319 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Mon, 10 Aug 2026 09:11:53 -0400 Subject: [PATCH] Track all link clicks (internal + external) in analytics; v1.1.33 Replaces outbound-only tracking with a useLinkClickTracking hook that captures every click on the site. Stores as link_click events keyed by destination URL, with an internal flag. Admin analytics page gains a Link Clicks table sorted by count, showing type (internal/external). Co-Authored-By: Claude Sonnet 4.6 --- package.json | 2 +- server/data.js | 8 +++++++ server/routes/analytics.js | 6 ++++- src/AdminPage.tsx | 1 + src/App.tsx | 4 ++-- src/analytics.ts | 37 ++++++++++++++++++++++++------- src/components/AnalyticsPanel.tsx | 27 ++++++++++++++++++++++ 7 files changed, 73 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 6c22891..8a2af5c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "1.1.32", + "version": "1.1.33", "type": "module", "scripts": { "dev": "vite", diff --git a/server/data.js b/server/data.js index 84c2187..40290d1 100644 --- a/server/data.js +++ b/server/data.js @@ -850,6 +850,7 @@ const EMPTY_ANALYTICS_EVENTS = { scrollDepth: {}, timeOnPage: {}, outboundClicks: {}, + linkClicks: {}, utmSources: {}, searchQueries: {}, notFound: {}, @@ -875,6 +876,7 @@ export function loadAnalyticsEventsFromDisk() { scrollDepth: parsed?.scrollDepth ?? {}, timeOnPage: parsed?.timeOnPage ?? {}, outboundClicks: parsed?.outboundClicks ?? {}, + linkClicks: parsed?.linkClicks ?? {}, utmSources: parsed?.utmSources ?? {}, searchQueries: parsed?.searchQueries ?? {}, notFound: parsed?.notFound ?? {}, @@ -902,6 +904,12 @@ export function recordAnalyticsEvent(type, data) { } else if (type === 'outbound_click') { const url = typeof data.url === 'string' ? data.url.slice(0, 500) : '' if (url) ev.outboundClicks[url] = (ev.outboundClicks[url] ?? 0) + 1 + } else if (type === 'link_click') { + const url = typeof data.url === 'string' ? data.url.slice(0, 500) : '' + if (url) { + if (!ev.linkClicks[url]) ev.linkClicks[url] = { count: 0, internal: data.internal === true } + ev.linkClicks[url].count += 1 + } } else if (type === 'utm') { const source = typeof data.utm_source === 'string' ? data.utm_source.slice(0, 100) : 'unknown' ev.utmSources[source] = (ev.utmSources[source] ?? 0) + 1 diff --git a/server/routes/analytics.js b/server/routes/analytics.js index b2d3894..29957e5 100644 --- a/server/routes/analytics.js +++ b/server/routes/analytics.js @@ -173,7 +173,7 @@ export function register(app) { const { isBot } = detectBot(ua) if (isBot) { res.json({ ok: false, reason: 'bot' }); return } const type = typeof req.body?.type === 'string' ? req.body.type : '' - const ALLOWED_TYPES = ['scroll_depth', 'time_on_page', 'outbound_click', 'utm', 'search_query', 'not_found', 'audio_pause', 'audio_completion', 'audio_listen_time'] + const ALLOWED_TYPES = ['scroll_depth', 'time_on_page', 'outbound_click', 'link_click', 'utm', 'search_query', 'not_found', 'audio_pause', 'audio_completion', 'audio_listen_time'] if (!ALLOWED_TYPES.includes(type)) { res.status(400).json({ ok: false, reason: 'invalid-type' }); return } recordAnalyticsEvent(type, req.body) res.json({ ok: true }) @@ -378,6 +378,10 @@ export function register(app) { .map(([url, count]) => ({ url, count })) .sort((a, b) => b.count - a.count) .slice(0, 20), + topLinkClicks: Object.entries(state.analyticsEvents.linkClicks ?? {}) + .map(([url, { count, internal }]) => ({ url, count, internal })) + .sort((a, b) => b.count - a.count) + .slice(0, 50), topUTMSources: Object.entries(state.analyticsEvents.utmSources ?? {}) .map(([source, count]) => ({ source, count })) .sort((a, b) => b.count - a.count), diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index c3cb049..a7087b7 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -601,6 +601,7 @@ export interface AdminStats { scrollDepth: Array<{ path: string; 25?: number; 50?: number; 75?: number; 90?: number }> timeOnPage: Array<{ path: string; avgSeconds: number; count: number }> topOutboundClicks: Array<{ url: string; count: number }> + topLinkClicks: Array<{ url: string; count: number; internal: boolean }> topUTMSources: Array<{ source: string; count: number }> topSearchQueries: Array<{ query: string; count: number }> top404s: Array<{ path: string; count: number }> diff --git a/src/App.tsx b/src/App.tsx index e1c6b2f..6d03968 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,7 +16,7 @@ import { useGlobalSearch } from './hooks/useGlobalSearch' import { GlobalSearch } from './components/GlobalSearch' import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer' import './App.css' -import { sendEvent, useScrollDepthTracking, useTimeOnPage, useUTMCapture, useOutboundLinkTracking } from './analytics' +import { sendEvent, useScrollDepthTracking, useTimeOnPage, useUTMCapture, useLinkClickTracking } from './analytics' const CONSENT_KEY = 'vbn_analytics_consent_choice' const HEADLINER_WIDGET_ID = 'WI_cmou3b4q7000701p0o9qmmcfj' @@ -2387,7 +2387,7 @@ export default function App() { useScrollDepthTracking() useTimeOnPage() useUTMCapture() - useOutboundLinkTracking() + useLinkClickTracking() useEffect(() => { const TARGET = 'salvation' diff --git a/src/analytics.ts b/src/analytics.ts index 7ebb7d9..54dea24 100644 --- a/src/analytics.ts +++ b/src/analytics.ts @@ -87,22 +87,43 @@ export function useUTMCapture() { }, []) } -// --- Outbound link clicks --- -export function useOutboundLinkTracking() { +// --- 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.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 */ } + 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 }) }, []) } + +/** @deprecated Use useLinkClickTracking instead */ +export function useOutboundLinkTracking() { useLinkClickTracking() } diff --git a/src/components/AnalyticsPanel.tsx b/src/components/AnalyticsPanel.tsx index 248c59b..49c847d 100644 --- a/src/components/AnalyticsPanel.tsx +++ b/src/components/AnalyticsPanel.tsx @@ -663,6 +663,33 @@ export function AnalyticsPanel({ )} + {/* All link clicks */} + {stats.engagement.topLinkClicks?.length > 0 && ( +
+

Link Clicks

+
+ + + + {stats.engagement.topLinkClicks.map((row: { url: string; count: number; internal: boolean }) => ( + + + + + + ))} + +
DestinationTypeClicks
+ {row.internal + ? {row.url} + : {row.url}} + + {row.internal ? 'internal' : 'external'} + {row.count.toLocaleString()}
+
+
+ )} + {/* Outbound clicks */} {stats.engagement.topOutboundClicks?.length > 0 && (