SkateSpots/src/components/ListingTradeActions.tsx
SkateSpots 4f0416c23d feat(barter): optional good-faith deposit with a verifiable receipt
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.
2026-10-01 18:39:30 +07:00

398 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 &amp; 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>
);
}