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:
Codex Agent
2025-12-27 23:55:48 +01:00
parent a8b54b75ea
commit 4ce409e918
36 changed files with 1288 additions and 579 deletions

View File

@@ -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)}