diff --git a/src/hooks/useGrowthStats.ts b/src/hooks/useGrowthStats.ts new file mode 100644 index 0000000..1f19f15 --- /dev/null +++ b/src/hooks/useGrowthStats.ts @@ -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({ + 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(); + 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; shops: Set; events: Set; + coaches: Set; filmers: Set; editors: Set; + tours: Set; pubkeys: Set; + }>(); + + 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(); + const mapperTotal = new Map(); + + 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({ + 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, + }); +} diff --git a/src/pages/CommunityStatsPage.tsx b/src/pages/CommunityStatsPage.tsx index 9d32a52..b6b15e5 100644 --- a/src/pages/CommunityStatsPage.tsx +++ b/src/pages/CommunityStatsPage.tsx @@ -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 ( + + +
+
+

{label}

+ {loading ? ( + + ) : ( +

{value.toLocaleString()}

+ )} + {sub &&

{sub}

} +
+
+ +
+
+
+
+ ); +} + +// ─── 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 = { 1: '#f7931a', 2: '#c0c0c0', 3: '#cd7f32' }; + + return ( +
+ + {rank <= 3 ? ['🥇','🥈','🥉'][rank - 1] : rank} + + + + + {name.slice(0, 2).toUpperCase()} + + + {name} + + {spotCount} {spotCount === 1 ? 'spot' : 'spots'} + +
+ ); +} + +// ─── Skeleton helpers ──────────────────────────────────────────────────────── + +function ChartSkeleton() { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ); +} + +// ─── 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(); - 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 ( - -
- -
- {Array.from({ length: 3 }).map((_, i) => ( - - - - - - ))} -
-
- {Array.from({ length: 2 }).map((_, i) => ( - - - - - - ))} -
-
-
- ); - } + // 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 ( -
- {/* Header */} -
-

- Community Stats +
+ + {/* ── Header ── */} +
+

+ + Community + +
+ Stats

-

- Trust analytics and community metrics for the SkateSpots network +

+ Live metrics pulled directly from the Nostr network. Transparent, decentralized, verifiable by anyone. +

+ {/* Data source badge */} +
+ + Data source:  + {SKATE_RELAY.replace('wss://', '')} +  ·  cached 15 min +
+
+ + {/* ── Error state ── */} + {isError && ( +
+ Could not reach the relay right now. Stats will load once the connection is restored. +
+ )} + + {/* ── Section 1: Hero numbers ── */} +
+

+ Community at a Glance +

+
+ + + + +
+
+ + + + +
+
+ + {/* ── Section 2: Growth charts ── */} +
+

+ Growth Over Time +

+ +
+ + {/* Cumulative spots — area chart */} + + + + Spots Mapped (Cumulative) + +

Total spots added to the map over time

+
+ + {isLoading ? : ( + + + + + + + + + + + + + + + + )} + +
+ + {/* Monthly new content — stacked bar */} + + + + Monthly Activity + +

New content published each month

+
+ + {isLoading ? : ( + + + + + + + + + + + + + + )} + +
+ + {/* Active mappers per month — bar */} + + + + Active Mappers / Month + +

Unique contributors publishing each month

+
+ + {isLoading ? : ( + + + + + + + + {recentMonthly.map((_, i) => ( + + ))} + + + + )} + +
+ + {/* Content mix — donut */} + + + + Content Mix + +

Breakdown by content type (all time)

+
+ + {isLoading ? ( +
+ +
+ ) : contentMix.length > 0 ? ( + + + + {contentMix.map((entry, i) => ( + + ))} + + + + + + ) : ( +

No data yet

+ )} +
+
+ +
+
+ + {/* ── Section 3: Top mappers ── */} +
+

+ Top Mappers + — most spots added +

+ +
+ {isLoading ? ( + <> + + + {Array.from({ length: 5 }).map((_, i) => ( +
+ + + + +
+ ))} +
+
+ + + {Array.from({ length: 5 }).map((_, i) => ( +
+ + + + +
+ ))} +
+
+ + ) : stats && stats.topMappers.length > 0 ? ( + <> + + + {stats.topMappers.slice(0, 5).map((m, i) => ( + + ))} + + + + + {stats.topMappers.slice(5, 10).map((m, i) => ( + + ))} + + + + ) : ( +
+ No mapper data yet +
+ )} +
+
+ + {/* ── Section 4: Supporters ── */} +
+

+ Community Supporters +

