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:
parent
046627b1dd
commit
38c99c1777
2 changed files with 96 additions and 0 deletions
92
src/components/ZapBadgesCard.tsx
Normal file
92
src/components/ZapBadgesCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue