SkateSpots/src/pages/CreateListingPage.tsx
SkateSpots 8c5b1636f7 feat(marketplace): local-first fulfilment (pickup/ships) + ring warning
- Listings carry optional ["fulfilment","pickup"|"ships"|"both"] (selling only):
  a create-page selector (default pickup), a card badge, and a Pickup/Ships filter
  on GearSwapPage. The app NEVER collects an address - a ships deal exchanges it
  privately in the NIP-17 trade chat, and ListingTradeActions nudges escrow for
  shipped deals. No KYC rail, no labels, no carrier API.
- TradeReputationCard adds a low-diversity ring warning (verified >= 5 across <= 2
  people) beyond the single-partner one.
2026-10-01 16:18:42 +07:00

1070 lines
43 KiB
TypeScript

import { useState, useEffect, useMemo } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useNavigate, Link, useSearchParams } from 'react-router-dom';
import { PageHeader } from '@/components/PageHeader';
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, recordListingCreation } from '@/hooks/useListingRestrictions';
import { LoginArea } from '@/components/auth/LoginArea';
import { Nip05Required } from '@/components/Nip05Required';
import { SpamFeePayment } from '@/components/SpamFeePayment';
import { MARKETPLACE_ITEM_KIND, GEAR_CATEGORIES, GEAR_CONDITIONS, PRICE_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';
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 [, setLastListingRecord] = useLocalStorage<{ timestamp: number; spotOwnerPubkey?: string } | null>(
'last-listing-record',
null
);
// Check if coming from stash
const fromStash = searchParams.get('fromStash') === 'true';
// Where to return after publishing / when going back (e.g. /marketplace).
const returnTo = searchParams.get('returnTo') || '';
// Form state - initialize from URL params if coming from stash
const [listingType, setListingType] = useState<'selling' | 'wtb'>('selling');
const [fulfilment, setFulfilment] = useState<'pickup' | 'ships' | 'both'>('pickup');
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<string>(searchParams.get('condition') || '');
const [priceType, setPriceType] = useState<string>(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<string[]>(() => {
const imagesParam = searchParams.get('images');
return imagesParam ? imagesParam.split(',').filter(Boolean) : [];
});
const [availability, setAvailability] = useState('');
const [expiration, setExpiration] = useState(30);
const [selectedSpotId, setSelectedSpotId] = useState<string | null>(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)
// 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<HTMLInputElement>) => {
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]);
// Fulfilment is only meaningful when selling (WTB is the buyer's ask).
if (listingType === 'selling') tags.push(['fulfilment', fulfilment]);
// 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(returnTo || '/map');
}
} catch (error) {
toast({
title: 'Failed to publish',
description: error instanceof Error ? error.message : 'Error',
variant: 'destructive',
});
}
};
if (!user) {
return (
<>
<div className="container px-4 py-8">
<Card className="max-w-xl mx-auto bg-gray-900 border-gray-700">
<CardContent className="py-12 text-center">
<ShoppingBag className="h-12 w-12 mx-auto mb-4 text-gray-600" />
<h2 className="text-xl font-bold mb-2">Login Required</h2>
<p className="text-gray-400 mb-6">You need to log in to list gear.</p>
<LoginArea className="justify-center max-w-60 mx-auto" />
</CardContent>
</Card>
</div>
</>
);
}
return (
<>
<div className="container px-4 sm:px-6 py-6 sm:py-8 max-w-2xl">
<div className="mb-6">
<Link
to={fromStash ? '/stash' : (selectedSpot ? `/spot/${selectedSpot.pubkey}/${selectedSpot.identifier}` : (returnTo || '/map'))}
className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-4"
>
<ArrowLeft className="h-4 w-4 mr-2" />
{fromStash ? 'Back to Stash' : (selectedSpot ? `Back to ${selectedSpot.title}` : (returnTo === '/marketplace' ? 'Back to Marketplace' : 'Back to Map'))}
</Link>
<PageHeader
title={listingType === 'wtb' ? 'Want to Buy' : 'List Your Gear'}
accent="#ff6600"
subtitle={
listingType === 'wtb'
? "Tell the community what gear you're looking for"
: 'Sell skateboarding gear to the community'
}
/>
</div>
{/* Coming from Stash indicator */}
{fromStash && (
<Card className="mb-6 bg-purple-950/30 border-2 border-purple-500/50">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-purple-500/20">
<ShoppingBag className="h-5 w-5 text-purple-400" />
</div>
<div className="flex-1">
<h3 className="font-bold text-purple-400 mb-0.5">Listing from your Stash</h3>
<p className="text-sm text-gray-300">
Pre-filled with your stash item details. Review and pick a meetup spot!
</p>
</div>
<Link to="/stash" className="text-purple-400 hover:text-purple-300">
<ArrowLeft className="h-4 w-4" />
</Link>
</div>
</CardContent>
</Card>
)}
{/* NIP-05 Requirement Notice - never shown to platform owners */}
{!hasNip05 && !restrictions.isPlatformOwner && (
<Nip05Required
variant="banner"
role="sellers"
reason="To prevent spam and protect buyers, you need a verified Nostr address (NIP-05) to list gear."
className="mb-6"
/>
)}
{hasNip05 && (
<Card className="mb-6 bg-green-950/30 border-2 border-green-500/50">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<CheckCircle className="h-5 w-5 text-green-400 flex-shrink-0" />
<div>
<span className="font-bold text-green-400">Verified: </span>
<span className="text-gray-300">{metadata?.nip05}</span>
</div>
</div>
</CardContent>
</Card>
)}
{/* Trust Level & Listing Limits - Loading state */}
{hasNip05 && restrictions.isLoading && (
<Card className="mb-6 bg-gray-900 border-gray-700">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<Loader2 className="h-5 w-5 text-blue-400 animate-spin" />
<div>
<span className="text-gray-400">Checking your trust level...</span>
</div>
</div>
</CardContent>
</Card>
)}
{/* Trust Level & Listing Limits - Loaded state */}
{hasNip05 && !restrictions.isLoading && (
<Card className="mb-6 bg-gray-900 border-gray-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Shield className="h-5 w-5 text-blue-400 flex-shrink-0" />
<div>
<div className="flex items-center gap-2">
<span className="font-bold text-gray-200">Trust Level: </span>
<Badge
variant="outline"
className={
restrictions.trustLevel === 'trusted' ? 'border-green-500 text-green-400' :
restrictions.trustLevel === 'active' ? 'border-blue-500 text-blue-400' :
restrictions.trustLevel === 'newcomer' ? 'border-yellow-500 text-yellow-400' :
'border-gray-500 text-gray-400'
}
>
{restrictions.trustLevel === 'trusted' ? '⭐ Trusted' :
restrictions.trustLevel === 'active' ? '🔥 Active' :
restrictions.trustLevel === 'newcomer' ? '🌱 Newcomer' :
'👋 New User'}
</Badge>
</div>
<p className="text-xs text-gray-400 mt-1">
{restrictions.remainingListings} of {restrictions.listingLimit} listings remaining this month
</p>
</div>
</div>
</div>
</CardContent>
</Card>
)}
{/* Cooldown Warning */}
{restrictions.isInCooldown && (
<Card className="mb-6 bg-orange-950/30 border-2 border-orange-500/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Clock className="h-5 w-5 text-orange-400 flex-shrink-0 mt-0.5" />
<div>
<h3 className="font-bold text-orange-400 mb-1">Cooldown Active</h3>
<p className="text-sm text-gray-300">
New users have a 24-hour cooldown between listings.
<br />
<span className="font-semibold">
Time remaining: {formatCooldownTime(restrictions.timeUntilCooldownEnds)}
</span>
</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Listing Limit Reached - never shown to platform owners */}
{restrictions.remainingListings <= 0 && !restrictions.isInCooldown && !restrictions.isPlatformOwner && (
<Card className="mb-6 bg-red-950/30 border-2 border-red-500/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-red-400 flex-shrink-0 mt-0.5" />
<div>
<h3 className="font-bold text-red-400 mb-1">Monthly Limit Reached</h3>
<p className="text-sm text-gray-300">
You've reached your limit of {restrictions.listingLimit} listings this month.
Build your reputation by creating spots or completing sales to increase your limit.
</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* 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 && (
<div className="mb-6">
<SpamFeePayment
feeRecipient={restrictions.feeRecipient}
feeRecipientName={restrictions.feeRecipientName || 'SkateSpots'}
onPaymentSuccess={() => setFeePaid(true)}
/>
</div>
)}
{/* Fee Paid Success */}
{feePaid && (
<Card className="mb-6 bg-green-950/30 border-2 border-green-500/50">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<Zap className="h-5 w-5 text-green-400 flex-shrink-0" />
<div>
<span className="font-bold text-green-400">Fee Paid!</span>
<span className="text-gray-300 ml-2">You can now create your listing.</span>
</div>
</div>
</CardContent>
</Card>
)}
<form onSubmit={handleSubmit}>
{/* Listing Type Toggle */}
<Card className="bg-gray-900 border-gray-700 mb-6">
<CardHeader>
<CardTitle>What do you want to do?</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<Button
type="button"
variant={listingType === 'selling' ? 'default' : 'outline'}
className={`h-20 flex-col gap-2 ${
listingType === 'selling'
? 'bg-orange-500 hover:bg-orange-600'
: 'border-gray-600'
}`}
onClick={() => setListingType('selling')}
>
<span className="text-2xl">💰</span>
<span className="font-bold">SELL GEAR</span>
</Button>
<Button
type="button"
variant={listingType === 'wtb' ? 'default' : 'outline'}
className={`h-20 flex-col gap-2 ${
listingType === 'wtb'
? 'bg-blue-500 hover:bg-blue-600'
: 'border-gray-600'
}`}
onClick={() => setListingType('wtb')}
>
<span className="text-2xl">🔍</span>
<span className="font-bold">WANT TO BUY</span>
</Button>
</div>
</CardContent>
</Card>
{/* Category Selection */}
<Card className="bg-gray-900 border-gray-700 mb-6">
<CardHeader>
<CardTitle>Category *</CardTitle>
<CardDescription className="text-gray-400">What type of gear?</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2">
{Object.entries(GEAR_CATEGORIES).map(([key, label]) => (
<Button
key={key}
type="button"
variant={category === key ? 'default' : 'outline'}
size="sm"
className={`${
category === key
? 'bg-orange-500 hover:bg-orange-600'
: 'border-gray-600 hover:bg-gray-800'
}`}
onClick={() => {
setCategory(key);
setSize('');
setTruckHeight('');
setWheelHardness('');
}}
>
{label}
</Button>
))}
</div>
</CardContent>
</Card>
{/* Gear Details */}
<Card className="bg-gray-900 border-gray-700 mb-6">
<CardHeader>
<CardTitle>Gear Details</CardTitle>
<CardDescription className="text-gray-400">
{listingType === 'wtb' ? 'Describe what you\'re looking for' : 'Tell buyers about your item'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
id="title"
placeholder={listingType === 'wtb' ? 'Looking for 8.0" deck' : 'Almost New Palace Deck 8.25"'}
value={title}
onChange={(e) => setTitle(e.target.value)}
className="bg-gray-800 border-gray-700"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="summary">Short Summary</Label>
<Input
id="summary"
placeholder={listingType === 'wtb' ? 'Any brand, prefer minimal graphics' : 'Great condition, barely used'}
value={summary}
onChange={(e) => setSummary(e.target.value)}
className="bg-gray-800 border-gray-700"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder={
listingType === 'wtb'
? 'Describe exactly what you\'re looking for - brand preferences, condition requirements, etc.'
: 'Got this deck for Christmas but prefer a smaller size. Happy to let you try it before buying!'
}
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
className="bg-gray-800 border-gray-700"
/>
</div>
{/* Condition (only for selling) */}
{listingType === 'selling' && (
<div className="space-y-2">
<Label>Condition {requiresCondition && <span className="text-red-400">*</span>}</Label>
<div className="grid grid-cols-2 gap-2">
{Object.entries(GEAR_CONDITIONS).map(([key, { label, emoji, description }]) => (
<Button
key={key}
type="button"
variant={condition === key ? 'default' : 'outline'}
className={`h-auto py-3 flex-col items-start ${
condition === key
? 'bg-orange-500 hover:bg-orange-600'
: 'border-gray-600 hover:bg-gray-800'
}`}
onClick={() => setCondition(key)}
>
<span className="font-bold">
{emoji} {label}
</span>
<span className="text-xs opacity-70">{description}</span>
</Button>
))}
</div>
</div>
)}
{listingType === 'selling' && (
<div className="space-y-2">
<Label>How is it fulfilled?</Label>
<div className="grid grid-cols-3 gap-2">
{([
['pickup', '📍 Pickup', 'Meet locally'],
['ships', '📦 Ships', 'Post it'],
['both', '📍📦 Both', 'Either'],
] as const).map(([key, label, hint]) => (
<Button
key={key}
type="button"
variant={fulfilment === key ? 'default' : 'outline'}
className={`h-auto py-3 flex-col items-start ${
fulfilment === key
? 'bg-[#00cc66] hover:bg-[#00cc66]/90 text-black'
: 'border-gray-600 hover:bg-gray-800'
}`}
onClick={() => setFulfilment(key)}
>
<span className="font-bold">{label}</span>
<span className="text-xs opacity-70">{hint}</span>
</Button>
))}
</div>
<p className="text-xs text-gray-500">
We never collect your address — for shipping, share it privately in the trade chat.
</p>
</div>
)}
{/* Size */}
{currentSizeOptions.length > 0 && (
<div className="space-y-2">
<Label>Size {requiresSize && <span className="text-red-400">*</span>}</Label>
<Select value={size} onValueChange={setSize}>
<SelectTrigger className="bg-gray-800 border-gray-700">
<SelectValue placeholder="Select size" />
</SelectTrigger>
<SelectContent>
{currentSizeOptions.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Truck Height */}
{showTruckHeight && (
<div className="space-y-2">
<Label>Truck Height</Label>
<Select value={truckHeight} onValueChange={setTruckHeight}>
<SelectTrigger className="bg-gray-800 border-gray-700">
<SelectValue placeholder="Select height" />
</SelectTrigger>
<SelectContent>
{TRUCK_HEIGHTS.map((h) => (
<SelectItem key={h} value={h}>
{h}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Wheel Hardness */}
{showWheelHardness && (
<div className="space-y-2">
<Label>Wheel Hardness</Label>
<Select value={wheelHardness} onValueChange={setWheelHardness}>
<SelectTrigger className="bg-gray-800 border-gray-700">
<SelectValue placeholder="Select hardness" />
</SelectTrigger>
<SelectContent>
{WHEEL_HARDNESS.map((h) => (
<SelectItem key={h} value={h}>
{h}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</CardContent>
</Card>
{/* Pricing */}
<Card className="bg-gray-900 border-gray-700 mb-6">
<CardHeader>
<CardTitle>Pricing</CardTitle>
<CardDescription className="text-gray-400">
{listingType === 'wtb' ? 'How do you want to pay?' : 'How do you want to sell?'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-2 gap-2">
{Object.entries(PRICE_TYPES).map(([key, { label, description }]) => (
<Button
key={key}
type="button"
variant={priceType === key ? 'default' : 'outline'}
className={`h-auto py-3 flex-col items-start ${
priceType === key
? 'bg-orange-500 hover:bg-orange-600'
: 'border-gray-600 hover:bg-gray-800'
}`}
onClick={() => setPriceType(key)}
>
<span className="font-bold">{label}</span>
<span className="text-xs opacity-70">{description}</span>
</Button>
))}
</div>
{priceType === 'fixed' && (
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2 space-y-2">
<Label htmlFor="price">Price *</Label>
<Input
id="price"
type="number"
placeholder="40"
value={price}
onChange={(e) => setPrice(e.target.value)}
className="bg-gray-800 border-gray-700"
required={priceType === 'fixed'}
min="0"
step="0.01"
/>
</div>
<div className="space-y-2">
<Label htmlFor="currency">Currency</Label>
<Select value={currency} onValueChange={setCurrency}>
<SelectTrigger className="bg-gray-800 border-gray-700">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-[300px]">
{CURRENCY_OPTIONS.map(({ code, label, region }) => (
<SelectItem key={code} value={code}>
{label} <span className="text-gray-500 text-xs ml-1">({region})</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
</CardContent>
</Card>
{/* Photos (required for selling) */}
<Card className="bg-gray-900 border-gray-700 mb-6">
<CardHeader>
<CardTitle>
Photos {listingType === 'selling' && '*'}
</CardTitle>
<CardDescription className="text-gray-400">
{listingType === 'selling'
? 'At least one photo is required'
: 'Optional - add reference images'}
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4">
{images.map((imageUrl, index) => (
<div
key={index}
className="relative aspect-square rounded-lg overflow-hidden bg-gray-800"
>
<img
src={imageUrl}
alt={`Product ${index + 1}`}
className="w-full h-full object-cover"
/>
<button
type="button"
onClick={() => removeImage(index)}
className="absolute top-2 right-2 p-1 rounded-full bg-black/80 hover:bg-red-500"
>
<X className="h-4 w-4" />
</button>
</div>
))}
{images.length < 5 && (
<label className="aspect-square rounded-lg border-2 border-dashed border-gray-700 flex flex-col items-center justify-center cursor-pointer hover:bg-gray-800/50">
<input
type="file"
accept="image/*"
className="hidden"
onChange={handleImageUpload}
disabled={isUploading}
/>
{isUploading ? (
<Loader2 className="h-6 w-6 animate-spin text-gray-500" />
) : (
<>
<Upload className="h-6 w-6 mb-1 text-gray-500" />
<span className="text-xs text-gray-500">Upload</span>
</>
)}
</label>
)}
</div>
<p className="text-xs text-gray-400 mt-2">
Add up to 5 photos. First image will be the main photo.
</p>
</CardContent>
</Card>
{/* Location / Spot Picker */}
<Card className="bg-gray-900 border-gray-700 mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MapPin className="h-5 w-5 text-orange-500" />
Pickup Location
</CardTitle>
<CardDescription className="text-gray-400">
Link to a skate spot for easy meetups
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{selectedSpot && (
<Card className="bg-orange-950/30 border-2 border-orange-500/50">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<MapPin className="h-5 w-5 text-orange-400" />
<div>
<h3 className="font-bold text-orange-400">{selectedSpot.title}</h3>
{selectedSpot.location && (
<p className="text-sm text-gray-400">{selectedSpot.location}</p>
)}
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setSelectedSpotId(null)}
className="text-gray-400 hover:text-white"
>
<X className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
)}
{!selectedSpot && (
<>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500" />
<Input
placeholder="Search spots..."
value={spotSearchQuery}
onChange={(e) => setSpotSearchQuery(e.target.value)}
className="bg-gray-800 border-gray-700 pl-9"
/>
</div>
<div className="max-h-48 overflow-y-auto space-y-2">
{filteredSpots.map((spot) => (
<Button
key={`${spot.pubkey}:${spot.identifier}`}
type="button"
variant="outline"
className="w-full justify-start h-auto py-3 border-gray-700 hover:bg-gray-800"
onClick={() => setSelectedSpotId(`${spot.pubkey}:${spot.identifier}`)}
>
<MapPin className="h-4 w-4 mr-2 text-orange-400" />
<div className="text-left">
<div className="font-medium">{spot.title}</div>
{spot.location && (
<div className="text-xs text-gray-400">{spot.location}</div>
)}
</div>
</Button>
))}
{filteredSpots.length === 0 && (
<p className="text-sm text-gray-400 text-center py-4">
No spots found. Try a different search.
</p>
)}
</div>
</>
)}
<div className="space-y-2">
<Label htmlFor="availability">When are you usually available?</Label>
<Input
id="availability"
placeholder="Weekends 10am-2pm, Tue/Thu evenings"
value={availability}
onChange={(e) => setAvailability(e.target.value)}
className="bg-gray-800 border-gray-700"
/>
<p className="text-xs text-gray-400">
💡 Helps buyers coordinate meetups with you
</p>
</div>
</CardContent>
</Card>
{/* Expiration */}
<Card className="bg-gray-900 border-gray-700 mb-6">
<CardHeader>
<CardTitle>Listing Duration</CardTitle>
<CardDescription className="text-gray-400">
How long should this listing stay active?
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-2">
{EXPIRATION_OPTIONS.map(({ value, label }) => (
<Button
key={value}
type="button"
variant={expiration === value ? 'default' : 'outline'}
className={`${
expiration === value
? 'bg-orange-500 hover:bg-orange-600'
: 'border-gray-600 hover:bg-gray-800'
}`}
onClick={() => setExpiration(value)}
>
{label}
</Button>
))}
</div>
</CardContent>
</Card>
{/* Submit Button */}
<Button
type="submit"
className="w-full bg-orange-500 hover:bg-orange-600 uppercase font-black h-14 text-lg"
disabled={
isPublishing ||
isUploading ||
(!hasNip05 && !restrictions.isPlatformOwner) ||
(restrictions.isLoading && !restrictions.isPlatformOwner) ||
(restrictions.isInCooldown && !restrictions.isPlatformOwner) ||
(restrictions.remainingListings <= 0 && !restrictions.isPlatformOwner) ||
(!restrictions.isLoading && restrictions.requiresFee && !feePaid && !restrictions.isPlatformOwner)
}
>
{isPublishing ? (
<>
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
Publishing...
</>
) : restrictions.isLoading && !restrictions.isPlatformOwner ? (
<>
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
Checking eligibility...
</>
) : restrictions.isInCooldown && !restrictions.isPlatformOwner ? (
<>
<Clock className="h-5 w-5 mr-2" />
COOLDOWN: {formatCooldownTime(restrictions.timeUntilCooldownEnds)}
</>
) : restrictions.remainingListings <= 0 && !restrictions.isPlatformOwner ? (
<>
<AlertTriangle className="h-5 w-5 mr-2" />
LIMIT REACHED
</>
) : restrictions.requiresFee && !feePaid && !restrictions.isPlatformOwner ? (
<>
<Zap className="h-5 w-5 mr-2" />
PAY FEE TO CONTINUE
</>
) : (
<>
<ShoppingBag className="h-5 w-5 mr-2" />
{listingType === 'wtb' ? 'POST WANT TO BUY' : 'LIST GEAR FOR SALE'}
</>
)}
</Button>
</form>
</div>
</>
);
}