## 1. sanitizeUrl() (already existed, verified applied) - Applied to ShopDetailPage, CoachDetailPage with urlDomain() tooltip hints - Blocks javascript:, data:, vbscript: and all non-http(s) schemes - Returns null for invalid URLs — link simply doesn't render ## 2. Geohash validation in parsers - Added isValidGeohash() in types.ts using ngeohash.decode() - Min 4 chars (country-level precision rejected), max 12 chars - Rejects lat/lng outside Earth bounds - Rejects 0,0 "null island" — classic bot artifact - VPN/Tor unaffected: geohash comes from map pin, not user IP ## 3. Content length limits in all parsers - MAX_TITLE_LEN=100, MAX_DESCRIPTION_LEN=3000, MAX_LOCATION_LEN=200 - MIN_TITLE_LEN=2, MIN_GEOHASH_LEN=4 - Applied to parseSkateSpot, parseSkateShop, parseSkateCoach, parseSkateFilmer, parseSkateEditor, parseSkateEvent ## 4. Smart WoT default (already in codebase, verified correct) - Logged out / <5 follows → 'all' (don't punish new/anon users) - Logged in with ≥5 follows → 'trusted' (sort trusted first) - Soft mode: trusted content floats to top, nothing hidden - Active in MapPage and EventsPage ## 5. Trusted count stats in useGrowthStats - Added trustedSpots and trustedMappers to GrowthStats type - Heuristic: pubkeys with ≥2 published items = organic community member - CommunityStatsPage now shows "🛡 X from active accounts" below spot count - Purely additive — both numbers always visible ## 6. OfficialBadge component (already existed, wired up) - Added to SpotCard, EventCard, ShopDetailPage, CoachDetailPage - Only renders for FOUNDER_PUBKEY (20e7c953...) - Zero-friction: invisible for all other pubkeys - Impersonation attempts immediately visible ## 7. Leaflet marker clustering - Installed leaflet.markercluster v1.5.3 + @types - MarkerClusterGroup replaces direct marker.addTo(map) - Custom cluster icon: red/orange gradient circle with count - maxClusterRadius: 60px, spiderfyOnMaxZoom: true - MarkerCluster CSS loaded from CDN in index.html ## 8. DM phishing warning banner - PhishingWarningBanner component in DirectMessagesPage - One-time dismissible (localStorage key: dm-phishing-warning-dismissed) - Message: SkateSpots will never DM you asking for keys/payments - Uses useLocalStorage hook, Shield icon, X dismiss button Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
633 lines
25 KiB
TypeScript
633 lines
25 KiB
TypeScript
import { useState, useMemo } 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 { 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, type SkateTour } from '@/lib/types';
|
|
import {
|
|
Search,
|
|
Plus,
|
|
Calendar,
|
|
X,
|
|
Route,
|
|
} from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
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);
|
|
|
|
// Main view: events or tours
|
|
const [mainView, setMainView] = useState<'events' | 'tours'>('events');
|
|
|
|
// Event filters
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [selectedTypes, setSelectedTypes] = useState<string[]>([]);
|
|
const [activeTab, setActiveTab] = useState<'upcoming' | 'past'>('upcoming');
|
|
const [trustFilter, setTrustFilter] = useState<TrustFilterLevel>(smartDefault);
|
|
const [sortBy, setSortBy] = useState<SortOption>('recent');
|
|
|
|
// Tour filters
|
|
const [tourSearchQuery, setTourSearchQuery] = useState('');
|
|
const [tourCategory, setTourCategory] = useState<TourCategory | 'all'>('all');
|
|
const [tourSkillLevel, setTourSkillLevel] = useState<string>('all');
|
|
const [tourVerifiedOnly, setTourVerifiedOnly] = useState(false);
|
|
const [tourTrustFilter, setTourTrustFilter] = useState<TrustFilterLevel>(smartDefault);
|
|
const [tourSortBy, setTourSortBy] = useState<SortOption>('recent');
|
|
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
// Fetch trust scores for all event organizers and tour guides
|
|
const allPubkeys = useMemo(() => {
|
|
const eventPubkeys = events?.map((e) => e.pubkey) || [];
|
|
const tourPubkeys = tours?.map((t) => t.pubkey) || [];
|
|
return [...new Set([...eventPubkeys, ...tourPubkeys])];
|
|
}, [events, tours]);
|
|
const { data: trustScores } = useBatchTrustScores(allPubkeys);
|
|
|
|
const getTrustScore = (pubkey: string) => trustScores?.get(pubkey);
|
|
|
|
// Fetch comment counts for popularity sorting
|
|
const allEventIds = useMemo(() => {
|
|
const eventIds = events?.map((e) => e.event.id) || [];
|
|
const tourIds = tours?.map((t) => t.event.id) || [];
|
|
return [...eventIds, ...tourIds];
|
|
}, [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;
|
|
};
|
|
|
|
// Filter and sort tours
|
|
const filteredTours = useMemo(() => {
|
|
if (!tours) return [];
|
|
|
|
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
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Type filter
|
|
if (selectedTypes.length > 0) {
|
|
const hasMatchingType = event.types.some((type) =>
|
|
selectedTypes.includes(type)
|
|
);
|
|
if (!hasMatchingType) 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) => {
|
|
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);
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
};
|
|
|
|
// 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);
|
|
});
|
|
|
|
return { upcoming, past };
|
|
}, [events, searchQuery, selectedTypes, trustFilter, trustScores, sortBy, now, allComments]);
|
|
|
|
const toggleType = (type: string) => {
|
|
setSelectedTypes((prev) =>
|
|
prev.includes(type)
|
|
? prev.filter((t) => t !== type)
|
|
: [...prev, type]
|
|
);
|
|
};
|
|
|
|
const clearFilters = () => {
|
|
setSearchQuery('');
|
|
setSelectedTypes([]);
|
|
setTrustFilter('all');
|
|
setSortBy('recent');
|
|
};
|
|
|
|
const clearTourFilters = () => {
|
|
setTourSearchQuery('');
|
|
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 isLoading = mainView === 'events' ? eventsLoading : toursLoading;
|
|
|
|
return (
|
|
<Layout>
|
|
<div className="container px-4 py-8">
|
|
{/* Header */}
|
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-6">
|
|
<div>
|
|
<h1 className="text-3xl font-black uppercase neon-lime" style={{
|
|
fontFamily: 'Impact, "Arial Black", sans-serif'
|
|
}}>
|
|
{mainView === 'events' ? t('events.title') : 'Skate Tours'}
|
|
</h1>
|
|
<p className="text-gray-400 mt-1">
|
|
{mainView === 'events'
|
|
? t('events.findEvents')
|
|
: 'Discover local guides, national road trips, and world destruction competitions'}
|
|
</p>
|
|
</div>
|
|
{user ? (
|
|
<Link to={mainView === 'events' ? '/create-event' : '/create-tour'}>
|
|
<Button className="gap-2 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase">
|
|
<Plus className="h-4 w-4" />
|
|
{mainView === 'events' ? t('events.createEvent') : 'Create Tour'}
|
|
</Button>
|
|
</Link>
|
|
) : (
|
|
<Badge variant="outline" className="border-yellow-500/50 text-yellow-400 px-3 py-1">
|
|
🔒 {t('events.loginToCreate')}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
|
|
{/* Main View Toggle: Events / Tours */}
|
|
<div className="flex gap-2 mb-6">
|
|
<Button
|
|
variant={mainView === 'events' ? 'default' : 'outline'}
|
|
onClick={() => setMainView('events')}
|
|
className={cn(
|
|
'gap-2 font-bold uppercase',
|
|
mainView === 'events'
|
|
? 'bg-[#ccff00] text-black hover:bg-[#ccff00]/90'
|
|
: 'border-[#ccff00]/30 text-[#ccff00] hover:bg-[#ccff00]/10'
|
|
)}
|
|
>
|
|
<Calendar className="h-4 w-4" />
|
|
Events
|
|
{events && events.length > 0 && (
|
|
<Badge variant="secondary" className={cn(
|
|
'ml-1',
|
|
mainView === 'events' ? 'bg-black/20 text-black' : 'bg-[#ccff00]/20 text-[#ccff00]'
|
|
)}>
|
|
{events.length}
|
|
</Badge>
|
|
)}
|
|
</Button>
|
|
<Button
|
|
variant={mainView === 'tours' ? 'default' : 'outline'}
|
|
onClick={() => setMainView('tours')}
|
|
className={cn(
|
|
'gap-2 font-bold uppercase',
|
|
mainView === 'tours'
|
|
? 'bg-cyan-500 text-white hover:bg-cyan-500/90'
|
|
: 'border-cyan-500/30 text-cyan-400 hover:bg-cyan-500/10'
|
|
)}
|
|
>
|
|
<Route className="h-4 w-4" />
|
|
Tours
|
|
{tours && tours.length > 0 && (
|
|
<Badge variant="secondary" className={cn(
|
|
'ml-1',
|
|
mainView === 'tours' ? 'bg-white/20 text-white' : 'bg-cyan-500/20 text-cyan-400'
|
|
)}>
|
|
{tours.length}
|
|
</Badge>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Events View */}
|
|
{mainView === 'events' && (
|
|
<>
|
|
{/* Filters */}
|
|
<div className="space-y-4 mb-8">
|
|
{/* Search, Trust Filter, and Sort */}
|
|
<div className="flex flex-col sm:flex-row gap-3">
|
|
<div className="relative flex-1 max-w-md">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder={t('events.searchPlaceholder')}
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<SortDropdown
|
|
value={sortBy}
|
|
onChange={setSortBy}
|
|
contentType="events"
|
|
/>
|
|
<TrustFilter
|
|
value={trustFilter}
|
|
onChange={setTrustFilter}
|
|
compact
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Type filters */}
|
|
<ScrollArea className="w-full whitespace-nowrap">
|
|
<div className="flex gap-2 pb-2">
|
|
{Object.entries(EVENT_TYPE_LABELS).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>
|
|
))}
|
|
{hasActiveFilters && (
|
|
<Badge
|
|
variant="destructive"
|
|
className="cursor-pointer gap-1"
|
|
onClick={clearFilters}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
{t('common.clearAll')}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<ScrollBar orientation="horizontal" />
|
|
</ScrollArea>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'upcoming' | 'past')}>
|
|
<TabsList className="mb-6">
|
|
<TabsTrigger value="upcoming" className="gap-2">
|
|
{t('events.upcomingEvents')}
|
|
{filteredEvents.upcoming.length > 0 && (
|
|
<Badge variant="secondary" className="ml-1">
|
|
{filteredEvents.upcoming.length}
|
|
</Badge>
|
|
)}
|
|
</TabsTrigger>
|
|
<TabsTrigger value="past" className="gap-2">
|
|
{t('events.pastEvents')}
|
|
{filteredEvents.past.length > 0 && (
|
|
<Badge variant="secondary" className="ml-1">
|
|
{filteredEvents.past.length}
|
|
</Badge>
|
|
)}
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="upcoming">
|
|
{eventsLoading ? (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 stagger-children">
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<EventCardSkeleton key={i} />
|
|
))}
|
|
</div>
|
|
) : filteredEvents.upcoming.length > 0 ? (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 stagger-children">
|
|
{filteredEvents.upcoming.map((event) => (
|
|
<EventCard key={event.id} event={event} />
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-12">
|
|
<Calendar className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
|
|
<p className="text-muted-foreground mb-4">
|
|
{hasActiveFilters
|
|
? t('events.noEventsMatch')
|
|
: t('events.noUpcoming')}
|
|
</p>
|
|
{hasActiveFilters ? (
|
|
<Button variant="outline" onClick={clearFilters}>
|
|
{t('common.clearFilters')}
|
|
</Button>
|
|
) : (
|
|
user && (
|
|
<Link to="/create-event">
|
|
<Button className="gap-2">
|
|
<Plus className="h-4 w-4" />
|
|
{t('events.createEvent')}
|
|
</Button>
|
|
</Link>
|
|
)
|
|
)}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="past">
|
|
{eventsLoading ? (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 stagger-children">
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<EventCardSkeleton key={i} />
|
|
))}
|
|
</div>
|
|
) : filteredEvents.past.length > 0 ? (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 stagger-children">
|
|
{filteredEvents.past.map((event) => (
|
|
<EventCard key={event.id} event={event} />
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-12">
|
|
<Calendar className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
|
|
<p className="text-muted-foreground">
|
|
{t('events.noPast')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
</>
|
|
)}
|
|
|
|
{/* Tours View */}
|
|
{mainView === 'tours' && (
|
|
<>
|
|
{/* Tour Filters */}
|
|
<div className="space-y-4 mb-8">
|
|
{/* Search, Category, Skill Level, Sort, Trust */}
|
|
<div className="flex flex-col sm:flex-row gap-3">
|
|
<div className="relative flex-1 max-w-md">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search tours by name, location, or country..."
|
|
value={tourSearchQuery}
|
|
onChange={(e) => setTourSearchQuery(e.target.value)}
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
<div className="flex gap-2 flex-wrap">
|
|
<Select value={tourCategory} onValueChange={(v) => setTourCategory(v as TourCategory | 'all')}>
|
|
<SelectTrigger className="w-[140px]">
|
|
<SelectValue placeholder="Category" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Categories</SelectItem>
|
|
{Object.entries(TOUR_CATEGORIES).map(([key, info]) => (
|
|
<SelectItem key={key} value={key}>
|
|
{info.emoji} {info.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Select value={tourSkillLevel} onValueChange={setTourSkillLevel}>
|
|
<SelectTrigger className="w-[130px]">
|
|
<SelectValue placeholder="Skill Level" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Levels</SelectItem>
|
|
{Object.entries(TOUR_SKILL_LEVELS).map(([key, info]) => (
|
|
<SelectItem key={key} value={key}>
|
|
{info.emoji} {info.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<SortDropdown
|
|
value={tourSortBy}
|
|
onChange={setTourSortBy}
|
|
contentType="tours"
|
|
/>
|
|
<TrustFilter
|
|
value={tourTrustFilter}
|
|
onChange={setTourTrustFilter}
|
|
compact
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tour Category Quick Filters */}
|
|
<ScrollArea className="w-full whitespace-nowrap">
|
|
<div className="flex gap-2 pb-2">
|
|
{Object.entries(TOUR_CATEGORIES).map(([key, info]) => (
|
|
<Badge
|
|
key={key}
|
|
variant={tourCategory === key ? 'default' : 'outline'}
|
|
className={cn(
|
|
'cursor-pointer transition-colors',
|
|
tourCategory === key
|
|
? 'text-white'
|
|
: 'hover:bg-muted'
|
|
)}
|
|
style={{
|
|
backgroundColor: tourCategory === key ? info.color : 'transparent',
|
|
borderColor: `${info.color}60`,
|
|
color: tourCategory === key ? (info.color === '#ccff00' ? 'black' : 'white') : info.color,
|
|
}}
|
|
onClick={() => setTourCategory(tourCategory === key ? 'all' : key as TourCategory)}
|
|
>
|
|
{info.emoji} {info.label}
|
|
</Badge>
|
|
))}
|
|
{hasActiveTourFilters && (
|
|
<Badge
|
|
variant="destructive"
|
|
className="cursor-pointer gap-1"
|
|
onClick={clearTourFilters}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
Clear All
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<ScrollBar orientation="horizontal" />
|
|
</ScrollArea>
|
|
</div>
|
|
|
|
{/* Tours Grid */}
|
|
{toursLoading ? (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 stagger-children">
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<TourCardSkeleton key={i} />
|
|
))}
|
|
</div>
|
|
) : filteredTours.length > 0 ? (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 stagger-children">
|
|
{filteredTours.map((tour) => (
|
|
<TourCard key={tour.id} tour={tour} />
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-12">
|
|
<Route className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
|
|
<p className="text-muted-foreground mb-4">
|
|
{hasActiveTourFilters
|
|
? 'No tours match your filters'
|
|
: 'No tours available yet'}
|
|
</p>
|
|
{hasActiveTourFilters ? (
|
|
<Button variant="outline" onClick={clearTourFilters}>
|
|
Clear Filters
|
|
</Button>
|
|
) : (
|
|
user && (
|
|
<Link to="/create-tour">
|
|
<Button className="gap-2 bg-cyan-500 hover:bg-cyan-600">
|
|
<Plus className="h-4 w-4" />
|
|
Create Tour
|
|
</Button>
|
|
</Link>
|
|
)
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</Layout>
|
|
);
|
|
}
|