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:
@@ -456,6 +456,7 @@ export function AnalyticsPanel({
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>IP</th>
|
||||
<th>User</th>
|
||||
<th>Country</th>
|
||||
<th>Path</th>
|
||||
<th>Referrer</th>
|
||||
@@ -479,6 +480,7 @@ export function AnalyticsPanel({
|
||||
>
|
||||
<td>{formatDate(row.at)}</td>
|
||||
<td><code className="admin-visitor-ip">{maskIp(row.ip)}</code></td>
|
||||
<td>{row.studyUser ? (row.studyUser.displayName || row.studyUser.username) : '—'}</td>
|
||||
<td>{row.country || '—'}</td>
|
||||
<td className="admin-visitor-path" title={row.path}>{row.path}</td>
|
||||
<td className="admin-visitor-path" title={row.referrer || ''}>{row.referrer || '—'}</td>
|
||||
@@ -492,7 +494,7 @@ export function AnalyticsPanel({
|
||||
</tr>
|
||||
{isExpanded && (row.pageHistory ?? []).length > 0 && (
|
||||
<tr key={`${rowKey}-history`} className="admin-visitor-history-row">
|
||||
<td colSpan={8}>
|
||||
<td colSpan={9}>
|
||||
<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">
|
||||
@@ -566,6 +568,160 @@ export function AnalyticsPanel({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Engagement */}
|
||||
{stats.engagement && (
|
||||
<>
|
||||
<div className="admin-stats-head admin-stats-head--visitors">
|
||||
<h2>Engagement</h2>
|
||||
<p>Scroll depth, time on page, audio, and interaction data from consenting visitors.</p>
|
||||
</div>
|
||||
|
||||
{/* Time on page */}
|
||||
{stats.engagement.timeOnPage?.length > 0 && (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Avg. Time on Page</h3>
|
||||
<div className="admin-stats-table-wrap">
|
||||
<table className="admin-stats-table">
|
||||
<thead><tr><th>Page</th><th>Avg Time</th><th>Sessions</th></tr></thead>
|
||||
<tbody>
|
||||
{stats.engagement.timeOnPage.slice(0, 15).map((row: { path: string; avgSeconds: number; count: number }) => (
|
||||
<tr key={row.path}>
|
||||
<td>{row.path}</td>
|
||||
<td>{row.avgSeconds >= 60 ? `${Math.floor(row.avgSeconds / 60)}m ${row.avgSeconds % 60}s` : `${row.avgSeconds}s`}</td>
|
||||
<td>{row.count.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scroll depth */}
|
||||
{stats.engagement.scrollDepth?.length > 0 && (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Scroll Depth</h3>
|
||||
<div className="admin-stats-table-wrap">
|
||||
<table className="admin-stats-table">
|
||||
<thead><tr><th>Page</th><th>25%</th><th>50%</th><th>75%</th><th>90%</th></tr></thead>
|
||||
<tbody>
|
||||
{stats.engagement.scrollDepth.slice(0, 15).map((row: { path: string; 25?: number; 50?: number; 75?: number; 90?: number }) => (
|
||||
<tr key={row.path}>
|
||||
<td>{row.path}</td>
|
||||
<td>{(row[25] ?? 0).toLocaleString()}</td>
|
||||
<td>{(row[50] ?? 0).toLocaleString()}</td>
|
||||
<td>{(row[75] ?? 0).toLocaleString()}</td>
|
||||
<td>{(row[90] ?? 0).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audio events */}
|
||||
{stats.engagement.audioEvents?.length > 0 && (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Audio Engagement</h3>
|
||||
<div className="admin-stats-table-wrap">
|
||||
<table className="admin-stats-table">
|
||||
<thead><tr><th>Episode</th><th>Completions</th><th>Pauses</th><th>Total Listen</th></tr></thead>
|
||||
<tbody>
|
||||
{stats.engagement.audioEvents.map((row: { title: string; pauses: number; completions: number; totalListenSeconds: number }) => {
|
||||
const hrs = Math.floor(row.totalListenSeconds / 3600)
|
||||
const mins = Math.floor((row.totalListenSeconds % 3600) / 60)
|
||||
const listenStr = hrs > 0 ? `${hrs}h ${mins}m` : `${mins}m`
|
||||
return (
|
||||
<tr key={row.title}>
|
||||
<td style={{ maxWidth: 240, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{row.title}</td>
|
||||
<td>{row.completions.toLocaleString()}</td>
|
||||
<td>{row.pauses.toLocaleString()}</td>
|
||||
<td>{listenStr}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search queries */}
|
||||
{stats.engagement.topSearchQueries?.length > 0 && (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Top Search Queries</h3>
|
||||
<div className="admin-stats-table-wrap">
|
||||
<table className="admin-stats-table">
|
||||
<thead><tr><th>Query</th><th>Searches</th></tr></thead>
|
||||
<tbody>
|
||||
{stats.engagement.topSearchQueries.map((row: { query: string; count: number }) => (
|
||||
<tr key={row.query}><td>{row.query}</td><td>{row.count.toLocaleString()}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Outbound clicks */}
|
||||
{stats.engagement.topOutboundClicks?.length > 0 && (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Outbound Link Clicks</h3>
|
||||
<div className="admin-stats-table-wrap">
|
||||
<table className="admin-stats-table">
|
||||
<thead><tr><th>URL</th><th>Clicks</th></tr></thead>
|
||||
<tbody>
|
||||
{stats.engagement.topOutboundClicks.map((row: { url: string; count: number }) => (
|
||||
<tr key={row.url}>
|
||||
<td style={{ maxWidth: 320, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
<a href={row.url} target="_blank" rel="noreferrer" style={{ color: '#c9a84c' }}>{row.url}</a>
|
||||
</td>
|
||||
<td>{row.count.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* UTM sources */}
|
||||
{stats.engagement.topUTMSources?.length > 0 && (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>UTM Sources</h3>
|
||||
<div className="admin-stats-table-wrap">
|
||||
<table className="admin-stats-table">
|
||||
<thead><tr><th>Source</th><th>Visits</th></tr></thead>
|
||||
<tbody>
|
||||
{stats.engagement.topUTMSources.map((row: { source: string; count: number }) => (
|
||||
<tr key={row.source}><td>{row.source}</td><td>{row.count.toLocaleString()}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 404s */}
|
||||
{stats.engagement.top404s?.length > 0 && (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>404 Not Found</h3>
|
||||
<div className="admin-stats-table-wrap">
|
||||
<table className="admin-stats-table">
|
||||
<thead><tr><th>Path</th><th>Hits</th></tr></thead>
|
||||
<tbody>
|
||||
{stats.engagement.top404s.map((row: { path: string; count: number }) => (
|
||||
<tr key={row.path}><td>{row.path}</td><td>{row.count.toLocaleString()}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Contact Summary */}
|
||||
<div className="admin-stats-head admin-stats-head--visitors">
|
||||
<h2>Contact Summary</h2>
|
||||
|
||||
Reference in New Issue
Block a user