- 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
2513 lines
120 KiB
TypeScript
2513 lines
120 KiB
TypeScript
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 & 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 & 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 & 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 & 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 & 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>
|
||
);
|
||
}
|