/** 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(); 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; }