diff --git a/NIP.md b/NIP.md index b04570b..f572008 100644 --- a/NIP.md +++ b/NIP.md @@ -1436,6 +1436,7 @@ They share a common base schema and are distinguished by kind and a self-tag. ["phone", ""], ["email", ""], ["website", ""], + ["entity", "individual"], ["a", "30818::"], ["alt", ""] ] @@ -1451,8 +1452,65 @@ They share a common base schema and are distinguished by kind and a self-tag. #### Shared Optional Tags `location`, `g` (geohash), `image`, `experience`, `rate`, `phone`, `email`, -`website`, `noffer` (CLINK payment code — see below), `a` (spot references to -kind 30818), `alt`. +`website`, `noffer` (CLINK payment code — see below), `entity` (see Entity Type +below), `p` (collective member pubkeys — see Collective Membership below), +`a` (spot references to kind 30818), `alt`. + +### Entity Type (individual vs. collective) + +Every Lab profile carries an optional `entity` tag declaring whether it +represents a single person or a group: + +| Tag | Value | Meaning | +|-----|-------|---------| +| `entity` | `individual` | A single person (default if omitted). | +| `entity` | `collective` | A multi-person entity: crew, studio, or company. | + +"Company" is presented as cosmetic sub-styling of a collective, **not** a +distinct trust tier — on Nostr a company and a collective share the same trust +model (a shared brand backed by verifiable member keys). + +```json +["entity", "collective"] +``` + +### Collective Membership (NIP-32 attestations) + +A collective profile lists its claimed members via `p` tags (member pubkeys in +hex). Because the collective's founder key alone signs the profile, a bare `p` +tag is only a **claim** — it cannot prove the listed person consented. + +To make membership **unforgeable**, each member independently publishes a +[NIP-32](https://github.com/nostr-protocol/nips/blob/master/32.md) label event +(kind `1985`) signed with their **own** key, referencing the collective's +addressable coordinate: + +```json +{ + "kind": 1985, + "content": "I'm a member of Barcelona Filmers", + "tags": [ + ["L", "org.skatespots.membership"], + ["l", "member", "org.skatespots.membership"], + ["a", "35002::"], + ["p", ""], + ["alt", "Skate collective membership confirmation for Barcelona Filmers"] + ] +} +``` + +**Client logic:** cross-reference the collective's `p` tags with member +attestations. A member is: + +- **Confirmed** — listed by the collective **AND** has published a matching + membership label. Rendered with a green "Confirmed" chip. +- **Pending** — listed by the collective but has not attested. Amber "Pending" + chip. +- A stray attestation from someone the collective does **not** list is ignored + (prevents unsolicited membership claims). + +Removing a member: the founder drops the `p` tag, or the member deletes their +label event (NIP-09). Either direction flips the member out of "confirmed". ### Photographer (kind 35004) diff --git a/src/components/CollectiveMembers.tsx b/src/components/CollectiveMembers.tsx new file mode 100644 index 0000000..397e3e4 --- /dev/null +++ b/src/components/CollectiveMembers.tsx @@ -0,0 +1,134 @@ +import { Link } from 'react-router-dom'; +import { nip19 } from 'nostr-tools'; +import { Users, Check, Clock, ShieldCheck } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { useAuthor } from '@/hooks/useAuthor'; +import { genUserName } from '@/lib/genUserName'; +import { useCollectiveMembers, type CollectiveMember } from '@/hooks/useCollectiveMembers'; +import { cn } from '@/lib/utils'; + +interface CollectiveMembersProps { + kind: number; + founderPubkey: string; + identifier: string; + claimedMembers: string[]; + accentText?: string; + accentBorder?: string; +} + +function StatusChip({ status }: { status: CollectiveMember['status'] }) { + if (status === 'confirmed') { + return ( + + Confirmed + + ); + } + return ( + + + + + Pending + + + + Listed by the collective — awaiting this member’s confirmation. + + + + ); +} + +function MemberRow({ member }: { member: CollectiveMember }) { + const author = useAuthor(member.pubkey); + const metadata = author.data?.metadata; + const name = metadata?.name ?? genUserName(member.pubkey); + const npub = nip19.npubEncode(member.pubkey); + + return ( + + + + {name.slice(0, 2).toUpperCase()} + + {name} + + + ); +} + +/** + * Renders a collective's members with confirmed / pending trust chips. + * Returns null for individuals or collectives with no listed members. + */ +export function CollectiveMembers({ + kind, + founderPubkey, + identifier, + claimedMembers, + accentText = 'text-indigo-400', + accentBorder = 'border-gray-700', +}: CollectiveMembersProps) { + const { data: members, isLoading } = useCollectiveMembers({ + kind, + founderPubkey, + identifier, + claimedMembers, + enabled: claimedMembers.length > 0, + }); + + if (claimedMembers.length === 0) return null; + + const confirmedCount = members?.filter((m) => m.status === 'confirmed').length ?? 0; + + return ( + + + + + Members · {claimedMembers.length} + + + +

