SkateSpots/src/pages/CreateTourPage.tsx

2528 lines
122 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 { 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 { PLATFORM_LIGHTNING_ADDRESS } from '@/lib/platform';
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 { TOUR_CATEGORY_KEYS, TOUR_CATEGORY_DESC_KEYS, TOUR_SPOT_TYPE_KEYS, TOUR_SKILL_KEYS } from '@/lib/labelKeys';
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';
import { useTranslation } from '@/contexts/language';
// 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-CN', label: 'Chinese (Simplified)', flag: '🇨🇳' },
{ code: 'zh-TW', label: 'Chinese (Traditional)', flag: '🇹🇼' },
{ code: 'uk', label: 'Ukrainian', flag: '🇺🇦' },
{ code: 'id', label: 'Indonesian', flag: '🇮🇩' },
{ code: 'cs', label: 'Czech', flag: '🇨🇿' },
{ code: 'ro', label: 'Romanian', flag: '🇷🇴' },
{ code: 'sv', label: 'Swedish', flag: '🇸🇪' },
{ code: 'el', label: 'Greek', flag: '🇬🇷' },
{ code: 'vi', label: 'Vietnamese', flag: '🇻🇳' },
{ code: 'tr', label: 'Turkish', 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: 'ko-KP', label: 'Korean (North)', 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 { t } = useTranslation();
const isEditing = !!existing;
useSeoMeta({
title: isEditing ? 'Edit Tour - SkateSpots' : 'Create a Tour - SkateSpots',
description: t('seoUi.createTourDesc'),
});
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: t('placesUi.createEvent.geoNotSupported'),
description: t('placesUi.createEvent.geoNotSupportedDesc'),
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: t('placesUi.createEvent.locationFound'),
description: t('placesUi.createTour.meetingLocationSet'),
});
},
(error) => {
setIsGettingLocation(false);
toast({
title: t('placesUi.createEvent.locationError'),
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: t('placesUi.createEvent.imageUploaded'),
description: t('placesUi.createTour.imageUploadedDesc'),
});
}
} catch (error) {
toast({
title: t('placesUi.createEvent.uploadFailed'),
description: error instanceof Error ? error.message : t('placesUi.createEvent.uploadFailedDesc'),
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: t('placesUi.createTour.titleRequired'),
description: t('placesUi.createTour.titleRequiredDesc'),
variant: 'destructive',
});
return;
}
// Location required only for local/national tours
if ((tourCategory === 'local' || tourCategory === 'national') && !location.trim()) {
toast({
title: t('placesUi.createTour.cityRequired'),
description: t('placesUi.createTour.cityRequiredDesc'),
variant: 'destructive',
});
return;
}
// Meeting point required only for local/national tours
if ((tourCategory === 'local' || tourCategory === 'national') && !meetingPoint.trim()) {
toast({
title: t('placesUi.createTour.meetingPointRequired'),
description: t('placesUi.createTour.meetingPointRequiredDesc'),
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: t('placesUi.createTour.incompleteCoords'),
description: t('placesUi.createTour.incompleteCoordsDesc'),
variant: 'destructive',
});
return;
}
lat = parseFloat(meetingLat);
lng = parseFloat(meetingLng);
if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) {
toast({
title: t('placesUi.createShop.invalidCoords'),
description: t('placesUi.createShop.invalidCoordsDesc'),
variant: 'destructive',
});
return;
}
hasCoords = true;
}
// Spot types required only for non-destruction tours
if (tourCategory !== 'destruction' && spotTypes.length === 0) {
toast({
title: t('placesUi.createTour.spotTypesRequired'),
description: t('placesUi.createTour.spotTypesRequiredDesc'),
variant: 'destructive',
});
return;
}
// International tour requires at least one country
if (tourCategory === 'international' && countries.length === 0) {
toast({
title: t('placesUi.createTour.countriesRequired'),
description: t('placesUi.createTour.countriesRequiredDesc'),
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: t('placesUi.createTour.sessionDateRequired'),
description: t('placesUi.createTour.sessionDateRequiredDesc'),
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: t('placesUi.createTour.departureRequired'),
description: t('placesUi.createTour.departureRequiredDesc'),
variant: 'destructive',
});
return;
}
if (endDate && inputToSecs(endDate)! < inputToSecs(startDate)!) {
toast({
title: t('placesUi.createTour.returnBeforeDeparture'),
description: t('placesUi.createTour.returnBeforeDepartureDesc'),
variant: 'destructive',
});
return;
}
}
// Destruction tour validations
if (tourCategory === 'destruction') {
if (!startDate) {
toast({
title: t('placesUi.createTour.startDateRequired'),
description: t('placesUi.createTour.startDateRequiredDesc'),
variant: 'destructive',
});
return;
}
if (endDate && inputToSecs(endDate)! < inputToSecs(startDate)!) {
toast({
title: t('placesUi.createTour.endBeforeStart'),
description: t('placesUi.createTour.endBeforeStartDesc'),
variant: 'destructive',
});
return;
}
if (challenges.length === 0) {
toast({
title: t('placesUi.createTour.challengesRequired'),
description: t('placesUi.createTour.challengesRequiredDesc'),
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', PLATFORM_LIGHTNING_ADDRESS, 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: t('placesUi.createTour.updated'), description: t('placesUi.createTour.updatedDesc') });
} else {
const toastMessages = {
local: { title: t('placesUi.createTour.createdLocal'), description: t('placesUi.createTour.createdLocalDesc') },
national: { title: t('placesUi.createTour.createdNational'), description: t('placesUi.createTour.createdNationalDesc') },
international: { title: t('placesUi.createTour.createdInternational'), description: t('placesUi.createTour.createdInternationalDesc') },
destruction: { title: t('placesUi.createTour.createdDestruction'), description: t('placesUi.createTour.createdDestructionDesc') },
};
toast(toastMessages[tourCategory]);
}
navigate(`/tour/${user?.pubkey}/${identifier}`);
} catch (error) {
console.error('Failed to create tour:', error);
toast({
title: t('placesUi.createEvent.publishFailed'),
description: error instanceof Error ? error.message : t('placesUi.createTour.somethingWrong'),
variant: 'destructive',
});
}
};
if (!user) {
return (
<>
<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">{t('errors.loginRequired')}</h2>
<p className="text-muted-foreground mb-6">
{t('placesUi.createTour.loginToCreate')}
</p>
<LoginArea className="justify-center" />
</CardContent>
</Card>
</div>
</>
);
}
// 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 (
<>
<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">{t('placesUi.createTour.loadingProfile')}</h2>
<p className="text-muted-foreground">
{t('placesUi.createTour.loadingProfileDesc')}
</p>
</CardContent>
</Card>
</div>
</>
);
}
// Require NIP-05 for tour creation
if (!hasNip05) {
return (
<>
<div className="container px-4 py-8">
<Nip05Required role="tour organizers" />
</div>
</>
);
}
return (
<>
<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" />
{t('placesUi.shop.backToMap')}
</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 ? t('common.edit') : t('common.new')} · {TOUR_CATEGORY_KEYS[tourCategory] ? t(TOUR_CATEGORY_KEYS[tourCategory]) : 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 ? t('placesUi.createTour.editTour') : t('placesUi.createTour.dropTour')}
</h1>
<p className="text-gray-400 mt-1 text-sm">
{TOUR_CATEGORY_DESC_KEYS[tourCategory] ? t(TOUR_CATEGORY_DESC_KEYS[tourCategory]) : 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">
{t('placesUi.createTour.pickRide')}
</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 }}>{t('placesUi.createTour.howLocalTitle')}</p>
<ul className="text-gray-300 space-y-1 text-xs">
<li>{t('placesUi.createTour.howLocal1')}</li>
<li>{t('placesUi.createTour.howLocal2')}</li>
<li>{t('placesUi.createTour.howLocal3')}</li>
<li>{t('placesUi.createTour.howLocal4')}</li>
<li>{t('placesUi.createTour.howLocal5')}</li>
</ul>
</>
)}
{tourCategory === 'national' && (
<>
<p className="font-bold mb-1" style={{ color: TOUR_CATEGORIES[tourCategory].color }}>{t('placesUi.createTour.howNationalTitle')}</p>
<ul className="text-gray-300 space-y-1 text-xs">
<li>{t('placesUi.createTour.howNational1')}</li>
<li>{t('placesUi.createTour.howNational2')}</li>
<li>{t('placesUi.createTour.howNational3')}</li>
<li>{t('placesUi.createTour.howNational4')}</li>
<li>{t('placesUi.createTour.howNational5')}</li>
</ul>
</>
)}
{tourCategory === 'international' && (
<>
<p className="font-bold mb-1" style={{ color: TOUR_CATEGORIES[tourCategory].color }}>{t('placesUi.createTour.howInternationalTitle')}</p>
<ul className="text-gray-300 space-y-1 text-xs">
<li>{t('placesUi.createTour.howInternational1')}</li>
<li>{t('placesUi.createTour.howInternational2')}</li>
<li>{t('placesUi.createTour.howInternational3')}</li>
<li>{t('placesUi.createTour.howInternational4')}</li>
<li>{t('placesUi.createTour.howInternational5')}</li>
</ul>
</>
)}
{tourCategory === 'destruction' && (
<>
<p className="font-bold mb-1" style={{ color: TOUR_CATEGORIES[tourCategory].color }}>{t('placesUi.createTour.howDestructionTitle')}</p>
<ul className="text-gray-300 space-y-1 text-xs">
<li>{t('placesUi.createTour.howDestruction1')}</li>
<li>{t('placesUi.createTour.howDestruction2')}</li>
<li>{t('placesUi.createTour.howDestruction3')}</li>
<li>{t('placesUi.createTour.howDestruction4')}</li>
<li>{t('placesUi.createTour.howDestruction5')}</li>
<li>{t('placesUi.createTour.howDestruction6')}</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' ? t('placesUi.createTour.worldDestruction') : t('placesUi.createTour.tourDetails')}
</CardTitle>
<CardDescription className="text-gray-400">
{tourCategory === 'destruction'
? t('placesUi.createTour.destructionDesc')
: t('placesUi.createTour.tourDesc')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Tour Title */}
<div className="space-y-2">
<Label htmlFor="title">{t('placesUi.createTour.tourTitle')} *</Label>
<Input
id="title"
placeholder={t('placesUi.createTour.titlePlaceholder')}
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">{t('spots.description')}</Label>
<Textarea
id="description"
placeholder={t('placesUi.createTour.descriptionPlaceholder')}
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' ? t('placesUi.createTour.cityRegion') : t('placesUi.createTour.startingCity')} *</Label>
<Input
id="location"
placeholder={tourCategory === 'local' ? t('placesUi.createTour.cityPlaceholder') : t('placesUi.createTour.startingCityPlaceholder')}
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 }}>{t('placesUi.createTour.meetingPointLabel')} *</Label>
</div>
<p className="text-sm text-gray-400 mb-3">
{t('placesUi.createTour.meetingPointDesc')}
</p>
<Input
placeholder={t('placesUi.createTour.meetingPointPlaceholder')}
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={t('placesUi.createTour.latOptional')}
value={meetingLat}
onChange={(e) => setMeetingLat(e.target.value)}
className="bg-black border-cyan-500/30"
/>
</div>
<div>
<Input
placeholder={t('placesUi.createTour.lngOptional')}
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">
{t('placesUi.createTour.coordsHint')}
</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" />
{t('createSpot.gettingLocation')}
</>
) : (
<>
<MapPin className="h-4 w-4 mr-2" />
{t('createSpot.useCurrentLocation')}
</>
)}
</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 }} />
{t('placesUi.createTour.duration')}
</Label>
<span className="font-bold" style={{ color: TOUR_CATEGORIES[tourCategory].color }}>
{tourCategory === 'destruction'
? t('placesUi.tourCard.durationMonths', { count: Math.ceil(duration[0] / 4) })
: tourCategory === 'international'
? t('placesUi.createTour.durationWeeksMonths', { weeks: duration[0], months: Math.ceil(duration[0] / 4) })
: durationUnit === 'hours'
? t('placesUi.tourCard.durationHours', { count: duration[0] })
: durationUnit === 'days'
? t('placesUi.tourCard.durationDays', { count: duration[0] })
: t('placesUi.tourCard.durationWeeks', { count: duration[0] })
}
</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' && t('placesUi.createTour.durationHintLocal')}
{tourCategory === 'national' && t('placesUi.createTour.durationHintNational')}
{tourCategory === 'international' && t('placesUi.createTour.durationHintInternational')}
{tourCategory === 'destruction' && t('placesUi.createTour.durationHintDestruction')}
</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 }}>
{t('placesUi.createTour.schedule')}
</Label>
</div>
</div>
<p className="text-xs text-gray-400 -mt-1">
{t('placesUi.createTour.scheduleDesc')}
</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" /> {t('placesUi.createTour.fixedSession')}
</span>
<span className={cn('text-[10px]', hasFixedSession ? 'text-white/80' : 'text-gray-500')}>
{t('placesUi.createTour.fixedSessionDesc')}
</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" /> {t('placesUi.createTour.flexible')}
</span>
<span className={cn('text-[10px]', !hasFixedSession ? 'text-white/80' : 'text-gray-500')}>
{t('placesUi.createTour.flexibleDesc')}
</span>
</Button>
</div>
{hasFixedSession ? (
<div className="space-y-2 pt-1">
<Label className="text-xs">{t('placesUi.createTour.sessionDateTime')} *</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">
{t('placesUi.createTour.sessionDateTimeHint')}
</p>
</div>
) : (
<div className="space-y-2 pt-1">
<Label htmlFor="availability" className="text-xs">{t('placesUi.createTour.availabilityQuestion')}</Label>
<Input
id="availability"
placeholder={t('placesUi.createTour.availabilityPlaceholder')}
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 }}>
{t('placesUi.createTour.tripDatesTitle')}
</Label>
</div>
<p className="text-xs text-gray-400 -mt-1">
{t('placesUi.createTour.tripDatesDesc')}
</p>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs">{t('placesUi.createTour.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">{t('placesUi.createTour.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>{t('placesUi.tourDetail.skillLevel')}</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">{TOUR_SKILL_KEYS[key] ? t(TOUR_SKILL_KEYS[key]) : level.label}</span>
</Button>
))}
</div>
</div>
)}
{/* Spot Types - not shown for destruction tours (all spots included) */}
{tourCategory !== 'destruction' && (
<div className="space-y-3">
<Label>{t('placesUi.createTour.spotTypes')} *</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>{TOUR_SPOT_TYPE_KEYS[key] ? t(TOUR_SPOT_TYPE_KEYS[key]) : 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 }} />
{t('placesUi.createTour.maxGroupSize')}
</Label>
<span className="font-bold" style={{ color: TOUR_CATEGORIES[tourCategory].color }}>{t('placesUi.tourDetail.people', { count: maxGroupSize[0] })}</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' ? t('placesUi.createTour.roadTripDetails') : t('placesUi.createTour.internationalDetails')}
</Label>
</div>
{/* Cities */}
<div className="space-y-2">
<Label>{t('placesUi.createTour.citiesOnTour')}</Label>
<div className="flex gap-2">
<Input
placeholder={t('placesUi.createTour.addCity')}
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>{t('placesUi.createTour.countriesLabel')} *</Label>
<div className="flex gap-2">
<Input
placeholder={t('placesUi.createTour.addCountry')}
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>{t('placesUi.createTour.transportInfo')}</Label>
<Textarea
placeholder={t('placesUi.createTour.transportPlaceholder')}
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>{t('placesUi.createTour.accommodationInfo')}</Label>
<Textarea
placeholder={t('placesUi.createTour.accommodationPlaceholder')}
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">{t('placesUi.createTour.competitionLocations')}</Label>
</div>
<p className="text-xs text-gray-400">{t('placesUi.createTour.competitionLocationsDesc')}</p>
{/* Cities */}
<div className="space-y-2">
<Label className="text-sm">{t('placesUi.createTour.citiesLabel')}</Label>
<div className="flex gap-2">
<Input
placeholder={t('placesUi.createTour.addCity')}
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">{t('placesUi.createTour.countriesLabel')}</Label>
<div className="flex gap-2">
<Input
placeholder={t('placesUi.createTour.addCountry')}
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" />
{t('placesUi.createTour.organizerContact')} *
</Label>
<Textarea
placeholder={t('placesUi.createTour.organizerContactPlaceholder')}
value={organizerContact}
onChange={(e) => setOrganizerContact(e.target.value)}
rows={2}
className="bg-black border-red-500/30"
/>
<p className="text-xs text-gray-500">{t('placesUi.createTour.organizerContactHint')}</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" />
{t('placesUi.createTour.transportInfo')}
</Label>
<Textarea
placeholder={t('placesUi.createTour.transportDestructionPlaceholder')}
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">{t('placesUi.createTour.competitionDates')}</Label>
</div>
<p className="text-xs text-gray-400">
{t('placesUi.createTour.competitionDatesWarning')}
</p>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs">{t('placesUi.createTour.startDate')} *</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">{t('placesUi.createTour.endDate')}</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">{t('placesUi.createTour.teamSettings')}</Label>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm">{t('placesUi.createTour.maxTeams')}</Label>
<span className="text-red-400 font-bold">{t('placesUi.createTour.teamsCount', { count: maxTeams[0] })}</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">{t('placesUi.createTour.minTeamSize')}</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">{t('placesUi.createTour.maxTeamSize')}</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">{t('placesUi.createTour.entryPrizes')}</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" />
{t('placesUi.createTour.entryFeePerTeam')}
</Label>
<Input
type="number"
placeholder={t('placesUi.createTour.entryFeePlaceholder')}
value={entryFee}
onChange={(e) => setEntryFee(e.target.value)}
className="bg-black border-red-500/30"
/>
<p className="text-[10px] text-gray-500">{t('placesUi.createTour.entryFeeHint')}</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" />
{t('placesUi.createTour.totalPrizePool')}
</Label>
<Input
type="number"
placeholder={t('placesUi.createTour.prizePoolPlaceholder')}
value={prizePool}
onChange={(e) => setPrizePool(e.target.value)}
className="bg-black border-red-500/30"
/>
<p className="text-[10px] text-gray-500">{t('placesUi.createTour.prizePoolHint')}</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">{t('placesUi.createTour.prizeDistribution')}</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 && t('placesUi.createTour.place1')}
{prize.place === 2 && t('placesUi.createTour.place2')}
{prize.place === 3 && t('placesUi.createTour.place3')}
</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">
{t('placesUi.createTour.totalPercent', { percent: 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">{t('placesUi.createTour.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" />
{t('placesUi.createTour.addChallenge')}
</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">
{t('placesUi.createTour.challengesSecret')}
</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">{t('placesUi.createTour.noChallenges')}</p>
<p className="text-xs text-gray-600">{t('placesUi.createTour.noChallengesDesc')}</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={t('placesUi.createTour.challengeTitlePlaceholder')}
value={challenge.title}
onChange={(e) => updateChallenge(idx, { title: e.target.value })}
className="bg-black border-gray-700"
/>
<Textarea
placeholder={t('placesUi.createTour.challengeDescPlaceholder')}
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">{t('placesUi.createTour.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" /> {t('placesUi.createTour.secretUntilStart')}
</span>
</div>
</div>
))}
</div>
)}
</div>
{/* Accepted Clip Sources */}
<div className="space-y-3">
<Label>{t('placesUi.createTour.acceptedClipSources')}</Label>
<p className="text-xs text-gray-400">{t('placesUi.createTour.acceptedClipSourcesDesc')}</p>
<div className="flex flex-wrap gap-2">
{[
{ id: 'blossom', label: t('placesUi.createTour.clipBlossom'), desc: '< 50MB' },
{ id: 'youtube', label: t('placesUi.createTour.clipYouTube') },
{ id: 'instagram', label: t('placesUi.createTour.clipInstagram') },
{ id: 'any', label: t('placesUi.createTour.clipAnyUrl') },
].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">{t('placesUi.createTour.filmPremieres')}</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 ? t('placesUi.createTour.enabled') : t('placesUi.createTour.enable')}
</Button>
</div>
<p className="text-xs text-gray-400 mt-1">
{t('placesUi.createTour.filmPremieresDesc')}
</p>
</div>
</div>
{enableFilmRelease && (
<div className="space-y-4 pt-3 border-t border-purple-500/20">
<p className="text-xs text-purple-300">
{t('placesUi.createTour.filmPremieresInfo')}
</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">{t('placesUi.createTour.localShopPremieres')}</span>
</div>
<span className={cn(
'text-xs',
filmReleaseAtShops ? 'text-black/70' : 'text-gray-400'
)}>
{t('placesUi.createTour.localShopPremieresDesc')}
</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">{t('placesUi.createTour.onlineRelease')}</span>
</div>
<span className={cn(
'text-xs',
filmReleaseOnline ? 'text-white/70' : 'text-gray-400'
)}>
{t('placesUi.createTour.onlineReleaseDesc')}
</span>
</Button>
</div>
{/* Film Release Date */}
<div className="space-y-2">
<Label className="text-purple-400 text-sm">{t('placesUi.createTour.filmReleaseDate')}</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">
{t('placesUi.createTour.filmReleaseDateHint')}
</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]">
{t('placesUi.createTour.shopPremieresHow')}
</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 ? t('placesUi.createTour.hide') : t('placesUi.createTour.show')} {t('placesUi.createTour.availableShops', { count: 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">{t('placesUi.createTour.loadingShops')}</span>
</div>
) : shops && shops.length > 0 ? (
<>
<p className="text-xs text-gray-400 px-2 pb-2 border-b border-gray-800">
{t('placesUi.createTour.shopPremieresContact')}
</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()}
>
🌐 {t('shops.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()}
>
📸 {t('placesUi.createShop.instagram')}
</a>
)}
{shop.email && (
<a
href={`mailto:${shop.email}`}
className="text-xs text-orange-400 hover:text-orange-300"
onClick={(e) => e.stopPropagation()}
>
✉️ {t('placesUi.createShop.email')}
</a>
)}
{shop.phone && (
<a
href={`tel:${shop.phone}`}
className="text-xs text-green-400 hover:text-green-300"
onClick={(e) => e.stopPropagation()}
>
📞 {t('placesUi.food.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()}
>
{t('placesUi.createTour.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">{t('placesUi.createTour.noShopsRegistered')}</p>
<p className="text-xs text-gray-500 mt-1">
{t('placesUi.createTour.shopsCanRegister')}
</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" />
{t('placesUi.createTour.pricePerPerson')}
</Label>
<Input
id="price"
type="number"
placeholder={t('placesUi.createTour.pricePlaceholder')}
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">
{t('placesUi.createTour.platformFee', { percent: tourCategory === 'local' ? '10%' : tourCategory === 'national' ? '7%' : '5%' })}
</p>
{price && parseInt(price) > 0 && (
<div className="text-xs mt-1" style={{ color: TOUR_CATEGORIES[tourCategory].color }}>
{t('placesUi.createTour.youReceive', { amount: Math.floor(parseInt(price) * (tourCategory === 'local' ? 0.9 : tourCategory === 'national' ? 0.93 : 0.95)).toLocaleString() })}
</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">{t('placesUi.tourDetail.partnerDiscount')}</Label>
<p className="text-xs text-gray-400">
{t('placesUi.createTour.partnerDiscountDesc')}
</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">{t('placesUi.createTour.howItWorks')}</p>
<ul className="space-y-1">
<li>{t('placesUi.createTour.partnerStep1')}</li>
<li>{t('placesUi.createTour.partnerStep2')}</li>
<li>{t('placesUi.createTour.partnerStep3')}</li>
<li>{t('placesUi.createTour.partnerStep4')}</li>
</ul>
</div>
{/* Shop Selection */}
<div className="space-y-2">
<Label>{t('placesUi.createTour.selectPartnerShop')}</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">{t('placesUi.createTour.loadingShops')}</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" />
{t('placesUi.createTour.noPartnerShop')}
</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>{t('placesUi.createTour.noShopsFound')}</p>
<Link to="/create-shop" className="text-[#ccff00] hover:underline inline-flex items-center gap-1">
<Store className="h-3 w-3" />
{t('placesUi.createTour.addShopFirst')}
</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">{t('placesUi.createTour.proTipTitle')}</p>
<p>{t('placesUi.createTour.proTipDesc')}</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]" />
{t('placesUi.createTour.discountPercentage')}
</Label>
<span className="text-[#ccff00] font-bold text-lg">{t('placesUi.createTour.percentOff', { percent: shopDiscount[0] })}</span>
</div>
<Slider
value={shopDiscount}
onValueChange={setShopDiscount}
min={5}
max={50}
step={5}
className="w-full"
/>
<p className="text-xs text-gray-500">
{t('placesUi.createTour.discountHint')}
</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" />
{t('placesUi.createTour.languagesSpoken')}
</Label>
{languages.length > 0 && (
<span className="text-xs text-cyan-400">
{t('placesUi.createTour.languagesSelected', { count: languages.length })}
</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 ? t('common.showLess') : t('placesUi.createTour.showAllLanguages', { count: ALL_LANGUAGES.length })}</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">{t('placesUi.createTour.alsoSelected')}</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>{t('placesUi.createTour.tourPhoto')}</Label>
<div className="flex gap-4">
{image ? (
<div className="relative w-32 h-24 rounded-lg overflow-hidden bg-muted">
<img src={image} alt={t('placesUi.createTour.tourPhoto')} 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">{t('placesUi.createTour.upload')}</span>
</>
)}
</label>
)}
<div className="text-xs text-gray-500 flex-1">
<p>{t('placesUi.createTour.photoHint')}</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"
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 ? t('placesUi.editEvent.saving') : t('createEvent.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
? t('placesUi.editEvent.saveChanges')
: tourCategory === 'destruction'
? t('placesUi.createTour.createDestructionTour')
: t('placesUi.events.createTour')}
</>
)}
</Button>
</CardContent>
</Card>
</form>
</div>
</>
);
}