import { useState, useMemo, useEffect, lazy, Suspense } from 'react'; import { useSeoMeta } from '@unhead/react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { Layout } from '@/components/Layout'; // Lazy-load the map β€” Leaflet is large (~150KB) and only needed on this page const SpotMap = lazy(() => import('@/components/SpotMap').then(m => ({ default: m.SpotMap }))); import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard'; import { WeatherToggle } from '@/components/WeatherToggle'; import { TrustFilter, type TrustFilterLevel, filterByTrustLevel } from '@/components/TrustFilter'; import { SortDropdown, type SortOption, calculateDistance } from '@/components/SortDropdown'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, SheetClose } from '@/components/ui/sheet'; import { Card, CardContent } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; import { Progress } from '@/components/ui/progress'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { LoginArea } from '@/components/auth/LoginArea'; import { useSkateSpots } from '@/hooks/useSkateSpots'; import { useShops } from '@/hooks/useShops'; import { useMarketplaceItems } from '@/hooks/useMarketplace'; import { useTours } from '@/hooks/useTours'; import { useUpcomingSkateEvents } from '@/hooks/useSkateEvents'; import { useCrews } from '@/hooks/useCrews'; import { useBtcMapPlaces } from '@/hooks/useBtcMapPlaces'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useFollowing } from '@/hooks/useFollowing'; import { useBatchTrustScores } from '@/hooks/useBatchTrustScores'; import { useMultipleSpotWeather } from '@/hooks/useSpotWeather'; import { useComments } from '@/hooks/useComments'; import { useStash, STASH_CATEGORIES, CONDITION_LABELS, RARITY_TIERS, type StashItem } from '@/hooks/useStash'; import { useAuthor } from '@/hooks/useAuthor'; import { useSatsToUsd } from '@/hooks/useExchangeRate'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { genUserName } from '@/lib/genUserName'; import { useTranslation } from '@/contexts/LanguageContext'; import { SPOT_CATEGORIES, SPOT_TYPE_LABELS } from '@/lib/types'; import type { SkateSpot, SkateShop, SkateTour } from '@/lib/types'; import ngeohash from 'ngeohash'; import { Search, Plus, List, Map, X, ShoppingBag, Route, Backpack, Package, Target, Star, Zap, Lock, Users, ArrowRightLeft, Crown, Shield, Trash2, AlertTriangle, Bitcoin, SlidersHorizontal, Check, Camera, Navigation, ExternalLink, MapPin, LocateFixed, LocateOff, Store, Info, Layers, Calendar, MessageCircle, } from 'lucide-react'; import { CameraCapture } from '@/components/CameraCapture'; import { SpotChat } from '@/components/SpotChat'; import { useUploadFile } from '@/hooks/useUploadFile'; import { useToast } from '@/hooks/useToast'; import { cn } from '@/lib/utils'; type ViewMode = 'map' | 'list'; // Compact stash item card for the drawer function MiniStashCard({ item, onDelete }: { item: StashItem; onDelete: () => void }) { const category = STASH_CATEGORIES[item.category]; const condition = CONDITION_LABELS[item.condition]; const rarity = (item.rarity || category.rarity) as keyof typeof RARITY_TIERS; const rarityTier = RARITY_TIERS[rarity]; const isWishlist = item.itemType === 'wtb'; const hasImage = item.images && item.images.length > 0; return (
{/* Wishlist badge */} {isWishlist && ( Wishlist )}
{/* Image or emoji */} {hasImage ? (
{item.title}
) : ( {category.emoji} )}

{item.title}

{item.brand && {item.brand} β€’ } {item.size && {item.size}}

