Completed the full mobile app polish pass: navigation feel, safe‑area consistency, input styling, list rows, FAB
patterns, skeleton loading, photo selection/bulk actions with shared‑element transitions, notification detail sheet,
offline banner, maskable manifest icons, and route prefetching.
Key changes
- Navigation/shell: press feedback on all header actions, glassy sticky header and tab bar, safer bottom spacing
(resources/js/admin/mobile/components/MobileShell.tsx, resources/js/admin/mobile/components/BottomNav.tsx).
- Forms + lists: shared mobile form controls, list‑style rows in settings/profile, consistent inputs across core
flows (resources/js/admin/mobile/components/FormControls.tsx, resources/js/admin/mobile/SettingsPage.tsx,
resources/js/admin/mobile/ProfilePage.tsx, resources/js/admin/mobile/EventFormPage.tsx, resources/js/admin/mobile/
EventMembersPage.tsx, resources/js/admin/mobile/EventTasksPage.tsx, resources/js/admin/mobile/
EventGuestNotificationsPage.tsx, resources/js/admin/mobile/NotificationsPage.tsx, resources/js/admin/mobile/
EventPhotosPage.tsx, resources/js/admin/mobile/EventsPage.tsx).
- Media workflows: shared‑element photo transitions, selection mode + bulk actions bar (resources/js/admin/mobile/
EventPhotosPage.tsx).
- Loading UX: shimmering skeletons (resources/css/app.css, resources/js/admin/mobile/components/Primitives.tsx).
- PWA polish + perf: maskable icons, offline banner hook, and route prefetch (public/manifest.json, resources/js/
admin/mobile/hooks/useOnlineStatus.tsx, resources/js/admin/mobile/prefetch.ts, resources/js/admin/main.tsx).
This commit is contained in:
@@ -5,8 +5,9 @@ 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 { 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';
|
||||
@@ -212,6 +213,8 @@ export default function MobileNotificationsPage() {
|
||||
const scopeParam = search.get('scope') ?? 'all';
|
||||
const statusParam = search.get('status') ?? 'unread';
|
||||
const [notifications, setNotifications] = React.useState<NotificationItem[]>([]);
|
||||
const [selectedNotification, setSelectedNotification] = React.useState<NotificationItem | null>(null);
|
||||
const [detailOpen, setDetailOpen] = React.useState(false);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [events, setEvents] = React.useState<TenantEvent[]>([]);
|
||||
@@ -284,15 +287,29 @@ export default function MobileNotificationsPage() {
|
||||
|
||||
const showFilterNotice = Boolean(slug) && filtered.length === 0 && notifications.length > 0;
|
||||
|
||||
const markSelectedRead = async () => {
|
||||
if (!selectedNotification) return;
|
||||
const id = Number(selectedNotification.id);
|
||||
if (!Number.isFinite(id)) return;
|
||||
try {
|
||||
await markNotificationLogs([id], 'read');
|
||||
await reload();
|
||||
setDetailOpen(false);
|
||||
setSelectedNotification(null);
|
||||
} catch {
|
||||
toast.error(t('notificationLogs.markFailed', 'Could not update notifications.'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<MobileShell
|
||||
activeTab="home"
|
||||
title={t('mobileNotifications.title', 'Notifications')}
|
||||
onBack={() => navigate(-1)}
|
||||
headerActions={
|
||||
<Pressable onPress={() => reload()}>
|
||||
<HeaderActionButton onPress={() => reload()} ariaLabel={t('common.refresh', 'Refresh')}>
|
||||
<RefreshCcw size={18} color={text} />
|
||||
</Pressable>
|
||||
</HeaderActionButton>
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
@@ -321,19 +338,21 @@ export default function MobileNotificationsPage() {
|
||||
) : null}
|
||||
|
||||
<XStack space="$2" marginBottom="$2">
|
||||
<select
|
||||
<MobileSelect
|
||||
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 }}
|
||||
compact
|
||||
style={{ minWidth: 120 }}
|
||||
>
|
||||
<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
|
||||
</MobileSelect>
|
||||
<MobileSelect
|
||||
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 }}
|
||||
compact
|
||||
style={{ minWidth: 140 }}
|
||||
>
|
||||
<option value="all">{t('notificationLogs.scope.all', 'All scopes')}</option>
|
||||
<option value="photos">{t('notificationLogs.scope.photos', 'Photos')}</option>
|
||||
@@ -342,7 +361,7 @@ export default function MobileNotificationsPage() {
|
||||
<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>
|
||||
</MobileSelect>
|
||||
{unreadIds.length ? (
|
||||
<CTAButton
|
||||
label={t('notificationLogs.markAllRead', 'Mark all read')}
|
||||
@@ -362,7 +381,7 @@ export default function MobileNotificationsPage() {
|
||||
{loading ? (
|
||||
<YStack space="$2">
|
||||
{Array.from({ length: 4 }).map((_, idx) => (
|
||||
<MobileCard key={`al-${idx}`} height={70} opacity={0.6} />
|
||||
<SkeletonCard key={`al-${idx}`} height={70} />
|
||||
))}
|
||||
</YStack>
|
||||
) : statusFiltered.length === 0 ? (
|
||||
@@ -382,34 +401,76 @@ export default function MobileNotificationsPage() {
|
||||
</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} />
|
||||
<Pressable
|
||||
key={item.id}
|
||||
onPress={() => {
|
||||
setSelectedNotification(item);
|
||||
setDetailOpen(true);
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</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>
|
||||
</MobileCard>
|
||||
</Pressable>
|
||||
))}
|
||||
</YStack>
|
||||
)}
|
||||
|
||||
<MobileSheet
|
||||
open={detailOpen && Boolean(selectedNotification)}
|
||||
onClose={() => {
|
||||
setDetailOpen(false);
|
||||
setSelectedNotification(null);
|
||||
}}
|
||||
title={selectedNotification?.title ?? t('mobileNotifications.title', 'Notifications')}
|
||||
footer={
|
||||
selectedNotification && !selectedNotification.is_read ? (
|
||||
<CTAButton label={t('notificationLogs.markRead', 'Mark as read')} onPress={() => markSelectedRead()} />
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{selectedNotification ? (
|
||||
<YStack space="$2">
|
||||
<Text fontSize="$sm" color={text} fontWeight="700">
|
||||
{selectedNotification.title}
|
||||
</Text>
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{selectedNotification.body}
|
||||
</Text>
|
||||
<XStack space="$2">
|
||||
<PillBadge tone={selectedNotification.tone === 'warning' ? 'warning' : 'muted'}>
|
||||
{selectedNotification.scope}
|
||||
</PillBadge>
|
||||
{!selectedNotification.is_read ? <PillBadge tone="warning">{t('notificationLogs.unread', 'Unread')}</PillBadge> : null}
|
||||
</XStack>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{selectedNotification.time}
|
||||
</Text>
|
||||
</YStack>
|
||||
) : null}
|
||||
</MobileSheet>
|
||||
|
||||
<MobileSheet
|
||||
open={showEventPicker}
|
||||
onClose={() => setShowEventPicker(false)}
|
||||
|
||||
Reference in New Issue
Block a user