diff --git a/src/App.js b/src/App.js
index 04504fd..f705751 100644
--- a/src/App.js
+++ b/src/App.js
@@ -1,14 +1,21 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
-import { BrowserRouter as Router, Routes, Route, Navigate, Link, useLocation, useNavigate } from 'react-router-dom';
-import { DateRange } from 'react-date-range';
+import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { startOfDay } from 'date-fns';
-import { de } from 'date-fns/locale';
import './App.css';
import 'react-date-range/dist/styles.css';
import 'react-date-range/dist/theme/default.css';
-import { buildSelectionRange, formatDateValue, formatRangeLabel } from './utils/dateUtils';
+import { formatDateValue, formatRangeLabel } from './utils/dateUtils';
import useSyncProgress from './hooks/useSyncProgress';
import useNotificationSettings from './hooks/useNotificationSettings';
+import NavigationTabs from './components/NavigationTabs';
+import LoginView from './components/LoginView';
+import DashboardView from './components/DashboardView';
+import AdminSettingsPanel from './components/AdminSettingsPanel';
+import AdminAccessMessage from './components/AdminAccessMessage';
+import DirtyNavigationDialog from './components/DirtyNavigationDialog';
+import ConfirmationDialog from './components/ConfirmationDialog';
+import StoreSyncOverlay from './components/StoreSyncOverlay';
+import RangePickerModal from './components/RangePickerModal';
const TOKEN_STORAGE_KEY = 'pickupConfigToken';
@@ -1059,65 +1066,14 @@ function App() {
if (!session?.token) {
return (
<>
-
+
>
);
@@ -1152,796 +1108,64 @@ function App() {
}
const dashboardContent = (
-
-
Foodsharing Pickup Manager
-
-
-
-
Angemeldet
-
{session.profile.name}
-
Profil-ID: {session.profile.id}
-
-
-
-
-
- {notificationLoading &&
Lade…}
-
-
-
-
- {notificationPanelOpen && (
-
- {notificationError && (
-
- {notificationError}
-
- )}
- {notificationMessage && (
-
- {notificationMessage}
-
- )}
-
-
-
-
-
ntfy
-
- Push aufs Handy über die ntfy-App oder Browser (Themenkanal notwendig).
-
-
-
-
- {!notificationCapabilities.ntfy.enabled ? (
-
- Diese Option wurde vom Admin deaktiviert. Bitte frage nach, wenn du ntfy nutzen möchtest.
-
- ) : (
-
-
-
- handleNotificationFieldChange('ntfy', 'topic', e.target.value)}
- placeholder="z. B. mein-pickup-topic"
- className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
- disabled={!notificationSettings.ntfy.enabled}
- />
-
- {notificationCapabilities.ntfy.serverUrl && (
-
- Server: {notificationCapabilities.ntfy.serverUrl} (vom Admin festgelegt)
-
- )}
- {notificationCapabilities.ntfy.topicPrefix && (
-
- Präfix: {notificationCapabilities.ntfy.topicPrefix} (Bindestrich wird automatisch ergänzt)
-
- )}
- {ntfyPreviewUrl && (
-
-
- {ntfyPreviewUrl}
-
-
-
- )}
- {copyFeedback && (
-
{copyFeedback}
- )}
-
-
- )}
-
-
-
-
-
-
Telegram
-
- Nutze den Bot des Admins, um Nachrichten direkt in Telegram zu erhalten.
-
-
-
-
- {!notificationCapabilities.telegram.enabled ? (
-
- Telegram-Benachrichtigungen sind derzeit deaktiviert oder der Bot ist nicht konfiguriert.
-
- ) : (
-
-
-
-
handleNotificationFieldChange('telegram', 'chatId', e.target.value)}
- placeholder="z. B. 123456789"
- className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
- disabled={!notificationSettings.telegram.enabled}
- />
-
- Tipp: Schreibe dem Telegram-Bot und nutze{' '}
-
- @userinfobot
- {' '}
- oder ein Chat-ID-Tool.
-
-
-
-
- )}
-
-
-
-
-
-
-
- )}
-
-
-
- {!availableCollapsed && (
-
- {stores.length === 0 && (
-
Noch keine Betriebe geladen. Aktualisiere nach dem Login.
- )}
- {stores.map((store) => {
- const storeId = String(store.id);
- const entry = configMap.get(storeId);
- const isVisible = entry && !entry.hidden;
- const needsRestore = entry && entry.hidden;
- const blockedByNoPickups = store.hasPickupSlots === false;
- let statusLabel = 'Hinzufügen';
- let statusClass = 'text-blue-600';
- if (isVisible) {
- statusLabel = 'Bereits in Konfiguration';
- statusClass = 'text-gray-500';
- } else if (needsRestore) {
- statusLabel = 'Ausgeblendet – erneut hinzufügen';
- statusClass = 'text-amber-600';
- }
- if (blockedByNoPickups) {
- statusLabel = 'Keine Pickups – automatisch verborgen';
- statusClass = 'text-red-600';
- }
- return (
-
- );
- })}
-
- )}
-
-
-
-
- {error && (
-
- {error}
-
-
- )}
-
- {status && (
-
- {status}
-
- )}
-
-
-
-
-
-
-
- {activeRangeEntry && !activeRangeEntry.desiredWeekday && (
-
setActiveRangePicker(null)}
- >
-
event.stopPropagation()}
- >
-
-
Zeitraum auswählen für
-
- {activeRangeEntry.label || `Store ${activeRangeEntry.id}`}
-
-
-
- {
- const { startDate, endDate } = ranges.selection;
- handleDateRangeSelection(activeRangeEntry.id, startDate, endDate);
- }}
- moveRangeOnFirstSelection={false}
- ranges={[
- buildSelectionRange(
- activeRangeEntry.desiredDateRange?.start,
- activeRangeEntry.desiredDateRange?.end,
- minSelectableDate
- )
- ]}
- rangeColors={['#2563EB']}
- months={1}
- direction="horizontal"
- showDateDisplay={false}
- locale={de}
- minDate={minSelectableDate}
- />
-
-
-
-
-
-
-
-
-
-
- )}
-
+ 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
+ }}
+ stores={stores}
+ availableCollapsed={availableCollapsed}
+ onToggleStores={() => setAvailableCollapsed((prev) => !prev)}
+ onStoreSelect={handleStoreSelection}
+ configMap={configMap}
+ error={error}
+ onDismissError={() => setError('')}
+ status={status}
+ visibleConfig={visibleConfig}
+ config={config}
+ onToggleActive={handleToggleActive}
+ onToggleProfileCheck={handleToggleProfileCheck}
+ onToggleOnlyNotify={handleToggleOnlyNotify}
+ onWeekdayChange={handleWeekdayChange}
+ weekdays={weekdays}
+ onRangePickerRequest={setActiveRangePicker}
+ formatRangeLabel={formatRangeLabel}
+ onSaveConfig={saveConfig}
+ onResetConfig={() => fetchConfig()}
+ />
);
const adminPageContent = session?.isAdmin ? (
-
-
-
-
Admin-Einstellungen
-
Globale Abläufe und Verzögerungen für die Abhol-Automation verwalten.
-
-
- Zur Konfiguration
-
-
- {error && (
-
- {error}
-
-
- )}
- {status && (
-
- {status}
-
- )}
-
- {adminSettingsLoading &&
Lade Admin-Einstellungen...
}
- {!adminSettingsLoading && !adminSettings && (
-
Keine Admin-Einstellungen verfügbar.
- )}
- {adminSettings && (
- <>
-
-
-
- handleAdminSettingChange('scheduleCron', e.target.value)}
- className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
- placeholder="z. B. 0 * * * *"
- />
-
-
-
-
-
-
handleAdminSettingChange('storePickupCheckDelayMs', e.target.value, true)}
- className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
- placeholder="z. B. 400"
- />
-
Hilft Rate-Limits beim Abfragen der Pickups zu vermeiden.
-
-
-
-
-
Benachrichtigungen
-
- Lege fest, welche Kanäle zur Verfügung stehen und welche Zugangsdaten verwendet werden.
-
-
-
-
-
-
ntfy
-
Server-basierte Push-Benachrichtigungen
-
-
-
-
-
-
- handleAdminNotificationChange('ntfy', 'serverUrl', e.target.value)}
- className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
- placeholder="https://ntfy.sh"
- />
-
-
-
-
handleAdminNotificationChange('ntfy', 'topicPrefix', e.target.value)}
- className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
- placeholder="optional"
- />
-
- Wird vor jedes User-Topic gesetzt (z. B. pickup-); getrennt wird automatisch per Bindestrich.
-
-
-
-
-
-
-
-
-
-
Telegram
-
Benachrichtigungen über einen Bot
-
-
-
-
-
-
handleAdminNotificationChange('telegram', 'botToken', e.target.value)}
- className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
- placeholder="123456:ABCDEF"
- />
-
- Erstelle einen Bot via @BotFather und trage den Token hier ein.
-
-
-
-
-
-
-
-
-
Ignorierte Slots
-
-
- {(!adminSettings.ignoredSlots || adminSettings.ignoredSlots.length === 0) && (
-
Keine Regeln definiert.
- )}
- {adminSettings.ignoredSlots?.map((slot, index) => (
-
- handleIgnoredSlotChange(index, 'storeId', e.target.value)}
- placeholder="Store-ID"
- className="md:col-span-2 border rounded p-2 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
- />
- handleIgnoredSlotChange(index, 'description', e.target.value)}
- placeholder="Beschreibung (optional)"
- className="md:col-span-2 border rounded p-2 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
- />
-
-
- ))}
-
-
-
-
-
- >
- )}
-
-
+ setError('')}
+ onSettingChange={handleAdminSettingChange}
+ onIgnoredSlotChange={handleIgnoredSlotChange}
+ onAddIgnoredSlot={addIgnoredSlot}
+ onRemoveIgnoredSlot={removeIgnoredSlot}
+ onNotificationChange={handleAdminNotificationChange}
+ onSave={saveAdminSettings}
+ />
) : (
);
@@ -1978,178 +1202,23 @@ function App() {
onConfirm={() => handleConfirmDialog(true)}
onCancel={() => handleConfirmDialog(false)}
/>
+
+ activeRangeEntry ? handleDateRangeSelection(activeRangeEntry.id, startDate, endDate) : null
+ }
+ onResetRange={() => {
+ if (activeRangeEntry) {
+ handleDateRangeSelection(activeRangeEntry.id, null, null);
+ }
+ setActiveRangePicker(null);
+ }}
+ onClose={() => setActiveRangePicker(null)}
+ />
>
);
}
-function NavigationTabs({ isAdmin, onProtectedNavigate }) {
- const location = useLocation();
- const navigate = useNavigate();
- if (!isAdmin) {
- return null;
- }
- const tabs = [
- { to: '/', label: 'Konfiguration' },
- { to: '/admin', label: 'Admin' }
- ];
-
- const handleClick = (event, tab) => {
- event.preventDefault();
- if (location.pathname === tab.to) {
- return;
- }
- if (onProtectedNavigate) {
- onProtectedNavigate(`zur Seite "${tab.label}" wechseln`, () => navigate(tab.to));
- } else {
- navigate(tab.to);
- }
- };
-
- return (
-
- {tabs.map((tab) => {
- const isActive = location.pathname === tab.to;
- return (
- handleClick(event, tab)}
- className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
- isActive ? 'bg-blue-600 text-white shadow' : 'bg-white text-blue-600 border border-blue-200 hover:bg-blue-50'
- }`}
- >
- {tab.label}
-
- );
- })}
-
- );
-}
-
-function AdminAccessMessage() {
- return (
-
-
Kein Zugriff
-
Dieser Bereich ist nur für Administratoren verfügbar.
-
- Zurück zur Konfiguration
-
-
- );
-}
-
-function DirtyNavigationDialog({ open, message, onSave, onDiscard, onCancel, saving }) {
- if (!open) {
- return null;
- }
- return (
-
-
-
Änderungen nicht gespeichert
-
{message || 'Es gibt ungespeicherte Änderungen. Wie möchtest du fortfahren?'}
-
-
-
-
-
-
-
- );
-}
-
-function ConfirmationDialog({ open, title, message, confirmLabel, cancelLabel, confirmTone = 'primary', onConfirm, onCancel }) {
- if (!open) {
- return null;
- }
- const confirmClasses =
- confirmTone === 'danger'
- ? 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
- : 'bg-blue-600 hover:bg-blue-700 focus:ring-blue-500';
- return (
-
-
-
{title || 'Bitte bestätigen'}
-
{message || 'Bist du sicher?'}
-
-
-
-
-
-
- );
-}
-
-function formatEta(seconds) {
- if (seconds == null || seconds === Infinity) {
- return null;
- }
- const clamped = Math.max(0, seconds);
- const mins = Math.floor(clamped / 60);
- const secs = clamped % 60;
- if (mins > 0) {
- return `${mins}m ${secs.toString().padStart(2, '0')}s`;
- }
- return `${secs}s`;
-}
-
-function StoreSyncOverlay({ state }) {
- if (!state?.active) {
- return null;
- }
- const percent = Math.round(state.percent || 0);
- const backgroundColor = state.block ? 'rgba(255,255,255,0.95)' : 'rgba(15,23,42,0.4)';
- const etaLabel = formatEta(state.etaSeconds);
-
- return (
-
-
-
{state.message || 'Synchronisiere...'}
-
-
- {percent}%
- {etaLabel ? `ETA ~ ${etaLabel}` : 'Bitte warten...'}
-
-
- Die Verzögerung schützt vor Rate-Limits während die Betriebe geprüft werden.
-
-
-
- );
-}
-
export default App;
diff --git a/src/components/AdminAccessMessage.js b/src/components/AdminAccessMessage.js
new file mode 100644
index 0000000..d345b7c
--- /dev/null
+++ b/src/components/AdminAccessMessage.js
@@ -0,0 +1,16 @@
+import { Link } from 'react-router-dom';
+
+const AdminAccessMessage = () => (
+
+
Kein Zugriff
+
Dieser Bereich ist nur für Administratoren verfügbar.
+
+ Zurück zur Konfiguration
+
+
+);
+
+export default AdminAccessMessage;
diff --git a/src/components/AdminSettingsPanel.js b/src/components/AdminSettingsPanel.js
new file mode 100644
index 0000000..b0995af
--- /dev/null
+++ b/src/components/AdminSettingsPanel.js
@@ -0,0 +1,253 @@
+import { Link } from 'react-router-dom';
+
+const AdminSettingsPanel = ({
+ adminSettings,
+ adminSettingsLoading,
+ status,
+ error,
+ onDismissError,
+ onSettingChange,
+ onIgnoredSlotChange,
+ onAddIgnoredSlot,
+ onRemoveIgnoredSlot,
+ onNotificationChange,
+ onSave
+}) => {
+ return (
+
+
+
+
Admin-Einstellungen
+
Globale Abläufe und Verzögerungen für die Abhol-Automation verwalten.
+
+
+ Zur Konfiguration
+
+
+
+ {error && (
+
+ {error}
+
+
+ )}
+
+ {status && (
+
+ {status}
+
+ )}
+
+
+ {adminSettingsLoading &&
Lade Admin-Einstellungen...
}
+ {!adminSettingsLoading && !adminSettings &&
Keine Admin-Einstellungen verfügbar.
}
+
+ {adminSettings && (
+ <>
+
+
+
+ onSettingChange('scheduleCron', event.target.value)}
+ className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
+ placeholder="z. B. 0 * * * *"
+ />
+
+
+
+
+
+
+
+
+ onSettingChange('storePickupCheckDelayMs', event.target.value, true)}
+ className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
+ placeholder="z. B. 3000"
+ />
+
+
+
+
+
+
Ignorierte Slots
+
+
+ {(!adminSettings.ignoredSlots || adminSettings.ignoredSlots.length === 0) && (
+
Keine Regeln definiert.
+ )}
+ {adminSettings.ignoredSlots?.map((slot, index) => (
+
+ onIgnoredSlotChange(index, 'storeId', event.target.value)}
+ placeholder="Store-ID"
+ className="md:col-span-2 border rounded p-2 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
+ />
+ onIgnoredSlotChange(index, 'description', event.target.value)}
+ placeholder="Beschreibung (optional)"
+ className="md:col-span-2 border rounded p-2 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
+ />
+
+
+ ))}
+
+
+
+
+
+
+
ntfy
+
Konfiguration für Systembenachrichtigungen via ntfy.
+
+
+
+
+ onNotificationChange('ntfy', 'serverUrl', event.target.value)}
+ className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
+ placeholder="Server-URL"
+ />
+ onNotificationChange('ntfy', 'topicPrefix', event.target.value)}
+ className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
+ placeholder="Themenpräfix"
+ />
+ onNotificationChange('ntfy', 'username', event.target.value)}
+ className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
+ placeholder="Benutzername"
+ />
+ onNotificationChange('ntfy', 'password', event.target.value)}
+ className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
+ placeholder="Passwort"
+ />
+
+
+
+
+
+
+
Telegram
+
Bot-Konfiguration für Telegram-Benachrichtigungen.
+
+
+
+
onNotificationChange('telegram', 'botToken', event.target.value)}
+ className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
+ placeholder="Bot-Token"
+ />
+
+
+
+
+
+
+ >
+ )}
+
+
+ );
+};
+
+export default AdminSettingsPanel;
diff --git a/src/components/ConfirmationDialog.js b/src/components/ConfirmationDialog.js
new file mode 100644
index 0000000..ca893aa
--- /dev/null
+++ b/src/components/ConfirmationDialog.js
@@ -0,0 +1,44 @@
+const ConfirmationDialog = ({
+ open,
+ title,
+ message,
+ confirmLabel,
+ cancelLabel,
+ confirmTone = 'primary',
+ onConfirm,
+ onCancel
+}) => {
+ if (!open) {
+ return null;
+ }
+
+ const confirmClasses =
+ confirmTone === 'danger'
+ ? 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
+ : 'bg-blue-600 hover:bg-blue-700 focus:ring-blue-500';
+
+ return (
+
+
+
{title || 'Bitte bestätigen'}
+
{message || 'Bist du sicher?'}
+
+
+
+
+
+
+ );
+};
+
+export default ConfirmationDialog;
diff --git a/src/components/DashboardView.js b/src/components/DashboardView.js
new file mode 100644
index 0000000..188d564
--- /dev/null
+++ b/src/components/DashboardView.js
@@ -0,0 +1,299 @@
+import NotificationPanel from './NotificationPanel';
+
+const DashboardView = ({
+ session,
+ onRefresh,
+ onLogout,
+ notificationPanelOpen,
+ onToggleNotificationPanel,
+ notificationProps,
+ stores,
+ availableCollapsed,
+ onToggleStores,
+ onStoreSelect,
+ configMap,
+ error,
+ onDismissError,
+ status,
+ visibleConfig,
+ config,
+ onToggleActive,
+ onToggleProfileCheck,
+ onToggleOnlyNotify,
+ onWeekdayChange,
+ weekdays,
+ onRangePickerRequest,
+ formatRangeLabel,
+ onSaveConfig,
+ onResetConfig
+}) => {
+ const {
+ error: notificationError,
+ message: notificationMessage,
+ settings: notificationSettings,
+ capabilities: notificationCapabilities,
+ loading: notificationLoading,
+ dirty: notificationDirty,
+ saving: notificationSaving,
+ onReset: onNotificationReset,
+ onSave: onNotificationSave,
+ onFieldChange: onNotificationFieldChange,
+ onSendTest: onNotificationTest,
+ onCopyLink: onNotificationCopy,
+ copyFeedback,
+ ntfyPreviewUrl
+ } = notificationProps;
+
+ return (
+
+
Foodsharing Pickup Manager
+
+
+
+
Angemeldet
+
{session.profile.name}
+
Profil-ID: {session.profile.id}
+
+
+
+
+
+ {notificationLoading &&
Lade…}
+
+
+
+
+ {notificationPanelOpen && (
+
+ )}
+
+
+
+ {!availableCollapsed && (
+
+ {stores.map((store) => {
+ const storeId = String(store.id);
+ const entry = configMap.get(storeId);
+ const isVisible = entry && !entry.hidden;
+ const blockedByNoPickups = store.hasPickupSlots === false;
+ let statusLabel = 'Hinzufügen';
+ let statusClass = 'text-blue-600';
+ if (isVisible) {
+ statusLabel = 'Bereits in Konfiguration';
+ statusClass = 'text-gray-500';
+ } else if (entry) {
+ statusLabel = 'Ausgeblendet – erneut hinzufügen';
+ statusClass = 'text-amber-600';
+ } else if (blockedByNoPickups) {
+ statusLabel = 'Keine Pickups – automatisch verborgen';
+ statusClass = 'text-red-600';
+ }
+ return (
+
+
+
{store.name}
+
+ {store.zip} {store.city}
+
+
{statusLabel}
+
+
+
+ );
+ })}
+
+ )}
+
+
+ {error && (
+
+ {error}
+
+
+ )}
+
+ {status && (
+
+ {status}
+
+ )}
+
+
+
+
+
+
+
+
+
+
Aktuelle JSON-Konfiguration:
+
{JSON.stringify(config, null, 2)}
+
+
+ );
+};
+
+export default DashboardView;
diff --git a/src/components/DirtyNavigationDialog.js b/src/components/DirtyNavigationDialog.js
new file mode 100644
index 0000000..bb3ac22
--- /dev/null
+++ b/src/components/DirtyNavigationDialog.js
@@ -0,0 +1,39 @@
+const DirtyNavigationDialog = ({ open, message, onSave, onDiscard, onCancel, saving }) => {
+ if (!open) {
+ return null;
+ }
+
+ return (
+
+
+
Änderungen nicht gespeichert
+
+ {message || 'Es gibt ungespeicherte Änderungen. Wie möchtest du fortfahren?'}
+
+
+
+
+
+
+
+
+ );
+};
+
+export default DirtyNavigationDialog;
diff --git a/src/components/LoginView.js b/src/components/LoginView.js
new file mode 100644
index 0000000..bd6d326
--- /dev/null
+++ b/src/components/LoginView.js
@@ -0,0 +1,73 @@
+const LoginView = ({ credentials, onCredentialsChange, error, loading, initializing, onSubmit }) => {
+ const handleChange = (field) => (event) => {
+ onCredentialsChange({ ...credentials, [field]: event.target.value });
+ };
+
+ return (
+
+ );
+};
+
+export default LoginView;
diff --git a/src/components/NavigationTabs.js b/src/components/NavigationTabs.js
new file mode 100644
index 0000000..e603e58
--- /dev/null
+++ b/src/components/NavigationTabs.js
@@ -0,0 +1,49 @@
+import { Link, useLocation, useNavigate } from 'react-router-dom';
+
+const NavigationTabs = ({ isAdmin, onProtectedNavigate }) => {
+ const location = useLocation();
+ const navigate = useNavigate();
+
+ if (!isAdmin) {
+ return null;
+ }
+
+ const tabs = [
+ { to: '/', label: 'Konfiguration' },
+ { to: '/admin', label: 'Admin' }
+ ];
+
+ const handleClick = (event, to) => {
+ event.preventDefault();
+ if (to === location.pathname) {
+ return;
+ }
+ onProtectedNavigate(`zur Seite "${tabs.find((tab) => tab.to === to)?.label || ''}" zu wechseln`, () =>
+ navigate(to)
+ );
+ };
+
+ return (
+
+ );
+};
+
+export default NavigationTabs;
diff --git a/src/components/NotificationPanel.js b/src/components/NotificationPanel.js
new file mode 100644
index 0000000..6557cfb
--- /dev/null
+++ b/src/components/NotificationPanel.js
@@ -0,0 +1,196 @@
+const NotificationPanel = ({
+ error,
+ message,
+ settings,
+ capabilities,
+ loading,
+ dirty,
+ saving,
+ onReset,
+ onSave,
+ onFieldChange,
+ onSendTest,
+ onCopyLink,
+ copyFeedback,
+ ntfyPreviewUrl
+}) => {
+ return (
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {message && (
+
+ {message}
+
+ )}
+
+
+
+
+
+
ntfy
+
+ Push aufs Handy über die ntfy-App oder Browser (Themenkanal notwendig).
+
+
+
+
+
+ {!capabilities.ntfy.enabled ? (
+
+ Diese Option wurde vom Admin deaktiviert. Bitte frage nach, wenn du ntfy nutzen möchtest.
+
+ ) : (
+
+
+
+ onFieldChange('ntfy', 'topic', event.target.value)}
+ placeholder="z. B. mein-pickup-topic"
+ className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
+ disabled={!settings.ntfy.enabled}
+ />
+
+ {capabilities.ntfy.serverUrl && (
+
+ Server: {capabilities.ntfy.serverUrl} (vom Admin festgelegt)
+
+ )}
+ {capabilities.ntfy.topicPrefix && (
+
+ Präfix: {capabilities.ntfy.topicPrefix} (Bindestrich wird automatisch ergänzt)
+
+ )}
+ {ntfyPreviewUrl && (
+
+
+ {ntfyPreviewUrl}
+
+
+
+ )}
+ {copyFeedback &&
{copyFeedback}
}
+
+
+ )}
+
+
+
+
+
+
Telegram
+
+ Nutze den Bot des Admins, um Nachrichten direkt in Telegram zu erhalten.
+
+
+
+
+
+ {!capabilities.telegram.enabled ? (
+
+ Telegram-Benachrichtigungen sind derzeit deaktiviert oder der Bot ist nicht konfiguriert.
+
+ ) : (
+
+
+
+
onFieldChange('telegram', 'chatId', event.target.value)}
+ placeholder="z. B. 123456789"
+ className="border rounded p-2 w-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
+ disabled={!settings.telegram.enabled}
+ />
+
+ Tipp: Schreibe dem Telegram-Bot und nutze{' '}
+
+ @userinfobot
+ {' '}
+ oder ein Chat-ID-Tool.
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+ );
+};
+
+export default NotificationPanel;
diff --git a/src/components/StoreSyncOverlay.js b/src/components/StoreSyncOverlay.js
new file mode 100644
index 0000000..a2f07c6
--- /dev/null
+++ b/src/components/StoreSyncOverlay.js
@@ -0,0 +1,45 @@
+const formatEta = (seconds) => {
+ if (seconds == null || seconds === Infinity) {
+ return null;
+ }
+ const clamped = Math.max(0, seconds);
+ const mins = Math.floor(clamped / 60);
+ const secs = clamped % 60;
+ if (mins > 0) {
+ return `${mins}m ${secs.toString().padStart(2, '0')}s`;
+ }
+ return `${secs}s`;
+};
+
+const StoreSyncOverlay = ({ state }) => {
+ if (!state?.active) {
+ return null;
+ }
+
+ const percent = Math.round(state.percent || 0);
+ const backgroundColor = state.block ? 'rgba(255,255,255,0.95)' : 'rgba(15,23,42,0.4)';
+ const etaLabel = formatEta(state.etaSeconds);
+
+ return (
+
+
+
{state.message || 'Synchronisiere...'}
+
+
+ {percent}%
+ {etaLabel ? `ETA ~ ${etaLabel}` : 'Bitte warten...'}
+
+
+ Die Verzögerung schützt vor Rate-Limits während die Betriebe geprüft werden.
+
+
+
+ );
+};
+
+export default StoreSyncOverlay;