Merge branch 'review/pr-542' into feat/search-autocomplete

This commit is contained in:
jubnl
2026-04-15 04:02:08 +02:00
24 changed files with 765 additions and 41 deletions
+2
View File
@@ -347,6 +347,8 @@ export const journeyApi = {
export const mapsApi = {
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data),
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }) =>
apiClient.post('/maps/autocomplete', { input, lang, locationBias }).then(r => r.data),
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data),
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data),
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data),
+193 -28
View File
@@ -6,7 +6,7 @@ import { useAuthStore } from '../../store/authStore'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { useToast } from '../shared/Toast'
import { Search, Paperclip, X, AlertTriangle } from 'lucide-react'
import { Search, Paperclip, X, AlertTriangle, Loader2 } from 'lucide-react'
import { useTranslation } from '../../i18n'
import CustomTimePicker from '../shared/CustomTimePicker'
import type { Place, Category, Assignment } from '../../types'
@@ -25,6 +25,25 @@ interface PlaceFormData {
website: string
}
function isGoogleMapsUrl(input: string): boolean {
try {
const { hostname, pathname } = new URL(input.trim())
const h = hostname.toLowerCase()
// maps.app.goo.gl, goo.gl/maps
if (h === 'maps.app.goo.gl') return true
if (h === 'goo.gl' && pathname.startsWith('/maps')) return true
// maps.google.* (e.g. maps.google.com, maps.google.co.uk)
// Must be maps.google.<tld> or maps.google.<sld>.<tld> — reject maps.google.evil.com
if (/^maps\.google\.[a-z]{2,3}(\.[a-z]{2})?$/.test(h)) return true
// google.*/maps (e.g. google.com/maps, www.google.co.uk/maps)
const bare = h.startsWith('www.') ? h.slice(4) : h
if (/^google\.[a-z]{2,3}(\.[a-z]{2})?$/.test(bare) && pathname.startsWith('/maps')) return true
return false
} catch {
return false
}
}
const DEFAULT_FORM: PlaceFormData = {
name: '',
description: '',
@@ -65,6 +84,10 @@ export default function PlaceFormModal({
const [isSaving, setIsSaving] = useState(false)
const [pendingFiles, setPendingFiles] = useState([])
const fileRef = useRef(null)
const [acSuggestions, setAcSuggestions] = useState<{ placeId: string; mainText: string; secondaryText: string }[]>([])
const [acHighlight, setAcHighlight] = useState(-1)
const acDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [acTrigger, setAcTrigger] = useState(0)
const toast = useToast()
const { t, language } = useTranslation()
const { hasMapsKey } = useAuthStore()
@@ -101,6 +124,60 @@ export default function PlaceFormModal({
setPendingFiles([])
}, [place, prefillCoords, isOpen])
// Derive location bias bounding box from the trip's existing places
const places = useTripStore((s) => s.places)
const locationBias = useMemo(() => {
const withCoords = (places || []).filter((p) => p.lat != null && p.lng != null)
if (withCoords.length === 0) return undefined
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity
for (const p of withCoords) {
const lat = Number(p.lat), lng = Number(p.lng)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue
if (lat < minLat) minLat = lat
if (lat > maxLat) maxLat = lat
if (lng < minLng) minLng = lng
if (lng > maxLng) maxLng = lng
}
if (!Number.isFinite(minLat)) return undefined
// Skip bias if the bounding box is too large (~500 km diagonal)
const dlat = maxLat - minLat
const dlng = maxLng - minLng
const avgLatRad = ((minLat + maxLat) / 2) * (Math.PI / 180)
const diagKm = Math.sqrt((dlat * 111) ** 2 + (dlng * 111 * Math.cos(avgLatRad)) ** 2)
if (diagKm > 500) return undefined
return { low: { lat: minLat, lng: minLng }, high: { lat: maxLat, lng: maxLng } }
}, [places])
// Debounced autocomplete
useEffect(() => {
if (acDebounceRef.current) clearTimeout(acDebounceRef.current)
const trimmed = mapsSearch.trim()
if (trimmed.length < 2 || isGoogleMapsUrl(trimmed)) {
setAcSuggestions([])
setAcHighlight(-1)
return
}
acDebounceRef.current = setTimeout(async () => {
try {
const result = await mapsApi.autocomplete(trimmed, language, locationBias)
setAcSuggestions(result.suggestions || [])
setAcHighlight(-1)
} catch (err) {
console.error('Autocomplete failed:', err)
setAcSuggestions([])
}
}, 300)
return () => {
if (acDebounceRef.current) clearTimeout(acDebounceRef.current)
}
}, [mapsSearch, language, locationBias, acTrigger])
const handleChange = (field, value) => {
setForm(prev => ({ ...prev, [field]: value }))
}
@@ -111,7 +188,7 @@ export default function PlaceFormModal({
try {
// Detect Google Maps URLs and resolve them directly
const trimmed = mapsSearch.trim()
if (trimmed.match(/^https?:\/\/(www\.)?(google\.[a-z.]+\/maps|maps\.google\.[a-z.]+|maps\.app\.goo\.gl|goo\.gl)/i)) {
if (isGoogleMapsUrl(trimmed)) {
const resolved = await mapsApi.resolveUrl(trimmed)
if (resolved.lat && resolved.lng) {
setForm(prev => ({
@@ -152,6 +229,56 @@ export default function PlaceFormModal({
setMapsSearch('')
}
const handleSelectSuggestion = async (suggestion: { placeId: string; mainText: string; secondaryText: string }) => {
setAcSuggestions([])
setAcHighlight(-1)
const previousSearch = mapsSearch
setMapsSearch('')
setForm(prev => ({ ...prev, name: suggestion.mainText }))
setIsSearchingMaps(true)
try {
const result = await mapsApi.details(suggestion.placeId, language)
if (result.place) {
handleSelectMapsResult(result.place)
} else {
setMapsSearch(previousSearch)
toast.error(t('places.mapsSearchError'))
}
} catch (err) {
console.error('Failed to fetch place details:', err)
setMapsSearch(previousSearch)
toast.error(t('places.mapsSearchError'))
} finally {
setIsSearchingMaps(false)
}
}
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
if (acSuggestions.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault()
setAcHighlight(prev => (prev + 1) % acSuggestions.length)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setAcHighlight(prev => (prev <= 0 ? acSuggestions.length - 1 : prev - 1))
} else if (e.key === 'Enter') {
e.preventDefault()
if (acHighlight >= 0) {
handleSelectSuggestion(acSuggestions[acHighlight])
} else {
setAcSuggestions([])
handleMapsSearch()
}
} else if (e.key === 'Escape') {
setAcSuggestions([])
setAcHighlight(-1)
}
} else if (e.key === 'Enter') {
e.preventDefault()
handleMapsSearch()
}
}
const handleCreateCategory = async () => {
if (!newCategoryName.trim()) return
try {
@@ -229,25 +356,56 @@ export default function PlaceFormModal({
{t('places.osmActive')}
</p>
)}
<div className="flex gap-2">
<input
type="text"
value={mapsSearch}
onChange={e => setMapsSearch(e.target.value)}
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), handleMapsSearch())}
placeholder={t('places.mapsSearchPlaceholder')}
autoFocus
className="flex-1 border border-slate-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 bg-white"
/>
<button
type="button"
onClick={handleMapsSearch}
disabled={isSearchingMaps}
className="bg-slate-900 text-white px-3 py-1.5 rounded-lg text-sm hover:bg-slate-700 disabled:opacity-60"
>
{isSearchingMaps ? '...' : <Search className="w-4 h-4" />}
</button>
<div className="relative">
<div className="flex gap-2">
<input
type="text"
value={mapsSearch}
onChange={e => setMapsSearch(e.target.value)}
onKeyDown={handleSearchKeyDown}
onBlur={() => setTimeout(() => setAcSuggestions([]), 150)}
onFocus={() => {
if (mapsSearch.trim().length >= 2 && acSuggestions.length === 0 && mapsResults.length === 0) {
setAcTrigger(prev => prev + 1)
}
}}
placeholder={t('places.mapsSearchPlaceholder')}
className="flex-1 border border-slate-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 bg-white"
/>
<button
type="button"
onClick={() => { setAcSuggestions([]); handleMapsSearch() }}
disabled={isSearchingMaps}
className="bg-slate-900 text-white px-3 py-1.5 rounded-lg text-sm hover:bg-slate-700 disabled:opacity-60"
>
{isSearchingMaps ? '...' : <Search className="w-4 h-4" />}
</button>
</div>
{/* Autocomplete dropdown */}
{acSuggestions.length > 0 && (
<div className="absolute left-0 right-0 z-20 mt-1 bg-white rounded-lg border border-slate-200 shadow-lg overflow-hidden">
{acSuggestions.map((s, idx) => (
<button
key={s.placeId}
type="button"
onMouseDown={() => handleSelectSuggestion(s)}
onMouseEnter={() => setAcHighlight(idx)}
className={`w-full text-left px-3 py-2 border-b border-slate-100 last:border-0 ${
idx === acHighlight ? 'bg-slate-100' : 'hover:bg-slate-50'
}`}
>
<div className="font-medium text-sm">{s.mainText}</div>
{s.secondaryText && (
<div className="text-xs text-slate-500 truncate">{s.secondaryText}</div>
)}
</button>
))}
</div>
)}
</div>
{/* Search results (populated after full search) */}
{mapsResults.length > 0 && (
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden max-h-40 overflow-y-auto mt-2">
{mapsResults.map((result, idx) => (
@@ -268,14 +426,21 @@ export default function PlaceFormModal({
{/* Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formName')} *</label>
<input
type="text"
value={form.name}
onChange={e => handleChange('name', e.target.value)}
required
placeholder={t('places.formNamePlaceholder')}
className="form-input"
/>
<div className="relative">
<input
type="text"
value={form.name}
onChange={e => handleChange('name', e.target.value)}
required
placeholder={t('places.formNamePlaceholder')}
className="form-input"
/>
{isSearchingMaps && (
<div className="absolute right-2.5 top-0 bottom-0 flex items-center">
<Loader2 className="w-4 h-4 animate-spin text-slate-400" />
</div>
)}
</div>
</div>
{/* Description */}
+1
View File
@@ -936,6 +936,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'places.reservationNotesPlaceholder': 'ملاحظات الحجز، رقم التأكيد...',
'places.mapsSearchPlaceholder': 'ابحث عن أماكن...',
'places.mapsSearchError': 'فشل البحث عن المكان.',
'places.loadingDetails': 'جارٍ تحميل تفاصيل المكان…',
'places.osmHint': 'يتم البحث عبر OpenStreetMap (بدون صور أو ساعات عمل أو تقييمات). أضف مفتاح Google API في الإعدادات للحصول على جميع التفاصيل.',
'places.osmActive': 'البحث عبر OpenStreetMap (بدون صور أو تقييمات أو ساعات عمل). أضف مفتاح Google API في الإعدادات لبيانات موسعة.',
'places.categoryCreateError': 'فشل إنشاء الفئة',
+1
View File
@@ -906,6 +906,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'places.reservationNotesPlaceholder': 'Notas da reserva, código de confirmação...',
'places.mapsSearchPlaceholder': 'Buscar lugares...',
'places.mapsSearchError': 'Falha na busca de lugares.',
'places.loadingDetails': 'Carregando detalhes do lugar…',
'places.osmHint': 'Busca via OpenStreetMap (sem fotos, horários ou avaliações). Adicione uma chave Google nas configurações para detalhes completos.',
'places.osmActive': 'Busca via OpenStreetMap (sem fotos, avaliações ou horário de funcionamento). Adicione uma chave Google em Configurações para mais dados.',
'places.categoryCreateError': 'Falha ao criar categoria',
+1
View File
@@ -934,6 +934,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'places.reservationNotesPlaceholder': 'Poznámky k rezervaci, potvrzovací kód...',
'places.mapsSearchPlaceholder': 'Hledat místa...',
'places.mapsSearchError': 'Hledání místa se nezdařilo.',
'places.loadingDetails': 'Načítání podrobností místa…',
'places.osmHint': 'Používáte hledání přes OpenStreetMap (bez fotek a hodnocení). Pro plné detaily přidejte Google API klíč v nastavení.',
'places.osmActive': 'Hledání přes OpenStreetMap.',
'places.categoryCreateError': 'Nepodařilo se vytvořit kategorii',
+1
View File
@@ -937,6 +937,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'places.reservationNotesPlaceholder': 'Reservierungsnotizen, Bestätigungsnummer...',
'places.mapsSearchPlaceholder': 'Ortssuche...',
'places.mapsSearchError': 'Ortssuche fehlgeschlagen.',
'places.loadingDetails': 'Ortsdetails werden geladen…',
'places.osmHint': 'OpenStreetMap-Suche aktiv (ohne Bilder, Öffnungszeiten, Bewertungen). Für erweiterte Daten Google API Key in den Einstellungen hinterlegen.',
'places.osmActive': 'Suche via OpenStreetMap (ohne Bilder, Bewertungen & Öffnungszeiten). Google API Key in den Einstellungen hinterlegen für erweiterte Daten.',
'places.categoryCreateError': 'Fehler beim Erstellen der Kategorie',
+1
View File
@@ -959,6 +959,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'places.reservationNotesPlaceholder': 'Reservation notes, confirmation number...',
'places.mapsSearchPlaceholder': 'Search places...',
'places.mapsSearchError': 'Place search failed.',
'places.loadingDetails': 'Loading place details…',
'places.osmHint': 'Using OpenStreetMap search (no photos, opening hours, or ratings). Add a Google API key in settings for full details.',
'places.osmActive': 'Search via OpenStreetMap (no photos, ratings or opening hours). Add a Google API key in Settings for enhanced data.',
'places.categoryCreateError': 'Failed to create category',
+1
View File
@@ -909,6 +909,7 @@ const es: Record<string, string> = {
'places.reservationNotesPlaceholder': 'Notas de reserva, número de confirmación...',
'places.mapsSearchPlaceholder': 'Buscar lugares...',
'places.mapsSearchError': 'La búsqueda de lugares falló.',
'places.loadingDetails': 'Cargando detalles del lugar…',
'places.osmHint': 'Usando búsqueda con OpenStreetMap (sin fotos, horarios ni valoraciones). Añade una clave API de Google en Ajustes para obtener todos los detalles.',
'places.osmActive': 'Búsqueda mediante OpenStreetMap (sin fotos, valoraciones ni horarios). Añade una clave API de Google en Ajustes para datos ampliados.',
'places.categoryCreateError': 'No se pudo crear la categoría',
+1
View File
@@ -933,6 +933,7 @@ const fr: Record<string, string> = {
'places.reservationNotesPlaceholder': 'Notes de réservation, numéro de confirmation…',
'places.mapsSearchPlaceholder': 'Rechercher des lieux…',
'places.mapsSearchError': 'La recherche de lieu a échoué.',
'places.loadingDetails': 'Chargement des détails du lieu…',
'places.osmHint': 'Recherche via OpenStreetMap (pas de photos, horaires ni notes). Ajoutez une clé API Google dans les paramètres pour plus de détails.',
'places.osmActive': 'Recherche via OpenStreetMap (pas de photos, notes ni horaires). Ajoutez une clé API Google dans les paramètres pour des données enrichies.',
'places.categoryCreateError': 'Impossible de créer la catégorie',
+1
View File
@@ -934,6 +934,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'places.reservationNotesPlaceholder': 'Foglalási jegyzetek, visszaigazolási szám...',
'places.mapsSearchPlaceholder': 'Helyek keresése...',
'places.mapsSearchError': 'Helykeresés sikertelen.',
'places.loadingDetails': 'Hely adatainak betöltése…',
'places.osmHint': 'OpenStreetMap keresés aktív (képek, nyitvatartás és értékelések nélkül). Bővített adatokhoz add meg a Google API kulcsot a beállításokban.',
'places.osmActive': 'Keresés OpenStreetMap-en keresztül (képek, értékelések és nyitvatartás nélkül). Bővített adatokhoz add meg a Google API kulcsot a beállításokban.',
'places.categoryCreateError': 'Nem sikerült létrehozni a kategóriát',
+1
View File
@@ -934,6 +934,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'places.reservationNotesPlaceholder': 'Note della prenotazione, numero di conferma...',
'places.mapsSearchPlaceholder': 'Cerca luoghi...',
'places.mapsSearchError': 'Impossibile cercare i luoghi.',
'places.loadingDetails': 'Caricamento dettagli del luogo…',
'places.osmHint': 'Uso della ricerca OpenStreetMap (senza foto, orari di apertura o valutazioni). Aggiungi una chiave API Google nelle impostazioni per i dettagli completi.',
'places.osmActive': 'Ricerca tramite OpenStreetMap (senza foto, valutazioni o orari di apertura). Aggiungi una chiave API Google nelle Impostazioni per dati avanzati.',
'places.categoryCreateError': 'Impossibile creare la categoria',
+1
View File
@@ -933,6 +933,7 @@ const nl: Record<string, string> = {
'places.reservationNotesPlaceholder': 'Reserveringsnotities, bevestigingsnummer...',
'places.mapsSearchPlaceholder': 'Plaatsen zoeken...',
'places.mapsSearchError': 'Zoeken naar plaatsen mislukt.',
'places.loadingDetails': 'Plaatsgegevens laden…',
'places.osmHint': 'Zoeken via OpenStreetMap (geen foto\'s, openingstijden of beoordelingen). Voeg een Google API-sleutel toe in instellingen voor volledige details.',
'places.osmActive': 'Zoeken via OpenStreetMap (geen foto\'s, beoordelingen of openingstijden). Voeg een Google API-sleutel toe in Instellingen voor uitgebreide gegevens.',
'places.categoryCreateError': 'Categorie aanmaken mislukt',
+1
View File
@@ -895,6 +895,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'places.reservationNotesPlaceholder': 'Notatki z rezerwacji, numer potwierdzenia...',
'places.mapsSearchPlaceholder': 'Szukaj miejsc...',
'places.mapsSearchError': 'Nie udało się wyszukać miejsca.',
'places.loadingDetails': 'Ładowanie szczegółów miejsca…',
'places.osmHint': 'Korzystając z OpenStreetMap (brak zdjęć, godzin otwarcia czy ocen). Dodaj klucz API Google w ustawieniach aby uzyskać pełne dane.',
'places.osmActive': 'Szukaj przez OpenStreetMap (brak zdjęć, ocen czy godzin otwarcia). Dodaj klucz API Google w ustawieniach aby uzyskać pełne dane.',
'places.categoryCreateError': 'Nie udało się utworzyć kategorii',
+1
View File
@@ -933,6 +933,7 @@ const ru: Record<string, string> = {
'places.reservationNotesPlaceholder': 'Заметки о бронировании, номер подтверждения...',
'places.mapsSearchPlaceholder': 'Поиск мест...',
'places.mapsSearchError': 'Ошибка поиска мест.',
'places.loadingDetails': 'Загрузка данных о месте…',
'places.osmHint': 'Поиск через OpenStreetMap (без фото, часов работы и рейтингов). Добавьте API-ключ Google в настройках для полной информации.',
'places.osmActive': 'Поиск через OpenStreetMap (без фото, рейтингов и часов работы). Добавьте API-ключ Google в настройках для расширенных данных.',
'places.categoryCreateError': 'Не удалось создать категорию',
+1
View File
@@ -933,6 +933,7 @@ const zh: Record<string, string> = {
'places.reservationNotesPlaceholder': '预订备注、确认号...',
'places.mapsSearchPlaceholder': '搜索地点...',
'places.mapsSearchError': '地点搜索失败。',
'places.loadingDetails': '正在加载地点详情…',
'places.osmHint': '使用 OpenStreetMap 搜索(无照片、营业时间或评分)。在设置中添加 Google API 密钥以获取完整信息。',
'places.osmActive': '通过 OpenStreetMap 搜索(无照片、评分或营业时间)。在设置中添加 Google API 密钥以获取增强数据。',
'places.categoryCreateError': '创建分类失败',
+1
View File
@@ -958,6 +958,7 @@ const zhTw: Record<string, string> = {
'places.reservationNotesPlaceholder': '預訂備註、確認號...',
'places.mapsSearchPlaceholder': '搜尋地點...',
'places.mapsSearchError': '地點搜尋失敗。',
'places.loadingDetails': '正在載入地點詳情…',
'places.osmHint': '使用 OpenStreetMap 搜尋(無照片、營業時間或評分)。在設定中新增 Google API 金鑰以獲取完整資訊。',
'places.osmActive': '透過 OpenStreetMap 搜尋(無照片、評分或營業時間)。在設定中新增 Google API 金鑰以獲取增強資料。',
'places.categoryCreateError': '建立分類失敗',
+4
View File
@@ -194,6 +194,7 @@ export const useAuthStore = create<AuthState>()(
await authApi.updateMapsKey(key)
set((state) => ({
user: state.user ? { ...state.user, maps_api_key: key || null } : null,
hasMapsKey: !!key,
}))
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error saving API key'))
@@ -204,6 +205,9 @@ export const useAuthStore = create<AuthState>()(
try {
const data = await authApi.updateApiKeys(keys)
set({ user: data.user })
if ('maps_api_key' in keys) {
set({ hasMapsKey: !!keys.maps_api_key })
}
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error saving API keys'))
}