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 15. **Localized trade status:** the trade-record `state` and the listing
`pending` status are no longer printed raw (`marketUi.trades.stateRevoked` / `pending` status are no longer printed raw (`marketUi.trades.stateRevoked` /
`stateVerifying`, `marketUi.statusPending`). `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 ## 7. Recommended order

View file

@ -19,6 +19,7 @@ import { saveReceipt } from '@/lib/receipts';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import type { MarketplaceItem } from '@/lib/types'; import type { MarketplaceItem } from '@/lib/types';
import { PayWithSats } from '@/components/PayWithSats'; import { PayWithSats } from '@/components/PayWithSats';
import { MarkSoldDialog } from '@/components/MarkSoldDialog';
import { Handshake, CheckCircle, X, Loader2, ArrowLeftRight, Undo2, Zap } from 'lucide-react'; import { Handshake, CheckCircle, X, Loader2, ArrowLeftRight, Undo2, Zap } from 'lucide-react';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
@ -72,6 +73,7 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
const [counterItem, setCounterItem] = useState(''); const [counterItem, setCounterItem] = useState('');
const [payFor, setPayFor] = useState<OfferThread | null>(null); const [payFor, setPayFor] = useState<OfferThread | null>(null);
const [paidThreads, setPaidThreads] = useState<Set<string>>(() => new Set()); const [paidThreads, setPaidThreads] = useState<Set<string>>(() => new Set());
const [closeDeal, setCloseDeal] = useState<OfferThread | null>(null);
if (!user) return null; if (!user) return null;
// A relay outage must not look like "no offers" — surface it instead of hiding // 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. */} {/* My turn: accept, decline, or counter. */}
{myTurn && ( {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> </CardContent>
</Card> </Card>
); );

View file

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { import {
Dialog, Dialog,
@ -43,20 +43,37 @@ export function MarkSoldDialog({
onOpenChange, onOpenChange,
event, event,
listing, listing,
defaultBuyer,
defaultAmountSats,
defaultMethod,
}: { }: {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
event: NostrEvent; event: NostrEvent;
listing: { pubkey: string; identifier: string }; 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 { t } = useTranslation();
const { publishTrade, isPending: publishing } = useTradeAttestation(); const { publishTrade, isPending: publishing } = useTradeAttestation();
const { markSold, isPending: marking } = useMarkListingSold(); const { markSold, isPending: marking } = useMarkListingSold();
const { toast } = useToast(); const { toast } = useToast();
const [buyer, setBuyer] = useState(''); const [buyer, setBuyer] = useState(defaultBuyer ?? '');
const [method, setMethod] = useState<TradeMethod>('cash'); const [method, setMethod] = useState<TradeMethod>(defaultMethod ?? 'cash');
const [amount, setAmount] = useState(''); 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 buyerPubkey = resolvePubkey(buyer);
const busy = publishing || marking; const busy = publishing || marking;

View file

@ -1781,6 +1781,7 @@ marketUi: {
declined: 'Declined', declined: 'Declined',
withdrawn: 'Withdrawn', withdrawn: 'Withdrawn',
open: 'Open', open: 'Open',
closeDeal: 'Close deal',
noAmount: 'no amount', noAmount: 'no amount',
titleOwner: 'Offers ({{count}})', titleOwner: 'Offers ({{count}})',
loadFailed: 'Could not load offers. Check your connection and try again.', loadFailed: 'Could not load offers. Check your connection and try again.',

View file

@ -1781,6 +1781,7 @@ marketUi: {
declined: 'Rechazada', declined: 'Rechazada',
withdrawn: 'Retirada', withdrawn: 'Retirada',
open: 'Abierta', open: 'Abierta',
closeDeal: 'Cerrar trato',
noAmount: 'sin importe', noAmount: 'sin importe',
titleOwner: 'Ofertas ({{count}})', titleOwner: 'Ofertas ({{count}})',
loadFailed: 'No se pudieron cargar las ofertas. Comprueba tu conexión e inténtalo de nuevo.', loadFailed: 'No se pudieron cargar las ofertas. Comprueba tu conexión e inténtalo de nuevo.',