SkateSpots/src/pages/EventsPage.tsx
shakespeare.diy f19851da92 security: implement all 8 protection measures
## 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>
2026-06-18 14:03:15 +07:00

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>
);
}