- Tour sponsor URL/logo sanitized before href/src (SponsorSlot) — same XSS guard EventSponsors already uses. - Official SkateSpots badge only shows when the NIP-05 actually verifies (NIP19Page), not on a self-asserted handle. - Community stats reads are null-guarded on persisted-cache fields (monthly/cumulativeSpots/topMappers) — no more crash on an old cache. - Overpass response no longer assumes .elements (food layer). - user-listings query key is memoized (was Date.now() → refetch storm). - Pro grant requires the l=pro label (was accepted with none). - Verify card RSVP maps accepted/tentative (was going/interested → showed 'No RSVP' on a valid ticket). - Expired listings can't be bought via a stale/shared URL. - Batch spot photo/status hooks include the relay suffix and use querySkateContent (relay-policy parity with useSpotReports). - Offline queue requeues rows stuck in 'publishing' after a crash. - querySkateContent/queryCanonical dedupe replaceable events by author+kind (not id) so a stale profile/relay list can't win. - Route offline cache key includes the origin (was destination+mode only). - Internal links use <Link> (MarketplacePage); EventCountdown reacts to language changes.
1468 lines
62 KiB
TypeScript
1468 lines
62 KiB
TypeScript
import { dateLocaleCode } from '@/lib/dateFnsI18n';
|
||
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||
import L from 'leaflet';
|
||
import { Loader2, MapPin, Navigation, AlertTriangle, LocateFixed, Play, Square, Crosshair, Volume2, VolumeX, Check, Search } from 'lucide-react';
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogDescription,
|
||
} from '@/components/ui/dialog';
|
||
import { Button } from '@/components/ui/button';
|
||
import { DestinationSearch } from '@/components/DestinationSearch';
|
||
import { cn } from '@/lib/utils';
|
||
import {
|
||
getCurrentPosition,
|
||
watchPosition,
|
||
GeolocationError,
|
||
type GeoCoords,
|
||
type GeoWatch,
|
||
} from '@/lib/geolocation';
|
||
import { getLastLocation } from '@/lib/lastLocation';
|
||
import { saveRoute, loadRoute, formatAge } from '@/lib/routeCache';
|
||
import { createCachedTileLayer } from '@/lib/cachedTileLayer';
|
||
import { calculateDistance } from '@/lib/sortOptions';
|
||
import {
|
||
fetchRoute,
|
||
fetchTransitRoute,
|
||
formatClock,
|
||
formatDistance,
|
||
formatDuration,
|
||
TRAVEL_MODES,
|
||
TRANSIT_ATTRIBUTION,
|
||
ROUTE_ERR_BUSY,
|
||
ROUTE_ERR_NO_ROUTE,
|
||
skateTime,
|
||
type TravelMode,
|
||
type RouteResult,
|
||
type RouteStep,
|
||
type TransitPlanOptions,
|
||
} from '@/lib/routing';
|
||
import {
|
||
TRANSIT_MODE_GROUPS,
|
||
legDurationS,
|
||
transitLegColor,
|
||
transitModeEmoji,
|
||
transitPrimaryEmoji,
|
||
} from '@/lib/transitUi';
|
||
import { StopDepartures } from '@/components/StopDepartures';
|
||
import { useTranslation } from '@/contexts/language';
|
||
|
||
/** Travel-mode labels live in `lib/routing`; map them to translation keys at render. */
|
||
const MODE_LABEL_KEYS: Record<TravelMode, string> = {
|
||
foot: 'routeUi.mode.foot',
|
||
bike: 'routeUi.mode.bike',
|
||
moto: 'routeUi.mode.moto',
|
||
car: 'routeUi.mode.car',
|
||
transit: 'routeUi.mode.transit',
|
||
};
|
||
|
||
/** Transit mode-group labels live in `lib/transitUi`; translate at render. */
|
||
const TRANSIT_GROUP_LABEL_KEYS: Record<string, string> = {
|
||
bus: 'routeUi.group.bus',
|
||
tram: 'routeUi.group.tram',
|
||
metro: 'routeUi.group.metro',
|
||
train: 'routeUi.group.train',
|
||
ferry: 'routeUi.group.ferry',
|
||
};
|
||
|
||
/** Minimal translation-function shape. */
|
||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||
|
||
/** Maneuver tokens live in `lib/routing`; map them to literal translation keys. */
|
||
const MANEUVER_LABEL_KEYS: Record<string, string> = {
|
||
headOut: 'routeUi.maneuver.headOut',
|
||
arrive: 'routeUi.maneuver.arrive',
|
||
continueStraight: 'routeUi.maneuver.continueStraight',
|
||
bearLeft: 'routeUi.maneuver.bearLeft',
|
||
bearRight: 'routeUi.maneuver.bearRight',
|
||
turnLeft: 'routeUi.maneuver.turnLeft',
|
||
turnRight: 'routeUi.maneuver.turnRight',
|
||
sharpLeft: 'routeUi.maneuver.sharpLeft',
|
||
sharpRight: 'routeUi.maneuver.sharpRight',
|
||
turn: 'routeUi.maneuver.turn',
|
||
keepLeft: 'routeUi.maneuver.keepLeft',
|
||
keepRight: 'routeUi.maneuver.keepRight',
|
||
takeFork: 'routeUi.maneuver.takeFork',
|
||
merge: 'routeUi.maneuver.merge',
|
||
roundabout: 'routeUi.maneuver.roundabout',
|
||
takeRamp: 'routeUi.maneuver.takeRamp',
|
||
takeExit: 'routeUi.maneuver.takeExit',
|
||
};
|
||
|
||
/**
|
||
* Translate a route step. New steps carry a maneuver token + optional road
|
||
* name (feed data, appended but never translated); legacy cached steps only
|
||
* have the English `instruction`.
|
||
*/
|
||
function stepInstruction(step: RouteStep, t: Translate): string {
|
||
if (!step.label) return step.instruction;
|
||
const key = MANEUVER_LABEL_KEYS[step.label];
|
||
if (!key) return step.instruction;
|
||
const base = t(key);
|
||
return step.road ? t('routeUi.maneuverOnto', { instruction: base, road: step.road }) : base;
|
||
}
|
||
|
||
/** A stop name that opens the departure board, when the feed gave a stop id. */
|
||
function StopButton({
|
||
id,
|
||
name,
|
||
onOpen,
|
||
}: {
|
||
id?: string;
|
||
name?: string;
|
||
onOpen: (id: string, name: string) => void;
|
||
}) {
|
||
if (!id || !name) return <>{name ?? ''}</>;
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => onOpen(id, name)}
|
||
className="underline decoration-dotted underline-offset-2 transition-colors hover:text-foreground"
|
||
>
|
||
{name}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
interface RouteDialogProps {
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
/** Destination coordinates. */
|
||
destination: { lat: number; lng: number } | null;
|
||
/** Destination label shown in the header. */
|
||
destinationLabel?: string;
|
||
/** Called when the in-dialog search changes the destination, so the parent
|
||
* can keep its own target in sync (avoids a stale re-seed on reopen). */
|
||
onDestinationChange?: (pick: { lat: number; lng: number; label: string }) => void;
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
/** The average speed the routing engine assumed for this mode, e.g. "21". */
|
||
function speedValue(distanceM: number, durationS: number): string | undefined {
|
||
if (!(distanceM > 0) || !(durationS > 0)) return undefined;
|
||
const kmh = distanceM / 1000 / (durationS / 3600);
|
||
if (!Number.isFinite(kmh) || kmh <= 0) return undefined;
|
||
return kmh < 10 ? kmh.toFixed(1) : String(Math.round(kmh));
|
||
}
|
||
|
||
/** Local clock time you'd arrive if you set off now. */
|
||
function etaClock(durationS: number): string {
|
||
return new Date(Date.now() + durationS * 1000).toLocaleTimeString(dateLocaleCode(), {
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
});
|
||
}
|
||
|
||
export function RouteDialog({
|
||
open,
|
||
onOpenChange,
|
||
destination,
|
||
destinationLabel,
|
||
onDestinationChange,
|
||
}: RouteDialogProps) {
|
||
const { t } = useTranslation();
|
||
// The destination is owned HERE so the in-dialog search can change it without
|
||
// the parent re-opening the dialog. Seeded from the prop whenever the dialog
|
||
// OPENS (or the parent's target actually changes) — not on every parent
|
||
// render, which would revert a search the user just made.
|
||
const [dest, setDest] = useState(destination);
|
||
const [destLabel, setDestLabel] = useState(destinationLabel ?? '');
|
||
const [searchOpen, setSearchOpen] = useState(false);
|
||
const lastPropKeyRef = useRef<string>('');
|
||
// The map's initial centre; read by the init effect without making it a dep
|
||
// (deps on the destination would tear down + rebuild the whole Leaflet map).
|
||
const destRef = useRef(destination);
|
||
destRef.current = destination;
|
||
|
||
useEffect(() => {
|
||
if (!open) {
|
||
lastPropKeyRef.current = '';
|
||
return;
|
||
}
|
||
const key = destination ? `${destination.lat},${destination.lng}` : '';
|
||
if (key === lastPropKeyRef.current) return;
|
||
lastPropKeyRef.current = key;
|
||
setDest(destination);
|
||
setDestLabel(destinationLabel ?? '');
|
||
}, [open, destination, destinationLabel]);
|
||
// The map container is inside Radix Dialog content, which mounts AFTER this
|
||
// component's first effect pass. A plain useRef is therefore still null when
|
||
// the init effect runs, and — because the effect's deps never changed again —
|
||
// the map was NEVER created (empty dialog, no route line). Holding the node in
|
||
// state makes the effect re-run the moment it actually mounts.
|
||
const [mapDiv, setMapDiv] = useState<HTMLDivElement | null>(null);
|
||
const mapRef = useRef<L.Map | null>(null);
|
||
const routeLayersRef = useRef<L.LayerGroup | 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);
|
||
// Remember the last mode used — a cyclist or driver shouldn't be reset to
|
||
// "Skate / Walk" every time they open Directions.
|
||
const [mode, setMode] = useState<TravelMode>(() => {
|
||
try {
|
||
const saved = localStorage.getItem('skatespots:route-mode') as TravelMode | null;
|
||
return saved && TRAVEL_MODES.some((m) => m.mode === saved) ? saved : 'foot';
|
||
} catch {
|
||
return 'foot';
|
||
}
|
||
});
|
||
const chooseMode = useCallback((next: TravelMode) => {
|
||
setMode(next);
|
||
try {
|
||
localStorage.setItem('skatespots:route-mode', next);
|
||
} catch {
|
||
// ignore (private mode / quota)
|
||
}
|
||
}, []);
|
||
/** Which step the user tapped (highlights + pans the map). */
|
||
const [activeStep, setActiveStep] = useState<number | null>(null);
|
||
|
||
// ── Start point: my location, or a point the user picks on the map ─────────
|
||
const [startMode, setStartMode] = useState<'me' | 'picked'>('me');
|
||
const [picked, setPicked] = useState<GeoCoords | null>(null);
|
||
const [pickingStart, setPickingStart] = useState(false);
|
||
|
||
// ── Follow mode (explicit; owns a GPS watcher) ─────────────────────────────
|
||
const [following, setFollowing] = useState(false);
|
||
const [live, setLive] = useState<GeoCoords | null>(null);
|
||
const [followError, setFollowError] = useState<string | null>(null);
|
||
const watchRef = useRef<GeoWatch | null>(null);
|
||
|
||
// ── Voice guidance, wake lock, arrival (phone navigation) ──────────────────
|
||
const [voiceOn, setVoiceOn] = useState(() => {
|
||
try {
|
||
return localStorage.getItem('skatespots:route-voice') !== 'off';
|
||
} catch {
|
||
return true;
|
||
}
|
||
});
|
||
const [arrived, setArrived] = useState(false);
|
||
/** stepIndex → set of announced distance bands (150 m / 40 m). */
|
||
const announcedRef = useRef<Map<number, Set<number>>>(new Map());
|
||
const arrivedRef = useRef(false);
|
||
const wakeLockRef = useRef<{ release: () => Promise<void> } | null>(null);
|
||
|
||
/** Route shown while offline came from the cache (with its age). */
|
||
const [cachedAge, setCachedAge] = useState<number | null>(null);
|
||
|
||
/** Where the route starts: a picked point wins, else the device location. */
|
||
const effectiveOrigin = useMemo<GeoCoords | null>(
|
||
() => (startMode === 'picked' && picked ? picked : origin),
|
||
[startMode, picked, origin],
|
||
);
|
||
|
||
/** Where to draw the "you are here" dot — live position while following. */
|
||
const markerPos = useMemo<GeoCoords | null>(
|
||
() => (following && live ? live : effectiveOrigin),
|
||
[following, live, effectiveOrigin],
|
||
);
|
||
|
||
// Keep the map-click handler current without rebuilding the map.
|
||
const pickingStartRef = useRef(false);
|
||
pickingStartRef.current = pickingStart;
|
||
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);
|
||
|
||
// Which transit alternative is selected (index into route.transitOptions).
|
||
// Reset to 0 whenever a new transit route is fetched.
|
||
const [transitIndex, setTransitIndex] = useState(0);
|
||
const selectedTransit = useMemo(() => {
|
||
if (mode !== 'transit') return null;
|
||
const opts = route?.transitOptions;
|
||
if (opts && opts.length > 0) return opts[Math.min(transitIndex, opts.length - 1)] ?? null;
|
||
return route?.transit ?? null;
|
||
}, [mode, route, transitIndex]);
|
||
|
||
// All alternatives (falling back to a single option for older results).
|
||
const options = route?.transitOptions ?? (route?.transit ? [route.transit] : []);
|
||
|
||
// Transit plan controls: leave-now vs depart-at/arrive-by, and a mode filter.
|
||
const [transitTimeMode, setTransitTimeMode] = useState<'now' | 'depart' | 'arrive'>('now');
|
||
const [transitTimeValue, setTransitTimeValue] = useState('');
|
||
const [transitModeFilter, setTransitModeFilter] = useState<string[]>([]);
|
||
const [stopBoard, setStopBoard] = useState<{ id: string; name: string } | null>(null);
|
||
|
||
const selectedTransitModes = useMemo(
|
||
() =>
|
||
TRANSIT_MODE_GROUPS.filter((g) => transitModeFilter.includes(g.key)).flatMap((g) => [...g.modes]),
|
||
[transitModeFilter],
|
||
);
|
||
|
||
const toggleModeGroup = (key: string) =>
|
||
setTransitModeFilter((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]));
|
||
|
||
/** Re-fit the map to the whole route (after tapping a step pans it away). */
|
||
const fitRoute = useCallback(() => {
|
||
const map = mapRef.current;
|
||
if (!map) return;
|
||
const coords = selectedTransit
|
||
? selectedTransit.legs.flatMap((l) => l.coordinates)
|
||
: route?.coordinates;
|
||
if (!coords || coords.length < 2) return;
|
||
map.fitBounds(L.latLngBounds(coords), { padding: [48, 48], maxZoom: 16 });
|
||
}, [route, selectedTransit]);
|
||
|
||
// A new route (mode/origin change) invalidates the tapped-step focus and the
|
||
// once-only voice/haptic announcements.
|
||
useEffect(() => {
|
||
setActiveStep(null);
|
||
announcedRef.current.clear();
|
||
arrivedRef.current = false;
|
||
setArrived(false);
|
||
}, [route]);
|
||
|
||
// ── Voice guidance (device TTS — free, offline, no server, no permission) ───
|
||
const speak = useCallback(
|
||
(text: string) => {
|
||
if (!voiceOn) return;
|
||
if (typeof window === 'undefined' || !('speechSynthesis' in window)) return;
|
||
try {
|
||
const synth = window.speechSynthesis;
|
||
const utter = new SpeechSynthesisUtterance(text);
|
||
utter.rate = 1;
|
||
const lang = (navigator.language || 'en').slice(0, 2).toLowerCase();
|
||
const voices = synth.getVoices();
|
||
// Prefer an on-device voice in the user's language (offline, no network).
|
||
const voice =
|
||
voices.find((v) => v.localService && v.lang.toLowerCase().startsWith(lang)) ??
|
||
voices.find((v) => v.lang.toLowerCase().startsWith(lang));
|
||
if (voice) {
|
||
utter.voice = voice;
|
||
utter.lang = voice.lang;
|
||
} else {
|
||
utter.lang = lang;
|
||
}
|
||
synth.cancel(); // never let prompts queue up and lag behind you
|
||
synth.speak(utter);
|
||
} catch {
|
||
// No TTS — haptics still guide.
|
||
}
|
||
},
|
||
[voiceOn],
|
||
);
|
||
|
||
const toggleVoice = useCallback(() => {
|
||
setVoiceOn((on) => {
|
||
const next = !on;
|
||
try {
|
||
localStorage.setItem('skatespots:route-voice', next ? 'on' : 'off');
|
||
} catch {
|
||
// ignore
|
||
}
|
||
if (!next && typeof window !== 'undefined' && 'speechSynthesis' in window) {
|
||
window.speechSynthesis.cancel();
|
||
}
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
/** Short haptic pulse (Android). Silent no-op where unsupported. */
|
||
const buzz = useCallback((pattern: number | number[]) => {
|
||
try {
|
||
if (typeof navigator.vibrate === 'function') navigator.vibrate(pattern);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}, []);
|
||
|
||
// ── Wake lock: keep the screen on while navigating ──────────────────────────
|
||
const acquireWakeLock = useCallback(async () => {
|
||
const navWithWakeLock = navigator as Navigator & {
|
||
wakeLock?: { request: (type: 'screen') => Promise<{ release: () => Promise<void> }> };
|
||
};
|
||
try {
|
||
const sentinel = await navWithWakeLock.wakeLock?.request('screen');
|
||
if (sentinel) wakeLockRef.current = sentinel;
|
||
} catch {
|
||
// Unsupported / denied — navigation still works, the screen may sleep.
|
||
}
|
||
}, []);
|
||
|
||
const releaseWakeLock = useCallback(() => {
|
||
try {
|
||
void wakeLockRef.current?.release();
|
||
} catch {
|
||
// ignore
|
||
}
|
||
wakeLockRef.current = null;
|
||
}, []);
|
||
|
||
// ── Follow mode ────────────────────────────────────────────────────────────
|
||
// Explicit only: the user taps Start, we watch their position, we keep the
|
||
// screen awake, and we stop the watcher the moment they stop or the dialog
|
||
// closes. Voice + haptics guide; voice is the point — you can't watch a
|
||
// screen while skating.
|
||
const stopFollowing = useCallback(() => {
|
||
watchRef.current?.stop();
|
||
watchRef.current = null;
|
||
setFollowing(false);
|
||
releaseWakeLock();
|
||
}, [releaseWakeLock]);
|
||
|
||
const startFollowing = useCallback(() => {
|
||
if (watchRef.current) return;
|
||
setFollowError(null);
|
||
setArrived(false);
|
||
arrivedRef.current = false;
|
||
announcedRef.current.clear();
|
||
setFollowing(true);
|
||
void acquireWakeLock();
|
||
// Spoken from the Start tap (a user gesture) — this also unlocks TTS.
|
||
speak(t('routeUi.voiceStart'));
|
||
watchRef.current = watchPosition(
|
||
(coords) => {
|
||
setLive(coords);
|
||
setFollowError(null);
|
||
},
|
||
(err) => {
|
||
// Only a real permission denial stops following. A timeout / temporary
|
||
// loss is normal (tunnels, indoors) and the watcher recovers by itself,
|
||
// so we keep running and just say we're waiting.
|
||
if (err.kind === 'denied') {
|
||
setFollowError(t('routeUi.errPermissionDenied'));
|
||
stopFollowing();
|
||
} else {
|
||
setFollowError(t('routeUi.errWaitingGps'));
|
||
}
|
||
},
|
||
);
|
||
}, [stopFollowing, acquireWakeLock, speak, t]);
|
||
|
||
// Never leave the GPS running: stop on close and on unmount.
|
||
useEffect(() => {
|
||
if (!open && watchRef.current) stopFollowing();
|
||
}, [open, stopFollowing]);
|
||
useEffect(
|
||
() => () => {
|
||
watchRef.current?.stop();
|
||
watchRef.current = null;
|
||
releaseWakeLock();
|
||
},
|
||
[releaseWakeLock],
|
||
);
|
||
|
||
// Browsers drop the wake lock whenever the page is hidden. Re-acquire it when
|
||
// the user comes back to a navigation that is still running.
|
||
useEffect(() => {
|
||
if (!following) return;
|
||
const onVisibility = () => {
|
||
if (document.visibilityState === 'visible') void acquireWakeLock();
|
||
};
|
||
document.addEventListener('visibilitychange', onVisibility);
|
||
return () => document.removeEventListener('visibilitychange', onVisibility);
|
||
}, [following, acquireWakeLock]);
|
||
|
||
// While following, keep the live dot centred — but only once you've actually
|
||
// moved, and with a gentle animation, so GPS jitter doesn't twitch the map.
|
||
const lastPanRef = useRef<{ lat: number; lng: number } | null>(null);
|
||
useEffect(() => {
|
||
if (!following || !live) return;
|
||
const map = mapRef.current;
|
||
if (!map) return;
|
||
const last = lastPanRef.current;
|
||
if (last && calculateDistance(last.lat, last.lng, live.lat, live.lng) * 1000 < 6) return;
|
||
lastPanRef.current = { lat: live.lat, lng: live.lng };
|
||
map.panTo([live.lat, live.lng], { animate: true, duration: 0.6, easeLinearity: 0.5 });
|
||
}, [following, live]);
|
||
|
||
/** Live guidance: distance left, and which step you're on. */
|
||
const nav = useMemo(() => {
|
||
if (!live || !dest) return null;
|
||
const toDestKm = calculateDistance(live.lat, live.lng, dest.lat, dest.lng);
|
||
let bestI = -1;
|
||
let bestKm = Infinity;
|
||
(route?.steps ?? []).forEach((s, i) => {
|
||
if (!s.lat && !s.lng) return;
|
||
const km = calculateDistance(live.lat, live.lng, s.lat, s.lng);
|
||
if (km < bestKm) {
|
||
bestKm = km;
|
||
bestI = i;
|
||
}
|
||
});
|
||
return { toDestKm, stepIndex: bestI, stepKm: bestKm };
|
||
}, [live, dest, route]);
|
||
|
||
const currentStepIndex = following && nav && nav.stepIndex >= 0 ? nav.stepIndex : activeStep;
|
||
|
||
// ── Voice + haptics + arrival while following ───────────────────────────────
|
||
useEffect(() => {
|
||
if (!following || !nav || !route) return;
|
||
|
||
// Arrival: within ~30 m of the destination.
|
||
if (nav.toDestKm < 0.03) {
|
||
if (!arrivedRef.current) {
|
||
arrivedRef.current = true;
|
||
speak(t('routeUi.voiceArrived'));
|
||
buzz([120, 80, 120]);
|
||
setArrived(true);
|
||
stopFollowing();
|
||
}
|
||
return;
|
||
}
|
||
|
||
const idx = nav.stepIndex;
|
||
const km = nav.stepKm;
|
||
if (idx < 0 || !Number.isFinite(km)) return;
|
||
|
||
const steps = route.steps ?? [];
|
||
const bands = announcedRef.current.get(idx) ?? new Set<number>();
|
||
|
||
if (km <= 0.15 && km > 0.04 && !bands.has(150)) {
|
||
// ~150 m out — a heads-up so you know what's coming.
|
||
bands.add(150);
|
||
announcedRef.current.set(idx, bands);
|
||
const metres = Math.max(50, Math.round((km * 1000) / 10) * 10);
|
||
speak(t('routeUi.voiceIn', {
|
||
metres,
|
||
instruction: steps[idx] ? stepInstruction(steps[idx], t) : t('routeUi.voiceContinue'),
|
||
}));
|
||
buzz(60);
|
||
} else if (km <= 0.04 && !bands.has(40)) {
|
||
// ~40 m out — the turn itself.
|
||
bands.add(40);
|
||
announcedRef.current.set(idx, bands);
|
||
speak(steps[idx] ? stepInstruction(steps[idx], t) : t('routeUi.voiceContinueCaps'));
|
||
buzz(140);
|
||
}
|
||
}, [following, nav, route, speak, buzz, stopFollowing, t]);
|
||
|
||
// ─── 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'
|
||
? t('routeUi.errPermissionDeniedLong')
|
||
: t('routeUi.errNoLocation')
|
||
: t('routeUi.errNoLocationShort');
|
||
setLocError(msg);
|
||
} finally {
|
||
setLocating(false);
|
||
}
|
||
}, [t]);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
// Seed from the last known fix so a route can be drawn immediately, then
|
||
// refine with a live reading. This is what makes opening Directions feel
|
||
// instant instead of hanging on "Finding your location…".
|
||
const cached = getLastLocation();
|
||
const fresh = !!cached && Date.now() - cached.at < 2 * 60 * 1000;
|
||
if (!origin && cached) setOrigin({ lat: cached.lat, lng: cached.lng });
|
||
// Only ask for a live GPS fix when the cached one is missing or stale. A
|
||
// recent fix means the route can draw instantly with no "Finding your
|
||
// location…" wait (and no needless location request on every open).
|
||
if (!fresh) locate();
|
||
// Re-run only when the dialog opens (locate is stable).
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [open]);
|
||
|
||
// Reset transient state when closed
|
||
useEffect(() => {
|
||
if (!open) {
|
||
setRoute(null);
|
||
setError(null);
|
||
setPickingStart(false);
|
||
setCachedAge(null);
|
||
}
|
||
}, [open]);
|
||
|
||
// ─── Initialize the Leaflet map once the dialog content is mounted ─────────
|
||
useEffect(() => {
|
||
const initial = destRef.current;
|
||
if (!open || !mapDiv || mapRef.current || !initial) return;
|
||
|
||
const map = L.map(mapDiv, {
|
||
center: [initial.lat, initial.lng],
|
||
zoom: 14,
|
||
scrollWheelZoom: true,
|
||
attributionControl: false,
|
||
minZoom: 2,
|
||
});
|
||
|
||
createCachedTileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
attribution: '',
|
||
subdomains: 'abc',
|
||
maxZoom: 19,
|
||
className: 'skatespots-dark-tiles',
|
||
}).addTo(map);
|
||
|
||
// Metric scale bar — genuinely useful on a route map (how long is that line?).
|
||
L.control.scale({ imperial: false, position: 'topleft' }).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([initial.lat, initial.lng], {
|
||
icon: destIcon,
|
||
}).addTo(map);
|
||
|
||
mapRef.current = map;
|
||
|
||
// "Choose on map" — the next tap sets the start point.
|
||
const onMapClick = (e: L.LeafletMouseEvent) => {
|
||
if (!pickingStartRef.current) return;
|
||
setPicked({ lat: e.latlng.lat, lng: e.latlng.lng });
|
||
setStartMode('picked');
|
||
setPickingStart(false);
|
||
};
|
||
map.on('click', onMapClick);
|
||
|
||
// The dialog animates in (a brief scale), so the container is still
|
||
// settling at first paint. Nudge Leaflet's size once now and again after the
|
||
// animation ends — otherwise the map renders at the wrong size for a frame
|
||
// and visibly jumps (the "flash" people see when they tap Directions).
|
||
const t1 = setTimeout(() => map.invalidateSize(), 120);
|
||
const t2 = setTimeout(() => map.invalidateSize(), 340);
|
||
|
||
return () => {
|
||
clearTimeout(t1);
|
||
clearTimeout(t2);
|
||
map.off('click', onMapClick);
|
||
map.remove();
|
||
mapRef.current = null;
|
||
routeLayersRef.current = null;
|
||
originMarkerRef.current = null;
|
||
destMarkerRef.current = null;
|
||
};
|
||
}, [open, mapDiv]);
|
||
|
||
// Move the destination marker when the in-dialog search changes the target.
|
||
// The map itself is NOT rebuilt — the route effect below re-fits the view.
|
||
useEffect(() => {
|
||
if (dest && destMarkerRef.current) {
|
||
destMarkerRef.current.setLatLng([dest.lat, dest.lng]);
|
||
}
|
||
}, [dest]);
|
||
|
||
// ─── Draw / update the "from" marker (device location, picked point, live) ──
|
||
useEffect(() => {
|
||
const map = mapRef.current;
|
||
if (!map || !markerPos) return;
|
||
|
||
if (originMarkerRef.current) {
|
||
originMarkerRef.current.setLatLng([markerPos.lat, markerPos.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([markerPos.lat, markerPos.lng], {
|
||
icon: userIcon,
|
||
}).addTo(map);
|
||
}
|
||
}, [markerPos]);
|
||
|
||
// ─── Fetch + draw the route whenever origin / mode changes ─────────────────
|
||
useEffect(() => {
|
||
if (!open || !effectiveOrigin || !dest) return;
|
||
|
||
const controller = new AbortController();
|
||
let cancelled = false;
|
||
|
||
(async () => {
|
||
setRouting(true);
|
||
setError(null);
|
||
try {
|
||
const planOpts: TransitPlanOptions = {};
|
||
if (mode === 'transit') {
|
||
if (transitTimeMode !== 'now' && transitTimeValue) {
|
||
const when = new Date(transitTimeValue);
|
||
if (!Number.isNaN(when.getTime())) {
|
||
planOpts.time = when.toISOString();
|
||
planOpts.arriveBy = transitTimeMode === 'arrive';
|
||
}
|
||
}
|
||
if (selectedTransitModes.length > 0) planOpts.transitModes = selectedTransitModes;
|
||
}
|
||
const result = mode === 'transit'
|
||
? await fetchTransitRoute(effectiveOrigin, dest, controller.signal, planOpts)
|
||
: await fetchRoute(effectiveOrigin, dest, mode, controller.signal);
|
||
if (cancelled) return;
|
||
setCachedAge(null);
|
||
setRoute(result);
|
||
setTransitIndex(0);
|
||
if (mode !== 'transit') saveRoute(effectiveOrigin, dest, mode, result);
|
||
|
||
const map = mapRef.current;
|
||
if (map) {
|
||
if (routeLayersRef.current) {
|
||
routeLayersRef.current.remove();
|
||
routeLayersRef.current = null;
|
||
}
|
||
// Transit legs are drawn by the effect just below, so switching
|
||
// between alternatives doesn't re-query the transit service.
|
||
if (!result.transit) {
|
||
const group = L.layerGroup().addTo(map);
|
||
L.polyline(result.coordinates, {
|
||
color: '#ccff00',
|
||
weight: 5,
|
||
opacity: 0.9,
|
||
lineJoin: 'round',
|
||
lineCap: 'round',
|
||
}).addTo(group);
|
||
routeLayersRef.current = group;
|
||
if (result.coordinates.length > 0) {
|
||
map.fitBounds(L.latLngBounds(result.coordinates), { padding: [48, 48], maxZoom: 16, animate: false });
|
||
}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
if (cancelled) return;
|
||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||
const msg = err instanceof Error ? err.message : '';
|
||
if (mode === 'transit') {
|
||
setError(
|
||
msg === 'No transit route found'
|
||
? selectedTransitModes.length > 0 || transitTimeMode !== 'now'
|
||
? t('routeUi.errTransitNoRouteFiltered')
|
||
: t('routeUi.errTransitNoRoute')
|
||
: t('routeUi.errTransitUnreachable'),
|
||
);
|
||
} else if (msg === ROUTE_ERR_BUSY) {
|
||
setError(t('routeUi.errRoutingBusy'));
|
||
} else if (msg === ROUTE_ERR_NO_ROUTE) {
|
||
setError(t('routeUi.errNoRouteMode'));
|
||
} else {
|
||
setError(t('routeUi.errRoutingUnreachable'));
|
||
}
|
||
|
||
// Offline / server down: fall back to the last route we saved for this
|
||
// destination + mode, clearly labelled, instead of a dead dialog.
|
||
if (mode !== 'transit') {
|
||
const hit = loadRoute(effectiveOrigin, dest, mode);
|
||
if (hit) {
|
||
setRoute(hit.route);
|
||
setCachedAge(hit.ageMs);
|
||
setError(null);
|
||
const map = mapRef.current;
|
||
if (map && hit.route.coordinates.length > 1) {
|
||
if (routeLayersRef.current) routeLayersRef.current.remove();
|
||
const group = L.layerGroup().addTo(map);
|
||
L.polyline(hit.route.coordinates, {
|
||
color: '#ccff00',
|
||
weight: 5,
|
||
opacity: 0.7,
|
||
dashArray: '8 8',
|
||
lineJoin: 'round',
|
||
lineCap: 'round',
|
||
}).addTo(group);
|
||
routeLayersRef.current = group;
|
||
map.fitBounds(L.latLngBounds(hit.route.coordinates), { padding: [48, 48], maxZoom: 16, animate: false });
|
||
}
|
||
}
|
||
}
|
||
} finally {
|
||
if (!cancelled) setRouting(false);
|
||
}
|
||
})();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
controller.abort();
|
||
};
|
||
}, [open, effectiveOrigin, dest, mode, transitTimeMode, transitTimeValue, selectedTransitModes, t]);
|
||
|
||
// ─── Draw the selected transit itinerary (redraws when an alternative is
|
||
// chosen — no re-query). Walking legs dashed grey, vehicles solid. ──────
|
||
useEffect(() => {
|
||
const map = mapRef.current;
|
||
if (!map || !selectedTransit) return;
|
||
if (routeLayersRef.current) {
|
||
routeLayersRef.current.remove();
|
||
routeLayersRef.current = null;
|
||
}
|
||
const group = L.layerGroup().addTo(map);
|
||
for (const leg of selectedTransit.legs) {
|
||
if (leg.coordinates.length < 2) continue;
|
||
const isWalk = leg.mode === 'WALK';
|
||
L.polyline(leg.coordinates, {
|
||
color: isWalk ? '#9ca3af' : transitLegColor(leg.mode),
|
||
weight: isWalk ? 3 : 5,
|
||
opacity: isWalk ? 0.7 : 0.9,
|
||
dashArray: isWalk ? '4 6' : undefined,
|
||
lineJoin: 'round',
|
||
lineCap: 'round',
|
||
}).addTo(group);
|
||
}
|
||
routeLayersRef.current = group;
|
||
const coords = selectedTransit.legs.flatMap((l) => l.coordinates);
|
||
if (coords.length > 0) {
|
||
map.fitBounds(L.latLngBounds(coords), { padding: [48, 48], maxZoom: 16, animate: false });
|
||
}
|
||
}, [selectedTransit]);
|
||
|
||
const destLabelText = destLabel || t('routeUi.destination');
|
||
const speed = route ? speedValue(route.distanceM, route.durationS) : undefined;
|
||
|
||
return (
|
||
<>
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
{/* App-like shell. VISUAL ORDER is set with flex `order` utilities, not DOM
|
||
order: the map sits directly under the header (bigger — it's the point
|
||
of the screen), the mode/vehicle row moves BELOW it, and the details
|
||
scroll with the Start/Stop bar pinned at the bottom. */}
|
||
<DialogContent className="flex max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-lg flex-col gap-0 overflow-hidden p-0 sm:max-w-xl">
|
||
<DialogHeader className="order-1 shrink-0 pl-4 pr-12 pt-4 pb-3">
|
||
<DialogTitle className="sr-only">{t('routeUi.routeTo', { label: destLabelText })}</DialogTitle>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSearchOpen(true)}
|
||
className="flex w-full items-center gap-3 rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-left transition-colors hover:border-[#ccff00]/40"
|
||
>
|
||
<Navigation className="h-5 w-5 shrink-0 text-[#ccff00]" />
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block text-[10px] font-bold uppercase tracking-widest text-gray-500">
|
||
{t('routeUi.routeToLabel')}
|
||
</span>
|
||
<span className="block truncate text-base font-bold text-white">{destLabelText}</span>
|
||
</span>
|
||
<span className="flex shrink-0 items-center gap-1.5 rounded-full border border-white/10 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-gray-300">
|
||
<Search className="h-3 w-3" /> {t('routeUi.search')}
|
||
</span>
|
||
</button>
|
||
{/* Copy kept out of the visible chrome so the route screen stays
|
||
clean; the same promise now lives on /privacy → "What leaves your
|
||
device" → Directions. sr-only keeps the dialog accessible. */}
|
||
<DialogDescription className="sr-only">
|
||
{t('routeUi.description')}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
{/* Map. The height lives on the WRAPPER: the global
|
||
`.leaflet-container { height: 100% }` override beats a `h-64` on the
|
||
map element itself, and against a height-less parent that resolved to
|
||
0 — a fully invisible map with no route line. */}
|
||
<div className={cn('order-2 relative shrink-0 bg-[#0b0b0c]', following ? 'h-[54vh] min-h-[320px] sm:h-[60vh]' : 'h-[34vh] min-h-[220px] sm:h-[400px]')}>
|
||
<div
|
||
ref={setMapDiv}
|
||
className="h-full w-full bg-[#0b0b0c]"
|
||
aria-label={t('routeUi.routeMapAria')}
|
||
/>
|
||
|
||
{/* Destination search — overlays the map while open. Mounted only
|
||
then, so opening Directions never pays for these queries. */}
|
||
{searchOpen && (
|
||
<div className="absolute inset-0 z-[1500] bg-background/95 backdrop-blur-md">
|
||
<DestinationSearch
|
||
near={dest ?? effectiveOrigin}
|
||
onSelect={(pick) => {
|
||
setDest({ lat: pick.lat, lng: pick.lng });
|
||
setDestLabel(pick.label);
|
||
onDestinationChange?.(pick);
|
||
setSearchOpen(false);
|
||
}}
|
||
onClose={() => setSearchOpen(false)}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Overlays */}
|
||
{/* Blocking overlay only when we have nothing to show yet. With a
|
||
cached origin we keep the map visible and refine quietly. */}
|
||
{((locating && !origin) || (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 && !origin ? t('routeUi.findingLocation') : t('routeUi.plottingRoute')}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Bottom-left stack: offline badge + "refining location" chip. Kept
|
||
out of the top-left so it never covers Leaflet's zoom control. */}
|
||
{(cachedAge !== null || (locating && origin)) && (
|
||
<div className="pointer-events-none absolute bottom-2 left-2 flex flex-col items-start gap-1">
|
||
{cachedAge !== null && (
|
||
<div className="rounded-full border border-amber-500/40 bg-amber-500/15 px-2 py-1 text-[10px] font-bold uppercase tracking-wide text-amber-300 backdrop-blur-sm">
|
||
{t('routeUi.offlineSavedRoute', { age: formatAge(cachedAge, t) })}
|
||
</div>
|
||
)}
|
||
{locating && origin && (
|
||
<div className="flex items-center gap-1.5 rounded-full bg-background/80 px-2 py-1 text-[10px] font-medium text-muted-foreground backdrop-blur-sm">
|
||
<Loader2 className="h-3 w-3 animate-spin" />
|
||
{t('routeUi.refiningLocation')}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* "Choose on map" hint */}
|
||
{pickingStart && (
|
||
<div className="pointer-events-none absolute inset-x-2 top-2 rounded-xl bg-black/80 px-3 py-2 text-center text-[11px] font-bold text-[#ccff00] backdrop-blur-sm">
|
||
{t('routeUi.tapMapStart')}
|
||
</div>
|
||
)}
|
||
|
||
{/* Follow mode: next turn + distance left */}
|
||
{following && nav && (
|
||
<div className="pointer-events-none absolute inset-x-2 top-2 rounded-xl bg-black/85 px-3 py-2 backdrop-blur-sm">
|
||
<p className="text-[11px] font-black uppercase tracking-widest text-[#ccff00]">
|
||
{nav.stepIndex >= 0 ? t('routeUi.nextStep', { n: nav.stepIndex + 1 }) : t('routeUi.next')}
|
||
</p>
|
||
<p className="truncate text-base font-black text-white">
|
||
{nav.stepIndex >= 0
|
||
? route?.steps?.[nav.stepIndex] ? stepInstruction(route.steps[nav.stepIndex], t) : t('routeUi.followTheLine')
|
||
: route?.steps?.[0] ? stepInstruction(route.steps[0], t) : t('routeUi.followTheLine')}
|
||
</p>
|
||
<p className="text-xs text-gray-400">
|
||
{nav.stepIndex >= 0 && Number.isFinite(nav.stepKm)
|
||
? `${formatDistance(nav.stepKm)} ${t('routeUi.toTheTurn')} · `
|
||
: ''}
|
||
{formatDistance(nav.toDestKm)} {t('routeUi.toGo')}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{followError && (
|
||
<div className="pointer-events-none absolute inset-x-2 top-2 rounded-xl bg-black/85 px-3 py-2 text-center text-[11px] font-bold text-amber-300 backdrop-blur-sm">
|
||
{followError}
|
||
</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" />
|
||
{t('routeUi.tryAgain')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Re-fit the whole route — handy after tapping a step pans the map. */}
|
||
{route && route.coordinates.length > 1 && (
|
||
<button
|
||
type="button"
|
||
onClick={fitRoute}
|
||
className="absolute bottom-2 right-2 flex items-center gap-1 rounded-lg border border-white/10 bg-background/85 px-2 py-1.5 text-[10px] font-bold uppercase tracking-wide text-muted-foreground shadow backdrop-blur-sm transition-colors hover:text-foreground"
|
||
aria-label={t('routeUi.fitRouteAria')}
|
||
>
|
||
<LocateFixed className="h-3.5 w-3.5" />
|
||
{t('routeUi.wholeRoute')}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Mode toggle (vehicles) — sits BELOW the map (order-3). */}
|
||
<div className="order-3 flex shrink-0 gap-1.5 px-4 pb-3">
|
||
{TRAVEL_MODES.map((m) => (
|
||
<button
|
||
key={m.mode}
|
||
type="button"
|
||
onClick={() => chooseMode(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] leading-tight text-center">{t(MODE_LABEL_KEYS[m.mode])}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Start point — default is where you are; you can pick another on the map */}
|
||
<div className="order-4 flex shrink-0 flex-wrap items-center gap-1.5 px-4 pb-3">
|
||
<span className="text-[10px] font-black uppercase tracking-widest text-muted-foreground">
|
||
{t('routeUi.from')}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setStartMode('me');
|
||
setPickingStart(false);
|
||
}}
|
||
className={cn(
|
||
'rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide transition-colors',
|
||
startMode === 'me'
|
||
? 'border-[#ccff00]/60 bg-[#ccff00]/15 text-[#ccff00]'
|
||
: 'border-white/10 text-muted-foreground hover:text-foreground',
|
||
)}
|
||
>
|
||
{t('routeUi.myLocation')}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setPickingStart((v) => !v)}
|
||
className={cn(
|
||
'flex items-center gap-1 rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide transition-colors',
|
||
pickingStart || startMode === 'picked'
|
||
? 'border-[#ccff00]/60 bg-[#ccff00]/15 text-[#ccff00]'
|
||
: 'border-white/10 text-muted-foreground hover:text-foreground',
|
||
)}
|
||
>
|
||
<Crosshair className="h-3 w-3" />
|
||
{pickingStart ? t('routeUi.tapTheMap') : t('routeUi.chooseOnMap')}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Scrollable details */}
|
||
<div className="order-5 min-h-0 flex-1 overflow-y-auto">
|
||
|
||
{/* Summary bar */}
|
||
<div className="flex items-center border-t bg-muted/30 px-4 py-2.5">
|
||
{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>
|
||
) : mode === 'transit' && selectedTransit ? (
|
||
<div className="w-full space-y-2">
|
||
<div className="flex items-center gap-3">
|
||
<div className="text-2xl leading-none">{transitPrimaryEmoji(selectedTransit)}</div>
|
||
<div className="min-w-0">
|
||
<div className="text-lg font-black tabular-nums">
|
||
{formatClock(selectedTransit.departISO)} → {formatClock(selectedTransit.arriveISO)}
|
||
</div>
|
||
<div className="text-xs text-muted-foreground">
|
||
{formatDuration(selectedTransit.durationS)} ·{' '}
|
||
{selectedTransit.transfers === 0
|
||
? t('routeUi.direct')
|
||
: selectedTransit.transfers === 1
|
||
? t('routeUi.transferOne')
|
||
: t('routeUi.transfers', { count: selectedTransit.transfers })}
|
||
{options.length > 1 && ` · ${t('routeUi.options', { count: options.length })}`}
|
||
{routing && <Loader2 className="ml-1 inline h-3 w-3 animate-spin" />}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{selectedTransit.legs.some((l) => l.mode !== 'WALK') && (
|
||
<div className="flex flex-wrap gap-1">
|
||
{selectedTransit.legs
|
||
.filter((l) => l.mode !== 'WALK')
|
||
.map((l, i) => (
|
||
<span
|
||
key={`${l.mode}-${l.line}-${i}`}
|
||
className="inline-flex items-center gap-1 rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] font-bold text-gray-300"
|
||
>
|
||
<span aria-hidden>{transitModeEmoji(l.mode)}</span>
|
||
{l.line ?? l.mode}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
<p className="text-[10px] text-muted-foreground/70">{TRANSIT_ATTRIBUTION}</p>
|
||
</div>
|
||
) : route ? (
|
||
<div className="w-full space-y-1.5">
|
||
{/* Compact summary — a single line instead of three ~90px tiles,
|
||
so the map above keeps the vertical space. */}
|
||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 leading-none">
|
||
<span className="text-base font-black tabular-nums text-white">
|
||
{formatDistance(route.distanceM / 1000)}
|
||
</span>
|
||
<span aria-hidden className="h-3.5 w-px bg-white/15" />
|
||
<span className="flex items-baseline gap-1.5">
|
||
<span className="text-base font-black tabular-nums text-white">
|
||
{formatDuration(route.durationS)}
|
||
</span>
|
||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||
{t('routeUi.ride')}
|
||
</span>
|
||
</span>
|
||
<span aria-hidden className="h-3.5 w-px bg-white/15" />
|
||
<span className="flex items-baseline gap-1.5">
|
||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||
{t('routeUi.arrive')}
|
||
</span>
|
||
<span className="text-base font-black tabular-nums text-white">
|
||
{etaClock(route.durationS)}
|
||
</span>
|
||
</span>
|
||
</div>
|
||
<p className="flex flex-wrap items-center gap-x-1.5 text-[10px] text-muted-foreground">
|
||
<span className="text-sm leading-none">
|
||
{TRAVEL_MODES.find((m) => m.mode === mode)?.emoji}
|
||
</span>
|
||
<span>
|
||
{route.steps && route.steps.length > 1
|
||
? t('routeUi.stepsCount', { count: route.steps.length })
|
||
: t('routeUi.direct')}
|
||
</span>
|
||
{mode === 'foot'
|
||
? <span>· {skateTime(route.distanceM / 1000, t)}</span>
|
||
: speed && (
|
||
<span>· {t('routeUi.speedAvg', { speed })}</span>
|
||
)}
|
||
{TRAVEL_MODES.find((m) => m.mode === mode)?.approximate && (
|
||
<span>· {t('routeUi.usesDrivingRoads')}</span>
|
||
)}
|
||
{route.approximate && <span>· {t('routeUi.drivingEstimate')}</span>}
|
||
{routing && <span>· {t('routeUi.updating')}</span>}
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div className="text-sm text-muted-foreground">
|
||
{origin ? t('routeUi.pickMode') : t('routeUi.waitingLocation')}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Turn-by-turn orientation — the planning list. While following, the map
|
||
and the live NEXT banner take over (so the list steps out of the way). */}
|
||
{!following && mode !== 'transit' && route?.steps && route.steps.length > 1 && (
|
||
<div className="border-t bg-muted/20">
|
||
<div className="flex flex-wrap items-baseline justify-between gap-x-2 px-4 pt-3">
|
||
<p className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-muted-foreground">
|
||
{t('routeUi.routeSteps')}
|
||
<span className="rounded-full bg-white/10 px-1.5 py-0.5 text-[9px] tabular-nums">
|
||
{route.steps.length}
|
||
</span>
|
||
</p>
|
||
{mode === 'foot' && (
|
||
<p className="text-[10px] text-muted-foreground/70">{t('routeUi.walkingRoute')}</p>
|
||
)}
|
||
</div>
|
||
<ol className="max-h-72 space-y-1.5 overflow-y-auto px-4 pb-2 pt-1.5">
|
||
{route.steps.map((s, i) => (
|
||
<li key={`${i}-${s.instruction}`}>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setActiveStep(i);
|
||
const map = mapRef.current;
|
||
if (map && s.lat && s.lng) map.panTo([s.lat, s.lng]);
|
||
}}
|
||
className={cn(
|
||
// `min-h-0` overrides the app-wide 44px mobile touch-target
|
||
// rule for `button`, which otherwise made every step row a
|
||
// giant 44px slab.
|
||
'flex min-h-0 w-full items-start gap-2 rounded-lg px-2 py-1.5 text-left text-xs transition-colors',
|
||
currentStepIndex === i ? 'bg-[#ccff00]/10 text-white' : 'hover:bg-white/5',
|
||
)}
|
||
>
|
||
<span className="mt-px w-4 shrink-0 text-right font-mono text-[10px] text-muted-foreground/60">
|
||
{i + 1}
|
||
</span>
|
||
<span className="min-w-0 flex-1 leading-snug text-gray-300">{stepInstruction(s, t)}</span>
|
||
{s.distanceM >= 10 && (
|
||
<span className="shrink-0 tabular-nums text-muted-foreground/70">
|
||
{formatDistance(s.distanceM / 1000)}
|
||
</span>
|
||
)}
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
<p className="px-4 pb-3 text-[10px] text-muted-foreground/60">
|
||
{t('routeUi.planningView')}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Transit itinerary: alternatives + a real leg-by-leg breakdown — all
|
||
from the same MOTIS response (nothing extra is fetched). */}
|
||
{!following && mode === 'transit' && selectedTransit && (
|
||
<div className="border-t bg-muted/20">
|
||
{/* When: leave now · depart at · arrive by */}
|
||
<div className="flex flex-wrap items-center gap-1.5 px-4 pt-3">
|
||
{(['now', 'depart', 'arrive'] as const).map((m) => (
|
||
<button
|
||
key={m}
|
||
type="button"
|
||
onClick={() => setTransitTimeMode(m)}
|
||
aria-pressed={transitTimeMode === m}
|
||
className={cn(
|
||
'min-h-0 rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide transition-colors',
|
||
transitTimeMode === m
|
||
? 'border-[#ccff00]/60 bg-[#ccff00]/10 text-white'
|
||
: 'border-white/10 text-gray-400 hover:border-white/25',
|
||
)}
|
||
>
|
||
{m === 'now' ? t('routeUi.leaveNow') : m === 'depart' ? t('routeUi.departAt') : t('routeUi.arriveBy')}
|
||
</button>
|
||
))}
|
||
{transitTimeMode !== 'now' && (
|
||
<input
|
||
type="datetime-local"
|
||
value={transitTimeValue}
|
||
onChange={(e) => setTransitTimeValue(e.target.value)}
|
||
aria-label={transitTimeMode === 'depart' ? t('routeUi.departureTime') : t('routeUi.arrivalTime')}
|
||
className="min-h-0 rounded-lg border border-white/15 bg-white/[0.04] px-2 py-1 text-[11px] text-white"
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* Only: filter the modes (nothing selected = all) */}
|
||
<div className="flex flex-wrap items-center gap-1.5 px-4 pt-2">
|
||
<span className="text-[10px] font-black uppercase tracking-widest text-muted-foreground">{t('routeUi.only')}</span>
|
||
{TRANSIT_MODE_GROUPS.map((g) => {
|
||
const on = transitModeFilter.includes(g.key);
|
||
return (
|
||
<button
|
||
key={g.key}
|
||
type="button"
|
||
onClick={() => toggleModeGroup(g.key)}
|
||
aria-pressed={on}
|
||
className={cn(
|
||
'flex min-h-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-bold transition-colors',
|
||
on
|
||
? 'border-[#ccff00]/60 bg-[#ccff00]/10 text-white'
|
||
: 'border-white/10 text-gray-400 hover:border-white/25',
|
||
)}
|
||
>
|
||
<span aria-hidden>{g.emoji}</span>
|
||
{t(TRANSIT_GROUP_LABEL_KEYS[g.key] ?? g.label)}
|
||
</button>
|
||
);
|
||
})}
|
||
{transitModeFilter.length > 0 && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setTransitModeFilter([])}
|
||
className="min-h-0 rounded-full px-2 py-0.5 text-[10px] font-bold text-muted-foreground underline decoration-dotted hover:text-foreground"
|
||
>
|
||
{t('routeUi.clear')}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{options.length > 1 && (
|
||
<div className="flex gap-1.5 overflow-x-auto px-4 pt-3">
|
||
{options.map((opt, i) => {
|
||
const active = i === transitIndex;
|
||
const modes = [...new Set(
|
||
opt.legs
|
||
.filter((l) => l.mode !== 'WALK' && l.mode !== 'BIKE')
|
||
.map((l) => transitModeEmoji(l.mode)),
|
||
)];
|
||
return (
|
||
<button
|
||
key={`${opt.departISO}-${i}`}
|
||
type="button"
|
||
onClick={() => setTransitIndex(i)}
|
||
className={cn(
|
||
// `min-h-0` beats the app-wide 44px mobile touch-target rule.
|
||
'flex min-h-0 shrink-0 flex-col items-start gap-0.5 rounded-xl border px-2.5 py-1.5 text-left transition-colors',
|
||
active
|
||
? 'border-[#ccff00]/60 bg-[#ccff00]/10'
|
||
: 'border-white/10 bg-white/[0.03] hover:border-white/25',
|
||
)}
|
||
>
|
||
<span className="text-xs font-black tabular-nums text-white">
|
||
{formatClock(opt.departISO)}–{formatClock(opt.arriveISO)}
|
||
</span>
|
||
<span className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||
<span aria-hidden>{modes.join('')}</span>
|
||
{formatDuration(opt.durationS)} ·{' '}
|
||
{opt.transfers === 0
|
||
? t('routeUi.direct')
|
||
: opt.transfers === 1
|
||
? t('routeUi.transferOne')
|
||
: t('routeUi.transfers', { count: opt.transfers })}
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
<p className="flex items-center gap-2 px-4 pt-3 text-[10px] font-black uppercase tracking-widest text-muted-foreground">
|
||
{t('routeUi.itinerary')}
|
||
<span className="rounded-full bg-white/10 px-1.5 py-0.5 text-[9px] tabular-nums">
|
||
{selectedTransit.legs.length}
|
||
</span>
|
||
</p>
|
||
<ol className="max-h-72 space-y-1 overflow-y-auto px-4 pb-2 pt-1.5">
|
||
{selectedTransit.legs.map((leg, i) => {
|
||
const isFoot = leg.mode === 'WALK' || leg.mode === 'BIKE';
|
||
const dur = legDurationS(leg);
|
||
const startClock = leg.startISO ? formatClock(leg.startISO) : '—';
|
||
const endClock = leg.endISO ? formatClock(leg.endISO) : '—';
|
||
return (
|
||
<li
|
||
key={`${leg.mode}-${leg.line}-${i}`}
|
||
className="flex min-h-0 items-start gap-2 rounded-lg px-2 py-1.5"
|
||
>
|
||
<span className="mt-px w-5 shrink-0 text-center text-base leading-none" aria-hidden>
|
||
{transitModeEmoji(leg.mode)}
|
||
</span>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
||
{!isFoot && (
|
||
<span
|
||
className="inline-flex items-center rounded-md px-1.5 py-0.5 text-[11px] font-black text-black"
|
||
style={{ background: transitLegColor(leg.mode) }}
|
||
>
|
||
{leg.line ?? leg.mode}
|
||
</span>
|
||
)}
|
||
<span className="text-xs font-bold text-white">
|
||
{isFoot
|
||
? leg.mode === 'BIKE' ? t('routeUi.rideVerb') : t('routeUi.walk')
|
||
: leg.headsign
|
||
? `→ ${leg.headsign}`
|
||
: (leg.line ?? leg.mode)}
|
||
</span>
|
||
{dur > 0 && (
|
||
<span className="text-[10px] text-muted-foreground">{formatDuration(dur)}</span>
|
||
)}
|
||
{leg.realTime && (
|
||
<span className="rounded bg-[#00ff88]/15 px-1 text-[9px] font-black uppercase text-[#00ff88]">
|
||
{t('routeUi.live')}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="text-[10px] text-muted-foreground">
|
||
{isFoot ? (
|
||
<>
|
||
{leg.toName ? (
|
||
<>
|
||
{t('routeUi.toStop')}{' '}
|
||
<StopButton
|
||
id={leg.toStopId}
|
||
name={leg.toName}
|
||
onOpen={(id, name) => setStopBoard({ id, name })}
|
||
/>
|
||
</>
|
||
) : leg.mode === 'BIKE' ? t('routeUi.rideLower') : t('routeUi.walkLower')}
|
||
{leg.distanceM ? ` · ${formatDistance(leg.distanceM / 1000)}` : ''}
|
||
</>
|
||
) : (
|
||
<>
|
||
<StopButton
|
||
id={leg.fromStopId}
|
||
name={leg.fromName}
|
||
onOpen={(id, name) => setStopBoard({ id, name })}
|
||
/>
|
||
{leg.fromTrack ? ` · ${t('routeUi.track', { track: leg.fromTrack })}` : ''} {startClock}
|
||
{' → '}
|
||
<StopButton
|
||
id={leg.toStopId}
|
||
name={leg.toName}
|
||
onOpen={(id, name) => setStopBoard({ id, name })}
|
||
/>
|
||
{leg.toTrack ? ` · ${t('routeUi.track', { track: leg.toTrack })}` : ''} {endClock}
|
||
{typeof leg.stops === 'number' && leg.stops > 0
|
||
? ` · ${leg.stops === 1 ? t('routeUi.stopOne') : t('routeUi.stopsCount', { count: leg.stops })}`
|
||
: ''}
|
||
{leg.agencyName ? ` · ${leg.agencyName}` : ''}
|
||
{leg.agencyFareUrl ? (
|
||
<>
|
||
{' · '}
|
||
<a
|
||
href={leg.agencyFareUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="underline decoration-dotted underline-offset-2 hover:text-foreground"
|
||
>
|
||
{t('routeUi.tickets')}
|
||
</a>
|
||
</>
|
||
) : ''}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</li>
|
||
);
|
||
})}
|
||
</ol>
|
||
</div>
|
||
)}
|
||
|
||
</div>{/* /scrollable details */}
|
||
|
||
{/* Follow mode: live turns + voice, with the screen kept awake. */}
|
||
{route && !route.transit && (
|
||
<div className="order-6 flex items-center gap-3 border-t bg-muted/30 px-4 py-3">
|
||
{arrived ? (
|
||
<Button
|
||
size="sm"
|
||
onClick={() => onOpenChange(false)}
|
||
className="gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||
>
|
||
<Check className="h-3.5 w-3.5" />
|
||
{t('routeUi.done')}
|
||
</Button>
|
||
) : following ? (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={stopFollowing}
|
||
className="gap-2 border-[#ff0040]/50 text-[#ff0040] hover:bg-[#ff0040]/10"
|
||
>
|
||
<Square className="h-3.5 w-3.5" />
|
||
{t('routeUi.stop')}
|
||
</Button>
|
||
) : (
|
||
<Button
|
||
size="sm"
|
||
onClick={startFollowing}
|
||
className="gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||
>
|
||
<Play className="h-3.5 w-3.5" />
|
||
{t('routeUi.start')}
|
||
</Button>
|
||
)}
|
||
<p className="min-w-0 flex-1 text-[11px] leading-snug text-muted-foreground">
|
||
{arrived
|
||
? t('routeUi.arrived')
|
||
: following
|
||
? voiceOn
|
||
? t('routeUi.voiceOnNote')
|
||
: t('routeUi.voiceOffNote')
|
||
: t('routeUi.followNote')}
|
||
</p>
|
||
<button
|
||
type="button"
|
||
onClick={toggleVoice}
|
||
aria-pressed={voiceOn}
|
||
aria-label={voiceOn ? t('routeUi.voiceOffAria') : t('routeUi.voiceOnAria')}
|
||
className={cn(
|
||
'flex h-9 w-9 shrink-0 items-center justify-center rounded-full border transition-colors',
|
||
voiceOn
|
||
? 'border-[#ccff00]/50 bg-[#ccff00]/15 text-[#ccff00]'
|
||
: 'border-white/10 text-muted-foreground hover:text-foreground',
|
||
)}
|
||
>
|
||
{voiceOn ? <Volume2 className="h-4 w-4" /> : <VolumeX className="h-4 w-4" />}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* Departure board — its own dialog, kept OUTSIDE the route dialog so the
|
||
two focus traps don't fight. */}
|
||
<StopDepartures
|
||
open={!!stopBoard}
|
||
onOpenChange={(o) => { if (!o) setStopBoard(null); }}
|
||
stopId={stopBoard?.id ?? null}
|
||
stopName={stopBoard?.name ?? ''}
|
||
/>
|
||
</>
|
||
);
|
||
}
|