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 (
+
+ {stats.count.toLocaleString()} zaps ·{' '}
+ {stats.sats.toLocaleString()} sats sent
+ {stats.rank !== null && (
+ <> · rank #{stats.rank}>
+ )}
+ Based on the last 30 days of zaps.