696 lines
24 KiB
TypeScript
696 lines
24 KiB
TypeScript
import React from 'react';
|
||
import { Link } from 'react-router-dom';
|
||
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
||
import {
|
||
User,
|
||
Heart,
|
||
Users,
|
||
PartyPopper,
|
||
Camera,
|
||
Bell,
|
||
ArrowUpRight,
|
||
MessageSquare,
|
||
Sparkles,
|
||
LifeBuoy,
|
||
UploadCloud,
|
||
AlertCircle,
|
||
Check,
|
||
X,
|
||
RefreshCw,
|
||
} from 'lucide-react';
|
||
import { useEventData } from '../hooks/useEventData';
|
||
import { useOptionalEventStats } from '../context/EventStatsContext';
|
||
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
|
||
import { SettingsSheet } from './settings-sheet';
|
||
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
|
||
import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '../context/EventBrandingContext';
|
||
import { useOptionalNotificationCenter, type NotificationCenterValue } from '../context/NotificationCenterContext';
|
||
import { useGuestTaskProgress, TASK_BADGE_TARGET } from '../hooks/useGuestTaskProgress';
|
||
import { usePushSubscription } from '../hooks/usePushSubscription';
|
||
|
||
const EVENT_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||
heart: Heart,
|
||
guests: Users,
|
||
party: PartyPopper,
|
||
camera: Camera,
|
||
};
|
||
|
||
const NOTIFICATION_ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
||
broadcast: MessageSquare,
|
||
feedback_request: MessageSquare,
|
||
achievement_major: Sparkles,
|
||
support_tip: LifeBuoy,
|
||
upload_alert: UploadCloud,
|
||
photo_activity: Camera,
|
||
};
|
||
|
||
function isLikelyEmoji(value: string): boolean {
|
||
if (!value) {
|
||
return false;
|
||
}
|
||
const characters = Array.from(value.trim());
|
||
if (characters.length === 0 || characters.length > 2) {
|
||
return false;
|
||
}
|
||
return characters.some((char) => {
|
||
const codePoint = char.codePointAt(0) ?? 0;
|
||
return codePoint > 0x2600;
|
||
});
|
||
}
|
||
|
||
function getInitials(name: string): string {
|
||
const words = name.split(' ').filter(Boolean);
|
||
if (words.length >= 2) {
|
||
return `${words[0][0]}${words[1][0]}`.toUpperCase();
|
||
}
|
||
return name.substring(0, 2).toUpperCase();
|
||
}
|
||
|
||
function renderEventAvatar(name: string, icon: unknown, accentColor: string, textColor: string) {
|
||
if (typeof icon === 'string') {
|
||
const trimmed = icon.trim();
|
||
if (trimmed) {
|
||
const normalized = trimmed.toLowerCase();
|
||
const IconComponent = EVENT_ICON_COMPONENTS[normalized];
|
||
if (IconComponent) {
|
||
return (
|
||
<div
|
||
className="flex h-10 w-10 items-center justify-center rounded-full shadow-sm"
|
||
style={{ backgroundColor: accentColor, color: textColor }}
|
||
>
|
||
<IconComponent className="h-5 w-5" aria-hidden />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (isLikelyEmoji(trimmed)) {
|
||
return (
|
||
<div
|
||
className="flex h-10 w-10 items-center justify-center rounded-full text-xl shadow-sm"
|
||
style={{ backgroundColor: accentColor, color: textColor }}
|
||
>
|
||
<span aria-hidden>{trimmed}</span>
|
||
<span className="sr-only">{name}</span>
|
||
</div>
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className="flex h-10 w-10 items-center justify-center rounded-full font-semibold text-sm shadow-sm"
|
||
style={{ backgroundColor: accentColor, color: textColor }}
|
||
>
|
||
{getInitials(name)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function Header({ eventToken, title = '' }: { eventToken?: string; title?: string }) {
|
||
const statsContext = useOptionalEventStats();
|
||
const identity = useOptionalGuestIdentity();
|
||
const { t } = useTranslation();
|
||
const brandingContext = useOptionalEventBranding();
|
||
const branding = brandingContext?.branding ?? DEFAULT_EVENT_BRANDING;
|
||
const primaryForeground = '#ffffff';
|
||
const { event, status } = useEventData();
|
||
const notificationCenter = useOptionalNotificationCenter();
|
||
const [notificationsOpen, setNotificationsOpen] = React.useState(false);
|
||
const taskProgress = useGuestTaskProgress(eventToken);
|
||
const panelRef = React.useRef<HTMLDivElement | null>(null);
|
||
const checklistItems = React.useMemo(
|
||
() => [
|
||
t('home.checklist.steps.first'),
|
||
t('home.checklist.steps.second'),
|
||
t('home.checklist.steps.third'),
|
||
],
|
||
[t],
|
||
);
|
||
|
||
React.useEffect(() => {
|
||
if (!notificationsOpen) {
|
||
return;
|
||
}
|
||
const handler = (event: MouseEvent) => {
|
||
if (!panelRef.current) return;
|
||
if (panelRef.current.contains(event.target as Node)) return;
|
||
setNotificationsOpen(false);
|
||
};
|
||
document.addEventListener('mousedown', handler);
|
||
return () => document.removeEventListener('mousedown', handler);
|
||
}, [notificationsOpen]);
|
||
|
||
if (!eventToken) {
|
||
const guestName = identity?.name && identity?.hydrated ? identity.name : null;
|
||
return (
|
||
<div
|
||
className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40"
|
||
style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}
|
||
>
|
||
<div className="flex flex-col">
|
||
<div className="font-semibold">{title}</div>
|
||
{guestName && (
|
||
<span className="text-xs text-muted-foreground">
|
||
{`${t('common.hi')} ${guestName}`}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<AppearanceToggleDropdown />
|
||
<SettingsSheet />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const guestName =
|
||
identity && identity.eventKey === eventToken && identity.hydrated && identity.name ? identity.name : null;
|
||
|
||
const headerStyle: React.CSSProperties = {
|
||
background: `linear-gradient(135deg, ${branding.primaryColor}, ${branding.secondaryColor})`,
|
||
color: primaryForeground,
|
||
fontFamily: branding.fontFamily ?? undefined,
|
||
};
|
||
|
||
const accentColor = branding.secondaryColor;
|
||
|
||
if (status === 'loading') {
|
||
return (
|
||
<div className="sticky top-0 z-20 flex items-center justify-between border-b border-white/10 px-4 py-2 text-white shadow-sm backdrop-blur" style={headerStyle}>
|
||
<div className="font-semibold" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>{t('header.loading')}</div>
|
||
<div className="flex items-center gap-2">
|
||
<AppearanceToggleDropdown />
|
||
<SettingsSheet />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (status !== 'ready' || !event) {
|
||
return null;
|
||
}
|
||
|
||
const stats =
|
||
statsContext && statsContext.eventKey === eventToken ? statsContext : undefined;
|
||
return (
|
||
<div
|
||
className="sticky top-0 z-20 flex items-center justify-between border-b border-white/10 px-4 py-3 text-white shadow-sm backdrop-blur"
|
||
style={headerStyle}
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
{renderEventAvatar(event.name, event.type?.icon, accentColor, primaryForeground)}
|
||
<div className="flex flex-col" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>
|
||
<div className="font-semibold text-base">{event.name}</div>
|
||
{guestName && (
|
||
<span className="text-xs text-white/80">
|
||
{`${t('common.hi')} ${guestName}`}
|
||
</span>
|
||
)}
|
||
<div className="flex items-center gap-2 text-xs text-white/70">
|
||
{stats && (
|
||
<>
|
||
<span className="flex items-center gap-1">
|
||
<User className="h-3 w-3" />
|
||
<span>{`${stats.onlineGuests} ${t('header.stats.online')}`}</span>
|
||
</span>
|
||
<span className="text-muted-foreground">|</span>
|
||
<span className="flex items-center gap-1">
|
||
<span className="font-medium">{stats.tasksSolved}</span>{' '}
|
||
{t('header.stats.tasksSolved')}
|
||
</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{notificationCenter && eventToken && (
|
||
<NotificationButton
|
||
eventToken={eventToken}
|
||
center={notificationCenter}
|
||
open={notificationsOpen}
|
||
onToggle={() => setNotificationsOpen((prev) => !prev)}
|
||
panelRef={panelRef}
|
||
checklistItems={checklistItems}
|
||
taskProgress={taskProgress?.hydrated ? taskProgress : undefined}
|
||
t={t}
|
||
/>
|
||
)}
|
||
<AppearanceToggleDropdown />
|
||
<SettingsSheet />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
type NotificationButtonProps = {
|
||
center: NotificationCenterValue;
|
||
eventToken: string;
|
||
open: boolean;
|
||
onToggle: () => void;
|
||
panelRef: React.RefObject<HTMLDivElement>;
|
||
checklistItems: string[];
|
||
taskProgress?: ReturnType<typeof useGuestTaskProgress>;
|
||
t: TranslateFn;
|
||
};
|
||
|
||
type PushState = ReturnType<typeof usePushSubscription>;
|
||
|
||
function NotificationButton({ center, eventToken, open, onToggle, panelRef, checklistItems, taskProgress, t }: NotificationButtonProps) {
|
||
const badgeCount = center.totalCount;
|
||
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');
|
||
const pushState = usePushSubscription(eventToken);
|
||
|
||
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">
|
||
<button
|
||
type="button"
|
||
onClick={onToggle}
|
||
className="relative rounded-full bg-white/15 p-2 text-white transition hover:bg-white/30"
|
||
aria-label={t('header.notifications.open', 'Benachrichtigungen anzeigen')}
|
||
>
|
||
<Bell className="h-5 w-5" aria-hidden />
|
||
{badgeCount > 0 && (
|
||
<span className="absolute -right-1 -top-1 rounded-full bg-white px-1.5 text-[10px] font-semibold text-pink-600">
|
||
{badgeCount > 9 ? '9+' : badgeCount}
|
||
</span>
|
||
)}
|
||
</button>
|
||
{open && (
|
||
<div
|
||
ref={panelRef}
|
||
className="absolute right-0 mt-2 w-80 rounded-2xl border border-white/30 bg-white/95 p-4 text-slate-900 shadow-2xl"
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<p className="text-sm font-semibold text-slate-900">{t('header.notifications.title', 'Benachrichtigungen')}</p>
|
||
<p className="text-xs text-slate-500">
|
||
{center.unreadCount > 0
|
||
? t('header.notifications.unread', '{{count}} neu', { count: center.unreadCount })
|
||
: t('header.notifications.allRead', 'Alles gelesen')}
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => center.refresh()}
|
||
disabled={center.loading}
|
||
className="flex items-center gap-1 rounded-full border border-slate-200 px-2 py-1 text-xs font-semibold text-slate-600 transition hover:border-pink-300 disabled:cursor-not-allowed"
|
||
>
|
||
<RefreshCw className={`h-3.5 w-3.5 ${center.loading ? 'animate-spin' : ''}`} aria-hidden />
|
||
{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}
|
||
push={pushState}
|
||
t={t}
|
||
/>
|
||
<div className="mt-3 max-h-80 space-y-2 overflow-y-auto pr-1">
|
||
{center.loading ? (
|
||
<NotificationSkeleton />
|
||
) : 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
|
||
}
|
||
/>
|
||
) : (
|
||
filteredNotifications.map((item) => (
|
||
<NotificationListItem
|
||
key={item.id}
|
||
item={item}
|
||
onMarkRead={() => center.markAsRead(item.id)}
|
||
onDismiss={() => center.dismiss(item.id)}
|
||
t={t}
|
||
/>
|
||
))
|
||
)}
|
||
</div>
|
||
<div className="mt-4 rounded-2xl border border-slate-200 bg-slate-50/80 p-3">
|
||
<div className="flex items-center justify-between text-sm">
|
||
<span className="text-slate-600">
|
||
{t('header.notifications.queueLabel', 'Uploads in Warteschlange')}
|
||
</span>
|
||
<span className="font-semibold text-slate-900">{center.queueCount}</span>
|
||
</div>
|
||
<Link
|
||
to={`/e/${encodeURIComponent(eventToken)}/queue`}
|
||
className="mt-2 inline-flex items-center gap-1 text-sm font-semibold text-pink-600"
|
||
onClick={() => {
|
||
if (center.unreadCount > 0) {
|
||
void center.refresh();
|
||
}
|
||
}}
|
||
>
|
||
{t('header.notifications.queueCta', 'Upload-Verlauf öffnen')}
|
||
<ArrowUpRight className="h-4 w-4" aria-hidden />
|
||
</Link>
|
||
</div>
|
||
{taskProgress && (
|
||
<div className="mt-3 rounded-2xl border border-slate-200 bg-slate-50/90 p-3">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-xs uppercase tracking-[0.3em] text-slate-400">{t('header.notifications.badgeLabel', 'Badge-Fortschritt')}</p>
|
||
<p className="text-lg font-semibold text-slate-900">
|
||
{taskProgress.completedCount}/{TASK_BADGE_TARGET}
|
||
</p>
|
||
</div>
|
||
<Link
|
||
to={`/e/${encodeURIComponent(eventToken)}/tasks`}
|
||
className="rounded-full border border-slate-200 px-3 py-1 text-xs font-semibold text-pink-600 transition hover:border-pink-300"
|
||
>
|
||
{t('header.notifications.tasksCta', 'Weiter')}
|
||
</Link>
|
||
</div>
|
||
<div className="mt-3 h-1.5 w-full rounded-full bg-slate-100">
|
||
<div
|
||
className="h-full rounded-full bg-pink-500"
|
||
style={{ width: `${progressRatio * 100}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="my-3 h-px w-full bg-slate-100" />
|
||
<p className="text-[11px] uppercase tracking-[0.3em] text-slate-400">{t('header.notifications.checklistTitle', 'So funktioniert’s')}</p>
|
||
<ul className="mt-2 space-y-2 text-sm text-slate-600">
|
||
{checklistItems.map((item) => (
|
||
<li key={item} className="flex gap-2">
|
||
<span className="mt-0.5 h-1.5 w-1.5 rounded-full bg-pink-500" />
|
||
<span>{item}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function NotificationListItem({
|
||
item,
|
||
onMarkRead,
|
||
onDismiss,
|
||
t,
|
||
}: {
|
||
item: NotificationCenterValue['notifications'][number];
|
||
onMarkRead: () => void;
|
||
onDismiss: () => void;
|
||
t: TranslateFn;
|
||
}) {
|
||
const IconComponent = NOTIFICATION_ICON_MAP[item.type] ?? Bell;
|
||
const isNew = item.status === 'new';
|
||
const createdLabel = item.createdAt ? formatRelativeTime(item.createdAt) : '';
|
||
|
||
return (
|
||
<div
|
||
className={`rounded-2xl border px-3 py-2.5 transition ${isNew ? 'border-pink-200 bg-pink-50/70' : 'border-slate-200 bg-white/90'}`}
|
||
onClick={() => {
|
||
if (isNew) {
|
||
onMarkRead();
|
||
}
|
||
}}
|
||
>
|
||
<div className="flex items-start gap-3">
|
||
<div className={`rounded-full p-1.5 ${isNew ? 'bg-white text-pink-600' : 'bg-slate-100 text-slate-500'}`}>
|
||
<IconComponent className="h-4 w-4" aria-hidden />
|
||
</div>
|
||
<div className="flex-1 space-y-1">
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div>
|
||
<p className="text-sm font-semibold text-slate-900">{item.title}</p>
|
||
{item.body && <p className="text-xs text-slate-600">{item.body}</p>}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
onDismiss();
|
||
}}
|
||
className="rounded-full p-1 text-slate-400 transition hover:text-slate-700"
|
||
aria-label={t('header.notifications.dismiss', 'Ausblenden')}
|
||
>
|
||
<X className="h-3.5 w-3.5" aria-hidden />
|
||
</button>
|
||
</div>
|
||
<div className="flex items-center gap-2 text-[11px] text-slate-400">
|
||
{createdLabel && <span>{createdLabel}</span>}
|
||
{isNew && (
|
||
<span className="inline-flex items-center gap-1 rounded-full bg-pink-100 px-1.5 py-0.5 text-[10px] font-semibold text-pink-600">
|
||
<Sparkles className="h-3 w-3" aria-hidden />
|
||
{t('header.notifications.badge.new', 'Neu')}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{item.cta && (
|
||
<NotificationCta cta={item.cta} onFollow={onMarkRead} />
|
||
)}
|
||
{!isNew && item.status !== 'dismissed' && (
|
||
<button
|
||
type="button"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
onMarkRead();
|
||
}}
|
||
className="inline-flex items-center gap-1 text-[11px] font-semibold text-pink-600"
|
||
>
|
||
<Check className="h-3 w-3" aria-hidden />
|
||
{t('header.notifications.markRead', 'Als gelesen markieren')}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function NotificationCta({ cta, onFollow }: { cta: { label?: string; href?: string }; onFollow: () => void }) {
|
||
const href = cta.href ?? '#';
|
||
const label = cta.label ?? '';
|
||
const isInternal = /^\//.test(href);
|
||
const content = (
|
||
<span className="inline-flex items-center gap-1">
|
||
{label}
|
||
<ArrowUpRight className="h-3.5 w-3.5" aria-hidden />
|
||
</span>
|
||
);
|
||
|
||
if (isInternal) {
|
||
return (
|
||
<Link
|
||
to={href}
|
||
className="inline-flex items-center gap-1 text-sm font-semibold text-pink-600"
|
||
onClick={onFollow}
|
||
>
|
||
{content}
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<a
|
||
href={href}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="inline-flex items-center gap-1 text-sm font-semibold text-pink-600"
|
||
onClick={onFollow}
|
||
>
|
||
{content}
|
||
</a>
|
||
);
|
||
}
|
||
|
||
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>{message ?? t('header.notifications.empty', 'Gerade gibt es keine neuen Hinweise.')}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function NotificationSkeleton() {
|
||
return (
|
||
<div className="space-y-2">
|
||
{[0, 1, 2].map((index) => (
|
||
<div key={index} className="animate-pulse rounded-2xl border border-slate-200 bg-slate-100/60 p-3">
|
||
<div className="flex items-center gap-3">
|
||
<div className="h-8 w-8 rounded-full bg-slate-200" />
|
||
<div className="flex-1 space-y-2">
|
||
<div className="h-3 w-3/4 rounded bg-slate-200" />
|
||
<div className="h-3 w-1/2 rounded bg-slate-200" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function formatRelativeTime(value: string): string {
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) {
|
||
return '';
|
||
}
|
||
|
||
const diffMs = Date.now() - date.getTime();
|
||
const diffMinutes = Math.max(0, Math.round(diffMs / 60000));
|
||
|
||
if (diffMinutes < 1) {
|
||
return 'Gerade eben';
|
||
}
|
||
|
||
if (diffMinutes < 60) {
|
||
return `${diffMinutes} min`;
|
||
}
|
||
|
||
const diffHours = Math.round(diffMinutes / 60);
|
||
if (diffHours < 24) {
|
||
return `${diffHours} h`;
|
||
}
|
||
|
||
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,
|
||
push,
|
||
t,
|
||
}: {
|
||
lastFetchedAt: Date | null;
|
||
isOffline: boolean;
|
||
push: PushState;
|
||
t: TranslateFn;
|
||
}) {
|
||
const label = lastFetchedAt ? formatRelativeTime(lastFetchedAt.toISOString()) : t('header.notifications.never', 'Noch keine Aktualisierung');
|
||
const pushDescription = React.useMemo(() => {
|
||
if (!push.supported) {
|
||
return t('header.notifications.pushUnsupported', 'Push wird nicht unterstützt');
|
||
}
|
||
if (push.permission === 'denied') {
|
||
return t('header.notifications.pushDenied', 'Browser blockiert Benachrichtigungen');
|
||
}
|
||
if (push.subscribed) {
|
||
return t('header.notifications.pushActive', 'Push aktiv');
|
||
}
|
||
return t('header.notifications.pushInactive', 'Push deaktiviert');
|
||
}, [push.permission, push.subscribed, push.supported, t]);
|
||
|
||
const buttonLabel = push.subscribed
|
||
? t('header.notifications.pushDisable', 'Deaktivieren')
|
||
: t('header.notifications.pushEnable', 'Aktivieren');
|
||
|
||
const pushButtonDisabled = push.loading || !push.supported || push.permission === 'denied';
|
||
|
||
return (
|
||
<div className="mt-2 space-y-2 text-[11px] text-slate-500">
|
||
<div className="flex items-center justify-between">
|
||
<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>
|
||
<div className="flex items-center justify-between gap-2 rounded-full bg-slate-100/80 px-3 py-1 text-[11px] font-semibold text-slate-600">
|
||
<div className="flex items-center gap-1">
|
||
<Bell className="h-3.5 w-3.5" aria-hidden />
|
||
<span>{pushDescription}</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => (push.subscribed ? push.disable() : push.enable())}
|
||
disabled={pushButtonDisabled}
|
||
className="rounded-full bg-white/80 px-3 py-0.5 text-[11px] font-semibold text-pink-600 shadow disabled:cursor-not-allowed disabled:opacity-60"
|
||
>
|
||
{push.loading ? t('header.notifications.pushLoading', '…') : buttonLabel}
|
||
</button>
|
||
</div>
|
||
{push.error && (
|
||
<p className="text-[11px] font-semibold text-rose-600">
|
||
{push.error}
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|