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
-24
View File
@@ -1,34 +1,10 @@
import { useEffect, useRef, useState } from 'react'
import type { ChangeEvent } from 'react'
import { Link } from 'react-router-dom'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Title,
Tooltip,
Legend,
ArcElement,
} from 'chart.js'
import type { SiteContent, CustomLink, CustomBlock, ArchivedSeries, ArchivedSeriesResourceLink, ArchivedSeriesNote, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings } from './content'
import { DEFAULTS } from './content'
import { AnalyticsPanel } from './components/AnalyticsPanel'
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Title,
Tooltip,
Legend,
ArcElement,
)
interface Props {
content: SiteContent
onSave: (c: SiteContent) => void
+20 -15
View File
@@ -192,8 +192,8 @@
.word-with {
color: var(--brand-warm-white);
font-weight: 300;
opacity: 0.85;
font-weight: 400;
opacity: 0.9;
}
.rule-divider {
@@ -413,11 +413,10 @@
.episode-desc {
font-family: var(--brand-font-body);
font-size: 0.88rem;
font-weight: 300;
line-height: 1.55;
font-weight: 400;
line-height: 1.6;
color: var(--brand-muted);
margin: 0 0 0.6rem;
opacity: 0.85;
}
.episode-listen-cta {
@@ -1190,7 +1189,7 @@
font-size: 0.88rem;
font-style: italic;
line-height: 1.6;
color: #6d6b66;
color: #a09888;
}
.contact-scripture-ref {
@@ -2333,27 +2332,33 @@
}
.admin-visits-table-scroll {
overflow-x: visible;
overflow-x: auto;
border-radius: 0.5rem;
border: 1px solid #2a2518;
}
.admin-visits-table {
width: 100%;
border-collapse: collapse;
min-width: 0;
table-layout: fixed;
min-width: 620px;
table-layout: auto;
}
.admin-visits-table th,
.admin-visits-table td {
text-align: left;
padding: 0.5rem 0.6rem;
border-bottom: 1px solid rgba(201, 168, 76, 0.16);
padding: 0.55rem 0.75rem;
border-bottom: 1px solid rgba(201, 168, 76, 0.1);
font-family: var(--brand-font-body);
color: var(--brand-warm-white);
font-size: 0.9rem;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
font-size: 0.875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.admin-visits-table tbody tr:hover {
background: rgba(201, 168, 76, 0.04);
}
.admin-visits-table th {
+104 -40
View File
@@ -1,7 +1,32 @@
import { useState } from 'react'
import { Line, Bar, Pie, Doughnut } from 'react-chartjs-2'
import GeoMaps from './GeoMaps'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Title,
Tooltip,
Legend,
ArcElement,
} from 'chart.js'
import type { AdminStats } from '../AdminPage'
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Title,
Tooltip,
Legend,
ArcElement,
)
interface Props {
stats: AdminStats | null
statsStatus: 'loading' | 'error' | 'ready'
@@ -49,13 +74,25 @@ export function AnalyticsPanel({
if (statsStatus === 'error') return <p className="admin-stats-note">Failed to load analytics.</p>
if (!stats) return null
// Normalize fields that may be missing from older data
const realHits = stats.realHits ?? 0
const botHits = stats.botHits ?? 0
const totalHits = stats.totalHits ?? 0
const last7DaysReal = stats.last7DaysReal ?? stats.last7Days.map((d: { day: string; hits: number }) => ({ day: d.day, hits: 0 }))
const last7DaysBot = stats.last7DaysBot ?? stats.last7Days.map((d: { day: string; hits: number }) => ({ day: d.day, hits: 0 }))
const last30DaysRealTotal = stats.last30DaysRealTotal ?? 0
const last30DaysBotTotal = stats.last30DaysBotTotal ?? 0
const topPathsReal = stats.topPathsReal ?? stats.topPaths
const topPathsBot = stats.topPathsBot ?? []
const botReasons = stats.botReasons ?? []
// Chart data - Daily trend
const dailyChartData = {
labels: stats.last7Days.map((d: { day: string; hits: number }) => d.day),
datasets: [
{
label: 'Real Visitors',
data: stats.last7DaysReal.map((d: { day: string; hits: number }) => d.hits),
data: last7DaysReal.map((d: { day: string; hits: number }) => d.hits),
borderColor: '#c9a84c',
backgroundColor: 'rgba(201, 168, 76, 0.1)',
tension: 0.4,
@@ -63,7 +100,7 @@ export function AnalyticsPanel({
},
{
label: 'Bot Traffic',
data: stats.last7DaysBot.map((d: { day: string; hits: number }) => d.hits),
data: last7DaysBot.map((d: { day: string; hits: number }) => d.hits),
borderColor: '#666',
backgroundColor: 'rgba(102, 102, 102, 0.1)',
tension: 0.4,
@@ -74,9 +111,9 @@ export function AnalyticsPanel({
// Chart data - Bot breakdown (pie)
const botPieData = {
labels: stats.botReasons.map((b: { reason: string; count: number }) => b.reason.replace(/-/g, ' ')),
labels: botReasons.map((b: { reason: string; count: number }) => b.reason.replace(/-/g, ' ')),
datasets: [{
data: stats.botReasons.map((b: { reason: string; count: number }) => b.count),
data: botReasons.map((b: { reason: string; count: number }) => b.count),
backgroundColor: [
'#c9a84c',
'#a0853d',
@@ -98,7 +135,7 @@ export function AnalyticsPanel({
const trafficRatioData = {
labels: ['Real Visitors', 'Bot Traffic'],
datasets: [{
data: [stats.realHits, stats.botHits],
data: [realHits, botHits],
backgroundColor: ['#c9a84c', '#999'],
borderColor: '#1a1a15',
borderWidth: 2,
@@ -106,9 +143,9 @@ export function AnalyticsPanel({
}
// Top paths comparison
const topPathsLabels = Array.from({ length: Math.max(stats.topPathsReal.length, stats.topPathsBot.length) }, (_, i: number) => {
const realPath = stats.topPathsReal[i]?.path || ''
const botPath = stats.topPathsBot[i]?.path || ''
const topPathsLabels = Array.from({ length: Math.max(topPathsReal.length, topPathsBot.length) }, (_, i: number) => {
const realPath = topPathsReal[i]?.path || ''
const botPath = topPathsBot[i]?.path || ''
return realPath || botPath || `Path ${i + 1}`
}).slice(0, 8)
@@ -117,12 +154,12 @@ export function AnalyticsPanel({
datasets: [
{
label: 'Real Hits',
data: topPathsLabels.map((_label, i) => stats.topPathsReal[i]?.hits || 0),
data: topPathsLabels.map((_label, i) => topPathsReal[i]?.hits || 0),
backgroundColor: '#c9a84c',
},
{
label: 'Bot Hits',
data: topPathsLabels.map((_label, i) => stats.topPathsBot[i]?.hits || 0),
data: topPathsLabels.map((_label, i) => topPathsBot[i]?.hits || 0),
backgroundColor: '#999',
},
],
@@ -175,18 +212,18 @@ export function AnalyticsPanel({
</article>
<article>
<h3>Real Traffic</h3>
<p>{stats.realHits.toLocaleString()}</p>
<small>{((stats.realHits / stats.totalHits) * 100).toFixed(1)}% of total</small>
<p>{realHits.toLocaleString()}</p>
<small>{totalHits > 0 ? ((realHits / totalHits) * 100).toFixed(1) : '0.0'}% of total</small>
</article>
<article>
<h3>Bot Traffic</h3>
<p>{stats.botHits.toLocaleString()}</p>
<small>{((stats.botHits / stats.totalHits) * 100).toFixed(1)}% of total</small>
<p>{botHits.toLocaleString()}</p>
<small>{totalHits > 0 ? ((botHits / totalHits) * 100).toFixed(1) : '0.0'}% of total</small>
</article>
<article>
<h3>Last 30 Days</h3>
<p>{stats.last30DaysRealTotal.toLocaleString()}</p>
<small>Real hits {stats.last30DaysBotTotal.toLocaleString()} bots</small>
<p>{last30DaysRealTotal.toLocaleString()}</p>
<small>Real hits {last30DaysBotTotal.toLocaleString()} bots</small>
</article>
<article>
<h3>Unique Visitors</h3>
@@ -226,7 +263,7 @@ export function AnalyticsPanel({
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))', gap: '2rem', marginBottom: '2rem' }}>
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518' }}>
<h3 style={{ marginBottom: '1rem' }}>Bot Sources</h3>
{stats.botReasons.length === 0 ? (
{botReasons.length === 0 ? (
<p className="admin-stats-note">No bot traffic detected.</p>
) : (
<Pie data={botPieData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} />
@@ -234,11 +271,11 @@ export function AnalyticsPanel({
</div>
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518' }}>
<h3 style={{ marginBottom: '1rem' }}>Bot Detection Details</h3>
{stats.botReasons.length === 0 ? (
{botReasons.length === 0 ? (
<p className="admin-stats-note">No bots detected yet.</p>
) : (
<ul style={{ listStyle: 'none', padding: 0 }}>
{stats.botReasons.map((reason: { reason: string; count: number }) => (
{botReasons.map((reason: { reason: string; count: number }) => (
<li key={reason.reason} style={{ padding: '0.5rem 0', display: 'flex', justifyContent: 'space-between', borderBottom: '1px solid #2a2518' }}>
<span>{reason.reason.replace(/-/g, ' ')}</span>
<strong style={{ color: '#c9a84c' }}>{reason.count.toLocaleString()}</strong>
@@ -252,26 +289,15 @@ export function AnalyticsPanel({
{/* Geographic Charts */}
{chartTab === 'geographic' && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))', gap: '2rem', marginBottom: '2rem' }}>
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518' }}>
<div style={{ marginBottom: '2rem' }}>
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518', marginBottom: '2rem' }}>
<h3 style={{ marginBottom: '1rem' }}>Top Pages</h3>
<Bar data={topPathsChartData} options={{...chartOptions, indexAxis: 'y' as const}} />
</div>
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518' }}>
<h3 style={{ marginBottom: '1rem' }}>Top Countries</h3>
{stats.visitors.topCountries.length === 0 ? (
<p className="admin-stats-note">No visitor data yet.</p>
) : (
<ul style={{ listStyle: 'none', padding: 0 }}>
{stats.visitors.topCountries.map((country: { name: string; hits: number }) => (
<li key={country.name} style={{ padding: '0.5rem 0', display: 'flex', justifyContent: 'space-between', borderBottom: '1px solid #2a2518' }}>
<span>{country.name}</span>
<strong style={{ color: '#c9a84c' }}>{country.hits.toLocaleString()}</strong>
</li>
))}
</ul>
)}
</div>
<GeoMaps
topCountries={stats.visitors.topCountries}
topStates={stats.visitors.topStates}
/>
</div>
)}
@@ -290,8 +316,22 @@ export function AnalyticsPanel({
</div>
<div className="admin-stats-lists">
<div><h3>Top Countries</h3><ul>{stats.visitors.topCountries.map((item: { name: string; hits: number }) => <li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>)}</ul></div>
<div><h3>Top States</h3><ul>{stats.visitors.topStates.map((item: { name: string; hits: number }) => <li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>)}</ul></div>
<div>
<h3>Top Countries</h3>
<ul>
{stats.visitors.topCountries.map((item: { name: string; hits: number }) => (
<li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>
))}
</ul>
</div>
<div>
<h3>Top States</h3>
<ul>
{stats.visitors.topStates.map((item: { name: string; hits: number }) => (
<li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>
))}
</ul>
</div>
</div>
<div className="admin-visits-table-wrap">
@@ -302,12 +342,36 @@ export function AnalyticsPanel({
<div className="admin-visits-table-scroll">
<table className="admin-visits-table">
<thead>
<tr><th>Time</th><th>IP</th><th>Country</th><th>Path</th><th>Returning</th><th>Visit #</th></tr>
<tr>
<th>Time</th>
<th>IP</th>
<th>Country</th>
<th>Path</th>
<th>Returning</th>
<th>Visit #</th>
</tr>
</thead>
<tbody>
{stats.visitors.recentVisits.map(row => (
<tr key={`${row.visitorId}-${row.at}`}>
<td>{formatDate(row.at)}</td><td>{maskIp(row.ip)}</td><td>{row.country}</td><td>{row.path}</td><td>{row.returningVisitor ? 'Yes' : 'No'}</td><td>{row.visitCount}</td>
<td>{formatDate(row.at)}</td>
<td><code style={{ fontSize: '0.8rem', color: '#aaa' }}>{maskIp(row.ip)}</code></td>
<td>{row.country || '—'}</td>
<td style={{ maxWidth: '220px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={row.path}>{row.path}</td>
<td>
<span style={{
display: 'inline-block',
padding: '0.1rem 0.5rem',
borderRadius: '999px',
fontSize: '0.75rem',
backgroundColor: row.returningVisitor ? '#1a3a1a' : '#1a1a15',
color: row.returningVisitor ? '#6dbf6d' : '#888',
border: `1px solid ${row.returningVisitor ? '#2a5a2a' : '#333'}`,
}}>
{row.returningVisitor ? 'Returning' : 'New'}
</span>
</td>
<td style={{ textAlign: 'center', color: '#c9a84c' }}>{row.visitCount}</td>
</tr>
))}
</tbody>
+181
View File
@@ -0,0 +1,181 @@
import { useState } from 'react'
import { ComposableMap, Geographies, Geography } from 'react-simple-maps'
const WORLD_URL = 'https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json'
const US_URL = 'https://cdn.jsdelivr.net/npm/us-atlas@3/states-10m.json'
// Normalize topojson country names to match what geolocation APIs typically return
const COUNTRY_ALIASES: Record<string, string> = {
'united states of america': 'united states',
'russian federation': 'russia',
'republic of korea': 'south korea',
"democratic people's republic of korea": 'north korea',
'united kingdom of great britain and northern ireland': 'united kingdom',
'iran (islamic republic of)': 'iran',
'syrian arab republic': 'syria',
'bolivarian republic of venezuela': 'venezuela',
'plurinational state of bolivia': 'bolivia',
'united republic of tanzania': 'tanzania',
'democratic republic of the congo': 'dr congo',
"lao people's democratic republic": 'laos',
'viet nam': 'vietnam',
'taiwan, province of china': 'taiwan',
}
function normalizeCountry(name: string): string {
const lower = name.toLowerCase()
return COUNTRY_ALIASES[lower] ?? lower
}
function getColor(hits: number, max: number, baseColor = '#1e1e18'): string {
if (!hits || max === 0) return baseColor
const t = Math.sqrt(hits / max) // sqrt for better visual spread
const r = Math.round(30 + (201 - 30) * t)
const g = Math.round(30 + (168 - 30) * t)
const b = Math.round(21 + (76 - 21) * t)
return `rgb(${r},${g},${b})`
}
interface TooltipState {
name: string
hits: number
x: number
y: number
}
interface GeoMapsProps {
topCountries: Array<{ name: string; hits: number }>
topStates: Array<{ name: string; hits: number }>
}
export default function GeoMaps({ topCountries, topStates }: GeoMapsProps) {
const [tooltip, setTooltip] = useState<TooltipState | null>(null)
const countryMap = new Map(topCountries.map(c => [c.name.toLowerCase(), c.hits]))
const stateMap = new Map(topStates.map(s => [s.name.toLowerCase(), s.hits]))
const maxCountry = Math.max(...topCountries.map(c => c.hits), 1)
const maxState = Math.max(...topStates.map(s => s.hits), 1)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
{/* World map */}
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518', position: 'relative' }}>
<h3 style={{ marginBottom: '0.75rem', color: '#c9a84c', fontFamily: 'var(--brand-font-body)', textTransform: 'uppercase', letterSpacing: '0.08em', fontSize: '0.85rem' }}>
Visitors by Country
</h3>
{topCountries.length === 0 && (
<p style={{ color: '#b0a48c', fontSize: '0.875rem' }}>No country data yet.</p>
)}
<ComposableMap
projectionConfig={{ scale: 145, center: [0, 10] }}
style={{ width: '100%', height: 'auto', display: 'block' }}
>
<Geographies geography={WORLD_URL}>
{({ geographies }) =>
geographies.map(geo => {
const geoName = geo.properties?.name ?? ''
const normalized = normalizeCountry(geoName)
const hits = countryMap.get(normalized) ?? countryMap.get(geoName.toLowerCase()) ?? 0
return (
<Geography
key={geo.rsmKey ?? geoName}
geography={geo}
fill={getColor(hits, maxCountry)}
stroke="#0a0a08"
strokeWidth={0.4}
style={{
default: { outline: 'none' },
hover: { outline: 'none', fill: hits ? '#e0c070' : '#2a2a22', cursor: 'default' },
pressed: { outline: 'none' },
}}
onMouseEnter={(e) => setTooltip({ name: geoName, hits, x: e.clientX, y: e.clientY })}
onMouseMove={(e) => setTooltip(t => t ? { ...t, x: e.clientX, y: e.clientY } : null)}
onMouseLeave={() => setTooltip(null)}
/>
)
})
}
</Geographies>
</ComposableMap>
{/* Color legend */}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginTop: '0.5rem' }}>
<span style={{ fontSize: '0.7rem', color: '#888' }}>0</span>
<div style={{ flex: 1, height: '8px', borderRadius: '4px', background: 'linear-gradient(to right, #1e1e18, rgb(201,168,76))' }} />
<span style={{ fontSize: '0.7rem', color: '#888' }}>{maxCountry.toLocaleString()} visits</span>
</div>
</div>
{/* US states map */}
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518', position: 'relative' }}>
<h3 style={{ marginBottom: '0.75rem', color: '#c9a84c', fontFamily: 'var(--brand-font-body)', textTransform: 'uppercase', letterSpacing: '0.08em', fontSize: '0.85rem' }}>
Visitors by US State
</h3>
{topStates.length === 0 && (
<p style={{ color: '#b0a48c', fontSize: '0.875rem' }}>No state data yet.</p>
)}
<ComposableMap
projection="geoAlbersUsa"
style={{ width: '100%', height: 'auto', display: 'block' }}
>
<Geographies geography={US_URL}>
{({ geographies }) =>
geographies.map(geo => {
const geoName = geo.properties?.name ?? ''
const hits = stateMap.get(geoName.toLowerCase()) ?? 0
return (
<Geography
key={geo.rsmKey ?? geoName}
geography={geo}
fill={getColor(hits, maxState)}
stroke="#0a0a08"
strokeWidth={0.8}
style={{
default: { outline: 'none' },
hover: { outline: 'none', fill: hits ? '#e0c070' : '#2a2a22', cursor: 'default' },
pressed: { outline: 'none' },
}}
onMouseEnter={(e) => setTooltip({ name: geoName, hits, x: e.clientX, y: e.clientY })}
onMouseMove={(e) => setTooltip(t => t ? { ...t, x: e.clientX, y: e.clientY } : null)}
onMouseLeave={() => setTooltip(null)}
/>
)
})
}
</Geographies>
</ComposableMap>
{/* Color legend */}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginTop: '0.5rem' }}>
<span style={{ fontSize: '0.7rem', color: '#888' }}>0</span>
<div style={{ flex: 1, height: '8px', borderRadius: '4px', background: 'linear-gradient(to right, #1e1e18, rgb(201,168,76))' }} />
<span style={{ fontSize: '0.7rem', color: '#888' }}>{maxState.toLocaleString()} visits</span>
</div>
</div>
{/* Tooltip */}
{tooltip && (
<div style={{
position: 'fixed',
left: tooltip.x + 14,
top: tooltip.y - 36,
backgroundColor: '#111',
border: '1px solid #c9a84c',
borderRadius: '4px',
padding: '4px 10px',
pointerEvents: 'none',
zIndex: 9999,
fontSize: '0.8rem',
color: '#f0ead8',
whiteSpace: 'nowrap',
boxShadow: '0 2px 8px rgba(0,0,0,0.5)',
}}>
<strong style={{ color: '#c9a84c' }}>{tooltip.name}</strong>
{' — '}
{tooltip.hits > 0 ? <>{tooltip.hits.toLocaleString()} visit{tooltip.hits !== 1 ? 's' : ''}</> : 'no visits'}
</div>
)}
</div>
)
}
+2 -2
View File
@@ -11,11 +11,11 @@
--brand-gold-light: #e0c070;
--brand-gold-dark: #8a6e28;
--brand-warm-white: #f0ead8;
--brand-muted: #7a7060;
--brand-muted: #b0a48c;
--brand-font-body: 'Cormorant Garamond', Georgia, serif;
--brand-font-heading: 'Cormorant Garamond', Georgia, serif;
font-family: var(--brand-font-body);
line-height: 1.5;
line-height: 1.6;
color: var(--brand-warm-white);
background: var(--brand-black);
font-synthesis: none;
+45
View File
@@ -0,0 +1,45 @@
declare module 'react-simple-maps' {
import { ComponentType, ReactNode, CSSProperties, MouseEvent } from 'react'
interface ComposableMapProps {
projection?: string
projectionConfig?: Record<string, unknown>
style?: CSSProperties
children?: ReactNode
}
interface GeographiesProps {
geography: string | object
children: (props: { geographies: GeoFeature[] }) => ReactNode
}
interface GeoFeature {
rsmKey?: string
id?: string | number
properties: Record<string, string>
geometry?: object
}
interface GeographyProps {
key?: string
geography: GeoFeature
fill?: string
stroke?: string
strokeWidth?: number
style?: {
default?: CSSProperties
hover?: CSSProperties
pressed?: CSSProperties
}
onMouseEnter?: (e: MouseEvent<SVGPathElement>) => void
onMouseMove?: (e: MouseEvent<SVGPathElement>) => void
onMouseLeave?: (e: MouseEvent<SVGPathElement>) => void
onClick?: (e: MouseEvent<SVGPathElement>) => void
}
export const ComposableMap: ComponentType<ComposableMapProps>
export const Geographies: ComponentType<GeographiesProps>
export const Geography: ComponentType<GeographyProps>
export const ZoomableGroup: ComponentType<{ children?: ReactNode; [key: string]: unknown }>
export const Marker: ComponentType<{ coordinates: [number, number]; children?: ReactNode; [key: string]: unknown }>
}