);
}
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, refetch: refetchSpots } = useSkateSpots(2000);
const { data: shops, isLoading: shopsLoading } = useShops(500);
// 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);
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);
// 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 - 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);
// "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 ? '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 || [])
.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;
// 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 */}
{/* 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. */}
{showWelcomeBanner && (
πΉ
New here?
Discover skate spots, events & the community.
{/* Desktop-only: grab the mobile app via Zapstore */}
Get on Zapstore
Get Started
)}
{/* ββ 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. */}
{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 */}
My Stash
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+. */}
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) */}
{/* Secondary add actions consolidated into one menu to de-clutter */}
{t('shops.addShop')}
Buy/Sell Gear
Offer Tour
>
) : (
π {t('errors.loginRequired')}
)}
}>
{/* 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 && (
Loading spots
)}
>
)}
{/* 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 && (
Offline Β· showing saved spots
)}
{/* 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 && (
No spots match
Try a different search or clear your filters.
)}
{/* 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 && (
Can't reach the relays
{online
? "You're online, but the community relay isn't answering right now."
: "You're offline."}{' '}
Spots you've already seen are saved on this device and will show here.
)}
{/* 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 && (
πΉ
No spots mapped yet
Be the first to map your park β it takes a minute, it's yours, and the next
skater who rolls through will thank you.
)}
{/* 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 }) => (
{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 */}