feat(marketplace): close the deal from an accepted offer

Seller UX: the offer thread shows a Close deal button on an accepted,
unsold listing. It opens MarkSoldDialog prefilled with the buyer, the
agreed amount, and the settlement method (lightning when a payment pointer
was attached, else cash), so the seller never pastes an npub by hand.
MarkSoldDialog gains defaultBuyer/defaultAmountSats/defaultMethod and
re-seeds on open. New marketUi.offers.closeDeal (en/es).
This commit is contained in:
SkateSpots 2026-10-10 15:51:58 +07:00
parent 057ef0a8f3
commit f1497fc0bf
5 changed files with 54 additions and 4 deletions

View file

@ -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

View file

@ -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<OfferThread | null>(null);
const [paidThreads, setPaidThreads] = useState<Set<string>>(() => new Set());
const [closeDeal, setCloseDeal] = useState<OfferThread | null>(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' && (
<Button
size="sm"
onClick={() => setCloseDeal(thread)}
className="w-full gap-1.5 bg-orange-500 font-bold text-white hover:bg-orange-600"
>
<Handshake className="h-3.5 w-3.5" /> {t('marketUi.offers.closeDeal')}
</Button>
)}
{/* My turn: accept, decline, or counter. */}
{myTurn && (
<>
@ -343,6 +357,18 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
}}
/>
)}
{closeDeal && (
<MarkSoldDialog
open={!!closeDeal}
onOpenChange={(o) => { 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'}
/>
)}
</CardContent>
</Card>
);

View file

@ -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<TradeMethod>('cash');
const [amount, setAmount] = useState('');
const [buyer, setBuyer] = useState(defaultBuyer ?? '');
const [method, setMethod] = useState<TradeMethod>(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;

View file

@ -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.',

View file

@ -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.',