SkateSpots/src/components/MarketplaceItemCard.tsx
SkateSpots 09735375c2 feat(i18n): Spanish (Castellano) — infra + Map/Spots, Marketplace, Lab (batch 1)
- First-visit language auto-detect (Spanish devices land in es; stored choice wins).
- Sasquatch AI replies in the app language (skate trick names kept English).
- i18n safety net: scripts/check-i18n.mjs wired into npm test — fails on any t()
  key missing from en/es or any en/es key drift.
- ~1,680 new keys (2,297 total) across mapUi / spotUi / marketUi / labUi and
  ~65 files wired to t(): Map, spots (detail/create/edit/chat), marketplace,
  trades, offers, escrow, shipping, The Lab, /become/* forms, coaches, skaters.
- Policy: only skate TRICK names stay English; user-generated content untouched.
2026-10-01 23:57:20 +07:00

567 lines
24 KiB
TypeScript

import { useState } from 'react';
import { Link } from 'react-router-dom';
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 { ImageWithFallback } from '@/components/ImageWithFallback';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from '@/components/ui/dropdown-menu';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useUsdToSats } from '@/hooks/useExchangeRate';
import { useToast } from '@/hooks/useToast';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useListingReports } from '@/hooks/useListingReports';
import { useSellerInfo } from '@/hooks/useSellerInfo';
import { useTrustScore } from '@/hooks/useTrustScore';
import { useMarkListingSold } from '@/hooks/useMarkListingSold';
import { MarkSoldDialog } from '@/components/MarkSoldDialog';
import { ListingTradeActions } from '@/components/ListingTradeActions';
import { useTradeReputation } from '@/hooks/useTradeAttestations';
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
import { genUserName } from '@/lib/genUserName';
import { getLastLocation } from '@/lib/lastLocation';
import { itemDistanceKm } from '@/lib/sortOptions';
import { TrustBadge } from '@/components/TrustBadge';
import { Clock, Search, Tag, Flag, MoreVertical, ShieldCheck, Calendar, Package, AlertTriangle, CheckCircle, RotateCcw, Trash2, Loader2 } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import type { MarketplaceItem } from '@/lib/types';
import { GEAR_CONDITIONS, GEAR_CATEGORIES, PRICE_TYPES, LISTING_REPORT_REASONS, formatPrice } from '@/lib/types';
import { useTranslation } from '@/contexts/language';
interface MarketplaceItemCardProps {
item: MarketplaceItem;
compact?: boolean;
}
export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCardProps) {
const { t } = useTranslation();
const { user } = useCurrentUser();
// Distance from the viewer's last known location (no GPS call — cache only).
const viewerLoc = getLastLocation();
const distanceKm = viewerLoc ? itemDistanceKm(item, viewerLoc) : null;
const { toast } = useToast();
const { mutateAsync: publishEvent } = useNostrPublish();
const { relist, isPending: isUpdatingStatus } = useMarkListingSold();
const author = useAuthor(item.pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(item.pubkey);
const avatar = metadata?.picture;
// Fetch reports for this listing
const { data: reportData } = useListingReports(item.id, item.pubkey);
// Fetch seller info
const { data: sellerInfo } = useSellerInfo(item.pubkey);
// Fetch trust score
const { data: trustScore } = useTrustScore(item.pubkey);
const [showReportDialog, setShowReportDialog] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [reportReason, setReportReason] = useState<string>('');
const [reportDetails, setReportDetails] = useState('');
const [isReporting, setIsReporting] = useState(false);
const [showMarkSold, setShowMarkSold] = useState(false);
// Trade attestations (Attestations NIP, kind 31871) — see useTradeAttestations.
const listingRef = { pubkey: item.pubkey, identifier: item.identifier };
const { data: sellerRep } = useTradeReputation(item.pubkey);
// Format price display
const priceDisplay = item.price
? `${formatPrice(item.price, item.priceType)}${item.price.frequency ? `/${item.price.frequency}` : ''}`
: formatPrice(undefined, item.priceType);
const timeAgo = formatDistanceToNow(new Date(item.createdAt * 1000), { addSuffix: true });
const primaryImage = item.images[0];
const detailUrl = `/listing/${item.pubkey}/${item.identifier}`;
const isWTB = item.listingType === 'wtb';
// Expiration is stored as unix timestamp in seconds
const isExpired = item.expiration ? item.expiration < Math.floor(Date.now() / 1000) : false;
// 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;
// Sats estimate from the LIVE BTC/USD rate — never a hardcoded constant
// (that quoted buyers wildly wrong amounts). Non-USD fiat is left unestimated
// rather than inventing a number.
const priceAmount = item.price?.amount ? parseFloat(item.price.amount) : 0;
const priceCurrency = item.price?.currency || 'USD';
const usdAsSats = useUsdToSats(priceCurrency === 'USD' ? priceAmount : null);
const priceInSats = !priceAmount
? 0
: priceCurrency === 'sats'
? Math.round(priceAmount)
: priceCurrency === 'BTC'
? Math.round(priceAmount * 100000000)
: (usdAsSats ?? 0);
// Seller verification status
const isVerifiedSeller = sellerInfo?.hasNip05 || false;
const isHighTrust = (trustScore || 0) >= 60;
const accountAgeDays = sellerInfo?.accountAge || 0;
const previousListings = sellerInfo?.listingCount || 0;
// Report status
const reportCount = reportData?.count || 0;
const shouldHide = reportData?.shouldHide || false;
const handleReport = async () => {
if (!user || !reportReason) {
toast({ title: t('marketUi.card.selectReason'), variant: 'destructive' });
return;
}
setIsReporting(true);
try {
// Publish NIP-56 report event (kind 1984)
await publishEvent({
kind: 1984,
content: reportDetails.trim(),
tags: [
['e', item.id, '', reportReason],
['p', item.pubkey],
['L', 'skate-spots.report'],
['l', reportReason, 'skate-spots.report'],
],
});
toast({
title: t('marketUi.card.reportSubmitted'),
description: t('marketUi.card.reportSubmittedBody'),
});
setShowReportDialog(false);
setReportReason('');
setReportDetails('');
} catch (error) {
toast({
variant: 'destructive',
title: t('marketUi.card.reportFailed'),
description: error instanceof Error ? error.message : t('common.tryAgain'),
});
} finally {
setIsReporting(false);
}
};
// Don't render expired or heavily reported listings
if (isExpired || shouldHide) {
return null;
}
const isOwnListing = user?.pubkey === item.pubkey;
const isSold = item.status === 'sold';
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 ${
isWTB
? 'border-blue-500/20 hover:border-blue-500/60 hover:shadow-blue-500/10'
: 'border-orange-500/20 hover:border-orange-500/60 hover:shadow-orange-500/10'
} hover:shadow-2xl transition-all relative`}
>
{/* Report warning badge */}
{reportCount > 0 && reportCount < 3 && (
<div className="absolute top-2 right-2 z-10">
<Badge variant="destructive" className="text-xs gap-1">
<AlertTriangle className="h-3 w-3" />
{reportCount > 1 ? t('marketUi.card.reports', { count: reportCount }) : t('marketUi.card.reportCount', { count: reportCount })}
</Badge>
</div>
)}
<CardContent className="p-4">
<div className="flex items-start gap-4">
{/* Product Image */}
<Link to={detailUrl} className="flex-shrink-0" aria-label={t('marketUi.card.viewTitle', { title: item.title })}>
{primaryImage ? (
<div
className={`w-20 h-20 rounded-lg overflow-hidden border-2 ${
isWTB ? 'border-blue-500/30' : 'border-orange-500/30'
}`}
>
<ImageWithFallback src={primaryImage} alt={item.title} className="w-full h-full object-cover" />
</div>
) : (
<div
className={`w-20 h-20 rounded-lg border-2 border-dashed ${
isWTB ? 'border-blue-500/30 bg-blue-500/5' : 'border-orange-500/30 bg-orange-500/5'
} flex items-center justify-center`}
>
<span className="text-3xl">{isWTB ? '🔍' : '🛹'}</span>
</div>
)}
</Link>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Listing Type Badge + Title */}
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex-1 min-w-0">
{isWTB && (
<Badge className="bg-blue-500/20 text-blue-400 border-blue-500/50 mb-1 text-xs">
<Search className="h-3 w-3 mr-1" />
{t('marketUi.listingDetail.wantToBuy')}
</Badge>
)}
<Link to={detailUrl}>
<h3
className={`font-black text-base uppercase ${
isWTB ? 'text-blue-400' : 'text-orange-400'
} leading-tight`}
>
{item.title}
</h3>
</Link>
</div>
<div className="text-right flex items-start gap-1">
<div>
<span
className={`text-lg font-bold ${
isWTB ? 'text-blue-500' : 'text-orange-500'
} whitespace-nowrap`}
>
{priceDisplay}
</span>
{priceTypeLabel && priceTypeLabel !== 'Fixed Price' && (
<div className="text-xs text-gray-400">{priceTypeLabel}</div>
)}
{priceInSats > 0 && (
<div className="text-xs text-[#ccff00] whitespace-nowrap">
≈ {priceInSats.toLocaleString()} {t('common.sats')}
</div>
)}
</div>
{/* Actions dropdown */}
{user && !isOwnListing && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6 text-gray-400">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => setShowReportDialog(true)}
className="text-red-400 focus:text-red-400"
>
<Flag className="h-4 w-4 mr-2" />
{t('marketUi.card.reportListing')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Owner management dropdown — mark sold / relist / delete */}
{user && isOwnListing && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-gray-400"
disabled={isUpdatingStatus}
aria-label={t('marketUi.card.manageListing')}
>
{isUpdatingStatus ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreVertical className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{isSold ? (
<DropdownMenuItem onClick={handleRelist} disabled={isUpdatingStatus}>
<RotateCcw className="h-4 w-4 mr-2" />
{t('marketUi.listingDetail.relistActive')}
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => setShowMarkSold(true)} disabled={isUpdatingStatus}>
<CheckCircle className="h-4 w-4 mr-2" />
{t('marketUi.listingDetail.markSold')}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setShowDeleteDialog(true)}
className="text-red-400 focus:text-red-400"
>
<Trash2 className="h-4 w-4 mr-2" />
{t('marketUi.listingDetail.deleteListing')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{/* Condition, Size, Category Badges */}
<div className="flex flex-wrap gap-1 mb-2">
{conditionInfo && (
<Badge variant="outline" className="text-xs border-gray-600 bg-gray-800/50">
{conditionInfo.emoji} {conditionInfo.label}
</Badge>
)}
{item.size && (
<Badge variant="outline" className="text-xs border-gray-600 bg-gray-800/50">
{item.size}
</Badge>
)}
{categoryLabel && (
<Badge variant="outline" className="text-xs border-gray-600 bg-gray-800/50">
<Tag className="h-3 w-3 mr-1" />
{categoryLabel}
</Badge>
)}
{item.fulfilment && (
<Badge variant="outline" className="text-xs border-gray-600 bg-gray-800/50">
{item.fulfilment === 'pickup'
? `📍 ${t('marketUi.create.pickup')}`
: item.fulfilment === 'ships'
? `📦 ${t('marketUi.create.ships')}`
: `📍📦 ${t('marketUi.gearSwap.pickupOrShip')}`}
</Badge>
)}
{distanceKm != null && (
<Badge variant="outline" className="text-xs border-gray-600 bg-gray-800/50">
{distanceKm < 1
? `${Math.round(distanceKm * 1000)} m`
: `${distanceKm.toFixed(1)} km`}
</Badge>
)}
{item.shipping && (
<Badge variant="outline" className="text-xs border-gray-600 bg-gray-800/50">
{item.shipping === 'buyer' ? t('marketUi.card.postageBuyer') : item.shipping === 'seller' ? t('marketUi.card.postageSeller') : t('marketUi.card.postageSplit')}
</Badge>
)}
{typeof item.powDifficulty === 'number' && item.powDifficulty >= 16 && (
<Badge
variant="outline"
className="text-xs border-gray-600 bg-gray-800/50"
title={t('marketUi.card.powTitle')}
>
⚡ PoW {item.powDifficulty}
</Badge>
)}
{sellerRep && sellerRep.verified > 0 && (
<Badge variant="outline" className="text-xs border-[#ccff00]/40 bg-[#ccff00]/10 text-[#ccff00]">
<CheckCircle className="h-3 w-3 mr-1" />
{sellerRep.verified === 1 ? t('marketUi.card.verifiedTradeOne', { count: sellerRep.verified }) : t('marketUi.card.verifiedTrades', { count: sellerRep.verified })}
</Badge>
)}
</div>
{/* Description */}
{(item.summary || item.description) && !compact && (
<p className="text-sm text-gray-300 line-clamp-2 mb-3">
{item.summary || item.description}
</p>
)}
{/* Seller Info with enhanced trust indicators */}
<div className="flex items-center gap-2 mb-2 flex-wrap">
<Avatar className="h-6 w-6 border border-orange-500/30">
<AvatarImage src={avatar} alt={displayName} />
<AvatarFallback className="bg-orange-500/20 text-orange-400 text-xs">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0 text-xs text-gray-400">
<span className="font-medium text-gray-300">{displayName}</span>
<span className="mx-1">•</span>
<span>{timeAgo}</span>
</div>
{/* Trust indicators */}
<div className="flex items-center gap-1">
{isVerifiedSeller && (
<Badge variant="outline" className="text-xs border-green-500/50 text-green-400 bg-green-500/10 gap-1">
<CheckCircle className="h-3 w-3" />
{t('common.verified')}
</Badge>
)}
{isHighTrust && !isVerifiedSeller && (
<Badge variant="outline" className="text-xs border-blue-500/50 text-blue-400 bg-blue-500/10 gap-1">
<ShieldCheck className="h-3 w-3" />
{t('marketUi.card.trusted')}
</Badge>
)}
<TrustBadge pubkey={item.pubkey} size="sm" showLabel={false} />
</div>
</div>
{/* Seller stats (account age, listings) */}
{!compact && sellerInfo && (
<div className="flex items-center gap-3 text-xs text-gray-500 mb-2">
{accountAgeDays > 0 && (
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
<span>
{accountAgeDays < 30
? t('marketUi.card.daysOld', { n: accountAgeDays })
: accountAgeDays < 365
? t('marketUi.card.monthsOld', { n: Math.floor(accountAgeDays / 30) })
: t('marketUi.card.yearsOld', { n: Math.floor(accountAgeDays / 365) })}
</span>
</div>
)}
{previousListings > 1 && (
<div className="flex items-center gap-1">
<Package className="h-3 w-3" />
<span>{t('marketUi.card.listingsCount', { count: previousListings })}</span>
</div>
)}
</div>
)}
{/* Availability */}
{item.availability && (
<div className="flex items-center gap-1 text-xs text-gray-400 mb-3">
<Clock className="h-3 w-3 text-orange-500" />
<span>{t('marketUi.listingDetail.usuallyHere', { availability: item.availability })}</span>
</div>
)}
{/* Status Badge */}
{item.status && item.status !== 'active' && (
<Badge
variant="secondary"
className={`text-xs mb-2 ${
item.status === 'sold' ? 'bg-gray-700 text-gray-400' : 'bg-yellow-900/50 text-yellow-400'
}`}
>
{item.status.toUpperCase()}
</Badge>
)}
{/* Action Buttons */}
<ListingTradeActions item={item} />
</div>
</div>
</CardContent>
{/* Seller: close the deal (mark sold + publish trade attestation) */}
{isOwnListing && (
<MarkSoldDialog
open={showMarkSold}
onOpenChange={setShowMarkSold}
event={item.event}
listing={listingRef}
/>
)}
{/* Delete confirmation (controlled — trigger lives in the owner dropdown) */}
<DeleteEntityDialog
event={item.event}
entityLabel="listing"
entityName={item.title}
triggerText={t('marketUi.listingDetail.deleteListing')}
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
/>
{/* Report Dialog */}
<Dialog open={showReportDialog} onOpenChange={setShowReportDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Flag className="h-5 w-5 text-red-400" />
{t('marketUi.card.reportListing')}
</DialogTitle>
<DialogDescription>
{t('marketUi.card.reportDialogBody')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>{t('marketUi.card.reasonLabel')}</Label>
<div className="grid grid-cols-1 gap-2">
{Object.entries(LISTING_REPORT_REASONS).map(([key, { label, description }]) => (
<Button
key={key}
type="button"
variant={reportReason === key ? 'default' : 'outline'}
className={`h-auto py-2 px-3 justify-start text-left ${
reportReason === key
? 'bg-red-500 hover:bg-red-600'
: 'border-gray-600 hover:bg-gray-800'
}`}
onClick={() => setReportReason(key)}
>
<div>
<div className="font-medium">{label}</div>
<div className="text-xs opacity-70">{description}</div>
</div>
</Button>
))}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="report-details">{t('marketUi.card.detailsLabel')}</Label>
<Textarea
id="report-details"
placeholder={t('marketUi.card.detailsPlaceholder')}
value={reportDetails}
onChange={(e) => setReportDetails(e.target.value)}
rows={3}
className="bg-gray-800 border-gray-700"
/>
</div>
<Card className="bg-gray-800/50 border-gray-700">
<CardContent className="p-3">
<p className="text-xs text-gray-400">
{t('marketUi.card.reportNote')}
</p>
</CardContent>
</Card>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowReportDialog(false)}>
{t('common.cancel')}
</Button>
<Button
onClick={handleReport}
disabled={isReporting || !reportReason}
className="bg-red-500 hover:bg-red-600"
>
{isReporting ? t('marketUi.card.submitting') : t('marketUi.card.submitReport')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}