feat(profile): zap badges (achievements) card

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.
This commit is contained in:
SkateSpots 2026-09-26 17:22:06 +07:00
parent 046627b1dd
commit 38c99c1777
2 changed files with 96 additions and 0 deletions

View file

@ -0,0 +1,92 @@
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>
);
}

View file

@ -9,6 +9,7 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { ZapButton } from '@/components/ZapButton';
import { ZapBadgesCard } from '@/components/ZapBadgesCard';
import { ClinkPayButton } from '@/components/ClinkPayButton';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@ -707,6 +708,9 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
{/* Sidebar */}
<div className="space-y-4 min-w-0">
{/* Zap achievements (only renders when this profile has sent zaps) */}
<ZapBadgesCard pubkey={pubkey} />
{/* Quick Info Card */}
<Card className="bg-black/90 border-2 border-gray-800">
<CardHeader className="pb-3">