Files
fotospiel-app/resources/js/guest/components/Header.tsx
Codex Agent 2287e7f32c
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Fix tenant photo moderation and guest updates
2026-01-12 14:35:57 +01:00

751 lines
27 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,
};
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 }) {
if (logo?.mode === 'upload' && logo.value) {
return (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white shadow-sm">
<img src={logo.value} alt={name} className="h-9 w-9 rounded-full object-contain" />
</div>
);
}
if (logo?.mode === 'emoticon' && logo.value && isLikelyEmoji(logo.value)) {
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>{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 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 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 basePath = eventToken ? `/e/${encodeURIComponent(eventToken)}` : '';
const showGalleryHelp = Boolean(
basePath
&& (location.pathname.startsWith(`${basePath}/gallery`) || location.pathname.startsWith(`${basePath}/photo`))
);
const galleryHelpHref = basePath ? `${basePath}/help/gallery-and-sharing` : '/help/gallery-and-sharing';
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="flex items-center gap-3">
{renderEventAvatar(event.name, event.type?.icon, accentColor, headerTextColor, branding.logo)}
<div className="flex flex-col" 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}
/>
)}
{showGalleryHelp && (
<Link
to={galleryHelpHref}
className="rounded-full bg-white/15 p-2 text-white transition hover:bg-white/30"
aria-label={t('header.helpGallery')}
>
<LifeBuoy className="h-5 w-5" aria-hidden />
</Link>
)}
<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>
);
}