import { useState, useMemo, useEffect, lazy, Suspense, useCallback } 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 { useZapTotals, zapTargetKey } from '@/hooks/useZapTotals';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
import { Skeleton } from '@/components/ui/skeleton';
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 { useBatchSpotStatus } from '@/hooks/useSpotStatus';
import { useMultipleSpotWeather } from '@/hooks/useSpotWeather';
import { useCommentCounts } from '@/hooks/useCommentCounts';
import { ImageWithFallback } from '@/components/ImageWithFallback';
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, SHELTER_INFO } from '@/lib/types';
import type { SkateSpot } from '@/lib/types';
import ngeohash from 'ngeohash';
import {
Search,
Plus,
List,
Map,
X,
ShoppingBag,
Route,
Backpack,
Package,
Target,
Star,
Zap,
ArrowRightLeft,
Shield,
Trash2,
AlertTriangle,
Bitcoin,
SlidersHorizontal,
Check,
Navigation,
ExternalLink,
MapPin,
LocateFixed,
LocateOff,
Store,
Layers,
Calendar,
MessageCircle,
ChevronDown,
Smartphone,
} from 'lucide-react';
import { SpotChat } from '@/components/SpotChat';
import { useToast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
import { getCurrentPosition, GeolocationError, isNativeGeolocation } from '@/lib/geolocation';
import { getLastLocation } from '@/lib/lastLocation';
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 ? (
) : (
{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, 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).
Learn More
);
}
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
Add First Item
) : (
<>
{/* 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 (
<>
setOpen(true)}
>
Directions
{open && (
<>
{/* Backdrop */}
setOpen(false)} />
{/* Menu β fixed, centered, never clips behind Sheet stacking context */}
>
)}
>
);
}
// 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] ? (
) : (
{isShop ? 'πͺ' : 'πΉ'}
)}
{/* Info */}
{spot.title}
{spot.location && (
{spot.location}
)}
{/* Type badges */}
{spot.shelter && spot.shelter !== 'outdoor' && SHELTER_INFO[spot.shelter] && (
{SHELTER_INFO[spot.shelter].emoji} {SHELTER_INFO[spot.shelter].short}
)}
{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 */}
navigate(route)}
className="h-12 font-bold text-sm bg-gradient-to-r from-[#ff0040] to-[#ff6600] hover:opacity-90 rounded-xl"
>
View Details
{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(2000);
const { data: shops, isLoading: shopsLoading } = useShops(500);
// Tell the cold-start splash that real content has arrived, so it can reveal
// the map already populated (rather than dismissing on bare connection). We
// signal readiness when the spots query has genuinely resolved β either it
// streamed in some spots, or it finished loading (empty relay / offline). With
// IndexedDB persistence this often fires on the very first frame (cache hit),
// so returning users get an instant, already-populated reveal. The splash
// keeps its own hard cap, so this can only ever make the reveal feel readier.
useEffect(() => {
const hasSpots = Array.isArray(spots) && spots.length > 0;
if (hasSpots || !spotsLoading) {
window.dispatchEvent(new Event('skatespots:content-ready'));
}
}, [spots, spotsLoading]);
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');
// Seed from the cached last-known location so the "you are here" badge and
// proximity sorting are available instantly; a fresh fix updates it.
const [userLocation, setUserLocation] = useState<{lat: number; lon: number} | null>(() => {
const cached = getLastLocation();
return cached ? { lat: cached.lat, lon: cached.lng } : 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 [, 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 / Event / Gear / Tour)
const [addDialOpen, setAddDialOpen] = useState(false);
// Mobile layers popover
const [layersOpen, setLayersOpen] = useState(false);
const { toast } = useToast();
// Optimistic initial view: if we have a cached last-known location, open the
// map ALREADY centered on the user's area (and zoomed to city level) instead
// of the default world view. Resolved once at mount β a fresh fix or a manual
// pan supersedes it. Makes the map feel "already yours" the moment it opens.
const [initialView] = useState(() => {
const cached = getLastLocation();
return cached
? { center: [cached.lat, cached.lng] as [number, number], zoom: 12, lat: cached.lat, lon: cached.lng }
: { center: [40.7128, -74.006] as [number, number], zoom: 3, lat: 40.7128, lon: -74.006 };
});
const initialCenter = initialView.center;
const initialZoom = initialView.zoom;
// BTC Map center for fetching places β seeded from the cached location so
// location-aware layers (BTC Map, weather) query the right area immediately.
const [mapCenter, setMapCenter] = useState<{ lat: number; lon: number }>({
lat: initialView.lat,
lon: initialView.lon,
});
// 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 = async () => {
// Clear previous error
setNearMeError(null);
setNearMeLoading(true);
// OPTIMISTIC: if we already know roughly where they are, fly there IMMEDIATELY
// so the map reacts on tap. The fresh fix below refines the exact position.
const cached = getLastLocation();
if (cached) {
setFlyTo({ lat: cached.lat, lng: cached.lng, zoom: 13 });
}
// Getting a GPS lock (especially first-time / on ROMs without Google's
// fused provider) can take a while β let the user know it's working.
const locatingToast = toast({
title: cached ? 'Refining your locationβ¦' : 'Locating youβ¦',
description: cached
? 'Centered on your last spot β dialing in a fresh GPS fix.'
: 'Getting a GPS fix. This can take up to a minute the first time.',
duration: 60000,
});
try {
const { lat, lng } = await getCurrentPosition();
locatingToast.dismiss();
setFlyTo({ lat, lng, zoom: 14 });
setUserLocation({ lat, lon: lng });
setNearMeLoading(false);
setNearMeError(null);
toast({
title: 'Found you',
description: 'Centered the map on your location.',
duration: 3000,
});
} catch (err) {
locatingToast.dismiss();
setNearMeLoading(false);
const kind = err instanceof GeolocationError ? err.kind : 'unavailable';
// Raw diagnostic text so we can see exactly what failed on-device
// (no adb needed). Shown appended to the toast + logged.
const rawMsg = err instanceof Error ? err.message : String(err);
console.warn('[NearMe] geolocation failed:', kind, rawMsg, err);
const diag = rawMsg ? ` [${rawMsg}]` : '';
if (kind === 'denied') {
setNearMeError('denied');
if (isNativeGeolocation()) {
// Native app: it's an OS permission, not a browser setting.
toast({
title: 'Location permission needed',
description:
'Allow location for SkateSpots in your device Settings β Apps β SkateSpots β Permissions, then tap Near Me again.' +
diag,
variant: 'destructive',
duration: 12000,
});
} else {
// Web: show the browser re-enable help sheet.
setShowLocationHelp(true);
}
} else if (kind === 'timeout') {
setNearMeError('timeout');
toast({
title: 'Location timed out',
description:
(isNativeGeolocation()
? "Couldn't get a location fix in time. If you're indoors, GPS can't see satellites β enable Network Location (Settings β Location) or step outside, then try again."
: 'Could not get your location in time. Check your signal and try again.') + diag,
variant: 'destructive',
duration: 12000,
});
} else if (kind === 'no_provider') {
setNearMeError('unavailable');
toast({
title: 'No location provider',
description:
(isNativeGeolocation()
? "Your phone isn't providing a location. On privacy-focused ROMs (e.g. GrapheneOS) enable Network Location under Settings β Location, or make sure GPS is on and try outdoors."
: 'No location provider is available. Enable location services and try again.') +
diag,
variant: 'destructive',
duration: 14000,
});
} else {
setNearMeError('unavailable');
toast({
title: 'Location unavailable',
description:
(isNativeGeolocation()
? 'Turn on Location/GPS in your phone settings, allow location for SkateSpots (Settings β Apps β SkateSpots β Permissions), then try again.'
: 'Your location could not be determined. Make sure location services are enabled.') +
diag,
variant: 'destructive',
duration: 12000,
});
}
}
};
// Fetch BTC Map places when enabled
const { data: btcMapPlaces, isLoading: btcMapLoading } = useBtcMapPlaces({
lat: mapCenter.lat,
lon: mapCenter.lon,
radiusKm: 50, // 50km radius
enabled: showBtcMap,
});
const isLoading = spotsLoading || shopsLoading || marketplaceLoading || toursLoading;
// Get user's location for proximity sorting
useEffect(() => {
if (sortBy === 'proximity' && !userLocation) {
getCurrentPosition()
.then(({ lat, lng }) => setUserLocation({ lat, lon: lng }))
.catch((error) => console.warn('[MapPage] Geolocation error:', error));
}
}, [sortBy, userLocation]);
// Silent background warm-up (once, on mount): quietly refresh the location fix
// so it's cached and ready the instant the user taps "Near Me". This never
// moves the map or shows a prompt-driven toast β it just updates the badge /
// cache in the background. Skipped on native, where it would surface an OS
// permission prompt out of context (native users trigger it via "Near Me").
useEffect(() => {
if (isNativeGeolocation()) return;
let cancelled = false;
const warm = () =>
getCurrentPosition()
.then(({ lat, lng }) => {
if (!cancelled) setUserLocation({ lat, lon: lng });
})
.catch(() => {
// Silent β user hasn't asked to be located yet.
});
// Only warm up when permission is ALREADY granted β otherwise this fires the
// browser's "Allow location?" prompt the moment someone opens /map.
if (typeof navigator !== 'undefined' && navigator.permissions?.query) {
navigator.permissions
.query({ name: 'geolocation' as PermissionName })
.then((status) => {
if (!cancelled && status.state === 'granted') warm();
})
.catch(() => {
/* permissions API unavailable β don't prompt */
});
}
return () => {
cancelled = true;
};
}, []);
// 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,
// SkateSpot-shaped fields so shops can share the unified spot list/map.
tags: [] as string[],
category: 'shop',
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 = useCallback((pubkey: string) => trustScores?.get(pubkey), [trustScores]);
// Fetch community status reports for all spots (not shops), batched in one query.
const statusSpotRefs = useMemo(
() => (spots || []).map((s) => ({ pubkey: s.pubkey, identifier: s.identifier })),
[spots],
);
const { getStatus: getSpotStatus } = useBatchSpotStatus(statusSpotRefs);
// Fetch comments for popularity sorting
const spotIds = useMemo(() => allLocations.map((l) => l.event.id), [allLocations]);
const { data: commentCounts } = useCommentCounts(spotIds);
const getCommentCount = useCallback((eventId: string) => {
return commentCounts?.get(eventId) ?? 0;
}, [commentCounts]);
// Batched zap totals for "Most Zapped" sorting (ONE query for every location).
const locationEvents = useMemo(() => allLocations.map((l) => l.event), [allLocations]);
const { data: zapTotals } = useZapTotals(locationEvents);
const getZapTotal = useCallback(
(event: (typeof locationEvents)[number]) => zapTotals?.get(zapTargetKey(event))?.totalSats ?? 0,
[zapTotals],
);
// 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);
return geohashes;
}, [allLocations]);
// Fetch weather data for all spots
const { data: weatherData } = 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)}`);
};
// 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.
// β’ 'trusted' (soft): keep everything visible but float trusted content to
// the top. This must be folded into the MAIN sort below as its primary
// key β running it as a separate .sort() first is pointless because the
// sort() that follows would immediately re-order everything and wipe it
// out (which it silently did for the default trusted+recent combo).
// β’ 'verified'/'following' (strict): actually hide low-trust content.
const softTrust = trustFilter === 'trusted';
if (trustScores && trustFilter !== 'all' && !softTrust) {
filtered = filterByTrustLevel(filtered, trustFilter, getTrustScore);
}
// Trust tier (1 = trusted, 0 = not) used as the primary sort key in soft mode.
const trustTier = (pubkey: string) => ((getTrustScore(pubkey) ?? -1) >= 60 ? 1 : 0);
// Apply sorting. In soft-trust mode, trusted content sorts first and the
// chosen `sortBy` becomes the tiebreaker WITHIN each trust tier.
filtered.sort((a, b) => {
if (softTrust && trustScores) {
const tierDiff = trustTier(b.pubkey) - trustTier(a.pubkey);
if (tierDiff !== 0) return tierDiff;
}
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 'zaps': {
const zapsA = getZapTotal(a.event);
const zapsB = getZapTotal(b.event);
if (zapsB !== zapsA) return zapsB - zapsA; // Most zapped 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, getTrustScore, trustScores, sortBy, userLocation, getCommentCount, getZapTotal]);
// Spots shown as MAP MARKERS. Hide spots the community has confirmed as
// "gone" (demolished) so they don't clutter the map β they still appear
// (dimmed, with a badge) in the card list below so the record isn't lost.
const mapSpots = useMemo(() => {
return filteredSpots.filter((item) => {
if (item.isShop) return true;
const status = getSpotStatus(item.pubkey, item.identifier);
return status?.status !== 'gone';
});
}, [filteredSpots, getSpotStatus]);
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
// 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 && (
setSearchQuery('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white">
)}
{/* View toggle β compact pill */}
setViewMode('map')}
aria-label="Map view"
>
setViewMode('list')}
aria-label="List view"
>
{/* Filter button β mobile */}
0
? 'bg-[#ff0040]/20 border-[#ff0040]/60 text-[#ff0040]'
: 'bg-white/5 border-white/10 text-gray-400 hover:text-gray-200 hover:border-white/20'
)}
aria-label="Filters"
>
{totalActiveFilters > 0 && (
{totalActiveFilters}
)}
Filters
{hasActiveFilters && (
{
clearFilters();
}}
>
Clear
)}
{/* Layer toggles */}
Show on Map
setShowSpots(!showSpots)}
>
πΉ Spots
setShowShops(!showShops)}
>
πͺ Shops
setShowGear(!showGear)}
>
π Gear
setShowTours(!showTours)}
>
πΊοΈ Tours
setShowEvents(!showEvents)}
>
π
Events
setShowCrews(!showCrews)}
>
π€ Crews
setShowBtcMap(!showBtcMap)}
>
Spedn
{/* Spot categories */}
Spot Type
{Object.entries(SPOT_CATEGORIES).map(([key, cat]) => (
toggleCategory(key)}
>
{cat.emoji} {cat.label}
))}
{/* Sort & Trust */}
{/* Weather toggle */}
Weather
{
const newWeatherEnabled = !weatherEnabled;
setWeatherEnabled(newWeatherEnabled);
if (!newWeatherEnabled) {
setWeatherFilter('all');
}
}}
/>
{/* Quick actions for logged in users.
Note: Add Spot / Shop / Event / Gear / Tour 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)}>
Buy/Sell Gear
setMobileFiltersOpen(false)}>
Stash
)}
{/* Show Results button β sticks to bottom, never overlaps content */}
setMobileFiltersOpen(false)}
>
Show Results
{/* 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. */}
Layers
{mobileLayerCount}
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 }) => (
{label}
{active && }
))}
{/* BTC Map toggle */}
setShowBtcMap(!showBtcMap)}
>
SPEDN β
{btcMapLoading && showBtcMap && (
)}
{showBtcMap
? `Showing ${btcMapPlaces?.length || 0} places where you can Spedn your sats nearby`
: 'Show places where you can Spedn your sats (via BTC Map)'
}
{/* Stash drawer */}
Stash
My Stash
Encrypted
{/* Separator */}
{/* Sort and Filter */}
{/* Near Me β desktop parity (mobile has the floating control) */}
{
if (nearMeError === 'denied' && !isNativeGeolocation()) {
setShowLocationHelp(true);
return;
}
handleNearMe();
}}
disabled={nearMeLoading}
title={nearMeError === 'denied' ? 'Enable location access' : 'Find my location'}
className="hidden md:inline-flex h-8 gap-1.5 border-white/20 flex-shrink-0 touch-manipulation"
>
{nearMeError === 'denied' ? (
) : (
)}
Near Me
{/* Clear filters */}
{hasActiveFilters && (
)}
{/* Spacer */}
{/* Local Chat - live geohash channel for wherever the map is centered.
Mobile only: on desktop the toolbar is dense and DMs/crew chat cover
messaging, so we hide the standalone Local Chat trigger at md+. */}
Local Chat
Live chat for wherever the map is centered. Pick how wide the channel reaches.
{/* Add buttons - icons only on mobile */}
{user ? (
<>
{/* Primary action: add a spot (this is a map, after all) */}
{t('home.addSpot')}
{/* Secondary add actions consolidated into one menu to de-clutter */}
More
{t('shops.addShop')}
Buy/Sell Gear
Offer Tour
>
) : (
π {t('errors.loginRequired')}
)}
{/* Row 3: Category filters (DESKTOP ONLY) */}
{Object.entries(SPOT_CATEGORIES).map(([key, cat]) => (
toggleCategory(key)}
>
{cat.emoji} {cat.label}
))}
{/* Welcome Banner β shown once to logged-out visitors */}
{showWelcomeBanner && (
πΉ
New here?
Discover skate spots, events & the community.
{/* Desktop-only: grab the mobile app via Zapstore */}
Get on Zapstore
Get Started
setWelcomeDismissed(true)}
className="flex-shrink-0 w-7 h-7 flex items-center justify-center rounded-full hover:bg-white/10 text-white/40 hover:text-white transition-colors"
aria-label="Dismiss"
>
)}
{/* Main Content */}
{viewMode === 'map' ? (
<>
}>
{/* Loading indicator β a slim, honest top bar. The map stays
visible underneath and spots stream in; we NEVER cover the map
with fake placeholder pins. An animated sweep sits on top of a
faint track so it reads as "working", plus a small labeled pill
during a genuine cold start (no markers yet) so the user knows
we're talking to the relays. */}
{isLoading && (
<>
{mapSpots.length === 0 && (
)}
>
)}
{/* Empty state β the active filters hid everything. Without this
the map just looks blank/unloaded. Only shown when the
spot/shop layers are on (other layers have their own markers). */}
{!isLoading && (showSpots || showShops) && filteredSpots.length === 0 && (
No spots match
Try a different search or clear your filters.
Clear filters
)}
{/* Map attribution β bottom-left, sits ABOVE the mobile bottom
nav so it never covers the Shops & Spots buttons. Tiny + subtle. */}
{/* 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 }) => (
))}
{
setShowLocationHelp(false);
setNearMeError(null);
}}
className="w-full py-3 rounded-xl bg-white/8 hover:bg-white/12 border border-white/10 text-sm font-bold text-white transition-all active:scale-[0.98]"
>
Got it
)}
{/* ββ 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 */}
{
// On native a denial is an OS permission the user can grant
// and retry; only the web needs the browser re-enable sheet.
if (nearMeError === 'denied' && !isNativeGeolocation()) {
setShowLocationHelp(true);
return;
}
handleNearMe();
}}
disabled={nearMeLoading}
className={cn(
'h-11 w-11 rounded-full flex items-center justify-center shadow-lg border backdrop-blur transition-all touch-manipulation active:scale-90',
nearMeError === 'denied'
? 'bg-red-900/80 border-red-500/30'
: 'bg-black/70 border-white/15 hover:border-white/30'
)}
aria-label="Find spots near me"
>
{nearMeLoading ? (
) : nearMeError === 'denied' ? (
) : (
)}
{/* Layers popover β labelled toggles with color dots */}
{mobileLayerCount < totalPrimaryLayers && (
{mobileLayerCount}
)}
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 }) => (
{label}
{active && }
))}
{/* ββ 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}
))}
>
)}
{/* Main FAB */}
setAddDialOpen(v => !v)}
className={cn(
'h-14 w-14 rounded-full flex items-center justify-center shadow-2xl transition-all duration-300 touch-manipulation active:scale-90',
addDialOpen
? 'bg-white rotate-45'
: 'bg-[#ff0040] shadow-[#ff0040]/40 hover:bg-[#ff0040]/90'
)}
aria-label={addDialOpen ? 'Close add menu' : 'Add spot, shop, event, gear, or tour'}
>
)}
{/* 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 ? (
{t('common.clearFilters')}
) : (
user && (
{t('mapPage.addFirstSpot')}
)
)}
)}
)}
{/* Spot Bottom Sheet β slides up when a marker is tapped, map stays visible */}
{ if (!open) setSelectedSpot(null); }}>
{selectedSpot?.title ?? 'Spot details'}
{selectedSpot && setSelectedSpot(null)} />}
);
}