A full Lab role, mirroring how Event Organizer was added: - kinds: SKATE_BARBER_KIND 35014, self-tag 'skatebarber', BARBER_*_LABELS, SkateBarber + parseSkateBarber (studio, books_open). - craft config at /become/barber (cyan accent, Sparkles icon), Lab tab 'Barbers', filter sections (services/styles/setting), empty state, FAB item. - Lab pipeline: useLabProfiles kind/tag/parser, LabPage ServiceType 'barbering' + ACCENT/maps/counts, CoachDetailPage per-role maps + studio & books-open block, LabLinkField label, growth stats + Community Stats card. - No crew-scene strip for barbers (LabCrewsStrip returns null). - Documented in NIP.md; smoke test now boots /become/barber.
517 lines
28 KiB
TypeScript
517 lines
28 KiB
TypeScript
import { useMemo } from 'react';
|
|
import { useSeoMeta } from '@unhead/react';
|
|
import { Link } from 'react-router-dom';
|
|
import { Layout } from '@/components/Layout';
|
|
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 { cn } from '@/lib/utils';
|
|
|
|
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 } 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 ──────────────────────────────────────────────────────────
|
|
|
|
function StatCard({
|
|
icon: Icon,
|
|
value,
|
|
label,
|
|
sub,
|
|
trustedValue,
|
|
color,
|
|
loading,
|
|
thisMonthDelta,
|
|
to,
|
|
}: {
|
|
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;
|
|
}) {
|
|
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 }}>{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 } = useGrowthStats();
|
|
|
|
|
|
// 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]);
|
|
|
|
return (
|
|
<Layout>
|
|
<div className="container max-w-6xl 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 */}
|
|
<div className="inline-flex items-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>
|
|
· cached 15 min
|
|
</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" />
|
|
<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>
|
|
</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 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>
|
|
</Layout>
|
|
);
|
|
}
|