{!isWishlist && (
{Array.from({ length: 5 }).map((_, i) => ( ))}
)} {(item.minPrice || item.maxPrice) && ( {isWishlist ? `≀${item.maxPrice?.toLocaleString()}` : item.minPrice?.toLocaleString()} )}
{rarityTier.label}
); } // Stash drawer component function StashDrawer() { const { user } = useCurrentUser(); const { items, stats, isLoading, addItem, removeItem, isEncryptionAvailable } = useStash(); const author = useAuthor(user?.pubkey || ''); const metadata = author.data?.metadata; const displayName = metadata?.name ?? genUserName(user?.pubkey || ''); const avatar = metadata?.picture; const usdValue = useSatsToUsd(stats.totalValue); const stashItems = items.filter(i => i.itemType !== 'wtb'); const wishlistItems = items.filter(i => i.itemType === 'wtb'); // Not logged in if (!user) { return (

Your Private Stash

Track your gear, set prices, and find trades with other skaters.

); } // No encryption if (!isEncryptionAvailable) { return (

Encryption Required

Use a signer with NIP-44 support (Alby, nos2x, Amber).

); } return (
{/* Profile header */}
{displayName.slice(0, 2).toUpperCase()}

{displayName}

{stats.total} items

{stats.totalValue > 999 ? Math.floor(stats.totalValue / 1000) + 'k' : stats.totalValue}

{usdValue ? `~$${usdValue.toFixed(0)}` : 'sats'}

{/* Quick stats */}
{stashItems.length}
Items
{wishlistItems.length}
Wishlist
{Object.keys(stats.byCategory).length}
Types
{/* Items list */}
{isLoading ? ( Array.from({ length: 3 }).map((_, i) => ( )) ) : items.length === 0 ? (

Your stash is empty

) : ( <> {/* Wishlist items first */} {wishlistItems.length > 0 && (

Wishlist ({wishlistItems.length})

{wishlistItems.slice(0, 3).map(item => ( removeItem.mutate(item.id)} /> ))}
)} {/* Stash items */} {stashItems.length > 0 && (

My Gear ({stashItems.length})

{stashItems.slice(0, 5).map(item => ( removeItem.mutate(item.id)} /> ))}
)} )}
{/* Footer actions */}
); } // Detect iOS/macOS for smarter maps ordering function isApplePlatform(): boolean { if (typeof navigator === 'undefined') return false; return /iPhone|iPad|iPod|Macintosh/.test(navigator.userAgent) && !/(Windows|Android)/.test(navigator.userAgent); } // Universal directions picker β€” works on iOS, Android, desktop function DirectionsMenu({ lat, lon, label }: { lat: string; lon: string; label: string }) { const [open, setOpen] = useState(false); const onApple = isApplePlatform(); const allOptions = [ { name: 'Apple Maps', sub: 'iOS & macOS', href: `https://maps.apple.com/?ll=${lat},${lon}&q=${label}&dirflg=d`, emoji: '🍎', appleOnly: true, }, { name: 'Google Maps', sub: 'Opens in browser or app', href: `https://maps.google.com/?q=${lat},${lon}`, emoji: 'πŸ”΅', appleOnly: false, }, { name: 'OpenStreetMap', sub: 'Privacy-friendly, no account', href: `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}&zoom=17`, emoji: 'πŸ—ΊοΈ', appleOnly: false, }, { name: 'Waze', sub: 'Community-powered navigation', href: `https://waze.com/ul?ll=${lat},${lon}&navigate=yes`, emoji: 'πŸ”Έ', appleOnly: false, }, ]; // On Apple platforms: Apple Maps first. On others: Google first, no Apple. const options = onApple ? allOptions : allOptions.filter(o => !o.appleOnly); return ( <> {open && ( <> {/* Backdrop */}
setOpen(false)} /> {/* Menu β€” fixed, centered, never clips behind Sheet stacking context */}

Open with

