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

@@ -13,8 +13,10 @@ import { adminPath } from '../../constants';
import { MobileSheet } from './Sheet';
import { MobileCard, PillBadge } from './Primitives';
import { useNotificationsBadge } from '../hooks/useNotificationsBadge';
import { useOnlineStatus } from '../hooks/useOnlineStatus';
import { formatEventDate, resolveEventDisplayName } from '../../lib/events';
import { TenantEvent, getEvents } from '../../api';
import { withAlpha } from './colors';
type MobileShellProps = {
title?: string;
@@ -31,12 +33,16 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
const navigate = useNavigate();
const { t, i18n } = useTranslation('mobile');
const { count: notificationCount } = useNotificationsBadge();
const online = useOnlineStatus();
const theme = useTheme();
const backgroundColor = String(theme.background?.val ?? '#f7f8fb');
const surfaceColor = String(theme.surface?.val ?? '#ffffff');
const borderColor = String(theme.borderColor?.val ?? '#e5e7eb');
const textColor = String(theme.color?.val ?? '#111827');
const mutedText = String(theme.gray?.val ?? '#6b7280');
const warningBg = String(theme.yellow3?.val ?? '#fef3c7');
const warningText = String(theme.yellow11?.val ?? '#92400e');
const headerSurface = withAlpha(surfaceColor, 0.94);
const [pickerOpen, setPickerOpen] = React.useState(false);
const [fallbackEvents, setFallbackEvents] = React.useState<TenantEvent[]>([]);
const [loadingEvents, setLoadingEvents] = React.useState(false);
@@ -90,7 +96,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
return (
<YStack backgroundColor={backgroundColor} minHeight="100vh" alignItems="center">
<YStack
backgroundColor={surfaceColor}
backgroundColor={headerSurface}
borderBottomWidth={1}
borderColor={borderColor}
paddingHorizontal="$4"
@@ -102,14 +108,22 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
shadowOffset={{ width: 0, height: 4 }}
width="100%"
maxWidth={800}
position="sticky"
top={0}
zIndex={60}
style={{
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
}}
>
<XStack alignItems="center" justifyContent="space-between" minHeight={48} space="$3">
{onBack ? (
<Pressable onPress={onBack}>
<HeaderActionButton onPress={onBack} ariaLabel={t('actions.back', 'Back')}>
<XStack alignItems="center" space="$1.5">
<ChevronLeft size={28} color="#007AFF" strokeWidth={2.5} />
</XStack>
</Pressable>
</HeaderActionButton>
) : (
<XStack width={28} />
)}
@@ -134,7 +148,10 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
</XStack>
<XStack alignItems="center" space="$2">
<Pressable onPress={() => navigate(adminPath('/mobile/notifications'))}>
<HeaderActionButton
onPress={() => navigate(adminPath('/mobile/notifications'))}
ariaLabel={t('mobile.notifications', 'Notifications')}
>
<XStack
width={34}
height={34}
@@ -164,9 +181,12 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
</YStack>
) : null}
</XStack>
</Pressable>
</HeaderActionButton>
{showQr ? (
<Pressable onPress={() => navigate(adminPath(`/mobile/events/${effectiveActive?.slug}/qr`))}>
<HeaderActionButton
onPress={() => navigate(adminPath(`/mobile/events/${effectiveActive?.slug}/qr`))}
ariaLabel={t('header.quickQr', 'Quick QR')}
>
<XStack
height={34}
paddingHorizontal="$3"
@@ -181,7 +201,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
{t('header.quickQr', 'Quick QR')}
</Text>
</XStack>
</Pressable>
</HeaderActionButton>
) : null}
{headerActions ?? null}
</XStack>
@@ -189,7 +209,29 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
</XStack>
</YStack>
<YStack flex={1} padding="$4" paddingBottom="$10" space="$3" width="100%" maxWidth={800}>
<YStack
flex={1}
padding="$4"
paddingBottom="$10"
space="$3"
width="100%"
maxWidth={800}
style={{ paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 96px)' }}
>
{!online ? (
<XStack
alignItems="center"
justifyContent="center"
borderRadius={12}
backgroundColor={warningBg}
paddingVertical="$2"
paddingHorizontal="$3"
>
<Text fontSize="$xs" fontWeight="700" color={warningText}>
{t('mobile.offline', 'Offline mode: changes will sync when you are back online.')}
</Text>
</XStack>
) : null}
{children}
</YStack>
@@ -268,6 +310,34 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
);
}
export function HeaderActionButton({
onPress,
children,
ariaLabel,
}: {
onPress: () => void;
children: React.ReactNode;
ariaLabel?: string;
}) {
const [pressed, setPressed] = React.useState(false);
return (
<Pressable
onPress={onPress}
onPressIn={() => setPressed(true)}
onPressOut={() => setPressed(false)}
onPointerLeave={() => setPressed(false)}
aria-label={ariaLabel}
style={{
transform: pressed ? 'scale(0.96)' : 'scale(1)',
opacity: pressed ? 0.86 : 1,
transition: 'transform 120ms ease, opacity 120ms ease',
}}
>
{children}
</Pressable>
);
}
export function renderEventLocation(event?: TenantEvent | null): string {
if (!event) return 'Location';
const settings = (event.settings ?? {}) as Record<string, unknown>;