SkateSpots/src/pages/TradesPage.tsx
SkateSpots e0e41ae625 feat(trades): revoke/dispute a trade record (s=revoked)
On /trades, each authored trade record gets a 'Revoke' action (with a confirm
dialog) that republishes the 31871 with s=revoked — retracting it from
reputation for a disputed or mistaken deal. The counterparty keeps their own
attestation. Revoked records are already excluded from aggregation.

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

372 lines
14 KiB
TypeScript

import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { nip19 } from 'nostr-tools';
import { Layout } from '@/components/Layout';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { LoginArea } from '@/components/auth/LoginArea';
import { TradeReputationCard } from '@/components/TradeReputationCard';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useToast } from '@/hooks/useToast';
import {
useIncomingTradeRequests,
usePendingTradeConfirmations,
useTradeReputation,
useTradeAttestation,
parseListingCoord,
} from '@/hooks/useTradeAttestations';
import { genUserName } from '@/lib/genUserName';
import { Handshake, CheckCircle, Loader2, Package, ArrowRight } from 'lucide-react';
function PeerName({ pubkey }: { pubkey: string }) {
const author = useAuthor(pubkey);
const name = author.data?.metadata?.name ?? genUserName(pubkey);
return (
<Link to={`/${nip19.npubEncode(pubkey)}`} className="font-bold text-white hover:text-[#ccff00]">
{name}
</Link>
);
}
function listingUrl(pubkey: string, identifier: string): string {
return `/listing/${pubkey}/${identifier}`;
}
/**
* `/trades` — one place to manage marketplace trades: incoming confirmation
* requests (as seller), pending confirmations (as buyer), and your own trade
* attestation records. Mirrors the DM banners, in a dedicated surface.
*/
export default function TradesPage() {
const { user } = useCurrentUser();
const { toast } = useToast();
const { publishTrade, isPending: publishing } = useTradeAttestation();
const { data: incoming, isLoading: loadingIncoming } = useIncomingTradeRequests(user?.pubkey);
const { data: pending, isLoading: loadingPending } = usePendingTradeConfirmations(user?.pubkey);
const { data: reputation, isLoading: loadingRep } = useTradeReputation(user?.pubkey);
const [busy, setBusy] = useState<string | null>(null);
const [revokeTarget, setRevokeTarget] = useState<{
listing: { pubkey: string; identifier: string };
role: 'sold' | 'bought';
counterparty: string;
} | null>(null);
const [revoking, setRevoking] = useState(false);
useSeoMeta({
title: 'My Trades — SkateSpots',
description: 'Manage your marketplace trades, confirmations and reputation.',
});
const handleConfirmSale = async (
listing: { pubkey: string; identifier: string },
requestor: string,
requestCoord: string,
) => {
setBusy(requestCoord);
try {
await publishTrade({
listing,
role: 'sold',
counterparty: requestor,
requestCoord,
});
toast({ title: 'Sale confirmed', description: 'They can now confirm their side.' });
} catch (e) {
toast({
title: 'Could not confirm the sale',
description: e instanceof Error ? e.message : 'Something went wrong.',
variant: 'destructive',
});
} finally {
setBusy(null);
}
};
const handleConfirmPurchase = async (
listing: { pubkey: string; identifier: string },
seller: string,
coord: string,
) => {
setBusy(coord);
try {
await publishTrade({ listing, role: 'bought', counterparty: seller });
toast({ title: 'Trade confirmed', description: 'Reputation updated for both of you.' });
} catch (e) {
toast({
title: 'Could not confirm the trade',
description: e instanceof Error ? e.message : 'Something went wrong.',
variant: 'destructive',
});
} finally {
setBusy(null);
}
};
const handleRevoke = async () => {
if (!revokeTarget) return;
setRevoking(true);
try {
await publishTrade({
listing: revokeTarget.listing,
role: revokeTarget.role,
counterparty: revokeTarget.counterparty,
state: 'revoked',
note: 'Retracted by the author.',
});
toast({
title: 'Trade record revoked',
description: 'It no longer counts toward reputation.',
});
setRevokeTarget(null);
} catch (e) {
toast({
title: 'Could not revoke the trade',
description: e instanceof Error ? e.message : 'Something went wrong.',
variant: 'destructive',
});
} finally {
setRevoking(false);
}
};
if (!user) {
return (
<Layout>
<div className="container mx-auto max-w-md px-4 py-16 text-center space-y-4">
<Handshake className="mx-auto h-10 w-10 text-gray-600" />
<h1 className="text-xl font-black uppercase text-white">My Trades</h1>
<p className="text-sm text-gray-400">
Log in to manage your marketplace trades and reputation.
</p>
<LoginArea className="justify-center" />
</div>
</Layout>
);
}
// Attestations this user authored (their own trade records).
const authored = (reputation?.attestations ?? []).filter((e) => e.pubkey === user.pubkey);
const actionNeeded =
(incoming?.length ?? 0) + (pending?.length ?? 0);
return (
<Layout>
<div className="container mx-auto max-w-3xl px-4 py-8 space-y-6">
<div>
<h1 className="flex items-center gap-3 text-3xl font-black uppercase text-white">
<Handshake className="h-8 w-8 text-[#ff6600]" /> My Trades
</h1>
<p className="mt-1 text-sm text-gray-400">
Confirmations, requests, and the reputation they build. Payment is arranged
with the other skater — the app only records the deal.
</p>
</div>
<TradeReputationCard pubkey={user.pubkey} />
{/* Action needed */}
<section className="space-y-3">
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500">
Action needed {actionNeeded > 0 && `· ${actionNeeded}`}
</h2>
{(loadingIncoming || loadingPending) && (
<div className="space-y-2">
<Skeleton className="h-20 w-full rounded-2xl" />
<Skeleton className="h-20 w-full rounded-2xl" />
</div>
)}
{/* Incoming: someone asked you (seller) to record a sale */}
{incoming?.map((r) => (
<Card key={r.coord} className="border-2 border-[#ff6600]/30 bg-[#ff6600]/5">
<CardContent className="flex items-center gap-3 p-3">
<Handshake className="h-5 w-5 shrink-0 text-[#ff6600]" />
<div className="min-w-0 flex-1">
<p className="text-sm text-white">
<PeerName pubkey={r.requestor} /> asked you to confirm a sale
</p>
<Link
to={listingUrl(r.listing.pubkey, r.listing.identifier)}
className="text-xs text-gray-400 hover:text-white"
>
View listing <ArrowRight className="inline h-3 w-3" />
</Link>
</div>
<Button
size="sm"
onClick={() => handleConfirmSale(r.listing, r.requestor, r.coord)}
disabled={busy === r.coord || publishing}
className="shrink-0 gap-1.5 bg-[#ff6600] font-bold text-black hover:bg-[#ff6600]/90"
>
{busy === r.coord ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle className="h-3.5 w-3.5" />}
Confirm sale
</Button>
</CardContent>
</Card>
))}
{/* Pending: a seller recorded a sale you haven't confirmed */}
{pending?.map((t) => (
<Card key={t.coord} className="border-2 border-[#ccff00]/30 bg-[#ccff00]/5">
<CardContent className="flex items-center gap-3 p-3">
<CheckCircle className="h-5 w-5 shrink-0 text-[#ccff00]" />
<div className="min-w-0 flex-1">
<p className="text-sm text-white">
<PeerName pubkey={t.seller} /> recorded a sale — confirm your side
</p>
<Link
to={listingUrl(t.listing.pubkey, t.listing.identifier)}
className="text-xs text-gray-400 hover:text-white"
>
View listing <ArrowRight className="inline h-3 w-3" />
</Link>
</div>
<Button
size="sm"
onClick={() => handleConfirmPurchase(t.listing, t.seller, t.coord)}
disabled={busy === t.coord || publishing}
className="shrink-0 gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
>
{busy === t.coord ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle className="h-3.5 w-3.5" />}
Confirm
</Button>
</CardContent>
</Card>
))}
{!loadingIncoming && !loadingPending && actionNeeded === 0 && (
<p className="text-sm text-gray-500">Nothing waiting on you right now. 🛹</p>
)}
</section>
{/* Your trade records */}
<section className="space-y-3">
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500">
Your trade records
</h2>
{loadingRep && <Skeleton className="h-16 w-full rounded-2xl" />}
{!loadingRep && authored.length === 0 && (
<Card className="border-dashed border-white/15 bg-black/60">
<CardContent className="py-10 text-center">
<Package className="mx-auto mb-3 h-8 w-8 text-gray-600" />
<p className="text-sm text-gray-400">
No trades yet. Close a deal and confirm it to start building reputation.
</p>
<Button asChild variant="outline" className="mt-4 border-white/15">
<Link to="/marketplace">Browse Gear Swap</Link>
</Button>
</CardContent>
</Card>
)}
{authored.map((ev) => {
const coord = ev.tags.find(([t]) => t === 'a')?.[1];
const parsed = parseListingCoord(coord);
const d = ev.tags.find(([t]) => t === 'd')?.[1] ?? '';
const roleTyped: 'sold' | 'bought' | null = d.includes(':trade:sold:')
? 'sold'
: d.includes(':trade:bought:')
? 'bought'
: null;
const role = roleTyped === 'sold' ? 'Sold' : roleTyped === 'bought' ? 'Bought' : 'Trade';
const state = ev.tags.find(([t]) => t === 's')?.[1] ?? 'valid';
const counterparty = ev.tags.find(([t]) => t === 'p')?.[1];
return (
<Card key={ev.id} className="bg-black/80 border-white/10">
<CardContent className="flex items-center gap-3 p-3">
<Badge
className={
role === 'Sold'
? 'bg-orange-500/15 text-orange-400 border border-orange-500/30'
: 'bg-[#ccff00]/15 text-[#ccff00] border border-[#ccff00]/30'
}
>
{role}
</Badge>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-gray-200">
{counterparty ? <>with <PeerName pubkey={counterparty} /></> : 'trade'}
</p>
<p className="text-[11px] text-gray-500">
{new Date(ev.created_at * 1000).toLocaleDateString()}
{state !== 'valid' && ` · ${state}`}
</p>
</div>
{parsed && (
<Link
to={listingUrl(parsed.pubkey, parsed.identifier)}
className="shrink-0 text-xs text-gray-400 hover:text-white"
>
View listing <ArrowRight className="inline h-3 w-3" />
</Link>
)}
{roleTyped && counterparty && parsed && (
<button
type="button"
onClick={() =>
setRevokeTarget({
listing: { pubkey: parsed.pubkey, identifier: parsed.identifier },
role: roleTyped,
counterparty,
})
}
className="shrink-0 text-xs text-red-400/70 hover:text-red-400"
>
Revoke
</button>
)}
</CardContent>
</Card>
);
})}
</section>
</div>
<Dialog open={!!revokeTarget} onOpenChange={(o) => !o && setRevokeTarget(null)}>
<DialogContent className="border-2 border-red-500/30 bg-black sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-white">Revoke this trade record?</DialogTitle>
<DialogDescription className="text-sm text-gray-400">
It will stop counting toward your reputation. The other party keeps their own
attestation. Use this for a disputed or mistaken deal.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2 pt-1">
<Button
variant="outline"
onClick={() => setRevokeTarget(null)}
className="flex-1 border-white/15"
>
Cancel
</Button>
<Button
onClick={handleRevoke}
disabled={revoking}
className="flex-1 gap-2 bg-red-500 font-bold text-white hover:bg-red-500/90"
>
{revoking ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
Revoke
</Button>
</div>
</DialogContent>
</Dialog>
</Layout>
);
}