diff --git a/package-lock.json b/package-lock.json index 83cadbb..f7686fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 18c16cf..648ea8e 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 34513e8..b1589ba 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -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 diff --git a/src/App.css b/src/App.css index 47a5491..7febf65 100644 --- a/src/App.css +++ b/src/App.css @@ -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 { diff --git a/src/components/AnalyticsPanel.tsx b/src/components/AnalyticsPanel.tsx index 8a766b9..45e1241 100644 --- a/src/components/AnalyticsPanel.tsx +++ b/src/components/AnalyticsPanel.tsx @@ -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

Failed to load analytics.

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({

Real Traffic

-

{stats.realHits.toLocaleString()}

- {((stats.realHits / stats.totalHits) * 100).toFixed(1)}% of total +

{realHits.toLocaleString()}

+ {totalHits > 0 ? ((realHits / totalHits) * 100).toFixed(1) : '0.0'}% of total

Bot Traffic

-

{stats.botHits.toLocaleString()}

- {((stats.botHits / stats.totalHits) * 100).toFixed(1)}% of total +

{botHits.toLocaleString()}

+ {totalHits > 0 ? ((botHits / totalHits) * 100).toFixed(1) : '0.0'}% of total

Last 30 Days

-

{stats.last30DaysRealTotal.toLocaleString()}

- Real hits • {stats.last30DaysBotTotal.toLocaleString()} bots +

{last30DaysRealTotal.toLocaleString()}

+ Real hits • {last30DaysBotTotal.toLocaleString()} bots

Unique Visitors

@@ -226,7 +263,7 @@ export function AnalyticsPanel({

Bot Sources

- {stats.botReasons.length === 0 ? ( + {botReasons.length === 0 ? (

No bot traffic detected.

) : ( @@ -234,11 +271,11 @@ export function AnalyticsPanel({

Bot Detection Details

- {stats.botReasons.length === 0 ? ( + {botReasons.length === 0 ? (

No bots detected yet.

) : (
    - {stats.botReasons.map((reason: { reason: string; count: number }) => ( + {botReasons.map((reason: { reason: string; count: number }) => (
  • {reason.reason.replace(/-/g, ' ')} {reason.count.toLocaleString()} @@ -252,26 +289,15 @@ export function AnalyticsPanel({ {/* Geographic Charts */} {chartTab === 'geographic' && ( -
    -
    +
    +

    Top Pages

    -
    -

    Top Countries

    - {stats.visitors.topCountries.length === 0 ? ( -

    No visitor data yet.

    - ) : ( -
      - {stats.visitors.topCountries.map((country: { name: string; hits: number }) => ( -
    • - {country.name} - {country.hits.toLocaleString()} -
    • - ))} -
    - )} -
    +
    )} @@ -290,8 +316,22 @@ export function AnalyticsPanel({
    -

    Top Countries

      {stats.visitors.topCountries.map((item: { name: string; hits: number }) =>
    • {item.name}{item.hits.toLocaleString()}
    • )}
    -

    Top States

      {stats.visitors.topStates.map((item: { name: string; hits: number }) =>
    • {item.name}{item.hits.toLocaleString()}
    • )}
    +
    +

    Top Countries

    +
      + {stats.visitors.topCountries.map((item: { name: string; hits: number }) => ( +
    • {item.name}{item.hits.toLocaleString()}
    • + ))} +
    +
    +
    +

    Top States

    +
      + {stats.visitors.topStates.map((item: { name: string; hits: number }) => ( +
    • {item.name}{item.hits.toLocaleString()}
    • + ))} +
    +
    @@ -302,12 +342,36 @@ export function AnalyticsPanel({
    - + + + + + + + + {stats.visitors.recentVisits.map(row => ( - + + + + + + ))} diff --git a/src/components/GeoMaps.tsx b/src/components/GeoMaps.tsx new file mode 100644 index 0000000..3205a2b --- /dev/null +++ b/src/components/GeoMaps.tsx @@ -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 = { + '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(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 ( +
    + + {/* World map */} +
    +

    + Visitors by Country +

    + {topCountries.length === 0 && ( +

    No country data yet.

    + )} + + + {({ geographies }) => + geographies.map(geo => { + const geoName = geo.properties?.name ?? '' + const normalized = normalizeCountry(geoName) + const hits = countryMap.get(normalized) ?? countryMap.get(geoName.toLowerCase()) ?? 0 + return ( + 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)} + /> + ) + }) + } + + + + {/* Color legend */} +
    + 0 +
    + {maxCountry.toLocaleString()} visits +
    +
    + + {/* US states map */} +
    +

    + Visitors by US State +

    + {topStates.length === 0 && ( +

    No state data yet.

    + )} + + + {({ geographies }) => + geographies.map(geo => { + const geoName = geo.properties?.name ?? '' + const hits = stateMap.get(geoName.toLowerCase()) ?? 0 + return ( + 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)} + /> + ) + }) + } + + + + {/* Color legend */} +
    + 0 +
    + {maxState.toLocaleString()} visits +
    +
    + + {/* Tooltip */} + {tooltip && ( +
    + {tooltip.name} + {' — '} + {tooltip.hits > 0 ? <>{tooltip.hits.toLocaleString()} visit{tooltip.hits !== 1 ? 's' : ''} : 'no visits'} +
    + )} +
    + ) +} diff --git a/src/index.css b/src/index.css index 23a99e7..bf47011 100644 --- a/src/index.css +++ b/src/index.css @@ -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; diff --git a/src/types/react-simple-maps.d.ts b/src/types/react-simple-maps.d.ts new file mode 100644 index 0000000..feda83b --- /dev/null +++ b/src/types/react-simple-maps.d.ts @@ -0,0 +1,45 @@ +declare module 'react-simple-maps' { + import { ComponentType, ReactNode, CSSProperties, MouseEvent } from 'react' + + interface ComposableMapProps { + projection?: string + projectionConfig?: Record + style?: CSSProperties + children?: ReactNode + } + + interface GeographiesProps { + geography: string | object + children: (props: { geographies: GeoFeature[] }) => ReactNode + } + + interface GeoFeature { + rsmKey?: string + id?: string | number + properties: Record + geometry?: object + } + + interface GeographyProps { + key?: string + geography: GeoFeature + fill?: string + stroke?: string + strokeWidth?: number + style?: { + default?: CSSProperties + hover?: CSSProperties + pressed?: CSSProperties + } + onMouseEnter?: (e: MouseEvent) => void + onMouseMove?: (e: MouseEvent) => void + onMouseLeave?: (e: MouseEvent) => void + onClick?: (e: MouseEvent) => void + } + + export const ComposableMap: ComponentType + export const Geographies: ComponentType + export const Geography: ComponentType + export const ZoomableGroup: ComponentType<{ children?: ReactNode; [key: string]: unknown }> + export const Marker: ComponentType<{ coordinates: [number, number]; children?: ReactNode; [key: string]: unknown }> +} diff --git a/vite.config.ts b/vite.config.ts index 5df1ba2..261dbc5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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',
    TimeIPCountryPathReturningVisit #
    TimeIPCountryPathReturningVisit #
    {formatDate(row.at)}{maskIp(row.ip)}{row.country}{row.path}{row.returningVisitor ? 'Yes' : 'No'}{row.visitCount}{formatDate(row.at)}{maskIp(row.ip)}{row.country || '—'}{row.path} + + {row.returningVisitor ? 'Returning' : 'New'} + + {row.visitCount}