SkateSpots/src/components/TradeReputationCard.tsx
SkateSpots 1577ddc86f feat(marketplace): trust-weight trade reputation + in-thread trade confirmation
- Reputation now exposes per-counterparty verified counts; the profile card
  trust-weights each confirmed trade by the counterparty's web-of-trust score
  (fresh-account rings can't inflate it) and labels Highly trusted / Building /
  New trader
- usePendingTradeConfirmations finds sales a seller recorded that name you but
  you haven't mirrored; the 1:1 DM thread shows a 'Did you complete this trade?'
  banner with one-tap Confirm
2026-09-26 20:22:37 +07:00

71 lines
3 KiB
TypeScript

import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useTradeReputation } from '@/hooks/useTradeAttestations';
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
import { cn } from '@/lib/utils';
import { Handshake } from 'lucide-react';
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 { data } = useTradeReputation(pubkey);
const verifiedWith = data?.verifiedWith ?? [];
// 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(verifiedWith.map((v) => v.pubkey));
const total = (data?.sales ?? 0) + (data?.purchases ?? 0);
if (!data || total === 0) return null;
const weighted = verifiedWith.reduce(
(sum, v) => sum + v.count * ((scores?.get(v.pubkey) ?? 0) / 100),
0,
);
const trustLabel =
weighted >= 5 ? 'Highly trusted trader' : weighted >= 1 ? 'Building reputation' : 'New trader';
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" />
Trades
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-3 gap-2 text-center">
<Stat label="Verified" value={data.verified} accent />
<Stat label="Sales" value={data.sales} />
<Stat label="Purchases" value={data.purchases} />
</div>
<p className="text-xs text-gray-500">
{data.verified > 0
? `${data.verified} mutually-confirmed ${data.verified === 1 ? 'trade' : 'trades'}`
: 'Awaiting mutual confirmation'}
{data.counterparties.length > 0 &&
` · ${data.counterparties.length} counterpart${data.counterparties.length === 1 ? 'y' : 'ies'}`}
</p>
{data.verified > 0 && (
<p className="text-xs text-gray-500">
Trust-weighted <span className="font-bold text-[#ccff00]">{weighted.toFixed(1)}</span>
{' · '}
{trustLabel}
</p>
)}
</CardContent>
</Card>
);
}