The marketplace Reputation tab showed a bare 'Loading...' text under 'Reviews & trade records', and TradeRecommendationsCard rendered its 'No recommendations yet.' card while still loading (then it vanished for a self/empty view) — together they read as broken. - Reviews/trade-records now render row-shaped skeletons while loading. - TradeRecommendationsCard renders nothing while its query is loading when the count is 0 (and only shows for a non-self viewer who can add one).
154 lines
7.1 KiB
TypeScript
154 lines
7.1 KiB
TypeScript
import { dateLocaleCode } from '@/lib/dateFnsI18n';
|
|
import { Link } from 'react-router-dom';
|
|
import type { NostrEvent } from '@nostrify/nostrify';
|
|
import { Star, ThumbsUp, ArrowUpRight } from 'lucide-react';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
import { TradeReputationCard } from '@/components/TradeReputationCard';
|
|
import { TradeRecommendationsCard } from '@/components/TradeRecommendationsCard';
|
|
import { DeckRating } from '@/components/DeckRating';
|
|
import { useTradeReputation, TRADE_METHODS, type TradeRole } from '@/hooks/useTradeAttestations';
|
|
import { useAuthor } from '@/hooks/useAuthor';
|
|
import { genUserName } from '@/lib/genUserName';
|
|
import { cn } from '@/lib/utils';
|
|
import { useTranslation } from '@/contexts/language';
|
|
|
|
/** One trade record / review row. */
|
|
function ReviewRow({ ev, selfPubkey }: { ev: NostrEvent; selfPubkey: string }) {
|
|
const { t } = useTranslation();
|
|
const role = ev.tags.find(([t]) => t === 'd')?.[1]?.split(':')[2] as TradeRole | undefined;
|
|
const counterparty = ev.tags.find(([t]) => t === 'p')?.[1] ?? '';
|
|
const method = ev.tags.find(([t]) => t === 'method')?.[1];
|
|
const amount = ev.tags.find(([t]) => t === 'amount')?.[1];
|
|
const rating = Number(ev.tags.find(([t]) => t === 'rating')?.[1] ?? 0);
|
|
const coord = ev.tags.find(([t]) => t === 'a')?.[1] ?? '';
|
|
const [, listingPubkey, listingId] = coord.split(':');
|
|
|
|
const author = useAuthor(counterparty);
|
|
const name = author.data?.metadata?.name ?? genUserName(counterparty);
|
|
const picture = author.data?.metadata?.picture;
|
|
|
|
const given = ev.pubkey === selfPubkey;
|
|
const date = new Date(ev.created_at * 1000).toLocaleDateString(dateLocaleCode());
|
|
|
|
return (
|
|
<div className="flex items-start gap-3 rounded-xl border border-white/8 bg-white/[0.03] p-3">
|
|
<Avatar className="h-9 w-9 shrink-0">
|
|
<AvatarImage src={picture} alt={name} />
|
|
<AvatarFallback className="bg-white/10 text-[10px] font-bold text-gray-300">
|
|
{name.slice(0, 2).toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-center gap-1.5 text-sm">
|
|
<Link to={`/${encodeURIComponent(counterparty)}`} className="font-bold text-white hover:text-[#ccff00]">
|
|
{name}
|
|
</Link>
|
|
<span
|
|
className={cn(
|
|
'rounded px-1.5 py-0.5 text-[9px] font-black uppercase tracking-wide',
|
|
role === 'sold' ? 'bg-[#ccff00]/15 text-[#ccff00]' : 'bg-sky-500/15 text-sky-300',
|
|
)}
|
|
>
|
|
{role === 'sold' ? t('marketUi.trades.roleSold') : t('marketUi.trades.roleBought')}
|
|
</span>
|
|
{!given && <span className="text-[9px] font-bold uppercase tracking-wide text-emerald-400">★ {t('marketUi.reputationPanel.reviewOfYou')}</span>}
|
|
{given && <span className="text-[9px] font-bold uppercase tracking-wide text-gray-500">{t('marketUi.reputationPanel.yourRecord')}</span>}
|
|
{rating > 0 && <DeckRating value={rating} compact className="ml-auto" />}
|
|
</div>
|
|
{ev.content && <p className="mt-1 line-clamp-2 text-xs text-gray-300">{ev.content}</p>}
|
|
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[11px] text-gray-500">
|
|
{method && <span>{TRADE_METHODS[method as keyof typeof TRADE_METHODS] ?? method}</span>}
|
|
{amount && <span>{Number(amount).toLocaleString()} {t('common.sats')}</span>}
|
|
<span>{date}</span>
|
|
{listingPubkey && listingId && (
|
|
<Link to={`/listing/${listingPubkey}/${listingId}`} className="inline-flex items-center gap-0.5 text-gray-400 hover:text-[#ccff00]">
|
|
{t('marketUi.reputationPanel.listing')} <ArrowUpRight className="h-3 w-3" />
|
|
</Link>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Loading placeholder shaped like a review row. */
|
|
function ReviewSkeleton() {
|
|
return (
|
|
<div className="flex items-start gap-3 rounded-xl border border-white/8 bg-white/[0.03] p-3">
|
|
<Skeleton className="h-9 w-9 shrink-0 rounded-full" />
|
|
<div className="min-w-0 flex-1 space-y-2">
|
|
<Skeleton className="h-3.5 w-40" />
|
|
<Skeleton className="h-3 w-full" />
|
|
<Skeleton className="h-3 w-24" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Marketplace → Reputation tab: this trader's reputation, peer recommendations,
|
|
* and their trade records / reviews (from kind-31871 attestations).
|
|
*/
|
|
export function TradeReputationPanel({ pubkey }: { pubkey: string }) {
|
|
const { t } = useTranslation();
|
|
const { data, isLoading } = useTradeReputation(pubkey);
|
|
const attestations = [...(data?.attestations ?? [])].sort((a, b) => b.created_at - a.created_at);
|
|
const reviews = attestations.filter((a) => a.pubkey !== pubkey);
|
|
const ratings = attestations
|
|
.map((a) => Number(a.tags.find(([t]) => t === 'rating')?.[1] ?? 0))
|
|
.filter((n) => n >= 1 && n <= 10);
|
|
const avg = ratings.length ? ratings.reduce((s, n) => s + n, 0) / ratings.length : 0;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<TradeReputationCard pubkey={pubkey} />
|
|
{avg > 0 && (
|
|
<div className="flex items-center gap-3 rounded-xl border border-[#ccff00]/20 bg-[#ccff00]/5 px-3 py-2.5">
|
|
<span className="text-lg font-black tabular-nums text-[#ccff00]">{avg.toFixed(1)}/10</span>
|
|
<DeckRating value={Math.round(avg)} compact className="text-[#ccff00]" />
|
|
<span className="text-xs text-gray-400">
|
|
{ratings.length === 1 ? t('marketUi.reputationPanel.averageOne', { count: ratings.length }) : t('marketUi.reputationPanel.averageMany', { count: ratings.length })}
|
|
</span>
|
|
</div>
|
|
)}
|
|
<TradeRecommendationsCard pubkey={pubkey} />
|
|
|
|
<div>
|
|
<h3 className="mb-3 flex items-center gap-2 text-sm font-black uppercase tracking-wide text-white">
|
|
<Star className="h-4 w-4 text-[#ccff00]" />
|
|
{t('marketUi.reputationPanel.reviewsTitle')}
|
|
{reviews.length > 0 && (
|
|
<span className="rounded-full bg-[#ccff00]/15 px-2 py-0.5 text-[10px] font-bold text-[#ccff00]">
|
|
{t('marketUi.reputationPanel.fromOthers', { count: reviews.length })}
|
|
</span>
|
|
)}
|
|
</h3>
|
|
|
|
{isLoading ? (
|
|
<div className="space-y-2">
|
|
<ReviewSkeleton />
|
|
<ReviewSkeleton />
|
|
<ReviewSkeleton />
|
|
</div>
|
|
) : attestations.length === 0 ? (
|
|
<Card className="border-dashed border-white/10">
|
|
<CardContent className="py-10 text-center">
|
|
<ThumbsUp className="mx-auto mb-3 h-8 w-8 text-gray-600" />
|
|
<p className="text-sm text-gray-400">
|
|
{t('marketUi.reputationPanel.emptyBefore')} <b>{t('marketUi.trades.title')}</b> {t('marketUi.reputationPanel.emptyAfter')}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{attestations.map((ev) => (
|
|
<ReviewRow key={ev.id} ev={ev} selfPubkey={pubkey} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|