SkateSpots/src/pages/CreateEventPage.tsx

1280 lines
58 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, 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>
</>
);
}