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_spots` / `find_events` / `find_coaches` (all Lab kinds 35001–35014) /
|
||||||
`find_gear` / `find_crews` / `find_shops` / `find_tours` +
|
`find_gear` / `find_crews` / `find_shops` / `find_tours` +
|
||||||
`get_community_stats` — kept at parity with the app's `useCVMClient`.
|
`get_community_stats` — kept at parity with the app's `useCVMClient`.
|
||||||
- **Routing / directions**: `RouteDialog` (opened from the spot / shop / event /
|
- **Routing / directions**: `RouteDialog` is the single in-app router, opened
|
||||||
food detail pages) draws an in-app route on Leaflet. Road modes
|
from the map spot sheet, the food cards, and the spot / shop / event detail
|
||||||
(skate-walk / bike / moto / car) come from keyless **OSRM** via the
|
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
|
`proxy.shakespeare.diy` CORS proxy (`src/lib/routing.ts`); moto≈car is
|
||||||
labelled honestly. **Transit** mode uses **Transitous** (`api.transitous.org`,
|
labelled honestly. **Transit** mode uses **Transitous** (`api.transitous.org`,
|
||||||
MOTIS 2, open CORS) — note origin/destination go to that service, unlike road
|
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
|
modes. **No external map-app handoff anywhere** (product decision) and no
|
||||||
Google / Apple / OSM (+ Organic Maps via `geo:` on Android). No scooter/moped
|
scooter/moped profile.
|
||||||
profile (deliberate).
|
|
||||||
- **Mints directory**: `/mints` page lists Cashu mints from the community
|
- **Mints directory**: `/mints` page lists Cashu mints from the community
|
||||||
**MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin
|
**MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin
|
||||||
at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* →
|
at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* →
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ import {
|
||||||
type TravelMode,
|
type TravelMode,
|
||||||
type RouteResult,
|
type RouteResult,
|
||||||
} from '@/lib/routing';
|
} from '@/lib/routing';
|
||||||
import { buildDirectionsLinks, openExternal } from '@/lib/directions';
|
|
||||||
|
|
||||||
// ─── Transit presentation helpers ────────────────────────────────────────────
|
// ─── Transit presentation helpers ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -415,27 +414,6 @@ export function RouteDialog({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</DialogContent>
|
||||||
</Dialog>
|
</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 { Layout } from '@/components/Layout';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { RouteDialog } from '@/components/RouteDialog';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import {
|
import {
|
||||||
|
|
@ -48,13 +49,10 @@ const FOOD_TYPE_LABELS: Record<string, string> = {
|
||||||
|
|
||||||
function FoodSpotCard({ spot }: { spot: FoodSpot }) {
|
function FoodSpotCard({ spot }: { spot: FoodSpot }) {
|
||||||
const Icon = FOOD_TYPE_ICONS[spot.type] || Utensils;
|
const Icon = FOOD_TYPE_ICONS[spot.type] || Utensils;
|
||||||
|
const [showRoute, setShowRoute] = useState(false);
|
||||||
|
|
||||||
const handleDirections = () => {
|
// In-app routing only — no external map handoff.
|
||||||
window.open(
|
const handleDirections = () => setShowRoute(true);
|
||||||
`https://www.google.com/maps/dir/?api=1&destination=${spot.lat},${spot.lng}`,
|
|
||||||
'_blank'
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="group hover:shadow-lg hover:shadow-primary/10 transition-all duration-300 border-border/50 hover:border-primary/30">
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
<RouteDialog
|
||||||
|
open={showRoute}
|
||||||
|
onOpenChange={setShowRoute}
|
||||||
|
destination={{ lat: spot.lat, lng: spot.lng }}
|
||||||
|
destinationLabel={spot.name}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { Layout } from '@/components/Layout';
|
||||||
const SpotMap = lazy(() => import('@/components/SpotMap').then(m => ({ default: m.SpotMap })));
|
const SpotMap = lazy(() => import('@/components/SpotMap').then(m => ({ default: m.SpotMap })));
|
||||||
import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard';
|
import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard';
|
||||||
import { WeatherToggle } from '@/components/WeatherToggle';
|
import { WeatherToggle } from '@/components/WeatherToggle';
|
||||||
|
import { RouteDialog } from '@/components/RouteDialog';
|
||||||
import { TrustFilter } from '@/components/TrustFilter';
|
import { TrustFilter } from '@/components/TrustFilter';
|
||||||
import { filterByTrustLevel, type TrustFilterLevel } from '@/lib/trustFilter';
|
import { filterByTrustLevel, type TrustFilterLevel } from '@/lib/trustFilter';
|
||||||
import { SortDropdown, type SortOption } from '@/components/SortDropdown';
|
import { SortDropdown, type SortOption } from '@/components/SortDropdown';
|
||||||
|
|
@ -345,52 +346,15 @@ function StashDrawer() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect iOS/macOS for smarter maps ordering
|
// In-app directions — opens the same RouteDialog used on the detail pages.
|
||||||
function isApplePlatform(): boolean {
|
// Deliberately NO external map handoff: routing stays inside SkateSpots.
|
||||||
if (typeof navigator === 'undefined') return false;
|
function DirectionsButton({ lat, lon, label }: { lat: string; lon: string; label: string }) {
|
||||||
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 }) {
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const onApple = isApplePlatform();
|
const destination = useMemo(() => {
|
||||||
|
const la = parseFloat(lat);
|
||||||
const allOptions = [
|
const lo = parseFloat(lon);
|
||||||
{
|
return Number.isFinite(la) && Number.isFinite(lo) ? { lat: la, lng: lo } : null;
|
||||||
name: 'Apple Maps',
|
}, [lat, lon]);
|
||||||
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);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -398,39 +362,17 @@ function DirectionsMenu({ lat, lon, label }: { lat: string; lon: string; label:
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-12 w-full font-bold text-sm border-white/20 hover:bg-white/10 rounded-xl"
|
className="h-12 w-full font-bold text-sm border-white/20 hover:bg-white/10 rounded-xl"
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
|
disabled={!destination}
|
||||||
>
|
>
|
||||||
<Navigation className="h-4 w-4 mr-2 text-[#ccff00]" />
|
<Navigation className="h-4 w-4 mr-2 text-[#ccff00]" />
|
||||||
Directions
|
Directions
|
||||||
</Button>
|
</Button>
|
||||||
|
<RouteDialog
|
||||||
{open && (
|
open={open}
|
||||||
<>
|
onOpenChange={setOpen}
|
||||||
{/* Backdrop */}
|
destination={destination}
|
||||||
<div className="fixed inset-0 z-[2000]" onClick={() => setOpen(false)} />
|
destinationLabel={label || 'Spot'}
|
||||||
{/* 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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -536,10 +478,10 @@ function SpotBottomSheet({ spot, onClose }: { spot: SkateSpot; onClose: () => vo
|
||||||
const { latitude, longitude } = ngeohash.decode(spot.geohash);
|
const { latitude, longitude } = ngeohash.decode(spot.geohash);
|
||||||
const lat = latitude.toFixed(6);
|
const lat = latitude.toFixed(6);
|
||||||
const lon = longitude.toFixed(6);
|
const lon = longitude.toFixed(6);
|
||||||
const label = encodeURIComponent(spot.title);
|
const label = spot.title;
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<DirectionsMenu lat={lat} lon={lon} label={label} />
|
<DirectionsButton lat={lat} lon={lon} label={label} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue