minor changes
This commit is contained in:
38
src/App.js
38
src/App.js
@@ -5,6 +5,7 @@ import './App.css';
|
|||||||
import 'react-date-range/dist/styles.css';
|
import 'react-date-range/dist/styles.css';
|
||||||
import 'react-date-range/dist/theme/default.css';
|
import 'react-date-range/dist/theme/default.css';
|
||||||
import { formatDateValue, formatRangeLabel } from './utils/dateUtils';
|
import { formatDateValue, formatRangeLabel } from './utils/dateUtils';
|
||||||
|
import { inferLocationLabel } from './utils/locationLabel';
|
||||||
import useSyncProgress from './hooks/useSyncProgress';
|
import useSyncProgress from './hooks/useSyncProgress';
|
||||||
import useNotificationSettings from './hooks/useNotificationSettings';
|
import useNotificationSettings from './hooks/useNotificationSettings';
|
||||||
import useConfigManager from './hooks/useConfigManager';
|
import useConfigManager from './hooks/useConfigManager';
|
||||||
@@ -634,14 +635,15 @@ function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dashboardContent = (
|
const userLocationWithLabel = useMemo(() => {
|
||||||
<DashboardView
|
if (!preferences?.location) {
|
||||||
session={session}
|
return null;
|
||||||
onRefresh={refreshStoresAndConfig}
|
}
|
||||||
onLogout={handleLogout}
|
const label = inferLocationLabel(preferences.location, stores);
|
||||||
notificationPanelOpen={notificationPanelOpen}
|
return label ? { ...preferences.location, label } : { ...preferences.location };
|
||||||
onToggleNotificationPanel={() => setNotificationPanelOpen((prev) => !prev)}
|
}, [preferences?.location, stores]);
|
||||||
notificationProps={{
|
|
||||||
|
const sharedNotificationProps = {
|
||||||
error: notificationError,
|
error: notificationError,
|
||||||
message: notificationMessage,
|
message: notificationMessage,
|
||||||
settings: notificationSettings,
|
settings: notificationSettings,
|
||||||
@@ -656,7 +658,16 @@ function App() {
|
|||||||
onCopyLink: copyToClipboard,
|
onCopyLink: copyToClipboard,
|
||||||
copyFeedback,
|
copyFeedback,
|
||||||
ntfyPreviewUrl
|
ntfyPreviewUrl
|
||||||
}}
|
};
|
||||||
|
|
||||||
|
const dashboardContent = (
|
||||||
|
<DashboardView
|
||||||
|
session={session}
|
||||||
|
onRefresh={refreshStoresAndConfig}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
notificationPanelOpen={notificationPanelOpen}
|
||||||
|
onToggleNotificationPanel={() => setNotificationPanelOpen((prev) => !prev)}
|
||||||
|
notificationProps={sharedNotificationProps}
|
||||||
stores={stores}
|
stores={stores}
|
||||||
availableCollapsed={availableCollapsed}
|
availableCollapsed={availableCollapsed}
|
||||||
onToggleStores={() => setAvailableCollapsed((prev) => !prev)}
|
onToggleStores={() => setAvailableCollapsed((prev) => !prev)}
|
||||||
@@ -681,7 +692,7 @@ function App() {
|
|||||||
canDelete={Boolean(session?.isAdmin)}
|
canDelete={Boolean(session?.isAdmin)}
|
||||||
focusedStoreId={focusedStoreId}
|
focusedStoreId={focusedStoreId}
|
||||||
onClearFocus={() => setFocusedStoreId(null)}
|
onClearFocus={() => setFocusedStoreId(null)}
|
||||||
userLocation={preferences?.location || null}
|
userLocation={userLocationWithLabel}
|
||||||
locationLoading={preferencesLoading}
|
locationLoading={preferencesLoading}
|
||||||
locationSaving={locationSaving}
|
locationSaving={locationSaving}
|
||||||
locationError={preferencesError}
|
locationError={preferencesError}
|
||||||
@@ -721,9 +732,14 @@ function App() {
|
|||||||
<StoreWatchPage
|
<StoreWatchPage
|
||||||
authorizedFetch={authorizedFetch}
|
authorizedFetch={authorizedFetch}
|
||||||
knownStores={stores}
|
knownStores={stores}
|
||||||
userLocation={preferences?.location || null}
|
userLocation={userLocationWithLabel}
|
||||||
locationLoading={preferencesLoading}
|
locationLoading={preferencesLoading}
|
||||||
|
locationSaving={locationSaving}
|
||||||
|
locationError={preferencesError}
|
||||||
onRequestLocation={updateLocation}
|
onRequestLocation={updateLocation}
|
||||||
|
notificationPanelOpen={notificationPanelOpen}
|
||||||
|
onToggleNotificationPanel={() => setNotificationPanelOpen((prev) => !prev)}
|
||||||
|
notificationProps={sharedNotificationProps}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -211,7 +211,11 @@ const NotificationPanel = ({
|
|||||||
<p>Standort wird geladen...</p>
|
<p>Standort wird geladen...</p>
|
||||||
) : location ? (
|
) : location ? (
|
||||||
<p>
|
<p>
|
||||||
Aktueller Standort: {location.lat.toFixed(4)}, {location.lon.toFixed(4)} (
|
Aktueller Standort: {location.lat.toFixed(4)}, {location.lon.toFixed(4)}
|
||||||
|
{location.label && (
|
||||||
|
<span className="ml-1 font-semibold text-blue-900">– {location.label}</span>
|
||||||
|
)}{' '}
|
||||||
|
(
|
||||||
{location.updatedAt ? new Date(location.updatedAt).toLocaleString('de-DE') : 'Zeit unbekannt'})
|
{location.updatedAt ? new Date(location.updatedAt).toLocaleString('de-DE') : 'Zeit unbekannt'})
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
useReactTable
|
useReactTable
|
||||||
} from '@tanstack/react-table';
|
} from '@tanstack/react-table';
|
||||||
import { haversineDistanceKm } from '../utils/distance';
|
import { haversineDistanceKm } from '../utils/distance';
|
||||||
|
import NotificationPanel from './NotificationPanel';
|
||||||
|
|
||||||
const REGION_STORAGE_KEY = 'storeWatchRegionSelection';
|
const REGION_STORAGE_KEY = 'storeWatchRegionSelection';
|
||||||
const WATCH_TABLE_STATE_KEY = 'storeWatchTableState';
|
const WATCH_TABLE_STATE_KEY = 'storeWatchTableState';
|
||||||
@@ -92,7 +93,12 @@ const StoreWatchPage = ({
|
|||||||
knownStores = [],
|
knownStores = [],
|
||||||
userLocation,
|
userLocation,
|
||||||
onRequestLocation,
|
onRequestLocation,
|
||||||
locationLoading = false
|
locationLoading = false,
|
||||||
|
locationSaving = false,
|
||||||
|
locationError = '',
|
||||||
|
notificationPanelOpen = false,
|
||||||
|
onToggleNotificationPanel = () => {},
|
||||||
|
notificationProps
|
||||||
}) => {
|
}) => {
|
||||||
const [regions, setRegions] = useState([]);
|
const [regions, setRegions] = useState([]);
|
||||||
const [selectedRegionId, setSelectedRegionId] = useState(() => {
|
const [selectedRegionId, setSelectedRegionId] = useState(() => {
|
||||||
@@ -120,6 +126,9 @@ const StoreWatchPage = ({
|
|||||||
const [locationPromptTriggered, setLocationPromptTriggered] = useState(false);
|
const [locationPromptTriggered, setLocationPromptTriggered] = useState(false);
|
||||||
const [locationPromptPending, setLocationPromptPending] = useState(false);
|
const [locationPromptPending, setLocationPromptPending] = useState(false);
|
||||||
const [locationPromptError, setLocationPromptError] = useState('');
|
const [locationPromptError, setLocationPromptError] = useState('');
|
||||||
|
const combinedLocationError = locationError || locationPromptError;
|
||||||
|
const locationActionBusy = locationSaving || locationPromptPending;
|
||||||
|
const formatCoordinate = (value) => (Number.isFinite(value) ? Number(value).toFixed(4) : '–');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -183,6 +192,16 @@ const StoreWatchPage = ({
|
|||||||
requestLocation();
|
requestLocation();
|
||||||
}, [userLocation, locationLoading, onRequestLocation, locationPromptTriggered, requestLocation]);
|
}, [userLocation, locationLoading, onRequestLocation, locationPromptTriggered, requestLocation]);
|
||||||
|
|
||||||
|
const handleManualDetectLocation = useCallback(() => {
|
||||||
|
setLocationPromptError('');
|
||||||
|
requestLocation();
|
||||||
|
}, [requestLocation]);
|
||||||
|
|
||||||
|
const handleClearStoredLocation = useCallback(async () => {
|
||||||
|
setLocationPromptError('');
|
||||||
|
await onRequestLocation?.(null);
|
||||||
|
}, [onRequestLocation]);
|
||||||
|
|
||||||
const watchedIds = useMemo(
|
const watchedIds = useMemo(
|
||||||
() => new Set(watchList.map((entry) => String(entry.storeId))),
|
() => new Set(watchList.map((entry) => String(entry.storeId))),
|
||||||
[watchList]
|
[watchList]
|
||||||
@@ -848,6 +867,58 @@ const StoreWatchPage = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleNotificationPanel}
|
||||||
|
className={`flex items-center justify-center w-11 h-11 rounded-full border ${
|
||||||
|
notificationPanelOpen ? 'border-blue-500 text-blue-600' : 'border-gray-300 text-gray-600'
|
||||||
|
} hover:text-blue-700 hover:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 transition`}
|
||||||
|
title="Benachrichtigungen konfigurieren"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543-.89 3.31.877 2.42 2.42a1.724 1.724 0 0 0 1.065 2.572c1.757.426 1.757 2.924 0 3.35a1.724 1.724 0 0 0-1.066 2.573c.89 1.543-.877 3.31-2.42 2.42a1.724 1.724 0 0 0-2.572 1.065c-.426 1.757-2.924 1.757-3.35 0a1.724 1.724 0 0 0-2.573-1.066c-1.543.89-3.31-.877-2.42-2.42a1.724 1.724 0 0 0-1.065-2.572c-1.757-.426-1.757-2.924 0-3.35a1.724 1.724 0 0 0 1.066-2.573c-.89-1.543.877-3.31 2.42-2.42.996.575 2.273.155 2.573-1.065z"
|
||||||
|
/>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{notificationProps?.loading && <span className="text-sm text-gray-500">Lade…</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{notificationPanelOpen && notificationProps && (
|
||||||
|
<NotificationPanel
|
||||||
|
{...notificationProps}
|
||||||
|
location={userLocation}
|
||||||
|
locationLoading={locationLoading}
|
||||||
|
locationSaving={locationActionBusy}
|
||||||
|
locationError={combinedLocationError}
|
||||||
|
onDetectLocation={handleManualDetectLocation}
|
||||||
|
onClearLocation={handleClearStoredLocation}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{userLocation && !notificationPanelOpen && (
|
||||||
|
<div className="mb-4 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900">
|
||||||
|
<p>
|
||||||
|
Standort gespeichert:{' '}
|
||||||
|
{userLocation.label ? (
|
||||||
|
<span className="font-semibold">{userLocation.label}</span>
|
||||||
|
) : (
|
||||||
|
'Koordinaten'
|
||||||
|
)}{' '}
|
||||||
|
• {formatCoordinate(userLocation.lat)}, {formatCoordinate(userLocation.lon)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-blue-800 mt-1">
|
||||||
|
Aktualisiert:{' '}
|
||||||
|
{userLocation.updatedAt ? new Date(userLocation.updatedAt).toLocaleString('de-DE') : 'Zeit unbekannt'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{(error || status) && (
|
{(error || status) && (
|
||||||
<div className="mb-4 space-y-2">
|
<div className="mb-4 space-y-2">
|
||||||
{error && (
|
{error && (
|
||||||
@@ -863,16 +934,16 @@ const StoreWatchPage = ({
|
|||||||
|
|
||||||
{!userLocation && !locationLoading && onRequestLocation && (
|
{!userLocation && !locationLoading && onRequestLocation && (
|
||||||
<div className="mb-6 rounded-lg border border-blue-200 bg-blue-50 p-4 text-sm text-blue-900">
|
<div className="mb-6 rounded-lg border border-blue-200 bg-blue-50 p-4 text-sm text-blue-900">
|
||||||
{locationPromptPending ? (
|
{locationActionBusy ? (
|
||||||
<p>Standort wird automatisch angefragt, um Entfernungen berechnen zu können...</p>
|
<p>Standort wird automatisch angefragt, um Entfernungen berechnen zu können...</p>
|
||||||
) : locationPromptError ? (
|
) : combinedLocationError ? (
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<p className="flex-1">{locationPromptError}</p>
|
<p className="flex-1">{combinedLocationError}</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={requestLocation}
|
onClick={handleManualDetectLocation}
|
||||||
className="inline-flex items-center justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700 disabled:opacity-60"
|
className="inline-flex items-center justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700 disabled:opacity-60"
|
||||||
disabled={locationPromptPending}
|
disabled={locationActionBusy}
|
||||||
>
|
>
|
||||||
Erneut versuchen
|
Erneut versuchen
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
65
src/utils/locationLabel.js
Normal file
65
src/utils/locationLabel.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user