analytics fixs

This commit is contained in:
nmemmert
2026-06-03 11:35:45 -04:00
parent b7aa32e4b7
commit e561bfa000
5 changed files with 339 additions and 53 deletions
+102 -6
View File
@@ -612,6 +612,7 @@ const EMPTY_VISITOR_STATS = {
firstVisitAt: null, firstVisitAt: null,
lastVisitAt: null, lastVisitAt: null,
visitors: {}, visitors: {},
ipHashIndex: {}, // ipHash → visitorId — prevents same IP counting as multiple unique visitors
recentVisits: [], recentVisits: [],
geoCacheByIp: {}, geoCacheByIp: {},
} }
@@ -1343,6 +1344,24 @@ function sanitizeUserAgent(userAgent) {
return userAgent.trim().slice(0, 300) || 'unknown' return userAgent.trim().slice(0, 300) || 'unknown'
} }
function detectDevice(userAgent) {
if (!userAgent || typeof userAgent !== 'string') return 'unknown'
const ua = userAgent.toLowerCase()
if (/tablet|ipad|playbook|silk|(android(?!.*mobile))/.test(ua)) return 'tablet'
if (/mobile|iphone|ipod|android|blackberry|opera mini|opera mobi|iemobile|windows phone|palm|smartphone/.test(ua)) return 'mobile'
return 'desktop'
}
function sanitizeReferrer(referrer) {
if (!referrer || typeof referrer !== 'string') return ''
try {
const parsed = new URL(referrer.trim())
return `${parsed.hostname}${parsed.pathname}`.slice(0, 200)
} catch {
return ''
}
}
function detectBot(userAgent, pathInfo = {}) { function detectBot(userAgent, pathInfo = {}) {
if (!userAgent || typeof userAgent !== 'string') { if (!userAgent || typeof userAgent !== 'string') {
return { isBot: true, reason: 'missing-user-agent' } return { isBot: true, reason: 'missing-user-agent' }
@@ -1472,7 +1491,7 @@ async function resolveGeo(ip) {
return fallback return fallback
} }
async function recordVisitor(req, res) { async function recordVisitor(req, res, overridePath = null, overrideReferrer = null) {
const cookies = parseCookies(req.headers.cookie) const cookies = parseCookies(req.headers.cookie)
let visitorId = cookies[VISITOR_COOKIE] let visitorId = cookies[VISITOR_COOKIE]
if (!visitorId) { if (!visitorId) {
@@ -1481,24 +1500,42 @@ async function recordVisitor(req, res) {
} }
const nowIso = new Date().toISOString() const nowIso = new Date().toISOString()
const pathKey = normalizeHitPath(req.path) const pathKey = overridePath ? normalizeHitPath(overridePath) : normalizeHitPath(req.path)
const referrer = overrideReferrer !== null ? sanitizeReferrer(overrideReferrer) : sanitizeReferrer(req.get('referer') || req.get('referrer') || '')
const ip = getClientIp(req) const ip = getClientIp(req)
const ua = sanitizeUserAgent(req.get('user-agent')) const ua = sanitizeUserAgent(req.get('user-agent'))
const device = detectDevice(ua)
const ipHash = createHash('sha256').update(ip).digest('hex')
const geo = await resolveGeo(ip)
// Resolve canonical visitorId by IP hash — if this IP was seen before under a
// different cookie (e.g. cleared cookies), reuse the existing record so the
// same person is never counted as a second unique visitor.
const existingIdByIp = visitorStats.ipHashIndex[ipHash]
if (existingIdByIp && existingIdByIp !== visitorId) {
// Reuse the existing record for this IP; overwrite cookie with canonical ID
visitorId = existingIdByIp
res.append('Set-Cookie', `${VISITOR_COOKIE}=${encodeURIComponent(visitorId)}; Max-Age=31536000; Path=/; SameSite=Lax`)
}
const existingVisitor = visitorStats.visitors[visitorId] const existingVisitor = visitorStats.visitors[visitorId]
const isReturning = Boolean(existingVisitor) const isReturning = Boolean(existingVisitor)
const geo = await resolveGeo(ip)
if (!existingVisitor) { if (!existingVisitor) {
visitorStats.uniqueVisitors += 1 visitorStats.uniqueVisitors += 1
visitorStats.ipHashIndex[ipHash] = visitorId
} else { } else {
visitorStats.returningVisits += 1 visitorStats.returningVisits += 1
} }
const ipHash = createHash('sha256').update(ip).digest('hex')
const nextVisitCount = (existingVisitor?.visitCount ?? 0) + 1 const nextVisitCount = (existingVisitor?.visitCount ?? 0) + 1
const userAgents = Array.from(new Set([...(existingVisitor?.userAgents ?? []), ua])).slice(0, 5) const userAgents = Array.from(new Set([...(existingVisitor?.userAgents ?? []), ua])).slice(0, 5)
// Append to page history, keeping last 100 entries per visitor
const prevHistory = existingVisitor?.pageHistory ?? []
const pageHistory = [...prevHistory, { at: nowIso, path: pathKey, referrer }].slice(-100)
visitorStats.visitors[visitorId] = { visitorStats.visitors[visitorId] = {
visitorId, visitorId,
ip, ip,
@@ -1510,6 +1547,8 @@ async function recordVisitor(req, res) {
returningVisitor: isReturning, returningVisitor: isReturning,
location: geo, location: geo,
userAgents, userAgents,
device,
pageHistory,
} }
visitorStats.totalVisits += 1 visitorStats.totalVisits += 1
@@ -1520,6 +1559,8 @@ async function recordVisitor(req, res) {
visitorId, visitorId,
ip, ip,
path: pathKey, path: pathKey,
referrer,
device,
country: geo.country, country: geo.country,
state: geo.state, state: geo.state,
county: geo.county, county: geo.county,
@@ -1536,13 +1577,26 @@ function loadVisitorStatsFromDisk() {
return readFile(VISITOR_STATS_FILE, 'utf8') return readFile(VISITOR_STATS_FILE, 'utf8')
.then(raw => { .then(raw => {
const parsed = JSON.parse(raw) const parsed = JSON.parse(raw)
const loadedVisitors = parsed?.visitors && typeof parsed.visitors === 'object' ? parsed.visitors : {}
// Rebuild ipHashIndex from saved visitors if not persisted (handles upgrades from old data)
let ipHashIndex = parsed?.ipHashIndex && typeof parsed.ipHashIndex === 'object' ? parsed.ipHashIndex : {}
if (Object.keys(ipHashIndex).length === 0 && Object.keys(loadedVisitors).length > 0) {
for (const [vid, visitor] of Object.entries(loadedVisitors)) {
if (visitor?.ipHash && typeof visitor.ipHash === 'string') {
ipHashIndex[visitor.ipHash] = vid
}
}
}
visitorStats = { visitorStats = {
totalVisits: Number(parsed?.totalVisits) || 0, totalVisits: Number(parsed?.totalVisits) || 0,
uniqueVisitors: Number(parsed?.uniqueVisitors) || 0, uniqueVisitors: Number(parsed?.uniqueVisitors) || 0,
returningVisits: Number(parsed?.returningVisits) || 0, returningVisits: Number(parsed?.returningVisits) || 0,
firstVisitAt: typeof parsed?.firstVisitAt === 'string' ? parsed.firstVisitAt : null, firstVisitAt: typeof parsed?.firstVisitAt === 'string' ? parsed.firstVisitAt : null,
lastVisitAt: typeof parsed?.lastVisitAt === 'string' ? parsed.lastVisitAt : null, lastVisitAt: typeof parsed?.lastVisitAt === 'string' ? parsed.lastVisitAt : null,
visitors: parsed?.visitors && typeof parsed.visitors === 'object' ? parsed.visitors : {}, visitors: loadedVisitors,
ipHashIndex,
recentVisits: Array.isArray(parsed?.recentVisits) ? parsed.recentVisits.slice(0, MAX_RECENT_VISITS) : [], recentVisits: Array.isArray(parsed?.recentVisits) ? parsed.recentVisits.slice(0, MAX_RECENT_VISITS) : [],
geoCacheByIp: parsed?.geoCacheByIp && typeof parsed.geoCacheByIp === 'object' ? parsed.geoCacheByIp : {}, geoCacheByIp: parsed?.geoCacheByIp && typeof parsed.geoCacheByIp === 'object' ? parsed.geoCacheByIp : {},
} }
@@ -4023,6 +4077,25 @@ app.post('/api/analytics-consent', (req, res) => {
res.json({ ok: true, consent }) res.json({ ok: true, consent })
}) })
// Client-side SPA pageview tracking (fires on every React Router navigation)
app.post('/api/analytics/pageview', async (req, res) => {
if (!hasVisitorConsent(req)) {
res.json({ ok: false, reason: 'no-consent' })
return
}
const ua = req.get('user-agent') ?? ''
const { isBot } = detectBot(ua)
if (isBot) {
res.json({ ok: false, reason: 'bot' })
return
}
const rawPath = typeof req.body?.path === 'string' ? req.body.path : '/'
const rawReferrer = typeof req.body?.referrer === 'string' ? req.body.referrer : ''
recordHit(rawPath, false)
await recordVisitor(req, res, rawPath, rawReferrer)
res.json({ ok: true })
})
app.get('/api/admin-stats', requireAdminAuth, (_req, res) => { app.get('/api/admin-stats', requireAdminAuth, (_req, res) => {
const topPaths = Object.entries(hitStats.byPath) const topPaths = Object.entries(hitStats.byPath)
.sort((a, b) => b[1] - a[1]) .sort((a, b) => b[1] - a[1])
@@ -4059,7 +4132,10 @@ app.get('/api/admin-stats', requireAdminAuth, (_req, res) => {
.slice(0, 10) .slice(0, 10)
.map(([reason, count]) => ({ reason, count })) .map(([reason, count]) => ({ reason, count }))
const recentVisitorRows = visitorStats.recentVisits.slice(0, 100) const recentVisitorRows = visitorStats.recentVisits.slice(0, 100).map(row => {
const fullVisitor = visitorStats.visitors[row.visitorId]
return { ...row, pageHistory: fullVisitor?.pageHistory ?? [] }
})
const enrollmentCountsBySlug = {} const enrollmentCountsBySlug = {}
for (const user of studyUsers) { for (const user of studyUsers) {
const userEnrollments = Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : [] const userEnrollments = Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : []
@@ -4126,6 +4202,26 @@ app.get('/api/admin-stats', requireAdminAuth, (_req, res) => {
topStates: buildTopLocations(recentVisitorRows, 'state'), topStates: buildTopLocations(recentVisitorRows, 'state'),
topCounties: buildTopLocations(recentVisitorRows, 'county'), topCounties: buildTopLocations(recentVisitorRows, 'county'),
topCities: buildTopLocations(recentVisitorRows, 'city'), topCities: buildTopLocations(recentVisitorRows, 'city'),
deviceBreakdown: (() => {
const counts = { mobile: 0, desktop: 0, tablet: 0, unknown: 0 }
for (const row of recentVisitorRows) {
const d = row.device ?? 'unknown'
counts[d] = (counts[d] ?? 0) + 1
}
return counts
})(),
topReferrers: (() => {
const counts = {}
for (const row of recentVisitorRows) {
if (!row.referrer) continue
counts[row.referrer] = (counts[row.referrer] ?? 0) + 1
}
return Object.entries(counts)
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([referrer, count]) => ({ referrer, count }))
})(),
last30DaysReal: buildLastNDaysStats(30).map(item => ({ day: item.day, hits: hitStats.byDayReal?.[item.day] ?? 0 })),
recentVisits: recentVisitorRows, recentVisits: recentVisitorRows,
}, },
writeStatus: { writeStatus: {
+6
View File
@@ -162,17 +162,23 @@ export interface AdminStats {
topStates: Array<{ name: string; hits: number }> topStates: Array<{ name: string; hits: number }>
topCounties: Array<{ name: string; hits: number }> topCounties: Array<{ name: string; hits: number }>
topCities: Array<{ name: string; hits: number }> topCities: Array<{ name: string; hits: number }>
deviceBreakdown: { mobile: number; desktop: number; tablet: number; unknown: number }
topReferrers: Array<{ referrer: string; count: number }>
last30DaysReal: Array<{ day: string; hits: number }>
recentVisits: Array<{ recentVisits: Array<{
at: string at: string
visitorId: string visitorId: string
ip: string ip: string
path: string path: string
referrer?: string
device?: string
country: string country: string
state: string state: string
county: string county: string
city: string city: string
returningVisitor: boolean returningVisitor: boolean
visitCount: number visitCount: number
pageHistory?: Array<{ at: string; path: string; referrer?: string }>
}> }>
} }
writeStatus: { writeStatus: {
+55
View File
@@ -3300,6 +3300,61 @@
background: rgba(201, 168, 76, 0.04); background: rgba(201, 168, 76, 0.04);
} }
.admin-visitor-row--expanded {
background: rgba(201, 168, 76, 0.07) !important;
}
.admin-visitor-history-row td {
padding: 0 !important;
background: #0d0d0a;
border-bottom: 1px solid rgba(201, 168, 76, 0.12);
}
.admin-visitor-history {
padding: 0.75rem 1.25rem 1rem;
}
.admin-visitor-history-label {
font-size: 0.78rem;
color: #8a7f5a;
margin: 0 0 0.5rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.admin-visitor-history-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.admin-visitor-history-entry {
display: flex;
gap: 0.75rem;
align-items: baseline;
font-size: 0.82rem;
flex-wrap: wrap;
}
.admin-visitor-history-time {
color: #5a5440;
white-space: nowrap;
flex-shrink: 0;
}
.admin-visitor-history-path {
color: #c9a84c;
font-family: monospace;
}
.admin-visitor-history-ref {
color: #5a5440;
font-size: 0.78rem;
}
.admin-visits-table th { .admin-visits-table th {
color: var(--brand-gold); color: var(--brand-gold);
letter-spacing: 0.08em; letter-spacing: 0.08em;
+15
View File
@@ -15,6 +15,20 @@ const SPOTIFY_EMBED_URL =
const CONSENT_KEY = 'vbn_analytics_consent_choice' const CONSENT_KEY = 'vbn_analytics_consent_choice'
const HEADLINER_WIDGET_ID = 'WI_cmou3b4q7000701p0o9qmmcfj' const HEADLINER_WIDGET_ID = 'WI_cmou3b4q7000701p0o9qmmcfj'
function usePageTracking() {
const location = useLocation()
useEffect(() => {
if (localStorage.getItem(CONSENT_KEY) !== 'accepted') return
const referrer = document.referrer || ''
fetch('/api/analytics/pageview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: location.pathname, referrer }),
keepalive: true,
}).catch(() => {})
}, [location.pathname])
}
function toSpotifyEpisodeEmbedUrl(url: string | undefined): string { function toSpotifyEpisodeEmbedUrl(url: string | undefined): string {
if (!url) return '' if (!url) return ''
@@ -2120,6 +2134,7 @@ export default function App() {
const [content, setContent] = useState<SiteContent>(DEFAULTS) const [content, setContent] = useState<SiteContent>(DEFAULTS)
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
usePageTracking()
useEffect(() => { useEffect(() => {
fetch('/api/admin-content') fetch('/api/admin-content')
+120 -6
View File
@@ -68,7 +68,8 @@ export function AnalyticsPanel({
onDeployHook, onDeployHook,
onRefreshStatus, onRefreshStatus,
}: Props) { }: Props) {
const [chartTab, setChartTab] = useState<'overview' | 'breakdown' | 'geographic'>('overview') const [chartTab, setChartTab] = useState<'overview' | 'breakdown' | 'geographic' | 'referrers'>('overview')
const [expandedVisitor, setExpandedVisitor] = useState<string | null>(null)
if (statsStatus === 'loading') return <p className="admin-stats-note">Loading analytics</p> if (statsStatus === 'loading') return <p className="admin-stats-note">Loading analytics</p>
if (statsStatus === 'error') return <p className="admin-stats-note">Failed to load analytics.</p> if (statsStatus === 'error') return <p className="admin-stats-note">Failed to load analytics.</p>
@@ -165,6 +166,33 @@ export function AnalyticsPanel({
], ],
} }
// 30-day trend chart
const last30DaysReal = stats.visitors.last30DaysReal ?? []
const thirtyDayChartData = {
labels: last30DaysReal.map((d: { day: string; hits: number }) => d.day.slice(5)),
datasets: [{
label: 'Real Visitors',
data: last30DaysReal.map((d: { day: string; hits: number }) => d.hits),
borderColor: '#c9a84c',
backgroundColor: 'rgba(201, 168, 76, 0.08)',
tension: 0.3,
fill: true,
pointRadius: 2,
}],
}
// Device breakdown (doughnut)
const deviceBreakdown = stats.visitors.deviceBreakdown ?? { mobile: 0, desktop: 0, tablet: 0, unknown: 0 }
const deviceChartData = {
labels: ['Desktop', 'Mobile', 'Tablet', 'Unknown'],
datasets: [{
data: [deviceBreakdown.desktop, deviceBreakdown.mobile, deviceBreakdown.tablet, deviceBreakdown.unknown],
backgroundColor: ['#c9a84c', '#a0853d', '#6d5a2e', '#444'],
borderColor: '#1a1a15',
borderWidth: 2,
}],
}
const chartOptions = { const chartOptions = {
responsive: true, responsive: true,
maintainAspectRatio: true, maintainAspectRatio: true,
@@ -240,12 +268,14 @@ export function AnalyticsPanel({
{/* Chart Tabs */} {/* Chart Tabs */}
<div className="admin-tabs admin-tabs--charts"> <div className="admin-tabs admin-tabs--charts">
<button type="button" className={`admin-tab${chartTab === 'overview' ? ' admin-tab--active' : ''}`} onClick={() => setChartTab('overview')}>Overview</button> <button type="button" className={`admin-tab${chartTab === 'overview' ? ' admin-tab--active' : ''}`} onClick={() => setChartTab('overview')}>Overview</button>
<button type="button" className={`admin-tab${chartTab === 'breakdown' ? ' admin-tab--active' : ''}`} onClick={() => setChartTab('breakdown')}>Bot Breakdown</button> <button type="button" className={`admin-tab${chartTab === 'breakdown' ? ' admin-tab--active' : ''}`} onClick={() => setChartTab('breakdown')}>Bot &amp; Devices</button>
<button type="button" className={`admin-tab${chartTab === 'geographic' ? ' admin-tab--active' : ''}`} onClick={() => setChartTab('geographic')}>Geographic</button> <button type="button" className={`admin-tab${chartTab === 'geographic' ? ' admin-tab--active' : ''}`} onClick={() => setChartTab('geographic')}>Geographic</button>
<button type="button" className={`admin-tab${chartTab === 'referrers' ? ' admin-tab--active' : ''}`} onClick={() => setChartTab('referrers')}>Referrers</button>
</div> </div>
{/* Overview Charts */} {/* Overview Charts */}
{chartTab === 'overview' && ( {chartTab === 'overview' && (
<>
<div className="admin-analytics-grid"> <div className="admin-analytics-grid">
<div className="admin-analytics-card"> <div className="admin-analytics-card">
<h3 className="admin-analytics-card-title">7-Day Trend (Real vs Bot)</h3> <h3 className="admin-analytics-card-title">7-Day Trend (Real vs Bot)</h3>
@@ -256,11 +286,34 @@ export function AnalyticsPanel({
<Doughnut data={trafficRatioData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} /> <Doughnut data={trafficRatioData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} />
</div> </div>
</div> </div>
<div className="admin-analytics-grid" style={{ marginTop: '1rem' }}>
<div className="admin-analytics-card admin-analytics-card--wide">
<h3 className="admin-analytics-card-title">30-Day Real Visitor Trend</h3>
<Line data={thirtyDayChartData} options={chartOptions} />
</div>
</div>
</>
)} )}
{/* Bot Breakdown Charts */} {/* Bot & Device Charts */}
{chartTab === 'breakdown' && ( {chartTab === 'breakdown' && (
<>
<div className="admin-analytics-grid"> <div className="admin-analytics-grid">
<div className="admin-analytics-card">
<h3 className="admin-analytics-card-title">Device Breakdown</h3>
<Doughnut data={deviceChartData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} />
</div>
<div className="admin-analytics-card">
<h3 className="admin-analytics-card-title">Device Counts</h3>
<ul className="admin-bot-reason-list">
<li><span>Desktop</span><strong>{deviceBreakdown.desktop.toLocaleString()}</strong></li>
<li><span>Mobile</span><strong>{deviceBreakdown.mobile.toLocaleString()}</strong></li>
<li><span>Tablet</span><strong>{deviceBreakdown.tablet.toLocaleString()}</strong></li>
{deviceBreakdown.unknown > 0 && <li><span>Unknown</span><strong>{deviceBreakdown.unknown.toLocaleString()}</strong></li>}
</ul>
</div>
</div>
<div className="admin-analytics-grid" style={{ marginTop: '1rem' }}>
<div className="admin-analytics-card"> <div className="admin-analytics-card">
<h3 className="admin-analytics-card-title">Bot Sources</h3> <h3 className="admin-analytics-card-title">Bot Sources</h3>
{botReasons.length === 0 ? ( {botReasons.length === 0 ? (
@@ -285,6 +338,7 @@ export function AnalyticsPanel({
)} )}
</div> </div>
</div> </div>
</>
)} )}
{/* Geographic Charts */} {/* Geographic Charts */}
@@ -301,6 +355,27 @@ export function AnalyticsPanel({
</div> </div>
)} )}
{/* Referrers Tab */}
{chartTab === 'referrers' && (
<div className="admin-analytics-grid">
<div className="admin-analytics-card admin-analytics-card--wide">
<h3 className="admin-analytics-card-title">Top Traffic Sources</h3>
{(stats.visitors.topReferrers ?? []).length === 0 ? (
<p className="admin-stats-note">No referrer data yet. Referrers are captured on SPA navigations after consent.</p>
) : (
<ul className="admin-bot-reason-list">
{(stats.visitors.topReferrers ?? []).map((item: { referrer: string; count: number }) => (
<li key={item.referrer}>
<span>{item.referrer}</span>
<strong>{item.count.toLocaleString()}</strong>
</li>
))}
</ul>
)}
</div>
</div>
)}
{/* Recent Visitors Table */} {/* Recent Visitors Table */}
<div className="admin-stats-head admin-stats-head--visitors"> <div className="admin-stats-head admin-stats-head--visitors">
<h2>Visitor Details</h2> <h2>Visitor Details</h2>
@@ -347,25 +422,64 @@ export function AnalyticsPanel({
<th>IP</th> <th>IP</th>
<th>Country</th> <th>Country</th>
<th>Path</th> <th>Path</th>
<th>Referrer</th>
<th>Device</th>
<th>Returning</th> <th>Returning</th>
<th>Visit #</th> <th>Visit #</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{stats.visitors.recentVisits.map(row => ( {stats.visitors.recentVisits.map(row => {
<tr key={`${row.visitorId}-${row.at}`}> const rowKey = `${row.visitorId}-${row.at}`
const isExpanded = expandedVisitor === rowKey
return (
<>
<tr
key={rowKey}
className={`admin-visitor-row${isExpanded ? ' admin-visitor-row--expanded' : ''}`}
style={{ cursor: 'pointer' }}
onClick={() => setExpandedVisitor(isExpanded ? null : rowKey)}
title="Click to view page history"
>
<td>{formatDate(row.at)}</td> <td>{formatDate(row.at)}</td>
<td><code className="admin-visitor-ip">{maskIp(row.ip)}</code></td> <td><code className="admin-visitor-ip">{maskIp(row.ip)}</code></td>
<td>{row.country || '—'}</td> <td>{row.country || '—'}</td>
<td className="admin-visitor-path" title={row.path}>{row.path}</td> <td className="admin-visitor-path" title={row.path}>{row.path}</td>
<td className="admin-visitor-path" title={row.referrer || ''}>{row.referrer || '—'}</td>
<td>{row.device || '—'}</td>
<td> <td>
<span className={`admin-visitor-tag admin-visitor-tag--${row.returningVisitor ? 'returning' : 'new'}`}> <span className={`admin-visitor-tag admin-visitor-tag--${row.returningVisitor ? 'returning' : 'new'}`}>
{row.returningVisitor ? 'Returning' : 'New'} {row.returningVisitor ? 'Returning' : 'New'}
</span> </span>
</td> </td>
<td className="admin-visitor-count">{row.visitCount}</td> <td className="admin-visitor-count">{row.visitCount} {isExpanded ? '▲' : '▼'}</td>
</tr> </tr>
{isExpanded && (row.pageHistory ?? []).length > 0 && (
<tr key={`${rowKey}-history`} className="admin-visitor-history-row">
<td colSpan={8}>
<div className="admin-visitor-history">
<p className="admin-visitor-history-label">Full page history for this visitor ({(row.pageHistory ?? []).length} pages):</p>
<ol className="admin-visitor-history-list">
{[...(row.pageHistory ?? [])].reverse().map((entry, i) => (
<li key={i} className="admin-visitor-history-entry">
<span className="admin-visitor-history-time">{formatDate(entry.at)}</span>
<span className="admin-visitor-history-path">{entry.path}</span>
{entry.referrer && <span className="admin-visitor-history-ref">from {entry.referrer}</span>}
</li>
))} ))}
</ol>
</div>
</td>
</tr>
)}
{isExpanded && (row.pageHistory ?? []).length === 0 && (
<tr key={`${rowKey}-history-empty`} className="admin-visitor-history-row">
<td colSpan={8}><p style={{ padding: '0.5rem 1rem', color: '#8a7f5a', margin: 0, fontSize: '0.85rem' }}>No page history yet history is built from SPA navigations after consent.</p></td>
</tr>
)}
</>
)
})}
</tbody> </tbody>
</table> </table>
</div> </div>