import { useState, useMemo, useRef, useEffect } from 'react'; import { useSeoMeta } from '@unhead/react'; import { Link } from 'react-router-dom'; import { Layout } from '@/components/Layout'; import { EventCard, EventCardSkeleton } from '@/components/EventCard'; import { TourCard, TourCardSkeleton } from '@/components/TourCard'; import { Badge } from '@/components/ui/badge'; import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { type TrustFilterLevel, filterByTrustLevel } from '@/components/TrustFilter'; import { type SortOption } from '@/components/SortDropdown'; import { useSkateEvents } from '@/hooks/useSkateEvents'; import { useTours } from '@/hooks/useTours'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useFollowing } from '@/hooks/useFollowing'; import { useBatchTrustScores } from '@/hooks/useBatchTrustScores'; import { useComments } from '@/hooks/useComments'; import { useTranslation } from '@/contexts/LanguageContext'; import { EVENT_TYPE_LABELS, TOUR_CATEGORIES, TOUR_SKILL_LEVELS, type TourCategory, } from '@/lib/types'; import { Search, Plus, Calendar, X, Route, MapPin, SlidersHorizontal, Clock, Star, TrendingUp, Shield, Users, CheckCircle2, ChevronDown, } from 'lucide-react'; import { cn } from '@/lib/utils'; // ─── Accent colours ─────────────────────────────────────────────────────────── const ACCENT = { events: { active: 'bg-[#ccff00] text-black border-[#ccff00]', chip: 'bg-[#ccff00] text-black border-[#ccff00]', chipHover: 'hover:border-[#ccff00]/60 hover:text-[#ccff00]', border: 'border-[#ccff00]/40', icon: 'text-[#ccff00]', badge: 'bg-[#ccff00]/15 text-[#ccff00] border-[#ccff00]/30', stripe: 'bg-[#ccff00]', }, tours: { active: 'bg-cyan-500 text-white border-cyan-500', chip: 'bg-cyan-500 text-white border-cyan-500', chipHover: 'hover:border-cyan-500/60 hover:text-cyan-400', border: 'border-cyan-500/40', icon: 'text-cyan-400', badge: 'bg-cyan-500/15 text-cyan-400 border-cyan-500/30', stripe: 'bg-cyan-500', }, } as const; type MainView = 'events' | 'tours'; // ─── FAB — create event / tour ──────────────────────────────────────────────── function CreateFAB({ mainView }: { mainView: MainView }) { const [open, setOpen] = useState(false); const ref = useRef(null); const { t } = useTranslation(); useEffect(() => { if (!open) return; const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [open]); const accent = ACCENT[mainView]; return (
{open && (
setOpen(false)} className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-bold uppercase tracking-wide bg-[#ccff00] text-black hover:bg-[#ccff00]/90 transition-all" > {t('events.createEvent')} setOpen(false)} className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-bold uppercase tracking-wide bg-cyan-500 text-white hover:bg-cyan-600 transition-all" > Create Tour
)}
); } // ─── Sticky toolbar ─────────────────────────────────────────────────────────── interface ToolbarProps { mainView: MainView; onViewChange: (v: MainView) => void; counts: { events: number; tours: number }; // Events searchQuery: string; onSearchChange: (q: string) => void; locationQuery: string; onLocationChange: (q: string) => void; activeFilterCount: number; onOpenFilters: () => void; // Active chips for events selectedTypes: string[]; onRemoveType: (t: string) => void; sortBy: SortOption; trustFilter: TrustFilterLevel; onClearAll: () => void; // Timing tab (events only) activeTab: 'upcoming' | 'past'; onTabChange: (t: 'upcoming' | 'past') => void; upcomingCount: number; pastCount: number; isLoading: boolean; totalCount: number; } function EventsToolbar({ mainView, onViewChange, counts, searchQuery, onSearchChange, locationQuery, onLocationChange, activeFilterCount, onOpenFilters, selectedTypes, onRemoveType, sortBy, trustFilter, onClearAll, activeTab, onTabChange, upcomingCount, pastCount, isLoading, totalCount, }: ToolbarProps) { const accent = ACCENT[mainView]; const hasActiveChips = selectedTypes.length > 0 || trustFilter !== 'all' || sortBy !== 'recent'; return (
{/* Main view tabs: Events / Tours */}
{(['events', 'tours'] as MainView[]).map((view) => { const isActive = mainView === view; const a = ACCENT[view]; const Icon = view === 'events' ? Calendar : Route; const label = view === 'events' ? 'Events' : 'Tours'; const count = counts[view]; return ( ); })}
{/* Upcoming / Past sub-tabs (events only) */} {mainView === 'events' && (
{(['upcoming', 'past'] as const).map((tab) => { const isActive = activeTab === tab; const count = tab === 'upcoming' ? upcomingCount : pastCount; return ( ); })}
)} {/* Search + Location + Filter row */}
{/* Search */}
onSearchChange(e.target.value)} placeholder={mainView === 'events' ? 'Event name…' : 'Tour name…'} className="w-full h-9 pl-8 pr-3 rounded-xl bg-white/5 border border-white/10 text-sm text-white placeholder:text-gray-600 focus:outline-none focus:border-white/30 transition-all" /> {searchQuery && ( )}
{/* Location */}
onLocationChange(e.target.value)} placeholder="City…" className="w-full h-9 pl-8 pr-3 rounded-xl bg-white/5 border border-white/10 text-sm text-white placeholder:text-gray-600 focus:outline-none focus:border-white/30 transition-all" /> {locationQuery && ( )}
{/* Filter button */}
{/* Active filter chips — only shown when something is selected */} {hasActiveChips && (
{selectedTypes.map((type) => ( ))} {sortBy !== 'recent' && ( {sortBy === 'trust' && <> Top Rated} {sortBy === 'popular' && <> Popular} )} {trustFilter !== 'all' && ( {trustFilter === 'verified' ? 'Verified' : trustFilter === 'trusted' ? 'Trusted' : 'Following'} )}
)} {/* Results count */} {!isLoading && (

{totalCount === 0 ? 'No results' : `${totalCount} result${totalCount !== 1 ? 's' : ''}`}

)}
); } // ─── Filter Drawer — bottom sheet mobile, right panel desktop ───────────────── interface FilterDrawerProps { open: boolean; onClose: () => void; mainView: MainView; // Events selectedTypes: string[]; onToggleType: (t: string) => void; sortBy: SortOption; onSortChange: (s: SortOption) => void; trustFilter: TrustFilterLevel; onTrustChange: (t: TrustFilterLevel) => void; // Tours tourCategory: TourCategory | 'all'; onTourCategoryChange: (c: TourCategory | 'all') => void; tourSkillLevel: string; onSkillLevelChange: (s: string) => void; tourSortBy: SortOption; onTourSortChange: (s: SortOption) => void; tourTrustFilter: TrustFilterLevel; onTourTrustChange: (t: TrustFilterLevel) => void; onClearAll: () => void; activeCount: number; } function FilterDrawer({ open, onClose, mainView, selectedTypes, onToggleType, sortBy, onSortChange, trustFilter, onTrustChange, tourCategory, onTourCategoryChange, tourSkillLevel, onSkillLevelChange, tourSortBy, onTourSortChange, tourTrustFilter, onTourTrustChange, onClearAll, activeCount, }: FilterDrawerProps) { const accent = ACCENT[mainView]; const trustOptions: { value: TrustFilterLevel; label: string; icon: React.ComponentType<{ className?: string }> }[] = [ { value: 'all', label: 'All', icon: Users }, { value: 'verified', label: 'Verified', icon: CheckCircle2 }, { value: 'trusted', label: 'Trusted', icon: Shield }, { value: 'following', label: 'Following', icon: CheckCircle2 }, ]; const sortOptions: { value: SortOption; label: string; icon: React.ComponentType<{ className?: string }> }[] = [ { value: 'recent', label: 'Newest', icon: Clock }, { value: 'trust', label: 'Top Rated', icon: Star }, { value: 'popular', label: 'Popular', icon: TrendingUp }, ]; const activeSortBy = mainView === 'events' ? sortBy : tourSortBy; const activeTrustFilter = mainView === 'events' ? trustFilter : tourTrustFilter; const handleSortChange = mainView === 'events' ? onSortChange : onTourSortChange; const handleTrustChange = mainView === 'events' ? onTrustChange : onTourTrustChange; const drawerContent = ( <> {/* Header */}
Filters {activeCount > 0 && ( {activeCount} active )}
{activeCount > 0 && ( )}
{/* Scrollable body */}
{/* Sort */}

