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>"],
|
["phone", "<phone>"],
|
||||||
["email", "<email>"],
|
["email", "<email>"],
|
||||||
["website", "<url>"],
|
["website", "<url>"],
|
||||||
|
["entity", "individual"],
|
||||||
["a", "30818:<pubkey>:<d-tag>"],
|
["a", "30818:<pubkey>:<d-tag>"],
|
||||||
["alt", "<human-readable description>"]
|
["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
|
#### Shared Optional Tags
|
||||||
`location`, `g` (geohash), `image`, `experience`, `rate`, `phone`, `email`,
|
`location`, `g` (geohash), `image`, `experience`, `rate`, `phone`, `email`,
|
||||||
`website`, `noffer` (CLINK payment code — see below), `a` (spot references to
|
`website`, `noffer` (CLINK payment code — see below), `entity` (see Entity Type
|
||||||
kind 30818), `alt`.
|
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)
|
### 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 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
|
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
|
* Validate a CLINK Offer code (noffer1...) — a Nostr-native, self-custodial
|
||||||
* static payment code (see https://github.com/shocknet/clink, kind 21001).
|
* static payment code (see https://github.com/shocknet/clink, kind 21001).
|
||||||
|
|
@ -682,6 +711,8 @@ export interface SkateCoach {
|
||||||
website?: string;
|
website?: string;
|
||||||
instagram?: string;
|
instagram?: string;
|
||||||
noffer?: string; // Optional CLINK Offer code (noffer1...) for self-custodial payments
|
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"
|
spotRefs?: string[]; // Array of spot references: "30818:pubkey:d-tag"
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
event: NostrEvent;
|
event: NostrEvent;
|
||||||
|
|
@ -706,6 +737,8 @@ export interface SkateFilmer {
|
||||||
social?: string; // Social media handle or link
|
social?: string; // Social media handle or link
|
||||||
gear?: string; // Freeform "what's in the bag" (cameras, lenses, rigs)
|
gear?: string; // Freeform "what's in the bag" (cameras, lenses, rigs)
|
||||||
noffer?: string; // Optional CLINK Offer code (noffer1...) for self-custodial payments
|
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"
|
spotRefs?: string[]; // Array of spot references: "30818:pubkey:d-tag"
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
event: NostrEvent;
|
event: NostrEvent;
|
||||||
|
|
@ -731,6 +764,8 @@ export interface SkateEditor {
|
||||||
hardware?: string; // e.g. "MacBook Pro M3, Sony FX3"
|
hardware?: string; // e.g. "MacBook Pro M3, Sony FX3"
|
||||||
software?: string; // e.g. "DaVinci Resolve, Premiere"
|
software?: string; // e.g. "DaVinci Resolve, Premiere"
|
||||||
noffer?: string; // Optional CLINK Offer code (noffer1...) for self-custodial payments
|
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;
|
createdAt: number;
|
||||||
event: NostrEvent;
|
event: NostrEvent;
|
||||||
}
|
}
|
||||||
|
|
@ -754,6 +789,8 @@ export interface SkatePhotographer {
|
||||||
social?: string;
|
social?: string;
|
||||||
gear?: string; // Freeform "what's in the bag" (bodies, lenses, lights)
|
gear?: string; // Freeform "what's in the bag" (bodies, lenses, lights)
|
||||||
noffer?: string; // Optional CLINK Offer code (noffer1...) for self-custodial payments
|
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"
|
spotRefs?: string[]; // Array of spot references: "30818:pubkey:d-tag"
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
event: NostrEvent;
|
event: NostrEvent;
|
||||||
|
|
@ -842,6 +879,8 @@ export function parseSkateCoach(event: NostrEvent): SkateCoach | null {
|
||||||
const instagram = event.tags.find(([name]) => name === 'instagram')?.[1];
|
const instagram = event.tags.find(([name]) => name === 'instagram')?.[1];
|
||||||
const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1];
|
const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1];
|
||||||
const noffer = isValidNoffer(nofferTag) ? nofferTag : undefined;
|
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)
|
// Extract spot references (a tags pointing to kind 30818 spots)
|
||||||
const spotRefs = event.tags
|
const spotRefs = event.tags
|
||||||
.filter(([name, value]) => name === 'a' && value?.startsWith('30818:'))
|
.filter(([name, value]) => name === 'a' && value?.startsWith('30818:'))
|
||||||
|
|
@ -864,6 +903,8 @@ export function parseSkateCoach(event: NostrEvent): SkateCoach | null {
|
||||||
website,
|
website,
|
||||||
instagram,
|
instagram,
|
||||||
noffer,
|
noffer,
|
||||||
|
entityType,
|
||||||
|
members,
|
||||||
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
|
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
|
||||||
createdAt: event.created_at,
|
createdAt: event.created_at,
|
||||||
event,
|
event,
|
||||||
|
|
@ -893,6 +934,8 @@ export function parseSkateFilmer(event: NostrEvent): SkateFilmer | null {
|
||||||
const gear = event.tags.find(([name]) => name === 'gear')?.[1];
|
const gear = event.tags.find(([name]) => name === 'gear')?.[1];
|
||||||
const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1];
|
const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1];
|
||||||
const noffer = isValidNoffer(nofferTag) ? nofferTag : undefined;
|
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)
|
// Extract spot references (a tags pointing to kind 30818 spots)
|
||||||
const spotRefs = event.tags
|
const spotRefs = event.tags
|
||||||
.filter(([name, value]) => name === 'a' && value?.startsWith('30818:'))
|
.filter(([name, value]) => name === 'a' && value?.startsWith('30818:'))
|
||||||
|
|
@ -917,6 +960,8 @@ export function parseSkateFilmer(event: NostrEvent): SkateFilmer | null {
|
||||||
social,
|
social,
|
||||||
gear,
|
gear,
|
||||||
noffer,
|
noffer,
|
||||||
|
entityType,
|
||||||
|
members,
|
||||||
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
|
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
|
||||||
createdAt: event.created_at,
|
createdAt: event.created_at,
|
||||||
event,
|
event,
|
||||||
|
|
@ -947,6 +992,8 @@ export function parseSkateEditor(event: NostrEvent): SkateEditor | null {
|
||||||
const software = event.tags.find(([name]) => name === 'software')?.[1];
|
const software = event.tags.find(([name]) => name === 'software')?.[1];
|
||||||
const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1];
|
const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1];
|
||||||
const noffer = isValidNoffer(nofferTag) ? nofferTag : undefined;
|
const noffer = isValidNoffer(nofferTag) ? nofferTag : undefined;
|
||||||
|
const entityType = parseEntityType(event.tags.find(([name]) => name === 'entity')?.[1]);
|
||||||
|
const members = parseMemberPubkeys(event);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
|
|
@ -968,6 +1015,8 @@ export function parseSkateEditor(event: NostrEvent): SkateEditor | null {
|
||||||
hardware,
|
hardware,
|
||||||
software,
|
software,
|
||||||
noffer,
|
noffer,
|
||||||
|
entityType,
|
||||||
|
members,
|
||||||
createdAt: event.created_at,
|
createdAt: event.created_at,
|
||||||
event,
|
event,
|
||||||
};
|
};
|
||||||
|
|
@ -996,6 +1045,8 @@ export function parseSkatePhotographer(event: NostrEvent): SkatePhotographer | n
|
||||||
const gear = event.tags.find(([name]) => name === 'gear')?.[1];
|
const gear = event.tags.find(([name]) => name === 'gear')?.[1];
|
||||||
const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1];
|
const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1];
|
||||||
const noffer = isValidNoffer(nofferTag) ? nofferTag : undefined;
|
const noffer = isValidNoffer(nofferTag) ? nofferTag : undefined;
|
||||||
|
const entityType = parseEntityType(event.tags.find(([name]) => name === 'entity')?.[1]);
|
||||||
|
const members = parseMemberPubkeys(event);
|
||||||
const spotRefs = event.tags
|
const spotRefs = event.tags
|
||||||
.filter(([name, value]) => name === 'a' && value?.startsWith('30818:'))
|
.filter(([name, value]) => name === 'a' && value?.startsWith('30818:'))
|
||||||
.map(([, value]) => value);
|
.map(([, value]) => value);
|
||||||
|
|
@ -1019,6 +1070,8 @@ export function parseSkatePhotographer(event: NostrEvent): SkatePhotographer | n
|
||||||
social,
|
social,
|
||||||
gear,
|
gear,
|
||||||
noffer,
|
noffer,
|
||||||
|
entityType,
|
||||||
|
members,
|
||||||
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
|
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
|
||||||
createdAt: event.created_at,
|
createdAt: event.created_at,
|
||||||
event,
|
event,
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,8 @@ import { WalletModal } from '@/components/WalletModal';
|
||||||
import { LoginArea } from '@/components/auth/LoginArea';
|
import { LoginArea } from '@/components/auth/LoginArea';
|
||||||
import { SpotPicker } from '@/components/SpotPicker';
|
import { SpotPicker } from '@/components/SpotPicker';
|
||||||
import { NofferField } from '@/components/NofferField';
|
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 { cn } from '@/lib/utils';
|
||||||
import { GraduationCap, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Zap } from 'lucide-react';
|
import { GraduationCap, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Zap } from 'lucide-react';
|
||||||
import { PlatformFeePayment } from '@/components/PlatformFeePayment';
|
import { PlatformFeePayment } from '@/components/PlatformFeePayment';
|
||||||
|
|
@ -60,6 +61,8 @@ export default function BecomeCoachPage() {
|
||||||
const [website, setWebsite] = useState('');
|
const [website, setWebsite] = useState('');
|
||||||
const [instagram, setInstagram] = useState('');
|
const [instagram, setInstagram] = useState('');
|
||||||
const [noffer, setNoffer] = useState('');
|
const [noffer, setNoffer] = useState('');
|
||||||
|
const [entityType, setEntityType] = useState<EntityType>('individual');
|
||||||
|
const [members, setMembers] = useState<string[]>([]);
|
||||||
const [selectedSpots, setSelectedSpots] = useState<string[]>([]);
|
const [selectedSpots, setSelectedSpots] = useState<string[]>([]);
|
||||||
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
||||||
|
|
||||||
|
|
@ -155,6 +158,11 @@ export default function BecomeCoachPage() {
|
||||||
if (instagram.trim()) tags.push(['instagram', instagram.trim()]);
|
if (instagram.trim()) tags.push(['instagram', instagram.trim()]);
|
||||||
if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]);
|
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
|
// Add spot references
|
||||||
for (const spotId of selectedSpots) {
|
for (const spotId of selectedSpots) {
|
||||||
const [spotPubkey, spotIdentifier] = spotId.split(':');
|
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" />
|
<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>
|
</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">
|
<div className="space-y-3">
|
||||||
<Label>Skills I Teach * (select at least one)</Label>
|
<Label>Skills I Teach * (select at least one)</Label>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,9 @@ import {
|
||||||
EDITOR_SKILL_LABELS,
|
EDITOR_SKILL_LABELS,
|
||||||
EDITOR_SKILL_DESCRIPTIONS,
|
EDITOR_SKILL_DESCRIPTIONS,
|
||||||
isValidNoffer,
|
isValidNoffer,
|
||||||
|
type EntityType,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
|
import { EntityTypeField } from '@/components/EntityTypeField';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Scissors, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Monitor, Zap } from 'lucide-react';
|
import { Scissors, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Monitor, Zap } from 'lucide-react';
|
||||||
import { PlatformFeePayment } from '@/components/PlatformFeePayment';
|
import { PlatformFeePayment } from '@/components/PlatformFeePayment';
|
||||||
|
|
@ -68,6 +70,8 @@ export default function BecomeEditorPage() {
|
||||||
const [portfolio, setPortfolio] = useState('');
|
const [portfolio, setPortfolio] = useState('');
|
||||||
const [social, setSocial] = useState('');
|
const [social, setSocial] = useState('');
|
||||||
const [noffer, setNoffer] = useState('');
|
const [noffer, setNoffer] = useState('');
|
||||||
|
const [entityType, setEntityType] = useState<EntityType>('individual');
|
||||||
|
const [members, setMembers] = useState<string[]>([]);
|
||||||
const [hardware, setHardware] = useState('');
|
const [hardware, setHardware] = useState('');
|
||||||
const [software, setSoftware] = useState('');
|
const [software, setSoftware] = useState('');
|
||||||
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
||||||
|
|
@ -156,6 +160,11 @@ export default function BecomeEditorPage() {
|
||||||
if (portfolio.trim()) tags.push(['portfolio', portfolio.trim()]);
|
if (portfolio.trim()) tags.push(['portfolio', portfolio.trim()]);
|
||||||
if (social.trim()) tags.push(['social', social.trim()]);
|
if (social.trim()) tags.push(['social', social.trim()]);
|
||||||
if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]);
|
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 (hardware.trim()) tags.push(['hardware', hardware.trim()]);
|
||||||
if (software.trim()) tags.push(['software', software.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" />
|
<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>
|
</div>
|
||||||
|
|
||||||
|
<EntityTypeField
|
||||||
|
entityType={entityType}
|
||||||
|
onEntityTypeChange={setEntityType}
|
||||||
|
members={members}
|
||||||
|
onMembersChange={setMembers}
|
||||||
|
accentBg="bg-purple-600 hover:bg-purple-700"
|
||||||
|
accentText="text-purple-400"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Edit Types */}
|
{/* Edit Types */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>Edit Types Offered * (select at least one)</Label>
|
<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 { LoginArea } from '@/components/auth/LoginArea';
|
||||||
import { SpotPicker } from '@/components/SpotPicker';
|
import { SpotPicker } from '@/components/SpotPicker';
|
||||||
import { NofferField } from '@/components/NofferField';
|
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 { cn } from '@/lib/utils';
|
||||||
import { Video, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Zap } from 'lucide-react';
|
import { Video, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Zap } from 'lucide-react';
|
||||||
import { PlatformFeePayment } from '@/components/PlatformFeePayment';
|
import { PlatformFeePayment } from '@/components/PlatformFeePayment';
|
||||||
|
|
@ -63,6 +64,8 @@ export default function BecomeFilmerPage() {
|
||||||
const [portfolio, setPortfolio] = useState('');
|
const [portfolio, setPortfolio] = useState('');
|
||||||
const [social, setSocial] = useState('');
|
const [social, setSocial] = useState('');
|
||||||
const [noffer, setNoffer] = useState('');
|
const [noffer, setNoffer] = useState('');
|
||||||
|
const [entityType, setEntityType] = useState<EntityType>('individual');
|
||||||
|
const [members, setMembers] = useState<string[]>([]);
|
||||||
const [gear, setGear] = useState('');
|
const [gear, setGear] = useState('');
|
||||||
const [selectedSpots, setSelectedSpots] = useState<string[]>([]);
|
const [selectedSpots, setSelectedSpots] = useState<string[]>([]);
|
||||||
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
||||||
|
|
@ -169,6 +172,11 @@ export default function BecomeFilmerPage() {
|
||||||
if (portfolio.trim()) tags.push(['portfolio', portfolio.trim()]);
|
if (portfolio.trim()) tags.push(['portfolio', portfolio.trim()]);
|
||||||
if (social.trim()) tags.push(['social', social.trim()]);
|
if (social.trim()) tags.push(['social', social.trim()]);
|
||||||
if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]);
|
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()]);
|
if (gear.trim()) tags.push(['gear', gear.trim()]);
|
||||||
|
|
||||||
// Add spot references
|
// 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" />
|
<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>
|
</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">
|
<div className="space-y-3">
|
||||||
<Label>Services Offered * (select at least one)</Label>
|
<Label>Services Offered * (select at least one)</Label>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
<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 { LoginArea } from '@/components/auth/LoginArea';
|
||||||
import { SpotPicker } from '@/components/SpotPicker';
|
import { SpotPicker } from '@/components/SpotPicker';
|
||||||
import { NofferField } from '@/components/NofferField';
|
import { NofferField } from '@/components/NofferField';
|
||||||
|
import { EntityTypeField } from '@/components/EntityTypeField';
|
||||||
|
import type { EntityType } from '@/lib/types';
|
||||||
import {
|
import {
|
||||||
SKATE_PHOTOGRAPHER_KIND,
|
SKATE_PHOTOGRAPHER_KIND,
|
||||||
SKATE_SPOT_KIND,
|
SKATE_SPOT_KIND,
|
||||||
|
|
@ -111,6 +113,8 @@ export default function BecomePhotographerPage() {
|
||||||
const [portfolio, setPortfolio] = useState('');
|
const [portfolio, setPortfolio] = useState('');
|
||||||
const [social, setSocial] = useState('');
|
const [social, setSocial] = useState('');
|
||||||
const [noffer, setNoffer] = useState('');
|
const [noffer, setNoffer] = useState('');
|
||||||
|
const [entityType, setEntityType] = useState<EntityType>('individual');
|
||||||
|
const [members, setMembers] = useState<string[]>([]);
|
||||||
const [selectedSpots, setSelectedSpots] = useState<string[]>([]);
|
const [selectedSpots, setSelectedSpots] = useState<string[]>([]);
|
||||||
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
||||||
|
|
||||||
|
|
@ -221,6 +225,11 @@ export default function BecomePhotographerPage() {
|
||||||
if (social.trim()) tags.push(['social', social.trim()]);
|
if (social.trim()) tags.push(['social', social.trim()]);
|
||||||
if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]);
|
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) {
|
for (const spotId of selectedSpots) {
|
||||||
const [spotPubkey, spotIdentifier] = spotId.split(':');
|
const [spotPubkey, spotIdentifier] = spotId.split(':');
|
||||||
tags.push(['a', `${SKATE_SPOT_KIND}:${spotPubkey}:${spotIdentifier}`]);
|
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" />
|
<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>
|
</div>
|
||||||
|
|
||||||
|
{/* Individual vs Collective */}
|
||||||
|
<EntityTypeField
|
||||||
|
entityType={entityType}
|
||||||
|
onEntityTypeChange={setEntityType}
|
||||||
|
members={members}
|
||||||
|
onMembersChange={setMembers}
|
||||||
|
accentBg="bg-indigo-600"
|
||||||
|
accentText="text-indigo-400"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Services */}
|
{/* Services */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>Services Offered * (select at least one)</Label>
|
<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 { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { ZapButton } from '@/components/ZapButton';
|
import { ZapButton } from '@/components/ZapButton';
|
||||||
import { ClinkPayButton } from '@/components/ClinkPayButton';
|
import { ClinkPayButton } from '@/components/ClinkPayButton';
|
||||||
|
import { CollectiveMembers } from '@/components/CollectiveMembers';
|
||||||
|
import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
|
||||||
import { TrustBadge } from '@/components/TrustBadge';
|
import { TrustBadge } from '@/components/TrustBadge';
|
||||||
import { useLabProfile } from '@/hooks/useLabProfiles';
|
import { useLabProfile } from '@/hooks/useLabProfiles';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
|
|
@ -30,6 +32,7 @@ import {
|
||||||
Camera,
|
Camera,
|
||||||
Scissors,
|
Scissors,
|
||||||
Aperture,
|
Aperture,
|
||||||
|
Users,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { nip19 } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
|
|
||||||
|
|
@ -102,6 +105,10 @@ export default function CoachDetailPage() {
|
||||||
|
|
||||||
const TypeIcon = isCoach ? GraduationCap : isFilmer ? Video : isPhotographer ? Camera : Scissors;
|
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
|
// All photographer label maps merged for chip resolution
|
||||||
const photoLabels: Record<string, string> = {
|
const photoLabels: Record<string, string> = {
|
||||||
...PHOTO_SERVICE_LABELS, ...PHOTO_CAMERA_LABELS, ...PHOTO_LENS_LABELS,
|
...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">
|
<div className="grid md:grid-cols-3 gap-6">
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="md:col-span-2 space-y-6">
|
<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 */}
|
{/* Profile Card */}
|
||||||
<Card className={`bg-black/90 border-2 ${accent.border}`}>
|
<Card className={`bg-black/90 border-2 ${accent.border}`}>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
|
|
@ -157,6 +176,11 @@ export default function CoachDetailPage() {
|
||||||
{profile.title}
|
{profile.title}
|
||||||
</h1>
|
</h1>
|
||||||
<TypeIcon className={`h-7 w-7 ${accent.text}`} />
|
<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" />
|
<TrustBadge pubkey={profile.pubkey} size="md" />
|
||||||
<OfficialBadge pubkey={profile.pubkey} size="md" />
|
<OfficialBadge pubkey={profile.pubkey} size="md" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -323,6 +347,18 @@ export default function CoachDetailPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 */}
|
{/* Location Card */}
|
||||||
{profile.location && (
|
{profile.location && (
|
||||||
<Card className="bg-black/90 border-2 border-gray-700">
|
<Card className="bg-black/90 border-2 border-gray-700">
|
||||||
|
|
|
||||||
|
|
@ -677,7 +677,18 @@ function ProfileCard({ profile }: { profile: LabProfile }) {
|
||||||
{/* Name row */}
|
{/* Name row */}
|
||||||
<div className="flex items-start justify-between gap-2 mb-1">
|
<div className="flex items-start justify-between gap-2 mb-1">
|
||||||
<div className="min-w-0">
|
<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 && (
|
{nip05 && (
|
||||||
<p className={cn('text-[10px] font-bold truncate leading-tight', accent.icon)}>
|
<p className={cn('text-[10px] font-bold truncate leading-tight', accent.icon)}>
|
||||||
✓ {nip05}
|
✓ {nip05}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue