SkateSpots/src/pages/EventsPage.tsx
shakespeare.diy 598362a196 Fix date-window chips overflowing off the phone screen
The chips (ANYTIME / TODAY / THIS WEEKEND / THIS WEEK / THIS MONTH) ran off the
right edge on mobile and got cut off. Now on phones they render as a 5-column
grid that fits the screen exactly — with compact labels (Any / Today / Wknd /
Week / Month) so nothing truncates. Desktop keeps the full-label flex row.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-03 15:11:30 +07:00

1227 lines
49 KiB
TypeScript

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 { HostingDashboard } from '@/components/HostingDashboard';
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 { useBatchEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
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,
Megaphone,
} 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',
},
hosting: {
active: 'bg-orange-500 text-white border-orange-500',
chip: 'bg-orange-500 text-white border-orange-500',
chipHover: 'hover:border-orange-500/60 hover:text-orange-400',
border: 'border-orange-500/40',
icon: 'text-orange-400',
badge: 'bg-orange-500/15 text-orange-400 border-orange-500/30',
stripe: 'bg-orange-500',
},
} as const;
type MainView = 'events' | 'tours' | 'hosting';
// ─── Date window filter (skaters plan by weekend) ─────────────────────────────
type DateWindow = 'all' | 'today' | 'weekend' | 'week' | 'month';
const DATE_WINDOW_LABELS: Record<Exclude<DateWindow, 'all'>, string> = {
today: 'Today',
weekend: 'Weekend',
week: 'This Week',
month: 'This Month',
};
/** Compact labels for the 5-across mobile grid (avoid truncation). */
const DATE_WINDOW_LABELS_SHORT: Record<DateWindow, string> = {
all: 'Any',
today: 'Today',
weekend: 'Wknd',
week: 'Week',
month: 'Month',
};
/**
* Returns [startSec, endSec) for a date window, in local time. `weekend` covers
* the upcoming Sat+Sun (or the current weekend if it's already Sat/Sun).
*/
function dateWindowRange(window: Exclude<DateWindow, 'all'>): [number, number] {
const now = new Date();
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const sec = (d: Date) => Math.floor(d.getTime() / 1000);
const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
switch (window) {
case 'today':
return [sec(startOfToday), sec(addDays(startOfToday, 1))];
case 'weekend': {
// day: 0=Sun … 6=Sat. Find the coming Saturday (or today if Sat/Sun).
const day = startOfToday.getDay();
let satOffset: number;
if (day === 6) satOffset = 0; // Saturday
else if (day === 0) satOffset = -1; // Sunday → weekend started yesterday
else satOffset = 6 - day; // upcoming Saturday
const sat = addDays(startOfToday, satOffset);
const end = addDays(sat, 2); // Sat 00:00 → Mon 00:00
// Never start the window in the past.
const start = sec(sat) < sec(startOfToday) ? sec(startOfToday) : sec(sat);
return [start, sec(end)];
}
case 'week':
return [sec(startOfToday), sec(addDays(startOfToday, 7))];
case 'month':
return [sec(startOfToday), sec(addDays(startOfToday, 30))];
}
}
// ─── FAB — create event / tour ────────────────────────────────────────────────
function CreateFAB({ mainView }: { mainView: MainView }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(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 (
<div ref={ref} className="relative">
<button
onClick={() => setOpen((v) => !v)}
className={cn(
'flex items-center gap-2 px-4 py-2.5 rounded-xl font-black text-sm uppercase tracking-wide transition-all active:scale-95',
open ? 'bg-white text-black' : accent.active
)}
>
{open ? <X className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
<span className="hidden sm:inline">Create</span>
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform', open && 'rotate-180')} />
</button>
{open && (
<div className="absolute right-0 top-full mt-2 w-48 rounded-xl border border-white/10 bg-black/95 shadow-2xl shadow-black/60 backdrop-blur z-50 overflow-hidden">
<div className="p-1.5 space-y-0.5">
<Link
to="/create-event"
onClick={() => 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"
>
<Calendar className="h-4 w-4 flex-shrink-0" />
{t('events.createEvent')}
</Link>
<Link
to="/create-tour"
onClick={() => 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"
>
<Route className="h-4 w-4 flex-shrink-0" />
Create Tour
</Link>
</div>
</div>
)}
</div>
);
}
// ─── Sticky toolbar ───────────────────────────────────────────────────────────
interface ToolbarProps {
mainView: MainView;
onViewChange: (v: MainView) => void;
counts: { events: number; tours: number };
/** Show the "Hosting" tab (logged-in users only). */
showHosting: boolean;
hostingCount: 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,
showHosting,
hostingCount,
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 (
<div className="sticky top-14 md:top-16 z-30 bg-black/95 backdrop-blur border-b border-white/5 -mx-4 px-4 py-2 md:py-3 space-y-2 md:space-y-2.5">
{/* Main view tabs: Events / Tours / Hosting */}
<div className="flex items-center gap-2">
{(['events', 'tours', ...(showHosting ? ['hosting'] as const : [])] as MainView[]).map((view) => {
const isActive = mainView === view;
const a = ACCENT[view];
const Icon = view === 'events' ? Calendar : view === 'tours' ? Route : Megaphone;
const label = view === 'events' ? 'Events' : view === 'tours' ? 'Tours' : 'Hosting';
const count = view === 'hosting' ? hostingCount : counts[view as 'events' | 'tours'];
return (
<button
key={view}
onClick={() => onViewChange(view)}
className={cn(
'relative flex items-center gap-1.5 px-3 py-1.5 md:py-2 rounded-xl font-black text-xs uppercase tracking-wide transition-all duration-150 active:scale-95 flex-1 justify-center',
isActive
? a.active
: 'text-gray-500 bg-white/5 hover:bg-white/10 hover:text-white border border-transparent'
)}
>
<Icon className="h-3.5 w-3.5 flex-shrink-0" />
<span>{label}</span>
{!isLoading && (
<span className={cn(
'ml-0.5 min-w-[18px] h-[18px] px-1 text-[9px] font-black rounded-full flex items-center justify-center leading-none',
count > 0
? isActive
? view === 'events' ? 'bg-black/20 text-black' : 'bg-white/25 text-white'
: 'bg-white/10 text-gray-400'
: 'bg-white/5 text-gray-600'
)}>
{count}
</span>
)}
</button>
);
})}
</div>
{/* Upcoming / Past sub-tabs (events only) */}
{mainView === 'events' && (
<div className="flex items-center gap-1.5 bg-white/5 p-1 rounded-xl">
{(['upcoming', 'past'] as const).map((tab) => {
const isActive = activeTab === tab;
const count = tab === 'upcoming' ? upcomingCount : pastCount;
return (
<button
key={tab}
onClick={() => onTabChange(tab)}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 px-3 py-1 md:py-1.5 rounded-lg text-xs font-black uppercase tracking-wide transition-all',
isActive
? 'bg-[#ccff00] text-black shadow-sm'
: 'text-gray-500 hover:text-gray-300'
)}
>
{tab === 'upcoming' ? 'Upcoming' : 'Past'}
{!isLoading && (
<span className={cn(
'min-w-[16px] h-[16px] px-1 text-[9px] font-black rounded-full flex items-center justify-center leading-none',
isActive ? 'bg-black/20 text-black' : 'bg-white/10 text-gray-500'
)}>
{count}
</span>
)}
</button>
);
})}
</div>
)}
{/* Search + Location + Filter row (not shown in Hosting) */}
{mainView !== 'hosting' && (
<div className="flex items-center gap-2">
{/* Search */}
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-500 pointer-events-none" />
<input
type="text"
value={searchQuery}
onChange={(e) => 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 && (
<button onClick={() => onSearchChange('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white transition-colors">
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
{/* Location */}
<div className="relative flex-1">
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-500 pointer-events-none" />
<input
type="text"
value={locationQuery}
onChange={(e) => 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 && (
<button onClick={() => onLocationChange('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white transition-colors">
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
{/* Filter button */}
<button
onClick={onOpenFilters}
className={cn(
'relative flex items-center gap-1.5 h-9 px-3 rounded-xl font-bold text-xs uppercase tracking-wide transition-all active:scale-95 border whitespace-nowrap flex-shrink-0',
activeFilterCount > 0
? `${accent.badge} border-current`
: 'bg-white/5 border-white/10 text-gray-400 hover:bg-white/10 hover:text-white'
)}
>
<SlidersHorizontal className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Filters</span>
{activeFilterCount > 0 && (
<span className="flex items-center justify-center w-4 h-4 text-[9px] font-black rounded-full bg-current/30 leading-none">
{activeFilterCount}
</span>
)}
</button>
</div>
)}
{/* Active filter chips — only shown when something is selected */}
{mainView !== 'hosting' && hasActiveChips && (
<div className="flex items-center gap-1.5 flex-wrap">
{selectedTypes.map((type) => (
<button
key={type}
onClick={() => onRemoveType(type)}
className={cn(
'flex items-center gap-1 px-2.5 py-1 rounded-full text-[10px] font-bold border transition-all hover:opacity-80 active:scale-95',
accent.chip
)}
>
{EVENT_TYPE_LABELS[type] || type}
<X className="h-2.5 w-2.5" />
</button>
))}
{sortBy !== 'recent' && (
<span className={cn('flex items-center gap-1 px-2.5 py-1 rounded-full text-[10px] font-bold border', accent.badge)}>
{sortBy === 'trust' && <><Star className="h-2.5 w-2.5" /> Top Rated</>}
{sortBy === 'popular' && <><TrendingUp className="h-2.5 w-2.5" /> Popular</>}
</span>
)}
{trustFilter !== 'all' && (
<span className={cn('flex items-center gap-1 px-2.5 py-1 rounded-full text-[10px] font-bold border', accent.badge)}>
<Shield className="h-2.5 w-2.5" />
{trustFilter === 'verified' ? 'Verified' : trustFilter === 'trusted' ? 'Trusted' : 'Following'}
</span>
)}
<button
onClick={onClearAll}
className="px-2.5 py-1 rounded-full text-[10px] font-bold text-gray-500 hover:text-red-400 border border-white/10 hover:border-red-500/30 transition-all"
>
Clear all
</button>
</div>
)}
{/* Results count — desktop only; on mobile it's just noise eating space */}
{mainView !== 'hosting' && !isLoading && (
<p className="hidden md:block text-[11px] text-gray-500 font-semibold leading-none">
{totalCount === 0 ? 'No results' : `${totalCount} result${totalCount !== 1 ? 's' : ''}`}
</p>
)}
</div>
);
}
// ─── 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 */}
<div className="flex items-center justify-between px-4 pt-4 pb-3 flex-shrink-0 border-b border-white/5">
<div className="flex items-center gap-2">
<SlidersHorizontal className={cn('h-4 w-4', accent.icon)} />
<span className="font-black text-base text-white">Filters</span>
{activeCount > 0 && (
<Badge className={cn('text-xs px-1.5 py-0', accent.badge)}>
{activeCount} active
</Badge>
)}
</div>
<div className="flex items-center gap-3">
{activeCount > 0 && (
<button onClick={onClearAll} className="text-xs text-gray-400 hover:text-red-400 transition-colors font-semibold">
Clear all
</button>
)}
<button onClick={onClose} className="w-7 h-7 flex items-center justify-center rounded-full bg-white/5 hover:bg-white/10 transition-colors">
<X className="h-3.5 w-3.5 text-gray-400" />
</button>
</div>
</div>
{/* Scrollable body */}
<div className="flex-1 min-h-0 overflow-y-auto px-4 space-y-5 py-4 scroll-container">
{/* Sort */}
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">Sort By</p>
<div className="flex gap-2 flex-wrap">
{sortOptions.map(({ value, label, icon: Icon }) => (
<button
key={value}
onClick={() => handleSortChange(value)}
className={cn(
'flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-bold border transition-all',
activeSortBy === value
? accent.chip
: 'bg-white/5 border-white/10 text-gray-400 hover:bg-white/10 hover:text-white'
)}
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
))}
</div>
</div>
{/* Trust — inline pills */}
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">Trust Level</p>
<div className="flex flex-wrap gap-2">
{trustOptions.map(({ value, label, icon: Icon }) => (
<button
key={value}
onClick={() => handleTrustChange(value)}
className={cn(
'flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-bold border transition-all',
activeTrustFilter === value
? accent.chip
: 'bg-white/5 border-white/10 text-gray-400 hover:bg-white/10 hover:text-white'
)}
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
))}
</div>
</div>
{/* Events: event type chips */}
{mainView === 'events' && (
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2.5">Event Type</p>
<div className="flex flex-wrap gap-1.5">
{Object.entries(EVENT_TYPE_LABELS).map(([type, label]) => {
const isActive = selectedTypes.includes(type);
return (
<button
key={type}
onClick={() => onToggleType(type)}
className={cn(
'px-3 py-1.5 rounded-full text-xs font-bold border transition-all active:scale-95',
isActive
? accent.chip
: `bg-white/5 border-white/10 text-gray-400 ${accent.chipHover}`
)}
>
{label}
</button>
);
})}
</div>
</div>
)}
{/* Tours: category chips */}
{mainView === 'tours' && (
<>
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2.5">Tour Category</p>
<div className="flex flex-wrap gap-1.5">
<button
onClick={() => onTourCategoryChange('all')}
className={cn(
'px-3 py-1.5 rounded-full text-xs font-bold border transition-all active:scale-95',
tourCategory === 'all'
? accent.chip
: 'bg-white/5 border-white/10 text-gray-400 hover:border-cyan-500/60 hover:text-cyan-400'
)}
>
All
</button>
{Object.entries(TOUR_CATEGORIES).map(([key, info]) => {
const isActive = tourCategory === key;
return (
<button
key={key}
onClick={() => onTourCategoryChange(isActive ? 'all' : key as TourCategory)}
className={cn(
'px-3 py-1.5 rounded-full text-xs font-bold border transition-all active:scale-95',
)}
style={{
backgroundColor: isActive ? info.color : 'transparent',
borderColor: isActive ? info.color : `${info.color}40`,
color: isActive ? (info.color === '#ccff00' ? 'black' : 'white') : info.color,
}}
>
{info.emoji} {info.label}
</button>
);
})}
</div>
</div>
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2.5">Skill Level</p>
<div className="flex flex-wrap gap-1.5">
<button
onClick={() => onSkillLevelChange('all')}
className={cn(
'px-3 py-1.5 rounded-full text-xs font-bold border transition-all active:scale-95',
tourSkillLevel === 'all'
? accent.chip
: 'bg-white/5 border-white/10 text-gray-400 hover:border-cyan-500/60 hover:text-cyan-400'
)}
>
All Levels
</button>
{Object.entries(TOUR_SKILL_LEVELS).filter(([k]) => k !== 'all').map(([key, info]) => {
const isActive = tourSkillLevel === key;
return (
<button
key={key}
onClick={() => onSkillLevelChange(isActive ? 'all' : key)}
className={cn(
'px-3 py-1.5 rounded-full text-xs font-bold border transition-all active:scale-95',
isActive
? accent.chip
: `bg-white/5 border-white/10 text-gray-400 ${accent.chipHover}`
)}
>
{info.emoji} {info.label}
</button>
);
})}
</div>
</div>
</>
)}
</div>
{/* Apply */}
<div className="flex-shrink-0 px-4 pt-3 pb-4 border-t border-white/5">
<button
onClick={onClose}
className={cn(
'w-full h-12 rounded-xl font-black text-base uppercase tracking-wide transition-all active:scale-[0.98]',
accent.active
)}
>
Show Results{activeCount > 0 ? ` · ${activeCount} active` : ''}
</button>
</div>
</>
);
return (
<>
{/* Mobile: bottom sheet */}
<div className="md:hidden">
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
<SheetContent side="bottom" className="h-[85vh] max-h-[750px] rounded-t-2xl p-0 flex flex-col bg-[#0a0a0a]">
<SheetHeader className="sr-only">
<SheetTitle>Filters</SheetTitle>
</SheetHeader>
{drawerContent}
</SheetContent>
</Sheet>
</div>
{/* Desktop: right side panel */}
<div className="hidden md:block">
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
<SheetContent side="right" className="w-80 p-0 flex flex-col bg-[#0a0a0a] border-l border-white/8">
<SheetHeader className="sr-only">
<SheetTitle>Filters</SheetTitle>
</SheetHeader>
{drawerContent}
</SheetContent>
</Sheet>
</div>
</>
);
}
// ─── Skeleton rows ────────────────────────────────────────────────────────────
function SkeletonGrid({ count = 6 }: { count?: number }) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 stagger-children">
{Array.from({ length: count }).map((_, i) => (
<EventCardSkeleton key={i} />
))}
</div>
);
}
function TourSkeletonGrid({ count = 6 }: { count?: number }) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 stagger-children">
{Array.from({ length: count }).map((_, i) => (
<TourCardSkeleton key={i} />
))}
</div>
);
}
// ─── 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 (
<div className="flex flex-col items-center justify-center py-20 px-4 text-center">
<div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4">
<Icon className="h-8 w-8 text-gray-600" />
</div>
<h3 className="text-lg font-black mb-2 text-white">{title}</h3>
<p className="text-gray-500 text-sm max-w-xs mb-6">{body}</p>
{hasFilters && onClear ? (
<button
onClick={onClear}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl font-black text-sm uppercase tracking-wide text-white bg-white/10 hover:bg-white/15 transition-all active:scale-95"
>
<X className="h-4 w-4" />
Clear Filters
</button>
) : cta && to ? (
<Link to={to}>
<button className={cn('flex items-center gap-2 px-5 py-2.5 rounded-xl font-black text-sm uppercase tracking-wide text-white transition-all active:scale-95', ctaClass)}>
<Plus className="h-4 w-4" />
{cta}
</button>
</Link>
) : null}
<Link to="/faq" className="mt-5 text-xs text-gray-600 hover:text-gray-400 underline underline-offset-2 transition-colors">
Have questions? Check the FAQ →
</Link>
</div>
);
}
// ─── 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<MainView>('events');
// ── Event filters ───────────────────────────────────────────────────────────
const [searchQuery, setSearchQuery] = useState('');
const [locationQuery, setLocationQuery] = 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');
const [dateWindow, setDateWindow] = useState<DateWindow>('all');
// ── Tour filters ─────────────────────────────────────────────────────────────
const [tourSearchQuery, setTourSearchQuery] = useState('');
const [tourLocationQuery, setTourLocationQuery] = useState('');
const [tourCategory, setTourCategory] = useState<TourCategory | 'all'>('all');
const [tourSkillLevel, setTourSkillLevel] = useState<string>('all');
const [tourTrustFilter, setTourTrustFilter] = useState<TrustFilterLevel>(smartDefault);
const [tourSortBy, setTourSortBy] = useState<SortOption>('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);
// `trustScores` is a plain Map<pubkey, score> (see useBatchTrustScores).
const getTrustScore = (pubkey: string): number | undefined => 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;
// ── Batch RSVP counts (one query for the whole grid, not one per card) ─────────
const eventCoords = useMemo(() => (events ?? []).map((e) => eventCoord(e)), [events]);
const batchRsvps = useBatchEventRsvps(eventCoords);
// ── Counts for tab badges ─────────────────────────────────────────────────────
const rawCounts = useMemo(() => ({
events: events?.length ?? 0,
tours: tours?.length ?? 0,
}), [events, tours]);
// Events the logged-in user is hosting (for the Hosting tab badge).
const hostingCount = useMemo(
() => (user && events ? events.filter((e) => e.pubkey === user.pubkey).length : 0),
[user, events],
);
// ── 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);
// Date window applies to upcoming events only.
if (dateWindow !== 'all') {
const [ws, we] = dateWindowRange(dateWindow);
upcoming = upcoming.filter((e) => e.start >= ws && e.start < we);
}
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, dateWindow, 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) +
(dateWindow !== 'all' ? 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');
setDateWindow('all');
};
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' || dateWindow !== 'all');
const hasTourFilters = !!(tourSearchQuery || tourLocationQuery || tourCategory !== 'all' || tourSkillLevel !== 'all' || tourTrustFilter !== 'all' || tourSortBy !== 'recent');
return (
<Layout>
<div className="container px-4 py-3 md:py-10 max-w-6xl mx-auto">
{/* ── Page header ── */}
<div className="flex items-start justify-between gap-4 mb-3 md:mb-6">
<div>
<h1 className="text-2xl md:text-5xl font-black uppercase leading-none mb-0.5 md:mb-1">
<span className="neon-lime">SKATE</span>{' '}
<span className="text-white">
{mainView === 'events' ? 'EVENTS' : mainView === 'tours' ? 'TOURS' : 'HOSTING'}
</span>
</h1>
{/* Subtitle is noise on a small screen — hide it on mobile to lift
the cards above the fold. */}
<p className="hidden md:block text-gray-500 text-sm">
{mainView === 'events'
? t('events.findEvents')
: mainView === 'tours'
? 'Local guides, road trips & world destruction competitions'
: 'Your events — RSVPs, updates & quick edits'}
</p>
</div>
{user ? (
<CreateFAB mainView={mainView} />
) : (
<Badge variant="outline" className="border-yellow-500/40 text-yellow-500/80 text-xs px-2.5 py-1 flex-shrink-0">
🔒 {t('events.loginToCreate')}
</Badge>
)}
</div>
{/* ── Sticky toolbar ── */}
<EventsToolbar
mainView={mainView}
onViewChange={handleViewChange}
counts={rawCounts}
showHosting={!!user}
hostingCount={hostingCount}
searchQuery={currentSearchQuery}
onSearchChange={handleSearchChange}
locationQuery={currentLocationQuery}
onLocationChange={handleLocationChange}
activeFilterCount={activeFilterCount}
onOpenFilters={() => 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}
/>
{/* ── Quick date-window chips (events · upcoming) ── */}
{mainView === 'events' && activeTab === 'upcoming' && (
<div className="mt-2 md:mt-4 grid grid-cols-5 gap-1.5 md:flex md:items-center md:gap-2">
{(['all', 'today', 'weekend', 'week', 'month'] as DateWindow[]).map((w) => {
const active = dateWindow === w;
const fullLabel = w === 'all' ? 'Anytime' : DATE_WINDOW_LABELS[w];
return (
<button
key={w}
type="button"
onClick={() => setDateWindow(w)}
className={cn(
'rounded-full border px-1.5 py-1.5 text-[10px] leading-none md:flex-shrink-0 md:px-3 md:text-xs font-bold uppercase tracking-tight md:tracking-wide transition-all active:scale-95 text-center',
active
? 'border-[#ccff00] bg-[#ccff00] text-black'
: 'border-white/10 bg-white/5 text-gray-400 hover:border-white/20 hover:text-white',
)}
>
{/* Short label on phones (fits 5-across), full label on desktop */}
<span className="md:hidden">{DATE_WINDOW_LABELS_SHORT[w]}</span>
<span className="hidden md:inline">{fullLabel}</span>
</button>
);
})}
</div>
)}
{/* ── Results ── */}
<div className="mt-3 md:mt-6">
{/* Hosting dashboard */}
{mainView === 'hosting' && <HostingDashboard />}
{/* Events */}
{mainView === 'events' && (
eventsLoading ? (
<SkeletonGrid />
) : activeTab === 'upcoming' ? (
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}
goingCount={batchRsvps.get(eventCoord(event))?.goingCount ?? 0}
/>
))}
</div>
) : (
<EmptyState
icon={Calendar}
title="No upcoming events"
body={hasEventFilters ? 'No events match your filters. Try adjusting or clearing them.' : 'No upcoming skate events found. Be the first to create one!'}
cta={user ? t('events.createEvent') : undefined}
to="/create-event"
ctaClass="bg-[#ccff00]/90 hover:bg-[#ccff00] !text-black"
onClear={hasEventFilters ? handleClearEventFilters : undefined}
hasFilters={hasEventFilters}
/>
)
) : (
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}
goingCount={batchRsvps.get(eventCoord(event))?.goingCount ?? 0}
/>
))}
</div>
) : (
<EmptyState
icon={Calendar}
title="No past events"
body="No past skate events found in your current filters."
onClear={hasEventFilters ? handleClearEventFilters : undefined}
hasFilters={hasEventFilters}
/>
)
)
)}
{/* Tours */}
{mainView === 'tours' && (
toursLoading ? (
<TourSkeletonGrid />
) : 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>
) : (
<EmptyState
icon={Route}
title="No tours found"
body={hasTourFilters ? 'No tours match your filters. Try adjusting or clearing them.' : 'No skate tours yet. Create the first one and take skaters on a spot discovery!'}
cta={user ? 'Create Tour' : undefined}
to="/create-tour"
ctaClass="bg-cyan-500 hover:bg-cyan-600"
onClear={hasTourFilters ? handleClearTourFilters : undefined}
hasFilters={hasTourFilters}
/>
)
)}
</div>
</div>
{/* ── Filter drawer ── */}
<FilterDrawer
open={filtersOpen}
onClose={() => 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}
/>
</Layout>
);
}