## 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>
462 lines
22 KiB
TypeScript
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:
|
|
<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} />
|
|
<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>
|
|
);
|
|
}
|