SkateSpots/src/components/MarketplaceItemCard.tsx
shakespeare.diy 080785a8c5 Marketplace: give sellers real listing management (mark sold / relist / delete) (1.2.30)
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>
2026-09-07 22:24:47 +07:00

651 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, 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>
);
}