SkateSpots/src/pages/CreateEventPage.tsx
shakespeare.diy dcfa1fe1b2 Add Pro/Featured display + optional featured events (Option B)
Lab:
- useLabProfiles sorts featured (Pro) profiles first
- ProBadge shown on Lab cards (LabPage) + Lab detail page; featured cards
  get a subtle amber highlight

Events (same no-paywall model as Lab):
- New FeaturedEventUpgrade: every event posts for free; optional fee adds a
  featured badge + top placement (reuses existing featuredEvent fee)
- CreateEventPage: removed the paid-event fee gate; opt-in featured toggle,
  writes a `featured` tag when active
- useSkateEvents sorts featured events first; EventCard shows ProBadge
  (compact in list view, overlay on the image in full view)

Docs:
- NIP.md documents the `featured` tag (unix-seconds expiry, self-attested)

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-07-24 15:39:41 +07:00

929 lines
39 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 } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useNavigate } 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 { 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 { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/LanguageContext';
import { LoginArea } from '@/components/auth/LoginArea';
import { CALENDAR_EVENT_KIND, EVENT_TYPE_LABELS, TOUR_CATEGORIES, SKATE_TOUR_KIND, SKATE_SPOT_KIND, SPOT_CATEGORIES, type SkateTour, type SkateSpot } from '@/lib/types';
import { cn } from '@/lib/utils';
import {
Calendar,
Upload,
X,
Loader2,
ArrowLeft,
MapPin,
Route,
ChevronDown,
Link2,
Skull,
Globe,
Map,
Zap,
} 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);
export default function CreateEventPage() {
const { t } = useTranslation();
useSeoMeta({
title: 'Create a Skate Event - SkateSpots',
description: 'Create a new skateboarding event for the community.',
});
const navigate = useNavigate();
const { user } = 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);
// 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('');
// Paid event / entry fee
const [entryFee, setEntryFee] = useState('');
const isPaidEvent = entryFee.trim().length > 0 && entryFee.trim() !== '0';
// 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);
// 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[]>);
const toggleType = (type: string) => {
setSelectedTypes((prev) =>
prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type]
);
};
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) => {
setLatitude(position.coords.latitude.toFixed(6));
setLongitude(position.coords.longitude.toFixed(6));
setIsGettingLocation(false);
toast({
title: 'Location found',
description: 'Your current location has been set.',
});
},
(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 image has been uploaded successfully.',
});
}
} catch (error) {
toast({
title: 'Upload failed',
description: error instanceof Error ? error.message : 'Failed to upload image',
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: 'Title required',
description: 'Please enter a name for this event.',
variant: 'destructive',
});
return;
}
if (!startDate || !startTime) {
toast({
title: 'Start time required',
description: 'Please enter when the event starts.',
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: 'Invalid date',
description: 'Please enter a valid start date and time.',
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: 'Invalid end time',
description: 'End time must be after start time.',
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']);
}
// 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]);
}
}
await createEvent({
kind: CALENDAR_EVENT_KIND,
content: description.trim(),
tags,
});
toast({
title: 'Event created!',
description: 'Your event has been published to the network.',
});
navigate(`/event/${user?.pubkey}/${identifier}`);
} catch (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">
<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>
</Layout>
);
}
const hasNip05 = user?.metadata?.nip05;
return (
<Layout>
<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>
{!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}
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}
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">
<div className="flex items-center gap-2">
<Map className="h-5 w-5 text-[#ff0040]" />
<div>
<Label className="text-base font-bold text-[#ff0040]">Link to a Skate Spot</Label>
<p className="text-xs text-gray-400">
{selectedSpot ? `Selected: ${selectedSpot.title}` : 'Select a spot from the map where this event takes place'}
</p>
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowSpotSelector(!showSpotSelector)}
className={cn(
'border-[#ff0040]/50',
selectedSpot
? 'bg-[#ff0040] text-white hover:bg-[#ff0040]/90'
: 'text-[#ff0040] hover:bg-[#ff0040]/10'
)}
>
{showSpotSelector ? 'Hide Spots' : selectedSpot ? 'Change Spot' : 'Select Spot'}
<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_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="Search spots by name, location, or category..."
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">Loading spots...</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 || '🛹'} {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 ? 'No spots match your search' : 'No spots available'}
</p>
<Link to="/create-spot" className="text-xs text-[#ff0040] hover:underline mt-1 block">
Add a new spot →
</Link>
</div>
)}
</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, label]) => (
<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)}
>
{label}
</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="Event cover" 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
? `Linked to: ${selectedTour.title}`
: 'Link to a Tour (Optional)'}
</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">
Link this event to a tour to show it's part of a larger series. Great for national tours,
international adventures, or World Destruction Tour challenge days.
</p>
<div className="space-y-2">
<Label className="text-cyan-400">Select Tour</Label>
<Select value={linkedTourId} onValueChange={setLinkedTourId}>
<SelectTrigger className="border-cyan-500/30">
<SelectValue placeholder={toursLoading ? 'Loading tours...' : 'No tour linked'} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No tour linked</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} {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]" />
Entry Fee
<span className="text-xs text-gray-500 font-normal">(optional — e.g. "500 sats" or "£10")</span>
</Label>
<Input
id="entryFee"
placeholder="Leave blank for free events"
value={entryFee}
onChange={(e) => setEntryFee(e.target.value)}
className="bg-black/50 border-white/20"
/>
</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 ? 'Publish Featured Event' : t('events.createEvent')}
</>
)}
</Button>
</CardContent>
</Card>
</form>
</div>
</Layout>
);
}