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 { spotTypeLabel } from '@/lib/spotTypeLabel'; import { cn } from '@/lib/utils'; import { useTranslation } from '@/contexts/language'; 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: 'placesUi.destination.spot', icon: MapPin, className: 'text-[#ff6600]' }, shop: { label: 'placesUi.destination.shop', icon: Store, className: 'text-emerald-400' }, merchant: { label: 'placesUi.destination.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 { t } = useTranslation(); 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((type) => spotTypeLabel(t, type)).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, t]); 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={t('placesUi.destination.placeholder')} className="h-10 border-white/15 bg-white/5 pl-9 text-sm text-white placeholder:text-gray-600" />
{query.trim().length < 2 ? (

{near ? t('placesUi.destination.typeCharsNear') : t('placesUi.destination.typeChars')}

) : results.length === 0 ? (

{t('placesUi.destination.nothingMatches', { query: query.trim() })}

) : (
{results.map((c) => { const meta = KIND_META[c.kind]; const Icon = meta.icon; return ( ); })}
)}
); }