Add individual/collective entity type + NIP-32 membership attestation to The Lab

Step 1 of the collective plan (shared primitive on existing Lab categories):
- types.ts: EntityType + entity/members fields on coach/filmer/editor/
  photographer interfaces & parsers; membership label namespace constants +
  parseMemberPubkeys / parseEntityType helpers
- useCollectiveMembers: cross-references claimed `p` tags against members'
  own NIP-32 (kind 1985) attestations -> confirmed / pending status
- useConfirmMembership: publishes a member's self-signed membership label
- EntityTypeField: individual/collective toggle + member npub picker (per-
  category accent) wired into all 4 Become-* forms
- CollectiveMembers: sidebar card with confirmed/pending trust chips
- ConfirmMembershipCard: one-tap confirm shown only to listed pending members
- CoachDetailPage: collective badge, members card, confirm-membership prompt
- Lab cards: compact collective badge with member count
- NIP.md: document entity tag + collective membership attestation flow

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-24 13:18:18 +07:00
parent feddbaf5ef
commit 9cb8d1181d
13 changed files with 845 additions and 5 deletions

62
NIP.md
View file

@ -1436,6 +1436,7 @@ They share a common base schema and are distinguished by kind and a self-tag.
["phone", "<phone>"],
["email", "<email>"],
["website", "<url>"],
["entity", "individual"],
["a", "30818:<pubkey>:<d-tag>"],
["alt", "<human-readable description>"]
]
@ -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:<founderPubkey>:<d-tag>"],
["p", "<founderPubkey>"],
["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)

View file

@ -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 (
<span className="inline-flex items-center gap-1 rounded-full bg-green-500/15 px-2 py-0.5 text-xs font-bold text-green-500">
<Check className="h-3 w-3" /> Confirmed
</span>
);
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2 py-0.5 text-xs font-bold text-amber-500">
<Clock className="h-3 w-3" /> Pending
</span>
</TooltipTrigger>
<TooltipContent>
Listed by the collective — awaiting this member’s confirmation.
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
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 (
<Link
to={`/${npub}`}
className="flex items-center gap-3 rounded-lg bg-black/40 p-2 transition-colors hover:bg-black/60"
>
<Avatar className="h-9 w-9">
<AvatarImage src={metadata?.picture} alt={name} />
<AvatarFallback className="text-xs">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-gray-200">{name}</span>
<StatusChip status={member.status} />
</Link>
);
}
/**
* 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 (
<Card className={cn('bg-black/90 border-2', accentBorder)}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
<Users className={cn('h-5 w-5', accentText)} />
Members · {claimedMembers.length}
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<p className="flex items-start gap-1.5 text-xs text-gray-400">
<ShieldCheck className={cn('mt-0.5 h-3.5 w-3.5 shrink-0', accentText)} />
<span>
{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.'}
</span>
</p>
{isLoading ? (
<div className="space-y-2">
{claimedMembers.slice(0, 3).map((_, i) => (
<div key={i} className="flex items-center gap-3 rounded-lg bg-black/40 p-2">
<Skeleton className="h-9 w-9 rounded-full" />
<Skeleton className="h-4 flex-1" />
<Skeleton className="h-5 w-20 rounded-full" />
</div>
))}
</div>
) : (
<div className="space-y-2">
{members?.map((m) => (
<MemberRow key={m.pubkey} member={m} />
))}
</div>
)}
</CardContent>
</Card>
);
}

View file

@ -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 (
<Card className="border-2 border-amber-500/50 bg-amber-950/20">
<CardContent className="space-y-3 p-4">
<div className="flex items-start gap-3">
<Users className="mt-0.5 h-6 w-6 shrink-0 text-amber-400" />
<div>
<h3 className="font-bold text-amber-300">You've been added to {collectiveName}</h3>
<p className="text-sm text-gray-300">
Confirm your membership so it shows as verified on your profile and theirs.
</p>
</div>
</div>
<Button
type="button"
onClick={handleConfirm}
disabled={isPending}
className={cn('w-full gap-2 font-bold', accentBg)}
>
{isPending ? (
<>
<Loader2 className="h-4 w-4 animate-spin" /> Confirming…
</>
) : (
<>
<Check className="h-4 w-4" /> Confirm Membership
</>
)}
</Button>
</CardContent>
</Card>
);
}

View file

@ -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 (
<div className="flex items-center gap-3 rounded-lg border border-gray-700 bg-gray-800/60 p-2">
<Avatar className="h-8 w-8">
<AvatarImage src={metadata?.picture} alt={name} />
<AvatarFallback className="text-xs">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{name}</p>
<p className="truncate text-xs text-gray-500">{npub.slice(0, 16)}…</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-gray-400 hover:text-red-400"
onClick={onRemove}
aria-label="Remove member"
>
<X className="h-4 w-4" />
</Button>
</div>
);
}
/**
* 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<string | null>(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 (
<div className="space-y-4 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
<div className="space-y-2">
<Label className="text-base font-bold">Who is this profile for?</Label>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => onEntityTypeChange('individual')}
className={cn(
'flex flex-col items-center gap-1 rounded-xl border p-4 transition-all active:scale-95',
entityType === 'individual'
? `${accentBg} border-transparent text-white`
: 'border-gray-700 bg-gray-800 text-gray-300 hover:bg-gray-700'
)}
>
<User className="h-6 w-6" />
<span className="text-sm font-bold">Individual</span>
<span className="text-[11px] opacity-80">A single person</span>
</button>
<button
type="button"
onClick={() => onEntityTypeChange('collective')}
className={cn(
'flex flex-col items-center gap-1 rounded-xl border p-4 transition-all active:scale-95',
entityType === 'collective'
? `${accentBg} border-transparent text-white`
: 'border-gray-700 bg-gray-800 text-gray-300 hover:bg-gray-700'
)}
>
<Users className="h-6 w-6" />
<span className="text-sm font-bold">Collective</span>
<span className="text-[11px] opacity-80">Crew, studio or company</span>
</button>
</div>
</div>
{entityType === 'collective' && (
<div className="space-y-3">
<div>
<Label className={cn('flex items-center gap-2', accentText)}>
<Users className="h-4 w-4" /> Members
</Label>
<p className="mt-1 text-xs text-gray-500">
Add the Nostr accounts of your crew. Each member confirms their own
spot with their key — until then they show as “pending”.
</p>
</div>
<div className="flex gap-2">
<Input
placeholder="npub1… / nprofile1… / hex pubkey"
value={input}
onChange={(e) => {
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"
/>
<Button type="button" onClick={addMember} className={cn('shrink-0 gap-1', accentBg)}>
<Plus className="h-4 w-4" /> Add
</Button>
</div>
{error && <p className="text-xs text-red-400">{error}</p>}
{members.length > 0 && (
<div className="space-y-2">
{members.map((pk) => (
<MemberRow key={pk} pubkey={pk} onRemove={() => onMembersChange(members.filter((m) => m !== pk))} />
))}
</div>
)}
<p className="flex items-start gap-1.5 rounded-lg border border-gray-700 bg-black/40 p-2 text-xs text-gray-400">
<ShieldCheck className={cn('mt-0.5 h-3.5 w-3.5 shrink-0', accentText)} />
<span>
Membership is verified per member — nobody can be added to your crew
without confirming from their own account.
</span>
</p>
</div>
)}
</div>
);
}

View file

@ -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<CollectiveMember[]> => {
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<string, NostrEvent>();
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,
});
}

View file

@ -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'] });
},
});
}

View file

@ -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", "<kind>:<founderPubkey>:<d-tag>"] // 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,

View file

@ -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<EntityType>('individual');
const [members, setMembers] = useState<string[]>([]);
const [selectedSpots, setSelectedSpots] = useState<string[]>([]);
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() {
<Textarea id="bio" placeholder="Your background, teaching style..." value={bio} onChange={(e) => setBio(e.target.value)} rows={5} className="bg-gray-800 border-gray-700" />
</div>
<EntityTypeField
entityType={entityType}
onEntityTypeChange={setEntityType}
members={members}
onMembersChange={setMembers}
accentBg="bg-[#ff0040] hover:bg-[#ff0040]/90"
accentText="text-[#ff0040]"
/>
<div className="space-y-3">
<Label>Skills I Teach * (select at least one)</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">

View file

@ -25,7 +25,9 @@ import {
EDITOR_SKILL_LABELS,
EDITOR_SKILL_DESCRIPTIONS,
isValidNoffer,
type EntityType,
} from '@/lib/types';
import { EntityTypeField } from '@/components/EntityTypeField';
import { cn } from '@/lib/utils';
import { Scissors, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Monitor, Zap } from 'lucide-react';
import { PlatformFeePayment } from '@/components/PlatformFeePayment';
@ -68,6 +70,8 @@ export default function BecomeEditorPage() {
const [portfolio, setPortfolio] = useState('');
const [social, setSocial] = useState('');
const [noffer, setNoffer] = useState('');
const [entityType, setEntityType] = useState<EntityType>('individual');
const [members, setMembers] = useState<string[]>([]);
const [hardware, setHardware] = useState('');
const [software, setSoftware] = useState('');
const [isGettingLocation, setIsGettingLocation] = useState(false);
@ -156,6 +160,11 @@ export default function BecomeEditorPage() {
if (portfolio.trim()) tags.push(['portfolio', portfolio.trim()]);
if (social.trim()) tags.push(['social', social.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]);
}
if (hardware.trim()) tags.push(['hardware', hardware.trim()]);
if (software.trim()) tags.push(['software', software.trim()]);
@ -298,6 +307,15 @@ export default function BecomeEditorPage() {
<Textarea id="bio" placeholder="Your editing style, background, notable projects…" value={bio} onChange={(e) => setBio(e.target.value)} rows={5} className="bg-gray-800 border-gray-700" />
</div>
<EntityTypeField
entityType={entityType}
onEntityTypeChange={setEntityType}
members={members}
onMembersChange={setMembers}
accentBg="bg-purple-600 hover:bg-purple-700"
accentText="text-purple-400"
/>
{/* Edit Types */}
<div className="space-y-3">
<Label>Edit Types Offered * (select at least one)</Label>

