Adopt Tamagui defaults for tabs and filters
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-02-04 08:29:50 +01:00
parent 0535f63b40
commit eecb1a5b85
14 changed files with 650 additions and 622 deletions

View File

@@ -6,7 +6,7 @@ import { Image as ImageIcon, RefreshCcw, UploadCloud, Trash2, ChevronDown, Save,
import { YStack, XStack } from '@tamagui/stacks'; import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Pressable } from '@tamagui/react-native-web-lite';
import { Slider } from 'tamagui'; import { Slider, Tabs } from 'tamagui';
import { MobileShell, HeaderActionButton } from './components/MobileShell'; import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives'; import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives';
import { MobileColorInput, MobileField, MobileFileInput, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls'; import { MobileColorInput, MobileField, MobileFileInput, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
@@ -637,15 +637,28 @@ export default function MobileBrandingPage() {
<ContextHelpLink slug="event-branding-assets" /> <ContextHelpLink slug="event-branding-assets" />
</XStack> </XStack>
<MobileCard gap="$2"> <Tabs
<XStack gap="$2"> value={activeTab}
<TabButton label={t('events.branding.titleShort', 'Branding')} active={activeTab === 'branding'} onPress={() => setActiveTab('branding')} /> onValueChange={(value) => setActiveTab(value as TabKey)}
<TabButton label={t('events.watermark.tab', 'Wasserzeichen')} active={activeTab === 'watermark'} onPress={() => setActiveTab('watermark')} /> orientation="horizontal"
</XStack> flexDirection="column"
</MobileCard> >
<MobileCard gap="$2">
<Tabs.List gap="$2">
<Tabs.Tab value="branding" flex={1} paddingVertical="$2.5" paddingHorizontal="$3" borderRadius="$4">
<Text fontSize="$sm" fontWeight="600" textAlign="center">
{t('events.branding.titleShort', 'Branding')}
</Text>
</Tabs.Tab>
<Tabs.Tab value="watermark" flex={1} paddingVertical="$2.5" paddingHorizontal="$3" borderRadius="$4">
<Text fontSize="$sm" fontWeight="600" textAlign="center">
{t('events.watermark.tab', 'Wasserzeichen')}
</Text>
</Tabs.Tab>
</Tabs.List>
</MobileCard>
{activeTab === 'branding' ? ( <Tabs.Content value="branding" paddingTop="$2">
<>
<MobileCard gap="$3"> <MobileCard gap="$3">
<Text fontSize="$sm" fontWeight="700" color={textStrong}> <Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.branding.previewTitle', 'Guest App Preview')} {t('events.branding.previewTitle', 'Guest App Preview')}
@@ -1216,12 +1229,14 @@ export default function MobileBrandingPage() {
onChange={(value) => setForm((prev) => ({ ...prev, linkColor: value }))} onChange={(value) => setForm((prev) => ({ ...prev, linkColor: value }))}
disabled={brandingDisabled} disabled={brandingDisabled}
/> />
</MobileCard> </MobileCard>
</> </>
</> </Tabs.Content>
) : (
renderWatermarkTab() <Tabs.Content value="watermark" paddingTop="$2">
)} {renderWatermarkTab()}
</Tabs.Content>
</Tabs>
<YStack gap="$2"> <YStack gap="$2">
<CTAButton label={saving ? t('events.branding.saving', 'Saving...') : t('events.branding.save', 'Save Branding')} onPress={() => handleSave()} /> <CTAButton label={saving ? t('events.branding.saving', 'Saving...') : t('events.branding.save', 'Save Branding')} onPress={() => handleSave()} />
@@ -1744,27 +1759,6 @@ function UpgradeCard({
); );
} }
function TabButton({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
const { primary, surfaceMuted, border, surface, textStrong } = useAdminTheme();
return (
<Pressable onPress={onPress} style={{ flex: 1 }}>
<XStack
alignItems="center"
justifyContent="center"
paddingVertical="$2.5"
borderRadius={12}
backgroundColor={active ? primary : surfaceMuted}
borderWidth={1}
borderColor={active ? primary : border}
>
<Text fontSize="$sm" color={active ? surface : textStrong} fontWeight="700">
{label}
</Text>
</XStack>
</Pressable>
);
}
function ModeButton({ function ModeButton({
label, label,
active, active,

View File

@@ -343,7 +343,6 @@ export default function MobileEventControlRoomPage() {
const liveResetRef = React.useRef(false); const liveResetRef = React.useRef(false);
const [fallbackAttempted, setFallbackAttempted] = React.useState(false); const [fallbackAttempted, setFallbackAttempted] = React.useState(false);
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events')); const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
const activeFilterBg = primary;
const saveControlRoomSettings = React.useCallback( const saveControlRoomSettings = React.useCallback(
async (nextSettings: ControlRoomSettings) => { async (nextSettings: ControlRoomSettings) => {
@@ -1376,60 +1375,52 @@ export default function MobileEventControlRoomPage() {
{t('mobilePhotos.filtersTitle', 'Filter')} {t('mobilePhotos.filtersTitle', 'Filter')}
</Text> </Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false}> <ScrollView horizontal showsHorizontalScrollIndicator={false}>
<XStack <ToggleGroup
type="single"
value={moderationFilter}
onValueChange={(value: string) => value && setModerationFilter(value as ModerationFilter)}
disableDeactivation
orientation="horizontal"
flexDirection="row"
alignItems="center" alignItems="center"
padding="$1" padding="$1"
borderRadius={999} gap="$1.5"
borderRadius="$pill"
borderWidth={1} borderWidth={1}
borderColor={border} borderColor={border}
backgroundColor={surfaceMuted} backgroundColor={surfaceMuted}
overflow="hidden"
> >
<ToggleGroup {MODERATION_FILTERS.map((option) => {
type="single" const count = moderationCounts[option.value] ?? 0;
value={moderationFilter} return (
onValueChange={(value: string) => value && setModerationFilter(value as ModerationFilter)} <ToggleGroup.Item
> key={option.value}
<XStack gap="$1.5"> value={option.value}
{MODERATION_FILTERS.map((option) => { borderRadius="$pill"
const active = option.value === moderationFilter; paddingVertical="$1.5"
const count = moderationCounts[option.value] ?? 0; paddingHorizontal="$3"
return ( >
<ToggleGroup.Item <XStack alignItems="center" gap="$1.5">
key={option.value} <Text fontSize="$xs" fontWeight="600">
value={option.value} {t(option.labelKey, option.fallback)}
unstyled </Text>
borderRadius={999} <XStack
paddingHorizontal="$1.5"
paddingVertical="$0.5"
borderRadius="$pill"
borderWidth={1} borderWidth={1}
borderColor="transparent" borderColor={border}
backgroundColor={active ? activeFilterBg : 'transparent'} backgroundColor="$backgroundStrong"
paddingVertical="$1.5"
paddingHorizontal="$3"
pressStyle={{ opacity: 0.85 }}
> >
<XStack alignItems="center" gap="$1.5"> <Text fontSize={10} fontWeight="700">
<Text fontSize="$xs" fontWeight="700" color={active ? '#fff' : muted}> {count}
{t(option.labelKey, option.fallback)} </Text>
</Text> </XStack>
<XStack </XStack>
paddingHorizontal="$1.5" </ToggleGroup.Item>
paddingVertical="$0.5" );
borderRadius={999} })}
borderWidth={1} </ToggleGroup>
borderColor={active ? 'transparent' : border}
backgroundColor={active ? withAlpha('#ffffff', 0.2) : surface}
>
<Text fontSize={10} fontWeight="800" color={active ? '#fff' : muted}>
{count}
</Text>
</XStack>
</XStack>
</ToggleGroup.Item>
);
})}
</XStack>
</ToggleGroup>
</XStack>
</ScrollView> </ScrollView>
</YStack> </YStack>
</MobileCard> </MobileCard>
@@ -1564,60 +1555,52 @@ export default function MobileEventControlRoomPage() {
{t('liveShowQueue.filterLabel', 'Live status')} {t('liveShowQueue.filterLabel', 'Live status')}
</Text> </Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false}> <ScrollView horizontal showsHorizontalScrollIndicator={false}>
<XStack <ToggleGroup
type="single"
value={liveStatusFilter}
onValueChange={(value: string) => value && setLiveStatusFilter(value as LiveShowQueueStatus)}
disableDeactivation
orientation="horizontal"
flexDirection="row"
alignItems="center" alignItems="center"
padding="$1" padding="$1"
borderRadius={999} gap="$1.5"
borderRadius="$pill"
borderWidth={1} borderWidth={1}
borderColor={border} borderColor={border}
backgroundColor={surfaceMuted} backgroundColor={surfaceMuted}
overflow="hidden"
> >
<ToggleGroup {LIVE_STATUS_OPTIONS.map((option) => {
type="single" const count = liveCounts[option.value] ?? 0;
value={liveStatusFilter} return (
onValueChange={(value: string) => value && setLiveStatusFilter(value as LiveShowQueueStatus)} <ToggleGroup.Item
> key={option.value}
<XStack gap="$1.5"> value={option.value}
{LIVE_STATUS_OPTIONS.map((option) => { borderRadius="$pill"
const active = option.value === liveStatusFilter; paddingVertical="$1.5"
const count = liveCounts[option.value] ?? 0; paddingHorizontal="$3"
return ( >
<ToggleGroup.Item <XStack alignItems="center" gap="$1.5">
key={option.value} <Text fontSize="$xs" fontWeight="600">
value={option.value} {t(option.labelKey, option.fallback)}
unstyled </Text>
borderRadius={999} <XStack
paddingHorizontal="$1.5"
paddingVertical="$0.5"
borderRadius="$pill"
borderWidth={1} borderWidth={1}
borderColor="transparent" borderColor={border}
backgroundColor={active ? activeFilterBg : 'transparent'} backgroundColor="$backgroundStrong"
paddingVertical="$1.5"
paddingHorizontal="$3"
pressStyle={{ opacity: 0.85 }}
> >
<XStack alignItems="center" gap="$1.5"> <Text fontSize={10} fontWeight="700">
<Text fontSize="$xs" fontWeight="700" color={active ? '#fff' : muted}> {count}
{t(option.labelKey, option.fallback)} </Text>
</Text> </XStack>
<XStack </XStack>
paddingHorizontal="$1.5" </ToggleGroup.Item>
paddingVertical="$0.5" );
borderRadius={999} })}
borderWidth={1} </ToggleGroup>
borderColor={active ? 'transparent' : border}
backgroundColor={active ? withAlpha('#ffffff', 0.2) : surface}
>
<Text fontSize={10} fontWeight="800" color={active ? '#fff' : muted}>
{count}
</Text>
</XStack>
</XStack>
</ToggleGroup.Item>
);
})}
</XStack>
</ToggleGroup>
</XStack>
</ScrollView> </ScrollView>
</YStack> </YStack>
</MobileCard> </MobileCard>

View File

@@ -5,6 +5,7 @@ import { Trash2, Copy, RefreshCcw } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks'; import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Pressable } from '@tamagui/react-native-web-lite';
import { ToggleGroup } from '@tamagui/toggle-group';
import { MobileShell, HeaderActionButton } from './components/MobileShell'; import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives'; import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
import { MobileField, MobileInput, MobileSelect } from './components/FormControls'; import { MobileField, MobileInput, MobileSelect } from './components/FormControls';
@@ -227,51 +228,67 @@ export default function MobileEventMembersPage() {
<Text fontSize="$xs" fontWeight="700" color={muted}> <Text fontSize="$xs" fontWeight="700" color={muted}>
{t('events.members.filters.statusLabel', 'Status')} {t('events.members.filters.statusLabel', 'Status')}
</Text> </Text>
<XStack gap="$2" flexWrap="wrap"> <ToggleGroup
{statusOptions.map((option) => { type="single"
const isActive = statusFilter === option.key; value={statusFilter}
return ( onValueChange={(value: string) => value && setStatusFilter(value as typeof statusFilter)}
<Pressable key={option.key} onPress={() => setStatusFilter(option.key)}> disableDeactivation
<XStack orientation="horizontal"
paddingHorizontal="$3" flexDirection="row"
paddingVertical="$1.5" flexWrap="wrap"
borderRadius={999} gap="$2"
borderWidth={1} padding="$1"
borderColor={isActive ? primary : border} borderRadius="$pill"
backgroundColor={isActive ? primary : 'transparent'} borderWidth={1}
> borderColor={border}
<Text fontSize="$xs" fontWeight="700" color={isActive ? 'white' : text}> backgroundColor="$background"
{option.label} >
</Text> {statusOptions.map((option) => (
</XStack> <ToggleGroup.Item
</Pressable> key={option.key}
); value={option.key}
})} borderRadius="$pill"
</XStack> paddingHorizontal="$3"
paddingVertical="$1.5"
>
<Text fontSize="$xs" fontWeight="600">
{option.label}
</Text>
</ToggleGroup.Item>
))}
</ToggleGroup>
<Text fontSize="$xs" fontWeight="700" color={muted}> <Text fontSize="$xs" fontWeight="700" color={muted}>
{t('events.members.filters.roleLabel', 'Role')} {t('events.members.filters.roleLabel', 'Role')}
</Text> </Text>
<XStack gap="$2" flexWrap="wrap"> <ToggleGroup
{roleOptions.map((option) => { type="single"
const isActive = roleFilter === option.key; value={roleFilter}
return ( onValueChange={(value: string) => value && setRoleFilter(value as typeof roleFilter)}
<Pressable key={option.key} onPress={() => setRoleFilter(option.key)}> disableDeactivation
<XStack orientation="horizontal"
paddingHorizontal="$3" flexDirection="row"
paddingVertical="$1.5" flexWrap="wrap"
borderRadius={999} gap="$2"
borderWidth={1} padding="$1"
borderColor={isActive ? primary : border} borderRadius="$pill"
backgroundColor={isActive ? primary : 'transparent'} borderWidth={1}
> borderColor={border}
<Text fontSize="$xs" fontWeight="700" color={isActive ? 'white' : text}> backgroundColor="$background"
{option.label} >
</Text> {roleOptions.map((option) => (
</XStack> <ToggleGroup.Item
</Pressable> key={option.key}
); value={option.key}
})} borderRadius="$pill"
</XStack> paddingHorizontal="$3"
paddingVertical="$1.5"
>
<Text fontSize="$xs" fontWeight="600">
{option.label}
</Text>
</ToggleGroup.Item>
))}
</ToggleGroup>
</YStack> </YStack>
) : null} ) : null}

View File

@@ -4,8 +4,8 @@ import { useTranslation } from 'react-i18next';
import { Share2, Sparkles, Trophy, Users, TrendingUp, Heart, Image as ImageIcon } from 'lucide-react'; import { Share2, Sparkles, Trophy, Users, TrendingUp, Heart, Image as ImageIcon } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks'; import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { Switch } from '@tamagui/switch'; import { Switch } from '@tamagui/switch';
import { Tabs } from 'tamagui';
import { MobileShell } from './components/MobileShell'; import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives'; import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
import { LegalConsentSheet } from './components/LegalConsentSheet'; import { LegalConsentSheet } from './components/LegalConsentSheet';
@@ -208,25 +208,31 @@ export default function MobileEventRecapPage() {
onBack={back} onBack={back}
> >
<YStack gap="$4"> <YStack gap="$4">
<XStack gap="$2"> <Tabs
<TabButton value={activeTab}
label={t('events.recap.tabs.overview', 'Overview')} onValueChange={(value) => setActiveTab(value as typeof activeTab)}
active={activeTab === 'overview'} orientation="horizontal"
onPress={() => setActiveTab('overview')} flexDirection="column"
/> >
<TabButton <Tabs.List gap="$2">
label={t('events.recap.tabs.engagement', 'Engagement')} <Tabs.Tab value="overview" flex={1} paddingVertical="$2.5" paddingHorizontal="$3" borderRadius="$4">
active={activeTab === 'engagement'} <Text fontSize="$sm" fontWeight="600" textAlign="center">
onPress={() => setActiveTab('engagement')} {t('events.recap.tabs.overview', 'Overview')}
/> </Text>
<TabButton </Tabs.Tab>
label={t('events.recap.tabs.compliance', 'Compliance')} <Tabs.Tab value="engagement" flex={1} paddingVertical="$2.5" paddingHorizontal="$3" borderRadius="$4">
active={activeTab === 'compliance'} <Text fontSize="$sm" fontWeight="600" textAlign="center">
onPress={() => setActiveTab('compliance')} {t('events.recap.tabs.engagement', 'Engagement')}
/> </Text>
</XStack> </Tabs.Tab>
<Tabs.Tab value="compliance" flex={1} paddingVertical="$2.5" paddingHorizontal="$3" borderRadius="$4">
<Text fontSize="$sm" fontWeight="600" textAlign="center">
{t('events.recap.tabs.compliance', 'Compliance')}
</Text>
</Tabs.Tab>
</Tabs.List>
{activeTab === 'overview' ? ( <Tabs.Content value="overview" paddingTop="$2">
<YStack gap="$4"> <YStack gap="$4">
<MobileCard gap="$3"> <MobileCard gap="$3">
<XStack alignItems="center" justifyContent="space-between"> <XStack alignItems="center" justifyContent="space-between">
@@ -362,9 +368,9 @@ export default function MobileEventRecapPage() {
</YStack> </YStack>
</MobileCard> </MobileCard>
</YStack> </YStack>
) : null} </Tabs.Content>
{activeTab === 'engagement' ? ( <Tabs.Content value="engagement" paddingTop="$2">
<YStack gap="$4"> <YStack gap="$4">
{engagementLoading ? ( {engagementLoading ? (
<YStack gap="$2"> <YStack gap="$2">
@@ -550,13 +556,14 @@ export default function MobileEventRecapPage() {
</YStack> </YStack>
)} )}
</YStack> </YStack>
) : null} </Tabs.Content>
{activeTab === 'compliance' ? ( <Tabs.Content value="compliance" paddingTop="$2">
<YStack gap="$4"> <YStack gap="$4">
<DataExportsPanel variant="recap" event={event} /> <DataExportsPanel variant="recap" event={event} />
</YStack> </YStack>
) : null} </Tabs.Content>
</Tabs>
</YStack> </YStack>
<LegalConsentSheet <LegalConsentSheet
@@ -609,27 +616,6 @@ function ToggleOption({ label, value, onToggle }: { label: string; value: boolea
); );
} }
function TabButton({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
const { primary, surfaceMuted, border, surface, textStrong } = useAdminTheme();
return (
<Pressable onPress={onPress} style={{ flex: 1 }}>
<XStack
alignItems="center"
justifyContent="center"
paddingVertical="$2.5"
borderRadius={12}
backgroundColor={active ? primary : surfaceMuted}
borderWidth={1}
borderColor={active ? primary : border}
>
<Text fontSize="$sm" color={active ? surface : textStrong} fontWeight="700">
{label}
</Text>
</XStack>
</Pressable>
);
}
function LeaderboardRow({ rank, name, value }: { rank: number; name: string; value: string }) { function LeaderboardRow({ rank, name, value }: { rank: number; name: string; value: string }) {
const { textStrong, muted, border, surfaceMuted } = useAdminTheme(); const { textStrong, muted, border, surfaceMuted } = useAdminTheme();
return ( return (

View File

@@ -1170,7 +1170,6 @@ export default function MobileEventTasksPage() {
borderRadius={16} borderRadius={16}
borderWidth={1} borderWidth={1}
borderColor={border} borderColor={border}
backgroundColor={surfaceMuted}
overflow="hidden" overflow="hidden"
gap="$0" gap="$0"
> >
@@ -1186,16 +1185,13 @@ export default function MobileEventTasksPage() {
key={tab.value} key={tab.value}
value={tab.value} value={tab.value}
flex={1} flex={1}
unstyled
paddingVertical="$2.5" paddingVertical="$2.5"
alignItems="center" alignItems="center"
justifyContent="center" justifyContent="center"
backgroundColor={isActive ? primary : 'transparent'}
borderRightWidth={index === arr.length - 1 ? 0 : 1} borderRightWidth={index === arr.length - 1 ? 0 : 1}
borderRightColor={border} borderRightColor={border}
pressStyle={{ backgroundColor: isActive ? primary : surface }}
> >
<Text fontSize="$sm" fontWeight={isActive ? '700' : '500'} color={isActive ? 'white' : text}> <Text fontSize="$sm" fontWeight={isActive ? '700' : '600'}>
{tab.label} {tab.label}
</Text> </Text>
</Tabs.Tab> </Tabs.Tab>

View File

@@ -235,9 +235,7 @@ function EventsList({
onEdit?: (slug: string) => void; onEdit?: (slug: string) => void;
}) { }) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const { text, muted, subtle, border, primary, surface, surfaceMuted, accentSoft, accent, shadow } = useAdminTheme(); const { text, muted, subtle, border, primary, surface, surfaceMuted, shadow } = useAdminTheme();
const activeBg = accentSoft;
const activeBorder = accent;
const statusCounts = React.useMemo(() => buildEventStatusCounts(events), [events]); const statusCounts = React.useMemo(() => buildEventStatusCounts(events), [events]);
const filteredByStatus = React.useMemo( const filteredByStatus = React.useMemo(
@@ -313,56 +311,49 @@ function EventsList({
</Text> </Text>
</XStack> </XStack>
<ScrollView horizontal showsHorizontalScrollIndicator={false}> <ScrollView horizontal showsHorizontalScrollIndicator={false}>
<XStack <ToggleGroup
type="single"
value={statusFilter}
onValueChange={(value: string) => value && onStatusChange(value as EventStatusKey)}
disableDeactivation
orientation="horizontal"
flexDirection="row"
alignItems="center" alignItems="center"
padding="$1" padding="$1"
borderRadius={999} gap="$1.5"
borderRadius="$pill"
borderWidth={1} borderWidth={1}
borderColor={border} borderColor={border}
backgroundColor={surfaceMuted} backgroundColor={surfaceMuted}
> >
<ToggleGroup {filters.map((filter) => (
type="single" <ToggleGroup.Item
value={statusFilter} key={filter.key}
onValueChange={(value: string) => value && onStatusChange(value as EventStatusKey)} value={filter.key}
> borderRadius="$pill"
<XStack gap="$1.5"> paddingVertical="$1.5"
{filters.map((filter) => { paddingHorizontal="$3"
const active = filter.key === statusFilter; >
return ( <XStack alignItems="center" gap="$1.5">
<ToggleGroup.Item <Text fontSize="$xs" fontWeight="600">
key={filter.key} {filter.label}
value={filter.key} </Text>
borderRadius={999} <XStack
borderWidth={1} paddingHorizontal="$1.5"
borderColor={active ? activeBorder : 'transparent'} paddingVertical="$0.5"
backgroundColor={active ? activeBg : 'transparent'} borderRadius="$pill"
paddingVertical="$1.5" borderWidth={1}
paddingHorizontal="$3" borderColor={border}
> backgroundColor="$backgroundStrong"
<XStack alignItems="center" gap="$1.5"> >
<Text fontSize="$xs" fontWeight="700" color={active ? primary : muted}> <Text fontSize={10} fontWeight="700">
{filter.label} {filter.count}
</Text> </Text>
<XStack </XStack>
paddingHorizontal="$1.5" </XStack>
paddingVertical="$0.5" </ToggleGroup.Item>
borderRadius={999} ))}
borderWidth={1} </ToggleGroup>
borderColor={active ? activeBorder : border}
backgroundColor={surface}
>
<Text fontSize={10} fontWeight="800" color={active ? primary : muted}>
{filter.count}
</Text>
</XStack>
</XStack>
</ToggleGroup.Item>
);
})}
</XStack>
</ToggleGroup>
</XStack>
</ScrollView> </ScrollView>
</YStack> </YStack>

View File

@@ -5,6 +5,7 @@ import { Bell, Check, ChevronRight, RefreshCcw } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks'; import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Pressable } from '@tamagui/react-native-web-lite';
import { ToggleGroup } from '@tamagui/toggle-group';
import { motion, useAnimationControls, type PanInfo } from 'framer-motion'; import { motion, useAnimationControls, type PanInfo } from 'framer-motion';
import { MobileShell, HeaderActionButton } from './components/MobileShell'; import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, PillBadge, SkeletonCard, CTAButton } from './components/Primitives'; import { MobileCard, PillBadge, SkeletonCard, CTAButton } from './components/Primitives';
@@ -326,7 +327,7 @@ export default function MobileNotificationsPage() {
const [events, setEvents] = React.useState<TenantEvent[]>([]); const [events, setEvents] = React.useState<TenantEvent[]>([]);
const [showEventPicker, setShowEventPicker] = React.useState(false); const [showEventPicker, setShowEventPicker] = React.useState(false);
const back = useBackNavigation(adminPath('/mobile/dashboard')); const back = useBackNavigation(adminPath('/mobile/dashboard'));
const { text, muted, border, warningBg, warningText, infoBg, primary, danger, accentSoft, subtle } = useAdminTheme(); const { text, muted, border, warningBg, warningText, infoBg, primary, danger, subtle } = useAdminTheme();
const warningIcon = warningText; const warningIcon = warningText;
const infoIcon = primary; const infoIcon = primary;
const errorText = danger; const errorText = danger;
@@ -552,7 +553,22 @@ export default function MobileNotificationsPage() {
) : null} ) : null}
</XStack> </XStack>
<XStack gap="$2" flexWrap="wrap" marginBottom="$2"> <ToggleGroup
type="single"
value={scopeParam ?? 'all'}
onValueChange={(value: string) => value && updateFilters({ scope: value as NotificationScope | 'all' })}
disableDeactivation
orientation="horizontal"
flexDirection="row"
flexWrap="wrap"
gap="$2"
padding="$1"
borderRadius="$pill"
borderWidth={1}
borderColor={border}
backgroundColor="$background"
marginBottom="$2"
>
{([ {([
{ key: 'all', label: t('notificationLogs.scope.all', 'All scopes') }, { key: 'all', label: t('notificationLogs.scope.all', 'All scopes') },
{ key: 'photos', label: t('notificationLogs.scope.photos', 'Photos') }, { key: 'photos', label: t('notificationLogs.scope.photos', 'Photos') },
@@ -561,28 +577,22 @@ export default function MobileNotificationsPage() {
{ key: 'events', label: t('notificationLogs.scope.events', 'Events') }, { key: 'events', label: t('notificationLogs.scope.events', 'Events') },
{ key: 'package', label: t('notificationLogs.scope.package', 'Package') }, { key: 'package', label: t('notificationLogs.scope.package', 'Package') },
{ key: 'general', label: t('notificationLogs.scope.general', 'General') }, { key: 'general', label: t('notificationLogs.scope.general', 'General') },
] as Array<{ key: NotificationScope | 'all'; label: string }>).map((filter) => { ] as Array<{ key: NotificationScope | 'all'; label: string }>).map((filter) => (
const active = scopeParam === filter.key; <ToggleGroup.Item
return ( key={filter.key}
<Pressable key={filter.key} onPress={() => updateFilters({ scope: filter.key })} style={{ flexGrow: 1 }}> value={filter.key}
<XStack borderRadius="$pill"
alignItems="center" paddingVertical="$2"
justifyContent="center" paddingHorizontal="$3"
paddingVertical="$2" flexGrow={1}
paddingHorizontal="$3" justifyContent="center"
borderRadius={14} >
borderWidth={1} <Text fontSize="$xs" fontWeight="600" textAlign="center">
borderColor={active ? primary : border} {filter.label}
backgroundColor={active ? accentSoft : 'transparent'} </Text>
> </ToggleGroup.Item>
<Text fontSize="$xs" fontWeight="700" color={active ? primary : muted}> ))}
{filter.label} </ToggleGroup>
</Text>
</XStack>
</Pressable>
);
})}
</XStack>
{loading ? ( {loading ? (
<YStack gap="$2"> <YStack gap="$2">

View File

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { CheckCircle2, Lock, MailWarning, User } from 'lucide-react'; import { CheckCircle2, Lock, MailWarning, User } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks'; import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Tabs } from 'tamagui';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { MobileShell } from './components/MobileShell'; import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives'; import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
@@ -300,6 +300,275 @@ export default function MobileProfileAccountPage() {
form.password.trim().length > 0 && form.password.trim().length > 0 &&
form.passwordConfirmation.trim().length > 0; form.passwordConfirmation.trim().length > 0;
const brandingDisabled = brandingLoading || brandingSaving; const brandingDisabled = brandingLoading || brandingSaving;
const brandingContent = (
<>
{brandingError ? (
<MobileCard>
<Text fontWeight="700" color={danger}>
{brandingError}
</Text>
</MobileCard>
) : null}
<MobileCard gap="$3">
<Text fontSize="$md" fontWeight="800" color={text}>
{t('profile.branding.title', 'Standard-Branding')}
</Text>
<Text fontSize="$sm" color={muted}>
{t('profile.branding.hint', 'Diese Werte dienen als Vorschlag für neue Events und beschleunigen die Einrichtung.')}
</Text>
</MobileCard>
<MobileCard gap="$3">
<Text fontSize="$md" fontWeight="800" color={text}>
{t('profile.branding.theme', 'Theme')}
</Text>
<YStack gap="$3">
<MobileField label={t('events.branding.mode', 'Theme')}>
<MobileSelect
value={brandingForm.mode}
onChange={(event) => setBrandingForm((prev) => ({ ...prev, mode: event.target.value as BrandingFormValues['mode'] }))}
disabled={brandingDisabled}
>
<option value="light">{t('events.branding.modeLight', 'Light')}</option>
<option value="auto">{t('events.branding.modeAuto', 'Auto')}</option>
<option value="dark">{t('events.branding.modeDark', 'Dark')}</option>
</MobileSelect>
</MobileField>
<MobileField label={t('events.branding.fontSize', 'Font Size')}>
<MobileSelect
value={brandingForm.fontSize}
onChange={(event) => setBrandingForm((prev) => ({ ...prev, fontSize: event.target.value as BrandingFormValues['fontSize'] }))}
disabled={brandingDisabled}
>
<option value="s">{t('events.branding.fontSizeSmall', 'S')}</option>
<option value="m">{t('events.branding.fontSizeMedium', 'M')}</option>
<option value="l">{t('events.branding.fontSizeLarge', 'L')}</option>
</MobileSelect>
</MobileField>
</YStack>
</MobileCard>
<MobileCard gap="$3">
<Text fontSize="$md" fontWeight="800" color={text}>
{t('events.branding.colors', 'Colors')}
</Text>
<YStack gap="$3">
<ColorField
label={t('events.branding.primary', 'Primary Color')}
value={brandingForm.primary}
onChange={(value) => setBrandingForm((prev) => ({ ...prev, primary: value }))}
disabled={brandingDisabled}
/>
<ColorField
label={t('events.branding.accent', 'Accent Color')}
value={brandingForm.accent}
onChange={(value) => setBrandingForm((prev) => ({ ...prev, accent: value }))}
disabled={brandingDisabled}
/>
<ColorField
label={t('events.branding.backgroundColor', 'Background Color')}
value={brandingForm.background}
onChange={(value) => setBrandingForm((prev) => ({ ...prev, background: value }))}
disabled={brandingDisabled}
/>
<ColorField
label={t('events.branding.surfaceColor', 'Surface Color')}
value={brandingForm.surface}
onChange={(value) => setBrandingForm((prev) => ({ ...prev, surface: value }))}
disabled={brandingDisabled}
/>
</YStack>
</MobileCard>
<MobileCard gap="$3">
<Text fontSize="$md" fontWeight="800" color={text}>
{t('events.branding.fonts', 'Fonts')}
</Text>
<YStack gap="$3">
<MobileField label={t('events.branding.headingFont', 'Headline Font')}>
<MobileInput
value={brandingForm.headingFont}
onChange={(event) => setBrandingForm((prev) => ({ ...prev, headingFont: event.target.value }))}
placeholder={t('events.branding.headingFontPlaceholder', 'Playfair Display')}
hasError={false}
disabled={brandingDisabled}
/>
</MobileField>
<MobileField label={t('events.branding.bodyFont', 'Body Font')}>
<MobileInput
value={brandingForm.bodyFont}
onChange={(event) => setBrandingForm((prev) => ({ ...prev, bodyFont: event.target.value }))}
placeholder={t('events.branding.bodyFontPlaceholder', 'Montserrat')}
hasError={false}
disabled={brandingDisabled}
/>
</MobileField>
</YStack>
</MobileCard>
<CTAButton
label={brandingSaving ? t('profile.branding.saving', 'Saving...') : t('profile.branding.save', 'Save defaults')}
onPress={handleBrandingSave}
disabled={brandingDisabled}
loading={brandingSaving}
/>
</>
);
const accountContent = (
<>
{error ? (
<MobileCard>
<Text fontWeight="700" color={danger}>
{error}
</Text>
</MobileCard>
) : null}
<MobileCard gap="$3">
<XStack alignItems="center" gap="$3">
<XStack
width={48}
height={48}
borderRadius={16}
alignItems="center"
justifyContent="center"
backgroundColor={accentSoft}
>
<User size={20} color={primary} />
</XStack>
<YStack gap="$1">
<Text fontSize="$md" fontWeight="800" color={text}>
{form.name || profile?.email || t('profile.title', 'Profil')}
</Text>
<Text fontSize="$sm" color={muted}>
{form.email || profile?.email || '—'}
</Text>
</YStack>
</XStack>
<XStack alignItems="center" gap="$2" flexWrap="wrap">
{profile?.email_verified ? (
<CheckCircle2 size={14} color={subtle} />
) : (
<MailWarning size={14} color={subtle} />
)}
<PillBadge tone={profile?.email_verified ? 'success' : 'warning'}>
{emailStatusLabel}
</PillBadge>
<Text fontSize="$xs" color={muted}>
{emailHint}
</Text>
</XStack>
</MobileCard>
<MobileCard gap="$3">
<XStack alignItems="center" gap="$2">
<User size={16} color={text} />
<Text fontSize="$md" fontWeight="800" color={text}>
{t('profile.sections.account.heading', 'Account-Informationen')}
</Text>
</XStack>
<Text fontSize="$sm" color={muted}>
{t('profile.sections.account.description', 'Passe Anzeigename, E-Mail-Adresse und Sprache der Admin-Oberfläche an.')}
</Text>
{loading ? (
<Text fontSize="$sm" color={muted}>
{t('profile.loading', 'Lädt ...')}
</Text>
) : (
<YStack gap="$3">
<MobileField label={t('profile.fields.name', 'Anzeigename')}>
<MobileInput
value={form.name}
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
placeholder={t('profile.placeholders.name', 'z. B. Hochzeitsplanung Schmidt')}
hasError={false}
/>
</MobileField>
<MobileField label={t('profile.fields.email', 'E-Mail-Adresse')}>
<MobileInput
value={form.email}
onChange={(event) => setForm((prev) => ({ ...prev, email: event.target.value }))}
placeholder="mail@beispiel.de"
type="email"
hasError={false}
/>
</MobileField>
<MobileField label={t('profile.fields.locale', 'Bevorzugte Sprache')}>
<MobileSelect
value={form.preferredLocale}
onChange={(event) => setForm((prev) => ({ ...prev, preferredLocale: event.target.value }))}
>
{LOCALE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label ?? t(option.labelKey, option.fallback)}
</option>
))}
</MobileSelect>
</MobileField>
<CTAButton
label={t('profile.actions.save', 'Speichern')}
onPress={handleAccountSave}
disabled={savingAccount || loading}
loading={savingAccount}
/>
</YStack>
)}
</MobileCard>
<MobileCard gap="$3">
<XStack alignItems="center" gap="$2">
<Lock size={16} color={text} />
<Text fontSize="$md" fontWeight="800" color={text}>
{t('profile.sections.password.heading', 'Passwort ändern')}
</Text>
</XStack>
<Text fontSize="$sm" color={muted}>
{t('profile.sections.password.description', 'Wähle ein sicheres Passwort, um deinen Admin-Zugang zu schützen.')}
</Text>
<YStack gap="$3">
<MobileField label={t('profile.fields.currentPassword', 'Aktuelles Passwort')}>
<MobileInput
value={form.currentPassword}
onChange={(event) => setForm((prev) => ({ ...prev, currentPassword: event.target.value }))}
placeholder="••••••••"
type="password"
hasError={false}
/>
</MobileField>
<MobileField
label={t('profile.fields.newPassword', 'Neues Passwort')}
hint={t('profile.sections.password.hint', 'Nutze mindestens 8 Zeichen und kombiniere Buchstaben sowie Zahlen für mehr Sicherheit.')}
>
<MobileInput
value={form.password}
onChange={(event) => setForm((prev) => ({ ...prev, password: event.target.value }))}
placeholder="••••••••"
type="password"
hasError={false}
/>
</MobileField>
<MobileField label={t('profile.fields.passwordConfirmation', 'Passwort bestätigen')}>
<MobileInput
value={form.passwordConfirmation}
onChange={(event) => setForm((prev) => ({ ...prev, passwordConfirmation: event.target.value }))}
placeholder="••••••••"
type="password"
hasError={false}
/>
</MobileField>
<CTAButton
label={t('profile.actions.updatePassword', 'Passwort aktualisieren')}
onPress={handlePasswordSave}
disabled={!passwordReady || savingPassword || loading}
loading={savingPassword}
tone="ghost"
/>
</YStack>
</MobileCard>
</>
);
return ( return (
<MobileShell <MobileShell
@@ -308,312 +577,38 @@ export default function MobileProfileAccountPage() {
onBack={back} onBack={back}
> >
{brandingTabEnabled ? ( {brandingTabEnabled ? (
<XStack gap="$2"> <Tabs
<TabButton value={activeTab}
label={t('profile.tabs.account', 'Account')} onValueChange={(value) => setActiveTab(value as TabKey)}
active={activeTab === 'account'} orientation="horizontal"
onPress={() => setActiveTab('account')} flexDirection="column"
/> >
<TabButton <Tabs.List gap="$2">
label={t('profile.tabs.branding', 'Standard-Branding')} <Tabs.Tab value="account" flex={1} paddingVertical="$2.5" paddingHorizontal="$3" borderRadius="$4">
active={activeTab === 'branding'} <Text fontSize="$sm" fontWeight="600" textAlign="center">
onPress={() => setActiveTab('branding')} {t('profile.tabs.account', 'Account')}
/>
</XStack>
) : null}
{activeTab === 'branding' && brandingTabEnabled ? (
<>
{brandingError ? (
<MobileCard>
<Text fontWeight="700" color={danger}>
{brandingError}
</Text> </Text>
</MobileCard> </Tabs.Tab>
) : null} <Tabs.Tab value="branding" flex={1} paddingVertical="$2.5" paddingHorizontal="$3" borderRadius="$4">
<Text fontSize="$sm" fontWeight="600" textAlign="center">
<MobileCard gap="$3"> {t('profile.tabs.branding', 'Standard-Branding')}
<Text fontSize="$md" fontWeight="800" color={text}> </Text>
{t('profile.branding.title', 'Standard-Branding')} </Tabs.Tab>
</Text> </Tabs.List>
<Text fontSize="$sm" color={muted}> <Tabs.Content value="account" paddingTop="$2">
{t('profile.branding.hint', 'Diese Werte dienen als Vorschlag für neue Events und beschleunigen die Einrichtung.')} {accountContent}
</Text> </Tabs.Content>
</MobileCard> <Tabs.Content value="branding" paddingTop="$2">
{brandingContent}
<MobileCard gap="$3"> </Tabs.Content>
<Text fontSize="$md" fontWeight="800" color={text}> </Tabs>
{t('profile.branding.theme', 'Theme')}
</Text>
<YStack gap="$3">
<MobileField label={t('events.branding.mode', 'Theme')}>
<MobileSelect
value={brandingForm.mode}
onChange={(event) => setBrandingForm((prev) => ({ ...prev, mode: event.target.value as BrandingFormValues['mode'] }))}
disabled={brandingDisabled}
>
<option value="light">{t('events.branding.modeLight', 'Light')}</option>
<option value="auto">{t('events.branding.modeAuto', 'Auto')}</option>
<option value="dark">{t('events.branding.modeDark', 'Dark')}</option>
</MobileSelect>
</MobileField>
<MobileField label={t('events.branding.fontSize', 'Font Size')}>
<MobileSelect
value={brandingForm.fontSize}
onChange={(event) => setBrandingForm((prev) => ({ ...prev, fontSize: event.target.value as BrandingFormValues['fontSize'] }))}
disabled={brandingDisabled}
>
<option value="s">{t('events.branding.fontSizeSmall', 'S')}</option>
<option value="m">{t('events.branding.fontSizeMedium', 'M')}</option>
<option value="l">{t('events.branding.fontSizeLarge', 'L')}</option>
</MobileSelect>
</MobileField>
</YStack>
</MobileCard>
<MobileCard gap="$3">
<Text fontSize="$md" fontWeight="800" color={text}>
{t('events.branding.colors', 'Colors')}
</Text>
<YStack gap="$3">
<ColorField
label={t('events.branding.primary', 'Primary Color')}
value={brandingForm.primary}
onChange={(value) => setBrandingForm((prev) => ({ ...prev, primary: value }))}
disabled={brandingDisabled}
/>
<ColorField
label={t('events.branding.accent', 'Accent Color')}
value={brandingForm.accent}
onChange={(value) => setBrandingForm((prev) => ({ ...prev, accent: value }))}
disabled={brandingDisabled}
/>
<ColorField
label={t('events.branding.backgroundColor', 'Background Color')}
value={brandingForm.background}
onChange={(value) => setBrandingForm((prev) => ({ ...prev, background: value }))}
disabled={brandingDisabled}
/>
<ColorField
label={t('events.branding.surfaceColor', 'Surface Color')}
value={brandingForm.surface}
onChange={(value) => setBrandingForm((prev) => ({ ...prev, surface: value }))}
disabled={brandingDisabled}
/>
</YStack>
</MobileCard>
<MobileCard gap="$3">
<Text fontSize="$md" fontWeight="800" color={text}>
{t('events.branding.fonts', 'Fonts')}
</Text>
<YStack gap="$3">
<MobileField label={t('events.branding.headingFont', 'Headline Font')}>
<MobileInput
value={brandingForm.headingFont}
onChange={(event) => setBrandingForm((prev) => ({ ...prev, headingFont: event.target.value }))}
placeholder={t('events.branding.headingFontPlaceholder', 'Playfair Display')}
hasError={false}
disabled={brandingDisabled}
/>
</MobileField>
<MobileField label={t('events.branding.bodyFont', 'Body Font')}>
<MobileInput
value={brandingForm.bodyFont}
onChange={(event) => setBrandingForm((prev) => ({ ...prev, bodyFont: event.target.value }))}
placeholder={t('events.branding.bodyFontPlaceholder', 'Montserrat')}
hasError={false}
disabled={brandingDisabled}
/>
</MobileField>
</YStack>
</MobileCard>
<CTAButton
label={brandingSaving ? t('profile.branding.saving', 'Saving...') : t('profile.branding.save', 'Save defaults')}
onPress={handleBrandingSave}
disabled={brandingDisabled}
loading={brandingSaving}
/>
</>
) : ( ) : (
<> accountContent
{error ? (
<MobileCard>
<Text fontWeight="700" color={danger}>
{error}
</Text>
</MobileCard>
) : null}
<MobileCard gap="$3">
<XStack alignItems="center" gap="$3">
<XStack
width={48}
height={48}
borderRadius={16}
alignItems="center"
justifyContent="center"
backgroundColor={accentSoft}
>
<User size={20} color={primary} />
</XStack>
<YStack gap="$1">
<Text fontSize="$md" fontWeight="800" color={text}>
{form.name || profile?.email || t('profile.title', 'Profil')}
</Text>
<Text fontSize="$sm" color={muted}>
{form.email || profile?.email || '—'}
</Text>
</YStack>
</XStack>
<XStack alignItems="center" gap="$2" flexWrap="wrap">
{profile?.email_verified ? (
<CheckCircle2 size={14} color={subtle} />
) : (
<MailWarning size={14} color={subtle} />
)}
<PillBadge tone={profile?.email_verified ? 'success' : 'warning'}>
{emailStatusLabel}
</PillBadge>
<Text fontSize="$xs" color={muted}>
{emailHint}
</Text>
</XStack>
</MobileCard>
<MobileCard gap="$3">
<XStack alignItems="center" gap="$2">
<User size={16} color={text} />
<Text fontSize="$md" fontWeight="800" color={text}>
{t('profile.sections.account.heading', 'Account-Informationen')}
</Text>
</XStack>
<Text fontSize="$sm" color={muted}>
{t('profile.sections.account.description', 'Passe Anzeigename, E-Mail-Adresse und Sprache der Admin-Oberfläche an.')}
</Text>
{loading ? (
<Text fontSize="$sm" color={muted}>
{t('profile.loading', 'Lädt ...')}
</Text>
) : (
<YStack gap="$3">
<MobileField label={t('profile.fields.name', 'Anzeigename')}>
<MobileInput
value={form.name}
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
placeholder={t('profile.placeholders.name', 'z. B. Hochzeitsplanung Schmidt')}
hasError={false}
/>
</MobileField>
<MobileField label={t('profile.fields.email', 'E-Mail-Adresse')}>
<MobileInput
value={form.email}
onChange={(event) => setForm((prev) => ({ ...prev, email: event.target.value }))}
placeholder="mail@beispiel.de"
type="email"
hasError={false}
/>
</MobileField>
<MobileField label={t('profile.fields.locale', 'Bevorzugte Sprache')}>
<MobileSelect
value={form.preferredLocale}
onChange={(event) => setForm((prev) => ({ ...prev, preferredLocale: event.target.value }))}
>
{LOCALE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label ?? t(option.labelKey, option.fallback)}
</option>
))}
</MobileSelect>
</MobileField>
<CTAButton
label={t('profile.actions.save', 'Speichern')}
onPress={handleAccountSave}
disabled={savingAccount || loading}
loading={savingAccount}
/>
</YStack>
)}
</MobileCard>
<MobileCard gap="$3">
<XStack alignItems="center" gap="$2">
<Lock size={16} color={text} />
<Text fontSize="$md" fontWeight="800" color={text}>
{t('profile.sections.password.heading', 'Passwort ändern')}
</Text>
</XStack>
<Text fontSize="$sm" color={muted}>
{t('profile.sections.password.description', 'Wähle ein sicheres Passwort, um deinen Admin-Zugang zu schützen.')}
</Text>
<YStack gap="$3">
<MobileField label={t('profile.fields.currentPassword', 'Aktuelles Passwort')}>
<MobileInput
value={form.currentPassword}
onChange={(event) => setForm((prev) => ({ ...prev, currentPassword: event.target.value }))}
placeholder="••••••••"
type="password"
hasError={false}
/>
</MobileField>
<MobileField
label={t('profile.fields.newPassword', 'Neues Passwort')}
hint={t('profile.sections.password.hint', 'Nutze mindestens 8 Zeichen und kombiniere Buchstaben sowie Zahlen für mehr Sicherheit.')}
>
<MobileInput
value={form.password}
onChange={(event) => setForm((prev) => ({ ...prev, password: event.target.value }))}
placeholder="••••••••"
type="password"
hasError={false}
/>
</MobileField>
<MobileField label={t('profile.fields.passwordConfirmation', 'Passwort bestätigen')}>
<MobileInput
value={form.passwordConfirmation}
onChange={(event) => setForm((prev) => ({ ...prev, passwordConfirmation: event.target.value }))}
placeholder="••••••••"
type="password"
hasError={false}
/>
</MobileField>
<CTAButton
label={t('profile.actions.updatePassword', 'Passwort aktualisieren')}
onPress={handlePasswordSave}
disabled={!passwordReady || savingPassword || loading}
loading={savingPassword}
tone="ghost"
/>
</YStack>
</MobileCard>
</>
)} )}
</MobileShell> </MobileShell>
); );
} }
function TabButton({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
const { primary, surfaceMuted, border, surface, text } = useAdminTheme();
return (
<Pressable onPress={onPress} style={{ flex: 1 }}>
<XStack
alignItems="center"
justifyContent="center"
paddingVertical="$2.5"
borderRadius={12}
backgroundColor={active ? primary : surfaceMuted}
borderWidth={1}
borderColor={active ? primary : border}
>
<Text fontSize="$sm" color={active ? surface : text} fontWeight="700">
{label}
</Text>
</XStack>
</Pressable>
);
}
function ColorField({ function ColorField({
label, label,
value, value,

View File

@@ -50,6 +50,7 @@ vi.mock('../components/FormControls', () => ({
MobileColorInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />, MobileColorInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
MobileFileInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />, MobileFileInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
MobileInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />, MobileInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
MobileTextArea: (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => <textarea {...props} />,
MobileSelect: ({ children, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) => ( MobileSelect: ({ children, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) => (
<select {...props}>{children}</select> <select {...props}>{children}</select>
), ),
@@ -111,16 +112,53 @@ vi.mock('@tamagui/react-native-web-lite', () => ({
), ),
})); }));
vi.mock('tamagui', () => ({ vi.mock('tamagui', () => {
Slider: Object.assign( const TabsValueContext = React.createContext<string | null>(null);
({ children }: { children: React.ReactNode }) => <div>{children}</div>, const TabsSetterContext = React.createContext<((value: string) => void) | null>(null);
{ const TabsRoot = ({
Track: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, children,
TrackActive: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>, value,
Thumb: () => <div />, onValueChange,
} }: {
), children: React.ReactNode;
})); value?: string;
onValueChange?: (value: string) => void;
}) => (
<TabsValueContext.Provider value={value ?? null}>
<TabsSetterContext.Provider value={onValueChange ?? null}>{children}</TabsSetterContext.Provider>
</TabsValueContext.Provider>
);
const Tabs = Object.assign(TabsRoot, {
List: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Tab: ({ children, value }: { children: React.ReactNode; value: string }) => {
const setValue = React.useContext(TabsSetterContext);
return (
<button type="button" onClick={() => setValue?.(value)}>
{children}
</button>
);
},
Content: ({ children, value }: { children: React.ReactNode; value: string }) => {
const active = React.useContext(TabsValueContext);
if (active !== value) {
return null;
}
return <div>{children}</div>;
},
});
return {
Slider: Object.assign(
({ children }: { children: React.ReactNode }) => <div>{children}</div>,
{
Track: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
TrackActive: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
Thumb: () => <div />,
}
),
Tabs,
};
});
const getEventMock = vi.fn(); const getEventMock = vi.fn();
const updateEventMock = vi.fn(); const updateEventMock = vi.fn();

View File

@@ -113,6 +113,14 @@ vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>, SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
})); }));
vi.mock('tamagui', () => ({
Tabs: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
List: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Tab: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>,
Content: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}),
}));
vi.mock('@tamagui/react-native-web-lite', () => ({ vi.mock('@tamagui/react-native-web-lite', () => ({
Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => ( Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => (
<button type="button" onClick={onPress}> <button type="button" onClick={onPress}>

View File

@@ -111,7 +111,11 @@ vi.mock('@tamagui/scroll-view', () => ({
vi.mock('tamagui', () => ({ vi.mock('tamagui', () => ({
Tabs: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, { Tabs: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
List: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, List: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Tab: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>, Tab: ({ children }: { children: React.ReactNode }) => (
<button type="button" role="tab">
{children}
</button>
),
Content: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, Content: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}), }),
})); }));
@@ -278,10 +282,10 @@ describe('MobileEventTasksPage', () => {
render(<MobileEventTasksPage />); render(<MobileEventTasksPage />);
expect(await screen.findByText('Photo tasks for guests')).toBeInTheDocument(); expect(await screen.findByText('Photo tasks for guests')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Tasks' })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: 'Tasks' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Task Library' })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: 'Task Library' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Emotions' })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: 'Emotions' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Collections' })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: 'Collections' })).toBeInTheDocument();
expect(screen.getByPlaceholderText('Search photo tasks')).toBeInTheDocument(); expect(screen.getByPlaceholderText('Search photo tasks')).toBeInTheDocument();
expect(screen.getByText('Emotion')).toBeInTheDocument(); expect(screen.getByText('Emotion')).toBeInTheDocument();

View File

@@ -82,6 +82,12 @@ vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>, SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
})); }));
vi.mock('@tamagui/toggle-group', () => ({
ToggleGroup: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
Item: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>,
}),
}));
vi.mock('../components/MobileShell', () => ({ vi.mock('../components/MobileShell', () => ({
MobileShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, MobileShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
HeaderActionButton: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, HeaderActionButton: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,

View File

@@ -63,6 +63,14 @@ vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>, SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
})); }));
vi.mock('tamagui', () => ({
Tabs: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
List: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Tab: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>,
Content: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}),
}));
vi.mock('@tamagui/react-native-web-lite', () => ({ vi.mock('@tamagui/react-native-web-lite', () => ({
Pressable: ({ children, ...props }: { children: React.ReactNode } & React.ButtonHTMLAttributes<HTMLButtonElement>) => ( Pressable: ({ children, ...props }: { children: React.ReactNode } & React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button type="button" {...props}> <button type="button" {...props}>

View File

@@ -434,8 +434,6 @@ export function ContentTabs({
tabs: { value: string; label: string; content: React.ReactNode }[]; tabs: { value: string; label: string; content: React.ReactNode }[];
header?: React.ReactNode; header?: React.ReactNode;
}) { }) {
const { border, muted, primary } = useAdminTheme();
return ( return (
<Tabs <Tabs
defaultValue={value} defaultValue={value}
@@ -445,27 +443,21 @@ export function ContentTabs({
flexDirection="column" flexDirection="column"
borderRadius="$4" borderRadius="$4"
borderWidth={1} borderWidth={1}
borderColor={border} borderColor="$borderColor"
overflow="hidden" overflow="hidden"
> >
<Tabs.List separator={<Separator vertical />} disablePassBorderRadius="bottom" backgroundColor="$surface"> <Tabs.List separator={<Separator vertical />} disablePassBorderRadius="bottom">
{tabs.map((tab) => ( {tabs.map((tab) => (
<Tabs.Tab <Tabs.Tab
key={tab.value} key={tab.value}
value={tab.value} value={tab.value}
flex={1} flex={1}
unstyled paddingVertical="$2.5"
paddingVertical="$3" paddingHorizontal="$3"
alignItems="center" alignItems="center"
justifyContent="center" justifyContent="center"
backgroundColor={value === tab.value ? primary : 'transparent'}
hoverStyle={{ backgroundColor: value === tab.value ? primary : '$backgroundHover' }}
> >
<Text <Text fontSize="$sm" fontWeight="600">
fontSize="$sm"
fontWeight={value === tab.value ? '700' : '500'}
color={value === tab.value ? '#fff' : muted}
>
{tab.label} {tab.label}
</Text> </Text>
</Tabs.Tab> </Tabs.Tab>