Neue Seite um Betriebe zu überwachen
This commit is contained in:
30
server.js
30
server.js
@@ -12,6 +12,7 @@ const adminConfig = require('./services/adminConfig');
|
||||
const { readNotificationSettings, writeNotificationSettings } = require('./services/userSettingsStore');
|
||||
const notificationService = require('./services/notificationService');
|
||||
const { readStoreWatch, writeStoreWatch } = require('./services/storeWatchStore');
|
||||
const { readPreferences, writePreferences } = require('./services/userPreferencesStore');
|
||||
|
||||
const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
|
||||
const adminEmail = (process.env.ADMIN_EMAIL || '').toLowerCase();
|
||||
@@ -463,6 +464,35 @@ app.post('/api/store-watch/subscriptions', requireAuth, (req, res) => {
|
||||
res.json({ success: true, stores: persisted });
|
||||
});
|
||||
|
||||
app.get('/api/user/preferences', requireAuth, (req, res) => {
|
||||
const preferences = readPreferences(req.session.profile.id);
|
||||
res.json(preferences);
|
||||
});
|
||||
|
||||
app.post('/api/user/preferences/location', requireAuth, (req, res) => {
|
||||
const { lat, lon } = req.body || {};
|
||||
if (lat === null || lon === null || lat === undefined || lon === undefined) {
|
||||
const updated = writePreferences(req.session.profile.id, { location: null });
|
||||
return res.json({ location: updated.location });
|
||||
}
|
||||
const parsedLat = Number(lat);
|
||||
const parsedLon = Number(lon);
|
||||
if (
|
||||
!Number.isFinite(parsedLat) ||
|
||||
!Number.isFinite(parsedLon) ||
|
||||
parsedLat < -90 ||
|
||||
parsedLat > 90 ||
|
||||
parsedLon < -180 ||
|
||||
parsedLon > 180
|
||||
) {
|
||||
return res.status(400).json({ error: 'Ungültige Koordinaten' });
|
||||
}
|
||||
const updated = writePreferences(req.session.profile.id, {
|
||||
location: { lat: parsedLat, lon: parsedLon }
|
||||
});
|
||||
res.json({ location: updated.location });
|
||||
});
|
||||
|
||||
app.get('/api/config', requireAuth, (req, res) => {
|
||||
const config = readConfig(req.session.profile.id);
|
||||
res.json(config);
|
||||
|
||||
75
services/userPreferencesStore.js
Normal file
75
services/userPreferencesStore.js
Normal file
@@ -0,0 +1,75 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PREF_DIR = path.join(__dirname, '..', 'config');
|
||||
|
||||
const DEFAULT_PREFERENCES = {
|
||||
location: null
|
||||
};
|
||||
|
||||
function ensureDir() {
|
||||
if (!fs.existsSync(PREF_DIR)) {
|
||||
fs.mkdirSync(PREF_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function getPreferencesPath(profileId = 'shared') {
|
||||
return path.join(PREF_DIR, `${profileId}-preferences.json`);
|
||||
}
|
||||
|
||||
function sanitizeLocation(location) {
|
||||
if (!location) {
|
||||
return null;
|
||||
}
|
||||
const lat = Number(location.lat);
|
||||
const lon = Number(location.lon);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||||
return null;
|
||||
}
|
||||
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
lat,
|
||||
lon,
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
function readPreferences(profileId) {
|
||||
ensureDir();
|
||||
const filePath = getPreferencesPath(profileId);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
fs.writeFileSync(filePath, JSON.stringify(DEFAULT_PREFERENCES, null, 2));
|
||||
return { ...DEFAULT_PREFERENCES };
|
||||
}
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
return {
|
||||
location: sanitizeLocation(parsed.location) || null
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[PREFERENCES] Konnte Datei ${filePath} nicht lesen:`, error.message);
|
||||
return { ...DEFAULT_PREFERENCES };
|
||||
}
|
||||
}
|
||||
|
||||
function writePreferences(profileId, patch = {}) {
|
||||
const current = readPreferences(profileId);
|
||||
const next = {
|
||||
location:
|
||||
patch.location === undefined
|
||||
? current.location
|
||||
: sanitizeLocation(patch.location)
|
||||
};
|
||||
ensureDir();
|
||||
const filePath = getPreferencesPath(profileId);
|
||||
fs.writeFileSync(filePath, JSON.stringify(next, null, 2));
|
||||
return next;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
readPreferences,
|
||||
writePreferences
|
||||
};
|
||||
28
src/App.js
28
src/App.js
@@ -12,6 +12,7 @@ import useStoreSync from './hooks/useStoreSync';
|
||||
import useDirtyNavigationGuard from './hooks/useDirtyNavigationGuard';
|
||||
import useSessionManager from './hooks/useSessionManager';
|
||||
import useAdminSettings from './hooks/useAdminSettings';
|
||||
import useUserPreferences from './hooks/useUserPreferences';
|
||||
import NavigationTabs from './components/NavigationTabs';
|
||||
import LoginView from './components/LoginView';
|
||||
import DashboardView from './components/DashboardView';
|
||||
@@ -116,6 +117,17 @@ function App() {
|
||||
setLoading
|
||||
});
|
||||
|
||||
const {
|
||||
preferences,
|
||||
loading: preferencesLoading,
|
||||
saving: locationSaving,
|
||||
error: preferencesError,
|
||||
updateLocation
|
||||
} = useUserPreferences({
|
||||
authorizedFetch,
|
||||
sessionToken: session?.token
|
||||
});
|
||||
|
||||
const {
|
||||
fetchConfig,
|
||||
syncStoresWithProgress,
|
||||
@@ -667,6 +679,11 @@ function App() {
|
||||
canDelete={Boolean(session?.isAdmin)}
|
||||
focusedStoreId={focusedStoreId}
|
||||
onClearFocus={() => setFocusedStoreId(null)}
|
||||
userLocation={preferences?.location || null}
|
||||
locationLoading={preferencesLoading}
|
||||
locationSaving={locationSaving}
|
||||
locationError={preferencesError}
|
||||
onUpdateLocation={updateLocation}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -696,7 +713,16 @@ function App() {
|
||||
<NavigationTabs isAdmin={session?.isAdmin} onProtectedNavigate={requestNavigation} />
|
||||
<Routes>
|
||||
<Route path="/" element={dashboardContent} />
|
||||
<Route path="/store-watch" element={<StoreWatchPage authorizedFetch={authorizedFetch} knownStores={stores} />} />
|
||||
<Route
|
||||
path="/store-watch"
|
||||
element={
|
||||
<StoreWatchPage
|
||||
authorizedFetch={authorizedFetch}
|
||||
knownStores={stores}
|
||||
userLocation={preferences?.location || null}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/admin" element={adminPageContent} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import NotificationPanel from './NotificationPanel';
|
||||
|
||||
const DashboardView = ({
|
||||
@@ -31,7 +31,12 @@ const DashboardView = ({
|
||||
onDeleteEntry,
|
||||
canDelete,
|
||||
focusedStoreId,
|
||||
onClearFocus
|
||||
onClearFocus,
|
||||
userLocation,
|
||||
locationLoading,
|
||||
locationSaving,
|
||||
locationError,
|
||||
onUpdateLocation
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
if (!focusedStoreId) {
|
||||
@@ -53,6 +58,44 @@ const DashboardView = ({
|
||||
row.classList.remove('dashboard-row-highlight', 'ring-4', 'ring-blue-400');
|
||||
};
|
||||
}, [focusedStoreId, onClearFocus]);
|
||||
const [geoBusy, setGeoBusy] = useState(false);
|
||||
const [geoError, setGeoError] = useState('');
|
||||
|
||||
const handleDetectLocation = useCallback(() => {
|
||||
if (!navigator.geolocation) {
|
||||
setGeoError('Standortbestimmung wird von diesem Browser nicht unterstützt.');
|
||||
return;
|
||||
}
|
||||
setGeoBusy(true);
|
||||
setGeoError('');
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
async (position) => {
|
||||
const lat = position.coords.latitude;
|
||||
const lon = position.coords.longitude;
|
||||
await onUpdateLocation?.({ lat, lon });
|
||||
setGeoBusy(false);
|
||||
},
|
||||
(error) => {
|
||||
setGeoBusy(false);
|
||||
setGeoError(
|
||||
error.code === error.PERMISSION_DENIED
|
||||
? 'Zugriff auf den Standort wurde verweigert.'
|
||||
: 'Standort konnte nicht ermittelt werden.'
|
||||
);
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000,
|
||||
maximumAge: 0
|
||||
}
|
||||
);
|
||||
}, [onUpdateLocation]);
|
||||
|
||||
const handleClearLocation = useCallback(async () => {
|
||||
setGeoError('');
|
||||
await onUpdateLocation?.(null);
|
||||
}, [onUpdateLocation]);
|
||||
|
||||
const {
|
||||
error: notificationError,
|
||||
message: notificationMessage,
|
||||
@@ -229,10 +272,56 @@ const DashboardView = ({
|
||||
{status && (
|
||||
<div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4 relative">
|
||||
<span className="block sm:inline">{status}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<div className="mb-6 border border-blue-100 bg-blue-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-blue-900">Standort für Entfernungssortierung</h2>
|
||||
<p className="text-sm text-blue-800">
|
||||
Damit die Monitoring-Liste nach Entfernung sortieren kann, hinterlege hier deinen Standort.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDetectLocation}
|
||||
className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-60"
|
||||
disabled={geoBusy || locationSaving || locationLoading}
|
||||
>
|
||||
{geoBusy || locationSaving ? 'Speichere...' : 'Standort ermitteln'}
|
||||
</button>
|
||||
{userLocation && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearLocation}
|
||||
className="px-4 py-2 rounded-md border border-blue-300 text-blue-700 text-sm bg-white disabled:opacity-60"
|
||||
disabled={locationSaving || locationLoading}
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-blue-900">
|
||||
{locationLoading ? (
|
||||
<p>Standort wird geladen...</p>
|
||||
) : userLocation ? (
|
||||
<p>
|
||||
Aktueller Standort: {userLocation.lat.toFixed(4)}, {userLocation.lon.toFixed(4)} (
|
||||
{userLocation.updatedAt ? new Date(userLocation.updatedAt).toLocaleString('de-DE') : 'Zeit unbekannt'})
|
||||
</p>
|
||||
) : (
|
||||
<p>Kein Standort hinterlegt.</p>
|
||||
)}
|
||||
</div>
|
||||
{(locationError || geoError) && (
|
||||
<p className="mt-2 text-sm text-red-600">{locationError || geoError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full bg-white border border-gray-200">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getSortedRowModel,
|
||||
useReactTable
|
||||
} from '@tanstack/react-table';
|
||||
import { haversineDistanceKm } from '../utils/distance';
|
||||
|
||||
const columnHelper = createColumnHelper();
|
||||
|
||||
@@ -45,7 +46,7 @@ const ColumnSelectFilter = ({ column, options }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
|
||||
const StoreWatchPage = ({ authorizedFetch, knownStores = [], userLocation }) => {
|
||||
const [regions, setRegions] = useState([]);
|
||||
const [selectedRegionId, setSelectedRegionId] = useState('');
|
||||
const [storesByRegion, setStoresByRegion] = useState({});
|
||||
@@ -65,18 +66,34 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
|
||||
[watchList]
|
||||
);
|
||||
|
||||
const selectedRegion = useMemo(
|
||||
() => regions.find((region) => String(region.id) === String(selectedRegionId)) || null,
|
||||
[regions, selectedRegionId]
|
||||
);
|
||||
const selectedRegion = useMemo(() => {
|
||||
if (selectedRegionId === 'all') {
|
||||
return null;
|
||||
}
|
||||
return regions.find((region) => String(region.id) === String(selectedRegionId)) || null;
|
||||
}, [regions, selectedRegionId]);
|
||||
|
||||
const currentStores = useMemo(() => {
|
||||
if (selectedRegionId === 'all') {
|
||||
const combined = new Map();
|
||||
regions.forEach((region) => {
|
||||
const entry = storesByRegion[String(region.id)];
|
||||
if (entry?.stores) {
|
||||
entry.stores.forEach((store) => {
|
||||
if (store?.id) {
|
||||
combined.set(String(store.id), store);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return Array.from(combined.values());
|
||||
}
|
||||
const regionEntry = storesByRegion[String(selectedRegionId)];
|
||||
if (!regionEntry || !Array.isArray(regionEntry.stores)) {
|
||||
return [];
|
||||
}
|
||||
return regionEntry.stores;
|
||||
}, [storesByRegion, selectedRegionId]);
|
||||
}, [regions, storesByRegion, selectedRegionId]);
|
||||
|
||||
const eligibleStores = useMemo(
|
||||
() => currentStores.filter((store) => Number(store.cooperationStatus) === 5),
|
||||
@@ -95,11 +112,21 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
|
||||
|
||||
const tableData = useMemo(
|
||||
() =>
|
||||
eligibleStores.map((store) => ({
|
||||
...store,
|
||||
membership: membershipMap.has(String(store.id))
|
||||
})),
|
||||
[eligibleStores, membershipMap]
|
||||
eligibleStores.map((store) => {
|
||||
const membership = membershipMap.has(String(store.id));
|
||||
const lat = Number(store.location?.lat);
|
||||
const lon = Number(store.location?.lon);
|
||||
const distance =
|
||||
userLocation && Number.isFinite(lat) && Number.isFinite(lon)
|
||||
? haversineDistanceKm(userLocation.lat, userLocation.lon, lat, lon)
|
||||
: null;
|
||||
return {
|
||||
...store,
|
||||
membership,
|
||||
distanceKm: distance
|
||||
};
|
||||
}),
|
||||
[eligibleStores, membershipMap, userLocation]
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
@@ -238,6 +265,46 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
|
||||
return Number(b) - Number(a);
|
||||
}
|
||||
}),
|
||||
columnHelper.accessor('distanceKm', {
|
||||
header: ({ column }) => (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between text-left font-semibold disabled:cursor-not-allowed disabled:text-gray-400"
|
||||
onClick={userLocation ? column.getToggleSortingHandler() : undefined}
|
||||
disabled={!userLocation}
|
||||
>
|
||||
<span>Entfernung</span>
|
||||
{column.getIsSorted() ? (
|
||||
<span className="text-xs text-gray-500">{column.getIsSorted() === 'asc' ? '▲' : '▼'}</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">⇅</span>
|
||||
)}
|
||||
</button>
|
||||
{!userLocation && <p className="mt-1 text-xs text-gray-500">Standort erforderlich</p>}
|
||||
</div>
|
||||
),
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue();
|
||||
if (!value && value !== 0) {
|
||||
return <span className="text-sm text-gray-500">–</span>;
|
||||
}
|
||||
return <span className="text-sm text-gray-800">{value.toFixed(2)} km</span>;
|
||||
},
|
||||
sortingFn: (rowA, rowB, columnId) => {
|
||||
const a = rowA.getValue(columnId);
|
||||
const b = rowB.getValue(columnId);
|
||||
if (a === null || a === undefined) {
|
||||
return 1;
|
||||
}
|
||||
if (b === null || b === undefined) {
|
||||
return -1;
|
||||
}
|
||||
return a - b;
|
||||
},
|
||||
enableColumnFilter: false,
|
||||
enableSorting: !!userLocation
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: 'watch',
|
||||
header: () => <span>Überwachen</span>,
|
||||
@@ -257,7 +324,7 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
|
||||
}
|
||||
})
|
||||
],
|
||||
[handleToggleStore, watchedIds]
|
||||
[handleToggleStore, watchedIds, userLocation]
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
@@ -321,14 +388,16 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
|
||||
}, [authorizedFetch]);
|
||||
|
||||
const fetchStoresForRegion = useCallback(
|
||||
async (regionId, { force } = {}) => {
|
||||
async (regionId, { force, silent } = {}) => {
|
||||
if (!authorizedFetch || !regionId) {
|
||||
return;
|
||||
}
|
||||
if (!force && storesByRegion[String(regionId)]) {
|
||||
return;
|
||||
}
|
||||
setStoresLoading(true);
|
||||
if (!silent) {
|
||||
setStoresLoading(true);
|
||||
}
|
||||
setError('');
|
||||
try {
|
||||
const endpoint = force
|
||||
@@ -350,22 +419,55 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
|
||||
} catch (err) {
|
||||
setError(`Betriebe konnten nicht geladen werden: ${err.message}`);
|
||||
} finally {
|
||||
setStoresLoading(false);
|
||||
if (!silent) {
|
||||
setStoresLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[authorizedFetch, storesByRegion]
|
||||
);
|
||||
|
||||
const fetchAllRegions = useCallback(
|
||||
async ({ force } = {}) => {
|
||||
if (!authorizedFetch || regions.length === 0) {
|
||||
return;
|
||||
}
|
||||
const targets = regions.filter(
|
||||
(region) => force || !storesByRegion[String(region.id)]
|
||||
);
|
||||
if (targets.length === 0) {
|
||||
return;
|
||||
}
|
||||
setStoresLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
for (const region of targets) {
|
||||
await fetchStoresForRegion(region.id, { force, silent: true });
|
||||
}
|
||||
} catch (err) {
|
||||
setError(`Betriebe konnten nicht geladen werden: ${err.message}`);
|
||||
} finally {
|
||||
setStoresLoading(false);
|
||||
}
|
||||
},
|
||||
[authorizedFetch, regions, storesByRegion, fetchStoresForRegion]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadRegions();
|
||||
loadSubscriptions();
|
||||
}, [loadRegions, loadSubscriptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRegionId) {
|
||||
if (!selectedRegionId) {
|
||||
return;
|
||||
}
|
||||
if (selectedRegionId === 'all') {
|
||||
fetchAllRegions({ force: false });
|
||||
} else {
|
||||
fetchStoresForRegion(selectedRegionId);
|
||||
}
|
||||
}, [selectedRegionId, fetchStoresForRegion]);
|
||||
}, [selectedRegionId, fetchStoresForRegion, fetchAllRegions]);
|
||||
|
||||
const handleToggleStore = useCallback(
|
||||
(store, checked) => {
|
||||
@@ -486,6 +588,7 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
|
||||
disabled={regionLoading}
|
||||
>
|
||||
<option value="">Region auswählen</option>
|
||||
<option value="all">Alle Regionen</option>
|
||||
{regions.map((region) => (
|
||||
<option key={region.id} value={region.id}>
|
||||
{region.name}
|
||||
@@ -504,7 +607,11 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchStoresForRegion(selectedRegionId, { force: true })}
|
||||
onClick={() =>
|
||||
selectedRegionId === 'all'
|
||||
? fetchAllRegions({ force: true })
|
||||
: fetchStoresForRegion(selectedRegionId, { force: true })
|
||||
}
|
||||
className="px-4 py-2 border rounded-md text-sm bg-white hover:bg-gray-100"
|
||||
disabled={!selectedRegionId || storesLoading}
|
||||
>
|
||||
|
||||
87
src/hooks/useUserPreferences.js
Normal file
87
src/hooks/useUserPreferences.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
const PREFERENCES_ENDPOINT = '/api/user/preferences';
|
||||
const LOCATION_ENDPOINT = '/api/user/preferences/location';
|
||||
|
||||
const useUserPreferences = ({ authorizedFetch, sessionToken }) => {
|
||||
const [preferences, setPreferences] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const loadPreferences = useCallback(async () => {
|
||||
if (!sessionToken || !authorizedFetch) {
|
||||
setPreferences(null);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const response = await authorizedFetch(PREFERENCES_ENDPOINT);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
setPreferences(data);
|
||||
} catch (err) {
|
||||
setError(`Einstellungen konnten nicht geladen werden: ${err.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [authorizedFetch, sessionToken]);
|
||||
|
||||
const updateLocation = useCallback(
|
||||
async (coords) => {
|
||||
if (!sessionToken || !authorizedFetch) {
|
||||
return false;
|
||||
}
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const payload =
|
||||
coords && typeof coords.lat === 'number' && typeof coords.lon === 'number'
|
||||
? { lat: coords.lat, lon: coords.lon }
|
||||
: { lat: null, lon: null };
|
||||
const response = await authorizedFetch(LOCATION_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
setPreferences((prev) => ({
|
||||
...(prev || {}),
|
||||
location: data.location || null
|
||||
}));
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(`Standort konnte nicht aktualisiert werden: ${err.message}`);
|
||||
return false;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[authorizedFetch, sessionToken]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionToken) {
|
||||
loadPreferences();
|
||||
} else {
|
||||
setPreferences(null);
|
||||
}
|
||||
}, [sessionToken, loadPreferences]);
|
||||
|
||||
return {
|
||||
preferences,
|
||||
loading,
|
||||
saving,
|
||||
error,
|
||||
loadPreferences,
|
||||
updateLocation
|
||||
};
|
||||
};
|
||||
|
||||
export default useUserPreferences;
|
||||
20
src/utils/distance.js
Normal file
20
src/utils/distance.js
Normal file
@@ -0,0 +1,20 @@
|
||||
const toRadians = (value) => (value * Math.PI) / 180;
|
||||
|
||||
export function haversineDistanceKm(lat1, lon1, lat2, lon2) {
|
||||
if (
|
||||
!Number.isFinite(lat1) ||
|
||||
!Number.isFinite(lon1) ||
|
||||
!Number.isFinite(lat2) ||
|
||||
!Number.isFinite(lon2)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const R = 6371;
|
||||
const dLat = toRadians(lat2 - lat1);
|
||||
const dLon = toRadians(lon2 - lon1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return Number((R * c).toFixed(2));
|
||||
}
|
||||
Reference in New Issue
Block a user