diff --git a/src/components/ZapBadgesCard.tsx b/src/components/ZapBadgesCard.tsx new file mode 100644 index 0000000..5711799 --- /dev/null +++ b/src/components/ZapBadgesCard.tsx @@ -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 ( + + + + + Zap Badges + + + +

+ {stats.count.toLocaleString()} zaps ·{' '} + {stats.sats.toLocaleString()} sats sent + {stats.rank !== null && ( + <> · rank #{stats.rank} + )} +

+ +
+ {BADGES.map((b) => { + const earned = b.earned(stats.count, stats.sats, stats.rank); + return ( +
+ + + {b.label} + +
+ ); + })} +
+ +

Based on the last 30 days of zaps.

+
+
+ ); +} diff --git a/src/pages/NIP19Page.tsx b/src/pages/NIP19Page.tsx index 25967b5..898bab2 100644 --- a/src/pages/NIP19Page.tsx +++ b/src/pages/NIP19Page.tsx @@ -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 */}
+ {/* Zap achievements (only renders when this profile has sent zaps) */} + + {/* Quick Info Card */}