- labelsUi.* (221+ keys): spot categories/features/types, access, shelter, event types, gear categories/conditions, price/listings, tours, crews, member-visibility, tag categories, spot status. New src/lib/labelKeys.ts holds the shared value->key maps (one source; the five duplicated EVENT_TYPE_KEYS copies now import it). spotStatus.statusTimeAgo() uses the locale date shim. - formatPrice() now takes an optional t so FREE/Trade/Make Offer translate. - Route helpers: skateTime/distanceReadout/maneuver descriptors/formatAge translated (routeUi.*), consumers updated. - Sasquatch: PRO_TIERS labels/badges + summarizeDraft titles/lines/ctas translated (sasquatchUi.*). - Lab *_LABELS/_DESCRIPTIONS maps remain English (documented out of scope).
476 lines
19 KiB
TypeScript
476 lines
19 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 { getLastLocation } from '@/lib/lastLocation';
|
|
import { itemDistanceKm } from '@/lib/sortOptions';
|
|
import { getSkateboardRating, getTrustLevel, SELLER_TRUST_BADGES } from '@/lib/skateboardRating';
|
|
import { GEAR_CONDITIONS, GEAR_CATEGORIES, formatPrice, type MarketplaceItem } from '@/lib/types';
|
|
import { GEAR_CATEGORY_KEYS, GEAR_CONDITION_KEYS } from '@/lib/labelKeys';
|
|
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 '@/lib/dateFnsI18n';
|
|
import { useTranslation } from '@/contexts/language';
|
|
|
|
interface GearSwapItemCardProps {
|
|
item: MarketplaceItem;
|
|
trustScore?: number;
|
|
salesCount: number;
|
|
isFollowing?: boolean;
|
|
}
|
|
|
|
export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemCardProps) {
|
|
const { t } = useTranslation();
|
|
const { user } = useCurrentUser();
|
|
const navigate = useNavigate();
|
|
// Distance from the viewer's last known location (cache only — no GPS call).
|
|
const viewerLoc = getLastLocation();
|
|
const distanceKm = viewerLoc ? itemDistanceKm(item, viewerLoc) : null;
|
|
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 ? t('marketUi.card.listingDeleted') : t('marketUi.card.listingRemoved'),
|
|
description: ownerDeleting
|
|
? t('marketUi.card.listingDeletedBody')
|
|
: t('marketUi.card.listingRemovedBody'),
|
|
});
|
|
setShowRemoveDialog(false);
|
|
} catch (error) {
|
|
toast({
|
|
title: t('marketUi.card.removeFailed'),
|
|
description: error instanceof Error ? error.message : t('marketUi.card.somethingWrong'),
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
// Format price display
|
|
const priceDisplay = formatPrice(item.price, item.priceType, t);
|
|
|
|
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 categoryValue = item.category?.find((c) => GEAR_CATEGORIES[c as keyof typeof GEAR_CATEGORIES]);
|
|
const categoryLabel = categoryValue
|
|
? GEAR_CATEGORY_KEYS[categoryValue]
|
|
? t(GEAR_CATEGORY_KEYS[categoryValue])
|
|
: GEAR_CATEGORIES[categoryValue 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: t('marketUi.card.markedSold'), description: t('marketUi.card.markedSoldBody') });
|
|
} catch (error) {
|
|
toast({
|
|
title: t('marketUi.card.couldNotUpdate'),
|
|
description: error instanceof Error ? error.message : t('marketUi.card.somethingWrong'),
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleRelist = async () => {
|
|
try {
|
|
await relist(item.event);
|
|
toast({ title: t('marketUi.card.relisted'), description: t('marketUi.card.relistedBody') });
|
|
} catch (error) {
|
|
toast({
|
|
title: t('marketUi.card.couldNotUpdate'),
|
|
description: error instanceof Error ? error.message : t('marketUi.card.somethingWrong'),
|
|
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">
|
|
{t('marketplace.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" />
|
|
{t('marketUi.gearSwap.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} {item.condition && GEAR_CONDITION_KEYS[item.condition] ? t(GEAR_CONDITION_KEYS[item.condition]) : 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={t('marketUi.card.listingOptions')}
|
|
>
|
|
{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" />
|
|
{t('marketUi.listingDetail.relistActive')}
|
|
</DropdownMenuItem>
|
|
) : (
|
|
<DropdownMenuItem
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleMarkSold();
|
|
}}
|
|
disabled={isUpdatingStatus}
|
|
>
|
|
<CheckCircle className="h-4 w-4 mr-2" />
|
|
{t('marketUi.listingDetail.markSold')}
|
|
</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" />
|
|
{t('marketUi.listingDetail.deleteListing')}
|
|
</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" />
|
|
{t('marketUi.card.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" />
|
|
{t('marketUi.card.removeAdmin')}
|
|
</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>
|
|
)}
|
|
{item.fulfilment && (
|
|
<Badge variant="outline" className="text-[10px] border-gray-700 text-gray-400">
|
|
{item.fulfilment === 'pickup'
|
|
? `📍 ${t('marketUi.create.pickup')}`
|
|
: item.fulfilment === 'ships'
|
|
? `📦 ${t('marketUi.create.ships')}`
|
|
: `📍📦 ${t('marketUi.create.both')}`}
|
|
</Badge>
|
|
)}
|
|
{item.shipping && (
|
|
<Badge variant="outline" className="text-[10px] border-gray-700 text-gray-400">
|
|
{t('marketUi.card.postage')}: {item.shipping}
|
|
</Badge>
|
|
)}
|
|
{distanceKm != null && (
|
|
<Badge variant="outline" className="text-[10px] border-gray-700 text-gray-400">
|
|
{distanceKm < 1
|
|
? `${Math.round(distanceKm * 1000)} m`
|
|
: `${distanceKm.toFixed(1)} km`}
|
|
</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">🛹 {t('marketUi.card.newSeller')}</span>
|
|
)}
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">
|
|
<div className="text-center">
|
|
<p className="font-bold">{salesCount === 1 ? t('marketUi.card.saleOne', { count: salesCount }) : t('marketUi.card.saleMany', { count: salesCount })}</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 ? t('marketUi.card.iHaveThis') : t('marketplace.contact')}
|
|
</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 ? t('marketUi.card.deleteTitle', { title: item.title }) : t('marketUi.card.adminRemoveTitle')}
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription className="text-gray-300">
|
|
{ownerDeleting
|
|
? t('marketUi.card.deleteBody', { title: item.title })
|
|
: t('marketUi.card.adminRemoveBody', { title: item.title })}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel className="border-gray-600" disabled={isRemoving}>
|
|
{t('common.cancel')}
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={handleRemove}
|
|
disabled={isRemoving}
|
|
className="bg-red-500 hover:bg-red-600 text-white"
|
|
>
|
|
{isRemoving ? (ownerDeleting ? t('marketUi.card.deleting') : t('marketUi.card.removing')) : (ownerDeleting ? t('marketUi.card.deleteListing') : t('marketUi.card.removeListing'))}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</Card>
|
|
);
|
|
}
|