+ +
+
+
+
+

Funded by the Community

+

+ Bitcoin Lightning supporters via Geyser Fund.
+ No ads, no investors, no data selling. +

+
+ + Support on Geyser + + + + View all supporters → + +
+
+
+ {geyser ? ( + <> +

+ {geyser.supporterCount} +

+

supporters

+ {geyser.totalSats > 0 && ( +

+ ⚡ {formatSats(geyser.totalSats)} sats raised +

+ )} + + ) : ( + + )} +
+
+
+
+ + {/* ── Footer / verifiability note ── */} +
+

+ All data is pulled live from{' '} + {SKATE_RELAY} + {' '}— a public Nostr relay.{' '} + Every event is signed by its creator and cryptographically verifiable.{' '} + No numbers can be faked.

- {/* Relay Info Notice */} -
- -
-

- Data source:{' '} - {relays.length === 1 ? ( - <>Querying {relays[0].replace('wss://', '')} - ) : ( - <>Querying {relays.length} relays - )} - {isLoggedIn && ( - <> - {' '}—{' '} - - Manage relays - - - )} -

- {!isLoggedIn && ( -

- Log in to customize which relays to query -

- )} -
-
- - {/* Key Metrics */} -
- {/* Total Contributors */} - - -
-
-

Total Contributors

-

{totalContributors}

-
- -
-
-
- - {verifiedPercentage}% verified -
-
-
-
- - {/* Total Content */} - - -
-
-

Total Content

-

{totalContent}

-
- -
-
-
- Spots: {spots?.length || 0} - Events: {events?.length || 0} -
-
- Coaches: {profiles?.length || 0} - Shops: {shops?.length || 0} -
-
-
-
- - {/* Trust Score */} - - -
-
-

Trusted Network

-

{trustedPercentage}%

-
- -
-
-
- - Contributors with trust ≥60 -
-
-
-
-
- - {/* Charts */} -
- {/* Trust Distribution Pie Chart */} - - - - - Trust Distribution - -

- Breakdown of contributors by trust level -

-
- - - - `${entry.name}: ${entry.value}`} - outerRadius={100} - fill="#8884d8" - dataKey="value" - > - {trustDistributionData.map((entry, index) => ( - - ))} - - - - - - -
- - {/* Content by Trust Bar Chart */} - - - - - Spots by Trust Level - -

- Number of skate spots created by each trust level -

-
- - - - - - - - - - - -
-
- - {/* Trust Level Breakdown */} -
- {/* Verified Users */} - - - - - Verified - - - -
-
- {trustDistribution.verified} - contributors -
-

- People you follow directly or your own content (trust ≥90) -

- - {verifiedPercentage}% of community - -
-
-
- - {/* Trusted Users */} - - - - - Trusted - - - -
-
- {trustDistribution.trusted} - contributors -
-

- Friends-of-friends or NIP-05 verified users (trust 60-89) -

- - {((trustDistribution.trusted / totalContributors) * 100).toFixed(1)}% of community - -
-
-
- - {/* Unverified Users */} - - - - - Unverified - - - -
-
- {trustDistribution.unverified} - contributors -
-

- Unknown or no trust signals (trust <60) -

- - {((trustDistribution.unverified / totalContributors) * 100).toFixed(1)}% of community - -
-
-
-
- - {/* Content Breakdown */} -
-

- Content Breakdown -

- -
- - - -

{spots?.length || 0}

-

Skate Spots

-
-
- ✓ {contentByTrust.verified} from verified users -
-
- 🛡️ {contentByTrust.trusted} from trusted users -
-
-
-
- - - - -

{events?.length || 0}

-

Events

-
-
- - - - -

{profiles?.length || 0}

-

Coaches/Filmers

-
-
- - - - -

{shops?.length || 0}

-

Skate Shops

-
-
-
-
- - {/* About Trust Scores */} - - - - - About Trust Scores - - - -
-

- Trust scores help identify reliable contributors in the skateboarding community using a - Web of Trust algorithm. -

- -
-
-
- - Verified (90-100) -
-

- Users you follow directly or your own content -

-
- -
-
- - Trusted (60-89) -
-

- Friends-of-friends or NIP-05 verified users -

-
- -
-
- - Unverified (0-59) -
-

- Unknown users with no trust signals -

-
-
- -

- Trust scores are calculated client-side using your follow list. Scores are personalized - - different users see different trust levels for the same contributor. -

-
-
-
);