feat(map): add autocomplete suggestions to the search bar

Typing in the map search pill now opens a suggestions dropdown built
from the items that already match the active filters:
- Spots: title matches; selecting one flies to it and opens its sheet.
- Places: distinct city/region/country tokens split out of each item's
  location; selecting one sets the query to that token (e.g. spain ->
  Spain).

buildSearchSuggestions lives in lib (pure, testable) and
MapSearchSuggestions.tsx is presentational; MapPage owns the list and
keyboard nav (ArrowUp/Down, Enter, Escape). Adds mapPage.searchSuggestPlaces
(en/es only; other locales fall back to English).
This commit is contained in:
SkateSpots 2026-10-09 16:31:31 +07:00
parent bbae55b610
commit 486ffdb000
6 changed files with 260 additions and 0 deletions

View file

@ -1641,6 +1641,18 @@ session with **no other memory of this project**. So:
result": callers use limits from 8 to 2000, so a small-limit query would wipe
valid cached spots — prune by tombstone only. Admin `hide` was already instant
via `localModeration`'s local verdicts; this closes the NIP-09 self-delete gap.
- **Map search autocomplete (2026-10-09):** the map search pill now shows a
suggestions dropdown. **`src/lib/mapSearchSuggestions.ts`**
(`buildSearchSuggestions`) builds two groups from the items that already match
the active filters: **spots** (title matches; click = fly to + open the sheet)
and **places** (distinct city/region/country tokens split out of each item's
`location`; click = set the query to that token, e.g. "spain" → "Spain").
**`components/MapSearchSuggestions.tsx`** is presentational only — `MapPage`
owns the list and `activeIndex`, plus ArrowUp/Down, Enter and Escape. New i18n
key `mapPage.searchSuggestPlaces` (en/es only, others fall back); the Spots
header reuses `nav.map`, the empty state reuses `common.noResults`. The filter
itself still matches `title/location/description`, so a place suggestion works
by putting that token into the query.
- **Offline / dead-relay behaviour (2026-09-28):** the app is a utility, so a
relay outage must degrade, not blank. ⚠️ `querySkateContent` used to swallow
**every** relay error and return `[]`, so a dead relay looked like "no spots"

View file

@ -0,0 +1,95 @@
import { MapPin, Store } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { SearchSuggestion } from '@/lib/mapSearchSuggestions';
interface MapSearchSuggestionsProps {
suggestions: SearchSuggestion[];
activeIndex: number;
spotsLabel: string;
placesLabel: string;
emptyLabel: string;
onHover: (index: number) => void;
onSelect: (suggestion: SearchSuggestion) => void;
}
/**
* Dropdown panel for the map search bar. Purely presentational: the parent owns
* the suggestion list (see `buildSearchSuggestions` in
* `@/lib/mapSearchSuggestions`), the active index and selection.
*/
export function MapSearchSuggestions({
suggestions,
activeIndex,
spotsLabel,
placesLabel,
emptyLabel,
onHover,
onSelect,
}: MapSearchSuggestionsProps) {
return (
<div
role="listbox"
// Keep the input focused when a row is pressed, so `blur` cannot close the
// panel before the click lands.
onMouseDown={(e) => e.preventDefault()}
className="absolute left-0 right-0 top-full z-[1001] mt-2 max-h-80 overflow-y-auto rounded-2xl border border-white/15 bg-black/95 p-1.5 shadow-2xl backdrop-blur-md"
>
{suggestions.length === 0 ? (
<div className="px-3 py-2.5 text-sm text-gray-400">{emptyLabel}</div>
) : (
suggestions.map((s, index) => {
const header =
index === 0 || suggestions[index - 1].kind !== s.kind
? s.kind === 'spot'
? spotsLabel
: placesLabel
: null;
const active = index === activeIndex;
return (
<div key={`${s.kind}:${s.label}:${index}`}>
{header && (
<div className="px-3 pb-1 pt-2 text-[10px] font-bold uppercase tracking-wider text-gray-500">
{header}
</div>
)}
<button
type="button"
role="option"
aria-selected={active}
ref={(el) => {
if (active && el) el.scrollIntoView({ block: 'nearest' });
}}
onMouseEnter={() => onHover(index)}
onClick={() => onSelect(s)}
className={cn(
'flex min-h-0 w-full items-center gap-3 rounded-xl px-3 py-2 text-left transition-colors',
active ? 'bg-white/10 text-white' : 'text-gray-300 hover:bg-white/5',
)}
>
<span className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg bg-white/5">
{s.isShop ? (
<Store className="h-3.5 w-3.5" />
) : (
<MapPin className="h-3.5 w-3.5" />
)}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{s.label}</span>
{s.kind === 'spot' && s.sublabel && (
<span className="block truncate text-xs text-gray-500">{s.sublabel}</span>
)}
</span>
{s.kind === 'place' && s.sublabel && (
<span className="flex-shrink-0 rounded-full bg-white/5 px-2 py-0.5 text-[10px] text-gray-400">
{s.sublabel}
</span>
)}
</button>
</div>
);
})
)}
</div>
);
}

