Wire guest branding theme

This commit is contained in:
Codex Agent
2026-01-15 08:06:21 +01:00
parent 53096fbf29
commit a1f37bb491
14 changed files with 478 additions and 123 deletions

View File

@@ -1920,9 +1920,17 @@
"previewSubtitle": "Aktuelle Farben & Schriften",
"primary": "Primärfarbe",
"accent": "Akzentfarbe",
"background": "Hintergrund",
"surface": "Fläche",
"mode": "Theme",
"modeLight": "Hell",
"modeAuto": "Auto",
"modeDark": "Dunkel",
"colors": "Farben",
"primaryColor": "Primärfarbe",
"accentColor": "Akzentfarbe",
"backgroundColor": "Hintergrundfarbe",
"surfaceColor": "Flächenfarbe",
"fonts": "Schriften",
"headingFont": "Überschrift-Schrift",
"headingFontPlaceholder": "SF Pro Display",

View File

@@ -1924,9 +1924,17 @@
"previewSubtitle": "Current colors & fonts",
"primary": "Primary",
"accent": "Accent",
"background": "Background",
"surface": "Surface",
"mode": "Theme",
"modeLight": "Light",
"modeAuto": "Auto",
"modeDark": "Dark",
"colors": "Colors",
"primaryColor": "Primary color",
"accentColor": "Accent color",
"backgroundColor": "Background color",
"surfaceColor": "Surface color",
"fonts": "Fonts",
"headingFont": "Headline font",
"headingFontPlaceholder": "SF Pro Display",

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import { extractBrandingForm } from '../brandingForm';
const defaults = {
primary: '#111111',
accent: '#222222',
background: '#ffffff',
surface: '#f0f0f0',
mode: 'auto' as const,
};
describe('extractBrandingForm', () => {
it('prefers palette values when available', () => {
const settings = {
branding: {
palette: {
primary: '#aa0000',
secondary: '#00aa00',
background: '#000000',
surface: '#111111',
},
primary_color: '#bbbbbb',
secondary_color: '#cccccc',
background_color: '#dddddd',
surface_color: '#eeeeee',
mode: 'dark',
},
};
const result = extractBrandingForm(settings, defaults);
expect(result.primary).toBe('#aa0000');
expect(result.accent).toBe('#00aa00');
expect(result.background).toBe('#000000');
expect(result.surface).toBe('#111111');
expect(result.mode).toBe('dark');
});
it('falls back to legacy keys and defaults', () => {
const settings = {
branding: {
accent_color: '#123456',
background_color: '#abcdef',
mode: 'light',
},
};
const result = extractBrandingForm(settings, defaults);
expect(result.primary).toBe(defaults.primary);
expect(result.accent).toBe('#123456');
expect(result.background).toBe('#abcdef');
expect(result.surface).toBe('#abcdef');
expect(result.mode).toBe('light');
});
});

View File

@@ -0,0 +1,56 @@
export type BrandingFormValues = {
primary: string;
accent: string;
background: string;
surface: string;
headingFont: string;
bodyFont: string;
logoDataUrl: string;
mode: 'light' | 'dark' | 'auto';
};
export type BrandingFormDefaults = Pick<BrandingFormValues, 'primary' | 'accent' | 'background' | 'surface' | 'mode'>;
type BrandingRecord = Record<string, unknown>;
const isRecord = (value: unknown): value is BrandingRecord =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const readHexColor = (value: unknown, fallback: string): string => {
if (typeof value === 'string' && value.trim().startsWith('#')) {
return value.trim();
}
return fallback;
};
export function extractBrandingForm(settings: unknown, defaults: BrandingFormDefaults): BrandingFormValues {
const settingsRecord = isRecord(settings) ? settings : {};
const branding = isRecord(settingsRecord.branding) ? (settingsRecord.branding as BrandingRecord) : settingsRecord;
const palette = isRecord(branding.palette) ? (branding.palette as BrandingRecord) : {};
const typography = isRecord(branding.typography) ? (branding.typography as BrandingRecord) : {};
const primary = readHexColor(palette.primary, readHexColor(branding.primary_color, defaults.primary));
const accent = readHexColor(
palette.secondary,
readHexColor(branding.secondary_color, readHexColor(branding.accent_color, defaults.accent))
);
const background = readHexColor(palette.background, readHexColor(branding.background_color, defaults.background));
const surface = readHexColor(palette.surface, readHexColor(branding.surface_color, background));
const headingFont = typeof typography.heading === 'string' ? typography.heading : (branding.heading_font as string | undefined);
const bodyFont = typeof typography.body === 'string' ? typography.body : (branding.body_font as string | undefined);
const mode = branding.mode === 'light' || branding.mode === 'dark' || branding.mode === 'auto'
? branding.mode
: defaults.mode;
return {
primary,
accent,
background,
surface,
headingFont: headingFont ?? '',
bodyFont: bodyFont ?? '',
logoDataUrl: typeof branding.logo_data_url === 'string' ? branding.logo_data_url : '',
mode,
};
}

