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:
SkateSpots 2026-09-29 18:10:40 +07:00
parent 51e66727d9
commit 3628cd5e85
3 changed files with 263 additions and 20 deletions

View 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>
);
}

View file

@ -1,6 +1,6 @@
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
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 {
Dialog,
DialogContent,
@ -9,6 +9,7 @@ import {
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { DestinationSearch } from '@/components/DestinationSearch';
import { cn } from '@/lib/utils';
import {
getCurrentPosition,
@ -85,6 +86,9 @@ interface RouteDialogProps {
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;
}
/**
@ -95,7 +99,6 @@ interface RouteDialogProps {
* 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.
*/
/** 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". */
function speedLabel(distanceM: number, durationS: number): string | undefined {
if (!(distanceM > 0) || !(durationS > 0)) return undefined;
@ -117,7 +120,32 @@ export function RouteDialog({
onOpenChange,
destination,
destinationLabel = 'Destination',
onDestinationChange,
}: 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
// 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 —
@ -372,8 +400,8 @@ export function RouteDialog({
/** Live guidance: distance left, and which step you're on. */
const nav = useMemo(() => {
if (!live || !destination) return null;
const toDestKm = calculateDistance(live.lat, live.lng, destination.lat, destination.lng);
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) => {
@ -385,7 +413,7 @@ export function RouteDialog({
}
});
return { toDestKm, stepIndex: bestI, stepKm: bestKm };
}, [live, destination, route]);
}, [live, dest, route]);
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 ─────────
useEffect(() => {
if (!open || !mapDiv || mapRef.current || !destination) return;
const initial = destRef.current;
if (!open || !mapDiv || mapRef.current || !initial) return;
const map = L.map(mapDiv, {
center: [destination.lat, destination.lng],
center: [initial.lat, initial.lng],
zoom: 14,
scrollWheelZoom: true,
attributionControl: false,
@ -503,7 +532,7 @@ export function RouteDialog({
iconSize: [32, 32],
iconAnchor: [16, 30],
});
destMarkerRef.current = L.marker([destination.lat, destination.lng], {
destMarkerRef.current = L.marker([initial.lat, initial.lng], {
icon: destIcon,
}).addTo(map);
@ -535,7 +564,15 @@ export function RouteDialog({
originMarkerRef.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) ──
useEffect(() => {
@ -559,7 +596,7 @@ export function RouteDialog({
// ─── Fetch + draw the route whenever origin / mode changes ─────────────────
useEffect(() => {
if (!open || !effectiveOrigin || !destination) return;
if (!open || !effectiveOrigin || !dest) return;
const controller = new AbortController();
let cancelled = false;
@ -569,12 +606,12 @@ export function RouteDialog({
setError(null);
try {
const result = mode === 'transit'
? await fetchTransitRoute(effectiveOrigin, destination, controller.signal)
: await fetchRoute(effectiveOrigin, destination, mode, controller.signal);
? await fetchTransitRoute(effectiveOrigin, dest, controller.signal)
: await fetchRoute(effectiveOrigin, dest, mode, controller.signal);
if (cancelled) return;
setCachedAge(null);
setRoute(result);
if (mode !== 'transit') saveRoute(destination, mode, result);
if (mode !== 'transit') saveRoute(dest, mode, result);
const map = mapRef.current;
if (map) {
@ -635,7 +672,7 @@ export function RouteDialog({
// 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(destination, mode);
const hit = loadRoute(dest, mode);
if (hit) {
setRoute(hit.route);
setCachedAge(hit.ageMs);
@ -666,7 +703,7 @@ export function RouteDialog({
cancelled = true;
controller.abort();
};
}, [open, effectiveOrigin, destination, mode]);
}, [open, effectiveOrigin, dest, mode]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@ -676,11 +713,24 @@ export function RouteDialog({
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 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">
<DialogTitle className="sr-only">Route to {destLabel}</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">
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'
? 'Public transport via Transitous (open timetables + OpenStreetMap)'
: 'In-app route · OpenStreetMap routing — nothing leaves to Google or Apple Maps'}
@ -698,6 +748,23 @@ export function RouteDialog({
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 */}
{/* Blocking overlay only when we have nothing to show yet. With a
cached origin we keep the map visible and refine quietly. */}

View file

@ -2082,6 +2082,7 @@ export default function MapPage() {
onOpenChange={(open) => { if (!open) setRouteTarget(null); }}
destination={routeTarget ? { lat: routeTarget.lat, lng: routeTarget.lng } : null}
destinationLabel={routeTarget?.label ?? 'Spot'}
onDestinationChange={(pick) => setRouteTarget({ lat: pick.lat, lng: pick.lng, label: pick.label })}
/>
</>
);