Instant cold-start: query persistence, optimistic geolocation, skeleton pins

Three UX wins so the app feels "already loaded" the moment it opens:

1. IndexedDB React Query persistence (lib/queryPersister.ts)
   - PersistQueryClientProvider rehydrates the last-seen spots, shops,
     events, tours, crews, coaches, listings and author profiles from
     IndexedDB on cold start, so returning users land on a populated map
     instead of a blank one; fresh data refetches quietly in the
     background.
   - Strict allow-list / deny-list: only public, JSON-serializable,
     cold-start-relevant queries are persisted. Private/encrypted data
     (stash, DMs, contacts, wallet/nwc/cashu/bitcoin, signer) is NEVER
     written to disk. 24h maxAge + version buster keep it fresh & safe.
   - Pinned @tanstack/react-query + query-core + persist-client to a
     single 5.102.8 to avoid a query-core mismatch (partialMatchKey).

2. Optimistic geolocation (lib/lastLocation.ts)
   - getCurrentPosition now caches every successful fix.
   - The map opens ALREADY centered on the user's last-known area
     (city zoom) instead of the world view; "you are here" + proximity
     sort seed from the cache instantly.
   - "Near Me" flies to the cached location immediately on tap, then
     refines to the fresh GPS fix. Silent background warm-up on web map
     mount so a later tap resolves instantly.

3. Cold-start skeleton (components/MapSkeletonPins.tsx)
   - When the map is genuinely empty + loading, show elegant pulsing
     ghost pins + a "Finding spots" pill instead of a blank map; they
     vanish the instant real (or cached) spots render. Splash now
     signals content-ready when spots actually resolve.

Respects prefers-reduced-motion throughout. Build + runtime verified
clean (no console errors).

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-09 21:58:22 +07:00
parent 96f8c795c4
commit a984084464
9 changed files with 445 additions and 24 deletions

29
package-lock.json generated
View file

@ -57,7 +57,9 @@
"@radix-ui/react-tooltip": "^1.1.4",
"@scure/bip32": "^2.0.1",
"@scure/bip39": "1.6.0",
"@tanstack/query-core": "^5.102.8",
"@tanstack/react-query": "^5.56.2",
"@tanstack/react-query-persist-client": "^5.102.8",
"@unhead/addons": "^2.0.10",
"@unhead/react": "^2.0.10",
"bitcoinjs-lib": "^7.0.0",
@ -76,6 +78,7 @@
"html2canvas": "^1.4.1",
"html5-qrcode": "^2.3.8",
"idb": "^8.0.3",
"idb-keyval": "^6.3.0",
"input-otp": "^1.2.4",
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3",
@ -3520,13 +3523,13 @@
}
},
"node_modules/@tanstack/query-core": {
"version": "5.75.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.75.0.tgz",
"integrity": "sha512-rk8KQuCdhoRkzjRVF3QxLgAfFUyS0k7+GCQjlGEpEGco+qazJ0eMH6aO1DjDjibH7/ik383nnztua3BG+lOnwg==",
"version": "5.102.8",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.102.8.tgz",
"integrity": "sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
"url": "https://github.com/sponsors/tannerlinsley",
"type": "github"
}
},
"node_modules/@tanstack/react-query": {
@ -3545,6 +3548,16 @@
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-query-persist-client": {
"version": "5.102.8",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-persist-client/-/react-query-persist-client-5.102.8.tgz",
"integrity": "sha512-WOZReC+m9klekPtG7eeZ3txJeMA9lg2R0RytSG3yXcZgJItQAVIyoobJdbdIQtlpgcU2tSzsOEBtJacs2vlCOA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/tannerlinsley",
"type": "github"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@ -5993,6 +6006,12 @@
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
"license": "ISC"
},
"node_modules/idb-keyval": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.3.0.tgz",
"integrity": "sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow==",
"license": "Apache-2.0"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",

View file

