SkateSpots/src/pages/CreateEventPage.tsx
shakespeare.diy d577c8e455 Prevent scheduling events in the past + full events-section audit
Audited the whole events flow end-to-end (create, hosting dashboard, discovery
grid, detail, RSVP, sponsors) and confirmed the wiring is coherent:
- Create → parse → display round-trips correctly for sponsors, tour/spot/crew/
  musician links, entry fee, and featured listings
- Sponsor parser safely skips shop-coordinate `sponsor` tags (no name at pos 2),
  so events and tour/shop sponsorships never collide
- Batch RSVP counts feed the grid; hosting dashboard + detail keep per-item counts

Fix surfaced by the audit: CreateEventPage let a host pick a start time in the
past, silently producing a dead event in the Past tab. Added a `min={today}` on
the date pickers and a submit guard (10-min grace) with a clear message.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-03 15:02:25 +07:00

1122 lines
48 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useMemo } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useNavigate, useSearchParams } 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, SKATE_CREW_KIND, SKATE_MUSICIAN_KIND, SPOT_CATEGORIES, EVENT_SPONSOR_TIERS, eventSponsorTags, type SkateTour, type SkateSpot, type EventSponsor } from '@/lib/types';
import { useCrew } from '@/hooks/useCrews';
import { useLabProfile } from '@/hooks/useLabProfiles';
import { Users } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Calendar,
Upload,
X,
Loader2,
ArrowLeft,
MapPin,
Route,
ChevronDown,
Link2,
Skull,
Globe,
Map,
Zap,
Music,
Sparkles,
Plus,
} 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 [searchParams] = useSearchParams();
// Optional crew host: `?crew=<pubkey>:<identifier>` — the event is hosted BY a crew.
const crewParam = searchParams.get('crew') ?? '';
const [crewHostPubkey, crewHostIdentifier] = crewParam.includes(':')
? crewParam.split(':')
: ['', ''];
const { data: hostCrew } = useCrew(crewHostIdentifier, crewHostPubkey);
// Optional featured musician: `?musician=<pubkey>:<identifier>` — a music act
// (Lab kind 35011) playing this event. Connects Music ↔ Events.
const musicianParam = searchParams.get('musician') ?? '';
const [musicianPubkey, musicianIdentifier] = musicianParam.includes(':')
? musicianParam.split(':')
: ['', ''];
const { data: musicAct } = useLabProfile(musicianIdentifier, musicianPubkey);
// Optional deep-link prefill: `?type=concert&title=...` — used by e.g. the
// "Book for a gig" action on a crew that has a band.
const prefillType = searchParams.get('type') ?? '';
const prefillTitle = searchParams.get('title') ?? '';
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 sponsors (logo strip on the event page).
const [sponsors, setSponsors] = useState<EventSponsor[]>([]);
const addSponsor = () =>
setSponsors((prev) => [...prev, { tier: 'partner', name: '', logo: '', url: '' }]);
const updateSponsor = (i: number, patch: Partial<EventSponsor>) =>
setSponsors((prev) => prev.map((s, idx) => (idx === i ? { ...s, ...patch } : s)));
const removeSponsor = (i: number) =>
setSponsors((prev) => prev.filter((_, idx) => idx !== i));
// 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[]>);
// Today (local) as YYYY-MM-DD, used as the min for the date pickers so hosts
// can't accidentally schedule an event in the past.
const todayStr = useMemo(() => {
const d = new Date();
const off = d.getTimezoneOffset();
return new Date(d.getTime() - off * 60_000).toISOString().split('T')[0];
}, []);
const toggleType = (type: string) => {
setSelectedTypes((prev) =>
prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type]
);
};
// Prefill from deep-link params once on mount (e.g. "Book for a gig").
useEffect(() => {
if (prefillType && EVENT_TYPE_LABELS[prefillType]) {
setSelectedTypes((prev) => (prev.includes(prefillType) ? prev : [...prev, prefillType]));
}
if (prefillTitle) {
setTitle((prev) => prev || prefillTitle);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
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;
}
// Guard against scheduling in the past (a 10-min grace window covers
// "starting right now" without rejecting it).
if (startTimestamp < Math.floor(Date.now() / 1000) - 600) {
toast({
title: 'That start time is in the past',
description: 'Pick a date and time in the future so skaters can RSVP and show up.',
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]);
}
}
// Crew host — link the event back to the crew coordinate so it shows on
// the crew page and on the event as "Hosted by [crew]".
if (hostCrew) {
tags.push(['a', `${SKATE_CREW_KIND}:${hostCrew.pubkey}:${hostCrew.identifier}`]);
tags.push(['crew-name', hostCrew.name]);
}
// Featured musician — link a music act (Lab kind 35011) to the event so it
// shows "🎸 Live: [act]" and surfaces on the musician's gig list.
if (musicAct && musicAct.profileType === 'musician') {
tags.push(['a', `${SKATE_MUSICIAN_KIND}:${musicAct.pubkey}:${musicAct.identifier}`]);
tags.push(['music-act', musicAct.title]);
}
// Sponsors — a logo strip on the event page.
const namedSponsors = sponsors.filter((s) => s.name.trim());
if (namedSponsors.length > 0) {
tags.push(...eventSponsorTags(namedSponsors));
}
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>
{hostCrew && (
<Card className="mt-4 border-2 border-teal-500/40 bg-teal-500/10">
<CardContent className="flex items-center gap-3 p-4">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-teal-500/20">
<Users className="h-5 w-5 text-teal-400" />
</div>
<div className="min-w-0">
<p className="text-sm font-bold text-teal-300">Hosting as {hostCrew.name}</p>
<p className="text-xs text-gray-400">
This event will show on your crew page and be tagged as crew-hosted.
</p>
</div>
</CardContent>
</Card>
)}
{musicAct && musicAct.profileType === 'musician' && (
<Card className="mt-4 border-2 border-fuchsia-500/40 bg-fuchsia-500/10">
<CardContent className="flex items-center gap-3 p-4">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-fuchsia-500/20">
<Music className="h-5 w-5 text-fuchsia-400" />
</div>
<div className="min-w-0">
<p className="text-sm font-bold text-fuchsia-300">Featuring {musicAct.title}</p>
<p className="text-xs text-gray-400">
This act will be tagged as live music and surfaced on their gig list.
</p>
</div>
</CardContent>
</Card>
)}
{!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}
min={todayStr}
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}
min={startDate || todayStr}
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>
{/* Sponsors (optional) */}
<div className="space-y-3 rounded-xl border border-white/10 bg-white/[0.02] p-4">
<div className="flex items-center justify-between gap-2">
<Label className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-[#ccff00]" />
Sponsors
<span className="text-xs font-normal text-gray-500">(optional)</span>
</Label>
<Button
type="button"
size="sm"
variant="outline"
onClick={addSponsor}
className="h-8 gap-1 border-[#ccff00]/30 text-[#ccff00] hover:bg-[#ccff00]/10"
>
<Plus className="h-3.5 w-3.5" />
Add
</Button>
</div>
{sponsors.length === 0 ? (
<p className="text-xs text-gray-500">
Got a shop or brand backing this? Add their logo — it shows as a "Presented by" strip on the event.
</p>
) : (
<div className="space-y-3">
{sponsors.map((s, i) => (
<div key={i} className="space-y-2 rounded-lg border border-white/10 bg-black/30 p-3">
<div className="flex items-center gap-2">
<select
value={s.tier}
onChange={(e) => updateSponsor(i, { tier: e.target.value as EventSponsor['tier'] })}
className="h-9 rounded-md border border-white/20 bg-black/50 px-2 text-sm text-white"
>
{Object.entries(EVENT_SPONSOR_TIERS).map(([key, { label }]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
<Input
placeholder="Sponsor name"
value={s.name}
onChange={(e) => updateSponsor(i, { name: e.target.value })}
className="flex-1 bg-black/50 border-white/20"
/>
<Button
type="button"
size="icon"
variant="ghost"
onClick={() => removeSponsor(i)}
className="h-9 w-9 flex-shrink-0 text-gray-500 hover:text-red-400"
aria-label="Remove sponsor"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<Input
placeholder="Logo URL (optional)"
value={s.logo ?? ''}
onChange={(e) => updateSponsor(i, { logo: e.target.value })}
className="bg-black/50 border-white/20 text-sm"
/>
<Input
placeholder="Website URL (optional)"
value={s.url ?? ''}
onChange={(e) => updateSponsor(i, { url: e.target.value })}
className="bg-black/50 border-white/20 text-sm"
/>
</div>
</div>
))}
</div>
)}
</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>
);
}