+ + + {confirmedCount > 0 + ? `${confirmedCount} of ${claimedMembers.length} verified — each confirmed member signed with their own Nostr key.` + : 'Membership is verified per member. Each member confirms with their own Nostr key.'} + +

+ + {isLoading ? ( +
+ {claimedMembers.slice(0, 3).map((_, i) => ( +
+ + + +
+ ))} +
+ ) : ( +
+ {members?.map((m) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/src/components/ConfirmMembershipCard.tsx b/src/components/ConfirmMembershipCard.tsx new file mode 100644 index 0000000..f8a4a04 --- /dev/null +++ b/src/components/ConfirmMembershipCard.tsx @@ -0,0 +1,100 @@ +import { Users, Check, Loader2 } from 'lucide-react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useCollectiveMembers } from '@/hooks/useCollectiveMembers'; +import { useConfirmMembership } from '@/hooks/useMembershipAttestation'; +import { useToast } from '@/hooks/useToast'; +import { cn } from '@/lib/utils'; + +interface ConfirmMembershipCardProps { + kind: number; + founderPubkey: string; + identifier: string; + collectiveName: string; + claimedMembers: string[]; + accentBg?: string; +} + +/** + * Shown to a logged-in user who has been LISTED as a member of a collective but + * has not yet confirmed. Publishes a NIP-32 attestation on click. Hidden for + * everyone else (non-members, already-confirmed members, the founder). + */ +export function ConfirmMembershipCard({ + kind, + founderPubkey, + identifier, + collectiveName, + claimedMembers, + accentBg = 'bg-indigo-600 hover:bg-indigo-700', +}: ConfirmMembershipCardProps) { + const { user } = useCurrentUser(); + const { toast } = useToast(); + const { mutateAsync: confirm, isPending } = useConfirmMembership(); + const { data: members } = useCollectiveMembers({ + kind, + founderPubkey, + identifier, + claimedMembers, + enabled: claimedMembers.length > 0, + }); + + if (!user) return null; + + const me = user.pubkey.toLowerCase(); + // Only relevant if I'm actually listed as a member and I'm not the founder. + if (me === founderPubkey.toLowerCase()) return null; + if (!claimedMembers.includes(me)) return null; + + const myStatus = members?.find((m) => m.pubkey === me)?.status; + if (myStatus === 'confirmed') return null; + + const handleConfirm = async () => { + try { + await confirm({ kind, founderPubkey, identifier, collectiveName }); + toast({ + title: 'Membership confirmed!', + description: `You now show as a verified member of ${collectiveName}.`, + }); + } catch (error) { + toast({ + title: 'Failed to confirm', + description: error instanceof Error ? error.message : 'Please try again.', + variant: 'destructive', + }); + } + }; + + return ( + + +
+ +
+

You've been added to {collectiveName}

+

+ Confirm your membership so it shows as verified on your profile and theirs. +

+
+
+ +
+
+ ); +} diff --git a/src/components/EntityTypeField.tsx b/src/components/EntityTypeField.tsx new file mode 100644 index 0000000..981bf46 --- /dev/null +++ b/src/components/EntityTypeField.tsx @@ -0,0 +1,194 @@ +import { useState } from 'react'; +import { nip19 } from 'nostr-tools'; +import { User, Users, X, Plus, ShieldCheck } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { useAuthor } from '@/hooks/useAuthor'; +import { genUserName } from '@/lib/genUserName'; +import type { EntityType } from '@/lib/types'; +import { cn } from '@/lib/utils'; + +interface EntityTypeFieldProps { + entityType: EntityType; + onEntityTypeChange: (t: EntityType) => void; + /** Member pubkeys (hex). */ + members: string[]; + onMembersChange: (members: string[]) => void; + /** Accent color class set for the current category (e.g. red/blue/indigo). */ + accentBg?: string; + accentText?: string; +} + +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)}…

