minor changes

This commit is contained in:
2025-11-10 21:30:43 +01:00
parent 7cdd18bf7f
commit 9a3903b761
4 changed files with 181 additions and 25 deletions

View File

@@ -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}
/> />
} }
/> />

View File

@@ -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>
) : ( ) : (

View File

@@ -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>

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