Sort By

{sortOptions.map(({ value, label, icon: Icon }) => ( ))}
{/* Trust — inline pills */}

Trust Level

{trustOptions.map(({ value, label, icon: Icon }) => ( ))}
{/* Events: event type chips */} {mainView === 'events' && (

Event Type

{Object.entries(EVENT_TYPE_LABELS).map(([type, label]) => { const isActive = selectedTypes.includes(type); return ( ); })}
)} {/* Tours: category chips */} {mainView === 'tours' && ( <>

Tour Category

{Object.entries(TOUR_CATEGORIES).map(([key, info]) => { const isActive = tourCategory === key; return ( ); })}

Skill Level

{Object.entries(TOUR_SKILL_LEVELS).filter(([k]) => k !== 'all').map(([key, info]) => { const isActive = tourSkillLevel === key; return ( ); })}
)}
{/* Apply */}
); return ( <> {/* Mobile: bottom sheet */}
!v && onClose()}> Filters {drawerContent}
{/* Desktop: right side panel */}
!v && onClose()}> Filters {drawerContent}
); } // ─── Skeleton rows ──────────────────────────────────────────────────────────── function SkeletonGrid({ count = 6 }: { count?: number }) { return (
{Array.from({ length: count }).map((_, i) => ( ))}
); } function TourSkeletonGrid({ count = 6 }: { count?: number }) { return (
{Array.from({ length: count }).map((_, i) => ( ))}
); } // ─── Empty states ───────────────────────────────────────────────────────────── function EmptyState({ icon: Icon, title, body, cta, to, ctaClass, onClear, hasFilters, }: { icon: React.ComponentType<{ className?: string }>; title: string; body: string; cta?: string; to?: string; ctaClass?: string; onClear?: () => void; hasFilters: boolean; }) { return (

{title}

{body}

{hasFilters && onClear ? ( ) : cta && to ? ( ) : null} Have questions? Check the FAQ →
); } // ─── Page ───────────────────────────────────────────────────────────────────── export default function EventsPage() { const { t } = useTranslation(); useSeoMeta({ title: 'Skate Events & Tours - SkateSpots', description: 'Discover skateboarding events, competitions, tours, and sessions near you.', }); const { user } = useCurrentUser(); const { data: followingList } = useFollowing(user?.pubkey); const followCount = followingList?.length ?? 0; const smartDefault: TrustFilterLevel = (user && followCount >= 5) ? 'trusted' : 'all'; const { data: events, isLoading: eventsLoading } = useSkateEvents(100); const { data: tours, isLoading: toursLoading } = useTours(100); // ── View state ────────────────────────────────────────────────────────────── const [mainView, setMainView] = useState('events'); // ── Event filters ─────────────────────────────────────────────────────────── const [searchQuery, setSearchQuery] = useState(''); const [locationQuery, setLocationQuery] = useState(''); const [selectedTypes, setSelectedTypes] = useState([]); const [activeTab, setActiveTab] = useState<'upcoming' | 'past'>('upcoming'); const [trustFilter, setTrustFilter] = useState(smartDefault); const [sortBy, setSortBy] = useState('recent'); // ── Tour filters ───────────────────────────────────────────────────────────── const [tourSearchQuery, setTourSearchQuery] = useState(''); const [tourLocationQuery, setTourLocationQuery] = useState(''); const [tourCategory, setTourCategory] = useState('all'); const [tourSkillLevel, setTourSkillLevel] = useState('all'); const [tourTrustFilter, setTourTrustFilter] = useState(smartDefault); const [tourSortBy, setTourSortBy] = useState('recent'); const [filtersOpen, setFiltersOpen] = useState(false); const now = Math.floor(Date.now() / 1000); // ── Trust scores ───────────────────────────────────────────────────────────── const allPubkeys = useMemo(() => { const ep = events?.map((e) => e.pubkey) || []; const tp = tours?.map((t) => t.pubkey) || []; return [...new Set([...ep, ...tp])]; }, [events, tours]); const { data: trustScores } = useBatchTrustScores(allPubkeys); const getTrustScore = (pubkey: string) => trustScores?.map.get ? (trustScores as unknown as Map).get(pubkey) : trustScores?.get(pubkey); // ── Comment counts ──────────────────────────────────────────────────────────── const allEventIds = useMemo(() => { const eIds = events?.map((e) => e.event.id) || []; const tIds = tours?.map((t) => t.event.id) || []; return [...eIds, ...tIds]; }, [events, tours]); const { data: allComments } = useComments(allEventIds); const getCommentCount = (eventId: string) => allComments?.filter((c) => c.tags.find(([tag]) => tag === 'e')?.[1] === eventId).length || 0; // ── Counts for tab badges ───────────────────────────────────────────────────── const rawCounts = useMemo(() => ({ events: events?.length ?? 0, tours: tours?.length ?? 0, }), [events, tours]); // ── Filter events ───────────────────────────────────────────────────────────── const filteredEvents = useMemo(() => { if (!events) return { upcoming: [], past: [] }; let filtered = events.filter((event) => { if (searchQuery) { const q = searchQuery.toLowerCase(); if ( !event.title.toLowerCase().includes(q) && !event.description?.toLowerCase().includes(q) ) return false; } if (locationQuery) { const lq = locationQuery.toLowerCase(); if (!event.location?.toLowerCase().includes(lq)) return false; } if (selectedTypes.length > 0) { if (!event.types.some((type) => selectedTypes.includes(type))) return false; } return true; }); if (trustScores && trustFilter !== 'all') { if (trustFilter === 'trusted') { filtered.sort((a, b) => { const tA = (getTrustScore(a.pubkey) ?? -1) >= 60 ? 1 : 0; const tB = (getTrustScore(b.pubkey) ?? -1) >= 60 ? 1 : 0; return tB - tA; }); } else { filtered = filterByTrustLevel(filtered, trustFilter, getTrustScore); } } let upcoming = filtered.filter((e) => e.start >= now); let past = filtered.filter((e) => e.start < now); const sortFn = (a: typeof events[0], b: typeof events[0]) => { if (sortBy === 'trust') { const diff = (getTrustScore(b.pubkey) || 0) - (getTrustScore(a.pubkey) || 0); if (diff !== 0) return diff; return a.start - b.start; } if (sortBy === 'popular') { const diff = getCommentCount(b.event.id) - getCommentCount(a.event.id); if (diff !== 0) return diff; return a.start - b.start; } return 0; }; upcoming.sort((a, b) => sortBy === 'recent' ? a.start - b.start : sortFn(a, b)); past.sort((a, b) => sortBy === 'recent' ? b.start - a.start : sortFn(a, b)); return { upcoming, past }; }, [events, searchQuery, locationQuery, selectedTypes, trustFilter, trustScores, sortBy, now, allComments]); // ── Filter tours ────────────────────────────────────────────────────────────── const filteredTours = useMemo(() => { if (!tours) return []; let filtered = tours.filter((tour) => { if (tourSearchQuery) { const q = tourSearchQuery.toLowerCase(); if ( !tour.title.toLowerCase().includes(q) && !tour.description?.toLowerCase().includes(q) && !tour.cities?.some((c) => c.toLowerCase().includes(q)) && !tour.countries?.some((c) => c.toLowerCase().includes(q)) ) return false; } if (tourLocationQuery) { const lq = tourLocationQuery.toLowerCase(); if (!tour.location?.toLowerCase().includes(lq)) return false; } if (tourCategory !== 'all' && tour.category !== tourCategory) return false; if (tourSkillLevel !== 'all' && tour.skillLevel !== tourSkillLevel) return false; return true; }); if (trustScores && tourTrustFilter !== 'all') { if (tourTrustFilter === 'trusted') { filtered.sort((a, b) => { const tA = (getTrustScore(a.pubkey) ?? -1) >= 60 ? 1 : 0; const tB = (getTrustScore(b.pubkey) ?? -1) >= 60 ? 1 : 0; return tB - tA; }); } else { filtered = filterByTrustLevel(filtered, tourTrustFilter, getTrustScore); } } filtered.sort((a, b) => { if (tourSortBy === 'trust') { const diff = (getTrustScore(b.pubkey) || 0) - (getTrustScore(a.pubkey) || 0); if (diff !== 0) return diff; } if (tourSortBy === 'popular') { const diff = getCommentCount(b.event.id) - getCommentCount(a.event.id); if (diff !== 0) return diff; } return b.createdAt - a.createdAt; }); return filtered; }, [tours, tourSearchQuery, tourLocationQuery, tourCategory, tourSkillLevel, tourTrustFilter, trustScores, tourSortBy, allComments]); // ── Active filter counts ────────────────────────────────────────────────────── const activeEventFilterCount = selectedTypes.length + (trustFilter !== 'all' ? 1 : 0) + (sortBy !== 'recent' ? 1 : 0); const activeTourFilterCount = (tourCategory !== 'all' ? 1 : 0) + (tourSkillLevel !== 'all' ? 1 : 0) + (tourTrustFilter !== 'all' ? 1 : 0) + (tourSortBy !== 'recent' ? 1 : 0); const activeFilterCount = mainView === 'events' ? activeEventFilterCount : activeTourFilterCount; const handleClearEventFilters = () => { setSearchQuery(''); setLocationQuery(''); setSelectedTypes([]); setTrustFilter('all'); setSortBy('recent'); }; const handleClearTourFilters = () => { setTourSearchQuery(''); setTourLocationQuery(''); setTourCategory('all'); setTourSkillLevel('all'); setTourTrustFilter('all'); setTourSortBy('recent'); }; const handleClearAll = () => { if (mainView === 'events') handleClearEventFilters(); else handleClearTourFilters(); }; const handleViewChange = (view: MainView) => { setMainView(view); setFiltersOpen(false); }; const isLoading = mainView === 'events' ? eventsLoading : toursLoading; const totalCount = mainView === 'events' ? (activeTab === 'upcoming' ? filteredEvents.upcoming.length : filteredEvents.past.length) : filteredTours.length; const currentSearchQuery = mainView === 'events' ? searchQuery : tourSearchQuery; const currentLocationQuery = mainView === 'events' ? locationQuery : tourLocationQuery; const handleSearchChange = mainView === 'events' ? setSearchQuery : setTourSearchQuery; const handleLocationChange = mainView === 'events' ? setLocationQuery : setTourLocationQuery; const hasEventFilters = !!(searchQuery || locationQuery || selectedTypes.length > 0 || trustFilter !== 'all' || sortBy !== 'recent'); const hasTourFilters = !!(tourSearchQuery || tourLocationQuery || tourCategory !== 'all' || tourSkillLevel !== 'all' || tourTrustFilter !== 'all' || tourSortBy !== 'recent'); return (
{/* ── Page header ── */}

SKATE{' '} {mainView === 'events' ? 'EVENTS' : 'TOURS'}

{mainView === 'events' ? t('events.findEvents') : 'Local guides, road trips & world destruction competitions'}

{user ? ( ) : ( 🔒 {t('events.loginToCreate')} )}
{/* ── Sticky toolbar ── */} setFiltersOpen(true)} selectedTypes={selectedTypes} onRemoveType={(type) => setSelectedTypes((prev) => prev.filter((t) => t !== type))} sortBy={sortBy} trustFilter={trustFilter} onClearAll={handleClearAll} activeTab={activeTab} onTabChange={setActiveTab} upcomingCount={filteredEvents.upcoming.length} pastCount={filteredEvents.past.length} isLoading={isLoading} totalCount={totalCount} /> {/* ── Results ── */}
{/* Events */} {mainView === 'events' && ( eventsLoading ? ( ) : activeTab === 'upcoming' ? ( filteredEvents.upcoming.length > 0 ? (
{filteredEvents.upcoming.map((event) => ( ))}
) : ( ) ) : ( filteredEvents.past.length > 0 ? (
{filteredEvents.past.map((event) => ( ))}
) : ( ) ) )} {/* Tours */} {mainView === 'tours' && ( toursLoading ? ( ) : filteredTours.length > 0 ? (
{filteredTours.map((tour) => ( ))}
) : ( ) )}
{/* ── Filter drawer ── */} setFiltersOpen(false)} mainView={mainView} selectedTypes={selectedTypes} onToggleType={(type) => setSelectedTypes((prev) => prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type] ) } sortBy={sortBy} onSortChange={setSortBy} trustFilter={trustFilter} onTrustChange={setTrustFilter} tourCategory={tourCategory} onTourCategoryChange={setTourCategory} tourSkillLevel={tourSkillLevel} onSkillLevelChange={setTourSkillLevel} tourSortBy={tourSortBy} onTourSortChange={setTourSortBy} tourTrustFilter={tourTrustFilter} onTourTrustChange={setTourTrustFilter} onClearAll={handleClearAll} activeCount={activeFilterCount} />
); }