Derives First Zap / Regular / Generous / Patron / Top 10 from the batched 30-day supporter leaderboard and shows earned vs locked badges in the profile sidebar. Client-side recognition only — publishes no events.
92 lines
3.7 KiB
TypeScript
92 lines
3.7 KiB
TypeScript
import { useMemo } from 'react';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { useZapLeaderboard } from '@/hooks/useZapLeaderboard';
|
|
import { cn } from '@/lib/utils';
|
|
import { Award, Crown, Sparkles, Trophy, Zap } from 'lucide-react';
|
|
|
|
interface BadgeRule {
|
|
id: string;
|
|
label: string;
|
|
hint: string;
|
|
Icon: typeof Zap;
|
|
earned: (count: number, sats: number, rank: number | null) => boolean;
|
|
}
|
|
|
|
const BADGES: BadgeRule[] = [
|
|
{ id: 'first', label: 'First Zap', hint: 'Send your first zap', Icon: Sparkles, earned: (c) => c >= 1 },
|
|
{ id: 'regular', label: 'Regular', hint: 'Send 10 zaps', Icon: Zap, earned: (c) => c >= 10 },
|
|
{ id: 'generous', label: 'Generous', hint: 'Send 10,000 sats', Icon: Award, earned: (_c, s) => s >= 10_000 },
|
|
{ id: 'patron', label: 'Patron', hint: 'Send 100,000 sats', Icon: Crown, earned: (_c, s) => s >= 100_000 },
|
|
{ id: 'top10', label: 'Top 10', hint: 'Reach the top 10 supporters', Icon: Trophy, earned: (_c, _s, r) => r !== null && r <= 10 },
|
|
];
|
|
|
|
/**
|
|
* Zap achievements for a pubkey, derived from the (batched) supporter
|
|
* leaderboard over the last 30 days. Badges are pure recognition — nothing is
|
|
* published, so this adds no events and no spam to the network.
|
|
*/
|
|
export function ZapBadgesCard({ pubkey, className }: { pubkey: string; className?: string }) {
|
|
const since = useMemo(() => Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60, []);
|
|
const { data, isLoading } = useZapLeaderboard(since);
|
|
|
|
const stats = useMemo(() => {
|
|
const senders = data?.senders ?? [];
|
|
const idx = senders.findIndex((s) => s.pubkey === pubkey);
|
|
return {
|
|
count: idx >= 0 ? senders[idx].count : 0,
|
|
sats: idx >= 0 ? senders[idx].sats : 0,
|
|
rank: idx >= 0 ? idx + 1 : null,
|
|
};
|
|
}, [data, pubkey]);
|
|
|
|
// Keep profiles clean: nothing to show (or still loading) → render nothing.
|
|
if (isLoading || stats.count === 0) return null;
|
|
|
|
return (
|
|
<Card className={cn('bg-black/90 border-2 border-[#ccff00]/25', className)}>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="flex items-center gap-2 text-lg text-[#ccff00]">
|
|
<Zap className="h-5 w-5" />
|
|
Zap Badges
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<p className="text-xs text-gray-400">
|
|
<span className="font-bold text-[#ccff00]">{stats.count.toLocaleString()}</span> zaps ·{' '}
|
|
<span className="font-bold text-[#ccff00]">{stats.sats.toLocaleString()}</span> sats sent
|
|
{stats.rank !== null && (
|
|
<> · rank <span className="font-bold text-white">#{stats.rank}</span></>
|
|
)}
|
|
</p>
|
|
|
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
|
{BADGES.map((b) => {
|
|
const earned = b.earned(stats.count, stats.sats, stats.rank);
|
|
return (
|
|
<div
|
|
key={b.id}
|
|
title={b.hint}
|
|
className={cn(
|
|
'flex flex-col items-center gap-1 rounded-lg border-2 px-2 py-3 text-center',
|
|
earned ? 'border-[#ccff00]/60 bg-[#ccff00]/10' : 'border-white/10 bg-black/40 opacity-45',
|
|
)}
|
|
>
|
|
<b.Icon className={cn('h-5 w-5', earned ? 'text-[#ccff00]' : 'text-gray-500')} />
|
|
<span
|
|
className={cn(
|
|
'text-[11px] font-bold uppercase tracking-wide',
|
|
earned ? 'text-white' : 'text-gray-500',
|
|
)}
|
|
>
|
|
{b.label}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<p className="text-[10px] text-gray-500">Based on the last 30 days of zaps.</p>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|