{options.map(opt => ( setOpen(false)} className="flex items-center gap-3 px-4 py-3 hover:bg-white/8 transition-colors" > {opt.emoji}
{opt.name}
{opt.sub}
))}
)} ); } // Bottom sheet shown when a map marker is tapped function SpotBottomSheet({ spot, onClose }: { spot: SkateSpot; onClose: () => void }) { const navigate = useNavigate(); const author = useAuthor(spot.pubkey); const metadata = author.data?.metadata; const displayName = metadata?.name ?? genUserName(spot.pubkey); const isShop = spot.types.includes('shop'); const route = isShop ? `/shop/${spot.pubkey}/${spot.identifier}` : `/spot/${spot.pubkey}/${spot.identifier}`; return (
{/* Drag handle */}
{/* Header row */}
{/* Thumbnail */}
{spot.images[0] ? ( {spot.title} ) : (
{isShop ? 'πŸͺ' : 'πŸ›Ή'}
)}
{/* Info */}

{spot.title}

{spot.location && (

{spot.location}

)} {/* Type badges */}
{spot.types.slice(0, 3).map((type) => ( {SPOT_TYPE_LABELS[type] || type} ))}
{/* Close */}
{/* Description */} {spot.description && (

{spot.description}

)} {/* Author */}
{displayName.slice(0, 2).toUpperCase()} Added by {displayName}
{/* CTA buttons */}
{spot.geohash && (() => { const { latitude, longitude } = ngeohash.decode(spot.geohash); const lat = latitude.toFixed(6); const lon = longitude.toFixed(6); const label = encodeURIComponent(spot.title); return (
); })()}
); } export default function MapPage() { const { t } = useTranslation(); useSeoMeta({ title: 'Skate Spots Map - SkateSpots', description: 'Explore skateboarding spots around the world on our interactive map.', }); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const { user } = useCurrentUser(); const { data: followingList } = useFollowing(user?.pubkey); const followCount = followingList?.length ?? 0; const { data: spots, isLoading: spotsLoading } = useSkateSpots(200); const { data: shops, isLoading: shopsLoading } = useShops(200); const { data: marketplaceItems, isLoading: marketplaceLoading } = useMarketplaceItems(200); const { data: tours, isLoading: toursLoading } = useTours(100); // Upcoming events for the (opt-in) Events map layer const { data: upcomingEvents } = useUpcomingSkateEvents(100); // Crews for the (opt-in) Crews map layer const { data: crews } = useCrews(200); // Check if coming with ?view=gear to focus on marketplace const viewParam = searchParams.get('view'); const focusGear = viewParam === 'gear'; const [viewMode, setViewMode] = useState('map'); const [searchQuery, setSearchQuery] = useState(''); const [selectedCategories, setSelectedCategories] = useState([]); const [selectedSpot, setSelectedSpot] = useState(null); const [weatherEnabled, setWeatherEnabled] = useState(false); const [weatherFilter, setWeatherFilter] = useState<'all' | 'good' | 'perfect'>('all'); // Smart WoT default: // - Logged out or <5 follows β†’ 'all' (show everything, don't punish new/anon users) // - Logged in with β‰₯5 follows β†’ 'trusted' (sort trusted first, badge unverified) // User can always override with the TrustFilter dropdown const smartDefault: TrustFilterLevel = (user && followCount >= 5) ? 'trusted' : 'all'; const [trustFilter, setTrustFilter] = useState(smartDefault); const [sortBy, setSortBy] = useState('recent'); const [userLocation, setUserLocation] = useState<{lat: number; lon: number} | null>(null); // Map entity type filters - if ?view=gear, only show gear initially const [showSpots, setShowSpots] = useState(!focusGear); const [showShops, setShowShops] = useState(!focusGear); const [showGear, setShowGear] = useState(true); const [showTours, setShowTours] = useState(!focusGear); // Events layer β€” off by default; users opt in via the Layers control const [showEvents, setShowEvents] = useState(false); // Crews layer β€” off by default; users opt in via the Layers control const [showCrews, setShowCrews] = useState(false); const [showBtcMap, setShowBtcMap] = useState(false); // Mobile filters sheet const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false); // Welcome banner β€” show once to new/logged-out visitors, dismissible const [welcomeDismissed, setWelcomeDismissed] = useLocalStorage('skatespots_welcome_dismissed', false); const showWelcomeBanner = !user && !welcomeDismissed; // Track Sasquatch panel open state so the Add Spot FAB can step aside const [sasquatchPanelOpen, setSasquatchPanelOpen] = useState(false); useEffect(() => { const handler = (e: Event) => { setSasquatchPanelOpen((e as CustomEvent<{ open: boolean }>).detail.open); }; window.addEventListener('sasquatch:panelchange', handler); return () => window.removeEventListener('sasquatch:panelchange', handler); }, []); // Mobile "Add" speed-dial (fans out Add Spot / Shop / Tour / Quick Snap) const [addDialOpen, setAddDialOpen] = useState(false); // Mobile layers popover const [layersOpen, setLayersOpen] = useState(false); // Quick camera capture const [showQuickCamera, setShowQuickCamera] = useState(false); const { mutateAsync: uploadFile, isPending: isUploadingQuickPhoto } = useUploadFile(); const { toast } = useToast(); // BTC Map center for fetching places const [mapCenter, setMapCenter] = useState<{ lat: number; lon: number }>({ lat: 40.7128, lon: -74.006 }); // Near Me fly-to state const [flyTo, setFlyTo] = useState<{ lat: number; lng: number; zoom?: number } | null>(null); const [nearMeLoading, setNearMeLoading] = useState(false); const [nearMeError, setNearMeError] = useState<'denied' | 'unavailable' | 'timeout' | null>(null); const [showLocationHelp, setShowLocationHelp] = useState(false); const handleNearMe = () => { // Clear previous error setNearMeError(null); if (!navigator.geolocation) { setNearMeError('unavailable'); toast({ title: 'Location not available', description: 'Your browser does not support geolocation. Try Chrome or Safari.', variant: 'destructive', }); return; } setNearMeLoading(true); navigator.geolocation.getCurrentPosition( (pos) => { setFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 14 }); setUserLocation({ lat: pos.coords.latitude, lon: pos.coords.longitude }); setNearMeLoading(false); setNearMeError(null); }, (err) => { setNearMeLoading(false); if (err.code === err.PERMISSION_DENIED) { setNearMeError('denied'); setShowLocationHelp(true); } else if (err.code === err.TIMEOUT) { setNearMeError('timeout'); toast({ title: 'Location timed out', description: 'Could not get your location in time. Check your signal and try again.', variant: 'destructive', }); } else { setNearMeError('unavailable'); toast({ title: 'Location unavailable', description: 'Your location could not be determined. Make sure location services are enabled.', variant: 'destructive', }); } }, { enableHighAccuracy: true, timeout: 10000, maximumAge: 30000 } ); }; const categoryKeys = Object.keys(SPOT_CATEGORIES); // Fetch BTC Map places when enabled const { data: btcMapPlaces, isLoading: btcMapLoading, error: btcMapError } = useBtcMapPlaces({ lat: mapCenter.lat, lon: mapCenter.lon, radiusKm: 50, // 50km radius enabled: showBtcMap, }); // Debug logging for BTC Map useEffect(() => { if (showBtcMap) { console.log('[MapPage] BTC Map enabled, center:', mapCenter, 'loading:', btcMapLoading, 'places:', btcMapPlaces?.length, 'error:', btcMapError); } }, [showBtcMap, mapCenter, btcMapLoading, btcMapPlaces, btcMapError]); const isLoading = spotsLoading || shopsLoading || marketplaceLoading || toursLoading; // Get user's location for proximity sorting useMemo(() => { if (sortBy === 'proximity' && !userLocation) { navigator.geolocation?.getCurrentPosition( (position) => { setUserLocation({ lat: position.coords.latitude, lon: position.coords.longitude, }); console.log('[MapPage] User location:', position.coords); }, (error) => { console.warn('[MapPage] Geolocation error:', error); } ); } }, [sortBy, userLocation]); // Combine spots and shops into unified list for map display const allLocations = useMemo(() => { const spotList = (spots || []).map(spot => ({ ...spot, isShop: false })); const shopList = (shops || []).map(shop => ({ ...shop, title: shop.name, types: ['shop' as const], isShop: true, accessType: undefined, contactInfo: undefined, })); return [...spotList, ...shopList]; }, [spots, shops]); // Fetch trust scores for all creators const locationPubkeys = useMemo(() => allLocations.map((l) => l.pubkey), [allLocations]); const { data: trustScores } = useBatchTrustScores(locationPubkeys); const getTrustScore = (pubkey: string) => trustScores?.get(pubkey); // Fetch comments for popularity sorting const spotIds = useMemo(() => allLocations.map((l) => l.event.id), [allLocations]); const { data: allComments } = useComments(spotIds); const getCommentCount = (eventId: string) => { return allComments?.filter((c) => { const eTag = c.tags.find(([tag]) => tag === 'e')?.[1]; return eTag === eventId; }).length || 0; }; // Get geohashes for weather fetching (only non-shop spots) const spotGeohashes = useMemo(() => { const geohashes = allLocations .filter(spot => !spot.isShop && spot.geohash) .map(spot => spot.geohash); console.log('[MapPage] Spot geohashes:', geohashes); console.log('[MapPage] Weather enabled:', weatherEnabled); return geohashes; }, [allLocations]); // Fetch weather data for all spots const { data: weatherData, isLoading: weatherLoading } = useMultipleSpotWeather( spotGeohashes, weatherEnabled ); const handleMapClick = (lat: number, lng: number) => { if (!user) return; // Navigate to create spot with coordinates pre-filled navigate(`/create-spot?lat=${lat.toFixed(6)}&lng=${lng.toFixed(6)}`); }; // Handle quick camera capture - upload and navigate to create spot const handleQuickCameraCapture = async (file: File) => { try { const tags = await uploadFile(file); const url = tags[0]?.[1]; if (url) { setShowQuickCamera(false); // Store the captured image URL in sessionStorage for the create spot page sessionStorage.setItem('quickCaptureImage', url); // Get current location and navigate to create spot if (navigator.geolocation) { navigator.geolocation.getCurrentPosition( (position) => { navigate(`/create-spot?lat=${position.coords.latitude.toFixed(6)}&lng=${position.coords.longitude.toFixed(6)}&quickCapture=true`); }, () => { // If geolocation fails, still navigate but without coords navigate('/create-spot?quickCapture=true'); } ); } else { navigate('/create-spot?quickCapture=true'); } toast({ title: 'Photo captured!', description: 'Now add the spot details.', }); } } catch (error) { toast({ title: 'Upload failed', description: error instanceof Error ? error.message : 'Failed to upload photo', variant: 'destructive', }); } }; // Filter combined spots and shops const filteredSpots = useMemo(() => { let filtered = allLocations.filter((item) => { // Entity type filter (showSpots/showShops toggles) if (item.isShop && !showShops) return false; if (!item.isShop && !showSpots) return false; // Search filter if (searchQuery) { const query = searchQuery.toLowerCase(); const matchesTitle = item.title.toLowerCase().includes(query); const matchesLocation = item.location?.toLowerCase().includes(query); const matchesDescription = item.description?.toLowerCase().includes(query); if (!matchesTitle && !matchesLocation && !matchesDescription) { return false; } } // Category filter if (selectedCategories.length > 0) { const hasMatchingCategory = item.types.some((type) => selectedCategories.includes(type) ); if (!hasMatchingCategory) return false; } // Weather filter (only applies when weather is enabled AND filter is not 'all') if (weatherEnabled && weatherFilter !== 'all' && weatherData && !item.isShop && item.geohash) { const spotWeather = weatherData.get(item.geohash); if (spotWeather) { if (weatherFilter === 'perfect' && spotWeather.skateability.score < 85) { return false; } if (weatherFilter === 'good' && spotWeather.skateability.score < 70) { return false; } } } return true; }); // Apply trust filter β€” when 'trusted', sort trusted content to the top // rather than hiding untrusted content (avoids penalising new users/anon browsers) // Strict filtering (verified/following) still hides low-trust content as expected if (trustScores && trustFilter !== 'all') { if (trustFilter === 'trusted') { // Soft mode: sort trusted first, keep everything visible filtered.sort((a, b) => { const scoreA = getTrustScore(a.pubkey) ?? -1; const scoreB = getTrustScore(b.pubkey) ?? -1; const trustedA = scoreA >= 60 ? 1 : 0; const trustedB = scoreB >= 60 ? 1 : 0; return trustedB - trustedA; }); } else { // Strict modes (verified, following) β€” actually filter filtered = filterByTrustLevel(filtered, trustFilter, getTrustScore); } } // Apply sorting filtered.sort((a, b) => { switch (sortBy) { case 'trust': { const scoreA = getTrustScore(a.pubkey) || 0; const scoreB = getTrustScore(b.pubkey) || 0; if (scoreB !== scoreA) return scoreB - scoreA; // Highest trust first return b.event.created_at - a.event.created_at; // Then newest } case 'proximity': { if (!userLocation || !a.geohash || !b.geohash) return 0; const posA = ngeohash.decode(a.geohash); const posB = ngeohash.decode(b.geohash); const distA = calculateDistance(userLocation.lat, userLocation.lon, posA.latitude, posA.longitude); const distB = calculateDistance(userLocation.lat, userLocation.lon, posB.latitude, posB.longitude); return distA - distB; // Closest first } case 'popular': { const commentsA = getCommentCount(a.event.id); const commentsB = getCommentCount(b.event.id); if (commentsB !== commentsA) return commentsB - commentsA; // Most comments first return b.event.created_at - a.event.created_at; } case 'recent': default: return b.event.created_at - a.event.created_at; // Newest first } }); return filtered; }, [allLocations, searchQuery, selectedCategories, weatherEnabled, weatherData, weatherFilter, showSpots, showShops, trustFilter, trustScores, sortBy, userLocation, allComments]); const toggleCategory = (category: string) => { setSelectedCategories((prev) => prev.includes(category) ? prev.filter((c) => c !== category) : [...prev, category] ); }; const clearFilters = () => { setSearchQuery(''); setSelectedCategories([]); setWeatherFilter('all'); setTrustFilter('all'); setSortBy('recent'); }; const hasActiveFilters = searchQuery || selectedCategories.length > 0 || weatherFilter !== 'all' || trustFilter !== 'all' || sortBy !== 'recent'; // Count active layer filters for mobile badge const activeLayerCount = [showSpots, showShops, showGear, showTours, showBtcMap].filter(Boolean).length; // Count of the primary layers active β€” shown as a badge on the Layers button const mobileLayerCount = [showSpots, showShops, showGear, showTours, showEvents, showCrews].filter(Boolean).length; const totalPrimaryLayers = 6; const activeCategoryCount = selectedCategories.length; const totalActiveFilters = activeCategoryCount + (trustFilter !== 'all' ? 1 : 0) + (sortBy !== 'recent' ? 1 : 0); return ( {/* Full-height map container: subtract header + bottom nav on mobile, header only on desktop */} {/* 100dvh handles iOS Safari URL bar shrink correctly */}
{/* ── Toolbar ── */}
{/* Single row: search pill + view toggle + filter button */}
{/* Search pill */}
setSearchQuery(e.target.value)} className="pl-9 h-9 bg-white/5 border-white/10 text-white placeholder:text-gray-600 rounded-full text-sm focus:border-[#ff0040]/50 focus-visible:ring-0 focus-visible:ring-offset-0 transition-all" /> {searchQuery && ( )}
{/* View toggle β€” compact pill */}
{/* Filter button β€” mobile */} Filters {hasActiveFilters && ( )}
{/* Layer toggles */}

Show on Map

{/* Spot categories */}

Spot Type

{Object.entries(SPOT_CATEGORIES).map(([key, cat]) => ( ))}
{/* Sort & Trust */}

Sort By

Trust Level

{/* Weather toggle */}

Weather

{ const newWeatherEnabled = !weatherEnabled; setWeatherEnabled(newWeatherEnabled); if (!newWeatherEnabled) { setWeatherFilter('all'); } }} />
{/* Quick actions for logged in users. Note: Add Spot / Shop / Tour / Quick Snap live in the map's "Add" speed-dial FAB β€” here we only surface the shortcuts that aren't already on the map (Marketplace, Stash) to avoid duplication. */} {user && (

Quick Actions

setMobileFiltersOpen(false)}> setMobileFiltersOpen(false)}>
)}
{/* Show Results button β€” sticks to bottom, never overlaps content */}
{/* Desktop: Weather toggle */}
{ const newWeatherEnabled = !weatherEnabled; setWeatherEnabled(newWeatherEnabled); if (!newWeatherEnabled) { setWeatherFilter('all'); } }} />
{/* Row 2: Entity toggles + Sort/Filter + Add buttons (DESKTOP ONLY) */}
{/* Layers β€” grouped popover (matches the mobile Layers control). Replaces four separate entity-toggle buttons to declutter the desktop toolbar while keeping the entity color language. */}

Show on Map

{[ { key: 'spots', label: 'Spots', dot: '#ff0040', active: showSpots, toggle: () => setShowSpots(v => !v) }, { key: 'shops', label: 'Shops', dot: '#9333ea', active: showShops, toggle: () => setShowShops(v => !v) }, { key: 'gear', label: 'Gear', dot: '#f97316', active: showGear, toggle: () => setShowGear(v => !v) }, { key: 'tours', label: 'Tours', dot: '#06b6d4', active: showTours, toggle: () => setShowTours(v => !v) }, { key: 'events', label: 'Events', dot: '#ccff00', active: showEvents, toggle: () => setShowEvents(v => !v) }, { key: 'crews', label: 'Crews', dot: '#14b8a6', active: showCrews, toggle: () => setShowCrews(v => !v) }, ].map(({ key, label, dot, active, toggle }) => ( ))}
{/* BTC Map toggle */}

{showBtcMap ? `Showing ${btcMapPlaces?.length || 0} Bitcoin-accepting places nearby` : 'Show places where you can spend your sats (via BTC Map)' }

{/* Stash drawer */}
My Stash Encrypted
{/* Separator */}
{/* Sort and Filter */} {/* Clear filters */} {hasActiveFilters && ( )} {/* Spacer */}
{/* Local Chat - live geohash channel for wherever the map is centered */} Local Chat

