SkateSpots/src/hooks/useZapLeaderboard.ts
SkateSpots 6ae4e674dc 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.
2026-09-26 17:17:21 +07:00

89 lines
2.8 KiB
TypeScript

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),
};
},
});
}