Native geolocation for Near Me + desktop map fits viewport with footer (v1.0.73)

1. Near Me "location blocked" on the APK:
   - navigator.geolocation returns PERMISSION_DENIED inside the Android WebView
     because the WebView never gets the OS location permission, so the app
     showed a bogus "your browser blocked location" help sheet.
   - New src/lib/geolocation.ts: platform-aware getCurrentPosition(). On native
     it uses Capacitor's Geolocation plugin via registerPlugin('Geolocation')
     from bundled @capacitor/core (same runtime pattern as the camera/Amber
     fixes) and calls requestPermissions() to trigger the real Android prompt;
     on web it falls back to navigator.geolocation.
   - MapPage handleNearMe, proximity sort, and quick-snap location now use it.
     On native, a denied permission shows an OS-permission toast instead of the
     browser help sheet.
   - Added @capacitor/geolocation dependency (native side picked up via cap sync;
     no bare import in the JS bundle).

2. Desktop map didn't fit the viewport (had to scroll to see footer):
   - Map wrapper was md:h-[calc(100vh-4rem)] — full height minus header only,
     leaving no room for the footer.
   - Added --footer-desktop token (5rem) and changed the desktop height to
     calc(100vh-4rem-var(--footer-desktop)) so map + footer fit without scroll.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-24 20:28:06 +07:00
parent a6a377f468
commit b3a025ca77
5 changed files with 203 additions and 64 deletions

7
package-lock.json generated
View file

@ -11,6 +11,7 @@
"@bitcoinerlab/secp256k1": "^1.2.0",
"@capacitor/camera": "^8.2.1",
"@capacitor/core": "^8.4.2",
"@capacitor/geolocation": "^8.2.0",
"@cashu/cashu-ts": "2.8.1",
"@contextvm/sdk": "^0.12.0",
"@fontsource-variable/inter": "^5.2.6",
@ -299,6 +300,12 @@
"integrity": "sha512-fQPRb3JXRaU2pnufDUvqOjhsrYxDQeFRZyaj/sHydIUxD2NxOGHKMpmKUdI+U4OOmKuGZyvRpi7TSJU+7Bjbmg==",
"license": "MIT"
},
"node_modules/@capacitor/geolocation": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/@capacitor/geolocation/-/geolocation-8.2.0.tgz",
"integrity": "sha512-N29QcoIPmme0xSxRkm7+3hjoHp6mBAOarxecvtCCZKyOBeKiJsFUq981cezg2XWBa6fhCXJMCCjQPngKK/dIag==",
"license": "MIT"
},
"node_modules/@cashu/cashu-ts": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/@cashu/cashu-ts/-/cashu-ts-2.8.1.tgz",

View file

@ -1,7 +1,7 @@
{
"name": "skate-spots",
"private": true,
"version": "1.0.72",
"version": "1.0.73",
"type": "module",
"homepage": "https://skate-spots.com",
"scripts": {
@ -19,6 +19,7 @@
"@bitcoinerlab/secp256k1": "^1.2.0",
"@capacitor/camera": "^8.2.1",
"@capacitor/core": "^8.4.2",
"@capacitor/geolocation": "^8.2.0",
"@cashu/cashu-ts": "2.8.1",
"@contextvm/sdk": "^0.12.0",
"@fontsource-variable/inter": "^5.2.6",

View file

@ -354,6 +354,10 @@
--header-mobile: 3.5rem;
/* Mobile bottom nav: 58px base + safe area */
--bottom-nav: calc(3.625rem + env(safe-area-inset-bottom, 0px));
/* Desktop footer: py-6 (3rem) + 1px border + ~1 line of content ≈ 5rem.
Full-bleed pages (map) subtract this so the footer stays in view without
scrolling. */
--footer-desktop: 5rem;
}
/* Full-bleed page — removes Layout bottom padding so the page can manage its own height */

142
src/lib/geolocation.ts Normal file
View file

@ -0,0 +1,142 @@
/**
* Platform-aware geolocation.
*
* THE BUG THIS FIXES
* ------------------
* On the native Android app (ZapStore APK) the in-WebView
* `navigator.geolocation.getCurrentPosition` returns PERMISSION_DENIED, because
* the WebView never gets the OS location permission on its own. The app then
* showed a "your BROWSER blocked location" help sheet — wrong and useless inside
* a native app.
*
* THE FIX
* -------
* On native we go through Capacitor's Geolocation plugin, accessed via
* `registerPlugin('Geolocation')` from the bundled @capacitor/core (same runtime
* pattern used for Camera + the Amber signer — no bare `@capacitor/geolocation`
* import in the JS bundle). We call requestPermissions() first, which triggers
* the real Android location prompt, then getCurrentPosition().
*
* IMPORTANT (native build): @capacitor/geolocation must be installed (it's in
* package.json) and synced with `npx cap sync android` so the native plugin is
* bundled into the APK and registered. On web we just use navigator.geolocation.
*/
import { Capacitor, registerPlugin } from '@capacitor/core';
export interface GeoCoords {
lat: number;
lng: number;
}
export type GeoErrorKind = 'denied' | 'unavailable' | 'timeout';
export class GeolocationError extends Error {
kind: GeoErrorKind;
constructor(kind: GeoErrorKind, message: string) {
super(message);
this.kind = kind;
this.name = 'GeolocationError';
}
}
// ─── Native plugin proxy ──────────────────────────────────────────────────────
interface CapPosition {
coords: { latitude: number; longitude: number };
}
interface CapPermissionStatus {
location: 'granted' | 'denied' | 'prompt' | 'prompt-with-rationale';
coarseLocation?: 'granted' | 'denied' | 'prompt' | 'prompt-with-rationale';
}
interface CapGeolocationPlugin {
getCurrentPosition(options?: { enableHighAccuracy?: boolean; timeout?: number; maximumAge?: number }): Promise<CapPosition>;
checkPermissions(): Promise<CapPermissionStatus>;
requestPermissions(options?: { permissions?: string[] }): Promise<CapPermissionStatus>;
}
let geoPlugin: CapGeolocationPlugin | null = null;
function getNativeGeo(): CapGeolocationPlugin | null {
try {
if (!Capacitor?.isNativePlatform?.()) return null;
} catch {
return null;
}
if (!geoPlugin) {
geoPlugin = registerPlugin<CapGeolocationPlugin>('Geolocation');
}
return geoPlugin;
}
/** True when running inside the native app (so we can tailor error copy). */
export function isNativeGeolocation(): boolean {
try {
return Capacitor?.isNativePlatform?.() ?? false;
} catch {
return false;
}
}
// ─── Public API ───────────────────────────────────────────────────────────────
/**
* Get the device's current position. Uses the native Geolocation plugin on the
* app (with a proper OS permission prompt) and navigator.geolocation on web.
* Throws a GeolocationError with a `kind` the UI can branch on.
*/
export async function getCurrentPosition(): Promise<GeoCoords> {
const native = getNativeGeo();
if (native) {
// ── Native path ──
try {
// Ask the OS for permission first — this shows the Android prompt.
let status = await native.checkPermissions().catch(() => null);
if (!status || (status.location !== 'granted' && status.coarseLocation !== 'granted')) {
status = await native.requestPermissions({ permissions: ['location', 'coarseLocation'] });
}
if (status && status.location !== 'granted' && status.coarseLocation !== 'granted') {
throw new GeolocationError('denied', 'Location permission denied.');
}
const pos = await native.getCurrentPosition({
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 30000,
});
return { lat: pos.coords.latitude, lng: pos.coords.longitude };
} catch (err) {
if (err instanceof GeolocationError) throw err;
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
if (msg.includes('denied') || msg.includes('permission')) {
throw new GeolocationError('denied', 'Location permission denied.');
}
if (msg.includes('timeout') || msg.includes('timed out')) {
throw new GeolocationError('timeout', 'Location request timed out.');
}
// "location disabled", "unavailable", etc.
throw new GeolocationError('unavailable', err instanceof Error ? err.message : 'Location unavailable.');
}
}
// ── Web path ──
if (!('geolocation' in navigator)) {
throw new GeolocationError('unavailable', 'Geolocation is not supported by this browser.');
}
return new Promise<GeoCoords>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(
(pos) => resolve({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
(err) => {
if (err.code === err.PERMISSION_DENIED) {
reject(new GeolocationError('denied', 'Location permission denied.'));
} else if (err.code === err.TIMEOUT) {
reject(new GeolocationError('timeout', 'Location request timed out.'));
} else {
reject(new GeolocationError('unavailable', 'Location unavailable.'));
}
},
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 30000 },
);
});
}

View file

