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:
SkateSpots 2026-09-26 22:30:43 +07:00
parent c0aec3d912
commit 5d1a0e0fb0
6 changed files with 616 additions and 206 deletions

View file

@ -128,6 +128,7 @@ const evalJS = async (expr) => (await send('Runtime.evaluate', { expression: exp
const ROUTES = [
'/',
'/marketplace',
'/listing/abc/def',
'/leaderboards',
'/bounties',
'/settings',

View file

@ -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 />} />

View 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 &amp; 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>
);
}

View file

@ -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,6 +223,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
WANT TO BUY
</Badge>
)}
<Link to={detailUrl}>
<h3
className={`font-black text-base uppercase ${
isWTB ? 'text-blue-400' : 'text-orange-400'
@ -333,6 +231,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
>
{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 &amp; 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

View file

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

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