Crews: opt-in member visibility + Lab vouch/web-of-trust anti-fraud + CrewDetail redesign

Step 3 — member visibility (best-of-both, honest):
- MemberVisibility ('public' | 'crew') stored on the member's OWN kind-1985
  attestation via a `visibility` tag, so a public crew can contain a private
  member. Confirmed crew-mates + founder see them; outsiders see anonymised
  "hidden member" slots. Framed honestly in-UI (app-level discovery privacy).
- VisibilityPicker in ConfirmMembershipCard (choose on join) +
  MemberVisibilityControl for confirmed members to change it anytime.
- useCollectiveMembers parses visibility; CollectiveMembers hides crew-only
  members from non-members.

Step 4 — anti-fraud vouch layer:
- useVouches/useVouchFor: NIP-32 vouch labels, trust-WEIGHTED via web-of-trust
  so fake-account vouch rings carry no weight. VouchCard with new/vouched/
  scene-verified tiers, wired into every Lab profile detail page.

Step 4 — CrewDetail UX redesign:
- Activity consolidated into clean Feed/Events/Chat tabs (no more giant scroll)
- Sidebar reordered: Members → Support → Band → Connections → Founder
- Member visibility control surfaced; tighter hero

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-08-10 21:01:57 +07:00
parent 0071bcba36
commit 926470a86e
10 changed files with 839 additions and 181 deletions

View file

@ -1,6 +1,6 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { Users, Check, Clock, ShieldCheck } from 'lucide-react'; import { Users, Check, Clock, ShieldCheck, EyeOff } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
@ -11,6 +11,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip'; } from '@/components/ui/tooltip';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import { useCollectiveMembers, type CollectiveMember } from '@/hooks/useCollectiveMembers'; import { useCollectiveMembers, type CollectiveMember } from '@/hooks/useCollectiveMembers';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -58,6 +59,24 @@ function AdminChip() {
); );
} }
function CrewOnlyChip() {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center gap-1 rounded-full bg-white/10 px-2 py-0.5 text-xs font-bold text-gray-300">
<EyeOff className="h-3 w-3" /> Crew only
</span>
</TooltipTrigger>
<TooltipContent>
This member chose to be visible only to crew-mates. You can see them
because you're in the crew.
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
function MemberRow({ member, isAdmin }: { member: CollectiveMember; isAdmin: boolean }) { function MemberRow({ member, isAdmin }: { member: CollectiveMember; isAdmin: boolean }) {
const author = useAuthor(member.pubkey); const author = useAuthor(member.pubkey);
const metadata = author.data?.metadata; const metadata = author.data?.metadata;
@ -74,12 +93,32 @@ function MemberRow({ member, isAdmin }: { member: CollectiveMember; isAdmin: boo
<AvatarFallback className="text-xs">{name.slice(0, 2).toUpperCase()}</AvatarFallback> <AvatarFallback className="text-xs">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar> </Avatar>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-gray-200">{name}</span> <span className="min-w-0 flex-1 truncate text-sm font-medium text-gray-200">{name}</span>
{member.visibility === 'crew' && <CrewOnlyChip />}
{isAdmin && <AdminChip />} {isAdmin && <AdminChip />}
<StatusChip status={member.status} /> <StatusChip status={member.status} />
</Link> </Link>
); );
} }
/** Anonymous placeholder shown to NON-members for a `crew`-only member. */
function HiddenMemberRow() {
return (
<div className="flex items-center gap-3 rounded-lg bg-black/40 p-2">
<Avatar className="h-9 w-9">
<AvatarFallback className="bg-white/5 text-gray-600">
<EyeOff className="h-4 w-4" />
</AvatarFallback>
</Avatar>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-gray-500 italic">
Hidden member
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-white/5 px-2 py-0.5 text-xs font-bold text-gray-500">
<EyeOff className="h-3 w-3" /> Crew only
</span>
</div>
);
}
/** /**
* Renders a collective's members with confirmed / pending trust chips. * Renders a collective's members with confirmed / pending trust chips.
* Returns null for individuals or collectives with no listed members. * Returns null for individuals or collectives with no listed members.
@ -93,6 +132,7 @@ export function CollectiveMembers({
accentText = 'text-indigo-400', accentText = 'text-indigo-400',
accentBorder = 'border-gray-700', accentBorder = 'border-gray-700',
}: CollectiveMembersProps) { }: CollectiveMembersProps) {
const { user } = useCurrentUser();
const { data: members, isLoading } = useCollectiveMembers({ const { data: members, isLoading } = useCollectiveMembers({
kind, kind,
founderPubkey, founderPubkey,
@ -106,6 +146,11 @@ export function CollectiveMembers({
const adminSet = new Set(admins); const adminSet = new Set(admins);
const confirmedCount = members?.filter((m) => m.status === 'confirmed').length ?? 0; const confirmedCount = members?.filter((m) => m.status === 'confirmed').length ?? 0;
// The viewer counts as a crew-mate (can see crew-only members) if they are
// the founder or a claimed member of this crew.
const viewerIsCrewmate =
!!user && (user.pubkey === founderPubkey || claimedMembers.includes(user.pubkey));
// Admins first, then the hook's existing confirmed-first ordering. // Admins first, then the hook's existing confirmed-first ordering.
const orderedMembers = members const orderedMembers = members
? [...members].sort((a, b) => { ? [...members].sort((a, b) => {
@ -115,6 +160,14 @@ export function CollectiveMembers({
}) })
: members; : members;
// Split into what the viewer may see vs. anonymised "hidden member" slots.
// A crew-only member is always visible to themselves.
const isHiddenFromViewer = (m: CollectiveMember) =>
m.visibility === 'crew' && !viewerIsCrewmate && m.pubkey !== user?.pubkey;
const visibleMembers = orderedMembers?.filter((m) => !isHiddenFromViewer(m)) ?? [];
const hiddenCount = orderedMembers?.filter(isHiddenFromViewer).length ?? 0;
return ( return (
<Card className={cn('bg-black/90 border-2', accentBorder)}> <Card className={cn('bg-black/90 border-2', accentBorder)}>
<CardHeader> <CardHeader>
@ -146,9 +199,17 @@ export function CollectiveMembers({
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{orderedMembers?.map((m) => ( {visibleMembers.map((m) => (
<MemberRow key={m.pubkey} member={m} isAdmin={adminSet.has(m.pubkey)} /> <MemberRow key={m.pubkey} member={m} isAdmin={adminSet.has(m.pubkey)} />
))} ))}
{/* Collapse all crew-only members into anonymous slots for outsiders */}
{hiddenCount > 0 && (
<>
{Array.from({ length: hiddenCount }).map((_, i) => (
<HiddenMemberRow key={`hidden-${i}`} />
))}
</>
)}
</div> </div>
)} )}
</CardContent> </CardContent>

