- Tour sponsor URL/logo sanitized before href/src (SponsorSlot) — same XSS guard EventSponsors already uses. - Official SkateSpots badge only shows when the NIP-05 actually verifies (NIP19Page), not on a self-asserted handle. - Community stats reads are null-guarded on persisted-cache fields (monthly/cumulativeSpots/topMappers) — no more crash on an old cache. - Overpass response no longer assumes .elements (food layer). - user-listings query key is memoized (was Date.now() → refetch storm). - Pro grant requires the l=pro label (was accepted with none). - Verify card RSVP maps accepted/tentative (was going/interested → showed 'No RSVP' on a valid ticket). - Expired listings can't be bought via a stale/shared URL. - Batch spot photo/status hooks include the relay suffix and use querySkateContent (relay-policy parity with useSpotReports). - Offline queue requeues rows stuck in 'publishing' after a crash. - querySkateContent/queryCanonical dedupe replaceable events by author+kind (not id) so a stale profile/relay list can't win. - Route offline cache key includes the origin (was destination+mode only). - Internal links use <Link> (MarketplacePage); EventCountdown reacts to language changes.
399 lines
15 KiB
TypeScript
399 lines
15 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';
|
|
import { useTranslation } from '@/contexts/language';
|
|
|
|
/**
|
|
* 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 { t } = useTranslation();
|
|
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 isExpired = !!item.expiration && item.expiration < Math.floor(Date.now() / 1000);
|
|
const canBuy =
|
|
!!user &&
|
|
!isOwnListing &&
|
|
!isWTB &&
|
|
!isSold &&
|
|
!isExpired &&
|
|
!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: t('marketUi.tradeActions.tradeConfirmed'),
|
|
description:
|
|
paymentProof || depositProof
|
|
? t('marketUi.tradeActions.tradeConfirmedWithProof')
|
|
: t('marketUi.tradeActions.tradeConfirmedBody'),
|
|
});
|
|
} catch (e) {
|
|
toast({
|
|
title: t('marketUi.tradeActions.tradeConfirmFailed'),
|
|
description: e instanceof Error ? e.message : t('marketUi.tradeActions.somethingWrong'),
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleRequestConfirmation = async () => {
|
|
if (!user) return;
|
|
try {
|
|
await publishRequest({
|
|
listing: listingRef,
|
|
attestor: item.pubkey,
|
|
note: t('marketUi.tradeActions.requestNote', { title: item.title }),
|
|
});
|
|
toast({
|
|
title: t('marketUi.tradeActions.requestSent'),
|
|
description: t('marketUi.tradeActions.requestSentBody'),
|
|
});
|
|
} catch (e) {
|
|
toast({
|
|
title: t('marketUi.tradeActions.requestFailed'),
|
|
description: e instanceof Error ? e.message : t('marketUi.tradeActions.somethingWrong'),
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleRefundEscrow = async () => {
|
|
if (!myEscrowToken) return;
|
|
try {
|
|
await escrow.redeemEscrow(myEscrowToken);
|
|
toast({ title: t('marketUi.tradeActions.escrowRefunded'), description: t('marketUi.tradeActions.escrowRefundedBody') });
|
|
} catch (e) {
|
|
toast({
|
|
title: t('marketUi.tradeActions.escrowRefundFailed'),
|
|
description: e instanceof Error ? e.message : t('marketUi.tradeActions.somethingWrong'),
|
|
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" />}
|
|
{t('marketUi.tradeActions.confirmThisTrade')}
|
|
</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" />
|
|
{t('marketUi.tradeActions.escrowAndRequest')}
|
|
</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">
|
|
📦 {t('marketUi.tradeActions.shipsEscrowHint')}
|
|
</p>
|
|
)}
|
|
{item.fulfilment === 'ships' && !canEscrow && (
|
|
<p className="text-[11px] text-center text-amber-400/90">
|
|
📦 {t('marketUi.tradeActions.shipsHint')}
|
|
</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" />}
|
|
{t('marketUi.tradeActions.askSeller')}
|
|
</Button>
|
|
)}
|
|
{iRequestedConfirmation && !iConfirmedTrade && (
|
|
<p className="text-xs text-center text-gray-500">
|
|
{t('marketUi.tradeActions.waitingSeller')}
|
|
</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" />
|
|
{t('marketUi.tradeActions.refundEscrow')}
|
|
</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" />
|
|
{t('marketUi.tradeActions.buyWithSats')}{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 ? t('marketUi.card.iHaveThis') : t('marketplace.contact')}
|
|
</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">
|
|
{t('marketUi.tradeActions.depositTitle')}
|
|
</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"
|
|
>
|
|
{t('marketUi.tradeActions.payDeposit')}
|
|
</Button>
|
|
</div>
|
|
<p className="text-[10px] leading-relaxed text-gray-600">
|
|
{t('marketUi.tradeActions.depositBefore')}{' '}
|
|
<strong className="text-gray-400">{t('marketUi.tradeActions.verifiableCost')}</strong>{t('marketUi.tradeActions.depositAfter')}
|
|
</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: t('marketUi.tradeActions.depositPaid'),
|
|
description: t('marketUi.tradeActions.depositPaidBody'),
|
|
});
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* 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: t('marketUi.tradeActions.paid'),
|
|
description: receipt?.preimage
|
|
? t('marketUi.tradeActions.paidWithProof')
|
|
: t('marketUi.tradeActions.paidBody'),
|
|
});
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{canEscrow && sellerEscrowPubkey && (
|
|
<EscrowRequestDialog
|
|
open={showEscrow}
|
|
onOpenChange={setShowEscrow}
|
|
listing={listingRef}
|
|
sellerPubkey={sellerEscrowPubkey}
|
|
sellerName={metadata?.name ?? ''}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|