Remove notification system and fix data refresh issues
- 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
This commit is contained in:
@ -1,5 +1,9 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
// Force dynamic rendering to prevent caching
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 0;
|
||||
|
||||
// Library branch locations with approximate coordinates
|
||||
const LIBRARY_LOCATIONS: Record<string, { lat: number; lng: number }> = {
|
||||
'Beckwourth': { lat: 32.7157, lng: -117.1611 },
|
||||
@ -127,10 +131,17 @@ export async function GET() {
|
||||
|
||||
const locations = Array.from(locationMap.values());
|
||||
|
||||
return NextResponse.json({
|
||||
const response = NextResponse.json({
|
||||
locations,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Prevent caching to ensure fresh data
|
||||
response.headers.set('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
response.headers.set('Pragma', 'no-cache');
|
||||
response.headers.set('Expires', '0');
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Error in API route:', error);
|
||||
return NextResponse.json(
|
||||
|
||||
@ -1,32 +0,0 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { calculateDistance, getCurrentLocation, getCoordinatesFromZip, isValidSanDiegoZip } from '../utils/notifications';
|
||||
import { calculateDistance, getCurrentLocation, getCoordinatesFromZip, isValidSanDiegoZip } from '../utils/location';
|
||||
|
||||
interface LocationData {
|
||||
name: string;
|
||||
|
||||
@ -1,275 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
37
app/page.tsx
37
app/page.tsx
@ -3,7 +3,6 @@
|
||||
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'), {
|
||||
@ -27,6 +26,7 @@ interface ApiResponse {
|
||||
export default function Home() {
|
||||
const [locations, setLocations] = useState<LocationData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdated, setLastUpdated] = useState<string>('');
|
||||
// Start with menu collapsed only on mobile
|
||||
@ -42,10 +42,22 @@ export default function Home() {
|
||||
fetchAvailability();
|
||||
}, []);
|
||||
|
||||
const fetchAvailability = async () => {
|
||||
const fetchAvailability = async (isRefresh = false) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/availability');
|
||||
if (isRefresh) {
|
||||
setRefreshing(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
// Add cache-busting parameter to ensure fresh data
|
||||
const timestamp = new Date().getTime();
|
||||
const response = await fetch(`/api/availability?t=${timestamp}`, {
|
||||
cache: 'no-cache',
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch data');
|
||||
}
|
||||
@ -56,7 +68,11 @@ export default function Home() {
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (isRefresh) {
|
||||
setRefreshing(false);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -111,8 +127,6 @@ export default function Home() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NotificationPanel locations={locations} />
|
||||
|
||||
{lastUpdated && (
|
||||
<p style={{ fontSize: '12px', color: '#666', marginTop: '10px' }}>
|
||||
Last updated: {new Date(lastUpdated).toLocaleString()}
|
||||
@ -120,19 +134,20 @@ export default function Home() {
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={fetchAvailability}
|
||||
onClick={() => fetchAvailability(true)}
|
||||
disabled={refreshing}
|
||||
style={{
|
||||
marginTop: '10px',
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#007cba',
|
||||
backgroundColor: refreshing ? '#ccc' : '#007cba',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
cursor: refreshing ? 'not-allowed' : 'pointer',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
Refresh Data
|
||||
{refreshing ? 'Refreshing...' : 'Refresh Data'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
89
app/utils/location.ts
Normal file
89
app/utils/location.ts
Normal file
@ -0,0 +1,89 @@
|
||||
// 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 }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// San Diego ZIP code validation and coordinates
|
||||
const SAN_DIEGO_ZIP_COORDINATES: Record<string, { lat: number; lng: number }> = {
|
||||
'92101': { lat: 32.7157, lng: -117.1611 }, // Downtown
|
||||
'92102': { lat: 32.7030, lng: -117.1289 }, // Logan Heights
|
||||
'92103': { lat: 32.7328, lng: -117.1461 }, // Core-Columbia
|
||||
'92104': { lat: 32.7482, lng: -117.0704 }, // College-Rolando
|
||||
'92105': { lat: 32.7089, lng: -117.1242 }, // Southeastern San Diego
|
||||
'92106': { lat: 32.7330, lng: -117.1430 }, // Point Loma
|
||||
'92107': { lat: 32.7469, lng: -117.1978 }, // Ocean Beach
|
||||
'92108': { lat: 32.7469, lng: -117.1978 }, // Mission Valley
|
||||
'92109': { lat: 32.7714, lng: -117.1789 }, // Pacific Beach
|
||||
'92110': { lat: 32.7714, lng: -117.1789 }, // Mission Bay
|
||||
'92111': { lat: 32.8328, lng: -117.2050 }, // Clairemont
|
||||
'92113': { lat: 32.6747, lng: -117.0742 }, // Southeastern San Diego
|
||||
'92114': { lat: 32.7030, lng: -117.1289 }, // Southeastern San Diego
|
||||
'92115': { lat: 32.7411, lng: -117.1045 }, // City Heights
|
||||
'92116': { lat: 32.7644, lng: -117.1164 }, // Kensington
|
||||
'92117': { lat: 32.8328, lng: -117.2050 }, // Clairemont
|
||||
'92118': { lat: 32.6144, lng: -117.0845 }, // Coronado
|
||||
'92119': { lat: 32.7482, lng: -117.0704 }, // College Area
|
||||
'92120': { lat: 32.7482, lng: -117.0704 }, // College Area
|
||||
'92121': { lat: 32.9340, lng: -117.2340 }, // Sorrento Valley
|
||||
'92122': { lat: 32.8344, lng: -117.2544 }, // La Jolla
|
||||
'92123': { lat: 32.7714, lng: -117.1789 }, // Serra Mesa
|
||||
'92124': { lat: 32.7714, lng: -117.1789 }, // Serra Mesa
|
||||
'92126': { lat: 32.9286, lng: -117.1311 }, // Carmel Mountain
|
||||
'92127': { lat: 33.0200, lng: -117.1156 }, // Rancho Bernardo
|
||||
'92128': { lat: 32.958034, lng: -117.121975 }, // Rancho Penasquitos
|
||||
'92129': { lat: 32.958034, lng: -117.121975 }, // Rancho Penasquitos
|
||||
'92130': { lat: 32.9340, lng: -117.2340 }, // Carmel Valley
|
||||
'92131': { lat: 32.9286, lng: -117.1311 }, // Carmel Mountain
|
||||
'92132': { lat: 32.8344, lng: -117.2544 }, // La Jolla
|
||||
'92133': { lat: 32.7157, lng: -117.1611 }, // Naval Base
|
||||
'92134': { lat: 32.7157, lng: -117.1611 }, // Naval Base
|
||||
'92135': { lat: 32.7157, lng: -117.1611 }, // Naval Base
|
||||
'92136': { lat: 32.7330, lng: -117.1430 }, // Point Loma
|
||||
'92137': { lat: 32.7330, lng: -117.1430 }, // Point Loma
|
||||
'92138': { lat: 32.6144, lng: -117.0845 }, // Naval Air Station
|
||||
'92139': { lat: 32.6747, lng: -117.0742 }, // Paradise Hills
|
||||
'92140': { lat: 32.6144, lng: -117.0845 }, // Coronado
|
||||
'92145': { lat: 32.7157, lng: -117.1611 }, // Naval Medical Center
|
||||
'92147': { lat: 32.7330, lng: -117.1430 }, // Point Loma
|
||||
'92154': { lat: 32.5592, lng: -117.0431 }, // San Ysidro
|
||||
'92155': { lat: 32.5592, lng: -117.0431 }, // San Ysidro
|
||||
'92173': { lat: 32.6781, lng: -117.0200 }, // Skyline Hills
|
||||
};
|
||||
|
||||
export function isValidSanDiegoZip(zip: string): boolean {
|
||||
return zip in SAN_DIEGO_ZIP_COORDINATES;
|
||||
}
|
||||
|
||||
export function getCoordinatesFromZip(zip: string): { lat: number; lng: number } | null {
|
||||
return SAN_DIEGO_ZIP_COORDINATES[zip] || null;
|
||||
}
|
||||
@ -1,182 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user