View file

@ -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_FILMER_KIND, SKATE_SPOT_KIND, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, FILMING_SKILL_DESCRIPTIONS, isValidNoffer } from '@/lib/types';
import { EntityTypeField } from '@/components/EntityTypeField';
import { SKATE_FILMER_KIND, SKATE_SPOT_KIND, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, FILMING_SKILL_DESCRIPTIONS, isValidNoffer, type EntityType } from '@/lib/types';
import { cn } from '@/lib/utils';
import { Video, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Zap } from 'lucide-react';
import { PlatformFeePayment } from '@/components/PlatformFeePayment';
@ -63,6 +64,8 @@ export default function BecomeFilmerPage() {
const [portfolio, setPortfolio] = useState('');
const [social, setSocial] = useState('');
const [noffer, setNoffer] = useState('');
const [entityType, setEntityType] = useState<EntityType>('individual');
const [members, setMembers] = useState<string[]>([]);
const [gear, setGear] = useState('');
const [selectedSpots, setSelectedSpots] = useState<string[]>([]);
const [isGettingLocation, setIsGettingLocation] = useState(false);
@ -169,6 +172,11 @@ export default function BecomeFilmerPage() {
if (portfolio.trim()) tags.push(['portfolio', portfolio.trim()]);
if (social.trim()) tags.push(['social', social.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]);
}
if (gear.trim()) tags.push(['gear', gear.trim()]);
// Add spot references
@ -324,6 +332,15 @@ export default function BecomeFilmerPage() {
<Textarea id="bio" placeholder="Your background, style, equipment..." value={bio} onChange={(e) => setBio(e.target.value)} rows={5} className="bg-gray-800 border-gray-700" />
</div>
<EntityTypeField
entityType={entityType}
onEntityTypeChange={setEntityType}
members={members}
onMembersChange={setMembers}
accentBg="bg-blue-600 hover:bg-blue-700"
accentText="text-blue-400"
/>
<div className="space-y-3">
<Label>Services Offered * (select at least one)</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">

View file

@ -19,6 +19,8 @@ import { WalletModal } from '@/components/WalletModal';
import { LoginArea } from '@/components/auth/LoginArea';
import { SpotPicker } from '@/components/SpotPicker';
import { NofferField } from '@/components/NofferField';
import { EntityTypeField } from '@/components/EntityTypeField';
import type { EntityType } from '@/lib/types';
import {
SKATE_PHOTOGRAPHER_KIND,
SKATE_SPOT_KIND,
@ -111,6 +113,8 @@ export default function BecomePhotographerPage() {
const [portfolio, setPortfolio] = useState('');
const [social, setSocial] = useState('');
const [noffer, setNoffer] = useState('');
const [entityType, setEntityType] = useState<EntityType>('individual');
const [members, setMembers] = useState<string[]>([]);
const [selectedSpots, setSelectedSpots] = useState<string[]>([]);
const [isGettingLocation, setIsGettingLocation] = useState(false);
@ -221,6 +225,11 @@ export default function BecomePhotographerPage() {
if (social.trim()) tags.push(['social', social.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]);
}
for (const spotId of selectedSpots) {
const [spotPubkey, spotIdentifier] = spotId.split(':');
tags.push(['a', `${SKATE_SPOT_KIND}:${spotPubkey}:${spotIdentifier}`]);
@ -365,6 +374,16 @@ export default function BecomePhotographerPage() {
<Textarea id="bio" placeholder="Your background, style, what you love shooting..." value={bio} onChange={(e) => setBio(e.target.value)} rows={5} className="bg-gray-800 border-gray-700" />
</div>
{/* Individual vs Collective */}
<EntityTypeField
entityType={entityType}
onEntityTypeChange={setEntityType}
members={members}
onMembersChange={setMembers}
accentBg="bg-indigo-600"
accentText="text-indigo-400"
/>
{/* Services */}
<div className="space-y-3">
<Label>Services Offered * (select at least one)</Label>

View file

@ -8,6 +8,8 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { ZapButton } from '@/components/ZapButton';
import { ClinkPayButton } from '@/components/ClinkPayButton';
import { CollectiveMembers } from '@/components/CollectiveMembers';
import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
import { TrustBadge } from '@/components/TrustBadge';
import { useLabProfile } from '@/hooks/useLabProfiles';
import { useAuthor } from '@/hooks/useAuthor';
@ -30,6 +32,7 @@ import {
Camera,
Scissors,
Aperture,
Users,
} from 'lucide-react';
import { nip19 } from 'nostr-tools';
@ -102,6 +105,10 @@ export default function CoachDetailPage() {
const TypeIcon = isCoach ? GraduationCap : isFilmer ? Video : isPhotographer ? Camera : Scissors;
// Kind for the collective coordinate (for NIP-32 membership attestations)
const profileKind = isCoach ? 35001 : isFilmer ? 35002 : isEditor ? 35003 : 35004;
const isCollective = profile.entityType === 'collective' && profile.members.length > 0;
// All photographer label maps merged for chip resolution
const photoLabels: Record<string, string> = {
...PHOTO_SERVICE_LABELS, ...PHOTO_CAMERA_LABELS, ...PHOTO_LENS_LABELS,
@ -139,6 +146,18 @@ export default function CoachDetailPage() {
<div className="grid md:grid-cols-3 gap-6">
{/* Main Content */}
<div className="md:col-span-2 space-y-6">
{/* Confirm-membership prompt (only for listed pending members) */}
{isCollective && (
<ConfirmMembershipCard
kind={profileKind}
founderPubkey={profile.pubkey}
identifier={profile.identifier}
collectiveName={profile.title}
claimedMembers={profile.members}
accentBg={`${accent.bg} hover:opacity-90`}
/>
)}
{/* Profile Card */}
<Card className={`bg-black/90 border-2 ${accent.border}`}>
<CardContent className="pt-6">
@ -157,6 +176,11 @@ export default function CoachDetailPage() {
{profile.title}
</h1>
<TypeIcon className={`h-7 w-7 ${accent.text}`} />
{profile.entityType === 'collective' && (
<Badge variant="outline" className={`gap-1 ${accent.text} ${accent.border}`}>
<Users className="h-3 w-3" /> Collective
</Badge>
)}
<TrustBadge pubkey={profile.pubkey} size="md" />
<OfficialBadge pubkey={profile.pubkey} size="md" />
</div>
@ -323,6 +347,18 @@ export default function CoachDetailPage() {
</CardContent>
</Card>
{/* Members Card (collectives only) */}
{isCollective && (
<CollectiveMembers
kind={profileKind}
founderPubkey={profile.pubkey}
identifier={profile.identifier}
claimedMembers={profile.members}
accentText={accent.text}
accentBorder={accent.border}
/>
)}
{/* Location Card */}
{profile.location && (
<Card className="bg-black/90 border-2 border-gray-700">

View file

@ -677,7 +677,18 @@ function ProfileCard({ profile }: { profile: LabProfile }) {
{/* Name row */}
<div className="flex items-start justify-between gap-2 mb-1">
<div className="min-w-0">
<h3 className="font-black text-sm text-white truncate leading-tight">{displayName}</h3>
<h3 className="font-black text-sm text-white truncate leading-tight flex items-center gap-1.5">
<span className="truncate">{displayName}</span>
{profile.entityType === 'collective' && (
<span
className={cn('inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[9px] font-bold flex-shrink-0', accent.badgeSkill)}
title="Collective"
>
<Users className="h-2.5 w-2.5" />
{profile.members.length > 0 ? profile.members.length : ''}
</span>
)}
</h3>
{nip05 && (
<p className={cn('text-[10px] font-bold truncate leading-tight', accent.icon)}>
✓ {nip05}