diff --git a/NIP.md b/NIP.md index 694280c..797d6ac 100644 --- a/NIP.md +++ b/NIP.md @@ -1752,6 +1752,47 @@ bare `p` tag on the crew is only a claim until the member self-labels. | `nip05` | Optional crew NIP-05 identifier (`local@domain`). Verified against the **founder** pubkey — see NIP-05 Verification. | | `website`, `instagram`, `social` | Links | | `noffer` | Optional shared tip jar (CLINK Offer — see below) | +| `goal` | Optional **crew treasury** — the event id of a NIP-75 Zap Goal (kind `9041`) the crew is raising funds for. See Crew Treasury below. | +| `looking-for` | Optional **open call** — a Lab craft the crew is hiring for. Repeatable. Slugs: `filmer`, `photographer`, `editor`, `coach`, `builder`, `members`. Surfaces the crew's needs so matching Lab providers can reach out. | + +### Crew-Hosted Events + +A crew can host calendar events (jams, comps, meetups). A crew-hosted event is an +ordinary NIP-52 time-based calendar event (kind `31923`, `t: skateboarding`) that +carries an `a` tag pointing back at the crew coordinate, plus a readable +`crew-name` tag: + +```json +{ + "kind": 31923, + "tags": [ + ["d", "bcn-night-jam-2025"], + ["title", "BCN Night Jam"], + ["start", "1730000000"], + ["t", "skateboarding"], + ["a", "35006::"], + ["crew-name", "Barcelona Night Crew"] + ] +} +``` + +Clients query a crew's events with `{ kinds: [31923], "#a": ["35006::"] }` +and display "Hosted by [crew]" on the event, closing the Crews ↔ Events ↔ Zaps loop. + +### Crew Treasury (NIP-75 Zap Goal) + +A crew may run a transparent, **non-custodial** funding goal for a shared cause +(fixing a DIY spot, a crew trip, a comp prize pot). The founder publishes a +[NIP-75](https://github.com/nostr-protocol/nips/blob/master/75.md) Zap Goal +(kind `9041`) with an `amount` (millisats target) and links it from the crew via +a `["goal", ""]` tag. + +Progress is tallied client-side from zap receipts (kind `9735`) that reference +the goal event id. Contributions are ordinary zaps that settle to the goal +author's (founder's) own Lightning address — **SkateSpots never custodies +funds**; it only reads the public tally. No shared wallet or new wallet settings +are required; the payment rail is the founder's existing Lightning address / +CLINK noffer, and contributors pay with their in-app wallet (WebLN / NWC / Cashu). ### Membership @@ -2067,3 +2108,37 @@ The `status` tag MUST hold one of the following values: - Spots with a winning status of `gone` are **hidden from map markers** (but still shown dimmed, with a badge, in card lists) so the map isn't cluttered by demolished spots while preserving the record. + +## Private Contacts (kind 30000, encrypted) + +To let a user maintain a personal "who I skate with" address book **without +leaking their social graph publicly** (unlike a kind-3 follow list), SkateSpots +stores a private, encrypted contacts list as a NIP-51 Follow Set (kind `30000`) +with a fixed `d` identifier `skatespots-contacts`. + +The contact pubkeys are NIP-44 encrypted to the user's **own** key and kept in +the event `content` — only the user can read them. This mirrors the "encrypted +private list" approach and keeps the scene network self-sovereign and private. + +### Event Format + +```json +{ + "kind": 30000, + "content": "", + "tags": [ + ["d", "skatespots-contacts"], + ["title", "My Skate Contacts"], + ["description", "Private, encrypted list of people in my scene."] + ] +} +``` + +### Client behavior + +- The list is decrypted with the logged-in user's NIP-44 signer. Clients that + cannot decrypt (different key) fall back to any public p tags present. +- The Contacts page also surfaces crew-mates (members of crews the user belongs + to), follows (kind 3), and recent DMs (NIP-17 conversations) as live tabs; any + of those can be starred into the private list. +- The encrypted list is never merged into the public kind-3 follow list. diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index ec7aee7..39e82f2 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -45,6 +45,7 @@ const SpotCollectionsPage = lazy(() => import("./pages/SpotCollectionsPage")); const CollectionDetailPage = lazy(() => import("./pages/CollectionDetailPage")); const DirectMessagesPage = lazy(() => import("./pages/DirectMessagesPage")); const ConversationPage = lazy(() => import("./pages/ConversationPage")); +const ContactsPage = lazy(() => import("./pages/ContactsPage")); const BountyLeaderboardPage = lazy(() => import("./pages/BountyLeaderboardPage")); const SignersPage = lazy(() => import("./pages/SignersPage")); const StashPage = lazy(() => import("./pages/StashPage")); @@ -118,6 +119,7 @@ export function AppRouter() { } /> } /> } /> + } /> {/* Bounty System */} } /> {/* Security & Signers */} diff --git a/src/components/CrewBadge.tsx b/src/components/CrewBadge.tsx new file mode 100644 index 0000000..6020bcc --- /dev/null +++ b/src/components/CrewBadge.tsx @@ -0,0 +1,82 @@ +import { cn } from '@/lib/utils'; +import { crewBadge, ACTIVITY_META, type CrewActivity } from '@/lib/crewHelpers'; +import type { SkateCrew } from '@/lib/types'; + +/** + * A small "legitimacy" badge (OG / Verified / Established) derived from a crew's + * founding year, NIP-05 and member count. Social proof for the directory. + */ +export function CrewRepBadge({ + crew, + confirmedMembers, + className, +}: { + crew: Pick; + confirmedMembers?: number; + className?: string; +}) { + const badge = crewBadge(crew, confirmedMembers); + if (badge.key === 'crew') return null; + + const styles: Record = { + og: 'bg-amber-500/15 text-amber-300 border-amber-500/40', + verified: 'bg-teal-500/15 text-teal-300 border-teal-500/40', + established: 'bg-fuchsia-500/15 text-fuchsia-300 border-fuchsia-500/40', + }; + + return ( + + {badge.emoji} + {badge.label} + + ); +} + +/** + * A subtle activity pulse ("🔥 Active this week"). Dormant crews render nothing + * unless `showDormant` is set, so the grid highlights life rather than death. + */ +export function CrewActivityPulse({ + activity, + showDormant = false, + className, +}: { + activity: CrewActivity | undefined; + showDormant?: boolean; + className?: string; +}) { + if (!activity) return null; + // In dense card grids we only surface positive signals — hide dormant crews. + if (activity.level === 'dormant' && !showDormant) return null; + + const meta = ACTIVITY_META[activity.level]; + const isHot = activity.level === 'hot'; + + return ( + 0 + ? `${activity.upcomingEvents} upcoming event${activity.upcomingEvents === 1 ? '' : 's'}` + : meta.label + } + > + + {isHot && ( + + )} + + + {activity.upcomingEvents > 0 + ? `${activity.upcomingEvents} event${activity.upcomingEvents === 1 ? '' : 's'} coming` + : meta.label} + + ); +} diff --git a/src/components/CrewForm.tsx b/src/components/CrewForm.tsx index f62f907..1ce51f2 100644 --- a/src/components/CrewForm.tsx +++ b/src/components/CrewForm.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { nip19 } from 'nostr-tools'; import ngeohash from 'ngeohash'; import { useNavigate } from 'react-router-dom'; @@ -20,16 +20,20 @@ import { NofferField } from '@/components/NofferField'; import { SKATE_CREW_KIND, SKATE_SPOT_KIND, + ZAP_GOAL_KIND, CREW_VIBE_LABELS, CREW_FOCUS_LABELS, CREW_FOCUS_DESCRIPTIONS, CREW_FOCUS_TAG, + CREW_LOOKING_FOR_OPTIONS, isValidNoffer, type SkateCrew, type CrewFocus, } from '@/lib/types'; +import { useCrewGoal } from '@/hooks/useCrewGoal'; +import { useAppContext } from '@/hooks/useAppContext'; import { cn } from '@/lib/utils'; -import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles } from 'lucide-react'; +import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles, Target, Megaphone } from 'lucide-react'; const FOCUS_ORDER: CrewFocus[] = ['mixed', 'builder', 'photo', 'coaching']; const FOCUS_ICONS: Record = { @@ -153,6 +157,29 @@ export function CrewForm({ existing }: CrewFormProps) { const [labRefs, setLabRefs] = useState(existing?.labRefs ?? []); const [isGettingLocation, setIsGettingLocation] = useState(false); + // Open call ("looking for") — Lab craft slugs the crew is hiring for. + const [lookingFor, setLookingFor] = useState(existing?.lookingFor ?? []); + const toggleLookingFor = (slug: string) => + setLookingFor((prev) => (prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug])); + + // Crew treasury (NIP-75 zap goal). Load existing goal to prefill when editing. + const { data: existingGoal } = useCrewGoal(existing?.goalRef); + const { config } = useAppContext(); + const [goalEnabled, setGoalEnabled] = useState(!!existing?.goalRef); + const [goalTitle, setGoalTitle] = useState(''); + const [goalTarget, setGoalTarget] = useState(''); + const [goalPrefilled, setGoalPrefilled] = useState(false); + + // When an existing goal loads, prefill the fields once. + useEffect(() => { + if (existingGoal && !goalPrefilled) { + setGoalPrefilled(true); + setGoalEnabled(true); + setGoalTitle(existingGoal.description || existingGoal.summary || ''); + setGoalTarget(existingGoal.targetSats ? String(existingGoal.targetSats) : ''); + } + }, [existingGoal, goalPrefilled]); + const toggleVibe = (v: string) => setVibes((prev) => (prev.includes(v) ? prev.filter((x) => x !== v) : [...prev, v])); @@ -260,6 +287,34 @@ export function CrewForm({ existing }: CrewFormProps) { try { const identifier = existing?.identifier ?? `crew-${Date.now().toString(36)}`; + // ── Crew treasury: publish/refresh a NIP-75 zap goal, then link it. ── + // The goal is non-custodial — zaps settle to the founder's own Lightning + // wallet. We only publish the public goal + tally later. + let goalRef: string | undefined = existing?.goalRef; + const targetSats = parseInt(goalTarget, 10); + if (goalEnabled && goalTitle.trim() && targetSats > 0) { + const relayUrls = config.relayMetadata.relays.filter((r) => r.write).map((r) => r.url); + const needsNewGoal = + !existingGoal || + existingGoal.description !== goalTitle.trim() || + existingGoal.targetSats !== targetSats; + if (needsNewGoal) { + const goalEvent = await createEvent({ + kind: ZAP_GOAL_KIND, + content: goalTitle.trim(), + tags: [ + ['amount', String(targetSats * 1000)], // millisats + ['relays', ...(relayUrls.length ? relayUrls : ['wss://relay.nostr.band'])], + ['summary', `${name.trim()} crew treasury`], + ['alt', `Crew funding goal: ${goalTitle.trim()}`], + ], + }); + goalRef = goalEvent.id; + } + } else if (!goalEnabled) { + goalRef = undefined; // turning the treasury off unlinks the goal + } + const tags: string[][] = [ ['d', identifier], ['title', name.trim()], @@ -313,6 +368,14 @@ export function CrewForm({ existing }: CrewFormProps) { tags.push(['a', coord]); } + // Crew treasury goal (NIP-75) linkage. + if (goalRef) tags.push(['goal', goalRef]); + + // Open call — Lab crafts the crew is looking for. + for (const slug of lookingFor) { + tags.push(['looking-for', slug]); + } + await createEvent({ kind: SKATE_CREW_KIND, content: bio.trim(), tags }); toast({ title: existing ? 'Crew updated!' : 'Crew created!', description: 'Published to the scene.' }); @@ -485,6 +548,104 @@ export function CrewForm({ existing }: CrewFormProps) { + {/* Open call — looking for (crew ↔ Lab hiring pipeline) */} +
+
+ +

