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:
parent
96f8c795c4
commit
a984084464
9 changed files with 445 additions and 24 deletions
29
package-lock.json
generated
29
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
31
src/App.tsx
31
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() {
|
|||
<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>
|
||||
|
|
|
|||
87
src/components/MapSkeletonPins.tsx
Normal file
87
src/components/MapSkeletonPins.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
67
src/lib/lastLocation.ts
Normal 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
134
src/lib/queryPersister.ts
Normal 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
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue