SkateSpots/src/components/TradeReputationCard.tsx

139 lines
6.7 KiB
TypeScript

import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useTradeReputation } from '@/hooks/useTradeAttestations';
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
import { useBatchActivity, useTradeRings } from '@/hooks/useTradeIntegrity';
import { cn } from '@/lib/utils';
import { Handshake } from 'lucide-react';
import { useTranslation } from '@/contexts/language';
function Stat({ label, value, accent }: { label: string; value: number; accent?: boolean }) {
return (
<div className="rounded-lg border border-white/10 bg-white/[0.03] px-2 py-3">
<p className={cn('text-xl font-black', accent ? 'text-[#ccff00]' : 'text-white')}>{value}</p>
<p className="text-[10px] uppercase tracking-wide text-gray-500">{label}</p>
</div>
);
}
/**
* Marketplace reputation for a profile, derived from mutually-signed trade
* attestations (kind 31871, see NIP.md → Trade Attestations).
*
* "Verified" is the strong signal: the seller AND the counterparty both signed
* an attestation for the same listing. Renders nothing when there are no trades.
*/
export function TradeReputationCard({ pubkey, className }: { pubkey: string; className?: string }) {
const { t } = useTranslation();
const { data, isError } = useTradeReputation(pubkey);
const verifiedWith = data?.verifiedWith ?? [];
const partnerPubkeys = verifiedWith.map((v) => v.pubkey);
// Trust-weight each mutually-confirmed trade by the counterparty's web-of-trust
// score, so a ring of fresh accounts can't inflate reputation.
const { data: scores } = useBatchTrustScores(partnerPubkeys);
// Anti-sybil depth: weight by REAL activity (spots/listings), and look for a
// closed partner cluster (accounts that mostly trade with each other).
const { data: activity } = useBatchActivity(partnerPubkeys);
const { data: rings } = useTradeRings(pubkey, partnerPubkeys);
const total = (data?.sales ?? 0) + (data?.purchases ?? 0);
// A relay outage must not silently read as "no reputation".
if (isError) {
return <p className="text-sm text-amber-400">{t('marketUi.loadFailed')}</p>;
}
if (!data || total === 0) return null;
const people = data.verifiedCounterparties;
// Cap each counterparty's contribution at 3 so one partner — or a colluding
// pair confirming each other over and over — can't inflate the score.
// Activity factor: a fresh account with no published content contributes ~0,
// so a ring of empty accounts can't move the score even if cross-confirmed.
const activityFactor = (pk: string) => Math.min(1, (activity?.get(pk) ?? 0) / 3);
const now = Math.floor(Date.now() / 1000);
const decayFactor = (v: { lastAt: number; recentCount: number }) => {
// 180-day half-life, floored so a good historical record is still visible.
// This changes the ranking weight only; lifetime counts stay unchanged.
const ageDays = Math.max(0, (now - v.lastAt) / 86_400);
return Math.max(0.25, Math.exp(-ageDays / 180));
};
const weighted = verifiedWith.reduce(
(sum, v) =>
sum +
Math.min(v.count, 3) *
((scores?.get(v.pubkey) ?? 0) / 100) *
activityFactor(v.pubkey) *
decayFactor(v),
0,
);
const trustLabel =
weighted >= 5 ? t('marketUi.reputation.highlyTrusted') : weighted >= 1 ? t('marketUi.reputation.building') : t('marketUi.reputation.newTrader');
// The classic fake-reputation pattern: several "trades", all with one account.
const singlePartnerWarning = data.verified >= 3 && people <= 1;
// A bigger ring: lots of confirmations from only a couple of accounts.
const lowDiversityWarning = !singlePartnerWarning && data.verified >= 5 && people <= 2;
return (
<Card className={cn('bg-black/90 border-2 border-[#ff6600]/25', className)}>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg text-[#ff6600]">
<Handshake className="h-5 w-5" />
{t('marketUi.reputation.title')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-3 gap-2 text-center">
<Stat label={t('marketUi.reputation.verified')} value={data.verified} accent />
<Stat label={t('marketUi.reputation.sales')} value={data.sales} />
<Stat label={t('marketUi.reputation.purchases')} value={data.purchases} />
</div>
<p className="text-xs text-gray-500">
{data.verified > 0
? `${data.verified} ${data.verified === 1 ? t('marketUi.reputation.tradeConfirmedOne') : t('marketUi.reputation.tradeConfirmedMany')} ${t('marketUi.reputation.with')} ${people} ${people === 1 ? t('marketUi.reputation.personOne') : t('marketUi.reputation.personMany')}`
: t('marketUi.reputation.awaiting')}
</p>
{data.recentVerified > 0 && (
<p className="text-xs text-gray-500">
<span className="font-bold text-[#ccff00]">{data.recentVerified}</span>{' '}
{t('marketUi.reputation.recentConfirmed')}
</p>
)}
{data.withPaymentProof > 0 && (
<p className="text-xs text-gray-500">
<span className="font-bold text-[#ccff00]">{data.withPaymentProof}</span> {t('marketUi.reputation.withPaymentProof')} <span className="text-gray-600">{t('marketUi.reputation.selfReported')}</span>
</p>
)}
{data.paymentVerified > 0 && (
<p className="text-xs text-gray-500">
<span className="font-bold text-[#ccff00]">{data.paymentVerified}</span> {t('marketUi.reputation.paymentVerified')}
<span className="text-gray-600"> {t('marketUi.reputation.zapReceipt')}</span>
</p>
)}
{singlePartnerWarning && (
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1.5 text-xs text-amber-300">
⚠ {t('marketUi.reputation.singlePartnerWarning')}
</p>
)}
{lowDiversityWarning && (
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1.5 text-xs text-amber-300">
⚠ {t('marketUi.reputation.lowDiversityWarning')}
</p>
)}
{rings?.isRing && (
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1.5 text-xs text-amber-300">
🔁 {t('marketUi.reputation.ringWarning')}
</p>
)}
{data.verified > 0 && (
<p className="text-xs text-gray-500">
{t('marketUi.reputation.weighted')} <span className="font-bold text-[#ccff00]">{weighted.toFixed(1)}</span>
{' · '}
{trustLabel}
</p>
)}
<p className="text-[10px] leading-tight text-gray-600">
{t('marketUi.reputation.footnote')}
</p>
</CardContent>
</Card>
);
}