);
}
export default function MapPage() {
const { t } = useTranslation();
useSeoMeta({
title: t('seoUi.mapTitle'),
description: t('seoUi.mapDesc'),
});
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// ?view=gear focuses the gear layer; compute before the data hooks so hidden
// layers don't query at all.
const viewParam = searchParams.get('view');
const focusGear = viewParam === 'gear';
const [showSpots, setShowSpots] = useState(!focusGear);
const [showShops, setShowShops] = useState(!focusGear);
const [showGear, setShowGear] = useState(true);
const [showTours, setShowTours] = useState(!focusGear);
// Opt-in layers β no relay query until the user turns them on.
const [showEvents, setShowEvents] = useLocalStorage('skatespots:map-show-events', false);
const [showCrews, setShowCrews] = useLocalStorage('skatespots:map-show-crews', false);
const { user } = useCurrentUser();
// The user's own profile picture, shown on the "you are here" pin.
const meAuthor = useAuthor(user?.pubkey || '');
const userPicture = meAuthor.data?.metadata?.picture ?? null;
const { data: followingList } = useFollowing(user?.pubkey);
const followCount = followingList?.length ?? 0;
const { data: spots, isLoading: spotsLoading, refetch: refetchSpots } = useSkateSpots(2000);
const { data: shops, isLoading: shopsLoading } = useShops(500, { enabled: showShops });
// Wi-Fi present β relay reachable (captive portal / relay outage). The utility
// prefers saved spots over a blank map, so we detect that honestly and say so.
// β οΈ Two different signals on purpose:
// β’ deviceOffline β the DEVICE has no connection, so the pins really are
// the saved cache. This is what the "showing saved spots" banner means.
// β’ relayUnreachable β the canonical community relay didn't answer its probe.
// The map reads from several relays, so this can be true while fresh data
// loads fine; use it only for the "nothing at all" card, never the banner
// (that mismatch is why the banner used to stick around online).
const online = useOnlineStatus();
const relay = useRelayStatus(SKATE_RELAY);
const deviceOffline = !online;
const relayUnreachable = !online || relay.status === 'offline';
// Dismissable for the session; re-armed when the connection comes back.
const [offlineBannerDismissed, setOfflineBannerDismissed] = useState(false);
useEffect(() => {
if (online) setOfflineBannerDismissed(false);
}, [online]);
// Spots reported "not a skate spot" by β₯3 distinct accounts with NO community
// signals are hidden from the map pending a human (see useAutoHiddenSpots).
const autoHideInput = useMemo(
() => (spots ?? []).map((s) => ({ id: s.id, pubkey: s.pubkey, identifier: s.identifier })),
[spots],
);
const { autoHiddenIds } = useAutoHiddenSpots(autoHideInput);
// 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, { enabled: showGear });
const { data: tours, isLoading: toursLoading } = useTours(100, { enabled: showTours });
// Upcoming events for the (opt-in) Events map layer
const { data: upcomingEvents } = useUpcomingSkateEvents(100, { enabled: showEvents });
// Crews for the (opt-in) Crews map layer
const { data: crews } = useCrews(200, { enabled: showCrews });
const [viewMode, setViewMode] = useState('map');
const [searchQuery, setSearchQuery] = useState('');
const [showSearchSuggestions, setShowSearchSuggestions] = useState(false);
const [searchActiveIndex, setSearchActiveIndex] = useState(0);
const [selectedCategories, setSelectedCategories] = useState([]);
const [selectedSpot, setSelectedSpot] = useState(null);
// Tap-to-add: a logged-in tap drops this pin and shows a confirm card instead
// of yanking the user into the create form (which made the map feel broken).
const [pendingAddPin, setPendingAddPin] = useState<{ lat: number; lng: number } | null>(null);
// Directions target lives at page level (NOT inside the spot Sheet) so the
// RouteDialog is never nested inside another modal.
const [routeTarget, setRouteTarget] = useState<{ lat: number; lng: number; label: string } | null>(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.
// Mobile-only: Near Me is a mobile affordance, so desktop shows no location
// pin (or proximity sort).
const isMobile = useIsMobile();
const [userLocation, setUserLocation] = useState<{lat: number; lon: number} | null>(() => {
if (!isMobile) return null;
const cached = getLastLocation();
return cached ? { lat: cached.lat, lon: cached.lng } : null;
});
// Map entity type filters (state lives above the queries so hidden layers
// don't open relay requests).
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(() => {
// Restore the last map view first; a brand-new device falls back to the
// cached location, then to the world view.
try {
const raw = localStorage.getItem('skatespots:map-view');
const saved = raw ? JSON.parse(raw) as { lat?: unknown; lon?: unknown; zoom?: unknown } : null;
if (
saved &&
typeof saved.lat === 'number' && Number.isFinite(saved.lat) &&
typeof saved.lon === 'number' && Number.isFinite(saved.lon) &&
typeof saved.zoom === 'number' && Number.isFinite(saved.zoom)
) {
return {
center: [saved.lat, saved.lon] as [number, number],
zoom: Math.min(18, Math.max(1, saved.zoom)),
lat: saved.lat,
lon: saved.lon,
};
}
} catch {
// fall through to the location/world default
}
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,
});
const handleMapMove = (center: { lat: number; lon: number; zoom?: number }) => {
setMapCenter({ lat: center.lat, lon: center.lon });
try {
localStorage.setItem('skatespots:map-view', JSON.stringify(center));
} catch {
// storage unavailable β map still works
}
};
// 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);
// "Near Me" should also MEAN "show me what's near" β surface the closest
// spots first instead of leaving the list on Most Recent.
setSortBy('proximity');
// 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 ? t('mapUi.refiningLocation') : t('mapUi.locatingYou'),
description: cached
? t('mapUi.refiningLocationDesc')
: t('mapUi.locatingDesc'),
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: t('mapUi.foundYou'),
description: t('mapUi.foundYouDesc'),
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: t('mapUi.locationPermissionNeeded'),
description:
t('mapUi.permissionNeededDesc') +
diag,
variant: 'destructive',
duration: 12000,
});
} else {
// Web: show the browser re-enable help sheet.
setShowLocationHelp(true);
}
} else if (kind === 'timeout') {
setNearMeError('timeout');
toast({
title: t('mapUi.locationTimedOut'),
description:
(isNativeGeolocation()
? t('mapUi.timeoutNativeDesc')
: t('mapUi.timeoutWebDesc')) + diag,
variant: 'destructive',
duration: 12000,
});
} else if (kind === 'no_provider') {
setNearMeError('unavailable');
toast({
title: t('mapUi.noLocationProvider'),
description:
(isNativeGeolocation()
? t('mapUi.noProviderNativeDesc')
: t('mapUi.noProviderWebDesc')) +
diag,
variant: 'destructive',
duration: 14000,
});
} else {
setNearMeError('unavailable');
toast({
title: t('mapUi.locationUnavailable'),
description:
(isNativeGeolocation()
? t('mapUi.unavailableNativeDesc')
: t('mapUi.unavailableWebDesc')) +
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 || [])
.filter((spot) => !autoHiddenIds.has(spot.id))
.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, autoHiddenIds]);
// 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;
// Drop a pin and ask first β do NOT navigate immediately. The user confirms
// with the card below, which is the only thing that opens the create form.
setSelectedSpot(null);
setPendingAddPin({ lat, lng });
};
// 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]);
// Autocomplete for the search pill. Derived from the already-filtered list so
// it respects the active filters; keyboard navigation is owned here.
const searchSuggestions = useMemo(
() => buildSearchSuggestions(filteredSpots, searchQuery),
[filteredSpots, searchQuery],
);
useEffect(() => {
setSearchActiveIndex(0);
}, [searchQuery]);
const handleSearchSuggestion = useCallback(
(suggestion: SearchSuggestion) => {
if (suggestion.kind === 'place' && suggestion.value) {
setSearchQuery(suggestion.value);
setShowSearchSuggestions(false);
return;
}
if (suggestion.kind === 'spot' && suggestion.itemId) {
const spot = allLocations.find((l) => l.id === suggestion.itemId);
if (spot) {
if (spot.geohash) {
const { latitude, longitude } = ngeohash.decode(spot.geohash);
setFlyTo({ lat: latitude, lng: longitude, zoom: 14 });
}
setSelectedSpot(spot);
}
setShowSearchSuggestions(false);
}
},
[allLocations],
);
// Community confidence for the cards (batched, one query). Shops use a
// different kind, so only spots are scored.
const communitySpots = useMemo(
() => filteredSpots
.filter((s) => !s.isShop)
.map((s) => ({ id: s.id, pubkey: s.pubkey, identifier: s.identifier })),
[filteredSpots],
);
const { data: communityScores } = useBatchSpotCommunityScores(communitySpots);
// 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 */}
{/* Welcome Banner β shown once to logged-out visitors. It lives ABOVE
the toolbar (both in flow) because the toolbar floats over the map:
as a later sibling the banner would sit at the container's top and
the floating toolbar would paint over it. */}
{/* First-run "get started" nudge for freshly signed-up users (hidden for
logged-out visitors, who get the welcome banner). */}
void handleNearMe()} />
{showWelcomeBanner && (
)}
{/* ββ Toolbar ββ
Mobile: floats OVER the map (absolute) so the map fills the whole
screen; the gradient fades into the map and the transparent wrapper
passes touches through to it. Desktop keeps the in-flow bar. With the
welcome banner present it drops into flow so it can't cover it. */}
{/* 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 && (
{/* 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. */}
{t('mapUi.myStash')}
{t('mapUi.encrypted')}
{/* Separator */}
{/* Sort and Filter */}
{/* 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+. */}
{t('mapUi.localChat')}
{t('mapUi.localChatDesc')}
{/* Add buttons - icons only on mobile */}
{user ? (
<>
{/* Primary action: add a spot (this is a map, after all) */}
{/* Secondary add actions consolidated into one menu to de-clutter */}
{t('shops.addShop')}
{t('mapUi.buySellGear')}
{t('mapUi.offerTour')}
>
) : (
π {t('errors.loginRequired')}
)}
}>
{/* Tap-to-add confirm. Tapping the map drops a pin (above) and
shows this card; only "Add spot" opens the create form, so a
stray tap no longer kicks you off the map. */}
{pendingAddPin && (
π
{t('mapUi.addSpotHereTitle')}
{t('mapUi.addSpotHereDesc')}
)}
{/* 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 && (
{t('mapUi.loadingSpots')}
)}
>
)}
{/* Offline / relay-down banner. The map keeps showing the
last-known spots from the local cache β this just says so
honestly, and offers a retry. Shown only once we know the relay
is unreachable, so a slow cold start never flashes it. */}
{!isLoading && deviceOffline && !offlineBannerDismissed && allLocations.length > 0 && (
{t('mapUi.offlineSavedSpots')}
)}
{/* 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 && allLocations.length > 0 && (
{t('mapUi.noSpotsMatchTitle')}
{t('mapUi.noSpotsMatchDesc')}
)}
{/* Offline with nothing cached yet β don't pretend the map is
empty ("be the first to map your park"): say what's actually
wrong and offer a retry. */}
{!isLoading && relayUnreachable && (showSpots || showShops) && allLocations.length === 0 && (
)}
{/* Nothing mapped here AT ALL β the honest empty map. This is the
density problem, not a filter problem, so the fix on offer is
"add the first one" rather than "clear your filters". */}
{!isLoading && !relayUnreachable && (showSpots || showShops) && allLocations.length === 0 && (
πΉ
{t('mapUi.noSpotsMappedYet')}
{t('mapUi.noSpotsMappedDesc')}
)}
{/* Map attribution β bottom-left, sits ABOVE the mobile bottom
nav so it never covers the Shops & Spots buttons. Tiny + subtle. */}
{/* Slim, dismissible site-links strip β the desktop footer is hidden
on this full-bleed route, so surface the links here instead. */}
{/* Weather score legend β only while the weather layer is on */}
{weatherEnabled && (
)}
{/* Location permission help dialog */}
{showLocationHelp && (
)}
{/* ββ 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 */}