mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-24 16:11:12 -05:00
Adds a street_address column to the locations table and updates the LocationSearch component to capture, store, and display full street addresses. This includes database migration, interface updates, and formatter logic.
313 lines
10 KiB
TypeScript
313 lines
10 KiB
TypeScript
import { useState, useCallback, useEffect } from 'react';
|
|
import { useDebounce } from '@/hooks/useDebounce';
|
|
import { supabase } from '@/lib/supabaseClient';
|
|
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';
|
|
import { handleNonCriticalError } from '@/lib/errorHandler';
|
|
|
|
interface LocationResult {
|
|
place_id: number;
|
|
display_name: string;
|
|
lat: string;
|
|
lon: string;
|
|
address: {
|
|
house_number?: string;
|
|
road?: string;
|
|
city?: string;
|
|
town?: string;
|
|
village?: string;
|
|
municipality?: string;
|
|
state?: string;
|
|
province?: string;
|
|
state_district?: string;
|
|
county?: string;
|
|
region?: string;
|
|
territory?: string;
|
|
country?: string;
|
|
country_code?: string;
|
|
postcode?: string;
|
|
};
|
|
}
|
|
|
|
interface SelectedLocation {
|
|
name: string;
|
|
street_address?: string;
|
|
city?: string;
|
|
state_province?: string;
|
|
country: string;
|
|
postal_code?: string;
|
|
latitude: number;
|
|
longitude: number;
|
|
timezone?: string;
|
|
display_name: string; // Full OSM display name for reference
|
|
}
|
|
|
|
interface LocationSearchProps {
|
|
onLocationSelect: (location: SelectedLocation) => void;
|
|
initialLocationId?: string;
|
|
className?: string;
|
|
}
|
|
|
|
export function LocationSearch({ onLocationSelect, initialLocationId, className }: LocationSearchProps): React.JSX.Element {
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [results, setResults] = useState<LocationResult[]>([]);
|
|
const [isSearching, setIsSearching] = useState(false);
|
|
const [searchError, setSearchError] = useState<string | null>(null);
|
|
const [selectedLocation, setSelectedLocation] = useState<SelectedLocation | null>(null);
|
|
const [showResults, setShowResults] = useState(false);
|
|
|
|
const debouncedSearch = useDebounce(searchQuery, 500);
|
|
|
|
// Load initial location if editing
|
|
useEffect(() => {
|
|
if (initialLocationId) {
|
|
void loadInitialLocation(initialLocationId);
|
|
}
|
|
}, [initialLocationId]);
|
|
|
|
const loadInitialLocation = async (locationId: string): Promise<void> => {
|
|
const { data, error } = await supabase
|
|
.from('locations')
|
|
.select('id, name, street_address, city, state_province, country, postal_code, latitude, longitude, timezone')
|
|
.eq('id', locationId)
|
|
.maybeSingle();
|
|
|
|
if (data && !error) {
|
|
setSelectedLocation({
|
|
name: data.name,
|
|
street_address: data.street_address || undefined,
|
|
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,
|
|
display_name: data.name, // Use name as display for existing locations
|
|
});
|
|
}
|
|
};
|
|
|
|
const searchLocations = useCallback(async (query: string) => {
|
|
if (!query || query.length < 3) {
|
|
setResults([]);
|
|
setSearchError(null);
|
|
return;
|
|
}
|
|
|
|
setIsSearching(true);
|
|
setSearchError(null);
|
|
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',
|
|
},
|
|
}
|
|
);
|
|
|
|
// Check if response is OK and content-type is JSON
|
|
if (!response.ok) {
|
|
const errorMsg = `Location search failed (${response.status}). Please try again.`;
|
|
setSearchError(errorMsg);
|
|
setResults([]);
|
|
setShowResults(false);
|
|
return;
|
|
}
|
|
|
|
const contentType = response.headers.get('content-type');
|
|
if (!contentType || !contentType.includes('application/json')) {
|
|
const errorMsg = 'Invalid response from location service. Please try again.';
|
|
setSearchError(errorMsg);
|
|
setResults([]);
|
|
setShowResults(false);
|
|
return;
|
|
}
|
|
|
|
const data = await response.json() as LocationResult[];
|
|
setResults(data);
|
|
setShowResults(true);
|
|
setSearchError(null);
|
|
} catch (error: unknown) {
|
|
handleNonCriticalError(error, {
|
|
action: 'Search locations',
|
|
metadata: { query: searchQuery }
|
|
});
|
|
setSearchError('Failed to search locations. Please check your connection.');
|
|
setResults([]);
|
|
setShowResults(false);
|
|
} finally {
|
|
setIsSearching(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (debouncedSearch) {
|
|
void searchLocations(debouncedSearch);
|
|
} else {
|
|
setResults([]);
|
|
setShowResults(false);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [debouncedSearch]);
|
|
|
|
const handleSelectResult = (result: LocationResult): void => {
|
|
const latitude = parseFloat(result.lat);
|
|
const longitude = parseFloat(result.lon);
|
|
|
|
// Safely access address properties with fallback
|
|
const address = result.address || {};
|
|
|
|
// Extract street address components
|
|
const houseNumber = address.house_number || '';
|
|
const road = address.road || '';
|
|
const streetAddress = [houseNumber, road].filter(Boolean).join(' ').trim() || undefined;
|
|
|
|
// Extract city
|
|
const city = address.city || address.town || address.village || address.municipality;
|
|
|
|
// Extract state/province (try multiple fields for international support)
|
|
const state = address.state ||
|
|
address.province ||
|
|
address.state_district ||
|
|
address.county ||
|
|
address.region ||
|
|
address.territory;
|
|
|
|
const country = address.country || 'Unknown';
|
|
const postalCode = address.postcode;
|
|
|
|
// Build location name
|
|
const locationParts = [streetAddress, city, state, country].filter(Boolean);
|
|
const locationName = locationParts.join(', ');
|
|
|
|
// Build location data object (no database operations)
|
|
const locationData: SelectedLocation = {
|
|
name: locationName,
|
|
street_address: streetAddress,
|
|
city: city || undefined,
|
|
state_province: state || undefined,
|
|
country: country,
|
|
postal_code: postalCode || undefined,
|
|
latitude,
|
|
longitude,
|
|
timezone: undefined, // Will be set by server during approval if needed
|
|
display_name: result.display_name,
|
|
};
|
|
|
|
setSelectedLocation(locationData);
|
|
setSearchQuery('');
|
|
setResults([]);
|
|
setShowResults(false);
|
|
onLocationSelect(locationData);
|
|
};
|
|
|
|
const handleClear = (): void => {
|
|
setSelectedLocation(null);
|
|
setSearchQuery('');
|
|
setResults([]);
|
|
setShowResults(false);
|
|
};
|
|
|
|
return (
|
|
<div className={className}>
|
|
{!selectedLocation ? (
|
|
<div className="space-y-2">
|
|
<div className="relative">
|
|
<MapPin className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
type="text"
|
|
placeholder="Search for a location (city, address, landmark...)"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-10"
|
|
/>
|
|
{isSearching && (
|
|
<Loader2 className="absolute right-3 top-3 h-4 w-4 animate-spin text-muted-foreground" />
|
|
)}
|
|
</div>
|
|
|
|
{searchError && (
|
|
<div className="text-sm text-destructive mt-1">
|
|
{searchError}
|
|
</div>
|
|
)}
|
|
|
|
{showResults && results.length > 0 && (
|
|
<Card className="absolute z-50 w-full max-h-64 overflow-y-auto">
|
|
<div className="divide-y">
|
|
{results.map((result) => (
|
|
<button
|
|
type="button"
|
|
key={result.place_id}
|
|
onClick={() => void handleSelectResult(result)}
|
|
className="w-full text-left p-3 hover:bg-accent transition-colors"
|
|
>
|
|
<div className="flex items-start gap-2">
|
|
<MapPin className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium truncate">{result.display_name}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{result.lat}, {result.lon}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
{showResults && results.length === 0 && !isSearching && !searchError && (
|
|
<div className="text-sm text-muted-foreground mt-1">
|
|
No locations found. Try a different search term.
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
<Card className="p-4">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex items-start gap-3 flex-1">
|
|
<MapPin className="h-5 w-5 text-primary mt-0.5" />
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium">{selectedLocation.name}</p>
|
|
<div className="text-sm text-muted-foreground space-y-1 mt-1">
|
|
{selectedLocation.street_address && <p>Street: {selectedLocation.street_address}</p>}
|
|
{selectedLocation.city && <p>City: {selectedLocation.city}</p>}
|
|
{selectedLocation.state_province && <p>State/Province: {selectedLocation.state_province}</p>}
|
|
<p>Country: {selectedLocation.country}</p>
|
|
{selectedLocation.postal_code && <p>Postal Code: {selectedLocation.postal_code}</p>}
|
|
<p className="text-xs">
|
|
Coordinates: {selectedLocation.latitude.toFixed(6)}, {selectedLocation.longitude.toFixed(6)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleClear}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
|
|
<ParkLocationMap
|
|
latitude={selectedLocation.latitude}
|
|
longitude={selectedLocation.longitude}
|
|
parkName={selectedLocation.name}
|
|
className="h-48"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|