SkateSpots/src/pages/CommunityStatsPage.tsx
SkateSpots 1a834f7b40 fix(stats): robust trade volume instead of a sybil-inflatable sum
The marketplace 'Sats Traded' was a naive sum of the self-reported amount tag on
31871, which two colluding accounts could inflate for free. tradedSats is now the
robust figure - mutual trades only (a listing attested by >=2 distinct pubkeys),
per-pair cap 3, amounts >1 BTC dropped - with tradedCount + tradedTraders, and
tradedSatsClaimed keeps the raw claim so the card shows both. Added query fields
=> PERSIST_BUSTER v5. No trade attestations exist on the relay yet (honest 0).
2026-10-01 15:53:13 +07:00

712 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useMemo, useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { Link } from 'react-router-dom';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { useGrowthStats } from '@/hooks/useGrowthStats';
import { RegionBackfillCard } from '@/components/RegionBackfillCard';
import { useDeriveRegions } from '@/hooks/useDeriveRegions';
import { cn } from '@/lib/utils';
import { formatAge } from '@/lib/routeCache';
const ANGOR_PROJECT_URL = 'https://angor.io/project/angor1qtunna00uqyx52rr0nvqa059z2gknhrmn7urd6z';
import { SKATE_RELAY } from '@/lib/types';
import { AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend } from 'recharts';
import { MapPin, Store, Calendar, GraduationCap, Video, Scissors, Route, Users, Users2, Camera, Hammer, TrendingUp, Radio, Zap, ExternalLink, Trophy, FlaskConical, Palette, SprayCan, PenTool, Dumbbell, Music, Gavel, Tent, Sparkles, ShoppingBag, Tag, Handshake, Globe, RefreshCw } from 'lucide-react';
// ─── Helpers ─────────────────────────────────────────────────────────────────
/** Sum of every Lab craft count — keeps the "total" badge and donut in sync. */
function labCraftTotal(s: {
totalCoaches: number; totalFilmers: number; totalEditors: number;
totalPhotographers: number; totalBuilders: number; totalArtists: number;
totalGraffiti: number; totalTattoo: number; totalTrainers: number;
totalMusicians: number; totalJudges: number; totalOrganizers: number; totalBarbers: number;
}): number {
return (
s.totalCoaches + s.totalFilmers + s.totalEditors + s.totalPhotographers +
s.totalBuilders + s.totalArtists + s.totalGraffiti + s.totalTattoo +
s.totalTrainers + s.totalMusicians + s.totalJudges + s.totalOrganizers +
s.totalBarbers
);
}
const CHART_TOOLTIP_STYLE = {
backgroundColor: '#0d0d0d',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '8px',
color: '#fff',
fontSize: '12px',
};
// ─── Hero stat card ──────────────────────────────────────────────────────────
/** Compact number for large values (1234567 → "1.2M"); exact value in the title. */
function compactNumber(n: number): string {
return new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }).format(n);
}
function StatCard({
icon: Icon,
value,
label,
sub,
trustedValue,
color,
loading,
thisMonthDelta,
to,
compact,
}: {
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
value: number;
label: string;
sub?: string;
/** If set, shows a secondary "X trusted" line below the main count */
trustedValue?: number;
color: string;
loading: boolean;
/** How many new items were added this month */
thisMonthDelta?: number;
/** If set, the whole card becomes a link to this route (view all items). */
to?: string;
/** Abbreviate big numbers (e.g. sats) so they don't overflow the card. */
compact?: boolean;
}) {
const clickable = !!to && !loading && value > 0;
const card = (
<Card
className={cn(
'bg-black/80 border transition-all h-full',
clickable && 'hover:bg-white/[0.03] hover:-translate-y-0.5 active:scale-[0.99] cursor-pointer',
)}
style={{ borderColor: `${color}33` }}
>
<CardContent className="pt-5 pb-5">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-xs font-bold uppercase tracking-widest mb-2" style={{ color: '#666' }}>{label}</p>
{loading ? (
<Skeleton className="h-10 w-20 mb-1" />
) : (
<div className="flex items-end gap-2 flex-wrap">
<p className="text-4xl font-black leading-none" style={{ color }} title={compact ? value.toLocaleString() : undefined}>
{compact ? compactNumber(value) : value.toLocaleString()}
</p>
{typeof thisMonthDelta === 'number' && thisMonthDelta > 0 && (
<span
className="inline-flex items-center gap-0.5 text-[10px] font-black uppercase tracking-wide px-1.5 py-0.5 rounded-full mb-0.5"
style={{ background: `${color}22`, color }}
>
<TrendingUp className="h-2.5 w-2.5" />
+{thisMonthDelta} this month
</span>
)}
</div>
)}
{!loading && trustedValue !== undefined && (
<p className="text-xs mt-1.5 flex items-center gap-1" style={{ color: '#555' }}>
<span className="inline-flex items-center gap-0.5 text-green-500/80">
🛡 {trustedValue.toLocaleString()} from active accounts
</span>
</p>
)}
{sub && <p className="text-xs mt-1.5" style={{ color: '#555' }}>{sub}</p>}
{clickable && (
<p className="text-[10px] mt-2 font-bold uppercase tracking-wide inline-flex items-center gap-1" style={{ color }}>
View all <ExternalLink className="h-2.5 w-2.5" />
</p>
)}
</div>
<div className="p-2.5 rounded-xl flex-shrink-0" style={{ background: `${color}15` }}>
<Icon className="h-6 w-6" style={{ color }} />
</div>
</div>
</CardContent>
</Card>
);
return clickable ? <Link to={to!} className="block h-full">{card}</Link> : card;
}
// ─── Top mapper row ──────────────────────────────────────────────────────────
function MapperRow({ pubkey, rank, spotCount }: { pubkey: string; rank: number; spotCount: number }) {
const author = useAuthor(pubkey);
const meta = author.data?.metadata;
const name = meta?.name ?? genUserName(pubkey);
const rankColors: Record<number, string> = { 1: '#f7931a', 2: '#c0c0c0', 3: '#cd7f32' };
return (
<div className="flex items-center gap-3 py-2.5 border-b border-white/5 last:border-0">
<span className="text-xs font-black w-5 flex-shrink-0 text-center" style={{ color: rankColors[rank] ?? '#555' }}>
{rank <= 3 ? ['🥇','🥈','🥉'][rank - 1] : rank}
</span>
<Avatar className="h-8 w-8 flex-shrink-0 border border-white/10">
<AvatarImage src={meta?.picture} alt={name} />
<AvatarFallback className="text-xs font-bold bg-gradient-to-br from-[#ff0040]/20 to-[#ccff00]/20">
{name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="flex-1 text-sm font-semibold truncate">{name}</span>
<span className="text-xs font-bold flex-shrink-0" style={{ color: '#ccff00' }}>
{spotCount} {spotCount === 1 ? 'spot' : 'spots'}
</span>
</div>
);
}
// ─── Skeleton helpers ────────────────────────────────────────────────────────
function ChartSkeleton() {
return (
<div className="h-52 flex items-end gap-2 px-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="flex-1 rounded-t" style={{ height: `${30 + Math.random() * 60}%` }} />
))}
</div>
);
}
// ─── Page ────────────────────────────────────────────────────────────────────
export default function CommunityStatsPage() {
useSeoMeta({
title: 'Community Stats — SkateSpots',
description: 'Live community metrics for SkateSpots — spots, events, coaches, filmers, editors, tours and growth over time. Pulled directly from Nostr.',
});
const { data: stats, isLoading, isError, isFetching, refetch, dataUpdatedAt } = useGrowthStats();
// Resolve a region for untagged pins (cached) so the region card matches the map.
const { pending: derivingPending } = useDeriveRegions();
// Collapse the 13 Lab craft cards on mobile (they were ~7 rows of scrolling
// before the first chart); always expanded from lg up.
const [showAllLab, setShowAllLab] = useState(false);
// Content mix for donut chart
const contentMix = useMemo(() => {
if (!stats) return [];
return [
{ name: 'Spots', value: stats.totalSpots, color: '#ff0040' },
{ name: 'Events', value: stats.totalEvents, color: '#ccff00' },
{ name: 'Lab', value: labCraftTotal(stats), color: '#ff6600' },
{ name: 'Shops', value: stats.totalShops, color: '#4ade80' },
{ name: 'Tours', value: stats.totalTours, color: '#c084fc' },
{ name: 'Crews', value: stats.totalCrews, color: '#2dd4bf' },
].filter(d => d.value > 0);
}, [stats]);
// Only show last 12 months on charts to keep them readable
const recentMonthly = useMemo(() => stats?.monthly.slice(-12) ?? [], [stats]);
const recentCumulative = useMemo(() => stats?.cumulativeSpots.slice(-12) ?? [], [stats]);
// Current month delta — how many items were added this month
const thisMonth = useMemo(() => stats?.monthly[stats.monthly.length - 1], [stats]);
// Total across ALL Lab creator/service crafts (matches what /lab lists)
const labTotal = useMemo(() => (stats ? labCraftTotal(stats) : 0), [stats]);
// Defensive reads: a persisted cache saved before these fields existed would
// lack them, and `stats.topCountries.length` would throw. (The persist buster
// was bumped too, but never trust a cache blindly.)
const tradesByMethod = stats?.tradesByMethod ?? [];
const topCountries = stats?.topCountries ?? [];
const topCities = stats?.topCities ?? [];
const otherCountries = stats?.otherCountries ?? 0;
const otherCities = stats?.otherCities ?? 0;
const uncategorised = stats?.uncategorised ?? 0;
const locationsTotal = stats?.locationsTotal ?? 0;
return (
<>
<div className="container max-w-7xl px-4 py-10 md:py-14">
{/* ── Header ── */}
<div className="text-center mb-10">
<h1 className="text-5xl md:text-7xl font-black uppercase leading-none mb-3">
<span className="bg-gradient-to-r from-[#ff0040] via-[#ff6600] to-[#ccff00] bg-clip-text text-transparent">
Community
</span>
<br />
<span className="text-white">Stats</span>
</h1>
<p className="text-gray-400 max-w-xl mx-auto text-sm md:text-base mb-5">
Live metrics pulled directly from the Nostr network. Transparent, decentralized, verifiable by anyone.
</p>
{/* Data source badge + freshness + manual refresh */}
<div className="inline-flex flex-wrap items-center justify-center gap-2 px-4 py-2 rounded-full bg-[#ccff00]/5 border border-[#ccff00]/20 text-xs text-gray-400">
<Radio className="h-3 w-3 text-[#ccff00]" />
Data source:&nbsp;
<span className="text-[#ccff00] font-mono">{SKATE_RELAY.replace('wss://', '')}</span>
{dataUpdatedAt > 0 && (
<><span aria-hidden>·</span><span>updated {formatAge(Date.now() - dataUpdatedAt)}</span></>
)}
<button
type="button"
onClick={() => refetch()}
disabled={isFetching}
className="ml-1 inline-flex items-center gap-1 rounded-full border border-white/10 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-gray-400 transition-colors hover:text-white disabled:opacity-50"
>
<RefreshCw className={cn('h-3 w-3', isFetching && 'animate-spin')} /> Refresh
</button>
</div>
{/* Honesty banner: a relay outage must not read as real zeros. */}
{stats?.partial && (
<div className="mt-5 rounded-2xl border border-amber-500/40 bg-amber-500/10 px-4 py-3">
<p className="text-xs font-bold text-amber-300">
Couldn't reach every relay — these numbers may be <span className="uppercase">incomplete</span>.
</p>
</div>
)}
</div>
{/* ── Error state ── */}
{isError && (
<div className="mb-8 p-4 rounded-xl border border-[#ff0040]/30 bg-[#ff0040]/5 text-center text-sm text-gray-400">
Could not reach the relay right now. Stats will load once the connection is restored.
</div>
)}
{/* ── Section 1: Hero numbers ── */}
<section className="mb-12">
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
<TrendingUp className="h-3.5 w-3.5" /> Community at a Glance
</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<StatCard icon={MapPin} value={stats?.totalSpots ?? 0} label="Skate Spots" color="#ff0040" loading={isLoading} trustedValue={stats?.trustedSpots} thisMonthDelta={thisMonth?.spots} to="/map" />
<StatCard icon={Store} value={stats?.totalShops ?? 0} label="Listed Shops" color="#4ade80" loading={isLoading} thisMonthDelta={thisMonth?.shops} to="/map" />
<StatCard icon={Calendar} value={stats?.totalEvents ?? 0} label="Events" color="#ccff00" loading={isLoading} thisMonthDelta={thisMonth?.events} to="/events" />
<StatCard icon={Users} value={stats?.totalMappers ?? 0} label="Mappers" color="#facc15" loading={isLoading} trustedValue={stats?.trustedMappers} sub="unique contributors" thisMonthDelta={thisMonth?.activeMappers} to="/bounties" />
</div>
</section>
{/* ── Section 1b: The Lab — creators & services ── */}
<section className="mb-12">
<div className="flex items-center justify-between mb-4 gap-2">
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 flex items-center gap-2">
<FlaskConical className="h-3.5 w-3.5 text-[#ff6600]" /> The Lab · Creators &amp; Services
</h2>
{!isLoading && labTotal > 0 && (
<span className="text-[10px] font-black uppercase tracking-wide px-2 py-0.5 rounded-full bg-[#ff6600]/15 text-[#ff6600]">
{labTotal.toLocaleString()} total
</span>
)}
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
<StatCard icon={GraduationCap} value={stats?.totalCoaches ?? 0} label="Coaches" color="#ff6600" loading={isLoading} thisMonthDelta={thisMonth?.coaches} to="/lab?craft=coaching" />
<StatCard icon={Video} value={stats?.totalFilmers ?? 0} label="Filmers" color="#60a5fa" loading={isLoading} thisMonthDelta={thisMonth?.filmers} to="/lab?craft=filming" />
<StatCard icon={Scissors} value={stats?.totalEditors ?? 0} label="Editors" color="#c084fc" loading={isLoading} thisMonthDelta={thisMonth?.editors} to="/lab?craft=editing" />
<StatCard icon={Camera} value={stats?.totalPhotographers ?? 0} label="Photographers" color="#38bdf8" loading={isLoading} thisMonthDelta={thisMonth?.photographers} to="/lab?craft=photography" />
<StatCard icon={Hammer} value={stats?.totalBuilders ?? 0} label="Builders" color="#fb923c" loading={isLoading} thisMonthDelta={thisMonth?.builders} to="/lab?craft=building" />
<StatCard icon={Palette} value={stats?.totalArtists ?? 0} label="Artists" color="#f472b6" loading={isLoading} to="/lab?craft=art" />
{/* Extra crafts: collapsed on mobile (they were ~7 rows of scroll
before the first chart), always visible from `lg` up. The
`contents` wrapper keeps its children as real grid items. */}
<div className={cn(showAllLab ? 'contents' : 'hidden lg:contents')}>
<StatCard icon={SprayCan} value={stats?.totalGraffiti ?? 0} label="Graffiti" color="#a3e635" loading={isLoading} to="/lab?craft=graffiti" />
<StatCard icon={PenTool} value={stats?.totalTattoo ?? 0} label="Tattoo" color="#f87171" loading={isLoading} to="/lab?craft=tattoo" />
<StatCard icon={Dumbbell} value={stats?.totalTrainers ?? 0} label="Trainers" color="#34d399" loading={isLoading} to="/lab?craft=training" />
<StatCard icon={Music} value={stats?.totalMusicians ?? 0} label="Musicians" color="#818cf8" loading={isLoading} to="/lab?craft=music" />
<StatCard icon={Gavel} value={stats?.totalJudges ?? 0} label="Judges" color="#fbbf24" loading={isLoading} to="/lab?craft=judging" />
<StatCard icon={Tent} value={stats?.totalOrganizers ?? 0} label="Organizers" color="#2dd4bf" loading={isLoading} to="/lab?craft=organizing" />
<StatCard icon={Sparkles} value={stats?.totalBarbers ?? 0} label="Barbers" color="#22d3ee" loading={isLoading} to="/lab?craft=barbering" />
</div>
</div>
<button
type="button"
onClick={() => setShowAllLab((v) => !v)}
className="mt-3 w-full rounded-full border border-white/10 py-2 text-[11px] font-bold uppercase tracking-wide text-gray-400 transition-colors hover:text-white lg:hidden"
>
{showAllLab ? 'Show fewer' : 'Show all 13 crafts'}
</button>
</section>
{/* ── Section 1c: Community — tours & crews ── */}
<section className="mb-12">
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
<Users2 className="h-3.5 w-3.5 text-[#2dd4bf]" /> Community
</h2>
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard icon={Route} value={stats?.totalTours ?? 0} label="Tours" color="#f472b6" loading={isLoading} thisMonthDelta={thisMonth?.tours} to="/map" />
<StatCard icon={Users2} value={stats?.totalCrews ?? 0} label="Crews" color="#2dd4bf" loading={isLoading} thisMonthDelta={thisMonth?.crews} to="/crews" />
</div>
</section>
{/* ── Section 1d: Marketplace ── */}
<section className="mb-12">
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
<ShoppingBag className="h-3.5 w-3.5 text-[#00cc66]" /> Marketplace
</h2>
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard icon={Tag} value={stats?.totalListings ?? 0} label="Live Listings" color="#00cc66" loading={isLoading} to="/marketplace" />
<StatCard icon={Handshake} value={stats?.totalTrades ?? 0} label="Trades Recorded" color="#22c55e" loading={isLoading} to="/trades" />
<StatCard icon={Zap} value={stats?.tradedSats ?? 0} label="Sats Traded" color="#facc15" loading={isLoading} compact />
<StatCard icon={Zap} value={stats?.zapCount ?? 0} label="Zaps to Mappers" color="#f472b6" loading={isLoading} />
</div>
{tradesByMethod.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{tradesByMethod.map((m) => (
<span
key={m.method}
className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide text-gray-300"
>
{m.method} · {m.count}
</span>
))}
</div>
)}
<p className="mt-3 text-[11px] leading-relaxed text-gray-600">
<strong className="text-gray-400">{stats?.tradedCount ?? 0}</strong> mutual trade
{(stats?.tradedCount ?? 0) === 1 ? '' : 's'} across{' '}
<strong className="text-gray-400">{stats?.tradedTraders ?? 0}</strong> people. Sats are
self-reported by the traders — the app can't observe an off-app payment — so the headline
counts only <strong className="text-gray-400">mutual</strong> trades with a per-pair cap;
the raw claim is{' '}
<strong className="text-gray-500">{(stats?.tradedSatsClaimed ?? 0).toLocaleString()}</strong>{' '}
sats. "Zaps to mappers" counts only receipts addressed to our top contributors — it is
<strong className="text-gray-500"> not </strong> global zap volume.
</p>
</section>
{/* ── Section 1e: Regions ── */}
{(topCountries.length > 0 || topCities.length > 0) && (
<section className="mb-12">
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
<Globe className="h-3.5 w-3.5 text-[#4ade80]" /> Spots &amp; Shops by Region
</h2>
{derivingPending > 0 && (
<p className="-mt-2 mb-4 flex items-center gap-2 text-xs text-amber-300/90">
<RefreshCw className="h-3.5 w-3.5 shrink-0 animate-spin" />
Resolving {derivingPending} untagged location{derivingPending === 1 ? '' : 's'} from
their coordinates… one-time, then cached on this device.
</p>
)}
<div className="grid md:grid-cols-2 gap-4">
{topCountries.length > 0 && (
<Card className="border-white/8">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-black uppercase tracking-wide text-[#4ade80]">Top Countries</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{topCountries.map((c) => (
<div key={c.name} className="flex items-center gap-3">
<span className="w-24 truncate text-xs text-gray-300" title={c.name}>{c.name}</span>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-white/5">
<div
className="h-full rounded-full bg-[#4ade80]"
style={{ width: `${Math.max(4, (c.spots / topCountries[0].spots) * 100)}%` }}
/>
</div>
<span className="w-8 shrink-0 text-right text-xs font-bold tabular-nums text-white">{c.spots}</span>
</div>
))}
{otherCountries > 0 && (
<div className="flex items-center gap-3">
<span className="w-24 truncate text-xs text-gray-500" title="Countries outside the top 6">Other</span>
<div className="h-2 flex-1 rounded-full bg-white/5" />
<span className="w-8 shrink-0 text-right text-xs font-bold tabular-nums text-gray-400">{otherCountries}</span>
</div>
)}
{uncategorised > 0 && (
<div className="flex items-center gap-3">
<span className="w-24 truncate text-xs text-gray-500" title="No city/country tag">Uncategorised</span>
<div className="h-2 flex-1 rounded-full bg-white/5" />
<span className="w-8 shrink-0 text-right text-xs font-bold tabular-nums text-gray-400">{uncategorised}</span>
</div>
)}
</CardContent>
</Card>
)}
{topCities.length > 0 && (
<Card className="border-white/8">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-black uppercase tracking-wide text-[#38bdf8]">Top Cities</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{topCities.map((c) => (
<div key={c.name} className="flex items-center gap-3">
<span className="w-24 truncate text-xs text-gray-300" title={c.name}>{c.name}</span>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-white/5">
<div
className="h-full rounded-full bg-[#38bdf8]"
style={{ width: `${Math.max(4, (c.spots / topCities[0].spots) * 100)}%` }}
/>
</div>
<span className="w-8 shrink-0 text-right text-xs font-bold tabular-nums text-white">{c.spots}</span>
</div>
))}
{otherCities > 0 && (
<div className="flex items-center gap-3">
<span className="w-24 truncate text-xs text-gray-500" title="Cities outside the top 6">Other</span>
<div className="h-2 flex-1 rounded-full bg-white/5" />
<span className="w-8 shrink-0 text-right text-xs font-bold tabular-nums text-gray-400">{otherCities}</span>
</div>
)}
{uncategorised > 0 && (
<div className="flex items-center gap-3">
<span className="w-24 truncate text-xs text-gray-500" title="No city/country tag">Uncategorised</span>
<div className="h-2 flex-1 rounded-full bg-white/5" />
<span className="w-8 shrink-0 text-right text-xs font-bold tabular-nums text-gray-400">{uncategorised}</span>
</div>
)}
</CardContent>
</Card>
)}
</div>
<p className="mt-3 text-[11px] text-gray-600">
Counts <strong className="text-gray-500">spots &amp; shops</strong>, not skaters — from each
one's own city/country tag, or derived from its pin’s coordinates when it has
none (cached on your device; countries grouped by ISO code when present),
community relay only, with moderation-hidden content excluded.
{locationsTotal > 0 && (
<> <strong className="text-gray-500">{uncategorised} of {locationsTotal}</strong> location{locationsTotal === 1 ? '' : 's'} have no location tag and show as “Uncategorised”.</>
)}
</p>
{/* Admin-only (renders null otherwise): fix the gap right where it
shows up — re-derive region tags for the viewer's own legacy pins. */}
{uncategorised > 0 && <div className="mt-4"><RegionBackfillCard /></div>}
</section>
)}
{/* ── Section 2: Growth charts ── */}
<section className="mb-12">
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
<TrendingUp className="h-3.5 w-3.5" /> Growth Over Time
</h2>
<div className="grid md:grid-cols-2 gap-4">
{/* Cumulative spots — area chart */}
<Card className="bg-black/80 border border-[#ff0040]/20">
<CardHeader className="pb-2 pt-5 px-5">
<CardTitle className="text-sm font-black uppercase tracking-wide text-[#ff0040]">
Spots Mapped (Cumulative)
</CardTitle>
<p className="text-xs text-gray-500">Total spots added to the map over time</p>
</CardHeader>
<CardContent className="px-2 pb-4">
{isLoading ? <ChartSkeleton /> : (
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={recentCumulative} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="gradSpots" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#ff0040" stopOpacity={0.3} />
<stop offset="95%" stopColor="#ff0040" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1a1a1a" />
<XAxis dataKey="label" tick={{ fill: '#555', fontSize: 10 }} tickLine={false} axisLine={false} />
<YAxis tick={{ fill: '#555', fontSize: 10 }} tickLine={false} axisLine={false} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
<Area type="monotone" dataKey="value" name="Spots" stroke="#ff0040" strokeWidth={2} fill="url(#gradSpots)" dot={false} activeDot={{ r: 4, fill: '#ff0040' }} />
</AreaChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
{/* Monthly new content — stacked bar */}
<Card className="bg-black/80 border border-[#ccff00]/20">
<CardHeader className="pb-2 pt-5 px-5">
<CardTitle className="text-sm font-black uppercase tracking-wide text-[#ccff00]">
Monthly Activity
</CardTitle>
<p className="text-xs text-gray-500">New content published each month</p>
</CardHeader>
<CardContent className="px-2 pb-4">
{isLoading ? <ChartSkeleton /> : (
<ResponsiveContainer width="100%" height={200}>
<BarChart data={recentMonthly} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1a1a1a" />
<XAxis dataKey="label" tick={{ fill: '#555', fontSize: 10 }} tickLine={false} axisLine={false} />
<YAxis tick={{ fill: '#555', fontSize: 10 }} tickLine={false} axisLine={false} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
<Bar dataKey="spots" name="Spots" stackId="a" fill="#ff0040" radius={[0,0,0,0]} />
<Bar dataKey="events" name="Events" stackId="a" fill="#ff6600" radius={[0,0,0,0]} />
<Bar dataKey="shops" name="Shops" stackId="a" fill="#4ade80" radius={[0,0,0,0]} />
<Bar dataKey="coaches" name="Coaches" stackId="a" fill="#ccff00" radius={[4,4,0,0]} />
<Legend wrapperStyle={{ fontSize: '10px', color: '#666', paddingTop: '8px' }} />
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
{/* Active mappers per month — bar */}
<Card className="bg-black/80 border border-[#ff6600]/20">
<CardHeader className="pb-2 pt-5 px-5">
<CardTitle className="text-sm font-black uppercase tracking-wide text-[#ff6600]">
Active Mappers / Month
</CardTitle>
<p className="text-xs text-gray-500">Unique contributors publishing each month</p>
</CardHeader>
<CardContent className="px-2 pb-4">
{isLoading ? <ChartSkeleton /> : (
<ResponsiveContainer width="100%" height={200}>
<BarChart data={recentMonthly} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1a1a1a" />
<XAxis dataKey="label" tick={{ fill: '#555', fontSize: 10 }} tickLine={false} axisLine={false} />
<YAxis tick={{ fill: '#555', fontSize: 10 }} tickLine={false} axisLine={false} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
<Bar dataKey="activeMappers" name="Active Mappers" fill="#ff6600" radius={[4,4,0,0]}>
{recentMonthly.map((_, i) => (
<Cell key={i} fill={i === recentMonthly.length - 1 ? '#ccff00' : '#ff6600'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
{/* Content mix — donut */}
<Card className="bg-black/80 border border-[#c084fc]/20">
<CardHeader className="pb-2 pt-5 px-5">
<CardTitle className="text-sm font-black uppercase tracking-wide text-[#c084fc]">
Content Mix
</CardTitle>
<p className="text-xs text-gray-500">Breakdown by content type (all time)</p>
</CardHeader>
<CardContent className="px-2 pb-4 flex items-center justify-center">
{isLoading ? (
<div className="h-52 flex items-center justify-center">
<Skeleton className="w-40 h-40 rounded-full" />
</div>
) : contentMix.length > 0 ? (
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie data={contentMix} cx="50%" cy="50%" innerRadius={55} outerRadius={80} paddingAngle={3} dataKey="value">
{contentMix.map((entry, i) => (
<Cell key={i} fill={entry.color} />
))}
</Pie>
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
<Legend wrapperStyle={{ fontSize: '11px', color: '#888' }} />
</PieChart>
</ResponsiveContainer>
) : (
<p className="text-sm text-gray-600 py-8">No data yet</p>
)}
</CardContent>
</Card>
</div>
</section>
{/* ── Section 3: Top mappers ── */}
<section className="mb-12">
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
<Trophy className="h-3.5 w-3.5" /> Top Mappers
<span className="text-gray-600 font-normal normal-case tracking-normal">— most spots added</span>
</h2>
<div className="grid md:grid-cols-2 gap-4">
{isLoading ? (
<>
<Card className="bg-black/80 border border-white/8">
<CardContent className="pt-5">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 py-2.5 border-b border-white/5 last:border-0">
<Skeleton className="w-5 h-4" />
<Skeleton className="w-8 h-8 rounded-full" />
<Skeleton className="flex-1 h-4" />
<Skeleton className="w-12 h-4" />
</div>
))}
</CardContent>
</Card>
<Card className="bg-black/80 border border-white/8">
<CardContent className="pt-5">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 py-2.5 border-b border-white/5 last:border-0">
<Skeleton className="w-5 h-4" />
<Skeleton className="w-8 h-8 rounded-full" />
<Skeleton className="flex-1 h-4" />
<Skeleton className="w-12 h-4" />
</div>
))}
</CardContent>
</Card>
</>
) : stats && stats.topMappers.length > 0 ? (
<>
<Card className="bg-black/80 border border-white/8">
<CardContent className="pt-5">
{stats.topMappers.slice(0, 5).map((m, i) => (
<MapperRow key={m.pubkey} pubkey={m.pubkey} rank={i + 1} spotCount={m.spotCount} />
))}
</CardContent>
</Card>
<Card className="bg-black/80 border border-white/8">
<CardContent className="pt-5">
{stats.topMappers.slice(5, 10).map((m, i) => (
<MapperRow key={m.pubkey} pubkey={m.pubkey} rank={i + 6} spotCount={m.spotCount} />
))}
</CardContent>
</Card>
</>
) : (
<div className="col-span-2 text-center py-10 text-gray-600 text-sm">
No mapper data yet
</div>
)}
</div>
</section>
{/* ── Section 4: Supporters ── */}
<section className="mb-12">
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
<Zap className="h-3.5 w-3.5" /> Community Supporters
</h2>
<div className="relative rounded-2xl overflow-hidden border border-[#ccff00]/20 bg-gradient-to-br from-[#ff0040]/5 to-[#ccff00]/5 p-6 md:p-8">
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-[#ff0040] via-[#ff6600] to-[#ccff00]" />
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-6">
<div>
<h3 className="text-xl font-black uppercase text-[#ccff00] mb-1">Funded by the Community</h3>
<p className="text-sm text-gray-500 max-w-sm mb-4">
Decentralized Bitcoin crowdfunding via Angor.<br />
No ads, no investors, no data selling.
</p>
<div className="flex flex-wrap gap-3">
<a
href={ANGOR_PROJECT_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-[#ff0040]/15 border border-[#ff0040]/40 text-[#ff0040] text-xs font-bold hover:bg-[#ff0040]/25 transition-colors"
>
<Zap className="h-3.5 w-3.5" /> Back us on Angor
<ExternalLink className="h-3 w-3 opacity-60" />
</a>
<Link
to="/supporters"
className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-[#ccff00]/8 border border-[#ccff00]/30 text-[#ccff00] text-xs font-bold hover:bg-[#ccff00]/15 transition-colors"
>
<Users className="h-3.5 w-3.5" /> Community →
</Link>
</div>
</div>
</div>
</div>
</section>
{/* ── Footer / verifiability note ── */}
<div className="text-center p-6 rounded-xl bg-white/[0.02] border border-white/5">
<p className="text-xs text-gray-600 leading-relaxed">
All data is pulled live from{' '}
<span className="text-[#ccff00] font-mono">{SKATE_RELAY}</span>
{' '}— a public Nostr relay.{' '}
Every event is signed by its creator and cryptographically verifiable.{' '}
No numbers can be faked.
</p>
</div>
</div>
</>
);
}