1280 lines
58 KiB
TypeScript
1280 lines
58 KiB
TypeScript
import { useState, useEffect, useMemo } from 'react';
|
||
import { useSeoMeta } from '@unhead/react';
|
||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||
import ngeohash from 'ngeohash';
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Textarea } from '@/components/ui/textarea';
|
||
import { Label } from '@/components/ui/label';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||
import { useTours } from '@/hooks/useTours';
|
||
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
||
import { useSkateShops } from '@/hooks/useSkateShops';
|
||
import { useToast } from '@/hooks/useToast';
|
||
import { useTranslation } from '@/contexts/language';
|
||
import { LoginArea } from '@/components/auth/LoginArea';
|
||
import { CALENDAR_EVENT_KIND, EVENT_TYPE_LABELS, TOUR_CATEGORIES, SKATE_TOUR_KIND, SKATE_SPOT_KIND, SKATE_SHOP_KIND, SKATE_CREW_KIND, SKATE_MUSICIAN_KIND, SPOT_CATEGORIES, EVENT_SPONSOR_TIERS, eventSponsorTags, type SkateTour, type SkateSpot, type EventSponsor } from '@/lib/types';
|
||
import { useCrew } from '@/hooks/useCrews';
|
||
import { useLabProfile } from '@/hooks/useLabProfiles';
|
||
import { Users } from 'lucide-react';
|
||
import { cn } from '@/lib/utils';
|
||
import {
|
||
Calendar,
|
||
Upload,
|
||
X,
|
||
Loader2,
|
||
ArrowLeft,
|
||
MapPin,
|
||
Route,
|
||
ChevronDown,
|
||
Link2,
|
||
Skull,
|
||
Globe,
|
||
Map,
|
||
Zap,
|
||
Music,
|
||
Sparkles,
|
||
Plus,
|
||
Store,
|
||
} from 'lucide-react';
|
||
import { FeaturedEventUpgrade } from '@/components/FeaturedEventUpgrade';
|
||
import { usePlatformFee } from '@/hooks/usePlatformFee';
|
||
import { Link } from 'react-router-dom';
|
||
|
||
const EVENT_TYPES = Object.entries(EVENT_TYPE_LABELS);
|
||
|
||
import { EVENT_TYPE_KEYS, SPOT_CATEGORY_KEYS, TOUR_CATEGORY_KEYS } from '@/lib/labelKeys';
|
||
|
||
function eventTypeLabel(t: (key: string) => string, type: string): string {
|
||
return EVENT_TYPE_KEYS[type] ? t(EVENT_TYPE_KEYS[type]) : (EVENT_TYPE_LABELS[type] || type);
|
||
}
|
||
|
||
export default function CreateEventPage() {
|
||
const { t } = useTranslation();
|
||
|
||
useSeoMeta({
|
||
title: t('seoUi.createEventTitle'),
|
||
description: t('seoUi.createEventDesc'),
|
||
});
|
||
|
||
const navigate = useNavigate();
|
||
const [searchParams] = useSearchParams();
|
||
// Optional crew host: `?crew=<pubkey>:<identifier>` — the event is hosted BY a crew.
|
||
const crewParam = searchParams.get('crew') ?? '';
|
||
const [crewHostPubkey, crewHostIdentifier] = crewParam.includes(':')
|
||
? crewParam.split(':')
|
||
: ['', ''];
|
||
const { data: hostCrew } = useCrew(crewHostIdentifier, crewHostPubkey);
|
||
// Optional featured musician: `?musician=<pubkey>:<identifier>` — a music act
|
||
// (Lab kind 35011) playing this event. Connects Music ↔ Events.
|
||
const musicianParam = searchParams.get('musician') ?? '';
|
||
const [musicianPubkey, musicianIdentifier] = musicianParam.includes(':')
|
||
? musicianParam.split(':')
|
||
: ['', ''];
|
||
const { data: musicAct } = useLabProfile(musicianIdentifier, musicianPubkey);
|
||
// Optional deep-link prefill: `?type=concert&title=...` — used by e.g. the
|
||
// "Book for a gig" action on a crew that has a band.
|
||
const prefillType = searchParams.get('type') ?? '';
|
||
const prefillTitle = searchParams.get('title') ?? '';
|
||
const { user, metadata } = useCurrentUser();
|
||
const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish();
|
||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||
const { toast } = useToast();
|
||
|
||
// Fetch available tours for linking
|
||
const { data: tours, isLoading: toursLoading } = useTours(100);
|
||
|
||
// Fetch available spots for linking
|
||
const { data: spots, isLoading: spotsLoading } = useSkateSpots(200);
|
||
// Fetch available shops for linking (an event can be held at / hosted by a shop)
|
||
const { data: shops, isLoading: shopsLoading } = useSkateShops(200);
|
||
|
||
// Form state
|
||
const [title, setTitle] = useState('');
|
||
const [description, setDescription] = useState('');
|
||
const [location, setLocation] = useState('');
|
||
const [latitude, setLatitude] = useState('');
|
||
const [longitude, setLongitude] = useState('');
|
||
const [startDate, setStartDate] = useState('');
|
||
const [startTime, setStartTime] = useState('');
|
||
const [endDate, setEndDate] = useState('');
|
||
const [endTime, setEndTime] = useState('');
|
||
const [selectedTypes, setSelectedTypes] = useState<string[]>([]);
|
||
const [image, setImage] = useState<string | null>(null);
|
||
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
||
|
||
// Tour linking state
|
||
const [linkedTourId, setLinkedTourId] = useState<string>('none');
|
||
const [tourLinkExpanded, setTourLinkExpanded] = useState(false);
|
||
|
||
// Spot linking state
|
||
const [linkedSpotId, setLinkedSpotId] = useState<string>('none');
|
||
const [showSpotSelector, setShowSpotSelector] = useState(false);
|
||
const [spotSearchQuery, setSpotSearchQuery] = useState('');
|
||
|
||
// Optional shop link — an event can be held at / hosted by a skate shop.
|
||
const [linkedShopId, setLinkedShopId] = useState<string>('none');
|
||
const [showShopSelector, setShowShopSelector] = useState(false);
|
||
const [shopSearchQuery, setShopSearchQuery] = useState('');
|
||
|
||
// Paid event / entry fee
|
||
const [entryFee, setEntryFee] = useState('');
|
||
const isPaidEvent = entryFee.trim().length > 0 && entryFee.trim() !== '0';
|
||
// Optional crowdfunding goal in sats (NIP-57 zaps fund the event).
|
||
const [fundingGoal, setFundingGoal] = useState('');
|
||
|
||
// Optional sponsors (logo strip on the event page).
|
||
const [sponsors, setSponsors] = useState<EventSponsor[]>([]);
|
||
const addSponsor = () =>
|
||
setSponsors((prev) => [...prev, { tier: 'partner', name: '', logo: '', url: '' }]);
|
||
const updateSponsor = (i: number, patch: Partial<EventSponsor>) =>
|
||
setSponsors((prev) => prev.map((s, idx) => (idx === i ? { ...s, ...patch } : s)));
|
||
const removeSponsor = (i: number) =>
|
||
setSponsors((prev) => prev.filter((_, idx) => idx !== i));
|
||
// Optional "Feature this event" upgrade — every event posts for free.
|
||
const { isActive: isFeatured, featuredUntilSeconds } = usePlatformFee('featuredEvent');
|
||
const [goFeatured, setGoFeatured] = useState(false);
|
||
|
||
// Get the selected tour (if any)
|
||
const selectedTour = tours?.find((t) => `${t.pubkey}:${t.identifier}` === linkedTourId);
|
||
|
||
// Get the selected spot (if any)
|
||
const selectedSpot = spots?.find((s) => `${s.pubkey}:${s.identifier}` === linkedSpotId);
|
||
// Get the selected shop (if any)
|
||
const selectedShop = shops?.find((s) => `${s.pubkey}:${s.identifier}` === linkedShopId);
|
||
const filteredShops = shops?.filter((shop) => {
|
||
if (!shopSearchQuery) return true;
|
||
const q = shopSearchQuery.toLowerCase();
|
||
return shop.name.toLowerCase().includes(q) || (shop.location?.toLowerCase().includes(q) ?? false);
|
||
});
|
||
|
||
// Filter spots by search query
|
||
const filteredSpots = spots?.filter((spot) => {
|
||
if (!spotSearchQuery) return true;
|
||
const query = spotSearchQuery.toLowerCase();
|
||
return (
|
||
spot.title.toLowerCase().includes(query) ||
|
||
spot.location?.toLowerCase().includes(query) ||
|
||
spot.category?.toLowerCase().includes(query)
|
||
);
|
||
});
|
||
|
||
// Group tours by category for easier selection
|
||
const toursByCategory = tours?.reduce((acc, tour) => {
|
||
const cat = tour.category || 'local';
|
||
if (!acc[cat]) acc[cat] = [];
|
||
acc[cat].push(tour);
|
||
return acc;
|
||
}, {} as Record<string, SkateTour[]>);
|
||
|
||
// Group spots by category for easier selection
|
||
const spotsByCategory = filteredSpots?.reduce((acc, spot) => {
|
||
const cat = spot.category || 'street';
|
||
if (!acc[cat]) acc[cat] = [];
|
||
acc[cat].push(spot);
|
||
return acc;
|
||
}, {} as Record<string, SkateSpot[]>);
|
||
|
||
// Today (local) as YYYY-MM-DD, used as the min for the date pickers so hosts
|
||
// can't accidentally schedule an event in the past.
|
||
const todayStr = useMemo(() => {
|
||
const d = new Date();
|
||
const off = d.getTimezoneOffset();
|
||
return new Date(d.getTime() - off * 60_000).toISOString().split('T')[0];
|
||
}, []);
|
||
|
||
const toggleType = (type: string) => {
|
||
setSelectedTypes((prev) =>
|
||
prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type]
|
||
);
|
||
};
|
||
|
||
// Prefill from deep-link params once on mount (e.g. "Book for a gig").
|
||
useEffect(() => {
|
||
if (prefillType && EVENT_TYPE_LABELS[prefillType]) {
|
||
setSelectedTypes((prev) => (prev.includes(prefillType) ? prev : [...prev, prefillType]));
|
||
}
|
||
if (prefillTitle) {
|
||
setTitle((prev) => prev || prefillTitle);
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
const getCurrentLocation = () => {
|
||
if (!navigator.geolocation) {
|
||
toast({
|
||
title: t('placesUi.createEvent.geoNotSupported'),
|
||
description: t('placesUi.createEvent.geoNotSupportedDesc'),
|
||
variant: 'destructive',
|
||
});
|
||
return;
|
||
}
|
||
|
||
setIsGettingLocation(true);
|
||
navigator.geolocation.getCurrentPosition(
|
||
(position) => {
|
||
setLatitude(position.coords.latitude.toFixed(6));
|
||
setLongitude(position.coords.longitude.toFixed(6));
|
||
setIsGettingLocation(false);
|
||
toast({
|
||
title: t('placesUi.createEvent.locationFound'),
|
||
description: t('placesUi.createEvent.locationFoundDesc'),
|
||
});
|
||
},
|
||
(error) => {
|
||
setIsGettingLocation(false);
|
||
toast({
|
||
title: t('placesUi.createEvent.locationError'),
|
||
description: error.message,
|
||
variant: 'destructive',
|
||
});
|
||
}
|
||
);
|
||
};
|
||
|
||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const files = e.target.files;
|
||
if (!files || files.length === 0) return;
|
||
|
||
try {
|
||
const file = files[0];
|
||
const tags = await uploadFile(file);
|
||
const url = tags[0]?.[1];
|
||
if (url) {
|
||
setImage(url);
|
||
toast({
|
||
title: t('placesUi.createEvent.imageUploaded'),
|
||
description: t('placesUi.createEvent.imageUploadedDesc'),
|
||
});
|
||
}
|
||
} catch (error) {
|
||
toast({
|
||
title: t('placesUi.createEvent.uploadFailed'),
|
||
description: error instanceof Error ? error.message : t('placesUi.createEvent.uploadFailedDesc'),
|
||
variant: 'destructive',
|
||
});
|
||
}
|
||
|
||
// Clear the input
|
||
e.target.value = '';
|
||
};
|
||
|
||
const removeImage = () => {
|
||
setImage(null);
|
||
};
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
|
||
if (!title.trim()) {
|
||
toast({
|
||
title: t('placesUi.createEvent.titleRequired'),
|
||
description: t('placesUi.createEvent.titleRequiredDesc'),
|
||
variant: 'destructive',
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (!startDate || !startTime) {
|
||
toast({
|
||
title: t('placesUi.createEvent.startRequired'),
|
||
description: t('placesUi.createEvent.startRequiredDesc'),
|
||
variant: 'destructive',
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Parse start time
|
||
const startDateTime = new Date(`${startDate}T${startTime}`);
|
||
const startTimestamp = Math.floor(startDateTime.getTime() / 1000);
|
||
|
||
if (isNaN(startTimestamp)) {
|
||
toast({
|
||
title: t('placesUi.createEvent.invalidDate'),
|
||
description: t('placesUi.createEvent.invalidDateDesc'),
|
||
variant: 'destructive',
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Guard against scheduling in the past (a 10-min grace window covers
|
||
// "starting right now" without rejecting it).
|
||
if (startTimestamp < Math.floor(Date.now() / 1000) - 600) {
|
||
toast({
|
||
title: t('placesUi.createEvent.pastTitle'),
|
||
description: t('placesUi.createEvent.pastDesc'),
|
||
variant: 'destructive',
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Parse end time if provided
|
||
let endTimestamp: number | undefined;
|
||
if (endDate && endTime) {
|
||
const endDateTime = new Date(`${endDate}T${endTime}`);
|
||
endTimestamp = Math.floor(endDateTime.getTime() / 1000);
|
||
|
||
if (isNaN(endTimestamp) || endTimestamp <= startTimestamp) {
|
||
toast({
|
||
title: t('placesUi.createEvent.invalidEnd'),
|
||
description: t('placesUi.createEvent.invalidEndDesc'),
|
||
variant: 'destructive',
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
|
||
try {
|
||
// Create unique identifier
|
||
const identifier = title
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9]+/g, '-')
|
||
.replace(/^-|-$/g, '')
|
||
.slice(0, 50) + '-' + Date.now().toString(36);
|
||
|
||
// Build tags
|
||
const tags: string[][] = [
|
||
['d', identifier],
|
||
['title', title.trim()],
|
||
['start', startTimestamp.toString()],
|
||
['t', 'skateboarding'],
|
||
['alt', `Skate event: ${title.trim()}`],
|
||
];
|
||
|
||
if (endTimestamp) {
|
||
tags.push(['end', endTimestamp.toString()]);
|
||
}
|
||
|
||
if (location.trim()) {
|
||
tags.push(['location', location.trim()]);
|
||
}
|
||
|
||
// Add geohash if coordinates provided
|
||
if (latitude && longitude) {
|
||
const lat = parseFloat(latitude);
|
||
const lng = parseFloat(longitude);
|
||
if (!isNaN(lat) && !isNaN(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) {
|
||
const geohash = ngeohash.encode(lat, lng, 7);
|
||
tags.push(['g', geohash]);
|
||
}
|
||
}
|
||
|
||
for (const type of selectedTypes) {
|
||
tags.push(['t', type]);
|
||
}
|
||
|
||
if (image) {
|
||
tags.push(['image', image]);
|
||
}
|
||
|
||
// Add tour link if selected
|
||
if (selectedTour && linkedTourId !== 'none') {
|
||
// Use 'a' tag to reference the tour (addressable event reference)
|
||
tags.push(['a', `${SKATE_TOUR_KIND}:${selectedTour.pubkey}:${selectedTour.identifier}`]);
|
||
// Also add a readable tour-name tag for easier display
|
||
tags.push(['tour-name', selectedTour.title]);
|
||
// Add tour category tag for filtering
|
||
tags.push(['tour-category', selectedTour.category]);
|
||
}
|
||
|
||
// Entry fee tag
|
||
if (isPaidEvent) {
|
||
tags.push(['entry-fee', entryFee.trim()]);
|
||
tags.push(['t', 'paid']);
|
||
}
|
||
|
||
// Optional crowdfunding goal (sats) — shown as a funding progress bar and
|
||
// funded peer-to-peer via zaps (no escrow).
|
||
if (fundingGoal.trim() && Number(fundingGoal) > 0) {
|
||
tags.push(['goal', String(Math.floor(Number(fundingGoal)))]);
|
||
}
|
||
|
||
// Featured listing (optional, paid)
|
||
if (isFeatured && featuredUntilSeconds) {
|
||
tags.push(['featured', String(featuredUntilSeconds)]);
|
||
}
|
||
|
||
// Add spot link if selected
|
||
if (selectedSpot && linkedSpotId !== 'none') {
|
||
// Use 'a' tag to reference the spot (addressable event reference)
|
||
tags.push(['a', `${SKATE_SPOT_KIND}:${selectedSpot.pubkey}:${selectedSpot.identifier}`]);
|
||
// Also add a readable spot-name tag for easier display
|
||
tags.push(['spot-name', selectedSpot.title]);
|
||
// Add spot category tag for filtering
|
||
if (selectedSpot.category) {
|
||
tags.push(['spot-category', selectedSpot.category]);
|
||
}
|
||
}
|
||
|
||
// Optional shop link (venue / host shop) — same addressable ref pattern.
|
||
if (selectedShop && linkedShopId !== 'none') {
|
||
tags.push(['a', `${SKATE_SHOP_KIND}:${selectedShop.pubkey}:${selectedShop.identifier}`]);
|
||
tags.push(['venue-name', selectedShop.name]);
|
||
}
|
||
|
||
// Crew host — link the event back to the crew coordinate so it shows on
|
||
// the crew page and on the event as "Hosted by [crew]".
|
||
if (hostCrew) {
|
||
tags.push(['a', `${SKATE_CREW_KIND}:${hostCrew.pubkey}:${hostCrew.identifier}`]);
|
||
tags.push(['crew-name', hostCrew.name]);
|
||
}
|
||
|
||
// Featured musician — link a music act (Lab kind 35011) to the event so it
|
||
// shows "🎸 Live: [act]" and surfaces on the musician's gig list.
|
||
if (musicAct && musicAct.profileType === 'musician') {
|
||
tags.push(['a', `${SKATE_MUSICIAN_KIND}:${musicAct.pubkey}:${musicAct.identifier}`]);
|
||
tags.push(['music-act', musicAct.title]);
|
||
}
|
||
|
||
// Sponsors — a logo strip on the event page.
|
||
const namedSponsors = sponsors.filter((s) => s.name.trim());
|
||
if (namedSponsors.length > 0) {
|
||
tags.push(...eventSponsorTags(namedSponsors));
|
||
}
|
||
|
||
await createEvent({
|
||
kind: CALENDAR_EVENT_KIND,
|
||
content: description.trim(),
|
||
tags,
|
||
});
|
||
|
||
toast({
|
||
title: t('placesUi.createEvent.created'),
|
||
description: t('placesUi.createEvent.createdDesc'),
|
||
});
|
||
|
||
navigate(`/event/${user?.pubkey}/${identifier}`);
|
||
} catch (error) {
|
||
toast({
|
||
title: t('placesUi.createEvent.publishFailed'),
|
||
description: error instanceof Error ? error.message : t('placesUi.createEvent.somethingWrong'),
|
||
variant: 'destructive',
|
||
});
|
||
}
|
||
};
|
||
|
||
if (!user) {
|
||
return (
|
||
<>
|
||
<div className="container px-4 py-8">
|
||
<Card className="max-w-xl mx-auto border-dashed">
|
||
<CardContent className="py-12 text-center">
|
||
<Calendar className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
|
||
<h2 className="text-xl font-bold mb-2">{t('errors.loginRequired')}</h2>
|
||
<p className="text-muted-foreground mb-6">
|
||
{t('createEvent.loginToCreate')}
|
||
</p>
|
||
<LoginArea className="justify-center" />
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
const hasNip05 = metadata?.nip05;
|
||
|
||
return (
|
||
<>
|
||
<div className="container px-4 sm:px-6 py-6 sm:py-8 max-w-2xl">
|
||
<div className="mb-6">
|
||
<Link to="/events" className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-4">
|
||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||
{t('createEvent.backToEvents')}
|
||
</Link>
|
||
<h1 className="text-3xl font-black uppercase neon-red" style={{
|
||
fontFamily: 'Impact, "Arial Black", sans-serif'
|
||
}}>{t('createEvent.title')}</h1>
|
||
<p className="text-gray-400 mt-1">
|
||
{t('createEvent.subtitle')}
|
||
</p>
|
||
|
||
{hostCrew && (
|
||
<Card className="mt-4 border-2 border-teal-500/40 bg-teal-500/10">
|
||
<CardContent className="flex items-center gap-3 p-4">
|
||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-teal-500/20">
|
||
<Users className="h-5 w-5 text-teal-400" />
|
||
</div>
|
||
<div className="min-w-0">
|
||
<p className="text-sm font-bold text-teal-300">{t('placesUi.createEvent.hostingAs', { name: hostCrew.name })}</p>
|
||
<p className="text-xs text-gray-400">
|
||
{t('placesUi.createEvent.hostingAsDesc')}
|
||
</p>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{musicAct && musicAct.profileType === 'musician' && (
|
||
<Card className="mt-4 border-2 border-fuchsia-500/40 bg-fuchsia-500/10">
|
||
<CardContent className="flex items-center gap-3 p-4">
|
||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-fuchsia-500/20">
|
||
<Music className="h-5 w-5 text-fuchsia-400" />
|
||
</div>
|
||
<div className="min-w-0">
|
||
<p className="text-sm font-bold text-fuchsia-300">{t('placesUi.createEvent.featuring', { name: musicAct.title })}</p>
|
||
<p className="text-xs text-gray-400">
|
||
{t('placesUi.createEvent.featuringDesc')}
|
||
</p>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{!hasNip05 && (
|
||
<Card className="mt-4 bg-yellow-950/30 border-2 border-yellow-500/50">
|
||
<CardContent className="p-4">
|
||
<div className="flex items-start gap-3">
|
||
<div className="text-yellow-500">⚠️</div>
|
||
<div>
|
||
<h3 className="font-bold text-yellow-400 mb-1">{t('createEvent.nip05Recommended')}</h3>
|
||
<p className="text-sm text-gray-300 mb-2">
|
||
{t('createEvent.nip05Desc')}
|
||
</p>
|
||
<Link to="/settings">
|
||
<Button variant="outline" size="sm" className="border-yellow-500/50 hover:bg-yellow-500/10 text-yellow-400 font-bold">
|
||
{t('createEvent.addNip05')}
|
||
</Button>
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
|
||
<form onSubmit={handleSubmit}>
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>{t('events.eventDetails')}</CardTitle>
|
||
<CardDescription>
|
||
{t('createEvent.requiredFields')}
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-6">
|
||
{/* Title */}
|
||
<div className="space-y-2">
|
||
<Label htmlFor="title">{t('events.eventName')} *</Label>
|
||
<Input
|
||
id="title"
|
||
placeholder={t('createEvent.namePlaceholder')}
|
||
value={title}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
{/* Description */}
|
||
<div className="space-y-2">
|
||
<Label htmlFor="description">{t('spots.description')}</Label>
|
||
<Textarea
|
||
id="description"
|
||
placeholder={t('createEvent.descriptionPlaceholder')}
|
||
value={description}
|
||
onChange={(e) => setDescription(e.target.value)}
|
||
rows={4}
|
||
/>
|
||
</div>
|
||
|
||
{/* Start Date/Time */}
|
||
<div className="space-y-2">
|
||
<Label>{t('createEvent.startDateTime')} *</Label>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<Input
|
||
type="date"
|
||
value={startDate}
|
||
min={todayStr}
|
||
onChange={(e) => setStartDate(e.target.value)}
|
||
required
|
||
/>
|
||
<Input
|
||
type="time"
|
||
value={startTime}
|
||
onChange={(e) => setStartTime(e.target.value)}
|
||
required
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* End Date/Time */}
|
||
<div className="space-y-2">
|
||
<Label>{t('createEvent.endDateTime')}</Label>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<Input
|
||
type="date"
|
||
value={endDate}
|
||
min={startDate || todayStr}
|
||
onChange={(e) => setEndDate(e.target.value)}
|
||
/>
|
||
<Input
|
||
type="time"
|
||
value={endTime}
|
||
onChange={(e) => setEndTime(e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Location Address */}
|
||
<div className="space-y-2">
|
||
<Label htmlFor="location">{t('events.venue')}</Label>
|
||
<Input
|
||
id="location"
|
||
placeholder={t('createEvent.venuePlaceholder')}
|
||
value={location}
|
||
onChange={(e) => setLocation(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
{/* Coordinates (optional for events) */}
|
||
<div className="space-y-2">
|
||
<Label>{t('createEvent.coordinatesOptional')}</Label>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<Input
|
||
placeholder={t('createSpot.latitudePlaceholder')}
|
||
value={latitude}
|
||
onChange={(e) => setLatitude(e.target.value)}
|
||
/>
|
||
<Input
|
||
placeholder={t('createSpot.longitudePlaceholder')}
|
||
value={longitude}
|
||
onChange={(e) => setLongitude(e.target.value)}
|
||
/>
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={getCurrentLocation}
|
||
disabled={isGettingLocation}
|
||
className="mt-2"
|
||
>
|
||
{isGettingLocation ? (
|
||
<>
|
||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||
{t('createSpot.gettingLocation')}
|
||
</>
|
||
) : (
|
||
<>
|
||
<MapPin className="h-4 w-4 mr-2" />
|
||
{t('createSpot.useCurrentLocation')}
|
||
</>
|
||
)}
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Link to Spot from Map */}
|
||
<div className="space-y-3 p-4 rounded-lg border-2 border-[#ff0040]/30 bg-[#ff0040]/5">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<Map className="h-5 w-5 text-[#ff0040] shrink-0" />
|
||
<div className="min-w-0">
|
||
<Label className="text-base font-bold text-[#ff0040]">{t('placesUi.createEvent.linkToSpot')}</Label>
|
||
<p className="text-xs text-gray-400">
|
||
{selectedSpot ? t('placesUi.createEvent.selectedSpot', { name: selectedSpot.title }) : t('placesUi.createEvent.selectSpotHint')}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setShowSpotSelector(!showSpotSelector)}
|
||
className={cn(
|
||
'shrink-0 border-[#ff0040]/50',
|
||
selectedSpot
|
||
? 'bg-[#ff0040] text-white hover:bg-[#ff0040]/90'
|
||
: 'text-[#ff0040] hover:bg-[#ff0040]/10'
|
||
)}
|
||
>
|
||
{showSpotSelector ? t('placesUi.createEvent.hideSpots') : selectedSpot ? t('placesUi.createEvent.changeSpot') : t('placesUi.createEvent.selectSpot')}
|
||
<ChevronDown className={cn('h-4 w-4 ml-1 transition-transform', showSpotSelector && 'rotate-180')} />
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Selected spot preview */}
|
||
{selectedSpot && !showSpotSelector && (
|
||
<div className="p-3 rounded-lg bg-black/40 border border-[#ff0040]/30">
|
||
<div className="flex items-start gap-3">
|
||
{selectedSpot.images && selectedSpot.images[0] ? (
|
||
<img
|
||
src={selectedSpot.images[0]}
|
||
alt={selectedSpot.title}
|
||
className="w-16 h-16 rounded-lg object-cover flex-shrink-0"
|
||
/>
|
||
) : (
|
||
<div className="w-16 h-16 rounded-lg bg-[#ff0040]/20 flex items-center justify-center flex-shrink-0">
|
||
<MapPin className="h-6 w-6 text-[#ff0040]" />
|
||
</div>
|
||
)}
|
||
<div className="flex-1 min-w-0">
|
||
{selectedSpot.category && SPOT_CATEGORIES[selectedSpot.category as keyof typeof SPOT_CATEGORIES] && (
|
||
<Badge variant="outline" className="mb-1 text-[10px] border-[#ff0040]/50 text-[#ff0040]">
|
||
{SPOT_CATEGORIES[selectedSpot.category as keyof typeof SPOT_CATEGORIES].emoji} {SPOT_CATEGORY_KEYS[selectedSpot.category] ? t(SPOT_CATEGORY_KEYS[selectedSpot.category]) : SPOT_CATEGORIES[selectedSpot.category as keyof typeof SPOT_CATEGORIES].label}
|
||
</Badge>
|
||
)}
|
||
<h4 className="font-bold text-white text-sm">{selectedSpot.title}</h4>
|
||
{selectedSpot.location && (
|
||
<p className="text-xs text-gray-400 flex items-center gap-1">
|
||
<MapPin className="h-3 w-3" />
|
||
{selectedSpot.location}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => {
|
||
setLinkedSpotId('none');
|
||
setLocation('');
|
||
setLatitude('');
|
||
setLongitude('');
|
||
}}
|
||
className="text-gray-400 hover:text-red-400 h-8 w-8 p-0"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Spot selector */}
|
||
{showSpotSelector && (
|
||
<div className="space-y-3">
|
||
{/* Search */}
|
||
<Input
|
||
placeholder={t('placesUi.createEvent.searchSpots')}
|
||
value={spotSearchQuery}
|
||
onChange={(e) => setSpotSearchQuery(e.target.value)}
|
||
className="bg-black border-[#ff0040]/30"
|
||
/>
|
||
|
||
{/* Spots list */}
|
||
<div className="max-h-64 overflow-y-auto rounded-lg border border-[#ff0040]/20 p-2 space-y-2">
|
||
{spotsLoading ? (
|
||
<div className="flex items-center justify-center py-4">
|
||
<Loader2 className="h-5 w-5 animate-spin text-[#ff0040]" />
|
||
<span className="ml-2 text-sm text-gray-400">{t('placesUi.createEvent.loadingSpots')}</span>
|
||
</div>
|
||
) : filteredSpots && filteredSpots.length > 0 ? (
|
||
<>
|
||
{/* Group by category */}
|
||
{spotsByCategory && Object.entries(spotsByCategory).map(([category, categorySpots]) => {
|
||
const catInfo = SPOT_CATEGORIES[category as keyof typeof SPOT_CATEGORIES];
|
||
return (
|
||
<div key={category}>
|
||
<div className="px-2 py-1.5 text-xs font-bold text-gray-400 uppercase flex items-center gap-1 sticky top-0 bg-black/80 backdrop-blur-sm">
|
||
{catInfo?.emoji || '🛹'} {SPOT_CATEGORY_KEYS[category] ? t(SPOT_CATEGORY_KEYS[category]) : (catInfo?.label || category)} ({categorySpots.length})
|
||
</div>
|
||
{categorySpots.map((spot) => (
|
||
<button
|
||
key={spot.id}
|
||
type="button"
|
||
onClick={() => {
|
||
setLinkedSpotId(`${spot.pubkey}:${spot.identifier}`);
|
||
// Auto-fill location and coordinates from spot
|
||
if (spot.location) setLocation(spot.location);
|
||
if (spot.geohash) {
|
||
const coords = ngeohash.decode(spot.geohash);
|
||
setLatitude(coords.latitude.toFixed(6));
|
||
setLongitude(coords.longitude.toFixed(6));
|
||
}
|
||
setShowSpotSelector(false);
|
||
setSpotSearchQuery('');
|
||
}}
|
||
className={cn(
|
||
'w-full p-3 rounded-lg text-left transition-colors',
|
||
linkedSpotId === `${spot.pubkey}:${spot.identifier}`
|
||
? 'bg-[#ff0040]/20 border-2 border-[#ff0040]'
|
||
: 'bg-black/50 border border-gray-800 hover:border-[#ff0040]/50'
|
||
)}
|
||
>
|
||
<div className="flex items-start gap-3">
|
||
{spot.images && spot.images[0] ? (
|
||
<img
|
||
src={spot.images[0]}
|
||
alt={spot.title}
|
||
className="w-12 h-12 rounded-lg object-cover flex-shrink-0"
|
||
/>
|
||
) : (
|
||
<div className="w-12 h-12 rounded-lg bg-[#ff0040]/20 flex items-center justify-center flex-shrink-0">
|
||
<MapPin className="h-5 w-5 text-[#ff0040]" />
|
||
</div>
|
||
)}
|
||
<div className="flex-1 min-w-0">
|
||
<h4 className="font-bold text-white text-sm truncate">{spot.title}</h4>
|
||
{spot.location && (
|
||
<p className="text-xs text-gray-400 flex items-center gap-1 truncate">
|
||
<MapPin className="h-3 w-3 flex-shrink-0" />
|
||
<span className="truncate">{spot.location}</span>
|
||
</p>
|
||
)}
|
||
{spot.tags && spot.tags.length > 0 && (
|
||
<div className="flex gap-1 mt-1 flex-wrap">
|
||
{spot.tags.slice(0, 3).map((tag) => (
|
||
<Badge key={tag} variant="outline" className="text-[9px] px-1 py-0 border-gray-700">
|
||
{tag}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{linkedSpotId === `${spot.pubkey}:${spot.identifier}` && (
|
||
<div className="text-[#ff0040]">✓</div>
|
||
)}
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
})}
|
||
</>
|
||
) : (
|
||
<div className="text-center py-4">
|
||
<MapPin className="h-8 w-8 mx-auto mb-2 text-gray-600" />
|
||
<p className="text-sm text-gray-400">
|
||
{spotSearchQuery ? t('placesUi.createEvent.noSpotsMatch') : t('placesUi.createEvent.noSpotsAvailable')}
|
||
</p>
|
||
<Link to="/create-spot" className="text-xs text-[#ff0040] hover:underline mt-1 block">
|
||
{t('placesUi.createEvent.addNewSpot')}
|
||
</Link>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Optional shop link — an event can be held at / hosted by a shop */}
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<div>
|
||
<Label className="text-base font-bold text-[#ff0040]">{t('placesUi.createEvent.linkToShop')}</Label>
|
||
<p className="text-xs text-gray-400">
|
||
{selectedShop ? t('placesUi.createEvent.selectedShop', { name: selectedShop.name }) : t('placesUi.createEvent.selectShopHint')}
|
||
</p>
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setShowShopSelector(!showShopSelector)}
|
||
className={cn(
|
||
'shrink-0 border-[#ff0040]/50',
|
||
selectedShop ? 'bg-[#ff0040] text-white hover:bg-[#ff0040]/90' : 'text-[#ff0040] hover:bg-[#ff0040]/10',
|
||
)}
|
||
>
|
||
{showShopSelector ? t('placesUi.createEvent.hideShops') : selectedShop ? t('placesUi.createEvent.changeShop') : t('placesUi.createEvent.selectShop')}
|
||
<ChevronDown className={cn('h-4 w-4 ml-1 transition-transform', showShopSelector && 'rotate-180')} />
|
||
</Button>
|
||
</div>
|
||
|
||
{selectedShop && !showShopSelector && (
|
||
<div className="flex items-center gap-3 rounded-lg border border-[#ff0040]/30 bg-black/40 p-3">
|
||
{selectedShop.images[0] ? (
|
||
<img src={selectedShop.images[0]} alt="" className="h-12 w-12 flex-shrink-0 rounded-lg object-cover" />
|
||
) : (
|
||
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-lg bg-[#ff0040]/20">
|
||
<Store className="h-5 w-5 text-[#ff0040]" />
|
||
</div>
|
||
)}
|
||
<div className="min-w-0 flex-1">
|
||
<h4 className="truncate text-sm font-bold text-white">{selectedShop.name}</h4>
|
||
{selectedShop.location && <p className="truncate text-xs text-gray-400">{selectedShop.location}</p>}
|
||
</div>
|
||
<Button type="button" variant="ghost" size="sm" onClick={() => setLinkedShopId('none')} className="h-8 w-8 p-0 text-gray-400 hover:text-red-400">
|
||
<X className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{showShopSelector && (
|
||
<div className="space-y-3">
|
||
<Input placeholder={t('placesUi.createEvent.searchShops')} value={shopSearchQuery} onChange={(e) => setShopSearchQuery(e.target.value)} className="border-[#ff0040]/30 bg-black" />
|
||
<div className="max-h-64 space-y-2 overflow-y-auto rounded-lg border border-[#ff0040]/20 p-2">
|
||
{shopsLoading ? (
|
||
<div className="flex items-center justify-center py-4">
|
||
<Loader2 className="h-5 w-5 animate-spin text-[#ff0040]" />
|
||
</div>
|
||
) : filteredShops && filteredShops.length > 0 ? (
|
||
filteredShops.map((shop) => (
|
||
<button
|
||
key={shop.id}
|
||
type="button"
|
||
onClick={() => {
|
||
setLinkedShopId(`${shop.pubkey}:${shop.identifier}`);
|
||
if (shop.location) setLocation(shop.location);
|
||
if (shop.geohash) {
|
||
const coords = ngeohash.decode(shop.geohash);
|
||
setLatitude(coords.latitude.toFixed(6));
|
||
setLongitude(coords.longitude.toFixed(6));
|
||
}
|
||
setShowShopSelector(false);
|
||
setShopSearchQuery('');
|
||
}}
|
||
className={cn(
|
||
'w-full rounded-lg p-3 text-left transition-colors',
|
||
linkedShopId === `${shop.pubkey}:${shop.identifier}`
|
||
? 'border-2 border-[#ff0040] bg-[#ff0040]/20'
|
||
: 'border border-gray-800 bg-black/50 hover:border-[#ff0040]/50',
|
||
)}
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
{shop.images[0] ? (
|
||
<img src={shop.images[0]} alt="" className="h-12 w-12 flex-shrink-0 rounded-lg object-cover" />
|
||
) : (
|
||
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-lg bg-[#ff0040]/20">
|
||
<Store className="h-5 w-5 text-[#ff0040]" />
|
||
</div>
|
||
)}
|
||
<div className="min-w-0 flex-1">
|
||
<h4 className="truncate text-sm font-bold text-white">{shop.name}</h4>
|
||
{shop.location && (
|
||
<p className="flex items-center gap-1 truncate text-xs text-gray-400">
|
||
<MapPin className="h-3 w-3 flex-shrink-0" />
|
||
<span className="truncate">{shop.location}</span>
|
||
</p>
|
||
)}
|
||
</div>
|
||
{linkedShopId === `${shop.pubkey}:${shop.identifier}` && <div className="text-[#ff0040]">✓</div>}
|
||
</div>
|
||
</button>
|
||
))
|
||
) : (
|
||
<p className="py-4 text-center text-sm text-gray-400">
|
||
{shopSearchQuery ? t('placesUi.createEvent.noShopsMatch') : t('placesUi.createEvent.noShopsAvailable')}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Event Types */}
|
||
<div className="space-y-2">
|
||
<Label>{t('events.eventType')}</Label>
|
||
<div className="flex flex-wrap gap-2">
|
||
{EVENT_TYPES.map(([type]) => (
|
||
<Badge
|
||
key={type}
|
||
variant={selectedTypes.includes(type) ? 'default' : 'outline'}
|
||
className={cn(
|
||
'cursor-pointer transition-colors',
|
||
selectedTypes.includes(type)
|
||
? 'bg-primary hover:bg-primary/90'
|
||
: 'hover:bg-muted'
|
||
)}
|
||
onClick={() => toggleType(type)}
|
||
>
|
||
{eventTypeLabel(t, type)}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Cover Image */}
|
||
<div className="space-y-2">
|
||
<Label>{t('createEvent.coverImage')}</Label>
|
||
{image ? (
|
||
<div className="relative aspect-video rounded-lg overflow-hidden bg-muted max-w-xs">
|
||
<img src={image} alt={t('placesUi.editEvent.eventCover')} className="w-full h-full object-cover" />
|
||
<button
|
||
type="button"
|
||
onClick={removeImage}
|
||
className="absolute top-2 right-2 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="block max-w-xs aspect-video rounded-lg border-2 border-dashed flex flex-col items-center justify-center cursor-pointer hover:bg-muted/50 transition-colors">
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={handleImageUpload}
|
||
disabled={isUploading}
|
||
/>
|
||
{isUploading ? (
|
||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||
) : (
|
||
<>
|
||
<Upload className="h-6 w-6 text-muted-foreground mb-1" />
|
||
<span className="text-sm text-muted-foreground">{t('createEvent.uploadCover')}</span>
|
||
</>
|
||
)}
|
||
</label>
|
||
)}
|
||
</div>
|
||
|
||
{/* Link to Tour (Optional) */}
|
||
<Collapsible open={tourLinkExpanded} onOpenChange={setTourLinkExpanded}>
|
||
<CollapsibleTrigger asChild>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
className={cn(
|
||
'w-full justify-between',
|
||
selectedTour ? 'border-cyan-500/50 bg-cyan-500/10' : ''
|
||
)}
|
||
>
|
||
<span className="flex items-center gap-2">
|
||
<Link2 className="h-4 w-4 text-cyan-400" />
|
||
<span>
|
||
{selectedTour
|
||
? t('placesUi.createEvent.linkedTo', { title: selectedTour.title })
|
||
: t('placesUi.createEvent.linkToTour')}
|
||
</span>
|
||
</span>
|
||
<ChevronDown className={cn('h-4 w-4 transition-transform', tourLinkExpanded && 'rotate-180')} />
|
||
</Button>
|
||
</CollapsibleTrigger>
|
||
<CollapsibleContent className="pt-4 space-y-4">
|
||
<div className="p-4 rounded-lg bg-cyan-500/10 border border-cyan-500/20">
|
||
<p className="text-sm text-gray-300 mb-4">
|
||
{t('placesUi.createEvent.linkTourDesc')}
|
||
</p>
|
||
|
||
<div className="space-y-2">
|
||
<Label className="text-cyan-400">{t('placesUi.createEvent.selectTour')}</Label>
|
||
<Select value={linkedTourId} onValueChange={setLinkedTourId}>
|
||
<SelectTrigger className="border-cyan-500/30">
|
||
<SelectValue placeholder={toursLoading ? t('placesUi.createEvent.loadingTours') : t('placesUi.createEvent.noTourLinked')} />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="none">{t('placesUi.createEvent.noTourLinked')}</SelectItem>
|
||
|
||
{/* Group by category */}
|
||
{toursByCategory && Object.entries(toursByCategory).map(([category, categoryTours]) => {
|
||
const catInfo = TOUR_CATEGORIES[category as keyof typeof TOUR_CATEGORIES];
|
||
return (
|
||
<div key={category}>
|
||
<div className="px-2 py-1.5 text-xs font-bold text-gray-400 uppercase flex items-center gap-1">
|
||
{catInfo?.emoji || '🛹'} {catInfo?.label || category}
|
||
</div>
|
||
{categoryTours.map((tour) => (
|
||
<SelectItem
|
||
key={`${tour.pubkey}:${tour.identifier}`}
|
||
value={`${tour.pubkey}:${tour.identifier}`}
|
||
>
|
||
<span className="flex items-center gap-2">
|
||
{tour.category === 'destruction' ? (
|
||
<Skull className="h-3 w-3 text-red-400" />
|
||
) : tour.category === 'international' ? (
|
||
<Globe className="h-3 w-3 text-purple-400" />
|
||
) : (
|
||
<Route className="h-3 w-3 text-cyan-400" />
|
||
)}
|
||
{tour.title}
|
||
{tour.location && (
|
||
<span className="text-gray-500 text-xs">
|
||
({tour.location})
|
||
</span>
|
||
)}
|
||
</span>
|
||
</SelectItem>
|
||
))}
|
||
</div>
|
||
);
|
||
})}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{selectedTour && (
|
||
<div className="mt-4 p-3 rounded-lg bg-black/40 border border-cyan-500/30">
|
||
<div className="flex items-start gap-3">
|
||
{selectedTour.image ? (
|
||
<img
|
||
src={selectedTour.image}
|
||
alt={selectedTour.title}
|
||
className="w-16 h-16 rounded-lg object-cover flex-shrink-0"
|
||
/>
|
||
) : (
|
||
<div
|
||
className="w-16 h-16 rounded-lg flex items-center justify-center flex-shrink-0"
|
||
style={{ backgroundColor: TOUR_CATEGORIES[selectedTour.category]?.color + '30' }}
|
||
>
|
||
{selectedTour.category === 'destruction' ? (
|
||
<Skull className="h-6 w-6" style={{ color: TOUR_CATEGORIES[selectedTour.category]?.color }} />
|
||
) : (
|
||
<Route className="h-6 w-6" style={{ color: TOUR_CATEGORIES[selectedTour.category]?.color }} />
|
||
)}
|
||
</div>
|
||
)}
|
||
<div className="flex-1 min-w-0">
|
||
<Badge
|
||
variant="outline"
|
||
className="mb-1 text-[10px]"
|
||
style={{
|
||
borderColor: TOUR_CATEGORIES[selectedTour.category]?.color,
|
||
color: TOUR_CATEGORIES[selectedTour.category]?.color
|
||
}}
|
||
>
|
||
{TOUR_CATEGORIES[selectedTour.category]?.emoji} {selectedTour.category && TOUR_CATEGORY_KEYS[selectedTour.category] ? t(TOUR_CATEGORY_KEYS[selectedTour.category]) : TOUR_CATEGORIES[selectedTour.category]?.label}
|
||
</Badge>
|
||
<h4 className="font-bold text-white text-sm">{selectedTour.title}</h4>
|
||
{selectedTour.location && (
|
||
<p className="text-xs text-gray-400 flex items-center gap-1">
|
||
<MapPin className="h-3 w-3" />
|
||
{selectedTour.location}
|
||
</p>
|
||
)}
|
||
{selectedTour.description && (
|
||
<p className="text-xs text-gray-500 line-clamp-2 mt-1">
|
||
{selectedTour.description}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
|
||
{/* Entry fee (optional) */}
|
||
<div className="space-y-2">
|
||
<Label htmlFor="entryFee" className="flex items-center gap-2">
|
||
<Zap className="h-4 w-4 text-[#ff6600]" />
|
||
{t('placesUi.createEvent.entryFee')}
|
||
<span className="text-xs text-gray-500 font-normal">{t('placesUi.createEvent.entryFeeHint')}</span>
|
||
</Label>
|
||
<Input
|
||
id="entryFee"
|
||
placeholder={t('placesUi.createEvent.entryFeePlaceholder')}
|
||
value={entryFee}
|
||
onChange={(e) => setEntryFee(e.target.value)}
|
||
className="bg-black/50 border-white/20"
|
||
/>
|
||
</div>
|
||
|
||
{/* Funding goal (optional) — community crowdfunding via zaps */}
|
||
<div className="space-y-2">
|
||
<Label htmlFor="fundingGoal" className="flex items-center gap-2">
|
||
<Zap className="h-4 w-4 text-[#ccff00]" />
|
||
{t('placesUi.createEvent.fundingGoal')}
|
||
<span className="text-xs text-gray-500 font-normal">{t('placesUi.createEvent.fundingGoalHint')}</span>
|
||
</Label>
|
||
<Input
|
||
id="fundingGoal"
|
||
type="number"
|
||
min="0"
|
||
inputMode="numeric"
|
||
placeholder={t('placesUi.createEvent.fundingGoalPlaceholder')}
|
||
value={fundingGoal}
|
||
onChange={(e) => setFundingGoal(e.target.value)}
|
||
className="bg-black/50 border-white/20"
|
||
/>
|
||
</div>
|
||
|
||
{/* Sponsors (optional) */}
|
||
<div className="space-y-3 rounded-xl border border-white/10 bg-white/[0.02] p-4">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<Label className="flex items-center gap-2">
|
||
<Sparkles className="h-4 w-4 text-[#ccff00]" />
|
||
{t('placesUi.createEvent.sponsors')}
|
||
<span className="text-xs font-normal text-gray-500">{t('placesUi.createEvent.optional')}</span>
|
||
</Label>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={addSponsor}
|
||
className="h-8 gap-1 border-[#ccff00]/30 text-[#ccff00] hover:bg-[#ccff00]/10"
|
||
>
|
||
<Plus className="h-3.5 w-3.5" />
|
||
{t('placesUi.createEvent.add')}
|
||
</Button>
|
||
</div>
|
||
{sponsors.length === 0 ? (
|
||
<p className="text-xs text-gray-500">
|
||
{t('placesUi.createEvent.sponsorsHint')}
|
||
</p>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{sponsors.map((s, i) => (
|
||
<div key={i} className="space-y-2 rounded-lg border border-white/10 bg-black/30 p-3">
|
||
<div className="flex items-center gap-2">
|
||
<select
|
||
value={s.tier}
|
||
onChange={(e) => updateSponsor(i, { tier: e.target.value as EventSponsor['tier'] })}
|
||
className="h-9 rounded-md border border-white/20 bg-black/50 px-2 text-sm text-white"
|
||
>
|
||
{Object.entries(EVENT_SPONSOR_TIERS).map(([key, { labelKey }]) => (
|
||
<option key={key} value={key}>
|
||
{t(labelKey)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<Input
|
||
placeholder={t('placesUi.createEvent.sponsorName')}
|
||
value={s.name}
|
||
onChange={(e) => updateSponsor(i, { name: e.target.value })}
|
||
className="flex-1 bg-black/50 border-white/20"
|
||
/>
|
||
<Button
|
||
type="button"
|
||
size="icon"
|
||
variant="ghost"
|
||
onClick={() => removeSponsor(i)}
|
||
className="h-9 w-9 flex-shrink-0 text-gray-500 hover:text-red-400"
|
||
aria-label={t('placesUi.createEvent.removeSponsor')}
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||
<Input
|
||
placeholder={t('placesUi.createEvent.logoUrl')}
|
||
value={s.logo ?? ''}
|
||
onChange={(e) => updateSponsor(i, { logo: e.target.value })}
|
||
className="bg-black/50 border-white/20 text-sm"
|
||
/>
|
||
<Input
|
||
placeholder={t('placesUi.createEvent.websiteUrl')}
|
||
value={s.url ?? ''}
|
||
onChange={(e) => updateSponsor(i, { url: e.target.value })}
|
||
className="bg-black/50 border-white/20 text-sm"
|
||
/>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Optional featured upgrade — every event posts for free */}
|
||
<FeaturedEventUpgrade enabled={goFeatured} onEnabledChange={setGoFeatured} />
|
||
|
||
{/* Submit */}
|
||
<Button
|
||
type="submit"
|
||
className="w-full"
|
||
disabled={isPublishing || isUploading}
|
||
>
|
||
{isPublishing ? (
|
||
<>
|
||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||
{t('createEvent.publishing')}
|
||
</>
|
||
) : (
|
||
<>
|
||
<Calendar className="h-4 w-4 mr-2" />
|
||
{isFeatured ? t('placesUi.createEvent.publishFeatured') : t('events.createEvent')}
|
||
</>
|
||
)}
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
</form>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|