diff --git a/src/App.js b/src/App.js index 3758f48..6f415be 100644 --- a/src/App.js +++ b/src/App.js @@ -5,6 +5,7 @@ import './App.css'; import 'react-date-range/dist/styles.css'; import 'react-date-range/dist/theme/default.css'; import { formatDateValue, formatRangeLabel } from './utils/dateUtils'; +import { inferLocationLabel } from './utils/locationLabel'; import useSyncProgress from './hooks/useSyncProgress'; import useNotificationSettings from './hooks/useNotificationSettings'; import useConfigManager from './hooks/useConfigManager'; @@ -634,6 +635,31 @@ function App() { ); } + const userLocationWithLabel = useMemo(() => { + if (!preferences?.location) { + return null; + } + const label = inferLocationLabel(preferences.location, stores); + return label ? { ...preferences.location, label } : { ...preferences.location }; + }, [preferences?.location, stores]); + + const sharedNotificationProps = { + error: notificationError, + message: notificationMessage, + settings: notificationSettings, + capabilities: notificationCapabilities, + loading: notificationLoading, + dirty: notificationDirty, + saving: notificationSaving, + onReset: loadNotificationSettings, + onSave: saveNotificationSettings, + onFieldChange: handleNotificationFieldChange, + onSendTest: sendNotificationTest, + onCopyLink: copyToClipboard, + copyFeedback, + ntfyPreviewUrl + }; + const dashboardContent = ( setNotificationPanelOpen((prev) => !prev)} - notificationProps={{ - error: notificationError, - message: notificationMessage, - settings: notificationSettings, - capabilities: notificationCapabilities, - loading: notificationLoading, - dirty: notificationDirty, - saving: notificationSaving, - onReset: loadNotificationSettings, - onSave: saveNotificationSettings, - onFieldChange: handleNotificationFieldChange, - onSendTest: sendNotificationTest, - onCopyLink: copyToClipboard, - copyFeedback, - ntfyPreviewUrl - }} + notificationProps={sharedNotificationProps} stores={stores} availableCollapsed={availableCollapsed} onToggleStores={() => setAvailableCollapsed((prev) => !prev)} @@ -681,7 +692,7 @@ function App() { canDelete={Boolean(session?.isAdmin)} focusedStoreId={focusedStoreId} onClearFocus={() => setFocusedStoreId(null)} - userLocation={preferences?.location || null} + userLocation={userLocationWithLabel} locationLoading={preferencesLoading} locationSaving={locationSaving} locationError={preferencesError} @@ -721,9 +732,14 @@ function App() { setNotificationPanelOpen((prev) => !prev)} + notificationProps={sharedNotificationProps} /> } /> diff --git a/src/components/NotificationPanel.js b/src/components/NotificationPanel.js index 3dbcd89..cfb6346 100644 --- a/src/components/NotificationPanel.js +++ b/src/components/NotificationPanel.js @@ -211,7 +211,11 @@ const NotificationPanel = ({

Standort wird geladen...

) : location ? (

- Aktueller Standort: {location.lat.toFixed(4)}, {location.lon.toFixed(4)} ( + Aktueller Standort: {location.lat.toFixed(4)}, {location.lon.toFixed(4)} + {location.label && ( + – {location.label} + )}{' '} + ( {location.updatedAt ? new Date(location.updatedAt).toLocaleString('de-DE') : 'Zeit unbekannt'})

) : ( diff --git a/src/components/StoreWatchPage.js b/src/components/StoreWatchPage.js index 35eaf80..704c414 100644 --- a/src/components/StoreWatchPage.js +++ b/src/components/StoreWatchPage.js @@ -8,6 +8,7 @@ import { useReactTable } from '@tanstack/react-table'; import { haversineDistanceKm } from '../utils/distance'; +import NotificationPanel from './NotificationPanel'; const REGION_STORAGE_KEY = 'storeWatchRegionSelection'; const WATCH_TABLE_STATE_KEY = 'storeWatchTableState'; @@ -92,7 +93,12 @@ const StoreWatchPage = ({ knownStores = [], userLocation, onRequestLocation, - locationLoading = false + locationLoading = false, + locationSaving = false, + locationError = '', + notificationPanelOpen = false, + onToggleNotificationPanel = () => {}, + notificationProps }) => { const [regions, setRegions] = useState([]); const [selectedRegionId, setSelectedRegionId] = useState(() => { @@ -120,6 +126,9 @@ const StoreWatchPage = ({ const [locationPromptTriggered, setLocationPromptTriggered] = useState(false); const [locationPromptPending, setLocationPromptPending] = useState(false); const [locationPromptError, setLocationPromptError] = useState(''); + const combinedLocationError = locationError || locationPromptError; + const locationActionBusy = locationSaving || locationPromptPending; + const formatCoordinate = (value) => (Number.isFinite(value) ? Number(value).toFixed(4) : '–'); useEffect(() => { if (typeof window === 'undefined') { @@ -183,6 +192,16 @@ const StoreWatchPage = ({ requestLocation(); }, [userLocation, locationLoading, onRequestLocation, locationPromptTriggered, requestLocation]); + const handleManualDetectLocation = useCallback(() => { + setLocationPromptError(''); + requestLocation(); + }, [requestLocation]); + + const handleClearStoredLocation = useCallback(async () => { + setLocationPromptError(''); + await onRequestLocation?.(null); + }, [onRequestLocation]); + const watchedIds = useMemo( () => new Set(watchList.map((entry) => String(entry.storeId))), [watchList] @@ -848,6 +867,58 @@ const StoreWatchPage = ({

+
+ + {notificationProps?.loading && Lade…} +
+ + {notificationPanelOpen && notificationProps && ( + + )} + + {userLocation && !notificationPanelOpen && ( +
+

+ Standort gespeichert:{' '} + {userLocation.label ? ( + {userLocation.label} + ) : ( + 'Koordinaten' + )}{' '} + • {formatCoordinate(userLocation.lat)}, {formatCoordinate(userLocation.lon)} +

+

+ Aktualisiert:{' '} + {userLocation.updatedAt ? new Date(userLocation.updatedAt).toLocaleString('de-DE') : 'Zeit unbekannt'} +

+
+ )} + {(error || status) && (
{error && ( @@ -863,16 +934,16 @@ const StoreWatchPage = ({ {!userLocation && !locationLoading && onRequestLocation && (
- {locationPromptPending ? ( + {locationActionBusy ? (

Standort wird automatisch angefragt, um Entfernungen berechnen zu können...

- ) : locationPromptError ? ( + ) : combinedLocationError ? (
-

{locationPromptError}

+

{combinedLocationError}

diff --git a/src/utils/locationLabel.js b/src/utils/locationLabel.js new file mode 100644 index 0000000..0b9c9d5 --- /dev/null +++ b/src/utils/locationLabel.js @@ -0,0 +1,65 @@ +import { haversineDistanceKm } from './distance'; + +const DEFAULT_MAX_DISTANCE_KM = 60; + +export function inferLocationLabel(location, stores = [], options = {}) { + if ( + !location || + typeof location !== 'object' || + !Number.isFinite(location.lat) || + !Number.isFinite(location.lon) || + !Array.isArray(stores) || + stores.length === 0 + ) { + return null; + } + + const maxDistanceKm = Number.isFinite(options.maxDistanceKm) + ? options.maxDistanceKm + : DEFAULT_MAX_DISTANCE_KM; + + let closest = null; + for (const store of stores) { + const storeLat = Number(store?.location?.lat); + const storeLon = Number(store?.location?.lon); + if (!Number.isFinite(storeLat) || !Number.isFinite(storeLon)) { + continue; + } + const distance = haversineDistanceKm(location.lat, location.lon, storeLat, storeLon); + if (distance === null) { + continue; + } + if (closest && distance >= closest.distance) { + continue; + } + const labelParts = []; + if (store.city) { + labelParts.push(store.city); + } + if (store.region?.name) { + const cityNormalized = store.city?.toLowerCase(); + const regionNormalized = store.region.name.toLowerCase(); + if (!cityNormalized || cityNormalized !== regionNormalized) { + labelParts.push(store.region.name); + } + } + if (labelParts.length === 0 && store.name) { + labelParts.push(store.name); + } + const label = + labelParts.length > 0 ? labelParts.join(' • ') : `Store ${store.id ?? ''}`.trim(); + closest = { + label, + distance + }; + } + + if (!closest) { + return null; + } + + if (maxDistanceKm <= 0 || closest.distance <= maxDistanceKm) { + return closest.label; + } + return null; +}