SkateSpots/src/pages/CommunityStatsPage.tsx

764 lines
43 KiB
TypeScript

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 { useTranslation } from '@/contexts/language';
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, Truck, Shirt, 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; totalTransport: number; totalDesigners: 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 + (s.totalTransport ?? 0) + (s.totalDesigners ?? 0)
);
}
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 { t } = useTranslation();
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" />
{t('walletUi.stats.thisMonth', { count: thisMonthDelta })}
</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">
🛡 {t('walletUi.stats.fromActive', { count: trustedValue.toLocaleString() })}
</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 }}>
{t('walletUi.stats.viewAll')} <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, count, unit }: { pubkey: string; rank: number; count: number; unit: 'spots' | 'shops' }) {
const { t } = useTranslation();
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 min-w-0 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="min-w-0 flex-1 text-sm font-semibold truncate">{name}</span>
<span className="text-xs font-bold flex-shrink-0" style={{ color: '#ccff00' }}>
{t(unit === 'shops' ? 'walletUi.stats.shopCount' : 'walletUi.stats.spotCount', { count })}
</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() {
const { t } = useTranslation();
useSeoMeta({
title: t('seoUi.communityStatsTitle'),
description: t('seoUi.communityStatsDesc'),
});
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: t('walletUi.stats.spots'), value: stats.totalSpots, color: '#ff0040' },
{ name: t('walletUi.stats.events'), value: stats.totalEvents, color: '#ccff00' },
{ name: t('walletUi.stats.lab'), value: labCraftTotal(stats), color: '#ff6600' },
{ name: t('walletUi.stats.shops'), value: stats.totalShops, color: '#4ade80' },
{ name: t('walletUi.stats.tours'), value: stats.totalTours, color: '#c084fc' },
{ name: t('walletUi.stats.crews'), value: stats.totalCrews, color: '#2dd4bf' },
].filter(d => d.value > 0);
}, [stats, t]);
// 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?.length ? stats.monthly[stats.monthly.length - 1] : undefined),
[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">
{t('walletUi.stats.subtitle')}
</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]" />
{t('walletUi.stats.dataSource')}&nbsp;
<span className="text-[#ccff00] font-mono">{SKATE_RELAY.replace('wss://', '')}</span>
{dataUpdatedAt > 0 && (
<><span aria-hidden>·</span><span>{t('walletUi.stats.updated', { age: formatAge(Date.now() - dataUpdatedAt, t) })}</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')} /> {t('walletUi.stats.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">
{t('walletUi.stats.partial')}
</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">
{t('walletUi.stats.error')}
</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" /> {t('walletUi.stats.atAGlance')}
</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<StatCard icon={MapPin} value={stats?.totalSpots ?? 0} label={t('walletUi.stats.skateSpots')} color="#ff0040" loading={isLoading} trustedValue={stats?.trustedSpots} thisMonthDelta={thisMonth?.spots} to="/map" />
<StatCard icon={Store} value={stats?.totalShops ?? 0} label={t('walletUi.stats.listedShops')} color="#4ade80" loading={isLoading} thisMonthDelta={thisMonth?.shops} to="/map" />
<StatCard icon={Calendar} value={stats?.totalEvents ?? 0} label={t('walletUi.stats.events')} color="#ccff00" loading={isLoading} thisMonthDelta={thisMonth?.events} to="/events" />
<StatCard icon={Users} value={stats?.totalMappers ?? 0} label={t('walletUi.stats.mappers')} color="#facc15" loading={isLoading} trustedValue={stats?.trustedMappers} sub={t('walletUi.stats.uniqueContributors')} 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]" /> {t('walletUi.stats.labHeader')}
</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]">
{t('walletUi.stats.total', { count: labTotal.toLocaleString() })}
</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={t('walletUi.stats.coaches')} color="#ff6600" loading={isLoading} thisMonthDelta={thisMonth?.coaches} to="/lab?craft=coaching" />
<StatCard icon={Video} value={stats?.totalFilmers ?? 0} label={t('walletUi.stats.filmers')} color="#60a5fa" loading={isLoading} thisMonthDelta={thisMonth?.filmers} to="/lab?craft=filming" />
<StatCard icon={Scissors} value={stats?.totalEditors ?? 0} label={t('walletUi.stats.editors')} color="#c084fc" loading={isLoading} thisMonthDelta={thisMonth?.editors} to="/lab?craft=editing" />
<StatCard icon={Camera} value={stats?.totalPhotographers ?? 0} label={t('walletUi.stats.photographers')} color="#38bdf8" loading={isLoading} thisMonthDelta={thisMonth?.photographers} to="/lab?craft=photography" />
<StatCard icon={Hammer} value={stats?.totalBuilders ?? 0} label={t('walletUi.stats.builders')} color="#fb923c" loading={isLoading} thisMonthDelta={thisMonth?.builders} to="/lab?craft=building" />
<StatCard icon={Palette} value={stats?.totalArtists ?? 0} label={t('walletUi.stats.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={t('walletUi.stats.graffiti')} color="#a3e635" loading={isLoading} to="/lab?craft=graffiti" />
<StatCard icon={PenTool} value={stats?.totalTattoo ?? 0} label={t('walletUi.stats.tattoo')} color="#f87171" loading={isLoading} to="/lab?craft=tattoo" />
<StatCard icon={Dumbbell} value={stats?.totalTrainers ?? 0} label={t('walletUi.stats.trainers')} color="#34d399" loading={isLoading} to="/lab?craft=training" />
<StatCard icon={Music} value={stats?.totalMusicians ?? 0} label={t('walletUi.stats.musicians')} color="#818cf8" loading={isLoading} to="/lab?craft=music" />
<StatCard icon={Gavel} value={stats?.totalJudges ?? 0} label={t('walletUi.stats.judges')} color="#fbbf24" loading={isLoading} to="/lab?craft=judging" />
<StatCard icon={Tent} value={stats?.totalOrganizers ?? 0} label={t('walletUi.stats.organizers')} color="#2dd4bf" loading={isLoading} to="/lab?craft=organizing" />
<StatCard icon={Sparkles} value={stats?.totalBarbers ?? 0} label={t('walletUi.stats.barbers')} color="#22d3ee" loading={isLoading} to="/lab?craft=barbering" />
<StatCard icon={Truck} value={stats?.totalTransport ?? 0} label={t('walletUi.stats.transport')} color="#f59e0b" loading={isLoading} to="/lab?craft=transport" />
<StatCard icon={Shirt} value={stats?.totalDesigners ?? 0} label={t('walletUi.stats.designers')} color="#a78bfa" loading={isLoading} to="/lab?craft=clothing" />
</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 ? t('walletUi.stats.showFewer') : t('walletUi.stats.showAllCrafts')}
</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]" /> {t('walletUi.stats.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={t('walletUi.stats.tours')} color="#f472b6" loading={isLoading} thisMonthDelta={thisMonth?.tours} to="/map" />
<StatCard icon={Users2} value={stats?.totalCrews ?? 0} label={t('walletUi.stats.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]" /> {t('walletUi.stats.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={t('walletUi.stats.liveListings')} color="#00cc66" loading={isLoading} to="/marketplace" />
<StatCard icon={Handshake} value={stats?.totalTrades ?? 0} label={t('walletUi.stats.tradesRecorded')} color="#22c55e" loading={isLoading} to="/trades" />
<StatCard icon={Zap} value={stats?.tradedSats ?? 0} label={t('walletUi.stats.satsTraded')} color="#facc15" loading={isLoading} compact />
<StatCard icon={Zap} value={stats?.zapCount ?? 0} label={t('walletUi.stats.zapsToMappers')} 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> {t('walletUi.stats.tradesNote1')}{' '}
<strong className="text-gray-400">{stats?.tradedTraders ?? 0}</strong> {t('walletUi.stats.tradesNote2')}{' '}
<strong className="text-gray-400">{t('walletUi.stats.mutual')}</strong> {t('walletUi.stats.tradesNote3')}{' '}
<strong className="text-gray-500">{(stats?.tradedSatsClaimed ?? 0).toLocaleString()}</strong>{' '}
{t('walletUi.stats.tradesNote4')}
<strong className="text-gray-500"> {t('walletUi.stats.not')} </strong> {t('walletUi.stats.tradesNote5')}
</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]" /> {t('walletUi.stats.byRegion')}
</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" />
{t('walletUi.stats.resolving', { count: derivingPending })}
</p>
)}
<div className="grid grid-cols-1 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]">{t('walletUi.stats.topCountries')}</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={t('walletUi.stats.otherCountriesTitle')}>{t('walletUi.stats.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={t('walletUi.stats.uncategorisedTitle')}>{t('walletUi.stats.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]">{t('walletUi.stats.topCities')}</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={t('walletUi.stats.otherCitiesTitle')}>{t('walletUi.stats.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={t('walletUi.stats.uncategorisedTitle')}>{t('walletUi.stats.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">
{t('walletUi.stats.regionNote1')} <strong className="text-gray-500">{t('walletUi.stats.spotsAndShops')}</strong>{t('walletUi.stats.regionNote2')}
{locationsTotal > 0 && (
<> <strong className="text-gray-500">{t('walletUi.stats.ofTotal', { count: uncategorised, total: locationsTotal })}</strong> {t('walletUi.stats.regionNote3')}</>
)}
</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" /> {t('walletUi.stats.growth')}
</h2>
<div className="grid grid-cols-1 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]">
{t('walletUi.stats.chartCumulativeTitle')}
</CardTitle>
<p className="text-xs text-gray-500">{t('walletUi.stats.chartCumulativeDesc')}</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]">
{t('walletUi.stats.chartMonthlyTitle')}
</CardTitle>
<p className="text-xs text-gray-500">{t('walletUi.stats.chartMonthlyDesc')}</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]">
{t('walletUi.stats.chartMappersTitle')}
</CardTitle>
<p className="text-xs text-gray-500">{t('walletUi.stats.chartMappersDesc')}</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]">
{t('walletUi.stats.chartMixTitle')}
</CardTitle>
<p className="text-xs text-gray-500">{t('walletUi.stats.chartMixDesc')}</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">{t('walletUi.stats.noData')}</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" /> {t('walletUi.stats.topMappers')}
<span className="text-gray-600 font-normal normal-case tracking-normal">{t('walletUi.stats.mostSpots')}</span>
</h2>
<div className="grid grid-cols-1 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} count={m.spotCount} unit="spots" />
))}
</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} count={m.spotCount} unit="spots" />
))}
</CardContent>
</Card>
</>
) : (
<div className="col-span-2 text-center py-10 text-gray-600 text-sm">
{t('walletUi.stats.noMapperData')}
</div>
)}
</div>
</section>
{/* ── Section 3b: Top shops ── */}
<section className="mb-12">
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
<Store className="h-3.5 w-3.5" /> {t('walletUi.stats.topShops')}
<span className="text-gray-600 font-normal normal-case tracking-normal">{t('walletUi.stats.mostShops')}</span>
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{isLoading ? (
<>
{[0, 1].map((c) => (
<Card key={c} 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.topShops ?? []).length > 0 ? (
<>
<Card className="bg-black/80 border border-white/8">
<CardContent className="pt-5">
{(stats.topShops ?? []).slice(0, 5).map((m, i) => (
<MapperRow key={m.pubkey} pubkey={m.pubkey} rank={i + 1} count={m.shopCount} unit="shops" />
))}
</CardContent>
</Card>
<Card className="bg-black/80 border border-white/8">
<CardContent className="pt-5">
{(stats.topShops ?? []).slice(5, 10).map((m, i) => (
<MapperRow key={m.pubkey} pubkey={m.pubkey} rank={i + 6} count={m.shopCount} unit="shops" />
))}
</CardContent>
</Card>
</>
) : (
<div className="col-span-2 text-center py-10 text-gray-600 text-sm">
{t('walletUi.stats.noShopData')}
</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" /> {t('walletUi.stats.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">{t('walletUi.stats.fundedTitle')}</h3>
<p className="text-sm text-gray-500 max-w-sm mb-4">
{t('walletUi.stats.fundedDesc1')}<br />
{t('walletUi.stats.fundedDesc2')}
</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" /> {t('walletUi.stats.backAngor')}
<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" /> {t('walletUi.stats.communityLink')}
</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">
{t('walletUi.stats.footer1')}{' '}
<span className="text-[#ccff00] font-mono">{SKATE_RELAY}</span>
{' '}{t('walletUi.stats.footer2')}{' '}
{t('walletUi.stats.footer3')}{' '}
{t('walletUi.stats.footer4')}
</p>
</div>
</div>
</>
);
}