+
+ +
+ ); +} + +/** + * Individual / Collective toggle plus, for collectives, a member npub picker. + * Members are stored as hex pubkeys. Membership only becomes "confirmed" once + * each member publishes a NIP-32 attestation with their own key (handled on + * the detail page). + */ +export function EntityTypeField({ + entityType, + onEntityTypeChange, + members, + onMembersChange, + accentBg = 'bg-indigo-600', + accentText = 'text-indigo-400', +}: EntityTypeFieldProps) { + const [input, setInput] = useState(''); + const [error, setError] = useState(null); + + const addMember = () => { + const raw = input.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) { + setError('Enter a valid npub, nprofile, or 64-char hex pubkey.'); + return; + } + if (members.includes(hex)) { + setError('That member is already added.'); + return; + } + onMembersChange([...members, hex]); + setInput(''); + setError(null); + }; + + return ( +
+
+ +
+ + +
+
+ + {entityType === 'collective' && ( +
+
+ +

+ Add the Nostr accounts of your crew. Each member confirms their own + spot with their key — until then they show as “pending”. +

+
+ +
+ { + setInput(e.target.value); + setError(null); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + addMember(); + } + }} + spellCheck={false} + autoCapitalize="none" + className="bg-gray-800 border-gray-700 font-mono text-sm" + /> + +
+ + {error &&

{error}

} + + {members.length > 0 && ( +
+ {members.map((pk) => ( + onMembersChange(members.filter((m) => m !== pk))} /> + ))} +
+ )} + +

+ + + Membership is verified per member — nobody can be added to your crew + without confirming from their own account. + +

