diff --git a/MARKETPLACE_FLOW_AUDIT.md b/MARKETPLACE_FLOW_AUDIT.md index c793f4c..0280a8b 100644 --- a/MARKETPLACE_FLOW_AUDIT.md +++ b/MARKETPLACE_FLOW_AUDIT.md @@ -232,6 +232,11 @@ Second batch (payment, attestation, escrow): 15. **Localized trade status:** the trade-record `state` and the listing `pending` status are no longer printed raw (`marketUi.trades.stateRevoked` / `stateVerifying`, `marketUi.statusPending`). +16. **Close the deal from an accepted offer** (product improvement): the offer + thread now shows a **Close deal** button to the seller on an accepted, + unsold listing, opening `MarkSoldDialog` prefilled with the buyer, the + agreed amount, and the settlement method — no more pasting an npub by hand. + New `marketUi.offers.closeDeal`. ## 7. Recommended order diff --git a/src/components/ListingOffersPanel.tsx b/src/components/ListingOffersPanel.tsx index 3f8863a..84cf76a 100644 --- a/src/components/ListingOffersPanel.tsx +++ b/src/components/ListingOffersPanel.tsx @@ -19,6 +19,7 @@ import { saveReceipt } from '@/lib/receipts'; import { genUserName } from '@/lib/genUserName'; import type { MarketplaceItem } from '@/lib/types'; import { PayWithSats } from '@/components/PayWithSats'; +import { MarkSoldDialog } from '@/components/MarkSoldDialog'; import { Handshake, CheckCircle, X, Loader2, ArrowLeftRight, Undo2, Zap } from 'lucide-react'; import { useTranslation } from '@/contexts/language'; @@ -72,6 +73,7 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i const [counterItem, setCounterItem] = useState(''); const [payFor, setPayFor] = useState(null); const [paidThreads, setPaidThreads] = useState>(() => new Set()); + const [closeDeal, setCloseDeal] = useState(null); if (!user) return null; // A relay outage must not look like "no offers" — surface it instead of hiding @@ -235,6 +237,18 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i ) )} + {/* Seller on an accepted deal: close it (names the buyer, records + the trade) without re-typing the buyer or the amount. */} + {latest.status === 'accepted' && isOwner && item.status !== 'sold' && ( + + )} + {/* My turn: accept, decline, or counter. */} {myTurn && ( <> @@ -343,6 +357,18 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i }} /> )} + + {closeDeal && ( + { if (!o) setCloseDeal(null); }} + event={item.event} + listing={{ pubkey: item.pubkey, identifier: item.identifier }} + defaultBuyer={nip19.npubEncode(closeDeal.buyer)} + defaultAmountSats={closeDeal.latest.amount} + defaultMethod={closeDeal.latest.payment ? 'lightning' : 'cash'} + /> + )} ); diff --git a/src/components/MarkSoldDialog.tsx b/src/components/MarkSoldDialog.tsx index 2c4884f..635d51c 100644 --- a/src/components/MarkSoldDialog.tsx +++ b/src/components/MarkSoldDialog.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { nip19 } from 'nostr-tools'; import { Dialog, @@ -43,20 +43,37 @@ export function MarkSoldDialog({ onOpenChange, event, listing, + defaultBuyer, + defaultAmountSats, + defaultMethod, }: { open: boolean; onOpenChange: (open: boolean) => void; event: NostrEvent; listing: { pubkey: string; identifier: string }; + /** Prefill from an accepted offer: the counterparty to name (hex or npub). */ + defaultBuyer?: string; + /** Prefill from an accepted offer: the agreed amount (sats). */ + defaultAmountSats?: number; + /** Prefill from an accepted offer: the settlement method. */ + defaultMethod?: TradeMethod; }) { const { t } = useTranslation(); const { publishTrade, isPending: publishing } = useTradeAttestation(); const { markSold, isPending: marking } = useMarkListingSold(); const { toast } = useToast(); - const [buyer, setBuyer] = useState(''); - const [method, setMethod] = useState('cash'); - const [amount, setAmount] = useState(''); + const [buyer, setBuyer] = useState(defaultBuyer ?? ''); + const [method, setMethod] = useState(defaultMethod ?? 'cash'); + const [amount, setAmount] = useState(defaultAmountSats ? String(defaultAmountSats) : ''); + + // Re-seed whenever the dialog is (re)opened, so a different offer prefills. + useEffect(() => { + if (!open) return; + setBuyer(defaultBuyer ?? ''); + setMethod(defaultMethod ?? 'cash'); + setAmount(defaultAmountSats ? String(defaultAmountSats) : ''); + }, [open, defaultBuyer, defaultAmountSats, defaultMethod]); const buyerPubkey = resolvePubkey(buyer); const busy = publishing || marking; diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 3e69cc6..e92491e 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -1781,6 +1781,7 @@ marketUi: { declined: 'Declined', withdrawn: 'Withdrawn', open: 'Open', + closeDeal: 'Close deal', noAmount: 'no amount', titleOwner: 'Offers ({{count}})', loadFailed: 'Could not load offers. Check your connection and try again.', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 65d8b62..1535d1e 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -1781,6 +1781,7 @@ marketUi: { declined: 'Rechazada', withdrawn: 'Retirada', open: 'Abierta', + closeDeal: 'Cerrar trato', noAmount: 'sin importe', titleOwner: 'Ofertas ({{count}})', loadFailed: 'No se pudieron cargar las ofertas. Comprueba tu conexión e inténtalo de nuevo.',