SkateSpots/src/components/MarketplaceItemCard.tsx
shakespeare.diy e29d35f1e4 fix: restore full skate-spots source tree from misplaced src/src/ copy
The entire skate-spots codebase was accidentally copied into src/src/ and
public/public/ instead of src/ and public/ directly. This left the real
src/ directory as blank MKStack template stubs, explaining why the landing
page showed the default template instead of SkateSpots.

Changes made:
- Copied all files from src/src/ up to src/ (App.tsx, AppRouter.tsx, main.tsx,
  all pages, components, hooks, contexts, lib, i18n, test)
- Copied all files from public/public/ up to public/ (images, manifest, etc.)
- Deleted src/src/ and public/public/ nested directories
- Removed HelloWorld.tsx template stub from pages/
- Retained the previously fixed index.css (Tailwind v3 directives)

Full skate-spots feature set is now active:
- All 30+ routes (map, events, spots, coaches, marketplace, wallet, etc.)
- NWCProvider, LanguageProvider, PWAInstallPrompt, AmberCallbackHandler
- Skate community relays (goskatespots.nostr1.com, wotr.relatr.xyz, etc.)
- Outfit Variable font, Amber pre-check in main.tsx
- All hooks (useSkateSpots, useCoaches, useZaps, etc.)
- i18n support (en/es)

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-06-15 22:18:28 +07:00

564 lines
21 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 {
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 { genUserName } from '@/lib/genUserName';
import { TrustBadge } from '@/components/TrustBadge';
import {
MessageCircle,
Clock,
Search,
Tag,
Flag,
MoreVertical,
ShieldCheck,
ShieldAlert,
Calendar,
Package,
AlertTriangle,
CheckCircle,
} 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 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 [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;
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'
}`}
>
<img 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>
)}
</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 && (
<img 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>
{/* 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>
);
}