SkateSpots/src/pages/CreateTourPage.tsx
SkateSpots d1f8870379 chore(lint): remove all explicit any + sweep dead imports
- Fix all 12 no-explicit-any (NPool, NostrEvent[], LabProfile, structural
  Cluster type, explicit SkateSpot cast) -> 0 remaining
- Remove unused imports/vars across 48 files (manual + a verified codemod for
  unused import specifiers only); tsc + tests + boot smoke all green
- Warning backlog: 182 -> 76 (remaining: ~55 no-unused-vars locals,
  14 react-hooks/exhaustive-deps, 7 react-refresh)
- AGENTS.md baseline updated
2026-09-26 21:40:42 +07:00

2513 lines
120 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, 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<TourCategory>(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<string>(existing?.skillLevel ?? 'all');
const [spotTypes, setSpotTypes] = useState<string[]>(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<string[]>(existing?.languages?.length ? existing.languages : ['en']);
const [languagesExpanded, setLanguagesExpanded] = useState(false);
const [image, setImage] = useState<string | null>(existing?.image ?? null);
const [isGettingLocation, setIsGettingLocation] = useState(false);
// Partner shop state
const [partnerShopId, setPartnerShopId] = useState<string>(() => {
// 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<string[]>(existing?.cities ?? []);
const [newCity, setNewCity] = useState('');
const [countries, setCountries] = useState<string[]>(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<boolean>(
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<DestructionChallenge[]>(existing?.challenges ?? []);
const [acceptedClipSources, setAcceptedClipSources] = useState<string[]>(
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<DestructionChallenge>) => {
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<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 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<TourCategory, string> = {
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 (
<Layout>
<div className="container px-4 py-8">
<Card className="max-w-xl mx-auto border-dashed">
<CardContent className="py-12 text-center">
<Route className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
<h2 className="text-xl font-bold mb-2">Login Required</h2>
<p className="text-muted-foreground mb-6">
You need to log in to create a tour.
</p>
<LoginArea className="justify-center" />
</CardContent>
</Card>
</div>
</Layout>
);
}
// 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 (
<Layout>
<div className="container px-4 py-8">
<Card className="max-w-xl mx-auto border-dashed">
<CardContent className="py-12 text-center">
<Loader2 className="h-12 w-12 mx-auto mb-4 animate-spin text-cyan-400" />
<h2 className="text-xl font-bold mb-2">Loading Profile...</h2>
<p className="text-muted-foreground">
Checking your NIP-05 verification status.
</p>
</CardContent>
</Card>
</div>
</Layout>
);
}
// Require NIP-05 for tour creation
if (!hasNip05) {
return (
<Layout>
<div className="container px-4 py-8">
<Nip05Required role="tour organizers" />
</div>
</Layout>
);
}
return (
<Layout>
<div className="container px-4 sm:px-6 py-6 sm:py-8 max-w-2xl">
{/* Immersive header — layered, category-tinted, skate-punk */}
<div className="mb-8">
<Link to="/map" className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-5">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Map
</Link>
<div
className="relative overflow-hidden rounded-2xl border-2 p-6 sm:p-8"
style={{
borderColor: `${TOUR_CATEGORIES[tourCategory].color}55`,
background: `radial-gradient(120% 120% at 0% 0%, ${TOUR_CATEGORIES[tourCategory].color}22 0%, rgba(0,0,0,0) 55%), linear-gradient(160deg, rgba(0,0,0,0.6), rgba(0,0,0,0.9))`,
}}
>
{/* Diagonal grunge stripes */}
<div
className="pointer-events-none absolute inset-0 opacity-[0.07]"
style={{
backgroundImage: `repeating-linear-gradient(45deg, ${TOUR_CATEGORIES[tourCategory].color} 0px, ${TOUR_CATEGORIES[tourCategory].color} 2px, transparent 2px, transparent 14px)`,
}}
/>
<div className="relative flex items-center gap-4">
<div
className="flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-xl text-3xl shadow-lg"
style={{
backgroundColor: `${TOUR_CATEGORIES[tourCategory].color}22`,
border: `2px solid ${TOUR_CATEGORIES[tourCategory].color}`,
boxShadow: `0 0 30px ${TOUR_CATEGORIES[tourCategory].color}40`,
}}
>
{TOUR_CATEGORIES[tourCategory].emoji}
</div>
<div className="min-w-0">
<p
className="text-[11px] font-black uppercase tracking-[0.25em]"
style={{ color: TOUR_CATEGORIES[tourCategory].color }}
>
{isEditing ? 'Edit' : 'New'} · {TOUR_CATEGORIES[tourCategory].label}
</p>
<h1
className="text-3xl sm:text-4xl font-black uppercase leading-none"
style={{
fontFamily: 'Impact, "Arial Black", sans-serif',
color: 'white',
textShadow: `0 0 18px ${TOUR_CATEGORIES[tourCategory].color}70`,
}}
>
{isEditing ? 'Edit Tour' : 'Drop a Tour'}
</h1>
<p className="text-gray-400 mt-1 text-sm">
{TOUR_CATEGORIES[tourCategory].description}
</p>
</div>
</div>
</div>
</div>
{/* Tour Category Selection */}
<div className="mb-6">
<Label className="text-xs font-black uppercase tracking-widest text-gray-400 mb-3 block">
Pick your ride
</Label>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2.5">
{Object.entries(TOUR_CATEGORIES).map(([key, cat]) => (
<button
key={key}
type="button"
onClick={() => setTourCategory(key as TourCategory)}
className={cn(
'group relative flex flex-col items-center gap-1 rounded-xl border-2 py-4 px-2 transition-all duration-200 overflow-hidden',
tourCategory === key
? 'scale-[1.02]'
: 'border-gray-800 bg-black/40 hover:border-gray-600 hover:-translate-y-0.5'
)}
style={
tourCategory === key
? {
borderColor: cat.color,
backgroundColor: `${cat.color}18`,
boxShadow: `0 8px 30px ${cat.color}30, inset 0 0 0 1px ${cat.color}30`,
}
: undefined
}
>
<span
className="text-2xl transition-transform duration-200 group-hover:scale-110"
style={{ filter: tourCategory === key ? `drop-shadow(0 0 8px ${cat.color})` : undefined }}
>
{cat.emoji}
</span>
<span
className="text-[11px] font-black uppercase tracking-wide leading-tight text-center"
style={{ color: tourCategory === key ? cat.color : '#e5e7eb' }}
>
{cat.label}
</span>
<span className="text-[9px] font-medium uppercase tracking-wider text-gray-500">
{cat.durationLabel}
</span>
{tourCategory === key && (
<span
className="absolute bottom-0 left-0 h-1 w-full"
style={{ backgroundColor: cat.color }}
/>
)}
</button>
))}
</div>
</div>
{/* Info banner - changes based on category */}
<Card className="mb-6" style={{
backgroundColor: `${TOUR_CATEGORIES[tourCategory].color}10`,
borderColor: `${TOUR_CATEGORIES[tourCategory].color}30`
}}>
<CardContent className="py-4">
<div className="flex gap-3">
<Info className="h-5 w-5 flex-shrink-0 mt-0.5" style={{ color: TOUR_CATEGORIES[tourCategory].color }} />
<div className="text-sm">
{tourCategory === 'local' && (
<>
<p className="font-bold mb-1" style={{ color: TOUR_CATEGORIES[tourCategory].color }}>How Local Tours Work</p>
<ul className="text-gray-300 space-y-1 text-xs">
<li>• You set your price — <strong>platform takes 10%</strong></li>
<li>• Hidden spots stay hidden — you show them in person</li>
<li>• Run a <strong>scheduled session</strong> or keep it flexible / by-appointment</li>
<li>• Skaters book via DM, pay in Lightning &amp; can add it to their calendar</li>
<li>• Build your rep with reviews and zaps</li>
</ul>
</>
)}
{tourCategory === 'national' && (
<>
<p className="font-bold mb-1" style={{ color: TOUR_CATEGORIES[tourCategory].color }}>How National Tours Work</p>
<ul className="text-gray-300 space-y-1 text-xs">
<li>• Multi-city road trips across your country</li>
<li>• You set the price — <strong>platform takes only 7%</strong></li>
<li>• <strong>Set trip dates</strong> so skaters can plan time off &amp; travel</li>
<li>• Include transport / accommodation info for the crew</li>
<li>• Build legendary skate adventures!</li>
</ul>
</>
)}
{tourCategory === 'international' && (
<>
<p className="font-bold mb-1" style={{ color: TOUR_CATEGORIES[tourCategory].color }}>How International Tours Work</p>
<ul className="text-gray-300 space-y-1 text-xs">
<li>• Cross-border adventures across multiple countries</li>
<li>• You set the price — <strong>platform takes only 5%</strong></li>
<li>• <strong>Set departure &amp; return dates</strong> so the crew can book flights</li>
<li>• Connect skaters from around the world</li>
<li>• Create unforgettable global experiences!</li>
</ul>
</>
)}
{tourCategory === 'destruction' && (
<>
<p className="font-bold mb-1" style={{ color: TOUR_CATEGORIES[tourCategory].color }}>💀 WORLD DESTRUCTION TOUR - KOTR Style</p>
<ul className="text-gray-300 space-y-1 text-xs">
<li>• Global team competition - up to 12 months long</li>
<li>• YOU define challenges, points, prizes - we just provide the platform</li>
<li>• <strong>All challenges are SECRET</strong> until competition start date</li>
<li>• Clips via Blossom upload OR external links (YouTube, Instagram, any URL)</li>
<li>• Advanced level only - all spot types included automatically</li>
<li>• <strong>Platform takes only 5%</strong> of entry fees (NOT prize pool)</li>
</ul>
</>
)}
</div>
</div>
</CardContent>
</Card>
<form onSubmit={handleSubmit}>
<Card className="bg-black/90 border-2 border-cyan-500/30">
<CardHeader>
<CardTitle className="uppercase font-black flex items-center gap-2" style={{ color: TOUR_CATEGORIES[tourCategory].color }}>
{tourCategory === 'destruction' ? <Skull className="h-5 w-5" /> : <Route className="h-5 w-5" />}
{tourCategory === 'destruction' ? 'World Destruction Tour' : 'Tour Details'}
</CardTitle>
<CardDescription className="text-gray-400">
{tourCategory === 'destruction'
? 'Set up your global KOTR-style competition - you define EVERYTHING!'
: 'Tell skaters what to expect on your tour'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Tour Title */}
<div className="space-y-2">
<Label htmlFor="title">Tour Title *</Label>
<Input
id="title"
placeholder="e.g., Berlin Kreuzberg - Underground Spots Tour"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="bg-black border-cyan-500/30"
required
/>
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Describe what skaters will experience: spots you'll visit, vibe, what makes your tour unique..."
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
className="bg-black border-cyan-500/30"
/>
</div>
{/* City/Region - only for local and national tours */}
{(tourCategory === 'local' || tourCategory === 'national') && (
<div className="space-y-2">
<Label htmlFor="location">{tourCategory === 'local' ? 'City / Region *' : 'Starting City *'}</Label>
<Input
id="location"
placeholder={tourCategory === 'local' ? 'e.g., Berlin, Germany' : 'e.g., Los Angeles, USA'}
value={location}
onChange={(e) => setLocation(e.target.value)}
className="bg-black"
style={{ borderColor: `${TOUR_CATEGORIES[tourCategory].color}30` }}
required
/>
</div>
)}
{/* Meeting Point - only for local and national tours */}
{(tourCategory === 'local' || tourCategory === 'national') && (
<div className="space-y-3 p-4 rounded-lg border-2 bg-opacity-5" style={{
borderColor: `${TOUR_CATEGORIES[tourCategory].color}30`,
backgroundColor: `${TOUR_CATEGORIES[tourCategory].color}05`
}}>
<div className="flex items-center gap-2 mb-2">
<MapPin className="h-5 w-5" style={{ color: TOUR_CATEGORIES[tourCategory].color }} />
<Label className="text-base font-bold" style={{ color: TOUR_CATEGORIES[tourCategory].color }}>Meeting Point *</Label>
</div>
<p className="text-sm text-gray-400 mb-3">
Where will you meet tour participants? Choose a public, well-known spot.
</p>
<Input
placeholder="e.g., Warschauer Straße Station"
value={meetingPoint}
onChange={(e) => setMeetingPoint(e.target.value)}
className="bg-black"
style={{ borderColor: `${TOUR_CATEGORIES[tourCategory].color}30` }}
required={tourCategory === 'local' || tourCategory === 'national'}
/>
<div className="grid grid-cols-2 gap-4 mt-3">
<div>
<Input
placeholder="Latitude (optional)"
value={meetingLat}
onChange={(e) => setMeetingLat(e.target.value)}
className="bg-black border-cyan-500/30"
/>
</div>
<div>
<Input
placeholder="Longitude (optional)"
value={meetingLng}
onChange={(e) => setMeetingLng(e.target.value)}
className="bg-black border-cyan-500/30"
/>
</div>
</div>
<p className="text-[11px] text-gray-500 mt-1">
Coordinates are optional. Add them to pin your meeting point on the map.
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={getCurrentLocation}
disabled={isGettingLocation}
className="mt-2 border-cyan-500/50 text-cyan-400 hover:bg-cyan-500/10"
>
{isGettingLocation ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Getting location...
</>
) : (
<>
<MapPin className="h-4 w-4 mr-2" />
Use my current location
</>
)}
</Button>
</div>
)}
{/* Duration - changes based on category */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="flex items-center gap-2">
<Clock className="h-4 w-4" style={{ color: TOUR_CATEGORIES[tourCategory].color }} />
Duration
</Label>
<span className="font-bold" style={{ color: TOUR_CATEGORIES[tourCategory].color }}>
{tourCategory === 'destruction'
? `${Math.ceil(duration[0] / 4)} month${Math.ceil(duration[0] / 4) > 1 ? 's' : ''}`
: tourCategory === 'international'
? `${duration[0]} week${duration[0] > 1 ? 's' : ''} (~${Math.ceil(duration[0] / 4)} month${Math.ceil(duration[0] / 4) > 1 ? 's' : ''})`
: `${duration[0]} ${durationUnit}`
}
</span>
</div>
<Slider
value={duration}
onValueChange={setDuration}
min={tourCategory === 'local' ? 1 : tourCategory === 'destruction' ? 4 : 1}
max={tourCategory === 'local' ? 8 : tourCategory === 'national' ? 14 : tourCategory === 'international' ? 8 : 48}
step={tourCategory === 'local' ? 0.5 : tourCategory === 'destruction' ? 4 : 1}
className="w-full"
/>
<p className="text-xs text-gray-500">
{tourCategory === 'local' && 'How long is your typical tour?'}
{tourCategory === 'national' && 'How many days is the road trip?'}
{tourCategory === 'international' && 'How many weeks is the adventure? (up to 2 months)'}
{tourCategory === 'destruction' && 'How long is the competition? (1-12 months)'}
</p>
</div>
{/* ============ SCHEDULE (local / national / international) ============ */}
{/* Local: optional fixed session date+time OR flexible/by-appointment. */}
{tourCategory === 'local' && (
<div
className="space-y-3 p-4 rounded-lg border-2"
style={{
borderColor: `${TOUR_CATEGORIES[tourCategory].color}30`,
backgroundColor: `${TOUR_CATEGORIES[tourCategory].color}08`,
}}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Calendar className="h-5 w-5" style={{ color: TOUR_CATEGORIES[tourCategory].color }} />
<Label className="text-base font-bold" style={{ color: TOUR_CATEGORIES[tourCategory].color }}>
Schedule
</Label>
</div>
</div>
<p className="text-xs text-gray-400 -mt-1">
Run this as a one-off scheduled session, or keep it flexible and let skaters book a time by DM.
</p>
{/* Fixed vs flexible toggle */}
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
variant={hasFixedSession ? 'default' : 'outline'}
onClick={() => setHasFixedSession(true)}
className={cn(
'h-auto py-2.5 flex-col items-start gap-0.5',
!hasFixedSession && 'border-gray-700 hover:border-gray-500'
)}
style={hasFixedSession ? { backgroundColor: TOUR_CATEGORIES[tourCategory].color, color: 'white' } : undefined}
>
<span className="text-xs font-bold flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" /> Fixed session
</span>
<span className={cn('text-[10px]', hasFixedSession ? 'text-white/80' : 'text-gray-500')}>
A set date &amp; time
</span>
</Button>
<Button
type="button"
variant={!hasFixedSession ? 'default' : 'outline'}
onClick={() => setHasFixedSession(false)}
className={cn(
'h-auto py-2.5 flex-col items-start gap-0.5',
hasFixedSession && 'border-gray-700 hover:border-gray-500'
)}
style={!hasFixedSession ? { backgroundColor: TOUR_CATEGORIES[tourCategory].color, color: 'white' } : undefined}
>
<span className="text-xs font-bold flex items-center gap-1">
<Clock className="h-3.5 w-3.5" /> Flexible
</span>
<span className={cn('text-[10px]', !hasFixedSession ? 'text-white/80' : 'text-gray-500')}>
Book anytime by DM
</span>
</Button>
</div>
{hasFixedSession ? (
<div className="space-y-2 pt-1">
<Label className="text-xs">Session date &amp; time *</Label>
<Input
type="datetime-local"
value={sessionDateTime}
onChange={(e) => setSessionDateTime(e.target.value)}
className="bg-black"
style={{ borderColor: `${TOUR_CATEGORIES[tourCategory].color}30`, colorScheme: 'dark' }}
/>
<p className="text-[11px] text-gray-500">
Skaters will see this time and can add it to their calendar from the tour page.
</p>
</div>
) : (
<div className="space-y-2 pt-1">
<Label htmlFor="availability" className="text-xs">When are you usually available?</Label>
<Input
id="availability"
placeholder="e.g., Weekends, Mornings only, By appointment"
value={availability}
onChange={(e) => setAvailability(e.target.value)}
className="bg-black"
style={{ borderColor: `${TOUR_CATEGORIES[tourCategory].color}30` }}
/>
</div>
)}
</div>
)}
{/* National & International: trip window (departure required, return optional). */}
{(tourCategory === 'national' || tourCategory === 'international') && (
<div
className="space-y-3 p-4 rounded-lg border-2"
style={{
borderColor: `${TOUR_CATEGORIES[tourCategory].color}30`,
backgroundColor: `${TOUR_CATEGORIES[tourCategory].color}08`,
}}
>
<div className="flex items-center gap-2">
<Calendar className="h-5 w-5" style={{ color: TOUR_CATEGORIES[tourCategory].color }} />
<Label className="text-base font-bold" style={{ color: TOUR_CATEGORIES[tourCategory].color }}>
Trip Dates
</Label>
</div>
<p className="text-xs text-gray-400 -mt-1">
Lock in the dates so skaters can request time off, book flights, and plan the trip.
</p>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs">Departure *</Label>
<Input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="bg-black"
style={{ borderColor: `${TOUR_CATEGORIES[tourCategory].color}30`, colorScheme: 'dark' }}
/>
</div>
<div className="space-y-2">
<Label className="text-xs">Return</Label>
<Input
type="date"
value={endDate}
min={startDate || undefined}
onChange={(e) => setEndDate(e.target.value)}
className="bg-black"
style={{ borderColor: `${TOUR_CATEGORIES[tourCategory].color}30`, colorScheme: 'dark' }}
/>
</div>
</div>
</div>
)}
{/* Skill Level - not shown for destruction tours (always advanced) */}
{tourCategory !== 'destruction' && (
<div className="space-y-3">
<Label>Skill Level</Label>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{Object.entries(TOUR_SKILL_LEVELS).map(([key, level]) => (
<Button
key={key}
type="button"
variant={skillLevel === key ? 'default' : 'outline'}
size="sm"
onClick={() => setSkillLevel(key)}
className={cn(
'flex-col h-auto py-2',
skillLevel === key
? ''
: 'border-gray-700 hover:border-gray-500'
)}
style={skillLevel === key ? { backgroundColor: TOUR_CATEGORIES[tourCategory].color } : undefined}
>
<span className="text-lg mb-1">{level.emoji}</span>
<span className="text-xs">{level.label}</span>
</Button>
))}
</div>
</div>
)}
{/* Spot Types - not shown for destruction tours (all spots included) */}
{tourCategory !== 'destruction' && (
<div className="space-y-3">
<Label>Types of Spots *</Label>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{Object.entries(TOUR_SPOT_TYPES).map(([key, type]) => (
<Button
key={key}
type="button"
variant={spotTypes.includes(key) ? 'default' : 'outline'}
size="sm"
onClick={() => toggleSpotType(key)}
className={cn(
'gap-2',
spotTypes.includes(key)
? ''
: 'border-gray-700 hover:border-gray-500'
)}
style={spotTypes.includes(key) ? { backgroundColor: TOUR_CATEGORIES[tourCategory].color } : undefined}
>
<span>{type.emoji}</span>
<span>{type.label}</span>
</Button>
))}
</div>
</div>
)}
{/* Max Group Size - only for non-destruction tours */}
{tourCategory !== 'destruction' && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="flex items-center gap-2">
<Users className="h-4 w-4" style={{ color: TOUR_CATEGORIES[tourCategory].color }} />
Max Group Size
</Label>
<span className="font-bold" style={{ color: TOUR_CATEGORIES[tourCategory].color }}>{maxGroupSize[0]} people</span>
</div>
<Slider
value={maxGroupSize}
onValueChange={setMaxGroupSize}
min={1}
max={tourCategory === 'local' ? 10 : 20}
step={1}
className="w-full"
/>
</div>
)}
{/* ============ NATIONAL/INTERNATIONAL SPECIFIC FIELDS ============ */}
{(tourCategory === 'national' || tourCategory === 'international') && (
<div className="space-y-4 p-4 rounded-lg border-2" style={{
borderColor: `${TOUR_CATEGORIES[tourCategory].color}30`,
backgroundColor: `${TOUR_CATEGORIES[tourCategory].color}05`
}}>
<div className="flex items-center gap-2 mb-2">
{tourCategory === 'national' ? <Car className="h-5 w-5" /> : <Plane className="h-5 w-5" />}
<Label className="text-base font-bold" style={{ color: TOUR_CATEGORIES[tourCategory].color }}>
{tourCategory === 'national' ? 'Road Trip Details' : 'International Adventure Details'}
</Label>
</div>
{/* Cities */}
<div className="space-y-2">
<Label>Cities on the Tour</Label>
<div className="flex gap-2">
<Input
placeholder="Add a city..."
value={newCity}
onChange={(e) => setNewCity(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), addCity())}
className="bg-black border-gray-700"
/>
<Button type="button" onClick={addCity} variant="outline" size="icon">
<Plus className="h-4 w-4" />
</Button>
</div>
{cities.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{cities.map((city) => (
<span key={city} className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full" style={{
backgroundColor: `${TOUR_CATEGORIES[tourCategory].color}20`,
color: TOUR_CATEGORIES[tourCategory].color
}}>
{city}
<button type="button" onClick={() => removeCity(city)} className="hover:text-red-400">
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
)}
</div>
{/* Countries (International only) */}
{tourCategory === 'international' && (
<div className="space-y-2">
<Label>Countries *</Label>
<div className="flex gap-2">
<Input
placeholder="Add a country..."
value={newCountry}
onChange={(e) => setNewCountry(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), addCountry())}
className="bg-black"
style={{ borderColor: `${TOUR_CATEGORIES[tourCategory].color}30` }}
/>
<Button type="button" onClick={addCountry} variant="outline" size="icon" style={{ borderColor: `${TOUR_CATEGORIES[tourCategory].color}50` }}>
<Plus className="h-4 w-4" />
</Button>
</div>
{countries.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{countries.map((country) => (
<span key={country} className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full" style={{
backgroundColor: `${TOUR_CATEGORIES[tourCategory].color}20`,
color: TOUR_CATEGORIES[tourCategory].color
}}>
{country}
<button type="button" onClick={() => removeCountry(country)} className="hover:text-red-400">
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
)}
</div>
)}
{/* Transport Info */}
<div className="space-y-2">
<Label>Transport Info</Label>
<Textarea
placeholder="How will participants travel? e.g., 'Van provided', 'Fly into Barcelona, we rent a van', 'Train between cities'"
value={transportInfo}
onChange={(e) => setTransportInfo(e.target.value)}
rows={2}
className="bg-black border-gray-700"
/>
</div>
{/* Accommodation Info */}
<div className="space-y-2">
<Label>Accommodation Info</Label>
<Textarea
placeholder="Accommodation recommendations or arrangements, e.g., 'Budget hostels along the route', 'Camping gear required', 'Airbnbs included'"
value={accommodationInfo}
onChange={(e) => setAccommodationInfo(e.target.value)}
rows={2}
className="bg-black border-gray-700"
/>
</div>
</div>
)}
{/* ============ WORLD DESTRUCTION TOUR SPECIFIC FIELDS ============ */}
{tourCategory === 'destruction' && (
<div className="space-y-6">
{/* Cities & Countries */}
<div className="space-y-4 p-4 rounded-lg border-2 border-red-500/30 bg-red-500/5">
<div className="flex items-center gap-2">
<Globe className="h-5 w-5 text-red-500" />
<Label className="text-base font-bold text-red-500">Competition Locations</Label>
</div>
<p className="text-xs text-gray-400">Where will teams be competing? Add all cities and countries involved.</p>
{/* Cities */}
<div className="space-y-2">
<Label className="text-sm">Cities</Label>
<div className="flex gap-2">
<Input
placeholder="Add a city..."
value={newCity}
onChange={(e) => setNewCity(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), addCity())}
className="bg-black border-red-500/30"
/>
<Button type="button" onClick={addCity} variant="outline" size="icon" className="border-red-500/50">
<Plus className="h-4 w-4" />
</Button>
</div>
{cities.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{cities.map((city) => (
<span key={city} className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full bg-red-500/20 text-red-400">
{city}
<button type="button" onClick={() => removeCity(city)} className="hover:text-white">
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
)}
</div>
{/* Countries */}
<div className="space-y-2">
<Label className="text-sm">Countries</Label>
<div className="flex gap-2">
<Input
placeholder="Add a country..."
value={newCountry}
onChange={(e) => setNewCountry(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), addCountry())}
className="bg-black border-red-500/30"
/>
<Button type="button" onClick={addCountry} variant="outline" size="icon" className="border-red-500/50">
<Plus className="h-4 w-4" />
</Button>
</div>
{countries.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{countries.map((country) => (
<span key={country} className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full bg-red-500/20 text-red-400">
{country}
<button type="button" onClick={() => removeCountry(country)} className="hover:text-white">
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
)}
</div>
</div>
{/* Organizer Contact */}
<div className="space-y-2">
<Label className="flex items-center gap-2 text-red-500">
<Users className="h-4 w-4" />
Organizer Contact Details *
</Label>
<Textarea
placeholder="How can teams contact you? e.g., Nostr npub, email, Telegram, etc."
value={organizerContact}
onChange={(e) => setOrganizerContact(e.target.value)}
rows={2}
className="bg-black border-red-500/30"
/>
<p className="text-xs text-gray-500">This will be visible to registered teams</p>
</div>
{/* Transport Info */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Car className="h-4 w-4 text-red-500" />
Transport Info
</Label>
<Textarea
placeholder="How will teams travel between locations? Any transport provided or recommended?"
value={transportInfo}
onChange={(e) => setTransportInfo(e.target.value)}
rows={2}
className="bg-black border-red-500/30"
/>
</div>
{/* Competition Dates */}
<div className="space-y-4 p-4 rounded-lg border-2 border-red-500/30 bg-red-500/5">
<div className="flex items-center gap-2">
<Calendar className="h-5 w-5 text-red-500" />
<Label className="text-base font-bold text-red-500">Competition Dates</Label>
</div>
<p className="text-xs text-gray-400">
⚠️ <strong>All challenges are SECRET until the start date!</strong> Teams won't see challenges until competition begins.
</p>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs">Start Date *</Label>
<Input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="bg-black border-red-500/30"
style={{ colorScheme: 'dark' }}
/>
</div>
<div className="space-y-2">
<Label className="text-xs">End Date</Label>
<Input
type="date"
value={endDate}
min={startDate || undefined}
onChange={(e) => setEndDate(e.target.value)}
className="bg-black border-red-500/30"
style={{ colorScheme: 'dark' }}
/>
</div>
</div>
</div>
{/* Team Settings */}
<div className="space-y-4 p-4 rounded-lg border-2 border-red-500/30 bg-red-500/5">
<div className="flex items-center gap-2">
<Users className="h-5 w-5 text-red-500" />
<Label className="text-base font-bold text-red-500">Team Settings</Label>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm">Max Teams</Label>
<span className="text-red-400 font-bold">{maxTeams[0]} teams</span>
</div>
<Slider
value={maxTeams}
onValueChange={setMaxTeams}
min={2}
max={16}
step={1}
className="w-full"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm">Min Team Size</Label>
<span className="text-red-400 font-bold">{minTeamSize[0]}</span>
</div>
<Slider
value={minTeamSize}
onValueChange={setMinTeamSize}
min={1}
max={8}
step={1}
className="w-full"
/>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm">Max Team Size</Label>
<span className="text-red-400 font-bold">{maxTeamSize[0]}</span>
</div>
<Slider
value={maxTeamSize}
onValueChange={setMaxTeamSize}
min={2}
max={12}
step={1}
className="w-full"
/>
</div>
</div>
</div>
{/* Entry Fee & Prize Pool */}
<div className="space-y-4 p-4 rounded-lg border-2 border-red-500/30 bg-red-500/5">
<div className="flex items-center gap-2">
<Trophy className="h-5 w-5 text-red-500" />
<Label className="text-base font-bold text-red-500">Entry & Prizes</Label>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-sm flex items-center gap-2">
<Zap className="h-3 w-3 text-orange-400" />
Entry Fee per Team (sats)
</Label>
<Input
type="number"
placeholder="e.g., 100000"
value={entryFee}
onChange={(e) => setEntryFee(e.target.value)}
className="bg-black border-red-500/30"
/>
<p className="text-[10px] text-gray-500">You keep 95% • Platform takes 5% • Leave empty for free entry</p>
</div>
<div className="space-y-2">
<Label className="text-sm flex items-center gap-2">
<Trophy className="h-3 w-3 text-yellow-400" />
Total Prize Pool (sats)
</Label>
<Input
type="number"
placeholder="e.g., 2000000"
value={prizePool}
onChange={(e) => setPrizePool(e.target.value)}
className="bg-black border-red-500/30"
/>
<p className="text-[10px] text-gray-500">Optional - total prizes available</p>
</div>
</div>
{/* Prize Distribution */}
{prizePool && parseInt(prizePool) > 0 && (
<div className="space-y-3 mt-4 p-3 rounded bg-black/50">
<Label className="text-sm">Prize Distribution</Label>
<div className="space-y-2">
{prizeDistribution.map((prize, idx) => (
<div key={prize.place} className="flex items-center gap-3">
<span className="text-sm w-16">
{prize.place === 1 && '🥇 1st'}
{prize.place === 2 && '🥈 2nd'}
{prize.place === 3 && '🥉 3rd'}
</span>
<Input
type="number"
value={prize.percentage}
onChange={(e) => {
const updated = [...prizeDistribution];
updated[idx].percentage = parseInt(e.target.value) || 0;
setPrizeDistribution(updated);
}}
className="w-20 bg-black border-gray-700 text-center"
/>
<span className="text-sm text-gray-500">%</span>
<span className="text-sm text-yellow-400 ml-auto">
{Math.floor(parseInt(prizePool) * prize.percentage / 100).toLocaleString()} sats
</span>
</div>
))}
</div>
<p className="text-[10px] text-gray-500">
Total: {prizeDistribution.reduce((sum, p) => sum + p.percentage, 0)}%
</p>
</div>
)}
</div>
{/* Challenges */}
<div className="space-y-4 p-4 rounded-lg border-2 border-red-500/30 bg-red-500/5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Target className="h-5 w-5 text-red-500" />
<Label className="text-base font-bold text-red-500">Challenges</Label>
</div>
<Button type="button" onClick={addChallenge} variant="outline" size="sm" className="border-red-500/50 text-red-400 hover:bg-red-500/10">
<Plus className="h-4 w-4 mr-1" />
Add Challenge
</Button>
</div>
<div className="flex items-center gap-2 p-2 rounded bg-yellow-500/10 border border-yellow-500/30">
<EyeOff className="h-4 w-4 text-yellow-500 flex-shrink-0" />
<p className="text-xs text-yellow-400">
<strong>All challenges are SECRET</strong> until competition start date. Teams won't see them until then!
</p>
</div>
{challenges.length === 0 ? (
<div className="text-center py-6 border-2 border-dashed border-gray-700 rounded-lg">
<Target className="h-8 w-8 mx-auto mb-2 text-gray-600" />
<p className="text-sm text-gray-500">No challenges yet</p>
<p className="text-xs text-gray-600">Add challenges for teams to complete</p>
</div>
) : (
<div className="space-y-3">
{challenges.map((challenge, idx) => (
<div key={challenge.id} className="p-3 rounded-lg bg-black/50 border border-gray-700 space-y-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 space-y-2">
<Input
placeholder="Challenge title, e.g., 'Kickflip a 10 stair'"
value={challenge.title}
onChange={(e) => updateChallenge(idx, { title: e.target.value })}
className="bg-black border-gray-700"
/>
<Textarea
placeholder="Optional description..."
value={challenge.description || ''}
onChange={(e) => updateChallenge(idx, { description: e.target.value })}
rows={2}
className="bg-black border-gray-700 text-sm"
/>
</div>
<Button type="button" variant="ghost" size="icon" onClick={() => removeChallenge(idx)} className="text-red-400 hover:bg-red-500/20">
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Label className="text-xs">Points:</Label>
<Input
type="number"
value={challenge.points}
onChange={(e) => updateChallenge(idx, { points: parseInt(e.target.value) || 0 })}
className="w-20 bg-black border-gray-700 text-center"
/>
</div>
<span className="text-xs text-gray-500 flex items-center gap-1">
<EyeOff className="h-3 w-3" /> Secret until start
</span>
</div>
</div>
))}
</div>
)}
</div>
{/* Accepted Clip Sources */}
<div className="space-y-3">
<Label>Accepted Clip Sources</Label>
<p className="text-xs text-gray-400">What formats will you accept for clip submissions?</p>
<div className="flex flex-wrap gap-2">
{[
{ id: 'blossom', label: '📦 Blossom Upload', desc: '< 50MB' },
{ id: 'youtube', label: '▶️ YouTube' },
{ id: 'instagram', label: '📸 Instagram' },
{ id: 'any', label: '🔗 Any URL' },
].map((source) => (
<Button
key={source.id}
type="button"
variant={acceptedClipSources.includes(source.id) ? 'default' : 'outline'}
size="sm"
onClick={() => {
setAcceptedClipSources((prev) =>
prev.includes(source.id)
? prev.filter((s) => s !== source.id)
: [...prev, source.id]
);
}}
className={cn(
acceptedClipSources.includes(source.id)
? 'bg-red-500 hover:bg-red-600'
: 'border-gray-700 hover:border-red-500/50'
)}
>
{source.label}
</Button>
))}
</div>
</div>
{/* Film Release Options */}
<div className="space-y-4 p-4 rounded-lg border-2 border-purple-500/30 bg-purple-500/5">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center flex-shrink-0">
<span className="text-xl">🎬</span>
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<Label className="text-base font-bold text-purple-400">Team Film Premieres</Label>
<Button
type="button"
variant={enableFilmRelease ? 'default' : 'outline'}
size="sm"
onClick={() => setEnableFilmRelease(!enableFilmRelease)}
className={cn(
enableFilmRelease
? 'bg-purple-500 hover:bg-purple-600'
: 'border-purple-500/50 text-purple-400 hover:bg-purple-500/10'
)}
>
{enableFilmRelease ? 'Enabled' : 'Enable'}
</Button>
</div>
<p className="text-xs text-gray-400 mt-1">
After the competition, each team's clips become a full film that can premiere at local skate shops worldwide.
</p>
</div>
</div>
{enableFilmRelease && (
<div className="space-y-4 pt-3 border-t border-purple-500/20">
<p className="text-xs text-purple-300">
🎥 Teams compile their challenge clips into a final film. Shops can host premiere events, connecting global competition to local communities.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* Premiere at Shops */}
<Button
type="button"
variant={filmReleaseAtShops ? 'default' : 'outline'}
onClick={() => setFilmReleaseAtShops(!filmReleaseAtShops)}
className={cn(
'h-auto py-3 flex-col items-start gap-1',
filmReleaseAtShops
? 'bg-[#ccff00] text-black hover:bg-[#ccff00]/90'
: 'border-[#ccff00]/50 hover:bg-[#ccff00]/10'
)}
>
<div className="flex items-center gap-2 w-full">
<Store className="h-4 w-4" />
<span className="font-bold">Local Shop Premieres</span>
</div>
<span className={cn(
'text-xs',
filmReleaseAtShops ? 'text-black/70' : 'text-gray-400'
)}>
Skate shops worldwide can host film nights
</span>
</Button>
{/* Online Release */}
<Button
type="button"
variant={filmReleaseOnline ? 'default' : 'outline'}
onClick={() => setFilmReleaseOnline(!filmReleaseOnline)}
className={cn(
'h-auto py-3 flex-col items-start gap-1',
filmReleaseOnline
? 'bg-cyan-500 text-white hover:bg-cyan-600'
: 'border-cyan-500/50 text-cyan-400 hover:bg-cyan-500/10'
)}
>
<div className="flex items-center gap-2 w-full">
<Globe className="h-4 w-4" />
<span className="font-bold">Online Release</span>
</div>
<span className={cn(
'text-xs',
filmReleaseOnline ? 'text-white/70' : 'text-gray-400'
)}>
Also release films on YouTube/Nostr
</span>
</Button>
</div>
{/* Film Release Date */}
<div className="space-y-2">
<Label className="text-purple-400 text-sm">Film Release Date (Optional)</Label>
<Input
type="date"
value={filmReleaseDate}
onChange={(e) => setFilmReleaseDate(e.target.value)}
min={endDate || startDate || undefined}
className="bg-black border-purple-500/30"
style={{ colorScheme: 'dark' }}
/>
<p className="text-xs text-gray-500">
When films will be available. Leave blank to release immediately after competition ends.
</p>
</div>
{filmReleaseAtShops && (
<div className="space-y-3">
<div className="p-3 rounded-lg bg-[#ccff00]/10 border border-[#ccff00]/30">
<p className="text-xs text-[#ccff00]">
<strong>💡 How Shop Premieres Work:</strong> Contact shops directly to arrange premiere nights.
All arrangements are private between you and the shops.
</p>
</div>
{/* Toggle to show available shops */}
<Button
type="button"
variant="outline"
onClick={() => setShowAvailableShops(!showAvailableShops)}
className="w-full border-[#ccff00]/50 text-[#ccff00] hover:bg-[#ccff00]/10"
>
<Store className="h-4 w-4 mr-2" />
{showAvailableShops ? 'Hide' : 'Show'} Available Skate Shops ({shops?.length || 0})
<ChevronDown className={cn(
'h-4 w-4 ml-2 transition-transform',
showAvailableShops && 'rotate-180'
)} />
</Button>
{/* Available shops list */}
{showAvailableShops && (
<div className="space-y-2 max-h-64 overflow-y-auto rounded-lg border border-[#ccff00]/20 p-2">
{shopsLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-[#ccff00]" />
<span className="ml-2 text-sm text-gray-400">Loading shops...</span>
</div>
) : shops && shops.length > 0 ? (
<>
<p className="text-xs text-gray-400 px-2 pb-2 border-b border-gray-800">
Contact these shops directly to arrange film premiere nights. All arrangements are private.
</p>
{shops.map((shop) => (
<div
key={shop.id}
className="p-3 rounded-lg bg-black/50 border border-gray-800 hover:border-[#ccff00]/30 transition-colors"
>
<div className="flex items-start gap-3">
{shop.images && shop.images[0] ? (
<img
src={shop.images[0]}
alt={shop.name}
className="w-12 h-12 rounded-lg object-cover flex-shrink-0"
/>
) : (
<div className="w-12 h-12 rounded-lg bg-[#ccff00]/20 flex items-center justify-center flex-shrink-0">
<Store className="h-5 w-5 text-[#ccff00]" />
</div>
)}
<div className="flex-1 min-w-0">
<h4 className="font-bold text-white text-sm truncate">{shop.name}</h4>
{shop.location && (
<p className="text-xs text-gray-400 flex items-center gap-1">
<MapPin className="h-3 w-3 flex-shrink-0" />
<span className="truncate">{shop.location}</span>
</p>
)}
<div className="flex items-center gap-2 mt-1 flex-wrap">
{shop.website && sanitizeUrl(shop.website) && (
<a
href={sanitizeUrl(shop.website)!}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-cyan-400 hover:text-cyan-300"
onClick={(e) => e.stopPropagation()}
>
🌐 Website
</a>
)}
{shop.instagram && (
<a
href={`https://instagram.com/${shop.instagram.replace('@', '')}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-pink-400 hover:text-pink-300"
onClick={(e) => e.stopPropagation()}
>
📸 Instagram
</a>
)}
{shop.email && (
<a
href={`mailto:${shop.email}`}
className="text-xs text-orange-400 hover:text-orange-300"
onClick={(e) => e.stopPropagation()}
>
✉️ Email
</a>
)}
{shop.phone && (
<a
href={`tel:${shop.phone}`}
className="text-xs text-green-400 hover:text-green-300"
onClick={(e) => e.stopPropagation()}
>
📞 Call
</a>
)}
</div>
</div>
<Link
to={`/shop/${shop.pubkey}/${shop.identifier}`}
className="text-xs text-[#ccff00] hover:underline flex-shrink-0"
onClick={(e) => e.stopPropagation()}
>
View →
</Link>
</div>
</div>
))}
</>
) : (
<div className="text-center py-4">
<Store className="h-8 w-8 mx-auto mb-2 text-gray-600" />
<p className="text-sm text-gray-400">No skate shops registered yet</p>
<p className="text-xs text-gray-500 mt-1">
Shops can register at /create-shop
</p>
</div>
)}
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
)}
{/* Price - only for non-destruction tours (destruction uses entry fee) */}
{tourCategory !== 'destruction' && (
<div className="space-y-2">
<Label htmlFor="price" className="flex items-center gap-2">
<Zap className="h-4 w-4 text-orange-400" />
Price per Person (sats)
</Label>
<Input
id="price"
type="number"
placeholder="e.g., 25000"
value={price}
onChange={(e) => setPrice(e.target.value)}
className="bg-black"
style={{ borderColor: `${TOUR_CATEGORIES[tourCategory].color}30` }}
/>
<p className="text-xs text-gray-500">
You set the price • Platform takes {tourCategory === 'local' ? '10%' : tourCategory === 'national' ? '7%' : '5%'}
</p>
{price && parseInt(price) > 0 && (
<div className="text-xs mt-1" style={{ color: TOUR_CATEGORIES[tourCategory].color }}>
You receive: {Math.floor(parseInt(price) * (tourCategory === 'local' ? 0.9 : tourCategory === 'national' ? 0.93 : 0.95)).toLocaleString()} sats per person
</div>
)}
</div>
)}
{/* Availability for local tours is handled in the Schedule section above.
National/International/Destruction use concrete dates instead. */}
{/* Partner Shop Discount */}
<Collapsible open={shopExpanded} onOpenChange={setShopExpanded}>
<div className="space-y-3 p-4 rounded-lg border-2 border-[#ccff00]/30 bg-[#ccff00]/5">
<CollapsibleTrigger asChild>
<button
type="button"
className="flex items-center justify-between w-full text-left"
>
<div className="flex items-center gap-2">
<Store className="h-5 w-5 text-[#ccff00]" />
<div>
<Label className="text-base font-bold text-[#ccff00] cursor-pointer">Partner Shop Discount</Label>
<p className="text-xs text-gray-400">
Offer tour participants a discount at a local skate shop
</p>
</div>
</div>
<ChevronDown className={cn(
'h-5 w-5 text-[#ccff00] transition-transform',
shopExpanded && 'rotate-180'
)} />
</button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-3">
<div className="text-xs text-gray-400 p-3 rounded bg-gray-900/50 border border-gray-800">
<p className="font-medium text-white mb-1">How it works:</p>
<ul className="space-y-1">
<li>1. Partner with a local skate shop you trust</li>
<li>2. Set a discount percentage for tour participants</li>
<li>3. After the tour, give participants a confirmation to show at the shop</li>
<li>4. Shop honors the discount - builds community & drives foot traffic!</li>
</ul>
</div>
{/* Shop Selection */}
<div className="space-y-2">
<Label>Select Partner Shop</Label>
{shopsLoading ? (
<div className="flex items-center gap-2 text-gray-400 py-4">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Loading shops...</span>
</div>
) : shops && shops.length > 0 ? (
<div className="grid grid-cols-1 gap-2 max-h-48 overflow-y-auto">
<Button
type="button"
variant={!partnerShopId ? 'default' : 'outline'}
size="sm"
onClick={() => setPartnerShopId('')}
className={cn(
'justify-start gap-2',
!partnerShopId
? 'bg-gray-700'
: 'border-gray-700'
)}
>
<X className="h-4 w-4" />
No partner shop
</Button>
{shops.map((shop) => {
const shopKey = `${shop.pubkey}:${shop.identifier}`;
return (
<Button
key={shopKey}
type="button"
variant={partnerShopId === shopKey ? 'default' : 'outline'}
size="sm"
onClick={() => setPartnerShopId(shopKey)}
className={cn(
'justify-start gap-2 h-auto py-2',
partnerShopId === shopKey
? 'bg-[#ccff00] text-black hover:bg-[#ccff00]/90'
: 'border-gray-700 hover:border-[#ccff00]/50'
)}
>
<Store className="h-4 w-4 flex-shrink-0" />
<div className="text-left">
<div className="font-medium">{shop.name}</div>
{shop.location && (
<div className={cn(
'text-xs',
partnerShopId === shopKey ? 'text-black/70' : 'text-gray-500'
)}>
{shop.location}
</div>
)}
</div>
</Button>
);
})}
</div>
) : (
<div className="text-sm text-gray-500 space-y-2">
<p>No skate shops found on the network yet.</p>
<Link to="/create-shop" className="text-[#ccff00] hover:underline inline-flex items-center gap-1">
<Store className="h-3 w-3" />
Add a shop first
</Link>
</div>
)}
</div>
{/* Pro tip about coordinating with shop */}
<div className="text-xs text-amber-400/80 p-3 rounded bg-amber-500/10 border border-amber-500/20">
<p className="font-medium text-amber-400 mb-1">💡 Pro Tip: Coordinate with the shop first!</p>
<p>Before listing a partner discount, contact the shop owner via DM to agree on the discount percentage. This ensures they're on board and will honor the discount for your tour participants.</p>
</div>
{/* Discount Slider */}
{partnerShopId && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="flex items-center gap-2">
<Percent className="h-4 w-4 text-[#ccff00]" />
Discount Percentage
</Label>
<span className="text-[#ccff00] font-bold text-lg">{shopDiscount[0]}% OFF</span>
</div>
<Slider
value={shopDiscount}
onValueChange={setShopDiscount}
min={5}
max={50}
step={5}
className="w-full"
/>
<p className="text-xs text-gray-500">
Tour participants can show their booking confirmation at the shop to receive this discount
</p>
</div>
)}
</CollapsibleContent>
</div>
</Collapsible>
{/* Languages */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="flex items-center gap-2">
<Globe className="h-4 w-4 text-cyan-400" />
Languages Spoken
</Label>
{languages.length > 0 && (
<span className="text-xs text-cyan-400">
{languages.length} selected
</span>
)}
</div>
{/* Common languages - always visible */}
<div className="flex flex-wrap gap-2">
{COMMON_LANGUAGES.map((lang) => (
<Button
key={lang.code}
type="button"
variant={languages.includes(lang.code) ? 'default' : 'outline'}
size="sm"
onClick={() => toggleLanguage(lang.code)}
className={cn(
'gap-1.5 h-8',
languages.includes(lang.code)
? 'bg-cyan-500 hover:bg-cyan-600'
: 'border-gray-700 hover:border-cyan-500/50'
)}
>
<span>{lang.flag}</span>
<span className="text-xs">{lang.label}</span>
</Button>
))}
</div>
{/* Collapsible section for all languages */}
<Collapsible open={languagesExpanded} onOpenChange={setLanguagesExpanded}>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="w-full justify-between text-gray-400 hover:text-cyan-400 hover:bg-cyan-500/10"
>
<span>{languagesExpanded ? 'Show less' : `Show all ${ALL_LANGUAGES.length} languages`}</span>
<ChevronDown className={cn(
'h-4 w-4 transition-transform',
languagesExpanded && 'rotate-180'
)} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-3">
<div className="flex flex-wrap gap-1.5 max-h-64 overflow-y-auto p-2 rounded-lg bg-gray-900/50 border border-gray-800">
{ALL_LANGUAGES.filter(l => !COMMON_LANGUAGES.find(c => c.code === l.code)).map((lang) => (
<Button
key={lang.code}
type="button"
variant={languages.includes(lang.code) ? 'default' : 'outline'}
size="sm"
onClick={() => toggleLanguage(lang.code)}
className={cn(
'gap-1 h-7 text-xs px-2',
languages.includes(lang.code)
? 'bg-cyan-500 hover:bg-cyan-600'
: 'border-gray-700 hover:border-cyan-500/50'
)}
>
<span>{lang.flag}</span>
<span>{lang.label}</span>
</Button>
))}
</div>
</CollapsibleContent>
</Collapsible>
{/* Show selected languages not in common list */}
{languages.filter(l => !COMMON_LANGUAGES.find(c => c.code === l)).length > 0 && !languagesExpanded && (
<div className="flex flex-wrap gap-1.5">
<span className="text-xs text-gray-500 mr-1">Also selected:</span>
{languages.filter(l => !COMMON_LANGUAGES.find(c => c.code === l)).map((code) => {
const lang = ALL_LANGUAGES.find(l => l.code === code);
if (!lang) return null;
return (
<span key={code} className="inline-flex items-center gap-1 text-xs bg-cyan-500/20 text-cyan-300 px-2 py-0.5 rounded-full">
{lang.flag} {lang.label}
<button
type="button"
onClick={() => toggleLanguage(code)}
className="hover:text-red-400"
>
<X className="h-3 w-3" />
</button>
</span>
);
})}
</div>
)}
</div>
{/* Tour Image */}
<div className="space-y-2">
<Label>Tour Photo</Label>
<div className="flex gap-4">
{image ? (
<div className="relative w-32 h-24 rounded-lg overflow-hidden bg-muted">
<img src={image} alt="Tour" className="w-full h-full object-cover" />
<button
type="button"
onClick={() => setImage(null)}
className="absolute top-1 right-1 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="w-32 h-24 rounded-lg border-2 border-dashed border-cyan-500/30 flex flex-col items-center justify-center cursor-pointer hover:bg-cyan-500/5 transition-colors">
<input
type="file"
accept="image/*"
className="hidden"
onChange={handleImageUpload}
disabled={isUploading}
/>
{isUploading ? (
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
) : (
<>
<Upload className="h-6 w-6 text-cyan-400 mb-1" />
<span className="text-xs text-gray-400">Upload</span>
</>
)}
</label>
)}
<div className="text-xs text-gray-500 flex-1">
<p>Add a photo of yourself skating or a cool spot from your tour.</p>
</div>
</div>
</div>
{/* Optional featured upgrade — tour posts for free regardless.
Hidden while editing: an existing featured window is preserved
automatically, and the paid upsell doesn't belong in an edit. */}
{!isEditing && (
<FeaturedUpgrade
type="featuredTour"
noun="tour"
placement="the tours list"
enabled={goFeatured}
onEnabledChange={setGoFeatured}
/>
)}
{/* Submit */}
<Button
type="submit"
className="w-full font-black uppercase"
style={{
backgroundColor: TOUR_CATEGORIES[tourCategory].color,
color: tourCategory === 'destruction' || tourCategory === 'national' ? 'black' : 'white',
}}
disabled={isPublishing || isUploading}
>
{isPublishing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{isEditing ? 'Saving...' : 'Publishing...'}
</>
) : (
<>
{tourCategory === 'destruction' ? <Skull className="h-4 w-4 mr-2" /> :
tourCategory === 'international' ? <Plane className="h-4 w-4 mr-2" /> :
tourCategory === 'national' ? <Car className="h-4 w-4 mr-2" /> :
<Route className="h-4 w-4 mr-2" />}
{isEditing
? 'Save Changes'
: tourCategory === 'destruction'
? 'Create World Destruction Tour'
: 'Create Tour'}
</>
)}
</Button>
</CardContent>
</Card>
</form>
</div>
</Layout>
);
}