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:
parent
baa770f1b8
commit
eec56db124
2 changed files with 67 additions and 1 deletions
|
|
@ -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
|
||||
**Numo/BTCPay-derived** "pay the seller in-app" rail; the request format is
|
||||
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
|
||||
refund key + locktime) — see **`ESCROW.md`** and `src/lib/cashuEscrow.ts`
|
||||
(unit-tested). Protocol: `cashu_token` on the `31872`; each user advertises a
|
||||
|
|
|
|||
|
|
@ -14,8 +14,10 @@ import {
|
|||
import { useCashuEscrow } from '@/hooks/wallet/useCashuEscrow';
|
||||
import { EscrowRequestDialog } from '@/components/EscrowRequestDialog';
|
||||
import { EscrowPanel } from '@/components/EscrowPanel';
|
||||
import { PayWithSats } from '@/components/PayWithSats';
|
||||
import { useUsdToSats } from '@/hooks/useExchangeRate';
|
||||
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';
|
||||
|
||||
/**
|
||||
|
|
@ -41,11 +43,29 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
|
|||
const escrow = useCashuEscrow();
|
||||
|
||||
const [showEscrow, setShowEscrow] = useState(false);
|
||||
const [showPay, setShowPay] = useState(false);
|
||||
|
||||
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 &&
|
||||
|
|
@ -56,6 +76,16 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
|
|||
const iConfirmedTrade = !!trade?.iConfirmed;
|
||||
const iRequestedConfirmation = !!requestState?.iRequested;
|
||||
|
||||
const canBuy =
|
||||
!!user &&
|
||||
!isOwnListing &&
|
||||
!isWTB &&
|
||||
!isSold &&
|
||||
!iConfirmedTrade &&
|
||||
item.priceType === 'fixed' &&
|
||||
typeof buyAmountSats === 'number' &&
|
||||
buyAmountSats > 0;
|
||||
|
||||
const sellerEscrowPubkey =
|
||||
typeof (metadata as Record<string, unknown> | undefined)?.cashu_escrow === 'string'
|
||||
? ((metadata as Record<string, unknown>).cashu_escrow as string)
|
||||
|
|
@ -194,6 +224,18 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
|
|||
</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"
|
||||
|
|
@ -209,6 +251,22 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
|
|||
{isWTB ? 'I Have This!' : 'Contact Seller'}
|
||||
</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 && (
|
||||
<EscrowRequestDialog
|
||||
open={showEscrow}
|
||||
|
|
|
|||
Loading…
Reference in a new issue