SkateSpots/src/components/DestinationSearch.tsx
SkateSpots e357833557 i18n(es): batch 3 part 3 — shared label maps + leftover routing/Sasquatch strings
- labelsUi.* (221+ keys): spot categories/features/types, access, shelter, event
  types, gear categories/conditions, price/listings, tours, crews,
  member-visibility, tag categories, spot status. New src/lib/labelKeys.ts holds
  the shared value->key maps (one source; the five duplicated EVENT_TYPE_KEYS
  copies now import it). spotStatus.statusTimeAgo() uses the locale date shim.
- formatPrice() now takes an optional t so FREE/Trade/Make Offer translate.
- Route helpers: skateTime/distanceReadout/maneuver descriptors/formatAge
  translated (routeUi.*), consumers updated.
- Sasquatch: PRO_TIERS labels/badges + summarizeDraft titles/lines/ctas
  translated (sasquatchUi.*).
- Lab *_LABELS/_DESCRIPTIONS maps remain English (documented out of scope).
2026-10-02 15:31:27 +07:00

176 lines
5.9 KiB
TypeScript

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<Kind, { label: string; icon: typeof MapPin; className: string }> = {
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<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((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 (
<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={t('placesUi.destination.placeholder')}
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={t('placesUi.destination.closeSearch')}
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">
{near ? t('placesUi.destination.typeCharsNear') : t('placesUi.destination.typeChars')}
</p>
) : results.length === 0 ? (
<p className="p-4 text-center text-xs text-gray-500">
{t('placesUi.destination.nothingMatches', { query: 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)}>
{t(meta.label)}
</span>
</button>
);
})}
</div>
)}
</div>
</div>
);
}