import { useQuery, type UseQueryResult } from '@tanstack/react-query'; import { useNostr } from '@nostrify/react'; import type { NostrEvent } from '@nostrify/nostrify'; import { ZAP_GOAL_KIND } from '@/lib/types'; import { getSatoshisFromBolt11 } from '@/lib/bolt11'; export interface CrewGoal { id: string; pubkey: string; /** Human-readable goal description (content). */ description: string; /** Target amount in sats. */ targetSats: number; /** Total raised so far in sats (tallied from zap receipts). */ raisedSats: number; /** Number of contributions. */ contributions: number; /** Optional deadline (unix seconds). */ closedAt?: number; /** Percentage complete (0–100+, may exceed 100). */ percent: number; image?: string; summary?: string; event: NostrEvent; } /** Sum sats from a set of zap receipts (kind 9735), reusing the bolt11/amount logic. */ function tallyZaps(receipts: NostrEvent[], closedAt?: number): { sats: number; count: number } { let sats = 0; let count = 0; for (const z of receipts) { if (closedAt && z.created_at > closedAt) continue; // NIP-75: ignore late zaps const amountTag = z.tags.find(([n]) => n === 'amount')?.[1]; if (amountTag) { const ms = parseInt(amountTag, 10); if (!Number.isNaN(ms)) { sats += Math.floor(ms / 1000); count++; continue; } } const bolt11 = z.tags.find(([n]) => n === 'bolt11')?.[1]; if (bolt11) { try { sats += getSatoshisFromBolt11(bolt11); count++; continue; } catch { // ignore unparseable invoice } } } return { sats, count }; } /** * Load a crew's NIP-75 funding goal (kind 9041) and tally its progress from * zap receipts (kind 9735) that reference the goal event. * * The goal is fully transparent & non-custodial: contributors zap the goal, and * funds settle to the goal author's own Lightning address. SkateSpots never * holds funds — it only reads the public tally. */ export function useCrewGoal(goalId?: string): UseQueryResult { const { nostr } = useNostr(); return useQuery({ queryKey: ['crew-goal', goalId], enabled: !!goalId, staleTime: 60 * 1000, refetchInterval: (q) => (q.getObserversCount() > 0 ? 90 * 1000 : false), queryFn: async (c): Promise => { if (!goalId) return null; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]); // Fetch the goal event and its zap receipts in one round-trip. const [goalEvents, receipts] = await Promise.all([ nostr.query([{ kinds: [ZAP_GOAL_KIND], ids: [goalId] }], { signal }), nostr.query([{ kinds: [9735], '#e': [goalId], limit: 500 }], { signal }), ]); const goal = goalEvents[0]; if (!goal) return null; const amountTag = goal.tags.find(([n]) => n === 'amount')?.[1]; const targetMs = amountTag ? parseInt(amountTag, 10) : 0; const targetSats = Number.isNaN(targetMs) ? 0 : Math.floor(targetMs / 1000); const closedAtTag = goal.tags.find(([n]) => n === 'closed_at')?.[1]; const closedAt = closedAtTag ? parseInt(closedAtTag, 10) : undefined; const { sats: raisedSats, count } = tallyZaps(receipts, closedAt); const percent = targetSats > 0 ? Math.round((raisedSats / targetSats) * 100) : 0; return { id: goal.id, pubkey: goal.pubkey, description: goal.content, targetSats, raisedSats, contributions: count, closedAt, percent, image: goal.tags.find(([n]) => n === 'image')?.[1], summary: goal.tags.find(([n]) => n === 'summary')?.[1], event: goal, }; }, }); }