- Removed NotificationPanel component and related files - Removed notifications API endpoint and utils - Fixed data refresh functionality with cache-busting - Added refreshing state with better UI feedback - Added no-cache headers to API endpoint - Improved refresh button with loading state
223 lines
7.4 KiB
TypeScript
223 lines
7.4 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { calculateDistance, getCurrentLocation, getCoordinatesFromZip, isValidSanDiegoZip } from '../utils/location';
|
|
|
|
interface LocationData {
|
|
name: string;
|
|
lat: number;
|
|
lng: number;
|
|
available: boolean;
|
|
passType: string;
|
|
}
|
|
|
|
interface LibraryListProps {
|
|
locations: LocationData[];
|
|
}
|
|
|
|
interface LocationWithDistance extends LocationData {
|
|
distance?: number;
|
|
}
|
|
|
|
export default function LibraryList({ locations }: { readonly locations: LocationData[] }) {
|
|
// Default to 92129 zip and distance sort
|
|
const defaultZip = '92129';
|
|
const defaultCoords = getCoordinatesFromZip(defaultZip);
|
|
const [sortBy, setSortBy] = useState<'name' | 'distance' | 'availability'>('distance');
|
|
const [userLocation, setUserLocation] = useState<{ lat: number; lng: number } | null>(defaultCoords ? { lat: defaultCoords.lat, lng: defaultCoords.lng } : null);
|
|
const [locationsWithDistance, setLocationsWithDistance] = useState<LocationWithDistance[]>(locations);
|
|
const [locationPermission, setLocationPermission] = useState<'granted' | 'denied' | 'prompt' | 'loading'>(defaultCoords ? 'granted' : 'prompt');
|
|
const [zipCode, setZipCode] = useState(defaultZip);
|
|
const [zipError, setZipError] = useState('');
|
|
|
|
const requestLocation = async () => {
|
|
setLocationPermission('loading');
|
|
const location = await getCurrentLocation();
|
|
if (location) {
|
|
setUserLocation(location);
|
|
setLocationPermission('granted');
|
|
setZipCode(''); // Clear zip code when GPS is used
|
|
} else {
|
|
setLocationPermission('denied');
|
|
}
|
|
};
|
|
|
|
const handleZipCode = () => {
|
|
if (!zipCode.trim()) {
|
|
setZipError('Please enter a zip code');
|
|
return;
|
|
}
|
|
|
|
if (!isValidSanDiegoZip(zipCode.trim())) {
|
|
setZipError('Please enter a valid San Diego area zip code (92101-92173)');
|
|
return;
|
|
}
|
|
|
|
const coords = getCoordinatesFromZip(zipCode.trim());
|
|
if (coords) {
|
|
setUserLocation({ lat: coords.lat, lng: coords.lng });
|
|
setLocationPermission('granted');
|
|
setZipError('');
|
|
} else {
|
|
setZipError('Invalid zip code');
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (userLocation) {
|
|
const withDistances = locations.map(loc => ({
|
|
...loc,
|
|
distance: calculateDistance(userLocation.lat, userLocation.lng, loc.lat, loc.lng)
|
|
}));
|
|
setLocationsWithDistance(withDistances);
|
|
} else {
|
|
setLocationsWithDistance(locations);
|
|
}
|
|
}, [locations, userLocation]);
|
|
|
|
const sortedLocations = [...locationsWithDistance].sort((a, b) => {
|
|
switch (sortBy) {
|
|
case 'name':
|
|
return a.name.localeCompare(b.name);
|
|
case 'distance': {
|
|
if (!a.distance && !b.distance) return 0;
|
|
if (!a.distance) return 1;
|
|
if (!b.distance) return -1;
|
|
// Round to avoid floating point precision issues
|
|
const aDist = Math.round(a.distance * 10000) / 10000;
|
|
const bDist = Math.round(b.distance * 10000) / 10000;
|
|
return aDist - bDist;
|
|
}
|
|
case 'availability':
|
|
if (a.available === b.available) {
|
|
return a.name.localeCompare(b.name);
|
|
}
|
|
return a.available ? -1 : 1;
|
|
default:
|
|
return 0;
|
|
}
|
|
});
|
|
|
|
return (
|
|
<details style={{ marginBottom: '10px' }}>
|
|
<summary style={{ cursor: 'pointer', fontWeight: 'bold' }}>
|
|
All Libraries ({locations.length})
|
|
</summary>
|
|
|
|
<div style={{ marginTop: '8px', marginBottom: '8px' }}>
|
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', fontSize: '11px', flexWrap: 'wrap' }}>
|
|
<span style={{ fontWeight: 'bold' }}>Sort by:</span>
|
|
<select
|
|
value={sortBy}
|
|
onChange={(e) => setSortBy(e.target.value as 'name' | 'distance' | 'availability')}
|
|
style={{ fontSize: '11px', padding: '2px' }}
|
|
>
|
|
<option value="name">Name</option>
|
|
<option value="availability">Availability</option>
|
|
<option value="distance" disabled={!userLocation}>
|
|
Distance {!userLocation ? '(Location needed)' : ''}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div style={{ marginTop: '8px', fontSize: '11px' }}>
|
|
<div style={{ display: 'flex', gap: '4px', alignItems: 'center', marginBottom: '4px' }}>
|
|
{locationPermission === 'prompt' && (
|
|
<button
|
|
onClick={requestLocation}
|
|
style={{
|
|
fontSize: '10px',
|
|
padding: '2px 6px',
|
|
backgroundColor: '#2196F3',
|
|
color: 'white',
|
|
border: 'none',
|
|
borderRadius: '3px',
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
📍 Get GPS
|
|
</button>
|
|
)}
|
|
|
|
<span style={{ color: '#666' }}>or</span>
|
|
|
|
<input
|
|
type="text"
|
|
placeholder="Enter zip code"
|
|
value={zipCode}
|
|
onChange={(e) => setZipCode(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && handleZipCode()}
|
|
style={{
|
|
fontSize: '10px',
|
|
padding: '2px 4px',
|
|
border: '1px solid #ccc',
|
|
borderRadius: '3px',
|
|
width: '80px',
|
|
}}
|
|
/>
|
|
|
|
<button
|
|
onClick={handleZipCode}
|
|
style={{
|
|
fontSize: '10px',
|
|
padding: '2px 6px',
|
|
backgroundColor: '#4CAF50',
|
|
color: 'white',
|
|
border: 'none',
|
|
borderRadius: '3px',
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
Set
|
|
</button>
|
|
</div>
|
|
|
|
{zipError && (
|
|
<div style={{ fontSize: '10px', color: '#f44336', marginBottom: '4px' }}>
|
|
{zipError}
|
|
</div>
|
|
)}
|
|
|
|
{locationPermission === 'loading' && (
|
|
<span style={{ fontSize: '10px', color: '#666' }}>Getting location...</span>
|
|
)}
|
|
|
|
{locationPermission === 'denied' && (
|
|
<span style={{ fontSize: '10px', color: '#f44336' }}>GPS denied - use zip code</span>
|
|
)}
|
|
|
|
{locationPermission === 'granted' && userLocation && (
|
|
<span style={{ fontSize: '10px', color: '#4CAF50' }}>📍 Location set</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ maxHeight: '200px', overflowY: 'auto', marginTop: '5px' }}>
|
|
{sortedLocations.map((loc) => (
|
|
<div key={loc.name} style={{
|
|
fontSize: '12px',
|
|
marginBottom: '3px',
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
padding: '2px 0'
|
|
}}>
|
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
|
<span style={{ color: loc.available ? '#4CAF50' : '#f44336', marginRight: '6px' }}>●</span>
|
|
<span>{loc.name}</span>
|
|
</div>
|
|
<div style={{ fontSize: '10px', color: '#666', textAlign: 'right' }}>
|
|
{loc.distance && (
|
|
<div>{loc.distance.toFixed(1)} mi</div>
|
|
)}
|
|
<div style={{ color: loc.available ? '#4CAF50' : '#f44336' }}>
|
|
{loc.available ? 'Available' : 'Not Available'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</details>
|
|
);
|
|
}
|