feat(routing): destination search inside the directions dialog
Tap the destination in the header to search and change where you're going: - New DestinationSearch: searches skate spots, skate shops and BTCMap merchants (merchants come from a radius around the current destination - BTCMap has no global name search - and are labelled as such). Mounted only while the search is open, so opening Directions pays nothing for it; spot/shop limits match the map page so the cache is reused. - RouteDialog now owns the destination internally (seeded from the prop on open, not on every render) so the search can change it without re-opening the dialog, and calls onDestinationChange so the parent stays in sync (wired in MapPage). - The Leaflet map is no longer rebuilt when the destination changes: it's created from a ref once per open, and a small effect just moves the destination marker (the route effect re-fits the view). This avoids a teardown/recreate flash.
This commit is contained in:
parent
51e66727d9
commit
3628cd5e85
3 changed files with 263 additions and 20 deletions
175
src/components/DestinationSearch.tsx
Normal file
175
src/components/DestinationSearch.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import ngeohash from 'ngeohash';
|
||||||
|
import { Bitcoin, MapPin, Search, Store, X } from 'lucide-react';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
||||||
|
import { useShops } from '@/hooks/useShops';
|
||||||
|
import { useBtcMapPlaces } from '@/hooks/useBtcMapPlaces';
|
||||||
|
import { SPOT_TYPE_LABELS } from '@/lib/types';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface DestinationPick {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Kind = 'spot' | 'shop' | 'merchant';
|
||||||
|
|
||||||
|
interface Candidate {
|
||||||
|
key: string;
|
||||||
|
kind: Kind;
|
||||||
|
label: string;
|
||||||
|
sub?: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND_META: Record<Kind, { label: string; icon: typeof MapPin; className: string }> = {
|
||||||
|
spot: { label: 'Spot', icon: MapPin, className: 'text-[#ff6600]' },
|
||||||
|
shop: { label: 'Shop', icon: Store, className: 'text-emerald-400' },
|
||||||
|
merchant: { label: 'Bitcoin', icon: Bitcoin, className: 'text-[#f7931a]' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-app destination search for the route dialog: skate spots, skate shops and
|
||||||
|
* (near the current destination) BTCMap merchants.
|
||||||
|
*
|
||||||
|
* Mounted ONLY while the search is open, so opening Directions never pays for
|
||||||
|
* these queries. BTCMap's API is radius-scoped (there is no global name search),
|
||||||
|
* so merchants come from around `near` and are labelled as such.
|
||||||
|
*/
|
||||||
|
export function DestinationSearch({
|
||||||
|
near,
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
near: { lat: number; lng: number } | null;
|
||||||
|
onSelect: (pick: DestinationPick) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const { data: spots } = useSkateSpots(2000);
|
||||||
|
const { data: shops } = useShops(500);
|
||||||
|
const { data: merchants } = useBtcMapPlaces({
|
||||||
|
lat: near?.lat ?? 0,
|
||||||
|
lon: near?.lng ?? 0,
|
||||||
|
radiusKm: 30,
|
||||||
|
enabled: !!near,
|
||||||
|
});
|
||||||
|
|
||||||
|
const candidates = useMemo<Candidate[]>(() => {
|
||||||
|
const out: Candidate[] = [];
|
||||||
|
|
||||||
|
for (const spot of spots ?? []) {
|
||||||
|
if (!spot.geohash) continue;
|
||||||
|
const { latitude, longitude } = ngeohash.decode(spot.geohash);
|
||||||
|
out.push({
|
||||||
|
key: `spot:${spot.pubkey}:${spot.identifier}`,
|
||||||
|
kind: 'spot',
|
||||||
|
label: spot.title,
|
||||||
|
sub: [spot.location, spot.types.map((t) => SPOT_TYPE_LABELS[t] ?? t).join(', ')]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · '),
|
||||||
|
lat: latitude,
|
||||||
|
lng: longitude,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const shop of shops ?? []) {
|
||||||
|
if (!shop.geohash) continue;
|
||||||
|
const { latitude, longitude } = ngeohash.decode(shop.geohash);
|
||||||
|
out.push({
|
||||||
|
key: `shop:${shop.pubkey}:${shop.identifier}`,
|
||||||
|
kind: 'shop',
|
||||||
|
label: shop.name,
|
||||||
|
sub: shop.location,
|
||||||
|
lat: latitude,
|
||||||
|
lng: longitude,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const place of merchants ?? []) {
|
||||||
|
out.push({
|
||||||
|
key: `btc:${place.id}`,
|
||||||
|
kind: 'merchant',
|
||||||
|
label: place.name,
|
||||||
|
sub: place.address,
|
||||||
|
lat: place.lat,
|
||||||
|
lng: place.lon,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}, [spots, shops, merchants]);
|
||||||
|
|
||||||
|
const results = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (q.length < 2) return [];
|
||||||
|
return candidates
|
||||||
|
.filter((c) => c.label.toLowerCase().includes(q) || (c.sub ?? '').toLowerCase().includes(q))
|
||||||
|
.slice(0, 30);
|
||||||
|
}, [candidates, query]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="flex shrink-0 items-center gap-2 border-b border-white/10 p-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search spots, shops, Bitcoin merchants…"
|
||||||
|
className="h-10 border-white/15 bg-white/5 pl-9 text-sm text-white placeholder:text-gray-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close search"
|
||||||
|
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-white/10 hover:text-white"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||||
|
{query.trim().length < 2 ? (
|
||||||
|
<p className="p-4 text-center text-xs text-gray-500">
|
||||||
|
Type at least 2 characters. Searches skate spots, skate shops, and Bitcoin
|
||||||
|
merchants{near ? ' near here' : ''}.
|
||||||
|
</p>
|
||||||
|
) : results.length === 0 ? (
|
||||||
|
<p className="p-4 text-center text-xs text-gray-500">
|
||||||
|
Nothing matches “{query.trim()}”.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{results.map((c) => {
|
||||||
|
const meta = KIND_META[c.kind];
|
||||||
|
const Icon = meta.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect({ lat: c.lat, lng: c.lng, label: c.label })}
|
||||||
|
className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left transition-colors hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<Icon className={cn('h-4 w-4 shrink-0', meta.className)} />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-sm font-bold text-white">{c.label}</span>
|
||||||
|
{c.sub && <span className="block truncate text-[11px] text-gray-500">{c.sub}</span>}
|
||||||
|
</span>
|
||||||
|
<span className={cn('shrink-0 text-[10px] font-black uppercase tracking-widest', meta.className)}>
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import { Loader2, MapPin, Navigation, AlertTriangle, LocateFixed, Play, Square, Crosshair, Volume2, VolumeX, Check } from 'lucide-react';
|
import { Loader2, MapPin, Navigation, AlertTriangle, LocateFixed, Play, Square, Crosshair, Volume2, VolumeX, Check, Search } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { DestinationSearch } from '@/components/DestinationSearch';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
getCurrentPosition,
|
getCurrentPosition,
|
||||||
|
|
@ -85,6 +86,9 @@ interface RouteDialogProps {
|
||||||
destination: { lat: number; lng: number } | null;
|
destination: { lat: number; lng: number } | null;
|
||||||
/** Destination label shown in the header. */
|
/** Destination label shown in the header. */
|
||||||
destinationLabel?: string;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -95,7 +99,6 @@ interface RouteDialogProps {
|
||||||
* maps app is opened — the route comes from the keyless public OSRM server and
|
* 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.
|
* is drawn here. See src/lib/routing.ts for the honest scope notes.
|
||||||
*/
|
*/
|
||||||
/** Big number + label tile used in the route summary. */
|
|
||||||
/** The average speed the routing engine assumed for this mode, e.g. "21 km/h avg". */
|
/** The average speed the routing engine assumed for this mode, e.g. "21 km/h avg". */
|
||||||
function speedLabel(distanceM: number, durationS: number): string | undefined {
|
function speedLabel(distanceM: number, durationS: number): string | undefined {
|
||||||
if (!(distanceM > 0) || !(durationS > 0)) return undefined;
|
if (!(distanceM > 0) || !(durationS > 0)) return undefined;
|
||||||
|
|
@ -117,7 +120,32 @@ export function RouteDialog({
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
destination,
|
destination,
|
||||||
destinationLabel = 'Destination',
|
destinationLabel = 'Destination',
|
||||||
|
onDestinationChange,
|
||||||
}: RouteDialogProps) {
|
}: RouteDialogProps) {
|
||||||
|
// 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
|
// 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
|
// 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 init effect runs, and — because the effect's deps never changed again —
|
||||||
|
|
@ -372,8 +400,8 @@ export function RouteDialog({
|
||||||
|
|
||||||
/** Live guidance: distance left, and which step you're on. */
|
/** Live guidance: distance left, and which step you're on. */
|
||||||
const nav = useMemo(() => {
|
const nav = useMemo(() => {
|
||||||
if (!live || !destination) return null;
|
if (!live || !dest) return null;
|
||||||
const toDestKm = calculateDistance(live.lat, live.lng, destination.lat, destination.lng);
|
const toDestKm = calculateDistance(live.lat, live.lng, dest.lat, dest.lng);
|
||||||
let bestI = -1;
|
let bestI = -1;
|
||||||
let bestKm = Infinity;
|
let bestKm = Infinity;
|
||||||
(route?.steps ?? []).forEach((s, i) => {
|
(route?.steps ?? []).forEach((s, i) => {
|
||||||
|
|
@ -385,7 +413,7 @@ export function RouteDialog({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return { toDestKm, stepIndex: bestI, stepKm: bestKm };
|
return { toDestKm, stepIndex: bestI, stepKm: bestKm };
|
||||||
}, [live, destination, route]);
|
}, [live, dest, route]);
|
||||||
|
|
||||||
const currentStepIndex = following && nav && nav.stepIndex >= 0 ? nav.stepIndex : activeStep;
|
const currentStepIndex = following && nav && nav.stepIndex >= 0 ? nav.stepIndex : activeStep;
|
||||||
|
|
||||||
|
|
@ -476,10 +504,11 @@ export function RouteDialog({
|
||||||
|
|
||||||
// ─── Initialize the Leaflet map once the dialog content is mounted ─────────
|
// ─── Initialize the Leaflet map once the dialog content is mounted ─────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !mapDiv || mapRef.current || !destination) return;
|
const initial = destRef.current;
|
||||||
|
if (!open || !mapDiv || mapRef.current || !initial) return;
|
||||||
|
|
||||||
const map = L.map(mapDiv, {
|
const map = L.map(mapDiv, {
|
||||||
center: [destination.lat, destination.lng],
|
center: [initial.lat, initial.lng],
|
||||||
zoom: 14,
|
zoom: 14,
|
||||||
scrollWheelZoom: true,
|
scrollWheelZoom: true,
|
||||||
attributionControl: false,
|
attributionControl: false,
|
||||||
|
|
@ -503,7 +532,7 @@ export function RouteDialog({
|
||||||
iconSize: [32, 32],
|
iconSize: [32, 32],
|
||||||
iconAnchor: [16, 30],
|
iconAnchor: [16, 30],
|
||||||
});
|
});
|
||||||
destMarkerRef.current = L.marker([destination.lat, destination.lng], {
|
destMarkerRef.current = L.marker([initial.lat, initial.lng], {
|
||||||
icon: destIcon,
|
icon: destIcon,
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
|
|
||||||
|
|
@ -535,7 +564,15 @@ export function RouteDialog({
|
||||||
originMarkerRef.current = null;
|
originMarkerRef.current = null;
|
||||||
destMarkerRef.current = null;
|
destMarkerRef.current = null;
|
||||||
};
|
};
|
||||||
}, [open, mapDiv, destination]);
|
}, [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) ──
|
// ─── Draw / update the "from" marker (device location, picked point, live) ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -559,7 +596,7 @@ export function RouteDialog({
|
||||||
|
|
||||||
// ─── Fetch + draw the route whenever origin / mode changes ─────────────────
|
// ─── Fetch + draw the route whenever origin / mode changes ─────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !effectiveOrigin || !destination) return;
|
if (!open || !effectiveOrigin || !dest) return;
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
@ -569,12 +606,12 @@ export function RouteDialog({
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const result = mode === 'transit'
|
const result = mode === 'transit'
|
||||||
? await fetchTransitRoute(effectiveOrigin, destination, controller.signal)
|
? await fetchTransitRoute(effectiveOrigin, dest, controller.signal)
|
||||||
: await fetchRoute(effectiveOrigin, destination, mode, controller.signal);
|
: await fetchRoute(effectiveOrigin, dest, mode, controller.signal);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setCachedAge(null);
|
setCachedAge(null);
|
||||||
setRoute(result);
|
setRoute(result);
|
||||||
if (mode !== 'transit') saveRoute(destination, mode, result);
|
if (mode !== 'transit') saveRoute(dest, mode, result);
|
||||||
|
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (map) {
|
if (map) {
|
||||||
|
|
@ -635,7 +672,7 @@ export function RouteDialog({
|
||||||
// Offline / server down: fall back to the last route we saved for this
|
// Offline / server down: fall back to the last route we saved for this
|
||||||
// destination + mode, clearly labelled, instead of a dead dialog.
|
// destination + mode, clearly labelled, instead of a dead dialog.
|
||||||
if (mode !== 'transit') {
|
if (mode !== 'transit') {
|
||||||
const hit = loadRoute(destination, mode);
|
const hit = loadRoute(dest, mode);
|
||||||
if (hit) {
|
if (hit) {
|
||||||
setRoute(hit.route);
|
setRoute(hit.route);
|
||||||
setCachedAge(hit.ageMs);
|
setCachedAge(hit.ageMs);
|
||||||
|
|
@ -666,7 +703,7 @@ export function RouteDialog({
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
controller.abort();
|
controller.abort();
|
||||||
};
|
};
|
||||||
}, [open, effectiveOrigin, destination, mode]);
|
}, [open, effectiveOrigin, dest, mode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
|
@ -676,11 +713,24 @@ export function RouteDialog({
|
||||||
scroll with the Start/Stop bar pinned at the bottom. */}
|
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">
|
<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 p-4 pb-3">
|
<DialogHeader className="order-1 shrink-0 p-4 pb-3">
|
||||||
<DialogTitle className="flex items-center gap-2 text-lg">
|
<DialogTitle className="sr-only">Route to {destLabel}</DialogTitle>
|
||||||
<Navigation className="h-5 w-5 text-[#ccff00]" />
|
<button
|
||||||
Route to {destinationLabel}
|
type="button"
|
||||||
</DialogTitle>
|
onClick={() => setSearchOpen(true)}
|
||||||
<DialogDescription className="text-xs">
|
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">
|
||||||
|
Route to
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-base font-bold text-white">{destLabel}</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" /> Search
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<DialogDescription className="mt-2 text-xs">
|
||||||
{mode === 'transit'
|
{mode === 'transit'
|
||||||
? 'Public transport via Transitous (open timetables + OpenStreetMap)'
|
? 'Public transport via Transitous (open timetables + OpenStreetMap)'
|
||||||
: 'In-app route · OpenStreetMap routing — nothing leaves to Google or Apple Maps'}
|
: 'In-app route · OpenStreetMap routing — nothing leaves to Google or Apple Maps'}
|
||||||
|
|
@ -698,6 +748,23 @@ export function RouteDialog({
|
||||||
aria-label="Route map"
|
aria-label="Route map"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 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 */}
|
{/* Overlays */}
|
||||||
{/* Blocking overlay only when we have nothing to show yet. With a
|
{/* Blocking overlay only when we have nothing to show yet. With a
|
||||||
cached origin we keep the map visible and refine quietly. */}
|
cached origin we keep the map visible and refine quietly. */}
|
||||||
|
|
|
||||||
|
|
@ -2082,6 +2082,7 @@ export default function MapPage() {
|
||||||
onOpenChange={(open) => { if (!open) setRouteTarget(null); }}
|
onOpenChange={(open) => { if (!open) setRouteTarget(null); }}
|
||||||
destination={routeTarget ? { lat: routeTarget.lat, lng: routeTarget.lng } : null}
|
destination={routeTarget ? { lat: routeTarget.lat, lng: routeTarget.lng } : null}
|
||||||
destinationLabel={routeTarget?.label ?? 'Spot'}
|
destinationLabel={routeTarget?.label ?? 'Spot'}
|
||||||
|
onDestinationChange={(pick) => setRouteTarget({ lat: pick.lat, lng: pick.lng, label: pick.label })}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue