feat(directions): every Directions button now uses the in-app router

- MapPage: replace the external 'Open with' menu (Google/OSM/Waze) with the
  shared in-app RouteDialog, so the map spot sheet routes natively (incl. the
  Transitous transit mode).
- FoodPage: replace the Google Maps handoff with RouteDialog.
- RouteDialog: remove the opt-in external 'Open in' handoff and delete
  src/lib/directions.ts — no external map apps anywhere (product decision).
This commit is contained in:
SkateSpots 2026-09-27 11:25:56 +07:00
parent 8893622bc3
commit b8757eccd0
5 changed files with 34 additions and 211 deletions

View file

@ -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/* →

View file

@ -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({
</div>
)}
</div>
{/* Open in maps — opt-in handoff to an external app (this shares the trip) */}
{origin && destination && (
<div className="px-4 pb-4 flex flex-wrap items-center gap-2">
<span className="w-full text-[10px] uppercase tracking-widest text-muted-foreground">
Open in
</span>
{buildDirectionsLinks(origin, destination, mode, destinationLabel).map((link) => (
<Button
key={link.id}
type="button"
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => openExternal(link.url)}
>
{link.label}
</Button>
))}
</div>
)}
</DialogContent>
</Dialog>
);

View file

@ -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<TravelMode, string> = {
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');
}

View file

@ -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<string, string> = {
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 (
<Card className="group hover:shadow-lg hover:shadow-primary/10 transition-all duration-300 border-border/50 hover:border-primary/30">
@ -133,6 +131,12 @@ function FoodSpotCard({ spot }: { spot: FoodSpot }) {
</Button>
</div>
</CardContent>
<RouteDialog
open={showRoute}
onOpenChange={setShowRoute}
destination={{ lat: spot.lat, lng: spot.lng }}
destinationLabel={spot.name}
/>
</Card>
);
}

View file

@ -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}
>
<Navigation className="h-4 w-4 mr-2 text-[#ccff00]" />
Directions
</Button>
{open && (
<>
{/* Backdrop */}
<div className="fixed inset-0 z-[2000]" onClick={() => setOpen(false)} />
{/* Menu — fixed, centered, never clips behind Sheet stacking context */}
<div className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[2001] bg-black/97 border border-white/15 rounded-2xl shadow-2xl overflow-hidden w-[min(18rem,calc(100vw-2rem))] backdrop-blur animate-in fade-in zoom-in-95 duration-150">
<div className="px-4 py-3 border-b border-white/8">
<p className="text-xs font-black uppercase tracking-widest text-white/50">Open with</p>
</div>
{options.map(opt => (
<a
key={opt.name}
href={opt.href}
target="_blank"
rel="noopener noreferrer"
onClick={() => setOpen(false)}
className="flex items-center gap-3 px-4 py-3 hover:bg-white/8 transition-colors"
>
<span className="text-xl w-7 text-center">{opt.emoji}</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-bold text-white">{opt.name}</div>
<div className="text-[10px] text-white/40">{opt.sub}</div>
</div>
</a>
))}
</div>
</>
)}
<RouteDialog
open={open}
onOpenChange={setOpen}
destination={destination}
destinationLabel={label || 'Spot'}
/>
</>
);
}
@ -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 (
<div className="relative">
<DirectionsMenu lat={lat} lon={lon} label={label} />
<DirectionsButton lat={lat} lon={lon} label={label} />
</div>
);
})()}