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 (
{item.is_read ? t('notificationLogs.read', 'Read') : t('notificationLogs.markRead', 'Mark read')} Details {children}
); } function formatLog( log: NotificationLogEntry, t: (key: string, defaultValue?: string, options?: Record) => 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, events?: TenantEvent[], filters?: { scope?: string; status?: string; eventSlug?: string } ): Promise { 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(); (events ?? []).forEach((event) => { lookup.set(event.id, typeof event.name === 'string' ? event.name : (event.name as Record)?.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([]); const [selectedNotification, setSelectedNotification] = React.useState(null); const [detailOpen, setDetailOpen] = React.useState(false); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); const [events, setEvents] = React.useState([]); 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, 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) => { 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 ( reload()} ariaLabel={t('common.refresh', 'Refresh')}> } > {error ? ( {error} reload()} /> ) : null} {showFilterNotice ? ( {t('notificationLogs.filterEmpty', 'No notifications for this event.')} { updateFilters({ event: '' }); }} > {t('notificationLogs.clearFilter', 'Show all notifications')} ) : null} updateFilters({ status: e.target.value })} compact style={{ minWidth: 120 }} > {unreadIds.length ? ( { try { await markNotificationLogs(unreadIds, 'read'); void reload(); triggerHaptic('success'); } catch { toast.error(t('notificationLogs.markFailed', 'Could not update notifications.')); } }} tone="ghost" /> ) : null} {([ { 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 ( updateFilters({ scope: filter.key })} style={{ flexGrow: 1 }}> {filter.label} ); })} {loading ? ( {Array.from({ length: 4 }).map((_, idx) => ( ))} ) : statusFiltered.length === 0 ? ( {t('mobileNotifications.emptyTitle', 'All caught up')} {t('mobileNotifications.emptyBody', 'Enable push to receive alerts about uploads, guests, and expiring galleries.')} navigate(adminPath('/mobile/settings'))} /> ) : ( {events.length ? ( setShowEventPicker(true)}> {t('mobileNotifications.filterByEvent', 'Nach Event filtern')} ) : null} {grouped.map((group) => ( {t(`notificationLogs.scope.${group.scope}`, group.scope)} {group.unread > 0 ? ( markGroupRead(group)}> {t('notificationLogs.markScopeRead', 'Mark read')} ) : null} {group.unread > 0 ? ( {t('notificationLogs.unread', 'Unread')} {group.unread} ) : null} {group.items.length} {group.items.map((item) => { const formattedTime = formatRelativeTime(item.time) || item.time || '—'; return ( {item.title} {item.body} {item.eventName ? ( {item.eventName} ) : null} {!item.is_read ? {t('notificationLogs.unread', 'Unread')} : null} {formattedTime} ); })} ))} )} { setDetailOpen(false); setSelectedNotification(null); if (notificationId) { navigate(`${notificationListPath}${location.search}`, { replace: true }); } }} title={selectedNotification?.title ?? t('mobileNotifications.title', 'Notifications')} footer={ selectedNotification && !selectedNotification.is_read ? ( markSelectedRead()} /> ) : null } > {selectedNotification ? ( {selectedNotification.title} {selectedNotification.body} {selectedNotification.scope} {!selectedNotification.is_read ? {t('notificationLogs.unread', 'Unread')} : null} {formatRelativeTime(selectedNotification.time) || selectedNotification.time || '—'} ) : null} setShowEventPicker(false)} title={t('mobileNotifications.filterByEvent', 'Nach Event filtern')} footer={null} > {events.length === 0 ? ( {t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')} ) : ( events.map((ev) => ( { setShowEventPicker(false); if (ev.slug) { updateFilters({ event: ev.slug }); } }} > {ev.name} {ev.slug} {ev.status ?? '—'} )) )} ); }