View file

@ -196,6 +196,7 @@ nav: {
// Map Page
mapPage: {
searchSuggestPlaces: 'Places',
title: 'Shops & Spots',
spots: 'Spots',
shops: 'Shops',

View file

@ -196,6 +196,7 @@ nav: {
// Map Page
mapPage: {
searchSuggestPlaces: 'Lugares',
title: 'Tiendas y Spots',
spots: 'Spots',
shops: 'Tiendas',

View file

@ -0,0 +1,83 @@
/** Minimal shape the map passes in (the unified spot/shop list). */
export interface MapSearchItem {
id: string;
title: string;
location?: string;
isShop?: boolean;
}
/** One row in the suggestion panel. */
export interface SearchSuggestion {
kind: 'spot' | 'place';
label: string;
/** Spot: its location. Place: the number of matching spots (as a string). */
sublabel?: string;
/** Set on `spot` rows — the unified list item id. */
itemId?: string;
isShop?: boolean;
/** Set on `place` rows — the text to put in the search box. */
value?: string;
}
/**
* Build autocomplete suggestions for the map search from the items that already
* matched the current query/filters.
*
* - `spot` — named spots/shops whose TITLE matches, so you can jump straight
* to one.
* - `place` — the distinct city/region/country tokens found in the matching
* items' `location` strings (e.g. "Barcelona, Spain" yields city
* "Barcelona" and country "Spain"), so a country query still
* suggests a concrete place to narrow down to.
*
* Deterministic and side-effect free, so MapPage can own keyboard navigation.
*/
export function buildSearchSuggestions(
items: MapSearchItem[],
query: string,
maxSpots = 6,
maxPlaces = 5,
): SearchSuggestion[] {
const q = query.trim().toLowerCase();
if (!q) return [];
const out: SearchSuggestion[] = [];
const titleMatches = items
.filter((it) => it.title.toLowerCase().includes(q))
.sort((a, b) => a.title.toLowerCase().indexOf(q) - b.title.toLowerCase().indexOf(q))
.slice(0, maxSpots);
for (const it of titleMatches) {
out.push({
kind: 'spot',
label: it.title,
sublabel: it.location,
itemId: it.id,
isShop: !!it.isShop,
});
}
const counts = new Map<string, { label: string; count: number }>();
for (const it of items) {
if (!it.location) continue;
for (const raw of it.location.split(',')) {
const part = raw.trim();
if (!part || !part.toLowerCase().includes(q)) continue;
const key = part.toLowerCase();
const existing = counts.get(key);
if (existing) existing.count += 1;
else counts.set(key, { label: part, count: 1 });
}
}
const places = [...counts.values()]
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label))
.slice(0, maxPlaces);
for (const p of places) {
out.push({ kind: 'place', label: p.label, sublabel: String(p.count), value: p.label });
}
return out;
}

View file

@ -9,6 +9,8 @@ import { MapFooterStrip } from '@/components/MapFooterStrip';
import { WeatherToggle } from '@/components/WeatherToggle';
import { RouteDialog } from '@/components/RouteDialog';
import { TrustFilter } from '@/components/TrustFilter';
import { MapSearchSuggestions } from '@/components/MapSearchSuggestions';
import { buildSearchSuggestions, type SearchSuggestion } from '@/lib/mapSearchSuggestions';
import { filterByTrustLevel, type TrustFilterLevel } from '@/lib/trustFilter';
import { SortDropdown, type SortOption } from '@/components/SortDropdown';
import { calculateDistance } from '@/lib/sortOptions';
@ -604,6 +606,8 @@ export default function MapPage() {
const [viewMode, setViewMode] = useState<ViewMode>('map');
const [searchQuery, setSearchQuery] = useState('');
const [showSearchSuggestions, setShowSearchSuggestions] = useState(false);
const [searchActiveIndex, setSearchActiveIndex] = useState(0);
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [selectedSpot, setSelectedSpot] = useState<SkateSpot | null>(null);
// Tap-to-add: a logged-in tap drops this pin and shows a confirm card instead
@ -1025,6 +1029,39 @@ export default function MapPage() {
return filtered;
}, [allLocations, searchQuery, selectedCategories, weatherEnabled, weatherData, weatherFilter, showSpots, showShops, trustFilter, getTrustScore, trustScores, sortBy, userLocation, getCommentCount, getZapTotal]);
// Autocomplete for the search pill. Derived from the already-filtered list so
// it respects the active filters; keyboard navigation is owned here.
const searchSuggestions = useMemo(
() => buildSearchSuggestions(filteredSpots, searchQuery),
[filteredSpots, searchQuery],
);
useEffect(() => {
setSearchActiveIndex(0);
}, [searchQuery]);
const handleSearchSuggestion = useCallback(
(suggestion: SearchSuggestion) => {
if (suggestion.kind === 'place' && suggestion.value) {
setSearchQuery(suggestion.value);
setShowSearchSuggestions(false);
return;
}
if (suggestion.kind === 'spot' && suggestion.itemId) {
const spot = allLocations.find((l) => l.id === suggestion.itemId);
if (spot) {
if (spot.geohash) {
const { latitude, longitude } = ngeohash.decode(spot.geohash);
setFlyTo({ lat: latitude, lng: longitude, zoom: 14 });
}
setSelectedSpot(spot);
}
setShowSearchSuggestions(false);
}
},
[allLocations],
);
// Community confidence for the cards (batched, one query). Shops use a
// different kind, so only spots are scored.
const communitySpots = useMemo(
@ -1137,6 +1174,26 @@ export default function MapPage() {
placeholder={t('mapPage.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onFocus={() => setShowSearchSuggestions(true)}
onBlur={() => setShowSearchSuggestions(false)}
onKeyDown={(e) => {
if (!showSearchSuggestions || searchSuggestions.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setSearchActiveIndex((i) => Math.min(i + 1, searchSuggestions.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSearchActiveIndex((i) => Math.max(i - 1, 0));
} else if (e.key === 'Enter') {
const suggestion = searchSuggestions[searchActiveIndex];
if (suggestion) {
e.preventDefault();
handleSearchSuggestion(suggestion);
}
} else if (e.key === 'Escape') {
setShowSearchSuggestions(false);
}
}}
className="pl-9 h-9 bg-black/50 backdrop-blur-md border-white/15 md:bg-white/5 md:border-white/10 text-white placeholder:text-gray-500 rounded-full text-sm focus:border-[#ff0040]/50 focus-visible:ring-0 focus-visible:ring-offset-0 transition-all"
/>
{searchQuery && (
@ -1144,6 +1201,17 @@ export default function MapPage() {
<X className="h-3.5 w-3.5" />
</button>
)}
{showSearchSuggestions && searchQuery.trim().length > 0 && (
<MapSearchSuggestions
suggestions={searchSuggestions}
activeIndex={searchActiveIndex}
spotsLabel={t('nav.map')}
placesLabel={t('mapPage.searchSuggestPlaces')}
emptyLabel={t('common.noResults')}
onHover={setSearchActiveIndex}
onSelect={handleSearchSuggestion}
/>
)}
</div>
{/* View toggle — compact pill */}