A pure swap has no invoice, so ListingTradeActions now offers a 'Good-faith deposit' input for price_type=trade listings. Paying it stores a deposit-slot receipt (saveReceipt(coord, r, 'deposit')); the buyer's confirm attaches it as ['proof','deposit',preimage,payment_hash] with method=trade, so a barter carries a verifiable COST without pretending the goods were proven.
398 lines
14 KiB
TypeScript
398 lines
14 KiB
TypeScript
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';
|
||
|
||
/**
|
||
* 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 { 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 canBuy =
|
||
!!user &&
|
||
!isOwnListing &&
|
||
!isWTB &&
|
||
!isSold &&
|
||
!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<string, unknown> | undefined)?.cashu_escrow === 'string'
|
||
? ((metadata as Record<string, unknown>).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: 'Trade confirmed',
|
||
description:
|
||
paymentProof || depositProof
|
||
? 'A verifiable receipt was attached — this counts toward verified trades.'
|
||
: 'Reputation updated for both of you.',
|
||
});
|
||
} catch (e) {
|
||
toast({
|
||
title: 'Could not confirm the trade',
|
||
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||
variant: 'destructive',
|
||
});
|
||
}
|
||
};
|
||
|
||
const handleRequestConfirmation = async () => {
|
||
if (!user) return;
|
||
try {
|
||
await publishRequest({
|
||
listing: listingRef,
|
||
attestor: item.pubkey,
|
||
note: `Please confirm our trade for “${item.title}”.`,
|
||
});
|
||
toast({
|
||
title: 'Confirmation requested',
|
||
description: 'The seller can now confirm the sale and build both reputations.',
|
||
});
|
||
} catch (e) {
|
||
toast({
|
||
title: 'Could not send the request',
|
||
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||
variant: 'destructive',
|
||
});
|
||
}
|
||
};
|
||
|
||
const handleRefundEscrow = async () => {
|
||
if (!myEscrowToken) return;
|
||
try {
|
||
await escrow.redeemEscrow(myEscrowToken);
|
||
toast({ title: 'Escrow refunded', description: 'The funds are back in your Cashu wallet.' });
|
||
} catch (e) {
|
||
toast({
|
||
title: 'Could not refund escrow',
|
||
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||
variant: 'destructive',
|
||
});
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-2">
|
||
{/* Active escrow the viewer holds on this listing (refundable after locktime) */}
|
||
{myEscrowToken && <EscrowPanel token={myEscrowToken} className="mb-1" />}
|
||
|
||
{/* Buyer: confirm the trade the seller recorded (builds both reps) */}
|
||
{sellerNamedMe && !iConfirmedTrade && (
|
||
<Button
|
||
size="sm"
|
||
onClick={handleConfirmTrade}
|
||
disabled={confirmingTrade}
|
||
className="w-full gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||
>
|
||
{confirmingTrade ? <Loader2 className="h-4 w-4 animate-spin" /> : <CheckCircle className="h-4 w-4" />}
|
||
Confirm this trade
|
||
</Button>
|
||
)}
|
||
|
||
{/* Buyer: lock funds in Cashu escrow and request confirmation (native) */}
|
||
{canEscrow && (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => setShowEscrow(true)}
|
||
className="w-full gap-2 border-[#ff6600]/40 text-[#ff6600] hover:bg-[#ff6600]/10"
|
||
>
|
||
<Lock className="h-4 w-4" />
|
||
Escrow & request
|
||
</Button>
|
||
)}
|
||
|
||
{/* Shipped deals are the case escrow exists for — nudge it. */}
|
||
{canEscrow && item.fulfilment === 'ships' && (
|
||
<p className="text-[11px] text-center text-amber-400/90">
|
||
📦 Shipped deal — use escrow so neither side ships into the void. Share your
|
||
address privately in the trade chat, never here.
|
||
</p>
|
||
)}
|
||
{item.fulfilment === 'ships' && !canEscrow && (
|
||
<p className="text-[11px] text-center text-amber-400/90">
|
||
📦 Shipped deal — prefer escrow if the seller offers it, and share your address
|
||
only in the chat.
|
||
</p>
|
||
)}
|
||
|
||
{/* Buyer: ask the seller to record the sale (kind 31872 request) */}
|
||
{!canEscrow && user && !isOwnListing && isSold && !iConfirmedTrade && !sellerNamedMe && !iRequestedConfirmation && (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={handleRequestConfirmation}
|
||
disabled={requestingTrade}
|
||
className="w-full gap-2 border-[#ccff00]/40 text-[#ccff00] hover:bg-[#ccff00]/10"
|
||
>
|
||
{requestingTrade ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||
Ask seller to confirm
|
||
</Button>
|
||
)}
|
||
{iRequestedConfirmation && !iConfirmedTrade && (
|
||
<p className="text-xs text-center text-gray-500">
|
||
Confirmation requested — waiting on the seller.
|
||
</p>
|
||
)}
|
||
|
||
{/* Buyer: reclaim escrow after the locktime if the seller didn't deliver */}
|
||
{escrow.available && myEscrowRefundable && (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={handleRefundEscrow}
|
||
className="w-full gap-2 border-[#ccff00]/40 text-[#ccff00] hover:bg-[#ccff00]/10"
|
||
>
|
||
<RotateCcw className="h-4 w-4" />
|
||
Refund escrow
|
||
</Button>
|
||
)}
|
||
|
||
{/* Buyer, fixed price: pay directly — no negotiation needed */}
|
||
{canBuy && (
|
||
<Button
|
||
size="sm"
|
||
onClick={() => setShowPay(true)}
|
||
className="w-full gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||
>
|
||
<Zap className="h-4 w-4" />
|
||
Buy with sats{buyAmountSats ? ` · ${buyAmountSats.toLocaleString()}` : ''}
|
||
</Button>
|
||
)}
|
||
|
||
{/* Contact button */}
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className={`w-full gap-2 ${
|
||
isWTB
|
||
? 'border-blue-500/50 text-blue-400 hover:bg-blue-500/10'
|
||
: 'border-orange-500/50 text-orange-400 hover:bg-orange-500/10'
|
||
}`}
|
||
onClick={() => navigate(`/messages/${nip19.npubEncode(item.pubkey)}`)}
|
||
>
|
||
<MessageCircle className="h-4 w-4" />
|
||
{isWTB ? 'I Have This!' : 'Contact Seller'}
|
||
</Button>
|
||
|
||
{/* Guided handoff — pre-filled DM templates, buyer only. */}
|
||
{user && !isOwnListing && !isWTB && !iConfirmedTrade && (
|
||
<HandoffHelper item={item} />
|
||
)}
|
||
|
||
{/* Barter: optional good-faith deposit (a verifiable cost for a swap). */}
|
||
{canDeposit && (
|
||
<div className="space-y-2 rounded-xl border border-white/10 bg-white/[0.03] p-3">
|
||
<p className="text-xs font-bold uppercase tracking-wide text-gray-400">
|
||
Good-faith deposit (optional)
|
||
</p>
|
||
<div className="flex gap-2">
|
||
<Input
|
||
type="number"
|
||
inputMode="numeric"
|
||
value={depositAmount}
|
||
onChange={(e) => setDepositAmount(e.target.value)}
|
||
className="h-9 flex-1 border-white/15 bg-white/5 text-sm text-white"
|
||
/>
|
||
<Button
|
||
size="sm"
|
||
onClick={() => setShowDeposit(true)}
|
||
className="shrink-0 gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||
>
|
||
Pay deposit
|
||
</Button>
|
||
</div>
|
||
<p className="text-[10px] leading-relaxed text-gray-600">
|
||
A swap has no invoice, so this is how it gets a{' '}
|
||
<strong className="text-gray-400">verifiable cost</strong>. Refund on delivery (or use
|
||
Cashu escrow for an automatic refund). ⚠️ Proves commitment, not delivery.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{showDeposit && (
|
||
<PayWithSats
|
||
open
|
||
onOpenChange={(open) => { if (!open) setShowDeposit(false); }}
|
||
sellerPubkey={item.pubkey}
|
||
amountSats={Number(depositAmount) > 0 ? Number(depositAmount) : undefined}
|
||
onPaid={(receipt) => {
|
||
if (receipt) saveReceipt(coord, receipt, 'deposit');
|
||
toast({
|
||
title: 'Deposit paid',
|
||
description: 'Saved — it will be attached when the trade is confirmed.',
|
||
});
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* Shipping logistics — self-reported, both sides. */}
|
||
{user && !isWTB && (item.fulfilment === 'ships' || item.fulfilment === 'both') && (
|
||
<ShippingStatusCard item={item} />
|
||
)}
|
||
|
||
{/* Mounted only while open — keeps the wallet hooks off every grid card. */}
|
||
{showPay && (
|
||
<PayWithSats
|
||
open
|
||
onOpenChange={(open) => { 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: 'Paid ⚡',
|
||
description: receipt?.preimage
|
||
? 'Saved a verifiable receipt — confirm the trade to make it count.'
|
||
: 'Ask the seller to confirm the trade so it counts for both of you.',
|
||
});
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{canEscrow && sellerEscrowPubkey && (
|
||
<EscrowRequestDialog
|
||
open={showEscrow}
|
||
onOpenChange={setShowEscrow}
|
||
listing={listingRef}
|
||
sellerPubkey={sellerEscrowPubkey}
|
||
sellerName={metadata?.name ?? ''}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|