import { useState, useEffect, useRef } 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 { Slider } from '@/components/ui/slider'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useUploadFile } from '@/hooks/useUploadFile'; import { useShops } from '@/hooks/useShops'; import { sanitizeUrl } from '@/lib/sanitizeUrl'; import { useToast } from '@/hooks/useToast'; import { LoginArea } from '@/components/auth/LoginArea'; import { Nip05Required } from '@/components/Nip05Required'; import { FeaturedUpgrade } from '@/components/FeaturedEventUpgrade'; import { usePlatformFee } from '@/hooks/usePlatformFee'; import { SKATE_TOUR_KIND, TOUR_SKILL_LEVELS, TOUR_SPOT_TYPES, TOUR_CATEGORIES, type TourCategory, type DestructionChallenge, type SkateTour } from '@/lib/types'; import { Route, Upload, X, Loader2, ArrowLeft, MapPin, Clock, Users, Zap, Globe, Info, ChevronDown, Store, Percent, Plane, Car, Skull, Trophy, Target, Plus, Trash2, Calendar, EyeOff } from 'lucide-react'; import { Link } from 'react-router-dom'; import { cn } from '@/lib/utils'; // Common languages shown by default const COMMON_LANGUAGES = [ { code: 'en', label: 'English', flag: '๐Ÿ‡บ๐Ÿ‡ธ' }, { code: 'es', label: 'Spanish', flag: '๐Ÿ‡ช๐Ÿ‡ธ' }, { code: 'pt', label: 'Portuguese', flag: '๐Ÿ‡ง๐Ÿ‡ท' }, { code: 'fr', label: 'French', flag: '๐Ÿ‡ซ๐Ÿ‡ท' }, { code: 'de', label: 'German', flag: '๐Ÿ‡ฉ๐Ÿ‡ช' }, { code: 'it', label: 'Italian', flag: '๐Ÿ‡ฎ๐Ÿ‡น' }, { code: 'ja', label: 'Japanese', flag: '๐Ÿ‡ฏ๐Ÿ‡ต' }, { code: 'zh', label: 'Chinese', flag: '๐Ÿ‡จ๐Ÿ‡ณ' }, ]; // Extended language list (shown when expanded) const ALL_LANGUAGES = [ // Common (also in COMMON_LANGUAGES) { code: 'en', label: 'English', flag: '๐Ÿ‡บ๐Ÿ‡ธ' }, { code: 'es', label: 'Spanish', flag: '๐Ÿ‡ช๐Ÿ‡ธ' }, { code: 'pt', label: 'Portuguese', flag: '๐Ÿ‡ง๐Ÿ‡ท' }, { code: 'fr', label: 'French', flag: '๐Ÿ‡ซ๐Ÿ‡ท' }, { code: 'de', label: 'German', flag: '๐Ÿ‡ฉ๐Ÿ‡ช' }, { code: 'it', label: 'Italian', flag: '๐Ÿ‡ฎ๐Ÿ‡น' }, { code: 'ja', label: 'Japanese', flag: '๐Ÿ‡ฏ๐Ÿ‡ต' }, { code: 'zh', label: 'Chinese', flag: '๐Ÿ‡จ๐Ÿ‡ณ' }, // European { code: 'nl', label: 'Dutch', flag: '๐Ÿ‡ณ๐Ÿ‡ฑ' }, { code: 'pl', label: 'Polish', flag: '๐Ÿ‡ต๐Ÿ‡ฑ' }, { code: 'ru', label: 'Russian', flag: '๐Ÿ‡ท๐Ÿ‡บ' }, { code: 'uk', label: 'Ukrainian', flag: '๐Ÿ‡บ๐Ÿ‡ฆ' }, { code: 'cs', label: 'Czech', flag: '๐Ÿ‡จ๐Ÿ‡ฟ' }, { code: 'sk', label: 'Slovak', flag: '๐Ÿ‡ธ๐Ÿ‡ฐ' }, { code: 'hu', label: 'Hungarian', flag: '๐Ÿ‡ญ๐Ÿ‡บ' }, { code: 'ro', label: 'Romanian', flag: '๐Ÿ‡ท๐Ÿ‡ด' }, { code: 'bg', label: 'Bulgarian', flag: '๐Ÿ‡ง๐Ÿ‡ฌ' }, { code: 'hr', label: 'Croatian', flag: '๐Ÿ‡ญ๐Ÿ‡ท' }, { code: 'sr', label: 'Serbian', flag: '๐Ÿ‡ท๐Ÿ‡ธ' }, { code: 'sl', label: 'Slovenian', flag: '๐Ÿ‡ธ๐Ÿ‡ฎ' }, { code: 'el', label: 'Greek', flag: '๐Ÿ‡ฌ๐Ÿ‡ท' }, { code: 'tr', label: 'Turkish', flag: '๐Ÿ‡น๐Ÿ‡ท' }, { code: 'sv', label: 'Swedish', flag: '๐Ÿ‡ธ๐Ÿ‡ช' }, { code: 'no', label: 'Norwegian', flag: '๐Ÿ‡ณ๐Ÿ‡ด' }, { code: 'da', label: 'Danish', flag: '๐Ÿ‡ฉ๐Ÿ‡ฐ' }, { code: 'fi', label: 'Finnish', flag: '๐Ÿ‡ซ๐Ÿ‡ฎ' }, { code: 'is', label: 'Icelandic', flag: '๐Ÿ‡ฎ๐Ÿ‡ธ' }, { code: 'et', label: 'Estonian', flag: '๐Ÿ‡ช๐Ÿ‡ช' }, { code: 'lv', label: 'Latvian', flag: '๐Ÿ‡ฑ๐Ÿ‡ป' }, { code: 'lt', label: 'Lithuanian', flag: '๐Ÿ‡ฑ๐Ÿ‡น' }, { code: 'ca', label: 'Catalan', flag: '๐Ÿด๓ ฅ๓ ณ๓ ฃ๓ ด๓ ฟ' }, { code: 'eu', label: 'Basque', flag: '๐Ÿด' }, { code: 'gl', label: 'Galician', flag: '๐Ÿด' }, // Asian { code: 'ko', label: 'Korean', flag: '๐Ÿ‡ฐ๐Ÿ‡ท' }, { code: 'vi', label: 'Vietnamese', flag: '๐Ÿ‡ป๐Ÿ‡ณ' }, { code: 'th', label: 'Thai', flag: '๐Ÿ‡น๐Ÿ‡ญ' }, { code: 'id', label: 'Indonesian', flag: '๐Ÿ‡ฎ๐Ÿ‡ฉ' }, { code: 'ms', label: 'Malay', flag: '๐Ÿ‡ฒ๐Ÿ‡พ' }, { code: 'tl', label: 'Filipino', flag: '๐Ÿ‡ต๐Ÿ‡ญ' }, { code: 'hi', label: 'Hindi', flag: '๐Ÿ‡ฎ๐Ÿ‡ณ' }, { code: 'bn', label: 'Bengali', flag: '๐Ÿ‡ง๐Ÿ‡ฉ' }, { code: 'ta', label: 'Tamil', flag: '๐Ÿ‡ฎ๐Ÿ‡ณ' }, { code: 'te', label: 'Telugu', flag: '๐Ÿ‡ฎ๐Ÿ‡ณ' }, { code: 'mr', label: 'Marathi', flag: '๐Ÿ‡ฎ๐Ÿ‡ณ' }, { code: 'gu', label: 'Gujarati', flag: '๐Ÿ‡ฎ๐Ÿ‡ณ' }, { code: 'kn', label: 'Kannada', flag: '๐Ÿ‡ฎ๐Ÿ‡ณ' }, { code: 'ml', label: 'Malayalam', flag: '๐Ÿ‡ฎ๐Ÿ‡ณ' }, { code: 'pa', label: 'Punjabi', flag: '๐Ÿ‡ฎ๐Ÿ‡ณ' }, { code: 'ne', label: 'Nepali', flag: '๐Ÿ‡ณ๐Ÿ‡ต' }, { code: 'si', label: 'Sinhala', flag: '๐Ÿ‡ฑ๐Ÿ‡ฐ' }, { code: 'my', label: 'Burmese', flag: '๐Ÿ‡ฒ๐Ÿ‡ฒ' }, { code: 'km', label: 'Khmer', flag: '๐Ÿ‡ฐ๐Ÿ‡ญ' }, { code: 'lo', label: 'Lao', flag: '๐Ÿ‡ฑ๐Ÿ‡ฆ' }, { code: 'mn', label: 'Mongolian', flag: '๐Ÿ‡ฒ๐Ÿ‡ณ' }, // Middle Eastern { code: 'ar', label: 'Arabic', flag: '๐Ÿ‡ธ๐Ÿ‡ฆ' }, { code: 'he', label: 'Hebrew', flag: '๐Ÿ‡ฎ๐Ÿ‡ฑ' }, { code: 'fa', label: 'Persian', flag: '๐Ÿ‡ฎ๐Ÿ‡ท' }, { code: 'ur', label: 'Urdu', flag: '๐Ÿ‡ต๐Ÿ‡ฐ' }, // African { code: 'sw', label: 'Swahili', flag: '๐Ÿ‡ฐ๐Ÿ‡ช' }, { code: 'am', label: 'Amharic', flag: '๐Ÿ‡ช๐Ÿ‡น' }, { code: 'zu', label: 'Zulu', flag: '๐Ÿ‡ฟ๐Ÿ‡ฆ' }, { code: 'xh', label: 'Xhosa', flag: '๐Ÿ‡ฟ๐Ÿ‡ฆ' }, { code: 'af', label: 'Afrikaans', flag: '๐Ÿ‡ฟ๐Ÿ‡ฆ' }, { code: 'ha', label: 'Hausa', flag: '๐Ÿ‡ณ๐Ÿ‡ฌ' }, { code: 'yo', label: 'Yoruba', flag: '๐Ÿ‡ณ๐Ÿ‡ฌ' }, { code: 'ig', label: 'Igbo', flag: '๐Ÿ‡ณ๐Ÿ‡ฌ' }, // Americas { code: 'pt-BR', label: 'Brazilian Portuguese', flag: '๐Ÿ‡ง๐Ÿ‡ท' }, { code: 'es-MX', label: 'Mexican Spanish', flag: '๐Ÿ‡ฒ๐Ÿ‡ฝ' }, { code: 'es-AR', label: 'Argentine Spanish', flag: '๐Ÿ‡ฆ๐Ÿ‡ท' }, { code: 'qu', label: 'Quechua', flag: '๐Ÿ‡ต๐Ÿ‡ช' }, { code: 'gn', label: 'Guarani', flag: '๐Ÿ‡ต๐Ÿ‡พ' }, // Sign Languages { code: 'asl', label: 'ASL (American Sign)', flag: '๐Ÿ‡บ๐Ÿ‡ธ' }, { code: 'bsl', label: 'BSL (British Sign)', flag: '๐Ÿ‡ฌ๐Ÿ‡ง' }, { code: 'lsf', label: 'LSF (French Sign)', flag: '๐Ÿ‡ซ๐Ÿ‡ท' }, { code: 'dgs', label: 'DGS (German Sign)', flag: '๐Ÿ‡ฉ๐Ÿ‡ช' }, { code: 'jsl', label: 'JSL (Japanese Sign)', flag: '๐Ÿ‡ฏ๐Ÿ‡ต' }, { code: 'auslan', label: 'Auslan (Australian Sign)', flag: '๐Ÿ‡ฆ๐Ÿ‡บ' }, { code: 'lse', label: 'LSE (Spanish Sign)', flag: '๐Ÿ‡ช๐Ÿ‡ธ' }, { code: 'libras', label: 'Libras (Brazilian Sign)', flag: '๐Ÿ‡ง๐Ÿ‡ท' }, // Oceanian { code: 'mi', label: 'Mฤori', flag: '๐Ÿ‡ณ๐Ÿ‡ฟ' }, { code: 'haw', label: 'Hawaiian', flag: '๐Ÿ‡บ๐Ÿ‡ธ' }, { code: 'sm', label: 'Samoan', flag: '๐Ÿ‡ผ๐Ÿ‡ธ' }, { code: 'to', label: 'Tongan', flag: '๐Ÿ‡น๐Ÿ‡ด' }, { code: 'fj', label: 'Fijian', flag: '๐Ÿ‡ซ๐Ÿ‡ฏ' }, // Additional European { code: 'ga', label: 'Irish (Gaeilge)', flag: '๐Ÿ‡ฎ๐Ÿ‡ช' }, { code: 'cy', label: 'Welsh', flag: '๐Ÿด๓ ง๓ ข๓ ท๓ ฌ๓ ณ๓ ฟ' }, { code: 'gd', label: 'Scottish Gaelic', flag: '๐Ÿด๓ ง๓ ข๓ ณ๓ ฃ๓ ด๓ ฟ' }, { code: 'mt', label: 'Maltese', flag: '๐Ÿ‡ฒ๐Ÿ‡น' }, { code: 'sq', label: 'Albanian', flag: '๐Ÿ‡ฆ๐Ÿ‡ฑ' }, { code: 'mk', label: 'Macedonian', flag: '๐Ÿ‡ฒ๐Ÿ‡ฐ' }, { code: 'bs', label: 'Bosnian', flag: '๐Ÿ‡ง๐Ÿ‡ฆ' }, { code: 'be', label: 'Belarusian', flag: '๐Ÿ‡ง๐Ÿ‡พ' }, { code: 'ka', label: 'Georgian', flag: '๐Ÿ‡ฌ๐Ÿ‡ช' }, { code: 'hy', label: 'Armenian', flag: '๐Ÿ‡ฆ๐Ÿ‡ฒ' }, { code: 'az', label: 'Azerbaijani', flag: '๐Ÿ‡ฆ๐Ÿ‡ฟ' }, // Central Asian { code: 'kk', label: 'Kazakh', flag: '๐Ÿ‡ฐ๐Ÿ‡ฟ' }, { code: 'uz', label: 'Uzbek', flag: '๐Ÿ‡บ๐Ÿ‡ฟ' }, { code: 'tg', label: 'Tajik', flag: '๐Ÿ‡น๐Ÿ‡ฏ' }, { code: 'ky', label: 'Kyrgyz', flag: '๐Ÿ‡ฐ๐Ÿ‡ฌ' }, { code: 'tk', label: 'Turkmen', flag: '๐Ÿ‡น๐Ÿ‡ฒ' }, // Additional African { code: 'so', label: 'Somali', flag: '๐Ÿ‡ธ๐Ÿ‡ด' }, { code: 'rw', label: 'Kinyarwanda', flag: '๐Ÿ‡ท๐Ÿ‡ผ' }, { code: 'sn', label: 'Shona', flag: '๐Ÿ‡ฟ๐Ÿ‡ผ' }, { code: 'mg', label: 'Malagasy', flag: '๐Ÿ‡ฒ๐Ÿ‡ฌ' }, { code: 'wo', label: 'Wolof', flag: '๐Ÿ‡ธ๐Ÿ‡ณ' }, // Caribbean & Creoles { code: 'ht', label: 'Haitian Creole', flag: '๐Ÿ‡ญ๐Ÿ‡น' }, { code: 'pap', label: 'Papiamento', flag: '๐Ÿ‡จ๐Ÿ‡ผ' }, { code: 'jm', label: 'Jamaican Patois', flag: '๐Ÿ‡ฏ๐Ÿ‡ฒ' }, ]; export default function CreateTourPage({ existing }: { existing?: SkateTour } = {}) { const isEditing = !!existing; useSeoMeta({ title: isEditing ? 'Edit Tour - SkateSpots' : 'Create a Tour - SkateSpots', description: 'Organize skate tours - local hidden gem tours, national road trips, international adventures, or KOTR-style destruction competitions.', }); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const { user, metadata, isLoadingProfile } = useCurrentUser(); const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish(); // Optional "Feature this tour" upgrade โ€” every tour posts for free. const { isActive: isFeatured, featuredUntilSeconds } = usePlatformFee('featuredTour'); const [goFeatured, setGoFeatured] = useState(false); const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); const { toast } = useToast(); // Tour category state // Convert a unix-seconds timestamp to a value a datetime-local input accepts (YYYY-MM-DDTHH:mm). const toLocalInput = (secs?: number) => { if (!secs) return ''; const d = new Date(secs * 1000); // Adjust for the local timezone offset so the picker shows the same wall time. const off = d.getTimezoneOffset() * 60000; return new Date(d.getTime() - off).toISOString().slice(0, 16); }; // Convert a unix-seconds timestamp to a value a date input accepts (YYYY-MM-DD). const toDateInput = (secs?: number) => { if (!secs) return ''; const d = new Date(secs * 1000); const off = d.getTimezoneOffset() * 60000; return new Date(d.getTime() - off).toISOString().slice(0, 10); }; // Parse a date OR datetime-local input string back to unix seconds (local time respected). const inputToSecs = (val: string): number | undefined => { if (!val) return undefined; const ms = new Date(val).getTime(); if (Number.isNaN(ms)) return undefined; return Math.floor(ms / 1000); }; const [tourCategory, setTourCategory] = useState(existing?.category ?? 'local'); // Form state โ€” prefilled from `existing` when editing. const [title, setTitle] = useState(existing?.title ?? ''); const [description, setDescription] = useState(existing?.description ?? ''); const [location, setLocation] = useState(existing?.location ?? ''); // City/region const [meetingPoint, setMeetingPoint] = useState(existing?.meetingPoint ?? ''); const [meetingLat, setMeetingLat] = useState(existing?.meetingPointCoords ? String(existing.meetingPointCoords.lat) : ''); const [meetingLng, setMeetingLng] = useState(existing?.meetingPointCoords ? String(existing.meetingPointCoords.lon) : ''); const [duration, setDuration] = useState([existing?.duration ?? 3]); // Hours/Days depending on category const [durationUnit, setDurationUnit] = useState<'hours' | 'days' | 'weeks'>(existing?.durationUnit ?? 'hours'); const [skillLevel, setSkillLevel] = useState(existing?.skillLevel ?? 'all'); const [spotTypes, setSpotTypes] = useState(existing?.spotTypes?.length ? existing.spotTypes : ['street']); const [maxGroupSize, setMaxGroupSize] = useState([existing?.maxGroupSize ?? 4]); const [price, setPrice] = useState(existing?.price != null ? String(existing.price) : ''); const [availability, setAvailability] = useState(existing?.availability ?? ''); const [languages, setLanguages] = useState(existing?.languages?.length ? existing.languages : ['en']); const [languagesExpanded, setLanguagesExpanded] = useState(false); const [image, setImage] = useState(existing?.image ?? null); const [isGettingLocation, setIsGettingLocation] = useState(false); // Partner shop state const [partnerShopId, setPartnerShopId] = useState(() => { // shopRef is stored as "30017:pubkey:identifier"; the picker keys on "pubkey:identifier". const parts = existing?.shopRef?.split(':'); return parts && parts.length >= 3 ? `${parts[1]}:${parts[2]}` : ''; }); const [shopDiscount, setShopDiscount] = useState([existing?.shopDiscount ?? 10]); // Default 10% const [shopExpanded, setShopExpanded] = useState(!!existing?.shopRef); // National/International specific const [cities, setCities] = useState(existing?.cities ?? []); const [newCity, setNewCity] = useState(''); const [countries, setCountries] = useState(existing?.countries ?? []); const [newCountry, setNewCountry] = useState(''); const [transportInfo, setTransportInfo] = useState(existing?.transportInfo ?? ''); const [accommodationInfo, setAccommodationInfo] = useState(existing?.accommodationInfo ?? ''); // ============ SCHEDULE (all categories) ============ // Local tours: a specific session date+time (datetime-local). Optional โ€” a guide // may run an ongoing, book-anytime tour instead of a fixed session. const [sessionDateTime, setSessionDateTime] = useState(toLocalInput(existing?.startDate)); // Whether the local tour has a fixed scheduled session (vs. flexible/by-appointment). const [hasFixedSession, setHasFixedSession] = useState( existing?.category === 'local' ? !!existing?.startDate : false ); // World Destruction Tour + National/International trip window (date only) const [startDate, setStartDate] = useState(toDateInput(existing?.startDate)); const [endDate, setEndDate] = useState(toDateInput(existing?.endDate)); const [entryFee, setEntryFee] = useState(existing?.entryFee != null ? String(existing.entryFee) : ''); const [prizePool, setPrizePool] = useState(existing?.prizePool != null ? String(existing.prizePool) : ''); const [prizeDistribution, setPrizeDistribution] = useState( existing?.prizeDistribution?.length ? existing.prizeDistribution : [ { place: 1, percentage: 50 }, { place: 2, percentage: 30 }, { place: 3, percentage: 20 }, ] ); const [maxTeams, setMaxTeams] = useState([existing?.maxTeams ?? 4]); const [minTeamSize, setMinTeamSize] = useState([existing?.minTeamSize ?? 4]); const [maxTeamSize, setMaxTeamSize] = useState([existing?.maxTeamSize ?? 6]); const [challenges, setChallenges] = useState(existing?.challenges ?? []); const [acceptedClipSources, setAcceptedClipSources] = useState( existing?.acceptedClipSources?.length ? existing.acceptedClipSources : ['blossom', 'youtube', 'instagram'] ); const [organizerContact, setOrganizerContact] = useState(existing?.organizerContact ?? ''); // Organizer contact details // Film release options for World Destruction Tours const [enableFilmRelease, setEnableFilmRelease] = useState(!!existing?.filmRelease); const [filmReleaseAtShops, setFilmReleaseAtShops] = useState(existing?.filmRelease?.atShops ?? true); // Release at partner skate shops const [filmReleaseOnline, setFilmReleaseOnline] = useState(existing?.filmRelease?.online ?? true); // Also release online const [filmReleaseDate, setFilmReleaseDate] = useState(toDateInput(existing?.filmRelease?.releaseDate)); // Optional specific date for film release const [showAvailableShops, setShowAvailableShops] = useState(false); // Toggle to show available shops for premieres // Fetch shops for partner selection const { data: shops, isLoading: shopsLoading } = useShops(100); // When editing, keep the tour's saved duration on first render instead of // resetting it to the category default (the effect below fires on mount). const skipDurationReset = useRef(isEditing); // Update duration unit based on category (skips the initial edit-mode render). useEffect(() => { if (skipDurationReset.current) { skipDurationReset.current = false; return; } if (tourCategory === 'local') { setDurationUnit('hours'); setDuration([3]); } else if (tourCategory === 'national') { setDurationUnit('days'); setDuration([5]); } else if (tourCategory === 'international') { setDurationUnit('weeks'); setDuration([4]); // Default 4 weeks / 1 month } else if (tourCategory === 'destruction') { // World Destruction uses months (stored as 'months' but we'll use weeks * 4) setDurationUnit('weeks'); // We'll display as months setDuration([4]); // 1 month = 4 weeks } }, [tourCategory]); // Challenge management - all challenges are secret until competition start date const addChallenge = () => { const newChallenge: DestructionChallenge = { id: `ch-${Date.now()}`, title: '', description: '', points: 100, isSecret: true, // Always secret until start date }; setChallenges([...challenges, newChallenge]); }; const updateChallenge = (index: number, updates: Partial) => { const updated = [...challenges]; updated[index] = { ...updated[index], ...updates }; setChallenges(updated); }; const removeChallenge = (index: number) => { setChallenges(challenges.filter((_, i) => i !== index)); }; // City/Country management for multi-city tours const addCity = () => { if (newCity.trim() && !cities.includes(newCity.trim())) { setCities([...cities, newCity.trim()]); setNewCity(''); } }; const removeCity = (city: string) => { setCities(cities.filter(c => c !== city)); }; const addCountry = () => { if (newCountry.trim() && !countries.includes(newCountry.trim())) { setCountries([...countries, newCountry.trim()]); setNewCountry(''); } }; const removeCountry = (country: string) => { setCountries(countries.filter(c => c !== country)); }; // Pre-fill coordinates from URL params (from map click) useEffect(() => { const lat = searchParams.get('lat'); const lng = searchParams.get('lng'); if (lat && lng) { setMeetingLat(lat); setMeetingLng(lng); } }, [searchParams]); 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) => { setMeetingLat(position.coords.latitude.toFixed(6)); setMeetingLng(position.coords.longitude.toFixed(6)); setIsGettingLocation(false); toast({ title: 'Location found', description: 'Your current location has been set as meeting point.', }); }, (error) => { setIsGettingLocation(false); toast({ title: 'Location error', 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: 'Image uploaded', description: 'Your tour image has been uploaded.', }); } } catch (error) { toast({ title: 'Upload failed', description: error instanceof Error ? error.message : 'Failed to upload image', variant: 'destructive', }); } e.target.value = ''; }; const toggleSpotType = (type: string) => { setSpotTypes((prev) => prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type] ); }; const toggleLanguage = (code: string) => { setLanguages((prev) => prev.includes(code) ? prev.filter((l) => l !== code) : [...prev, code] ); }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!title.trim()) { toast({ title: 'Tour title required', description: 'Please enter a name for your tour.', variant: 'destructive', }); return; } // Location required only for local/national tours if ((tourCategory === 'local' || tourCategory === 'national') && !location.trim()) { toast({ title: 'City/region required', description: 'Please enter your city or region.', variant: 'destructive', }); return; } // Meeting point required only for local/national tours if ((tourCategory === 'local' || tourCategory === 'national') && !meetingPoint.trim()) { toast({ title: 'Meeting point required', description: 'Please enter where you will meet tour participants.', variant: 'destructive', }); return; } // Coordinates are optional. If one is provided, both must be provided and valid. let lat = 0; let lng = 0; let hasCoords = false; if ((tourCategory === 'local' || tourCategory === 'national') && (meetingLat || meetingLng)) { if (!meetingLat || !meetingLng) { toast({ title: 'Incomplete coordinates', description: 'Please enter both latitude and longitude, or leave both empty.', variant: 'destructive', }); return; } lat = parseFloat(meetingLat); lng = parseFloat(meetingLng); if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) { toast({ title: 'Invalid coordinates', description: 'Please enter valid latitude (-90 to 90) and longitude (-180 to 180).', variant: 'destructive', }); return; } hasCoords = true; } // Spot types required only for non-destruction tours if (tourCategory !== 'destruction' && spotTypes.length === 0) { toast({ title: 'Spot types required', description: 'Please select at least one type of spot you will show.', variant: 'destructive', }); return; } // International tour requires at least one country if (tourCategory === 'international' && countries.length === 0) { toast({ title: 'Countries required', description: 'Please add at least one country for your international tour.', variant: 'destructive', }); return; } // ---- Schedule validation (consistent across categories) ---- // Local: if the guide flagged a fixed session, a date/time is mandatory. if (tourCategory === 'local' && hasFixedSession && !sessionDateTime) { toast({ title: 'Session date required', description: 'Pick the date and time for your scheduled session, or switch to a flexible schedule.', variant: 'destructive', }); return; } // National & International: a trip needs a start date so skaters can plan/book travel. if (tourCategory === 'national' || tourCategory === 'international') { if (!startDate) { toast({ title: 'Departure date required', description: 'Set when the trip departs so skaters can plan and book.', variant: 'destructive', }); return; } if (endDate && inputToSecs(endDate)! < inputToSecs(startDate)!) { toast({ title: 'Return before departure', description: 'The return date must be on or after the departure date.', variant: 'destructive', }); return; } } // Destruction tour validations if (tourCategory === 'destruction') { if (!startDate) { toast({ title: 'Start date required', description: 'Please set when the competition starts (challenges will be revealed on this date).', variant: 'destructive', }); return; } if (endDate && inputToSecs(endDate)! < inputToSecs(startDate)!) { toast({ title: 'End before start', description: 'The competition end date must be on or after the start date.', variant: 'destructive', }); return; } if (challenges.length === 0) { toast({ title: 'Challenges required', description: 'Please add at least one challenge for teams to complete.', variant: 'destructive', }); return; } } try { // Generate low-precision geohash for city-level location (4 chars = ~39km) const geohash = hasCoords ? ngeohash.encode(lat, lng, 4) : ''; // Reuse the identifier when editing (keeps the same addressable event & // URL); otherwise mint a fresh one. const identifier = existing?.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()], ['t', 'skate-tour'], // Primary identifier ['tour-category', tourCategory], // Tour category ]; // Location/geohash only for local/national tours if (tourCategory === 'local' || tourCategory === 'national') { tags.push(['location', location.trim()]); tags.push(['meeting-point', meetingPoint.trim()]); // Coordinates are optional - only include when provided if (hasCoords) { tags.push(['g', geohash]); tags.push(['meeting-point-coords', lat.toString(), lng.toString()]); } } // Duration - store in appropriate unit const durationVal = tourCategory === 'destruction' ? duration[0].toString() // weeks (displayed as months) : duration[0].toString(); const durationUnitVal = tourCategory === 'destruction' ? 'weeks' : durationUnit; tags.push(['duration', durationVal, durationUnitVal]); // Skill level - destruction tours are always advanced if (tourCategory === 'destruction') { tags.push(['skill-level', 'advanced']); } else { tags.push(['skill-level', skillLevel]); } // Max group size (not for destruction tours) if (tourCategory !== 'destruction') { tags.push(['max-group', maxGroupSize[0].toString()]); } // Add spot type tags - destruction tours include ALL spot types automatically if (tourCategory === 'destruction') { Object.keys(TOUR_SPOT_TYPES).forEach((type) => { tags.push(['t', type]); }); } else { spotTypes.forEach((type) => { tags.push(['t', type]); }); } // Add optional tags if (price && tourCategory !== 'destruction') { tags.push(['price', price]); } // Availability only for local tours running a flexible (by-appointment) schedule. if (availability.trim() && tourCategory === 'local' && !hasFixedSession) { tags.push(['available', availability.trim()]); } if (languages.length > 0) { tags.push(['languages', ...languages]); } if (image) { tags.push(['image', image]); } // Partner shop discount if (partnerShopId) { const selectedShop = shops?.find(s => `${s.pubkey}:${s.identifier}` === partnerShopId); if (selectedShop) { // Store reference as naddr-like format tags.push(['shop', `30017:${selectedShop.pubkey}:${selectedShop.identifier}`]); tags.push(['shop-name', selectedShop.name]); tags.push(['shop-discount', shopDiscount[0].toString()]); } } // ============ SCHEDULE TAGS (local / national / international) ============ // Destruction dates are written in its own block below. For the other three // categories we store the schedule with the same `start-date` / `end-date` // tags so every tour surfaces a real date + an "Add to calendar" action. if (tourCategory === 'local' && hasFixedSession) { const sessionSecs = inputToSecs(sessionDateTime); if (sessionSecs) tags.push(['start-date', sessionSecs.toString()]); } if (tourCategory === 'national' || tourCategory === 'international') { const startSecs = inputToSecs(startDate); const endSecs = inputToSecs(endDate); if (startSecs) tags.push(['start-date', startSecs.toString()]); if (endSecs) tags.push(['end-date', endSecs.toString()]); } // ============ NATIONAL/INTERNATIONAL SPECIFIC TAGS ============ if (tourCategory === 'national' || tourCategory === 'international') { if (cities.length > 0) { tags.push(['cities', ...cities]); } if (tourCategory === 'international' && countries.length > 0) { tags.push(['countries', ...countries]); } if (transportInfo.trim()) { tags.push(['transport', transportInfo.trim()]); } if (accommodationInfo.trim()) { tags.push(['accommodation', accommodationInfo.trim()]); } } // ============ WORLD DESTRUCTION TOUR SPECIFIC TAGS ============ if (tourCategory === 'destruction') { // Cities and countries for the competition if (cities.length > 0) { tags.push(['cities', ...cities]); } if (countries.length > 0) { tags.push(['countries', ...countries]); } // Organizer contact if (organizerContact.trim()) { tags.push(['organizer-contact', organizerContact.trim()]); } // Transport info if (transportInfo.trim()) { tags.push(['transport', transportInfo.trim()]); } // Competition dates - challenges are revealed on start date const startSecs = inputToSecs(startDate); const startTimestamp = startSecs ? startSecs.toString() : ''; if (startTimestamp) { tags.push(['start-date', startTimestamp]); } const endSecs = inputToSecs(endDate); if (endSecs) { tags.push(['end-date', endSecs.toString()]); } // Entry fee if (entryFee) { tags.push(['entry-fee', entryFee]); } // Prize pool and distribution if (prizePool) { tags.push(['prize-pool', prizePool]); // Format: ["prize-dist", "1", "50", "2", "30", "3", "20"] const distParts = prizeDistribution.flatMap(p => [p.place.toString(), p.percentage.toString()]); tags.push(['prize-dist', ...distParts]); } // Team settings tags.push(['max-teams', maxTeams[0].toString()]); tags.push(['min-team-size', minTeamSize[0].toString()]); tags.push(['max-team-size', maxTeamSize[0].toString()]); // Challenges - format: ["challenge", "id", "title", "description", "points", "revealDate"] // All challenges are secret and revealed on start date challenges.forEach((ch) => { tags.push(['challenge', ch.id, ch.title, ch.description || '', ch.points.toString(), startTimestamp]); }); // Accepted clip sources if (acceptedClipSources.length > 0) { tags.push(['clip-sources', ...acceptedClipSources]); } // Film release options if (enableFilmRelease) { const releaseOptions: string[] = []; if (filmReleaseAtShops) releaseOptions.push('shops'); if (filmReleaseOnline) releaseOptions.push('online'); if (releaseOptions.length > 0) { tags.push(['film-release', ...releaseOptions]); } const filmSecs = inputToSecs(filmReleaseDate); if (filmSecs) { tags.push(['film-release-date', filmSecs.toString()]); } } } // Platform fee - varies by tour category // Local: 10%, National: 7%, International: 5%, World Destruction: 5% const platformFees: Record = { local: '10', national: '7', international: '5', destruction: '5', }; tags.push(['zap-split', 'skatespots@minibits.cash', platformFees[tourCategory]]); // NIP-31 alt tag const locationText = tourCategory === 'destruction' || tourCategory === 'international' ? (countries.length > 0 ? countries.join(', ') : cities.join(', ')) : location.trim(); const altText = tourCategory === 'destruction' ? `World Destruction Tour: ${title.trim()} - ${locationText}` : `Skate Tour: ${title.trim()} in ${locationText}`; tags.push(['alt', altText]); // Featured listing (optional, paid). When editing, keep whatever featured // window the tour already has so an edit never silently drops paid status. const existingFeaturedTag = existing?.event.tags.find(([n]) => n === 'featured'); if (isFeatured && featuredUntilSeconds) { tags.push(['featured', String(featuredUntilSeconds)]); } else if (isEditing && existingFeaturedTag) { tags.push(existingFeaturedTag); } // Preserve any tag this form doesn't manage (e.g. sponsors, or tags set by // other clients) so an edit never strips them โ€” kind 30823 is addressable, // so re-publishing REPLACES the whole tag set. if (isEditing && existing) { const managed = new Set([ 'd', 'title', 't', 'tour-category', 'location', 'meeting-point', 'meeting-point-coords', 'g', 'duration', 'skill-level', 'max-group', 'price', 'available', 'languages', 'image', 'shop', 'shop-name', 'shop-discount', 'cities', 'countries', 'transport', 'accommodation', 'organizer-contact', 'start-date', 'end-date', 'entry-fee', 'prize-pool', 'prize-dist', 'max-teams', 'min-team-size', 'max-team-size', 'challenge', 'clip-sources', 'film-release', 'film-release-date', 'zap-split', 'alt', 'featured', ]); for (const tag of existing.event.tags) { if (!managed.has(tag[0])) tags.push(tag); } } await createEvent({ kind: SKATE_TOUR_KIND, content: description.trim(), tags, }); if (isEditing) { toast({ title: 'Tour updated!', description: 'Your changes have been published.' }); } else { const toastMessages = { local: { title: '๐Ÿ  Local Tour Created!', description: 'Your tour is now visible. Skaters can book via DM!' }, national: { title: '๐Ÿš National Tour Created!', description: 'Your road trip is live! Start coordinating with interested skaters.' }, international: { title: 'โœˆ๏ธ International Tour Created!', description: 'Your global adventure is listed! Connect with skaters worldwide.' }, destruction: { title: '๐Ÿ’€ WORLD DESTRUCTION TOUR CREATED!', description: 'Teams can register. Challenges revealed on start date!' }, }; toast(toastMessages[tourCategory]); } navigate(`/tour/${user?.pubkey}/${identifier}`); } catch (error) { console.error('Failed to create tour:', error); toast({ title: 'Failed to publish', description: error instanceof Error ? error.message : 'Something went wrong.', variant: 'destructive', }); } }; if (!user) { return (

Login Required

You need to log in to create a tour.

); } // NIP-05 verification required for tour creation (spam prevention) // Note: metadata comes from useAuthor which loads asynchronously const hasNip05 = !!metadata?.nip05; // Show loading state while profile is being fetched if (isLoadingProfile) { return (

Loading Profile...

Checking your NIP-05 verification status.

); } // Require NIP-05 for tour creation if (!hasNip05) { return (
); } return (
{/* Immersive header โ€” layered, category-tinted, skate-punk */}
Back to Map
{/* Diagonal grunge stripes */}
{TOUR_CATEGORIES[tourCategory].emoji}

{isEditing ? 'Edit' : 'New'} ยท {TOUR_CATEGORIES[tourCategory].label}

{isEditing ? 'Edit Tour' : 'Drop a Tour'}

{TOUR_CATEGORIES[tourCategory].description}

{/* Tour Category Selection */}
{Object.entries(TOUR_CATEGORIES).map(([key, cat]) => ( ))}
{/* Info banner - changes based on category */}
{tourCategory === 'local' && ( <>

How Local Tours Work

  • โ€ข You set your price โ€” platform takes 10%
  • โ€ข Hidden spots stay hidden โ€” you show them in person
  • โ€ข Run a scheduled session or keep it flexible / by-appointment
  • โ€ข Skaters book via DM, pay in Lightning & can add it to their calendar
  • โ€ข Build your rep with reviews and zaps
)} {tourCategory === 'national' && ( <>

How National Tours Work

  • โ€ข Multi-city road trips across your country
  • โ€ข You set the price โ€” platform takes only 7%
  • โ€ข Set trip dates so skaters can plan time off & travel
  • โ€ข Include transport / accommodation info for the crew
  • โ€ข Build legendary skate adventures!
)} {tourCategory === 'international' && ( <>

How International Tours Work

  • โ€ข Cross-border adventures across multiple countries
  • โ€ข You set the price โ€” platform takes only 5%
  • โ€ข Set departure & return dates so the crew can book flights
  • โ€ข Connect skaters from around the world
  • โ€ข Create unforgettable global experiences!
)} {tourCategory === 'destruction' && ( <>

๐Ÿ’€ WORLD DESTRUCTION TOUR - KOTR Style

  • โ€ข Global team competition - up to 12 months long
  • โ€ข YOU define challenges, points, prizes - we just provide the platform
  • โ€ข All challenges are SECRET until competition start date
  • โ€ข Clips via Blossom upload OR external links (YouTube, Instagram, any URL)
  • โ€ข Advanced level only - all spot types included automatically
  • โ€ข Platform takes only 5% of entry fees (NOT prize pool)
)}
{tourCategory === 'destruction' ? : } {tourCategory === 'destruction' ? 'World Destruction Tour' : 'Tour Details'} {tourCategory === 'destruction' ? 'Set up your global KOTR-style competition - you define EVERYTHING!' : 'Tell skaters what to expect on your tour'} {/* Tour Title */}
setTitle(e.target.value)} className="bg-black border-cyan-500/30" required />
{/* Description */}