387 lines
14 KiB
TypeScript
387 lines
14 KiB
TypeScript
|
|
import React, { useState, useEffect, useRef } from 'react'
|
|||
|
|
import { motion } from 'framer-motion'
|
|||
|
|
import { HiX, HiOutlineSearch, HiOutlineLocationMarker } from 'react-icons/hi'
|
|||
|
|
import classNames from 'classnames'
|
|||
|
|
|
|||
|
|
export interface Location {
|
|||
|
|
id: string
|
|||
|
|
name: string
|
|||
|
|
address: string
|
|||
|
|
lat: number
|
|||
|
|
lng: number
|
|||
|
|
placeId?: string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface LocationPickerProps {
|
|||
|
|
onSelect: (location: Location) => void
|
|||
|
|
onClose: () => void
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Google Maps API key - .env dosyasından alınmalı
|
|||
|
|
const GOOGLE_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_API_KEY || ''
|
|||
|
|
|
|||
|
|
declare global {
|
|||
|
|
interface Window {
|
|||
|
|
google: any
|
|||
|
|
initGoogleMaps?: () => void
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const LocationPicker: React.FC<LocationPickerProps> = ({ onSelect, onClose }) => {
|
|||
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|||
|
|
const [locations, setLocations] = useState<Location[]>([])
|
|||
|
|
const [selectedLocation, setSelectedLocation] = useState<Location | null>(null)
|
|||
|
|
const [isLoading, setIsLoading] = useState(false)
|
|||
|
|
const [error, setError] = useState<string | null>(null)
|
|||
|
|
const [isGoogleLoaded, setIsGoogleLoaded] = useState(false)
|
|||
|
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
|||
|
|
const autocompleteServiceRef = useRef<any>(null)
|
|||
|
|
const placesServiceRef = useRef<any>(null)
|
|||
|
|
const debounceTimerRef = useRef<NodeJS.Timeout>()
|
|||
|
|
const scriptLoadedRef = useRef(false)
|
|||
|
|
|
|||
|
|
// Google Maps SDK'yı yükle
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (scriptLoadedRef.current) return
|
|||
|
|
|
|||
|
|
const loadGoogleMaps = () => {
|
|||
|
|
if (window.google && window.google.maps && window.google.maps.places) {
|
|||
|
|
setIsGoogleLoaded(true)
|
|||
|
|
autocompleteServiceRef.current = new window.google.maps.places.AutocompleteService()
|
|||
|
|
const mapDiv = document.createElement('div')
|
|||
|
|
const map = new window.google.maps.Map(mapDiv)
|
|||
|
|
placesServiceRef.current = new window.google.maps.places.PlacesService(map)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!GOOGLE_API_KEY) {
|
|||
|
|
setError('Google Maps API anahtarı bulunamadı. Lütfen .env dosyasına VITE_GOOGLE_MAPS_API_KEY ekleyin.')
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Script zaten yüklendiyse sadece bekle
|
|||
|
|
const existingScript = document.querySelector('script[src*="maps.googleapis.com"]')
|
|||
|
|
if (existingScript) {
|
|||
|
|
const checkInterval = setInterval(() => {
|
|||
|
|
if (window.google && window.google.maps && window.google.maps.places) {
|
|||
|
|
clearInterval(checkInterval)
|
|||
|
|
setIsGoogleLoaded(true)
|
|||
|
|
autocompleteServiceRef.current = new window.google.maps.places.AutocompleteService()
|
|||
|
|
const mapDiv = document.createElement('div')
|
|||
|
|
const map = new window.google.maps.Map(mapDiv)
|
|||
|
|
placesServiceRef.current = new window.google.maps.places.PlacesService(map)
|
|||
|
|
}
|
|||
|
|
}, 100)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Yeni script ekle
|
|||
|
|
const script = document.createElement('script')
|
|||
|
|
script.src = `https://maps.googleapis.com/maps/api/js?key=${GOOGLE_API_KEY}&libraries=places&language=tr`
|
|||
|
|
script.async = true
|
|||
|
|
script.defer = true
|
|||
|
|
|
|||
|
|
script.onload = () => {
|
|||
|
|
if (window.google && window.google.maps && window.google.maps.places) {
|
|||
|
|
setIsGoogleLoaded(true)
|
|||
|
|
autocompleteServiceRef.current = new window.google.maps.places.AutocompleteService()
|
|||
|
|
const mapDiv = document.createElement('div')
|
|||
|
|
const map = new window.google.maps.Map(mapDiv)
|
|||
|
|
placesServiceRef.current = new window.google.maps.places.PlacesService(map)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
script.onerror = () => {
|
|||
|
|
setError('Google Maps yüklenemedi. Lütfen internet bağlantınızı kontrol edin.')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
document.head.appendChild(script)
|
|||
|
|
scriptLoadedRef.current = true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
loadGoogleMaps()
|
|||
|
|
}, [])
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
searchInputRef.current?.focus()
|
|||
|
|
}, [])
|
|||
|
|
|
|||
|
|
// Google Places Autocomplete ile konum arama
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (debounceTimerRef.current) {
|
|||
|
|
clearTimeout(debounceTimerRef.current)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (searchQuery.trim() === '') {
|
|||
|
|
setLocations([])
|
|||
|
|
setError(null)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!isGoogleLoaded) {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
debounceTimerRef.current = setTimeout(async () => {
|
|||
|
|
setIsLoading(true)
|
|||
|
|
setError(null)
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
// Google Places Autocomplete Service kullan (CORS yok)
|
|||
|
|
autocompleteServiceRef.current.getPlacePredictions(
|
|||
|
|
{
|
|||
|
|
input: searchQuery,
|
|||
|
|
componentRestrictions: { country: 'tr' },
|
|||
|
|
language: 'tr'
|
|||
|
|
},
|
|||
|
|
async (predictions: any, status: any) => {
|
|||
|
|
if (status === window.google.maps.places.PlacesServiceStatus.ZERO_RESULTS) {
|
|||
|
|
setLocations([])
|
|||
|
|
setIsLoading(false)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (status !== window.google.maps.places.PlacesServiceStatus.OK) {
|
|||
|
|
setError('Konum arama başarısız')
|
|||
|
|
setIsLoading(false)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!predictions || predictions.length === 0) {
|
|||
|
|
setLocations([])
|
|||
|
|
setIsLoading(false)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Her bir prediction için detaylı bilgi al
|
|||
|
|
const detailedLocations: Location[] = []
|
|||
|
|
let completed = 0
|
|||
|
|
|
|||
|
|
predictions.forEach((prediction: any) => {
|
|||
|
|
placesServiceRef.current.getDetails(
|
|||
|
|
{
|
|||
|
|
placeId: prediction.place_id,
|
|||
|
|
fields: ['name', 'formatted_address', 'geometry', 'place_id']
|
|||
|
|
},
|
|||
|
|
(place: any, placeStatus: any) => {
|
|||
|
|
completed++
|
|||
|
|
|
|||
|
|
if (placeStatus === window.google.maps.places.PlacesServiceStatus.OK && place) {
|
|||
|
|
detailedLocations.push({
|
|||
|
|
id: place.place_id,
|
|||
|
|
name: place.name,
|
|||
|
|
address: place.formatted_address,
|
|||
|
|
lat: place.geometry.location.lat(),
|
|||
|
|
lng: place.geometry.location.lng(),
|
|||
|
|
placeId: place.place_id
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Tüm istekler tamamlandıysa state'i güncelle
|
|||
|
|
if (completed === predictions.length) {
|
|||
|
|
setLocations(detailedLocations)
|
|||
|
|
setIsLoading(false)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('Location search error:', err)
|
|||
|
|
setError('Konum arama sırasında bir hata oluştu')
|
|||
|
|
setIsLoading(false)
|
|||
|
|
}
|
|||
|
|
}, 500) // 500ms debounce
|
|||
|
|
|
|||
|
|
return () => {
|
|||
|
|
if (debounceTimerRef.current) {
|
|||
|
|
clearTimeout(debounceTimerRef.current)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}, [searchQuery, isGoogleLoaded])
|
|||
|
|
|
|||
|
|
const handleSelect = (location: Location) => {
|
|||
|
|
setSelectedLocation(location)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleConfirm = () => {
|
|||
|
|
if (selectedLocation) {
|
|||
|
|
onSelect(selectedLocation)
|
|||
|
|
onClose()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|||
|
|
<motion.div
|
|||
|
|
initial={{ opacity: 0, scale: 0.9 }}
|
|||
|
|
animate={{ opacity: 1, scale: 1 }}
|
|||
|
|
exit={{ opacity: 0, scale: 0.9 }}
|
|||
|
|
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col"
|
|||
|
|
>
|
|||
|
|
{/* Header */}
|
|||
|
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
|||
|
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Konum Ekle</h2>
|
|||
|
|
<button
|
|||
|
|
onClick={onClose}
|
|||
|
|
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full transition-colors"
|
|||
|
|
>
|
|||
|
|
<HiX className="w-5 h-5 text-gray-500 dark:text-gray-400" />
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Search */}
|
|||
|
|
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
|||
|
|
<div className="relative">
|
|||
|
|
<HiOutlineSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
|||
|
|
<input
|
|||
|
|
ref={searchInputRef}
|
|||
|
|
type="text"
|
|||
|
|
value={searchQuery}
|
|||
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|||
|
|
placeholder="Konum ara..."
|
|||
|
|
disabled={!isGoogleLoaded}
|
|||
|
|
className="w-full pl-10 pr-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-600 disabled:cursor-not-allowed"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
{!isGoogleLoaded && (
|
|||
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
|||
|
|
Google Maps yükleniyor...
|
|||
|
|
</p>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Location List */}
|
|||
|
|
<div className="flex-1 overflow-y-auto p-4">
|
|||
|
|
{!isGoogleLoaded ? (
|
|||
|
|
<div className="text-center py-12">
|
|||
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
|||
|
|
<p className="text-gray-500 dark:text-gray-400">Google Maps yükleniyor...</p>
|
|||
|
|
</div>
|
|||
|
|
) : isLoading ? (
|
|||
|
|
<div className="text-center py-12">
|
|||
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
|||
|
|
<p className="text-gray-500 dark:text-gray-400">Konumlar aranıyor...</p>
|
|||
|
|
</div>
|
|||
|
|
) : error ? (
|
|||
|
|
<div className="text-center py-12">
|
|||
|
|
<HiOutlineLocationMarker className="w-16 h-16 mx-auto mb-4 text-red-400" />
|
|||
|
|
<p className="text-red-500 dark:text-red-400">{error}</p>
|
|||
|
|
</div>
|
|||
|
|
) : searchQuery.trim() === '' ? (
|
|||
|
|
<div className="text-center py-12">
|
|||
|
|
<HiOutlineSearch className="w-16 h-16 mx-auto mb-4 text-gray-400" />
|
|||
|
|
<p className="text-gray-500 dark:text-gray-400">
|
|||
|
|
Aramak istediğiniz konumu yazın
|
|||
|
|
</p>
|
|||
|
|
<p className="text-sm text-gray-400 dark:text-gray-500 mt-2">
|
|||
|
|
Örn: Taksim, İstanbul
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
) : locations.length === 0 ? (
|
|||
|
|
<div className="text-center py-12">
|
|||
|
|
<HiOutlineLocationMarker className="w-16 h-16 mx-auto mb-4 text-gray-400" />
|
|||
|
|
<p className="text-gray-500 dark:text-gray-400">
|
|||
|
|
Konum bulunamadı. Farklı bir arama yapın.
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
{locations.map((location) => (
|
|||
|
|
<button
|
|||
|
|
key={location.id}
|
|||
|
|
onClick={() => handleSelect(location)}
|
|||
|
|
className={classNames(
|
|||
|
|
'w-full text-left p-3 rounded-lg transition-all hover:bg-gray-50 dark:hover:bg-gray-700',
|
|||
|
|
selectedLocation?.id === location.id
|
|||
|
|
? 'bg-blue-50 dark:bg-blue-900/30 border-2 border-blue-500'
|
|||
|
|
: 'border-2 border-transparent'
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
<div className="flex items-start gap-3">
|
|||
|
|
<div className="mt-1">
|
|||
|
|
<HiOutlineLocationMarker
|
|||
|
|
className={classNames(
|
|||
|
|
'w-5 h-5',
|
|||
|
|
selectedLocation?.id === location.id
|
|||
|
|
? 'text-blue-600'
|
|||
|
|
: 'text-gray-400'
|
|||
|
|
)}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex-1 min-w-0">
|
|||
|
|
<h3
|
|||
|
|
className={classNames(
|
|||
|
|
'font-semibold mb-1',
|
|||
|
|
selectedLocation?.id === location.id
|
|||
|
|
? 'text-blue-600 dark:text-blue-400'
|
|||
|
|
: 'text-gray-900 dark:text-gray-100'
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
{location.name}
|
|||
|
|
</h3>
|
|||
|
|
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
|||
|
|
{location.address}
|
|||
|
|
</p>
|
|||
|
|
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
|||
|
|
{location.lat.toFixed(4)}, {location.lng.toFixed(4)}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
{selectedLocation?.id === location.id && (
|
|||
|
|
<div className="mt-1">
|
|||
|
|
<div className="w-5 h-5 bg-blue-600 rounded-full flex items-center justify-center">
|
|||
|
|
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
|||
|
|
<path
|
|||
|
|
fillRule="evenodd"
|
|||
|
|
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
|||
|
|
clipRule="evenodd"
|
|||
|
|
/>
|
|||
|
|
</svg>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Footer */}
|
|||
|
|
<div className="flex items-center justify-between p-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-750">
|
|||
|
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
|||
|
|
{selectedLocation ? (
|
|||
|
|
<span className="flex items-center gap-2">
|
|||
|
|
<HiOutlineLocationMarker className="w-4 h-4 text-blue-600" />
|
|||
|
|
<span className="font-medium text-gray-900 dark:text-gray-100">
|
|||
|
|
{selectedLocation.name}
|
|||
|
|
</span>
|
|||
|
|
</span>
|
|||
|
|
) : (
|
|||
|
|
<span>Bir konum seçin</span>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
<div className="flex gap-2">
|
|||
|
|
<button
|
|||
|
|
onClick={onClose}
|
|||
|
|
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
|||
|
|
>
|
|||
|
|
İptal
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
onClick={handleConfirm}
|
|||
|
|
disabled={!selectedLocation}
|
|||
|
|
className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
|||
|
|
>
|
|||
|
|
Ekle
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</motion.div>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export default LocationPicker
|