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(''); 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 ( {/* Report warning badge */} {reportCount > 0 && reportCount < 3 && (
{reportCount > 1 ? t('marketUi.card.reports', { count: reportCount }) : t('marketUi.card.reportCount', { count: reportCount })}
)}
{/* Product Image */} {primaryImage ? (
) : (
{isWTB ? '🔍' : '🛹'}
)} {/* Content */}
{/* Listing Type Badge + Title */}
{isWTB && ( {t('marketUi.listingDetail.wantToBuy')} )}

{item.title}

{priceDisplay} {priceTypeLabel && priceTypeLabel !== 'Fixed Price' && (
{priceTypeLabel}
)} {priceInSats > 0 && (
≈ {priceInSats.toLocaleString()} {t('common.sats')}
)}
{/* Actions dropdown */} {user && !isOwnListing && ( setShowReportDialog(true)} className="text-red-400 focus:text-red-400" > {t('marketUi.card.reportListing')} )} {/* Owner management dropdown — mark sold / relist / delete */} {user && isOwnListing && ( {isSold ? ( {t('marketUi.listingDetail.relistActive')} ) : ( setShowMarkSold(true)} disabled={isUpdatingStatus}> {t('marketUi.listingDetail.markSold')} )} setShowDeleteDialog(true)} className="text-red-400 focus:text-red-400" > {t('marketUi.listingDetail.deleteListing')} )}
{/* Condition, Size, Category Badges */}
{conditionInfo && ( {conditionInfo.emoji} {conditionInfo.label} )} {item.size && ( {item.size} )} {categoryLabel && ( {categoryLabel} )} {item.fulfilment && ( {item.fulfilment === 'pickup' ? `📍 ${t('marketUi.create.pickup')}` : item.fulfilment === 'ships' ? `📦 ${t('marketUi.create.ships')}` : `📍📦 ${t('marketUi.gearSwap.pickupOrShip')}`} )} {distanceKm != null && ( {distanceKm < 1 ? `${Math.round(distanceKm * 1000)} m` : `${distanceKm.toFixed(1)} km`} )} {item.shipping && ( {item.shipping === 'buyer' ? t('marketUi.card.postageBuyer') : item.shipping === 'seller' ? t('marketUi.card.postageSeller') : t('marketUi.card.postageSplit')} )} {typeof item.powDifficulty === 'number' && item.powDifficulty >= 16 && ( ⚡ PoW {item.powDifficulty} )} {sellerRep && sellerRep.verified > 0 && ( {sellerRep.verified === 1 ? t('marketUi.card.verifiedTradeOne', { count: sellerRep.verified }) : t('marketUi.card.verifiedTrades', { count: sellerRep.verified })} )}
{/* Description */} {(item.summary || item.description) && !compact && (

{item.summary || item.description}

)} {/* Seller Info with enhanced trust indicators */}
{displayName.slice(0, 2).toUpperCase()}
{displayName} • {timeAgo}
{/* Trust indicators */}
{isVerifiedSeller && ( {t('common.verified')} )} {isHighTrust && !isVerifiedSeller && ( {t('marketUi.card.trusted')} )}
{/* Seller stats (account age, listings) */} {!compact && sellerInfo && (
{accountAgeDays > 0 && (
{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) })}
)} {previousListings > 1 && (
{t('marketUi.card.listingsCount', { count: previousListings })}
)}
)} {/* Availability */} {item.availability && (
{t('marketUi.listingDetail.usuallyHere', { availability: item.availability })}
)} {/* Status Badge */} {item.status && item.status !== 'active' && ( {item.status.toUpperCase()} )} {/* Action Buttons */}
{/* Seller: close the deal (mark sold + publish trade attestation) */} {isOwnListing && ( )} {/* Delete confirmation (controlled — trigger lives in the owner dropdown) */} {/* Report Dialog */} {t('marketUi.card.reportListing')} {t('marketUi.card.reportDialogBody')}
{Object.entries(LISTING_REPORT_REASONS).map(([key, { label, description }]) => ( ))}