diff --git a/src/pages/EventsPage.tsx b/src/pages/EventsPage.tsx index bbdf4a5..68fa8fc 100644 --- a/src/pages/EventsPage.tsx +++ b/src/pages/EventsPage.tsx @@ -1,17 +1,13 @@ -import { useState, useMemo } from 'react'; +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 { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { TrustFilter, type TrustFilterLevel, filterByTrustLevel } from '@/components/TrustFilter'; -import { SortDropdown, type SortOption } from '@/components/SortDropdown'; +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'; @@ -19,19 +15,700 @@ 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, type SkateTour } from '@/lib/types'; +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} +
+ ); +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + export default function EventsPage() { const { t } = useTranslation(); - + useSeoMeta({ title: 'Skate Events & Tours - SkateSpots', description: 'Discover skateboarding events, competitions, tours, and sessions near you.', @@ -41,150 +718,81 @@ export default function EventsPage() { 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); - // Main view: events or tours - const [mainView, setMainView] = useState<'events' | 'tours'>('events'); - - // Event filters + // ── 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 + + // ── Tour filters ───────────────────────────────────────────────────────────── const [tourSearchQuery, setTourSearchQuery] = useState(''); + const [tourLocationQuery, setTourLocationQuery] = useState(''); const [tourCategory, setTourCategory] = useState('all'); const [tourSkillLevel, setTourSkillLevel] = useState('all'); - const [tourVerifiedOnly, setTourVerifiedOnly] = useState(false); const [tourTrustFilter, setTourTrustFilter] = useState(smartDefault); const [tourSortBy, setTourSortBy] = useState('recent'); + const [filtersOpen, setFiltersOpen] = useState(false); + const now = Math.floor(Date.now() / 1000); - // Fetch trust scores for all event organizers and tour guides + // ── Trust scores ───────────────────────────────────────────────────────────── const allPubkeys = useMemo(() => { - const eventPubkeys = events?.map((e) => e.pubkey) || []; - const tourPubkeys = tours?.map((t) => t.pubkey) || []; - return [...new Set([...eventPubkeys, ...tourPubkeys])]; + 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); - const getTrustScore = (pubkey: string) => trustScores?.get(pubkey); - - // Fetch comment counts for popularity sorting + // ── Comment counts ──────────────────────────────────────────────────────────── const allEventIds = useMemo(() => { - const eventIds = events?.map((e) => e.event.id) || []; - const tourIds = tours?.map((t) => t.event.id) || []; - return [...eventIds, ...tourIds]; + 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) => { - return allComments?.filter((c) => { - const eTag = c.tags.find(([tag]) => tag === 'e')?.[1]; - return eTag === eventId; - }).length || 0; - }; + const getCommentCount = (eventId: string) => + allComments?.filter((c) => c.tags.find(([tag]) => tag === 'e')?.[1] === eventId).length || 0; - // Filter and sort tours - const filteredTours = useMemo(() => { - if (!tours) return []; + // ── Counts for tab badges ───────────────────────────────────────────────────── + const rawCounts = useMemo(() => ({ + events: events?.length ?? 0, + tours: tours?.length ?? 0, + }), [events, tours]); - let filtered = tours.filter((tour) => { - // Search filter - if (tourSearchQuery) { - const query = tourSearchQuery.toLowerCase(); - const matchesTitle = tour.title.toLowerCase().includes(query); - const matchesLocation = tour.location?.toLowerCase().includes(query); - const matchesDescription = tour.description?.toLowerCase().includes(query); - const matchesCities = tour.cities?.some(c => c.toLowerCase().includes(query)); - const matchesCountries = tour.countries?.some(c => c.toLowerCase().includes(query)); - if (!matchesTitle && !matchesLocation && !matchesDescription && !matchesCities && !matchesCountries) { - return false; - } - } - - // Category filter - if (tourCategory !== 'all' && tour.category !== tourCategory) { - return false; - } - - // Skill level filter - if (tourSkillLevel !== 'all' && tour.skillLevel !== tourSkillLevel) { - return false; - } - - return true; - }); - - // Apply trust filter (soft for 'trusted', strict for 'verified'/'following') - 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); - } - } - - // Apply sorting - filtered.sort((a, b) => { - switch (tourSortBy) { - case 'trust': { - const scoreA = getTrustScore(a.pubkey) || 0; - const scoreB = getTrustScore(b.pubkey) || 0; - if (scoreB !== scoreA) return scoreB - scoreA; - return b.createdAt - a.createdAt; - } - case 'popular': { - const popA = getCommentCount(a.event.id); - const popB = getCommentCount(b.event.id); - if (popB !== popA) return popB - popA; - return b.createdAt - a.createdAt; - } - case 'recent': - default: - return b.createdAt - a.createdAt; - } - }); - - return filtered; - }, [tours, tourSearchQuery, tourCategory, tourSkillLevel, tourTrustFilter, trustScores, tourSortBy]); - - // Filter and sort events + // ── Filter events ───────────────────────────────────────────────────────────── const filteredEvents = useMemo(() => { if (!events) return { upcoming: [], past: [] }; let filtered = events.filter((event) => { - // Search filter if (searchQuery) { - const query = searchQuery.toLowerCase(); - const matchesTitle = event.title.toLowerCase().includes(query); - const matchesLocation = event.location?.toLowerCase().includes(query); - const matchesDescription = event.description?.toLowerCase().includes(query); - if (!matchesTitle && !matchesLocation && !matchesDescription) { - return false; - } + 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; } - - // Type filter if (selectedTypes.length > 0) { - const hasMatchingType = event.types.some((type) => - selectedTypes.includes(type) - ); - if (!hasMatchingType) return false; + if (!event.types.some((type) => selectedTypes.includes(type))) return false; } - return true; }); - // Apply trust filter (soft for 'trusted', strict for 'verified'/'following') if (trustScores && trustFilter !== 'all') { if (trustFilter === 'trusted') { filtered.sort((a, b) => { @@ -197,404 +805,236 @@ export default function EventsPage() { } } - // Split into upcoming and past let upcoming = filtered.filter((e) => e.start >= now); let past = filtered.filter((e) => e.start < now); - // Apply sorting const sortFn = (a: typeof events[0], b: typeof events[0]) => { - switch (sortBy) { - case 'trust': { - const scoreA = getTrustScore(a.pubkey) || 0; - const scoreB = getTrustScore(b.pubkey) || 0; - if (scoreB !== scoreA) return scoreB - scoreA; // Highest first - return a.start - b.start; // Then by time - } - case 'popular': { - const popA = getCommentCount(a.event.id); - const popB = getCommentCount(b.event.id); - if (popB !== popA) return popB - popA; // Most popular first - return a.start - b.start; - } - case 'recent': - default: - return 0; // Keep time-based sorting + 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; }; - // Sort upcoming by start time (ascending) + secondary sort - upcoming.sort((a, b) => { - if (sortBy === 'recent') return a.start - b.start; - return sortFn(a, b); - }); - - // Sort past by start time (descending) + secondary sort - past.sort((a, b) => { - if (sortBy === 'recent') return b.start - a.start; - return sortFn(a, b); - }); + 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, selectedTypes, trustFilter, trustScores, sortBy, now, allComments]); + }, [events, searchQuery, locationQuery, selectedTypes, trustFilter, trustScores, sortBy, now, allComments]); - const toggleType = (type: string) => { - setSelectedTypes((prev) => - prev.includes(type) - ? prev.filter((t) => t !== type) - : [...prev, type] - ); - }; + // ── Filter tours ────────────────────────────────────────────────────────────── + const filteredTours = useMemo(() => { + if (!tours) return []; - const clearFilters = () => { + 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 clearTourFilters = () => { + const handleClearTourFilters = () => { setTourSearchQuery(''); + setTourLocationQuery(''); setTourCategory('all'); setTourSkillLevel('all'); - setTourVerifiedOnly(false); setTourTrustFilter('all'); setTourSortBy('recent'); }; - const hasActiveFilters = searchQuery || selectedTypes.length > 0 || trustFilter !== 'all' || sortBy !== 'recent'; - const hasActiveTourFilters = tourSearchQuery || tourCategory !== 'all' || tourSkillLevel !== 'all' || tourVerifiedOnly || tourTrustFilter !== 'all' || tourSortBy !== '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 ( -
- {/* Header */} -
+
+ + {/* ── Page header ── */} +
-

- {mainView === 'events' ? t('events.title') : 'Skate Tours'} +

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

-

- {mainView === 'events' +

+ {mainView === 'events' ? t('events.findEvents') - : 'Discover local guides, national road trips, and world destruction competitions'} + : 'Local guides, road trips & world destruction competitions'}

+ {user ? ( - - - + ) : ( - + 🔒 {t('events.loginToCreate')} )}
- {/* Main View Toggle: Events / Tours */} -
- - -
+ {/* ── 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} + /> - {/* Events View */} - {mainView === 'events' && ( - <> - {/* Filters */} -
- {/* Search, Trust Filter, and Sort */} -
-
- - setSearchQuery(e.target.value)} - className="pl-9" - /> -
-
- - -
-
+ {/* ── Results ── */} +
- {/* Type filters */} - -
- {Object.entries(EVENT_TYPE_LABELS).map(([type, label]) => ( - toggleType(type)} - > - {label} - + {/* Events */} + {mainView === 'events' && ( + eventsLoading ? ( + + ) : activeTab === 'upcoming' ? ( + filteredEvents.upcoming.length > 0 ? ( +
+ {filteredEvents.upcoming.map((event) => ( + ))} - {hasActiveFilters && ( - - - {t('common.clearAll')} - - )}
- - -
- - {/* Tabs */} - setActiveTab(v as 'upcoming' | 'past')}> - - - {t('events.upcomingEvents')} - {filteredEvents.upcoming.length > 0 && ( - - {filteredEvents.upcoming.length} - - )} - - - {t('events.pastEvents')} - {filteredEvents.past.length > 0 && ( - - {filteredEvents.past.length} - - )} - - - - - {eventsLoading ? ( -
- {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
- ) : filteredEvents.upcoming.length > 0 ? ( -
- {filteredEvents.upcoming.map((event) => ( - - ))} -
- ) : ( -
- -

- {hasActiveFilters - ? t('events.noEventsMatch') - : t('events.noUpcoming')} -

- {hasActiveFilters ? ( - - ) : ( - user && ( - - - - ) - )} -
- )} -
- - - {eventsLoading ? ( -
- {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
- ) : filteredEvents.past.length > 0 ? ( -
- {filteredEvents.past.map((event) => ( - - ))} -
- ) : ( -
- -

- {t('events.noPast')} -

-
- )} -
-
- - )} - - {/* Tours View */} - {mainView === 'tours' && ( - <> - {/* Tour Filters */} -
- {/* Search, Category, Skill Level, Sort, Trust */} -
-
- - setTourSearchQuery(e.target.value)} - className="pl-9" - /> -
-
- - - - - - -
-
- - {/* Tour Category Quick Filters */} - -
- {Object.entries(TOUR_CATEGORIES).map(([key, info]) => ( - setTourCategory(tourCategory === key ? 'all' : key as TourCategory)} - > - {info.emoji} {info.label} - + ) : ( + + ) + ) : ( + filteredEvents.past.length > 0 ? ( +
+ {filteredEvents.past.map((event) => ( + ))} - {hasActiveTourFilters && ( - - - Clear All - - )}
- - -
+ ) : ( + + ) + ) + )} - {/* Tours Grid */} - {toursLoading ? ( -
- {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
+ {/* Tours */} + {mainView === 'tours' && ( + toursLoading ? ( + ) : filteredTours.length > 0 ? (
{filteredTours.map((tour) => ( @@ -602,32 +1042,47 @@ export default function EventsPage() { ))}
) : ( -
- -

- {hasActiveTourFilters - ? 'No tours match your filters' - : 'No tours available yet'} -

- {hasActiveTourFilters ? ( - - ) : ( - user && ( - - - - ) - )} -
- )} - - )} + + ) + )} +
+ + {/* ── 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} + /> ); }