SkateSpots/src/components/GearSwapItemCard.tsx
SkateSpots 662a14bcd2 perf(marketplace): lazy-mount grid cards + open the detail page on click
- LazyMount: mount a grid card only when it scrolls within 600px (skeleton-space
  placeholder otherwise), so a long feed no longer mounts every card + its
  hooks/DOM up front. Falls back to immediate render without IntersectionObserver.
- GearSwapItemCard: clicking the card now opens /listing/:pubkey/:identifier
  (was opening a DM). 'Contact Seller' still opens the DM. Card set to h-full so
  the lazy wrapper keeps the equal-height grid.

Gate: 101 tests, 0 errors/warnings, boot smoke green.
2026-09-26 23:08:01 +07:00

444 lines
18 KiB
TypeScript

import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useDeleteEntity } from '@/hooks/useDeleteEntity';
import { useMarkListingSold } from '@/hooks/useMarkListingSold';
import { useToast } from '@/hooks/useToast';
import { isAdminPubkey } from '@/lib/admins';
import { genUserName } from '@/lib/genUserName';
import { getSkateboardRating, getTrustLevel, SELLER_TRUST_BADGES } from '@/lib/skateboardRating';
import { GEAR_CONDITIONS, GEAR_CATEGORIES, formatPrice, type MarketplaceItem } from '@/lib/types';
import { MessageCircle, MapPin, Search, CheckCircle, ShieldCheck, AlertTriangle, MoreVertical, Flag, Trash2, RotateCcw, Loader2 } from 'lucide-react';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from '@/components/ui/dropdown-menu';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
import { ReportDialog } from '@/components/ReportDialog';
import { formatDistanceToNow } from 'date-fns';
interface GearSwapItemCardProps {
item: MarketplaceItem;
trustScore?: number;
salesCount: number;
isFollowing?: boolean;
}
export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemCardProps) {
const { user } = useCurrentUser();
const navigate = useNavigate();
const author = useAuthor(item.pubkey);
const { mutateAsync: deleteEntity, isPending: isRemoving } = useDeleteEntity();
const { markSold, relist, isPending: isUpdatingStatus } = useMarkListingSold();
const { toast } = useToast();
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(item.pubkey);
const avatar = metadata?.picture;
const [showReportDialog, setShowReportDialog] = useState(false);
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
const npub = nip19.npubEncode(item.pubkey);
const isOwnListingCheck = user?.pubkey === item.pubkey;
const isAdmin = isAdminPubkey(user?.pubkey);
// Used by both the owner ("Delete listing") and admin ("Remove") flows —
// the wording adapts to which one is acting.
const ownerDeleting = isOwnListingCheck;
const handleRemove = async () => {
try {
await deleteEntity({
event: item.event,
reason: ownerDeleting
? `Deleted listing "${item.title}"`
: `SkateSpots admin removed listing "${item.title}"`,
});
toast({
title: ownerDeleting ? 'Listing deleted' : 'Listing removed',
description: ownerDeleting
? 'Your listing has been removed. Relays that honor deletions will stop serving it.'
: 'This listing has been taken down and will be hidden across SkateSpots.',
});
setShowRemoveDialog(false);
} catch (error) {
toast({
title: 'Remove failed',
description: error instanceof Error ? error.message : 'Something went wrong',
variant: 'destructive',
});
}
};
// Format price display
const priceDisplay = formatPrice(item.price, item.priceType);
const timeAgo = formatDistanceToNow(new Date(item.createdAt * 1000), { addSuffix: true });
const primaryImage = item.images[0];
const isWTB = item.listingType === 'wtb';
// Get condition info
const conditionInfo = item.condition ? GEAR_CONDITIONS[item.condition] : null;
// Get category label
const categoryLabel = item.category?.find((c) => GEAR_CATEGORIES[c as keyof typeof GEAR_CATEGORIES])
? GEAR_CATEGORIES[item.category.find((c) => GEAR_CATEGORIES[c as keyof typeof GEAR_CATEGORIES]) as keyof typeof GEAR_CATEGORIES]
: null;
// Get price type label
// Skateboard rating for seller
const sellerRating = getSkateboardRating(salesCount);
// Trust level
const trustLevel = getTrustLevel(trustScore);
const trustBadge = SELLER_TRUST_BADGES[trustLevel];
const isSold = item.status === 'sold';
const handleMarkSold = async () => {
try {
await markSold(item.event);
toast({ title: 'Marked as sold', description: 'Your listing is now shown as sold.' });
} catch (error) {
toast({
title: 'Could not update',
description: error instanceof Error ? error.message : 'Something went wrong',
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 (
<Card
className={`bg-black/90 border-2 group cursor-pointer transition-all hover:scale-[1.02] h-full ${
isWTB
? 'border-blue-500/20 hover:border-blue-500/60 hover:shadow-[0_0_30px_rgba(59,130,246,0.2)]'
: 'border-orange-500/20 hover:border-orange-500/60 hover:shadow-[0_0_30px_rgba(255,102,0,0.2)]'
}`}
onClick={() => navigate(`/listing/${item.pubkey}/${item.identifier}`)}
>
<CardContent className="p-0">
{/* Image */}
<div className="relative aspect-square overflow-hidden rounded-t-lg">
{primaryImage ? (
<img
src={primaryImage}
alt={item.title}
className="w-full h-full object-cover transition-transform group-hover:scale-105"
/>
) : (
<div
className={`w-full h-full flex items-center justify-center ${
isWTB ? 'bg-blue-950/50' : 'bg-orange-950/50'
}`}
>
<span className="text-6xl">{isWTB ? '🔍' : '🛹'}</span>
</div>
)}
{/* Sold overlay — dims the image and stamps it clearly */}
{isSold && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/60">
<span className="rotate-[-12deg] rounded-md border-2 border-white/90 px-4 py-1 text-2xl font-black uppercase tracking-widest text-white shadow-lg">
Sold
</span>
</div>
)}
{/* WTB Badge */}
{isWTB && (
<Badge className="absolute top-2 left-2 bg-blue-500 text-white border-0 shadow-lg">
<Search className="h-3 w-3 mr-1" />
WTB
</Badge>
)}
{/* Price Badge */}
<div className="absolute top-2 right-2">
<Badge
className={`text-sm font-bold border-0 shadow-lg ${
item.priceType === 'free'
? 'bg-green-500 text-white'
: isWTB
? 'bg-blue-500 text-white'
: 'bg-orange-500 text-white'
}`}
>
{priceDisplay}
</Badge>
</div>
{/* Condition Badge (bottom left) */}
{conditionInfo && (
<Badge
variant="outline"
className="absolute bottom-2 left-2 bg-black/80 border-gray-600 text-xs backdrop-blur-sm"
>
{conditionInfo.emoji} {conditionInfo.label}
</Badge>
)}
{/* More options menu — owners manage their listing (mark sold /
relist / delete), non-owners can report, admins can take down. */}
{user && (
<div className="absolute bottom-2 right-2 z-20">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 bg-black/80 backdrop-blur-sm text-gray-400 hover:text-white"
onClick={(e) => e.stopPropagation()}
disabled={isUpdatingStatus}
aria-label="Listing options"
>
{isUpdatingStatus ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<MoreVertical className="h-3.5 w-3.5" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
{/* Owner: mark sold / relist + delete */}
{isOwnListingCheck && (
<>
{isSold ? (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleRelist();
}}
disabled={isUpdatingStatus}
>
<RotateCcw className="h-4 w-4 mr-2" />
Relist as active
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleMarkSold();
}}
disabled={isUpdatingStatus}
>
<CheckCircle className="h-4 w-4 mr-2" />
Mark as sold
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setShowRemoveDialog(true);
}}
className="text-red-500 focus:text-red-500"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete listing
</DropdownMenuItem>
</>
)}
{!isOwnListingCheck && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setShowReportDialog(true);
}}
className="text-red-500 focus:text-red-500"
>
<Flag className="h-4 w-4 mr-2" />
Report
</DropdownMenuItem>
)}
{isAdmin && !isOwnListingCheck && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setShowRemoveDialog(true);
}}
className="text-red-500 focus:text-red-500"
>
<Trash2 className="h-4 w-4 mr-2" />
Remove (admin)
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
{/* Content */}
<div className="p-4">
{/* Title */}
<h3
className={`font-bold text-sm uppercase mb-1 line-clamp-1 ${
isWTB ? 'text-blue-400' : 'text-orange-400'
}`}
>
{item.title}
</h3>
{/* Category + Size */}
<div className="flex flex-wrap gap-1 mb-3">
{categoryLabel && (
<Badge variant="outline" className="text-[10px] border-gray-700 text-gray-400">
{categoryLabel}
</Badge>
)}
{item.size && (
<Badge variant="outline" className="text-[10px] border-gray-700 text-gray-400">
{item.size}
</Badge>
)}
</div>
{/* Location */}
{item.location && (
<div className="flex items-center gap-1 text-xs text-gray-500 mb-3">
<MapPin className="h-3 w-3 text-orange-500" />
<span className="truncate">{item.location}</span>
</div>
)}
{/* Divider */}
<div className="border-t border-gray-800 pt-3">
{/* Seller Info */}
<div className="flex items-center gap-2 mb-2">
<Avatar className="h-6 w-6 border border-gray-700">
<AvatarImage src={avatar} alt={displayName} />
<AvatarFallback className="bg-gray-800 text-gray-400 text-xs">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="text-xs text-gray-400 truncate flex-1">{displayName}</span>
{/* Trust Badge */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className={`text-[10px] px-1.5 gap-0.5 ${trustBadge.className}`}
>
{trustLevel === 'verified' && <CheckCircle className="h-3 w-3" />}
{trustLevel === 'trusted' && <ShieldCheck className="h-3 w-3" />}
{trustLevel === 'new' && <AlertTriangle className="h-3 w-3" />}
{trustBadge.label}
</Badge>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs">{trustBadge.description}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{/* Skateboard Rating */}
<div className="flex items-center justify-between">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1">
{sellerRating.rating > 0 ? (
<span className="text-sm">{sellerRating.display}</span>
) : (
<span className="text-xs text-gray-500">🛹 New seller</span>
)}
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
<div className="text-center">
<p className="font-bold">{salesCount} sale{salesCount !== 1 ? 's' : ''}</p>
<p className="text-xs text-muted-foreground">{sellerRating.label}</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<span className="text-[10px] text-gray-600">{timeAgo}</span>
</div>
</div>
{/* Contact Button */}
<Button
variant="outline"
size="sm"
className={`w-full mt-3 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={(e) => {
e.stopPropagation();
navigate(`/messages/${npub}`);
}}
>
<MessageCircle className="h-4 w-4" />
{isWTB ? 'I Have This!' : 'Contact Seller'}
</Button>
</div>
</CardContent>
{/* Report Dialog */}
<ReportDialog
open={showReportDialog}
onOpenChange={setShowReportDialog}
targetType="listing"
targetPubkey={item.pubkey}
targetEventId={item.id}
targetTitle={item.title}
/>
{/* Admin takedown confirmation */}
<AlertDialog open={showRemoveDialog} onOpenChange={setShowRemoveDialog}>
<AlertDialogContent
className="bg-black border-2 border-red-500/50"
onClick={(e) => e.stopPropagation()}
>
<AlertDialogHeader>
<AlertDialogTitle className="text-white flex items-center gap-2">
<Trash2 className="h-5 w-5 text-red-500" />
{ownerDeleting ? `Delete "${item.title}"?` : 'Remove this listing? (admin)'}
</AlertDialogTitle>
<AlertDialogDescription className="text-gray-300">
{ownerDeleting
? `This asks relays to remove your listing "${item.title}". It can't be undone from here — you'd have to create a new one.`
: `As a SkateSpots admin you're taking down "${item.title}". It will be hidden across the app for everyone and a deletion request will be sent to relays.`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="border-gray-600" disabled={isRemoving}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={handleRemove}
disabled={isRemoving}
className="bg-red-500 hover:bg-red-600 text-white"
>
{isRemoving ? (ownerDeleting ? 'Deleting…' : 'Removing…') : (ownerDeleting ? 'Delete Listing' : 'Remove Listing')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Card>
);
}