feat(marketplace): listing detail page + shared trade/escrow actions
- New '/listing/:pubkey/:identifier' page: large gallery + lightbox, full description, details, seller trust + trade reputation card, and full actions (shared) for buyers and owners (mark sold/relist/delete) - Extract ListingTradeActions (confirm/escrow request/ask-to-confirm/refund/ contact + escrow dialog) and use it in BOTH the grid card and the detail page so there is one implementation - useMarketplaceItem(pubkey, identifier) fetch hook - Cards link their image + title to the detail page - Smoke test covers /listing/:pubkey/:identifier Gate: 101 tests, 0 errors/warnings, boot smoke green.
This commit is contained in:
parent
c0aec3d912
commit
5d1a0e0fb0
6 changed files with 616 additions and 206 deletions
|
|
@ -128,6 +128,7 @@ const evalJS = async (expr) => (await send('Runtime.evaluate', { expression: exp
|
|||
const ROUTES = [
|
||||
'/',
|
||||
'/marketplace',
|
||||
'/listing/abc/def',
|
||||
'/leaderboards',
|
||||
'/bounties',
|
||||
'/settings',
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ const EditShopPage = lazyWithRetry(() => import("./pages/EditShopPage"));
|
|||
const EditEventPage = lazyWithRetry(() => import("./pages/EditEventPage"));
|
||||
const EditTourPage = lazyWithRetry(() => import("./pages/EditTourPage"));
|
||||
const GearSwapPage = lazyWithRetry(() => import("./pages/GearSwapPage"));
|
||||
const ListingDetailPage = lazyWithRetry(() => import("./pages/ListingDetailPage"));
|
||||
const SpotDetailPage = lazyWithRetry(() => import("./pages/SpotDetailPage"));
|
||||
const ShopDetailPage = lazyWithRetry(() => import("./pages/ShopDetailPage"));
|
||||
const EventDetailPage = lazyWithRetry(() => import("./pages/EventDetailPage"));
|
||||
|
|
@ -119,6 +120,7 @@ export function AppRouter() {
|
|||
<Route path="/onboarding" element={<OnboardingPage />} />
|
||||
<Route path="/pyramid" element={<PyramidPage />} />
|
||||
<Route path="/marketplace" element={<GearSwapPage />} />
|
||||
<Route path="/listing/:pubkey/:identifier" element={<ListingDetailPage />} />
|
||||
<Route path="/stats" element={<CommunityStatsPage />} />
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
<Route path="/faq" element={<FAQPage />} />
|
||||
|
|
|
|||
219
src/components/ListingTradeActions.tsx
Normal file
219
src/components/ListingTradeActions.tsx
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Button } from '@/components/ui/button';
|
||||
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 { parseEscrowToken } from '@/lib/cashuEscrow';
|
||||
import { MessageCircle, Loader2, CheckCircle, Lock, Send, RotateCcw } from 'lucide-react';
|
||||
import type { MarketplaceItem } from '@/lib/types';
|
||||
|
||||
/**
|
||||
* 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 { 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 { 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 isOwnListing = user?.pubkey === item.pubkey;
|
||||
const isSold = item.status === 'sold';
|
||||
const isWTB = item.listingType === 'wtb';
|
||||
|
||||
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 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;
|
||||
try {
|
||||
await publishTrade({ listing: listingRef, role: 'bought', counterparty: item.pubkey });
|
||||
toast({ title: 'Trade confirmed', description: 'Reputation updated for both of you.' });
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not confirm the trade',
|
||||
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestConfirmation = async () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
await publishRequest({
|
||||
listing: listingRef,
|
||||
attestor: item.pubkey,
|
||||
note: `Please confirm our trade for “${item.title}”.`,
|
||||
});
|
||||
toast({
|
||||
title: 'Confirmation requested',
|
||||
description: 'The seller can now confirm the sale and build both reputations.',
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not send the request',
|
||||
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefundEscrow = async () => {
|
||||
if (!myEscrowToken) return;
|
||||
try {
|
||||
await escrow.redeemEscrow(myEscrowToken);
|
||||
toast({ title: 'Escrow refunded', description: 'The funds are back in your Cashu wallet.' });
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not refund escrow',
|
||||
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* 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" />}
|
||||
Confirm this trade
|
||||
</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" />
|
||||
Escrow & request
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 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" />}
|
||||
Ask seller to confirm
|
||||
</Button>
|
||||
)}
|
||||
{iRequestedConfirmation && !iConfirmedTrade && (
|
||||
<p className="text-xs text-center text-gray-500">
|
||||
Confirmation requested — waiting on the seller.
|
||||
</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" />
|
||||
Refund escrow
|
||||
</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 ? 'I Have This!' : 'Contact Seller'}
|
||||
</Button>
|
||||
|
||||
{canEscrow && sellerEscrowPubkey && (
|
||||
<EscrowRequestDialog
|
||||
open={showEscrow}
|
||||
onOpenChange={setShowEscrow}
|
||||
listing={listingRef}
|
||||
sellerPubkey={sellerEscrowPubkey}
|
||||
sellerName={metadata?.name ?? ''}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
|
@ -20,14 +19,12 @@ import { useSellerInfo } from '@/hooks/useSellerInfo';
|
|||
import { useTrustScore } from '@/hooks/useTrustScore';
|
||||
import { useMarkListingSold } from '@/hooks/useMarkListingSold';
|
||||
import { MarkSoldDialog } from '@/components/MarkSoldDialog';
|
||||
import { EscrowRequestDialog } from '@/components/EscrowRequestDialog';
|
||||
import { useCashuEscrow } from '@/hooks/wallet/useCashuEscrow';
|
||||
import { parseEscrowToken } from '@/lib/cashuEscrow';
|
||||
import { useListingTrade, useTradeAttestation, useTradeReputation, useListingTradeRequest, useTradeAttestationRequest } from '@/hooks/useTradeAttestations';
|
||||
import { ListingTradeActions } from '@/components/ListingTradeActions';
|
||||
import { useTradeReputation } from '@/hooks/useTradeAttestations';
|
||||
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { TrustBadge } from '@/components/TrustBadge';
|
||||
import { MessageCircle, Clock, Search, Tag, Flag, MoreVertical, ShieldCheck, Calendar, Package, AlertTriangle, CheckCircle, RotateCcw, Trash2, Loader2, Send, Lock } from 'lucide-react';
|
||||
import { Clock, Search, Tag, Flag, MoreVertical, ShieldCheck, Calendar, Package, AlertTriangle, CheckCircle, RotateCcw, Trash2, Loader2 } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import type { MarketplaceItem } from '@/lib/types';
|
||||
import { GEAR_CONDITIONS, GEAR_CATEGORIES, PRICE_TYPES, LISTING_REPORT_REASONS, formatPrice } from '@/lib/types';
|
||||
|
|
@ -40,7 +37,6 @@ interface MarketplaceItemCardProps {
|
|||
export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCardProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const { relist, isPending: isUpdatingStatus } = useMarkListingSold();
|
||||
const author = useAuthor(item.pubkey);
|
||||
|
|
@ -48,8 +44,6 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
const displayName = metadata?.name ?? genUserName(item.pubkey);
|
||||
const avatar = metadata?.picture;
|
||||
|
||||
const npub = nip19.npubEncode(item.pubkey);
|
||||
|
||||
// Fetch reports for this listing
|
||||
const { data: reportData } = useListingReports(item.id, item.pubkey);
|
||||
|
||||
|
|
@ -68,13 +62,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
|
||||
// Trade attestations (Attestations NIP, kind 31871) — see useTradeAttestations.
|
||||
const listingRef = { pubkey: item.pubkey, identifier: item.identifier };
|
||||
const { data: trade } = useListingTrade(listingRef, user?.pubkey);
|
||||
const { data: sellerRep } = useTradeReputation(item.pubkey);
|
||||
const { publishTrade, isPending: confirmingTrade } = useTradeAttestation();
|
||||
const { data: requestState } = useListingTradeRequest(listingRef, user?.pubkey);
|
||||
const { publishRequest, isPending: requestingTrade } = useTradeAttestationRequest();
|
||||
const escrow = useCashuEscrow();
|
||||
const [showEscrow, setShowEscrow] = useState(false);
|
||||
|
||||
// Format price display
|
||||
const priceDisplay = item.price
|
||||
|
|
@ -84,6 +72,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
const timeAgo = formatDistanceToNow(new Date(item.createdAt * 1000), { addSuffix: true });
|
||||
|
||||
const primaryImage = item.images[0];
|
||||
const detailUrl = `/listing/${item.pubkey}/${item.identifier}`;
|
||||
const isWTB = item.listingType === 'wtb';
|
||||
// Expiration is stored as unix timestamp in seconds
|
||||
const isExpired = item.expiration ? item.expiration < Math.floor(Date.now() / 1000) : false;
|
||||
|
|
@ -169,98 +158,6 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
const isOwnListing = user?.pubkey === item.pubkey;
|
||||
const isSold = item.status === 'sold';
|
||||
|
||||
// Was the viewer named as the buyer by the seller? Then they can confirm.
|
||||
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;
|
||||
|
||||
// Seller's advertised Cashu escrow pubkey (kind 0 `cashu_escrow`), if any.
|
||||
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;
|
||||
|
||||
// The buyer's own escrow token (from their published 31872), refundable after
|
||||
// the locktime if the seller never delivered.
|
||||
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 handleRefundEscrow = async () => {
|
||||
if (!myEscrowToken) return;
|
||||
try {
|
||||
await escrow.redeemEscrow(myEscrowToken);
|
||||
toast({
|
||||
title: 'Escrow refunded',
|
||||
description: 'The funds are back in your Cashu wallet.',
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not refund escrow',
|
||||
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmTrade = async () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
await publishTrade({ listing: listingRef, role: 'bought', counterparty: item.pubkey });
|
||||
toast({ title: 'Trade confirmed', description: 'Reputation updated for both of you.' });
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not confirm the trade',
|
||||
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestConfirmation = async () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
await publishRequest({
|
||||
listing: listingRef,
|
||||
attestor: item.pubkey,
|
||||
note: `Please confirm our trade for “${item.title}”.`,
|
||||
});
|
||||
toast({
|
||||
title: 'Confirmation requested',
|
||||
description: 'The seller can now confirm the sale and build both reputations.',
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not send the request',
|
||||
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRelist = async () => {
|
||||
try {
|
||||
await relist(item.event);
|
||||
|
|
@ -295,7 +192,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Product Image */}
|
||||
<div className="flex-shrink-0">
|
||||
<Link to={detailUrl} className="flex-shrink-0" aria-label={`View ${item.title}`}>
|
||||
{primaryImage ? (
|
||||
<div
|
||||
className={`w-20 h-20 rounded-lg overflow-hidden border-2 ${
|
||||
|
|
@ -313,7 +210,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
<span className="text-3xl">{isWTB ? '🔍' : '🛹'}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
|
|
@ -326,13 +223,15 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
WANT TO BUY
|
||||
</Badge>
|
||||
)}
|
||||
<h3
|
||||
className={`font-black text-base uppercase ${
|
||||
isWTB ? 'text-blue-400' : 'text-orange-400'
|
||||
} leading-tight`}
|
||||
>
|
||||
{item.title}
|
||||
</h3>
|
||||
<Link to={detailUrl}>
|
||||
<h3
|
||||
className={`font-black text-base uppercase ${
|
||||
isWTB ? 'text-blue-400' : 'text-orange-400'
|
||||
} leading-tight`}
|
||||
>
|
||||
{item.title}
|
||||
</h3>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="text-right flex items-start gap-1">
|
||||
<div>
|
||||
|
|
@ -527,85 +426,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="space-y-2">
|
||||
{/* NOTE: there is deliberately no "Pay with Cashu" button here.
|
||||
Payments are not implemented, and a button that only shows a
|
||||
"coming soon" toast is a dead end — buyers contact the seller
|
||||
and arrange payment directly. */}
|
||||
|
||||
{/* 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" />}
|
||||
Confirm this trade
|
||||
</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" />
|
||||
Escrow & request
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 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" />}
|
||||
Ask seller to confirm
|
||||
</Button>
|
||||
)}
|
||||
{iRequestedConfirmation && !iConfirmedTrade && (
|
||||
<p className="text-xs text-center text-gray-500">
|
||||
Confirmation requested — waiting on the seller.
|
||||
</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" />
|
||||
Refund escrow
|
||||
</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/${npub}`)}
|
||||
>
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
{isWTB ? 'I Have This!' : 'Contact Seller'}
|
||||
</Button>
|
||||
</div>
|
||||
<ListingTradeActions item={item} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -620,16 +441,6 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Buyer: Cashu escrow + confirmation request (native-only) */}
|
||||
{canEscrow && sellerEscrowPubkey && (
|
||||
<EscrowRequestDialog
|
||||
open={showEscrow}
|
||||
onOpenChange={setShowEscrow}
|
||||
listing={listingRef}
|
||||
sellerPubkey={sellerEscrowPubkey}
|
||||
sellerName={displayName}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation (controlled — trigger lives in the owner dropdown) */}
|
||||
<DeleteEntityDialog
|
||||
|
|
|
|||
|
|
@ -88,3 +88,25 @@ export function useMarketplaceAtSpot(spotPubkey: string, spotIdentifier: string,
|
|||
refetchOnMount: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch a single marketplace listing by author + `d` (including sold/inactive). */
|
||||
export function useMarketplaceItem(pubkey?: string, identifier?: string) {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['marketplace-item', pubkey, identifier, getQueryKeySuffix()],
|
||||
enabled: !!pubkey && !!identifier,
|
||||
staleTime: STALE_TIME,
|
||||
gcTime: GC_TIME,
|
||||
queryFn: async (): Promise<MarketplaceItem | null> => {
|
||||
if (!pubkey || !identifier) return null;
|
||||
const signal = AbortSignal.timeout(TIMEOUT);
|
||||
const events = await querySkateContent(
|
||||
[{ kinds: [MARKETPLACE_ITEM_KIND], authors: [pubkey], '#d': [identifier], limit: 5 }],
|
||||
{ signal },
|
||||
);
|
||||
const newest = [...events].sort((a, b) => b.created_at - a.created_at)[0];
|
||||
return newest ? parseMarketplaceItem(newest) : null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
355
src/pages/ListingDetailPage.tsx
Normal file
355
src/pages/ListingDetailPage.tsx
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
import { useState } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Layout } from '@/components/Layout';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ImageWithFallback } from '@/components/ImageWithFallback';
|
||||
import { ImageLightbox, type LightboxImage } from '@/components/ImageLightbox';
|
||||
import { TrustBadge } from '@/components/TrustBadge';
|
||||
import { TradeReputationCard } from '@/components/TradeReputationCard';
|
||||
import { ListingTradeActions } from '@/components/ListingTradeActions';
|
||||
import { MarkSoldDialog } from '@/components/MarkSoldDialog';
|
||||
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
||||
import { useMarketplaceItem } from '@/hooks/useMarketplace';
|
||||
import { useMarkListingSold } from '@/hooks/useMarkListingSold';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useSellerInfo } from '@/hooks/useSellerInfo';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { formatPrice, GEAR_CONDITIONS, GEAR_CATEGORIES } from '@/lib/types';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
ArrowLeft,
|
||||
MessageCircle,
|
||||
CheckCircle,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Package,
|
||||
MapPin,
|
||||
Calendar,
|
||||
Tag,
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Full marketplace listing page (`/listing/:pubkey/:identifier`).
|
||||
*
|
||||
* Gives a listing a dedicated, shareable surface: a large gallery, the full
|
||||
* description, seller trust + trade reputation, and the shared trade/escrow
|
||||
* actions (`ListingTradeActions`, also used by the grid card).
|
||||
*/
|
||||
export default function ListingDetailPage() {
|
||||
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useCurrentUser();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: item, isLoading } = useMarketplaceItem(pubkey, identifier);
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const { data: sellerInfo } = useSellerInfo(pubkey);
|
||||
const { relist, isPending: isUpdatingStatus } = useMarkListingSold();
|
||||
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
const [showMarkSold, setShowMarkSold] = useState(false);
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
|
||||
useSeoMeta({
|
||||
title: item ? `${item.title} — SkateSpots Marketplace` : 'Listing — SkateSpots',
|
||||
description: item?.summary || item?.description || 'A listing on the SkateSpots marketplace.',
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container mx-auto max-w-4xl px-4 py-8 space-y-4">
|
||||
<Skeleton className="h-6 w-40" />
|
||||
<Skeleton className="aspect-video w-full rounded-2xl" />
|
||||
<Skeleton className="h-8 w-2/3" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container mx-auto max-w-md px-4 py-16 text-center space-y-4">
|
||||
<Package className="mx-auto h-10 w-10 text-gray-600" />
|
||||
<h1 className="text-xl font-black uppercase text-white">Listing not found</h1>
|
||||
<p className="text-sm text-gray-400">It may have been deleted, or the link is wrong.</p>
|
||||
<Button asChild className="bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90">
|
||||
<Link to="/marketplace">Back to Gear Swap</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const isOwnListing = user?.pubkey === item.pubkey;
|
||||
const isWTB = item.listingType === 'wtb';
|
||||
const isSold = item.status === 'sold';
|
||||
const priceDisplay = item.price
|
||||
? `${formatPrice(item.price, item.priceType)}${item.price.frequency ? `/${item.price.frequency}` : ''}`
|
||||
: formatPrice(undefined, item.priceType);
|
||||
const conditionInfo = item.condition ? GEAR_CONDITIONS[item.condition] : null;
|
||||
const categoryLabel = item.category.find((c) => GEAR_CATEGORIES[c as keyof typeof GEAR_CATEGORIES]);
|
||||
const displayName = metadata?.name ?? (pubkey ? genUserName(pubkey) : 'Unknown');
|
||||
const npub = pubkey ? nip19.npubEncode(pubkey) : '';
|
||||
|
||||
const lightboxImages: LightboxImage[] = item.images.map((url) => ({ url, isOwner: isOwnListing }));
|
||||
|
||||
const handleRelist = async () => {
|
||||
try {
|
||||
await relist(item.event);
|
||||
toast({ title: 'Relisted', description: 'Your listing is active again.' });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Could not update',
|
||||
description: error instanceof Error ? error.message : 'Something went wrong',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container mx-auto max-w-5xl px-4 py-6">
|
||||
<button
|
||||
onClick={() => navigate('/marketplace')}
|
||||
className="mb-4 inline-flex items-center gap-1.5 text-sm text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" /> Gear Swap
|
||||
</button>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
{/* Main column */}
|
||||
<div className="min-w-0 space-y-6">
|
||||
{/* Gallery */}
|
||||
{item.images.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{item.images.map((url, i) => (
|
||||
<button
|
||||
key={url + i}
|
||||
type="button"
|
||||
onClick={() => setLightboxIndex(i)}
|
||||
className={cn(
|
||||
'group relative overflow-hidden rounded-2xl border border-white/10 bg-black/40',
|
||||
i === 0 && item.images.length > 1 ? 'sm:col-span-2 aspect-video' : 'aspect-square',
|
||||
)}
|
||||
aria-label={`View photo ${i + 1}`}
|
||||
>
|
||||
<ImageWithFallback
|
||||
src={url}
|
||||
alt={`${item.title} — photo ${i + 1}`}
|
||||
className="h-full w-full object-cover transition-transform group-hover:scale-[1.02]"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title + price */}
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
{isWTB && (
|
||||
<Badge className="border-blue-500/50 bg-blue-500/20 text-blue-400">
|
||||
<Package className="mr-1 h-3 w-3" /> WANT TO BUY
|
||||
</Badge>
|
||||
)}
|
||||
{isSold && (
|
||||
<Badge variant="secondary" className="bg-gray-700 text-gray-300">
|
||||
SOLD
|
||||
</Badge>
|
||||
)}
|
||||
{item.status && item.status !== 'active' && !isSold && (
|
||||
<Badge variant="secondary" className="bg-yellow-900/50 text-yellow-400">
|
||||
{item.status.toUpperCase()}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-2xl font-black uppercase leading-tight text-white sm:text-3xl">
|
||||
{item.title}
|
||||
</h1>
|
||||
<p className="mt-2 text-2xl font-bold text-orange-400">{priceDisplay}</p>
|
||||
</div>
|
||||
|
||||
{/* Meta badges */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{conditionInfo && (
|
||||
<Badge variant="outline" className="border-gray-600 bg-gray-800/50">
|
||||
{conditionInfo.emoji} {conditionInfo.label}
|
||||
</Badge>
|
||||
)}
|
||||
{item.size && (
|
||||
<Badge variant="outline" className="border-gray-600 bg-gray-800/50">
|
||||
{item.size}
|
||||
</Badge>
|
||||
)}
|
||||
{categoryLabel && (
|
||||
<Badge variant="outline" className="border-gray-600 bg-gray-800/50">
|
||||
<Tag className="mr-1 h-3 w-3" /> {categoryLabel}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{item.description && (
|
||||
<Card className="bg-black/90 border-white/10">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm uppercase tracking-wide text-gray-400">
|
||||
Description
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="whitespace-pre-wrap break-words text-sm text-gray-200">
|
||||
{item.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Details */}
|
||||
<Card className="bg-black/90 border-white/10">
|
||||
<CardContent className="grid grid-cols-1 gap-3 p-4 text-sm sm:grid-cols-2">
|
||||
{item.location && (
|
||||
<div className="flex items-center gap-2 text-gray-300">
|
||||
<MapPin className="h-4 w-4 text-gray-500" /> {item.location}
|
||||
</div>
|
||||
)}
|
||||
{item.availability && (
|
||||
<div className="flex items-center gap-2 text-gray-300">
|
||||
<Calendar className="h-4 w-4 text-gray-500" /> Usually here {item.availability}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
<Calendar className="h-4 w-4" /> Listed{' '}
|
||||
{new Date(item.createdAt * 1000).toLocaleDateString()}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className="space-y-6">
|
||||
{/* Seller */}
|
||||
<Card className="bg-black/90 border-white/10">
|
||||
<CardContent className="space-y-3 p-4">
|
||||
<Link to={`/${npub}`} className="flex items-center gap-3 group">
|
||||
<div className="h-10 w-10 overflow-hidden rounded-full border border-white/10 bg-white/5">
|
||||
{metadata?.picture ? (
|
||||
<ImageWithFallback
|
||||
src={metadata.picture}
|
||||
alt={displayName}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-bold text-white group-hover:text-[#ccff00]">
|
||||
{displayName}
|
||||
</p>
|
||||
<p className="truncate text-xs text-gray-500">{npub}</p>
|
||||
</div>
|
||||
<TrustBadge pubkey={item.pubkey} size="sm" showLabel={false} className="ml-auto" />
|
||||
</Link>
|
||||
{sellerInfo && sellerInfo.listingCount > 0 && (
|
||||
<p className="text-xs text-gray-500">
|
||||
{sellerInfo.listingCount} listing{sellerInfo.listingCount === 1 ? '' : 's'} ·{' '}
|
||||
{sellerInfo.accountAge}d old
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
{isOwnListing ? (
|
||||
<Card className="bg-black/90 border-white/10">
|
||||
<CardContent className="space-y-2 p-4">
|
||||
{isSold ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRelist}
|
||||
disabled={isUpdatingStatus}
|
||||
className="w-full gap-2 border-white/15"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" /> Relist as active
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowMarkSold(true)}
|
||||
className="w-full gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4" /> Mark as sold
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowDelete(true)}
|
||||
className="w-full gap-2 border-red-500/30 text-red-400 hover:bg-red-500/10"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" /> Delete listing
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="bg-black/90 border-white/10">
|
||||
<CardContent className="p-4">
|
||||
<ListingTradeActions item={item} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
asChild
|
||||
className="mt-2 w-full gap-2 text-gray-400 hover:text-white"
|
||||
>
|
||||
<Link to={`/messages/${npub}`}>
|
||||
<MessageCircle className="h-4 w-4" /> Message {displayName}
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Seller trade reputation */}
|
||||
<TradeReputationCard pubkey={item.pubkey} />
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ImageLightbox
|
||||
images={lightboxImages}
|
||||
index={lightboxIndex}
|
||||
onIndexChange={setLightboxIndex}
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
/>
|
||||
|
||||
{isOwnListing && (
|
||||
<MarkSoldDialog
|
||||
open={showMarkSold}
|
||||
onOpenChange={setShowMarkSold}
|
||||
event={item.event}
|
||||
listing={{ pubkey: item.pubkey, identifier: item.identifier }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isOwnListing && (
|
||||
<DeleteEntityDialog
|
||||
event={item.event}
|
||||
entityLabel="listing"
|
||||
entityName={item.title}
|
||||
open={showDelete}
|
||||
onOpenChange={setShowDelete}
|
||||
redirectTo="/marketplace"
|
||||
/>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue