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>
111 lines
3.6 KiB
TypeScript
111 lines
3.6 KiB
TypeScript
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,
|
||
};
|
||
},
|
||
});
|
||
}
|