weitere perfektionierung der neuen mobile app

This commit is contained in:
Codex Agent
2025-12-11 12:18:08 +01:00
parent 7b01a77083
commit b4417db5cd
38 changed files with 4265 additions and 3040 deletions

View File

@@ -6,6 +6,8 @@ import { Home, CheckSquare, Image as ImageIcon, User } from 'lucide-react';
import { useTheme } from '@tamagui/core';
import { useTranslation } from 'react-i18next';
const ICON_SIZE = 18;
export type NavKey = 'home' | 'tasks' | 'uploads' | 'profile';
export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate: (key: NavKey) => void }) {
@@ -19,17 +21,17 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
];
return (
<YStack
position="fixed"
bottom={0}
left={0}
right={0}
backgroundColor="white"
borderTopWidth={1}
borderColor="#e5e7eb"
paddingVertical="$2"
paddingHorizontal="$4"
zIndex={50}
<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}
shadowColor="#0f172a"
shadowOpacity={0.08}
shadowRadius={12}
@@ -44,15 +46,31 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
return (
<Pressable key={item.key} onPress={() => onNavigate(item.key)}>
<YStack
flexGrow={1}
flexBasis="0%"
alignItems="center"
justifyContent="center"
space="$1"
position="relative"
padding="$2"
minWidth={88}
minHeight={64}
paddingHorizontal="$3"
paddingVertical="$2"
borderRadius={12}
backgroundColor={activeState ? '#e8f1ff' : 'transparent'}
gap="$1"
>
<IconCmp size={20} color={activeState ? String(theme.primary?.val ?? '#007AFF') : '#94a3b8'} />
<Text fontSize="$xs" color={activeState ? '$primary' : '#6b7280'}>
<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>
<Text
fontSize="$xs"
fontWeight="700"
fontFamily="$body"
color={activeState ? '$primary' : '#6b7280'}
textAlign="center"
flexShrink={1}
>
{item.label}
</Text>
</YStack>

View File

@@ -1,19 +1,21 @@
import React from 'react';
import React, { Suspense } from 'react';
import { useNavigate } from 'react-router-dom';
import { ChevronDown, ChevronLeft, Bell, QrCode } 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 { useTranslation } from 'react-i18next';
import { useTheme } from '@tamagui/core';
import { useEventContext } from '../../context/EventContext';
import { BottomNav, NavKey } from './BottomNav';
import { useMobileNav } from '../hooks/useMobileNav';
import { adminPath } from '../../constants';
import { MobileSheet } from './Sheet';
import { MobileCard, PillBadge } from './Primitives';
import { useAlertsBadge } from '../hooks/useAlertsBadge';
import { useNotificationsBadge } from '../hooks/useNotificationsBadge';
import { formatEventDate, resolveEventDisplayName } from '../../lib/events';
import { TenantEvent, getEvents } from '../../api';
const DevTenantSwitcher = React.lazy(() => import('../../components/DevTenantSwitcher'));
type MobileShellProps = {
title?: string;
@@ -29,11 +31,18 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
const { go } = useMobileNav(activeEvent?.slug);
const navigate = useNavigate();
const { t, i18n } = useTranslation('mobile');
const { count: alertCount } = useAlertsBadge();
const { count: notificationCount } = useNotificationsBadge();
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 [pickerOpen, setPickerOpen] = React.useState(false);
const [fallbackEvents, setFallbackEvents] = React.useState<TenantEvent[]>([]);
const [loadingEvents, setLoadingEvents] = React.useState(false);
const [attemptedFetch, setAttemptedFetch] = React.useState(false);
const showDevTenantSwitcher = import.meta.env.DEV && import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true';
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
const effectiveEvents = events.length ? events : fallbackEvents;
@@ -81,11 +90,11 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
const showQr = Boolean(effectiveActive?.slug);
return (
<YStack backgroundColor="#f7f8fb" minHeight="100vh">
<YStack backgroundColor={backgroundColor} minHeight="100vh">
<YStack
backgroundColor="white"
backgroundColor={surfaceColor}
borderBottomWidth={1}
borderColor="#e5e7eb"
borderColor={borderColor}
paddingHorizontal="$4"
paddingTop="$4"
paddingBottom="$3"
@@ -94,82 +103,88 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
shadowRadius={10}
shadowOffset={{ width: 0, height: 4 }}
>
<XStack alignItems="center" justifyContent="space-between" space="$3">
<XStack alignItems="center" space="$2">
{onBack ? (
<Pressable onPress={onBack}>
<XStack alignItems="center" space="$1.5">
<ChevronLeft size={18} color="#007AFF" />
</XStack>
</Pressable>
) : null}
<Pressable
disabled={!showEventSwitcher}
onPress={() => setPickerOpen(true)}
style={{ alignItems: 'flex-start' }}
>
<Text fontSize="$lg" fontWeight="800" color="#111827">
{eventTitle}
</Text>
{subtitleText ? (
<Text fontSize="$xs" color="#6b7280">
{subtitleText}
</Text>
) : null}
</Pressable>
{showEventSwitcher ? <ChevronDown size={14} color="#111827" /> : null}
</XStack>
<XStack alignItems="center" space="$2.5">
<Pressable onPress={() => navigate(adminPath('/mobile/alerts'))}>
<XStack
width={34}
height={34}
borderRadius={12}
backgroundColor="#f4f5f7"
alignItems="center"
justifyContent="center"
position="relative"
>
<Bell size={16} color="#111827" />
{alertCount > 0 ? (
<YStack
position="absolute"
top={-4}
right={-4}
minWidth={18}
height={18}
paddingHorizontal={6}
borderRadius={999}
backgroundColor="#ef4444"
alignItems="center"
justifyContent="center"
>
<Text fontSize={10} color="white" fontWeight="700">
{alertCount > 9 ? '9+' : alertCount}
</Text>
</YStack>
) : null}
<XStack alignItems="center" justifyContent="space-between" minHeight={48} space="$3">
{onBack ? (
<Pressable onPress={onBack}>
<XStack alignItems="center" space="$1.5">
<ChevronLeft size={18} color="#007AFF" />
</XStack>
</Pressable>
{showQr ? (
<Pressable onPress={() => navigate(adminPath(`/mobile/events/${effectiveActive?.slug}/qr`))}>
) : (
<XStack width={18} />
)}
<XStack alignItems="center" space="$2.5" flex={1} justifyContent="flex-end">
<XStack alignItems="center" space="$1" maxWidth="55%">
<Pressable
disabled={!showEventSwitcher}
onPress={() => setPickerOpen(true)}
style={{ alignItems: 'flex-end' }}
>
<Text fontSize="$lg" fontWeight="800" fontFamily="$display" color={textColor} textAlign="right" numberOfLines={1}>
{eventTitle}
</Text>
{subtitleText ? (
<Text fontSize="$xs" color={mutedText} textAlign="right" numberOfLines={1} fontFamily="$body">
{subtitleText}
</Text>
) : null}
</Pressable>
{showEventSwitcher ? <ChevronDown size={14} color={textColor} /> : null}
</XStack>
<XStack alignItems="center" space="$2">
<Pressable onPress={() => navigate(adminPath('/mobile/notifications'))}>
<XStack
width={34}
height={34}
paddingHorizontal="$3"
borderRadius={12}
backgroundColor="#0ea5e9"
backgroundColor={surfaceColor}
alignItems="center"
justifyContent="center"
space="$1.5"
position="relative"
>
<QrCode size={16} color="white" />
<Text fontSize="$xs" fontWeight="800" color="white">
{t('header.quickQr', 'Quick QR')}
</Text>
<Bell size={16} color={textColor} />
{notificationCount > 0 ? (
<YStack
position="absolute"
top={-4}
right={-4}
minWidth={18}
height={18}
paddingHorizontal={6}
borderRadius={999}
backgroundColor="#ef4444"
alignItems="center"
justifyContent="center"
>
<Text fontSize={10} color="white" fontWeight="700">
{notificationCount > 9 ? '9+' : notificationCount}
</Text>
</YStack>
) : null}
</XStack>
</Pressable>
) : null}
{headerActions ?? null}
{showQr ? (
<Pressable onPress={() => navigate(adminPath(`/mobile/events/${effectiveActive?.slug}/qr`))}>
<XStack
height={34}
paddingHorizontal="$3"
borderRadius={12}
backgroundColor="#0ea5e9"
alignItems="center"
justifyContent="center"
space="$1.5"
>
<QrCode size={16} color="white" />
<Text fontSize="$xs" fontWeight="800" color="white">
{t('header.quickQr', 'Quick QR')}
</Text>
</XStack>
</Pressable>
) : null}
{headerActions ?? null}
</XStack>
</XStack>
</XStack>
</YStack>
@@ -178,6 +193,12 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
{children}
</YStack>
{showDevTenantSwitcher ? (
<Suspense fallback={null}>
<DevTenantSwitcher bottomOffset={96} />
</Suspense>
) : null}
<BottomNav active={activeTab} onNavigate={go} />
<MobileSheet
@@ -190,10 +211,10 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
<YStack space="$2">
{effectiveEvents.length === 0 ? (
<MobileCard alignItems="flex-start" space="$2">
<Text fontSize="$sm" color="#111827" fontWeight="700">
<Text fontSize="$sm" color={textColor} fontWeight="700">
{t('header.noEventsTitle', 'Create your first event')}
</Text>
<Text fontSize="$xs" color="#4b5563">
<Text fontSize="$xs" color={mutedText}>
{t('header.noEventsBody', 'Start an event to access tasks, uploads, QR posters and more.')}
</Text>
<Pressable onPress={() => navigate(adminPath('/mobile/events/new'))}>
@@ -219,10 +240,10 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
>
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
<YStack space="$0.5">
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color={textColor}>
{resolveEventDisplayName(event)}
</Text>
<Text fontSize="$xs" color="#6b7280">
<Text fontSize="$xs" color={mutedText}>
{formatEventDate(event.event_date, locale) ?? t('header.noDate', 'Date tbd')}
</Text>
</YStack>

View File

@@ -5,12 +5,13 @@ import { Pressable } from '@tamagui/react-native-web-lite';
import { useTheme } from '@tamagui/core';
export function MobileCard({ children, ...rest }: React.ComponentProps<typeof YStack>) {
const theme = useTheme();
return (
<YStack
backgroundColor="white"
backgroundColor={String(theme.surface?.val ?? 'white')}
borderRadius={16}
borderWidth={1}
borderColor="#e5e7eb"
borderColor={String(theme.borderColor?.val ?? '#e5e7eb')}
shadowColor="#0f172a"
shadowOpacity={0.06}
shadowRadius={12}
@@ -31,10 +32,23 @@ export function PillBadge({
tone?: 'success' | 'warning' | 'muted';
children: React.ReactNode;
}) {
const theme = useTheme();
const palette: Record<typeof tone, { bg: string; text: string; border: string }> = {
success: { bg: '#ecfdf3', text: '#047857', border: '#bbf7d0' },
warning: { bg: '#fffbeb', text: '#92400e', border: '#fef3c7' },
muted: { bg: '#f3f4f6', text: '#374151', border: '#e5e7eb' },
success: {
bg: String(theme.backgroundStrong?.val ?? '#ecfdf3'),
text: String(theme.green10?.val ?? '#047857'),
border: String(theme.green6?.val ?? '#bbf7d0'),
},
warning: {
bg: String(theme.yellow3?.val ?? '#fffbeb'),
text: String(theme.yellow11?.val ?? '#92400e'),
border: String(theme.yellow6?.val ?? '#fef3c7'),
},
muted: {
bg: String(theme.gray3?.val ?? '#f3f4f6'),
text: String(theme.gray11?.val ?? '#374151'),
border: String(theme.gray6?.val ?? '#e5e7eb'),
},
};
const colors = palette[tone] ?? palette.muted;
return (
@@ -72,11 +86,11 @@ export function CTAButton({
borderRadius={14}
alignItems="center"
justifyContent="center"
backgroundColor={isPrimary ? String(theme.primary?.val ?? '#007AFF') : 'white'}
backgroundColor={isPrimary ? String(theme.primary?.val ?? '#007AFF') : String(theme.surface?.val ?? 'white')}
borderWidth={isPrimary ? 0 : 1}
borderColor={isPrimary ? 'transparent' : '#e5e7eb'}
borderColor={isPrimary ? 'transparent' : String(theme.borderColor?.val ?? '#e5e7eb')}
>
<Text fontSize="$sm" fontWeight="800" color={isPrimary ? 'white' : '#111827'}>
<Text fontSize="$sm" fontWeight="800" color={isPrimary ? 'white' : String(theme.color?.val ?? '#111827')}>
{label}
</Text>
</XStack>
@@ -93,17 +107,25 @@ export function KpiTile({
label: string;
value: string | number;
}) {
const theme = useTheme();
return (
<MobileCard borderRadius={14} padding="$3" width="32%" minWidth={110} alignItems="flex-start">
<XStack alignItems="center" space="$2">
<XStack width={32} height={32} borderRadius={12} backgroundColor="#e5f0ff" alignItems="center" justifyContent="center">
<IconCmp size={16} color="#2563eb" />
<XStack
width={32}
height={32}
borderRadius={12}
backgroundColor={String(theme.blue3?.val ?? '#e5f0ff')}
alignItems="center"
justifyContent="center"
>
<IconCmp size={16} color={String(theme.primary?.val ?? '#2563eb')} />
</XStack>
<Text fontSize="$xs" color="#111827">
<Text fontSize="$xs" color={String(theme.color?.val ?? '#111827')}>
{label}
</Text>
</XStack>
<Text fontSize="$xl" fontWeight="800" color="#111827">
<Text fontSize="$xl" fontWeight="800" color={String(theme.color?.val ?? '#111827')}>
{value}
</Text>
</MobileCard>
@@ -121,6 +143,8 @@ export function ActionTile({
color: string;
onPress: () => void;
}) {
const theme = useTheme();
const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc');
return (
<Pressable onPress={onPress} style={{ width: '48%', marginBottom: 12 }}>
<YStack
@@ -137,7 +161,7 @@ export function ActionTile({
<XStack width={34} height={34} borderRadius={12} backgroundColor={color} alignItems="center" justifyContent="center">
<IconCmp size={16} color="white" />
</XStack>
<Text fontSize="$sm" fontWeight="700" color="#111827" textAlign="center">
<Text fontSize="$sm" fontWeight="700" color={text} textAlign="center">
{label}
</Text>
</YStack>

View File

@@ -4,6 +4,7 @@ import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { ChevronLeft } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useTheme } from '@tamagui/core';
type MobileScaffoldProps = {
title: string;
@@ -15,24 +16,30 @@ type MobileScaffoldProps = {
export function MobileScaffold({ title, onBack, rightSlot, children, footer }: MobileScaffoldProps) {
const { t } = useTranslation('mobile');
const theme = useTheme();
const background = String(theme.background?.val ?? '#f7f8fb');
const surface = String(theme.surface?.val ?? '#ffffff');
const border = String(theme.borderColor?.val ?? '#e5e7eb');
const textColor = String(theme.color?.val ?? '#111827');
return (
<YStack backgroundColor="#f7f8fb" minHeight="100vh">
<YStack backgroundColor={background} minHeight="100vh">
<XStack
alignItems="center"
justifyContent="space-between"
paddingHorizontal="$4"
paddingTop="$4"
paddingBottom="$3"
backgroundColor="white"
backgroundColor={surface}
borderBottomWidth={1}
borderColor="#e5e7eb"
borderColor={border}
>
<XStack alignItems="center" space="$2">
{onBack ? (
<Pressable onPress={onBack}>
<XStack alignItems="center" space="$1.5">
<ChevronLeft size={18} color="#007AFF" />
<Text fontSize="$sm" color="#007AFF" fontWeight="600">
<ChevronLeft size={18} color={String(theme.primary?.val ?? '#007AFF')} />
<Text fontSize="$sm" color={String(theme.primary?.val ?? '#007AFF')} fontWeight="600">
{t('actions.back', 'Back')}
</Text>
</XStack>
@@ -41,7 +48,7 @@ export function MobileScaffold({ title, onBack, rightSlot, children, footer }: M
<Text />
)}
</XStack>
<Text fontSize="$lg" fontWeight="800" color="#111827">
<Text fontSize="$lg" fontWeight="800" color={textColor}>
{title}
</Text>
<XStack minWidth={40} justifyContent="flex-end">

View File

@@ -3,6 +3,7 @@ import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { useTranslation } from 'react-i18next';
import { useTheme } from '@tamagui/core';
type SheetProps = {
open: boolean;
@@ -16,15 +17,21 @@ type SheetProps = {
export function MobileSheet({ open, title, onClose, children, footer, bottomOffsetPx = 88 }: SheetProps) {
const { t } = useTranslation('mobile');
const theme = useTheme();
const surface = String(theme.surface?.val ?? '#111827');
const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc');
const muted = String(theme.gray11?.val ?? theme.gray?.val ?? '#cbd5e1');
const overlay = String(theme.gray12?.val ?? 'rgba(0,0,0,0.6)');
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 backdrop-blur-sm">
<div className="fixed inset-0 z-50 flex items-end justify-center backdrop-blur-sm" style={{ backgroundColor: `${overlay}66` }}>
<YStack
width="100%"
maxWidth={520}
borderTopLeftRadius={24}
borderTopRightRadius={24}
backgroundColor="white"
backgroundColor={surface}
padding="$4"
paddingBottom="$7"
space="$3"
@@ -38,11 +45,11 @@ export function MobileSheet({ open, title, onClose, children, footer, bottomOffs
style={{ marginBottom: `max(env(safe-area-inset-bottom, 0px), ${bottomOffsetPx}px)` }}
>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$md" fontWeight="800" color="#111827">
<Text fontSize="$md" fontWeight="800" color={text}>
{title}
</Text>
<Pressable onPress={onClose}>
<Text fontSize="$md" color="#6b7280">
<Text fontSize="$md" color={muted}>
{t('actions.close', 'Close')}
</Text>
</Pressable>

View File

@@ -1,20 +1,25 @@
import React from 'react';
import { SizableText as Text } from '@tamagui/text';
import { XStack } from '@tamagui/stacks';
import { useTheme } from '@tamagui/core';
export function Tag({ label, color }: { label: string; color?: string }) {
const theme = useTheme();
const baseColor = color ?? String(theme.borderColor?.val ?? '#e5e7eb');
const textColor = String(theme.color?.val ?? '#111827');
export function Tag({ label, color = '#e5e7eb' }: { label: string; color?: string }) {
return (
<XStack
alignItems="center"
paddingHorizontal="$2"
paddingVertical={2}
borderRadius={999}
backgroundColor={`${color}22`}
backgroundColor={`${baseColor}22`}
borderWidth={1}
borderColor={`${color}55`}
borderColor={`${baseColor}55`}
alignSelf="flex-start"
>
<Text fontSize={11} fontWeight="600" color="#111827">
<Text fontSize={11} fontWeight="600" color={textColor}>
{label}
</Text>
</XStack>