SkateSpots/src/components/ListingTradeActions.tsx
SkateSpots e016ee20bd fix: tier 3 — medium bugs & hardening
- 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.
2026-10-02 22:26:46 +07:00

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