- 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).
176 lines
5.9 KiB
TypeScript
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>
|
|
);
|
|
}
|