From 4d7c30497690315ea7d12ddfd83813e89482315e Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 2 Oct 2025 20:25:26 +0000 Subject: [PATCH] feat: Add OpenStreetMap integration to ParkForm --- src/components/admin/LocationSearch.tsx | 278 ++++++++++++++++++++++++ src/components/admin/ParkForm.tsx | 17 ++ 2 files changed, 295 insertions(+) create mode 100644 src/components/admin/LocationSearch.tsx diff --git a/src/components/admin/LocationSearch.tsx b/src/components/admin/LocationSearch.tsx new file mode 100644 index 00000000..42e86998 --- /dev/null +++ b/src/components/admin/LocationSearch.tsx @@ -0,0 +1,278 @@ +import { useState, useCallback, useEffect } from 'react'; +import { useDebounce } from '@/hooks/useDebounce'; +import { supabase } from '@/integrations/supabase/client'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { MapPin, Loader2, X } from 'lucide-react'; +import { ParkLocationMap } from '@/components/maps/ParkLocationMap'; + +interface LocationResult { + place_id: number; + display_name: string; + lat: string; + lon: string; + address: { + city?: string; + town?: string; + village?: string; + state?: string; + country?: string; + postcode?: string; + }; +} + +interface SelectedLocation { + id?: string; + name: string; + city?: string; + state_province?: string; + country: string; + postal_code?: string; + latitude: number; + longitude: number; + timezone?: string; +} + +interface LocationSearchProps { + onLocationSelect: (location: SelectedLocation) => void; + initialLocationId?: string; + className?: string; +} + +export function LocationSearch({ onLocationSelect, initialLocationId, className }: LocationSearchProps) { + const [searchQuery, setSearchQuery] = useState(''); + const [results, setResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [selectedLocation, setSelectedLocation] = useState(null); + const [showResults, setShowResults] = useState(false); + + const debouncedSearch = useDebounce(searchQuery, 500); + + // Load initial location if editing + useEffect(() => { + if (initialLocationId) { + loadInitialLocation(initialLocationId); + } + }, [initialLocationId]); + + const loadInitialLocation = async (locationId: string) => { + const { data, error } = await supabase + .from('locations') + .select('*') + .eq('id', locationId) + .single(); + + if (data && !error) { + setSelectedLocation({ + id: data.id, + name: data.name, + city: data.city || undefined, + state_province: data.state_province || undefined, + country: data.country, + postal_code: data.postal_code || undefined, + latitude: parseFloat(data.latitude?.toString() || '0'), + longitude: parseFloat(data.longitude?.toString() || '0'), + timezone: data.timezone || undefined, + }); + } + }; + + const searchLocations = useCallback(async (query: string) => { + if (!query || query.length < 3) { + setResults([]); + return; + } + + setIsSearching(true); + try { + const response = await fetch( + `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&addressdetails=1&limit=5`, + { + headers: { + 'User-Agent': 'ThemeParkDatabase/1.0', + }, + } + ); + const data = await response.json(); + setResults(data); + setShowResults(true); + } catch (error) { + console.error('Error searching locations:', error); + setResults([]); + } finally { + setIsSearching(false); + } + }, []); + + useEffect(() => { + if (debouncedSearch) { + searchLocations(debouncedSearch); + } else { + setResults([]); + setShowResults(false); + } + }, [debouncedSearch, searchLocations]); + + const handleSelectResult = async (result: LocationResult) => { + const latitude = parseFloat(result.lat); + const longitude = parseFloat(result.lon); + + const city = result.address.city || result.address.town || result.address.village; + const locationName = city + ? `${city}, ${result.address.state || ''} ${result.address.country}`.trim() + : result.display_name; + + // Check if location exists in database + const { data: existingLocation } = await supabase + .from('locations') + .select('*') + .eq('latitude', latitude) + .eq('longitude', longitude) + .maybeSingle(); + + let locationData: SelectedLocation; + + if (existingLocation) { + locationData = { + id: existingLocation.id, + name: existingLocation.name, + city: existingLocation.city || undefined, + state_province: existingLocation.state_province || undefined, + country: existingLocation.country, + postal_code: existingLocation.postal_code || undefined, + latitude: parseFloat(existingLocation.latitude?.toString() || '0'), + longitude: parseFloat(existingLocation.longitude?.toString() || '0'), + timezone: existingLocation.timezone || undefined, + }; + } else { + // Create new location + const { data: newLocation, error } = await supabase + .from('locations') + .insert({ + name: locationName, + city: city || null, + state_province: result.address.state || null, + country: result.address.country || '', + postal_code: result.address.postcode || null, + latitude, + longitude, + }) + .select() + .single(); + + if (error || !newLocation) { + console.error('Error creating location:', error); + return; + } + + locationData = { + id: newLocation.id, + name: newLocation.name, + city: newLocation.city || undefined, + state_province: newLocation.state_province || undefined, + country: newLocation.country, + postal_code: newLocation.postal_code || undefined, + latitude: parseFloat(newLocation.latitude?.toString() || '0'), + longitude: parseFloat(newLocation.longitude?.toString() || '0'), + timezone: newLocation.timezone || undefined, + }; + } + + setSelectedLocation(locationData); + setSearchQuery(''); + setResults([]); + setShowResults(false); + onLocationSelect(locationData); + }; + + const handleClear = () => { + setSelectedLocation(null); + setSearchQuery(''); + setResults([]); + setShowResults(false); + }; + + return ( +
+ {!selectedLocation ? ( +
+
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> + {isSearching && ( + + )} +
+ + {showResults && results.length > 0 && ( + +
+ {results.map((result) => ( + + ))} +
+
+ )} +
+ ) : ( +
+ +
+
+ +
+

{selectedLocation.name}

+
+ {selectedLocation.city &&

City: {selectedLocation.city}

} + {selectedLocation.state_province &&

State/Province: {selectedLocation.state_province}

} +

Country: {selectedLocation.country}

+ {selectedLocation.postal_code &&

Postal Code: {selectedLocation.postal_code}

} +

+ Coordinates: {selectedLocation.latitude.toFixed(6)}, {selectedLocation.longitude.toFixed(6)} +

+
+
+
+ +
+
+ + +
+ )} +
+ ); +} diff --git a/src/components/admin/ParkForm.tsx b/src/components/admin/ParkForm.tsx index 1825521b..4fc1ae87 100644 --- a/src/components/admin/ParkForm.tsx +++ b/src/components/admin/ParkForm.tsx @@ -18,6 +18,7 @@ import { Combobox } from '@/components/ui/combobox'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { useOperators, usePropertyOwners } from '@/hooks/useAutocompleteData'; import { useUserRole } from '@/hooks/useUserRole'; +import { LocationSearch } from './LocationSearch'; const parkSchema = z.object({ name: z.string().min(1, 'Park name is required'), @@ -27,6 +28,7 @@ const parkSchema = z.object({ status: z.string().min(1, 'Status is required'), opening_date: z.string().optional(), closing_date: z.string().optional(), + location_id: z.string().uuid().optional(), website_url: z.string().url().optional().or(z.literal('')), phone: z.string().optional(), email: z.string().email().optional().or(z.literal('')), @@ -118,6 +120,7 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }: status: initialData?.status || 'Operating', opening_date: initialData?.opening_date || '', closing_date: initialData?.closing_date || '', + location_id: (initialData as any)?.location_id || undefined, website_url: initialData?.website_url || '', phone: initialData?.phone || '', email: initialData?.email || '', @@ -281,6 +284,20 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }: + {/* Location */} +
+ + { + setValue('location_id', location.id); + }} + initialLocationId={watch('location_id')} + /> +

+ Search for the park's location using OpenStreetMap +

+
+ {/* Operator & Property Owner Selection */}

Operator & Property Owner