Initial commit: working SD Park Pass Map app with deploy scripts and .gitignore

This commit is contained in:
Aram Chia Sarafian
2025-07-13 18:59:51 -07:00
commit ba831bf15a
20 changed files with 9669 additions and 0 deletions

View File

@ -0,0 +1,141 @@
import { NextResponse } from 'next/server';
// Library branch locations with approximate coordinates
const LIBRARY_LOCATIONS: Record<string, { lat: number; lng: number }> = {
'Beckwourth': { lat: 32.7157, lng: -117.1611 },
'Benjamin': { lat: 32.8328, lng: -117.2713 },
'Balboa': { lat: 32.7330, lng: -117.1430 },
'Carmel Mountain': { lat: 32.9286, lng: -117.1311 },
'Carmel Valley': { lat: 32.9340, lng: -117.2340 },
'Central Library': { lat: 32.7216, lng: -117.1574 },
'City Heights': { lat: 32.7411, lng: -117.1045 },
'Clairemont': { lat: 32.8328, lng: -117.2050 },
'College-Rolando': { lat: 32.7482, lng: -117.0704 },
'Kensington': { lat: 32.7644, lng: -117.1164 },
'La Jolla': { lat: 32.8344, lng: -117.2544 },
'Linda Vista': { lat: 32.7714, lng: -117.1789 },
'Logan Heights': { lat: 32.7030, lng: -117.1289 },
'Malcolm X': { lat: 32.7089, lng: -117.1242 },
'Mission Hills': { lat: 32.7469, lng: -117.1978 },
'Oak Park': { lat: 32.7328, lng: -117.1461 },
'Paradise Hills': { lat: 32.6747, lng: -117.0742 },
'Rancho Bernardo': { lat: 33.0200, lng: -117.1156 },
'Rancho Penasquitos': { lat: 32.958034, lng: -117.121975 },
'San Ysidro': { lat: 32.5592, lng: -117.0431 },
'Skyline Hills': { lat: 32.6781, lng: -117.0200 },
};
interface BibItem {
branchName: string;
availability: {
statusType: string;
};
}
interface BiblioResponse {
entities: {
bibItems: Record<string, BibItem>;
};
}
interface LocationData {
name: string;
lat: number;
lng: number;
available: boolean;
passType: string;
}
async function fetchAvailabilityData(itemNumber: string): Promise<BibItem[]> {
try {
const response = await fetch(
`https://gateway.bibliocommons.com/v2/libraries/sandiego/bibs/${itemNumber}/availability?locale=en-US`,
{
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; LibraryChecker/1.0)',
},
}
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: BiblioResponse = await response.json();
return Object.values(data.entities.bibItems || {});
} catch (error) {
console.error(`Error fetching data for ${itemNumber}:`, error);
return [];
}
}
export async function GET() {
try {
// Fetch both types of passes
const [passItems, backpackItems] = await Promise.all([
fetchAvailabilityData('S161C1690437'), // Pure parking pass
fetchAvailabilityData('S161C1805116'), // Backpack with pass
]);
const locationMap = new Map<string, LocationData>();
// Process parking passes
passItems.forEach(item => {
const location = LIBRARY_LOCATIONS[item.branchName];
if (location && item.branchName) {
const isAvailable = item.availability?.statusType === 'AVAILABLE';
const existing = locationMap.get(item.branchName);
if (existing) {
// Update existing entry - if either item type is available, mark as available
existing.available = existing.available || isAvailable;
existing.passType = 'Both';
} else {
locationMap.set(item.branchName, {
name: item.branchName,
lat: location.lat,
lng: location.lng,
available: isAvailable,
passType: 'Pass Only',
});
}
}
});
// Process backpack items
backpackItems.forEach(item => {
const location = LIBRARY_LOCATIONS[item.branchName];
if (location && item.branchName) {
const isAvailable = item.availability?.statusType === 'AVAILABLE';
const existing = locationMap.get(item.branchName);
if (existing) {
// Update existing entry - if either item type is available, mark as available
existing.available = existing.available || isAvailable;
existing.passType = 'Both';
} else {
locationMap.set(item.branchName, {
name: item.branchName,
lat: location.lat,
lng: location.lng,
available: isAvailable,
passType: 'Backpack Only',
});
}
}
});
const locations = Array.from(locationMap.values());
return NextResponse.json({
locations,
lastUpdated: new Date().toISOString(),
});
} catch (error) {
console.error('Error in API route:', error);
return NextResponse.json(
{ error: 'Failed to fetch availability data' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,32 @@
import { NextResponse } from 'next/server';
import { NotificationManager } from '../../utils/notifications';
// This API endpoint can be used for periodic checking
export async function GET() {
try {
const notifications = NotificationManager.getNotifications();
// In a real implementation, you might want to:
// 1. Fetch current availability data
// 2. Check against notification preferences
// 3. Send email notifications
// 4. Return results
return NextResponse.json({
notificationCount: notifications.length,
notifications: notifications.map(n => ({
id: n.id,
libraryName: n.libraryName,
passTypes: n.passTypes,
createdAt: n.createdAt,
lastChecked: n.lastChecked,
})),
});
} catch (error) {
console.error('Error checking notifications:', error);
return NextResponse.json(
{ error: 'Failed to check notifications' },
{ status: 500 }
);
}
}

View 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>
);
}

View 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='&copy; <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>
);
}

