Trade-record state (revoked/verifying) and the listing pending status were printed raw. New marketUi.trades.stateRevoked/stateVerifying and marketUi.statusPending (en/es). Also reclassify the request tag as cosmetic (never read in the client) in the audit.
359 lines
14 KiB
TypeScript
359 lines
14 KiB
TypeScript
import { dateLocaleCode } from '@/lib/dateFnsI18n';
|
|
import { useState } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { useSeoMeta } from '@unhead/react';
|
|
import { nip19 } from 'nostr-tools';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { TradeConfirmRow } from '@/components/TradeConfirmRow';
|
|
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';
|
|
import { useTranslation } from '@/contexts/language';
|
|
|
|
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 { t } = useTranslation();
|
|
const { user } = useCurrentUser();
|
|
const { toast } = useToast();
|
|
const { publishTrade, isPending: publishing } = useTradeAttestation();
|
|
|
|
const { data: incoming, isLoading: loadingIncoming, isError: incomingError } = useIncomingTradeRequests(user?.pubkey);
|
|
const { data: pending, isLoading: loadingPending, isError: pendingError } = usePendingTradeConfirmations(user?.pubkey);
|
|
const { data: reputation, isLoading: loadingRep, isError: repError } = useTradeReputation(user?.pubkey);
|
|
// A relay outage must not look like "nothing waiting" / "no trades".
|
|
const tradeError = incomingError || pendingError || repError;
|
|
|
|
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: t('seoUi.tradesTitle'),
|
|
description: t('seoUi.tradesDesc'),
|
|
});
|
|
|
|
const handleConfirmSale = async (
|
|
listing: { pubkey: string; identifier: string },
|
|
requestor: string,
|
|
requestCoord: string,
|
|
rating?: number,
|
|
) => {
|
|
setBusy(requestCoord);
|
|
try {
|
|
await publishTrade({
|
|
listing,
|
|
role: 'sold',
|
|
counterparty: requestor,
|
|
requestCoord,
|
|
rating,
|
|
});
|
|
toast({ title: t('marketUi.trades.saleConfirmed'), description: t('marketUi.trades.saleConfirmedBody') });
|
|
} catch (e) {
|
|
toast({
|
|
title: t('marketUi.trades.saleConfirmFailed'),
|
|
description: e instanceof Error ? e.message : t('marketUi.trades.somethingWrong'),
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
};
|
|
|
|
const handleConfirmPurchase = async (
|
|
listing: { pubkey: string; identifier: string },
|
|
seller: string,
|
|
coord: string,
|
|
rating?: number,
|
|
) => {
|
|
setBusy(coord);
|
|
try {
|
|
await publishTrade({ listing, role: 'bought', counterparty: seller, rating });
|
|
toast({ title: t('marketUi.trades.tradeConfirmed'), description: t('marketUi.trades.tradeConfirmedBody') });
|
|
} catch (e) {
|
|
toast({
|
|
title: t('marketUi.trades.tradeConfirmFailed'),
|
|
description: e instanceof Error ? e.message : t('marketUi.trades.somethingWrong'),
|
|
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: t('marketUi.trades.revokeNote'),
|
|
});
|
|
toast({
|
|
title: t('marketUi.trades.revoked'),
|
|
description: t('marketUi.trades.revokedBody'),
|
|
});
|
|
setRevokeTarget(null);
|
|
} catch (e) {
|
|
toast({
|
|
title: t('marketUi.trades.revokeFailed'),
|
|
description: e instanceof Error ? e.message : t('marketUi.trades.somethingWrong'),
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setRevoking(false);
|
|
}
|
|
};
|
|
|
|
if (!user) {
|
|
return (
|
|
<>
|
|
<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">{t('marketUi.trades.title')}</h1>
|
|
<p className="text-sm text-gray-400">
|
|
{t('marketUi.trades.loginBody')}
|
|
</p>
|
|
<LoginArea className="justify-center" />
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<>
|
|
<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]" /> {t('marketUi.trades.title')}
|
|
</h1>
|
|
<p className="mt-1 text-sm text-gray-400">
|
|
{t('marketUi.trades.subtitle')}
|
|
</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">
|
|
{t('marketUi.trades.actionNeeded')} {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) => (
|
|
<TradeConfirmRow
|
|
key={r.coord}
|
|
tone="sale"
|
|
icon={Handshake}
|
|
text={<><PeerName pubkey={r.requestor} /> {t('marketUi.trades.askedToConfirmSale')}</>}
|
|
listingUrl={listingUrl(r.listing.pubkey, r.listing.identifier)}
|
|
busy={busy === r.coord}
|
|
disabled={busy === r.coord || publishing}
|
|
actionLabel={t('marketUi.trades.confirmSale')}
|
|
onConfirm={(rating) => handleConfirmSale(r.listing, r.requestor, r.coord, rating)}
|
|
/>
|
|
))}
|
|
|
|
{/* Pending: a seller recorded a sale you haven't confirmed */}
|
|
{pending?.map((p) => (
|
|
<TradeConfirmRow
|
|
key={p.coord}
|
|
tone="purchase"
|
|
icon={CheckCircle}
|
|
text={<><PeerName pubkey={p.seller} /> {t('marketUi.trades.recordedSale')}</>}
|
|
listingUrl={listingUrl(p.listing.pubkey, p.listing.identifier)}
|
|
busy={busy === p.coord}
|
|
disabled={busy === p.coord || publishing}
|
|
actionLabel={t('marketUi.trades.confirm')}
|
|
onConfirm={(rating) => handleConfirmPurchase(p.listing, p.seller, p.coord, rating)}
|
|
/>
|
|
))}
|
|
|
|
{tradeError && (
|
|
<p className="text-sm text-amber-400">{t('marketUi.loadFailed')}</p>
|
|
)}
|
|
|
|
{!tradeError && !loadingIncoming && !loadingPending && actionNeeded === 0 && (
|
|
<p className="text-sm text-gray-500">{t('marketUi.trades.nothingWaiting')}</p>
|
|
)}
|
|
</section>
|
|
|
|
{/* Your trade records */}
|
|
<section className="space-y-3">
|
|
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500">
|
|
{t('marketUi.trades.yourRecords')}
|
|
</h2>
|
|
|
|
{loadingRep && <Skeleton className="h-16 w-full rounded-2xl" />}
|
|
|
|
{repError && (
|
|
<p className="text-sm text-amber-400">{t('marketUi.loadFailed')}</p>
|
|
)}
|
|
|
|
{!repError && !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">
|
|
{t('marketUi.trades.noTrades')}
|
|
</p>
|
|
<Button asChild variant="outline" className="mt-4 border-white/15">
|
|
<Link to="/marketplace">{t('marketUi.trades.browseGearSwap')}</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' ? t('marketUi.trades.roleSold') : roleTyped === 'bought' ? t('marketUi.trades.roleBought') : t('marketUi.trades.roleTrade');
|
|
const state = ev.tags.find(([t]) => t === 's')?.[1] ?? 'valid';
|
|
const stateLabel = state === 'revoked' ? t('marketUi.trades.stateRevoked') : state === 'verifying' ? t('marketUi.trades.stateVerifying') : state;
|
|
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={
|
|
roleTyped === '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 ? <>{t('marketUi.trades.with')} <PeerName pubkey={counterparty} /></> : t('marketUi.trades.roleTrade').toLowerCase()}
|
|
</p>
|
|
<p className="text-[11px] text-gray-500">
|
|
{new Date(ev.created_at * 1000).toLocaleDateString(dateLocaleCode())}
|
|
{state !== 'valid' && ` · ${stateLabel}`}
|
|
</p>
|
|
</div>
|
|
{parsed && (
|
|
<Link
|
|
to={listingUrl(parsed.pubkey, parsed.identifier)}
|
|
className="shrink-0 text-xs text-gray-400 hover:text-white"
|
|
>
|
|
{t('marketUi.trades.viewListing')} <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"
|
|
>
|
|
{t('marketUi.trades.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">{t('marketUi.trades.revokeTitle')}</DialogTitle>
|
|
<DialogDescription className="text-sm text-gray-400">
|
|
{t('marketUi.trades.revokeBody')}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="flex gap-2 pt-1">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setRevokeTarget(null)}
|
|
className="flex-1 border-white/15"
|
|
>
|
|
{t('common.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}
|
|
{t('marketUi.trades.revoke')}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|