Files
grimoire/src/hooks/useGoalProgress.ts
Alejandro 13fec0345f feat: zap goals nip-75 (#174)
* feat: add NIP-75 Zap Goal rendering

Add feed and detail view rendering for kind 9041 (Zap Goals):

- GoalRenderer: Shows clickable title, description, and progress bar
  with target/raised amounts
- GoalDetailRenderer: Adds sorted contributor breakdown with
  individual contribution totals
- nip75-helpers: Helper functions for extracting goal metadata
  (amount, relays, deadline, beneficiaries)

Both views fetch and tally zaps from the goal's specified relays.

* fix: improve NIP-75 goal rendering

- Remove icons from goal renderers
- Remove "sats" suffixes from amounts
- Use muted text instead of destructive color for closed goals
- Content is the title, summary tag is the description
- Only show description if summary tag exists

* feat: add zap button to goal renderers

Show a 'Zap this Goal' button in both feed and detail views
when the goal is still open (not past its closed_at deadline).

* style: unify progress indicator styles

- Update user menu and welcome page progress indicators to match
  goal renderer style: bar on top, progress/total below with percentage
- Remove "sats" suffix from progress displays
- Make goal zap button primary variant and full-width

* fix: polish goal renderers for release

- Remove limit parameter from useTimeline (use whatever relays send)
- Add more spacing between "Support Grimoire" header and progress bar

* refactor: extract useGoalProgress hook for NIP-75 goals

- Create useGoalProgress hook with shared goal logic
- Handle relay selection: goal relays → user inbox → aggregators
- Calculate progress, contributors, and all metadata in one place
- Simplify both GoalRenderer and GoalDetailRenderer

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-20 17:28:57 +01:00

165 lines
4.1 KiB
TypeScript

import { useMemo, useState, useEffect } from "react";
import type { NostrEvent } from "@/types/nostr";
import { useTimeline } from "@/hooks/useTimeline";
import { useAccount } from "@/hooks/useAccount";
import {
getGoalAmount,
getGoalRelays,
getGoalClosedAt,
getGoalTitle,
getGoalSummary,
isGoalClosed,
getGoalBeneficiaries,
} from "@/lib/nip75-helpers";
import { getZapAmount, getZapSender } from "applesauce-common/helpers/zap";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
import { relayListCache } from "@/services/relay-list-cache";
export interface Contributor {
pubkey: string;
totalAmount: number;
zapCount: number;
}
export interface GoalProgressResult {
// Metadata
title: string;
summary: string | undefined;
targetAmount: number | undefined;
closedAt: number | undefined;
closed: boolean;
beneficiaries: string[];
// Progress
targetSats: number;
raisedSats: number;
progress: number;
contributors: Contributor[];
// Loading state
loading: boolean;
zaps: NostrEvent[];
}
/**
* Hook for fetching and calculating NIP-75 Zap Goal progress
*
* Handles:
* - Parsing goal metadata from event
* - Selecting relays (goal relays → user inbox relays → aggregators)
* - Fetching zap receipts
* - Calculating total raised and contributor breakdown
*/
export function useGoalProgress(event: NostrEvent): GoalProgressResult {
const { pubkey: userPubkey } = useAccount();
// Parse goal metadata
const title = getGoalTitle(event);
const summary = getGoalSummary(event);
const targetAmount = getGoalAmount(event);
const closedAt = getGoalClosedAt(event);
const closed = isGoalClosed(event);
const beneficiaries = getGoalBeneficiaries(event);
const goalRelays = getGoalRelays(event);
// Get user's inbox relays as fallback
const [userInboxRelays, setUserInboxRelays] = useState<string[]>([]);
useEffect(() => {
if (!userPubkey) {
setUserInboxRelays([]);
return;
}
relayListCache.getInboxRelays(userPubkey).then((relays) => {
setUserInboxRelays(relays || []);
});
}, [userPubkey]);
// Determine which relays to use: goal relays → user inbox → aggregators
const relays = useMemo(() => {
if (goalRelays.length > 0) {
return [...goalRelays, ...AGGREGATOR_RELAYS];
}
if (userInboxRelays.length > 0) {
return [...userInboxRelays, ...AGGREGATOR_RELAYS];
}
return AGGREGATOR_RELAYS;
}, [goalRelays, userInboxRelays]);
// Fetch zaps for this goal
const zapFilter = useMemo(
() => ({
kinds: [9735],
"#e": [event.id],
}),
[event.id],
);
const { events: zaps, loading } = useTimeline(
`goal-zaps-${event.id}`,
zapFilter,
relays,
);
// Calculate total raised and contributor breakdown
const { totalRaised, contributors } = useMemo(() => {
const contributorMap = new Map<string, Contributor>();
let total = 0;
for (const zap of zaps) {
const amount = getZapAmount(zap) || 0;
const sender = getZapSender(zap);
total += amount;
if (sender) {
const existing = contributorMap.get(sender);
if (existing) {
existing.totalAmount += amount;
existing.zapCount += 1;
} else {
contributorMap.set(sender, {
pubkey: sender,
totalAmount: amount,
zapCount: 1,
});
}
}
}
// Sort by amount descending
const sortedContributors = Array.from(contributorMap.values()).sort(
(a, b) => b.totalAmount - a.totalAmount,
);
return { totalRaised: total, contributors: sortedContributors };
}, [zaps]);
// Convert to sats for display
const targetSats = targetAmount ? Math.floor(targetAmount / 1000) : 0;
const raisedSats = Math.floor(totalRaised / 1000);
const progress =
targetSats > 0 ? Math.min((raisedSats / targetSats) * 100, 100) : 0;
return {
// Metadata
title,
summary,
targetAmount,
closedAt,
closed,
beneficiaries,
// Progress
targetSats,
raisedSats,
progress,
contributors,
// Loading state
loading,
zaps,
};
}