From 8d8be5645c3d589dd78323ebbdb21ee435df2bfb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Jan 2026 10:03:47 +0000 Subject: [PATCH] feat: add monthly donation progress to welcome page Add a compact donation leaderboard that displays: - Progress bar showing current month's donations toward 210k sats goal - Total amount raised and percentage funded - Top 3 supporters with their contribution amounts The component queries kind:9735 zap receipts from the current month, aggregates by sender, and displays the results in a clean card format on the welcome page. --- src/components/DonationProgress.tsx | 148 ++++++++++++++++++++++++++++ src/components/GrimoireWelcome.tsx | 4 + 2 files changed, 152 insertions(+) create mode 100644 src/components/DonationProgress.tsx diff --git a/src/components/DonationProgress.tsx b/src/components/DonationProgress.tsx new file mode 100644 index 0000000..a3aba04 --- /dev/null +++ b/src/components/DonationProgress.tsx @@ -0,0 +1,148 @@ +import { useMemo } from "react"; +import { useTimeline } from "@/hooks/useTimeline"; +import { getZapAmount, getZapSender } from "applesauce-common/helpers/zap"; +import { UserName } from "./nostr/UserName"; +import { Zap } from "lucide-react"; +import { AGGREGATOR_RELAYS } from "@/services/loaders"; + +const GRIMOIRE_PUBKEY = + "c8fb0d3aa788b9ace4f6cb92dd97d3f292db25b5c9f92462ef6c64926129fbaf"; +const MONTHLY_GOAL = 210_000; // 210k sats + +/** + * DonationProgress - Displays monthly donation progress + * Shows progress bar, total raised, and top donors + */ +export function DonationProgress() { + // Get start of current month timestamp + const monthStart = useMemo(() => { + const now = new Date(); + return Math.floor( + new Date(now.getFullYear(), now.getMonth(), 1).getTime() / 1000, + ); + }, []); + + // Query zaps to Grimoire from this month + const { events: zaps, loading } = useTimeline( + "grimoire-donations", + { + kinds: [9735], + "#p": [GRIMOIRE_PUBKEY], + since: monthStart, + limit: 500, + }, + AGGREGATOR_RELAYS, + { limit: 500 }, + ); + + // Aggregate donations by sender + const { totalRaised, topDonors } = useMemo(() => { + const byDonor = new Map(); + + for (const zap of zaps) { + const sender = getZapSender(zap); + const amount = getZapAmount(zap); + + if (sender && amount) { + const currentTotal = byDonor.get(sender) || 0; + byDonor.set(sender, currentTotal + amount); + } + } + + // Convert msats to sats and calculate total + const totalMsats = Array.from(byDonor.values()).reduce( + (sum, amt) => sum + amt, + 0, + ); + const totalSats = Math.floor(totalMsats / 1000); + + // Get top 3 donors + const sortedDonors = Array.from(byDonor.entries()) + .map(([pubkey, msats]) => ({ + pubkey, + sats: Math.floor(msats / 1000), + })) + .sort((a, b) => b.sats - a.sats) + .slice(0, 3); + + return { + totalRaised: totalSats, + topDonors: sortedDonors, + }; + }, [zaps]); + + // Calculate progress percentage + const progress = Math.min(100, (totalRaised / MONTHLY_GOAL) * 100); + + if (loading && zaps.length === 0) { + return null; // Don't show until data loads + } + + return ( +
+ {/* Header */} +
+
+ + + Support Grimoire + +
+ + This Month + +
+ + {/* Progress Bar */} +
+
+ + {totalRaised.toLocaleString()} sats + + + {MONTHLY_GOAL.toLocaleString()} goal + +
+
+
+
+
+ {progress.toFixed(1)}% funded +
+
+ + {/* Top Donors */} + {topDonors.length > 0 && ( +
+
+ Top Supporters +
+
+ {topDonors.map((donor, idx) => ( +
+
+ + {idx + 1}. + + +
+ + {donor.sats.toLocaleString()}⚡ + +
+ ))} +
+
+ )} +
+ ); +} diff --git a/src/components/GrimoireWelcome.tsx b/src/components/GrimoireWelcome.tsx index db6097c..313a910 100644 --- a/src/components/GrimoireWelcome.tsx +++ b/src/components/GrimoireWelcome.tsx @@ -1,6 +1,7 @@ import { Terminal } from "lucide-react"; import { Button } from "./ui/button"; import { Kbd, KbdGroup } from "./ui/kbd"; +import { DonationProgress } from "./DonationProgress"; interface GrimoireWelcomeProps { onLaunchCommand: () => void; @@ -62,6 +63,9 @@ export function GrimoireWelcome({

+ {/* Donation Progress */} + + {/* Launch button */}