feat(marketplace): Buy with sats for fixed-price listings

A price_type=fixed listing now shows a direct 'Buy with sats' button in
ListingTradeActions (card + detail) - no negotiation needed. The price resolves
to sats (SATS directly, USD via useUsdToSats) and pays through PayWithSats (NWC ->
WebLN -> Cashu melt, falling back to the seller's lud16). The sheet mounts only
while open so the wallet hooks stay off every grid card. Reuses the accepted-offer
Pay rail.
This commit is contained in:
SkateSpots 2026-10-01 16:11:14 +07:00
parent baa770f1b8
commit eec56db124
2 changed files with 67 additions and 1 deletions

View file

@ -119,6 +119,14 @@ session with **no other memory of this project**. So:
NIP.md). `creq`/`clink` are recognized but **not payable yet**. This is the NIP.md). `creq`/`clink` are recognized but **not payable yet**. This is the
**Numo/BTCPay-derived** "pay the seller in-app" rail; the request format is **Numo/BTCPay-derived** "pay the seller in-app" rail; the request format is
ours (offer tags), not a new kind. ours (offer tags), not a new kind.
- **Buy with sats — fixed price, no negotiation (2026-10-01):** a
`price_type=fixed` listing shows a direct **Buy with sats** button in
`ListingTradeActions` (shared by card + detail). The price is resolved to
sats — **SATS** directly, **USD** via `useUsdToSats` (live rate); other
currencies fall back to the offer/contact flow — and paid through the same
`PayWithSats` sheet (NWC → WebLN → Cashu melt, falling back to the seller's
`lud16`). ⚠️ The sheet is **mounted only while open** so its wallet hooks
don't run on every grid card.
- **Cashu escrow** (marketplace): time-locked **P2PK** (lock to seller + buyer - **Cashu escrow** (marketplace): time-locked **P2PK** (lock to seller + buyer
refund key + locktime) — see **`ESCROW.md`** and `src/lib/cashuEscrow.ts` refund key + locktime) — see **`ESCROW.md`** and `src/lib/cashuEscrow.ts`
(unit-tested). Protocol: `cashu_token` on the `31872`; each user advertises a (unit-tested). Protocol: `cashu_token` on the `31872`; each user advertises a

View file

@ -14,8 +14,10 @@ import {
import { useCashuEscrow } from '@/hooks/wallet/useCashuEscrow'; import { useCashuEscrow } from '@/hooks/wallet/useCashuEscrow';
import { EscrowRequestDialog } from '@/components/EscrowRequestDialog'; import { EscrowRequestDialog } from '@/components/EscrowRequestDialog';
import { EscrowPanel } from '@/components/EscrowPanel'; import { EscrowPanel } from '@/components/EscrowPanel';
import { PayWithSats } from '@/components/PayWithSats';
import { useUsdToSats } from '@/hooks/useExchangeRate';
import { parseEscrowToken } from '@/lib/cashuEscrow'; import { parseEscrowToken } from '@/lib/cashuEscrow';
import { MessageCircle, Loader2, CheckCircle, Lock, Send, RotateCcw } from 'lucide-react'; import { MessageCircle, Loader2, CheckCircle, Lock, Send, RotateCcw, Zap } from 'lucide-react';
import type { MarketplaceItem } from '@/lib/types'; import type { MarketplaceItem } from '@/lib/types';
/** /**
@ -41,11 +43,29 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
const escrow = useCashuEscrow(); const escrow = useCashuEscrow();
const [showEscrow, setShowEscrow] = useState(false); const [showEscrow, setShowEscrow] = useState(false);
const [showPay, setShowPay] = useState(false);
const isOwnListing = user?.pubkey === item.pubkey; const isOwnListing = user?.pubkey === item.pubkey;
const isSold = item.status === 'sold'; const isSold = item.status === 'sold';
const isWTB = item.listingType === 'wtb'; 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 = const sellerNamedMe =
!!user && !!user &&
!isOwnListing && !isOwnListing &&
@ -56,6 +76,16 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
const iConfirmedTrade = !!trade?.iConfirmed; const iConfirmedTrade = !!trade?.iConfirmed;
const iRequestedConfirmation = !!requestState?.iRequested; const iRequestedConfirmation = !!requestState?.iRequested;
const canBuy =
!!user &&
!isOwnListing &&
!isWTB &&
!isSold &&
!iConfirmedTrade &&
item.priceType === 'fixed' &&
typeof buyAmountSats === 'number' &&
buyAmountSats > 0;
const sellerEscrowPubkey = const sellerEscrowPubkey =
typeof (metadata as Record<string, unknown> | undefined)?.cashu_escrow === 'string' typeof (metadata as Record<string, unknown> | undefined)?.cashu_escrow === 'string'
? ((metadata as Record<string, unknown>).cashu_escrow as string) ? ((metadata as Record<string, unknown>).cashu_escrow as string)
@ -194,6 +224,18 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
</Button> </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 */} {/* Contact button */}
<Button <Button
variant="outline" variant="outline"
@ -209,6 +251,22 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
{isWTB ? 'I Have This!' : 'Contact Seller'} {isWTB ? 'I Have This!' : 'Contact Seller'}
</Button> </Button>
{/* 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={() =>
toast({
title: 'Paid ⚡',
description: 'Ask the seller to confirm the trade so it counts for both of you.',
})
}
/>
)}
{canEscrow && sellerEscrowPubkey && ( {canEscrow && sellerEscrowPubkey && (
<EscrowRequestDialog <EscrowRequestDialog
open={showEscrow} open={showEscrow}