183 lines
6.2 KiB
TypeScript
183 lines
6.2 KiB
TypeScript
import { type FC, useState, useEffect } from 'react'
|
||
import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet'
|
||
import { Icon } from 'leaflet'
|
||
import type { LatLngTuple } from 'leaflet'
|
||
|
||
interface LocationPickerProps {
|
||
initialPosition?: [number, number]
|
||
onLocationSelect: (lat: number, lng: number) => void
|
||
}
|
||
|
||
interface NominatimSearchResult {
|
||
place_id: number
|
||
display_name: string
|
||
lat: string
|
||
lon: string
|
||
}
|
||
|
||
const ChangeView = ({ center, zoom }: { center: LatLngTuple; zoom: number }) => {
|
||
const map = useMap()
|
||
useEffect(() => {
|
||
map.setView(center, zoom)
|
||
}, [map, center, zoom])
|
||
return null
|
||
}
|
||
|
||
const LocationPicker: FC<LocationPickerProps> = ({ initialPosition, onLocationSelect }) => {
|
||
const [position, setPosition] = useState<LatLngTuple>(
|
||
initialPosition || [35.6892, 51.3890] // تهران به عنوان پیشفرض
|
||
)
|
||
const [searchQuery, setSearchQuery] = useState('')
|
||
const [searchResults, setSearchResults] = useState<NominatimSearchResult[]>([])
|
||
const [isSearching, setIsSearching] = useState(false)
|
||
const [searchError, setSearchError] = useState('')
|
||
|
||
useEffect(() => {
|
||
if (initialPosition) {
|
||
setPosition(initialPosition)
|
||
}
|
||
}, [initialPosition])
|
||
|
||
useEffect(() => {
|
||
const query = searchQuery.trim()
|
||
|
||
if (!query) {
|
||
setSearchResults([])
|
||
setSearchError('')
|
||
setIsSearching(false)
|
||
return
|
||
}
|
||
|
||
const controller = new AbortController()
|
||
const timer = window.setTimeout(async () => {
|
||
setIsSearching(true)
|
||
setSearchError('')
|
||
|
||
try {
|
||
const params = new URLSearchParams({
|
||
q: query,
|
||
format: 'json',
|
||
addressdetails: '1',
|
||
limit: '5',
|
||
})
|
||
|
||
const response = await fetch(
|
||
`https://nominatim.openstreetmap.org/search?${params.toString()}`,
|
||
{
|
||
headers: {
|
||
Accept: 'application/json',
|
||
},
|
||
signal: controller.signal,
|
||
}
|
||
)
|
||
|
||
if (!response.ok) {
|
||
throw new Error('search request failed')
|
||
}
|
||
|
||
const data = (await response.json()) as NominatimSearchResult[]
|
||
if (!data.length) {
|
||
setSearchResults([])
|
||
setSearchError('نتیجهای پیدا نشد')
|
||
return
|
||
}
|
||
|
||
setSearchResults(data)
|
||
} catch (error) {
|
||
if ((error as DOMException).name === 'AbortError') {
|
||
return
|
||
}
|
||
setSearchResults([])
|
||
setSearchError('خطا در جستجوی موقعیت')
|
||
} finally {
|
||
setIsSearching(false)
|
||
}
|
||
}, 500)
|
||
|
||
return () => {
|
||
controller.abort()
|
||
window.clearTimeout(timer)
|
||
}
|
||
}, [searchQuery])
|
||
|
||
const handleSelectSearchResult = (result: NominatimSearchResult) => {
|
||
const lat = Number(result.lat)
|
||
const lng = Number(result.lon)
|
||
const newPosition: LatLngTuple = [lat, lng]
|
||
setPosition(newPosition)
|
||
}
|
||
|
||
const LocationMarker = () => {
|
||
useMapEvents({
|
||
click(e) {
|
||
const newPosition: LatLngTuple = [e.latlng.lat, e.latlng.lng]
|
||
setPosition(newPosition)
|
||
onLocationSelect(newPosition[0], newPosition[1])
|
||
},
|
||
})
|
||
|
||
const customIcon = new Icon({
|
||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||
iconSize: [25, 41],
|
||
iconAnchor: [12, 41],
|
||
popupAnchor: [1, -34],
|
||
shadowSize: [41, 41],
|
||
})
|
||
|
||
return position ? <Marker position={position} icon={customIcon} /> : null
|
||
}
|
||
|
||
return (
|
||
<div className='w-full'>
|
||
<div className='mb-3'>
|
||
<input
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
placeholder='جستجوی آدرس یا مکان'
|
||
className='w-full h-10 border border-border rounded-xl px-3 text-sm outline-none focus:border-primary'
|
||
/>
|
||
</div>
|
||
|
||
{isSearching && <div className='text-xs text-description mb-2'>در حال جستجو...</div>}
|
||
|
||
{searchError && <div className='text-xs text-red-500 mb-2'>{searchError}</div>}
|
||
|
||
{searchResults.length > 0 && (
|
||
<div className='mb-3 border border-border rounded-xl max-h-32 overflow-auto'>
|
||
{searchResults.map((result) => (
|
||
<button
|
||
key={result.place_id}
|
||
type='button'
|
||
onClick={() => handleSelectSearchResult(result)}
|
||
className='w-full text-right px-3 py-2 text-xs hover:bg-gray-50 border-b border-border last:border-b-0'
|
||
>
|
||
{result.display_name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className='w-full h-[400px] rounded-xl overflow-hidden'>
|
||
<MapContainer
|
||
center={position}
|
||
zoom={13}
|
||
style={{ height: '100%', width: '100%' }}
|
||
scrollWheelZoom={true}
|
||
>
|
||
<ChangeView center={position} zoom={13} />
|
||
<TileLayer
|
||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||
/>
|
||
<LocationMarker />
|
||
</MapContainer>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default LocationPicker
|
||
|