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:
parent
8893622bc3
commit
b8757eccd0
5 changed files with 34 additions and 211 deletions
12
AGENTS.md
12
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/* →
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})()}
|
||||
|
|
|
|||
Loading…
Reference in a new issue