diff --git a/AGENTS.md b/AGENTS.md index 4405f55..72e70ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,15 +123,15 @@ session with **no other memory of this project**. So: `find_spots` / `find_events` / `find_coaches` (all Lab kinds 35001–35014) / `find_gear` / `find_crews` / `find_shops` / `find_tours` + `get_community_stats` — kept at parity with the app's `useCVMClient`. -- **Routing / directions**: `RouteDialog` (opened from the spot / shop / event / - food detail pages) draws an in-app route on Leaflet. Road modes - (skate-walk / bike / moto / car) come from keyless **OSRM** via the +- **Routing / directions**: `RouteDialog` is the single in-app router, opened + from the map spot sheet, the food cards, and the spot / shop / event detail + pages (MapPage's old external "Open with" Google/OSM/Waze menu was removed). + Road modes (skate-walk / bike / moto / car) come from keyless **OSRM** via the `proxy.shakespeare.diy` CORS proxy (`src/lib/routing.ts`); moto≈car is labelled honestly. **Transit** mode uses **Transitous** (`api.transitous.org`, MOTIS 2, open CORS) — note origin/destination go to that service, unlike road - modes. An opt-in **"Open in"** handoff (`src/lib/directions.ts`) deep-links - Google / Apple / OSM (+ Organic Maps via `geo:` on Android). No scooter/moped - profile (deliberate). + modes. **No external map-app handoff anywhere** (product decision) and no + scooter/moped profile. - **Mints directory**: `/mints` page lists Cashu mints from the community **MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* → diff --git a/src/components/RouteDialog.tsx b/src/components/RouteDialog.tsx index c801b7f..a4cce27 100644 --- a/src/components/RouteDialog.tsx +++ b/src/components/RouteDialog.tsx @@ -22,7 +22,6 @@ import { type TravelMode, type RouteResult, } from '@/lib/routing'; -import { buildDirectionsLinks, openExternal } from '@/lib/directions'; // ─── Transit presentation helpers ──────────────────────────────────────────── @@ -415,27 +414,6 @@ export function RouteDialog({ )} - - {/* Open in maps — opt-in handoff to an external app (this shares the trip) */} - {origin && destination && ( -
- - Open in - - {buildDirectionsLinks(origin, destination, mode, destinationLabel).map((link) => ( - - ))} -
- )} ); diff --git a/src/lib/directions.ts b/src/lib/directions.ts deleted file mode 100644 index 5403f16..0000000 --- a/src/lib/directions.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * "Open in maps" handoff — deep links to external navigation apps. - * ───────────────────────────────────────────────────────────────────────────── - * The in-app RouteDialog deliberately keeps routing on our own map (OSRM + - * Transitous) so nothing goes to Google by default. This module is the *opt-in* - * escape hatch: build links so a user who wants real turn-by-turn / offline - * navigation can hand the trip to another app. - * - * Providers are chosen for reliable, keyless web URLs that also deep-link into - * their native apps: - * • Google Maps — supports driving / walking / bicycling / transit - * • Apple Maps — iOS/macOS (opens the web version elsewhere) - * • OpenStreetMap — car / bike / foot (no transit) - * • Organic Maps — Android `geo:` intent (privacy-first, offline) - * - * Handing off *does* share the trip with that provider — hence opt-in. - */ -import { Capacitor } from '@capacitor/core'; -import type { TravelMode } from '@/lib/routing'; - -export interface DirectionsLink { - id: string; - label: string; - url: string; -} - -interface Point { - lat: number; - lng: number; -} - -const GOOGLE_MODE: Record = { - foot: 'walking', - bike: 'bicycling', - moto: 'driving', - car: 'driving', - transit: 'transit', -}; - -function googleUrl(from: Point, to: Point, mode: TravelMode): string { - return `https://www.google.com/maps/dir/?api=1&origin=${from.lat},${from.lng}&destination=${to.lat},${to.lng}&travelmode=${GOOGLE_MODE[mode]}`; -} - -/** Apple Maps "dirflg": w = walking, r = public transit, d = driving. */ -function appleFlag(mode: TravelMode): string { - if (mode === 'foot') return 'w'; - if (mode === 'transit') return 'r'; - return 'd'; // car / moto / bike -} - -function appleUrl(from: Point, to: Point, mode: TravelMode): string { - return `https://maps.apple.com/?saddr=${from.lat},${from.lng}&daddr=${to.lat},${to.lng}&dirflg=${appleFlag(mode)}`; -} - -/** OSM has no public-transport routing; returns null for transit. */ -function osmEngine(mode: TravelMode): string | null { - if (mode === 'foot') return 'fossgis_osrm_foot'; - if (mode === 'bike') return 'fossgis_osrm_bike'; - if (mode === 'transit') return null; - return 'fossgis_osrm_car'; // car / moto -} - -function osmUrl(from: Point, to: Point, mode: TravelMode): string | null { - const engine = osmEngine(mode); - if (!engine) return null; - return `https://www.openstreetmap.org/directions?engine=${engine}&route=${from.lat},${from.lng};${to.lat},${to.lng}`; -} - -/** `geo:` intent — Android hands it to Organic Maps (or the default map app). */ -function organicUrl(to: Point, label: string): string { - return `geo:${to.lat},${to.lng}?q=${to.lat},${to.lng}(${encodeURIComponent(label)})`; -} - -export function buildDirectionsLinks( - from: Point, - to: Point, - mode: TravelMode, - destinationLabel: string, -): DirectionsLink[] { - const links: DirectionsLink[] = [ - { id: 'google', label: 'Google Maps', url: googleUrl(from, to, mode) }, - { id: 'apple', label: 'Apple Maps', url: appleUrl(from, to, mode) }, - ]; - - const osm = osmUrl(from, to, mode); - if (osm) links.push({ id: 'osm', label: 'OpenStreetMap', url: osm }); - - if (Capacitor.getPlatform() === 'android') { - links.push({ id: 'organic', label: 'Organic Maps', url: organicUrl(to, destinationLabel) }); - } - - return links; -} - -/** - * Open an external URL. Capacitor intercepts the `_system` target and hands the - * URL to the OS (system browser / registered app); on the web it's a new tab. - */ -export function openExternal(url: string): void { - window.open(url, '_system'); -} diff --git a/src/pages/FoodPage.tsx b/src/pages/FoodPage.tsx index 8f1307e..458a4ef 100644 --- a/src/pages/FoodPage.tsx +++ b/src/pages/FoodPage.tsx @@ -3,6 +3,7 @@ import { useSeoMeta } from '@unhead/react'; import { Layout } from '@/components/Layout'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; +import { RouteDialog } from '@/components/RouteDialog'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; import { @@ -48,13 +49,10 @@ const FOOD_TYPE_LABELS: Record = { function FoodSpotCard({ spot }: { spot: FoodSpot }) { const Icon = FOOD_TYPE_ICONS[spot.type] || Utensils; + const [showRoute, setShowRoute] = useState(false); - const handleDirections = () => { - window.open( - `https://www.google.com/maps/dir/?api=1&destination=${spot.lat},${spot.lng}`, - '_blank' - ); - }; + // In-app routing only — no external map handoff. + const handleDirections = () => setShowRoute(true); return ( @@ -133,6 +131,12 @@ function FoodSpotCard({ spot }: { spot: FoodSpot }) { + ); } diff --git a/src/pages/MapPage.tsx b/src/pages/MapPage.tsx index 930995f..2894736 100644 --- a/src/pages/MapPage.tsx +++ b/src/pages/MapPage.tsx @@ -7,6 +7,7 @@ import { Layout } from '@/components/Layout'; const SpotMap = lazy(() => import('@/components/SpotMap').then(m => ({ default: m.SpotMap }))); import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard'; import { WeatherToggle } from '@/components/WeatherToggle'; +import { RouteDialog } from '@/components/RouteDialog'; import { TrustFilter } from '@/components/TrustFilter'; import { filterByTrustLevel, type TrustFilterLevel } from '@/lib/trustFilter'; import { SortDropdown, type SortOption } from '@/components/SortDropdown'; @@ -345,52 +346,15 @@ function StashDrawer() { ); } -// Detect iOS/macOS for smarter maps ordering -function isApplePlatform(): boolean { - if (typeof navigator === 'undefined') return false; - return /iPhone|iPad|iPod|Macintosh/.test(navigator.userAgent) && !/(Windows|Android)/.test(navigator.userAgent); -} - -// Universal directions picker — works on iOS, Android, desktop -function DirectionsMenu({ lat, lon, label }: { lat: string; lon: string; label: string }) { +// In-app directions — opens the same RouteDialog used on the detail pages. +// Deliberately NO external map handoff: routing stays inside SkateSpots. +function DirectionsButton({ lat, lon, label }: { lat: string; lon: string; label: string }) { const [open, setOpen] = useState(false); - const onApple = isApplePlatform(); - - const allOptions = [ - { - name: 'Apple Maps', - sub: 'iOS & macOS', - href: `https://maps.apple.com/?ll=${lat},${lon}&q=${label}&dirflg=d`, - emoji: '🍎', - appleOnly: true, - }, - { - name: 'Google Maps', - sub: 'Opens in browser or app', - href: `https://maps.google.com/?q=${lat},${lon}`, - emoji: '🔵', - appleOnly: false, - }, - { - name: 'OpenStreetMap', - sub: 'Privacy-friendly, no account', - href: `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}&zoom=17`, - emoji: '🗺️', - appleOnly: false, - }, - { - name: 'Waze', - sub: 'Community-powered navigation', - href: `https://waze.com/ul?ll=${lat},${lon}&navigate=yes`, - emoji: '🔸', - appleOnly: false, - }, - ]; - - // On Apple platforms: Apple Maps first. On others: Google first, no Apple. - const options = onApple - ? allOptions - : allOptions.filter(o => !o.appleOnly); + const destination = useMemo(() => { + const la = parseFloat(lat); + const lo = parseFloat(lon); + return Number.isFinite(la) && Number.isFinite(lo) ? { lat: la, lng: lo } : null; + }, [lat, lon]); return ( <> @@ -398,39 +362,17 @@ function DirectionsMenu({ lat, lon, label }: { lat: string; lon: string; label: variant="outline" className="h-12 w-full font-bold text-sm border-white/20 hover:bg-white/10 rounded-xl" onClick={() => setOpen(true)} + disabled={!destination} > Directions - - {open && ( - <> - {/* Backdrop */} -
setOpen(false)} /> - {/* Menu — fixed, centered, never clips behind Sheet stacking context */} -
-
-

Open with

-
- {options.map(opt => ( - setOpen(false)} - className="flex items-center gap-3 px-4 py-3 hover:bg-white/8 transition-colors" - > - {opt.emoji} -
-
{opt.name}
-
{opt.sub}
-
-
- ))} -
- - )} + ); } @@ -536,10 +478,10 @@ function SpotBottomSheet({ spot, onClose }: { spot: SkateSpot; onClose: () => vo const { latitude, longitude } = ngeohash.decode(spot.geohash); const lat = latitude.toFixed(6); const lon = longitude.toFixed(6); - const label = encodeURIComponent(spot.title); + const label = spot.title; return (
- +
); })()}