From 3628cd5e859a9eff09461f086ee238f67b020169 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Tue, 29 Sep 2026 18:10:40 +0700 Subject: [PATCH] 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. --- src/components/DestinationSearch.tsx | 175 +++++++++++++++++++++++++++ src/components/RouteDialog.tsx | 107 +++++++++++++--- src/pages/MapPage.tsx | 1 + 3 files changed, 263 insertions(+), 20 deletions(-) create mode 100644 src/components/DestinationSearch.tsx diff --git a/src/components/DestinationSearch.tsx b/src/components/DestinationSearch.tsx new file mode 100644 index 0000000..3accbc4 --- /dev/null +++ b/src/components/DestinationSearch.tsx @@ -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 = { + 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(() => { + 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 ( +
+
+
+ + 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" + /> +
+ +
+ +
+ {query.trim().length < 2 ? ( +

+ Type at least 2 characters. Searches skate spots, skate shops, and Bitcoin + merchants{near ? ' near here' : ''}. +

+ ) : results.length === 0 ? ( +

+ Nothing matches “{query.trim()}”. +

+ ) : ( +
+ {results.map((c) => { + const meta = KIND_META[c.kind]; + const Icon = meta.icon; + return ( + + ); + })} +
+ )} +
+
+ ); +} diff --git a/src/components/RouteDialog.tsx b/src/components/RouteDialog.tsx index 2b88b8d..8430b0c 100644 --- a/src/components/RouteDialog.tsx +++ b/src/components/RouteDialog.tsx @@ -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(''); + // 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 ( @@ -676,11 +713,24 @@ export function RouteDialog({ scroll with the Start/Stop bar pinned at the bottom. */} - - - Route to {destinationLabel} - - + Route to {destLabel} + + {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 && ( +
+ { + setDest({ lat: pick.lat, lng: pick.lng }); + setDestLabel(pick.label); + onDestinationChange?.(pick); + setSearchOpen(false); + }} + onClose={() => setSearchOpen(false)} + /> +
+ )} + {/* Overlays */} {/* Blocking overlay only when we have nothing to show yet. With a cached origin we keep the map visible and refine quietly. */} diff --git a/src/pages/MapPage.tsx b/src/pages/MapPage.tsx index a2d654a..4cb1c16 100644 --- a/src/pages/MapPage.tsx +++ b/src/pages/MapPage.tsx @@ -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 })} /> );