View file

@ -1,4 +1,5 @@
import { Users, Check, Loader2 } from 'lucide-react'; import { useState } from 'react';
import { Users, Check, Loader2, Globe, EyeOff } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
@ -6,6 +7,10 @@ import { useCollectiveMembers } from '@/hooks/useCollectiveMembers';
import { useConfirmMembership } from '@/hooks/useMembershipAttestation'; import { useConfirmMembership } from '@/hooks/useMembershipAttestation';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import {
MEMBER_VISIBILITY_DESCRIPTIONS,
type MemberVisibility,
} from '@/lib/types';
interface ConfirmMembershipCardProps { interface ConfirmMembershipCardProps {
kind: number; kind: number;
@ -16,10 +21,55 @@ interface ConfirmMembershipCardProps {
accentBg?: string; accentBg?: string;
} }
/** Two-option visibility picker used both when confirming and when editing. */
export function VisibilityPicker({
value,
onChange,
accentBg = 'bg-indigo-600',
}: {
value: MemberVisibility;
onChange: (v: MemberVisibility) => void;
accentBg?: string;
}) {
const options: { key: MemberVisibility; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
{ key: 'public', label: 'Public', icon: Globe },
{ key: 'crew', label: 'Crew only', icon: EyeOff },
];
return (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
{options.map(({ key, label, icon: Icon }) => {
const active = value === key;
return (
<button
key={key}
type="button"
onClick={() => onChange(key)}
className={cn(
'flex items-center justify-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-bold transition-all active:scale-95',
active
? cn('border-transparent text-white', accentBg)
: 'border-white/10 bg-white/5 text-gray-400 hover:bg-white/10 hover:text-white'
)}
>
<Icon className="h-4 w-4" />
{label}
</button>
);
})}
</div>
<p className="text-xs leading-relaxed text-gray-500">
{MEMBER_VISIBILITY_DESCRIPTIONS[value]}
</p>
</div>
);
}
/** /**
* Shown to a logged-in user who has been LISTED as a member of a collective but * 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 * has not yet confirmed. Lets them pick their visibility and publishes a NIP-32
* everyone else (non-members, already-confirmed members, the founder). * attestation on click. Hidden for everyone else (non-members, already-confirmed
* members, the founder).
*/ */
export function ConfirmMembershipCard({ export function ConfirmMembershipCard({
kind, kind,
@ -32,6 +82,7 @@ export function ConfirmMembershipCard({
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { toast } = useToast(); const { toast } = useToast();
const { mutateAsync: confirm, isPending } = useConfirmMembership(); const { mutateAsync: confirm, isPending } = useConfirmMembership();
const [visibility, setVisibility] = useState<MemberVisibility>('public');
const { data: members } = useCollectiveMembers({ const { data: members } = useCollectiveMembers({
kind, kind,
founderPubkey, founderPubkey,
@ -52,10 +103,13 @@ export function ConfirmMembershipCard({
const handleConfirm = async () => { const handleConfirm = async () => {
try { try {
await confirm({ kind, founderPubkey, identifier, collectiveName }); await confirm({ kind, founderPubkey, identifier, collectiveName, visibility });
toast({ toast({
title: 'Membership confirmed!', title: 'Membership confirmed!',
description: `You now show as a verified member of ${collectiveName}.`, description:
visibility === 'crew'
? `You're verified in ${collectiveName} — visible to crew-mates only.`
: `You now show as a verified member of ${collectiveName}.`,
}); });
} catch (error) { } catch (error) {
toast({ toast({
@ -68,7 +122,7 @@ export function ConfirmMembershipCard({
return ( return (
<Card className="border-2 border-amber-500/50 bg-amber-950/20"> <Card className="border-2 border-amber-500/50 bg-amber-950/20">
<CardContent className="space-y-3 p-4"> <CardContent className="space-y-4 p-4">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Users className="mt-0.5 h-6 w-6 shrink-0 text-amber-400" /> <Users className="mt-0.5 h-6 w-6 shrink-0 text-amber-400" />
<div> <div>
@ -78,6 +132,14 @@ export function ConfirmMembershipCard({
</p> </p>
</div> </div>
</div> </div>
<div>
<p className="mb-2 text-xs font-black uppercase tracking-widest text-gray-400">
Your visibility
</p>
<VisibilityPicker value={visibility} onChange={setVisibility} accentBg={accentBg.split(' ')[0]} />
</div>
<Button <Button
type="button" type="button"
onClick={handleConfirm} onClick={handleConfirm}

View file

@ -0,0 +1,110 @@
import { useEffect, useState } from 'react';
import { Loader2, EyeOff, Globe } 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 { VisibilityPicker } from '@/components/ConfirmMembershipCard';
import { cn } from '@/lib/utils';
import type { MemberVisibility } from '@/lib/types';
interface Props {
kind: number;
founderPubkey: string;
identifier: string;
collectiveName: string;
claimedMembers: string[];
accentBg?: string;
}
/**
* Shown to a CONFIRMED member so they can change their visibility (public vs
* crew-only) at any time. Re-publishes their kind-1985 attestation with the new
* `visibility` tag. Hidden for the founder, non-members, and pending members
* (those use the ConfirmMembershipCard instead).
*/
export function MemberVisibilityControl({
kind,
founderPubkey,
identifier,
collectiveName,
claimedMembers,
accentBg = 'bg-teal-500 hover:bg-teal-600',
}: Props) {
const { user } = useCurrentUser();
const { toast } = useToast();
const { mutateAsync: republish, isPending } = useConfirmMembership();
const { data: members } = useCollectiveMembers({
kind,
founderPubkey,
identifier,
claimedMembers,
enabled: claimedMembers.length > 0,
});
const me = user?.pubkey.toLowerCase();
const mine = members?.find((m) => m.pubkey === me);
const [visibility, setVisibility] = useState<MemberVisibility>('public');
// Sync local state to the published value once it loads.
useEffect(() => {
if (mine?.visibility) setVisibility(mine.visibility);
}, [mine?.visibility]);
if (!user || !me) return null;
if (me === founderPubkey.toLowerCase()) return null;
// Only for confirmed members.
if (!mine || mine.status !== 'confirmed') return null;
const changed = visibility !== (mine.visibility ?? 'public');
const handleSave = async () => {
try {
await republish({ kind, founderPubkey, identifier, collectiveName, visibility });
toast({
title: 'Visibility updated',
description:
visibility === 'crew'
? 'You now appear only to crew-mates.'
: 'You now appear publicly in this crew.',
});
} catch (error) {
toast({
title: 'Could not update',
description: error instanceof Error ? error.message : 'Please try again.',
variant: 'destructive',
});
}
};
return (
<Card className="border border-white/10 bg-black/60">
<CardContent className="space-y-3 p-4">
<div className="flex items-center gap-2">
{(mine.visibility ?? 'public') === 'crew' ? (
<EyeOff className="h-4 w-4 text-gray-400" />
) : (
<Globe className="h-4 w-4 text-gray-400" />
)}
<p className="text-xs font-black uppercase tracking-widest text-gray-400">
Your visibility in this crew
</p>
</div>
<VisibilityPicker value={visibility} onChange={setVisibility} accentBg={accentBg.split(' ')[0]} />
{changed && (
<Button
type="button"
onClick={handleSave}
disabled={isPending}
className={cn('w-full gap-2 font-bold', accentBg)}
>
{isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
Save visibility
</Button>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,176 @@
import { ShieldCheck, Loader2, HeartHandshake, Users } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useVouches, useVouchFor } from '@/hooks/useVouches';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
interface VouchCardProps {
/** Provider coordinate `kind:pubkey:d`. */
coord: string;
/** Provider pubkey (to hide the button for the owner). */
providerPubkey: string;
/** Provider display name. */
providerName: string;
accentText?: string;
accentBg?: string;
}
function VoucherAvatar({ pubkey }: { pubkey: string }) {
const author = useAuthor(pubkey);
const meta = author.data?.metadata;
const name = meta?.name ?? genUserName(pubkey);
const npub = nip19.npubEncode(pubkey);
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Link to={`/${npub}`}>
<Avatar className="h-7 w-7 border border-white/10">
<AvatarImage src={meta?.picture} alt={name} />
<AvatarFallback className="text-[10px]">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
</Link>
</TooltipTrigger>
<TooltipContent>{name}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
/**
* The community anti-fraud primitive. Skaters vouch for real providers; the
* verdict is trust-weighted so fake profiles with only cold/fake vouches read as
* "new / unvouched" while genuinely-known people surface as scene-verified.
*/
export function VouchCard({
coord,
providerPubkey,
providerName,
accentText = 'text-teal-400',
accentBg = 'bg-teal-500 hover:bg-teal-600',
}: VouchCardProps) {
const { user } = useCurrentUser();
const { data, isLoading } = useVouches(coord);
const { mutateAsync: vouch, isPending } = useVouchFor();
const { toast } = useToast();
const isOwner = user?.pubkey.toLowerCase() === providerPubkey.toLowerCase();
// Trust tier from the trust-weighted score.
const tier =
data.trustedVouchers >= 3 || data.weightedScore >= 150
? 'verified'
: data.count > 0
? 'vouched'
: 'new';
const handleVouch = async () => {
try {
await vouch({ coord, providerName, retract: data.viewerVouched });
toast({
title: data.viewerVouched ? 'Vouch removed' : 'Vouched!',
description: data.viewerVouched
? `You no longer vouch for ${providerName}.`
: `You vouched for ${providerName}. The scene trusts its own.`,
});
} catch (error) {
toast({
title: 'Could not vouch',
description: error instanceof Error ? error.message : 'Please try again.',
variant: 'destructive',
});
}
};
return (
<Card className="border border-white/10 bg-black/60">
<CardContent className="space-y-3 p-4">
<div className="flex items-center justify-between gap-2">
<p className="flex items-center gap-2 text-xs font-black uppercase tracking-widest text-gray-400">
<HeartHandshake className={cn('h-4 w-4', accentText)} /> Scene Vouches
</p>
{tier === 'verified' && (
<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-400">
<ShieldCheck className="h-3 w-3" /> Scene-verified
</span>
)}
{tier === 'vouched' && (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/15 px-2 py-0.5 text-xs font-bold text-amber-400">
<Users className="h-3 w-3" /> Vouched
</span>
)}
{tier === 'new' && (
<span className="inline-flex items-center gap-1 rounded-full bg-white/5 px-2 py-0.5 text-xs font-bold text-gray-500">
New · unvouched
</span>
)}
</div>
{isLoading ? (
<p className="text-xs text-gray-500">Checking the scene…</p>
) : data.count === 0 ? (
<p className="text-xs leading-relaxed text-gray-500">
No vouches yet. Vouches from skaters in your circles are the best signal that
someone's the real deal — not a random who wandered in.
</p>
) : (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<div className="flex -space-x-2">
{data.vouchers.slice(0, 6).map((pk) => (
<VoucherAvatar key={pk} pubkey={pk} />
))}
</div>
<span className="text-sm font-bold text-gray-300">
{data.count} vouch{data.count !== 1 ? 'es' : ''}
</span>
</div>
{data.trustedVouchers > 0 && (
<p className="text-xs text-green-500/80">
🛡 {data.trustedVouchers} from skaters in your web of trust
</p>
)}
</div>
)}
{user && !isOwner && (
<Button
type="button"
onClick={handleVouch}
disabled={isPending}
variant={data.viewerVouched ? 'outline' : 'default'}
className={cn(
'w-full gap-2 font-bold',
data.viewerVouched ? 'border-white/15 text-gray-300' : accentBg
)}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<HeartHandshake className="h-4 w-4" />
)}
{data.viewerVouched ? 'Remove vouch' : `Vouch for ${providerName.split(' ')[0]}`}
</Button>
)}
{isOwner && (
<p className="text-center text-[11px] text-gray-600">
Skaters who know your work can vouch for you here.
</p>
)}
</CardContent>
</Card>
);
}

View file

@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useSkateRelays } from '@/hooks/useSkateRelays'; import { useSkateRelays } from '@/hooks/useSkateRelays';
import { MEMBERSHIP_LABEL_NAMESPACE, MEMBERSHIP_LABEL_VALUE } from '@/lib/types'; import { MEMBERSHIP_LABEL_NAMESPACE, MEMBERSHIP_LABEL_VALUE, parseMemberVisibility, type MemberVisibility } from '@/lib/types';
import type { NostrEvent } from '@nostrify/nostrify'; import type { NostrEvent } from '@nostrify/nostrify';
export type MembershipStatus = 'confirmed' | 'pending'; export type MembershipStatus = 'confirmed' | 'pending';
@ -10,6 +10,12 @@ export interface CollectiveMember {
status: MembershipStatus; status: MembershipStatus;
/** The confirming label event (kind 1985), if the member has attested. */ /** The confirming label event (kind 1985), if the member has attested. */
attestation?: NostrEvent; attestation?: NostrEvent;
/**
* Member's opt-in visibility from their own attestation. `public` (default)
* or `crew` (hidden from non-members). Pending members are treated as
* `public` until they attest with a choice.
*/
visibility: MemberVisibility;
} }
interface UseCollectiveMembersParams { interface UseCollectiveMembersParams {
@ -96,10 +102,14 @@ export function useCollectiveMembers({
// Produce a status for every claimed member, confirmed first. // Produce a status for every claimed member, confirmed first.
const members: CollectiveMember[] = claimedMembers.map((pubkey) => { const members: CollectiveMember[] = claimedMembers.map((pubkey) => {
const attestation = confirmed.get(pubkey); const attestation = confirmed.get(pubkey);
const visibility = parseMemberVisibility(
attestation?.tags.find(([n]) => n === 'visibility')?.[1]
);
return { return {
pubkey, pubkey,
status: attestation ? 'confirmed' : 'pending', status: attestation ? 'confirmed' : 'pending',
attestation, attestation,
visibility,
}; };
}); });

View file

@ -4,6 +4,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { import {
MEMBERSHIP_LABEL_NAMESPACE, MEMBERSHIP_LABEL_NAMESPACE,
MEMBERSHIP_LABEL_VALUE, MEMBERSHIP_LABEL_VALUE,
type MemberVisibility,
} from '@/lib/types'; } from '@/lib/types';
interface AttestParams { interface AttestParams {
@ -17,6 +18,8 @@ interface AttestParams {
collectiveName?: string; collectiveName?: string;
/** Relay hint for the referenced collective. */ /** Relay hint for the referenced collective. */
relayHint?: string; relayHint?: string;
/** Opt-in visibility choice — defaults to 'public' if omitted. */
visibility?: MemberVisibility;
} }
/** /**
@ -36,24 +39,28 @@ export function useConfirmMembership() {
identifier, identifier,
collectiveName, collectiveName,
relayHint, relayHint,
visibility = 'public',
}: AttestParams) => { }: AttestParams) => {
if (!user) throw new Error('You must be logged in to confirm membership'); if (!user) throw new Error('You must be logged in to confirm membership');
const address = `${kind}:${founderPubkey}:${identifier}`; const address = `${kind}:${founderPubkey}:${identifier}`;
const aTag = relayHint ? ['a', address, relayHint] : ['a', address]; const aTag = relayHint ? ['a', address, relayHint] : ['a', address];
const tags: string[][] = [
['L', MEMBERSHIP_LABEL_NAMESPACE],
['l', MEMBERSHIP_LABEL_VALUE, MEMBERSHIP_LABEL_NAMESPACE],
aTag,
['p', founderPubkey],
['visibility', visibility],
['alt', `Skate collective membership confirmation for ${collectiveName ?? address}`],
];
const event = await user.signer.signEvent({ const event = await user.signer.signEvent({
kind: 1985, kind: 1985,
content: collectiveName content: collectiveName
? `I'm a member of ${collectiveName}` ? `I'm a member of ${collectiveName}`
: 'Confirmed collective membership', : 'Confirmed collective membership',
tags: [ 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), created_at: Math.floor(Date.now() / 1000),
}); });

146
src/hooks/useVouches.ts Normal file
View file

@ -0,0 +1,146 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
import {
VOUCH_LABEL_NAMESPACE,
VOUCH_LABEL_VALUE,
} from '@/lib/types';
import type { NostrEvent } from '@nostrify/nostrify';
export interface VouchData {
/** All voucher pubkeys (deduped, latest label per author). */
vouchers: string[];
/** Total vouch count. */
count: number;
/**
* Trust-weighted score: sum of each voucher's web-of-trust score (0-100)
* relative to the viewer. A ring of untrusted fakes barely moves this.
*/
weightedScore: number;
/** How many vouchers are in the viewer's web of trust (score > 0). */
trustedVouchers: number;
/** Whether the current viewer has already vouched. */
viewerVouched: boolean;
}
/**
* Query vouches for a Lab profile coordinate (`kind:pubkey:d`) and compute a
* trust-weighted score using the viewer's web of trust.
*/
export function useVouches(coord: string | undefined) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const { user } = useCurrentUser();
const query = useQuery({
queryKey: ['vouches', coord, getQueryKeySuffix()],
queryFn: async (): Promise<string[]> => {
if (!coord) return [];
const signal = AbortSignal.timeout(8000);
const events = await querySkateContent(
[
{
kinds: [1985],
'#a': [coord],
'#l': [VOUCH_LABEL_VALUE],
limit: 500,
},
],
{ signal }
);
// Latest valid vouch per author.
const latest = new Map<string, NostrEvent>();
for (const ev of events) {
const hasNamespace = ev.tags.some(([n, v]) => n === 'L' && v === VOUCH_LABEL_NAMESPACE);
const hasLabel = ev.tags.some(([n, v]) => n === 'l' && v === VOUCH_LABEL_VALUE);
const refs = ev.tags.some(([n, v]) => n === 'a' && v === coord);
if (!hasNamespace || !hasLabel || !refs) continue;
// A retraction is a label with `["l","vouch"]` removed — we simply treat
// the newest event; if its content is "retracted" we drop it.
const author = ev.pubkey.toLowerCase();
const existing = latest.get(author);
if (!existing || ev.created_at > existing.created_at) latest.set(author, ev);
}
// Drop retracted vouches (content marker) and self-vouches.
const [, providerPubkey] = coord.split(':');
const vouchers: string[] = [];
for (const [author, ev] of latest) {
if (ev.content === 'retracted') continue;
if (author === providerPubkey?.toLowerCase()) continue; // no self-vouch
vouchers.push(author);
}
return vouchers;
},
enabled: !!coord,
staleTime: 30000,
});
const vouchers = query.data ?? [];
const { data: trustScores } = useBatchTrustScores(vouchers);
let weightedScore = 0;
let trustedVouchers = 0;
for (const pk of vouchers) {
const score = trustScores?.get(pk) ?? 0;
weightedScore += score;
if (score > 0) trustedVouchers += 1;
}
const data: VouchData = {
vouchers,
count: vouchers.length,
weightedScore,
trustedVouchers,
viewerVouched: !!user && vouchers.includes(user.pubkey.toLowerCase()),
};
return { ...query, data };
}
interface VouchParams {
/** Provider profile coordinate `kind:pubkey:d`. */
coord: string;
/** Provider display name (for readability). */
providerName?: string;
/** Set true to retract a previous vouch. */
retract?: boolean;
}
/** Publish (or retract) a vouch for a Lab provider, signed with the viewer's key. */
export function useVouchFor() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ coord, providerName, retract }: VouchParams) => {
if (!user) throw new Error('You must be logged in to vouch');
const [, providerPubkey] = coord.split(':');
if (providerPubkey?.toLowerCase() === user.pubkey.toLowerCase()) {
throw new Error("You can't vouch for your own profile");
}
const event = await user.signer.signEvent({
kind: 1985,
content: retract ? 'retracted' : `Vouches for ${providerName ?? 'this skater'}`,
tags: [
['L', VOUCH_LABEL_NAMESPACE],
['l', VOUCH_LABEL_VALUE, VOUCH_LABEL_NAMESPACE],
['a', coord],
['p', providerPubkey ?? ''],
['alt', retract ? 'Retracted a SkateSpots vouch' : `Vouches for a SkateSpots provider (${providerName ?? coord})`],
],
created_at: Math.floor(Date.now() / 1000),
});
await nostr.event(event, { signal: AbortSignal.timeout(15000) });
return event;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['vouches'] });
},
});
}

View file

@ -85,6 +85,56 @@ export function parseEntityType(value: string | undefined | null): EntityType {
export const MEMBERSHIP_LABEL_NAMESPACE = 'org.skatespots.membership'; export const MEMBERSHIP_LABEL_NAMESPACE = 'org.skatespots.membership';
export const MEMBERSHIP_LABEL_VALUE = 'member'; export const MEMBERSHIP_LABEL_VALUE = 'member';
/**
* Member visibility — an OPT-IN privacy choice a member makes on their OWN
* membership attestation (kind 1985), so a member can be private even inside a
* public crew. Stored as `["visibility", <level>]` on the member's own label.
*
* - `public` (default / absent): the member shows everywhere — public crew
* page, member counts, crew-mate lists.
* - `crew`: the member is hidden from NON-members. To outsiders they render as
* an anonymous "hidden member" slot; confirmed crew-mates (and the founder)
* still see them normally. This is DISCOVERY privacy: the underlying `p` tag
* on the founder's crew event is still public on relays (that data belongs
* to the founder, not the member, so it can't be encrypted away) — but the
* app never surfaces the member's identity to non-members, and they are
* excluded from public-facing crew-mate discovery.
*
* We deliberately DON'T offer a "cryptographically hidden membership" tier: the
* membership claim lives in the founder's event and cannot be encrypted by the
* member, so promising crypto-privacy there would be misleading. `crew` is
* honest app-level privacy; anyone wanting true anonymity should simply not
* confirm membership publicly.
*/
export type MemberVisibility = 'public' | 'crew';
export const MEMBER_VISIBILITY_LABELS: Record<MemberVisibility, string> = {
public: 'Public',
crew: 'Crew only',
};
export const MEMBER_VISIBILITY_DESCRIPTIONS: Record<MemberVisibility, string> = {
public: 'Anyone can see you in this crew — on the public crew page and in crew-mate discovery.',
crew: "Only confirmed crew-mates and the founder see you here. To everyone else you appear as an anonymous “hidden member”. Note: this is app-level privacy — your membership tag still exists publicly on relays, it's just never shown to outsiders.",
};
/** Parse a member visibility value, defaulting to 'public'. */
export function parseMemberVisibility(v?: string): MemberVisibility {
return v === 'crew' ? 'crew' : 'public';
}
/**
* Lab vouches (NIP-32) — the community's anti-fraud lever. On an open protocol
* anyone can publish a profile claiming to be a physio, artist, etc., so instead
* of trying (and failing) to gatekeep, SkateSpots lets skaters CO-SIGN providers
* they personally vouch for. A voucher publishes a kind-1985 label with their
* OWN key referencing the provider's addressable coordinate. Vouches are counted
* client-side and, crucially, weighted by the viewer's web-of-trust so a ring of
* fake accounts vouching for each other carries no weight with real skaters.
*/
export const VOUCH_LABEL_NAMESPACE = 'org.skatespots.vouch';
export const VOUCH_LABEL_VALUE = 'vouch';
/** /**
* Crew feed/chat moderation (NIP-32). * Crew feed/chat moderation (NIP-32).
* *

View file

@ -10,6 +10,7 @@ import { ZapButton } from '@/components/ZapButton';
import { ClinkPayButton } from '@/components/ClinkPayButton'; import { ClinkPayButton } from '@/components/ClinkPayButton';
import { CollectiveMembers } from '@/components/CollectiveMembers'; import { CollectiveMembers } from '@/components/CollectiveMembers';
import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard'; import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
import { VouchCard } from '@/components/VouchCard';
import { LinkedCrewCard } from '@/components/CrossLinkCards'; import { LinkedCrewCard } from '@/components/CrossLinkCards';
import { TrustBadge } from '@/components/TrustBadge'; import { TrustBadge } from '@/components/TrustBadge';
import { useLabProfile } from '@/hooks/useLabProfiles'; import { useLabProfile } from '@/hooks/useLabProfiles';
@ -506,6 +507,15 @@ export default function CoachDetailPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Scene vouches — community anti-fraud / trust signal */}
<VouchCard
coord={`${profileKind}:${profile.pubkey}:${profile.identifier}`}
providerPubkey={profile.pubkey}
providerName={profile.title}
accentText={accent.text}
accentBg={`${accent.bg} hover:opacity-90`}
/>
{/* Owner controls — delete your own Lab profile */} {/* Owner controls — delete your own Lab profile */}
{user && user.pubkey === profile.pubkey && ( {user && user.pubkey === profile.pubkey && (
<Card className="bg-black/90 border-2 border-red-500/30"> <Card className="bg-black/90 border-2 border-red-500/30">

View file

@ -22,7 +22,9 @@ import { CrewRepBadge } from '@/components/CrewBadge';
import { useCrewEvents } from '@/hooks/useCrewEvents'; import { useCrewEvents } from '@/hooks/useCrewEvents';
import { EventCard } from '@/components/EventCard'; import { EventCard } from '@/components/EventCard';
import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard'; import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
import { MemberVisibilityControl } from '@/components/MemberVisibilityControl';
import { LinkedLabCard } from '@/components/CrossLinkCards'; import { LinkedLabCard } from '@/components/CrossLinkCards';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -335,84 +337,6 @@ export default function CrewDetailPage() {
{/* Crew treasury — hidden for now (kept in code, re-enable by flipping SHOW_CREW_TREASURY) */} {/* Crew treasury — hidden for now (kept in code, re-enable by flipping SHOW_CREW_TREASURY) */}
{SHOW_CREW_TREASURY && crew.goalRef && <CrewTreasuryCard goalId={crew.goalRef} />} {SHOW_CREW_TREASURY && crew.goalRef && <CrewTreasuryCard goalId={crew.goalRef} />}
{/* Crew-hosted events (jams / comps / meetups) */}
{((crewEvents && crewEvents.length > 0) || canManage) && (
<Card className="bg-black/90 border-2 border-[#ccff00]/20">
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
<Calendar className="h-5 w-5 text-[#ccff00]" /> Crew Events
</CardTitle>
{canManage && (
<Link to={`/create-event?crew=${crew.pubkey}:${crew.identifier}`}>
<Button size="sm" className="gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90">
<Calendar className="h-3.5 w-3.5" /> Host a jam
</Button>
</Link>
)}
</CardHeader>
<CardContent className="space-y-3">
{crewEvents && crewEvents.length > 0 ? (
crewEvents.slice(0, 5).map((ev) => (
<EventCard key={ev.id} event={ev} compact />
))
) : (
<p className="py-4 text-center text-sm text-gray-500">
No crew events yet. Host a jam, comp or meetup as the crew — it connects your
crew to the events calendar and lets people zap it.
</p>
)}
</CardContent>
</Card>
)}
{/* Crew feed */}
<Card className="bg-black/90 border-2 border-white/5">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
<MessageSquare className="h-5 w-5 text-teal-400" /> Crew Feed
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{feedLoading ? (
Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-xl border border-white/5 bg-black/40 p-3 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
))
) : (() => {
// Moderators still see hidden posts (greyed via the badge on hide),
// but for everyone the hidden set is filtered out.
const visible = (feed ?? []).filter(
(ev) => canManage || !hiddenIds?.has(ev.id),
);
if (visible.length === 0) {
return (
<p className="text-sm text-gray-500 py-4 text-center">
No recent posts from the crew yet.
</p>
);
}
return visible.map((ev) => (
<div key={ev.id} className={cn(hiddenIds?.has(ev.id) && 'opacity-50')}>
{hiddenIds?.has(ev.id) && (
<p className="mb-1 flex items-center gap-1 text-[11px] font-bold uppercase text-red-400">
<EyeOff className="h-3 w-3" /> Hidden from feed
</p>
)}
<FeedItem
event={ev}
canModerate={canManage && !hiddenIds?.has(ev.id)}
onHide={handleHidePost}
isHiding={isHiding}
/>
</div>
));
})()}
</CardContent>
</Card>
{/* Crew Agent — AI guide, shown when the crew enabled it */} {/* Crew Agent — AI guide, shown when the crew enabled it */}
{crew.agentEnabled && ( {crew.agentEnabled && (
<button <button
@ -438,7 +362,87 @@ export default function CrewDetailPage() {
</button> </button>
)} )}
{/* Crew Chat — private E2E-encrypted group chat for members */} {/* Activity — one clean tabbed surface instead of three stacked cards */}
<Tabs defaultValue="feed" className="w-full">
<TabsList className="grid w-full grid-cols-3 bg-black/60 border border-white/10">
<TabsTrigger value="feed" className="gap-1.5 data-[state=active]:bg-teal-500/20 data-[state=active]:text-teal-300">
<MessageSquare className="h-4 w-4" /> Feed
</TabsTrigger>
<TabsTrigger value="events" className="gap-1.5 data-[state=active]:bg-[#ccff00]/20 data-[state=active]:text-[#ccff00]">
<Calendar className="h-4 w-4" /> Events
{crewEvents && crewEvents.length > 0 && (
<span className="ml-0.5 rounded-full bg-white/10 px-1.5 text-[10px]">{crewEvents.length}</span>
)}
</TabsTrigger>
<TabsTrigger value="chat" className="gap-1.5 data-[state=active]:bg-teal-500/20 data-[state=active]:text-teal-300">
<MessageSquare className="h-4 w-4" /> Chat
</TabsTrigger>
</TabsList>
{/* Feed */}
<TabsContent value="feed" className="mt-4 space-y-3">
{feedLoading ? (
Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-xl border border-white/5 bg-black/40 p-3 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
))
) : (() => {
const visible = (feed ?? []).filter(
(ev) => canManage || !hiddenIds?.has(ev.id),
);
if (visible.length === 0) {
return (
<Card className="border-dashed border-white/10 bg-black/40">
<CardContent className="py-10 text-center text-sm text-gray-500">
No recent posts from the crew yet.
</CardContent>
</Card>
);
}
return visible.map((ev) => (
<div key={ev.id} className={cn(hiddenIds?.has(ev.id) && 'opacity-50')}>
{hiddenIds?.has(ev.id) && (
<p className="mb-1 flex items-center gap-1 text-[11px] font-bold uppercase text-red-400">
<EyeOff className="h-3 w-3" /> Hidden from feed
</p>
)}
<FeedItem
event={ev}
canModerate={canManage && !hiddenIds?.has(ev.id)}
onHide={handleHidePost}
isHiding={isHiding}
/>
</div>
));
})()}
</TabsContent>
{/* Events */}
<TabsContent value="events" className="mt-4 space-y-3">
{canManage && (
<Link to={`/create-event?crew=${crew.pubkey}:${crew.identifier}`} className="block">
<Button size="sm" className="w-full gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90">
<Calendar className="h-3.5 w-3.5" /> Host a jam
</Button>
</Link>
)}
{crewEvents && crewEvents.length > 0 ? (
crewEvents.slice(0, 8).map((ev) => <EventCard key={ev.id} event={ev} compact />)
) : (
<Card className="border-dashed border-white/10 bg-black/40">
<CardContent className="py-10 text-center text-sm text-gray-500">
No crew events yet. Host a jam, comp or meetup as the crew — it connects your
crew to the events calendar and lets people zap it.
</CardContent>
</Card>
)}
</TabsContent>
{/* Chat */}
<TabsContent value="chat" className="mt-4">
<CrewChat <CrewChat
crewName={crew.name} crewName={crew.name}
founderPubkey={crew.pubkey} founderPubkey={crew.pubkey}
@ -447,66 +451,45 @@ export default function CrewDetailPage() {
crewCoord={crewCoord} crewCoord={crewCoord}
canModerate={canManage} canModerate={canManage}
/> />
</TabsContent>
</Tabs>
</div> </div>
{/* Sidebar */} {/* Sidebar — ordered: who → support → band → connections → founder */}
<div className="space-y-6"> <div className="space-y-6">
{/* Founder / links / support */} {/* Members (with per-member visibility respected) */}
{hasMembers && (
<CollectiveMembers
kind={SKATE_CREW_KIND}
founderPubkey={crew.pubkey}
identifier={crew.identifier}
claimedMembers={crew.members}
admins={crew.admins}
accentText="text-teal-400"
accentBorder="border-teal-500/40"
/>
)}
{/* A confirmed member can change their own visibility here */}
{hasMembers && (
<MemberVisibilityControl
kind={SKATE_CREW_KIND}
founderPubkey={crew.pubkey}
identifier={crew.identifier}
collectiveName={crew.name}
claimedMembers={crew.members}
accentBg="bg-teal-500 hover:bg-teal-600"
/>
)}
{/* Support the crew */}
<Card className="bg-black/90 border-2 border-teal-500/40"> <Card className="bg-black/90 border-2 border-teal-500/40">
<CardHeader> <CardHeader>
<CardTitle className="text-lg text-teal-400 uppercase font-black flex items-center gap-2"> <CardTitle className="text-lg text-teal-400 uppercase font-black flex items-center gap-2">
<Users className="h-5 w-5" /> Crew <Zap className="h-5 w-5" /> Support
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-2">
<Link to={`/${npub}`} className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors">
<Avatar className="h-6 w-6">
<AvatarImage src={author.data?.metadata?.picture} alt={founderName} />
<AvatarFallback className="text-[10px]">{founderName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<span className="truncate">Started by {founderName}</span>
</Link>
{sanitizeUrl(crew.website) && (
<a href={sanitizeUrl(crew.website)!} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors" title={`Opens: ${urlDomain(crew.website!)}`}>
<Globe className="h-4 w-4 text-teal-400" />
<span className="truncate">{urlDomain(crew.website!)}</span>
</a>
)}
{crew.instagram && (
<a href={`https://instagram.com/${crew.instagram.replace('@', '')}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors">
<Instagram className="h-4 w-4 text-teal-400" />
<span className="truncate">@{crew.instagram.replace('@', '')}</span>
</a>
)}
{crew.socials.map((s, i) => {
const url = sanitizeUrl(s);
return url ? (
<a
key={`${s}-${i}`}
href={url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors"
title={`Opens: ${urlDomain(s)}`}
>
<Globe className="h-4 w-4 text-teal-400" />
<span className="truncate">{urlDomain(s)}</span>
</a>
) : (
<div
key={`${s}-${i}`}
className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded"
>
<Globe className="h-4 w-4 text-teal-400" />
<span className="truncate">{s}</span>
</div>
);
})}
<div className="pt-3 border-t border-gray-700 space-y-2">
{/* Zap the whole crew — split across founder + confirmed members */} {/* Zap the whole crew — split across founder + confirmed members */}
{crewZapRecipients.length > 1 && ( {crewZapRecipients.length > 1 && (
<ZapCrewDialog crewName={crew.name} recipients={crewZapRecipients}> <ZapCrewDialog crewName={crew.name} recipients={crewZapRecipients}>
@ -521,7 +504,6 @@ export default function CrewDetailPage() {
{crew.noffer && ( {crew.noffer && (
<ClinkPayButton noffer={crew.noffer} recipientName={crew.name} className="w-full justify-center" /> <ClinkPayButton noffer={crew.noffer} recipientName={crew.name} className="w-full justify-center" />
)} )}
</div>
</CardContent> </CardContent>
</Card> </Card>
@ -581,19 +563,6 @@ export default function CrewDetailPage() {
</Card> </Card>
)} )}
{/* Members */}
{hasMembers && (
<CollectiveMembers
kind={SKATE_CREW_KIND}
founderPubkey={crew.pubkey}
identifier={crew.identifier}
claimedMembers={crew.members}
admins={crew.admins}
accentText="text-teal-400"
accentBorder="border-teal-500/40"
/>
)}
{/* Linked Lab profiles — hire this crew */} {/* Linked Lab profiles — hire this crew */}
<LinkedLabCard labRefs={crew.labRefs} /> <LinkedLabCard labRefs={crew.labRefs} />
@ -625,6 +594,63 @@ export default function CrewDetailPage() {
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Founder & links (bottom — provenance / external) */}
<Card className="bg-black/90 border border-white/10">
<CardHeader>
<CardTitle className="text-sm text-gray-400 uppercase font-black tracking-widest">
Founder & Links
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Link to={`/${npub}`} className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors">
<Avatar className="h-6 w-6">
<AvatarImage src={author.data?.metadata?.picture} alt={founderName} />
<AvatarFallback className="text-[10px]">{founderName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<span className="truncate">Started by {founderName}</span>
</Link>
{sanitizeUrl(crew.website) && (
<a href={sanitizeUrl(crew.website)!} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors" title={`Opens: ${urlDomain(crew.website!)}`}>
<Globe className="h-4 w-4 text-teal-400" />
<span className="truncate">{urlDomain(crew.website!)}</span>
</a>
)}
{crew.instagram && (
<a href={`https://instagram.com/${crew.instagram.replace('@', '')}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors">
<Instagram className="h-4 w-4 text-teal-400" />
<span className="truncate">@{crew.instagram.replace('@', '')}</span>
</a>
)}
{crew.socials.map((s, i) => {
const url = sanitizeUrl(s);
return url ? (
<a
key={`${s}-${i}`}
href={url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors"
title={`Opens: ${urlDomain(s)}`}
>
<Globe className="h-4 w-4 text-teal-400" />
<span className="truncate">{urlDomain(s)}</span>
</a>
) : (
<div
key={`${s}-${i}`}
className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded"
>
<Globe className="h-4 w-4 text-teal-400" />
<span className="truncate">{s}</span>
</div>
);
})}
</CardContent>
</Card>
</div> </div>
</div> </div>
</div> </div>