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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue