SkateSpots/src/components/VouchCard.tsx
shakespeare.diy 926470a86e 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>
2026-08-10 21:01:57 +07:00

176 lines
6.2 KiB
TypeScript

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>
);
}