Files
TREK/client/src/components/Planner/AddressInput.tsx
T
Maurice b80fdf27f2 fix(bookings): show where an imported stay lands on the map before it is saved
An imported hotel was looked up once, by its name and its address together.
A booking site's romanized address (Agoda for a stay in Japan) often matches
nothing and sank the hotel's name with it, so the place was created without
coordinates and silently never showed on the map.

The review now finds the stay right away (name and address, then the name,
then the address) and says under the address whether it is on the map and
where, or that it is not yet, with a way to search again. Picking an address
suggestion sets the spot by hand. The save uses that spot, falls back the same
way when there is none, and a stay saved without a spot says so in a toast.
Desktop dialog and phone sheet share the lookup through useReservationForm.
2026-10-25 18:59:55 +01:00

105 lines
4.2 KiB
TypeScript

import { useEffect, useRef, useState } from 'react'
import { mapsApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { useLocationBias } from '../../hooks/useLocationBias'
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage'
import { useSuggestionDropdown } from './useSuggestionDropdown'
import { PlaceSuggestion, SuggestionList, SuggestionRow } from './SuggestionDropdown'
import type { MapMatch } from '../../repo/mapMatchRepo'
interface Props {
value: string
onChange: (value: string) => void
placeholder?: string
className?: string
/** A picked suggestion's spot on the map, for a form that saves where the address is (an imported stay). */
onPick?: (match: MapMatch) => void
}
// Free-text address input with location autocomplete, backed by the same maps
// search as LocationSelect. Unlike LocationSelect the typed text is
// authoritative: every keystroke reaches the parent so a hand-written address
// is never lost, and picking a suggestion just replaces the text (#1496).
export default function AddressInput({ value, onChange, placeholder, className, onPick }: Props) {
const { locale } = useTranslation()
const placeLang = usePlaceLanguage()
// Ohne Reisekontext ist der Hinweis leer, und die Suche laeuft wie bisher.
const { point: locationBias } = useLocationBias()
const { open, setOpen, highlight, setHighlight, wrapRef, handleKey } = useSuggestionDropdown()
const [results, setResults] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Clearing the timer does nothing to a request that is already out, and
// mapsApi.search takes no signal, so results are matched by ticket instead.
const reqIdRef = useRef(0)
useEffect(() => () => { if (debounceRef.current) clearTimeout(debounceRef.current) }, [])
// Search on typing only (not on focus or external value changes), so opening
// a modal with a saved address doesn't fire a request.
const search = (text: string) => {
if (debounceRef.current) clearTimeout(debounceRef.current)
const trimmed = text.trim()
// Same reason as in pick(): a request for the longer text may still land.
if (trimmed.length < 3) { reqIdRef.current++; setResults([]); setLoading(false); return }
debounceRef.current = setTimeout(async () => {
const myReq = ++reqIdRef.current
setLoading(true)
try {
const data = await mapsApi.search(trimmed, placeLang, locationBias)
if (myReq !== reqIdRef.current) return
setResults(data.places || [])
setHighlight(-1)
} catch {
if (myReq === reqIdRef.current) setResults([])
} finally {
if (myReq === reqIdRef.current) setLoading(false)
}
}, 320)
}
const pick = (r: any) => {
if (debounceRef.current) clearTimeout(debounceRef.current)
// A response still on its way must not repopulate the list behind the pick.
reqIdRef.current++
onChange(r.address || r.name || '')
if (onPick && Number.isFinite(r.lat) && Number.isFinite(r.lng)) {
onPick({ lat: r.lat, lng: r.lng, label: [r.name, r.address].filter(Boolean).join(', ') })
}
setOpen(false)
setResults([])
setLoading(false)
}
return (
<div ref={wrapRef} style={{ position: 'relative' }}>
<input
type="text"
value={value}
placeholder={placeholder}
onChange={e => { onChange(e.target.value); setOpen(true); search(e.target.value) }}
// Opens its list on focus, so a dialog must not focus it by itself (#1302).
data-no-autofocus
onFocus={() => setOpen(true)}
onKeyDown={(e) => handleKey(e, results, pick)}
className={className}
/>
{open && (
<SuggestionList loading={loading} rowCount={results.length}>
{results.map((r, i) => (
<SuggestionRow
key={`${r.osm_id || r.google_place_id || i}`}
active={i === highlight}
onPick={() => pick(r)}
onHover={() => setHighlight(i)}
align="flex-start"
>
<PlaceSuggestion name={r.name} address={r.address} />
</SuggestionRow>
))}
</SuggestionList>
)}
</div>
)
}