766 lines
28 KiB
TypeScript
766 lines
28 KiB
TypeScript
import React from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { Link, useLocation } from 'react-router-dom';
|
|
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
|
import {
|
|
User,
|
|
Heart,
|
|
Users,
|
|
PartyPopper,
|
|
Camera,
|
|
Bell,
|
|
ArrowUpRight,
|
|
Clock,
|
|
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 { usePushSubscription } from '../hooks/usePushSubscription';
|
|
import { getContrastingTextColor, relativeLuminance } from '../lib/color';
|
|
import { isTaskModeEnabled } from '../lib/engagement';
|
|
|
|
const EVENT_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: string }>> = {
|
|
heart: Heart,
|
|
guests: Users,
|
|
party: PartyPopper,
|
|
camera: Camera,
|
|
};
|
|
|
|
type LogoSize = 's' | 'm' | 'l';
|
|
|
|
const LOGO_SIZE_CLASSES: Record<LogoSize, { container: string; image: string; emoji: string; icon: string }> = {
|
|
s: { container: 'h-8 w-8', image: 'h-7 w-7', emoji: 'text-lg', icon: 'h-4 w-4' },
|
|
m: { container: 'h-10 w-10', image: 'h-9 w-9', emoji: 'text-xl', icon: 'h-5 w-5' },
|
|
l: { container: 'h-12 w-12', image: 'h-11 w-11', emoji: 'text-2xl', icon: 'h-6 w-6' },
|
|
};
|
|
|
|
function getLogoClasses(size?: LogoSize) {
|
|
return LOGO_SIZE_CLASSES[size ?? 'm'];
|
|
}
|
|
|
|
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,
|
|
logo?: { mode: 'emoticon' | 'upload'; value: string | null; size?: LogoSize }
|
|
) {
|
|
const sizes = getLogoClasses(logo?.size);
|
|
if (logo?.mode === 'upload' && logo.value) {
|
|
return (
|
|
<div className={`flex items-center justify-center rounded-full bg-white shadow-sm ${sizes.container}`}>
|
|
<img src={logo.value} alt={name} className={`rounded-full object-contain ${sizes.image}`} />
|
|
</div>
|
|
);
|
|
}
|
|
if (logo?.mode === 'emoticon' && logo.value && isLikelyEmoji(logo.value)) {
|
|
return (
|
|
<div
|
|
className={`flex items-center justify-center rounded-full shadow-sm ${sizes.container} ${sizes.emoji}`}
|
|
style={{ backgroundColor: accentColor, color: textColor }}
|
|
>
|
|
<span aria-hidden>{logo.value}</span>
|
|
<span className="sr-only">{name}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 items-center justify-center rounded-full shadow-sm ${sizes.container}`}
|
|
style={{ backgroundColor: accentColor, color: textColor }}
|
|
>
|
|
<IconComponent className={sizes.icon} aria-hidden />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isLikelyEmoji(trimmed)) {
|
|
return (
|
|
<div
|
|
className={`flex items-center justify-center rounded-full shadow-sm ${sizes.container} ${sizes.emoji}`}
|
|
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 location = useLocation();
|
|
const statsContext = useOptionalEventStats();
|
|
const identity = useOptionalGuestIdentity();
|
|
const { t } = useTranslation();
|
|
const brandingContext = useOptionalEventBranding();
|
|
const branding = brandingContext?.branding ?? DEFAULT_EVENT_BRANDING;
|
|
const headerTextColor = React.useMemo(() => {
|
|
const primaryLum = relativeLuminance(branding.primaryColor);
|
|
const secondaryLum = relativeLuminance(branding.secondaryColor);
|
|
const avgLum = (primaryLum + secondaryLum) / 2;
|
|
|
|
if (avgLum > 0.55) {
|
|
return getContrastingTextColor(branding.primaryColor, '#0f172a', '#ffffff');
|
|
}
|
|
|
|
return '#ffffff';
|
|
}, [branding.primaryColor, branding.secondaryColor]);
|
|
const { event, status } = useEventData();
|
|
const notificationCenter = useOptionalNotificationCenter();
|
|
const [notificationsOpen, setNotificationsOpen] = React.useState(false);
|
|
const tasksEnabled = isTaskModeEnabled(event);
|
|
const panelRef = React.useRef<HTMLDivElement | null>(null);
|
|
const notificationButtonRef = React.useRef<HTMLButtonElement | null>(null);
|
|
React.useEffect(() => {
|
|
if (!notificationsOpen) {
|
|
return;
|
|
}
|
|
const handler = (event: MouseEvent) => {
|
|
if (notificationButtonRef.current?.contains(event.target as Node)) {
|
|
return;
|
|
}
|
|
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) {
|
|
return (
|
|
<div
|
|
className="guest-header z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 dark:bg-black/40"
|
|
style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}
|
|
>
|
|
<div className="flex flex-col">
|
|
<div className="font-semibold">{title}</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<AppearanceToggleDropdown />
|
|
<SettingsSheet />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const headerFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;
|
|
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
|
|
const logoPosition = branding.logo?.position ?? 'left';
|
|
const headerStyle: React.CSSProperties = {
|
|
background: `linear-gradient(135deg, ${branding.primaryColor}, ${branding.secondaryColor})`,
|
|
color: headerTextColor,
|
|
fontFamily: headerFont,
|
|
};
|
|
|
|
const accentColor = branding.secondaryColor;
|
|
|
|
if (status === 'loading') {
|
|
return (
|
|
<div className="guest-header 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="guest-header 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={
|
|
logoPosition === 'center'
|
|
? 'flex flex-col items-center gap-2 text-center'
|
|
: logoPosition === 'right'
|
|
? 'flex flex-row-reverse items-center gap-3'
|
|
: 'flex items-center gap-3'
|
|
}
|
|
>
|
|
{renderEventAvatar(event.name, event.type?.icon, accentColor, headerTextColor, branding.logo)}
|
|
<div
|
|
className={`flex flex-col${logoPosition === 'center' ? ' items-center text-center' : ''}`}
|
|
style={headerFont ? { fontFamily: headerFont } : undefined}
|
|
>
|
|
<div className="font-semibold text-lg">{event.name}</div>
|
|
<div className="flex items-center gap-2 text-xs text-white/70" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
|
|
{stats && tasksEnabled && (
|
|
<>
|
|
<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}
|
|
buttonRef={notificationButtonRef}
|
|
t={t}
|
|
/>
|
|
)}
|
|
<AppearanceToggleDropdown />
|
|
<SettingsSheet />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type NotificationButtonProps = {
|
|
center: NotificationCenterValue;
|
|
eventToken: string;
|
|
open: boolean;
|
|
onToggle: () => void;
|
|
panelRef: React.RefObject<HTMLDivElement | null>;
|
|
buttonRef: React.RefObject<HTMLButtonElement | null>;
|
|
t: TranslateFn;
|
|
};
|
|
|
|
type PushState = ReturnType<typeof usePushSubscription>;
|
|
|
|
function NotificationButton({ center, eventToken, open, onToggle, panelRef, buttonRef, t }: NotificationButtonProps) {
|
|
const badgeCount = center.unreadCount;
|
|
const [activeTab, setActiveTab] = React.useState<'unread' | 'all' | 'uploads'>(center.unreadCount > 0 ? 'unread' : 'all');
|
|
const [scopeFilter, setScopeFilter] = React.useState<'all' | 'tips' | 'general'>('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(() => {
|
|
let base: typeof center.notifications = [];
|
|
switch (activeTab) {
|
|
case 'unread':
|
|
base = unreadNotifications;
|
|
break;
|
|
case 'uploads':
|
|
base = uploadNotifications;
|
|
break;
|
|
default:
|
|
base = center.notifications;
|
|
}
|
|
return base;
|
|
}, [activeTab, center.notifications, unreadNotifications, uploadNotifications]);
|
|
|
|
const scopedNotifications = React.useMemo(() => {
|
|
if (activeTab === 'uploads' || scopeFilter === 'all') {
|
|
return filteredNotifications;
|
|
}
|
|
return filteredNotifications.filter((item) => {
|
|
if (scopeFilter === 'tips') {
|
|
return item.type === 'support_tip' || item.type === 'achievement_major';
|
|
}
|
|
return item.type === 'broadcast' || item.type === 'feedback_request';
|
|
});
|
|
}, [filteredNotifications, scopeFilter]);
|
|
|
|
return (
|
|
<div className="relative z-50">
|
|
<button
|
|
ref={buttonRef}
|
|
type="button"
|
|
onClick={onToggle}
|
|
className="relative rounded-full bg-white/15 p-2 text-white transition hover:bg-white/30"
|
|
aria-label={open ? t('header.notifications.close', 'Benachrichtigungen schließen') : t('header.notifications.open', 'Benachrichtigungen anzeigen')}
|
|
>
|
|
<Bell className="h-5 w-5" aria-hidden />
|
|
{badgeCount > 0 && (
|
|
<span className="absolute -right-1 -top-1 min-h-[18px] min-w-[18px] rounded-full bg-pink-500 px-1.5 text-[11px] font-semibold leading-[18px] text-white shadow-lg">
|
|
{badgeCount > 9 ? '9+' : badgeCount}
|
|
</span>
|
|
)}
|
|
</button>
|
|
{open && createPortal(
|
|
<div
|
|
ref={panelRef}
|
|
className="fixed right-4 top-16 z-[2147483000] 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', 'Updates')}</p>
|
|
<p className="text-xs text-slate-500">
|
|
{center.unreadCount > 0
|
|
? t('header.notifications.unread', { defaultValue: '{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', 'Nachrichten'), badge: unreadNotifications.length },
|
|
{ key: 'uploads', label: t('header.notifications.tabUploads', 'Uploads'), badge: uploadNotifications.length },
|
|
{ key: 'all', label: t('header.notifications.tabAll', 'Alle Updates'), badge: center.notifications.length },
|
|
]}
|
|
activeTab={activeTab}
|
|
onTabChange={(next) => setActiveTab(next as typeof activeTab)}
|
|
/>
|
|
{activeTab !== 'uploads' && (
|
|
<div className="mt-3">
|
|
<div className="flex gap-2 overflow-x-auto text-xs whitespace-nowrap pb-1">
|
|
{(
|
|
[
|
|
{ key: 'all', label: t('header.notifications.scope.all', 'Alle') },
|
|
{ key: 'tips', label: t('header.notifications.scope.tips', 'Tipps & Achievements') },
|
|
{ key: 'general', label: t('header.notifications.scope.general', 'Allgemein') },
|
|
] as const
|
|
).map((option) => (
|
|
<button
|
|
key={option.key}
|
|
type="button"
|
|
onClick={() => {
|
|
setScopeFilter(option.key);
|
|
center.setFilters({ scope: option.key });
|
|
}}
|
|
className={`rounded-full border px-3 py-1 font-semibold transition ${
|
|
scopeFilter === option.key
|
|
? 'border-pink-200 bg-pink-50 text-pink-700'
|
|
: 'border-slate-200 bg-white text-slate-600 hover:border-pink-200 hover:text-pink-700'
|
|
}`}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{activeTab === 'uploads' && (center.pendingCount > 0 || center.queueCount > 0) && (
|
|
<div className="mt-3 space-y-2">
|
|
{center.pendingCount > 0 && (
|
|
<div className="flex items-center justify-between rounded-xl bg-amber-50/90 px-3 py-2 text-xs text-amber-900">
|
|
<div className="flex items-center gap-2">
|
|
<Clock className="h-4 w-4 text-amber-500" aria-hidden />
|
|
<span>{t('header.notifications.pendingLabel', 'Uploads in Prüfung')}</span>
|
|
<span className="font-semibold text-amber-900">{center.pendingCount}</span>
|
|
</div>
|
|
<Link
|
|
to={`/e/${encodeURIComponent(eventToken)}/queue`}
|
|
className="inline-flex items-center gap-1 font-semibold text-amber-700"
|
|
onClick={() => {
|
|
if (center.unreadCount > 0) {
|
|
void center.refresh();
|
|
}
|
|
}}
|
|
>
|
|
{t('header.notifications.pendingCta', 'Details')}
|
|
<ArrowUpRight className="h-4 w-4" aria-hidden />
|
|
</Link>
|
|
</div>
|
|
)}
|
|
{center.queueCount > 0 && (
|
|
<div className="flex items-center justify-between rounded-xl bg-slate-50/90 px-3 py-2 text-xs text-slate-600">
|
|
<div className="flex items-center gap-2">
|
|
<UploadCloud className="h-4 w-4 text-slate-400" aria-hidden />
|
|
<span>{t('header.notifications.queueLabel', 'Upload-Warteschlange (offline)')}</span>
|
|
<span className="font-semibold text-slate-900">{center.queueCount}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="mt-3 max-h-80 space-y-2 overflow-y-auto pr-1">
|
|
{center.loading ? (
|
|
<NotificationSkeleton />
|
|
) : scopedNotifications.length === 0 ? (
|
|
<NotificationEmptyState
|
|
t={t}
|
|
message={
|
|
activeTab === 'unread'
|
|
? t('header.notifications.emptyUnread', 'Du bist auf dem neuesten Stand!')
|
|
: activeTab === 'uploads'
|
|
? t('header.notifications.emptyStatus', 'Keine Upload-Hinweise oder Wartungen aktiv.')
|
|
: undefined
|
|
}
|
|
/>
|
|
) : (
|
|
scopedNotifications.map((item) => (
|
|
<NotificationListItem
|
|
key={item.id}
|
|
item={item}
|
|
onMarkRead={() => center.markAsRead(item.id)}
|
|
onDismiss={() => center.dismiss(item.id)}
|
|
t={t}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
<NotificationStatusBar
|
|
lastFetchedAt={center.lastFetchedAt}
|
|
isOffline={center.isOffline}
|
|
push={pushState}
|
|
t={t}
|
|
/>
|
|
</div>,
|
|
(typeof document !== 'undefined' ? document.body : null) as any
|
|
)}
|
|
</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-4 space-y-2 border-t border-slate-200 pt-3 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>
|
|
);
|
|
}
|