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).
83 lines
2.5 KiB
TypeScript
83 lines
2.5 KiB
TypeScript
/** 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;
|
|
}
|