feat: add guest notification insights

This commit is contained in:
Codex Agent
2025-11-12 19:31:13 +01:00
parent 642541c8fb
commit 2c412e3764
7 changed files with 440 additions and 7 deletions

View File

@@ -259,6 +259,33 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, chec
const progressRatio = taskProgress
? Math.min(1, taskProgress.completedCount / TASK_BADGE_TARGET)
: 0;
const [activeTab, setActiveTab] = React.useState<'unread' | 'all' | 'status'>(center.unreadCount > 0 ? 'unread' : 'all');
React.useEffect(() => {
if (!open) {
setActiveTab(center.unreadCount > 0 ? 'unread' : 'all');
}
}, [open, center.unreadCount]);
const uploadNotifications = React.useMemo(
() => center.notifications.filter((item) => item.type === 'upload_alert'),
[center.notifications]
);
const unreadNotifications = React.useMemo(
() => center.notifications.filter((item) => item.status === 'new'),
[center.notifications]
);
const filteredNotifications = React.useMemo(() => {
switch (activeTab) {
case 'unread':
return unreadNotifications;
case 'status':
return uploadNotifications;
default:
return center.notifications;
}
}, [activeTab, center.notifications, unreadNotifications, uploadNotifications]);
return (
<div className="relative">
@@ -299,13 +326,36 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, chec
{t('header.notifications.refresh', 'Aktualisieren')}
</button>
</div>
<NotificationTabs
tabs={[
{ key: 'unread', label: t('header.notifications.tabUnread', 'Neu'), badge: unreadNotifications.length },
{ key: 'status', label: t('header.notifications.tabStatus', 'Uploads/Status'), badge: uploadNotifications.length },
{ key: 'all', label: t('header.notifications.tabAll', 'Alle'), badge: center.notifications.length },
]}
activeTab={activeTab}
onTabChange={(next) => setActiveTab(next as typeof activeTab)}
/>
<NotificationStatusBar
lastFetchedAt={center.lastFetchedAt}
isOffline={center.isOffline}
t={t}
/>
<div className="mt-3 max-h-80 space-y-2 overflow-y-auto pr-1">
{center.loading ? (
<NotificationSkeleton />
) : center.notifications.length === 0 ? (
<NotificationEmptyState t={t} />
) : filteredNotifications.length === 0 ? (
<NotificationEmptyState
t={t}
message={
activeTab === 'unread'
? t('header.notifications.emptyUnread', 'Du bist auf dem neuesten Stand!')
: activeTab === 'status'
? t('header.notifications.emptyStatus', 'Keine Upload-Hinweise oder Wartungen aktiv.')
: undefined
}
/>
) : (
center.notifications.map((item) => (
filteredNotifications.map((item) => (
<NotificationListItem
key={item.id}
item={item}
@@ -490,11 +540,11 @@ function NotificationCta({ cta, onFollow }: { cta: { label?: string; href?: stri
);
}
function NotificationEmptyState({ t }: { t: TranslateFn }) {
function NotificationEmptyState({ t, message }: { t: TranslateFn; message?: string }) {
return (
<div className="rounded-2xl border border-dashed border-slate-200 bg-white/70 p-4 text-center text-sm text-slate-500">
<AlertCircle className="mx-auto mb-2 h-5 w-5 text-slate-400" aria-hidden />
<p>{t('header.notifications.empty', 'Gerade gibt es keine neuen Hinweise.')}</p>
<p>{message ?? t('header.notifications.empty', 'Gerade gibt es keine neuen Hinweise.')}</p>
</div>
);
}
@@ -542,3 +592,51 @@ function formatRelativeTime(value: string): string {
const diffDays = Math.round(diffHours / 24);
return `${diffDays} d`;
}
function NotificationTabs({
tabs,
activeTab,
onTabChange,
}: {
tabs: Array<{ key: string; label: string; badge?: number }>;
activeTab: string;
onTabChange: (key: string) => void;
}) {
return (
<div className="mt-3 flex gap-2 rounded-full bg-slate-100/80 p-1 text-xs font-semibold text-slate-600">
{tabs.map((tab) => (
<button
key={tab.key}
type="button"
className={`flex flex-1 items-center justify-center gap-1 rounded-full px-3 py-1 transition ${
activeTab === tab.key ? 'bg-white text-pink-600 shadow' : 'text-slate-500'
}`}
onClick={() => onTabChange(tab.key)}
>
{tab.label}
{typeof tab.badge === 'number' && tab.badge > 0 && (
<span className="rounded-full bg-pink-100 px-2 text-[11px] text-pink-600">{tab.badge}</span>
)}
</button>
))}
</div>
);
}
function NotificationStatusBar({ lastFetchedAt, isOffline, t }: { lastFetchedAt: Date | null; isOffline: boolean; t: TranslateFn }) {
const label = lastFetchedAt ? formatRelativeTime(lastFetchedAt.toISOString()) : t('header.notifications.never', 'Noch keine Aktualisierung');
return (
<div className="mt-2 flex items-center justify-between text-[11px] text-slate-500">
<span>
{t('header.notifications.lastSync', 'Zuletzt aktualisiert')}: {label}
</span>
{isOffline && (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 font-semibold text-amber-700">
<AlertCircle className="h-3 w-3" aria-hidden />
{t('header.notifications.offline', 'Offline')}
</span>
)}
</div>
);
}

View File

@@ -19,6 +19,8 @@ export type NotificationCenterValue = {
markAsRead: (id: number) => Promise<void>;
dismiss: (id: number) => Promise<void>;
eventToken: string;
lastFetchedAt: Date | null;
isOffline: boolean;
};
const NotificationCenterContext = React.createContext<NotificationCenterValue | null>(null);
@@ -30,6 +32,8 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
const [loadingNotifications, setLoadingNotifications] = React.useState(true);
const etagRef = React.useRef<string | null>(null);
const fetchLockRef = React.useRef(false);
const [lastFetchedAt, setLastFetchedAt] = React.useState<Date | null>(null);
const [isOffline, setIsOffline] = React.useState<boolean>(typeof navigator !== 'undefined' ? !navigator.onLine : false);
const queueCount = React.useMemo(
() => items.filter((item) => item.status !== 'done').length,
@@ -59,14 +63,19 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
if (!result.notModified) {
setNotifications(result.notifications);
setUnreadCount(result.unreadCount);
setLastFetchedAt(new Date());
}
etagRef.current = result.etag;
setIsOffline(false);
} catch (error) {
console.error('Failed to load guest notifications', error);
if (!options.silent) {
setNotifications([]);
setUnreadCount(0);
}
if (typeof navigator !== 'undefined' && !navigator.onLine) {
setIsOffline(true);
}
} finally {
fetchLockRef.current = false;
if (!options.silent) {
@@ -103,6 +112,19 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
return () => window.clearInterval(interval);
}, [eventToken, loadNotifications]);
React.useEffect(() => {
const handleOnline = () => setIsOffline(false);
const handleOffline = () => setIsOffline(true);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
const markAsRead = React.useCallback(
async (id: number) => {
if (!eventToken) {
@@ -199,6 +221,8 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
markAsRead,
dismiss,
eventToken,
lastFetchedAt,
isOffline,
};
return (