+
+ )} +
+ ); +} diff --git a/src/hooks/useCollectiveMembers.ts b/src/hooks/useCollectiveMembers.ts new file mode 100644 index 0000000..21096b4 --- /dev/null +++ b/src/hooks/useCollectiveMembers.ts @@ -0,0 +1,116 @@ +import { useQuery } from '@tanstack/react-query'; +import { useSkateRelays } from '@/hooks/useSkateRelays'; +import { MEMBERSHIP_LABEL_NAMESPACE, MEMBERSHIP_LABEL_VALUE } from '@/lib/types'; +import type { NostrEvent } from '@nostrify/nostrify'; + +export type MembershipStatus = 'confirmed' | 'pending'; + +export interface CollectiveMember { + pubkey: string; + status: MembershipStatus; + /** The confirming label event (kind 1985), if the member has attested. */ + attestation?: NostrEvent; +} + +interface UseCollectiveMembersParams { + /** Kind of the collective profile (e.g. 35002). */ + kind: number; + /** Founder pubkey (author of the collective profile). */ + founderPubkey: string; + /** d-tag identifier of the collective profile. */ + identifier: string; + /** Member pubkeys (hex) the collective claims via `p` tags. */ + claimedMembers: string[]; + /** Whether the profile is actually a collective (skip query for individuals). */ + enabled?: boolean; +} + +/** + * Cross-reference a collective's claimed members (`p` tags on the profile) + * against NIP-32 membership attestations that each member publishes with + * their OWN key. A member is: + * - `confirmed`: claimed by the collective AND has published a matching + * kind 1985 label referencing this collective's address. + * - `pending`: claimed by the collective but has not (yet) attested. + * + * This makes membership unforgeable: the collective cannot fake a member's + * confirmation, and a non-member's stray attestation is ignored unless the + * collective also lists them. + */ +export function useCollectiveMembers({ + kind, + founderPubkey, + identifier, + claimedMembers, + enabled = true, +}: UseCollectiveMembersParams) { + const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); + + const address = `${kind}:${founderPubkey}:${identifier}`; + + return useQuery({ + queryKey: ['collective-members', address, claimedMembers.join(','), getQueryKeySuffix()], + queryFn: async (): Promise => { + if (claimedMembers.length === 0) return []; + + const signal = AbortSignal.timeout(8000); + + // Query NIP-32 label events from the claimed members that reference + // this collective's addressable coordinate. + const events = await querySkateContent( + [ + { + kinds: [1985], + authors: claimedMembers, + '#a': [address], + '#l': [MEMBERSHIP_LABEL_VALUE], + limit: claimedMembers.length * 2, + }, + ], + { signal } + ); + + // Build a map of pubkey -> confirming attestation (latest wins). + const confirmed = new Map(); + for (const ev of events) { + // Validate it's a genuine membership label for THIS collective. + const hasNamespace = ev.tags.some( + ([n, v]) => n === 'L' && v === MEMBERSHIP_LABEL_NAMESPACE + ); + const hasLabel = ev.tags.some( + ([n, v]) => n === 'l' && v === MEMBERSHIP_LABEL_VALUE + ); + const refsCollective = ev.tags.some(([n, v]) => n === 'a' && v === address); + if (!hasNamespace || !hasLabel || !refsCollective) continue; + + const author = ev.pubkey.toLowerCase(); + // Only count members the collective actually claims. + if (!claimedMembers.includes(author)) continue; + + const existing = confirmed.get(author); + if (!existing || ev.created_at > existing.created_at) { + confirmed.set(author, ev); + } + } + + // Produce a status for every claimed member, confirmed first. + const members: CollectiveMember[] = claimedMembers.map((pubkey) => { + const attestation = confirmed.get(pubkey); + return { + pubkey, + status: attestation ? 'confirmed' : 'pending', + attestation, + }; + }); + + members.sort((a, b) => { + if (a.status === b.status) return 0; + return a.status === 'confirmed' ? -1 : 1; + }); + + return members; + }, + enabled: enabled && claimedMembers.length > 0, + staleTime: 30000, + }); +} diff --git a/src/hooks/useMembershipAttestation.ts b/src/hooks/useMembershipAttestation.ts new file mode 100644 index 0000000..48c1f5c --- /dev/null +++ b/src/hooks/useMembershipAttestation.ts @@ -0,0 +1,67 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useNostr } from '@nostrify/react'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { + MEMBERSHIP_LABEL_NAMESPACE, + MEMBERSHIP_LABEL_VALUE, +} from '@/lib/types'; + +interface AttestParams { + /** Kind of the collective profile being joined. */ + kind: number; + /** Founder pubkey (author of the collective profile). */ + founderPubkey: string; + /** d-tag identifier of the collective profile. */ + identifier: string; + /** Collective display name (stored in content for readability). */ + collectiveName?: string; + /** Relay hint for the referenced collective. */ + relayHint?: string; +} + +/** + * Publish a NIP-32 membership attestation (kind 1985) confirming that the + * logged-in user is a member of the given collective. Signed with the user's + * OWN key, so the confirmation cannot be forged by the collective. + */ +export function useConfirmMembership() { + const { nostr } = useNostr(); + const { user } = useCurrentUser(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + kind, + founderPubkey, + identifier, + collectiveName, + relayHint, + }: AttestParams) => { + if (!user) throw new Error('You must be logged in to confirm membership'); + + const address = `${kind}:${founderPubkey}:${identifier}`; + const aTag = relayHint ? ['a', address, relayHint] : ['a', address]; + + const event = await user.signer.signEvent({ + kind: 1985, + content: collectiveName + ? `I'm a member of ${collectiveName}` + : 'Confirmed collective membership', + tags: [ + ['L', MEMBERSHIP_LABEL_NAMESPACE], + ['l', MEMBERSHIP_LABEL_VALUE, MEMBERSHIP_LABEL_NAMESPACE], + aTag, + ['p', founderPubkey], + ['alt', `Skate collective membership confirmation for ${collectiveName ?? address}`], + ], + created_at: Math.floor(Date.now() / 1000), + }); + + await nostr.event(event, { signal: AbortSignal.timeout(15000) }); + return event; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['collective-members'] }); + }, + }); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index fa20a14..f44efbd 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -53,6 +53,35 @@ export const MARKETPLACE_ITEM_KIND = 30402; // NIP-99 Classified Listings export const SPOT_COLLECTION_KIND = 30003; // NIP-51 Bookmark sets for spot collections export const SKATE_TOUR_KIND = 30823; // Local skate tours - guides offer to show hidden spots +/** + * Lab provider entity type. + * - `individual`: a single person (default). + * - `collective`: a multi-person entity (crew, collective, or company). "Company" + * is presented as cosmetic sub-styling of a collective, not a distinct trust tier. + */ +export type EntityType = 'individual' | 'collective'; + +export function parseEntityType(value: string | undefined | null): EntityType { + return value === 'collective' ? 'collective' : 'individual'; +} + +/** + * NIP-32 label namespace used for collective membership attestations. + * A member proves membership by publishing a kind 1985 label event with: + * ["L", "org.skatespots.membership"] + * ["l", "member", "org.skatespots.membership"] + * ["a", "::"] // the collective being joined + */ +export const MEMBERSHIP_LABEL_NAMESPACE = 'org.skatespots.membership'; +export const MEMBERSHIP_LABEL_VALUE = 'member'; + +/** Extract member pubkeys (hex) claimed by a Lab profile via `p` tags. */ +export function parseMemberPubkeys(event: NostrEvent): string[] { + return event.tags + .filter(([name, value]) => name === 'p' && /^[0-9a-f]{64}$/i.test(value ?? '')) + .map(([, value]) => value.toLowerCase()); +} + /** * Validate a CLINK Offer code (noffer1...) — a Nostr-native, self-custodial * static payment code (see https://github.com/shocknet/clink, kind 21001). @@ -682,6 +711,8 @@ export interface SkateCoach { website?: string; instagram?: string; noffer?: string; // Optional CLINK Offer code (noffer1...) for self-custodial payments + entityType: EntityType; // individual (default) or collective + members: string[]; // Member pubkeys (hex) claimed via `p` tags (collective only) spotRefs?: string[]; // Array of spot references: "30818:pubkey:d-tag" createdAt: number; event: NostrEvent; @@ -706,6 +737,8 @@ export interface SkateFilmer { social?: string; // Social media handle or link gear?: string; // Freeform "what's in the bag" (cameras, lenses, rigs) noffer?: string; // Optional CLINK Offer code (noffer1...) for self-custodial payments + entityType: EntityType; // individual (default) or collective + members: string[]; // Member pubkeys (hex) claimed via `p` tags (collective only) spotRefs?: string[]; // Array of spot references: "30818:pubkey:d-tag" createdAt: number; event: NostrEvent; @@ -731,6 +764,8 @@ export interface SkateEditor { hardware?: string; // e.g. "MacBook Pro M3, Sony FX3" software?: string; // e.g. "DaVinci Resolve, Premiere" noffer?: string; // Optional CLINK Offer code (noffer1...) for self-custodial payments + entityType: EntityType; // individual (default) or collective + members: string[]; // Member pubkeys (hex) claimed via `p` tags (collective only) createdAt: number; event: NostrEvent; } @@ -754,6 +789,8 @@ export interface SkatePhotographer { social?: string; gear?: string; // Freeform "what's in the bag" (bodies, lenses, lights) noffer?: string; // Optional CLINK Offer code (noffer1...) for self-custodial payments + entityType: EntityType; // individual (default) or collective + members: string[]; // Member pubkeys (hex) claimed via `p` tags (collective only) spotRefs?: string[]; // Array of spot references: "30818:pubkey:d-tag" createdAt: number; event: NostrEvent; @@ -842,6 +879,8 @@ export function parseSkateCoach(event: NostrEvent): SkateCoach | null { const instagram = event.tags.find(([name]) => name === 'instagram')?.[1]; const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1]; const noffer = isValidNoffer(nofferTag) ? nofferTag : undefined; + const entityType = parseEntityType(event.tags.find(([name]) => name === 'entity')?.[1]); + const members = parseMemberPubkeys(event); // Extract spot references (a tags pointing to kind 30818 spots) const spotRefs = event.tags .filter(([name, value]) => name === 'a' && value?.startsWith('30818:')) @@ -864,6 +903,8 @@ export function parseSkateCoach(event: NostrEvent): SkateCoach | null { website, instagram, noffer, + entityType, + members, spotRefs: spotRefs.length > 0 ? spotRefs : undefined, createdAt: event.created_at, event, @@ -893,6 +934,8 @@ export function parseSkateFilmer(event: NostrEvent): SkateFilmer | null { const gear = event.tags.find(([name]) => name === 'gear')?.[1]; const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1]; const noffer = isValidNoffer(nofferTag) ? nofferTag : undefined; + const entityType = parseEntityType(event.tags.find(([name]) => name === 'entity')?.[1]); + const members = parseMemberPubkeys(event); // Extract spot references (a tags pointing to kind 30818 spots) const spotRefs = event.tags .filter(([name, value]) => name === 'a' && value?.startsWith('30818:')) @@ -917,6 +960,8 @@ export function parseSkateFilmer(event: NostrEvent): SkateFilmer | null { social, gear, noffer, + entityType, + members, spotRefs: spotRefs.length > 0 ? spotRefs : undefined, createdAt: event.created_at, event, @@ -947,6 +992,8 @@ export function parseSkateEditor(event: NostrEvent): SkateEditor | null { const software = event.tags.find(([name]) => name === 'software')?.[1]; const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1]; const noffer = isValidNoffer(nofferTag) ? nofferTag : undefined; + const entityType = parseEntityType(event.tags.find(([name]) => name === 'entity')?.[1]); + const members = parseMemberPubkeys(event); return { id: event.id, @@ -968,6 +1015,8 @@ export function parseSkateEditor(event: NostrEvent): SkateEditor | null { hardware, software, noffer, + entityType, + members, createdAt: event.created_at, event, }; @@ -996,6 +1045,8 @@ export function parseSkatePhotographer(event: NostrEvent): SkatePhotographer | n const gear = event.tags.find(([name]) => name === 'gear')?.[1]; const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1]; const noffer = isValidNoffer(nofferTag) ? nofferTag : undefined; + const entityType = parseEntityType(event.tags.find(([name]) => name === 'entity')?.[1]); + const members = parseMemberPubkeys(event); const spotRefs = event.tags .filter(([name, value]) => name === 'a' && value?.startsWith('30818:')) .map(([, value]) => value); @@ -1019,6 +1070,8 @@ export function parseSkatePhotographer(event: NostrEvent): SkatePhotographer | n social, gear, noffer, + entityType, + members, spotRefs: spotRefs.length > 0 ? spotRefs : undefined, createdAt: event.created_at, event, diff --git a/src/pages/BecomeCoachPage.tsx b/src/pages/BecomeCoachPage.tsx index f909ea2..185b083 100644 --- a/src/pages/BecomeCoachPage.tsx +++ b/src/pages/BecomeCoachPage.tsx @@ -19,7 +19,8 @@ import { WalletModal } from '@/components/WalletModal'; import { LoginArea } from '@/components/auth/LoginArea'; import { SpotPicker } from '@/components/SpotPicker'; import { NofferField } from '@/components/NofferField'; -import { SKATE_COACH_KIND, SKATE_SPOT_KIND, COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, isValidNoffer } from '@/lib/types'; +import { EntityTypeField } from '@/components/EntityTypeField'; +import { SKATE_COACH_KIND, SKATE_SPOT_KIND, COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, isValidNoffer, type EntityType } from '@/lib/types'; import { cn } from '@/lib/utils'; import { GraduationCap, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Zap } from 'lucide-react'; import { PlatformFeePayment } from '@/components/PlatformFeePayment'; @@ -60,6 +61,8 @@ export default function BecomeCoachPage() { const [website, setWebsite] = useState(''); const [instagram, setInstagram] = useState(''); const [noffer, setNoffer] = useState(''); + const [entityType, setEntityType] = useState('individual'); + const [members, setMembers] = useState([]); const [selectedSpots, setSelectedSpots] = useState([]); const [isGettingLocation, setIsGettingLocation] = useState(false); @@ -154,6 +157,11 @@ export default function BecomeCoachPage() { if (website.trim()) tags.push(['website', website.trim()]); if (instagram.trim()) tags.push(['instagram', instagram.trim()]); if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]); + + tags.push(['entity', entityType]); + if (entityType === 'collective') { + for (const pk of members) tags.push(['p', pk]); + } // Add spot references for (const spotId of selectedSpots) { @@ -308,6 +316,15 @@ export default function BecomeCoachPage() {