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,14 +5,18 @@ import { Pressable } from '@tamagui/react-native-web-lite';
import { Home, CheckSquare, Image as ImageIcon, User } from 'lucide-react';
import { useTheme } from '@tamagui/core';
import { useTranslation } from 'react-i18next';
import { withAlpha } from './colors';
const ICON_SIZE = 18;
const ICON_SIZE = 20;
export type NavKey = 'home' | 'tasks' | 'uploads' | 'profile';
export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate: (key: NavKey) => void }) {
const { t } = useTranslation('mobile');
const theme = useTheme();
const surfaceColor = String(theme.surface?.val ?? 'white');
const navSurface = withAlpha(surfaceColor, 0.92);
const [pressedKey, setPressedKey] = React.useState<NavKey | null>(null);
const items: Array<{ key: NavKey; icon: React.ComponentType<{ size?: number; color?: string }>; label: string }> = [
{ key: 'home', icon: Home, label: t('nav.home', 'Home') },
{ key: 'tasks', icon: CheckSquare, label: t('nav.tasks', 'Tasks') },
@@ -21,30 +25,41 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
];
return (
<YStack
position="fixed"
bottom={0}
left={0}
right={0}
backgroundColor={String(theme.surface?.val ?? 'white')}
borderTopWidth={1}
borderColor={String(theme.borderColor?.val ?? '#e5e7eb')}
paddingVertical="$2"
paddingHorizontal="$4"
zIndex={50}
<YStack
position="fixed"
bottom={0}
left={0}
right={0}
backgroundColor={navSurface}
borderTopWidth={1}
borderColor={String(theme.borderColor?.val ?? '#e5e7eb')}
paddingVertical="$2"
paddingHorizontal="$4"
zIndex={50}
shadowColor="#0f172a"
shadowOpacity={0.08}
shadowRadius={12}
shadowOffset={{ width: 0, height: -4 }}
// allow for safe-area inset on modern phones
style={{ paddingBottom: 'max(env(safe-area-inset-bottom, 0px), 8px)' }}
style={{
paddingBottom: 'max(env(safe-area-inset-bottom, 0px), 8px)',
backdropFilter: 'blur(14px)',
WebkitBackdropFilter: 'blur(14px)',
}}
>
<XStack justifyContent="space-between" alignItems="center">
{items.map((item) => {
const activeState = item.key === active;
const isPressed = pressedKey === item.key;
const IconCmp = item.icon;
return (
<Pressable key={item.key} onPress={() => onNavigate(item.key)}>
<Pressable
key={item.key}
onPress={() => onNavigate(item.key)}
onPressIn={() => setPressedKey(item.key)}
onPressOut={() => setPressedKey(null)}
onPointerLeave={() => setPressedKey(null)}
>
<YStack
flexGrow={1}
flexBasis="0%"
@@ -59,7 +74,22 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
borderRadius={12}
backgroundColor={activeState ? '#e8f1ff' : 'transparent'}
gap="$1"
style={{
transform: isPressed ? 'scale(0.96)' : 'scale(1)',
opacity: isPressed ? 0.9 : 1,
transition: 'transform 140ms ease, background-color 140ms ease, opacity 140ms ease',
}}
>
{activeState ? (
<YStack
position="absolute"
top={6}
width={28}
height={3}
borderRadius={999}
backgroundColor={String(theme.primary?.val ?? '#007AFF')}
/>
) : null}
<YStack width={ICON_SIZE} height={ICON_SIZE} alignItems="center" justifyContent="center" shrink={0}>
<IconCmp size={ICON_SIZE} color={activeState ? String(theme.primary?.val ?? '#007AFF') : '#94a3b8'} />
</YStack>

View File

@@ -0,0 +1,193 @@
import React from 'react';
import { ChevronDown } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { useTheme } from '@tamagui/core';
import { withAlpha } from './colors';
type FieldProps = {
label: string;
hint?: string;
error?: string | null;
children: React.ReactNode;
};
export function MobileField({ label, hint, error, children }: FieldProps) {
const theme = useTheme();
const labelColor = String(theme.color?.val ?? '#111827');
const hintColor = String(theme.gray?.val ?? '#6b7280');
const errorColor = String(theme.red10?.val ?? '#b91c1c');
return (
<YStack space="$1.5">
<Text fontSize="$sm" fontWeight="800" color={labelColor}>
{label}
</Text>
{children}
{hint ? (
<Text fontSize="$xs" color={hintColor}>
{hint}
</Text>
) : null}
{error ? (
<Text fontSize="$xs" color={errorColor}>
{error}
</Text>
) : null}
</YStack>
);
}
type ControlProps = {
hasError?: boolean;
compact?: boolean;
};
export const MobileInput = React.forwardRef<HTMLInputElement, React.ComponentPropsWithoutRef<'input'> & ControlProps>(
function MobileInput({ hasError = false, compact = false, style, ...props }, ref) {
const theme = useTheme();
const [focused, setFocused] = React.useState(false);
const border = String(theme.borderColor?.val ?? '#e5e7eb');
const surface = String(theme.surface?.val ?? '#ffffff');
const text = String(theme.color?.val ?? '#111827');
const primary = String(theme.primary?.val ?? '#2563eb');
const danger = String(theme.red10?.val ?? '#b91c1c');
const height = compact ? 36 : 44;
const borderColor = hasError ? danger : focused ? primary : border;
const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
return (
<input
ref={ref}
{...props}
onFocus={(event) => {
setFocused(true);
props.onFocus?.(event);
}}
onBlur={(event) => {
setFocused(false);
props.onBlur?.(event);
}}
style={{
width: '100%',
height,
borderRadius: 12,
border: `1px solid ${borderColor}`,
padding: '0 12px',
fontSize: compact ? 13 : 14,
background: surface,
color: text,
outline: 'none',
boxShadow: focused || hasError ? `0 0 0 3px ${ringColor}` : 'none',
transition: 'border-color 150ms ease, box-shadow 150ms ease',
...style,
}}
/>
);
},
);
export const MobileTextArea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentPropsWithoutRef<'textarea'> & ControlProps
>(function MobileTextArea({ hasError = false, compact = false, style, ...props }, ref) {
const theme = useTheme();
const [focused, setFocused] = React.useState(false);
const border = String(theme.borderColor?.val ?? '#e5e7eb');
const surface = String(theme.surface?.val ?? '#ffffff');
const text = String(theme.color?.val ?? '#111827');
const primary = String(theme.primary?.val ?? '#2563eb');
const danger = String(theme.red10?.val ?? '#b91c1c');
const borderColor = hasError ? danger : focused ? primary : border;
const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
return (
<textarea
ref={ref}
{...props}
onFocus={(event) => {
setFocused(true);
props.onFocus?.(event);
}}
onBlur={(event) => {
setFocused(false);
props.onBlur?.(event);
}}
style={{
width: '100%',
borderRadius: 12,
border: `1px solid ${borderColor}`,
padding: '10px 12px',
fontSize: compact ? 13 : 14,
background: surface,
color: text,
outline: 'none',
minHeight: compact ? 72 : 96,
boxShadow: focused || hasError ? `0 0 0 3px ${ringColor}` : 'none',
transition: 'border-color 150ms ease, box-shadow 150ms ease',
resize: 'vertical',
...style,
}}
/>
);
});
export function MobileSelect({
children,
hasError = false,
compact = false,
style,
...props
}: React.ComponentPropsWithoutRef<'select'> & ControlProps) {
const theme = useTheme();
const [focused, setFocused] = React.useState(false);
const border = String(theme.borderColor?.val ?? '#e5e7eb');
const surface = String(theme.surface?.val ?? '#ffffff');
const text = String(theme.color?.val ?? '#111827');
const primary = String(theme.primary?.val ?? '#2563eb');
const danger = String(theme.red10?.val ?? '#b91c1c');
const muted = String(theme.gray?.val ?? '#94a3b8');
const height = compact ? 36 : 44;
const borderColor = hasError ? danger : focused ? primary : border;
const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
return (
<XStack position="relative" alignItems="center">
<select
{...props}
onFocus={(event) => {
setFocused(true);
props.onFocus?.(event);
}}
onBlur={(event) => {
setFocused(false);
props.onBlur?.(event);
}}
style={{
width: '100%',
height,
borderRadius: 12,
border: `1px solid ${borderColor}`,
padding: '0 36px 0 12px',
fontSize: compact ? 13 : 14,
background: surface,
color: text,
outline: 'none',
appearance: 'none',
WebkitAppearance: 'none',
boxShadow: focused || hasError ? `0 0 0 3px ${ringColor}` : 'none',
transition: 'border-color 150ms ease, box-shadow 150ms ease',
...style,
}}
>
{children}
</select>
<XStack position="absolute" right={12} pointerEvents="none">
<ChevronDown size={16} color={muted} />
</XStack>
</XStack>
);
}

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
import { Outlet, useLocation, useNavigationType } from 'react-router-dom';
export default function MobileAnimatedOutlet() {
const location = useLocation();
const navigationType = useNavigationType();
const reduceMotion = useReducedMotion();
const direction = navigationType === 'POP' ? -1 : 1;
const variants = reduceMotion
? {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
}
: {
initial: { opacity: 0, x: 16 * direction },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: -16 * direction },
};
return (
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={location.key}
initial="initial"
animate="animate"
exit="exit"
variants={variants}
transition={{ duration: 0.22, ease: 'easeOut' }}
style={{ height: '100%' }}
>
<Outlet />
</motion.div>
</AnimatePresence>
);
}

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>;

View File

@@ -147,6 +147,12 @@ export function KpiTile({
);
}
export function SkeletonCard({ height = 80 }: { height?: number }) {
return (
<MobileCard className="mobile-skeleton" height={height} />
);
}
export function ActionTile({
icon: IconCmp,
label,
@@ -189,3 +195,53 @@ export function ActionTile({
</Pressable>
);
}
export function FloatingActionButton({
onPress,
label,
icon: IconCmp,
}: {
onPress: () => void;
label: string;
icon: React.ComponentType<{ size?: number; color?: string }>;
}) {
const theme = useTheme();
const [pressed, setPressed] = React.useState(false);
return (
<Pressable
onPress={onPress}
onPressIn={() => setPressed(true)}
onPressOut={() => setPressed(false)}
onPointerLeave={() => setPressed(false)}
style={{
position: 'fixed',
right: 18,
bottom: 'calc(env(safe-area-inset-bottom, 0px) + 96px)',
zIndex: 60,
transform: pressed ? 'scale(0.96)' : 'scale(1)',
opacity: pressed ? 0.92 : 1,
transition: 'transform 140ms ease, opacity 140ms ease',
}}
aria-label={label}
>
<XStack
height={56}
paddingHorizontal="$4"
borderRadius={999}
alignItems="center"
justifyContent="center"
space="$2"
backgroundColor={String(theme.primary?.val ?? '#007AFF')}
shadowColor="#0f172a"
shadowOpacity={0.2}
shadowRadius={16}
shadowOffset={{ width: 0, height: 8 }}
>
<IconCmp size={18} color="white" />
<Text fontSize="$sm" fontWeight="800" color="white">
{label}
</Text>
</XStack>
</Pressable>
);
}

View File

@@ -5,6 +5,7 @@ import { Pressable } from '@tamagui/react-native-web-lite';
import { ChevronLeft } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useTheme } from '@tamagui/core';
import { withAlpha } from './colors';
type MobileScaffoldProps = {
title: string;
@@ -21,6 +22,7 @@ export function MobileScaffold({ title, onBack, rightSlot, children, footer }: M
const surface = String(theme.surface?.val ?? '#ffffff');
const border = String(theme.borderColor?.val ?? '#e5e7eb');
const textColor = String(theme.color?.val ?? '#111827');
const headerSurface = withAlpha(surface, 0.94);
return (
<YStack backgroundColor={background} minHeight="100vh">
@@ -30,9 +32,17 @@ export function MobileScaffold({ title, onBack, rightSlot, children, footer }: M
paddingHorizontal="$4"
paddingTop="$4"
paddingBottom="$3"
backgroundColor={surface}
backgroundColor={headerSurface}
borderBottomWidth={1}
borderColor={border}
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" space="$2">
{onBack ? (

View File

@@ -0,0 +1,25 @@
export function withAlpha(color: string, alpha: number): string {
const trimmed = color.trim();
if (trimmed.startsWith('#')) {
const hex = trimmed.slice(1);
const normalized = hex.length === 3 ? hex.split('').map((ch) => ch + ch).join('') : hex;
if (normalized.length === 6) {
const r = Number.parseInt(normalized.slice(0, 2), 16);
const g = Number.parseInt(normalized.slice(2, 4), 16);
const b = Number.parseInt(normalized.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
}
const rgb = trimmed.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/i);
if (rgb) {
return `rgba(${rgb[1]}, ${rgb[2]}, ${rgb[3]}, ${alpha})`;
}
const rgba = trimmed.match(/^rgba\((\d+),\s*(\d+),\s*(\d+),\s*([0-9.]+)\)$/i);
if (rgba) {
return `rgba(${rgba[1]}, ${rgba[2]}, ${rgba[3]}, ${alpha})`;
}
return color;
}