From 5d1a0e0fb00b27e00223263725c8b8b0ef8df7b2 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Sat, 26 Sep 2026 22:30:43 +0700 Subject: [PATCH] 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. --- scripts/smoke-test.mjs | 1 + src/AppRouter.tsx | 2 + src/components/ListingTradeActions.tsx | 219 +++++++++++++++ src/components/MarketplaceItemCard.tsx | 223 ++-------------- src/hooks/useMarketplace.ts | 22 ++ src/pages/ListingDetailPage.tsx | 355 +++++++++++++++++++++++++ 6 files changed, 616 insertions(+), 206 deletions(-) create mode 100644 src/components/ListingTradeActions.tsx create mode 100644 src/pages/ListingDetailPage.tsx diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs index 196f629..7a20e44 100755 --- a/scripts/smoke-test.mjs +++ b/scripts/smoke-test.mjs @@ -128,6 +128,7 @@ const evalJS = async (expr) => (await send('Runtime.evaluate', { expression: exp const ROUTES = [ '/', '/marketplace', + '/listing/abc/def', '/leaderboards', '/bounties', '/settings', diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index be852e0..a191650 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/ListingTradeActions.tsx b/src/components/ListingTradeActions.tsx new file mode 100644 index 0000000..a68f480 --- /dev/null +++ b/src/components/ListingTradeActions.tsx @@ -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 | undefined)?.cashu_escrow === 'string' + ? ((metadata as Record).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 ( +
+ {/* Buyer: confirm the trade the seller recorded (builds both reps) */} + {sellerNamedMe && !iConfirmedTrade && ( + + )} + + {/* Buyer: lock funds in Cashu escrow and request confirmation (native) */} + {canEscrow && ( + + )} + + {/* Buyer: ask the seller to record the sale (kind 31872 request) */} + {!canEscrow && user && !isOwnListing && isSold && !iConfirmedTrade && !sellerNamedMe && !iRequestedConfirmation && ( + + )} + {iRequestedConfirmation && !iConfirmedTrade && ( +

+ Confirmation requested — waiting on the seller. +

+ )} + + {/* Buyer: reclaim escrow after the locktime if the seller didn't deliver */} + {escrow.available && myEscrowRefundable && ( + + )} + + {/* Contact button */} + + + {canEscrow && sellerEscrowPubkey && ( + + )} +
+ ); +} diff --git a/src/components/MarketplaceItemCard.tsx b/src/components/MarketplaceItemCard.tsx index 474e95a..1726f99 100644 --- a/src/components/MarketplaceItemCard.tsx +++ b/src/components/MarketplaceItemCard.tsx @@ -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 | undefined)?.cashu_escrow === 'string' - ? ((metadata as Record).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
{/* Product Image */} -
+ {primaryImage ? (
{isWTB ? '🔍' : '🛹'}
)} -
+ {/* Content */}
@@ -326,13 +223,15 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa WANT TO BUY )} -

- {item.title} -

+ +

+ {item.title} +

+
@@ -527,85 +426,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa )} {/* Action Buttons */} -
- {/* 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 && ( - - )} - - {/* Buyer: lock funds in Cashu escrow and request confirmation (native) */} - {canEscrow && ( - - )} - - {/* Buyer: ask the seller to record the sale (kind 31872 request) */} - {!canEscrow && user && !isOwnListing && isSold && !iConfirmedTrade && !sellerNamedMe && !iRequestedConfirmation && ( - - )} - {iRequestedConfirmation && !iConfirmedTrade && ( -

- Confirmation requested — waiting on the seller. -

- )} - - {/* Buyer: reclaim escrow after the locktime if the seller didn't deliver */} - {escrow.available && myEscrowRefundable && ( - - )} - - {/* Contact button */} - -
+
@@ -620,16 +441,6 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa /> )} - {/* Buyer: Cashu escrow + confirmation request (native-only) */} - {canEscrow && sellerEscrowPubkey && ( - - )} {/* Delete confirmation (controlled — trigger lives in the owner dropdown) */} => { + 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; + }, + }); +} diff --git a/src/pages/ListingDetailPage.tsx b/src/pages/ListingDetailPage.tsx new file mode 100644 index 0000000..212762f --- /dev/null +++ b/src/pages/ListingDetailPage.tsx @@ -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(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 ( + +
+ + + + +
+
+ ); + } + + if (!item) { + return ( + +
+ +

Listing not found

+

It may have been deleted, or the link is wrong.

+ +
+
+ ); + } + + 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 ( + +
+ + +
+ {/* Main column */} +
+ {/* Gallery */} + {item.images.length > 0 && ( +
+ {item.images.map((url, i) => ( + + ))} +
+ )} + + {/* Title + price */} +
+
+ {isWTB && ( + + WANT TO BUY + + )} + {isSold && ( + + SOLD + + )} + {item.status && item.status !== 'active' && !isSold && ( + + {item.status.toUpperCase()} + + )} +
+

+ {item.title} +

+

{priceDisplay}

+
+ + {/* Meta badges */} +
+ {conditionInfo && ( + + {conditionInfo.emoji} {conditionInfo.label} + + )} + {item.size && ( + + {item.size} + + )} + {categoryLabel && ( + + {categoryLabel} + + )} +
+ + {/* Description */} + {item.description && ( + + + + Description + + + +

+ {item.description} +

+
+
+ )} + + {/* Details */} + + + {item.location && ( +
+ {item.location} +
+ )} + {item.availability && ( +
+ Usually here {item.availability} +
+ )} +
+ Listed{' '} + {new Date(item.createdAt * 1000).toLocaleDateString()} +
+
+
+
+ + {/* Sidebar */} + +
+
+ + setLightboxIndex(null)} + /> + + {isOwnListing && ( + + )} + + {isOwnListing && ( + + )} +
+ ); +}