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:
parent
0071bcba36
commit
926470a86e
10 changed files with 839 additions and 181 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
110
src/components/MemberVisibilityControl.tsx
Normal file
110
src/components/MemberVisibilityControl.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
176
src/components/VouchCard.tsx
Normal file
176
src/components/VouchCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
146
src/hooks/useVouches.ts
Normal 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'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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).
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue