I finished the remaining polish so the admin app now feels fully “app‑like” across the core screens.

This commit is contained in:
Codex Agent
2025-12-28 20:48:32 +01:00
parent d3b6c6c029
commit 1e0c38fce4
23 changed files with 1250 additions and 112 deletions

View File

@@ -19,6 +19,9 @@ import { useTheme } from '@tamagui/core';
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';
type NotificationItem = {
id: string;
@@ -27,8 +30,9 @@ type NotificationItem = {
time: string;
tone: 'info' | 'warning';
eventId?: number | null;
eventName?: string | null;
is_read?: boolean;
scope: 'photos' | 'guests' | 'gallery' | 'events' | 'package' | 'general';
scope: NotificationScope;
};
type NotificationSwipeRowProps = {
@@ -167,6 +171,7 @@ function formatLog(
time: log.sent_at ?? log.failed_at ?? '',
tone: 'warning',
eventId,
eventName,
is_read: isRead,
scope,
};
@@ -181,6 +186,7 @@ function formatLog(
time: log.sent_at ?? log.failed_at ?? '',
tone: 'warning',
eventId,
eventName,
is_read: isRead,
scope,
};
@@ -194,6 +200,7 @@ function formatLog(
time: log.sent_at ?? log.failed_at ?? '',
tone: 'warning',
eventId,
eventName,
is_read: isRead,
scope,
};
@@ -208,6 +215,7 @@ function formatLog(
time: log.sent_at ?? log.failed_at ?? '',
tone: 'warning',
eventId,
eventName,
is_read: isRead,
scope,
};
@@ -221,6 +229,7 @@ function formatLog(
time: log.sent_at ?? log.failed_at ?? '',
tone: 'warning',
eventId,
eventName,
is_read: isRead,
scope,
};
@@ -236,6 +245,7 @@ function formatLog(
time: log.sent_at ?? log.failed_at ?? '',
tone: 'info',
eventId,
eventName,
is_read: isRead,
scope,
};
@@ -251,6 +261,7 @@ function formatLog(
time: log.sent_at ?? log.failed_at ?? '',
tone: 'info',
eventId,
eventName,
is_read: isRead,
scope,
};
@@ -262,6 +273,7 @@ function formatLog(
time: log.sent_at ?? log.failed_at ?? '',
tone: 'info',
eventId,
eventName,
is_read: isRead,
scope,
};
@@ -389,10 +401,9 @@ export default function MobileNotificationsPage() {
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 grouped = React.useMemo(() => groupNotificationsByScope(statusFiltered), [statusFiltered]);
const unreadIds = React.useMemo(() => collectUnreadIds(scoped), [scoped]);
const showFilterNotice = Boolean(slug) && filtered.length === 0 && notifications.length > 0;
@@ -421,6 +432,34 @@ export default function MobileNotificationsPage() {
}, [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) => {
@@ -475,7 +514,7 @@ export default function MobileNotificationsPage() {
</Text>
<Pressable
onPress={() => {
navigate(notificationListPath, { replace: true });
updateFilters({ event: '' });
}}
>
<Text fontSize="$sm" color={primary} fontWeight="700">
@@ -488,15 +527,7 @@ export default function MobileNotificationsPage() {
<XStack space="$2" marginBottom="$2">
<MobileSelect
value={statusParam}
onChange={(e) =>
navigate(
`${notificationListPath}?${new URLSearchParams({
status: e.target.value,
scope: scopeParam,
event: slug ?? '',
}).toString()}`,
)
}
onChange={(e) => updateFilters({ status: e.target.value })}
compact
style={{ minWidth: 120 }}
>
@@ -504,28 +535,6 @@ export default function MobileNotificationsPage() {
<option value="read">{t('notificationLogs.filter.read', 'Read')}</option>
<option value="all">{t('notificationLogs.filter.all', 'All')}</option>
</MobileSelect>
<MobileSelect
value={scopeParam}
onChange={(e) =>
navigate(
`${notificationListPath}?${new URLSearchParams({
scope: e.target.value,
status: statusParam,
event: slug ?? '',
}).toString()}`,
)
}
compact
style={{ minWidth: 140 }}
>
<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>
</MobileSelect>
{unreadIds.length ? (
<CTAButton
label={t('notificationLogs.markAllRead', 'Mark all read')}
@@ -543,6 +552,38 @@ export default function MobileNotificationsPage() {
) : 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 ? String(theme.blue3?.val ?? '#e0f2fe') : '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) => (
@@ -574,38 +615,68 @@ export default function MobileNotificationsPage() {
</Text>
</Pressable>
) : null}
{statusFiltered.map((item) => (
<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>
</YStack>
{!item.is_read ? <PillBadge tone="warning">{t('notificationLogs.unread', 'Unread')}</PillBadge> : null}
<PillBadge tone={item.tone === 'warning' ? 'warning' : 'muted'}>{item.time}</PillBadge>
{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>
</MobileCard>
</NotificationSwipeRow>
</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>
)}
@@ -641,7 +712,7 @@ export default function MobileNotificationsPage() {
{!selectedNotification.is_read ? <PillBadge tone="warning">{t('notificationLogs.unread', 'Unread')}</PillBadge> : null}
</XStack>
<Text fontSize="$xs" color={muted}>
{selectedNotification.time}
{formatRelativeTime(selectedNotification.time) || selectedNotification.time || '—'}
</Text>
</YStack>
) : null}
@@ -665,7 +736,7 @@ export default function MobileNotificationsPage() {
onPress={() => {
setShowEventPicker(false);
if (ev.slug) {
navigate(`${notificationListPath}?event=${ev.slug}`);
updateFilters({ event: ev.slug });
}
}}
>