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
|
||||
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"
|
||||
|
|
|
|||
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
|
||||
mapPage: {
|
||||
searchSuggestPlaces: 'Places',
|
||||
title: 'Shops & Spots',
|
||||
spots: 'Spots',
|
||||
shops: 'Shops',
|
||||
|
|
|
|||
|
|
@ -196,6 +196,7 @@ nav: {
|
|||
|
||||
// Map Page
|
||||
mapPage: {
|
||||
searchSuggestPlaces: 'Lugares',
|
||||
title: 'Tiendas y Spots',
|
||||
spots: 'Spots',
|
||||
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 { 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<ViewMode>('map');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showSearchSuggestions, setShowSearchSuggestions] = useState(false);
|
||||
const [searchActiveIndex, setSearchActiveIndex] = useState(0);
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||
const [selectedSpot, setSelectedSpot] = useState<SkateSpot | null>(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() {
|
|||
<X className="h-3.5 w-3.5" />
|
||||
</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>
|
||||
|
||||
{/* View toggle — compact pill */}
|
||||
|
|
|
|||
Loading…
Reference in a new issue