analytics fixs
This commit is contained in:
@@ -162,17 +162,23 @@ export interface AdminStats {
|
||||
topStates: Array<{ name: string; hits: number }>
|
||||
topCounties: 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<{
|
||||
at: string
|
||||
visitorId: string
|
||||
ip: string
|
||||
path: string
|
||||
referrer?: string
|
||||
device?: string
|
||||
country: string
|
||||
state: string
|
||||
county: string
|
||||
city: string
|
||||
returningVisitor: boolean
|
||||
visitCount: number
|
||||
pageHistory?: Array<{ at: string; path: string; referrer?: string }>
|
||||
}>
|
||||
}
|
||||
writeStatus: {
|
||||
|
||||
+55
@@ -3300,6 +3300,61 @@
|
||||
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 {
|
||||
color: var(--brand-gold);
|
||||
letter-spacing: 0.08em;
|
||||
|
||||
+15
@@ -15,6 +15,20 @@ const SPOTIFY_EMBED_URL =
|
||||
const CONSENT_KEY = 'vbn_analytics_consent_choice'
|
||||
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 {
|
||||
if (!url) return ''
|
||||
|
||||
@@ -2120,6 +2134,7 @@ export default function App() {
|
||||
const [content, setContent] = useState<SiteContent>(DEFAULTS)
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
usePageTracking()
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/admin-content')
|
||||
|
||||
@@ -68,7 +68,8 @@ export function AnalyticsPanel({
|
||||
onDeployHook,
|
||||
onRefreshStatus,
|
||||
}: 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 === '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 = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
@@ -240,51 +268,77 @@ export function AnalyticsPanel({
|
||||
{/* Chart Tabs */}
|
||||
<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 === '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 & 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 === 'referrers' ? ' admin-tab--active' : ''}`} onClick={() => setChartTab('referrers')}>Referrers</button>
|
||||
</div>
|
||||
|
||||
{/* Overview Charts */}
|
||||
{chartTab === 'overview' && (
|
||||
<div className="admin-analytics-grid">
|
||||
<div className="admin-analytics-card">
|
||||
<h3 className="admin-analytics-card-title">7-Day Trend (Real vs Bot)</h3>
|
||||
<Line data={dailyChartData} options={chartOptions} />
|
||||
<>
|
||||
<div className="admin-analytics-grid">
|
||||
<div className="admin-analytics-card">
|
||||
<h3 className="admin-analytics-card-title">7-Day Trend (Real vs Bot)</h3>
|
||||
<Line data={dailyChartData} options={chartOptions} />
|
||||
</div>
|
||||
<div className="admin-analytics-card">
|
||||
<h3 className="admin-analytics-card-title">Traffic Composition</h3>
|
||||
<Doughnut data={trafficRatioData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-analytics-card">
|
||||
<h3 className="admin-analytics-card-title">Traffic Composition</h3>
|
||||
<Doughnut data={trafficRatioData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} />
|
||||
<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>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Bot Breakdown Charts */}
|
||||
{/* Bot & Device Charts */}
|
||||
{chartTab === 'breakdown' && (
|
||||
<div className="admin-analytics-grid">
|
||||
<div className="admin-analytics-card">
|
||||
<h3 className="admin-analytics-card-title">Bot Sources</h3>
|
||||
{botReasons.length === 0 ? (
|
||||
<p className="admin-stats-note">No bot traffic detected.</p>
|
||||
) : (
|
||||
<Pie data={botPieData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} />
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-analytics-card">
|
||||
<h3 className="admin-analytics-card-title">Bot Detection Details</h3>
|
||||
{botReasons.length === 0 ? (
|
||||
<p className="admin-stats-note">No bots detected yet.</p>
|
||||
) : (
|
||||
<>
|
||||
<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">
|
||||
{botReasons.map((reason: { reason: string; count: number }) => (
|
||||
<li key={reason.reason} className="admin-bot-reason-item">
|
||||
<span>{reason.reason.replace(/-/g, ' ')}</span>
|
||||
<strong>{reason.count.toLocaleString()}</strong>
|
||||
</li>
|
||||
))}
|
||||
<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>
|
||||
<div className="admin-analytics-grid" style={{ marginTop: '1rem' }}>
|
||||
<div className="admin-analytics-card">
|
||||
<h3 className="admin-analytics-card-title">Bot Sources</h3>
|
||||
{botReasons.length === 0 ? (
|
||||
<p className="admin-stats-note">No bot traffic detected.</p>
|
||||
) : (
|
||||
<Pie data={botPieData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} />
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-analytics-card">
|
||||
<h3 className="admin-analytics-card-title">Bot Detection Details</h3>
|
||||
{botReasons.length === 0 ? (
|
||||
<p className="admin-stats-note">No bots detected yet.</p>
|
||||
) : (
|
||||
<ul className="admin-bot-reason-list">
|
||||
{botReasons.map((reason: { reason: string; count: number }) => (
|
||||
<li key={reason.reason} className="admin-bot-reason-item">
|
||||
<span>{reason.reason.replace(/-/g, ' ')}</span>
|
||||
<strong>{reason.count.toLocaleString()}</strong>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Geographic Charts */}
|
||||
@@ -301,6 +355,27 @@ export function AnalyticsPanel({
|
||||
</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 */}
|
||||
<div className="admin-stats-head admin-stats-head--visitors">
|
||||
<h2>Visitor Details</h2>
|
||||
@@ -347,25 +422,64 @@ export function AnalyticsPanel({
|
||||
<th>IP</th>
|
||||
<th>Country</th>
|
||||
<th>Path</th>
|
||||
<th>Referrer</th>
|
||||
<th>Device</th>
|
||||
<th>Returning</th>
|
||||
<th>Visit #</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stats.visitors.recentVisits.map(row => (
|
||||
<tr key={`${row.visitorId}-${row.at}`}>
|
||||
<td>{formatDate(row.at)}</td>
|
||||
<td><code className="admin-visitor-ip">{maskIp(row.ip)}</code></td>
|
||||
<td>{row.country || '—'}</td>
|
||||
<td className="admin-visitor-path" title={row.path}>{row.path}</td>
|
||||
<td>
|
||||
<span className={`admin-visitor-tag admin-visitor-tag--${row.returningVisitor ? 'returning' : 'new'}`}>
|
||||
{row.returningVisitor ? 'Returning' : 'New'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="admin-visitor-count">{row.visitCount}</td>
|
||||
</tr>
|
||||
))}
|
||||
{stats.visitors.recentVisits.map(row => {
|
||||
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><code className="admin-visitor-ip">{maskIp(row.ip)}</code></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>
|
||||
<td>{row.device || '—'}</td>
|
||||
<td>
|
||||
<span className={`admin-visitor-tag admin-visitor-tag--${row.returningVisitor ? 'returning' : 'new'}`}>
|
||||
{row.returningVisitor ? 'Returning' : 'New'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="admin-visitor-count">{row.visitCount} {isExpanded ? '▲' : '▼'}</td>
|
||||
</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>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user