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 { DEFAULT_FORM, isGoogleMapsUrl, type PlaceFormData } from './PlaceFormModal.helpers' import { getApiErrorMessage } from '../../utils/apiError' import type { Place, Category, Assignment } from '../../types' // The submit payload mirrors the form, but lat/lng are parsed to numbers and // category_id is normalised, plus any files chosen before the place existed. export interface PlaceSubmitData extends Omit { lat: number | null lng: number | null category_id: string | null _pendingFiles?: File[] } interface PlaceFormModalProps { isOpen: boolean onClose: () => void onSave: (data: PlaceSubmitData, files?: File[]) => Promise | void place: Place | null prefillCoords?: { lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string } | null tripId: number categories: Category[] onCategoryCreated: (category: { name: string; color?: string; icon?: string }) => Promise | undefined assignmentId: number | null dayAssignments?: Assignment[] } /** Place create/edit form state: maps search + Google-URL resolve + autocomplete, * category creation, file attachments and submit. Keeps PlaceFormModal a thin * render over the form fields. */ // #1152: a manually-added place is treated as a likely duplicate of an existing // trip place if it shares the Google Place ID, the (case-insensitive) name, or // near-identical coordinates (~11 m). Mirrors the server-side import dedup. const DUP_COORD_TOLERANCE = 0.0001 function findDuplicatePlace( form: PlaceFormData, places: { name?: string | null; lat?: number | null; lng?: number | null; google_place_id?: string | null }[], ): { name?: string | null } | null { const name = (form.name || '').trim().toLowerCase() const gid = (form.google_place_id || '').trim() const lat = form.lat ? parseFloat(form.lat) : null const lng = form.lng ? parseFloat(form.lng) : null for (const p of places || []) { if (gid && p.google_place_id && p.google_place_id === gid) return p if (name && p.name && p.name.trim().toLowerCase() === name) return p if ( lat != null && lng != null && p.lat != null && p.lng != null && Math.abs(Number(p.lat) - lat) <= DUP_COORD_TOLERANCE && Math.abs(Number(p.lng) - lng) <= DUP_COORD_TOLERANCE ) return p } return null } function usePlaceFormModal(props: PlaceFormModalProps) { const { isOpen, onClose, onSave, place, prefillCoords, tripId, categories, onCategoryCreated, assignmentId, dayAssignments = [], } = props 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 [duplicateWarning, setDuplicateWarning] = useState(null) 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 != null ? String(place.lat) : '', lng: place.lng != null ? String(place.lng) : '', category_id: place.category_id != null ? String(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 || '', website: prefillCoords.website || '', phone: prefillCoords.phone || '', osm_id: prefillCoords.osm_id, }) } else { setForm(DEFAULT_FORM) } setPendingFiles([]) setDuplicateWarning(null) }, [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: string, value: string) => { 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(getApiErrorMessage(err, 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(getApiErrorMessage(err, 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: String(cat.id) })) setNewCategoryName('') setShowNewCategory(false) } catch (err: unknown) { toast.error(t('places.categoryCreateError')) } } const handleFileAdd = (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []) setPendingFiles(prev => [...prev, ...files]) e.target.value = '' } const handleRemoveFile = (idx: number) => { setPendingFiles(prev => prev.filter((_, i) => i !== idx)) } // Paste support for files/images const handlePaste = (e: React.ClipboardEvent) => { 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 } // #1152: only for new places, and only on the first attempt — a second click // (with the warning already showing) is the explicit "add anyway" confirmation. if (!place && !duplicateWarning) { const dup = findDuplicatePlace(form, places) if (dup) { const dupName = dup.name || form.name setDuplicateWarning(dupName) toast.warning(t('places.duplicateExists', { name: dupName })) 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 { isOpen, onClose, onSave, place, prefillCoords, tripId, categories, onCategoryCreated, assignmentId, dayAssignments, form, setForm, mapsSearch, setMapsSearch, mapsResults, setMapsResults, isSearchingMaps, setIsSearchingMaps, newCategoryName, setNewCategoryName, showNewCategory, setShowNewCategory, isSaving, setIsSaving, pendingFiles, setPendingFiles, fileRef, acSuggestions, setAcSuggestions, acHighlight, setAcHighlight, acDebounceRef, acAbortRef, toast, t, language, hasMapsKey, can, tripObj, canUploadFiles, places, locationBias, fetchSuggestions, handleChange, handleMapsSearch, handleSelectMapsResult, handleSelectSuggestion, handleSearchKeyDown, handleCreateCategory, handleFileAdd, handleRemoveFile, handlePaste, hasTimeError, handleSubmit, duplicateWarning, } } export default function PlaceFormModal(props: PlaceFormModalProps) { const S = usePlaceFormModal(props) const { isOpen, onClose, onSave, place, prefillCoords, tripId, categories, onCategoryCreated, assignmentId, dayAssignments, form, setForm, mapsSearch, setMapsSearch, mapsResults, setMapsResults, isSearchingMaps, setIsSearchingMaps, newCategoryName, setNewCategoryName, showNewCategory, setShowNewCategory, isSaving, setIsSaving, pendingFiles, setPendingFiles, fileRef, acSuggestions, setAcSuggestions, acHighlight, setAcHighlight, acDebounceRef, acAbortRef, toast, t, language, hasMapsKey, can, tripObj, canUploadFiles, places, locationBias, fetchSuggestions, handleChange, handleMapsSearch, handleSelectMapsResult, handleSelectSuggestion, handleSearchKeyDown, handleCreateCategory, handleFileAdd, handleRemoveFile, handlePaste, hasTimeError, handleSubmit, duplicateWarning, } = S 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 */}