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.
This commit is contained in:
parent
83a3e8ae03
commit
6ae4e674dc
6 changed files with 385 additions and 16 deletions
|
|
@ -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)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Route path="/contacts" element={<ContactsPage />} />
|
||||
{/* Bounty System */}
|
||||
<Route path="/bounties" element={<BountyLeaderboardPage />} />
|
||||
{/* Zap Leaderboards */}
|
||||
<Route path="/leaderboards" element={<ZapLeaderboardPage />} />
|
||||
{/* Security & Signers */}
|
||||
<Route path="/signers" element={<SignersPage />} />
|
||||
{/* Stash - Private Inventory */}
|
||||
|
|
|
|||
|
|
@ -231,6 +231,12 @@ function MobileHeaderUserMenu({
|
|||
<span className="text-orange-400">Wallet</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/leaderboards" className="flex items-center gap-2 cursor-pointer">
|
||||
<BarChart3 className="h-4 w-4 text-[#ccff00]" />
|
||||
<span className="text-[#ccff00]">Leaderboards</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* ── Accounts: switch between logged-in npubs + add another ── */}
|
||||
<DropdownMenuSeparator />
|
||||
|
|
|
|||
89
src/hooks/useZapLeaderboard.ts
Normal file
89
src/hooks/useZapLeaderboard.ts
Normal file
|
|
@ -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<ZapLeaderboard>({
|
||||
queryKey: ['zap-leaderboard', since ?? 'all'],
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async (c): Promise<ZapLeaderboard> => {
|
||||
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<string, { sats: number; count: number }>();
|
||||
const senders = new Map<string, { sats: number; count: number }>();
|
||||
|
||||
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),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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<string, ZapTotal>();
|
||||
|
||||
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;
|
||||
|
|
|
|||
259
src/pages/ZapLeaderboardPage.tsx
Normal file
259
src/pages/ZapLeaderboardPage.tsx
Normal file
|
|
@ -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 <Crown className="h-5 w-5 text-yellow-400" />;
|
||||
if (rank === 2) return <Medal className="h-5 w-5 text-gray-300" />;
|
||||
if (rank === 3) return <Medal className="h-5 w-5 text-amber-600" />;
|
||||
return <span className="text-sm font-bold text-gray-500">#{rank}</span>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="text-right">
|
||||
<div className="flex items-center gap-1 font-bold text-[#ccff00]">
|
||||
<Zap className="h-4 w-4" />
|
||||
{sats.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-[11px] text-gray-500">
|
||||
{count} zap{count === 1 ? '' : 's'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 = (
|
||||
<div className={rowClasses(rank)}>
|
||||
<div className="flex h-6 w-6 flex-shrink-0 items-center justify-center">
|
||||
<RankBadge rank={rank} />
|
||||
</div>
|
||||
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-[#ff0040]/15 text-[#ff0040]">
|
||||
{isShop ? <Store className="h-4 w-4" /> : <MapPin className="h-4 w-4" />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-bold uppercase tracking-wide text-white">{label}</p>
|
||||
{spot?.location && (
|
||||
<p className="truncate text-xs text-gray-500">{spot.location}</p>
|
||||
)}
|
||||
</div>
|
||||
<Sats sats={target.sats} count={target.count} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return to ? <Link to={to} className="block">{inner}</Link> : 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 (
|
||||
<Link to={`/${nip19.npubEncode(entry.pubkey)}`} className="block">
|
||||
<div className={rowClasses(rank)}>
|
||||
<div className="flex h-6 w-6 flex-shrink-0 items-center justify-center">
|
||||
<RankBadge rank={rank} />
|
||||
</div>
|
||||
<Avatar className="h-9 w-9 flex-shrink-0 border border-[#ccff00]/30">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-black text-[#ccff00] text-xs">
|
||||
{displayName.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-bold text-white">{displayName}</p>
|
||||
<p className="truncate text-xs text-gray-500">Supporter</p>
|
||||
</div>
|
||||
<Sats sats={entry.sats} count={entry.count} />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function RowsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[62px] w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ZapLeaderboardPage() {
|
||||
useSeoMeta({
|
||||
title: 'Zap Leaderboards - SkateSpots',
|
||||
description: 'Top zapped skate spots and the community members supporting them.',
|
||||
});
|
||||
|
||||
const [range, setRange] = useState<RangeKey>('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<string, SkateSpot>();
|
||||
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 (
|
||||
<Layout>
|
||||
<div className="container mx-auto max-w-3xl px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="flex items-center gap-2 text-2xl font-black uppercase tracking-wide text-white sm:text-3xl">
|
||||
<Trophy className="h-7 w-7 text-[#ccff00]" />
|
||||
Zap Leaderboards
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-400">
|
||||
The spots getting the most love — and the skaters giving it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Time range */}
|
||||
<Tabs value={range} onValueChange={(v) => setRange(v as RangeKey)} className="mb-6">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="week">This week</TabsTrigger>
|
||||
<TabsTrigger value="month">This month</TabsTrigger>
|
||||
<TabsTrigger value="all">All time</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{/* Top spots */}
|
||||
<Card className="mb-6 border-2 border-[#ff0040]/25 bg-black/80">
|
||||
<CardContent className="p-4">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-black uppercase tracking-[0.15em] text-[#ff0040]">
|
||||
<MapPin className="h-4 w-4" />
|
||||
Top zapped spots
|
||||
</h2>
|
||||
{isLoading ? (
|
||||
<RowsSkeleton />
|
||||
) : topTargets.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-gray-500">
|
||||
No zaps yet in this period. Be the first — hit the ⚡ on a spot you love.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{topTargets.map((t, i) => (
|
||||
<TargetRow key={t.key} rank={i + 1} target={t} spot={spotByCoord.get(t.key)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Top supporters */}
|
||||
<Card className="border-2 border-[#ccff00]/25 bg-black/80">
|
||||
<CardContent className="p-4">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-black uppercase tracking-[0.15em] text-[#ccff00]">
|
||||
<Users className="h-4 w-4" />
|
||||
Top supporters
|
||||
</h2>
|
||||
{isLoading ? (
|
||||
<RowsSkeleton />
|
||||
) : topSenders.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-gray-500">
|
||||
No zaps sent yet in this period.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{topSenders.map((s, i) => (
|
||||
<SupporterRow key={s.pubkey} rank={i + 1} entry={s} />
|
||||
))}
|
||||
</div>
|
||||
{totalZapped > 0 && (
|
||||
<Badge variant="secondary" className="mt-4 font-mono text-[11px]">
|
||||
⚡ {totalZapped.toLocaleString()} sats from the top 10
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue