Gap found: the app's data model, sales tracking (useSellerSales), and Stash
showcase all key off a listing's status='sold', and both listing cards render a
"sold" state — but there was NO way for a seller to ever set it. Owners had zero
management actions: the MarketplaceItemCard dropdown only rendered for
non-owners (Report), and GearSwapItemCard's owner branch literally said "owners
manage their listing elsewhere" — a place that didn't exist. So sold gear stayed
"active" forever (until 30-day expiry) and the whole sold/sales-count feature was
unreachable.
Added owner-facing management on BOTH listing cards (GearSwapItemCard drives the
main GearSwap grid; MarketplaceItemCard is used on spot pages):
- Mark as sold / Relist as active
- Delete listing (NIP-09, via the existing useDeleteEntity)
New useMarkListingSold hook flips status in place, PRESERVING every other tag
(title, price, images, size, spot ref, geohash, expiration, category t-tags) —
kind 30402 is addressable, so rebuilding from scratch would wipe the photos and
price. Same "preserve unmanaged tags" discipline as the recent event-edit fix.
Also:
- GearSwapItemCard now shows a clear diagonal "SOLD" overlay when sold, so the
status is actually visible to buyers and the owner (previously invisible).
- Generalized its admin-remove flow to handle owner deletes too, with wording
that adapts (owner "Delete" vs admin "Remove").
- DeleteEntityDialog gained an optional controlled open/onOpenChange so it can
be driven from inside a dropdown menu without unmounting on menu close.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
651 lines
25 KiB
TypeScript
651 lines
25 KiB
TypeScript
import { useState } from 'react';
|
||
import { Link, 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 { 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 { useCashu } from '@/hooks/wallet/useCashu';
|
||
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 { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
||
import { genUserName } from '@/lib/genUserName';
|
||
import { TrustBadge } from '@/components/TrustBadge';
|
||
import {
|
||
MessageCircle,
|
||
Clock,
|
||
Search,
|
||
Tag,
|
||
Flag,
|
||
MoreVertical,
|
||
ShieldCheck,
|
||
ShieldAlert,
|
||
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';
|
||
|
||
interface MarketplaceItemCardProps {
|
||
item: MarketplaceItem;
|
||
compact?: boolean;
|
||
}
|
||
|
||
export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCardProps) {
|
||
const { user } = useCurrentUser();
|
||
const { toast } = useToast();
|
||
const navigate = useNavigate();
|
||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||
const { markSold, 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;
|
||
|
||
const npub = nip19.npubEncode(item.pubkey);
|
||
|
||
// 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);
|
||
|
||
// Cashu wallet integration
|
||
const { totalBalance: cashuBalance, isInitialized: hasCashu, coco } = useCashu();
|
||
const [showPaymentDialog, setShowPaymentDialog] = useState(false);
|
||
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 [isPaying, setIsPaying] = useState(false);
|
||
|
||
// 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 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;
|
||
|
||
// Calculate price in sats (rough estimate: $1 = 2500 sats at $40k/BTC)
|
||
const priceInSats = item.price?.amount ? Math.round(parseFloat(item.price.amount) * 2500) : 0;
|
||
const canPayWithCashu = hasCashu && cashuBalance >= priceInSats && priceInSats > 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 handleCashuPayment = async () => {
|
||
if (!coco || !user) return;
|
||
|
||
setIsPaying(true);
|
||
try {
|
||
toast({
|
||
title: 'Payment Feature Coming Soon',
|
||
description: `Cashu peer-to-peer payments will be available soon! For now, please contact the seller directly.`,
|
||
});
|
||
setShowPaymentDialog(false);
|
||
} catch (error) {
|
||
toast({
|
||
variant: 'destructive',
|
||
title: 'Payment Failed',
|
||
description: error instanceof Error ? error.message : 'Failed to process payment',
|
||
});
|
||
} finally {
|
||
setIsPaying(false);
|
||
}
|
||
};
|
||
|
||
const handleReport = async () => {
|
||
if (!user || !reportReason) {
|
||
toast({ title: 'Please select a reason', 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: 'Report submitted',
|
||
description: 'Thank you for helping keep the marketplace safe.',
|
||
});
|
||
setShowReportDialog(false);
|
||
setReportReason('');
|
||
setReportDetails('');
|
||
} catch (error) {
|
||
toast({
|
||
variant: 'destructive',
|
||
title: 'Failed to submit report',
|
||
description: error instanceof Error ? error.message : 'Please try again',
|
||
});
|
||
} 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 handleMarkSold = async () => {
|
||
try {
|
||
await markSold(item.event);
|
||
toast({ title: 'Marked as sold', description: 'Your listing is now shown as sold.' });
|
||
} catch (error) {
|
||
toast({
|
||
title: 'Could not update',
|
||
description: error instanceof Error ? error.message : 'Something went wrong',
|
||
variant: 'destructive',
|
||
});
|
||
}
|
||
};
|
||
|
||
const handleRelist = async () => {
|
||
try {
|
||
await relist(item.event);
|
||
toast({ title: 'Relisted', description: 'Your listing is active again.' });
|
||
} catch (error) {
|
||
toast({
|
||
title: 'Could not update',
|
||
description: error instanceof Error ? error.message : 'Something went wrong',
|
||
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} report{reportCount > 1 ? 's' : ''}
|
||
</Badge>
|
||
</div>
|
||
)}
|
||
|
||
<CardContent className="p-4">
|
||
<div className="flex items-start gap-4">
|
||
{/* Product Image */}
|
||
<div className="flex-shrink-0">
|
||
{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>
|
||
)}
|
||
</div>
|
||
|
||
{/* 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" />
|
||
WANT TO BUY
|
||
</Badge>
|
||
)}
|
||
<h3
|
||
className={`font-black text-base uppercase ${
|
||
isWTB ? 'text-blue-400' : 'text-orange-400'
|
||
} leading-tight`}
|
||
>
|
||
{item.title}
|
||
</h3>
|
||
</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>
|
||
)}
|
||
</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" />
|
||
Report Listing
|
||
</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="Manage listing"
|
||
>
|
||
{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" />
|
||
Relist as active
|
||
</DropdownMenuItem>
|
||
) : (
|
||
<DropdownMenuItem onClick={handleMarkSold} disabled={isUpdatingStatus}>
|
||
<CheckCircle className="h-4 w-4 mr-2" />
|
||
Mark as sold
|
||
</DropdownMenuItem>
|
||
)}
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem
|
||
onClick={() => setShowDeleteDialog(true)}
|
||
className="text-red-400 focus:text-red-400"
|
||
>
|
||
<Trash2 className="h-4 w-4 mr-2" />
|
||
Delete listing
|
||
</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>
|
||
)}
|
||
</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" />
|
||
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" />
|
||
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
|
||
? `${accountAgeDays}d old`
|
||
: accountAgeDays < 365
|
||
? `${Math.floor(accountAgeDays / 30)}mo old`
|
||
: `${Math.floor(accountAgeDays / 365)}y old`}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{previousListings > 1 && (
|
||
<div className="flex items-center gap-1">
|
||
<Package className="h-3 w-3" />
|
||
<span>{previousListings} listings</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>Usually here {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 */}
|
||
<div className="space-y-2">
|
||
{/* Pay with Cashu button */}
|
||
{user && !isOwnListing && canPayWithCashu && item.status === 'active' && !isWTB && (
|
||
<Button
|
||
onClick={() => setShowPaymentDialog(true)}
|
||
size="sm"
|
||
className="w-full gap-2 bg-orange-500 hover:bg-orange-600 text-white"
|
||
>
|
||
🥜 Pay {priceInSats.toLocaleString()} sats with Cashu
|
||
</Button>
|
||
)}
|
||
|
||
{/* Contact button */}
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className={`w-full 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={() => navigate(`/messages/${npub}`)}
|
||
>
|
||
<MessageCircle className="h-4 w-4" />
|
||
{isWTB ? 'I Have This!' : 'Contact Seller'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
|
||
{/* Payment Confirmation Dialog */}
|
||
<Dialog open={showPaymentDialog} onOpenChange={setShowPaymentDialog}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>Confirm Payment</DialogTitle>
|
||
<DialogDescription>Review the payment details before confirming</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="space-y-4">
|
||
{/* Item details */}
|
||
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted">
|
||
{primaryImage && (
|
||
<ImageWithFallback src={primaryImage} alt={item.title} className="w-16 h-16 rounded object-cover" />
|
||
)}
|
||
<div className="flex-1">
|
||
<h4 className="font-bold">{item.title}</h4>
|
||
<p className="text-sm text-muted-foreground">Sold by {displayName}</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Payment details */}
|
||
<div className="space-y-2">
|
||
<div className="flex justify-between text-sm">
|
||
<span>Price:</span>
|
||
<span className="font-bold">{priceDisplay}</span>
|
||
</div>
|
||
<div className="flex justify-between text-sm">
|
||
<span>Amount (sats):</span>
|
||
<span className="font-bold">{priceInSats.toLocaleString()} sats</span>
|
||
</div>
|
||
<div className="flex justify-between text-sm">
|
||
<span>Your Cashu Balance:</span>
|
||
<span className="font-bold">{cashuBalance.toLocaleString()} sats</span>
|
||
</div>
|
||
<div className="flex justify-between text-sm text-muted-foreground">
|
||
<span>After Payment:</span>
|
||
<span>{(cashuBalance - priceInSats).toLocaleString()} sats</span>
|
||
</div>
|
||
</div>
|
||
|
||
<Card className="bg-yellow-950/30 border-yellow-500/30">
|
||
<CardContent className="p-3">
|
||
<p className="text-xs text-yellow-200">
|
||
⚠️ <strong>Peer-to-peer payments are coming soon!</strong> This feature will allow
|
||
you to send Cashu tokens directly to the seller. For now, please contact the seller
|
||
to arrange payment.
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<div className="flex gap-2">
|
||
<Button onClick={() => setShowPaymentDialog(false)} variant="outline" className="flex-1">
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
onClick={handleCashuPayment}
|
||
disabled={isPaying}
|
||
className="flex-1 bg-orange-500 hover:bg-orange-600"
|
||
>
|
||
{isPaying ? 'Processing...' : 'Confirm Payment'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* Delete confirmation (controlled — trigger lives in the owner dropdown) */}
|
||
<DeleteEntityDialog
|
||
event={item.event}
|
||
entityLabel="listing"
|
||
entityName={item.title}
|
||
triggerText="Delete listing"
|
||
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" />
|
||
Report Listing
|
||
</DialogTitle>
|
||
<DialogDescription>
|
||
Help keep the marketplace safe by reporting inappropriate or misleading listings.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="space-y-4">
|
||
<div className="space-y-2">
|
||
<Label>Reason for report *</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">Additional details (optional)</Label>
|
||
<Textarea
|
||
id="report-details"
|
||
placeholder="Provide any additional context that might help..."
|
||
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">
|
||
Reports are published to Nostr and help the community identify problematic listings.
|
||
Listings with multiple reports may be automatically hidden.
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setShowReportDialog(false)}>
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
onClick={handleReport}
|
||
disabled={isReporting || !reportReason}
|
||
className="bg-red-500 hover:bg-red-600"
|
||
>
|
||
{isReporting ? 'Submitting...' : 'Submit Report'}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</Card>
|
||
);
|
||
}
|