SkateSpots/src/pages/ListingDetailPage.tsx
SkateSpots 087f490acc refactor(layout): hoist Layout into the router so the chrome mounts once
- AppRouter wraps every route (except /amber-callback and the 404) in ONE
  <Route element={<Layout/>}> with an <Outlet/>; the chrome now persists across
  navigation instead of being torn down and rebuilt on every page.
- Layout: children optional (falls back to <Outlet/>); the map is full-bleed via
  a route check (isFullBleed) instead of a per-page noPadding prop.
- All 59 pages + CraftProfileForm/ErrorState/NewsletterIssueView now render
  fragments instead of <Layout> (structure-preserving), imports dropped.

Removes the whole remount class: chrome no longer re-runs its hooks per
navigation, Sasquatch chat state survives navigation, and the header can't
re-open Amber on every page change.
2026-09-27 13:41:42 +07:00

386 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 { 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 (
<>
<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>
</>
);
}
if (!item) {
return (
<>
<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>
</>
);
}
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 (
<>
<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"
/>
)}
</>
);
}