From 6ae4e674dce9bd4565baae0e4fd614da25bfbd1b Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Sat, 26 Sep 2026 17:17:21 +0700 Subject: [PATCH] feat(leaderboards): zap leaderboards page (top spots + top supporters) New /leaderboards route (linked from the More menu): aggregates kind-9735 receipts into top-zapped spots and top supporters, with week/month/all-time tabs. Reuses the batched zap helpers. Also hardens the deploy smoke test to boot lazy routes (/, /leaderboards, /bounties), since route chunks can fail independently. --- scripts/smoke-test.mjs | 40 +++-- src/AppRouter.tsx | 3 + src/components/Layout.tsx | 6 + src/hooks/useZapLeaderboard.ts | 89 +++++++++++ src/hooks/useZapTotals.ts | 4 +- src/pages/ZapLeaderboardPage.tsx | 259 +++++++++++++++++++++++++++++++ 6 files changed, 385 insertions(+), 16 deletions(-) create mode 100644 src/hooks/useZapLeaderboard.ts create mode 100644 src/pages/ZapLeaderboardPage.tsx diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs index 4568c65..11c1fff 100755 --- a/scripts/smoke-test.mjs +++ b/scripts/smoke-test.mjs @@ -118,29 +118,41 @@ ws.on('message', (d) => { const send = (method, params = {}) => new Promise((res) => { const i = ++id; pend.set(i, res); ws.send(JSON.stringify({ id: i, method, params })); }); await new Promise((r) => ws.on('open', r)); await send('Runtime.enable'); +await send('Page.enable'); const evalJS = async (expr) => (await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }))?.result?.result?.value; -// ── wait for the app to mount ──────────────────────────────────────────────── -let mounted = false; let lastText = ''; -const deadline = Date.now() + timeoutMs; -while (Date.now() < deadline) { - lastText = (await evalJS('document.body ? document.body.innerText : ""')) || ''; - const kids = await evalJS('document.getElementById("root")?.children.length ?? 0'); - if (kids > 0) { mounted = true; break; } - if (/failed to start/i.test(lastText)) break; - await sleep(400); +// ── boot each route (lazy route chunks can fail independently) ────────────── +const ROUTES = ['/', '/leaderboards', '/bounties']; +const problems = []; +let lastText = ''; + +for (const route of ROUTES) { + const url = appUrl.replace(/\/$/, '') + route; + await send('Page.navigate', { url }); + + let mounted = false; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + lastText = (await evalJS('document.body ? document.body.innerText : ""')) || ''; + const kids = await evalJS('document.getElementById("root")?.children.length ?? 0'); + if (kids > 0) { mounted = true; break; } + if (/failed to start/i.test(lastText)) break; + await sleep(400); + } + + const crashed = /failed to start/i.test(lastText); + if (!mounted) problems.push(`${route}: #root never mounted`); + if (crashed) problems.push(`${route}: crash screen`); } ws.close(); -const crashed = /failed to start/i.test(lastText); -const ok = mounted && !crashed && exceptions.length === 0; +const ok = problems.length === 0 && exceptions.length === 0; if (ok) { - log('PASS — production bundle booted and mounted.'); + log(`PASS — production bundle booted and mounted (${ROUTES.join(', ')}).`); } else { console.error('[smoke-test] FAIL — the built bundle did not boot:'); - if (!mounted) console.error(' • #root never mounted'); - if (crashed) console.error(' • app rendered its "failed to start" crash screen'); + for (const p of problems) console.error(' • ' + p); for (const e of exceptions.slice(0, 4)) console.error(' • ' + e.split('\n')[0]); console.error(' body: ' + JSON.stringify(lastText.slice(0, 160))); } diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index fc036b1..cf558d0 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -49,6 +49,7 @@ const DirectMessagesPage = lazy(() => import("./pages/DirectMessagesPage")); const ConversationPage = lazy(() => import("./pages/ConversationPage")); const ContactsPage = lazy(() => import("./pages/ContactsPage")); const BountyLeaderboardPage = lazy(() => import("./pages/BountyLeaderboardPage")); +const ZapLeaderboardPage = lazy(() => import("./pages/ZapLeaderboardPage")); const SignersPage = lazy(() => import("./pages/SignersPage")); const StashPage = lazy(() => import("./pages/StashPage")); const NIP19Page = lazy(() => import("./pages/NIP19Page").then(m => ({ default: m.NIP19Page }))); @@ -140,6 +141,8 @@ export function AppRouter() { } /> {/* Bounty System */} } /> + {/* Zap Leaderboards */} + } /> {/* Security & Signers */} } /> {/* Stash - Private Inventory */} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 203105b..1f99817 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -231,6 +231,12 @@ function MobileHeaderUserMenu({ Wallet + + + + Leaderboards + + {/* ── Accounts: switch between logged-in npubs + add another ── */} diff --git a/src/hooks/useZapLeaderboard.ts b/src/hooks/useZapLeaderboard.ts new file mode 100644 index 0000000..e1149e8 --- /dev/null +++ b/src/hooks/useZapLeaderboard.ts @@ -0,0 +1,89 @@ +import { useQuery } from '@tanstack/react-query'; +import { useNostr } from '@nostrify/react'; +import type { NostrEvent } from '@nostrify/nostrify'; +import { zapReceiptSats } from '@/hooks/useZapTotals'; + +export interface ZapLeaderboardTarget { + /** `a` coordinate (`kind:pubkey:identifier`) or event id. */ + key: string; + sats: number; + count: number; +} + +export interface ZapLeaderboardSender { + pubkey: string; + sats: number; + count: number; +} + +export interface ZapLeaderboard { + targets: ZapLeaderboardTarget[]; + senders: ZapLeaderboardSender[]; +} + +/** + * Aggregate zap receipts into leaderboards. + * + * One query for kind-9735 receipts (bounded by `since` for time-ranged boards), + * then two tallies: + * - **targets** — total sats per zapped item (spot/shop/event coordinate). + * - **senders** — total sats sent per zapper (taken from the embedded zap + * request, since a receipt's own pubkey is the LNURL server, not the sender). + */ +export function useZapLeaderboard(since?: number) { + const { nostr } = useNostr(); + + return useQuery({ + queryKey: ['zap-leaderboard', since ?? 'all'], + staleTime: 5 * 60 * 1000, + queryFn: async (c): Promise => { + const signal = AbortSignal.any([c.signal, AbortSignal.timeout(12000)]); + + const receipts: NostrEvent[] = await nostr.query( + [{ kinds: [9735], ...(since ? { since } : {}), limit: 1000 }], + { signal }, + ); + + const targets = new Map(); + const senders = new Map(); + + for (const zap of receipts) { + const sats = zapReceiptSats(zap); + + const aTag = zap.tags.find(([t]) => t === 'a')?.[1]; + const eTag = zap.tags.find(([t]) => t === 'e')?.[1]; + const target = aTag ?? eTag; + if (target) { + const cur = targets.get(target) ?? { sats: 0, count: 0 }; + cur.sats += sats; + cur.count += 1; + targets.set(target, cur); + } + + const description = zap.tags.find(([t]) => t === 'description')?.[1]; + if (description) { + try { + const sender = JSON.parse(description)?.pubkey as string | undefined; + if (sender) { + const cur = senders.get(sender) ?? { sats: 0, count: 0 }; + cur.sats += sats; + cur.count += 1; + senders.set(sender, cur); + } + } catch { + /* malformed zap request — skip */ + } + } + } + + return { + targets: [...targets.entries()] + .map(([key, v]) => ({ key, ...v })) + .sort((a, b) => b.sats - a.sats), + senders: [...senders.entries()] + .map(([pubkey, v]) => ({ pubkey, ...v })) + .sort((a, b) => b.sats - a.sats), + }; + }, + }); +} diff --git a/src/hooks/useZapTotals.ts b/src/hooks/useZapTotals.ts index e94e63b..26e9e43 100644 --- a/src/hooks/useZapTotals.ts +++ b/src/hooks/useZapTotals.ts @@ -23,7 +23,7 @@ export function zapTargetKey(event: NostrEvent): string { } /** Extract the sats from one kind-9735 zap receipt. Mirrors useZaps' logic. */ -function receiptSats(zap: NostrEvent): number { +export function zapReceiptSats(zap: NostrEvent): number { const amountTag = zap.tags.find(([name]) => name === 'amount')?.[1]; if (amountTag) return Math.floor(parseInt(amountTag, 10) / 1000); @@ -88,7 +88,7 @@ export function useZapTotals(events: NostrEvent[]) { const totals = new Map(); for (const zap of receipts) { - const sats = receiptSats(zap); + const sats = zapReceiptSats(zap); // A receipt copies the tags it's paying; count each target it points at. for (const [, value] of zap.tags) { if (!wanted.has(value)) continue; diff --git a/src/pages/ZapLeaderboardPage.tsx b/src/pages/ZapLeaderboardPage.tsx new file mode 100644 index 0000000..566d901 --- /dev/null +++ b/src/pages/ZapLeaderboardPage.tsx @@ -0,0 +1,259 @@ +import { useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useSeoMeta } from '@unhead/react'; +import { nip19 } from 'nostr-tools'; +import { Layout } from '@/components/Layout'; +import { Card, CardContent } from '@/components/ui/card'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Badge } from '@/components/ui/badge'; +import { useZapLeaderboard } from '@/hooks/useZapLeaderboard'; +import { useSkateSpots } from '@/hooks/useSkateSpots'; +import { useAuthor } from '@/hooks/useAuthor'; +import { genUserName } from '@/lib/genUserName'; +import { SKATE_SPOT_KIND, type SkateSpot } from '@/lib/types'; +import { cn } from '@/lib/utils'; +import { Crown, Medal, MapPin, Store, Trophy, Users, Zap } from 'lucide-react'; +import type { ZapLeaderboardSender, ZapLeaderboardTarget } from '@/hooks/useZapLeaderboard'; + +const RANGES = { + week: 7 * 24 * 60 * 60, + month: 30 * 24 * 60 * 60, + all: null, +} as const; + +type RangeKey = keyof typeof RANGES; + +function RankBadge({ rank }: { rank: number }) { + if (rank === 1) return ; + if (rank === 2) return ; + if (rank === 3) return ; + return #{rank}; +} + +function rowClasses(rank: number) { + return cn( + 'flex items-center gap-3 rounded-xl border-2 bg-black/60 px-3 py-2.5 transition-colors', + rank === 1 + ? 'border-yellow-500/40 hover:border-yellow-500/70' + : rank === 2 + ? 'border-gray-400/30 hover:border-gray-400/60' + : rank === 3 + ? 'border-amber-600/30 hover:border-amber-600/60' + : 'border-white/10 hover:border-[#ff0040]/50', + ); +} + +function Sats({ sats, count }: { sats: number; count: number }) { + return ( +
+
+ + {sats.toLocaleString()} +
+
+ {count} zap{count === 1 ? '' : 's'} +
+
+ ); +} + +function TargetRow({ + rank, + target, + spot, +}: { + rank: number; + target: ZapLeaderboardTarget; + spot?: SkateSpot; +}) { + // `a` coordinate = kind:pubkey:identifier (identifier may itself contain ':'). + const [kindStr, pubkey, ...rest] = target.key.split(':'); + const identifier = rest.join(':'); + const kind = Number(kindStr); + const isSpotCoord = kind === SKATE_SPOT_KIND && !!pubkey && identifier !== ''; + const isShop = spot?.types.includes('shop') ?? false; + + const to = spot + ? isShop + ? `/shop/${spot.pubkey}/${spot.identifier}` + : `/spot/${spot.pubkey}/${spot.identifier}` + : isSpotCoord + ? `/${nip19.naddrEncode({ kind, pubkey, identifier })}` + : undefined; + + const label = spot?.title ?? (isShop ? 'Skate shop' : 'Skate spot'); + + const inner = ( +
+
+ +
+
+ {isShop ? : } +
+
+

{label}

+ {spot?.location && ( +

{spot.location}

+ )} +
+ +
+ ); + + return to ? {inner} : inner; +} + +function SupporterRow({ rank, entry }: { rank: number; entry: ZapLeaderboardSender }) { + const author = useAuthor(entry.pubkey); + const metadata = author.data?.metadata; + const displayName = metadata?.name ?? genUserName(entry.pubkey); + + return ( + +
+
+ +
+ + + + {displayName.slice(0, 2).toUpperCase()} + + +
+

{displayName}

+

Supporter

+
+ +
+ + ); +} + +function RowsSkeleton() { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ); +} + +export default function ZapLeaderboardPage() { + useSeoMeta({ + title: 'Zap Leaderboards - SkateSpots', + description: 'Top zapped skate spots and the community members supporting them.', + }); + + const [range, setRange] = useState('month'); + + const since = useMemo(() => { + const seconds = RANGES[range]; + return seconds ? Math.floor(Date.now() / 1000) - seconds : undefined; + }, [range]); + + const { data, isLoading } = useZapLeaderboard(since); + const { data: spots } = useSkateSpots(200); + + const spotByCoord = useMemo(() => { + const map = new Map(); + for (const s of spots ?? []) { + map.set(`${SKATE_SPOT_KIND}:${s.pubkey}:${s.identifier}`, s); + } + return map; + }, [spots]); + + const topTargets = useMemo( + () => (data?.targets ?? []).filter((t) => t.key.startsWith(`${SKATE_SPOT_KIND}:`)).slice(0, 10), + [data], + ); + const topSenders = useMemo(() => (data?.senders ?? []).slice(0, 10), [data]); + + const totalZapped = useMemo( + () => topSenders.reduce((sum, s) => sum + s.sats, 0), + [topSenders], + ); + + return ( + +
+ {/* Header */} +
+

+ + Zap Leaderboards +

+

+ The spots getting the most love — and the skaters giving it. +

+
+ + {/* Time range */} + setRange(v as RangeKey)} className="mb-6"> + + This week + This month + All time + + + + {/* Top spots */} + + +

+ + Top zapped spots +

+ {isLoading ? ( + + ) : topTargets.length === 0 ? ( +

+ No zaps yet in this period. Be the first — hit the ⚡ on a spot you love. +

+ ) : ( +
+ {topTargets.map((t, i) => ( + + ))} +
+ )} +
+
+ + {/* Top supporters */} + + +

+ + Top supporters +

+ {isLoading ? ( + + ) : topSenders.length === 0 ? ( +

+ No zaps sent yet in this period. +

+ ) : ( + <> +
+ {topSenders.map((s, i) => ( + + ))} +
+ {totalZapped > 0 && ( + + ⚡ {totalZapped.toLocaleString()} sats from the top 10 + + )} + + )} +
+
+
+
+ ); +}