Lab: - useLabProfiles sorts featured (Pro) profiles first - ProBadge shown on Lab cards (LabPage) + Lab detail page; featured cards get a subtle amber highlight Events (same no-paywall model as Lab): - New FeaturedEventUpgrade: every event posts for free; optional fee adds a featured badge + top placement (reuses existing featuredEvent fee) - CreateEventPage: removed the paid-event fee gate; opt-in featured toggle, writes a `featured` tag when active - useSkateEvents sorts featured events first; EventCard shows ProBadge (compact in list view, overlay on the image in full view) Docs: - NIP.md documents the `featured` tag (unix-seconds expiry, self-attested) Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
929 lines
39 KiB
TypeScript
929 lines
39 KiB
TypeScript
import { useState } from 'react';
|
||
import { useSeoMeta } from '@unhead/react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import ngeohash from 'ngeohash';
|
||
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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||
import { useTours } from '@/hooks/useTours';
|
||
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
||
import { useToast } from '@/hooks/useToast';
|
||
import { useTranslation } from '@/contexts/LanguageContext';
|
||
import { LoginArea } from '@/components/auth/LoginArea';
|
||
import { CALENDAR_EVENT_KIND, EVENT_TYPE_LABELS, TOUR_CATEGORIES, SKATE_TOUR_KIND, SKATE_SPOT_KIND, SPOT_CATEGORIES, type SkateTour, type SkateSpot } from '@/lib/types';
|
||
import { cn } from '@/lib/utils';
|
||
import {
|
||
Calendar,
|
||
Upload,
|
||
X,
|
||
Loader2,
|
||
ArrowLeft,
|
||
MapPin,
|
||
Route,
|
||
ChevronDown,
|
||
Link2,
|
||
Skull,
|
||
Globe,
|
||
Map,
|
||
Zap,
|
||
} from 'lucide-react';
|
||
import { FeaturedEventUpgrade } from '@/components/FeaturedEventUpgrade';
|
||
import { usePlatformFee } from '@/hooks/usePlatformFee';
|
||
import { Link } from 'react-router-dom';
|
||
|
||
const EVENT_TYPES = Object.entries(EVENT_TYPE_LABELS);
|
||
|
||
export default function CreateEventPage() {
|
||
const { t } = useTranslation();
|
||
|
||
useSeoMeta({
|
||
title: 'Create a Skate Event - SkateSpots',
|
||
description: 'Create a new skateboarding event for the community.',
|
||
});
|
||
|
||
const navigate = useNavigate();
|
||
const { user } = useCurrentUser();
|
||
const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish();
|
||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||
const { toast } = useToast();
|
||
|
||
// Fetch available tours for linking
|
||
const { data: tours, isLoading: toursLoading } = useTours(100);
|
||
|
||
// Fetch available spots for linking
|
||
const { data: spots, isLoading: spotsLoading } = useSkateSpots(200);
|
||
|
||
// Form state
|
||
const [title, setTitle] = useState('');
|
||
const [description, setDescription] = useState('');
|
||
const [location, setLocation] = useState('');
|
||
const [latitude, setLatitude] = useState('');
|
||
const [longitude, setLongitude] = useState('');
|
||
const [startDate, setStartDate] = useState('');
|
||
const [startTime, setStartTime] = useState('');
|
||
const [endDate, setEndDate] = useState('');
|
||
const [endTime, setEndTime] = useState('');
|
||
const [selectedTypes, setSelectedTypes] = useState<string[]>([]);
|
||
const [image, setImage] = useState<string | null>(null);
|
||
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
||
|
||
// Tour linking state
|
||
const [linkedTourId, setLinkedTourId] = useState<string>('none');
|
||
const [tourLinkExpanded, setTourLinkExpanded] = useState(false);
|
||
|
||
// Spot linking state
|
||
const [linkedSpotId, setLinkedSpotId] = useState<string>('none');
|
||
const [showSpotSelector, setShowSpotSelector] = useState(false);
|
||
const [spotSearchQuery, setSpotSearchQuery] = useState('');
|
||
|
||
// Paid event / entry fee
|
||
const [entryFee, setEntryFee] = useState('');
|
||
const isPaidEvent = entryFee.trim().length > 0 && entryFee.trim() !== '0';
|
||
// Optional "Feature this event" upgrade — every event posts for free.
|
||
const { isActive: isFeatured, featuredUntilSeconds } = usePlatformFee('featuredEvent');
|
||
const [goFeatured, setGoFeatured] = useState(false);
|
||
|
||
// Get the selected tour (if any)
|
||
const selectedTour = tours?.find((t) => `${t.pubkey}:${t.identifier}` === linkedTourId);
|
||
|
||
// Get the selected spot (if any)
|
||
const selectedSpot = spots?.find((s) => `${s.pubkey}:${s.identifier}` === linkedSpotId);
|
||
|
||
// Filter spots by search query
|
||
const filteredSpots = spots?.filter((spot) => {
|
||
if (!spotSearchQuery) return true;
|
||
const query = spotSearchQuery.toLowerCase();
|
||
return (
|
||
spot.title.toLowerCase().includes(query) ||
|
||
spot.location?.toLowerCase().includes(query) ||
|
||
spot.category?.toLowerCase().includes(query)
|
||
);
|
||
});
|
||
|
||
// Group tours by category for easier selection
|
||
const toursByCategory = tours?.reduce((acc, tour) => {
|
||
const cat = tour.category || 'local';
|
||
if (!acc[cat]) acc[cat] = [];
|
||
acc[cat].push(tour);
|
||
return acc;
|
||
}, {} as Record<string, SkateTour[]>);
|
||
|
||
// Group spots by category for easier selection
|
||
const spotsByCategory = filteredSpots?.reduce((acc, spot) => {
|
||
const cat = spot.category || 'street';
|
||
if (!acc[cat]) acc[cat] = [];
|
||
acc[cat].push(spot);
|
||
return acc;
|
||
}, {} as Record<string, SkateSpot[]>);
|
||
|
||
const toggleType = (type: string) => {
|
||
setSelectedTypes((prev) =>
|
||
prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type]
|
||
);
|
||
};
|
||
|
||
const getCurrentLocation = () => {
|
||
if (!navigator.geolocation) {
|
||
toast({
|
||
title: 'Geolocation not supported',
|
||
description: 'Your browser does not support geolocation.',
|
||
variant: 'destructive',
|
||
});
|
||
return;
|
||
}
|
||
|
||
setIsGettingLocation(true);
|
||
navigator.geolocation.getCurrentPosition(
|
||
(position) => {
|
||
setLatitude(position.coords.latitude.toFixed(6));
|
||
setLongitude(position.coords.longitude.toFixed(6));
|
||
setIsGettingLocation(false);
|
||
toast({
|
||
title: 'Location found',
|
||
description: 'Your current location has been set.',
|
||
});
|
||
},
|
||
(error) => {
|
||
setIsGettingLocation(false);
|
||
toast({
|
||
title: 'Location error',
|
||
description: error.message,
|
||
variant: 'destructive',
|
||
});
|
||
}
|
||
);
|
||
};
|
||
|
||
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) {
|
||
setImage(url);
|
||
toast({
|
||
title: 'Image uploaded',
|
||
description: 'Your image has been uploaded successfully.',
|
||
});
|
||
}
|
||
} catch (error) {
|
||
toast({
|
||
title: 'Upload failed',
|
||
description: error instanceof Error ? error.message : 'Failed to upload image',
|
||
variant: 'destructive',
|
||
});
|
||
}
|
||
|
||
// Clear the input
|
||
e.target.value = '';
|
||
};
|
||
|
||
const removeImage = () => {
|
||
setImage(null);
|
||
};
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
|
||
if (!title.trim()) {
|
||
toast({
|
||
title: 'Title required',
|
||
description: 'Please enter a name for this event.',
|
||
variant: 'destructive',
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (!startDate || !startTime) {
|
||
toast({
|
||
title: 'Start time required',
|
||
description: 'Please enter when the event starts.',
|
||
variant: 'destructive',
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Parse start time
|
||
const startDateTime = new Date(`${startDate}T${startTime}`);
|
||
const startTimestamp = Math.floor(startDateTime.getTime() / 1000);
|
||
|
||
if (isNaN(startTimestamp)) {
|
||
toast({
|
||
title: 'Invalid date',
|
||
description: 'Please enter a valid start date and time.',
|
||
variant: 'destructive',
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Parse end time if provided
|
||
let endTimestamp: number | undefined;
|
||
if (endDate && endTime) {
|
||
const endDateTime = new Date(`${endDate}T${endTime}`);
|
||
endTimestamp = Math.floor(endDateTime.getTime() / 1000);
|
||
|
||
if (isNaN(endTimestamp) || endTimestamp <= startTimestamp) {
|
||
toast({
|
||
title: 'Invalid end time',
|
||
description: 'End time must be after start time.',
|
||
variant: 'destructive',
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
|
||
try {
|
||
// Create unique identifier
|
||
const identifier = title
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9]+/g, '-')
|
||
.replace(/^-|-$/g, '')
|
||
.slice(0, 50) + '-' + Date.now().toString(36);
|
||
|
||
// Build tags
|
||
const tags: string[][] = [
|
||
['d', identifier],
|
||
['title', title.trim()],
|
||
['start', startTimestamp.toString()],
|
||
['t', 'skateboarding'],
|
||
['alt', `Skate event: ${title.trim()}`],
|
||
];
|
||
|
||
if (endTimestamp) {
|
||
tags.push(['end', endTimestamp.toString()]);
|
||
}
|
||
|
||
if (location.trim()) {
|
||
tags.push(['location', location.trim()]);
|
||
}
|
||
|
||
// Add geohash if coordinates provided
|
||
if (latitude && longitude) {
|
||
const lat = parseFloat(latitude);
|
||
const lng = parseFloat(longitude);
|
||
if (!isNaN(lat) && !isNaN(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) {
|
||
const geohash = ngeohash.encode(lat, lng, 7);
|
||
tags.push(['g', geohash]);
|
||
}
|
||
}
|
||
|
||
for (const type of selectedTypes) {
|
||
tags.push(['t', type]);
|
||
}
|
||
|
||
if (image) {
|
||
tags.push(['image', image]);
|
||
}
|
||
|
||
// Add tour link if selected
|
||
if (selectedTour && linkedTourId !== 'none') {
|
||
// Use 'a' tag to reference the tour (addressable event reference)
|
||
tags.push(['a', `${SKATE_TOUR_KIND}:${selectedTour.pubkey}:${selectedTour.identifier}`]);
|
||
// Also add a readable tour-name tag for easier display
|
||
tags.push(['tour-name', selectedTour.title]);
|
||
// Add tour category tag for filtering
|
||
tags.push(['tour-category', selectedTour.category]);
|
||
}
|
||
|
||
// Entry fee tag
|
||
if (isPaidEvent) {
|
||
tags.push(['entry-fee', entryFee.trim()]);
|
||
tags.push(['t', 'paid']);
|
||
}
|
||
|
||
// Featured listing (optional, paid)
|
||
if (isFeatured && featuredUntilSeconds) {
|
||
tags.push(['featured', String(featuredUntilSeconds)]);
|
||
}
|
||
|
||
// Add spot link if selected
|
||
if (selectedSpot && linkedSpotId !== 'none') {
|
||
// Use 'a' tag to reference the spot (addressable event reference)
|
||
tags.push(['a', `${SKATE_SPOT_KIND}:${selectedSpot.pubkey}:${selectedSpot.identifier}`]);
|
||
// Also add a readable spot-name tag for easier display
|
||
tags.push(['spot-name', selectedSpot.title]);
|
||
// Add spot category tag for filtering
|
||
if (selectedSpot.category) {
|
||
tags.push(['spot-category', selectedSpot.category]);
|
||
}
|
||
}
|
||
|
||
await createEvent({
|
||
kind: CALENDAR_EVENT_KIND,
|
||
content: description.trim(),
|
||
tags,
|
||
});
|
||
|
||
toast({
|
||
title: 'Event created!',
|
||
description: 'Your event has been published to the network.',
|
||
});
|
||
|
||
navigate(`/event/${user?.pubkey}/${identifier}`);
|
||
} catch (error) {
|
||
toast({
|
||
title: 'Failed to publish',
|
||
description: error instanceof Error ? error.message : 'Something went wrong',
|
||
variant: 'destructive',
|
||
});
|
||
}
|
||
};
|
||
|
||
if (!user) {
|
||
return (
|
||
<Layout>
|
||
<div className="container px-4 py-8">
|
||
<Card className="max-w-xl mx-auto border-dashed">
|
||
<CardContent className="py-12 text-center">
|
||
<Calendar className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
|
||
<h2 className="text-xl font-bold mb-2">{t('errors.loginRequired')}</h2>
|
||
<p className="text-muted-foreground mb-6">
|
||
{t('createEvent.loginToCreate')}
|
||
</p>
|
||
<LoginArea className="justify-center" />
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</Layout>
|
||
);
|
||
}
|
||
|
||
const hasNip05 = user?.metadata?.nip05;
|
||
|
||
return (
|
||
<Layout>
|
||
<div className="container px-4 sm:px-6 py-6 sm:py-8 max-w-2xl">
|
||
<div className="mb-6">
|
||
<Link to="/events" className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-4">
|
||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||
{t('createEvent.backToEvents')}
|
||
</Link>
|
||
<h1 className="text-3xl font-black uppercase neon-red" style={{
|
||
fontFamily: 'Impact, "Arial Black", sans-serif'
|
||
}}>{t('createEvent.title')}</h1>
|
||
<p className="text-gray-400 mt-1">
|
||
{t('createEvent.subtitle')}
|
||
</p>
|
||
|
||
{!hasNip05 && (
|
||
<Card className="mt-4 bg-yellow-950/30 border-2 border-yellow-500/50">
|
||
<CardContent className="p-4">
|
||
<div className="flex items-start gap-3">
|
||
<div className="text-yellow-500">⚠️</div>
|
||
<div>
|
||
<h3 className="font-bold text-yellow-400 mb-1">{t('createEvent.nip05Recommended')}</h3>
|
||
<p className="text-sm text-gray-300 mb-2">
|
||
{t('createEvent.nip05Desc')}
|
||
</p>
|
||
<Link to="/settings">
|
||
<Button variant="outline" size="sm" className="border-yellow-500/50 hover:bg-yellow-500/10 text-yellow-400 font-bold">
|
||
{t('createEvent.addNip05')}
|
||
</Button>
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
|
||
<form onSubmit={handleSubmit}>
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>{t('events.eventDetails')}</CardTitle>
|
||
<CardDescription>
|
||
{t('createEvent.requiredFields')}
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-6">
|
||
{/* Title */}
|
||
<div className="space-y-2">
|
||
<Label htmlFor="title">{t('events.eventName')} *</Label>
|
||
<Input
|
||
id="title"
|
||
placeholder={t('createEvent.namePlaceholder')}
|
||
value={title}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
{/* Description */}
|
||
<div className="space-y-2">
|
||
<Label htmlFor="description">{t('spots.description')}</Label>
|
||
<Textarea
|
||
id="description"
|
||
placeholder={t('createEvent.descriptionPlaceholder')}
|
||
value={description}
|
||
onChange={(e) => setDescription(e.target.value)}
|
||
rows={4}
|
||
/>
|
||
</div>
|
||
|
||
{/* Start Date/Time */}
|
||
<div className="space-y-2">
|
||
<Label>{t('createEvent.startDateTime')} *</Label>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<Input
|
||
type="date"
|
||
value={startDate}
|
||
onChange={(e) => setStartDate(e.target.value)}
|
||
required
|
||
/>
|
||
<Input
|
||
type="time"
|
||
value={startTime}
|
||
onChange={(e) => setStartTime(e.target.value)}
|
||
required
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* End Date/Time */}
|
||
<div className="space-y-2">
|
||
<Label>{t('createEvent.endDateTime')}</Label>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<Input
|
||
type="date"
|
||
value={endDate}
|
||
onChange={(e) => setEndDate(e.target.value)}
|
||
/>
|
||
<Input
|
||
type="time"
|
||
value={endTime}
|
||
onChange={(e) => setEndTime(e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Location Address */}
|
||
<div className="space-y-2">
|
||
<Label htmlFor="location">{t('events.venue')}</Label>
|
||
<Input
|
||
id="location"
|
||
placeholder={t('createEvent.venuePlaceholder')}
|
||
value={location}
|
||
onChange={(e) => setLocation(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
{/* Coordinates (optional for events) */}
|
||
<div className="space-y-2">
|
||
<Label>{t('createEvent.coordinatesOptional')}</Label>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<Input
|
||
placeholder={t('createSpot.latitudePlaceholder')}
|
||
value={latitude}
|
||
onChange={(e) => setLatitude(e.target.value)}
|
||
/>
|
||
<Input
|
||
placeholder={t('createSpot.longitudePlaceholder')}
|
||
value={longitude}
|
||
onChange={(e) => setLongitude(e.target.value)}
|
||
/>
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={getCurrentLocation}
|
||
disabled={isGettingLocation}
|
||
className="mt-2"
|
||
>
|
||
{isGettingLocation ? (
|
||
<>
|
||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||
{t('createSpot.gettingLocation')}
|
||
</>
|
||
) : (
|
||
<>
|
||
<MapPin className="h-4 w-4 mr-2" />
|
||
{t('createSpot.useCurrentLocation')}
|
||
</>
|
||
)}
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Link to Spot from Map */}
|
||
<div className="space-y-3 p-4 rounded-lg border-2 border-[#ff0040]/30 bg-[#ff0040]/5">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<Map className="h-5 w-5 text-[#ff0040]" />
|
||
<div>
|
||
<Label className="text-base font-bold text-[#ff0040]">Link to a Skate Spot</Label>
|
||
<p className="text-xs text-gray-400">
|
||
{selectedSpot ? `Selected: ${selectedSpot.title}` : 'Select a spot from the map where this event takes place'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setShowSpotSelector(!showSpotSelector)}
|
||
className={cn(
|
||
'border-[#ff0040]/50',
|
||
selectedSpot
|
||
? 'bg-[#ff0040] text-white hover:bg-[#ff0040]/90'
|
||
: 'text-[#ff0040] hover:bg-[#ff0040]/10'
|
||
)}
|
||
>
|
||
{showSpotSelector ? 'Hide Spots' : selectedSpot ? 'Change Spot' : 'Select Spot'}
|
||
<ChevronDown className={cn('h-4 w-4 ml-1 transition-transform', showSpotSelector && 'rotate-180')} />
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Selected spot preview */}
|
||
{selectedSpot && !showSpotSelector && (
|
||
<div className="p-3 rounded-lg bg-black/40 border border-[#ff0040]/30">
|
||
<div className="flex items-start gap-3">
|
||
{selectedSpot.images && selectedSpot.images[0] ? (
|
||
<img
|
||
src={selectedSpot.images[0]}
|
||
alt={selectedSpot.title}
|
||
className="w-16 h-16 rounded-lg object-cover flex-shrink-0"
|
||
/>
|
||
) : (
|
||
<div className="w-16 h-16 rounded-lg bg-[#ff0040]/20 flex items-center justify-center flex-shrink-0">
|
||
<MapPin className="h-6 w-6 text-[#ff0040]" />
|
||
</div>
|
||
)}
|
||
<div className="flex-1 min-w-0">
|
||
{selectedSpot.category && SPOT_CATEGORIES[selectedSpot.category as keyof typeof SPOT_CATEGORIES] && (
|
||
<Badge variant="outline" className="mb-1 text-[10px] border-[#ff0040]/50 text-[#ff0040]">
|
||
{SPOT_CATEGORIES[selectedSpot.category as keyof typeof SPOT_CATEGORIES].emoji} {SPOT_CATEGORIES[selectedSpot.category as keyof typeof SPOT_CATEGORIES].label}
|
||
</Badge>
|
||
)}
|
||
<h4 className="font-bold text-white text-sm">{selectedSpot.title}</h4>
|
||
{selectedSpot.location && (
|
||
<p className="text-xs text-gray-400 flex items-center gap-1">
|
||
<MapPin className="h-3 w-3" />
|
||
{selectedSpot.location}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => {
|
||
setLinkedSpotId('none');
|
||
setLocation('');
|
||
setLatitude('');
|
||
setLongitude('');
|
||
}}
|
||
className="text-gray-400 hover:text-red-400 h-8 w-8 p-0"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Spot selector */}
|
||
{showSpotSelector && (
|
||
<div className="space-y-3">
|
||
{/* Search */}
|
||
<Input
|
||
placeholder="Search spots by name, location, or category..."
|
||
value={spotSearchQuery}
|
||
onChange={(e) => setSpotSearchQuery(e.target.value)}
|
||
className="bg-black border-[#ff0040]/30"
|
||
/>
|
||
|
||
{/* Spots list */}
|
||
<div className="max-h-64 overflow-y-auto rounded-lg border border-[#ff0040]/20 p-2 space-y-2">
|
||
{spotsLoading ? (
|
||
<div className="flex items-center justify-center py-4">
|
||
<Loader2 className="h-5 w-5 animate-spin text-[#ff0040]" />
|
||
<span className="ml-2 text-sm text-gray-400">Loading spots...</span>
|
||
</div>
|
||
) : filteredSpots && filteredSpots.length > 0 ? (
|
||
<>
|
||
{/* Group by category */}
|
||
{spotsByCategory && Object.entries(spotsByCategory).map(([category, categorySpots]) => {
|
||
const catInfo = SPOT_CATEGORIES[category as keyof typeof SPOT_CATEGORIES];
|
||
return (
|
||
<div key={category}>
|
||
<div className="px-2 py-1.5 text-xs font-bold text-gray-400 uppercase flex items-center gap-1 sticky top-0 bg-black/80 backdrop-blur-sm">
|
||
{catInfo?.emoji || '🛹'} {catInfo?.label || category} ({categorySpots.length})
|
||
</div>
|
||
{categorySpots.map((spot) => (
|
||
<button
|
||
key={spot.id}
|
||
type="button"
|
||
onClick={() => {
|
||
setLinkedSpotId(`${spot.pubkey}:${spot.identifier}`);
|
||
// Auto-fill location and coordinates from spot
|
||
if (spot.location) setLocation(spot.location);
|
||
if (spot.geohash) {
|
||
const coords = ngeohash.decode(spot.geohash);
|
||
setLatitude(coords.latitude.toFixed(6));
|
||
setLongitude(coords.longitude.toFixed(6));
|
||
}
|
||
setShowSpotSelector(false);
|
||
setSpotSearchQuery('');
|
||
}}
|
||
className={cn(
|
||
'w-full p-3 rounded-lg text-left transition-colors',
|
||
linkedSpotId === `${spot.pubkey}:${spot.identifier}`
|
||
? 'bg-[#ff0040]/20 border-2 border-[#ff0040]'
|
||
: 'bg-black/50 border border-gray-800 hover:border-[#ff0040]/50'
|
||
)}
|
||
>
|
||
<div className="flex items-start gap-3">
|
||
{spot.images && spot.images[0] ? (
|
||
<img
|
||
src={spot.images[0]}
|
||
alt={spot.title}
|
||
className="w-12 h-12 rounded-lg object-cover flex-shrink-0"
|
||
/>
|
||
) : (
|
||
<div className="w-12 h-12 rounded-lg bg-[#ff0040]/20 flex items-center justify-center flex-shrink-0">
|
||
<MapPin className="h-5 w-5 text-[#ff0040]" />
|
||
</div>
|
||
)}
|
||
<div className="flex-1 min-w-0">
|
||
<h4 className="font-bold text-white text-sm truncate">{spot.title}</h4>
|
||
{spot.location && (
|
||
<p className="text-xs text-gray-400 flex items-center gap-1 truncate">
|
||
<MapPin className="h-3 w-3 flex-shrink-0" />
|
||
<span className="truncate">{spot.location}</span>
|
||
</p>
|
||
)}
|
||
{spot.tags && spot.tags.length > 0 && (
|
||
<div className="flex gap-1 mt-1 flex-wrap">
|
||
{spot.tags.slice(0, 3).map((tag) => (
|
||
<Badge key={tag} variant="outline" className="text-[9px] px-1 py-0 border-gray-700">
|
||
{tag}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{linkedSpotId === `${spot.pubkey}:${spot.identifier}` && (
|
||
<div className="text-[#ff0040]">✓</div>
|
||
)}
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
})}
|
||
</>
|
||
) : (
|
||
<div className="text-center py-4">
|
||
<MapPin className="h-8 w-8 mx-auto mb-2 text-gray-600" />
|
||
<p className="text-sm text-gray-400">
|
||
{spotSearchQuery ? 'No spots match your search' : 'No spots available'}
|
||
</p>
|
||
<Link to="/create-spot" className="text-xs text-[#ff0040] hover:underline mt-1 block">
|
||
Add a new spot →
|
||
</Link>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Event Types */}
|
||
<div className="space-y-2">
|
||
<Label>{t('events.eventType')}</Label>
|
||
<div className="flex flex-wrap gap-2">
|
||
{EVENT_TYPES.map(([type, label]) => (
|
||
<Badge
|
||
key={type}
|
||
variant={selectedTypes.includes(type) ? 'default' : 'outline'}
|
||
className={cn(
|
||
'cursor-pointer transition-colors',
|
||
selectedTypes.includes(type)
|
||
? 'bg-primary hover:bg-primary/90'
|
||
: 'hover:bg-muted'
|
||
)}
|
||
onClick={() => toggleType(type)}
|
||
>
|
||
{label}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Cover Image */}
|
||
<div className="space-y-2">
|
||
<Label>{t('createEvent.coverImage')}</Label>
|
||
{image ? (
|
||
<div className="relative aspect-video rounded-lg overflow-hidden bg-muted max-w-xs">
|
||
<img src={image} alt="Event cover" className="w-full h-full object-cover" />
|
||
<button
|
||
type="button"
|
||
onClick={removeImage}
|
||
className="absolute top-2 right-2 p-1 rounded-full bg-background/80 hover:bg-destructive hover:text-destructive-foreground transition-colors"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<label className="block max-w-xs aspect-video rounded-lg border-2 border-dashed flex flex-col items-center justify-center cursor-pointer hover:bg-muted/50 transition-colors">
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={handleImageUpload}
|
||
disabled={isUploading}
|
||
/>
|
||
{isUploading ? (
|
||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||
) : (
|
||
<>
|
||
<Upload className="h-6 w-6 text-muted-foreground mb-1" />
|
||
<span className="text-sm text-muted-foreground">{t('createEvent.uploadCover')}</span>
|
||
</>
|
||
)}
|
||
</label>
|
||
)}
|
||
</div>
|
||
|
||
{/* Link to Tour (Optional) */}
|
||
<Collapsible open={tourLinkExpanded} onOpenChange={setTourLinkExpanded}>
|
||
<CollapsibleTrigger asChild>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
className={cn(
|
||
'w-full justify-between',
|
||
selectedTour ? 'border-cyan-500/50 bg-cyan-500/10' : ''
|
||
)}
|
||
>
|
||
<span className="flex items-center gap-2">
|
||
<Link2 className="h-4 w-4 text-cyan-400" />
|
||
<span>
|
||
{selectedTour
|
||
? `Linked to: ${selectedTour.title}`
|
||
: 'Link to a Tour (Optional)'}
|
||
</span>
|
||
</span>
|
||
<ChevronDown className={cn('h-4 w-4 transition-transform', tourLinkExpanded && 'rotate-180')} />
|
||
</Button>
|
||
</CollapsibleTrigger>
|
||
<CollapsibleContent className="pt-4 space-y-4">
|
||
<div className="p-4 rounded-lg bg-cyan-500/10 border border-cyan-500/20">
|
||
<p className="text-sm text-gray-300 mb-4">
|
||
Link this event to a tour to show it's part of a larger series. Great for national tours,
|
||
international adventures, or World Destruction Tour challenge days.
|
||
</p>
|
||
|
||
<div className="space-y-2">
|
||
<Label className="text-cyan-400">Select Tour</Label>
|
||
<Select value={linkedTourId} onValueChange={setLinkedTourId}>
|
||
<SelectTrigger className="border-cyan-500/30">
|
||
<SelectValue placeholder={toursLoading ? 'Loading tours...' : 'No tour linked'} />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="none">No tour linked</SelectItem>
|
||
|
||
{/* Group by category */}
|
||
{toursByCategory && Object.entries(toursByCategory).map(([category, categoryTours]) => {
|
||
const catInfo = TOUR_CATEGORIES[category as keyof typeof TOUR_CATEGORIES];
|
||
return (
|
||
<div key={category}>
|
||
<div className="px-2 py-1.5 text-xs font-bold text-gray-400 uppercase flex items-center gap-1">
|
||
{catInfo?.emoji || '🛹'} {catInfo?.label || category}
|
||
</div>
|
||
{categoryTours.map((tour) => (
|
||
<SelectItem
|
||
key={`${tour.pubkey}:${tour.identifier}`}
|
||
value={`${tour.pubkey}:${tour.identifier}`}
|
||
>
|
||
<span className="flex items-center gap-2">
|
||
{tour.category === 'destruction' ? (
|
||
<Skull className="h-3 w-3 text-red-400" />
|
||
) : tour.category === 'international' ? (
|
||
<Globe className="h-3 w-3 text-purple-400" />
|
||
) : (
|
||
<Route className="h-3 w-3 text-cyan-400" />
|
||
)}
|
||
{tour.title}
|
||
{tour.location && (
|
||
<span className="text-gray-500 text-xs">
|
||
({tour.location})
|
||
</span>
|
||
)}
|
||
</span>
|
||
</SelectItem>
|
||
))}
|
||
</div>
|
||
);
|
||
})}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{selectedTour && (
|
||
<div className="mt-4 p-3 rounded-lg bg-black/40 border border-cyan-500/30">
|
||
<div className="flex items-start gap-3">
|
||
{selectedTour.image ? (
|
||
<img
|
||
src={selectedTour.image}
|
||
alt={selectedTour.title}
|
||
className="w-16 h-16 rounded-lg object-cover flex-shrink-0"
|
||
/>
|
||
) : (
|
||
<div
|
||
className="w-16 h-16 rounded-lg flex items-center justify-center flex-shrink-0"
|
||
style={{ backgroundColor: TOUR_CATEGORIES[selectedTour.category]?.color + '30' }}
|
||
>
|
||
{selectedTour.category === 'destruction' ? (
|
||
<Skull className="h-6 w-6" style={{ color: TOUR_CATEGORIES[selectedTour.category]?.color }} />
|
||
) : (
|
||
<Route className="h-6 w-6" style={{ color: TOUR_CATEGORIES[selectedTour.category]?.color }} />
|
||
)}
|
||
</div>
|
||
)}
|
||
<div className="flex-1 min-w-0">
|
||
<Badge
|
||
variant="outline"
|
||
className="mb-1 text-[10px]"
|
||
style={{
|
||
borderColor: TOUR_CATEGORIES[selectedTour.category]?.color,
|
||
color: TOUR_CATEGORIES[selectedTour.category]?.color
|
||
}}
|
||
>
|
||
{TOUR_CATEGORIES[selectedTour.category]?.emoji} {TOUR_CATEGORIES[selectedTour.category]?.label}
|
||
</Badge>
|
||
<h4 className="font-bold text-white text-sm">{selectedTour.title}</h4>
|
||
{selectedTour.location && (
|
||
<p className="text-xs text-gray-400 flex items-center gap-1">
|
||
<MapPin className="h-3 w-3" />
|
||
{selectedTour.location}
|
||
</p>
|
||
)}
|
||
{selectedTour.description && (
|
||
<p className="text-xs text-gray-500 line-clamp-2 mt-1">
|
||
{selectedTour.description}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
|
||
{/* Entry fee (optional) */}
|
||
<div className="space-y-2">
|
||
<Label htmlFor="entryFee" className="flex items-center gap-2">
|
||
<Zap className="h-4 w-4 text-[#ff6600]" />
|
||
Entry Fee
|
||
<span className="text-xs text-gray-500 font-normal">(optional — e.g. "500 sats" or "£10")</span>
|
||
</Label>
|
||
<Input
|
||
id="entryFee"
|
||
placeholder="Leave blank for free events"
|
||
value={entryFee}
|
||
onChange={(e) => setEntryFee(e.target.value)}
|
||
className="bg-black/50 border-white/20"
|
||
/>
|
||
</div>
|
||
|
||
{/* Optional featured upgrade — every event posts for free */}
|
||
<FeaturedEventUpgrade enabled={goFeatured} onEnabledChange={setGoFeatured} />
|
||
|
||
{/* Submit */}
|
||
<Button
|
||
type="submit"
|
||
className="w-full"
|
||
disabled={isPublishing || isUploading}
|
||
>
|
||
{isPublishing ? (
|
||
<>
|
||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||
{t('createEvent.publishing')}
|
||
</>
|
||
) : (
|
||
<>
|
||
<Calendar className="h-4 w-4 mr-2" />
|
||
{isFeatured ? 'Publish Featured Event' : t('events.createEvent')}
|
||
</>
|
||
)}
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
</form>
|
||
</div>
|
||
</Layout>
|
||
);
|
||
}
|