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.
89 lines
2.8 KiB
TypeScript
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),
|
|
};
|
|
},
|
|
});
|
|
}
|