feat(stats): honesty banner, marketplace + regions, mobile Lab collapse
Three improvements to /stats. 1. A relay outage must not read as real zeros. Every sub-query swallowed its error, so a failed relay rendered a full board of 0s. The queryFn now counts failures and returns 'partial'; the page shows 'Couldn't reach every relay — these numbers may be incomplete'. Badge also gains 'updated Nm ago' + Refresh. 2. Surface more of the community: Marketplace (live listings, completed trades, self-reported sats, cash/barter split) and Regions (top countries/cities from each spot's own tags). Zap receipts are counted ONLY for the top contributors and labelled as not-global-volume, because global zap volume isn't measurable on a public relay. 3. Mobile/desktop: collapse the 13 Lab cards on mobile behind 'Show all' (a display:contents wrapper keeps them grid items); widen max-w-6xl -> max-w-7xl; StatCard gains a compact prop so big sats don't overflow. Docs: AGENTS.md community-stats note.
This commit is contained in:
parent
c1bf6516f5
commit
d32df04c79
3 changed files with 276 additions and 16 deletions
21
AGENTS.md
21
AGENTS.md
|
|
@ -808,6 +808,27 @@ session with **no other memory of this project**. So:
|
||||||
with the QR; the `bunker://` paste stays as the fallback. ⚠️ **NOT verified
|
with the QR; the `bunker://` paste stays as the fallback. ⚠️ **NOT verified
|
||||||
against a real signer — needs an on-device check with Amber** (scan → pair →
|
against a real signer — needs an on-device check with Amber** (scan → pair →
|
||||||
signed in); only the URI shape was verified in Node.
|
signed in); only the URI shape was verified in Node.
|
||||||
|
- **Community stats (`/stats`, 2026-10-01):** `useGrowthStats` + `CommunityStatsPage`.
|
||||||
|
- ⚠️ **A relay outage used to render a board of ZEROS as truth** — every
|
||||||
|
sub-query ended in `.catch(() => [])`. The queryFn now counts failures (a
|
||||||
|
tracked `querySkateContent` wrapper) and returns **`partial: boolean`**; the
|
||||||
|
page shows *"Couldn't reach every relay — these numbers may be incomplete"*.
|
||||||
|
Don't drop this: zeros must never be presented as real numbers.
|
||||||
|
- New data: **marketplace** — live listings (`30402`, `#t:['skateboarding']`,
|
||||||
|
`showcase=true` excluded), completed trades (`31871` from
|
||||||
|
`useTradeAttestations`, `s=revoked` excluded), `tradedSats` summed from the
|
||||||
|
self-reported `amount` tag, `tradesByMethod`; **regions** —
|
||||||
|
`topCountries`/`topCities` built from each spot's own `city`/`country` tags
|
||||||
|
(spots without one aren't counted); and a **bounded zap signal** —
|
||||||
|
`zapCount`/`zapSats` from kind-9735 receipts `#p` the top ~25 contributors
|
||||||
|
(amounts decoded with `getSatoshisFromBolt11`). ⚠️ **Global zap volume is NOT
|
||||||
|
measurable on a public relay**, so the code and copy say "not global volume"
|
||||||
|
— never relabel it as the community total.
|
||||||
|
- UX: the badge shows **"updated Nm ago" + Refresh** (`dataUpdatedAt`/`refetch`);
|
||||||
|
the **13 Lab cards collapse on mobile** (a `display:contents` wrapper toggled
|
||||||
|
`hidden lg:contents`, so children stay real grid items) with a "Show all 13
|
||||||
|
crafts" toggle; container widened `max-w-6xl` → `max-w-7xl`; `StatCard` gained
|
||||||
|
a `compact` prop (`+compactNumber`) so big sats don't overflow `text-4xl`.
|
||||||
- **NIP-05 names — `you@skate-spots.org` (2026-09-28):** SkateSpots hands out
|
- **NIP-05 names — `you@skate-spots.org` (2026-09-28):** SkateSpots hands out
|
||||||
free handles so a new skater has a human name from minute one. The registry is
|
free handles so a new skater has a human name from minute one. The registry is
|
||||||
the Netlify Function **`netlify/functions/nip05.mjs`**
|
the Netlify Function **`netlify/functions/nip05.mjs`**
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,10 @@ import {
|
||||||
SKATE_BARBER_KIND,
|
SKATE_BARBER_KIND,
|
||||||
SKATE_TOUR_KIND,
|
SKATE_TOUR_KIND,
|
||||||
SKATE_CREW_KIND,
|
SKATE_CREW_KIND,
|
||||||
|
MARKETPLACE_ITEM_KIND,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
|
import { TRADE_ATTESTATION_KIND } from '@/hooks/useTradeAttestations';
|
||||||
|
import { getSatoshisFromBolt11 } from '@/lib/bolt11';
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -87,6 +90,32 @@ export interface GrowthStats {
|
||||||
|
|
||||||
// ── Leaderboard ─────────────────────────────────────────────────────────
|
// ── Leaderboard ─────────────────────────────────────────────────────────
|
||||||
topMappers: TopMapper[];
|
topMappers: TopMapper[];
|
||||||
|
|
||||||
|
// ── Marketplace ──────────────────────────────────────────────────────────
|
||||||
|
/** Live NIP-99 listings (stash "showcase-only" items excluded). */
|
||||||
|
totalListings: number;
|
||||||
|
/** Completed trades (sale/purchase attestations; revoked excluded). */
|
||||||
|
totalTrades: number;
|
||||||
|
/** Sats recorded across those attestations (self-reported). */
|
||||||
|
tradedSats: number;
|
||||||
|
/** Trades by self-reported method (cash / lightning / trade / other). */
|
||||||
|
tradesByMethod: { method: string; count: number }[];
|
||||||
|
|
||||||
|
// ── Zaps (bounded — NOT global volume) ───────────────────────────────────
|
||||||
|
/** Zap receipts addressed to the top contributors. */
|
||||||
|
zapCount: number;
|
||||||
|
/** Sats across those receipts. */
|
||||||
|
zapSats: number;
|
||||||
|
|
||||||
|
// ── Regions (from each spot's own `country`/`city` tags) ─────────────────
|
||||||
|
topCountries: { name: string; spots: number }[];
|
||||||
|
topCities: { name: string; spots: number }[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when one or more relay sub-queries failed. The page must say the
|
||||||
|
* numbers may be incomplete — never present an outage as real zeros.
|
||||||
|
*/
|
||||||
|
partial: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -127,13 +156,21 @@ export function useGrowthStats() {
|
||||||
// so the numbers always match the authoritative community dataset — not a
|
// so the numbers always match the authoritative community dataset — not a
|
||||||
// blend of the user's personal relays that would drift per-device.
|
// blend of the user's personal relays that would drift per-device.
|
||||||
const { queryCanonical } = useSkateRelays();
|
const { queryCanonical } = useSkateRelays();
|
||||||
const querySkateContent = queryCanonical;
|
|
||||||
|
|
||||||
return useQuery<GrowthStats>({
|
return useQuery<GrowthStats>({
|
||||||
queryKey: ['growth-stats', 'canonical'],
|
queryKey: ['growth-stats', 'canonical'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const signal = AbortSignal.timeout(15000);
|
const signal = AbortSignal.timeout(15000);
|
||||||
|
|
||||||
|
// Every sub-query below swallows its own error so one dead request can't
|
||||||
|
// blank the page — but we COUNT the failures so the UI can say the numbers
|
||||||
|
// may be incomplete instead of presenting an outage as a board of zeros.
|
||||||
|
let failedQueries = 0;
|
||||||
|
const querySkateContent = (
|
||||||
|
filters: Parameters<typeof queryCanonical>[0],
|
||||||
|
opts: Parameters<typeof queryCanonical>[1],
|
||||||
|
) => queryCanonical(filters, opts).catch((err) => { failedQueries++; throw err; });
|
||||||
|
|
||||||
// Query all content types in parallel from the community relay.
|
// Query all content types in parallel from the community relay.
|
||||||
// High limits to get full history — this is a stats page, not a feed.
|
// High limits to get full history — this is a stats page, not a feed.
|
||||||
const [
|
const [
|
||||||
|
|
@ -155,6 +192,8 @@ export function useGrowthStats() {
|
||||||
judgeEvents,
|
judgeEvents,
|
||||||
organizerEvents,
|
organizerEvents,
|
||||||
barberEvents,
|
barberEvents,
|
||||||
|
listingEvents,
|
||||||
|
tradeEvents,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
querySkateContent([{ kinds: [SKATE_SPOT_KIND], '#t': ['skatespot'], limit: 2000 }], { signal }).catch(() => []),
|
querySkateContent([{ kinds: [SKATE_SPOT_KIND], '#t': ['skatespot'], limit: 2000 }], { signal }).catch(() => []),
|
||||||
querySkateContent([{ kinds: [SKATE_SHOP_KIND], limit: 2000 }], { signal }).catch(() => []),
|
querySkateContent([{ kinds: [SKATE_SHOP_KIND], limit: 2000 }], { signal }).catch(() => []),
|
||||||
|
|
@ -176,6 +215,9 @@ export function useGrowthStats() {
|
||||||
querySkateContent([{ kinds: [SKATE_JUDGE_KIND], limit: 500 }], { signal }).catch(() => []),
|
querySkateContent([{ kinds: [SKATE_JUDGE_KIND], limit: 500 }], { signal }).catch(() => []),
|
||||||
querySkateContent([{ kinds: [SKATE_ORGANIZER_KIND], limit: 500 }], { signal }).catch(() => []),
|
querySkateContent([{ kinds: [SKATE_ORGANIZER_KIND], limit: 500 }], { signal }).catch(() => []),
|
||||||
querySkateContent([{ kinds: [SKATE_BARBER_KIND], limit: 500 }], { signal }).catch(() => []),
|
querySkateContent([{ kinds: [SKATE_BARBER_KIND], limit: 500 }], { signal }).catch(() => []),
|
||||||
|
// Marketplace: live listings + completed-trade attestations.
|
||||||
|
querySkateContent([{ kinds: [MARKETPLACE_ITEM_KIND], '#t': ['skateboarding'], limit: 2000 }], { signal }).catch(() => []),
|
||||||
|
querySkateContent([{ kinds: [TRADE_ATTESTATION_KIND], limit: 1000 }], { signal }).catch(() => []),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Deduplicate addressable events (keep newest per pubkey+kind+d)
|
// Deduplicate addressable events (keep newest per pubkey+kind+d)
|
||||||
|
|
@ -211,6 +253,40 @@ export function useGrowthStats() {
|
||||||
// Aggregate of the "other" crafts for active-mapper / total rollups.
|
// Aggregate of the "other" crafts for active-mapper / total rollups.
|
||||||
const otherLab = [...artists, ...graffiti, ...tattoo, ...trainers, ...musicians, ...judges, ...organizers, ...barbers];
|
const otherLab = [...artists, ...graffiti, ...tattoo, ...trainers, ...musicians, ...judges, ...organizers, ...barbers];
|
||||||
|
|
||||||
|
// ── Marketplace ──────────────────────────────────────────────────────
|
||||||
|
// Showcase-only stash items are public but NOT for sale, so exclude them.
|
||||||
|
const listings = dedup(listingEvents).filter(
|
||||||
|
(e) => !e.tags.some((t) => t[0] === 'showcase' && t[1] === 'true'),
|
||||||
|
);
|
||||||
|
const trades = dedup(tradeEvents).filter(
|
||||||
|
(e) => e.tags.find((t) => t[0] === 's')?.[1] !== 'revoked',
|
||||||
|
);
|
||||||
|
let tradedSats = 0;
|
||||||
|
const methodCounts = new Map<string, number>();
|
||||||
|
for (const t of trades) {
|
||||||
|
const amount = Number(t.tags.find((x) => x[0] === 'amount')?.[1] ?? 0);
|
||||||
|
if (Number.isFinite(amount) && amount > 0) tradedSats += amount;
|
||||||
|
const method = t.tags.find((x) => x[0] === 'method')?.[1] ?? 'other';
|
||||||
|
methodCounts.set(method, (methodCounts.get(method) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const tradesByMethod = Array.from(methodCounts.entries())
|
||||||
|
.map(([method, count]) => ({ method, count }))
|
||||||
|
.sort((a, b) => b.count - a.count);
|
||||||
|
|
||||||
|
// ── Regions — from each spot's own country/city tags ─────────────────
|
||||||
|
const regionCounts = (tagName: string) => {
|
||||||
|
const m = new Map<string, number>();
|
||||||
|
for (const s of spots) {
|
||||||
|
const v = s.tags.find((t) => t[0] === tagName && t[1])?.[1]?.trim();
|
||||||
|
if (v) m.set(v, (m.get(v) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return Array.from(m.entries())
|
||||||
|
.map(([name, count]) => ({ name, spots: count }))
|
||||||
|
.sort((a, b) => b.spots - a.spots);
|
||||||
|
};
|
||||||
|
const topCountries = regionCounts('country').slice(0, 6);
|
||||||
|
const topCities = regionCounts('city').slice(0, 6);
|
||||||
|
|
||||||
// ── Monthly bucketing ───────────────────────────────────────────────
|
// ── Monthly bucketing ───────────────────────────────────────────────
|
||||||
|
|
||||||
const bucketMap = new Map<string, {
|
const bucketMap = new Map<string, {
|
||||||
|
|
@ -315,6 +391,29 @@ export function useGrowthStats() {
|
||||||
const trustedSpots = spots.filter(e => isTrustedPubkey(e.pubkey)).length;
|
const trustedSpots = spots.filter(e => isTrustedPubkey(e.pubkey)).length;
|
||||||
const trustedMappers = Array.from(allPubkeys).filter(isTrustedPubkey).length;
|
const trustedMappers = Array.from(allPubkeys).filter(isTrustedPubkey).length;
|
||||||
|
|
||||||
|
// ── Zaps (bounded) ───────────────────────────────────────────────────
|
||||||
|
// Global zap volume is NOT reliably measurable on a public relay, so we
|
||||||
|
// deliberately count only receipts addressed to known contributors and
|
||||||
|
// label them that way — never implying this is the community's total.
|
||||||
|
const topPubkeys = Array.from(mapperTotal.entries())
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 25)
|
||||||
|
.map(([pk]) => pk);
|
||||||
|
let zapCount = 0;
|
||||||
|
let zapSats = 0;
|
||||||
|
if (topPubkeys.length > 0) {
|
||||||
|
const zapReceipts = await querySkateContent(
|
||||||
|
[{ kinds: [9735], '#p': topPubkeys, limit: 500 }],
|
||||||
|
{ signal },
|
||||||
|
).catch(() => []);
|
||||||
|
zapCount = zapReceipts.length;
|
||||||
|
for (const z of zapReceipts) {
|
||||||
|
const invoice = z.tags.find((t) => t[0] === 'bolt11')?.[1];
|
||||||
|
if (!invoice) continue;
|
||||||
|
try { zapSats += getSatoshisFromBolt11(invoice); } catch { /* not decodable */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalSpots: spots.length,
|
totalSpots: spots.length,
|
||||||
totalShops: shops.length,
|
totalShops: shops.length,
|
||||||
|
|
@ -340,6 +439,15 @@ export function useGrowthStats() {
|
||||||
monthly,
|
monthly,
|
||||||
cumulativeSpots,
|
cumulativeSpots,
|
||||||
topMappers,
|
topMappers,
|
||||||
|
totalListings: listings.length,
|
||||||
|
totalTrades: trades.length,
|
||||||
|
tradedSats,
|
||||||
|
tradesByMethod,
|
||||||
|
zapCount,
|
||||||
|
zapSats,
|
||||||
|
topCountries,
|
||||||
|
topCities,
|
||||||
|
partial: failedQueries > 0,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
staleTime: 15 * 60 * 1000, // 15 min — stats don't need to be live
|
staleTime: 15 * 60 * 1000, // 15 min — stats don't need to be live
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useMemo } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useSeoMeta } from '@unhead/react';
|
import { useSeoMeta } from '@unhead/react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
@ -8,11 +8,12 @@ import { genUserName } from '@/lib/genUserName';
|
||||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { useGrowthStats } from '@/hooks/useGrowthStats';
|
import { useGrowthStats } from '@/hooks/useGrowthStats';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { formatAge } from '@/lib/routeCache';
|
||||||
|
|
||||||
const ANGOR_PROJECT_URL = 'https://angor.io/project/angor1qtunna00uqyx52rr0nvqa059z2gknhrmn7urd6z';
|
const ANGOR_PROJECT_URL = 'https://angor.io/project/angor1qtunna00uqyx52rr0nvqa059z2gknhrmn7urd6z';
|
||||||
import { SKATE_RELAY } from '@/lib/types';
|
import { SKATE_RELAY } from '@/lib/types';
|
||||||
import { AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend } from 'recharts';
|
import { AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend } from 'recharts';
|
||||||
import { MapPin, Store, Calendar, GraduationCap, Video, Scissors, Route, Users, Users2, Camera, Hammer, TrendingUp, Radio, Zap, ExternalLink, Trophy, FlaskConical, Palette, SprayCan, PenTool, Dumbbell, Music, Gavel, Tent, Sparkles } from 'lucide-react';
|
import { MapPin, Store, Calendar, GraduationCap, Video, Scissors, Route, Users, Users2, Camera, Hammer, TrendingUp, Radio, Zap, ExternalLink, Trophy, FlaskConical, Palette, SprayCan, PenTool, Dumbbell, Music, Gavel, Tent, Sparkles, ShoppingBag, Tag, Handshake, Globe, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -41,6 +42,11 @@ const CHART_TOOLTIP_STYLE = {
|
||||||
|
|
||||||
// ─── Hero stat card ──────────────────────────────────────────────────────────
|
// ─── Hero stat card ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Compact number for large values (1234567 → "1.2M"); exact value in the title. */
|
||||||
|
function compactNumber(n: number): string {
|
||||||
|
return new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }).format(n);
|
||||||
|
}
|
||||||
|
|
||||||
function StatCard({
|
function StatCard({
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
value,
|
value,
|
||||||
|
|
@ -51,6 +57,7 @@ function StatCard({
|
||||||
loading,
|
loading,
|
||||||
thisMonthDelta,
|
thisMonthDelta,
|
||||||
to,
|
to,
|
||||||
|
compact,
|
||||||
}: {
|
}: {
|
||||||
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
|
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
|
||||||
value: number;
|
value: number;
|
||||||
|
|
@ -64,6 +71,8 @@ function StatCard({
|
||||||
thisMonthDelta?: number;
|
thisMonthDelta?: number;
|
||||||
/** If set, the whole card becomes a link to this route (view all items). */
|
/** If set, the whole card becomes a link to this route (view all items). */
|
||||||
to?: string;
|
to?: string;
|
||||||
|
/** Abbreviate big numbers (e.g. sats) so they don't overflow the card. */
|
||||||
|
compact?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const clickable = !!to && !loading && value > 0;
|
const clickable = !!to && !loading && value > 0;
|
||||||
const card = (
|
const card = (
|
||||||
|
|
@ -82,7 +91,9 @@ function StatCard({
|
||||||
<Skeleton className="h-10 w-20 mb-1" />
|
<Skeleton className="h-10 w-20 mb-1" />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-end gap-2 flex-wrap">
|
<div className="flex items-end gap-2 flex-wrap">
|
||||||
<p className="text-4xl font-black leading-none" style={{ color }}>{value.toLocaleString()}</p>
|
<p className="text-4xl font-black leading-none" style={{ color }} title={compact ? value.toLocaleString() : undefined}>
|
||||||
|
{compact ? compactNumber(value) : value.toLocaleString()}
|
||||||
|
</p>
|
||||||
{typeof thisMonthDelta === 'number' && thisMonthDelta > 0 && (
|
{typeof thisMonthDelta === 'number' && thisMonthDelta > 0 && (
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center gap-0.5 text-[10px] font-black uppercase tracking-wide px-1.5 py-0.5 rounded-full mb-0.5"
|
className="inline-flex items-center gap-0.5 text-[10px] font-black uppercase tracking-wide px-1.5 py-0.5 rounded-full mb-0.5"
|
||||||
|
|
@ -166,7 +177,10 @@ export default function CommunityStatsPage() {
|
||||||
description: 'Live community metrics for SkateSpots — spots, events, coaches, filmers, editors, tours and growth over time. Pulled directly from Nostr.',
|
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: stats, isLoading, isError, isFetching, refetch, dataUpdatedAt } = useGrowthStats();
|
||||||
|
// Collapse the 13 Lab craft cards on mobile (they were ~7 rows of scrolling
|
||||||
|
// before the first chart); always expanded from lg up.
|
||||||
|
const [showAllLab, setShowAllLab] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
// Content mix for donut chart
|
// Content mix for donut chart
|
||||||
|
|
@ -194,7 +208,7 @@ export default function CommunityStatsPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="container max-w-6xl px-4 py-10 md:py-14">
|
<div className="container max-w-7xl px-4 py-10 md:py-14">
|
||||||
|
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<div className="text-center mb-10">
|
<div className="text-center mb-10">
|
||||||
|
|
@ -208,13 +222,32 @@ export default function CommunityStatsPage() {
|
||||||
<p className="text-gray-400 max-w-xl mx-auto text-sm md:text-base mb-5">
|
<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.
|
Live metrics pulled directly from the Nostr network. Transparent, decentralized, verifiable by anyone.
|
||||||
</p>
|
</p>
|
||||||
{/* Data source badge */}
|
{/* Data source badge + freshness + manual refresh */}
|
||||||
<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">
|
<div className="inline-flex flex-wrap items-center justify-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]" />
|
<Radio className="h-3 w-3 text-[#ccff00]" />
|
||||||
Data source:
|
Data source:
|
||||||
<span className="text-[#ccff00] font-mono">{SKATE_RELAY.replace('wss://', '')}</span>
|
<span className="text-[#ccff00] font-mono">{SKATE_RELAY.replace('wss://', '')}</span>
|
||||||
· cached 15 min
|
{dataUpdatedAt > 0 && (
|
||||||
|
<><span aria-hidden>·</span><span>updated {formatAge(Date.now() - dataUpdatedAt)}</span></>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={isFetching}
|
||||||
|
className="ml-1 inline-flex items-center gap-1 rounded-full border border-white/10 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-gray-400 transition-colors hover:text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('h-3 w-3', isFetching && 'animate-spin')} /> Refresh
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Honesty banner: a relay outage must not read as real zeros. */}
|
||||||
|
{stats?.partial && (
|
||||||
|
<div className="mt-5 rounded-2xl border border-amber-500/40 bg-amber-500/10 px-4 py-3">
|
||||||
|
<p className="text-xs font-bold text-amber-300">
|
||||||
|
Couldn't reach every relay — these numbers may be <span className="uppercase">incomplete</span>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Error state ── */}
|
{/* ── Error state ── */}
|
||||||
|
|
@ -256,14 +289,26 @@ export default function CommunityStatsPage() {
|
||||||
<StatCard icon={Camera} value={stats?.totalPhotographers ?? 0} label="Photographers" color="#38bdf8" loading={isLoading} thisMonthDelta={thisMonth?.photographers} to="/lab?craft=photography" />
|
<StatCard icon={Camera} value={stats?.totalPhotographers ?? 0} label="Photographers" color="#38bdf8" loading={isLoading} thisMonthDelta={thisMonth?.photographers} to="/lab?craft=photography" />
|
||||||
<StatCard icon={Hammer} value={stats?.totalBuilders ?? 0} label="Builders" color="#fb923c" loading={isLoading} thisMonthDelta={thisMonth?.builders} to="/lab?craft=building" />
|
<StatCard icon={Hammer} value={stats?.totalBuilders ?? 0} label="Builders" color="#fb923c" loading={isLoading} thisMonthDelta={thisMonth?.builders} to="/lab?craft=building" />
|
||||||
<StatCard icon={Palette} value={stats?.totalArtists ?? 0} label="Artists" color="#f472b6" loading={isLoading} to="/lab?craft=art" />
|
<StatCard icon={Palette} value={stats?.totalArtists ?? 0} label="Artists" color="#f472b6" loading={isLoading} to="/lab?craft=art" />
|
||||||
<StatCard icon={SprayCan} value={stats?.totalGraffiti ?? 0} label="Graffiti" color="#a3e635" loading={isLoading} to="/lab?craft=graffiti" />
|
{/* Extra crafts: collapsed on mobile (they were ~7 rows of scroll
|
||||||
<StatCard icon={PenTool} value={stats?.totalTattoo ?? 0} label="Tattoo" color="#f87171" loading={isLoading} to="/lab?craft=tattoo" />
|
before the first chart), always visible from `lg` up. The
|
||||||
<StatCard icon={Dumbbell} value={stats?.totalTrainers ?? 0} label="Trainers" color="#34d399" loading={isLoading} to="/lab?craft=training" />
|
`contents` wrapper keeps its children as real grid items. */}
|
||||||
<StatCard icon={Music} value={stats?.totalMusicians ?? 0} label="Musicians" color="#818cf8" loading={isLoading} to="/lab?craft=music" />
|
<div className={cn(showAllLab ? 'contents' : 'hidden lg:contents')}>
|
||||||
<StatCard icon={Gavel} value={stats?.totalJudges ?? 0} label="Judges" color="#fbbf24" loading={isLoading} to="/lab?craft=judging" />
|
<StatCard icon={SprayCan} value={stats?.totalGraffiti ?? 0} label="Graffiti" color="#a3e635" loading={isLoading} to="/lab?craft=graffiti" />
|
||||||
<StatCard icon={Tent} value={stats?.totalOrganizers ?? 0} label="Organizers" color="#2dd4bf" loading={isLoading} to="/lab?craft=organizing" />
|
<StatCard icon={PenTool} value={stats?.totalTattoo ?? 0} label="Tattoo" color="#f87171" loading={isLoading} to="/lab?craft=tattoo" />
|
||||||
<StatCard icon={Sparkles} value={stats?.totalBarbers ?? 0} label="Barbers" color="#22d3ee" loading={isLoading} to="/lab?craft=barbering" />
|
<StatCard icon={Dumbbell} value={stats?.totalTrainers ?? 0} label="Trainers" color="#34d399" loading={isLoading} to="/lab?craft=training" />
|
||||||
|
<StatCard icon={Music} value={stats?.totalMusicians ?? 0} label="Musicians" color="#818cf8" loading={isLoading} to="/lab?craft=music" />
|
||||||
|
<StatCard icon={Gavel} value={stats?.totalJudges ?? 0} label="Judges" color="#fbbf24" loading={isLoading} to="/lab?craft=judging" />
|
||||||
|
<StatCard icon={Tent} value={stats?.totalOrganizers ?? 0} label="Organizers" color="#2dd4bf" loading={isLoading} to="/lab?craft=organizing" />
|
||||||
|
<StatCard icon={Sparkles} value={stats?.totalBarbers ?? 0} label="Barbers" color="#22d3ee" loading={isLoading} to="/lab?craft=barbering" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAllLab((v) => !v)}
|
||||||
|
className="mt-3 w-full rounded-full border border-white/10 py-2 text-[11px] font-bold uppercase tracking-wide text-gray-400 transition-colors hover:text-white lg:hidden"
|
||||||
|
>
|
||||||
|
{showAllLab ? 'Show fewer' : 'Show all 13 crafts'}
|
||||||
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Section 1c: Community — tours & crews ── */}
|
{/* ── Section 1c: Community — tours & crews ── */}
|
||||||
|
|
@ -277,6 +322,92 @@ export default function CommunityStatsPage() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* ── Section 1d: Marketplace ── */}
|
||||||
|
<section className="mb-12">
|
||||||
|
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
|
||||||
|
<ShoppingBag className="h-3.5 w-3.5 text-[#00cc66]" /> Marketplace
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
<StatCard icon={Tag} value={stats?.totalListings ?? 0} label="Live Listings" color="#00cc66" loading={isLoading} to="/marketplace" />
|
||||||
|
<StatCard icon={Handshake} value={stats?.totalTrades ?? 0} label="Trades Recorded" color="#22c55e" loading={isLoading} to="/trades" />
|
||||||
|
<StatCard icon={Zap} value={stats?.tradedSats ?? 0} label="Sats Traded" color="#facc15" loading={isLoading} compact />
|
||||||
|
<StatCard icon={Zap} value={stats?.zapCount ?? 0} label="Zaps to Mappers" color="#f472b6" loading={isLoading} />
|
||||||
|
</div>
|
||||||
|
{stats && stats.tradesByMethod.length > 0 && (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{stats.tradesByMethod.map((m) => (
|
||||||
|
<span
|
||||||
|
key={m.method}
|
||||||
|
className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide text-gray-300"
|
||||||
|
>
|
||||||
|
{m.method} · {m.count}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="mt-3 text-[11px] leading-relaxed text-gray-600">
|
||||||
|
Sats traded are self-reported in trade records (the app can't observe an off-app payment).
|
||||||
|
"Zaps to mappers" counts only receipts addressed to our top contributors — it is
|
||||||
|
<strong className="text-gray-500"> not </strong> global zap volume.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Section 1e: Regions ── */}
|
||||||
|
{stats && (stats.topCountries.length > 0 || stats.topCities.length > 0) && (
|
||||||
|
<section className="mb-12">
|
||||||
|
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
|
||||||
|
<Globe className="h-3.5 w-3.5 text-[#4ade80]" /> Where Skaters Are
|
||||||
|
</h2>
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
{stats.topCountries.length > 0 && (
|
||||||
|
<Card className="border-white/8">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-black uppercase tracking-wide text-[#4ade80]">Top Countries</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{stats.topCountries.map((c) => (
|
||||||
|
<div key={c.name} className="flex items-center gap-3">
|
||||||
|
<span className="w-24 truncate text-xs text-gray-300" title={c.name}>{c.name}</span>
|
||||||
|
<div className="h-2 flex-1 overflow-hidden rounded-full bg-white/5">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-[#4ade80]"
|
||||||
|
style={{ width: `${Math.max(4, (c.spots / stats.topCountries[0].spots) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="w-8 shrink-0 text-right text-xs font-bold tabular-nums text-white">{c.spots}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
{stats.topCities.length > 0 && (
|
||||||
|
<Card className="border-white/8">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-black uppercase tracking-wide text-[#38bdf8]">Top Cities</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{stats.topCities.map((c) => (
|
||||||
|
<div key={c.name} className="flex items-center gap-3">
|
||||||
|
<span className="w-24 truncate text-xs text-gray-300" title={c.name}>{c.name}</span>
|
||||||
|
<div className="h-2 flex-1 overflow-hidden rounded-full bg-white/5">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-[#38bdf8]"
|
||||||
|
style={{ width: `${Math.max(4, (c.spots / stats.topCities[0].spots) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="w-8 shrink-0 text-right text-xs font-bold tabular-nums text-white">{c.spots}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-[11px] text-gray-600">
|
||||||
|
Derived from each spot's own city/country tag — spots without one aren't counted.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Section 2: Growth charts ── */}
|
{/* ── Section 2: Growth charts ── */}
|
||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
|
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue