diff --git a/resources/js/admin/mobile/BrandingPage.tsx b/resources/js/admin/mobile/BrandingPage.tsx index d27c248a..7adae97b 100644 --- a/resources/js/admin/mobile/BrandingPage.tsx +++ b/resources/js/admin/mobile/BrandingPage.tsx @@ -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() { - - - setActiveTab('branding')} /> - setActiveTab('watermark')} /> - - + setActiveTab(value as TabKey)} + orientation="horizontal" + flexDirection="column" + > + + + + + {t('events.branding.titleShort', 'Branding')} + + + + + {t('events.watermark.tab', 'Wasserzeichen')} + + + + - {activeTab === 'branding' ? ( - <> + {t('events.branding.previewTitle', 'Guest App Preview')} @@ -1216,12 +1229,14 @@ export default function MobileBrandingPage() { onChange={(value) => setForm((prev) => ({ ...prev, linkColor: value }))} disabled={brandingDisabled} /> - + - - ) : ( - renderWatermarkTab() - )} + + + + {renderWatermarkTab()} + + 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 ( - - - - {label} - - - - ); -} - function ModeButton({ label, active, diff --git a/resources/js/admin/mobile/EventControlRoomPage.tsx b/resources/js/admin/mobile/EventControlRoomPage.tsx index 091e8325..2a76821c 100644 --- a/resources/js/admin/mobile/EventControlRoomPage.tsx +++ b/resources/js/admin/mobile/EventControlRoomPage.tsx @@ -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')} - 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" > - value && setModerationFilter(value as ModerationFilter)} - > - - {MODERATION_FILTERS.map((option) => { - const active = option.value === moderationFilter; - const count = moderationCounts[option.value] ?? 0; - return ( - { + const count = moderationCounts[option.value] ?? 0; + return ( + + + + {t(option.labelKey, option.fallback)} + + - - - {t(option.labelKey, option.fallback)} - - - - {count} - - - - - ); - })} - - - + + {count} + + + + + ); + })} + @@ -1564,60 +1555,52 @@ export default function MobileEventControlRoomPage() { {t('liveShowQueue.filterLabel', 'Live status')} - 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" > - value && setLiveStatusFilter(value as LiveShowQueueStatus)} - > - - {LIVE_STATUS_OPTIONS.map((option) => { - const active = option.value === liveStatusFilter; - const count = liveCounts[option.value] ?? 0; - return ( - { + const count = liveCounts[option.value] ?? 0; + return ( + + + + {t(option.labelKey, option.fallback)} + + - - - {t(option.labelKey, option.fallback)} - - - - {count} - - - - - ); - })} - - - + + {count} + + + + + ); + })} + diff --git a/resources/js/admin/mobile/EventMembersPage.tsx b/resources/js/admin/mobile/EventMembersPage.tsx index 4475a163..862c5608 100644 --- a/resources/js/admin/mobile/EventMembersPage.tsx +++ b/resources/js/admin/mobile/EventMembersPage.tsx @@ -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() { {t('events.members.filters.statusLabel', 'Status')} - - {statusOptions.map((option) => { - const isActive = statusFilter === option.key; - return ( - setStatusFilter(option.key)}> - - - {option.label} - - - - ); - })} - + 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) => ( + + + {option.label} + + + ))} + {t('events.members.filters.roleLabel', 'Role')} - - {roleOptions.map((option) => { - const isActive = roleFilter === option.key; - return ( - setRoleFilter(option.key)}> - - - {option.label} - - - - ); - })} - + 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) => ( + + + {option.label} + + + ))} + ) : null} diff --git a/resources/js/admin/mobile/EventRecapPage.tsx b/resources/js/admin/mobile/EventRecapPage.tsx index 8b0d6698..cf3c3124 100644 --- a/resources/js/admin/mobile/EventRecapPage.tsx +++ b/resources/js/admin/mobile/EventRecapPage.tsx @@ -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} > - - setActiveTab('overview')} - /> - setActiveTab('engagement')} - /> - setActiveTab('compliance')} - /> - + setActiveTab(value as typeof activeTab)} + orientation="horizontal" + flexDirection="column" + > + + + + {t('events.recap.tabs.overview', 'Overview')} + + + + + {t('events.recap.tabs.engagement', 'Engagement')} + + + + + {t('events.recap.tabs.compliance', 'Compliance')} + + + - {activeTab === 'overview' ? ( + @@ -362,9 +368,9 @@ export default function MobileEventRecapPage() { - ) : null} + - {activeTab === 'engagement' ? ( + {engagementLoading ? ( @@ -550,13 +556,14 @@ export default function MobileEventRecapPage() { )} - ) : null} + - {activeTab === 'compliance' ? ( - - - - ) : null} + + + + + + void }) { - const { primary, surfaceMuted, border, surface, textStrong } = useAdminTheme(); - return ( - - - - {label} - - - - ); -} - function LeaderboardRow({ rank, name, value }: { rank: number; name: string; value: string }) { const { textStrong, muted, border, surfaceMuted } = useAdminTheme(); return ( diff --git a/resources/js/admin/mobile/EventTasksPage.tsx b/resources/js/admin/mobile/EventTasksPage.tsx index c6b50a56..58c10dcf 100644 --- a/resources/js/admin/mobile/EventTasksPage.tsx +++ b/resources/js/admin/mobile/EventTasksPage.tsx @@ -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 }} > - + {tab.label} diff --git a/resources/js/admin/mobile/EventsPage.tsx b/resources/js/admin/mobile/EventsPage.tsx index c576145a..f039c121 100644 --- a/resources/js/admin/mobile/EventsPage.tsx +++ b/resources/js/admin/mobile/EventsPage.tsx @@ -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({ - 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} > - value && onStatusChange(value as EventStatusKey)} - > - - {filters.map((filter) => { - const active = filter.key === statusFilter; - return ( - - - - {filter.label} - - - - {filter.count} - - - - - ); - })} - - - + {filters.map((filter) => ( + + + + {filter.label} + + + + {filter.count} + + + + + ))} + diff --git a/resources/js/admin/mobile/NotificationsPage.tsx b/resources/js/admin/mobile/NotificationsPage.tsx index 2a38f026..c47a6931 100644 --- a/resources/js/admin/mobile/NotificationsPage.tsx +++ b/resources/js/admin/mobile/NotificationsPage.tsx @@ -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([]); 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} - + 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 ( - updateFilters({ scope: filter.key })} style={{ flexGrow: 1 }}> - - - {filter.label} - - - - ); - })} - + ] as Array<{ key: NotificationScope | 'all'; label: string }>).map((filter) => ( + + + {filter.label} + + + ))} + {loading ? ( diff --git a/resources/js/admin/mobile/ProfileAccountPage.tsx b/resources/js/admin/mobile/ProfileAccountPage.tsx index c4f99b01..253105d3 100644 --- a/resources/js/admin/mobile/ProfileAccountPage.tsx +++ b/resources/js/admin/mobile/ProfileAccountPage.tsx @@ -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 ? ( + + + {brandingError} + + + ) : null} + + + + {t('profile.branding.title', 'Standard-Branding')} + + + {t('profile.branding.hint', 'Diese Werte dienen als Vorschlag für neue Events und beschleunigen die Einrichtung.')} + + + + + + {t('profile.branding.theme', 'Theme')} + + + + setBrandingForm((prev) => ({ ...prev, mode: event.target.value as BrandingFormValues['mode'] }))} + disabled={brandingDisabled} + > + + + + + + + setBrandingForm((prev) => ({ ...prev, fontSize: event.target.value as BrandingFormValues['fontSize'] }))} + disabled={brandingDisabled} + > + + + + + + + + + + + {t('events.branding.colors', 'Colors')} + + + setBrandingForm((prev) => ({ ...prev, primary: value }))} + disabled={brandingDisabled} + /> + setBrandingForm((prev) => ({ ...prev, accent: value }))} + disabled={brandingDisabled} + /> + setBrandingForm((prev) => ({ ...prev, background: value }))} + disabled={brandingDisabled} + /> + setBrandingForm((prev) => ({ ...prev, surface: value }))} + disabled={brandingDisabled} + /> + + + + + + {t('events.branding.fonts', 'Fonts')} + + + + setBrandingForm((prev) => ({ ...prev, headingFont: event.target.value }))} + placeholder={t('events.branding.headingFontPlaceholder', 'Playfair Display')} + hasError={false} + disabled={brandingDisabled} + /> + + + setBrandingForm((prev) => ({ ...prev, bodyFont: event.target.value }))} + placeholder={t('events.branding.bodyFontPlaceholder', 'Montserrat')} + hasError={false} + disabled={brandingDisabled} + /> + + + + + + + ); + + const accountContent = ( + <> + {error ? ( + + + {error} + + + ) : null} + + + + + + + + + {form.name || profile?.email || t('profile.title', 'Profil')} + + + {form.email || profile?.email || '—'} + + + + + {profile?.email_verified ? ( + + ) : ( + + )} + + {emailStatusLabel} + + + {emailHint} + + + + + + + + + {t('profile.sections.account.heading', 'Account-Informationen')} + + + + {t('profile.sections.account.description', 'Passe Anzeigename, E-Mail-Adresse und Sprache der Admin-Oberfläche an.')} + + {loading ? ( + + {t('profile.loading', 'Lädt ...')} + + ) : ( + + + setForm((prev) => ({ ...prev, name: event.target.value }))} + placeholder={t('profile.placeholders.name', 'z. B. Hochzeitsplanung Schmidt')} + hasError={false} + /> + + + setForm((prev) => ({ ...prev, email: event.target.value }))} + placeholder="mail@beispiel.de" + type="email" + hasError={false} + /> + + + setForm((prev) => ({ ...prev, preferredLocale: event.target.value }))} + > + {LOCALE_OPTIONS.map((option) => ( + + ))} + + + + + )} + + + + + + + {t('profile.sections.password.heading', 'Passwort ändern')} + + + + {t('profile.sections.password.description', 'Wähle ein sicheres Passwort, um deinen Admin-Zugang zu schützen.')} + + + + setForm((prev) => ({ ...prev, currentPassword: event.target.value }))} + placeholder="••••••••" + type="password" + hasError={false} + /> + + + setForm((prev) => ({ ...prev, password: event.target.value }))} + placeholder="••••••••" + type="password" + hasError={false} + /> + + + setForm((prev) => ({ ...prev, passwordConfirmation: event.target.value }))} + placeholder="••••••••" + type="password" + hasError={false} + /> + + + + + + ); return ( {brandingTabEnabled ? ( - - setActiveTab('account')} - /> - setActiveTab('branding')} - /> - - ) : null} - - {activeTab === 'branding' && brandingTabEnabled ? ( - <> - {brandingError ? ( - - - {brandingError} + setActiveTab(value as TabKey)} + orientation="horizontal" + flexDirection="column" + > + + + + {t('profile.tabs.account', 'Account')} - - ) : null} - - - - {t('profile.branding.title', 'Standard-Branding')} - - - {t('profile.branding.hint', 'Diese Werte dienen als Vorschlag für neue Events und beschleunigen die Einrichtung.')} - - - - - - {t('profile.branding.theme', 'Theme')} - - - - setBrandingForm((prev) => ({ ...prev, mode: event.target.value as BrandingFormValues['mode'] }))} - disabled={brandingDisabled} - > - - - - - - - setBrandingForm((prev) => ({ ...prev, fontSize: event.target.value as BrandingFormValues['fontSize'] }))} - disabled={brandingDisabled} - > - - - - - - - - - - - {t('events.branding.colors', 'Colors')} - - - setBrandingForm((prev) => ({ ...prev, primary: value }))} - disabled={brandingDisabled} - /> - setBrandingForm((prev) => ({ ...prev, accent: value }))} - disabled={brandingDisabled} - /> - setBrandingForm((prev) => ({ ...prev, background: value }))} - disabled={brandingDisabled} - /> - setBrandingForm((prev) => ({ ...prev, surface: value }))} - disabled={brandingDisabled} - /> - - - - - - {t('events.branding.fonts', 'Fonts')} - - - - setBrandingForm((prev) => ({ ...prev, headingFont: event.target.value }))} - placeholder={t('events.branding.headingFontPlaceholder', 'Playfair Display')} - hasError={false} - disabled={brandingDisabled} - /> - - - setBrandingForm((prev) => ({ ...prev, bodyFont: event.target.value }))} - placeholder={t('events.branding.bodyFontPlaceholder', 'Montserrat')} - hasError={false} - disabled={brandingDisabled} - /> - - - - - - + + + + {t('profile.tabs.branding', 'Standard-Branding')} + + + + + {accountContent} + + + {brandingContent} + + ) : ( - <> - {error ? ( - - - {error} - - - ) : null} - - - - - - - - - {form.name || profile?.email || t('profile.title', 'Profil')} - - - {form.email || profile?.email || '—'} - - - - - {profile?.email_verified ? ( - - ) : ( - - )} - - {emailStatusLabel} - - - {emailHint} - - - - - - - - - {t('profile.sections.account.heading', 'Account-Informationen')} - - - - {t('profile.sections.account.description', 'Passe Anzeigename, E-Mail-Adresse und Sprache der Admin-Oberfläche an.')} - - {loading ? ( - - {t('profile.loading', 'Lädt ...')} - - ) : ( - - - setForm((prev) => ({ ...prev, name: event.target.value }))} - placeholder={t('profile.placeholders.name', 'z. B. Hochzeitsplanung Schmidt')} - hasError={false} - /> - - - setForm((prev) => ({ ...prev, email: event.target.value }))} - placeholder="mail@beispiel.de" - type="email" - hasError={false} - /> - - - setForm((prev) => ({ ...prev, preferredLocale: event.target.value }))} - > - {LOCALE_OPTIONS.map((option) => ( - - ))} - - - - - )} - - - - - - - {t('profile.sections.password.heading', 'Passwort ändern')} - - - - {t('profile.sections.password.description', 'Wähle ein sicheres Passwort, um deinen Admin-Zugang zu schützen.')} - - - - setForm((prev) => ({ ...prev, currentPassword: event.target.value }))} - placeholder="••••••••" - type="password" - hasError={false} - /> - - - setForm((prev) => ({ ...prev, password: event.target.value }))} - placeholder="••••••••" - type="password" - hasError={false} - /> - - - setForm((prev) => ({ ...prev, passwordConfirmation: event.target.value }))} - placeholder="••••••••" - type="password" - hasError={false} - /> - - - - - + accountContent )} ); } -function TabButton({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) { - const { primary, surfaceMuted, border, surface, text } = useAdminTheme(); - return ( - - - - {label} - - - - ); -} - function ColorField({ label, value, diff --git a/resources/js/admin/mobile/__tests__/BrandingPage.test.tsx b/resources/js/admin/mobile/__tests__/BrandingPage.test.tsx index 9eea65d1..93cececa 100644 --- a/resources/js/admin/mobile/__tests__/BrandingPage.test.tsx +++ b/resources/js/admin/mobile/__tests__/BrandingPage.test.tsx @@ -50,6 +50,7 @@ vi.mock('../components/FormControls', () => ({ MobileColorInput: (props: React.InputHTMLAttributes) => , MobileFileInput: (props: React.InputHTMLAttributes) => , MobileInput: (props: React.InputHTMLAttributes) => , + MobileTextArea: (props: React.TextareaHTMLAttributes) =>