Improve analytics UX with geo maps and readability updates
This commit is contained in:
Generated
+181
-1
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Vendored
+45
@@ -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 }>
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user