import { useState, useEffect, useRef, useMemo, useCallback } from 'react' import Modal from '../shared/Modal' import CustomSelect from '../shared/CustomSelect' import { mapsApi } from '../../api/client' 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, Loader2 } from 'lucide-react' import { useTranslation } from '../../i18n' import CustomTimePicker from '../shared/CustomTimePicker' import type { Place, Category, Assignment } from '../../types' interface PlaceFormData { name: string description: string address: string lat: string lng: string category_id: string place_time: string end_time: string notes: string transport_mode: string 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. or maps.google.. — 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: '', address: '', lat: '', lng: '', category_id: '', place_time: '', end_time: '', notes: '', transport_mode: 'walking', website: '', } interface PlaceFormModalProps { isOpen: boolean onClose: () => void onSave: (data: PlaceFormData, files?: File[]) => Promise | void place: Place | null prefillCoords?: { lat: number; lng: number; name?: string; address?: string } | null tripId: number categories: Category[] onCategoryCreated: (category: Category) => void assignmentId: number | null dayAssignments?: Assignment[] } export default function PlaceFormModal({ isOpen, onClose, onSave, place, prefillCoords, tripId, categories, onCategoryCreated, assignmentId, dayAssignments = [], }: PlaceFormModalProps) { const [form, setForm] = useState(DEFAULT_FORM) const [mapsSearch, setMapsSearch] = useState('') const [mapsResults, setMapsResults] = useState([]) const [isSearchingMaps, setIsSearchingMaps] = useState(false) const [newCategoryName, setNewCategoryName] = useState('') const [showNewCategory, setShowNewCategory] = useState(false) 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 | null>(null) const acAbortRef = useRef(null) const toast = useToast() const { t, language } = useTranslation() const { hasMapsKey } = useAuthStore() const can = useCanDo() const tripObj = useTripStore((s) => s.trip) const canUploadFiles = can('file_upload', tripObj) useEffect(() => { if (place) { setForm({ name: place.name || '', description: place.description || '', address: place.address || '', lat: place.lat || '', lng: place.lng || '', category_id: place.category_id || '', place_time: place.place_time || '', end_time: place.end_time || '', notes: place.notes || '', transport_mode: place.transport_mode || 'walking', website: place.website || '', }) } else if (prefillCoords) { setForm({ ...DEFAULT_FORM, lat: String(prefillCoords.lat), lng: String(prefillCoords.lng), name: prefillCoords.name || '', address: prefillCoords.address || '', }) } else { setForm(DEFAULT_FORM) } 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]) // Autocomplete fetch — aborts any in-flight request before starting a new one const fetchSuggestions = useCallback(async (query: string) => { if (query.length < 2 || isGoogleMapsUrl(query)) { setAcSuggestions([]) setAcHighlight(-1) return } acAbortRef.current?.abort() const controller = new AbortController() acAbortRef.current = controller try { const result = await mapsApi.autocomplete(query, language, locationBias, controller.signal) setAcSuggestions(result.suggestions || []) setAcHighlight(-1) } catch (err: unknown) { if (err instanceof Error && err.name === 'AbortError') return if (err instanceof Error && err.name === 'CanceledError') return // axios abort console.error('Autocomplete failed:', err) setAcSuggestions([]) } }, [language, locationBias]) // Debounce effect — only watches mapsSearch useEffect(() => { if (acDebounceRef.current) clearTimeout(acDebounceRef.current) const trimmed = mapsSearch.trim() if (trimmed.length < 2 || isGoogleMapsUrl(trimmed)) { setAcSuggestions([]) setAcHighlight(-1) return } acDebounceRef.current = setTimeout(() => fetchSuggestions(trimmed), 300) return () => { if (acDebounceRef.current) clearTimeout(acDebounceRef.current) } }, [mapsSearch, fetchSuggestions]) const handleChange = (field, value) => { setForm(prev => ({ ...prev, [field]: value })) } const handleMapsSearch = async () => { if (!mapsSearch.trim()) return setIsSearchingMaps(true) try { // Detect Google Maps URLs and resolve them directly const trimmed = mapsSearch.trim() if (isGoogleMapsUrl(trimmed)) { const resolved = await mapsApi.resolveUrl(trimmed) if (resolved.lat && resolved.lng) { setForm(prev => ({ ...prev, name: resolved.name || prev.name, address: resolved.address || prev.address, lat: String(resolved.lat), lng: String(resolved.lng), })) setMapsResults([]) setMapsSearch('') toast.success(t('places.urlResolved')) return } } const result = await mapsApi.search(mapsSearch, language) setMapsResults(result.places || []) } catch (err: unknown) { toast.error(t('places.mapsSearchError')) } finally { setIsSearchingMaps(false) } } const handleSelectMapsResult = (result) => { setForm(prev => ({ ...prev, name: result.name || prev.name, address: result.address || prev.address, lat: result.lat || prev.lat, lng: result.lng || prev.lng, google_place_id: result.google_place_id || prev.google_place_id, osm_id: result.osm_id || prev.osm_id, website: result.website || prev.website, phone: result.phone || prev.phone, })) setMapsResults([]) 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 { const cat = await onCategoryCreated?.({ name: newCategoryName, color: '#6366f1', icon: 'MapPin' }) if (cat) setForm(prev => ({ ...prev, category_id: cat.id })) setNewCategoryName('') setShowNewCategory(false) } catch (err: unknown) { toast.error(t('places.categoryCreateError')) } } const handleFileAdd = (e) => { const files = Array.from((e.target as HTMLInputElement).files || []) setPendingFiles(prev => [...prev, ...files]) e.target.value = '' } const handleRemoveFile = (idx) => { setPendingFiles(prev => prev.filter((_, i) => i !== idx)) } // Paste support for files/images const handlePaste = (e) => { if (!canUploadFiles) return const items = e.clipboardData?.items if (!items) return for (const item of Array.from(items)) { if (item.type.startsWith('image/') || item.type === 'application/pdf') { e.preventDefault() const file = item.getAsFile() if (file) setPendingFiles(prev => [...prev, file]) return } } } const hasTimeError = place && form.place_time && form.end_time && form.place_time.length >= 5 && form.end_time.length >= 5 && form.end_time <= form.place_time const handleSubmit = async (e) => { e.preventDefault() if (!form.name.trim()) { toast.error(t('places.nameRequired')) return } setIsSaving(true) try { await onSave({ ...form, lat: form.lat ? parseFloat(form.lat) : null, lng: form.lng ? parseFloat(form.lng) : null, category_id: form.category_id || null, _pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined, }) onClose() } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('places.saveError')) } finally { setIsSaving(false) } } return (
{/* Place Search */}
{!hasMapsKey && (

{t('places.osmActive')}

)}
setMapsSearch(e.target.value)} onKeyDown={handleSearchKeyDown} onBlur={() => setTimeout(() => setAcSuggestions([]), 150)} onFocus={() => { if (mapsSearch.trim().length >= 2 && acSuggestions.length === 0 && mapsResults.length === 0) { fetchSuggestions(mapsSearch.trim()) } }} 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" />
{/* Autocomplete dropdown */} {acSuggestions.length > 0 && (
{acSuggestions.map((s, idx) => ( ))}
)}
{/* Search results (populated after full search) */} {mapsResults.length > 0 && (
{mapsResults.map((result, idx) => ( ))}
)}
{/* Name */}
handleChange('name', e.target.value)} required placeholder={t('places.formNamePlaceholder')} className="form-input" /> {isSearchingMaps && (
)}
{/* Description */}