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.
This commit is contained in:
Claude
2026-01-18 10:03:47 +00:00
parent c7cced2a9e
commit 8d8be5645c
2 changed files with 152 additions and 0 deletions

View File

@@ -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<string, number>();
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 (
<div className="w-full max-w-md mx-auto mb-8 border border-border rounded-lg p-4 bg-card/50">
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Zap className="size-4 fill-zap text-zap" />
<span className="text-sm font-mono text-foreground">
Support Grimoire
</span>
</div>
<span className="text-xs text-muted-foreground font-mono">
This Month
</span>
</div>
{/* Progress Bar */}
<div className="mb-3">
<div className="flex justify-between text-xs font-mono mb-1">
<span className="text-zap font-medium">
{totalRaised.toLocaleString()} sats
</span>
<span className="text-muted-foreground">
{MONTHLY_GOAL.toLocaleString()} goal
</span>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-zap to-yellow-400 transition-all duration-500 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
<div className="text-xs text-muted-foreground font-mono mt-1 text-center">
{progress.toFixed(1)}% funded
</div>
</div>
{/* Top Donors */}
{topDonors.length > 0 && (
<div className="border-t border-border pt-3">
<div className="text-xs text-muted-foreground font-mono mb-2">
Top Supporters
</div>
<div className="flex flex-col gap-1">
{topDonors.map((donor, idx) => (
<div
key={donor.pubkey}
className="flex items-center justify-between text-sm"
>
<div className="flex items-center gap-2">
<span className="text-muted-foreground font-mono text-xs w-4">
{idx + 1}.
</span>
<UserName
pubkey={donor.pubkey}
className="text-foreground truncate max-w-[120px]"
/>
</div>
<span className="text-zap font-mono text-xs font-medium">
{donor.sats.toLocaleString()}
</span>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -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({
</p>
</div>
{/* Donation Progress */}
<DonationProgress />
{/* Launch button */}
<div className="flex flex-col items-center gap-3">
<p className="text-muted-foreground text-sm font-mono mb-2">