I finished the remaining polish so the admin app now feels fully “app‑like” across the core screens.
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user