import { useState, useEffect, useMemo } from 'react'; import { useSeoMeta } from '@unhead/react'; import { useNavigate, Link, useSearchParams } from 'react-router-dom'; import { Layout } from '@/components/Layout'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useUploadFile } from '@/hooks/useUploadFile'; import { useToast } from '@/hooks/useToast'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useSkateSpot, useSkateSpots } from '@/hooks/useSkateSpots'; import { useListingRestrictions, LISTING_FEE_SATS, recordListingCreation } from '@/hooks/useListingRestrictions'; import { LoginArea } from '@/components/auth/LoginArea'; import { SpamFeePayment } from '@/components/SpamFeePayment'; import { MARKETPLACE_ITEM_KIND, GEAR_CATEGORIES, GEAR_CONDITIONS, PRICE_TYPES, LISTING_TYPES, SIZE_OPTIONS, TRUCK_HEIGHTS, WHEEL_HARDNESS, EXPIRATION_OPTIONS, CURRENCY_OPTIONS, CATEGORY_REQUIRED_FIELDS, } from '@/lib/types'; import { ShoppingBag, Upload, X, Loader2, ArrowLeft, MapPin, AlertTriangle, CheckCircle, Search, Clock, Shield, Zap, } from 'lucide-react'; import ngeohash from 'ngeohash'; export default function CreateListingPage() { useSeoMeta({ title: 'List Gear - SkateSpots', description: 'List skateboarding gear for sale or find gear you want to buy.', }); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const { user, metadata } = useCurrentUser(); const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish(); const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); const { toast } = useToast(); // Get spot reference from URL parameter const spotParam = searchParams.get('spot'); // Format: "pubkey:identifier" const [spotPubkey, spotIdentifier] = spotParam?.split(':') || ['', '']; const { data: linkedSpot } = useSkateSpot(spotIdentifier, spotPubkey); // Get all spots for picker const { data: allSpots } = useSkateSpots(100); // Anti-spam restrictions const restrictions = useListingRestrictions(linkedSpot?.pubkey); const [feePaid, setFeePaid] = useState(false); const [lastListingRecord, setLastListingRecord] = useLocalStorage<{ timestamp: number; spotOwnerPubkey?: string } | null>( 'last-listing-record', null ); // Check if coming from stash const fromStash = searchParams.get('fromStash') === 'true'; const stashId = searchParams.get('stashId'); // Form state - initialize from URL params if coming from stash const [listingType, setListingType] = useState<'selling' | 'wtb'>('selling'); const [category, setCategory] = useState(searchParams.get('category') || ''); const [title, setTitle] = useState(searchParams.get('title') || ''); const [description, setDescription] = useState(searchParams.get('description') || ''); const [summary, setSummary] = useState(searchParams.get('brand') ? `Brand: ${searchParams.get('brand')}` : ''); const [condition, setCondition] = useState(searchParams.get('condition') || ''); const [priceType, setPriceType] = useState(searchParams.get('priceType') || 'fixed'); const [price, setPrice] = useState(searchParams.get('price') || ''); const [currency, setCurrency] = useState('sats'); // Default to sats when coming from stash (stash uses sats) const [size, setSize] = useState(searchParams.get('size') || ''); const [truckHeight, setTruckHeight] = useState(''); const [wheelHardness, setWheelHardness] = useState(''); const [images, setImages] = useState(() => { const imagesParam = searchParams.get('images'); return imagesParam ? imagesParam.split(',').filter(Boolean) : []; }); const [availability, setAvailability] = useState(''); const [expiration, setExpiration] = useState(30); const [selectedSpotId, setSelectedSpotId] = useState(spotParam || null); const [spotSearchQuery, setSpotSearchQuery] = useState(''); // Check if user has NIP-05 verification (or is platform owner who bypasses this) const hasNip05 = !!metadata?.nip05 || restrictions.isPlatformOwner; // Format cooldown time remaining const formatCooldownTime = (ms: number | null): string => { if (!ms) return ''; const hours = Math.floor(ms / (1000 * 60 * 60)); const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60)); if (hours > 0) { return `${hours}h ${minutes}m`; } return `${minutes}m`; }; // Check if user can proceed (fee paid or not required) const canProceedWithListing = !restrictions.requiresFee || feePaid; // Get selected spot details const selectedSpot = useMemo(() => { if (!selectedSpotId) return null; if (linkedSpot && spotParam === selectedSpotId) return linkedSpot; if (!allSpots) return null; const [pubkey, identifier] = selectedSpotId.split(':'); return allSpots.find(s => s.pubkey === pubkey && s.identifier === identifier) || null; }, [selectedSpotId, linkedSpot, allSpots, spotParam]); // Filter spots for search const filteredSpots = useMemo(() => { if (!allSpots) return []; if (!spotSearchQuery) return allSpots.slice(0, 10); const query = spotSearchQuery.toLowerCase(); return allSpots .filter(s => s.title.toLowerCase().includes(query) || s.location?.toLowerCase().includes(query) ) .slice(0, 10); }, [allSpots, spotSearchQuery]); // Pre-fill spot if coming from URL useEffect(() => { if (linkedSpot && !selectedSpotId) { setSelectedSpotId(spotParam); } }, [linkedSpot, spotParam, selectedSpotId]); // Size options based on category const currentSizeOptions = SIZE_OPTIONS[category] || []; const showTruckHeight = category === 'trucks'; const showWheelHardness = category === 'wheels'; const handleImageUpload = async (e: React.ChangeEvent) => { const files = e.target.files; if (!files || files.length === 0) return; try { const file = files[0]; const tags = await uploadFile(file); const url = tags[0]?.[1]; if (url) { setImages((prev) => [...prev, url]); toast({ title: 'Image uploaded' }); } } catch (error) { toast({ title: 'Upload failed', description: error instanceof Error ? error.message : 'Failed', variant: 'destructive', }); } e.target.value = ''; }; const removeImage = (index: number) => { setImages((prev) => prev.filter((_, i) => i !== index)); }; // Get category-specific requirements const categoryRequirements = category ? CATEGORY_REQUIRED_FIELDS[category] : null; const requiresSize = categoryRequirements?.size && listingType === 'selling'; const requiresCondition = categoryRequirements?.condition && listingType === 'selling'; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); // Anti-spam validation if (!restrictions.canCreateListing && !feePaid) { if (restrictions.isInCooldown) { toast({ title: 'Cooldown active', description: `Please wait ${formatCooldownTime(restrictions.timeUntilCooldownEnds)} before creating another listing`, variant: 'destructive', }); return; } if (restrictions.remainingListings <= 0) { toast({ title: 'Listing limit reached', description: `You've reached your monthly limit of ${restrictions.listingLimit} listings`, variant: 'destructive', }); return; } } if (restrictions.requiresFee && !feePaid) { toast({ title: 'Fee required', description: 'Please pay the anti-spam fee to continue', variant: 'destructive', }); return; } // Validation if (!title.trim()) { toast({ title: 'Title required', variant: 'destructive' }); return; } if (!category) { toast({ title: 'Category required', variant: 'destructive' }); return; } if (listingType === 'selling' && images.length === 0) { toast({ title: 'Photo required', description: 'At least one photo is required when selling gear', variant: 'destructive' }); return; } if (!hasNip05) { toast({ title: 'NIP-05 verification required', description: 'You need a verified Nostr address to list gear', variant: 'destructive', }); return; } if (priceType === 'fixed' && !price.trim()) { toast({ title: 'Price required for fixed price listings', variant: 'destructive' }); return; } // Category-specific validation if (requiresSize && !size) { toast({ title: 'Size required', description: `Please select a size for ${GEAR_CATEGORIES[category as keyof typeof GEAR_CATEGORIES]}`, variant: 'destructive' }); return; } if (requiresCondition && !condition) { toast({ title: 'Condition required', description: `Please select the condition of your ${GEAR_CATEGORIES[category as keyof typeof GEAR_CATEGORIES]}`, variant: 'destructive' }); return; } try { const identifier = `listing-${Date.now()}`; const now = Math.floor(Date.now() / 1000); const expirationTimestamp = now + (expiration * 24 * 60 * 60); const tags: string[][] = [ ['d', identifier], ['title', title.trim()], ['t', 'skateboarding'], ['t', category], ['price_type', priceType], ['listing_type', listingType], ['expiration', expirationTimestamp.toString()], ['status', 'active'], ['published_at', now.toString()], ['alt', `${listingType === 'wtb' ? 'WTB' : 'Gear listing'}: ${title}`], ]; // Price (only for fixed price) if (priceType === 'fixed' && price.trim()) { tags.push(['price', price.trim(), currency]); } if (summary.trim()) tags.push(['summary', summary.trim()]); if (condition) tags.push(['condition', condition]); // Size with optional modifiers if (size) { let sizeValue = size; if (showTruckHeight && truckHeight) { sizeValue = `${size} ${truckHeight}`; } if (showWheelHardness && wheelHardness) { sizeValue = `${size} ${wheelHardness}`; } tags.push(['size', sizeValue]); } if (availability.trim()) tags.push(['available', availability.trim()]); // Spot reference and geohash if (selectedSpot) { const spotRef = `30818:${selectedSpot.pubkey}:${selectedSpot.identifier}`; tags.push(['a', spotRef]); tags.push(['location', selectedSpot.title]); if (selectedSpot.geohash) { tags.push(['g', selectedSpot.geohash]); } } // Add images for (const imageUrl of images) { tags.push(['image', imageUrl]); } await createEvent({ kind: MARKETPLACE_ITEM_KIND, content: description.trim(), tags, }); // Record listing creation for cooldown tracking (only for unknown trust level) if (restrictions.trustLevel === 'unknown') { recordListingCreation(setLastListingRecord, selectedSpot?.pubkey); } toast({ title: listingType === 'wtb' ? 'Want to Buy posted!' : 'Listing created!', description: listingType === 'wtb' ? 'Sellers will be able to find your request.' : 'Your gear is now listed.', }); // Navigate back if (selectedSpot) { navigate(`/spot/${selectedSpot.pubkey}/${selectedSpot.identifier}`); } else { navigate('/map'); } } catch (error) { toast({ title: 'Failed to publish', description: error instanceof Error ? error.message : 'Error', variant: 'destructive', }); } }; if (!user) { return (

