import React from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Bell, 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 { MobileShell } from './components/MobileShell'; import { MobileCard, PillBadge } from './components/Primitives'; 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 { useTheme } from '@tamagui/core'; type NotificationItem = { id: string; title: string; body: string; time: string; tone: 'info' | 'warning'; eventId?: number | null; is_read?: boolean; scope: 'photos' | 'guests' | 'gallery' | 'events' | 'package' | 'general'; }; 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, 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, 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, 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, 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, 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, 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, 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, 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 { 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 [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); const [events, setEvents] = React.useState([]); const [showEventPicker, setShowEventPicker] = React.useState(false); const theme = useTheme(); const text = String(theme.color?.val ?? '#111827'); const muted = String(theme.gray?.val ?? '#4b5563'); const border = String(theme.borderColor?.val ?? '#e5e7eb'); const warningBg = String(theme.yellow3?.val ?? '#fef3c7'); const warningIcon = String(theme.yellow11?.val ?? '#92400e'); const infoBg = String(theme.blue3?.val ?? '#e0f2fe'); const infoIcon = String(theme.primary?.val ?? '#2563eb'); const errorText = String(theme.red10?.val ?? '#b91c1c'); const primary = String(theme.primary?.val ?? '#007AFF'); 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(() => { (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 unreadIds = React.useMemo( () => scoped.filter((item) => !item.is_read).map((item) => Number(item.id)).filter((id) => Number.isFinite(id)), [scoped] ); const showFilterNotice = Boolean(slug) && filtered.length === 0 && notifications.length > 0; return ( navigate(-1)} headerActions={ reload()}> } > {error ? ( {error} ) : null} {showFilterNotice ? ( {t('notificationLogs.filterEmpty', 'No notifications for this event.')} { navigate('/admin/mobile/notifications', { replace: true }); }} > {t('notificationLogs.clearFilter', 'Show all notifications')} ) : null} {unreadIds.length ? ( { try { await markNotificationLogs(unreadIds, 'read'); void reload(); } catch { toast.error(t('notificationLogs.markFailed', 'Could not update notifications.')); } }} tone="ghost" /> ) : null} {loading ? ( {Array.from({ length: 4 }).map((_, idx) => ( ))} ) : statusFiltered.length === 0 ? ( {t('mobileNotifications.empty', 'Keine Benachrichtigungen vorhanden.')} ) : ( {events.length ? ( setShowEventPicker(true)}> {t('mobileNotifications.filterByEvent', 'Nach Event filtern')} ) : null} {statusFiltered.map((item) => ( {item.title} {item.body} {!item.is_read ? {t('notificationLogs.unread', 'Unread')} : null} {item.time} ))} )} 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) { navigate(`/admin/mobile/notifications?event=${ev.slug}`); } }} > {ev.name} {ev.slug} {ev.status ?? '—'} )) )} ); }