diff --git a/package-lock.json b/package-lock.json
index f74dd62..2cc98c2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 00253d7..c52c543 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/App.tsx b/src/App.tsx
index afb2218..2a2ccfc 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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() {
-
+
@@ -78,7 +103,7 @@ export function App() {
-
+
diff --git a/src/components/MapSkeletonPins.tsx b/src/components/MapSkeletonPins.tsx
new file mode 100644
index 0000000..0c36826
--- /dev/null
+++ b/src/components/MapSkeletonPins.tsx
@@ -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 (
+
+ {GHOSTS.map((g, i) => (
+
+ {/* Soft ground shadow / radar ping under each pin */}
+
+ {/* SkateSpots teardrop pin outline */}
+
+
+ ))}
+
+ {/* Centered, quiet status label */}
+
+
+
+
+
+
+
+ Finding spots
+
+
+
+
+ );
+}
+
+export default MapSkeletonPins;
diff --git a/src/index.css b/src/index.css
index 0891906..3406dcc 100644
--- a/src/index.css
+++ b/src/index.css
@@ -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;
}
diff --git a/src/lib/geolocation.ts b/src/lib/geolocation.ts
index f1c893c..fd7382c 100644
--- a/src/lib/geolocation.ts
+++ b/src/lib/geolocation.ts
@@ -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 {
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;
}
/**
diff --git a/src/lib/lastLocation.ts b/src/lib/lastLocation.ts
new file mode 100644
index 0000000..f9df434
--- /dev/null
+++ b/src/lib/lastLocation.ts
@@ -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;
+ 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;
+ }
+}
diff --git a/src/lib/queryPersister.ts b/src/lib/queryPersister.ts
new file mode 100644
index 0000000..5ae256d
--- /dev/null
+++ b/src/lib/queryPersister.ts
@@ -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([
+ // 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([
+ '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(IDB_KEY)) ?? undefined;
+ } catch {
+ return undefined;
+ }
+ },
+ removeClient: async () => {
+ try {
+ await del(IDB_KEY);
+ } catch {
+ // ignore
+ }
+ },
+ };
+}
diff --git a/src/pages/MapPage.tsx b/src/pages/MapPage.tsx
index 6d9da5b..f3a5c0c 100644
--- a/src/pages/MapPage.tsx
+++ b/src/pages/MapPage.tsx
@@ -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(smartDefault);
const [sortBy, setSortBy] = useState('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);
@@ -624,9 +635,26 @@ export default function MapPage() {
const [layersOpen, setLayersOpen] = useState(false);
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() {
}>
)}
+ {/* 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 && }
+
{/* Map attribution — bottom-left, sits ABOVE the mobile bottom