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 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. */}
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue