diff --git a/package-lock.json b/package-lock.json index 69e443b..c593485 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 62c5d54..c5ebe43 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/index.css b/src/index.css index 01811cd..9ad5aee 100644 --- a/src/index.css +++ b/src/index.css @@ -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 */ diff --git a/src/lib/geolocation.ts b/src/lib/geolocation.ts new file mode 100644 index 0000000..3c6ff22 --- /dev/null +++ b/src/lib/geolocation.ts @@ -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; + checkPermissions(): Promise; + requestPermissions(options?: { permissions?: string[] }): Promise; +} + +let geoPlugin: CapGeolocationPlugin | null = null; +function getNativeGeo(): CapGeolocationPlugin | null { + try { + if (!Capacitor?.isNativePlatform?.()) return null; + } catch { + return null; + } + if (!geoPlugin) { + geoPlugin = registerPlugin('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 { + 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((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 }, + ); + }); +} diff --git a/src/pages/MapPage.tsx b/src/pages/MapPage.tsx index 68155ea..50d5ea7 100644 --- a/src/pages/MapPage.tsx +++ b/src/pages/MapPage.tsx @@ -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() { {/* Full-height map container: subtract header + bottom nav on mobile, header only on desktop */} {/* 100dvh handles iOS Safari URL bar shrink correctly */} -
+
{/* ── Toolbar ── */}