import { useState, useEffect, useMemo } from 'react'; import { useSeoMeta } from '@unhead/react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import ngeohash from 'ngeohash'; 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 { useSkateShops } from '@/hooks/useSkateShops'; import { useToast } from '@/hooks/useToast'; import { useTranslation } from '@/contexts/language'; import { LoginArea } from '@/components/auth/LoginArea'; import { CALENDAR_EVENT_KIND, EVENT_TYPE_LABELS, TOUR_CATEGORIES, SKATE_TOUR_KIND, SKATE_SPOT_KIND, SKATE_SHOP_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, Store, } 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); import { EVENT_TYPE_KEYS, SPOT_CATEGORY_KEYS, TOUR_CATEGORY_KEYS } from '@/lib/labelKeys'; function eventTypeLabel(t: (key: string) => string, type: string): string { return EVENT_TYPE_KEYS[type] ? t(EVENT_TYPE_KEYS[type]) : (EVENT_TYPE_LABELS[type] || type); } export default function CreateEventPage() { const { t } = useTranslation(); useSeoMeta({ title: t('seoUi.createEventTitle'), description: t('seoUi.createEventDesc'), }); const navigate = useNavigate(); const [searchParams] = useSearchParams(); // Optional crew host: `?crew=:` — 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=:` — 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, metadata } = 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); // Fetch available shops for linking (an event can be held at / hosted by a shop) const { data: shops, isLoading: shopsLoading } = useSkateShops(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([]); const [image, setImage] = useState(null); const [isGettingLocation, setIsGettingLocation] = useState(false); // Tour linking state const [linkedTourId, setLinkedTourId] = useState('none'); const [tourLinkExpanded, setTourLinkExpanded] = useState(false); // Spot linking state const [linkedSpotId, setLinkedSpotId] = useState('none'); const [showSpotSelector, setShowSpotSelector] = useState(false); const [spotSearchQuery, setSpotSearchQuery] = useState(''); // Optional shop link — an event can be held at / hosted by a skate shop. const [linkedShopId, setLinkedShopId] = useState('none'); const [showShopSelector, setShowShopSelector] = useState(false); const [shopSearchQuery, setShopSearchQuery] = useState(''); // Paid event / entry fee const [entryFee, setEntryFee] = useState(''); const isPaidEvent = entryFee.trim().length > 0 && entryFee.trim() !== '0'; // Optional crowdfunding goal in sats (NIP-57 zaps fund the event). const [fundingGoal, setFundingGoal] = useState(''); // Optional sponsors (logo strip on the event page). const [sponsors, setSponsors] = useState([]); const addSponsor = () => setSponsors((prev) => [...prev, { tier: 'partner', name: '', logo: '', url: '' }]); const updateSponsor = (i: number, patch: Partial) => 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); // Get the selected shop (if any) const selectedShop = shops?.find((s) => `${s.pubkey}:${s.identifier}` === linkedShopId); const filteredShops = shops?.filter((shop) => { if (!shopSearchQuery) return true; const q = shopSearchQuery.toLowerCase(); return shop.name.toLowerCase().includes(q) || (shop.location?.toLowerCase().includes(q) ?? false); }); // 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); // 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); // 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: t('placesUi.createEvent.geoNotSupported'), description: t('placesUi.createEvent.geoNotSupportedDesc'), 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: t('placesUi.createEvent.locationFound'), description: t('placesUi.createEvent.locationFoundDesc'), }); }, (error) => { setIsGettingLocation(false); toast({ title: t('placesUi.createEvent.locationError'), description: error.message, variant: 'destructive', }); } ); }; const handleImageUpload = async (e: React.ChangeEvent) => { 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: t('placesUi.createEvent.imageUploaded'), description: t('placesUi.createEvent.imageUploadedDesc'), }); } } catch (error) { toast({ title: t('placesUi.createEvent.uploadFailed'), description: error instanceof Error ? error.message : t('placesUi.createEvent.uploadFailedDesc'), 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: t('placesUi.createEvent.titleRequired'), description: t('placesUi.createEvent.titleRequiredDesc'), variant: 'destructive', }); return; } if (!startDate || !startTime) { toast({ title: t('placesUi.createEvent.startRequired'), description: t('placesUi.createEvent.startRequiredDesc'), 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: t('placesUi.createEvent.invalidDate'), description: t('placesUi.createEvent.invalidDateDesc'), 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: t('placesUi.createEvent.pastTitle'), description: t('placesUi.createEvent.pastDesc'), 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: t('placesUi.createEvent.invalidEnd'), description: t('placesUi.createEvent.invalidEndDesc'), 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']); } // Optional crowdfunding goal (sats) — shown as a funding progress bar and // funded peer-to-peer via zaps (no escrow). if (fundingGoal.trim() && Number(fundingGoal) > 0) { tags.push(['goal', String(Math.floor(Number(fundingGoal)))]); } // 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]); } } // Optional shop link (venue / host shop) — same addressable ref pattern. if (selectedShop && linkedShopId !== 'none') { tags.push(['a', `${SKATE_SHOP_KIND}:${selectedShop.pubkey}:${selectedShop.identifier}`]); tags.push(['venue-name', selectedShop.name]); } // 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: t('placesUi.createEvent.created'), description: t('placesUi.createEvent.createdDesc'), }); navigate(`/event/${user?.pubkey}/${identifier}`); } catch (error) { toast({ title: t('placesUi.createEvent.publishFailed'), description: error instanceof Error ? error.message : t('placesUi.createEvent.somethingWrong'), variant: 'destructive', }); } }; if (!user) { return ( <>

{t('errors.loginRequired')}

{t('createEvent.loginToCreate')}

); } const hasNip05 = metadata?.nip05; return ( <>
{t('createEvent.backToEvents')}

{t('createEvent.title')}

{t('createEvent.subtitle')}

{hostCrew && (

{t('placesUi.createEvent.hostingAs', { name: hostCrew.name })}

{t('placesUi.createEvent.hostingAsDesc')}

)} {musicAct && musicAct.profileType === 'musician' && (

{t('placesUi.createEvent.featuring', { name: musicAct.title })}

{t('placesUi.createEvent.featuringDesc')}

)} {!hasNip05 && (
⚠️

{t('createEvent.nip05Recommended')}

{t('createEvent.nip05Desc')}

)}
{t('events.eventDetails')} {t('createEvent.requiredFields')} {/* Title */}
setTitle(e.target.value)} required />
{/* Description */}