The entire skate-spots codebase was accidentally copied into src/src/ and public/public/ instead of src/ and public/ directly. This left the real src/ directory as blank MKStack template stubs, explaining why the landing page showed the default template instead of SkateSpots. Changes made: - Copied all files from src/src/ up to src/ (App.tsx, AppRouter.tsx, main.tsx, all pages, components, hooks, contexts, lib, i18n, test) - Copied all files from public/public/ up to public/ (images, manifest, etc.) - Deleted src/src/ and public/public/ nested directories - Removed HelloWorld.tsx template stub from pages/ - Retained the previously fixed index.css (Tailwind v3 directives) Full skate-spots feature set is now active: - All 30+ routes (map, events, spots, coaches, marketplace, wallet, etc.) - NWCProvider, LanguageProvider, PWAInstallPrompt, AmberCallbackHandler - Skate community relays (goskatespots.nostr1.com, wotr.relatr.xyz, etc.) - Outfit Variable font, Amber pre-check in main.tsx - All hooks (useSkateSpots, useCoaches, useZaps, etc.) - i18n support (en/es) Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
296 lines
11 KiB
TypeScript
296 lines
11 KiB
TypeScript
import { useState } from 'react';
|
|
import { Link, 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 { genUserName } from '@/lib/genUserName';
|
|
import { getSkateboardRating, getTrustLevel, SELLER_TRUST_BADGES } from '@/lib/skateboardRating';
|
|
import { GEAR_CONDITIONS, GEAR_CATEGORIES, PRICE_TYPES, formatPrice, type MarketplaceItem } from '@/lib/types';
|
|
import {
|
|
MessageCircle,
|
|
MapPin,
|
|
Search,
|
|
Tag,
|
|
CheckCircle,
|
|
ShieldCheck,
|
|
AlertTriangle,
|
|
MoreVertical,
|
|
Flag,
|
|
} from 'lucide-react';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
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, isFollowing }: GearSwapItemCardProps) {
|
|
const { user } = useCurrentUser();
|
|
const navigate = useNavigate();
|
|
const author = useAuthor(item.pubkey);
|
|
const metadata = author.data?.metadata;
|
|
const displayName = metadata?.name ?? genUserName(item.pubkey);
|
|
const avatar = metadata?.picture;
|
|
const [showReportDialog, setShowReportDialog] = useState(false);
|
|
|
|
const npub = nip19.npubEncode(item.pubkey);
|
|
const isOwnListingCheck = user?.pubkey === item.pubkey;
|
|
|
|
// 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
|
|
const priceTypeLabel = item.priceType ? PRICE_TYPES[item.priceType]?.label : null;
|
|
|
|
// Skateboard rating for seller
|
|
const sellerRating = getSkateboardRating(salesCount);
|
|
|
|
// Trust level
|
|
const trustLevel = getTrustLevel(trustScore);
|
|
const trustBadge = SELLER_TRUST_BADGES[trustLevel];
|
|
|
|
const isOwnListing = user?.pubkey === item.pubkey;
|
|
|
|
return (
|
|
<Card
|
|
className={`bg-black/90 border-2 group cursor-pointer transition-all hover:scale-[1.02] ${
|
|
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(`/messages/${npub}`)}
|
|
>
|
|
<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>
|
|
)}
|
|
|
|
{/* 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 */}
|
|
{!isOwnListingCheck && (
|
|
<div className="absolute bottom-2 right-2">
|
|
<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()}
|
|
>
|
|
<MoreVertical className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-40">
|
|
<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>
|
|
</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}
|
|
/>
|
|
</Card>
|
|
);
|
|
}
|