feat: extended community stats page with Nostr growth data
New useGrowthStats hook: - Queries wss://goskatespots.nostr1.com directly (authoritative source) - Fetches all 7 content types in parallel: spots, shops, events, coaches, filmers, editors, tours - Deduplicates addressable events (keep newest per pubkey+kind+d) - Buckets events by month with gap-filling for continuous X axis - Computes cumulative spots line chart data - Extracts top 10 mappers by spot count - 15 min stale time — stats don't need to be live New useGeyserStats hook: - Queries Geyser GraphQL for supporter count + total sats raised - Reuses same API already used in SupportersPage Rewritten CommunityStatsPage: - Section 1: 8 hero stat cards (spots, shops, events, coaches, filmers, editors, tours, mappers) with per-type accent colours - Section 2: 4 charts (cumulative spots area, monthly stacked bar, active mappers bar, content mix donut) - Section 3: Top 10 mappers leaderboard with avatars + 🥇🥈🥉 - Section 4: Supporters banner with live Geyser count + sats raised - Footer: data source + verifiability note - Full skeleton loading for each section independently - Error state for relay connectivity issues Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
0e5abcb2db
commit
0451887a17
2 changed files with 701 additions and 489 deletions
280
src/hooks/useGrowthStats.ts
Normal file
280
src/hooks/useGrowthStats.ts
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import {
|
||||
SKATE_SPOT_KIND,
|
||||
SKATE_SHOP_KIND,
|
||||
CALENDAR_EVENT_KIND,
|
||||
SKATE_COACH_KIND,
|
||||
SKATE_FILMER_KIND,
|
||||
SKATE_EDITOR_KIND,
|
||||
SKATE_TOUR_KIND,
|
||||
SKATE_RELAY,
|
||||
} from '@/lib/types';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MonthBucket {
|
||||
/** "Jan '25" */
|
||||
label: string;
|
||||
/** YYYY-MM for sorting */
|
||||
key: string;
|
||||
spots: number;
|
||||
shops: number;
|
||||
events: number;
|
||||
coaches: number;
|
||||
filmers: number;
|
||||
editors: number;
|
||||
tours: number;
|
||||
total: number;
|
||||
/** Unique pubkeys that published anything this month */
|
||||
activeMappers: number;
|
||||
}
|
||||
|
||||
export interface TopMapper {
|
||||
pubkey: string;
|
||||
spotCount: number;
|
||||
totalContent: number;
|
||||
}
|
||||
|
||||
export interface GrowthStats {
|
||||
// ── Totals ──────────────────────────────────────────────────────────────
|
||||
totalSpots: number;
|
||||
totalShops: number;
|
||||
totalEvents: number;
|
||||
totalCoaches: number;
|
||||
totalFilmers: number;
|
||||
totalEditors: number;
|
||||
totalTours: number;
|
||||
/** All unique pubkeys that have published any content */
|
||||
totalMappers: number;
|
||||
|
||||
// ── Time series ─────────────────────────────────────────────────────────
|
||||
/** Monthly buckets oldest → newest */
|
||||
monthly: MonthBucket[];
|
||||
/** Cumulative spots per month (for line chart) */
|
||||
cumulativeSpots: { key: string; label: string; value: number }[];
|
||||
|
||||
// ── Leaderboard ─────────────────────────────────────────────────────────
|
||||
topMappers: TopMapper[];
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function toMonthKey(ts: number): string {
|
||||
const d = new Date(ts * 1000);
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
return `${y}-${m}`;
|
||||
}
|
||||
|
||||
function toMonthLabel(key: string): string {
|
||||
const [y, m] = key.split('-');
|
||||
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
return `${months[parseInt(m) - 1]} '${y.slice(2)}`;
|
||||
}
|
||||
|
||||
function fillMonthGaps(keys: string[]): string[] {
|
||||
if (keys.length === 0) return [];
|
||||
const sorted = [...keys].sort();
|
||||
const first = sorted[0];
|
||||
const last = sorted[sorted.length - 1];
|
||||
const result: string[] = [];
|
||||
let [y, m] = first.split('-').map(Number);
|
||||
const [ey, em] = last.split('-').map(Number);
|
||||
while (y < ey || (y === ey && m <= em)) {
|
||||
result.push(`${y}-${String(m).padStart(2, '0')}`);
|
||||
m++;
|
||||
if (m > 12) { m = 1; y++; }
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Hook ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useGrowthStats() {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
return useQuery<GrowthStats>({
|
||||
queryKey: ['growth-stats'],
|
||||
queryFn: async () => {
|
||||
const relay = nostr.relay(SKATE_RELAY);
|
||||
const signal = AbortSignal.timeout(15000);
|
||||
|
||||
// Query all content types in parallel from the community relay.
|
||||
// High limits to get full history — this is a stats page, not a feed.
|
||||
const [
|
||||
spotEvents,
|
||||
shopEvents,
|
||||
calendarEvents,
|
||||
coachEvents,
|
||||
filmerEvents,
|
||||
editorEvents,
|
||||
tourEvents,
|
||||
] = await Promise.all([
|
||||
relay.query([{ kinds: [SKATE_SPOT_KIND], '#t': ['skatespot'], limit: 2000 }], { signal }).catch(() => []),
|
||||
relay.query([{ kinds: [SKATE_SHOP_KIND], limit: 2000 }], { signal }).catch(() => []),
|
||||
relay.query([{ kinds: [CALENDAR_EVENT_KIND], '#t': ['skateboarding'], limit: 2000 }], { signal }).catch(() => []),
|
||||
relay.query([{ kinds: [SKATE_COACH_KIND], limit: 500 }], { signal }).catch(() => []),
|
||||
relay.query([{ kinds: [SKATE_FILMER_KIND], limit: 500 }], { signal }).catch(() => []),
|
||||
relay.query([{ kinds: [SKATE_EDITOR_KIND], limit: 500 }], { signal }).catch(() => []),
|
||||
relay.query([{ kinds: [SKATE_TOUR_KIND], limit: 500 }], { signal }).catch(() => []),
|
||||
]);
|
||||
|
||||
// Deduplicate addressable events (keep newest per pubkey+kind+d)
|
||||
function dedup(events: typeof spotEvents) {
|
||||
const map = new Map<string, typeof events[0]>();
|
||||
for (const e of events) {
|
||||
const d = e.tags.find(t => t[0] === 'd')?.[1] ?? e.id;
|
||||
const key = `${e.pubkey}:${e.kind}:${d}`;
|
||||
const existing = map.get(key);
|
||||
if (!existing || e.created_at > existing.created_at) map.set(key, e);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
const spots = dedup(spotEvents);
|
||||
const shops = dedup(shopEvents);
|
||||
const events = dedup(calendarEvents);
|
||||
const coaches = dedup(coachEvents);
|
||||
const filmers = dedup(filmerEvents);
|
||||
const editors = dedup(editorEvents);
|
||||
const tours = dedup(tourEvents);
|
||||
|
||||
// ── Monthly bucketing ───────────────────────────────────────────────
|
||||
|
||||
const bucketMap = new Map<string, {
|
||||
spots: Set<string>; shops: Set<string>; events: Set<string>;
|
||||
coaches: Set<string>; filmers: Set<string>; editors: Set<string>;
|
||||
tours: Set<string>; pubkeys: Set<string>;
|
||||
}>();
|
||||
|
||||
function ensureBucket(key: string) {
|
||||
if (!bucketMap.has(key)) {
|
||||
bucketMap.set(key, {
|
||||
spots: new Set(), shops: new Set(), events: new Set(),
|
||||
coaches: new Set(), filmers: new Set(), editors: new Set(),
|
||||
tours: new Set(), pubkeys: new Set(),
|
||||
});
|
||||
}
|
||||
return bucketMap.get(key)!;
|
||||
}
|
||||
|
||||
for (const e of spots) { const b = ensureBucket(toMonthKey(e.created_at)); b.spots.add(e.id); b.pubkeys.add(e.pubkey); }
|
||||
for (const e of shops) { const b = ensureBucket(toMonthKey(e.created_at)); b.shops.add(e.id); b.pubkeys.add(e.pubkey); }
|
||||
for (const e of events) { const b = ensureBucket(toMonthKey(e.created_at)); b.events.add(e.id); b.pubkeys.add(e.pubkey); }
|
||||
for (const e of coaches) { const b = ensureBucket(toMonthKey(e.created_at)); b.coaches.add(e.id); b.pubkeys.add(e.pubkey); }
|
||||
for (const e of filmers) { const b = ensureBucket(toMonthKey(e.created_at)); b.filmers.add(e.id); b.pubkeys.add(e.pubkey); }
|
||||
for (const e of editors) { const b = ensureBucket(toMonthKey(e.created_at)); b.editors.add(e.id); b.pubkeys.add(e.pubkey); }
|
||||
for (const e of tours) { const b = ensureBucket(toMonthKey(e.created_at)); b.tours.add(e.id); b.pubkeys.add(e.pubkey); }
|
||||
|
||||
// Fill any gaps between first and last month so charts have continuous X axis
|
||||
const allKeys = fillMonthGaps(Array.from(bucketMap.keys()));
|
||||
|
||||
const monthly: MonthBucket[] = allKeys.map(key => {
|
||||
const b = bucketMap.get(key);
|
||||
const s = b?.spots.size ?? 0;
|
||||
const sh = b?.shops.size ?? 0;
|
||||
const ev = b?.events.size ?? 0;
|
||||
const co = b?.coaches.size ?? 0;
|
||||
const fi = b?.filmers.size ?? 0;
|
||||
const ed = b?.editors.size ?? 0;
|
||||
const to = b?.tours.size ?? 0;
|
||||
return {
|
||||
label: toMonthLabel(key),
|
||||
key,
|
||||
spots: s, shops: sh, events: ev,
|
||||
coaches: co, filmers: fi, editors: ed, tours: to,
|
||||
total: s + sh + ev + co + fi + ed + to,
|
||||
activeMappers: b?.pubkeys.size ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
// Cumulative spots line chart data
|
||||
let running = 0;
|
||||
const cumulativeSpots = monthly.map(m => {
|
||||
running += m.spots;
|
||||
return { key: m.key, label: m.label, value: running };
|
||||
});
|
||||
|
||||
// ── Top mappers by spot count ────────────────────────────────────────
|
||||
|
||||
const mapperSpots = new Map<string, number>();
|
||||
const mapperTotal = new Map<string, number>();
|
||||
|
||||
for (const e of spots) { mapperSpots.set(e.pubkey, (mapperSpots.get(e.pubkey) ?? 0) + 1); }
|
||||
const allContent = [...spots, ...shops, ...events, ...coaches, ...filmers, ...editors, ...tours];
|
||||
for (const e of allContent) { mapperTotal.set(e.pubkey, (mapperTotal.get(e.pubkey) ?? 0) + 1); }
|
||||
|
||||
const topMappers: TopMapper[] = Array.from(mapperSpots.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([pubkey, spotCount]) => ({
|
||||
pubkey,
|
||||
spotCount,
|
||||
totalContent: mapperTotal.get(pubkey) ?? spotCount,
|
||||
}));
|
||||
|
||||
// ── Unique mappers ───────────────────────────────────────────────────
|
||||
|
||||
const allPubkeys = new Set(allContent.map(e => e.pubkey));
|
||||
|
||||
return {
|
||||
totalSpots: spots.length,
|
||||
totalShops: shops.length,
|
||||
totalEvents: events.length,
|
||||
totalCoaches: coaches.length,
|
||||
totalFilmers: filmers.length,
|
||||
totalEditors: editors.length,
|
||||
totalTours: tours.length,
|
||||
totalMappers: allPubkeys.size,
|
||||
monthly,
|
||||
cumulativeSpots,
|
||||
topMappers,
|
||||
};
|
||||
},
|
||||
staleTime: 15 * 60 * 1000, // 15 min — stats don't need to be live
|
||||
gcTime: 60 * 60 * 1000, // 1 hr in memory
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Geyser supporter count ──────────────────────────────────────────────────
|
||||
|
||||
const GEYSER_API_URL = 'https://api.geyser.fund/graphql';
|
||||
|
||||
export interface GeyserStats {
|
||||
supporterCount: number;
|
||||
totalSats: number;
|
||||
}
|
||||
|
||||
export function useGeyserStats() {
|
||||
return useQuery<GeyserStats>({
|
||||
queryKey: ['geyser-stats'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const res = await fetch(GEYSER_API_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
query: `{
|
||||
projectGet(where: { name: "skatespots" }) {
|
||||
fundersCount
|
||||
balance
|
||||
}
|
||||
}`,
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
const project = json?.data?.projectGet;
|
||||
return {
|
||||
supporterCount: project?.fundersCount ?? 0,
|
||||
totalSats: project?.balance ?? 0,
|
||||
};
|
||||
} catch {
|
||||
return { supporterCount: 0, totalSats: 0 };
|
||||
}
|
||||
},
|
||||
staleTime: 15 * 60 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
|
@ -3,517 +3,449 @@ 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 { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
||||
import { useSkateEvents } from '@/hooks/useSkateEvents';
|
||||
import { useLabProfiles } from '@/hooks/useLabProfiles';
|
||||
import { useShops } from '@/hooks/useShops';
|
||||
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||
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 {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
AreaChart, Area,
|
||||
BarChart, Bar,
|
||||
XAxis, YAxis,
|
||||
CartesianGrid, Tooltip,
|
||||
ResponsiveContainer,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
Legend,
|
||||
PieChart, Pie, Cell, Legend,
|
||||
} from 'recharts';
|
||||
import {
|
||||
TrendingUp,
|
||||
Users,
|
||||
Shield,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
MapPin,
|
||||
Calendar,
|
||||
GraduationCap,
|
||||
Store,
|
||||
Radio,
|
||||
MapPin, Store, Calendar, GraduationCap,
|
||||
Video, Scissors, Route, Users,
|
||||
TrendingUp, Radio, Zap, ExternalLink,
|
||||
Trophy,
|
||||
} from 'lucide-react';
|
||||
|
||||
const TRUST_COLORS = {
|
||||
verified: '#22c55e', // green-500
|
||||
trusted: '#eab308', // yellow-500
|
||||
unverified: '#6b7280', // gray-500
|
||||
// ─── 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,
|
||||
color,
|
||||
loading,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
value: number;
|
||||
label: string;
|
||||
sub?: string;
|
||||
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>
|
||||
)}
|
||||
{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: 'View trust analytics and community statistics for the SkateSpots community.',
|
||||
title: 'Community Stats — SkateSpots',
|
||||
description: 'Live community metrics for SkateSpots — spots, events, coaches, filmers, editors, tours and growth over time. Pulled directly from Nostr.',
|
||||
});
|
||||
|
||||
// Call all hooks unconditionally at the top
|
||||
const { getReadRelays, isLoggedIn } = useSkateRelays();
|
||||
const relays = getReadRelays();
|
||||
const { data: stats, isLoading, isError } = useGrowthStats();
|
||||
const { data: geyser } = useGeyserStats();
|
||||
|
||||
const { data: spots, isLoading: spotsLoading } = useSkateSpots(500);
|
||||
const { data: events, isLoading: eventsLoading } = useSkateEvents(500);
|
||||
const { data: profiles, isLoading: profilesLoading } = useLabProfiles(500);
|
||||
const { data: shops, isLoading: shopsLoading } = useShops(500);
|
||||
// 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]);
|
||||
|
||||
// Combine all unique pubkeys
|
||||
const allPubkeys = useMemo(() => {
|
||||
const pubkeySet = new Set<string>();
|
||||
spots?.forEach((s) => pubkeySet.add(s.pubkey));
|
||||
events?.forEach((e) => pubkeySet.add(e.pubkey));
|
||||
profiles?.forEach((p) => pubkeySet.add(p.pubkey));
|
||||
shops?.forEach((s) => pubkeySet.add(s.pubkey));
|
||||
return Array.from(pubkeySet);
|
||||
}, [spots, events, profiles, shops]);
|
||||
|
||||
const { data: trustScores, isLoading: trustLoading } = useBatchTrustScores(allPubkeys);
|
||||
|
||||
const isLoading = spotsLoading || eventsLoading || profilesLoading || shopsLoading || trustLoading;
|
||||
|
||||
// Calculate trust distribution
|
||||
const trustDistribution = useMemo(() => {
|
||||
if (!trustScores) return { verified: 0, trusted: 0, unverified: 0 };
|
||||
|
||||
const distribution = {
|
||||
verified: 0, // score >= 90
|
||||
trusted: 0, // score >= 60 && score < 90
|
||||
unverified: 0, // score < 60
|
||||
};
|
||||
|
||||
Array.from(trustScores.values()).forEach((score) => {
|
||||
if (score >= 90) distribution.verified++;
|
||||
else if (score >= 60) distribution.trusted++;
|
||||
else distribution.unverified++;
|
||||
});
|
||||
|
||||
return distribution;
|
||||
}, [trustScores]);
|
||||
|
||||
// Calculate content by trust level
|
||||
const contentByTrust = useMemo(() => {
|
||||
if (!trustScores) return { verified: 0, trusted: 0, unverified: 0 };
|
||||
|
||||
const spotsByTrust = { verified: 0, trusted: 0, unverified: 0 };
|
||||
spots?.forEach((spot) => {
|
||||
const score = trustScores.get(spot.pubkey) || 0;
|
||||
if (score >= 90) spotsByTrust.verified++;
|
||||
else if (score >= 60) spotsByTrust.trusted++;
|
||||
else spotsByTrust.unverified++;
|
||||
});
|
||||
|
||||
return spotsByTrust;
|
||||
}, [spots, trustScores]);
|
||||
|
||||
// Prepare chart data
|
||||
const trustDistributionData = [
|
||||
{ name: 'Verified', value: trustDistribution.verified, color: TRUST_COLORS.verified },
|
||||
{ name: 'Trusted', value: trustDistribution.trusted, color: TRUST_COLORS.trusted },
|
||||
{ name: 'Unverified', value: trustDistribution.unverified, color: TRUST_COLORS.unverified },
|
||||
];
|
||||
|
||||
const contentByTrustData = [
|
||||
{ category: 'Verified', spots: contentByTrust.verified },
|
||||
{ category: 'Trusted', spots: contentByTrust.trusted },
|
||||
{ category: 'Unverified', spots: contentByTrust.unverified },
|
||||
];
|
||||
|
||||
const totalContributors = allPubkeys.length;
|
||||
const totalContent = (spots?.length || 0) + (events?.length || 0) + (profiles?.length || 0) + (shops?.length || 0);
|
||||
const verifiedPercentage = totalContributors > 0 ? ((trustDistribution.verified / totalContributors) * 100).toFixed(1) : '0';
|
||||
const trustedPercentage = totalContributors > 0 ? (((trustDistribution.verified + trustDistribution.trusted) / totalContributors) * 100).toFixed(1) : '0';
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container px-4 py-12 max-w-6xl">
|
||||
<Skeleton className="h-12 w-64 mb-8" />
|
||||
<div className="grid md:grid-cols-3 gap-6 mb-8">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="pt-6">
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="pt-6">
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
// 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 px-4 py-12 max-w-6xl">
|
||||
{/* Header */}
|
||||
<div className="mb-12 text-center">
|
||||
<h1 className="text-4xl md:text-6xl font-black uppercase mb-4 gradient-text" style={{
|
||||
fontFamily: 'Impact, "Arial Black", sans-serif',
|
||||
letterSpacing: '0.05em'
|
||||
}}>
|
||||
Community Stats
|
||||
<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-xl text-gray-400 max-w-2xl mx-auto">
|
||||
Trust analytics and community metrics for the SkateSpots network
|
||||
<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} />
|
||||
<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} 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>
|
||||
|
||||
{/* Relay Info Notice */}
|
||||
<div className="mb-8 p-4 rounded-lg bg-gray-900/50 border border-gray-700 flex items-start gap-3">
|
||||
<Radio className="h-5 w-5 text-gray-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-gray-400">
|
||||
<p>
|
||||
<span className="text-gray-300 font-medium">Data source:</span>{' '}
|
||||
{relays.length === 1 ? (
|
||||
<>Querying <span className="text-[#ccff00] font-mono text-xs">{relays[0].replace('wss://', '')}</span></>
|
||||
) : (
|
||||
<>Querying {relays.length} relays</>
|
||||
)}
|
||||
{isLoggedIn && (
|
||||
<>
|
||||
{' '}—{' '}
|
||||
<Link to="/settings?tab=relays" className="text-[#ccff00] hover:underline">
|
||||
Manage relays
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{!isLoggedIn && (
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Log in to customize which relays to query
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<div className="grid md:grid-cols-3 gap-6 mb-8">
|
||||
{/* Total Contributors */}
|
||||
<Card className="bg-black/90 border-2 border-[#ff0040]/30">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400 uppercase font-bold mb-2">Total Contributors</p>
|
||||
<p className="text-4xl font-black text-white">{totalContributors}</p>
|
||||
</div>
|
||||
<Users className="h-12 w-12 text-[#ff0040]" />
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-[#ff0040]/20">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span className="text-gray-300">{verifiedPercentage}% verified</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Total Content */}
|
||||
<Card className="bg-black/90 border-2 border-[#ccff00]/30">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400 uppercase font-bold mb-2">Total Content</p>
|
||||
<p className="text-4xl font-black text-white">{totalContent}</p>
|
||||
</div>
|
||||
<MapPin className="h-12 w-12 text-[#ccff00]" />
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-[#ccff00]/20 space-y-1">
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>Spots: {spots?.length || 0}</span>
|
||||
<span>Events: {events?.length || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>Coaches: {profiles?.length || 0}</span>
|
||||
<span>Shops: {shops?.length || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Trust Score */}
|
||||
<Card className="bg-black/90 border-2 border-green-500/30">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400 uppercase font-bold mb-2">Trusted Network</p>
|
||||
<p className="text-4xl font-black text-white">{trustedPercentage}%</p>
|
||||
</div>
|
||||
<Shield className="h-12 w-12 text-green-500" />
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-green-500/20">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<TrendingUp className="h-4 w-4 text-green-500" />
|
||||
Contributors with trust ≥60
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid md:grid-cols-2 gap-6 mb-8">
|
||||
{/* Trust Distribution Pie Chart */}
|
||||
<Card className="bg-black/90 border-2 border-[#ff0040]/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-black uppercase text-[#ccff00] flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
Trust Distribution
|
||||
</CardTitle>
|
||||
<p className="text-sm text-gray-400">
|
||||
Breakdown of contributors by trust level
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={trustDistributionData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={(entry) => `${entry.name}: ${entry.value}`}
|
||||
outerRadius={100}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{trustDistributionData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#000',
|
||||
border: '2px solid #ff0040',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Content by Trust Bar Chart */}
|
||||
<Card className="bg-black/90 border-2 border-[#ccff00]/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-black uppercase text-[#ff0040] flex items-center gap-2">
|
||||
<MapPin className="h-5 w-5" />
|
||||
Spots by Trust Level
|
||||
</CardTitle>
|
||||
<p className="text-sm text-gray-400">
|
||||
Number of skate spots created by each trust level
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={contentByTrustData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
||||
<XAxis dataKey="category" stroke="#999" />
|
||||
<YAxis stroke="#999" />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#000',
|
||||
border: '2px solid #ccff00',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="spots" fill="#ff0040" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Trust Level Breakdown */}
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
{/* Verified Users */}
|
||||
<Card className="bg-black/90 border-2 border-green-500/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-black uppercase text-green-500 flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
Verified
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-5xl font-black text-white">{trustDistribution.verified}</span>
|
||||
<span className="text-gray-400 text-sm">contributors</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">
|
||||
People you follow directly or your own content (trust ≥90)
|
||||
</p>
|
||||
<Badge className="bg-green-500 text-white">
|
||||
{verifiedPercentage}% of community
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Trusted Users */}
|
||||
<Card className="bg-black/90 border-2 border-yellow-500/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-black uppercase text-yellow-500 flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Trusted
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-5xl font-black text-white">{trustDistribution.trusted}</span>
|
||||
<span className="text-gray-400 text-sm">contributors</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">
|
||||
Friends-of-friends or NIP-05 verified users (trust 60-89)
|
||||
</p>
|
||||
<Badge className="bg-yellow-500 text-white">
|
||||
{((trustDistribution.trusted / totalContributors) * 100).toFixed(1)}% of community
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Unverified Users */}
|
||||
<Card className="bg-black/90 border-2 border-gray-500/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-black uppercase text-gray-400 flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
Unverified
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-5xl font-black text-white">{trustDistribution.unverified}</span>
|
||||
<span className="text-gray-400 text-sm">contributors</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">
|
||||
Unknown or no trust signals (trust <60)
|
||||
</p>
|
||||
<Badge variant="outline" className="border-gray-500 text-gray-400">
|
||||
{((trustDistribution.unverified / totalContributors) * 100).toFixed(1)}% of community
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Content Breakdown */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-3xl font-black uppercase text-[#ccff00] mb-6" style={{
|
||||
fontFamily: 'Impact, "Arial Black", sans-serif'
|
||||
}}>
|
||||
Content Breakdown
|
||||
</h2>
|
||||
|
||||
<div className="grid md:grid-cols-4 gap-4">
|
||||
<Card className="bg-black/90 border-2 border-[#ff0040]/30">
|
||||
<CardContent className="pt-6">
|
||||
<MapPin className="h-8 w-8 text-[#ff0040] mb-3" />
|
||||
<p className="text-3xl font-black text-white mb-1">{spots?.length || 0}</p>
|
||||
<p className="text-sm text-gray-400 uppercase font-bold">Skate Spots</p>
|
||||
<div className="mt-3 space-y-1">
|
||||
<div className="text-xs text-gray-500">
|
||||
✓ {contentByTrust.verified} from verified users
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
🛡️ {contentByTrust.trusted} from trusted users
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-black/90 border-2 border-[#ccff00]/30">
|
||||
<CardContent className="pt-6">
|
||||
<Calendar className="h-8 w-8 text-[#ccff00] mb-3" />
|
||||
<p className="text-3xl font-black text-white mb-1">{events?.length || 0}</p>
|
||||
<p className="text-sm text-gray-400 uppercase font-bold">Events</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-black/90 border-2 border-[#ff6600]/30">
|
||||
<CardContent className="pt-6">
|
||||
<GraduationCap className="h-8 w-8 text-[#ff6600] mb-3" />
|
||||
<p className="text-3xl font-black text-white mb-1">{profiles?.length || 0}</p>
|
||||
<p className="text-sm text-gray-400 uppercase font-bold">Coaches/Filmers</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-black/90 border-2 border-green-500/30">
|
||||
<CardContent className="pt-6">
|
||||
<Store className="h-8 w-8 text-green-500 mb-3" />
|
||||
<p className="text-3xl font-black text-white mb-1">{shops?.length || 0}</p>
|
||||
<p className="text-sm text-gray-400 uppercase font-bold">Skate Shops</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* About Trust Scores */}
|
||||
<Card className="mt-8 bg-gradient-to-br from-[#ff0040]/10 to-[#ccff00]/10 border-2 border-[#ccff00]/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-black uppercase text-[#ccff00] flex items-center gap-2">
|
||||
<Shield className="h-6 w-6" />
|
||||
About Trust Scores
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-300">
|
||||
Trust scores help identify reliable contributors in the skateboarding community using a
|
||||
<strong className="text-[#ccff00]"> Web of Trust</strong> algorithm.
|
||||
</p>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<div className="p-4 rounded-lg bg-green-500/10 border border-green-500/30">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||
<span className="font-bold text-green-500 uppercase text-sm">Verified (90-100)</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
Users you follow directly or your own content
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Shield className="h-5 w-5 text-yellow-500" />
|
||||
<span className="font-bold text-yellow-500 uppercase text-sm">Trusted (60-89)</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
Friends-of-friends or NIP-05 verified users
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-gray-500/10 border border-gray-500/30">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertCircle className="h-5 w-5 text-gray-400" />
|
||||
<span className="font-bold text-gray-400 uppercase text-sm">Unverified (0-59)</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
Unknown users with no trust signals
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-400 italic">
|
||||
Trust scores are calculated client-side using your follow list. Scores are personalized -
|
||||
different users see different trust levels for the same contributor.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue