596 lines
25 KiB
TypeScript
596 lines
25 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 { 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>
|
||
);
|
||
}
|