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": "^5.2.1",
"express-rate-limit": "^8.4.1", "express-rate-limit": "^8.4.1",
"otplib": "^13.4.0", "otplib": "^13.4.0",
"prop-types": "^15.8.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^19.2.4", "react": "^19.2.4",
"react-chartjs-2": "^5.2.0", "react-chartjs-2": "^5.2.0",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^7.13.1", "react-router-dom": "^7.13.1",
"react-simple-maps": "^3.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"resend": "^6.10.0" "resend": "^6.10.0"
}, },
@@ -1038,6 +1040,7 @@
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@@ -1769,6 +1772,12 @@
"url": "https://github.com/sponsors/wooorm" "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": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -1887,8 +1896,105 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT" "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": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2801,6 +2907,12 @@
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
"license": "MIT" "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": { "node_modules/ip-address": {
"version": "10.1.0", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
@@ -2924,7 +3036,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
@@ -3305,6 +3416,18 @@
"url": "https://github.com/sponsors/wooorm" "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": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -4286,6 +4409,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -4542,6 +4674,17 @@
"node": ">= 0.8.0" "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": { "node_modules/property-information": {
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
@@ -4780,6 +4923,12 @@
"react": "^19.2.4" "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": { "node_modules/react-markdown": {
"version": "10.1.0", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
@@ -4845,6 +4994,23 @@
"react-dom": ">=18" "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": { "node_modules/remark-gfm": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
@@ -5376,6 +5542,20 @@
"node": ">=0.6" "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": { "node_modules/tree-kill": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", "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": "^5.2.1",
"express-rate-limit": "^8.4.1", "express-rate-limit": "^8.4.1",
"otplib": "^13.4.0", "otplib": "^13.4.0",
"prop-types": "^15.8.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^19.2.4", "react": "^19.2.4",
"react-chartjs-2": "^5.2.0", "react-chartjs-2": "^5.2.0",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^7.13.1", "react-router-dom": "^7.13.1",
"react-simple-maps": "^3.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"resend": "^6.10.0" "resend": "^6.10.0"
}, },
-24
View File
@@ -1,34 +1,10 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import type { ChangeEvent } from 'react' import type { ChangeEvent } from 'react'
import { Link } from 'react-router-dom' 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 type { SiteContent, CustomLink, CustomBlock, ArchivedSeries, ArchivedSeriesResourceLink, ArchivedSeriesNote, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings } from './content'
import { DEFAULTS } from './content' import { DEFAULTS } from './content'
import { AnalyticsPanel } from './components/AnalyticsPanel' import { AnalyticsPanel } from './components/AnalyticsPanel'
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Title,
Tooltip,
Legend,
ArcElement,
)
interface Props { interface Props {
content: SiteContent content: SiteContent
onSave: (c: SiteContent) => void onSave: (c: SiteContent) => void
+20 -15
View File
@@ -192,8 +192,8 @@
.word-with { .word-with {
color: var(--brand-warm-white); color: var(--brand-warm-white);
font-weight: 300; font-weight: 400;
opacity: 0.85; opacity: 0.9;
} }
.rule-divider { .rule-divider {
@@ -413,11 +413,10 @@
.episode-desc { .episode-desc {
font-family: var(--brand-font-body); font-family: var(--brand-font-body);
font-size: 0.88rem; font-size: 0.88rem;
font-weight: 300; font-weight: 400;
line-height: 1.55; line-height: 1.6;
color: var(--brand-muted); color: var(--brand-muted);
margin: 0 0 0.6rem; margin: 0 0 0.6rem;
opacity: 0.85;
} }
.episode-listen-cta { .episode-listen-cta {
@@ -1190,7 +1189,7 @@
font-size: 0.88rem; font-size: 0.88rem;
font-style: italic; font-style: italic;
line-height: 1.6; line-height: 1.6;
color: #6d6b66; color: #a09888;
} }
.contact-scripture-ref { .contact-scripture-ref {
@@ -2333,27 +2332,33 @@
} }
.admin-visits-table-scroll { .admin-visits-table-scroll {
overflow-x: visible; overflow-x: auto;
border-radius: 0.5rem;
border: 1px solid #2a2518;
} }
.admin-visits-table { .admin-visits-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
min-width: 0; min-width: 620px;
table-layout: fixed; table-layout: auto;
} }
.admin-visits-table th, .admin-visits-table th,
.admin-visits-table td { .admin-visits-table td {
text-align: left; text-align: left;
padding: 0.5rem 0.6rem; padding: 0.55rem 0.75rem;
border-bottom: 1px solid rgba(201, 168, 76, 0.16); border-bottom: 1px solid rgba(201, 168, 76, 0.1);
font-family: var(--brand-font-body); font-family: var(--brand-font-body);
color: var(--brand-warm-white); color: var(--brand-warm-white);
font-size: 0.9rem; font-size: 0.875rem;
white-space: normal; white-space: nowrap;
overflow-wrap: anywhere; overflow: hidden;
word-break: break-word; text-overflow: ellipsis;
}
.admin-visits-table tbody tr:hover {
background: rgba(201, 168, 76, 0.04);
} }
.admin-visits-table th { .admin-visits-table th {
+104 -40
View File
@@ -1,7 +1,32 @@
import { useState } from 'react' import { useState } from 'react'
import { Line, Bar, Pie, Doughnut } from 'react-chartjs-2' 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' import type { AdminStats } from '../AdminPage'
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Title,
Tooltip,
Legend,
ArcElement,
)
interface Props { interface Props {
stats: AdminStats | null stats: AdminStats | null
statsStatus: 'loading' | 'error' | 'ready' 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 (statsStatus === 'error') return <p className="admin-stats-note">Failed to load analytics.</p>
if (!stats) return null 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 // Chart data - Daily trend
const dailyChartData = { const dailyChartData = {
labels: stats.last7Days.map((d: { day: string; hits: number }) => d.day), labels: stats.last7Days.map((d: { day: string; hits: number }) => d.day),
datasets: [ datasets: [
{ {
label: 'Real Visitors', 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', borderColor: '#c9a84c',
backgroundColor: 'rgba(201, 168, 76, 0.1)', backgroundColor: 'rgba(201, 168, 76, 0.1)',
tension: 0.4, tension: 0.4,
@@ -63,7 +100,7 @@ export function AnalyticsPanel({
}, },
{ {
label: 'Bot Traffic', 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', borderColor: '#666',
backgroundColor: 'rgba(102, 102, 102, 0.1)', backgroundColor: 'rgba(102, 102, 102, 0.1)',
tension: 0.4, tension: 0.4,
@@ -74,9 +111,9 @@ export function AnalyticsPanel({
// Chart data - Bot breakdown (pie) // Chart data - Bot breakdown (pie)
const botPieData = { 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: [{ datasets: [{
data: stats.botReasons.map((b: { reason: string; count: number }) => b.count), data: botReasons.map((b: { reason: string; count: number }) => b.count),
backgroundColor: [ backgroundColor: [
'#c9a84c', '#c9a84c',
'#a0853d', '#a0853d',
@@ -98,7 +135,7 @@ export function AnalyticsPanel({
const trafficRatioData = { const trafficRatioData = {
labels: ['Real Visitors', 'Bot Traffic'], labels: ['Real Visitors', 'Bot Traffic'],
datasets: [{ datasets: [{
data: [stats.realHits, stats.botHits], data: [realHits, botHits],
backgroundColor: ['#c9a84c', '#999'], backgroundColor: ['#c9a84c', '#999'],
borderColor: '#1a1a15', borderColor: '#1a1a15',
borderWidth: 2, borderWidth: 2,
@@ -106,9 +143,9 @@ export function AnalyticsPanel({
} }
// Top paths comparison // Top paths comparison
const topPathsLabels = Array.from({ length: Math.max(stats.topPathsReal.length, stats.topPathsBot.length) }, (_, i: number) => { const topPathsLabels = Array.from({ length: Math.max(topPathsReal.length, topPathsBot.length) }, (_, i: number) => {
const realPath = stats.topPathsReal[i]?.path || '' const realPath = topPathsReal[i]?.path || ''
const botPath = stats.topPathsBot[i]?.path || '' const botPath = topPathsBot[i]?.path || ''
return realPath || botPath || `Path ${i + 1}` return realPath || botPath || `Path ${i + 1}`
}).slice(0, 8) }).slice(0, 8)
@@ -117,12 +154,12 @@ export function AnalyticsPanel({
datasets: [ datasets: [
{ {
label: 'Real Hits', label: 'Real Hits',
data: topPathsLabels.map((_label, i) => stats.topPathsReal[i]?.hits || 0), data: topPathsLabels.map((_label, i) => topPathsReal[i]?.hits || 0),
backgroundColor: '#c9a84c', backgroundColor: '#c9a84c',
}, },
{ {
label: 'Bot Hits', label: 'Bot Hits',
data: topPathsLabels.map((_label, i) => stats.topPathsBot[i]?.hits || 0), data: topPathsLabels.map((_label, i) => topPathsBot[i]?.hits || 0),
backgroundColor: '#999', backgroundColor: '#999',
}, },
], ],
@@ -175,18 +212,18 @@ export function AnalyticsPanel({
</article> </article>
<article> <article>
<h3>Real Traffic</h3> <h3>Real Traffic</h3>
<p>{stats.realHits.toLocaleString()}</p> <p>{realHits.toLocaleString()}</p>
<small>{((stats.realHits / stats.totalHits) * 100).toFixed(1)}% of total</small> <small>{totalHits > 0 ? ((realHits / totalHits) * 100).toFixed(1) : '0.0'}% of total</small>
</article> </article>
<article> <article>
<h3>Bot Traffic</h3> <h3>Bot Traffic</h3>
<p>{stats.botHits.toLocaleString()}</p> <p>{botHits.toLocaleString()}</p>
<small>{((stats.botHits / stats.totalHits) * 100).toFixed(1)}% of total</small> <small>{totalHits > 0 ? ((botHits / totalHits) * 100).toFixed(1) : '0.0'}% of total</small>
</article> </article>
<article> <article>
<h3>Last 30 Days</h3> <h3>Last 30 Days</h3>
<p>{stats.last30DaysRealTotal.toLocaleString()}</p> <p>{last30DaysRealTotal.toLocaleString()}</p>
<small>Real hits {stats.last30DaysBotTotal.toLocaleString()} bots</small> <small>Real hits {last30DaysBotTotal.toLocaleString()} bots</small>
</article> </article>
<article> <article>
<h3>Unique Visitors</h3> <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={{ 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={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518' }}>
<h3 style={{ marginBottom: '1rem' }}>Bot Sources</h3> <h3 style={{ marginBottom: '1rem' }}>Bot Sources</h3>
{stats.botReasons.length === 0 ? ( {botReasons.length === 0 ? (
<p className="admin-stats-note">No bot traffic detected.</p> <p className="admin-stats-note">No bot traffic detected.</p>
) : ( ) : (
<Pie data={botPieData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} /> <Pie data={botPieData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} />
@@ -234,11 +271,11 @@ export function AnalyticsPanel({
</div> </div>
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518' }}> <div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518' }}>
<h3 style={{ marginBottom: '1rem' }}>Bot Detection Details</h3> <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> <p className="admin-stats-note">No bots detected yet.</p>
) : ( ) : (
<ul style={{ listStyle: 'none', padding: 0 }}> <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' }}> <li key={reason.reason} style={{ padding: '0.5rem 0', display: 'flex', justifyContent: 'space-between', borderBottom: '1px solid #2a2518' }}>
<span>{reason.reason.replace(/-/g, ' ')}</span> <span>{reason.reason.replace(/-/g, ' ')}</span>
<strong style={{ color: '#c9a84c' }}>{reason.count.toLocaleString()}</strong> <strong style={{ color: '#c9a84c' }}>{reason.count.toLocaleString()}</strong>
@@ -252,26 +289,15 @@ export function AnalyticsPanel({
{/* Geographic Charts */} {/* Geographic Charts */}
{chartTab === 'geographic' && ( {chartTab === 'geographic' && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))', gap: '2rem', marginBottom: '2rem' }}> <div style={{ marginBottom: '2rem' }}>
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518' }}> <div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518', marginBottom: '2rem' }}>
<h3 style={{ marginBottom: '1rem' }}>Top Pages</h3> <h3 style={{ marginBottom: '1rem' }}>Top Pages</h3>
<Bar data={topPathsChartData} options={{...chartOptions, indexAxis: 'y' as const}} /> <Bar data={topPathsChartData} options={{...chartOptions, indexAxis: 'y' as const}} />
</div> </div>
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518' }}> <GeoMaps
<h3 style={{ marginBottom: '1rem' }}>Top Countries</h3> topCountries={stats.visitors.topCountries}
{stats.visitors.topCountries.length === 0 ? ( topStates={stats.visitors.topStates}
<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>
</div> </div>
)} )}
@@ -290,8 +316,22 @@ export function AnalyticsPanel({
</div> </div>
<div className="admin-stats-lists"> <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>
<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> <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>
<div className="admin-visits-table-wrap"> <div className="admin-visits-table-wrap">
@@ -302,12 +342,36 @@ export function AnalyticsPanel({
<div className="admin-visits-table-scroll"> <div className="admin-visits-table-scroll">
<table className="admin-visits-table"> <table className="admin-visits-table">
<thead> <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> </thead>
<tbody> <tbody>
{stats.visitors.recentVisits.map(row => ( {stats.visitors.recentVisits.map(row => (
<tr key={`${row.visitorId}-${row.at}`}> <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> </tr>
))} ))}
</tbody> </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-light: #e0c070;
--brand-gold-dark: #8a6e28; --brand-gold-dark: #8a6e28;
--brand-warm-white: #f0ead8; --brand-warm-white: #f0ead8;
--brand-muted: #7a7060; --brand-muted: #b0a48c;
--brand-font-body: 'Cormorant Garamond', Georgia, serif; --brand-font-body: 'Cormorant Garamond', Georgia, serif;
--brand-font-heading: 'Cormorant Garamond', Georgia, serif; --brand-font-heading: 'Cormorant Garamond', Georgia, serif;
font-family: var(--brand-font-body); font-family: var(--brand-font-body);
line-height: 1.5; line-height: 1.6;
color: var(--brand-warm-white); color: var(--brand-warm-white);
background: var(--brand-black); background: var(--brand-black);
font-synthesis: none; 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/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
resolve: {
alias: {
'react-simple-maps': 'react-simple-maps/dist/index.es.js',
},
},
server: { server: {
proxy: { proxy: {
'/api': 'http://localhost:4173', '/api': 'http://localhost:4173',