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 { Handshake, CheckCircle, X, Loader2, ArrowLeftRight, Undo2, Zap } from 'lucide-react'; import { useTranslation } from '@/contexts/language'; type TFunc = (key: string, params?: Record) => string; function PeerName({ pubkey }: { pubkey: string }) { const author = useAuthor(pubkey); const name = author.data?.metadata?.name ?? genUserName(pubkey); return ( {name} ); } function statusBadge(status: MarketplaceOffer['status'], t: TFunc) { switch (status) { case 'accepted': return {t('marketUi.offers.accepted')}; case 'declined': return {t('marketUi.offers.declined')}; case 'withdrawn': return {t('marketUi.offers.withdrawn')}; default: return {t('marketUi.offers.open')}; } } 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 } = useListingOffers({ pubkey: item.pubkey, identifier: item.identifier }); const { respondToOffer, counterOffer } = useOfferActions(); const { mutateAsync: sendDM } = useSendDirectMessage(); const { toast } = useToast(); const [busy, setBusy] = useState(null); const [counterFor, setCounterFor] = useState(null); const [counterAmount, setCounterAmount] = useState(''); const [counterItem, setCounterItem] = useState(''); const [payFor, setPayFor] = useState(null); const [paidThreads, setPaidThreads] = useState>(() => new Set()); if (!user || !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 { // On accept, attach my Lightning address (if I have one) so the buyer can // pay in-app. If I have none, their side falls back to my profile `lud16`. const myLud16 = metadata?.lud16?.trim(); 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 (

{isOwner ? t('marketUi.offers.titleOwner', { count: mine.length }) : t('marketUi.offers.titleBuyer')}

{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 (
{statusBadge(latest.status, t)} {amountLabel(latest, t)}
{/* Earlier rounds — the volley, so the haggling is visible. */} {history.length > 0 && (
{history.map((m) => (

{m.author === user.pubkey ? t('marketUi.offers.you') : t('marketUi.offers.them')}: {amountLabel(m, t)} {m.status !== 'open' ? ` · ${m.status}` : ''}

))}
)} {latest.message &&

{latest.message}

} {/* Buyer on an accepted deal: settle it straight from the wallet. */} {latest.status === 'accepted' && user.pubkey === thread.buyer && ( paidThreads.has(`${thread.listingCoord}:${thread.buyer}`) ? (

{t('marketUi.offers.paidAskConfirm')}

) : ( ) )} {/* My turn: accept, decline, or counter. */} {myTurn && ( <>
{counterFor === latest.id && (
setCounterAmount(e.target.value)} placeholder={t('marketUi.offers.counterPlaceholder')} className="h-9 border-white/15 bg-white/5 text-sm text-white" />
setCounterItem(e.target.value)} placeholder={t('marketUi.offers.counterItemPlaceholder')} className="h-9 border-white/15 bg-white/5 text-sm text-white" />
)} )} {/* Their turn: waiting, with the option to pull my own offer. */} {!myTurn && latest.status === 'open' && (

{t('marketUi.offers.waitingOn')} {t('marketUi.offers.waitingOnSuffix')}

{latest.author === user.pubkey && ( )}
)}
); })} {payFor && ( { if (!o) setPayFor(null); }} sellerPubkey={payFor.latest.seller} amountSats={payFor.latest.amount} pointer={payFor.latest.payment} pointerKind={payFor.latest.paymentKind} onPaid={(receipt) => { // Carry the verifiable receipt to the confirm step (same coord key). if (receipt) saveReceipt(payFor.listingCoord, receipt); setPaidThreads((prev) => new Set(prev).add(`${payFor.listingCoord}:${payFor.buyer}`)); }} /> )}
); }