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>
1122 lines
48 KiB
TypeScript
1122 lines
48 KiB
TypeScript
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>
|
||
);
|
||
}
|