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