SkateSpots/src/components/GearSwapItemCard.tsx
SkateSpots e357833557 i18n(es): batch 3 part 3 — shared label maps + leftover routing/Sasquatch strings
- 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).
2026-10-02 15:31:27 +07:00

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>
);
}