Live chat for wherever the map is centered. Pick how wide the channel reaches.

{/* Add buttons - icons only on mobile */} {user ? ( <> ) : ( πŸ”’ {t('errors.loginRequired')} )}
{/* Row 3: Category filters (DESKTOP ONLY) */}
{Object.entries(SPOT_CATEGORIES).map(([key, cat]) => ( ))}
{/* Welcome Banner β€” shown once to logged-out visitors */} {showWelcomeBanner && (
πŸ›Ή

New here?

Discover skate spots, events & the community.

Get Started
)} {/* Main Content */}
{viewMode === 'map' ? ( <>
Loading map...
}>
{/* Loading indicator β€” slim top edge bar */} {isLoading && (
)} {/* Map attribution β€” bottom-left, sits ABOVE the mobile bottom nav so it never covers the Shops & Spots buttons. Tiny + subtle. */}
CARTO {' Β· '} OSM {showBtcMap && ( <>{' Β· '}BTC Map )}
{/* Location permission help dialog */} {showLocationHelp && (
setShowLocationHelp(false)} />

Location Blocked

Your browser has blocked location access for this site. Here's how to re-enable it:

{[ { platform: 'πŸ“± Android Chrome', steps: 'Tap the lock icon in the address bar β†’ Site settings β†’ Location β†’ Allow' }, { platform: '🍎 iOS Safari', steps: 'Settings app β†’ Safari β†’ Location β†’ Allow (or tap the "AA" button in Safari\'s address bar)' }, { platform: 'πŸ’» Desktop Chrome', steps: 'Click the lock icon β†’ Site settings β†’ Location β†’ Allow' }, { platform: '🦊 Firefox', steps: 'Click the lock icon β†’ Connection secure β†’ More info β†’ Permissions β†’ Access your location β†’ Allow' }, ].map(({ platform, steps }) => (

{platform}

{steps}

))}
)} {/* ── Mobile map controls (redesigned) ────────────────────────── Right edge holds just TWO small controls: Near Me + Layers. Primary "Add" action is a speed-dial FAB (bottom-right). This replaces the old 12-item vertical strip. */}
{/* Near Me */} {/* Layers popover β€” labelled toggles with color dots */}

