From f10aa7971c889776c6bdc3e343f1c3124dc95302 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Fri, 24 Jul 2026 13:59:50 +0700 Subject: [PATCH] =?UTF-8?q?Step=203:=20Crews=20=E2=80=94=20map-anchored=20?= =?UTF-8?q?collective=20feature=20(kind=2035006)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New SkateCrew kind 35006 + type/parser + useCrews/useCrew hooks - /crews directory, /create-crew, /edit-crew, /crew/:pubkey/:identifier pages - Reuses the collective engine: NIP-32 membership attestation (confirmed/pending), CollectiveMembers + ConfirmMembershipCard, ClinkPayButton, SpotPicker - Activity v1: home spots + crew member feed (no events, per plan) - Map: teal/cyan Crews layer at home turf with crew markers + popups; wired through SpotMap props, layer toggles (mobile popover + filters sheet), and mobileLayerCount badge - Nav: Crews added to navItemKeys (desktop header, mobile bottom bar, sidebar) with en/es i18n keys - NIP.md: documents kind 35006, tag taxonomy, and shared membership mechanism Co-authored-by: shakespeare.diy --- NIP.md | 87 ++++++++- src/AppRouter.tsx | 8 + src/components/CrewForm.tsx | 367 +++++++++++++++++++++++++++++++++++ src/components/Layout.tsx | 2 + src/components/SpotMap.tsx | 127 +++++++++++- src/hooks/useCrewFeed.ts | 39 ++++ src/hooks/useCrews.ts | 72 +++++++ src/i18n/en.ts | 1 + src/i18n/es.ts | 1 + src/lib/types.ts | 89 +++++++++ src/pages/CreateCrewPage.tsx | 52 +++++ src/pages/CrewDetailPage.tsx | 294 ++++++++++++++++++++++++++++ src/pages/CrewsPage.tsx | 154 +++++++++++++++ src/pages/EditCrewPage.tsx | 85 ++++++++ src/pages/MapPage.tsx | 24 ++- 15 files changed, 1397 insertions(+), 5 deletions(-) create mode 100644 src/components/CrewForm.tsx create mode 100644 src/hooks/useCrewFeed.ts create mode 100644 src/hooks/useCrews.ts create mode 100644 src/pages/CreateCrewPage.tsx create mode 100644 src/pages/CrewDetailPage.tsx create mode 100644 src/pages/CrewsPage.tsx create mode 100644 src/pages/EditCrewPage.tsx diff --git a/NIP.md b/NIP.md index a49c261..fd5c4d4 100644 --- a/NIP.md +++ b/NIP.md @@ -1628,10 +1628,95 @@ on the map. } ``` +## Skate Crews (kind 35006) + +A **Crew** is a map-anchored collective identity — the crew you skate/roll with, +not a service you hire. It answers *"who skates here?"* rather than *"who can I +hire?"* (that's The Lab). Crews are addressable events identified by +`pubkey + kind + d-tag`. + +A crew is structurally a **collective** (it reuses the exact NIP-32 membership +attestation mechanism defined in [The Lab § Collective Membership](#collective-membership-nip-32-attestations)) +minus the service/rate fields, plus a **home turf** (map anchor) and optional +**home spots**. Members are always attested per-member with their own key — a +bare `p` tag on the crew is only a claim until the member self-labels. + +### Event Format + +```json +{ + "kind": 35006, + "content": "Late-night ledge crew. We haunt the plaza. All welcome, no kooks.", + "tags": [ + ["d", "barcelona-night-crew"], + ["title", "Barcelona Night Crew"], + ["t", "skatecrew"], + ["image", "https://blossom.example/crew-logo.png"], + ["g", "sp3e"], + ["location", "Barcelona, Spain"], + ["founded", "2019"], + ["t", "street"], + ["t", "transition"], + ["p", ""], + ["p", ""], + ["a", "30818::"], + ["website", "https://bcnnightcrew.example"], + ["noffer", "noffer1qqs..."], + ["alt", "Skate crew: Barcelona Night Crew"] + ] +} +``` + +### Required Tags + +| Tag | Description | +|-----|-------------| +| `d` | Unique crew identifier | +| `title` | Crew name | +| `t: skatecrew` | Self-tag for discovery/querying | + +### Optional Tags + +| Tag | Description | +|-----|-------------| +| `image` | Crew logo / photo | +| `g` | Geohash of the crew's **home turf** (map anchor) | +| `location` | Human-readable home location | +| `founded` | Year founded (cosmetic) | +| `t: ` | Style/focus tags: `street`, `transition`, `diy`, `oldschool`, `allterrain`, `longboard`, `bmx-friendly`, `bowl`, `vert`, `cruising`, `downhill`, etc. | +| `p` | Member pubkeys (hex). Confirmed only when the member self-attests (see below). | +| `a` | Home spots the crew claims/haunts — references to kind `30818` skate spots (up to 8). Cross-links the crew to the map. | +| `website`, `instagram`, `social` | Links | +| `noffer` | Optional shared tip jar (CLINK Offer — see below) | + +### Membership + +Crews reuse the collective membership model verbatim. A member confirms +belonging by publishing a NIP-32 label (kind `1985`) referencing the crew's +`a`-coordinate, `["l", "member", "org.skatespots.membership"]`. Confirmed / +pending chips and removal semantics are identical to Lab collectives. + +### Map Integration + +Crews with a `g` (home turf) render on the SkateSpots map as teal pins. Clicking +a crew pin opens its profile. A crew's `a`-referenced home spots remain regular +spots on the map and cross-link back to the crew. + +### Querying Crews + +```json +{ + "kinds": [35006], + "#t": ["skatecrew"], + "limit": 200 +} +``` + ## CLINK Payments (`noffer` tag) SkateSpots supports an **optional, opt-in** self-custodial payment code on Lab -provider profiles (kinds 35001–35004) and skate shop stalls (kind 30017). This +provider profiles (kinds 35001–35004), skate crews (kind 35006), and skate shop +stalls (kind 30017). This is layered on top of — never a replacement for — the existing Lightning Address (`lud16` from kind 0) and zap flows. diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index bdf37fa..ec7aee7 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -31,6 +31,10 @@ const BecomeFilmerPage = lazy(() => import("./pages/BecomeFilmerPage")); const BecomeEditorPage = lazy(() => import("./pages/BecomeEditorPage")); const BecomePhotographerPage = lazy(() => import("./pages/BecomePhotographerPage")); const BecomeBuilderPage = lazy(() => import("./pages/BecomeBuilderPage")); +const CrewsPage = lazy(() => import("./pages/CrewsPage")); +const CreateCrewPage = lazy(() => import("./pages/CreateCrewPage")); +const EditCrewPage = lazy(() => import("./pages/EditCrewPage")); +const CrewDetailPage = lazy(() => import("./pages/CrewDetailPage")); const CoachDetailPage = lazy(() => import("./pages/CoachDetailPage")); const TourDetailPage = lazy(() => import("./pages/TourDetailPage")); const AboutPage = lazy(() => import("./pages/AboutPage")); @@ -84,6 +88,10 @@ export function AppRouter() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/src/components/CrewForm.tsx b/src/components/CrewForm.tsx new file mode 100644 index 0000000..9d56131 --- /dev/null +++ b/src/components/CrewForm.tsx @@ -0,0 +1,367 @@ +import { useState } from 'react'; +import { nip19 } from 'nostr-tools'; +import ngeohash from 'ngeohash'; +import { useNavigate } from 'react-router-dom'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Label } from '@/components/ui/label'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useUploadFile } from '@/hooks/useUploadFile'; +import { useToast } from '@/hooks/useToast'; +import { useAuthor } from '@/hooks/useAuthor'; +import { genUserName } from '@/lib/genUserName'; +import { SpotPicker } from '@/components/SpotPicker'; +import { NofferField } from '@/components/NofferField'; +import { + SKATE_CREW_KIND, + SKATE_SPOT_KIND, + CREW_VIBE_LABELS, + isValidNoffer, + type SkateCrew, +} from '@/lib/types'; +import { cn } from '@/lib/utils'; +import { Users, Upload, X, Loader2, MapPin, Plus } from 'lucide-react'; + +const VIBE_ENTRIES = Object.entries(CREW_VIBE_LABELS); +const TEAL = 'bg-teal-500 hover:bg-teal-600 text-black'; + +function MemberRow({ pubkey, onRemove }: { pubkey: string; onRemove: () => void }) { + const author = useAuthor(pubkey); + const metadata = author.data?.metadata; + const name = metadata?.name ?? genUserName(pubkey); + const npub = nip19.npubEncode(pubkey); + return ( +
+ + + {name.slice(0, 2).toUpperCase()} + +
+

{name}

+

{npub.slice(0, 16)}…

+
+ +
+ ); +} + +interface CrewFormProps { + /** Existing crew when editing. */ + existing?: SkateCrew | null; +} + +export function CrewForm({ existing }: CrewFormProps) { + const navigate = useNavigate(); + const { user } = useCurrentUser(); + const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish(); + const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); + const { toast } = useToast(); + + const [name, setName] = useState(existing?.name ?? ''); + const [bio, setBio] = useState(existing?.bio ?? ''); + const [location, setLocation] = useState(existing?.location ?? ''); + const [latitude, setLatitude] = useState(''); + const [longitude, setLongitude] = useState(''); + const [vibes, setVibes] = useState(existing?.vibes ?? []); + const [founded, setFounded] = useState(existing?.founded ?? ''); + const [image, setImage] = useState(existing?.image ?? null); + const [website, setWebsite] = useState(existing?.website ?? ''); + const [instagram, setInstagram] = useState(existing?.instagram ?? ''); + const [social, setSocial] = useState(existing?.social ?? ''); + const [noffer, setNoffer] = useState(existing?.noffer ?? ''); + const [members, setMembers] = useState(existing?.members ?? []); + const [memberInput, setMemberInput] = useState(''); + const [memberError, setMemberError] = useState(null); + const [selectedSpots, setSelectedSpots] = useState( + (existing?.spotRefs ?? []).map((ref) => { + const parts = ref.split(':'); + return `${parts[1]}:${parts[2]}`; + }) + ); + const [isGettingLocation, setIsGettingLocation] = useState(false); + + const toggleVibe = (v: string) => + setVibes((prev) => (prev.includes(v) ? prev.filter((x) => x !== v) : [...prev, v])); + + const addMember = () => { + const raw = memberInput.trim(); + if (!raw) return; + let hex: string | null = null; + try { + if (raw.startsWith('npub1') || raw.startsWith('nostr:npub1')) { + const decoded = nip19.decode(raw.replace(/^nostr:/, '')); + if (decoded.type === 'npub') hex = decoded.data; + } else if (raw.startsWith('nprofile1')) { + const decoded = nip19.decode(raw); + if (decoded.type === 'nprofile') hex = decoded.data.pubkey; + } else if (/^[0-9a-f]{64}$/i.test(raw)) { + hex = raw.toLowerCase(); + } + } catch { + hex = null; + } + if (!hex) { + setMemberError('Enter a valid npub, nprofile, or hex pubkey.'); + return; + } + if (members.includes(hex)) { + setMemberError('That member is already added.'); + return; + } + setMembers([...members, hex]); + setMemberInput(''); + setMemberError(null); + }; + + const getCurrentLocation = () => { + if (!navigator.geolocation) { + toast({ title: 'Geolocation not supported', variant: 'destructive' }); + return; + } + setIsGettingLocation(true); + navigator.geolocation.getCurrentPosition( + (position) => { + setLatitude(position.coords.latitude.toFixed(6)); + setLongitude(position.coords.longitude.toFixed(6)); + setIsGettingLocation(false); + toast({ title: 'Location found' }); + }, + (error) => { + setIsGettingLocation(false); + toast({ title: 'Location error', description: error.message, variant: 'destructive' }); + } + ); + }; + + const handleImageUpload = async (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files || files.length === 0) return; + try { + const tags = await uploadFile(files[0]); + const url = tags[0]?.[1]; + if (url) { + setImage(url); + toast({ title: 'Logo uploaded' }); + } + } catch (error) { + toast({ title: 'Upload failed', description: error instanceof Error ? error.message : 'Failed', variant: 'destructive' }); + } + e.target.value = ''; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim()) { + toast({ title: 'Name required', description: 'Give your crew a name.', variant: 'destructive' }); + return; + } + + try { + const identifier = existing?.identifier ?? `crew-${Date.now().toString(36)}`; + + const tags: string[][] = [ + ['d', identifier], + ['title', name.trim()], + ['t', 'skatecrew'], + ['alt', `Skate crew: ${name.trim()}`], + ]; + + if (location.trim()) tags.push(['location', location.trim()]); + + // Home turf geohash — prefer freshly captured coords, else keep existing + if (latitude && longitude) { + const lat = parseFloat(latitude); + const lng = parseFloat(longitude); + if (!isNaN(lat) && !isNaN(lng)) tags.push(['g', ngeohash.encode(lat, lng, 7)]); + } else if (existing?.geohash) { + tags.push(['g', existing.geohash]); + } + + for (const v of vibes) tags.push(['t', v]); + if (founded.trim()) tags.push(['founded', founded.trim()]); + if (image) tags.push(['image', image]); + if (website.trim()) tags.push(['website', website.trim()]); + if (instagram.trim()) tags.push(['instagram', instagram.trim()]); + if (social.trim()) tags.push(['social', social.trim()]); + if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]); + + for (const pk of members) tags.push(['p', pk]); + + for (const spotId of selectedSpots) { + const [spotPubkey, spotIdentifier] = spotId.split(':'); + tags.push(['a', `${SKATE_SPOT_KIND}:${spotPubkey}:${spotIdentifier}`]); + } + + await createEvent({ kind: SKATE_CREW_KIND, content: bio.trim(), tags }); + + toast({ title: existing ? 'Crew updated!' : 'Crew created!', description: 'Published to the scene.' }); + navigate(`/crew/${user?.pubkey}/${identifier}`); + } catch (error) { + toast({ title: 'Failed to publish', description: error instanceof Error ? error.message : 'Error', variant: 'destructive' }); + } + }; + + return ( +
+ + + + {existing ? 'Edit Crew' : 'Crew Details'} + + Your crew's identity, home turf and members + + +
+ + setName(e.target.value)} className="bg-gray-800 border-gray-700" /> +
+ +
+ +