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 ( {name} ); } 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(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 ( <>

{t('marketUi.trades.title')}

{t('marketUi.trades.loginBody')}

); } // 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 ( <>

{t('marketUi.trades.title')}

{t('marketUi.trades.subtitle')}

{/* Action needed */}

{t('marketUi.trades.actionNeeded')} {actionNeeded > 0 && `· ${actionNeeded}`}

{(loadingIncoming || loadingPending) && (
)} {/* Incoming: someone asked you (seller) to record a sale */} {incoming?.map((r) => ( {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) => ( {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 && (

{t('marketUi.loadFailed')}

)} {!tradeError && !loadingIncoming && !loadingPending && actionNeeded === 0 && (

{t('marketUi.trades.nothingWaiting')}

)}
{/* Your trade records */}

{t('marketUi.trades.yourRecords')}

{loadingRep && } {repError && (

{t('marketUi.loadFailed')}

)} {!repError && !loadingRep && authored.length === 0 && (

{t('marketUi.trades.noTrades')}

)} {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 ( {role}

{counterparty ? <>{t('marketUi.trades.with')} : t('marketUi.trades.roleTrade').toLowerCase()}

{new Date(ev.created_at * 1000).toLocaleDateString(dateLocaleCode())} {state !== 'valid' && ` · ${stateLabel}`}

{parsed && ( {t('marketUi.trades.viewListing')} )} {roleTyped && counterparty && parsed && ( )}
); })}
!o && setRevokeTarget(null)}> {t('marketUi.trades.revokeTitle')} {t('marketUi.trades.revokeBody')}
); }