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>
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as TabKey)}
orientation="horizontal"
flexDirection="column"
>
<MobileCard gap="$2"> <MobileCard gap="$2">
<XStack gap="$2"> <Tabs.List gap="$2">
<TabButton label={t('events.branding.titleShort', 'Branding')} active={activeTab === 'branding'} onPress={() => setActiveTab('branding')} /> <Tabs.Tab value="branding" flex={1} paddingVertical="$2.5" paddingHorizontal="$3" borderRadius="$4">
<TabButton label={t('events.watermark.tab', 'Wasserzeichen')} active={activeTab === 'watermark'} onPress={() => setActiveTab('watermark')} /> <Text fontSize="$sm" fontWeight="600" textAlign="center">
</XStack> {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> </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')}
@@ -1218,10 +1231,12 @@ export default function MobileBrandingPage() {
/> />
</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,50 +1375,44 @@ export default function MobileEventControlRoomPage() {
{t('mobilePhotos.filtersTitle', 'Filter')} {t('mobilePhotos.filtersTitle', 'Filter')}
</Text> </Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false}> <ScrollView horizontal showsHorizontalScrollIndicator={false}>
<XStack
alignItems="center"
padding="$1"
borderRadius={999}
borderWidth={1}
borderColor={border}
backgroundColor={surfaceMuted}
overflow="hidden"
>
<ToggleGroup <ToggleGroup
type="single" type="single"
value={moderationFilter} value={moderationFilter}
onValueChange={(value: string) => value && setModerationFilter(value as ModerationFilter)} onValueChange={(value: string) => value && setModerationFilter(value as ModerationFilter)}
disableDeactivation
orientation="horizontal"
flexDirection="row"
alignItems="center"
padding="$1"
gap="$1.5"
borderRadius="$pill"
borderWidth={1}
borderColor={border}
backgroundColor={surfaceMuted}
> >
<XStack gap="$1.5">
{MODERATION_FILTERS.map((option) => { {MODERATION_FILTERS.map((option) => {
const active = option.value === moderationFilter;
const count = moderationCounts[option.value] ?? 0; const count = moderationCounts[option.value] ?? 0;
return ( return (
<ToggleGroup.Item <ToggleGroup.Item
key={option.value} key={option.value}
value={option.value} value={option.value}
unstyled borderRadius="$pill"
borderRadius={999}
borderWidth={1}
borderColor="transparent"
backgroundColor={active ? activeFilterBg : 'transparent'}
paddingVertical="$1.5" paddingVertical="$1.5"
paddingHorizontal="$3" paddingHorizontal="$3"
pressStyle={{ opacity: 0.85 }}
> >
<XStack alignItems="center" gap="$1.5"> <XStack alignItems="center" gap="$1.5">
<Text fontSize="$xs" fontWeight="700" color={active ? '#fff' : muted}> <Text fontSize="$xs" fontWeight="600">
{t(option.labelKey, option.fallback)} {t(option.labelKey, option.fallback)}
</Text> </Text>
<XStack <XStack
paddingHorizontal="$1.5" paddingHorizontal="$1.5"
paddingVertical="$0.5" paddingVertical="$0.5"
borderRadius={999} borderRadius="$pill"
borderWidth={1} borderWidth={1}
borderColor={active ? 'transparent' : border} borderColor={border}
backgroundColor={active ? withAlpha('#ffffff', 0.2) : surface} backgroundColor="$backgroundStrong"
> >
<Text fontSize={10} fontWeight="800" color={active ? '#fff' : muted}> <Text fontSize={10} fontWeight="700">
{count} {count}
</Text> </Text>
</XStack> </XStack>
@@ -1427,9 +1420,7 @@ export default function MobileEventControlRoomPage() {
</ToggleGroup.Item> </ToggleGroup.Item>
); );
})} })}
</XStack>
</ToggleGroup> </ToggleGroup>
</XStack>
</ScrollView> </ScrollView>
</YStack> </YStack>
</MobileCard> </MobileCard>
@@ -1564,50 +1555,44 @@ 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
alignItems="center"
padding="$1"
borderRadius={999}
borderWidth={1}
borderColor={border}
backgroundColor={surfaceMuted}
overflow="hidden"
>
<ToggleGroup <ToggleGroup
type="single" type="single"
value={liveStatusFilter} value={liveStatusFilter}
onValueChange={(value: string) => value && setLiveStatusFilter(value as LiveShowQueueStatus)} onValueChange={(value: string) => value && setLiveStatusFilter(value as LiveShowQueueStatus)}
disableDeactivation
orientation="horizontal"
flexDirection="row"
alignItems="center"
padding="$1"
gap="$1.5"
borderRadius="$pill"
borderWidth={1}
borderColor={border}
backgroundColor={surfaceMuted}
> >
<XStack gap="$1.5">
{LIVE_STATUS_OPTIONS.map((option) => { {LIVE_STATUS_OPTIONS.map((option) => {
const active = option.value === liveStatusFilter;
const count = liveCounts[option.value] ?? 0; const count = liveCounts[option.value] ?? 0;
return ( return (
<ToggleGroup.Item <ToggleGroup.Item
key={option.value} key={option.value}
value={option.value} value={option.value}
unstyled borderRadius="$pill"
borderRadius={999}
borderWidth={1}
borderColor="transparent"
backgroundColor={active ? activeFilterBg : 'transparent'}
paddingVertical="$1.5" paddingVertical="$1.5"
paddingHorizontal="$3" paddingHorizontal="$3"
pressStyle={{ opacity: 0.85 }}
> >
<XStack alignItems="center" gap="$1.5"> <XStack alignItems="center" gap="$1.5">
<Text fontSize="$xs" fontWeight="700" color={active ? '#fff' : muted}> <Text fontSize="$xs" fontWeight="600">
{t(option.labelKey, option.fallback)} {t(option.labelKey, option.fallback)}
</Text> </Text>
<XStack <XStack
paddingHorizontal="$1.5" paddingHorizontal="$1.5"
paddingVertical="$0.5" paddingVertical="$0.5"
borderRadius={999} borderRadius="$pill"
borderWidth={1} borderWidth={1}
borderColor={active ? 'transparent' : border} borderColor={border}
backgroundColor={active ? withAlpha('#ffffff', 0.2) : surface} backgroundColor="$backgroundStrong"
> >
<Text fontSize={10} fontWeight="800" color={active ? '#fff' : muted}> <Text fontSize={10} fontWeight="700">
{count} {count}
</Text> </Text>
</XStack> </XStack>
@@ -1615,9 +1600,7 @@ export default function MobileEventControlRoomPage() {
</ToggleGroup.Item> </ToggleGroup.Item>
); );
})} })}
</XStack>
</ToggleGroup> </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"
flexDirection="row"
flexWrap="wrap"
gap="$2"
padding="$1"
borderRadius="$pill"
borderWidth={1}
borderColor={border}
backgroundColor="$background"
>
{statusOptions.map((option) => (
<ToggleGroup.Item
key={option.key}
value={option.key}
borderRadius="$pill"
paddingHorizontal="$3" paddingHorizontal="$3"
paddingVertical="$1.5" paddingVertical="$1.5"
borderRadius={999}
borderWidth={1}
borderColor={isActive ? primary : border}
backgroundColor={isActive ? primary : 'transparent'}
> >
<Text fontSize="$xs" fontWeight="700" color={isActive ? 'white' : text}> <Text fontSize="$xs" fontWeight="600">
{option.label} {option.label}
</Text> </Text>
</XStack> </ToggleGroup.Item>
</Pressable> ))}
); </ToggleGroup>
})}
</XStack>
<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"
flexDirection="row"
flexWrap="wrap"
gap="$2"
padding="$1"
borderRadius="$pill"
borderWidth={1}
borderColor={border}
backgroundColor="$background"
>
{roleOptions.map((option) => (
<ToggleGroup.Item
key={option.key}
value={option.key}
borderRadius="$pill"
paddingHorizontal="$3" paddingHorizontal="$3"
paddingVertical="$1.5" paddingVertical="$1.5"
borderRadius={999}
borderWidth={1}
borderColor={isActive ? primary : border}
backgroundColor={isActive ? primary : 'transparent'}
> >
<Text fontSize="$xs" fontWeight="700" color={isActive ? 'white' : text}> <Text fontSize="$xs" fontWeight="600">
{option.label} {option.label}
</Text> </Text>
</XStack> </ToggleGroup.Item>
</Pressable> ))}
); </ToggleGroup>
})}
</XStack>
</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
alignItems="center"
padding="$1"
borderRadius={999}
borderWidth={1}
borderColor={border}
backgroundColor={surfaceMuted}
>
<ToggleGroup <ToggleGroup
type="single" type="single"
value={statusFilter} value={statusFilter}
onValueChange={(value: string) => value && onStatusChange(value as EventStatusKey)} onValueChange={(value: string) => value && onStatusChange(value as EventStatusKey)}
disableDeactivation
orientation="horizontal"
flexDirection="row"
alignItems="center"
padding="$1"
gap="$1.5"
borderRadius="$pill"
borderWidth={1}
borderColor={border}
backgroundColor={surfaceMuted}
> >
<XStack gap="$1.5"> {filters.map((filter) => (
{filters.map((filter) => {
const active = filter.key === statusFilter;
return (
<ToggleGroup.Item <ToggleGroup.Item
key={filter.key} key={filter.key}
value={filter.key} value={filter.key}
borderRadius={999} borderRadius="$pill"
borderWidth={1}
borderColor={active ? activeBorder : 'transparent'}
backgroundColor={active ? activeBg : 'transparent'}
paddingVertical="$1.5" paddingVertical="$1.5"
paddingHorizontal="$3" paddingHorizontal="$3"
> >
<XStack alignItems="center" gap="$1.5"> <XStack alignItems="center" gap="$1.5">
<Text fontSize="$xs" fontWeight="700" color={active ? primary : muted}> <Text fontSize="$xs" fontWeight="600">
{filter.label} {filter.label}
</Text> </Text>
<XStack <XStack
paddingHorizontal="$1.5" paddingHorizontal="$1.5"
paddingVertical="$0.5" paddingVertical="$0.5"
borderRadius={999} borderRadius="$pill"
borderWidth={1} borderWidth={1}
borderColor={active ? activeBorder : border} borderColor={border}
backgroundColor={surface} backgroundColor="$backgroundStrong"
> >
<Text fontSize={10} fontWeight="800" color={active ? primary : muted}> <Text fontSize={10} fontWeight="700">
{filter.count} {filter.count}
</Text> </Text>
</XStack> </XStack>
</XStack> </XStack>
</ToggleGroup.Item> </ToggleGroup.Item>
); ))}
})}
</XStack>
</ToggleGroup> </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"
justifyContent="center"
paddingVertical="$2" paddingVertical="$2"
paddingHorizontal="$3" paddingHorizontal="$3"
borderRadius={14} flexGrow={1}
borderWidth={1} justifyContent="center"
borderColor={active ? primary : border}
backgroundColor={active ? accentSoft : 'transparent'}
> >
<Text fontSize="$xs" fontWeight="700" color={active ? primary : muted}> <Text fontSize="$xs" fontWeight="600" textAlign="center">
{filter.label} {filter.label}
</Text> </Text>
</XStack> </ToggleGroup.Item>
</Pressable> ))}
); </ToggleGroup>
})}
</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,29 +300,7 @@ 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 = (
return (
<MobileShell
activeTab="profile"
title={t('profile.title', 'Profil')}
onBack={back}
>
{brandingTabEnabled ? (
<XStack gap="$2">
<TabButton
label={t('profile.tabs.account', 'Account')}
active={activeTab === 'account'}
onPress={() => setActiveTab('account')}
/>
<TabButton
label={t('profile.tabs.branding', 'Standard-Branding')}
active={activeTab === 'branding'}
onPress={() => setActiveTab('branding')}
/>
</XStack>
) : null}
{activeTab === 'branding' && brandingTabEnabled ? (
<> <>
{brandingError ? ( {brandingError ? (
<MobileCard> <MobileCard>
@@ -436,7 +414,9 @@ export default function MobileProfileAccountPage() {
loading={brandingSaving} loading={brandingSaving}
/> />
</> </>
) : ( );
const accountContent = (
<> <>
{error ? ( {error ? (
<MobileCard> <MobileCard>
@@ -588,32 +568,47 @@ export default function MobileProfileAccountPage() {
</YStack> </YStack>
</MobileCard> </MobileCard>
</> </>
);
return (
<MobileShell
activeTab="profile"
title={t('profile.title', 'Profil')}
onBack={back}
>
{brandingTabEnabled ? (
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as TabKey)}
orientation="horizontal"
flexDirection="column"
>
<Tabs.List gap="$2">
<Tabs.Tab value="account" flex={1} paddingVertical="$2.5" paddingHorizontal="$3" borderRadius="$4">
<Text fontSize="$sm" fontWeight="600" textAlign="center">
{t('profile.tabs.account', 'Account')}
</Text>
</Tabs.Tab>
<Tabs.Tab value="branding" flex={1} paddingVertical="$2.5" paddingHorizontal="$3" borderRadius="$4">
<Text fontSize="$sm" fontWeight="600" textAlign="center">
{t('profile.tabs.branding', 'Standard-Branding')}
</Text>
</Tabs.Tab>
</Tabs.List>
<Tabs.Content value="account" paddingTop="$2">
{accountContent}
</Tabs.Content>
<Tabs.Content value="branding" paddingTop="$2">
{brandingContent}
</Tabs.Content>
</Tabs>
) : (
accountContent
)} )}
</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,7 +112,42 @@ vi.mock('@tamagui/react-native-web-lite', () => ({
), ),
})); }));
vi.mock('tamagui', () => ({ vi.mock('tamagui', () => {
const TabsValueContext = React.createContext<string | null>(null);
const TabsSetterContext = React.createContext<((value: string) => void) | null>(null);
const TabsRoot = ({
children,
value,
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( Slider: Object.assign(
({ children }: { children: React.ReactNode }) => <div>{children}</div>, ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
{ {
@@ -120,7 +156,9 @@ vi.mock('tamagui', () => ({
Thumb: () => <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
fontSize="$sm"
fontWeight={value === tab.value ? '700' : '500'}
color={value === tab.value ? '#fff' : muted}
> >
<Text fontSize="$sm" fontWeight="600">
{tab.label} {tab.label}
</Text> </Text>
</Tabs.Tab> </Tabs.Tab>