SkateSpots/src/hooks/useCrewGoal.ts
shakespeare.diy 6848a72869 Crews: proximity discovery, activity/rep signals, treasury, hiring pipeline, crew-hosted events + reverse links; add Contacts page
Crews page
- "Near me" geolocation toggle with haversine distance sort + distance chips
- Crew count + cities social-proof stat header
- Deterministic per-crew vibe-colour accents (no more wall of teal)
- Activity pulse ("🔥 active this week") + OG/Verified/Established rep badges
- Rich empty state ("Claim your local scene") instead of dead "No crews yet"

Crew synergies
- Crew treasury: transparent, non-custodial NIP-75 zap goal (kind 9041) with
  live progress bar tallied from zap receipts; funds settle to founder's own
  Lightning wallet — no shared wallet or new wallet settings needed
- Crew-hosted events: host a jam/comp as the crew (event a-tag → crew coord),
  "Hosted by [crew]" on event pages, Crew Events list on the crew page
- Crew ↔ Lab hiring pipeline: "Open Call / looking-for" flags (filmer, editor,
  coach, builder, members) surfaced on the crew page
- Request to join: encrypted NIP-17 DM to founder + admins (no founder-push
  bottleneck, no public join event)
- Reverse links: "Crews that skate here" on spot pages

Contacts (0xchat-style + Armada-style privacy)
- New /contacts page with Crew-mates / Follows / Recent DMs / Saved tabs
- Per-contact quick actions: message, zap, view profile, star
- Private encrypted contacts list (NIP-51 kind 30000, NIP-44 encrypted to self)
- Added to burger menu + desktop More menu (gated to logged-in users)

Docs: NIP.md updated with crew treasury, crew-hosted events, looking-for tags,
and the private encrypted contacts list.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-07-25 12:38:45 +07:00

111 lines
3.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { nip57 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
import { ZAP_GOAL_KIND } from '@/lib/types';
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 += nip57.getSatoshisAmountFromBolt11(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) {
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<CrewGoal | null> => {
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,
};
},
});
}