Files
fotospiel-app/resources/js/admin/mobile/NotificationsPage.tsx

454 lines
17 KiB
TypeScript

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, 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,
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, 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 { 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 [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 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 (
<MobileShell
activeTab="home"
title={t('mobileNotifications.title', 'Notifications')}
onBack={() => navigate(-1)}
headerActions={
<Pressable onPress={() => reload()}>
<RefreshCcw size={18} color={text} />
</Pressable>
}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color={errorText}>
{error}
</Text>
</MobileCard>
) : null}
{showFilterNotice ? (
<MobileCard space="$2">
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('notificationLogs.filterEmpty', 'No notifications for this event.')}
</Text>
<Pressable
onPress={() => {
navigate('/admin/mobile/notifications', { replace: true });
}}
>
<Text fontSize="$sm" color={primary} fontWeight="700">
{t('notificationLogs.clearFilter', 'Show all notifications')}
</Text>
</Pressable>
</MobileCard>
) : null}
<XStack space="$2" marginBottom="$2">
<select
value={statusParam}
onChange={(e) => navigate(`/admin/mobile/notifications?${new URLSearchParams({ status: e.target.value, scope: scopeParam, event: slug ?? '' }).toString()}`)}
style={{ border: `1px solid ${border}`, borderRadius: 10, padding: '6px 10px', fontSize: 13 }}
>
<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>
</select>
<select
value={scopeParam}
onChange={(e) => navigate(`/admin/mobile/notifications?${new URLSearchParams({ scope: e.target.value, status: statusParam, event: slug ?? '' }).toString()}`)}
style={{ border: `1px solid ${border}`, borderRadius: 10, padding: '6px 10px', fontSize: 13 }}
>
<option value="all">{t('notificationLogs.scope.all', 'All scopes')}</option>
<option value="photos">{t('notificationLogs.scope.photos', 'Photos')}</option>
<option value="guests">{t('notificationLogs.scope.guests', 'Guests')}</option>
<option value="gallery">{t('notificationLogs.scope.gallery', 'Gallery')}</option>
<option value="events">{t('notificationLogs.scope.events', 'Events')}</option>
<option value="package">{t('notificationLogs.scope.package', 'Package')}</option>
<option value="general">{t('notificationLogs.scope.general', 'General')}</option>
</select>
{unreadIds.length ? (
<CTAButton
label={t('notificationLogs.markAllRead', 'Mark all read')}
onPress={async () => {
try {
await markNotificationLogs(unreadIds, 'read');
void reload();
} catch {
toast.error(t('notificationLogs.markFailed', 'Could not update notifications.'));
}
}}
tone="ghost"
/>
) : null}
</XStack>
{loading ? (
<YStack space="$2">
{Array.from({ length: 4 }).map((_, idx) => (
<MobileCard key={`al-${idx}`} height={70} opacity={0.6} />
))}
</YStack>
) : statusFiltered.length === 0 ? (
<MobileCard alignItems="center" justifyContent="center" space="$2">
<Bell size={24} color={String(theme.gray9?.val ?? '#9ca3af')} />
<Text fontSize="$sm" color={muted}>
{t('mobileNotifications.empty', 'Keine Benachrichtigungen vorhanden.')}
</Text>
</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}
{statusFiltered.map((item) => (
<MobileCard key={item.id} 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>
</YStack>
{!item.is_read ? <PillBadge tone="warning">{t('notificationLogs.unread', 'Unread')}</PillBadge> : null}
<PillBadge tone={item.tone === 'warning' ? 'warning' : 'muted'}>{item.time}</PillBadge>
</XStack>
</MobileCard>
))}
</YStack>
)}
<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) {
navigate(`/admin/mobile/notifications?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>
);
}