SkateSpots/src/components/VouchCard.tsx

179 lines
6.4 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 { useTranslation } from '@/contexts/language';
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 { t } = useTranslation();
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 ? t('socialUi.vouch.removedTitle') : t('socialUi.vouch.vouchedTitle'),
description: data.viewerVouched
? t('socialUi.vouch.removedDesc', { name: providerName })
: t('socialUi.vouch.vouchedDesc', { name: providerName }),
});
} catch (error) {
toast({
title: t('socialUi.vouch.couldNotVouch'),
description: error instanceof Error ? error.message : t('common.tryAgain'),
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)} /> {t('socialUi.vouch.title')}
</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" /> {t('socialUi.vouch.sceneVerified')}
</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" /> {t('socialUi.vouch.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">
{t('socialUi.vouch.newUnvouched')}
</span>
)}
</div>
{isLoading ? (
<p className="text-xs text-gray-500">{t('socialUi.vouch.checking')}</p>
) : data.count === 0 ? (
<p className="text-xs leading-relaxed text-gray-500">
{t('socialUi.vouch.empty')}
</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 === 1
? t('socialUi.vouch.countOne', { count: data.count })
: t('socialUi.vouch.countMany', { count: data.count })}
</span>
</div>
{data.trustedVouchers > 0 && (
<p className="text-xs text-green-500/80">
🛡 {t('socialUi.vouch.trusted', { count: data.trustedVouchers })}
</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 ? t('socialUi.vouch.remove') : t('socialUi.vouch.vouchFor', { name: providerName.split(' ')[0] })}
</Button>
)}
{isOwner && (
<p className="text-center text-[11px] text-gray-400">
{t('socialUi.vouch.ownerNote')}
</p>
)}
</CardContent>
</Card>
);
}