diff --git a/AGENTS.md b/AGENTS.md
index 0cad225..ee3d440 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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"
diff --git a/src/components/MapSearchSuggestions.tsx b/src/components/MapSearchSuggestions.tsx
new file mode 100644
index 0000000..be6be53
--- /dev/null
+++ b/src/components/MapSearchSuggestions.tsx
@@ -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 (
+
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 ? (
+
{emptyLabel}
+ ) : (
+ 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 (
+
+ {header && (
+
+ {header}
+
+ )}
+
{
+ 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',
+ )}
+ >
+
+ {s.isShop ? (
+
+ ) : (
+
+ )}
+
+
+ {s.label}
+ {s.kind === 'spot' && s.sublabel && (
+ {s.sublabel}
+ )}
+
+ {s.kind === 'place' && s.sublabel && (
+
+ {s.sublabel}
+
+ )}
+
+
+ );
+ })
+ )}
+
+ );
+}
diff --git a/src/i18n/en.ts b/src/i18n/en.ts
index 376d182..1b8a9ef 100644
--- a/src/i18n/en.ts
+++ b/src/i18n/en.ts
@@ -196,6 +196,7 @@ nav: {
// Map Page
mapPage: {
+ searchSuggestPlaces: 'Places',
title: 'Shops & Spots',
spots: 'Spots',
shops: 'Shops',
diff --git a/src/i18n/es.ts b/src/i18n/es.ts
index e018747..3d518ec 100644
--- a/src/i18n/es.ts
+++ b/src/i18n/es.ts
@@ -196,6 +196,7 @@ nav: {
// Map Page
mapPage: {
+ searchSuggestPlaces: 'Lugares',
title: 'Tiendas y Spots',
spots: 'Spots',
shops: 'Tiendas',
diff --git a/src/lib/mapSearchSuggestions.ts b/src/lib/mapSearchSuggestions.ts
new file mode 100644
index 0000000..f286fa2
--- /dev/null
+++ b/src/lib/mapSearchSuggestions.ts
@@ -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();
+ 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;
+}
diff --git a/src/pages/MapPage.tsx b/src/pages/MapPage.tsx
index 12182a0..61352b3 100644
--- a/src/pages/MapPage.tsx
+++ b/src/pages/MapPage.tsx
@@ -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('map');
const [searchQuery, setSearchQuery] = useState('');
+ const [showSearchSuggestions, setShowSearchSuggestions] = useState(false);
+ const [searchActiveIndex, setSearchActiveIndex] = useState(0);
const [selectedCategories, setSelectedCategories] = useState([]);
const [selectedSpot, setSelectedSpot] = useState(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() {
)}
+ {showSearchSuggestions && searchQuery.trim().length > 0 && (
+
+ )}
{/* View toggle — compact pill */}