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",
|
"@bitcoinerlab/secp256k1": "^1.2.0",
|
||||||
"@capacitor/camera": "^8.2.1",
|
"@capacitor/camera": "^8.2.1",
|
||||||
"@capacitor/core": "^8.4.2",
|
"@capacitor/core": "^8.4.2",
|
||||||
|
"@capacitor/geolocation": "^8.2.0",
|
||||||
"@cashu/cashu-ts": "2.8.1",
|
"@cashu/cashu-ts": "2.8.1",
|
||||||
"@contextvm/sdk": "^0.12.0",
|
"@contextvm/sdk": "^0.12.0",
|
||||||
"@fontsource-variable/inter": "^5.2.6",
|
"@fontsource-variable/inter": "^5.2.6",
|
||||||
|
|
@ -299,6 +300,12 @@
|
||||||
"integrity": "sha512-fQPRb3JXRaU2pnufDUvqOjhsrYxDQeFRZyaj/sHydIUxD2NxOGHKMpmKUdI+U4OOmKuGZyvRpi7TSJU+7Bjbmg==",
|
"integrity": "sha512-fQPRb3JXRaU2pnufDUvqOjhsrYxDQeFRZyaj/sHydIUxD2NxOGHKMpmKUdI+U4OOmKuGZyvRpi7TSJU+7Bjbmg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@cashu/cashu-ts": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/@cashu/cashu-ts/-/cashu-ts-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/@cashu/cashu-ts/-/cashu-ts-2.8.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "skate-spots",
|
"name": "skate-spots",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.72",
|
"version": "1.0.73",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"homepage": "https://skate-spots.com",
|
"homepage": "https://skate-spots.com",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
"@bitcoinerlab/secp256k1": "^1.2.0",
|
"@bitcoinerlab/secp256k1": "^1.2.0",
|
||||||
"@capacitor/camera": "^8.2.1",
|
"@capacitor/camera": "^8.2.1",
|
||||||
"@capacitor/core": "^8.4.2",
|
"@capacitor/core": "^8.4.2",
|
||||||
|
"@capacitor/geolocation": "^8.2.0",
|
||||||
"@cashu/cashu-ts": "2.8.1",
|
"@cashu/cashu-ts": "2.8.1",
|
||||||
"@contextvm/sdk": "^0.12.0",
|
"@contextvm/sdk": "^0.12.0",
|
||||||
"@fontsource-variable/inter": "^5.2.6",
|
"@fontsource-variable/inter": "^5.2.6",
|
||||||
|
|
|
||||||
|
|
@ -354,6 +354,10 @@
|
||||||
--header-mobile: 3.5rem;
|
--header-mobile: 3.5rem;
|
||||||
/* Mobile bottom nav: 58px base + safe area */
|
/* Mobile bottom nav: 58px base + safe area */
|
||||||
--bottom-nav: calc(3.625rem + env(safe-area-inset-bottom, 0px));
|
--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 */
|
/* 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 { useToast } from '@/hooks/useToast';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { isNativeCapture, captureFromNativeCamera } from '@/lib/quickCapture';
|
import { isNativeCapture, captureFromNativeCamera } from '@/lib/quickCapture';
|
||||||
|
import { getCurrentPosition, GeolocationError, isNativeGeolocation } from '@/lib/geolocation';
|
||||||
|
|
||||||
|
|
||||||
type ViewMode = 'map' | 'list';
|
type ViewMode = 'map' | 'list';
|
||||||
|
|
@ -624,35 +625,36 @@ export default function MapPage() {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleNearMe = () => {
|
const handleNearMe = async () => {
|
||||||
// Clear previous error
|
// Clear previous error
|
||||||
setNearMeError(null);
|
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);
|
setNearMeLoading(true);
|
||||||
|
|
||||||
navigator.geolocation.getCurrentPosition(
|
try {
|
||||||
(pos) => {
|
const { lat, lng } = await getCurrentPosition();
|
||||||
setFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 14 });
|
setFlyTo({ lat, lng, zoom: 14 });
|
||||||
setUserLocation({ lat: pos.coords.latitude, lon: pos.coords.longitude });
|
setUserLocation({ lat, lon: lng });
|
||||||
setNearMeLoading(false);
|
setNearMeLoading(false);
|
||||||
setNearMeError(null);
|
setNearMeError(null);
|
||||||
},
|
} catch (err) {
|
||||||
(err) => {
|
|
||||||
setNearMeLoading(false);
|
setNearMeLoading(false);
|
||||||
if (err.code === err.PERMISSION_DENIED) {
|
const kind = err instanceof GeolocationError ? err.kind : 'unavailable';
|
||||||
|
|
||||||
|
if (kind === 'denied') {
|
||||||
setNearMeError('denied');
|
setNearMeError('denied');
|
||||||
|
if (isNativeGeolocation()) {
|
||||||
|
// Native app: it's an OS permission, not a browser setting.
|
||||||
|
toast({
|
||||||
|
title: 'Location permission needed',
|
||||||
|
description:
|
||||||
|
'Allow location for SkateSpots in your device Settings → Apps → SkateSpots → Permissions, then tap Near Me again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Web: show the browser re-enable help sheet.
|
||||||
setShowLocationHelp(true);
|
setShowLocationHelp(true);
|
||||||
} else if (err.code === err.TIMEOUT) {
|
}
|
||||||
|
} else if (kind === 'timeout') {
|
||||||
setNearMeError('timeout');
|
setNearMeError('timeout');
|
||||||
toast({
|
toast({
|
||||||
title: 'Location timed out',
|
title: 'Location timed out',
|
||||||
|
|
@ -667,9 +669,7 @@ export default function MapPage() {
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 30000 }
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const categoryKeys = Object.keys(SPOT_CATEGORIES);
|
const categoryKeys = Object.keys(SPOT_CATEGORIES);
|
||||||
|
|
@ -692,20 +692,11 @@ export default function MapPage() {
|
||||||
const isLoading = spotsLoading || shopsLoading || marketplaceLoading || toursLoading;
|
const isLoading = spotsLoading || shopsLoading || marketplaceLoading || toursLoading;
|
||||||
|
|
||||||
// Get user's location for proximity sorting
|
// Get user's location for proximity sorting
|
||||||
useMemo(() => {
|
useEffect(() => {
|
||||||
if (sortBy === 'proximity' && !userLocation) {
|
if (sortBy === 'proximity' && !userLocation) {
|
||||||
navigator.geolocation?.getCurrentPosition(
|
getCurrentPosition()
|
||||||
(position) => {
|
.then(({ lat, lng }) => setUserLocation({ lat, lon: lng }))
|
||||||
setUserLocation({
|
.catch((error) => console.warn('[MapPage] Geolocation error:', error));
|
||||||
lat: position.coords.latitude,
|
|
||||||
lon: position.coords.longitude,
|
|
||||||
});
|
|
||||||
console.log('[MapPage] User location:', position.coords);
|
|
||||||
},
|
|
||||||
(error) => {
|
|
||||||
console.warn('[MapPage] Geolocation error:', error);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}, [sortBy, userLocation]);
|
}, [sortBy, userLocation]);
|
||||||
|
|
||||||
|
|
@ -814,20 +805,14 @@ export default function MapPage() {
|
||||||
if (url) {
|
if (url) {
|
||||||
// Store the captured image URL in sessionStorage for the create spot page
|
// Store the captured image URL in sessionStorage for the create spot page
|
||||||
sessionStorage.setItem('quickCaptureImage', url);
|
sessionStorage.setItem('quickCaptureImage', url);
|
||||||
// Get current location and navigate to create spot
|
// Get current location (native-aware) and navigate to create spot.
|
||||||
if (navigator.geolocation) {
|
try {
|
||||||
navigator.geolocation.getCurrentPosition(
|
const { lat, lng } = await getCurrentPosition();
|
||||||
(position) => {
|
navigate(`/create-spot?lat=${lat.toFixed(6)}&lng=${lng.toFixed(6)}&quickCapture=true`);
|
||||||
navigate(`/create-spot?lat=${position.coords.latitude.toFixed(6)}&lng=${position.coords.longitude.toFixed(6)}&quickCapture=true`);
|
} catch {
|
||||||
},
|
|
||||||
() => {
|
|
||||||
// If geolocation fails, still navigate but without coords
|
// If geolocation fails, still navigate but without coords
|
||||||
navigate('/create-spot?quickCapture=true');
|
navigate('/create-spot?quickCapture=true');
|
||||||
}
|
}
|
||||||
);
|
|
||||||
} else {
|
|
||||||
navigate('/create-spot?quickCapture=true');
|
|
||||||
}
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Photo captured!',
|
title: 'Photo captured!',
|
||||||
description: 'Now add the spot details.',
|
description: 'Now add the spot details.',
|
||||||
|
|
@ -965,7 +950,7 @@ export default function MapPage() {
|
||||||
<Layout noPadding>
|
<Layout noPadding>
|
||||||
{/* Full-height map container: subtract header + bottom nav on mobile, header only on desktop */}
|
{/* Full-height map container: subtract header + bottom nav on mobile, header only on desktop */}
|
||||||
{/* 100dvh handles iOS Safari URL bar shrink correctly */}
|
{/* 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 ── */}
|
{/* ── Toolbar ── */}
|
||||||
<div className="border-b border-white/8 bg-black/95 backdrop-blur">
|
<div className="border-b border-white/8 bg-black/95 backdrop-blur">
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue