- useDirectMessages: correct nip44 getConversationKey (Uint8Array) + finalizeEvent EventTemplate (remove pubkey/unused ephemeralPubkey/bytesToHex)
- Access current-user metadata via useCurrentUser() spread instead of user.metadata across ~10 files (rename to userMetadata where author metadata already bound)
- useCrewGoal: explicit UseQueryResult<CrewGoal|null> return type (fixes CrewForm/CrewTreasuryCard {} inference)
- New useCommentCounts hook; fix EventsPage/MapPage misuse of useComments(string[])
- CoachDetailPage: typed pget/pbool accessors for craft-specific union fields
- MapPage: shop entries include tags/category to satisfy SkateSpot
- SkateGlobe: read event.tags; LabPage: remove duplicate serviceType; LabCrewsStrip: add 'judging'
- Icon prop widened to accept style; ReportDialog subtype guard; SecuritySettings/TourCard/EventsPage comparisons; CashuWallet onClick; ConversationPage recipients; useLoggedInAccounts NLoginType import; LicensePage icon; TourDetailPage ZapButton target + real npub link; TestApp useSkateRelay
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
1086 lines
42 KiB
TypeScript
1086 lines
42 KiB
TypeScript
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<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)
|
|
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<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]);
|
|
|
|
// 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 (
|
|
<Layout>
|
|
<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>
|
|
</Layout>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Layout>
|
|
<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}` : '/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}` : 'Back to Map')}
|
|
</Link>
|
|
<h1
|
|
className="text-3xl font-black uppercase neon-orange"
|
|
style={{
|
|
fontFamily: 'Impact, "Arial Black", sans-serif',
|
|
}}
|
|
>
|
|
{listingType === 'wtb' ? 'WANT TO BUY' : 'LIST YOUR GEAR'}
|
|
</h1>
|
|
<p className="text-gray-400 mt-1">
|
|
{listingType === 'wtb'
|
|
? 'Tell the community what gear you\'re looking for'
|
|
: 'Sell skateboarding gear to the community'}
|
|
</p>
|
|
</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 && (
|
|
<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">NIP-05 Verification Required</h3>
|
|
<p className="text-sm text-gray-300 mb-3">
|
|
To prevent spam and protect buyers, you need a verified Nostr address (NIP-05) to list gear.
|
|
</p>
|
|
<Link to="/settings">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="border-red-500/50 hover:bg-red-500/10 text-red-400"
|
|
>
|
|
Add NIP-05 in Settings
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{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>
|
|
)}
|
|
|
|
{/* 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>
|
|
</Layout>
|
|
);
|
|
}
|