SkateSpots/src/pages/CommunityStatsPage.tsx
shakespeare.diy f19851da92 security: implement all 8 protection measures
## 1. sanitizeUrl() (already existed, verified applied)
- Applied to ShopDetailPage, CoachDetailPage with urlDomain() tooltip hints
- Blocks javascript:, data:, vbscript: and all non-http(s) schemes
- Returns null for invalid URLs — link simply doesn't render

## 2. Geohash validation in parsers
- Added isValidGeohash() in types.ts using ngeohash.decode()
- Min 4 chars (country-level precision rejected), max 12 chars
- Rejects lat/lng outside Earth bounds
- Rejects 0,0 "null island" — classic bot artifact
- VPN/Tor unaffected: geohash comes from map pin, not user IP

## 3. Content length limits in all parsers
- MAX_TITLE_LEN=100, MAX_DESCRIPTION_LEN=3000, MAX_LOCATION_LEN=200
- MIN_TITLE_LEN=2, MIN_GEOHASH_LEN=4
- Applied to parseSkateSpot, parseSkateShop, parseSkateCoach,
  parseSkateFilmer, parseSkateEditor, parseSkateEvent

## 4. Smart WoT default (already in codebase, verified correct)
- Logged out / <5 follows → 'all' (don't punish new/anon users)
- Logged in with ≥5 follows → 'trusted' (sort trusted first)
- Soft mode: trusted content floats to top, nothing hidden
- Active in MapPage and EventsPage

## 5. Trusted count stats in useGrowthStats
- Added trustedSpots and trustedMappers to GrowthStats type
- Heuristic: pubkeys with ≥2 published items = organic community member
- CommunityStatsPage now shows "🛡 X from active accounts" below spot count
- Purely additive — both numbers always visible

## 6. OfficialBadge component (already existed, wired up)
- Added to SpotCard, EventCard, ShopDetailPage, CoachDetailPage
- Only renders for FOUNDER_PUBKEY (20e7c953...)
- Zero-friction: invisible for all other pubkeys
- Impersonation attempts immediately visible

## 7. Leaflet marker clustering
- Installed leaflet.markercluster v1.5.3 + @types
- MarkerClusterGroup replaces direct marker.addTo(map)
- Custom cluster icon: red/orange gradient circle with count
- maxClusterRadius: 60px, spiderfyOnMaxZoom: true
- MarkerCluster CSS loaded from CDN in index.html

## 8. DM phishing warning banner
- PhishingWarningBanner component in DirectMessagesPage
- One-time dismissible (localStorage key: dm-phishing-warning-dismissed)
- Message: SkateSpots will never DM you asking for keys/payments
- Uses useLocalStorage hook, Shield icon, X dismiss button

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-06-18 14:03:15 +07:00

462 lines
22 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, useGeyserStats } from '@/hooks/useGrowthStats';
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,
TrendingUp, Radio, Zap, ExternalLink,
Trophy,
} from 'lucide-react';
// ─── Helpers ─────────────────────────────────────────────────────────────────
function formatSats(sats: number): string {
if (sats >= 1_000_000) return `${(sats / 1_000_000).toFixed(1)}M`;
if (sats >= 1_000) return `${Math.round(sats / 1_000)}k`;
return sats.toLocaleString();
}
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,
}: {
icon: React.ComponentType<{ className?: string }>;
value: number;
label: string;
sub?: string;
/** If set, shows a secondary "X trusted" line below the main count */
trustedValue?: number;
color: string;
loading: boolean;
}) {
return (
<Card className="bg-black/80 border" 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" />
) : (
<p className="text-4xl font-black leading-none" style={{ color }}>{value.toLocaleString()}</p>
)}
{!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>}
</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>
);
}
// ─── 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();
const { data: geyser } = useGeyserStats();
// 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: stats.totalCoaches + stats.totalFilmers + stats.totalEditors, color: '#ff6600' },
{ name: 'Shops', value: stats.totalShops, color: '#4ade80' },
{ name: 'Tours', value: stats.totalTours, color: '#c084fc' },
].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]);
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:&nbsp;
<span className="text-[#ccff00] font-mono">{SKATE_RELAY.replace('wss://', '')}</span>
&nbsp;·&nbsp; 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} />
<StatCard icon={Store} value={stats?.totalShops ?? 0} label="Listed Shops" color="#4ade80" loading={isLoading} />
<StatCard icon={Calendar} value={stats?.totalEvents ?? 0} label="Events" color="#ccff00" loading={isLoading} />
<StatCard icon={Users} value={stats?.totalMappers ?? 0} label="Mappers" color="#facc15" loading={isLoading} trustedValue={stats?.trustedMappers} sub="unique contributors" />
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-3">
<StatCard icon={GraduationCap} value={stats?.totalCoaches ?? 0} label="Coaches" color="#ff6600" loading={isLoading} />
<StatCard icon={Video} value={stats?.totalFilmers ?? 0} label="Filmers" color="#60a5fa" loading={isLoading} />
<StatCard icon={Scissors} value={stats?.totalEditors ?? 0} label="Editors" color="#c084fc" loading={isLoading} />
<StatCard icon={Route} value={stats?.totalTours ?? 0} label="Tours" color="#f472b6" loading={isLoading} />
</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">
Bitcoin Lightning supporters via Geyser Fund.<br />
No ads, no investors, no data selling.
</p>
<div className="flex flex-wrap gap-3">
<a
href="https://geyser.fund/project/skatespots"
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" /> Support on Geyser
<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" /> View all supporters →
</Link>
</div>
</div>
<div className="text-right flex-shrink-0">
{geyser ? (
<>
<p className="text-5xl font-black text-[#ccff00] leading-none">
{geyser.supporterCount}
</p>
<p className="text-sm text-gray-500 mt-1">supporters</p>
{geyser.totalSats > 0 && (
<p className="text-xs text-[#f7931a] font-bold mt-1">
⚡ {formatSats(geyser.totalSats)} sats raised
</p>
)}
</>
) : (
<Skeleton className="w-20 h-14" />
)}
</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>
);
}