diff --git a/src/components/nostr/kinds/ZapGoalDetailRenderer.tsx b/src/components/nostr/kinds/ZapGoalDetailRenderer.tsx
new file mode 100644
index 0000000..15d5aba
--- /dev/null
+++ b/src/components/nostr/kinds/ZapGoalDetailRenderer.tsx
@@ -0,0 +1,235 @@
+import { useMemo } from "react";
+import { Target, Zap, TrendingUp } from "lucide-react";
+import { getTagValue } from "applesauce-core/helpers";
+import { getZapAmount, getZapSender } from "applesauce-common/helpers/zap";
+import { UserName } from "../UserName";
+import { useTimeline } from "@/hooks/useTimeline";
+import { AGGREGATOR_RELAYS } from "@/services/loaders";
+import type { NostrEvent } from "@/types/nostr";
+
+/**
+ * Detail renderer for Kind 9041 - Zap Goals (NIP-75)
+ * Displays full goal information with contributor breakdown
+ */
+export function ZapGoalDetailRenderer({ event }: { event: NostrEvent }) {
+ // Get goal metadata from tags
+ const amountMsats = getTagValue(event, "amount");
+ const summary = getTagValue(event, "summary");
+ const closedAtStr = getTagValue(event, "closed_at");
+ const relaysTag = event.tags.find((t) => t[0] === "relays");
+ const goalRelays = relaysTag ? relaysTag.slice(1) : [];
+
+ // Parse amount (in millisatoshis)
+ const targetAmount = amountMsats ? parseInt(amountMsats, 10) : 0;
+ const targetSats = Math.floor(targetAmount / 1000);
+
+ // Check if goal is closed
+ const closedAt = closedAtStr ? parseInt(closedAtStr, 10) : null;
+ const isClosed = closedAt ? Date.now() / 1000 > closedAt : false;
+
+ // Format goal description
+ const description = summary || event.content;
+
+ // Query for zap receipts that reference this goal
+ const relays = goalRelays.length > 0 ? goalRelays : AGGREGATOR_RELAYS;
+ const { events: zapReceipts } = useTimeline(
+ `zap-goal-${event.id}`,
+ { kinds: [9735], "#e": [event.id] },
+ relays,
+ { limit: 1000 },
+ );
+
+ // Aggregate zaps by sender pubkey
+ const contributors = useMemo(() => {
+ const contributorMap = new Map<
+ string,
+ { pubkey: string; totalMsats: number; zapCount: number }
+ >();
+
+ for (const zapReceipt of zapReceipts) {
+ const sender = getZapSender(zapReceipt);
+ const amount = getZapAmount(zapReceipt);
+
+ if (!sender || !amount) continue;
+
+ // Skip zaps after closed_at if goal is closed
+ if (closedAt && zapReceipt.created_at > closedAt) continue;
+
+ const existing = contributorMap.get(sender);
+ if (existing) {
+ existing.totalMsats += amount;
+ existing.zapCount += 1;
+ } else {
+ contributorMap.set(sender, {
+ pubkey: sender,
+ totalMsats: amount,
+ zapCount: 1,
+ });
+ }
+ }
+
+ // Convert to array and sort by total amount descending
+ return Array.from(contributorMap.values()).sort(
+ (a, b) => b.totalMsats - a.totalMsats,
+ );
+ }, [zapReceipts, closedAt]);
+
+ // Calculate total raised
+ const totalRaisedMsats = contributors.reduce(
+ (sum, c) => sum + c.totalMsats,
+ 0,
+ );
+ const totalRaisedSats = Math.floor(totalRaisedMsats / 1000);
+
+ // Calculate progress percentage
+ const progressPercentage =
+ targetAmount > 0 ? (totalRaisedMsats / targetAmount) * 100 : 0;
+
+ return (
+
+ {/* Goal Header */}
+
+
+
+
+
+
Zap Goal
+ {isClosed && (
+
+ Closed
+
+ )}
+
+ {description && (
+
{description}
+ )}
+
+
+
+ {/* Progress Section */}
+
+ {/* Progress Bar */}
+
+
+ {/* Progress Stats */}
+
+
+
+
+ {totalRaisedSats.toLocaleString("en")}
+
+
+ / {targetSats.toLocaleString("en")} sats
+
+
+
+ {progressPercentage.toFixed(1)}%
+
+
+
+
+ {closedAt && (
+
+ {isClosed ? "Closed" : "Closes"} on{" "}
+ {new Date(closedAt * 1000).toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ })}
+
+ )}
+
+
+ {/* Contributors Section */}
+
+
+
+
+ Top Contributors ({contributors.length})
+
+
+
+ {contributors.length === 0 ? (
+
+ No contributions yet
+
+ ) : (
+
+ {contributors.map((contributor, index) => {
+ const sats = Math.floor(contributor.totalMsats / 1000);
+ const percentOfTotal =
+ totalRaisedMsats > 0
+ ? (contributor.totalMsats / totalRaisedMsats) * 100
+ : 0;
+
+ return (
+
+ {/* Rank */}
+
+ {index + 1}
+
+
+ {/* Contributor Info */}
+
+
+
+ {contributor.zapCount}{" "}
+ {contributor.zapCount === 1 ? "zap" : "zaps"}
+
+
+
+ {/* Amount */}
+
+
+
+
+ {sats.toLocaleString("en")}
+
+
+
+ {percentOfTotal.toFixed(1)}%
+
+
+
+ );
+ })}
+
+ )}
+
+
+ {/* Goal Metadata */}
+ {goalRelays.length > 0 && (
+
+
+ Goal Relays
+
+
+ {goalRelays.map((relay) => (
+
+ {relay}
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/src/components/nostr/kinds/ZapGoalRenderer.tsx b/src/components/nostr/kinds/ZapGoalRenderer.tsx
new file mode 100644
index 0000000..e632e0d
--- /dev/null
+++ b/src/components/nostr/kinds/ZapGoalRenderer.tsx
@@ -0,0 +1,122 @@
+import { useMemo } from "react";
+import { BaseEventProps, BaseEventContainer } from "./BaseEventRenderer";
+import { Target, Zap } from "lucide-react";
+import { getTagValue } from "applesauce-core/helpers";
+import { getZapAmount, getZapSender } from "applesauce-common/helpers/zap";
+import { useTimeline } from "@/hooks/useTimeline";
+import { AGGREGATOR_RELAYS } from "@/services/loaders";
+import { Progress } from "@/components/ui/progress";
+
+/**
+ * Renderer for Kind 9041 - Zap Goals (NIP-75)
+ * Displays fundraising goal with progress bar
+ */
+export function ZapGoalRenderer({ event }: BaseEventProps) {
+ // Get goal metadata from tags
+ const amountMsats = getTagValue(event, "amount");
+ const summary = getTagValue(event, "summary");
+ const closedAtStr = getTagValue(event, "closed_at");
+ const relaysTag = event.tags.find((t) => t[0] === "relays");
+ const goalRelays = relaysTag ? relaysTag.slice(1) : [];
+
+ // Parse amount (in millisatoshis)
+ const targetAmount = amountMsats ? parseInt(amountMsats, 10) : 0;
+ const targetSats = Math.floor(targetAmount / 1000);
+
+ // Check if goal is closed
+ const closedAt = closedAtStr ? parseInt(closedAtStr, 10) : null;
+ const isClosed = closedAt ? Date.now() / 1000 > closedAt : false;
+
+ // Format goal description
+ const description = summary || event.content;
+
+ // Query for zap receipts that reference this goal
+ const relays = goalRelays.length > 0 ? goalRelays : AGGREGATOR_RELAYS;
+ const { events: zapReceipts } = useTimeline(
+ `zap-goal-${event.id}`,
+ { kinds: [9735], "#e": [event.id] },
+ relays,
+ { limit: 1000 },
+ );
+
+ // Calculate total raised
+ const totalRaisedMsats = useMemo(() => {
+ let total = 0;
+ for (const zapReceipt of zapReceipts) {
+ const sender = getZapSender(zapReceipt);
+ const amount = getZapAmount(zapReceipt);
+
+ if (!sender || !amount) continue;
+
+ // Skip zaps after closed_at if goal is closed
+ if (closedAt && zapReceipt.created_at > closedAt) continue;
+
+ total += amount;
+ }
+ return total;
+ }, [zapReceipts, closedAt]);
+
+ const totalRaisedSats = Math.floor(totalRaisedMsats / 1000);
+
+ // Calculate progress percentage
+ const progressPercentage =
+ targetAmount > 0 ? (totalRaisedMsats / targetAmount) * 100 : 0;
+
+ return (
+
+
+ {/* Goal Header */}
+
+
+
+
+
Zap Goal
+ {isClosed && (
+
+ Closed
+
+ )}
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+
+ {/* Progress Bar */}
+
+
+
+ {/* Progress Stats */}
+
+
+
+
+ {totalRaisedSats.toLocaleString("en")}
+
+
+ / {targetSats.toLocaleString("en")} sats
+
+
+
+ {progressPercentage.toFixed(0)}%
+
+
+
+
+ {closedAt && !isClosed && (
+
+ Closes{" "}
+ {new Date(closedAt * 1000).toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ })}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx
index 0494f88..35e2dd9 100644
--- a/src/components/nostr/kinds/index.tsx
+++ b/src/components/nostr/kinds/index.tsx
@@ -54,6 +54,8 @@ import { ApplicationHandlerRenderer } from "./ApplicationHandlerRenderer";
import { ApplicationHandlerDetailRenderer } from "./ApplicationHandlerDetailRenderer";
import { HandlerRecommendationRenderer } from "./HandlerRecommendationRenderer";
import { HandlerRecommendationDetailRenderer } from "./HandlerRecommendationDetailRenderer";
+import { ZapGoalRenderer } from "./ZapGoalRenderer";
+import { ZapGoalDetailRenderer } from "./ZapGoalDetailRenderer";
import { CalendarDateEventRenderer } from "./CalendarDateEventRenderer";
import { CalendarDateEventDetailRenderer } from "./CalendarDateEventDetailRenderer";
import { CalendarTimeEventRenderer } from "./CalendarTimeEventRenderer";
@@ -157,6 +159,7 @@ const kindRenderers: Record> = {
1617: PatchRenderer, // Patch (NIP-34)
1618: PullRequestRenderer, // Pull Request (NIP-34)
1621: IssueRenderer, // Issue (NIP-34)
+ 9041: ZapGoalRenderer, // Zap Goal (NIP-75)
9735: Kind9735Renderer, // Zap Receipt
9802: Kind9802Renderer, // Highlight
777: SpellRenderer, // Spell (Grimoire)
@@ -254,6 +257,7 @@ const detailRenderers: Record<
1617: PatchDetailRenderer, // Patch Detail (NIP-34)
1618: PullRequestDetailRenderer, // Pull Request Detail (NIP-34)
1621: IssueDetailRenderer, // Issue Detail (NIP-34)
+ 9041: ZapGoalDetailRenderer, // Zap Goal Detail (NIP-75)
9802: Kind9802DetailRenderer, // Highlight Detail
10000: MuteListDetailRenderer, // Mute List Detail (NIP-51)
10001: PinListDetailRenderer, // Pin List Detail (NIP-51)