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).
712 lines
39 KiB
TypeScript
712 lines
39 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 { 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:
|
||
<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 & 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 & 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 & 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>
|
||
</>
|
||
);
|
||
}
|