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:
parent
a6a377f468
commit
b3a025ca77
5 changed files with 203 additions and 64 deletions
7
package-lock.json
generated
7
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
142
src/lib/geolocation.ts
Normal 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 },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue