mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 14:21:46 +00:00
Merge branch 'review/pr-542' into feat/search-autocomplete
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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': 'فشل إنشاء الفئة',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Не удалось создать категорию',
|
||||
|
||||
@@ -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': '创建分类失败',
|
||||
|
||||
@@ -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': '建立分類失敗',
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user