Improve analytics UX with geo maps and readability updates
This commit is contained in:
@@ -1,7 +1,32 @@
|
||||
import { useState } from 'react'
|
||||
import { Line, Bar, Pie, Doughnut } from 'react-chartjs-2'
|
||||
import GeoMaps from './GeoMaps'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ArcElement,
|
||||
} from 'chart.js'
|
||||
import type { AdminStats } from '../AdminPage'
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ArcElement,
|
||||
)
|
||||
|
||||
interface Props {
|
||||
stats: AdminStats | null
|
||||
statsStatus: 'loading' | 'error' | 'ready'
|
||||
@@ -49,13 +74,25 @@ export function AnalyticsPanel({
|
||||
if (statsStatus === 'error') return <p className="admin-stats-note">Failed to load analytics.</p>
|
||||
if (!stats) return null
|
||||
|
||||
// Normalize fields that may be missing from older data
|
||||
const realHits = stats.realHits ?? 0
|
||||
const botHits = stats.botHits ?? 0
|
||||
const totalHits = stats.totalHits ?? 0
|
||||
const last7DaysReal = stats.last7DaysReal ?? stats.last7Days.map((d: { day: string; hits: number }) => ({ day: d.day, hits: 0 }))
|
||||
const last7DaysBot = stats.last7DaysBot ?? stats.last7Days.map((d: { day: string; hits: number }) => ({ day: d.day, hits: 0 }))
|
||||
const last30DaysRealTotal = stats.last30DaysRealTotal ?? 0
|
||||
const last30DaysBotTotal = stats.last30DaysBotTotal ?? 0
|
||||
const topPathsReal = stats.topPathsReal ?? stats.topPaths
|
||||
const topPathsBot = stats.topPathsBot ?? []
|
||||
const botReasons = stats.botReasons ?? []
|
||||
|
||||
// Chart data - Daily trend
|
||||
const dailyChartData = {
|
||||
labels: stats.last7Days.map((d: { day: string; hits: number }) => d.day),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Real Visitors',
|
||||
data: stats.last7DaysReal.map((d: { day: string; hits: number }) => d.hits),
|
||||
data: last7DaysReal.map((d: { day: string; hits: number }) => d.hits),
|
||||
borderColor: '#c9a84c',
|
||||
backgroundColor: 'rgba(201, 168, 76, 0.1)',
|
||||
tension: 0.4,
|
||||
@@ -63,7 +100,7 @@ export function AnalyticsPanel({
|
||||
},
|
||||
{
|
||||
label: 'Bot Traffic',
|
||||
data: stats.last7DaysBot.map((d: { day: string; hits: number }) => d.hits),
|
||||
data: last7DaysBot.map((d: { day: string; hits: number }) => d.hits),
|
||||
borderColor: '#666',
|
||||
backgroundColor: 'rgba(102, 102, 102, 0.1)',
|
||||
tension: 0.4,
|
||||
@@ -74,9 +111,9 @@ export function AnalyticsPanel({
|
||||
|
||||
// Chart data - Bot breakdown (pie)
|
||||
const botPieData = {
|
||||
labels: stats.botReasons.map((b: { reason: string; count: number }) => b.reason.replace(/-/g, ' ')),
|
||||
labels: botReasons.map((b: { reason: string; count: number }) => b.reason.replace(/-/g, ' ')),
|
||||
datasets: [{
|
||||
data: stats.botReasons.map((b: { reason: string; count: number }) => b.count),
|
||||
data: botReasons.map((b: { reason: string; count: number }) => b.count),
|
||||
backgroundColor: [
|
||||
'#c9a84c',
|
||||
'#a0853d',
|
||||
@@ -98,7 +135,7 @@ export function AnalyticsPanel({
|
||||
const trafficRatioData = {
|
||||
labels: ['Real Visitors', 'Bot Traffic'],
|
||||
datasets: [{
|
||||
data: [stats.realHits, stats.botHits],
|
||||
data: [realHits, botHits],
|
||||
backgroundColor: ['#c9a84c', '#999'],
|
||||
borderColor: '#1a1a15',
|
||||
borderWidth: 2,
|
||||
@@ -106,9 +143,9 @@ export function AnalyticsPanel({
|
||||
}
|
||||
|
||||
// Top paths comparison
|
||||
const topPathsLabels = Array.from({ length: Math.max(stats.topPathsReal.length, stats.topPathsBot.length) }, (_, i: number) => {
|
||||
const realPath = stats.topPathsReal[i]?.path || ''
|
||||
const botPath = stats.topPathsBot[i]?.path || ''
|
||||
const topPathsLabels = Array.from({ length: Math.max(topPathsReal.length, topPathsBot.length) }, (_, i: number) => {
|
||||
const realPath = topPathsReal[i]?.path || ''
|
||||
const botPath = topPathsBot[i]?.path || ''
|
||||
return realPath || botPath || `Path ${i + 1}`
|
||||
}).slice(0, 8)
|
||||
|
||||
@@ -117,12 +154,12 @@ export function AnalyticsPanel({
|
||||
datasets: [
|
||||
{
|
||||
label: 'Real Hits',
|
||||
data: topPathsLabels.map((_label, i) => stats.topPathsReal[i]?.hits || 0),
|
||||
data: topPathsLabels.map((_label, i) => topPathsReal[i]?.hits || 0),
|
||||
backgroundColor: '#c9a84c',
|
||||
},
|
||||
{
|
||||
label: 'Bot Hits',
|
||||
data: topPathsLabels.map((_label, i) => stats.topPathsBot[i]?.hits || 0),
|
||||
data: topPathsLabels.map((_label, i) => topPathsBot[i]?.hits || 0),
|
||||
backgroundColor: '#999',
|
||||
},
|
||||
],
|
||||
@@ -175,18 +212,18 @@ export function AnalyticsPanel({
|
||||
</article>
|
||||
<article>
|
||||
<h3>Real Traffic</h3>
|
||||
<p>{stats.realHits.toLocaleString()}</p>
|
||||
<small>{((stats.realHits / stats.totalHits) * 100).toFixed(1)}% of total</small>
|
||||
<p>{realHits.toLocaleString()}</p>
|
||||
<small>{totalHits > 0 ? ((realHits / totalHits) * 100).toFixed(1) : '0.0'}% of total</small>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Bot Traffic</h3>
|
||||
<p>{stats.botHits.toLocaleString()}</p>
|
||||
<small>{((stats.botHits / stats.totalHits) * 100).toFixed(1)}% of total</small>
|
||||
<p>{botHits.toLocaleString()}</p>
|
||||
<small>{totalHits > 0 ? ((botHits / totalHits) * 100).toFixed(1) : '0.0'}% of total</small>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Last 30 Days</h3>
|
||||
<p>{stats.last30DaysRealTotal.toLocaleString()}</p>
|
||||
<small>Real hits • {stats.last30DaysBotTotal.toLocaleString()} bots</small>
|
||||
<p>{last30DaysRealTotal.toLocaleString()}</p>
|
||||
<small>Real hits • {last30DaysBotTotal.toLocaleString()} bots</small>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Unique Visitors</h3>
|
||||
@@ -226,7 +263,7 @@ export function AnalyticsPanel({
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))', gap: '2rem', marginBottom: '2rem' }}>
|
||||
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518' }}>
|
||||
<h3 style={{ marginBottom: '1rem' }}>Bot Sources</h3>
|
||||
{stats.botReasons.length === 0 ? (
|
||||
{botReasons.length === 0 ? (
|
||||
<p className="admin-stats-note">No bot traffic detected.</p>
|
||||
) : (
|
||||
<Pie data={botPieData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} />
|
||||
@@ -234,11 +271,11 @@ export function AnalyticsPanel({
|
||||
</div>
|
||||
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518' }}>
|
||||
<h3 style={{ marginBottom: '1rem' }}>Bot Detection Details</h3>
|
||||
{stats.botReasons.length === 0 ? (
|
||||
{botReasons.length === 0 ? (
|
||||
<p className="admin-stats-note">No bots detected yet.</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', padding: 0 }}>
|
||||
{stats.botReasons.map((reason: { reason: string; count: number }) => (
|
||||
{botReasons.map((reason: { reason: string; count: number }) => (
|
||||
<li key={reason.reason} style={{ padding: '0.5rem 0', display: 'flex', justifyContent: 'space-between', borderBottom: '1px solid #2a2518' }}>
|
||||
<span>{reason.reason.replace(/-/g, ' ')}</span>
|
||||
<strong style={{ color: '#c9a84c' }}>{reason.count.toLocaleString()}</strong>
|
||||
@@ -252,26 +289,15 @@ export function AnalyticsPanel({
|
||||
|
||||
{/* Geographic Charts */}
|
||||
{chartTab === 'geographic' && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))', gap: '2rem', marginBottom: '2rem' }}>
|
||||
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518' }}>
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518', marginBottom: '2rem' }}>
|
||||
<h3 style={{ marginBottom: '1rem' }}>Top Pages</h3>
|
||||
<Bar data={topPathsChartData} options={{...chartOptions, indexAxis: 'y' as const}} />
|
||||
</div>
|
||||
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518' }}>
|
||||
<h3 style={{ marginBottom: '1rem' }}>Top Countries</h3>
|
||||
{stats.visitors.topCountries.length === 0 ? (
|
||||
<p className="admin-stats-note">No visitor data yet.</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', padding: 0 }}>
|
||||
{stats.visitors.topCountries.map((country: { name: string; hits: number }) => (
|
||||
<li key={country.name} style={{ padding: '0.5rem 0', display: 'flex', justifyContent: 'space-between', borderBottom: '1px solid #2a2518' }}>
|
||||
<span>{country.name}</span>
|
||||
<strong style={{ color: '#c9a84c' }}>{country.hits.toLocaleString()}</strong>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<GeoMaps
|
||||
topCountries={stats.visitors.topCountries}
|
||||
topStates={stats.visitors.topStates}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -290,8 +316,22 @@ export function AnalyticsPanel({
|
||||
</div>
|
||||
|
||||
<div className="admin-stats-lists">
|
||||
<div><h3>Top Countries</h3><ul>{stats.visitors.topCountries.map((item: { name: string; hits: number }) => <li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>)}</ul></div>
|
||||
<div><h3>Top States</h3><ul>{stats.visitors.topStates.map((item: { name: string; hits: number }) => <li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>)}</ul></div>
|
||||
<div>
|
||||
<h3>Top Countries</h3>
|
||||
<ul>
|
||||
{stats.visitors.topCountries.map((item: { name: string; hits: number }) => (
|
||||
<li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Top States</h3>
|
||||
<ul>
|
||||
{stats.visitors.topStates.map((item: { name: string; hits: number }) => (
|
||||
<li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-visits-table-wrap">
|
||||
@@ -302,12 +342,36 @@ export function AnalyticsPanel({
|
||||
<div className="admin-visits-table-scroll">
|
||||
<table className="admin-visits-table">
|
||||
<thead>
|
||||
<tr><th>Time</th><th>IP</th><th>Country</th><th>Path</th><th>Returning</th><th>Visit #</th></tr>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>IP</th>
|
||||
<th>Country</th>
|
||||
<th>Path</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>{maskIp(row.ip)}</td><td>{row.country}</td><td>{row.path}</td><td>{row.returningVisitor ? 'Yes' : 'No'}</td><td>{row.visitCount}</td>
|
||||
<td>{formatDate(row.at)}</td>
|
||||
<td><code style={{ fontSize: '0.8rem', color: '#aaa' }}>{maskIp(row.ip)}</code></td>
|
||||
<td>{row.country || '—'}</td>
|
||||
<td style={{ maxWidth: '220px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={row.path}>{row.path}</td>
|
||||
<td>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
padding: '0.1rem 0.5rem',
|
||||
borderRadius: '999px',
|
||||
fontSize: '0.75rem',
|
||||
backgroundColor: row.returningVisitor ? '#1a3a1a' : '#1a1a15',
|
||||
color: row.returningVisitor ? '#6dbf6d' : '#888',
|
||||
border: `1px solid ${row.returningVisitor ? '#2a5a2a' : '#333'}`,
|
||||
}}>
|
||||
{row.returningVisitor ? 'Returning' : 'New'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center', color: '#c9a84c' }}>{row.visitCount}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
Reference in New Issue
Block a user