Adopt Tamagui defaults for tabs and filters
This commit is contained in:
@@ -6,7 +6,7 @@ import { Image as ImageIcon, RefreshCcw, UploadCloud, Trash2, ChevronDown, Save,
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { Slider } from 'tamagui';
|
||||
import { Slider, Tabs } from 'tamagui';
|
||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives';
|
||||
import { MobileColorInput, MobileField, MobileFileInput, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
|
||||
@@ -637,15 +637,28 @@ export default function MobileBrandingPage() {
|
||||
<ContextHelpLink slug="event-branding-assets" />
|
||||
</XStack>
|
||||
|
||||
<MobileCard gap="$2">
|
||||
<XStack gap="$2">
|
||||
<TabButton label={t('events.branding.titleShort', 'Branding')} active={activeTab === 'branding'} onPress={() => setActiveTab('branding')} />
|
||||
<TabButton label={t('events.watermark.tab', 'Wasserzeichen')} active={activeTab === 'watermark'} onPress={() => setActiveTab('watermark')} />
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as TabKey)}
|
||||
orientation="horizontal"
|
||||
flexDirection="column"
|
||||
>
|
||||
<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">
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{t('events.branding.previewTitle', 'Guest App Preview')}
|
||||
@@ -1216,12 +1229,14 @@ export default function MobileBrandingPage() {
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, linkColor: value }))}
|
||||
disabled={brandingDisabled}
|
||||
/>
|
||||
</MobileCard>
|
||||
</MobileCard>
|
||||
</>
|
||||
</>
|
||||
) : (
|
||||
renderWatermarkTab()
|
||||
)}
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="watermark" paddingTop="$2">
|
||||
{renderWatermarkTab()}
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
|
||||
<YStack gap="$2">
|
||||
<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({
|
||||
label,
|
||||
active,
|
||||
|
||||
@@ -343,7 +343,6 @@ export default function MobileEventControlRoomPage() {
|
||||
const liveResetRef = React.useRef(false);
|
||||
const [fallbackAttempted, setFallbackAttempted] = React.useState(false);
|
||||
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
|
||||
const activeFilterBg = primary;
|
||||
|
||||
const saveControlRoomSettings = React.useCallback(
|
||||
async (nextSettings: ControlRoomSettings) => {
|
||||
@@ -1376,60 +1375,52 @@ export default function MobileEventControlRoomPage() {
|
||||
{t('mobilePhotos.filtersTitle', 'Filter')}
|
||||
</Text>
|
||||
<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"
|
||||
padding="$1"
|
||||
borderRadius={999}
|
||||
gap="$1.5"
|
||||
borderRadius="$pill"
|
||||
borderWidth={1}
|
||||
borderColor={border}
|
||||
backgroundColor={surfaceMuted}
|
||||
overflow="hidden"
|
||||
>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={moderationFilter}
|
||||
onValueChange={(value: string) => value && setModerationFilter(value as ModerationFilter)}
|
||||
>
|
||||
<XStack gap="$1.5">
|
||||
{MODERATION_FILTERS.map((option) => {
|
||||
const active = option.value === moderationFilter;
|
||||
const count = moderationCounts[option.value] ?? 0;
|
||||
return (
|
||||
<ToggleGroup.Item
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
unstyled
|
||||
borderRadius={999}
|
||||
{MODERATION_FILTERS.map((option) => {
|
||||
const count = moderationCounts[option.value] ?? 0;
|
||||
return (
|
||||
<ToggleGroup.Item
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
borderRadius="$pill"
|
||||
paddingVertical="$1.5"
|
||||
paddingHorizontal="$3"
|
||||
>
|
||||
<XStack alignItems="center" gap="$1.5">
|
||||
<Text fontSize="$xs" fontWeight="600">
|
||||
{t(option.labelKey, option.fallback)}
|
||||
</Text>
|
||||
<XStack
|
||||
paddingHorizontal="$1.5"
|
||||
paddingVertical="$0.5"
|
||||
borderRadius="$pill"
|
||||
borderWidth={1}
|
||||
borderColor="transparent"
|
||||
backgroundColor={active ? activeFilterBg : 'transparent'}
|
||||
paddingVertical="$1.5"
|
||||
paddingHorizontal="$3"
|
||||
pressStyle={{ opacity: 0.85 }}
|
||||
borderColor={border}
|
||||
backgroundColor="$backgroundStrong"
|
||||
>
|
||||
<XStack alignItems="center" gap="$1.5">
|
||||
<Text fontSize="$xs" fontWeight="700" color={active ? '#fff' : muted}>
|
||||
{t(option.labelKey, option.fallback)}
|
||||
</Text>
|
||||
<XStack
|
||||
paddingHorizontal="$1.5"
|
||||
paddingVertical="$0.5"
|
||||
borderRadius={999}
|
||||
borderWidth={1}
|
||||
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>
|
||||
<Text fontSize={10} fontWeight="700">
|
||||
{count}
|
||||
</Text>
|
||||
</XStack>
|
||||
</XStack>
|
||||
</ToggleGroup.Item>
|
||||
);
|
||||
})}
|
||||
</ToggleGroup>
|
||||
</ScrollView>
|
||||
</YStack>
|
||||
</MobileCard>
|
||||
@@ -1564,60 +1555,52 @@ export default function MobileEventControlRoomPage() {
|
||||
{t('liveShowQueue.filterLabel', 'Live status')}
|
||||
</Text>
|
||||
<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"
|
||||
padding="$1"
|
||||
borderRadius={999}
|
||||
gap="$1.5"
|
||||
borderRadius="$pill"
|
||||
borderWidth={1}
|
||||
borderColor={border}
|
||||
backgroundColor={surfaceMuted}
|
||||
overflow="hidden"
|
||||
>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={liveStatusFilter}
|
||||
onValueChange={(value: string) => value && setLiveStatusFilter(value as LiveShowQueueStatus)}
|
||||
>
|
||||
<XStack gap="$1.5">
|
||||
{LIVE_STATUS_OPTIONS.map((option) => {
|
||||
const active = option.value === liveStatusFilter;
|
||||
const count = liveCounts[option.value] ?? 0;
|
||||
return (
|
||||
<ToggleGroup.Item
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
unstyled
|
||||
borderRadius={999}
|
||||
{LIVE_STATUS_OPTIONS.map((option) => {
|
||||
const count = liveCounts[option.value] ?? 0;
|
||||
return (
|
||||
<ToggleGroup.Item
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
borderRadius="$pill"
|
||||
paddingVertical="$1.5"
|
||||
paddingHorizontal="$3"
|
||||
>
|
||||
<XStack alignItems="center" gap="$1.5">
|
||||
<Text fontSize="$xs" fontWeight="600">
|
||||
{t(option.labelKey, option.fallback)}
|
||||
</Text>
|
||||
<XStack
|
||||
paddingHorizontal="$1.5"
|
||||
paddingVertical="$0.5"
|
||||
borderRadius="$pill"
|
||||
borderWidth={1}
|
||||
borderColor="transparent"
|
||||
backgroundColor={active ? activeFilterBg : 'transparent'}
|
||||
paddingVertical="$1.5"
|
||||
paddingHorizontal="$3"
|
||||
pressStyle={{ opacity: 0.85 }}
|
||||
borderColor={border}
|
||||
backgroundColor="$backgroundStrong"
|
||||
>
|
||||
<XStack alignItems="center" gap="$1.5">
|
||||
<Text fontSize="$xs" fontWeight="700" color={active ? '#fff' : muted}>
|
||||
{t(option.labelKey, option.fallback)}
|
||||
</Text>
|
||||
<XStack
|
||||
paddingHorizontal="$1.5"
|
||||
paddingVertical="$0.5"
|
||||
borderRadius={999}
|
||||
borderWidth={1}
|
||||
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>
|
||||
<Text fontSize={10} fontWeight="700">
|
||||
{count}
|
||||
</Text>
|
||||
</XStack>
|
||||
</XStack>
|
||||
</ToggleGroup.Item>
|
||||
);
|
||||
})}
|
||||
</ToggleGroup>
|
||||
</ScrollView>
|
||||
</YStack>
|
||||
</MobileCard>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Trash2, Copy, RefreshCcw } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { ToggleGroup } from '@tamagui/toggle-group';
|
||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
|
||||
import { MobileField, MobileInput, MobileSelect } from './components/FormControls';
|
||||
@@ -227,51 +228,67 @@ export default function MobileEventMembersPage() {
|
||||
<Text fontSize="$xs" fontWeight="700" color={muted}>
|
||||
{t('events.members.filters.statusLabel', 'Status')}
|
||||
</Text>
|
||||
<XStack gap="$2" flexWrap="wrap">
|
||||
{statusOptions.map((option) => {
|
||||
const isActive = statusFilter === option.key;
|
||||
return (
|
||||
<Pressable key={option.key} onPress={() => setStatusFilter(option.key)}>
|
||||
<XStack
|
||||
paddingHorizontal="$3"
|
||||
paddingVertical="$1.5"
|
||||
borderRadius={999}
|
||||
borderWidth={1}
|
||||
borderColor={isActive ? primary : border}
|
||||
backgroundColor={isActive ? primary : 'transparent'}
|
||||
>
|
||||
<Text fontSize="$xs" fontWeight="700" color={isActive ? 'white' : text}>
|
||||
{option.label}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={statusFilter}
|
||||
onValueChange={(value: string) => value && setStatusFilter(value as typeof statusFilter)}
|
||||
disableDeactivation
|
||||
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"
|
||||
paddingVertical="$1.5"
|
||||
>
|
||||
<Text fontSize="$xs" fontWeight="600">
|
||||
{option.label}
|
||||
</Text>
|
||||
</ToggleGroup.Item>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
<Text fontSize="$xs" fontWeight="700" color={muted}>
|
||||
{t('events.members.filters.roleLabel', 'Role')}
|
||||
</Text>
|
||||
<XStack gap="$2" flexWrap="wrap">
|
||||
{roleOptions.map((option) => {
|
||||
const isActive = roleFilter === option.key;
|
||||
return (
|
||||
<Pressable key={option.key} onPress={() => setRoleFilter(option.key)}>
|
||||
<XStack
|
||||
paddingHorizontal="$3"
|
||||
paddingVertical="$1.5"
|
||||
borderRadius={999}
|
||||
borderWidth={1}
|
||||
borderColor={isActive ? primary : border}
|
||||
backgroundColor={isActive ? primary : 'transparent'}
|
||||
>
|
||||
<Text fontSize="$xs" fontWeight="700" color={isActive ? 'white' : text}>
|
||||
{option.label}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={roleFilter}
|
||||
onValueChange={(value: string) => value && setRoleFilter(value as typeof roleFilter)}
|
||||
disableDeactivation
|
||||
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"
|
||||
paddingVertical="$1.5"
|
||||
>
|
||||
<Text fontSize="$xs" fontWeight="600">
|
||||
{option.label}
|
||||
</Text>
|
||||
</ToggleGroup.Item>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Share2, Sparkles, Trophy, Users, TrendingUp, Heart, Image as ImageIcon } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { Switch } from '@tamagui/switch';
|
||||
import { Tabs } from 'tamagui';
|
||||
import { MobileShell } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
|
||||
import { LegalConsentSheet } from './components/LegalConsentSheet';
|
||||
@@ -208,25 +208,31 @@ export default function MobileEventRecapPage() {
|
||||
onBack={back}
|
||||
>
|
||||
<YStack gap="$4">
|
||||
<XStack gap="$2">
|
||||
<TabButton
|
||||
label={t('events.recap.tabs.overview', 'Overview')}
|
||||
active={activeTab === 'overview'}
|
||||
onPress={() => setActiveTab('overview')}
|
||||
/>
|
||||
<TabButton
|
||||
label={t('events.recap.tabs.engagement', 'Engagement')}
|
||||
active={activeTab === 'engagement'}
|
||||
onPress={() => setActiveTab('engagement')}
|
||||
/>
|
||||
<TabButton
|
||||
label={t('events.recap.tabs.compliance', 'Compliance')}
|
||||
active={activeTab === 'compliance'}
|
||||
onPress={() => setActiveTab('compliance')}
|
||||
/>
|
||||
</XStack>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as typeof activeTab)}
|
||||
orientation="horizontal"
|
||||
flexDirection="column"
|
||||
>
|
||||
<Tabs.List gap="$2">
|
||||
<Tabs.Tab value="overview" flex={1} paddingVertical="$2.5" paddingHorizontal="$3" borderRadius="$4">
|
||||
<Text fontSize="$sm" fontWeight="600" textAlign="center">
|
||||
{t('events.recap.tabs.overview', 'Overview')}
|
||||
</Text>
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="engagement" flex={1} paddingVertical="$2.5" paddingHorizontal="$3" borderRadius="$4">
|
||||
<Text fontSize="$sm" fontWeight="600" textAlign="center">
|
||||
{t('events.recap.tabs.engagement', 'Engagement')}
|
||||
</Text>
|
||||
</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">
|
||||
<MobileCard gap="$3">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
@@ -362,9 +368,9 @@ export default function MobileEventRecapPage() {
|
||||
</YStack>
|
||||
</MobileCard>
|
||||
</YStack>
|
||||
) : null}
|
||||
</Tabs.Content>
|
||||
|
||||
{activeTab === 'engagement' ? (
|
||||
<Tabs.Content value="engagement" paddingTop="$2">
|
||||
<YStack gap="$4">
|
||||
{engagementLoading ? (
|
||||
<YStack gap="$2">
|
||||
@@ -550,13 +556,14 @@ export default function MobileEventRecapPage() {
|
||||
</YStack>
|
||||
)}
|
||||
</YStack>
|
||||
) : null}
|
||||
</Tabs.Content>
|
||||
|
||||
{activeTab === 'compliance' ? (
|
||||
<YStack gap="$4">
|
||||
<DataExportsPanel variant="recap" event={event} />
|
||||
</YStack>
|
||||
) : null}
|
||||
<Tabs.Content value="compliance" paddingTop="$2">
|
||||
<YStack gap="$4">
|
||||
<DataExportsPanel variant="recap" event={event} />
|
||||
</YStack>
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</YStack>
|
||||
|
||||
<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 }) {
|
||||
const { textStrong, muted, border, surfaceMuted } = useAdminTheme();
|
||||
return (
|
||||
|
||||
@@ -1170,7 +1170,6 @@ export default function MobileEventTasksPage() {
|
||||
borderRadius={16}
|
||||
borderWidth={1}
|
||||
borderColor={border}
|
||||
backgroundColor={surfaceMuted}
|
||||
overflow="hidden"
|
||||
gap="$0"
|
||||
>
|
||||
@@ -1186,16 +1185,13 @@ export default function MobileEventTasksPage() {
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
flex={1}
|
||||
unstyled
|
||||
paddingVertical="$2.5"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor={isActive ? primary : 'transparent'}
|
||||
borderRightWidth={index === arr.length - 1 ? 0 : 1}
|
||||
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}
|
||||
</Text>
|
||||
</Tabs.Tab>
|
||||
|
||||
@@ -235,9 +235,7 @@ function EventsList({
|
||||
onEdit?: (slug: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const { text, muted, subtle, border, primary, surface, surfaceMuted, accentSoft, accent, shadow } = useAdminTheme();
|
||||
const activeBg = accentSoft;
|
||||
const activeBorder = accent;
|
||||
const { text, muted, subtle, border, primary, surface, surfaceMuted, shadow } = useAdminTheme();
|
||||
|
||||
const statusCounts = React.useMemo(() => buildEventStatusCounts(events), [events]);
|
||||
const filteredByStatus = React.useMemo(
|
||||
@@ -313,56 +311,49 @@ function EventsList({
|
||||
</Text>
|
||||
</XStack>
|
||||
<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"
|
||||
padding="$1"
|
||||
borderRadius={999}
|
||||
gap="$1.5"
|
||||
borderRadius="$pill"
|
||||
borderWidth={1}
|
||||
borderColor={border}
|
||||
backgroundColor={surfaceMuted}
|
||||
>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={statusFilter}
|
||||
onValueChange={(value: string) => value && onStatusChange(value as EventStatusKey)}
|
||||
>
|
||||
<XStack gap="$1.5">
|
||||
{filters.map((filter) => {
|
||||
const active = filter.key === statusFilter;
|
||||
return (
|
||||
<ToggleGroup.Item
|
||||
key={filter.key}
|
||||
value={filter.key}
|
||||
borderRadius={999}
|
||||
borderWidth={1}
|
||||
borderColor={active ? activeBorder : 'transparent'}
|
||||
backgroundColor={active ? activeBg : 'transparent'}
|
||||
paddingVertical="$1.5"
|
||||
paddingHorizontal="$3"
|
||||
>
|
||||
<XStack alignItems="center" gap="$1.5">
|
||||
<Text fontSize="$xs" fontWeight="700" color={active ? primary : muted}>
|
||||
{filter.label}
|
||||
</Text>
|
||||
<XStack
|
||||
paddingHorizontal="$1.5"
|
||||
paddingVertical="$0.5"
|
||||
borderRadius={999}
|
||||
borderWidth={1}
|
||||
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>
|
||||
{filters.map((filter) => (
|
||||
<ToggleGroup.Item
|
||||
key={filter.key}
|
||||
value={filter.key}
|
||||
borderRadius="$pill"
|
||||
paddingVertical="$1.5"
|
||||
paddingHorizontal="$3"
|
||||
>
|
||||
<XStack alignItems="center" gap="$1.5">
|
||||
<Text fontSize="$xs" fontWeight="600">
|
||||
{filter.label}
|
||||
</Text>
|
||||
<XStack
|
||||
paddingHorizontal="$1.5"
|
||||
paddingVertical="$0.5"
|
||||
borderRadius="$pill"
|
||||
borderWidth={1}
|
||||
borderColor={border}
|
||||
backgroundColor="$backgroundStrong"
|
||||
>
|
||||
<Text fontSize={10} fontWeight="700">
|
||||
{filter.count}
|
||||
</Text>
|
||||
</XStack>
|
||||
</XStack>
|
||||
</ToggleGroup.Item>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</ScrollView>
|
||||
</YStack>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Bell, Check, ChevronRight, RefreshCcw } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { ToggleGroup } from '@tamagui/toggle-group';
|
||||
import { motion, useAnimationControls, type PanInfo } from 'framer-motion';
|
||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||
import { MobileCard, PillBadge, SkeletonCard, CTAButton } from './components/Primitives';
|
||||
@@ -326,7 +327,7 @@ export default function MobileNotificationsPage() {
|
||||
const [events, setEvents] = React.useState<TenantEvent[]>([]);
|
||||
const [showEventPicker, setShowEventPicker] = React.useState(false);
|
||||
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 infoIcon = primary;
|
||||
const errorText = danger;
|
||||
@@ -552,7 +553,22 @@ export default function MobileNotificationsPage() {
|
||||
) : null}
|
||||
</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: 'photos', label: t('notificationLogs.scope.photos', 'Photos') },
|
||||
@@ -561,28 +577,22 @@ export default function MobileNotificationsPage() {
|
||||
{ key: 'events', label: t('notificationLogs.scope.events', 'Events') },
|
||||
{ key: 'package', label: t('notificationLogs.scope.package', 'Package') },
|
||||
{ key: 'general', label: t('notificationLogs.scope.general', 'General') },
|
||||
] as Array<{ key: NotificationScope | 'all'; label: string }>).map((filter) => {
|
||||
const active = scopeParam === filter.key;
|
||||
return (
|
||||
<Pressable key={filter.key} onPress={() => updateFilters({ scope: filter.key })} style={{ flexGrow: 1 }}>
|
||||
<XStack
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
borderRadius={14}
|
||||
borderWidth={1}
|
||||
borderColor={active ? primary : border}
|
||||
backgroundColor={active ? accentSoft : 'transparent'}
|
||||
>
|
||||
<Text fontSize="$xs" fontWeight="700" color={active ? primary : muted}>
|
||||
{filter.label}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
] as Array<{ key: NotificationScope | 'all'; label: string }>).map((filter) => (
|
||||
<ToggleGroup.Item
|
||||
key={filter.key}
|
||||
value={filter.key}
|
||||
borderRadius="$pill"
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
flexGrow={1}
|
||||
justifyContent="center"
|
||||
>
|
||||
<Text fontSize="$xs" fontWeight="600" textAlign="center">
|
||||
{filter.label}
|
||||
</Text>
|
||||
</ToggleGroup.Item>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
|
||||
{loading ? (
|
||||
<YStack gap="$2">
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { CheckCircle2, Lock, MailWarning, User } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { Tabs } from 'tamagui';
|
||||
import toast from 'react-hot-toast';
|
||||
import { MobileShell } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
||||
@@ -300,6 +300,275 @@ export default function MobileProfileAccountPage() {
|
||||
form.password.trim().length > 0 &&
|
||||
form.passwordConfirmation.trim().length > 0;
|
||||
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 (
|
||||
<MobileShell
|
||||
@@ -308,312 +577,38 @@ export default function MobileProfileAccountPage() {
|
||||
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 ? (
|
||||
<MobileCard>
|
||||
<Text fontWeight="700" color={danger}>
|
||||
{brandingError}
|
||||
<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>
|
||||
</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}
|
||||
/>
|
||||
</>
|
||||
</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>
|
||||
) : (
|
||||
<>
|
||||
{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>
|
||||
</>
|
||||
accountContent
|
||||
)}
|
||||
</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({
|
||||
label,
|
||||
value,
|
||||
|
||||
@@ -50,6 +50,7 @@ vi.mock('../components/FormControls', () => ({
|
||||
MobileColorInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
|
||||
MobileFileInput: (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>) => (
|
||||
<select {...props}>{children}</select>
|
||||
),
|
||||
@@ -111,16 +112,53 @@ vi.mock('@tamagui/react-native-web-lite', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('tamagui', () => ({
|
||||
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 />,
|
||||
}
|
||||
),
|
||||
}));
|
||||
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(
|
||||
({ 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 updateEventMock = vi.fn();
|
||||
|
||||
@@ -113,6 +113,14 @@ vi.mock('@tamagui/text', () => ({
|
||||
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', () => ({
|
||||
Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => (
|
||||
<button type="button" onClick={onPress}>
|
||||
|
||||
@@ -111,7 +111,11 @@ vi.mock('@tamagui/scroll-view', () => ({
|
||||
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>,
|
||||
Tab: ({ children }: { children: React.ReactNode }) => (
|
||||
<button type="button" role="tab">
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Content: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}),
|
||||
}));
|
||||
@@ -278,10 +282,10 @@ describe('MobileEventTasksPage', () => {
|
||||
render(<MobileEventTasksPage />);
|
||||
|
||||
expect(await screen.findByText('Photo tasks for guests')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Tasks' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Task Library' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Emotions' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Collections' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Tasks' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Task Library' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Emotions' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Collections' })).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Search photo tasks')).toBeInTheDocument();
|
||||
expect(screen.getByText('Emotion')).toBeInTheDocument();
|
||||
|
||||
|
||||
@@ -82,6 +82,12 @@ vi.mock('@tamagui/text', () => ({
|
||||
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', () => ({
|
||||
MobileShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
HeaderActionButton: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
|
||||
@@ -63,6 +63,14 @@ vi.mock('@tamagui/text', () => ({
|
||||
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', () => ({
|
||||
Pressable: ({ children, ...props }: { children: React.ReactNode } & React.ButtonHTMLAttributes<HTMLButtonElement>) => (
|
||||
<button type="button" {...props}>
|
||||
|
||||
@@ -434,8 +434,6 @@ export function ContentTabs({
|
||||
tabs: { value: string; label: string; content: React.ReactNode }[];
|
||||
header?: React.ReactNode;
|
||||
}) {
|
||||
const { border, muted, primary } = useAdminTheme();
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
defaultValue={value}
|
||||
@@ -445,27 +443,21 @@ export function ContentTabs({
|
||||
flexDirection="column"
|
||||
borderRadius="$4"
|
||||
borderWidth={1}
|
||||
borderColor={border}
|
||||
borderColor="$borderColor"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Tabs.List separator={<Separator vertical />} disablePassBorderRadius="bottom" backgroundColor="$surface">
|
||||
<Tabs.List separator={<Separator vertical />} disablePassBorderRadius="bottom">
|
||||
{tabs.map((tab) => (
|
||||
<Tabs.Tab
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
flex={1}
|
||||
unstyled
|
||||
paddingVertical="$3"
|
||||
paddingVertical="$2.5"
|
||||
paddingHorizontal="$3"
|
||||
alignItems="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}
|
||||
</Text>
|
||||
</Tabs.Tab>
|
||||
|
||||
Reference in New Issue
Block a user