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)