@ -64,7 +64,9 @@
"@radix-ui/react-tooltip": "^1.1.4",
"@scure/bip32": "^2.0.1",
"@scure/bip39": "1.6.0",
"@tanstack/react-query": "^5.56.2",
"@tanstack/query-core": "^5.102.8",
"@tanstack/react-query": "5.102.8",
"@tanstack/react-query-persist-client": "5.102.8",
"@unhead/addons": "^2.0.10",
"@unhead/react": "^2.0.10",
"bitcoinjs-lib": "^7.0.0",
@ -83,6 +85,7 @@
"html2canvas": "^1.4.1",
"html5-qrcode": "^2.3.8",
"idb": "^8.0.3",
"idb-keyval": "^6.3.0",
"input-otp": "^1.2.4",
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3",

View file

@ -1,10 +1,17 @@
// NOTE: This file should normally not be modified unless you are adding a new provider.
// To add new routes, edit the AppRouter.tsx file.
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { QueryClient } from "@tanstack/react-query";
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { createHead, UnheadProvider } from '@unhead/react/client';
import { InferSeoMetaPlugin } from '@unhead/addons';
import { Suspense } from 'react';
import {
createIDBPersister,
shouldDehydrateQuery,
PERSIST_BUSTER,
PERSIST_MAX_AGE,
} from '@/lib/queryPersister';
import NostrProvider from '@/components/NostrProvider';
import { NostrSync } from '@/components/NostrSync';
import { Toaster } from "@/components/ui/toaster";
@ -36,6 +43,10 @@ const queryClient = new QueryClient({
},
});
// IndexedDB-backed persistence so the app rehydrates the last-seen spots,
// shops, events and profiles instantly on a cold start (see lib/queryPersister).
const persister = createIDBPersister();
const defaultConfig: AppConfig = {
theme: "dark",
relayMetadata: {
@ -56,7 +67,21 @@ export function App() {
<UnheadProvider head={head}>
<LanguageProvider>
<AppProvider storageKey="nostr:app-config" defaultConfig={defaultConfig}>
<QueryClientProvider client={queryClient}>
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister,
// Discard persisted content older than 24h so nothing stale lingers.
maxAge: PERSIST_MAX_AGE,
// Invalidate the whole cache when the persisted data shape changes.
buster: PERSIST_BUSTER,
dehydrateOptions: {
// Only persist public, cold-start-relevant queries — never
// private/encrypted data. See lib/queryPersister.
shouldDehydrateQuery,
},
}}
>
<NostrLoginProvider storageKey='nostr:login'>
<NostrProvider>
<SplashScreen />
@ -78,7 +103,7 @@ export function App() {
</NWCProvider>
</NostrProvider>
</NostrLoginProvider>
</QueryClientProvider>
</PersistQueryClientProvider>
</AppProvider>
</LanguageProvider>
</UnheadProvider>

View file

@ -0,0 +1,87 @@
/**
* MapSkeletonPins
* ─────────────────────────────────────────────────────────────────────────────
* A tasteful "spots are loading" overlay shown ONLY on a genuinely cold, empty
* map (no cached spots yet + still fetching). Instead of a blank map that reads
* as broken, we scatter a few softly-pulsing ghost pins in the SkateSpots pin
* shape, so the moment reads as "finding spots near you…".
*
* It's purely decorative and non-interactive (`pointer-events-none`), sits above
* the tiles but below every real control, and fully respects reduced-motion.
* The instant real spots arrive the parent unmounts this — no layout shift.
*/
interface Ghost {
/** left/top as viewport percentages so it scales on any screen. */
left: number;
top: number;
/** teardrop scale + animation delay for an organic, staggered feel. */
scale: number;
delay: number;
}
// Hand-placed so they feel scattered-but-balanced (not a rigid grid).
const GHOSTS: Ghost[] = [
{ left: 22, top: 34, scale: 1.0, delay: 0 },
{ left: 48, top: 22, scale: 0.82, delay: 0.35 },
{ left: 71, top: 38, scale: 1.1, delay: 0.15 },
{ left: 34, top: 58, scale: 0.9, delay: 0.6 },
{ left: 62, top: 62, scale: 1.0, delay: 0.45 },
{ left: 82, top: 66, scale: 0.78, delay: 0.8 },
];
export function MapSkeletonPins() {
return (
<div
className="pointer-events-none absolute inset-0 z-[900] overflow-hidden"
aria-hidden="true"
>
{GHOSTS.map((g, i) => (
<div
key={i}
className="absolute motion-safe:animate-ghost-pin"
style={{
left: `${g.left}%`,
top: `${g.top}%`,
animationDelay: `${g.delay}s`,
}}
>
{/* Soft ground shadow / radar ping under each pin */}
<span
className="absolute left-1/2 top-full h-2 w-8 -translate-x-1/2 -translate-y-1 rounded-[50%] bg-[#ff0040]/20 blur-[3px] motion-safe:animate-ghost-ping"
style={{ animationDelay: `${g.delay}s` }}
/>
{/* SkateSpots teardrop pin outline */}
<div
style={{
width: 30 * g.scale,
height: 30 * g.scale,
borderRadius: '50% 50% 50% 0',
transform: 'rotate(-45deg)',
background:
'linear-gradient(135deg, rgba(255,0,64,0.18), rgba(255,102,0,0.14))',
border: '2px solid rgba(255,255,255,0.14)',
boxShadow: '0 4px 14px rgba(255,0,64,0.15)',
backdropFilter: 'blur(1px)',
}}
/>
</div>
))}
{/* Centered, quiet status label */}
<div className="absolute left-1/2 top-[46%] -translate-x-1/2 -translate-y-1/2">
<div className="flex items-center gap-2 rounded-full bg-black/45 px-3.5 py-1.5 backdrop-blur-sm ring-1 ring-white/10">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full rounded-full bg-[#ff0040] opacity-70 motion-safe:animate-ping" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-[#ff0040]" />
</span>
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-white/70">
Finding spots
</span>
</div>
</div>
</div>
);
}
export default MapSkeletonPins;

View file

@ -342,6 +342,24 @@
to { opacity: 1; transform: translateY(0); }
}
/* ── Map skeleton "ghost pins" (shown on a cold, empty, loading map) ──
The pin gently rises + breathes; the shadow under it pulses like a radar
ping. Kept subtle so it reads as "loading", never as real content. */
@keyframes ghostPin {
0%, 100% { transform: translateY(0) scale(1); opacity: 0.55; }
50% { transform: translateY(-6px) scale(1.04); opacity: 0.9; }
}
@keyframes ghostPing {
0%, 100% { transform: translateX(-50%) translateY(-0.25rem) scaleX(1); opacity: 0.35; }
50% { transform: translateX(-50%) translateY(-0.25rem) scaleX(1.6); opacity: 0.12; }
}
.animate-ghost-pin {
animation: ghostPin 2.4s ease-in-out infinite;
}
.animate-ghost-ping {
animation: ghostPing 2.4s ease-in-out infinite;
}
.animate-fadeIn {
animation: fadeIn 0.4s ease-out forwards;
}

View file

@ -40,6 +40,7 @@
*/
import { Capacitor, registerPlugin } from '@capacitor/core';
import { saveLastLocation } from '@/lib/lastLocation';
export interface GeoCoords {
lat: number;
@ -161,11 +162,13 @@ export function isNativePlatform(): boolean {
export async function getCurrentPosition(): Promise<GeoCoords> {
const native = getNativeGeo();
if (native) {
return getNativePosition(native);
}
const coords = native ? await getNativePosition(native) : await getWebPosition();
return getWebPosition();
// Remember the fix so the map can open optimistically centered next time and
// "Near Me" has something to fly to instantly (see lib/lastLocation).
saveLastLocation(coords.lat, coords.lng);
return coords;
}
/**

67
src/lib/lastLocation.ts Normal file
View file

@ -0,0 +1,67 @@
/**
* Last-known-location cache.
* ─────────────────────────────────────────────────────────────────────────────
* Getting a fresh GPS fix takes time. To make the map feel instant we remember
* the user's last successful location in localStorage and use it to:
* 1. Open the map ALREADY centered on their area (optimistic initial view).
* 2. Give "Near Me" something to fly to immediately while the fresh fix
* resolves in the background.
*
* This is a coarse convenience cache, not a source of truth — a fresh fix always
* supersedes it. We never store accuracy or timestamps beyond what's needed and
* it lives only in this browser/app.
*/
export interface LastLocation {
lat: number;
lng: number;
/** Epoch ms when this fix was captured. */
at: number;
}
const KEY = 'skatespots:last-location';
/** Consider a cached location "usable for centering" for 30 days. */
const MAX_AGE = 30 * 24 * 60 * 60 * 1000;
/** Persist a successful location fix. Never throws. */
export function saveLastLocation(lat: number, lng: number): void {
try {
if (
typeof lat !== 'number' ||
typeof lng !== 'number' ||
Number.isNaN(lat) ||
Number.isNaN(lng) ||
(lat === 0 && lng === 0)
) {
return;
}
const payload: LastLocation = { lat, lng, at: Date.now() };
localStorage.setItem(KEY, JSON.stringify(payload));
} catch {
// ignore private-mode / quota failures
}
}
/**
* Read the cached location if present and not expired. Returns null otherwise.
* Never throws.
*/
export function getLastLocation(): LastLocation | null {
try {
const raw = localStorage.getItem(KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<LastLocation>;
if (
typeof parsed?.lat !== 'number' ||
typeof parsed?.lng !== 'number' ||
typeof parsed?.at !== 'number'
) {
return null;
}
if (Date.now() - parsed.at > MAX_AGE) return null;
return { lat: parsed.lat, lng: parsed.lng, at: parsed.at };
} catch {
return null;
}
}

134
src/lib/queryPersister.ts Normal file
View file

@ -0,0 +1,134 @@
/**
* IndexedDB-backed React Query persistence.
* ─────────────────────────────────────────────────────────────────────────────
* WHY
* On a cold start every Nostr query begins from an empty cache, so users land on
* a blank map/profile list and watch it populate as the relay round-trips
* complete (1–4s). By persisting the React Query cache to IndexedDB we can
* REHYDRATE the last-seen spots/shops/events/profiles instantly on the next
* launch — the app opens already populated and quietly refetches fresh data in
* the background.
*
* WHAT WE PERSIST (allow-list, see `shouldDehydrateQuery`)
* Only PUBLIC, plain-JSON, cold-start-relevant queries: skate spots, shops,
* events, tours, crews, coaches, marketplace listings, author profiles, and a
* few small stats. We DO NOT persist:
* - Private / encrypted data (stash, DMs, private contacts) — must never touch
* disk in plaintext.
* - Per-interaction / volatile data (zaps, unread counts, RSVPs).
* - Errored or still-pending queries.
* This keeps the persisted blob small, safe, and genuinely useful.
*
* SAFETY
* - `idb-keyval` writes to IndexedDB off the main thread; failures are swallowed
* (private-mode / quota) so persistence can never break app boot.
* - A `buster` invalidates the whole cache when the data shape changes.
* - `maxAge` discards anything older than 24h so stale content can't linger.
*/
import { get, set, del } from 'idb-keyval';
import type {
PersistedClient,
Persister,
} from '@tanstack/react-query-persist-client';
import type { Query } from '@tanstack/react-query';
const IDB_KEY = 'skatespots:react-query-cache';
/** Bump when the persisted data shape changes to invalidate old caches. */
export const PERSIST_BUSTER = 'v1';
/** Discard any persisted cache older than this (ms). */
export const PERSIST_MAX_AGE = 24 * 60 * 60 * 1000; // 24h
/**
* Query-key prefixes that are SAFE and USEFUL to persist for instant cold-start.
* A query is persisted only if its first key segment is in this set (and it has
* successfully-fetched data — see `shouldDehydrateQuery`).
*/
const PERSIST_ALLOW_LIST = new Set<string>([
// Map + directory content (the stuff that makes the app look "loaded")
'skate-spots',
'skate-spot',
'user-skate-spots',
'skate-shops',
'shops',
'skate-events',
'upcoming-skate-events',
'tours',
'crews',
'coaches',
'lab-profiles',
'marketplace',
'marketplace-items',
'skaters',
// Author profiles (kind 0) — avatars/names everywhere benefit hugely
'author',
'authors',
// Small, non-sensitive aggregate stats
'growth-stats',
'community-stats',
]);
/**
* Query-key prefixes that must NEVER be persisted, even if they somehow match
* the allow-list. Private, encrypted, signer-derived, or highly volatile data.
*/
const PERSIST_DENY_LIST = new Set<string>([
'stash',
'stash-showcase',
'direct-messages',
'dms',
'private-contacts',
'nwc',
'wallet',
'cashu',
'bitcoin',
'signer',
]);
/**
* Decide whether a single query should be written to disk.
* Persist only: successful queries, with data, whose key prefix is explicitly
* allow-listed and not deny-listed.
*/
export function shouldDehydrateQuery(query: Query): boolean {
if (query.state.status !== 'success') return false;
if (query.state.data === undefined || query.state.data === null) return false;
const prefix = Array.isArray(query.queryKey) ? query.queryKey[0] : query.queryKey;
if (typeof prefix !== 'string') return false;
if (PERSIST_DENY_LIST.has(prefix)) return false;
return PERSIST_ALLOW_LIST.has(prefix);
}
/**
* An async Persister backed by IndexedDB (via idb-keyval). All operations are
* defensively wrapped so a storage failure never bubbles into React.
*/
export function createIDBPersister(): Persister {
return {
persistClient: async (client: PersistedClient) => {
try {
await set(IDB_KEY, client);
} catch {
// ignore — private mode / quota exceeded
}
},
restoreClient: async () => {
try {
return (await get<PersistedClient>(IDB_KEY)) ?? undefined;
} catch {
return undefined;
}
},
removeClient: async () => {
try {
await del(IDB_KEY);
} catch {
// ignore
}
},
};
}

View file

@ -84,6 +84,8 @@ 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';
import { MapSkeletonPins } from '@/components/MapSkeletonPins';
type ViewMode = 'map' | 'list';
@ -556,14 +558,18 @@ export default function MapPage() {
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). The
// splash still has its own hard cap, so this only ever makes the reveal feel
// more "ready" — it can't delay dismissal.
// 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(() => {
if (!spotsLoading) {
const hasSpots = Array.isArray(spots) && spots.length > 0;
if (hasSpots || !spotsLoading) {
window.dispatchEvent(new Event('skatespots:content-ready'));
}
}, [spotsLoading]);
}, [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
@ -588,7 +594,12 @@ export default function MapPage() {
const smartDefault: TrustFilterLevel = (user && followCount >= 5) ? 'trusted' : 'all';
const [trustFilter, setTrustFilter] = useState<TrustFilterLevel>(smartDefault);
const [sortBy, setSortBy] = useState<SortOption>('recent');
const [userLocation, setUserLocation] = useState<{lat: number; lon: number} | null>(null);
// 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);
@ -625,8 +636,25 @@ export default function MapPage() {
const { toast } = useToast();
// BTC Map center for fetching places
const [mapCenter, setMapCenter] = useState<{ lat: number; lon: number }>({ lat: 40.7128, lon: -74.006 });
// 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);
@ -641,11 +669,20 @@ export default function MapPage() {
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: 'Locating you…',
description: 'Getting a GPS fix. This can take up to a minute the first time.',
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,
});
@ -748,6 +785,27 @@ export default function MapPage() {
}
}, [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;
getCurrentPosition()
.then(({ lat, lng }) => {
if (!cancelled) setUserLocation({ lat, lon: lng });
})
.catch(() => {
// Silent — user hasn't asked to be located yet.
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Combine spots and shops into unified list for map display
const allLocations = useMemo(() => {
const spotList = (spots || []).map(spot => ({ ...spot, isShop: false }));
@ -1520,6 +1578,8 @@ export default function MapPage() {
}>
<SpotMap
spots={mapSpots}
center={initialCenter}
zoom={initialZoom}
marketplaceItems={marketplaceItems || []}
tours={tours || []}
btcMapPlaces={btcMapPlaces || []}
@ -1551,6 +1611,11 @@ export default function MapPage() {
</div>
)}
{/* Cold-start skeleton: only while genuinely loading with NO
markers on the map yet. The instant real (or cached) spots
render, mapSpots.length > 0 and this disappears — no blank map. */}
{isLoading && mapSpots.length === 0 && <MapSkeletonPins />}
{/* Map attribution — bottom-left, sits ABOVE the mobile bottom