Add in-app routing (foot/bike/moto/car) via OSRM — no external maps handoff
- New src/lib/routing.ts: keyless OSRM client, travel modes, distance/skate-time formatters - New RouteDialog: self-contained Leaflet map drawing the route from the user's location to a spot/shop/event, with a mode toggle and distance/time summary - Wire the "Directions" buttons on Spot/Shop/Event detail pages to route in-app instead of opening Google/Apple Maps Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
f4bca7b736
commit
35915557bb
5 changed files with 505 additions and 17 deletions
311
src/components/RouteDialog.tsx
Normal file
311
src/components/RouteDialog.tsx
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import L from 'leaflet';
|
||||
import { Loader2, MapPin, Navigation, AlertTriangle, LocateFixed } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getCurrentPosition, GeolocationError } from '@/lib/geolocation';
|
||||
import {
|
||||
fetchRoute,
|
||||
formatDistance,
|
||||
formatDuration,
|
||||
TRAVEL_MODES,
|
||||
type TravelMode,
|
||||
type RouteResult,
|
||||
} from '@/lib/routing';
|
||||
|
||||
interface RouteDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Destination coordinates. */
|
||||
destination: { lat: number; lng: number } | null;
|
||||
/** Destination label shown in the header. */
|
||||
destinationLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-app routing dialog.
|
||||
*
|
||||
* Shows a self-contained Leaflet map with a route drawn from the user's current
|
||||
* location to a destination, with a foot/bike/moto/car mode toggle. No external
|
||||
* maps app is opened — the route comes from the keyless public OSRM server and
|
||||
* is drawn here. See src/lib/routing.ts for the honest scope notes.
|
||||
*/
|
||||
export function RouteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
destination,
|
||||
destinationLabel = 'Destination',
|
||||
}: RouteDialogProps) {
|
||||
const mapDivRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<L.Map | null>(null);
|
||||
const routeLayerRef = useRef<L.Polyline | null>(null);
|
||||
const originMarkerRef = useRef<L.Marker | null>(null);
|
||||
const destMarkerRef = useRef<L.Marker | null>(null);
|
||||
|
||||
const [origin, setOrigin] = useState<{ lat: number; lng: number } | null>(null);
|
||||
const [mode, setMode] = useState<TravelMode>('foot');
|
||||
const [route, setRoute] = useState<RouteResult | null>(null);
|
||||
const [locating, setLocating] = useState(false);
|
||||
const [routing, setRouting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [locError, setLocError] = useState<string | null>(null);
|
||||
|
||||
// ─── Get the user's location when the dialog opens ─────────────────────────
|
||||
const locate = useCallback(async () => {
|
||||
setLocating(true);
|
||||
setLocError(null);
|
||||
try {
|
||||
const pos = await getCurrentPosition();
|
||||
setOrigin(pos);
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err instanceof GeolocationError
|
||||
? err.kind === 'denied'
|
||||
? 'Location permission denied. Enable location to route from where you are.'
|
||||
: 'Could not get your location. Try again.'
|
||||
: 'Could not get your location.';
|
||||
setLocError(msg);
|
||||
} finally {
|
||||
setLocating(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !origin && !locating && !locError) {
|
||||
locate();
|
||||
}
|
||||
}, [open, origin, locating, locError, locate]);
|
||||
|
||||
// Reset transient state when closed
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setRoute(null);
|
||||
setError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// ─── Initialize the Leaflet map once the dialog content is mounted ─────────
|
||||
useEffect(() => {
|
||||
if (!open || !mapDivRef.current || mapRef.current || !destination) return;
|
||||
|
||||
const map = L.map(mapDivRef.current, {
|
||||
center: [destination.lat, destination.lng],
|
||||
zoom: 14,
|
||||
scrollWheelZoom: true,
|
||||
attributionControl: false,
|
||||
minZoom: 2,
|
||||
});
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '',
|
||||
subdomains: 'abc',
|
||||
maxZoom: 19,
|
||||
className: 'skatespots-dark-tiles',
|
||||
}).addTo(map);
|
||||
|
||||
// Destination marker
|
||||
const destIcon = L.divIcon({
|
||||
className: 'route-dest-icon',
|
||||
html: `<div style="width:32px;height:32px;background:linear-gradient(135deg,#ff0040,#ff6600);border-radius:50% 50% 50% 0;transform:rotate(-45deg);box-shadow:0 4px 12px rgba(255,0,64,0.5);border:3px solid rgba(255,255,255,0.9);"></div>`,
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 30],
|
||||
});
|
||||
destMarkerRef.current = L.marker([destination.lat, destination.lng], {
|
||||
icon: destIcon,
|
||||
}).addTo(map);
|
||||
|
||||
mapRef.current = map;
|
||||
|
||||
setTimeout(() => map.invalidateSize(), 120);
|
||||
|
||||
return () => {
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
routeLayerRef.current = null;
|
||||
originMarkerRef.current = null;
|
||||
destMarkerRef.current = null;
|
||||
};
|
||||
}, [open, destination]);
|
||||
|
||||
// ─── Draw / update the origin marker when we have the user's location ──────
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !origin) return;
|
||||
|
||||
if (originMarkerRef.current) {
|
||||
originMarkerRef.current.setLatLng([origin.lat, origin.lng]);
|
||||
} else {
|
||||
const userIcon = L.divIcon({
|
||||
className: 'route-origin-icon',
|
||||
html: `<div style="width:18px;height:18px;background:#00b3ff;border-radius:50%;box-shadow:0 0 0 6px rgba(0,179,255,0.25),0 2px 8px rgba(0,0,0,0.4);border:3px solid #fff;"></div>`,
|
||||
iconSize: [18, 18],
|
||||
iconAnchor: [9, 9],
|
||||
});
|
||||
originMarkerRef.current = L.marker([origin.lat, origin.lng], {
|
||||
icon: userIcon,
|
||||
}).addTo(map);
|
||||
}
|
||||
}, [origin]);
|
||||
|
||||
// ─── Fetch + draw the route whenever origin / mode changes ─────────────────
|
||||
useEffect(() => {
|
||||
if (!open || !origin || !destination) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
setRouting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await fetchRoute(origin, destination, mode, controller.signal);
|
||||
if (cancelled) return;
|
||||
setRoute(result);
|
||||
|
||||
const map = mapRef.current;
|
||||
if (map) {
|
||||
if (routeLayerRef.current) {
|
||||
routeLayerRef.current.remove();
|
||||
routeLayerRef.current = null;
|
||||
}
|
||||
const line = L.polyline(result.coordinates, {
|
||||
color: '#ccff00',
|
||||
weight: 5,
|
||||
opacity: 0.9,
|
||||
lineJoin: 'round',
|
||||
lineCap: 'round',
|
||||
}).addTo(map);
|
||||
routeLayerRef.current = line;
|
||||
map.fitBounds(line.getBounds(), { padding: [48, 48], maxZoom: 16 });
|
||||
}
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||
setError(
|
||||
err instanceof Error && err.message === 'No route found'
|
||||
? 'No route found for this mode. Try another mode.'
|
||||
: "Couldn't reach the routing service. Check your connection and try again.",
|
||||
);
|
||||
} finally {
|
||||
if (!cancelled) setRouting(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [open, origin, destination, mode]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg p-0 gap-0 overflow-hidden">
|
||||
<DialogHeader className="p-4 pb-3">
|
||||
<DialogTitle className="flex items-center gap-2 text-lg">
|
||||
<Navigation className="h-5 w-5 text-[#ccff00]" />
|
||||
Route to {destinationLabel}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
Routed in-app · nothing leaves to Google or Apple Maps
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Mode toggle */}
|
||||
<div className="px-4 pb-3 flex gap-1.5">
|
||||
{TRAVEL_MODES.map((m) => (
|
||||
<button
|
||||
key={m.mode}
|
||||
type="button"
|
||||
onClick={() => setMode(m.mode)}
|
||||
className={cn(
|
||||
'flex-1 flex flex-col items-center gap-0.5 rounded-xl py-2 text-xs font-bold uppercase tracking-wide transition-all border-2',
|
||||
mode === m.mode
|
||||
? 'bg-[#ccff00]/15 border-[#ccff00] text-[#ccff00]'
|
||||
: 'border-transparent bg-muted/50 text-muted-foreground hover:bg-muted',
|
||||
)}
|
||||
aria-pressed={mode === m.mode}
|
||||
>
|
||||
<span className="text-lg leading-none">{m.emoji}</span>
|
||||
<span className="text-[10px]">{m.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Map */}
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={mapDivRef}
|
||||
className="h-64 w-full bg-muted"
|
||||
aria-label="Route map"
|
||||
/>
|
||||
|
||||
{/* Overlays */}
|
||||
{(locating || (routing && !route)) && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/60 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-[#ccff00]" />
|
||||
{locating ? 'Finding your location…' : 'Plotting your route…'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{locError && !origin && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 p-6">
|
||||
<div className="text-center space-y-3 max-w-xs">
|
||||
<MapPin className="h-8 w-8 mx-auto text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">{locError}</p>
|
||||
<Button size="sm" variant="outline" onClick={locate} className="gap-2">
|
||||
<LocateFixed className="h-4 w-4" />
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Summary bar */}
|
||||
<div className="p-4 border-t bg-muted/30 min-h-[64px] flex items-center">
|
||||
{error ? (
|
||||
<div className="flex items-center gap-2 text-sm text-amber-500">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
) : route ? (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-2xl leading-none">
|
||||
{TRAVEL_MODES.find((m) => m.mode === mode)?.emoji}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-black tabular-nums">
|
||||
{formatDistance(route.distanceM / 1000)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatDuration(route.durationS)}
|
||||
{TRAVEL_MODES.find((m) => m.mode === mode)?.approximate && (
|
||||
<span className="ml-1">· uses driving roads</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{routing && (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{origin ? 'Pick a mode to see your route.' : 'Waiting for your location…'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
156
src/lib/routing.ts
Normal file
156
src/lib/routing.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
/**
|
||||
* In-app routing — OSRM client + skate-friendly distance/time formatting.
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* We deliberately do NOT hand off to Google/Apple Maps. Instead we fetch a route
|
||||
* from the keyless, account-free public OSRM server and draw it on our own
|
||||
* Leaflet map. This keeps navigation in-app, private, and dependency-light.
|
||||
*
|
||||
* SCOPE (on purpose):
|
||||
* • We return a static route line + distance + duration summary.
|
||||
* • We do NOT do live turn-by-turn, voice, or rerouting — that's a huge native
|
||||
* effort and out of scope for a lean PWA.
|
||||
*
|
||||
* MODES:
|
||||
* foot / bike / car are real OSRM profiles. "moto" (motorbike) isn't a real
|
||||
* OSRM profile, so we route it as `car` (driving roads) and label it honestly.
|
||||
*
|
||||
* TRANSIT (subway/public transport) is intentionally NOT here: there is no free,
|
||||
* keyless, worldwide transit routing API. It needs per-city GTFS data and would
|
||||
* be empty in most cities — a worse experience than omitting it.
|
||||
*/
|
||||
|
||||
/** Travel modes offered in the UI. */
|
||||
export type TravelMode = 'foot' | 'bike' | 'moto' | 'car';
|
||||
|
||||
export interface TravelModeMeta {
|
||||
mode: TravelMode;
|
||||
/** OSRM routing profile actually used. */
|
||||
profile: 'foot' | 'bike' | 'car';
|
||||
label: string;
|
||||
emoji: string;
|
||||
/** True when we're being honest that the profile is an approximation. */
|
||||
approximate?: boolean;
|
||||
}
|
||||
|
||||
export const TRAVEL_MODES: TravelModeMeta[] = [
|
||||
{ mode: 'foot', profile: 'foot', label: 'Skate / Walk', emoji: '🛹' },
|
||||
{ mode: 'bike', profile: 'bike', label: 'Bike', emoji: '🚲' },
|
||||
{ mode: 'moto', profile: 'car', label: 'Moto', emoji: '🏍️', approximate: true },
|
||||
{ mode: 'car', profile: 'car', label: 'Car', emoji: '🚗' },
|
||||
];
|
||||
|
||||
export function travelModeMeta(mode: TravelMode): TravelModeMeta {
|
||||
return TRAVEL_MODES.find((m) => m.mode === mode) ?? TRAVEL_MODES[0];
|
||||
}
|
||||
|
||||
export interface RouteResult {
|
||||
/** Decoded [lat, lng] path suitable for a Leaflet polyline. */
|
||||
coordinates: Array<[number, number]>;
|
||||
/** Straight-line-ish route distance in meters. */
|
||||
distanceM: number;
|
||||
/** Estimated travel time in seconds (from OSRM for the profile). */
|
||||
durationS: number;
|
||||
/** Which mode the route was requested for. */
|
||||
mode: TravelMode;
|
||||
}
|
||||
|
||||
const OSRM_BASE = 'https://router.project-osrm.org/route/v1';
|
||||
const CORS_PROXY = 'https://proxy.shakespeare.diy/?url=';
|
||||
|
||||
interface OsrmResponse {
|
||||
code: string;
|
||||
routes?: Array<{
|
||||
distance: number;
|
||||
duration: number;
|
||||
geometry: { coordinates: Array<[number, number]> }; // [lng, lat]
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a route between two points for a given travel mode using OSRM.
|
||||
* Throws on network error or when OSRM returns no route.
|
||||
*/
|
||||
export async function fetchRoute(
|
||||
from: { lat: number; lng: number },
|
||||
to: { lat: number; lng: number },
|
||||
mode: TravelMode,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RouteResult> {
|
||||
const { profile } = travelModeMeta(mode);
|
||||
// OSRM wants lon,lat;lon,lat
|
||||
const coords = `${from.lng},${from.lat};${to.lng},${to.lat}`;
|
||||
const target = `${OSRM_BASE}/${profile}/${coords}?overview=full&geometries=geojson`;
|
||||
const url = `${CORS_PROXY}${encodeURIComponent(target)}`;
|
||||
|
||||
const res = await fetch(url, { signal });
|
||||
if (!res.ok) {
|
||||
throw new Error(`Routing service returned ${res.status}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as OsrmResponse;
|
||||
if (data.code !== 'Ok' || !data.routes || data.routes.length === 0) {
|
||||
throw new Error('No route found');
|
||||
}
|
||||
|
||||
const route = data.routes[0];
|
||||
// GeoJSON is [lng, lat] → Leaflet wants [lat, lng]
|
||||
const coordinates = route.geometry.coordinates.map(
|
||||
([lng, lat]) => [lat, lng] as [number, number],
|
||||
);
|
||||
|
||||
return {
|
||||
coordinates,
|
||||
distanceM: route.distance,
|
||||
durationS: route.duration,
|
||||
mode,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Formatting helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Format a distance in kilometers, human-friendly.
|
||||
* e.g. 0.34 → "340 m", 3.4 → "3.4 km", 12 → "12 km"
|
||||
*/
|
||||
export function formatDistance(km: number): string {
|
||||
if (!isFinite(km) || km < 0) return '—';
|
||||
if (km < 1) return `${Math.round(km * 1000)} m`;
|
||||
if (km < 10) return `${km.toFixed(1)} km`;
|
||||
return `${Math.round(km)} km`;
|
||||
}
|
||||
|
||||
/** Format a duration in seconds → "12 min", "1 h 5 min". */
|
||||
export function formatDuration(seconds: number): string {
|
||||
if (!isFinite(seconds) || seconds < 0) return '—';
|
||||
const mins = Math.round(seconds / 60);
|
||||
if (mins < 1) return '<1 min';
|
||||
if (mins < 60) return `${mins} min`;
|
||||
const h = Math.floor(mins / 60);
|
||||
const m = mins % 60;
|
||||
return m > 0 ? `${h} h ${m} min` : `${h} h`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skaters think in time, not distance. Estimate a cruising time from a
|
||||
* straight-line distance (km) at a relaxed ~10 km/h push, and phrase it in a
|
||||
* skate-native way. No routing call needed — pure formula, works offline.
|
||||
*/
|
||||
export function skateTime(km: number): string {
|
||||
if (!isFinite(km) || km < 0) return '';
|
||||
const CRUISE_KMH = 10; // relaxed cruising pace
|
||||
const mins = Math.round((km / CRUISE_KMH) * 60);
|
||||
if (mins < 1) return 'right here';
|
||||
if (mins < 60) return `~${mins} min cruise`;
|
||||
const h = (mins / 60).toFixed(1);
|
||||
return `~${h} h cruise`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact one-line readout for cards/popups given a distance in km.
|
||||
* e.g. "1.2 km · ~7 min cruise"
|
||||
*/
|
||||
export function distanceReadout(km: number): string {
|
||||
const d = formatDistance(km);
|
||||
const t = skateTime(km);
|
||||
return t ? `${d} · ${t}` : d;
|
||||
}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import ngeohash from 'ngeohash';
|
||||
import { Layout } from '@/components/Layout';
|
||||
import { SpotMap } from '@/components/SpotMap';
|
||||
import { RouteDialog } from '@/components/RouteDialog';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
|
|
@ -92,6 +93,8 @@ export default function EventDetailPage() {
|
|||
const metadata = author.data?.metadata;
|
||||
const displayName = metadata?.name ?? genUserName(event?.pubkey || '');
|
||||
|
||||
const [showRouteDialog, setShowRouteDialog] = useState(false);
|
||||
|
||||
const coordinates = useMemo(() => {
|
||||
if (!event?.geohash) return null;
|
||||
const { latitude, longitude } = ngeohash.decode(event.geohash);
|
||||
|
|
@ -145,10 +148,8 @@ export default function EventDetailPage() {
|
|||
|
||||
const handleDirections = () => {
|
||||
if (coordinates) {
|
||||
window.open(
|
||||
`https://www.google.com/maps/dir/?api=1&destination=${coordinates.lat},${coordinates.lng}`,
|
||||
'_blank'
|
||||
);
|
||||
// In-app routing — nothing leaves to Google/Apple Maps.
|
||||
setShowRouteDialog(true);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -420,6 +421,14 @@ export default function EventDetailPage() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* In-app route (no external maps) */}
|
||||
<RouteDialog
|
||||
open={showRouteDialog}
|
||||
onOpenChange={setShowRouteDialog}
|
||||
destination={coordinates}
|
||||
destinationLabel={event?.title ?? 'Event'}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
|||
import ngeohash from 'ngeohash';
|
||||
import { Layout } from '@/components/Layout';
|
||||
import { SpotMap } from '@/components/SpotMap';
|
||||
import { RouteDialog } from '@/components/RouteDialog';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
|
|
@ -85,6 +86,7 @@ export default function ShopDetailPage() {
|
|||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [showRouteDialog, setShowRouteDialog] = useState(false);
|
||||
|
||||
const isOwner = user && shop && user.pubkey === shop.pubkey;
|
||||
const isAdmin = isAdminPubkey(user?.pubkey);
|
||||
|
|
@ -139,10 +141,8 @@ export default function ShopDetailPage() {
|
|||
|
||||
const handleDirections = () => {
|
||||
if (coordinates) {
|
||||
window.open(
|
||||
`https://www.google.com/maps/dir/?api=1&destination=${coordinates.lat},${coordinates.lng}`,
|
||||
'_blank'
|
||||
);
|
||||
// In-app routing — nothing leaves to Google/Apple Maps.
|
||||
setShowRouteDialog(true);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -507,6 +507,14 @@ export default function ShopDetailPage() {
|
|||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* In-app route (no external maps) */}
|
||||
<RouteDialog
|
||||
open={showRouteDialog}
|
||||
onOpenChange={setShowRouteDialog}
|
||||
destination={coordinates}
|
||||
destinationLabel={shop?.name ?? 'Shop'}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { ZapButton } from '@/components/ZapButton';
|
|||
import { CommentsSection } from '@/components/comments/CommentsSection';
|
||||
import { SpotChat } from '@/components/SpotChat';
|
||||
import { CrewsForSpotCard } from '@/components/CrewsForSpotCard';
|
||||
import { RouteDialog } from '@/components/RouteDialog';
|
||||
import { useSkateSpot } from '@/hooks/useSkateSpots';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useDeleteEntity } from '@/hooks/useDeleteEntity';
|
||||
|
|
@ -131,6 +132,7 @@ export default function SpotDetailPage() {
|
|||
const [showCollectionDialog, setShowCollectionDialog] = useState(false);
|
||||
const [showReportDialog, setShowReportDialog] = useState(false);
|
||||
const [showStatusDialog, setShowStatusDialog] = useState(false);
|
||||
const [showRouteDialog, setShowRouteDialog] = useState(false);
|
||||
|
||||
const isOwner = user && spot && user.pubkey === spot.pubkey;
|
||||
const isAdmin = isAdminPubkey(user?.pubkey);
|
||||
|
|
@ -184,14 +186,8 @@ export default function SpotDetailPage() {
|
|||
|
||||
const handleDirections = () => {
|
||||
if (!coordinates) return;
|
||||
const { lat, lng } = coordinates;
|
||||
const label = encodeURIComponent(spot?.title ?? 'Skate Spot');
|
||||
// Use Apple Maps on iOS/macOS, Google Maps everywhere else
|
||||
const isApple = /iPhone|iPad|iPod|Macintosh/.test(navigator.userAgent) && !/(Windows|Android)/.test(navigator.userAgent);
|
||||
const url = isApple
|
||||
? `https://maps.apple.com/?ll=${lat},${lng}&q=${label}&dirflg=d`
|
||||
: `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`;
|
||||
window.open(url, '_blank');
|
||||
// In-app routing — draws the route on our own map, nothing leaves to Google/Apple.
|
||||
setShowRouteDialog(true);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
|
|
@ -1026,6 +1022,14 @@ export default function SpotDetailPage() {
|
|||
onOpenChange={setShowStatusDialog}
|
||||
preselectedSpot={spot}
|
||||
/>
|
||||
|
||||
{/* In-app route (no external maps) */}
|
||||
<RouteDialog
|
||||
open={showRouteDialog}
|
||||
onOpenChange={setShowRouteDialog}
|
||||
destination={coordinates}
|
||||
destinationLabel={spot?.title ?? 'Spot'}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue