- 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.
444 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|