SkateSpots/src/components/TradeRecommendationsCard.tsx
SkateSpots 584561e390 feat(trades): trusted-trader recommendations (31873) + offer DM nudges
- TradeRecommendationsCard on profiles: shows who recommends this trader
  (kind 31873 'Attestor Recommendation') and lets a logged-in viewer recommend
  them. New useTraderRecommendations hook.
- OfferDialog sends a best-effort NIP-17 DM to the seller when an offer is made;
  ListingOffersPanel DMs the buyer when the seller accepts/declines.
- AGENTS.md notes 31873.

Gate: 101 tests, 0 errors/warnings, boot smoke green.
2026-09-26 23:24:23 +07:00

114 lines
4 KiB
TypeScript

import { useState } from 'react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useToast } from '@/hooks/useToast';
import {
useTraderRecommendations,
useMyRecommendation,
useRecommendationActions,
} from '@/hooks/useTraderRecommendations';
import { genUserName } from '@/lib/genUserName';
import { ShieldCheck, Loader2 } from 'lucide-react';
function Recommender({ pubkey }: { pubkey: string }) {
const author = useAuthor(pubkey);
const name = author.data?.metadata?.name ?? genUserName(pubkey);
return (
<Link to={`/${nip19.npubEncode(pubkey)}`} className="flex items-center gap-2">
<Avatar className="h-6 w-6 border border-white/10">
<AvatarImage src={author.data?.metadata?.picture} alt={name} />
<AvatarFallback className="bg-gray-800 text-[10px] text-gray-400">
{name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="truncate text-xs text-gray-300 hover:text-white">{name}</span>
</Link>
);
}
/**
* Trusted-trader recommendations for a profile (kind 31873). Shows who vouches
* for this trader, and lets a logged-in viewer add their own recommendation.
*/
export function TradeRecommendationsCard({ pubkey, className }: { pubkey: string; className?: string }) {
const { user } = useCurrentUser();
const { data: recs, isLoading } = useTraderRecommendations(pubkey);
const { data: mine } = useMyRecommendation(pubkey);
const { recommend } = useRecommendationActions();
const { toast } = useToast();
const [busy, setBusy] = useState(false);
const isSelf = user?.pubkey === pubkey;
const count = recs?.length ?? 0;
// Nothing to show: no recommendations and the viewer can't add one.
if (!isLoading && count === 0 && (!user || isSelf)) return null;
const handleRecommend = async () => {
setBusy(true);
try {
await recommend(pubkey);
toast({ title: 'Recommendation published', description: 'Thanks for vouching.' });
} catch (e) {
toast({
title: 'Could not recommend',
description: e instanceof Error ? e.message : 'Something went wrong.',
variant: 'destructive',
});
} finally {
setBusy(false);
}
};
return (
<Card className={`bg-black/90 border-2 border-[#ff6600]/25 ${className ?? ''}`}>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg text-[#ff6600]">
<ShieldCheck className="h-5 w-5" />
Trusted trader
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-gray-300">
{count > 0 ? (
<>
Recommended by <span className="font-bold text-white">{count}</span> skater
{count === 1 ? '' : 's'}.
</>
) : (
'No recommendations yet.'
)}
</p>
{count > 0 && (
<div className="flex flex-wrap gap-x-4 gap-y-2">
{recs!.slice(0, 8).map((r) => (
<Recommender key={r.id} pubkey={r.recommender} />
))}
</div>
)}
{user && !isSelf && (
mine ? (
<p className="text-xs text-[#ccff00]">You recommend this trader ✓</p>
) : (
<Button
size="sm"
onClick={handleRecommend}
disabled={busy}
className="w-full gap-2 bg-[#ff6600] font-bold text-black hover:bg-[#ff6600]/90"
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <ShieldCheck className="h-4 w-4" />}
Recommend as trusted trader
</Button>
)
)}
</CardContent>
</Card>
);
}