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:
parent
bbae55b610
commit
486ffdb000
6 changed files with 260 additions and 0 deletions
12
AGENTS.md
12
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
|
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
|
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.
|
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
|
- **Offline / dead-relay behaviour (2026-09-28):** the app is a utility, so a
|
||||||
relay outage must degrade, not blank. ⚠️ `querySkateContent` used to swallow
|
relay outage must degrade, not blank. ⚠️ `querySkateContent` used to swallow
|
||||||
**every** relay error and return `[]`, so a dead relay looked like "no spots"
|
**every** relay error and return `[]`, so a dead relay looked like "no spots"
|
||||||
|
|
|
||||||
95
src/components/MapSearchSuggestions.tsx
Normal file
95
src/components/MapSearchSuggestions.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -196,6 +196,7 @@ nav: {
|
||||||
|
|
||||||
// Map Page
|
// Map Page
|
||||||
mapPage: {
|
mapPage: {
|
||||||
|
searchSuggestPlaces: 'Places',
|
||||||
title: 'Shops & Spots',
|
title: 'Shops & Spots',
|
||||||
spots: 'Spots',
|
spots: 'Spots',
|
||||||
shops: 'Shops',
|
shops: 'Shops',
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,7 @@ nav: {
|
||||||
|
|
||||||
// Map Page
|
// Map Page
|
||||||
mapPage: {
|
mapPage: {
|
||||||
|
searchSuggestPlaces: 'Lugares',
|
||||||
title: 'Tiendas y Spots',
|
title: 'Tiendas y Spots',
|
||||||
spots: 'Spots',
|
spots: 'Spots',
|
||||||
shops: 'Tiendas',
|
shops: 'Tiendas',
|
||||||
|
|
|
||||||
83
src/lib/mapSearchSuggestions.ts
Normal file
83
src/lib/mapSearchSuggestions.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,8 @@ import { MapFooterStrip } from '@/components/MapFooterStrip';
|
||||||
import { WeatherToggle } from '@/components/WeatherToggle';
|
import { WeatherToggle } from '@/components/WeatherToggle';
|
||||||
import { RouteDialog } from '@/components/RouteDialog';
|
import { RouteDialog } from '@/components/RouteDialog';
|
||||||
import { TrustFilter } from '@/components/TrustFilter';
|
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 { filterByTrustLevel, type TrustFilterLevel } from '@/lib/trustFilter';
|
||||||
import { SortDropdown, type SortOption } from '@/components/SortDropdown';
|
import { SortDropdown, type SortOption } from '@/components/SortDropdown';
|
||||||
import { calculateDistance } from '@/lib/sortOptions';
|
import { calculateDistance } from '@/lib/sortOptions';
|
||||||
|
|
@ -604,6 +606,8 @@ export default function MapPage() {
|
||||||
|
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('map');
|
const [viewMode, setViewMode] = useState<ViewMode>('map');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [showSearchSuggestions, setShowSearchSuggestions] = useState(false);
|
||||||
|
const [searchActiveIndex, setSearchActiveIndex] = useState(0);
|
||||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||||
const [selectedSpot, setSelectedSpot] = useState<SkateSpot | null>(null);
|
const [selectedSpot, setSelectedSpot] = useState<SkateSpot | null>(null);
|
||||||
// Tap-to-add: a logged-in tap drops this pin and shows a confirm card instead
|
// 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;
|
return filtered;
|
||||||
}, [allLocations, searchQuery, selectedCategories, weatherEnabled, weatherData, weatherFilter, showSpots, showShops, trustFilter, getTrustScore, trustScores, sortBy, userLocation, getCommentCount, getZapTotal]);
|
}, [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
|
// Community confidence for the cards (batched, one query). Shops use a
|
||||||
// different kind, so only spots are scored.
|
// different kind, so only spots are scored.
|
||||||
const communitySpots = useMemo(
|
const communitySpots = useMemo(
|
||||||
|
|
@ -1137,6 +1174,26 @@ export default function MapPage() {
|
||||||
placeholder={t('mapPage.searchPlaceholder')}
|
placeholder={t('mapPage.searchPlaceholder')}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
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"
|
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 && (
|
{searchQuery && (
|
||||||
|
|
@ -1144,6 +1201,17 @@ export default function MapPage() {
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-3.5 w-3.5" />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* View toggle — compact pill */}
|
{/* View toggle — compact pill */}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue