SkateSpots/src/components/ListingOffersPanel.tsx
SkateSpots f1497fc0bf 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).
2026-10-10 15:51:58 +07:00

375 lines
16 KiB
TypeScript

import { useState } from 'react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useToast } from '@/hooks/useToast';
import {
useOfferActions,
useListingOffers,
type MarketplaceOffer,
type OfferThread,
} from '@/hooks/useMarketplaceOffers';
import { useSendDirectMessage } from '@/hooks/useDirectMessages';
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';
type TFunc = (key: string, params?: Record<string, string | number>) => string;
function PeerName({ pubkey }: { pubkey: string }) {
const author = useAuthor(pubkey);
const name = author.data?.metadata?.name ?? genUserName(pubkey);
return (
<Link to={`/${nip19.npubEncode(pubkey)}`} className="font-bold text-white hover:text-[#ccff00]">
{name}
</Link>
);
}
function statusBadge(status: MarketplaceOffer['status'], t: TFunc) {
switch (status) {
case 'accepted':
return <Badge className="bg-[#ccff00]/15 text-[#ccff00] border border-[#ccff00]/30">{t('marketUi.offers.accepted')}</Badge>;
case 'declined':
return <Badge className="bg-red-500/15 text-red-400 border border-red-500/30">{t('marketUi.offers.declined')}</Badge>;
case 'withdrawn':
return <Badge variant="secondary" className="bg-gray-700 text-gray-400">{t('marketUi.offers.withdrawn')}</Badge>;
default:
return <Badge className="bg-orange-500/15 text-orange-400 border border-orange-500/30">{t('marketUi.offers.open')}</Badge>;
}
}
function amountLabel(m: MarketplaceOffer, t: TFunc): string {
const parts: string[] = [];
if (m.amount) parts.push(`${m.amount.toLocaleString()} ${m.currency ?? 'SATS'}`);
if (m.offerItem) parts.push(m.offerItem);
return parts.length ? parts.join(' + ') : t('marketUi.offers.noAmount');
}
/**
* Offers for a listing, as a **negotiation thread**. Either side can accept,
* decline, withdraw, or **counter** with a new amount — and it's always clear
* whose turn it is.
*/
export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; isOwner: boolean }) {
const { t } = useTranslation();
const { user, metadata } = useCurrentUser();
const { data, isError } = useListingOffers({ pubkey: item.pubkey, identifier: item.identifier });
const { respondToOffer, counterOffer } = useOfferActions();
const { mutateAsync: sendDM } = useSendDirectMessage();
const { toast } = useToast();
const [busy, setBusy] = useState<string | null>(null);
const [counterFor, setCounterFor] = useState<string | null>(null);
const [counterAmount, setCounterAmount] = useState('');
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
// the panel entirely.
if (isError) {
return <p className="text-sm text-muted-foreground">{t('marketUi.offers.loadFailed')}</p>;
}
if (!data) return null;
const mine = data.threads.filter((t) => t.buyer === user.pubkey || t.seller === user.pubkey);
if (mine.length === 0) return null;
const nudge = async (to: string, content: string) => {
try {
await sendDM({ recipients: to, content });
} catch {
/* DM is a best-effort extra nudge; the event itself already carries it */
}
};
const respond = async (thread: OfferThread, status: 'accepted' | 'declined') => {
setBusy(thread.latest.id);
try {
// Only the SELLER (listing owner) attaches a payment pointer on accept.
// If the buyer accepts the seller's counter, the buyer must NOT publish
// their own address as the payee — that would misdirect the payment.
const isSeller = user?.pubkey === item.pubkey;
const myLud16 = isSeller ? metadata?.lud16?.trim() : undefined;
await respondToOffer(
thread.latest,
status,
undefined,
status === 'accepted' && myLud16 ? { pointer: myLud16, kind: 'lnaddress' } : undefined,
);
await nudge(
thread.latest.author,
status === 'accepted'
? t('marketUi.offers.dmAccepted')
: t('marketUi.offers.dmDeclined'),
);
toast({ title: status === 'accepted' ? t('marketUi.offers.offerAccepted') : t('marketUi.offers.offerDeclined') });
} catch (e) {
toast({
title: t('marketUi.offers.respondFailed'),
description: e instanceof Error ? e.message : t('marketUi.offers.somethingWrong'),
variant: 'destructive',
});
} finally {
setBusy(null);
}
};
const withdraw = async (thread: OfferThread) => {
setBusy(thread.latest.id);
try {
await respondToOffer(thread.latest, 'withdrawn');
toast({ title: t('marketUi.offers.offerWithdrawn') });
} catch (e) {
toast({
title: t('marketUi.offers.withdrawFailed'),
description: e instanceof Error ? e.message : t('marketUi.offers.somethingWrong'),
variant: 'destructive',
});
} finally {
setBusy(null);
}
};
const sendCounter = async (thread: OfferThread) => {
const amount = Number(counterAmount);
const hasAmount = counterAmount.trim() !== '' && Number.isFinite(amount) && amount > 0;
if (!hasAmount && !counterItem.trim()) {
toast({ title: t('marketUi.offers.enterAmountOrItem'), variant: 'destructive' });
return;
}
setBusy(thread.latest.id);
try {
await counterOffer({
listing: { pubkey: item.pubkey, identifier: item.identifier },
previous: thread.latest,
amount: hasAmount ? amount : undefined,
offerItem: counterItem,
});
const what = [hasAmount ? `${amount.toLocaleString()} sats` : null, counterItem.trim() || null]
.filter(Boolean)
.join(' + ');
await nudge(thread.latest.author, `↩️ ${t('marketUi.offers.counterDm')}: ${what}`);
toast({ title: t('marketUi.offers.counterSent'), description: t('marketUi.offers.counterSentBody') });
setCounterFor(null);
setCounterAmount('');
setCounterItem('');
} catch (e) {
toast({
title: t('marketUi.offers.counterFailed'),
description: e instanceof Error ? e.message : t('marketUi.offers.somethingWrong'),
variant: 'destructive',
});
} finally {
setBusy(null);
}
};
return (
<Card className="bg-black/90 border-2 border-orange-500/25">
<CardContent className="space-y-3 p-4">
<h3 className="flex items-center gap-2 text-sm font-bold uppercase tracking-wide text-orange-400">
<Handshake className="h-4 w-4" /> {isOwner ? t('marketUi.offers.titleOwner', { count: mine.length }) : t('marketUi.offers.titleBuyer')}
</h3>
{mine.map((thread) => {
const latest = thread.latest;
const other = latest.author === user.pubkey
? latest.buyer === user.pubkey
? latest.seller
: latest.buyer
: latest.author;
const myTurn = thread.awaiting === user.pubkey;
const history = thread.messages.slice(0, -1);
return (
<div
key={`${thread.listingCoord}:${thread.buyer}`}
className="space-y-2 rounded-xl border border-white/10 bg-white/[0.03] p-3"
>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-gray-200">
<PeerName pubkey={other} />
</span>
{statusBadge(latest.status, t)}
<span className="ml-auto text-sm font-bold text-[#ccff00]">{amountLabel(latest, t)}</span>
</div>
{/* Earlier rounds — the volley, so the haggling is visible. */}
{history.length > 0 && (
<div className="space-y-0.5 border-l border-white/10 pl-2">
{history.map((m) => (
<p key={m.id} className="text-[11px] text-gray-500">
{m.author === user.pubkey ? t('marketUi.offers.you') : t('marketUi.offers.them')}: {amountLabel(m, t)}
{m.status !== 'open' ? ` · ${m.status}` : ''}
</p>
))}
</div>
)}
{latest.message && <p className="text-xs text-gray-400">{latest.message}</p>}
{/* Buyer on an accepted deal: settle it straight from the wallet. */}
{latest.status === 'accepted' && user.pubkey === thread.buyer && (
paidThreads.has(`${thread.listingCoord}:${thread.buyer}`) ? (
<p className="flex items-center gap-1.5 text-xs font-bold text-green-400">
<CheckCircle className="h-3.5 w-3.5" /> {t('marketUi.offers.paidAskConfirm')}
</p>
) : (
<Button
size="sm"
onClick={() => setPayFor(thread)}
className="w-full gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
>
<Zap className="h-3.5 w-3.5" /> {t('marketUi.offers.payWithSats')}
</Button>
)
)}
{/* 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 && (
<>
<div className="flex gap-2 pt-1">
<Button
size="sm"
onClick={() => respond(thread, 'accepted')}
disabled={busy === latest.id}
className="flex-1 gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
>
{busy === latest.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle className="h-3.5 w-3.5" />}
{t('marketUi.offers.accept')}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => respond(thread, 'declined')}
disabled={busy === latest.id}
className="flex-1 gap-1.5 border-red-500/30 text-red-400 hover:bg-red-500/10"
>
<X className="h-3.5 w-3.5" /> {t('marketUi.offers.decline')}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
setCounterFor(counterFor === latest.id ? null : latest.id);
setCounterAmount(latest.amount ? String(Math.round(latest.amount)) : '');
setCounterItem(latest.offerItem ?? '');
}}
className="flex-1 gap-1.5 border-orange-500/40 text-orange-400 hover:bg-orange-500/10"
>
<ArrowLeftRight className="h-3.5 w-3.5" /> {t('marketUi.offers.counter')}
</Button>
</div>
{counterFor === latest.id && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Input
autoFocus
type="number"
inputMode="numeric"
value={counterAmount}
onChange={(e) => setCounterAmount(e.target.value)}
placeholder={t('marketUi.offers.counterPlaceholder')}
className="h-9 border-white/15 bg-white/5 text-sm text-white"
/>
<Button
size="sm"
onClick={() => sendCounter(thread)}
disabled={busy === latest.id}
className="shrink-0 gap-1.5 bg-orange-500 font-bold text-black hover:bg-orange-500/90"
>
{busy === latest.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
{t('marketUi.offers.send')}
</Button>
</div>
<Input
value={counterItem}
onChange={(e) => setCounterItem(e.target.value)}
placeholder={t('marketUi.offers.counterItemPlaceholder')}
className="h-9 border-white/15 bg-white/5 text-sm text-white"
/>
</div>
)}
</>
)}
{/* Their turn: waiting, with the option to pull my own offer. */}
{!myTurn && latest.status === 'open' && (
<div className="flex items-center gap-2">
<p className="flex-1 text-xs text-gray-500">
{t('marketUi.offers.waitingOn')} <PeerName pubkey={other} /> {t('marketUi.offers.waitingOnSuffix')}
</p>
{latest.author === user.pubkey && (
<Button
size="sm"
variant="outline"
onClick={() => withdraw(thread)}
disabled={busy === latest.id}
className="shrink-0 gap-1.5 border-white/15"
>
<Undo2 className="h-3.5 w-3.5" /> {t('marketUi.offers.withdraw')}
</Button>
)}
</div>
)}
</div>
);
})}
{payFor && (
<PayWithSats
open={!!payFor}
onOpenChange={(o) => { if (!o) setPayFor(null); }}
sellerPubkey={payFor.latest.seller}
amountSats={payFor.latest.currency && payFor.latest.currency.toUpperCase() !== 'SATS' ? undefined : payFor.latest.amount}
pointer={payFor.latest.payment}
pointerKind={payFor.latest.paymentKind}
paymentHash={payFor.latest.paymentHash}
currency={payFor.latest.currency}
onPaid={(receipt) => {
// Carry the verifiable receipt to the confirm step (same coord key).
if (receipt) saveReceipt(payFor.listingCoord, receipt, 'payment', user?.pubkey ?? '');
setPaidThreads((prev) => new Set(prev).add(`${payFor.listingCoord}:${payFor.buyer}`));
}}
/>
)}
{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>
);
}