View 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>
);
}

75
app/globals.css Normal file
View File

@ -0,0 +1,75 @@
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
color: inherit;
text-decoration: none;
}
.leaflet-container {
height: 100vh;
width: 100%;
}
.info-panel {
position: absolute;
top: 10px;
left: 10px;
background: white;
padding: 15px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
z-index: 1000;
max-width: 350px;
max-height: 90vh;
overflow-y: auto;
transition: all 0.3s ease;
}
.info-panel.collapsed {
max-width: 200px;
max-height: 60px;
overflow: hidden;
}
.legend {
margin-top: 10px;
}
.legend-item {
display: flex;
align-items: center;
margin-bottom: 5px;
}
.legend-color {
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 8px;
}
.available {
background-color: #4CAF50;
}
.unavailable {
background-color: #f44336;
}

22
app/layout.tsx Normal file
View File

@ -0,0 +1,22 @@
import './globals.css'
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'SD Beach Park Pass Locator',
description: 'Find available beach parking passes at San Diego libraries',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
)
}

146
app/page.tsx Normal file
View File

@ -0,0 +1,146 @@
'use client';
import dynamic from 'next/dynamic';
import { useState, useEffect } from 'react';
import LibraryList from './components/LibraryList';
import NotificationPanel from './components/NotificationPanel';
// Dynamically import the map component to avoid SSR issues
const MapComponent = dynamic(() => import('./components/MapComponent'), {
ssr: false,
loading: () => <div>Loading map...</div>
});
interface LocationData {
name: string;
lat: number;
lng: number;
available: boolean;
passType: string;
}
interface ApiResponse {
locations: LocationData[];
lastUpdated: string;
}
export default function Home() {
const [locations, setLocations] = useState<LocationData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<string>('');
// Start with menu collapsed only on mobile
const [panelCollapsed, setPanelCollapsed] = useState(false);
useEffect(() => {
// Collapse menu on mobile only
if (typeof window !== 'undefined') {
const isMobile = /Mobi|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(window.navigator.userAgent);
if (isMobile) setPanelCollapsed(true);
}
// Trigger data refresh on page load
fetchAvailability();
}, []);
const fetchAvailability = async () => {
try {
setLoading(true);
const response = await fetch('/api/availability');
if (!response.ok) {
throw new Error('Failed to fetch data');
}
const data: ApiResponse = await response.json();
setLocations(data.locations);
setLastUpdated(data.lastUpdated);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
};
const availableCount = locations.filter(loc => loc.available).length;
const totalCount = locations.length;
return (
<div style={{ position: 'relative', height: '100vh' }}>
<div className={`info-panel ${panelCollapsed ? 'collapsed' : ''}`}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '10px' }}>
<h2 style={{ fontSize: '18px', fontWeight: 'bold', margin: 0 }}>
SD Beach Park Pass Locator
</h2>
<button
onClick={() => setPanelCollapsed(!panelCollapsed)}
style={{
background: 'none',
border: '1px solid #ccc',
borderRadius: '4px',
padding: '4px 8px',
cursor: 'pointer',
fontSize: '12px',
}}
title={panelCollapsed ? 'Expand panel' : 'Collapse panel'}
>
{panelCollapsed ? '▶' : '◀'}
</button>
</div>
{!panelCollapsed && (
<>
{loading && <p>Loading availability data...</p>}
{error && <p style={{ color: 'red' }}>Error: {error}</p>}
{!loading && !error && (
<>
<p style={{ marginBottom: '10px' }}>
<strong>{availableCount}</strong> of <strong>{totalCount}</strong> locations have available passes
</p>
<LibraryList locations={locations} />
<div className="legend">
<h4 style={{ marginBottom: '8px' }}>Legend:</h4>
<div className="legend-item">
<div className="legend-color available"></div>
<span>Available</span>
</div>
<div className="legend-item">
<div className="legend-color unavailable"></div>
<span>Not Available</span>
</div>
</div>
<NotificationPanel locations={locations} />
{lastUpdated && (
<p style={{ fontSize: '12px', color: '#666', marginTop: '10px' }}>
Last updated: {new Date(lastUpdated).toLocaleString()}
</p>
)}
<button
onClick={fetchAvailability}
style={{
marginTop: '10px',
padding: '8px 16px',
backgroundColor: '#007cba',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
width: '100%',
}}
>
Refresh Data
</button>
</>
)}
</>
)}
</div>
{!loading && !error && <MapComponent locations={locations} />}
</div>
);
}

