import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAuthor } from '@/hooks/useAuthor'; import { useToast } from '@/hooks/useToast'; import { useListingTrade, useListingTradeRequest, useTradeAttestation, useTradeAttestationRequest, } from '@/hooks/useTradeAttestations'; import { useCashuEscrow } from '@/hooks/wallet/useCashuEscrow'; import { EscrowRequestDialog } from '@/components/EscrowRequestDialog'; import { EscrowPanel } from '@/components/EscrowPanel'; import { PayWithSats } from '@/components/PayWithSats'; import { HandoffHelper } from '@/components/HandoffHelper'; import { ShippingStatusCard } from '@/components/ShippingStatusCard'; import { saveReceipt, getReceipt, clearReceipt } from '@/lib/receipts'; import { MARKETPLACE_ITEM_KIND } from '@/lib/types'; import { useUsdToSats } from '@/hooks/useExchangeRate'; import { parseEscrowToken } from '@/lib/cashuEscrow'; import { MessageCircle, Loader2, CheckCircle, Lock, Send, RotateCcw, Zap } from 'lucide-react'; import type { MarketplaceItem } from '@/lib/types'; import { useTranslation } from '@/contexts/language'; /** * Buyer/seller trade + escrow actions for a marketplace listing, shared by the * grid card and the listing detail page so there is ONE implementation. * * Payments aren't implemented โ€” buyers contact the seller and settle off-app; * these actions only record/confirm the trade for reputation (and lock escrow * funds on native). */ export function ListingTradeActions({ item }: { item: MarketplaceItem }) { const { t } = useTranslation(); const { user } = useCurrentUser(); const author = useAuthor(item.pubkey); const metadata = author.data?.metadata; const { toast } = useToast(); const navigate = useNavigate(); const listingRef = { pubkey: item.pubkey, identifier: item.identifier }; const coord = `${MARKETPLACE_ITEM_KIND}:${item.pubkey}:${item.identifier}`; const { data: trade } = useListingTrade(listingRef, user?.pubkey); const { data: requestState } = useListingTradeRequest(listingRef, user?.pubkey); const { publishTrade, isPending: confirmingTrade } = useTradeAttestation(); const { publishRequest, isPending: requestingTrade } = useTradeAttestationRequest(); const escrow = useCashuEscrow(); const [showEscrow, setShowEscrow] = useState(false); const [showPay, setShowPay] = useState(false); const [showDeposit, setShowDeposit] = useState(false); const [depositAmount, setDepositAmount] = useState('1000'); const isOwnListing = user?.pubkey === item.pubkey; const isSold = item.status === 'sold'; const isWTB = item.listingType === 'wtb'; // "Buy with sats" for fixed-price listings: resolve the price to sats โ€” a SATS // price directly, a USD price via the live rate. Other currencies fall back to // the offer/contact flow. const priceCurrency = item.price?.currency?.toUpperCase(); const priceAmount = Number(item.price?.amount); const usdSats = useUsdToSats( priceCurrency === 'USD' && Number.isFinite(priceAmount) ? priceAmount : null, ); const buyAmountSats = item.priceType === 'fixed' && item.price && Number.isFinite(priceAmount) ? priceCurrency === 'SATS' || priceCurrency === 'SAT' ? priceAmount : priceCurrency === 'USD' ? usdSats : null : null; const sellerNamedMe = !!user && !isOwnListing && !!trade && trade.attestations.some( (ev) => ev.pubkey === item.pubkey && ev.tags.some(([t, v]) => t === 'p' && v === user.pubkey), ); const iConfirmedTrade = !!trade?.iConfirmed; const iRequestedConfirmation = !!requestState?.iRequested; const isExpired = !!item.expiration && item.expiration < Math.floor(Date.now() / 1000); const canBuy = !!user && !isOwnListing && !isWTB && !isSold && !isExpired && !iConfirmedTrade && item.priceType === 'fixed' && typeof buyAmountSats === 'number' && buyAmountSats > 0; // Barter: a swap has no invoice, so an optional refundable deposit gives it a // verifiable cost (and a receipt) without pretending the goods were proven. const canDeposit = !!user && !isOwnListing && !isWTB && !isSold && !iConfirmedTrade && item.priceType === 'trade'; const sellerEscrowPubkey = typeof (metadata as Record | undefined)?.cashu_escrow === 'string' ? ((metadata as Record).cashu_escrow as string) : undefined; const canEscrow = escrow.available && !!sellerEscrowPubkey && !!user && !isOwnListing && !iConfirmedTrade && !sellerNamedMe && !iRequestedConfirmation; const myEscrowToken = requestState?.requests .find((e) => e.pubkey === user?.pubkey) ?.tags.find(([t]) => t === 'cashu_token')?.[1]; const myEscrowRefundable = (() => { if (!myEscrowToken) return false; try { const info = parseEscrowToken(myEscrowToken); return info.locked && info.refundable; } catch { return false; } })(); const handleConfirmTrade = async () => { if (!user) return; // Attach the Lightning receipts captured at Pay time, if any โ€” this is what // makes the trade payment-VERIFIED (sha256(preimage) === payment_hash), and // gives a barter a verifiable DEPOSIT cost. const receipt = getReceipt(coord); const paymentProof = receipt?.preimage && receipt.paymentHash ? { preimage: receipt.preimage, paymentHash: receipt.paymentHash } : undefined; const depositReceipt = getReceipt(coord, 'deposit'); const depositProof = depositReceipt?.preimage && depositReceipt.paymentHash ? { preimage: depositReceipt.preimage, paymentHash: depositReceipt.paymentHash } : undefined; try { await publishTrade({ listing: listingRef, role: 'bought', counterparty: item.pubkey, method: paymentProof ? 'lightning' : depositProof ? 'trade' : undefined, amountSats: buyAmountSats ?? undefined, paymentProof, depositProof, }); clearReceipt(coord); clearReceipt(coord, 'deposit'); toast({ title: t('marketUi.tradeActions.tradeConfirmed'), description: paymentProof || depositProof ? t('marketUi.tradeActions.tradeConfirmedWithProof') : t('marketUi.tradeActions.tradeConfirmedBody'), }); } catch (e) { toast({ title: t('marketUi.tradeActions.tradeConfirmFailed'), description: e instanceof Error ? e.message : t('marketUi.tradeActions.somethingWrong'), variant: 'destructive', }); } }; const handleRequestConfirmation = async () => { if (!user) return; try { await publishRequest({ listing: listingRef, attestor: item.pubkey, note: t('marketUi.tradeActions.requestNote', { title: item.title }), }); toast({ title: t('marketUi.tradeActions.requestSent'), description: t('marketUi.tradeActions.requestSentBody'), }); } catch (e) { toast({ title: t('marketUi.tradeActions.requestFailed'), description: e instanceof Error ? e.message : t('marketUi.tradeActions.somethingWrong'), variant: 'destructive', }); } }; const handleRefundEscrow = async () => { if (!myEscrowToken) return; try { await escrow.redeemEscrow(myEscrowToken); toast({ title: t('marketUi.tradeActions.escrowRefunded'), description: t('marketUi.tradeActions.escrowRefundedBody') }); } catch (e) { toast({ title: t('marketUi.tradeActions.escrowRefundFailed'), description: e instanceof Error ? e.message : t('marketUi.tradeActions.somethingWrong'), variant: 'destructive', }); } }; return (
{/* Active escrow the viewer holds on this listing (refundable after locktime) */} {myEscrowToken && } {/* Buyer: confirm the trade the seller recorded (builds both reps) */} {sellerNamedMe && !iConfirmedTrade && ( )} {/* Buyer: lock funds in Cashu escrow and request confirmation (native) */} {canEscrow && ( )} {/* Shipped deals are the case escrow exists for โ€” nudge it. */} {canEscrow && item.fulfilment === 'ships' && (

๐Ÿ“ฆ {t('marketUi.tradeActions.shipsEscrowHint')}

)} {item.fulfilment === 'ships' && !canEscrow && (

๐Ÿ“ฆ {t('marketUi.tradeActions.shipsHint')}

)} {/* Buyer: ask the seller to record the sale (kind 31872 request) */} {!canEscrow && user && !isOwnListing && isSold && !iConfirmedTrade && !sellerNamedMe && !iRequestedConfirmation && ( )} {iRequestedConfirmation && !iConfirmedTrade && (

{t('marketUi.tradeActions.waitingSeller')}

)} {/* Buyer: reclaim escrow after the locktime if the seller didn't deliver */} {escrow.available && myEscrowRefundable && ( )} {/* Buyer, fixed price: pay directly โ€” no negotiation needed */} {canBuy && ( )} {/* Contact button */} {/* Guided handoff โ€” pre-filled DM templates, buyer only. */} {user && !isOwnListing && !isWTB && !iConfirmedTrade && ( )} {/* Barter: optional good-faith deposit (a verifiable cost for a swap). */} {canDeposit && (

{t('marketUi.tradeActions.depositTitle')}

setDepositAmount(e.target.value)} className="h-9 flex-1 border-white/15 bg-white/5 text-sm text-white" />

{t('marketUi.tradeActions.depositBefore')}{' '} {t('marketUi.tradeActions.verifiableCost')}{t('marketUi.tradeActions.depositAfter')}

)} {showDeposit && ( { if (!open) setShowDeposit(false); }} sellerPubkey={item.pubkey} amountSats={Number(depositAmount) > 0 ? Number(depositAmount) : undefined} onPaid={(receipt) => { if (receipt) saveReceipt(coord, receipt, 'deposit'); toast({ title: t('marketUi.tradeActions.depositPaid'), description: t('marketUi.tradeActions.depositPaidBody'), }); }} /> )} {/* Shipping logistics โ€” self-reported, both sides. */} {user && !isWTB && (item.fulfilment === 'ships' || item.fulfilment === 'both') && ( )} {/* Mounted only while open โ€” keeps the wallet hooks off every grid card. */} {showPay && ( { if (!open) setShowPay(false); }} sellerPubkey={item.pubkey} amountSats={buyAmountSats ?? undefined} onPaid={(receipt) => { if (receipt) { saveReceipt(`${MARKETPLACE_ITEM_KIND}:${item.pubkey}:${item.identifier}`, receipt); } toast({ title: t('marketUi.tradeActions.paid'), description: receipt?.preimage ? t('marketUi.tradeActions.paidWithProof') : t('marketUi.tradeActions.paidBody'), }); }} /> )} {canEscrow && sellerEscrowPubkey && ( )}
); }