From 6848a72869829405090561dbc9a3dda2ce556aff Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Sat, 25 Jul 2026 12:38:45 +0700 Subject: [PATCH] Crews: proximity discovery, activity/rep signals, treasury, hiring pipeline, crew-hosted events + reverse links; add Contacts page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- NIP.md | 75 ++++++ src/AppRouter.tsx | 2 + src/components/CrewBadge.tsx | 82 +++++++ src/components/CrewForm.tsx | 165 ++++++++++++- src/components/CrewLookingFor.tsx | 39 +++ src/components/CrewRequestToJoin.tsx | 133 ++++++++++ src/components/CrewTreasuryCard.tsx | 116 +++++++++ src/components/CrewsForSpotCard.tsx | 76 ++++++ src/components/Layout.tsx | 17 ++ src/hooks/useCrewActivity.ts | 104 ++++++++ src/hooks/useCrewEvents.ts | 54 +++++ src/hooks/useCrewGoal.ts | 111 +++++++++ src/hooks/useCrewmates.ts | 45 ++++ src/hooks/useCrewsForSpot.ts | 45 ++++ src/hooks/useGeolocation.ts | 55 +++++ src/hooks/usePrivateContacts.ts | 125 ++++++++++ src/lib/crewHelpers.ts | 189 +++++++++++++++ src/lib/types.ts | 32 +++ src/pages/ContactsPage.tsx | 350 +++++++++++++++++++++++++++ src/pages/CreateEventPage.tsx | 36 ++- src/pages/CrewDetailPage.tsx | 53 ++++ src/pages/CrewsPage.tsx | 268 +++++++++++++++----- src/pages/EventDetailPage.tsx | 25 +- src/pages/SpotDetailPage.tsx | 6 + 24 files changed, 2142 insertions(+), 61 deletions(-) create mode 100644 src/components/CrewBadge.tsx create mode 100644 src/components/CrewLookingFor.tsx create mode 100644 src/components/CrewRequestToJoin.tsx create mode 100644 src/components/CrewTreasuryCard.tsx create mode 100644 src/components/CrewsForSpotCard.tsx create mode 100644 src/hooks/useCrewActivity.ts create mode 100644 src/hooks/useCrewEvents.ts create mode 100644 src/hooks/useCrewGoal.ts create mode 100644 src/hooks/useCrewmates.ts create mode 100644 src/hooks/useCrewsForSpot.ts create mode 100644 src/hooks/useGeolocation.ts create mode 100644 src/hooks/usePrivateContacts.ts create mode 100644 src/lib/crewHelpers.ts create mode 100644 src/pages/ContactsPage.tsx 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. + + +