763 lines
28 KiB
TypeScript
763 lines
28 KiB
TypeScript
import React from 'react';
|
|
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Bell, Check, ChevronRight, RefreshCcw } from 'lucide-react';
|
|
import { YStack, XStack } from '@tamagui/stacks';
|
|
import { SizableText as Text } from '@tamagui/text';
|
|
import { Pressable } from '@tamagui/react-native-web-lite';
|
|
import { motion, useAnimationControls, type PanInfo } from 'framer-motion';
|
|
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
|
import { MobileCard, PillBadge, SkeletonCard, CTAButton } from './components/Primitives';
|
|
import { MobileSelect } from './components/FormControls';
|
|
import { listNotificationLogs, markNotificationLogs, NotificationLogEntry } from '../api';
|
|
import { isAuthError } from '../auth/tokens';
|
|
import { getApiErrorMessage } from '../lib/apiError';
|
|
import toast from 'react-hot-toast';
|
|
import { MobileSheet } from './components/Sheet';
|
|
import { getEvents, TenantEvent } from '../api';
|
|
import { triggerHaptic } from './lib/haptics';
|
|
import { adminPath } from '../constants';
|
|
import { useBackNavigation } from './hooks/useBackNavigation';
|
|
import { groupNotificationsByScope, type NotificationScope, type NotificationGroup } from './lib/notificationGrouping';
|
|
import { collectUnreadIds } from './lib/notificationUnread';
|
|
import { formatRelativeTime } from './lib/relativeTime';
|
|
import { useAdminTheme } from './theme';
|
|
|
|
type NotificationItem = {
|
|
id: string;
|
|
title: string;
|
|
body: string;
|
|
time: string;
|
|
tone: 'info' | 'warning';
|
|
eventId?: number | null;
|
|
eventName?: string | null;
|
|
is_read?: boolean;
|
|
scope: NotificationScope;
|
|
};
|
|
|
|
type NotificationSwipeRowProps = {
|
|
item: NotificationItem;
|
|
onOpen: (item: NotificationItem) => void;
|
|
onMarkRead: (item: NotificationItem) => void;
|
|
children: React.ReactNode;
|
|
};
|
|
|
|
function NotificationSwipeRow({ item, onOpen, onMarkRead, children }: NotificationSwipeRowProps) {
|
|
const { t } = useTranslation('management');
|
|
const { successBg, successText, infoBg, infoText } = useAdminTheme();
|
|
const controls = useAnimationControls();
|
|
const dragged = React.useRef(false);
|
|
const markBg = successBg;
|
|
const markText = successText;
|
|
const detailBg = infoBg;
|
|
const detailText = infoText;
|
|
|
|
const handleDrag = (_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
|
|
dragged.current = Math.abs(info.offset.x) > 6;
|
|
};
|
|
|
|
const handleDragEnd = (_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
|
|
const swipeThreshold = 64;
|
|
const offsetX = info.offset.x;
|
|
if (offsetX > swipeThreshold && !item.is_read) {
|
|
void onMarkRead(item);
|
|
} else if (offsetX < -swipeThreshold) {
|
|
onOpen(item);
|
|
}
|
|
dragged.current = false;
|
|
void controls.start({ x: 0, transition: { type: 'spring', stiffness: 320, damping: 26 } });
|
|
};
|
|
|
|
const handlePress = () => {
|
|
if (dragged.current) {
|
|
dragged.current = false;
|
|
return;
|
|
}
|
|
onOpen(item);
|
|
};
|
|
|
|
return (
|
|
<YStack position="relative">
|
|
<XStack
|
|
alignItems="center"
|
|
justifyContent="space-between"
|
|
paddingHorizontal="$3"
|
|
borderRadius="$4"
|
|
pointerEvents="none"
|
|
style={{ position: 'absolute', top: 0, right: 0, bottom: 0, left: 0 }}
|
|
>
|
|
<XStack alignItems="center" space="$2">
|
|
<Check size={16} color={markText} />
|
|
<Text fontSize="$xs" fontWeight="700" color={markText}>
|
|
{item.is_read ? t('notificationLogs.read', 'Read') : t('notificationLogs.markRead', 'Mark read')}
|
|
</Text>
|
|
</XStack>
|
|
<XStack alignItems="center" space="$2">
|
|
<Text fontSize="$xs" fontWeight="700" color={detailText}>
|
|
Details
|
|
</Text>
|
|
<ChevronRight size={16} color={detailText} />
|
|
</XStack>
|
|
</XStack>
|
|
<XStack
|
|
borderRadius="$4"
|
|
overflow="hidden"
|
|
pointerEvents="none"
|
|
backgroundColor={item.is_read ? detailBg : markBg}
|
|
opacity={0.5}
|
|
style={{ position: 'absolute', top: 0, right: 0, bottom: 0, left: 0 }}
|
|
/>
|
|
<motion.div
|
|
drag="x"
|
|
dragElastic={0.2}
|
|
dragConstraints={{ left: -96, right: 96 }}
|
|
onDrag={handleDrag}
|
|
onDragEnd={handleDragEnd}
|
|
animate={controls}
|
|
initial={{ x: 0 }}
|
|
style={{ touchAction: 'pan-y', position: 'relative', zIndex: 1 }}
|
|
>
|
|
<Pressable onPress={handlePress}>{children}</Pressable>
|
|
</motion.div>
|
|
</YStack>
|
|
);
|
|
}
|
|
|
|
function formatLog(
|
|
log: NotificationLogEntry,
|
|
t: (key: string, defaultValue?: string, options?: Record<string, unknown>) => string,
|
|
eventName?: string | null
|
|
): NotificationItem {
|
|
const ctx = log.context ?? {};
|
|
const limit = typeof ctx.limit === 'number' ? ctx.limit : null;
|
|
const used = typeof ctx.used === 'number' ? ctx.used : null;
|
|
const remaining = typeof ctx.remaining === 'number' ? ctx.remaining : null;
|
|
const days = typeof ctx.day === 'number' ? ctx.day : null;
|
|
const ctxEventId = ctx.event_id ?? ctx.eventId;
|
|
const eventId = typeof ctxEventId === 'string' ? Number(ctxEventId) : (typeof ctxEventId === 'number' ? ctxEventId : null);
|
|
const name = eventName ?? t('mobileNotifications.unknownEvent', 'Event');
|
|
const isRead = log.is_read === true;
|
|
const scope = (() => {
|
|
switch (log.type) {
|
|
case 'photo_limit':
|
|
case 'photo_threshold':
|
|
return 'photos';
|
|
case 'guest_limit':
|
|
case 'guest_threshold':
|
|
return 'guests';
|
|
case 'gallery_warning':
|
|
case 'gallery_expired':
|
|
return 'gallery';
|
|
case 'event_limit':
|
|
case 'event_threshold':
|
|
return 'events';
|
|
case 'package_expiring':
|
|
case 'package_expired':
|
|
return 'package';
|
|
default:
|
|
return 'general';
|
|
}
|
|
})();
|
|
|
|
switch (log.type) {
|
|
case 'photo_limit':
|
|
return {
|
|
id: String(log.id),
|
|
title: t('notificationLogs.photoLimit.title', 'Photo limit reached'),
|
|
body: t('notificationLogs.photoLimit.body', '{{event}} reached its photo limit of {{limit}}.', {
|
|
event: name,
|
|
limit: limit ?? '—',
|
|
}),
|
|
time: log.sent_at ?? log.failed_at ?? '',
|
|
tone: 'warning',
|
|
eventId,
|
|
eventName,
|
|
is_read: isRead,
|
|
scope,
|
|
};
|
|
case 'guest_limit':
|
|
return {
|
|
id: String(log.id),
|
|
title: t('notificationLogs.guestLimit.title', 'Guest limit reached'),
|
|
body: t('notificationLogs.guestLimit.body', '{{event}} reached its guest limit of {{limit}}.', {
|
|
event: name,
|
|
limit: limit ?? '—',
|
|
}),
|
|
time: log.sent_at ?? log.failed_at ?? '',
|
|
tone: 'warning',
|
|
eventId,
|
|
eventName,
|
|
is_read: isRead,
|
|
scope,
|
|
};
|
|
case 'event_limit':
|
|
return {
|
|
id: String(log.id),
|
|
title: t('notificationLogs.eventLimit.title', 'Event quota reached'),
|
|
body: t('notificationLogs.eventLimit.body', 'Your package allows no more events. Limit: {{limit}}.', {
|
|
limit: limit ?? '—',
|
|
}),
|
|
time: log.sent_at ?? log.failed_at ?? '',
|
|
tone: 'warning',
|
|
eventId,
|
|
eventName,
|
|
is_read: isRead,
|
|
scope,
|
|
};
|
|
case 'gallery_warning':
|
|
return {
|
|
id: String(log.id),
|
|
title: t('notificationLogs.galleryWarning.title', 'Gallery expiring soon'),
|
|
body: t('notificationLogs.galleryWarning.body', '{{event}} expires in {{days}} days.', {
|
|
event: name,
|
|
days: days ?? ctx.threshold ?? '—',
|
|
}),
|
|
time: log.sent_at ?? log.failed_at ?? '',
|
|
tone: 'warning',
|
|
eventId,
|
|
eventName,
|
|
is_read: isRead,
|
|
scope,
|
|
};
|
|
case 'gallery_expired':
|
|
return {
|
|
id: String(log.id),
|
|
title: t('notificationLogs.galleryExpired.title', 'Gallery expired'),
|
|
body: t('notificationLogs.galleryExpired.body', '{{event}} gallery is offline. Extend to reactivate.', {
|
|
event: name,
|
|
}),
|
|
time: log.sent_at ?? log.failed_at ?? '',
|
|
tone: 'warning',
|
|
eventId,
|
|
eventName,
|
|
is_read: isRead,
|
|
scope,
|
|
};
|
|
case 'photo_threshold':
|
|
return {
|
|
id: String(log.id),
|
|
title: t('notificationLogs.photoThreshold.title', 'Photo usage warning'),
|
|
body: t('notificationLogs.photoThreshold.body', '{{event}} is at {{used}} / {{limit}} photos.', {
|
|
event: name,
|
|
used: used ?? '—',
|
|
limit: limit ?? '—',
|
|
}),
|
|
time: log.sent_at ?? log.failed_at ?? '',
|
|
tone: 'info',
|
|
eventId,
|
|
eventName,
|
|
is_read: isRead,
|
|
scope,
|
|
};
|
|
case 'guest_threshold':
|
|
return {
|
|
id: String(log.id),
|
|
title: t('notificationLogs.guestThreshold.title', 'Guest usage warning'),
|
|
body: t('notificationLogs.guestThreshold.body', '{{event}} is at {{used}} / {{limit}} guests.', {
|
|
event: name,
|
|
used: used ?? '—',
|
|
limit: limit ?? '—',
|
|
}),
|
|
time: log.sent_at ?? log.failed_at ?? '',
|
|
tone: 'info',
|
|
eventId,
|
|
eventName,
|
|
is_read: isRead,
|
|
scope,
|
|
};
|
|
default:
|
|
return {
|
|
id: String(log.id),
|
|
title: log.type,
|
|
body: t('notificationLogs.generic.body', 'Notification sent via {{channel}}.', { channel: log.channel }),
|
|
time: log.sent_at ?? log.failed_at ?? '',
|
|
tone: 'info',
|
|
eventId,
|
|
eventName,
|
|
is_read: isRead,
|
|
scope,
|
|
};
|
|
}
|
|
}
|
|
|
|
async function loadNotifications(
|
|
t: (key: string, defaultValue?: string, options?: Record<string, unknown>) => string,
|
|
events?: TenantEvent[],
|
|
filters?: { scope?: string; status?: string; eventSlug?: string }
|
|
): Promise<NotificationItem[]> {
|
|
try {
|
|
const eventId = filters?.eventSlug ? (events ?? []).find((ev) => ev.slug === filters.eventSlug)?.id ?? undefined : undefined;
|
|
const response = await listNotificationLogs({
|
|
perPage: 50,
|
|
scope: filters?.scope && filters.scope !== 'all' ? filters.scope : undefined,
|
|
status: filters?.status === 'all' ? undefined : filters?.status,
|
|
eventId: eventId,
|
|
});
|
|
const lookup = new Map<number, string>();
|
|
(events ?? []).forEach((event) => {
|
|
lookup.set(event.id, typeof event.name === 'string' ? event.name : (event.name as Record<string, string>)?.en ?? '');
|
|
});
|
|
|
|
return (response.data ?? [])
|
|
.map((log) => {
|
|
const ctxEventId = log.context?.event_id ?? log.context?.eventId;
|
|
const parsed = typeof ctxEventId === 'string' ? Number(ctxEventId) : ctxEventId;
|
|
return formatLog(log, t, lookup.get(parsed as number));
|
|
});
|
|
} catch (err) {
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export default function MobileNotificationsPage() {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const { notificationId } = useParams<{ notificationId?: string }>();
|
|
const { t } = useTranslation('management');
|
|
const search = new URLSearchParams(typeof window !== 'undefined' ? window.location.search : '');
|
|
const slug = search.get('event') ?? undefined;
|
|
const scopeParam = search.get('scope') ?? 'all';
|
|
const statusParam = search.get('status') ?? 'unread';
|
|
const [notifications, setNotifications] = React.useState<NotificationItem[]>([]);
|
|
const [selectedNotification, setSelectedNotification] = React.useState<NotificationItem | null>(null);
|
|
const [detailOpen, setDetailOpen] = React.useState(false);
|
|
const [loading, setLoading] = React.useState(true);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
const [events, setEvents] = React.useState<TenantEvent[]>([]);
|
|
const [showEventPicker, setShowEventPicker] = React.useState(false);
|
|
const back = useBackNavigation(adminPath('/mobile/dashboard'));
|
|
const { text, muted, border, warningBg, warningText, infoBg, primary, danger, accentSoft, subtle } = useAdminTheme();
|
|
const warningIcon = warningText;
|
|
const infoIcon = primary;
|
|
const errorText = danger;
|
|
|
|
const reload = React.useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await loadNotifications(t as any, events, { scope: scopeParam, status: statusParam, eventSlug: slug });
|
|
setNotifications(data);
|
|
setError(null);
|
|
} catch (err) {
|
|
if (!isAuthError(err)) {
|
|
const message = getApiErrorMessage(err, t('events.errors.loadFailed', 'Benachrichtigungen konnten nicht geladen werden.'));
|
|
setError(message);
|
|
toast.error(message);
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [slug, t, events]);
|
|
|
|
React.useEffect(() => {
|
|
void reload();
|
|
}, [reload]);
|
|
|
|
React.useEffect(() => {
|
|
const handleMessage = (event: MessageEvent) => {
|
|
if (event.data?.type === 'admin-notification-refresh') {
|
|
void reload();
|
|
}
|
|
};
|
|
|
|
navigator.serviceWorker?.addEventListener('message', handleMessage);
|
|
|
|
return () => {
|
|
navigator.serviceWorker?.removeEventListener('message', handleMessage);
|
|
};
|
|
}, [reload]);
|
|
|
|
React.useEffect(() => {
|
|
(async () => {
|
|
try {
|
|
const list = await getEvents();
|
|
setEvents(list);
|
|
} catch {
|
|
// non-fatal
|
|
}
|
|
})();
|
|
}, []);
|
|
|
|
const filtered = React.useMemo(() => {
|
|
if (!slug) return notifications;
|
|
const target = events.find((ev) => ev.slug === slug);
|
|
if (!target) return notifications;
|
|
return notifications.filter((item) => item.eventId === target.id || item.body.includes(String(target.name)) || item.title.includes(String(target.name)));
|
|
}, [notifications, slug, events]);
|
|
|
|
const scoped = React.useMemo(() => {
|
|
if (scopeParam === 'all') return filtered;
|
|
return filtered.filter((item) => item.scope === scopeParam);
|
|
}, [filtered, scopeParam]);
|
|
|
|
const statusFiltered = React.useMemo(() => {
|
|
if (statusParam === 'all') return scoped;
|
|
if (statusParam === 'read') return scoped.filter((item) => item.is_read);
|
|
return scoped.filter((item) => !item.is_read);
|
|
}, [scoped, statusParam]);
|
|
|
|
const grouped = React.useMemo(() => groupNotificationsByScope(statusFiltered), [statusFiltered]);
|
|
|
|
const unreadIds = React.useMemo(() => collectUnreadIds(scoped), [scoped]);
|
|
|
|
const showFilterNotice = Boolean(slug) && filtered.length === 0 && notifications.length > 0;
|
|
|
|
const markNotificationRead = React.useCallback(
|
|
async (item: NotificationItem, options?: { close?: boolean }) => {
|
|
const id = Number(item.id);
|
|
if (!Number.isFinite(id)) return;
|
|
try {
|
|
await markNotificationLogs([id], 'read');
|
|
await reload();
|
|
triggerHaptic('success');
|
|
if (options?.close) {
|
|
setDetailOpen(false);
|
|
setSelectedNotification(null);
|
|
}
|
|
} catch {
|
|
toast.error(t('notificationLogs.markFailed', 'Could not update notifications.'));
|
|
}
|
|
},
|
|
[reload, t],
|
|
);
|
|
|
|
const markSelectedRead = React.useCallback(async () => {
|
|
if (!selectedNotification) return;
|
|
await markNotificationRead(selectedNotification, { close: true });
|
|
}, [markNotificationRead, selectedNotification]);
|
|
|
|
const notificationListPath = adminPath('/mobile/notifications');
|
|
const updateFilters = React.useCallback(
|
|
(params: { scope?: string; status?: string; event?: string | null }) => {
|
|
const next = new URLSearchParams({
|
|
status: params.status ?? statusParam,
|
|
scope: params.scope ?? scopeParam,
|
|
event: params.event ?? slug ?? '',
|
|
});
|
|
navigate(`${notificationListPath}?${next.toString()}`, { replace: false });
|
|
},
|
|
[navigate, notificationListPath, scopeParam, slug, statusParam],
|
|
);
|
|
|
|
const markGroupRead = React.useCallback(
|
|
async (group: NotificationGroup<NotificationItem>) => {
|
|
const ids = collectUnreadIds(group.items);
|
|
if (!ids.length) {
|
|
return;
|
|
}
|
|
try {
|
|
await markNotificationLogs(ids, 'read');
|
|
void reload();
|
|
triggerHaptic('success');
|
|
} catch {
|
|
toast.error(t('notificationLogs.markFailed', 'Could not update notifications.'));
|
|
}
|
|
},
|
|
[reload, t],
|
|
);
|
|
|
|
const openNotification = React.useCallback(
|
|
(item: NotificationItem) => {
|
|
setSelectedNotification(item);
|
|
setDetailOpen(true);
|
|
if (notificationId !== String(item.id)) {
|
|
navigate(`${notificationListPath}/${item.id}${location.search}`, { replace: false });
|
|
}
|
|
triggerHaptic('light');
|
|
},
|
|
[location.search, navigate, notificationId, notificationListPath],
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
if (!notificationId || loading) {
|
|
return;
|
|
}
|
|
const targetId = Number(notificationId);
|
|
if (!Number.isFinite(targetId)) {
|
|
return;
|
|
}
|
|
const target = notifications.find((item) => Number(item.id) === targetId);
|
|
if (target) {
|
|
setSelectedNotification(target);
|
|
setDetailOpen(true);
|
|
}
|
|
}, [notificationId, notifications, loading]);
|
|
|
|
return (
|
|
<MobileShell
|
|
activeTab="home"
|
|
title={t('mobileNotifications.title', 'Notifications')}
|
|
onBack={back}
|
|
headerActions={
|
|
<HeaderActionButton onPress={() => reload()} ariaLabel={t('common.refresh', 'Refresh')}>
|
|
<RefreshCcw size={18} color={text} />
|
|
</HeaderActionButton>
|
|
}
|
|
>
|
|
{error ? (
|
|
<MobileCard>
|
|
<Text fontWeight="700" color={errorText}>
|
|
{error}
|
|
</Text>
|
|
<CTAButton
|
|
label={t('common.retry', 'Retry')}
|
|
tone="ghost"
|
|
fullWidth={false}
|
|
onPress={() => reload()}
|
|
/>
|
|
</MobileCard>
|
|
) : null}
|
|
|
|
{showFilterNotice ? (
|
|
<MobileCard space="$2">
|
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
|
{t('notificationLogs.filterEmpty', 'No notifications for this event.')}
|
|
</Text>
|
|
<Pressable
|
|
onPress={() => {
|
|
updateFilters({ event: '' });
|
|
}}
|
|
>
|
|
<Text fontSize="$sm" color={primary} fontWeight="700">
|
|
{t('notificationLogs.clearFilter', 'Show all notifications')}
|
|
</Text>
|
|
</Pressable>
|
|
</MobileCard>
|
|
) : null}
|
|
|
|
<XStack space="$2" marginBottom="$2">
|
|
<MobileSelect
|
|
value={statusParam}
|
|
onChange={(e) => updateFilters({ status: e.target.value })}
|
|
compact
|
|
style={{ minWidth: 120 }}
|
|
>
|
|
<option value="unread">{t('notificationLogs.filter.unread', 'Unread')}</option>
|
|
<option value="read">{t('notificationLogs.filter.read', 'Read')}</option>
|
|
<option value="all">{t('notificationLogs.filter.all', 'All')}</option>
|
|
</MobileSelect>
|
|
{unreadIds.length ? (
|
|
<CTAButton
|
|
label={t('notificationLogs.markAllRead', 'Mark all read')}
|
|
onPress={async () => {
|
|
try {
|
|
await markNotificationLogs(unreadIds, 'read');
|
|
void reload();
|
|
triggerHaptic('success');
|
|
} catch {
|
|
toast.error(t('notificationLogs.markFailed', 'Could not update notifications.'));
|
|
}
|
|
}}
|
|
tone="ghost"
|
|
/>
|
|
) : null}
|
|
</XStack>
|
|
|
|
<XStack space="$2" flexWrap="wrap" marginBottom="$2">
|
|
{([
|
|
{ key: 'all', label: t('notificationLogs.scope.all', 'All scopes') },
|
|
{ key: 'photos', label: t('notificationLogs.scope.photos', 'Photos') },
|
|
{ key: 'guests', label: t('notificationLogs.scope.guests', 'Guests') },
|
|
{ key: 'gallery', label: t('notificationLogs.scope.gallery', 'Gallery') },
|
|
{ key: 'events', label: t('notificationLogs.scope.events', 'Events') },
|
|
{ key: 'package', label: t('notificationLogs.scope.package', 'Package') },
|
|
{ key: 'general', label: t('notificationLogs.scope.general', 'General') },
|
|
] as Array<{ key: NotificationScope | 'all'; label: string }>).map((filter) => {
|
|
const active = scopeParam === filter.key;
|
|
return (
|
|
<Pressable key={filter.key} onPress={() => updateFilters({ scope: filter.key })} style={{ flexGrow: 1 }}>
|
|
<XStack
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
paddingVertical="$2"
|
|
paddingHorizontal="$3"
|
|
borderRadius={14}
|
|
borderWidth={1}
|
|
borderColor={active ? primary : border}
|
|
backgroundColor={active ? accentSoft : 'transparent'}
|
|
>
|
|
<Text fontSize="$xs" fontWeight="700" color={active ? primary : muted}>
|
|
{filter.label}
|
|
</Text>
|
|
</XStack>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</XStack>
|
|
|
|
{loading ? (
|
|
<YStack space="$2">
|
|
{Array.from({ length: 4 }).map((_, idx) => (
|
|
<SkeletonCard key={`al-${idx}`} height={70} />
|
|
))}
|
|
</YStack>
|
|
) : statusFiltered.length === 0 ? (
|
|
<MobileCard alignItems="center" justifyContent="center" space="$2">
|
|
<Bell size={24} color={subtle} />
|
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
|
{t('mobileNotifications.emptyTitle', 'All caught up')}
|
|
</Text>
|
|
<Text fontSize="$xs" color={muted} textAlign="center">
|
|
{t('mobileNotifications.emptyBody', 'Enable push to receive alerts about uploads, guests, and expiring galleries.')}
|
|
</Text>
|
|
<CTAButton
|
|
label={t('mobileNotifications.emptyAction', 'Check notification settings')}
|
|
tone="ghost"
|
|
fullWidth={false}
|
|
onPress={() => navigate(adminPath('/mobile/settings'))}
|
|
/>
|
|
</MobileCard>
|
|
) : (
|
|
<YStack space="$2">
|
|
{events.length ? (
|
|
<Pressable onPress={() => setShowEventPicker(true)}>
|
|
<Text fontSize="$sm" color={primary} fontWeight="700">
|
|
{t('mobileNotifications.filterByEvent', 'Nach Event filtern')}
|
|
</Text>
|
|
</Pressable>
|
|
) : null}
|
|
{grouped.map((group) => (
|
|
<YStack key={group.scope} space="$2">
|
|
<XStack alignItems="center" justifyContent="space-between">
|
|
<Text fontSize="$xs" fontWeight="700" color={muted}>
|
|
{t(`notificationLogs.scope.${group.scope}`, group.scope)}
|
|
</Text>
|
|
<XStack space="$2" alignItems="center">
|
|
{group.unread > 0 ? (
|
|
<Pressable onPress={() => markGroupRead(group)}>
|
|
<Text fontSize="$xs" color={primary} fontWeight="700">
|
|
{t('notificationLogs.markScopeRead', 'Mark read')}
|
|
</Text>
|
|
</Pressable>
|
|
) : null}
|
|
{group.unread > 0 ? (
|
|
<PillBadge tone="warning">
|
|
{t('notificationLogs.unread', 'Unread')} {group.unread}
|
|
</PillBadge>
|
|
) : null}
|
|
<PillBadge tone="muted">{group.items.length}</PillBadge>
|
|
</XStack>
|
|
</XStack>
|
|
{group.items.map((item) => {
|
|
const formattedTime = formatRelativeTime(item.time) || item.time || '—';
|
|
return (
|
|
<NotificationSwipeRow
|
|
key={item.id}
|
|
item={item}
|
|
onOpen={openNotification}
|
|
onMarkRead={markNotificationRead}
|
|
>
|
|
<MobileCard space="$2" borderColor={item.is_read ? border : primary}>
|
|
<XStack alignItems="center" space="$2">
|
|
<XStack
|
|
width={36}
|
|
height={36}
|
|
borderRadius={12}
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
backgroundColor={item.tone === 'warning' ? warningBg : infoBg}
|
|
>
|
|
<Bell size={18} color={item.tone === 'warning' ? warningIcon : infoIcon} />
|
|
</XStack>
|
|
<YStack space="$0.5" flex={1}>
|
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
|
{item.title}
|
|
</Text>
|
|
<Text fontSize="$xs" color={muted}>
|
|
{item.body}
|
|
</Text>
|
|
{item.eventName ? (
|
|
<PillBadge tone="muted">{item.eventName}</PillBadge>
|
|
) : null}
|
|
</YStack>
|
|
{!item.is_read ? <PillBadge tone="warning">{t('notificationLogs.unread', 'Unread')}</PillBadge> : null}
|
|
<PillBadge tone={item.tone === 'warning' ? 'warning' : 'muted'}>{formattedTime}</PillBadge>
|
|
</XStack>
|
|
</MobileCard>
|
|
</NotificationSwipeRow>
|
|
);
|
|
})}
|
|
</YStack>
|
|
))}
|
|
</YStack>
|
|
)}
|
|
|
|
<MobileSheet
|
|
open={detailOpen && Boolean(selectedNotification)}
|
|
onClose={() => {
|
|
setDetailOpen(false);
|
|
setSelectedNotification(null);
|
|
if (notificationId) {
|
|
navigate(`${notificationListPath}${location.search}`, { replace: true });
|
|
}
|
|
}}
|
|
title={selectedNotification?.title ?? t('mobileNotifications.title', 'Notifications')}
|
|
snapPoints={[94]}
|
|
footer={
|
|
selectedNotification && !selectedNotification.is_read ? (
|
|
<CTAButton label={t('notificationLogs.markRead', 'Mark as read')} onPress={() => markSelectedRead()} />
|
|
) : null
|
|
}
|
|
>
|
|
{selectedNotification ? (
|
|
<YStack space="$2">
|
|
<Text fontSize="$sm" color={text} fontWeight="700">
|
|
{selectedNotification.title}
|
|
</Text>
|
|
<Text fontSize="$sm" color={muted}>
|
|
{selectedNotification.body}
|
|
</Text>
|
|
<XStack space="$2" flexWrap="wrap" style={{ rowGap: 8 }}>
|
|
<PillBadge tone={selectedNotification.tone === 'warning' ? 'warning' : 'muted'}>
|
|
{selectedNotification.scope}
|
|
</PillBadge>
|
|
{!selectedNotification.is_read ? <PillBadge tone="warning">{t('notificationLogs.unread', 'Unread')}</PillBadge> : null}
|
|
</XStack>
|
|
<Text fontSize="$xs" color={muted}>
|
|
{formatRelativeTime(selectedNotification.time) || selectedNotification.time || '—'}
|
|
</Text>
|
|
</YStack>
|
|
) : null}
|
|
</MobileSheet>
|
|
|
|
<MobileSheet
|
|
open={showEventPicker}
|
|
onClose={() => setShowEventPicker(false)}
|
|
title={t('mobileNotifications.filterByEvent', 'Nach Event filtern')}
|
|
footer={null}
|
|
>
|
|
<YStack space="$2">
|
|
{events.length === 0 ? (
|
|
<Text fontSize="$sm" color={muted}>
|
|
{t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')}
|
|
</Text>
|
|
) : (
|
|
events.map((ev) => (
|
|
<Pressable
|
|
key={ev.slug}
|
|
onPress={() => {
|
|
setShowEventPicker(false);
|
|
if (ev.slug) {
|
|
updateFilters({ event: ev.slug });
|
|
}
|
|
}}
|
|
>
|
|
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
|
|
<YStack>
|
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
|
{ev.name}
|
|
</Text>
|
|
<Text fontSize="$xs" color={muted}>
|
|
{ev.slug}
|
|
</Text>
|
|
</YStack>
|
|
<PillBadge tone="muted">{ev.status ?? '—'}</PillBadge>
|
|
</XStack>
|
|
</Pressable>
|
|
))
|
|
)}
|
|
</YStack>
|
|
</MobileSheet>
|
|
</MobileShell>
|
|
);
|
|
}
|