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:
parent
feddbaf5ef
commit
9cb8d1181d
13 changed files with 845 additions and 5 deletions
62
NIP.md
62
NIP.md
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
134
src/components/CollectiveMembers.tsx
Normal file
134
src/components/CollectiveMembers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
src/components/ConfirmMembershipCard.tsx
Normal file
100
src/components/ConfirmMembershipCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
194
src/components/EntityTypeField.tsx
Normal file
194
src/components/EntityTypeField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
src/hooks/useCollectiveMembers.ts
Normal file
116
src/hooks/useCollectiveMembers.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
67
src/hooks/useMembershipAttestation.ts
Normal file
67
src/hooks/useMembershipAttestation.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
@ -155,6 +158,11 @@ export default function BecomeCoachPage() {
|
|||
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) {
|
||||
const [spotPubkey, spotIdentifier] = spotId.split(':');
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue