SkateSpots/src/components/MarketplaceItemCard.tsx

596 lines
25 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { useVenuesWithPasses } from '@/hooks/useRiderCard';
import { useTrustBucket } from '@/hooks/useTrustBucket';
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 { MuteMenuItem } from '@/components/MuteMenuItem';
import { Clock, Search, Tag, Flag, MoreVertical, ShieldCheck, Calendar, Package, AlertTriangle, CheckCircle, RotateCcw, Trash2, Loader2 } from 'lucide-react';
import { formatDistanceToNow } from '@/lib/dateFnsI18n';
import type { MarketplaceItem } from '@/lib/types';
import { GEAR_CONDITIONS, GEAR_CATEGORIES, PRICE_TYPES, LISTING_REPORT_REASONS, formatPrice } from '@/lib/types';
import { GEAR_CATEGORY_KEYS, GEAR_CONDITION_KEYS, PRICE_TYPE_KEYS } from '@/lib/labelKeys';
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);
// Does this seller own a venue that issues access passes? (one shared query)
const { data: passIndex } = useVenuesWithPasses();
const sellerIssuesPasses = !!passIndex?.owners.has(item.pubkey);
// Fetch trust score
const { localScore: trustScore } = useTrustBucket(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, t)}${item.price.frequency ? `/${item.price.frequency}` : ''}`
: formatPrice(undefined, item.priceType, t);
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 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
const priceTypeLabel = item.priceType
? PRICE_TYPE_KEYS[item.priceType]
? t(PRICE_TYPE_KEYS[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 && item.priceType !== 'fixed' && (
<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="min-h-0 min-w-0 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>
<MuteMenuItem pubkey={item.pubkey} />
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Owner management dropdown — mark sold / relist / delete */}
{user && isOwnListing && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="min-h-0 min-w-0 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} {item.condition && GEAR_CONDITION_KEYS[item.condition] ? t(GEAR_CONDITION_KEYS[item.condition]) : 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>
)}
{item.escrow === 'required' && (
<Badge variant="outline" className="text-xs border-[#ff6600]/50 bg-[#ff6600]/10 text-[#ff6600]">
🔒 {t('marketUi.create.requireEscrow')}
</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} />
{sellerIssuesPasses && (
<Badge
variant="outline"
className="text-xs border-[#ccff00]/50 text-[#ccff00] bg-[#ccff00]/10 gap-1"
title={t('venueAccess.sellerHint')}
>
🎟️ {t('venueAccess.badge')}
</Badge>
)}
</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>
);
}