- TradeRecommendationsCard on profiles: shows who recommends this trader (kind 31873 'Attestor Recommendation') and lets a logged-in viewer recommend them. New useTraderRecommendations hook. - OfferDialog sends a best-effort NIP-17 DM to the seller when an offer is made; ListingOffersPanel DMs the buyer when the seller accepts/declines. - AGENTS.md notes 31873. Gate: 101 tests, 0 errors/warnings, boot smoke green.
387 lines
15 KiB
TypeScript
387 lines
15 KiB
TypeScript
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 { ListingOffersPanel } from '@/components/ListingOffersPanel';
|
|
import { OfferDialog } from '@/components/OfferDialog';
|
|
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,
|
|
CheckCircle,
|
|
RotateCcw,
|
|
Trash2,
|
|
Package,
|
|
MapPin,
|
|
Calendar,
|
|
Tag,
|
|
Handshake,
|
|
Share2,
|
|
} 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);
|
|
const [showOffer, setShowOffer] = 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 handleShare = async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(window.location.href);
|
|
toast({ title: 'Link copied', description: 'Share it with a skater.' });
|
|
} catch {
|
|
toast({ title: 'Could not copy the link', variant: 'destructive' });
|
|
}
|
|
};
|
|
|
|
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="outline"
|
|
size="sm"
|
|
onClick={() => setShowOffer(true)}
|
|
className="mt-2 w-full gap-2 border-orange-500/40 text-orange-400 hover:bg-orange-500/10"
|
|
>
|
|
<Handshake className="h-4 w-4" /> Make an offer
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleShare}
|
|
className="mt-1 w-full gap-2 text-gray-400 hover:text-white"
|
|
>
|
|
<Share2 className="h-4 w-4" /> Share listing
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Offers: seller sees incoming; buyer sees their own offer */}
|
|
<ListingOffersPanel item={item} isOwner={isOwnListing} />
|
|
|
|
{/* Seller trade reputation */}
|
|
<TradeReputationCard pubkey={item.pubkey} />
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
|
|
<ImageLightbox
|
|
images={lightboxImages}
|
|
index={lightboxIndex}
|
|
onIndexChange={setLightboxIndex}
|
|
onClose={() => setLightboxIndex(null)}
|
|
/>
|
|
|
|
{!isOwnListing && (
|
|
<OfferDialog
|
|
open={showOffer}
|
|
onOpenChange={setShowOffer}
|
|
listing={{ pubkey: item.pubkey, identifier: item.identifier }}
|
|
title={item.title}
|
|
sellerName={displayName}
|
|
/>
|
|
)}
|
|
|
|
{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>
|
|
);
|
|
}
|