Show on Map

{[ { key: 'spots', label: 'Spots', dot: '#ff0040', active: showSpots, toggle: () => setShowSpots(v => !v) }, { key: 'shops', label: 'Shops', dot: '#9333ea', active: showShops, toggle: () => setShowShops(v => !v) }, { key: 'gear', label: 'Gear', dot: '#f97316', active: showGear, toggle: () => setShowGear(v => !v) }, { key: 'tours', label: 'Tours', dot: '#06b6d4', active: showTours, toggle: () => setShowTours(v => !v) }, { key: 'events', label: 'Events', dot: '#ccff00', active: showEvents, toggle: () => setShowEvents(v => !v) }, { key: 'crews', label: 'Crews', dot: '#14b8a6', active: showCrews, toggle: () => setShowCrews(v => !v) }, ].map(({ key, label, dot, active, toggle }) => ( ))}
{/* ── Add speed-dial FAB (mobile, logged in) ──────────────────── */} {user && (
{/* Fan-out actions */} {addDialOpen && ( <> {[ { label: 'Add Spot', to: '/create-spot', bg: 'bg-[#ff0040]', icon: }, { label: 'Add Shop', to: '/create-shop', bg: 'bg-[#9333ea]', icon: }, { label: 'Add Event', to: '/create-event', bg: 'bg-[#ccff00]', icon: }, { label: 'Sell Gear', to: '/create-listing', bg: 'bg-[#f97316]', icon: }, { label: 'Offer Tour', to: '/create-tour', bg: 'bg-[#06b6d4]', icon: }, ].map((a, i) => ( setAddDialOpen(false)} className="flex items-center gap-2.5 animate-in fade-in slide-in-from-bottom-2 duration-200" style={{ animationDelay: `${i * 40}ms` }} > {a.label} {a.icon} ))} {/* Quick Snap */} )} {/* Main FAB */}
)} {/* Tap-catcher to close the speed-dial */} {addDialOpen && (
setAddDialOpen(false)} /> )} ) : (
{isLoading ? (
{Array.from({ length: 8 }).map((_, i) => ( ))}
) : filteredSpots.length > 0 ? ( <>

{t('mapPage.locationsFound', { count: filteredSpots.length })} {showSpots && !showShops && ` (${t('mapPage.spotsOnly')})`} {!showSpots && showShops && ` (${t('mapPage.shopsOnly')})`} {showSpots && showShops && ` (${t('mapPage.spotsAndShops')})`}

{filteredSpots.map((spot) => ( ))}
) : (

{hasActiveFilters ? t('mapPage.noSpotsMatch') : t('mapPage.noSpotsYet')}

{hasActiveFilters ? ( ) : ( user && ( ) )}
)}
)}
{/* Quick Camera Capture Dialog */} {/* Spot Bottom Sheet β€” slides up when a marker is tapped, map stays visible */} { if (!open) setSelectedSpot(null); }}> {selectedSpot && setSelectedSpot(null)} />} ); }