View File

@@ -16,13 +16,22 @@ import toast from 'react-hot-toast';
import { adminPath } from '../constants';
import { useBackNavigation } from './hooks/useBackNavigation';
import { ADMIN_COLORS, ADMIN_GRADIENTS, useAdminTheme } from './theme';
import { extractBrandingForm, type BrandingFormValues } from '../lib/brandingForm';
import { getContrastingTextColor } from '@/guest/lib/color';
type BrandingForm = {
primary: string;
accent: string;
headingFont: string;
bodyFont: string;
logoDataUrl: string;
const BRANDING_FORM_DEFAULTS = {
primary: ADMIN_COLORS.primary,
accent: ADMIN_COLORS.accent,
background: '#ffffff',
surface: '#ffffff',
mode: 'auto' as const,
};
const BRANDING_FORM_BASE: BrandingFormValues = {
...BRANDING_FORM_DEFAULTS,
headingFont: '',
bodyFont: '',
logoDataUrl: '',
};
type WatermarkPosition =
@@ -58,13 +67,7 @@ export default function MobileBrandingPage() {
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: ADMIN_COLORS.primary,
accent: ADMIN_COLORS.accent,
headingFont: '',
bodyFont: '',
logoDataUrl: '',
});
const [form, setForm] = React.useState<BrandingFormValues>(BRANDING_FORM_BASE);
const [watermarkForm, setWatermarkForm] = React.useState<WatermarkForm>({
mode: 'base',
assetPath: '',
@@ -94,7 +97,7 @@ export default function MobileBrandingPage() {
try {
const data = await getEvent(slug);
setEvent(data);
setForm(extractBranding(data));
setForm(extractBrandingForm(data.settings ?? {}, BRANDING_FORM_DEFAULTS));
setWatermarkForm(extractWatermark(data));
setError(null);
} catch (err) {
@@ -122,6 +125,7 @@ export default function MobileBrandingPage() {
const previewTitle = event ? renderName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event');
const previewHeadingFont = form.headingFont || 'Fraunces';
const previewBodyFont = form.bodyFont || 'Manrope';
const previewSurfaceText = getContrastingTextColor(form.surface, '#ffffff', '#0f172a');
const watermarkAllowed = event?.package?.watermark_allowed !== false;
const brandingAllowed = isBrandingAllowed(event ?? null);
const watermarkLocked = watermarkAllowed && !brandingAllowed;
@@ -158,9 +162,13 @@ export default function MobileBrandingPage() {
settings.branding = {
...(typeof settings.branding === 'object' ? (settings.branding as Record<string, unknown>) : {}),
primary_color: form.primary,
secondary_color: form.accent,
accent_color: form.accent,
background_color: form.background,
surface_color: form.surface,
heading_font: form.headingFont,
body_font: form.bodyFont,
mode: form.mode,
typography: {
...(typeof (settings.branding as Record<string, unknown> | undefined)?.typography === 'object'
? ((settings.branding as Record<string, unknown>).typography as Record<string, unknown>)
@@ -174,6 +182,8 @@ export default function MobileBrandingPage() {
: {}),
primary: form.primary,
secondary: form.accent,
background: form.background,
surface: form.surface,
},
logo_data_url: form.logoDataUrl || null,
logo: form.logoDataUrl
@@ -217,7 +227,7 @@ export default function MobileBrandingPage() {
function handleReset() {
if (event) {
setForm(extractBranding(event));
setForm(extractBrandingForm(event.settings ?? {}, BRANDING_FORM_DEFAULTS));
setWatermarkForm(extractWatermark(event));
}
}
@@ -435,25 +445,50 @@ export default function MobileBrandingPage() {
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.branding.previewTitle', 'Guest App Preview')}
</Text>
<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 borderRadius={16} borderWidth={1} borderColor={border} backgroundColor={form.background} padding="$3" space="$2" alignItems="center">
<YStack width="100%" borderRadius={12} backgroundColor={form.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={textStrong} style={{ fontFamily: previewHeadingFont }}>
<Text fontSize="$md" fontWeight="800" color={previewSurfaceText} style={{ fontFamily: previewHeadingFont }}>
{previewTitle}
</Text>
<Text fontSize="$sm" color={muted} style={{ fontFamily: previewBodyFont }}>
<Text fontSize="$sm" color={previewSurfaceText} style={{ fontFamily: previewBodyFont, opacity: 0.7 }}>
{t('events.branding.previewSubtitle', 'Aktuelle Farben & Schriften')}
</Text>
<XStack space="$2" marginTop="$1">
<ColorSwatch color={form.primary} label={t('events.branding.primary', 'Primary')} />
<ColorSwatch color={form.accent} label={t('events.branding.accent', 'Accent')} />
<ColorSwatch color={form.background} label={t('events.branding.background', 'Background')} />
<ColorSwatch color={form.surface} label={t('events.branding.surface', 'Surface')} />
</XStack>
</YStack>
</YStack>
</YStack>
</MobileCard>
<MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.branding.mode', 'Theme')}
</Text>
<XStack space="$2">
<ModeButton
label={t('events.branding.modeLight', 'Light')}
active={form.mode === 'light'}
onPress={() => setForm((prev) => ({ ...prev, mode: 'light' }))}
/>
<ModeButton
label={t('events.branding.modeAuto', 'Auto')}
active={form.mode === 'auto'}
onPress={() => setForm((prev) => ({ ...prev, mode: 'auto' }))}
/>
<ModeButton
label={t('events.branding.modeDark', 'Dark')}
active={form.mode === 'dark'}
onPress={() => setForm((prev) => ({ ...prev, mode: 'dark' }))}
/>
</XStack>
</MobileCard>
<MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.branding.colors', 'Colors')}
@@ -468,6 +503,16 @@ export default function MobileBrandingPage() {
value={form.accent}
onChange={(value) => setForm((prev) => ({ ...prev, accent: value }))}
/>
<ColorField
label={t('events.branding.backgroundColor', 'Background Color')}
value={form.background}
onChange={(value) => setForm((prev) => ({ ...prev, background: value }))}
/>
<ColorField
label={t('events.branding.surfaceColor', 'Surface Color')}
value={form.surface}
onChange={(value) => setForm((prev) => ({ ...prev, surface: value }))}
/>
</MobileCard>
<MobileCard space="$3">
@@ -671,26 +716,6 @@ export default function MobileBrandingPage() {
);
}
function extractBranding(event: TenantEvent): BrandingForm {
const source = (event.settings as Record<string, unknown>) ?? {};
const branding = (source.branding as Record<string, unknown>) ?? source;
const readColor = (key: string, fallback: string) => {
const value = branding[key];
return typeof value === 'string' && value.startsWith('#') ? value : fallback;
};
const readText = (key: string) => {
const value = branding[key];
return typeof value === 'string' ? value : '';
};
return {
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'),
};
}
function extractWatermark(event: TenantEvent): WatermarkForm {
const settings = (event.settings as Record<string, unknown>) ?? {};
const wm = (settings.watermark as Record<string, unknown>) ?? {};
@@ -1078,3 +1103,24 @@ function TabButton({ label, active, onPress }: { label: string; active: boolean;
</Pressable>
);
}
function ModeButton({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
const { backdrop, surfaceMuted, border, surface } = useAdminTheme();
return (
<Pressable onPress={onPress} style={{ flex: 1 }}>
<XStack
alignItems="center"
justifyContent="center"
paddingVertical="$2"
borderRadius={10}
backgroundColor={active ? backdrop : surfaceMuted}
borderWidth={1}
borderColor={active ? backdrop : border}
>
<Text fontSize="$xs" color={active ? surface : backdrop} fontWeight="700">
{label}
</Text>
</XStack>
</Pressable>
);
}