Improve analytics UX with geo maps and readability updates

This commit is contained in:
nmemmert
2026-05-07 10:29:49 -04:00
parent 3cbb7cc409
commit 90e5c677d8
9 changed files with 540 additions and 82 deletions
+181 -1
View File
@@ -12,12 +12,14 @@
"express": "^5.2.1",
"express-rate-limit": "^8.4.1",
"otplib": "^13.4.0",
"prop-types": "^15.8.1",
"qrcode": "^1.5.4",
"react": "^19.2.4",
"react-chartjs-2": "^5.2.0",
"react-dom": "^19.2.4",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.1",
"react-simple-maps": "^3.0.0",
"remark-gfm": "^4.0.1",
"resend": "^6.10.0"
},
@@ -1038,6 +1040,7 @@
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -1769,6 +1772,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT"
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -1887,8 +1896,105 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "2.12.1",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz",
"integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==",
"license": "BSD-3-Clause",
"dependencies": {
"internmap": "^1.0.0"
}
},
"node_modules/d3-color": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-2.0.0.tgz",
"integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==",
"license": "BSD-3-Clause"
},
"node_modules/d3-dispatch": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-2.0.0.tgz",
"integrity": "sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA==",
"license": "BSD-3-Clause"
},
"node_modules/d3-drag": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-2.0.0.tgz",
"integrity": "sha512-g9y9WbMnF5uqB9qKqwIIa/921RYWzlUDv9Jl1/yONQwxbOfszAWTCm8u7HOTgJgRDXiRZN56cHT9pd24dmXs8w==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-dispatch": "1 - 2",
"d3-selection": "2"
}
},
"node_modules/d3-ease": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-2.0.0.tgz",
"integrity": "sha512-68/n9JWarxXkOWMshcT5IcjbB+agblQUaIsbnXmrzejn2O82n3p2A9R2zEB9HIEFWKFwPAEDDN8gR0VdSAyyAQ==",
"license": "BSD-3-Clause"
},
"node_modules/d3-geo": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-2.0.2.tgz",
"integrity": "sha512-8pM1WGMLGFuhq9S+FpPURxic+gKzjluCD/CHTuUF3mXMeiCo0i6R0tO1s4+GArRFde96SLcW/kOFRjoAosPsFA==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-array": "^2.5.0"
}
},
"node_modules/d3-interpolate": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz",
"integrity": "sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-color": "1 - 2"
}
},
"node_modules/d3-selection": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-2.0.0.tgz",
"integrity": "sha512-XoGGqhLUN/W14NmaqcO/bb1nqjDAw5WtSYb2X8wiuQWvSZUsUVYsOSkOybUrNvcBjaywBdYPy03eXHMXjk9nZA==",
"license": "BSD-3-Clause"
},
"node_modules/d3-timer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz",
"integrity": "sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA==",
"license": "BSD-3-Clause"
},
"node_modules/d3-transition": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-2.0.0.tgz",
"integrity": "sha512-42ltAGgJesfQE3u9LuuBHNbGrI/AJjNL2OAUdclE70UE6Vy239GCBEYD38uBPoLeNsOhFStGpPI0BAOV+HMxog==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-color": "1 - 2",
"d3-dispatch": "1 - 2",
"d3-ease": "1 - 2",
"d3-interpolate": "1 - 2",
"d3-timer": "1 - 2"
},
"peerDependencies": {
"d3-selection": "2"
}
},
"node_modules/d3-zoom": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-2.0.0.tgz",
"integrity": "sha512-fFg7aoaEm9/jf+qfstak0IYpnesZLiMX6GZvXtUSdv8RH2o4E2qeelgdU09eKS6wGuiGMfcnMI0nTIqWzRHGpw==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-dispatch": "1 - 2",
"d3-drag": "2",
"d3-interpolate": "1 - 2",
"d3-selection": "2",
"d3-transition": "2"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2801,6 +2907,12 @@
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
"license": "MIT"
},
"node_modules/internmap": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz",
"integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==",
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
@@ -2924,7 +3036,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -3305,6 +3416,18 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -4286,6 +4409,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -4542,6 +4674,17 @@
"node": ">= 0.8.0"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/property-information": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
@@ -4780,6 +4923,12 @@
"react": "^19.2.4"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-markdown": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
@@ -4845,6 +4994,23 @@
"react-dom": ">=18"
}
},
"node_modules/react-simple-maps": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/react-simple-maps/-/react-simple-maps-3.0.0.tgz",
"integrity": "sha512-vKNFrvpPG8Vyfdjnz5Ne1N56rZlDfHXv5THNXOVZMqbX1rWZA48zQuYT03mx6PAKanqarJu/PDLgshIZAfHHqw==",
"license": "MIT",
"dependencies": {
"d3-geo": "^2.0.2",
"d3-selection": "^2.0.0",
"d3-zoom": "^2.0.0",
"topojson-client": "^3.1.0"
},
"peerDependencies": {
"prop-types": "^15.7.2",
"react": "^16.8.0 || 17.x || 18.x",
"react-dom": "^16.8.0 || 17.x || 18.x"
}
},
"node_modules/remark-gfm": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
@@ -5376,6 +5542,20 @@
"node": ">=0.6"
}
},
"node_modules/topojson-client": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
"integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==",
"license": "ISC",
"dependencies": {
"commander": "2"
},
"bin": {
"topo2geo": "bin/topo2geo",
"topomerge": "bin/topomerge",
"topoquantize": "bin/topoquantize"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
+2
View File
@@ -17,12 +17,14 @@
"express": "^5.2.1",
"express-rate-limit": "^8.4.1",
"otplib": "^13.4.0",
"prop-types": "^15.8.1",
"qrcode": "^1.5.4",
"react": "^19.2.4",
"react-chartjs-2": "^5.2.0",
"react-dom": "^19.2.4",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.1",
"react-simple-maps": "^3.0.0",
"remark-gfm": "^4.0.1",
"resend": "^6.10.0"
},
-24
View File
@@ -1,34 +1,10 @@
import { useEffect, useRef, useState } from 'react'
import type { ChangeEvent } from 'react'
import { Link } from 'react-router-dom'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Title,
Tooltip,
Legend,
ArcElement,
} from 'chart.js'
import type { SiteContent, CustomLink, CustomBlock, ArchivedSeries, ArchivedSeriesResourceLink, ArchivedSeriesNote, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings } from './content'
import { DEFAULTS } from './content'
import { AnalyticsPanel } from './components/AnalyticsPanel'
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Title,
Tooltip,
Legend,
ArcElement,
)
interface Props {
content: SiteContent
onSave: (c: SiteContent) => void
+20 -15
View File
@@ -192,8 +192,8 @@
.word-with {
color: var(--brand-warm-white);
font-weight: 300;
opacity: 0.85;
font-weight: 400;
opacity: 0.9;
}
.rule-divider {
@@ -413,11 +413,10 @@
.episode-desc {
font-family: var(--brand-font-body);
font-size: 0.88rem;
font-weight: 300;
line-height: 1.55;
font-weight: 400;
line-height: 1.6;
color: var(--brand-muted);
margin: 0 0 0.6rem;
opacity: 0.85;
}
.episode-listen-cta {
@@ -1190,7 +1189,7 @@
font-size: 0.88rem;
font-style: italic;
line-height: 1.6;
color: #6d6b66;
color: #a09888;
}
.contact-scripture-ref {
@@ -2333,27 +2332,33 @@
}
.admin-visits-table-scroll {
overflow-x: visible;
overflow-x: auto;
border-radius: 0.5rem;
border: 1px solid #2a2518;
}
.admin-visits-table {
width: 100%;
border-collapse: collapse;
min-width: 0;
table-layout: fixed;
min-width: 620px;
table-layout: auto;
}
.admin-visits-table th,
.admin-visits-table td {
text-align: left;
padding: 0.5rem 0.6rem;
border-bottom: 1px solid rgba(201, 168, 76, 0.16);
padding: 0.55rem 0.75rem;
border-bottom: 1px solid rgba(201, 168, 76, 0.1);
font-family: var(--brand-font-body);
color: var(--brand-warm-white);
font-size: 0.9rem;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
font-size: 0.875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.admin-visits-table tbody tr:hover {
background: rgba(201, 168, 76, 0.04);
}
.admin-visits-table th {
+104 -40
View File
@@ -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>
+181
View File
@@ -0,0 +1,181 @@
import { useState } from 'react'
import { ComposableMap, Geographies, Geography } from 'react-simple-maps'
const WORLD_URL = 'https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json'
const US_URL = 'https://cdn.jsdelivr.net/npm/us-atlas@3/states-10m.json'
// Normalize topojson country names to match what geolocation APIs typically return
const COUNTRY_ALIASES: Record<string, string> = {
'united states of america': 'united states',
'russian federation': 'russia',
'republic of korea': 'south korea',
"democratic people's republic of korea": 'north korea',
'united kingdom of great britain and northern ireland': 'united kingdom',
'iran (islamic republic of)': 'iran',
'syrian arab republic': 'syria',
'bolivarian republic of venezuela': 'venezuela',
'plurinational state of bolivia': 'bolivia',
'united republic of tanzania': 'tanzania',
'democratic republic of the congo': 'dr congo',
"lao people's democratic republic": 'laos',
'viet nam': 'vietnam',
'taiwan, province of china': 'taiwan',
}
function normalizeCountry(name: string): string {
const lower = name.toLowerCase()
return COUNTRY_ALIASES[lower] ?? lower
}
function getColor(hits: number, max: number, baseColor = '#1e1e18'): string {
if (!hits || max === 0) return baseColor
const t = Math.sqrt(hits / max) // sqrt for better visual spread
const r = Math.round(30 + (201 - 30) * t)
const g = Math.round(30 + (168 - 30) * t)
const b = Math.round(21 + (76 - 21) * t)
return `rgb(${r},${g},${b})`
}
interface TooltipState {
name: string
hits: number
x: number
y: number
}
interface GeoMapsProps {
topCountries: Array<{ name: string; hits: number }>
topStates: Array<{ name: string; hits: number }>
}
export default function GeoMaps({ topCountries, topStates }: GeoMapsProps) {
const [tooltip, setTooltip] = useState<TooltipState | null>(null)
const countryMap = new Map(topCountries.map(c => [c.name.toLowerCase(), c.hits]))
const stateMap = new Map(topStates.map(s => [s.name.toLowerCase(), s.hits]))
const maxCountry = Math.max(...topCountries.map(c => c.hits), 1)
const maxState = Math.max(...topStates.map(s => s.hits), 1)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
{/* World map */}
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518', position: 'relative' }}>
<h3 style={{ marginBottom: '0.75rem', color: '#c9a84c', fontFamily: 'var(--brand-font-body)', textTransform: 'uppercase', letterSpacing: '0.08em', fontSize: '0.85rem' }}>
Visitors by Country
</h3>
{topCountries.length === 0 && (
<p style={{ color: '#b0a48c', fontSize: '0.875rem' }}>No country data yet.</p>
)}
<ComposableMap
projectionConfig={{ scale: 145, center: [0, 10] }}
style={{ width: '100%', height: 'auto', display: 'block' }}
>
<Geographies geography={WORLD_URL}>
{({ geographies }) =>
geographies.map(geo => {
const geoName = geo.properties?.name ?? ''
const normalized = normalizeCountry(geoName)
const hits = countryMap.get(normalized) ?? countryMap.get(geoName.toLowerCase()) ?? 0
return (
<Geography
key={geo.rsmKey ?? geoName}
geography={geo}
fill={getColor(hits, maxCountry)}
stroke="#0a0a08"
strokeWidth={0.4}
style={{
default: { outline: 'none' },
hover: { outline: 'none', fill: hits ? '#e0c070' : '#2a2a22', cursor: 'default' },
pressed: { outline: 'none' },
}}
onMouseEnter={(e) => setTooltip({ name: geoName, hits, x: e.clientX, y: e.clientY })}
onMouseMove={(e) => setTooltip(t => t ? { ...t, x: e.clientX, y: e.clientY } : null)}
onMouseLeave={() => setTooltip(null)}
/>
)
})
}
</Geographies>
</ComposableMap>
{/* Color legend */}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginTop: '0.5rem' }}>
<span style={{ fontSize: '0.7rem', color: '#888' }}>0</span>
<div style={{ flex: 1, height: '8px', borderRadius: '4px', background: 'linear-gradient(to right, #1e1e18, rgb(201,168,76))' }} />
<span style={{ fontSize: '0.7rem', color: '#888' }}>{maxCountry.toLocaleString()} visits</span>
</div>
</div>
{/* US states map */}
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518', position: 'relative' }}>
<h3 style={{ marginBottom: '0.75rem', color: '#c9a84c', fontFamily: 'var(--brand-font-body)', textTransform: 'uppercase', letterSpacing: '0.08em', fontSize: '0.85rem' }}>
Visitors by US State
</h3>
{topStates.length === 0 && (
<p style={{ color: '#b0a48c', fontSize: '0.875rem' }}>No state data yet.</p>
)}
<ComposableMap
projection="geoAlbersUsa"
style={{ width: '100%', height: 'auto', display: 'block' }}
>
<Geographies geography={US_URL}>
{({ geographies }) =>
geographies.map(geo => {
const geoName = geo.properties?.name ?? ''
const hits = stateMap.get(geoName.toLowerCase()) ?? 0
return (
<Geography
key={geo.rsmKey ?? geoName}
geography={geo}
fill={getColor(hits, maxState)}
stroke="#0a0a08"
strokeWidth={0.8}
style={{
default: { outline: 'none' },
hover: { outline: 'none', fill: hits ? '#e0c070' : '#2a2a22', cursor: 'default' },
pressed: { outline: 'none' },
}}
onMouseEnter={(e) => setTooltip({ name: geoName, hits, x: e.clientX, y: e.clientY })}
onMouseMove={(e) => setTooltip(t => t ? { ...t, x: e.clientX, y: e.clientY } : null)}
onMouseLeave={() => setTooltip(null)}
/>
)
})
}
</Geographies>
</ComposableMap>
{/* Color legend */}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginTop: '0.5rem' }}>
<span style={{ fontSize: '0.7rem', color: '#888' }}>0</span>
<div style={{ flex: 1, height: '8px', borderRadius: '4px', background: 'linear-gradient(to right, #1e1e18, rgb(201,168,76))' }} />
<span style={{ fontSize: '0.7rem', color: '#888' }}>{maxState.toLocaleString()} visits</span>
</div>
</div>
{/* Tooltip */}
{tooltip && (
<div style={{
position: 'fixed',
left: tooltip.x + 14,
top: tooltip.y - 36,
backgroundColor: '#111',
border: '1px solid #c9a84c',
borderRadius: '4px',
padding: '4px 10px',
pointerEvents: 'none',
zIndex: 9999,
fontSize: '0.8rem',
color: '#f0ead8',
whiteSpace: 'nowrap',
boxShadow: '0 2px 8px rgba(0,0,0,0.5)',
}}>
<strong style={{ color: '#c9a84c' }}>{tooltip.name}</strong>
{' — '}
{tooltip.hits > 0 ? <>{tooltip.hits.toLocaleString()} visit{tooltip.hits !== 1 ? 's' : ''}</> : 'no visits'}
</div>
)}
</div>
)
}
+2 -2
View File
@@ -11,11 +11,11 @@
--brand-gold-light: #e0c070;
--brand-gold-dark: #8a6e28;
--brand-warm-white: #f0ead8;
--brand-muted: #7a7060;
--brand-muted: #b0a48c;
--brand-font-body: 'Cormorant Garamond', Georgia, serif;
--brand-font-heading: 'Cormorant Garamond', Georgia, serif;
font-family: var(--brand-font-body);
line-height: 1.5;
line-height: 1.6;
color: var(--brand-warm-white);
background: var(--brand-black);
font-synthesis: none;
+45
View File
@@ -0,0 +1,45 @@
declare module 'react-simple-maps' {
import { ComponentType, ReactNode, CSSProperties, MouseEvent } from 'react'
interface ComposableMapProps {
projection?: string
projectionConfig?: Record<string, unknown>
style?: CSSProperties
children?: ReactNode
}
interface GeographiesProps {
geography: string | object
children: (props: { geographies: GeoFeature[] }) => ReactNode
}
interface GeoFeature {
rsmKey?: string
id?: string | number
properties: Record<string, string>
geometry?: object
}
interface GeographyProps {
key?: string
geography: GeoFeature
fill?: string
stroke?: string
strokeWidth?: number
style?: {
default?: CSSProperties
hover?: CSSProperties
pressed?: CSSProperties
}
onMouseEnter?: (e: MouseEvent<SVGPathElement>) => void
onMouseMove?: (e: MouseEvent<SVGPathElement>) => void
onMouseLeave?: (e: MouseEvent<SVGPathElement>) => void
onClick?: (e: MouseEvent<SVGPathElement>) => void
}
export const ComposableMap: ComponentType<ComposableMapProps>
export const Geographies: ComponentType<GeographiesProps>
export const Geography: ComponentType<GeographyProps>
export const ZoomableGroup: ComponentType<{ children?: ReactNode; [key: string]: unknown }>
export const Marker: ComponentType<{ coordinates: [number, number]; children?: ReactNode; [key: string]: unknown }>
}
+5
View File
@@ -4,6 +4,11 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'react-simple-maps': 'react-simple-maps/dist/index.es.js',
},
},
server: {
proxy: {
'/api': 'http://localhost:4173',