182
app/utils/notifications.ts Normal file
View File

@ -0,0 +1,182 @@
// Simple notification storage using localStorage
export interface NotificationRequest {
id: string;
libraryName: string;
passTypes: ('Pass Only' | 'Backpack Only' | 'Both')[];
email?: string; // Optional for future email notifications
createdAt: string;
lastChecked?: string;
}
export class NotificationManager {
private static STORAGE_KEY = 'park-pass-notifications';
static getNotifications(): NotificationRequest[] {
if (typeof window === 'undefined') return [];
try {
const stored = localStorage.getItem(this.STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
static addNotification(request: Omit<NotificationRequest, 'id' | 'createdAt'>): NotificationRequest {
const newRequest: NotificationRequest = {
...request,
id: Date.now().toString() + Math.random().toString(36).substr(2, 9),
createdAt: new Date().toISOString(),
};
const notifications = this.getNotifications();
notifications.push(newRequest);
if (typeof window !== 'undefined') {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(notifications));
}
return newRequest;
}
static removeNotification(id: string): void {
const notifications = this.getNotifications().filter(n => n.id !== id);
if (typeof window !== 'undefined') {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(notifications));
}
}
static updateLastChecked(id: string): void {
const notifications = this.getNotifications();
const notification = notifications.find(n => n.id === id);
if (notification) {
notification.lastChecked = new Date().toISOString();
if (typeof window !== 'undefined') {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(notifications));
}
}
}
}
// Geolocation utilities
export function calculateDistance(lat1: number, lng1: number, lat2: number, lng2: number): number {
const R = 3959; // Earth's radius in miles
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLng = (lng2 - lng1) * Math.PI / 180;
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLng / 2) * Math.sin(dLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
export async function getCurrentLocation(): Promise<{ lat: number; lng: number } | null> {
return new Promise((resolve) => {
if (!navigator.geolocation) {
resolve(null);
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
resolve({
lat: position.coords.latitude,
lng: position.coords.longitude,
});
},
() => {
resolve(null);
},
{ timeout: 10000 }
);
});
}
// Web Push Notification utilities
export class WebPushManager {
static async requestPermission(): Promise<boolean> {
if (!('Notification' in window)) {
console.log('This browser does not support notifications');
return false;
}
if (Notification.permission === 'granted') {
return true;
}
if (Notification.permission === 'denied') {
return false;
}
const permission = await Notification.requestPermission();
return permission === 'granted';
}
static async showNotification(title: string, body: string, options?: NotificationOptions): Promise<void> {
if (!await this.requestPermission()) {
console.log('Notification permission denied');
return;
}
new Notification(title, {
body,
icon: '/favicon.ico',
badge: '/favicon.ico',
tag: 'park-pass-notification',
requireInteraction: true,
...options
});
}
static isSupported(): boolean {
return 'Notification' in window;
}
static getPermissionStatus(): NotificationPermission {
return Notification.permission;
}
}
// Zip code to coordinates lookup (San Diego area zip codes)
const ZIP_COORDINATES: Record<string, { lat: number; lng: number; area: string }> = {
// Central San Diego
'92101': { lat: 32.7157, lng: -117.1611, area: 'Downtown' },
'92102': { lat: 32.7081, lng: -117.1367, area: 'Golden Hill' },
'92103': { lat: 32.7441, lng: -117.1739, area: 'Hillcrest' },
'92104': { lat: 32.7489, lng: -117.1364, area: 'Normal Heights' },
'92105': { lat: 32.7089, lng: -117.1242, area: 'City Heights' },
'92106': { lat: 32.7330, lng: -117.1430, area: 'Point Loma' },
'92107': { lat: 32.7572, lng: -117.2528, area: 'Ocean Beach' },
'92108': { lat: 32.7678, lng: -117.1189, area: 'Mission Valley' },
'92109': { lat: 32.7889, lng: -117.2306, area: 'Pacific Beach' },
'92110': { lat: 32.7742, lng: -117.1936, area: 'Mission Bay' },
'92111': { lat: 32.7714, lng: -117.1789, area: 'Linda Vista' },
'92113': { lat: 32.6781, lng: -117.0200, area: 'Skyline' },
'92114': { lat: 32.7030, lng: -117.1289, area: 'Logan Heights' },
'92115': { lat: 32.7328, lng: -117.1461, area: 'Oak Park' },
'92116': { lat: 32.7644, lng: -117.1164, area: 'Kensington' },
'92117': { lat: 32.8328, lng: -117.2050, area: 'Clairemont' },
'92118': { lat: 32.6100, lng: -117.0842, area: 'Coronado' },
'92119': { lat: 32.7482, lng: -117.0704, area: 'College Area' },
'92120': { lat: 32.7678, lng: -117.0731, area: 'Del Cerro' },
'92121': { lat: 32.8797, lng: -117.2072, area: 'Sorrento Valley' },
'92122': { lat: 32.8544, lng: -117.2144, area: 'University City' },
'92123': { lat: 32.8100, lng: -117.1350, area: 'Serra Mesa' },
'92124': { lat: 32.8086, lng: -117.0547, area: 'Tierrasanta' },
'92126': { lat: 32.8831, lng: -117.1558, area: 'Mira Mesa' },
'92127': { lat: 32.9581, lng: -117.1089, area: 'Rancho Penasquitos' },
'92128': { lat: 33.0200, lng: -117.1156, area: 'Rancho Bernardo' },
'92129': { lat: 32.9584, lng: -117.1253, area: 'Rancho Penasquitos' },
'92130': { lat: 32.9340, lng: -117.2340, area: 'Carmel Valley' },
'92131': { lat: 32.9286, lng: -117.1311, area: 'Carmel Mountain' },
'92132': { lat: 32.6747, lng: -117.0742, area: 'Paradise Hills' },
'92154': { lat: 32.5592, lng: -117.0431, area: 'San Ysidro' },
'92173': { lat: 32.6400, lng: -117.0200, area: 'San Diego East' },
};
export function getCoordinatesFromZip(zipCode: string): { lat: number; lng: number; area: string } | null {
return ZIP_COORDINATES[zipCode] || null;
}
export function isValidSanDiegoZip(zipCode: string): boolean {
return zipCode in ZIP_COORDINATES;
}