Initial commit: working SD Park Pass Map app with deploy scripts and .gitignore
This commit is contained in:
222
app/components/LibraryList.tsx
Normal file
222
app/components/LibraryList.tsx
Normal file
@ -0,0 +1,222 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { calculateDistance, getCurrentLocation, getCoordinatesFromZip, isValidSanDiegoZip } from '../utils/notifications';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
84
app/components/MapComponent.tsx
Normal file
84
app/components/MapComponent.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
// Fix for default markers in react-leaflet
|
||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
|
||||
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
|
||||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
|
||||
});
|
||||
|
||||
interface LocationData {
|
||||
name: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
available: boolean;
|
||||
passType: string;
|
||||
}
|
||||
|
||||
interface MapComponentProps {
|
||||
locations: LocationData[];
|
||||
}
|
||||
|
||||
const createCustomIcon = (available: boolean) => {
|
||||
const color = available ? '#4CAF50' : '#f44336';
|
||||
|
||||
return L.divIcon({
|
||||
className: 'custom-marker',
|
||||
html: `<div style="
|
||||
background-color: ${color};
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||
"></div>`,
|
||||
iconSize: [20, 20],
|
||||
iconAnchor: [10, 10],
|
||||
});
|
||||
};
|
||||
|
||||
export default function MapComponent({ locations }: MapComponentProps) {
|
||||
// Center map on San Diego
|
||||
const center: [number, number] = [32.7157, -117.1611];
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={11}
|
||||
zoomControl={false}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
|
||||
{locations.map((location, index) => (
|
||||
<Marker
|
||||
key={`${location.name}-${index}`}
|
||||
position={[location.lat, location.lng]}
|
||||
icon={createCustomIcon(location.available)}
|
||||
>
|
||||
<Popup>
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 8px 0', fontSize: '16px' }}>
|
||||
{location.name} Library
|
||||
</h3>
|
||||
<p style={{ margin: '4px 0' }}>
|
||||
<strong>Status:</strong> {location.available ? 'Available' : 'Not Available'}
|
||||
</p>
|
||||
<p style={{ margin: '4px 0' }}>
|
||||
<strong>Pass Type:</strong> {location.passType}
|
||||
</p>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</MapContainer>
|
||||
);
|
||||
}
|
||||
275
app/components/NotificationPanel.tsx
Normal file
275
app/components/NotificationPanel.tsx
Normal file
@ -0,0 +1,275 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { NotificationManager, type NotificationRequest, WebPushManager } from '../utils/notifications';
|
||||
|
||||
interface LocationData {
|
||||
name: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
available: boolean;
|
||||
passType: string;
|
||||
}
|
||||
|
||||
interface NotificationPanelProps {
|
||||
locations: LocationData[];
|
||||
}
|
||||
|
||||
export default function NotificationPanel({ locations }: { readonly locations: LocationData[] }) {
|
||||
const [notifications, setNotifications] = useState<NotificationRequest[]>(() =>
|
||||
NotificationManager.getNotifications()
|
||||
);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [selectedLibrary, setSelectedLibrary] = useState('');
|
||||
const [selectedPassTypes, setSelectedPassTypes] = useState<string[]>([]);
|
||||
const [notificationPermission, setNotificationPermission] = useState<NotificationPermission>(
|
||||
WebPushManager.isSupported() ? WebPushManager.getPermissionStatus() : 'denied'
|
||||
);
|
||||
|
||||
const handlePassTypeChange = (type: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedPassTypes(prev => [...prev, type]);
|
||||
} else {
|
||||
setSelectedPassTypes(prev => prev.filter(t => t !== type));
|
||||
}
|
||||
};
|
||||
|
||||
const requestNotificationPermission = async () => {
|
||||
const granted = await WebPushManager.requestPermission();
|
||||
setNotificationPermission(granted ? 'granted' : 'denied');
|
||||
};
|
||||
|
||||
const handleAddNotification = () => {
|
||||
if (!selectedLibrary || selectedPassTypes.length === 0) return;
|
||||
|
||||
const newNotification = NotificationManager.addNotification({
|
||||
libraryName: selectedLibrary,
|
||||
passTypes: selectedPassTypes as ('Pass Only' | 'Backpack Only' | 'Both')[],
|
||||
});
|
||||
|
||||
setNotifications(prev => [...prev, newNotification]);
|
||||
setShowAddForm(false);
|
||||
setSelectedLibrary('');
|
||||
setSelectedPassTypes([]);
|
||||
};
|
||||
|
||||
const handleRemoveNotification = (id: string) => {
|
||||
NotificationManager.removeNotification(id);
|
||||
setNotifications(prev => prev.filter(n => n.id !== id));
|
||||
};
|
||||
|
||||
const checkNotifications = async () => {
|
||||
const availableNow = notifications.filter(notification => {
|
||||
const library = locations.find(loc => loc.name === notification.libraryName);
|
||||
return library && library.available && (
|
||||
notification.passTypes.includes('Both') ||
|
||||
notification.passTypes.some(type => library.passType.includes(type.replace(' Only', '')))
|
||||
);
|
||||
});
|
||||
|
||||
if (availableNow.length > 0) {
|
||||
const message = availableNow.map(n => `${n.libraryName} has passes available!`).join('\n');
|
||||
|
||||
// Try web notification first
|
||||
if (notificationPermission === 'granted') {
|
||||
for (const notification of availableNow) {
|
||||
await WebPushManager.showNotification(
|
||||
'🎉 Beach Pass Available!',
|
||||
`${notification.libraryName} has ${notification.passTypes.join(' and ')} available!`,
|
||||
{
|
||||
tag: `park-pass-${notification.id}`,
|
||||
requireInteraction: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Also show alert for immediate feedback
|
||||
alert(`🎉 Great news!\n\n${message}`);
|
||||
|
||||
// Update last checked for all notifications
|
||||
notifications.forEach(n => NotificationManager.updateLastChecked(n.id));
|
||||
} else {
|
||||
alert('No passes currently available for your requested libraries.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: '15px', borderTop: '1px solid #eee', paddingTop: '15px' }}>
|
||||
<h4 style={{ marginBottom: '10px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span>🔔 Notifications ({notifications.length})</span>
|
||||
<button
|
||||
onClick={() => setShowAddForm(!showAddForm)}
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#4CAF50',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{showAddForm ? 'Cancel' : '+ Add'}
|
||||
</button>
|
||||
</h4>
|
||||
|
||||
{/* Notification Permission Status */}
|
||||
{WebPushManager.isSupported() && (
|
||||
<div style={{
|
||||
marginBottom: '10px',
|
||||
padding: '8px',
|
||||
backgroundColor: notificationPermission === 'granted' ? '#e8f5e8' : '#fff3cd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
border: `1px solid ${notificationPermission === 'granted' ? '#4CAF50' : '#ffc107'}`
|
||||
}}>
|
||||
<div style={{ fontWeight: 'bold', marginBottom: '4px' }}>
|
||||
Push Notifications: {notificationPermission === 'granted' ? '✅ Enabled' : '❌ Disabled'}
|
||||
</div>
|
||||
{notificationPermission !== 'granted' && (
|
||||
<div>
|
||||
<div style={{ marginBottom: '6px', color: '#666' }}>
|
||||
Enable push notifications to get alerts when passes become available.
|
||||
</div>
|
||||
<button
|
||||
onClick={requestNotificationPermission}
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#007cba',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Enable Notifications
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAddForm && (
|
||||
<div style={{
|
||||
backgroundColor: '#f9f9f9',
|
||||
padding: '10px',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '10px',
|
||||
fontSize: '13px'
|
||||
}}>
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<div style={{ display: 'block', marginBottom: '4px', fontWeight: 'bold' }}>
|
||||
Library:
|
||||
</div>
|
||||
<select
|
||||
value={selectedLibrary}
|
||||
onChange={(e) => setSelectedLibrary(e.target.value)}
|
||||
style={{ width: '100%', padding: '4px', fontSize: '12px' }}
|
||||
aria-label="Select library"
|
||||
>
|
||||
<option value="">Select a library...</option>
|
||||
{locations.map(loc => (
|
||||
<option key={loc.name} value={loc.name}>{loc.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<div style={{ display: 'block', marginBottom: '4px', fontWeight: 'bold' }}>
|
||||
Pass Types:
|
||||
</div>
|
||||
{['Pass Only', 'Backpack Only', 'Both'].map(type => (
|
||||
<div key={type} style={{ display: 'block', fontSize: '12px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedPassTypes.includes(type)}
|
||||
onChange={(e) => handlePassTypeChange(type, e.target.checked)}
|
||||
style={{ marginRight: '6px' }}
|
||||
id={`passtype-${type}`}
|
||||
/>
|
||||
<label htmlFor={`passtype-${type}`}>
|
||||
{type}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleAddNotification}
|
||||
disabled={!selectedLibrary || selectedPassTypes.length === 0}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: selectedLibrary && selectedPassTypes.length > 0 ? '#007cba' : '#ccc',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: selectedLibrary && selectedPassTypes.length > 0 ? 'pointer' : 'not-allowed',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
Add Notification
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notifications.length > 0 && (
|
||||
<>
|
||||
<div style={{ maxHeight: '120px', overflowY: 'auto', marginBottom: '8px' }}>
|
||||
{notifications.map(notification => (
|
||||
<div key={notification.id} style={{
|
||||
fontSize: '11px',
|
||||
marginBottom: '6px',
|
||||
padding: '6px',
|
||||
backgroundColor: '#f0f0f0',
|
||||
borderRadius: '3px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 'bold' }}>{notification.libraryName}</div>
|
||||
<div style={{ color: '#666' }}>
|
||||
{notification.passTypes.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveNotification(notification.id)}
|
||||
style={{
|
||||
backgroundColor: '#f44336',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '2px',
|
||||
padding: '2px 6px',
|
||||
fontSize: '10px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={checkNotifications}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
backgroundColor: '#ff9800',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
Check Now
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user