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:
parent
057ef0a8f3
commit
f1497fc0bf
5 changed files with 54 additions and 4 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
Loading…
Reference in a new issue