@ -82,6 +82,7 @@ import { useUploadFile } from '@/hooks/useUploadFile';
import { useToast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
import { isNativeCapture, captureFromNativeCamera } from '@/lib/quickCapture';
import { getCurrentPosition, GeolocationError, isNativeGeolocation } from '@/lib/geolocation';
type ViewMode = 'map' | 'list';
@ -624,52 +625,51 @@ export default function MapPage() {
const handleNearMe = () => {
const handleNearMe = async () => {
// Clear previous error
setNearMeError(null);
if (!navigator.geolocation) {
setNearMeError('unavailable');
toast({
title: 'Location not available',
description: 'Your browser does not support geolocation. Try Chrome or Safari.',
variant: 'destructive',
});
return;
}
setNearMeLoading(true);
navigator.geolocation.getCurrentPosition(
(pos) => {
setFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 14 });
setUserLocation({ lat: pos.coords.latitude, lon: pos.coords.longitude });
setNearMeLoading(false);
setNearMeError(null);
},
(err) => {
setNearMeLoading(false);
if (err.code === err.PERMISSION_DENIED) {
setNearMeError('denied');
setShowLocationHelp(true);
} else if (err.code === err.TIMEOUT) {
setNearMeError('timeout');
try {
const { lat, lng } = await getCurrentPosition();
setFlyTo({ lat, lng, zoom: 14 });
setUserLocation({ lat, lon: lng });
setNearMeLoading(false);
setNearMeError(null);
} catch (err) {
setNearMeLoading(false);
const kind = err instanceof GeolocationError ? err.kind : 'unavailable';
if (kind === 'denied') {
setNearMeError('denied');
if (isNativeGeolocation()) {
// Native app: it's an OS permission, not a browser setting.
toast({
title: 'Location timed out',
description: 'Could not get your location in time. Check your signal and try again.',
title: 'Location permission needed',
description:
'Allow location for SkateSpots in your device Settings → Apps → SkateSpots → Permissions, then tap Near Me again.',
variant: 'destructive',
});
} else {
setNearMeError('unavailable');
toast({
title: 'Location unavailable',
description: 'Your location could not be determined. Make sure location services are enabled.',
variant: 'destructive',
});
// Web: show the browser re-enable help sheet.
setShowLocationHelp(true);
}
},
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 30000 }
);
} else if (kind === 'timeout') {
setNearMeError('timeout');
toast({
title: 'Location timed out',
description: 'Could not get your location in time. Check your signal and try again.',
variant: 'destructive',
});
} else {
setNearMeError('unavailable');
toast({
title: 'Location unavailable',
description: 'Your location could not be determined. Make sure location services are enabled.',
variant: 'destructive',
});
}
}
};
const categoryKeys = Object.keys(SPOT_CATEGORIES);
@ -692,20 +692,11 @@ export default function MapPage() {
const isLoading = spotsLoading || shopsLoading || marketplaceLoading || toursLoading;
// Get user's location for proximity sorting
useMemo(() => {
useEffect(() => {
if (sortBy === 'proximity' && !userLocation) {
navigator.geolocation?.getCurrentPosition(
(position) => {
setUserLocation({
lat: position.coords.latitude,
lon: position.coords.longitude,
});
console.log('[MapPage] User location:', position.coords);
},
(error) => {
console.warn('[MapPage] Geolocation error:', error);
}
);
getCurrentPosition()
.then(({ lat, lng }) => setUserLocation({ lat, lon: lng }))
.catch((error) => console.warn('[MapPage] Geolocation error:', error));
}
}, [sortBy, userLocation]);
@ -814,18 +805,12 @@ export default function MapPage() {
if (url) {
// Store the captured image URL in sessionStorage for the create spot page
sessionStorage.setItem('quickCaptureImage', url);
// Get current location and navigate to create spot
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(position) => {
navigate(`/create-spot?lat=${position.coords.latitude.toFixed(6)}&lng=${position.coords.longitude.toFixed(6)}&quickCapture=true`);
},
() => {
// If geolocation fails, still navigate but without coords
navigate('/create-spot?quickCapture=true');
}
);
} else {
// Get current location (native-aware) and navigate to create spot.
try {
const { lat, lng } = await getCurrentPosition();
navigate(`/create-spot?lat=${lat.toFixed(6)}&lng=${lng.toFixed(6)}&quickCapture=true`);
} catch {
// If geolocation fails, still navigate but without coords
navigate('/create-spot?quickCapture=true');
}
toast({
@ -965,7 +950,7 @@ export default function MapPage() {
<Layout noPadding>
{/* Full-height map container: subtract header + bottom nav on mobile, header only on desktop */}
{/* 100dvh handles iOS Safari URL bar shrink correctly */}
<div className="h-[calc(100dvh-var(--header-mobile)-var(--bottom-nav))] md:h-[calc(100vh-4rem)] flex flex-col">
<div className="h-[calc(100dvh-var(--header-mobile)-var(--bottom-nav))] md:h-[calc(100vh-4rem-var(--footer-desktop))] flex flex-col">
{/* ── Toolbar ── */}
<div className="border-b border-white/8 bg-black/95 backdrop-blur">
<div className="px-3 py-2">