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 */}