+ Looking for a filmer, editor or new blood? Put out a call — it shows on your crew page so + matching folks from The Lab can reach out. +

+
+
+ {CREW_LOOKING_FOR_OPTIONS.map((opt) => { + const on = lookingFor.includes(opt.slug); + return ( + + ); + })} +
+
+ + {/* Crew treasury (NIP-75 zap goal) */} +
+
+
+ +

+ Raise sats for a shared goal — fixing the local DIY spot, a crew trip, a comp prize pot. + It's a transparent Lightning goal; zaps go straight to your wallet{' '} + (via your profile's Lightning address). SkateSpots never holds the funds. +

+
+ +
+ {goalEnabled && ( +
+
+ + setGoalTitle(e.target.value)} + className="bg-gray-800 border-gray-700" + /> +
+
+ + setGoalTarget(e.target.value)} + className="bg-gray-800 border-gray-700" + /> +
+

+ + Make sure your Nostr profile has a Lightning address (lud16) so contributions can land. +

+
+ )} +
+ {/* Members */}
diff --git a/src/components/CrewLookingFor.tsx b/src/components/CrewLookingFor.tsx new file mode 100644 index 0000000..8e1df71 --- /dev/null +++ b/src/components/CrewLookingFor.tsx @@ -0,0 +1,39 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { CREW_LOOKING_FOR_LABELS } from '@/lib/types'; +import { Megaphone } from 'lucide-react'; + +/** + * Display a crew's open calls ("looking for a filmer / editor / new members"). + * This is the crew ↔ Lab hiring pipeline: crews broadcast what they need, and + * matching Lab providers can reach out. Slugs map to Lab scenes. + */ +export function CrewLookingForCard({ lookingFor }: { lookingFor: string[] }) { + if (!lookingFor || lookingFor.length === 0) return null; + + return ( + + + + Open Call + +

This crew is looking for:

+
+ +
+ {lookingFor.map((slug) => { + const meta = CREW_LOOKING_FOR_LABELS[slug]; + return ( + + {meta?.emoji ?? '🛹'} + {meta?.label ?? slug} + + ); + })} +
+
+
+ ); +} diff --git a/src/components/CrewRequestToJoin.tsx b/src/components/CrewRequestToJoin.tsx new file mode 100644 index 0000000..a4c7855 --- /dev/null +++ b/src/components/CrewRequestToJoin.tsx @@ -0,0 +1,133 @@ +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useSendDirectMessage } from '@/hooks/useDirectMessages'; +import { useToast } from '@/hooks/useToast'; +import { UserPlus, Loader2, Lock } from 'lucide-react'; + +interface CrewRequestToJoinProps { + crewName: string; + founderPubkey: string; + /** Delegated admins — they also receive the request so growth isn't founder-only. */ + admins?: string[]; +} + +/** + * Lets a skater request to join a crew via an encrypted NIP-17 DM to the + * founder and admins. This removes the founder-push-only bottleneck: anyone can + * knock, and any admin can then add them from the crew editor. + * + * Privacy: the request is a gift-wrapped DM (kinds 14/13/1059) — only the + * recipients can read it. No public "join" event leaks the social graph. + */ +export function CrewRequestToJoin({ crewName, founderPubkey, admins = [] }: CrewRequestToJoinProps) { + const { user } = useCurrentUser(); + const { mutateAsync: sendMessage, isPending } = useSendDirectMessage(); + const { toast } = useToast(); + const [open, setOpen] = useState(false); + const [note, setNote] = useState(''); + + if (!user) return null; + + const recipients = Array.from(new Set([founderPubkey, ...admins])).filter( + (pk) => pk !== user.pubkey, + ); + + // Don't show for people already in the leadership. + if (recipients.length === 0) return null; + + const canDM = !!user.signer?.nip44; + + const handleSend = async () => { + if (!canDM) { + toast({ + title: 'Encryption unavailable', + description: 'Your signer does not support NIP-44 encryption. Please upgrade your extension.', + variant: 'destructive', + }); + return; + } + try { + const body = + `🛹 Request to join ${crewName}` + + (note.trim() ? `\n\n${note.trim()}` : '\n\nWould love to roll with the crew.'); + await sendMessage({ + recipients, + content: body, + subject: `${crewName} — join request`, + }); + toast({ + title: 'Request sent!', + description: `The ${crewName} crew leadership got your encrypted message.`, + }); + setOpen(false); + setNote(''); + } catch (err) { + toast({ + title: 'Could not send request', + description: err instanceof Error ? err.message : 'Please try again.', + variant: 'destructive', + }); + } + }; + + return ( + + + + + + + + Join {crewName} + + + + Sends a private, encrypted message to the crew's founder & admins. + + +