Login Required

You need to log in to list gear.

); } return (
{fromStash ? 'Back to Stash' : (selectedSpot ? `Back to ${selectedSpot.title}` : 'Back to Map')}

{listingType === 'wtb' ? 'WANT TO BUY' : 'LIST YOUR GEAR'}

{listingType === 'wtb' ? 'Tell the community what gear you\'re looking for' : 'Sell skateboarding gear to the community'}

{/* Coming from Stash indicator */} {fromStash && (

Listing from your Stash

Pre-filled with your stash item details. Review and pick a meetup spot!

)} {/* NIP-05 Requirement Notice - never shown to platform owners */} {!hasNip05 && !restrictions.isPlatformOwner && (

NIP-05 Verification Required

To prevent spam and protect buyers, you need a verified Nostr address (NIP-05) to list gear.

)} {hasNip05 && (
Verified: {metadata?.nip05}
)} {/* Trust Level & Listing Limits - Loading state */} {hasNip05 && restrictions.isLoading && (
Checking your trust level...
)} {/* Trust Level & Listing Limits - Loaded state */} {hasNip05 && !restrictions.isLoading && (
Trust Level: {restrictions.trustLevel === 'trusted' ? '⭐ Trusted' : restrictions.trustLevel === 'active' ? '🔥 Active' : restrictions.trustLevel === 'newcomer' ? '🌱 Newcomer' : '👋 New User'}

{restrictions.remainingListings} of {restrictions.listingLimit} listings remaining this month

)} {/* Cooldown Warning */} {restrictions.isInCooldown && (

Cooldown Active

New users have a 24-hour cooldown between listings.
Time remaining: {formatCooldownTime(restrictions.timeUntilCooldownEnds)}

)} {/* Listing Limit Reached - never shown to platform owners */} {restrictions.remainingListings <= 0 && !restrictions.isInCooldown && !restrictions.isPlatformOwner && (

Monthly Limit Reached

You've reached your limit of {restrictions.listingLimit} listings this month. Build your reputation by creating spots or completing sales to increase your limit.

)} {/* Anti-Spam Fee Payment - for newcomers (NIP-05 verified but no spots/listings yet) */} {hasNip05 && !restrictions.isLoading && restrictions.requiresFee && !feePaid && restrictions.feeRecipient && !restrictions.isInCooldown && restrictions.remainingListings > 0 && (
setFeePaid(true)} />
)} {/* Fee Paid Success */} {feePaid && (
Fee Paid! You can now create your listing.
)}
{/* Listing Type Toggle */} What do you want to do?
{/* Category Selection */} Category * What type of gear?
{Object.entries(GEAR_CATEGORIES).map(([key, label]) => ( ))}
{/* Gear Details */} Gear Details {listingType === 'wtb' ? 'Describe what you\'re looking for' : 'Tell buyers about your item'}
setTitle(e.target.value)} className="bg-gray-800 border-gray-700" required />
setSummary(e.target.value)} className="bg-gray-800 border-gray-700" />