neues Admin UI Layout eingeführt. Alle Tests auf den neusten Stand gebracht.

This commit is contained in:
Codex Agent
2025-12-30 10:24:06 +01:00
parent 902e78cae9
commit efe2f25b3e
85 changed files with 95235 additions and 19197 deletions

View File

@@ -15,6 +15,7 @@ import { MobileSheet } from './components/Sheet';
import toast from 'react-hot-toast';
import { adminPath } from '../constants';
import { useBackNavigation } from './hooks/useBackNavigation';
import { ADMIN_COLORS, ADMIN_GRADIENTS, useAdminTheme } from './theme';
type BrandingForm = {
primary: string;
@@ -54,11 +55,12 @@ export default function MobileBrandingPage() {
const slug = slugParam ?? null;
const navigate = useNavigate();
const { t } = useTranslation('management');
const { textStrong, muted, subtle, border, primary, accentSoft, danger, surfaceMuted, surface } = useAdminTheme();
const [event, setEvent] = React.useState<TenantEvent | null>(null);
const [form, setForm] = React.useState<BrandingForm>({
primary: '#007AFF',
accent: '#5AD2F4',
primary: ADMIN_COLORS.primary,
accent: ADMIN_COLORS.accent,
headingFont: '',
bodyFont: '',
logoDataUrl: '',
@@ -118,8 +120,8 @@ export default function MobileBrandingPage() {
}, [showFontsSheet, fontsLoaded]);
const previewTitle = event ? renderName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event');
const previewHeadingFont = form.headingFont || 'Montserrat';
const previewBodyFont = form.bodyFont || 'Montserrat';
const previewHeadingFont = form.headingFont || 'Fraunces';
const previewBodyFont = form.bodyFont || 'Manrope';
const watermarkAllowed = event?.package?.watermark_allowed !== false;
const brandingAllowed = isBrandingAllowed(event ?? null);
const watermarkLocked = watermarkAllowed && !brandingAllowed;
@@ -229,7 +231,7 @@ export default function MobileBrandingPage() {
return (
<>
<MobileCard space="$3">
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.watermark.previewTitle', 'Watermark Preview')}
</Text>
<WatermarkPreview
@@ -244,7 +246,7 @@ export default function MobileBrandingPage() {
{disabled ? (
<InfoBadge
icon={<Lock size={16} color="#b91c1c" />}
icon={<Lock size={16} color={danger} />}
text={t('events.watermark.lockedDisabled', 'Kein Wasserzeichen in diesem Paket.')}
tone="danger"
/>
@@ -252,13 +254,13 @@ export default function MobileBrandingPage() {
{watermarkLocked ? (
<InfoBadge
icon={<Lock size={16} color="#111827" />}
icon={<Lock size={16} color={textStrong} />}
text={t('events.watermark.lockedBranding', 'Custom-Wasserzeichen ist im aktuellen Paket gesperrt. Standard wird genutzt.')}
/>
) : null}
<MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color="#111827">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.watermark.title', 'Wasserzeichen')}
</Text>
@@ -291,7 +293,7 @@ export default function MobileBrandingPage() {
{mode === 'custom' && !controlsLocked ? (
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.watermark.upload', 'Wasserzeichen hochladen')}
</Text>
<Pressable onPress={() => document.getElementById('watermark-upload-input')?.click()}>
@@ -302,11 +304,11 @@ export default function MobileBrandingPage() {
paddingVertical="$2.5"
borderRadius={12}
borderWidth={1}
borderColor="#e5e7eb"
backgroundColor="white"
borderColor={border}
backgroundColor={surface}
>
<UploadCloud size={18} color="#007AFF" />
<Text fontSize="$sm" color="#007AFF" fontWeight="700">
<UploadCloud size={18} color={primary} />
<Text fontSize="$sm" color={primary} fontWeight="700">
{watermarkForm.assetPath
? t('events.watermark.replace', 'Wasserzeichen ersetzen')
: t('events.watermark.uploadCta', 'PNG/SVG/JPG (max. 3 MB)')}
@@ -334,7 +336,7 @@ export default function MobileBrandingPage() {
reader.readAsDataURL(file);
}}
/>
<Text fontSize="$xs" color="#6b7280">
<Text fontSize="$xs" color={muted}>
{t('events.watermark.uploadHint', 'PNG mit transparenter Fläche empfohlen.')}
</Text>
</YStack>
@@ -342,7 +344,7 @@ export default function MobileBrandingPage() {
</MobileCard>
<MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color="#111827">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.watermark.placement', 'Position & Größe')}
</Text>
<PositionGrid
@@ -408,13 +410,13 @@ export default function MobileBrandingPage() {
onBack={back}
headerActions={
<HeaderActionButton onPress={() => handleSave()} ariaLabel={t('common.save', 'Save')}>
<Save size={18} color="#007AFF" />
<Save size={18} color={primary} />
</HeaderActionButton>
}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color="#b91c1c">
<Text fontWeight="700" color={danger}>
{error}
</Text>
</MobileCard>
@@ -430,17 +432,17 @@ export default function MobileBrandingPage() {
{activeTab === 'branding' ? (
<>
<MobileCard space="$3">
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.branding.previewTitle', 'Guest App Preview')}
</Text>
<YStack borderRadius={16} borderWidth={1} borderColor="#e5e7eb" backgroundColor="#f8fafc" padding="$3" space="$2" alignItems="center">
<YStack width="100%" borderRadius={12} backgroundColor="white" borderWidth={1} borderColor="#e5e7eb" overflow="hidden">
<YStack borderRadius={16} borderWidth={1} borderColor={border} backgroundColor={surfaceMuted} padding="$3" space="$2" alignItems="center">
<YStack width="100%" borderRadius={12} backgroundColor={surface} borderWidth={1} borderColor={border} overflow="hidden">
<YStack backgroundColor={form.primary} height={64} />
<YStack padding="$3" space="$1.5">
<Text fontSize="$md" fontWeight="800" color="#111827" style={{ fontFamily: previewHeadingFont }}>
<Text fontSize="$md" fontWeight="800" color={textStrong} style={{ fontFamily: previewHeadingFont }}>
{previewTitle}
</Text>
<Text fontSize="$sm" color="#4b5563" style={{ fontFamily: previewBodyFont }}>
<Text fontSize="$sm" color={muted} style={{ fontFamily: previewBodyFont }}>
{t('events.branding.previewSubtitle', 'Aktuelle Farben & Schriften')}
</Text>
<XStack space="$2" marginTop="$1">
@@ -453,7 +455,7 @@ export default function MobileBrandingPage() {
</MobileCard>
<MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color="#111827">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.branding.colors', 'Colors')}
</Text>
<ColorField
@@ -469,13 +471,13 @@ export default function MobileBrandingPage() {
</MobileCard>
<MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color="#111827">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.branding.fonts', 'Fonts')}
</Text>
<InputField
label={t('events.branding.headingFont', 'Headline Font')}
value={form.headingFont}
placeholder="SF Pro Display"
placeholder={t('events.branding.headingFontPlaceholder', 'SF Pro Display')}
onChange={(value) => setForm((prev) => ({ ...prev, headingFont: value }))}
onPicker={() => {
setFontField('heading');
@@ -485,7 +487,7 @@ export default function MobileBrandingPage() {
<InputField
label={t('events.branding.bodyFont', 'Body Font')}
value={form.bodyFont}
placeholder="SF Pro Text"
placeholder={t('events.branding.bodyFontPlaceholder', 'SF Pro Text')}
onChange={(value) => setForm((prev) => ({ ...prev, bodyFont: value }))}
onPicker={() => {
setFontField('body');
@@ -495,14 +497,14 @@ export default function MobileBrandingPage() {
</MobileCard>
<MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color="#111827">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.branding.logo', 'Logo')}
</Text>
<YStack
borderRadius={14}
borderWidth={1}
borderColor="#e5e7eb"
backgroundColor="#f8fafc"
borderColor={border}
backgroundColor={surfaceMuted}
padding="$3"
alignItems="center"
justifyContent="center"
@@ -510,7 +512,11 @@ export default function MobileBrandingPage() {
>
{form.logoDataUrl ? (
<>
<img src={form.logoDataUrl} alt="Logo" style={{ maxHeight: 80, maxWidth: '100%', objectFit: 'contain' }} />
<img
src={form.logoDataUrl}
alt={t('events.branding.logoAlt', 'Logo')}
style={{ maxHeight: 80, maxWidth: '100%', objectFit: 'contain' }}
/>
<XStack space="$2">
<CTAButton
label={t('events.branding.replaceLogo', 'Replace logo')}
@@ -524,10 +530,10 @@ export default function MobileBrandingPage() {
paddingVertical="$2"
borderRadius={12}
borderWidth={1}
borderColor="#e5e7eb"
borderColor={border}
>
<Trash2 size={16} color="#b91c1c" />
<Text fontSize="$sm" color="#b91c1c" fontWeight="700">
<Trash2 size={16} color={danger} />
<Text fontSize="$sm" color={danger} fontWeight="700">
{t('events.branding.removeLogo', 'Remove')}
</Text>
</XStack>
@@ -536,8 +542,8 @@ export default function MobileBrandingPage() {
</>
) : (
<>
<ImageIcon size={28} color="#94a3b8" />
<Text fontSize="$sm" color="#4b5563" textAlign="center">
<ImageIcon size={28} color={subtle} />
<Text fontSize="$sm" color={muted} textAlign="center">
{t('events.branding.logoHint', 'Upload a logo to brand guest invites and QR posters.')}
</Text>
<Pressable onPress={() => document.getElementById('branding-logo-input')?.click()}>
@@ -548,11 +554,11 @@ export default function MobileBrandingPage() {
paddingVertical="$2.5"
borderRadius={12}
borderWidth={1}
borderColor="#e5e7eb"
backgroundColor="white"
borderColor={border}
backgroundColor={surface}
>
<UploadCloud size={18} color="#007AFF" />
<Text fontSize="$sm" color="#007AFF" fontWeight="700">
<UploadCloud size={18} color={primary} />
<Text fontSize="$sm" color={primary} fontWeight="700">
{t('events.branding.uploadLogo', 'Upload logo (max. 1 MB)')}
</Text>
</XStack>
@@ -600,13 +606,13 @@ export default function MobileBrandingPage() {
borderRadius={14}
alignItems="center"
justifyContent="center"
backgroundColor="white"
backgroundColor={surface}
borderWidth={1}
borderColor="#e5e7eb"
borderColor={border}
space="$2"
>
<RefreshCcw size={16} color="#111827" />
<Text fontSize="$sm" color="#111827" fontWeight="700">
<RefreshCcw size={16} color={textStrong} />
<Text fontSize="$sm" color={textStrong} fontWeight="700">
{t('events.branding.reset', 'Reset to Defaults')}
</Text>
</XStack>
@@ -624,7 +630,7 @@ export default function MobileBrandingPage() {
{fontsLoading ? (
Array.from({ length: 4 }).map((_, idx) => <SkeletonCard key={`font-sk-${idx}`} height={48} />)
) : fonts.length === 0 ? (
<Text fontSize="$sm" color="#4b5563">
<Text fontSize="$sm" color={muted}>
{t('events.branding.noFonts', 'Keine Schriftarten gefunden.')}
</Text>
) : (
@@ -641,17 +647,17 @@ export default function MobileBrandingPage() {
>
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
<YStack>
<Text fontSize="$sm" color="#111827" style={{ fontFamily: font.family }}>
<Text fontSize="$sm" color={textStrong} style={{ fontFamily: font.family }}>
{font.family}
</Text>
{font.variants?.length ? (
<Text fontSize="$xs" color="#6b7280" style={{ fontFamily: font.family }}>
<Text fontSize="$xs" color={muted} style={{ fontFamily: font.family }}>
{font.variants.map((v) => v.style ?? v.weight ?? '').filter(Boolean).join(', ')}
</Text>
) : null}
</YStack>
{form[fontField === 'heading' ? 'headingFont' : 'bodyFont'] === font.family ? (
<Text fontSize="$xs" color="#007AFF">
<Text fontSize="$xs" color={primary}>
{t('common.active', 'Active')}
</Text>
) : null}
@@ -677,8 +683,8 @@ function extractBranding(event: TenantEvent): BrandingForm {
return typeof value === 'string' ? value : '';
};
return {
primary: readColor('primary_color', '#007AFF'),
accent: readColor('accent_color', '#5AD2F4'),
primary: readColor('primary_color', ADMIN_COLORS.primary),
accent: readColor('accent_color', ADMIN_COLORS.accent),
headingFont: readText('heading_font'),
bodyFont: readText('body_font'),
logoDataUrl: readText('logo_data_url'),
@@ -757,9 +763,10 @@ function renderName(name: TenantEvent['name']): string {
}
function ColorField({ label, value, onChange }: { label: string; value: string; onChange: (next: string) => void }) {
const { textStrong, muted, border, surface } = useAdminTheme();
return (
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{label}
</Text>
<XStack alignItems="center" space="$2">
@@ -767,9 +774,9 @@ function ColorField({ label, value, onChange }: { label: string; value: string;
type="color"
value={value}
onChange={(event) => onChange(event.target.value)}
style={{ width: 52, height: 52, borderRadius: 12, border: '1px solid #e5e7eb', background: 'white' }}
style={{ width: 52, height: 52, borderRadius: 12, border: `1px solid ${border}`, background: surface }}
/>
<Text fontSize="$sm" color="#4b5563">
<Text fontSize="$sm" color={muted}>
{value}
</Text>
</XStack>
@@ -778,10 +785,11 @@ function ColorField({ label, value, onChange }: { label: string; value: string;
}
function ColorSwatch({ color, label }: { color: string; label: string }) {
const { border, muted } = useAdminTheme();
return (
<YStack alignItems="center" space="$1">
<YStack width={40} height={40} borderRadius={12} borderWidth={1} borderColor="#e5e7eb" backgroundColor={color} />
<Text fontSize="$xs" color="#4b5563">
<YStack width={40} height={40} borderRadius={12} borderWidth={1} borderColor={border} backgroundColor={color} />
<Text fontSize="$xs" color={muted}>
{label}
</Text>
</YStack>
@@ -803,20 +811,21 @@ function InputField({
onPicker?: () => void;
children?: React.ReactNode;
}) {
const { textStrong, border, surface, primary } = useAdminTheme();
return (
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{label}
</Text>
<XStack
alignItems="center"
borderRadius={12}
borderWidth={1}
borderColor="#e5e7eb"
borderColor={border}
paddingLeft="$3"
paddingRight="$2"
height={48}
backgroundColor="white"
backgroundColor={surface}
space="$2"
>
{children ?? (
@@ -838,7 +847,7 @@ function InputField({
)}
{onPicker ? (
<Pressable onPress={onPicker}>
<ChevronDown size={16} color="#007AFF" />
<ChevronDown size={16} color={primary} />
</Pressable>
) : null}
</XStack>
@@ -865,13 +874,14 @@ function LabeledSlider({
disabled?: boolean;
suffix?: string;
}) {
const { textStrong, muted } = useAdminTheme();
return (
<YStack space="$1.5">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{label}
</Text>
<Text fontSize="$xs" color="#4b5563">
<Text fontSize="$xs" color={muted}>
{value}
{suffix ? ` ${suffix}` : ''}
</Text>
@@ -899,6 +909,7 @@ function PositionGrid({
onChange: (value: WatermarkPosition) => void;
disabled?: boolean;
}) {
const { textStrong, primary, border, accentSoft, surface } = useAdminTheme();
const positions: WatermarkPosition[] = [
'top-left',
'top-center',
@@ -913,7 +924,7 @@ function PositionGrid({
return (
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
Position
</Text>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 8 }}>
@@ -926,11 +937,11 @@ function PositionGrid({
style={{
height: 40,
borderRadius: 10,
border: value === pos ? '2px solid #007AFF' : '1px solid #e5e7eb',
background: value === pos ? '#eff6ff' : '#fff',
border: value === pos ? `2px solid ${primary}` : `1px solid ${border}`,
background: value === pos ? accentSoft : surface,
}}
>
<Text fontSize="$xs" color="#0f172a" textAlign="center">
<Text fontSize="$xs" color={textStrong} textAlign="center">
{pos.replace('-', ' ')}
</Text>
</button>
@@ -955,6 +966,7 @@ function WatermarkPreview({
offsetX: number;
offsetY: number;
}) {
const { border, muted, textStrong, overlay } = useAdminTheme();
const width = 280;
const height = 180;
const wmWidth = Math.max(24, Math.round(width * Math.min(1, Math.max(0.05, scale))));
@@ -1001,11 +1013,11 @@ function WatermarkPreview({
height,
borderRadius: 16,
overflow: 'hidden',
border: '1px solid #e5e7eb',
background: 'linear-gradient(135deg, #e0f2fe, #c7d2fe)',
border: `1px solid ${border}`,
background: ADMIN_GRADIENTS.softCard,
}}
>
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(180deg, #0f172a66, transparent)' }} />
<div style={{ position: 'absolute', inset: 0, background: `linear-gradient(180deg, ${overlay}, transparent)` }} />
<div
style={{
position: 'absolute',
@@ -1019,10 +1031,10 @@ function WatermarkPreview({
alignItems: 'center',
justifyContent: 'center',
opacity: Math.min(1, Math.max(0, opacity)),
border: '1px dashed #64748b',
border: `1px dashed ${muted}`,
}}
>
<Droplets size={18} color="#0f172a" />
<Droplets size={18} color={textStrong} />
</div>
</div>
</div>
@@ -1030,11 +1042,12 @@ function WatermarkPreview({
}
function InfoBadge({ icon, text, tone = 'info' }: { icon?: React.ReactNode; text: string; tone?: 'info' | 'danger' }) {
const background = tone === 'danger' ? '#fef2f2' : '#f1f5f9';
const color = tone === 'danger' ? '#991b1b' : '#0f172a';
const { dangerBg, dangerText, surfaceMuted, textStrong, border } = useAdminTheme();
const background = tone === 'danger' ? dangerBg : surfaceMuted;
const color = tone === 'danger' ? dangerText : textStrong;
return (
<MobileCard space="$2" backgroundColor={background} borderColor="#e2e8f0">
<MobileCard space="$2" backgroundColor={background} borderColor={border}>
<XStack space="$2" alignItems="center">
{icon}
<Text fontSize="$sm" color={color}>
@@ -1046,6 +1059,7 @@ function InfoBadge({ icon, text, tone = 'info' }: { icon?: React.ReactNode; text
}
function TabButton({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
const { backdrop, surfaceMuted, border, surface } = useAdminTheme();
return (
<Pressable onPress={onPress} style={{ flex: 1 }}>
<XStack
@@ -1053,11 +1067,11 @@ function TabButton({ label, active, onPress }: { label: string; active: boolean;
justifyContent="center"
paddingVertical="$2.5"
borderRadius={12}
backgroundColor={active ? '#0f172a' : '#f1f5f9'}
backgroundColor={active ? backdrop : surfaceMuted}
borderWidth={1}
borderColor={active ? '#0f172a' : '#e5e7eb'}
borderColor={active ? backdrop : border}
>
<Text fontSize="$sm" color={active ? '#fff' : '#0f172a'} fontWeight="700">
<Text fontSize="$sm" color={active ? surface : backdrop} fontWeight="700">
{label}
</Text>
</XStack>