- AppRouter wraps every route (except /amber-callback and the 404) in ONE
<Route element={<Layout/>}> with an <Outlet/>; the chrome now persists across
navigation instead of being torn down and rebuilt on every page.
- Layout: children optional (falls back to <Outlet/>); the map is full-bleed via
a route check (isFullBleed) instead of a per-page noPadding prop.
- All 59 pages + CraftProfileForm/ErrorState/NewsletterIssueView now render
fragments instead of <Layout> (structure-preserving), imports dropped.
Removes the whole remount class: chrome no longer re-runs its hooks per
navigation, Sasquatch chat state survives navigation, and the header can't
re-open Amber on every page change.
1324 lines
52 KiB
TypeScript
1324 lines
52 KiB
TypeScript
import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
|
|
import { useSeoMeta } from '@unhead/react';
|
|
import { Link } from 'react-router-dom';
|
|
import { LazyMount } from '@/components/LazyMount';
|
|
import { EventCard, EventCardSkeleton } from '@/components/EventCard';
|
|
import { TourCard, TourCardSkeleton } from '@/components/TourCard';
|
|
import { HostingDashboard } from '@/components/HostingDashboard';
|
|
import { EventsCalendar } from '@/components/EventsCalendar';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
|
import { filterByTrustLevel, type TrustFilterLevel } from '@/lib/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 { useCommentCounts } from '@/hooks/useCommentCounts';
|
|
import { useTranslation } from '@/contexts/language';
|
|
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,
|
|
LayoutGrid,
|
|
CalendarDays,
|
|
} from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { EmptyState as SharedEmptyState, type EmptyStateAction } from '@/components/EmptyState';
|
|
|
|
// ─── 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 as string) === '#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>
|
|
);
|
|
}
|
|
|
|
function CalendarSkeleton() {
|
|
return (
|
|
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_360px] gap-4 md:gap-6">
|
|
<div className="rounded-2xl border border-white/10 bg-white/[0.02] p-3 md:p-5">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<Skeleton className="h-8 w-40" />
|
|
<Skeleton className="h-8 w-28" />
|
|
</div>
|
|
<div className="grid grid-cols-7 gap-1.5">
|
|
{Array.from({ length: 42 }).map((_, i) => (
|
|
<Skeleton key={i} className="aspect-square rounded-xl" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="rounded-2xl border border-white/10 bg-white/[0.02] p-4 md:p-5 space-y-3">
|
|
<Skeleton className="h-5 w-32" />
|
|
{Array.from({ length: 3 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-16 w-full rounded-xl" />
|
|
))}
|
|
</div>
|
|
</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;
|
|
}) {
|
|
const actions: EmptyStateAction[] =
|
|
hasFilters && onClear
|
|
? [{ label: 'Clear Filters', onClick: onClear, icon: X, variant: 'secondary' }]
|
|
: cta && to
|
|
? [{ label: cta, to, icon: Plus, className: ctaClass }]
|
|
: [];
|
|
|
|
return (
|
|
<SharedEmptyState
|
|
icon={Icon}
|
|
title={title}
|
|
hint={body}
|
|
actions={actions}
|
|
footer={{ label: 'Have questions? Check the FAQ →', to: '/faq' }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// ─── Page ─────────────────────────────────────────────────────────────────────
|
|
|
|
export default function EventsPage() {
|
|
const { t } = useTranslation();
|
|
|
|
useSeoMeta({
|
|
title: 'Skate Events & Tours - SkateSpots',
|
|
description: 'Discover skateboarding events, competitions, tours, and sessions near you.',
|
|
});
|
|
|
|
const { user } = useCurrentUser();
|
|
const { data: followingList } = useFollowing(user?.pubkey);
|
|
const followCount = followingList?.length ?? 0;
|
|
const smartDefault: TrustFilterLevel = (user && followCount >= 5) ? 'trusted' : 'all';
|
|
|
|
const { data: events, isLoading: eventsLoading } = useSkateEvents(100);
|
|
const { data: tours, isLoading: toursLoading } = useTours(100);
|
|
|
|
// ── View state ──────────────────────────────────────────────────────────────
|
|
const [mainView, setMainView] = useState<MainView>('events');
|
|
// Grid (list) vs month-calendar layout. Applies to events & tours only.
|
|
const [layout, setLayout] = useState<'list' | 'calendar'>('list');
|
|
|
|
// ── 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 = useCallback((pubkey: string): number | undefined => trustScores?.get(pubkey), [trustScores]);
|
|
|
|
// ── 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: commentCounts } = useCommentCounts(allEventIds);
|
|
|
|
const getCommentCount = useCallback((eventId: string) => commentCounts?.get(eventId) ?? 0, [commentCounts]);
|
|
|
|
// ── 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);
|
|
|
|
// Going counts keyed by SkateEvent.id, for the calendar agenda.
|
|
const goingById = useMemo(() => {
|
|
const map = new Map<string, number>();
|
|
for (const e of events ?? []) {
|
|
map.set(e.id, batchRsvps.get(eventCoord(e))?.goingCount ?? 0);
|
|
}
|
|
return map;
|
|
}, [events, batchRsvps]);
|
|
|
|
// ── 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);
|
|
const 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, getTrustScore, trustScores, sortBy, dateWindow, now, getCommentCount]);
|
|
|
|
// ── 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, getTrustScore, trustScores, tourSortBy, getCommentCount]);
|
|
|
|
// ── 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 (
|
|
<>
|
|
<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 · list) ── */}
|
|
{mainView === 'events' && activeTab === 'upcoming' && layout === 'list' && (
|
|
<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>
|
|
)}
|
|
|
|
{/* ── Layout toggle (list ⇄ calendar) — events & tours only ── */}
|
|
{mainView !== 'hosting' && (
|
|
<div className="mt-3 md:mt-5 flex items-center justify-end">
|
|
<div className="inline-flex rounded-full border border-white/10 bg-white/5 p-0.5">
|
|
{([
|
|
{ key: 'list' as const, label: 'List', Icon: LayoutGrid },
|
|
{ key: 'calendar' as const, label: 'Calendar', Icon: CalendarDays },
|
|
]).map(({ key, label, Icon }) => {
|
|
const active = layout === key;
|
|
const activeCls = mainView === 'events'
|
|
? 'bg-[#ccff00] text-black'
|
|
: 'bg-cyan-500 text-white';
|
|
return (
|
|
<button
|
|
key={key}
|
|
type="button"
|
|
onClick={() => {
|
|
setLayout(key);
|
|
// The date-window chips only render in list view, so a
|
|
// window chosen there would silently keep filtering the
|
|
// calendar with no way to see or clear it.
|
|
if (key === 'calendar') setDateWindow('all');
|
|
}}
|
|
aria-pressed={active}
|
|
className={cn(
|
|
'flex items-center gap-1.5 rounded-full px-3 py-1.5 text-[11px] font-bold uppercase tracking-wide transition-all active:scale-95',
|
|
active ? activeCls : 'text-gray-400 hover:text-white',
|
|
)}
|
|
>
|
|
<Icon className="h-3.5 w-3.5" />
|
|
{label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Results ── */}
|
|
<div className="mt-3 md:mt-6">
|
|
|
|
{/* Hosting dashboard */}
|
|
{mainView === 'hosting' && <HostingDashboard />}
|
|
|
|
{/* Calendar layout — events */}
|
|
{mainView === 'events' && layout === 'calendar' && (
|
|
eventsLoading ? (
|
|
<CalendarSkeleton />
|
|
) : (
|
|
<EventsCalendar
|
|
show="events"
|
|
events={activeTab === 'upcoming' ? filteredEvents.upcoming : filteredEvents.past}
|
|
tours={[]}
|
|
goingCounts={goingById}
|
|
anchorUnix={
|
|
activeTab === 'past' && filteredEvents.past.length > 0
|
|
? filteredEvents.past.reduce((m, e) => Math.max(m, e.start), 0)
|
|
: undefined
|
|
}
|
|
/>
|
|
)
|
|
)}
|
|
|
|
{/* Calendar layout — tours */}
|
|
{mainView === 'tours' && layout === 'calendar' && (
|
|
toursLoading ? (
|
|
<CalendarSkeleton />
|
|
) : (
|
|
<EventsCalendar show="tours" events={[]} tours={filteredTours} />
|
|
)
|
|
)}
|
|
|
|
{/* Events (list) */}
|
|
{mainView === 'events' && layout === 'list' && (
|
|
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) => (
|
|
<LazyMount key={event.id} className="h-full">
|
|
<EventCard
|
|
event={event}
|
|
goingCount={batchRsvps.get(eventCoord(event))?.goingCount ?? 0}
|
|
/>
|
|
</LazyMount>
|
|
))}
|
|
</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) => (
|
|
<LazyMount key={event.id} className="h-full">
|
|
<EventCard
|
|
event={event}
|
|
goingCount={batchRsvps.get(eventCoord(event))?.goingCount ?? 0}
|
|
/>
|
|
</LazyMount>
|
|
))}
|
|
</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 (list) */}
|
|
{mainView === 'tours' && layout === 'list' && (
|
|
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) => (
|
|
<LazyMount key={tour.id} className="h-full">
|
|
<TourCard tour={tour} />
|
|
</LazyMount>
|
|
))}
|
|
</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}
|
|
/>
|
|
</>
|
|
);
|
|
}
|