Adjust branding defaults and tenant presets
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-30 18:15:52 +01:00
parent b1f9f7cee0
commit e39ddd2143
9 changed files with 592 additions and 220 deletions

View File

@@ -1093,12 +1093,8 @@ class EventPublicController extends BaseController
$brandingAllowed = $this->determineBrandingAllowed($event); $brandingAllowed = $this->determineBrandingAllowed($event);
$eventBranding = $brandingAllowed ? Arr::get($event->settings, 'branding', []) : []; $eventBranding = $brandingAllowed ? Arr::get($event->settings, 'branding', []) : [];
$tenantBranding = $brandingAllowed ? Arr::get($event->tenant?->settings, 'branding', []) : [];
$useDefault = (bool) Arr::get($eventBranding, 'use_default_branding', Arr::get($eventBranding, 'use_default', false)); $useDefault = (bool) Arr::get($eventBranding, 'use_default_branding', Arr::get($eventBranding, 'use_default', false));
$sources = $brandingAllowed $sources = $brandingAllowed ? [$eventBranding] : [[]];
? ($useDefault ? [$tenantBranding] : [$eventBranding, $tenantBranding])
: [[]];
$primary = $this->normalizeHexColor( $primary = $this->normalizeHexColor(
$this->firstStringFromSources($sources, ['palette.primary', 'primary_color']), $this->firstStringFromSources($sources, ['palette.primary', 'primary_color']),

View File

@@ -2448,6 +2448,28 @@ export async function getTenantSettings(): Promise<TenantSettingsPayload> {
}; };
} }
export async function updateTenantSettings(settings: Record<string, unknown>): Promise<TenantSettingsPayload> {
const response = await authorizedFetch('/api/v1/tenant/settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ settings }),
});
const data = await jsonOrThrow<{ data?: { id?: number; settings?: Record<string, unknown>; updated_at?: string | null } }>(
response,
'Failed to update tenant settings',
);
const payload = (data.data ?? {}) as Record<string, unknown>;
return {
id: Number(payload.id ?? 0),
settings: (payload.settings ?? {}) as Record<string, unknown>,
updated_at: (payload.updated_at ?? null) as string | null,
};
}
export async function getTenantPackagesOverview(options?: { force?: boolean }): Promise<{ export async function getTenantPackagesOverview(options?: { force?: boolean }): Promise<{
packages: TenantPackageSummary[]; packages: TenantPackageSummary[];
activePackage: TenantPackageSummary | null; activePackage: TenantPackageSummary | null;

View File

@@ -6,6 +6,8 @@ const defaults = {
accent: '#222222', accent: '#222222',
background: '#ffffff', background: '#ffffff',
surface: '#f0f0f0', surface: '#f0f0f0',
headingFont: 'Default Heading',
bodyFont: 'Default Body',
mode: 'auto' as const, mode: 'auto' as const,
buttonStyle: 'filled' as const, buttonStyle: 'filled' as const,
buttonRadius: 12, buttonRadius: 12,
@@ -87,7 +89,6 @@ describe('extractBrandingForm', () => {
position: 'center', position: 'center',
size: 'l', size: 'l',
}, },
use_default_branding: true,
}, },
}; };
@@ -105,7 +106,6 @@ describe('extractBrandingForm', () => {
expect(result.logoValue).toBe('🎉'); expect(result.logoValue).toBe('🎉');
expect(result.logoPosition).toBe('center'); expect(result.logoPosition).toBe('center');
expect(result.logoSize).toBe('l'); expect(result.logoSize).toBe('l');
expect(result.useDefaultBranding).toBe(true);
}); });
it('normalizes stored logo paths for previews', () => { it('normalizes stored logo paths for previews', () => {

View File

@@ -17,7 +17,6 @@ export type BrandingFormValues = {
buttonPrimary: string; buttonPrimary: string;
buttonSecondary: string; buttonSecondary: string;
linkColor: string; linkColor: string;
useDefaultBranding: boolean;
}; };
export type BrandingFormDefaults = Pick< export type BrandingFormDefaults = Pick<
@@ -26,6 +25,8 @@ export type BrandingFormDefaults = Pick<
| 'accent' | 'accent'
| 'background' | 'background'
| 'surface' | 'surface'
| 'headingFont'
| 'bodyFont'
| 'mode' | 'mode'
| 'buttonStyle' | 'buttonStyle'
| 'buttonRadius' | 'buttonRadius'
@@ -130,8 +131,8 @@ export function extractBrandingForm(settings: unknown, defaults: BrandingFormDef
accent, accent,
background, background,
surface, surface,
headingFont: headingFont ?? '', headingFont: headingFont ?? defaults.headingFont ?? '',
bodyFont: bodyFont ?? '', bodyFont: bodyFont ?? defaults.bodyFont ?? '',
fontSize, fontSize,
logoDataUrl: resolveAssetPreviewUrl(logoUploadValue), logoDataUrl: resolveAssetPreviewUrl(logoUploadValue),
logoValue, logoValue,
@@ -144,6 +145,5 @@ export function extractBrandingForm(settings: unknown, defaults: BrandingFormDef
buttonPrimary, buttonPrimary,
buttonSecondary, buttonSecondary,
linkColor, linkColor,
useDefaultBranding: branding.use_default_branding === true,
}; };
} }

View File

@@ -28,6 +28,8 @@ const BRANDING_FORM_DEFAULTS = {
accent: DEFAULT_EVENT_BRANDING.secondaryColor, accent: DEFAULT_EVENT_BRANDING.secondaryColor,
background: DEFAULT_EVENT_BRANDING.backgroundColor, background: DEFAULT_EVENT_BRANDING.backgroundColor,
surface: DEFAULT_EVENT_BRANDING.palette?.surface ?? DEFAULT_EVENT_BRANDING.backgroundColor, surface: DEFAULT_EVENT_BRANDING.palette?.surface ?? DEFAULT_EVENT_BRANDING.backgroundColor,
headingFont: DEFAULT_EVENT_BRANDING.typography?.heading ?? DEFAULT_EVENT_BRANDING.fontFamily ?? '',
bodyFont: DEFAULT_EVENT_BRANDING.typography?.body ?? DEFAULT_EVENT_BRANDING.fontFamily ?? '',
mode: DEFAULT_EVENT_BRANDING.mode ?? 'auto', mode: DEFAULT_EVENT_BRANDING.mode ?? 'auto',
buttonStyle: DEFAULT_EVENT_BRANDING.buttons?.style ?? 'filled', buttonStyle: DEFAULT_EVENT_BRANDING.buttons?.style ?? 'filled',
buttonRadius: DEFAULT_EVENT_BRANDING.buttons?.radius ?? 12, buttonRadius: DEFAULT_EVENT_BRANDING.buttons?.radius ?? 12,
@@ -40,14 +42,11 @@ const BRANDING_FORM_DEFAULTS = {
logoSize: DEFAULT_EVENT_BRANDING.logo?.size ?? 'm', logoSize: DEFAULT_EVENT_BRANDING.logo?.size ?? 'm',
}; };
const BRANDING_FORM_BASE: BrandingFormValues = { const buildBrandingFormBase = (defaults: typeof BRANDING_FORM_DEFAULTS): BrandingFormValues => ({
...BRANDING_FORM_DEFAULTS, ...defaults,
headingFont: '',
bodyFont: '',
logoDataUrl: '', logoDataUrl: '',
logoValue: '', logoValue: '',
useDefaultBranding: false, });
};
const FONT_SIZE_SCALE: Record<BrandingFormValues['fontSize'], number> = { const FONT_SIZE_SCALE: Record<BrandingFormValues['fontSize'], number> = {
s: 0.94, s: 0.94,
@@ -61,6 +60,38 @@ const LOGO_SIZE_PREVIEW: Record<BrandingFormValues['logoSize'], number> = {
l: 44, l: 44,
}; };
const resolveBrandingDefaults = (tenantBranding: BrandingFormValues | null) => {
if (!tenantBranding) {
return BRANDING_FORM_DEFAULTS;
}
const primary = tenantBranding.primary.trim() ? tenantBranding.primary : BRANDING_FORM_DEFAULTS.primary;
const accent = tenantBranding.accent.trim() ? tenantBranding.accent : BRANDING_FORM_DEFAULTS.accent;
const background = tenantBranding.background.trim() ? tenantBranding.background : BRANDING_FORM_DEFAULTS.background;
const surface = tenantBranding.surface.trim() ? tenantBranding.surface : BRANDING_FORM_DEFAULTS.surface;
const headingFont = tenantBranding.headingFont.trim()
? tenantBranding.headingFont
: BRANDING_FORM_DEFAULTS.headingFont;
const bodyFont = tenantBranding.bodyFont.trim()
? tenantBranding.bodyFont
: BRANDING_FORM_DEFAULTS.bodyFont;
return {
...BRANDING_FORM_DEFAULTS,
primary,
accent,
background,
surface,
headingFont,
bodyFont,
fontSize: tenantBranding.fontSize ?? BRANDING_FORM_DEFAULTS.fontSize,
mode: tenantBranding.mode ?? BRANDING_FORM_DEFAULTS.mode,
buttonPrimary: primary,
buttonSecondary: accent,
linkColor: accent,
};
};
type WatermarkPosition = type WatermarkPosition =
| 'top-left' | 'top-left'
| 'top-center' | 'top-center'
@@ -95,7 +126,7 @@ export default function MobileBrandingPage() {
const { textStrong, muted, subtle, border, primary, accentSoft, danger, surfaceMuted, surface } = useAdminTheme(); const { textStrong, muted, subtle, border, primary, accentSoft, danger, surfaceMuted, surface } = useAdminTheme();
const [event, setEvent] = React.useState<TenantEvent | null>(null); const [event, setEvent] = React.useState<TenantEvent | null>(null);
const [form, setForm] = React.useState<BrandingFormValues>(BRANDING_FORM_BASE); const [form, setForm] = React.useState<BrandingFormValues>(() => buildBrandingFormBase(BRANDING_FORM_DEFAULTS));
const [watermarkForm, setWatermarkForm] = React.useState<WatermarkForm>({ const [watermarkForm, setWatermarkForm] = React.useState<WatermarkForm>({
mode: 'base', mode: 'base',
assetPath: '', assetPath: '',
@@ -120,10 +151,13 @@ export default function MobileBrandingPage() {
const [fontsLoaded, setFontsLoaded] = React.useState(false); const [fontsLoaded, setFontsLoaded] = React.useState(false);
const [tenantBranding, setTenantBranding] = React.useState<BrandingFormValues | null>(null); const [tenantBranding, setTenantBranding] = React.useState<BrandingFormValues | null>(null);
const [tenantBrandingLoaded, setTenantBrandingLoaded] = React.useState(false); const [tenantBrandingLoaded, setTenantBrandingLoaded] = React.useState(false);
const [formInitialized, setFormInitialized] = React.useState(false);
const resolvedDefaults = React.useMemo(() => resolveBrandingDefaults(tenantBranding), [tenantBranding]);
React.useEffect(() => { React.useEffect(() => {
if (!slug) return; if (!slug) return;
(async () => { (async () => {
setFormInitialized(false);
setLoading(true); setLoading(true);
try { try {
const data = await getEvent(slug); const data = await getEvent(slug);
@@ -132,7 +166,6 @@ export default function MobileBrandingPage() {
return; return;
} }
setEvent(data); setEvent(data);
setForm(extractBrandingForm(data.settings ?? {}, BRANDING_FORM_DEFAULTS));
setWatermarkForm(extractWatermark(data)); setWatermarkForm(extractWatermark(data));
setError(null); setError(null);
} catch (err) { } catch (err) {
@@ -145,6 +178,12 @@ export default function MobileBrandingPage() {
})(); })();
}, [slug, t]); }, [slug, t]);
React.useEffect(() => {
if (!event || !tenantBrandingLoaded || formInitialized) return;
setForm(extractBrandingForm(event.settings ?? {}, resolvedDefaults));
setFormInitialized(true);
}, [event, tenantBrandingLoaded, formInitialized, resolvedDefaults]);
React.useEffect(() => { React.useEffect(() => {
if (!showFontsSheet || fontsLoaded) return; if (!showFontsSheet || fontsLoaded) return;
setFontsLoading(true); setFontsLoading(true);
@@ -177,7 +216,7 @@ export default function MobileBrandingPage() {
}; };
}, [tenantBrandingLoaded]); }, [tenantBrandingLoaded]);
const previewForm = form.useDefaultBranding && tenantBranding ? tenantBranding : form; const previewForm = form;
const previewBackground = previewForm.background; const previewBackground = previewForm.background;
const previewSurfaceCandidate = previewForm.surface || previewBackground; const previewSurfaceCandidate = previewForm.surface || previewBackground;
const backgroundLuminance = relativeLuminance(previewBackground); const backgroundLuminance = relativeLuminance(previewBackground);
@@ -206,7 +245,7 @@ export default function MobileBrandingPage() {
const brandingAllowed = isBrandingAllowed(event ?? null); const brandingAllowed = isBrandingAllowed(event ?? null);
const customWatermarkAllowed = watermarkAllowed && brandingAllowed; const customWatermarkAllowed = watermarkAllowed && brandingAllowed;
const watermarkLocked = watermarkAllowed && !brandingAllowed; const watermarkLocked = watermarkAllowed && !brandingAllowed;
const brandingDisabled = !brandingAllowed || form.useDefaultBranding; const brandingDisabled = !brandingAllowed;
React.useEffect(() => { React.useEffect(() => {
setWatermarkForm((prev) => { setWatermarkForm((prev) => {
@@ -243,7 +282,6 @@ export default function MobileBrandingPage() {
settings.branding = { settings.branding = {
...(typeof settings.branding === 'object' ? (settings.branding as Record<string, unknown>) : {}), ...(typeof settings.branding === 'object' ? (settings.branding as Record<string, unknown>) : {}),
use_default_branding: form.useDefaultBranding,
primary_color: form.primary, primary_color: form.primary,
secondary_color: form.accent, secondary_color: form.accent,
accent_color: form.accent, accent_color: form.accent,
@@ -334,7 +372,7 @@ export default function MobileBrandingPage() {
function handleReset() { function handleReset() {
if (event) { if (event) {
setForm(extractBrandingForm(event.settings ?? {}, BRANDING_FORM_DEFAULTS)); setForm(extractBrandingForm(event.settings ?? {}, resolvedDefaults));
setWatermarkForm(extractWatermark(event)); setWatermarkForm(extractWatermark(event));
} }
} }
@@ -662,36 +700,7 @@ export default function MobileBrandingPage() {
/> />
) : null} ) : null}
<MobileCard space="$3"> <>
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.branding.source', 'Branding Source')}
</Text>
<Text fontSize="$sm" color={muted}>
{t('events.branding.sourceHint', 'Use the default branding or customize this event only.')}
</Text>
<XStack space="$2">
<ModeButton
label={t('events.branding.useDefault', 'Default')}
active={form.useDefaultBranding}
onPress={() => setForm((prev) => ({ ...prev, useDefaultBranding: true }))}
disabled={!brandingAllowed}
/>
<ModeButton
label={t('events.branding.useCustom', 'This event')}
active={!form.useDefaultBranding}
onPress={() => setForm((prev) => ({ ...prev, useDefaultBranding: false }))}
disabled={!brandingAllowed}
/>
</XStack>
<Text fontSize="$xs" color={muted}>
{form.useDefaultBranding
? t('events.branding.usingDefault', 'Account-Branding aktiv')
: t('events.branding.usingCustom', 'Event-Branding aktiv')}
</Text>
</MobileCard>
{form.useDefaultBranding ? null : (
<>
<MobileCard space="$3"> <MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color={textStrong}> <Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.branding.mode', 'Theme')} {t('events.branding.mode', 'Theme')}
@@ -1024,8 +1033,7 @@ export default function MobileBrandingPage() {
disabled={brandingDisabled} disabled={brandingDisabled}
/> />
</MobileCard> </MobileCard>
</> </>
)}
</> </>
) : ( ) : (
renderWatermarkTab() renderWatermarkTab()

View File

@@ -3,16 +3,26 @@ import { useTranslation } from 'react-i18next';
import { CheckCircle2, Lock, MailWarning, User } from 'lucide-react'; import { CheckCircle2, Lock, MailWarning, User } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks'; import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { MobileShell } from './components/MobileShell'; import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives'; import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
import { MobileField, MobileInput, MobileSelect } from './components/FormControls'; import { MobileField, MobileInput, MobileSelect, MobileColorInput } from './components/FormControls';
import { fetchTenantProfile, updateTenantProfile, type TenantAccountProfile } from '../api'; import {
fetchTenantProfile,
updateTenantProfile,
getTenantPackagesOverview,
getTenantSettings,
updateTenantSettings,
type TenantAccountProfile,
} from '../api';
import { getApiErrorMessage, getApiValidationMessage } from '../lib/apiError'; import { getApiErrorMessage, getApiValidationMessage } from '../lib/apiError';
import { ADMIN_PROFILE_PATH } from '../constants'; import { ADMIN_PROFILE_PATH } from '../constants';
import { useBackNavigation } from './hooks/useBackNavigation'; import { useBackNavigation } from './hooks/useBackNavigation';
import { useAdminTheme } from './theme'; import { useAdminTheme } from './theme';
import i18n from '../i18n'; import i18n from '../i18n';
import { extractBrandingForm, type BrandingFormValues } from '../lib/brandingForm';
import { DEFAULT_EVENT_BRANDING } from '@/guest/context/EventBrandingContext';
type ProfileFormState = { type ProfileFormState = {
name: string; name: string;
@@ -29,12 +39,46 @@ const LOCALE_OPTIONS = [
{ value: 'en', label: 'English' }, { value: 'en', label: 'English' },
]; ];
type TabKey = 'account' | 'branding';
const TENANT_BRANDING_DEFAULTS = {
primary: DEFAULT_EVENT_BRANDING.primaryColor,
accent: DEFAULT_EVENT_BRANDING.secondaryColor,
background: DEFAULT_EVENT_BRANDING.backgroundColor,
surface: DEFAULT_EVENT_BRANDING.palette?.surface ?? DEFAULT_EVENT_BRANDING.backgroundColor,
headingFont: DEFAULT_EVENT_BRANDING.typography?.heading ?? DEFAULT_EVENT_BRANDING.fontFamily ?? '',
bodyFont: DEFAULT_EVENT_BRANDING.typography?.body ?? DEFAULT_EVENT_BRANDING.fontFamily ?? '',
mode: DEFAULT_EVENT_BRANDING.mode ?? 'auto',
buttonStyle: DEFAULT_EVENT_BRANDING.buttons?.style ?? 'filled',
buttonRadius: DEFAULT_EVENT_BRANDING.buttons?.radius ?? 12,
buttonPrimary: DEFAULT_EVENT_BRANDING.buttons?.primary ?? DEFAULT_EVENT_BRANDING.primaryColor,
buttonSecondary: DEFAULT_EVENT_BRANDING.buttons?.secondary ?? DEFAULT_EVENT_BRANDING.secondaryColor,
linkColor: DEFAULT_EVENT_BRANDING.buttons?.linkColor ?? DEFAULT_EVENT_BRANDING.secondaryColor,
fontSize: DEFAULT_EVENT_BRANDING.typography?.sizePreset ?? 'm',
logoMode: DEFAULT_EVENT_BRANDING.logo?.mode ?? 'emoticon',
logoPosition: DEFAULT_EVENT_BRANDING.logo?.position ?? 'left',
logoSize: DEFAULT_EVENT_BRANDING.logo?.size ?? 'm',
};
const buildTenantBrandingFormBase = (): BrandingFormValues => ({
...TENANT_BRANDING_DEFAULTS,
logoDataUrl: '',
logoValue: '',
});
export default function MobileProfileAccountPage() { export default function MobileProfileAccountPage() {
const { t } = useTranslation('settings'); const { t } = useTranslation('settings');
const { text, muted, danger, subtle, primary, accentSoft } = useAdminTheme(); const { text, muted, danger, subtle, primary, accentSoft } = useAdminTheme();
const back = useBackNavigation(ADMIN_PROFILE_PATH); const back = useBackNavigation(ADMIN_PROFILE_PATH);
const [profile, setProfile] = React.useState<TenantAccountProfile | null>(null); const [profile, setProfile] = React.useState<TenantAccountProfile | null>(null);
const [activeTab, setActiveTab] = React.useState<TabKey>('account');
const [brandingAllowed, setBrandingAllowed] = React.useState(false);
const [brandingLoading, setBrandingLoading] = React.useState(true);
const [brandingSaving, setBrandingSaving] = React.useState(false);
const [brandingError, setBrandingError] = React.useState<string | null>(null);
const [tenantSettings, setTenantSettings] = React.useState<Record<string, unknown> | null>(null);
const [brandingForm, setBrandingForm] = React.useState<BrandingFormValues>(buildTenantBrandingFormBase);
const [form, setForm] = React.useState<ProfileFormState>({ const [form, setForm] = React.useState<ProfileFormState>({
name: '', name: '',
email: '', email: '',
@@ -48,6 +92,8 @@ export default function MobileProfileAccountPage() {
const [savingPassword, setSavingPassword] = React.useState(false); const [savingPassword, setSavingPassword] = React.useState(false);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const loadErrorMessage = t('profile.errors.load', 'Profil konnte nicht geladen werden.'); const loadErrorMessage = t('profile.errors.load', 'Profil konnte nicht geladen werden.');
const brandingLoadError = t('profile.errors.brandingLoad', 'Standard-Branding konnte nicht geladen werden.');
const brandingSaveError = t('profile.errors.brandingSave', 'Standard-Branding konnte nicht gespeichert werden.');
const dateFormatter = React.useMemo( const dateFormatter = React.useMemo(
() => () =>
@@ -80,6 +126,52 @@ export default function MobileProfileAccountPage() {
})(); })();
}, []); }, []);
React.useEffect(() => {
let active = true;
(async () => {
try {
const overview = await getTenantPackagesOverview();
if (!active) return;
setBrandingAllowed(Boolean(overview.activePackage?.branding_allowed));
} catch {
if (active) {
setBrandingAllowed(false);
}
}
})();
return () => {
active = false;
};
}, []);
React.useEffect(() => {
let active = true;
(async () => {
setBrandingLoading(true);
try {
const payload = await getTenantSettings();
if (!active) return;
const settings = (payload.settings ?? {}) as Record<string, unknown>;
setTenantSettings(settings);
setBrandingForm(extractBrandingForm(settings, TENANT_BRANDING_DEFAULTS));
setBrandingError(null);
} catch (err) {
if (active) {
setBrandingError(getApiErrorMessage(err, brandingLoadError));
}
} finally {
if (active) {
setBrandingLoading(false);
}
}
})();
return () => {
active = false;
};
}, [brandingLoadError]);
const verifiedAt = profile?.email_verified_at ? new Date(profile.email_verified_at) : null; const verifiedAt = profile?.email_verified_at ? new Date(profile.email_verified_at) : null;
const verifiedDate = verifiedAt ? dateFormatter.format(verifiedAt) : null; const verifiedDate = verifiedAt ? dateFormatter.format(verifiedAt) : null;
const emailStatusLabel = profile?.email_verified const emailStatusLabel = profile?.email_verified
@@ -88,6 +180,13 @@ export default function MobileProfileAccountPage() {
const emailHint = profile?.email_verified const emailHint = profile?.email_verified
? t('profile.status.verifiedHint', 'Bestätigt am {{date}}.', { date: verifiedDate ?? '' }) ? t('profile.status.verifiedHint', 'Bestätigt am {{date}}.', { date: verifiedDate ?? '' })
: t('profile.status.unverifiedHint', 'Bei Änderung der E-Mail senden wir dir automatisch eine neue Bestätigung.'); : t('profile.status.unverifiedHint', 'Bei Änderung der E-Mail senden wir dir automatisch eine neue Bestätigung.');
const brandingTabEnabled = brandingAllowed;
React.useEffect(() => {
if (!brandingTabEnabled && activeTab === 'branding') {
setActiveTab('account');
}
}, [brandingTabEnabled, activeTab]);
const buildPayload = (includePassword: boolean) => ({ const buildPayload = (includePassword: boolean) => ({
name: form.name.trim(), name: form.name.trim(),
@@ -140,10 +239,65 @@ export default function MobileProfileAccountPage() {
} }
}; };
const handleBrandingSave = async () => {
if (!tenantSettings) {
return;
}
setBrandingSaving(true);
try {
const existingBranding =
tenantSettings && typeof (tenantSettings as Record<string, unknown>).branding === 'object'
? ((tenantSettings as Record<string, unknown>).branding as Record<string, unknown>)
: {};
const settings = {
...tenantSettings,
branding: {
...existingBranding,
primary_color: brandingForm.primary,
secondary_color: brandingForm.accent,
accent_color: brandingForm.accent,
background_color: brandingForm.background,
surface_color: brandingForm.surface,
font_family: brandingForm.bodyFont,
heading_font: brandingForm.headingFont,
body_font: brandingForm.bodyFont,
font_size: brandingForm.fontSize,
mode: brandingForm.mode,
typography: {
...(typeof existingBranding.typography === 'object' ? (existingBranding.typography as Record<string, unknown>) : {}),
heading: brandingForm.headingFont,
body: brandingForm.bodyFont,
size: brandingForm.fontSize,
},
palette: {
...(typeof existingBranding.palette === 'object' ? (existingBranding.palette as Record<string, unknown>) : {}),
primary: brandingForm.primary,
secondary: brandingForm.accent,
background: brandingForm.background,
surface: brandingForm.surface,
},
},
};
const updated = await updateTenantSettings(settings);
const nextSettings = (updated.settings ?? {}) as Record<string, unknown>;
setTenantSettings(nextSettings);
setBrandingForm(extractBrandingForm(nextSettings, TENANT_BRANDING_DEFAULTS));
setBrandingError(null);
toast.success(t('profile.branding.updated', 'Standard-Branding gespeichert.'));
} catch (err) {
const message = getApiErrorMessage(err, brandingSaveError);
setBrandingError(message);
toast.error(message);
} finally {
setBrandingSaving(false);
}
};
const passwordReady = const passwordReady =
form.currentPassword.trim().length > 0 && form.currentPassword.trim().length > 0 &&
form.password.trim().length > 0 && form.password.trim().length > 0 &&
form.passwordConfirmation.trim().length > 0; form.passwordConfirmation.trim().length > 0;
const brandingDisabled = brandingLoading || brandingSaving;
return ( return (
<MobileShell <MobileShell
@@ -151,152 +305,340 @@ export default function MobileProfileAccountPage() {
title={t('profile.title', 'Profil')} title={t('profile.title', 'Profil')}
onBack={back} onBack={back}
> >
{error ? ( {brandingTabEnabled ? (
<MobileCard> <XStack space="$2">
<Text fontWeight="700" color={danger}> <TabButton
{error} label={t('profile.tabs.account', 'Account')}
</Text> active={activeTab === 'account'}
</MobileCard> onPress={() => setActiveTab('account')}
/>
<TabButton
label={t('profile.tabs.branding', 'Standard-Branding')}
active={activeTab === 'branding'}
onPress={() => setActiveTab('branding')}
/>
</XStack>
) : null} ) : null}
<MobileCard space="$3"> {activeTab === 'branding' && brandingTabEnabled ? (
<XStack alignItems="center" space="$3"> <>
<XStack {brandingError ? (
width={48} <MobileCard>
height={48} <Text fontWeight="700" color={danger}>
borderRadius={16} {brandingError}
alignItems="center" </Text>
justifyContent="center" </MobileCard>
backgroundColor={accentSoft} ) : null}
>
<User size={20} color={primary} /> <MobileCard space="$3">
</XStack>
<YStack space="$1">
<Text fontSize="$md" fontWeight="800" color={text}> <Text fontSize="$md" fontWeight="800" color={text}>
{form.name || profile?.email || t('profile.title', 'Profil')} {t('profile.branding.title', 'Standard-Branding')}
</Text> </Text>
<Text fontSize="$sm" color={muted}> <Text fontSize="$sm" color={muted}>
{form.email || profile?.email || '—'} {t('profile.branding.hint', 'Diese Werte dienen als Vorschlag für neue Events und beschleunigen die Einrichtung.')}
</Text> </Text>
</YStack> </MobileCard>
</XStack>
<XStack alignItems="center" space="$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 space="$3"> <MobileCard space="$3">
<XStack alignItems="center" space="$2"> <Text fontSize="$md" fontWeight="800" color={text}>
<User size={16} color={text} /> {t('profile.branding.theme', 'Theme')}
<Text fontSize="$md" fontWeight="800" color={text}> </Text>
{t('profile.sections.account.heading', 'Account-Informationen')} <YStack space="$3">
</Text> <MobileField label={t('events.branding.mode', 'Theme')}>
</XStack> <MobileSelect
<Text fontSize="$sm" color={muted}> value={brandingForm.mode}
{t('profile.sections.account.description', 'Passe Anzeigename, E-Mail-Adresse und Sprache der Admin-Oberfläche an.')} onChange={(event) => setBrandingForm((prev) => ({ ...prev, mode: event.target.value as BrandingFormValues['mode'] }))}
</Text> disabled={brandingDisabled}
{loading ? ( >
<Text fontSize="$sm" color={muted}> <option value="light">{t('events.branding.modeLight', 'Light')}</option>
{t('profile.loading', 'Lädt ...')} <option value="auto">{t('events.branding.modeAuto', 'Auto')}</option>
</Text> <option value="dark">{t('events.branding.modeDark', 'Dark')}</option>
) : ( </MobileSelect>
<YStack space="$3"> </MobileField>
<MobileField label={t('profile.fields.name', 'Anzeigename')}> <MobileField label={t('events.branding.fontSize', 'Font Size')}>
<MobileInput <MobileSelect
value={form.name} value={brandingForm.fontSize}
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))} onChange={(event) => setBrandingForm((prev) => ({ ...prev, fontSize: event.target.value as BrandingFormValues['fontSize'] }))}
placeholder={t('profile.placeholders.name', 'z. B. Hochzeitsplanung Schmidt')} disabled={brandingDisabled}
hasError={false} >
/> <option value="s">{t('events.branding.fontSizeSmall', 'S')}</option>
</MobileField> <option value="m">{t('events.branding.fontSizeMedium', 'M')}</option>
<MobileField label={t('profile.fields.email', 'E-Mail-Adresse')}> <option value="l">{t('events.branding.fontSizeLarge', 'L')}</option>
<MobileInput </MobileSelect>
value={form.email} </MobileField>
onChange={(event) => setForm((prev) => ({ ...prev, email: event.target.value }))} </YStack>
placeholder="mail@beispiel.de" </MobileCard>
type="email"
hasError={false} <MobileCard space="$3">
/> <Text fontSize="$md" fontWeight="800" color={text}>
</MobileField> {t('events.branding.colors', 'Colors')}
<MobileField label={t('profile.fields.locale', 'Bevorzugte Sprache')}> </Text>
<MobileSelect <YStack space="$3">
value={form.preferredLocale} <ColorField
onChange={(event) => setForm((prev) => ({ ...prev, preferredLocale: event.target.value }))} label={t('events.branding.primary', 'Primary Color')}
> value={brandingForm.primary}
{LOCALE_OPTIONS.map((option) => ( onChange={(value) => setBrandingForm((prev) => ({ ...prev, primary: value }))}
<option key={option.value} value={option.value}> disabled={brandingDisabled}
{option.label ?? t(option.labelKey, option.fallback)} />
</option> <ColorField
))} label={t('events.branding.accent', 'Accent Color')}
</MobileSelect> value={brandingForm.accent}
</MobileField> onChange={(value) => setBrandingForm((prev) => ({ ...prev, accent: value }))}
<CTAButton disabled={brandingDisabled}
label={t('profile.actions.save', 'Speichern')} />
onPress={handleAccountSave} <ColorField
disabled={savingAccount || loading} label={t('events.branding.backgroundColor', 'Background Color')}
loading={savingAccount} value={brandingForm.background}
/> onChange={(value) => setBrandingForm((prev) => ({ ...prev, background: value }))}
</YStack> disabled={brandingDisabled}
)} />
</MobileCard> <ColorField
label={t('events.branding.surfaceColor', 'Surface Color')}
value={brandingForm.surface}
onChange={(value) => setBrandingForm((prev) => ({ ...prev, surface: value }))}
disabled={brandingDisabled}
/>
</YStack>
</MobileCard>
<MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color={text}>
{t('events.branding.fonts', 'Fonts')}
</Text>
<YStack space="$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>
<MobileCard space="$3">
<XStack alignItems="center" space="$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 space="$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 <CTAButton
label={t('profile.actions.updatePassword', 'Passwort aktualisieren')} label={brandingSaving ? t('profile.branding.saving', 'Saving...') : t('profile.branding.save', 'Save defaults')}
onPress={handlePasswordSave} onPress={handleBrandingSave}
disabled={!passwordReady || savingPassword || loading} disabled={brandingDisabled}
loading={savingPassword} loading={brandingSaving}
tone="ghost"
/> />
</YStack> </>
</MobileCard> ) : (
<>
{error ? (
<MobileCard>
<Text fontWeight="700" color={danger}>
{error}
</Text>
</MobileCard>
) : null}
<MobileCard space="$3">
<XStack alignItems="center" space="$3">
<XStack
width={48}
height={48}
borderRadius={16}
alignItems="center"
justifyContent="center"
backgroundColor={accentSoft}
>
<User size={20} color={primary} />
</XStack>
<YStack space="$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" space="$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 space="$3">
<XStack alignItems="center" space="$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 space="$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 space="$3">
<XStack alignItems="center" space="$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 space="$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>
</>
)}
</MobileShell> </MobileShell>
); );
} }
function TabButton({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
const { primary, surfaceMuted, border, surface, text } = useAdminTheme();
return (
<Pressable onPress={onPress} style={{ flex: 1 }}>
<XStack
alignItems="center"
justifyContent="center"
paddingVertical="$2.5"
borderRadius={12}
backgroundColor={active ? primary : surfaceMuted}
borderWidth={1}
borderColor={active ? primary : border}
>
<Text fontSize="$sm" color={active ? surface : text} fontWeight="700">
{label}
</Text>
</XStack>
</Pressable>
);
}
function ColorField({
label,
value,
onChange,
disabled,
}: {
label: string;
value: string;
onChange: (next: string) => void;
disabled?: boolean;
}) {
const { text, muted } = useAdminTheme();
return (
<YStack space="$2" opacity={disabled ? 0.6 : 1}>
<Text fontSize="$sm" fontWeight="700" color={text}>
{label}
</Text>
<XStack alignItems="center" space="$2">
<MobileColorInput
value={value}
onChange={(event) => onChange(event.target.value)}
disabled={disabled}
/>
<Text fontSize="$sm" color={muted}>
{value}
</Text>
</XStack>
</YStack>
);
}

View File

@@ -10,9 +10,11 @@ vi.mock('react-router-dom', () => ({
useParams: () => ({ slug: 'demo-event' }), useParams: () => ({ slug: 'demo-event' }),
})); }));
const tMock = (key: string, fallback?: string) => fallback ?? key;
vi.mock('react-i18next', () => ({ vi.mock('react-i18next', () => ({
useTranslation: () => ({ useTranslation: () => ({
t: (key: string, fallback?: string) => fallback ?? key, t: tMock,
}), }),
initReactI18next: { initReactI18next: {
type: '3rdParty', type: '3rdParty',
@@ -152,35 +154,18 @@ describe('MobileBrandingPage', () => {
getTenantSettingsMock.mockResolvedValue({ settings: {} }); getTenantSettingsMock.mockResolvedValue({ settings: {} });
}); });
it('hides custom branding controls when default branding is active', async () => { it('shows branding controls when the event loads', async () => {
getEventMock.mockResolvedValueOnce({ getEventMock.mockResolvedValueOnce({
...baseEvent, ...baseEvent,
settings: { branding: { use_default_branding: true } }, settings: { branding: {} },
}); });
render(<MobileBrandingPage />); render(<MobileBrandingPage />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Branding Source')).toBeInTheDocument(); expect(screen.getByText('Theme')).toBeInTheDocument();
}); });
expect(screen.queryByText('Theme')).toBeNull();
expect(screen.queryByText('Colors')).toBeNull();
});
it('shows custom branding controls when event branding is active', async () => {
getEventMock.mockResolvedValueOnce({
...baseEvent,
settings: { branding: { use_default_branding: false } },
});
render(<MobileBrandingPage />);
await waitFor(() => {
expect(screen.getByText('Branding Source')).toBeInTheDocument();
});
expect(screen.getByText('Theme')).toBeInTheDocument();
expect(screen.getByText('Colors')).toBeInTheDocument(); expect(screen.getByText('Colors')).toBeInTheDocument();
}); });
@@ -210,7 +195,7 @@ describe('MobileBrandingPage', () => {
render(<MobileBrandingPage />); render(<MobileBrandingPage />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Branding Source')).toBeInTheDocument(); expect(screen.getByText('Theme')).toBeInTheDocument();
}); });
fireEvent.click(screen.getByText('Wasserzeichen')); fireEvent.click(screen.getByText('Wasserzeichen'));

View File

@@ -11,6 +11,9 @@ vi.mock('../hooks/useBackNavigation', () => ({
vi.mock('../../api', () => ({ vi.mock('../../api', () => ({
fetchTenantProfile: vi.fn(), fetchTenantProfile: vi.fn(),
updateTenantProfile: vi.fn(), updateTenantProfile: vi.fn(),
getTenantPackagesOverview: vi.fn(),
getTenantSettings: vi.fn(),
updateTenantSettings: vi.fn(),
})); }));
vi.mock('react-hot-toast', () => ({ vi.mock('react-hot-toast', () => ({
@@ -47,6 +50,7 @@ vi.mock('../components/FormControls', () => ({
MobileInput: ({ hasError, compact, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { hasError?: boolean; compact?: boolean }) => ( MobileInput: ({ hasError, compact, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { hasError?: boolean; compact?: boolean }) => (
<input {...props} /> <input {...props} />
), ),
MobileColorInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
MobileSelect: ({ children, ...props }: { children: React.ReactNode }) => <select {...props}>{children}</select>, MobileSelect: ({ children, ...props }: { children: React.ReactNode }) => <select {...props}>{children}</select>,
})); }));
@@ -59,6 +63,14 @@ vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>, SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
})); }));
vi.mock('@tamagui/react-native-web-lite', () => ({
Pressable: ({ children, ...props }: { children: React.ReactNode } & React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button type="button" {...props}>
{children}
</button>
),
}));
vi.mock('../theme', () => ({ vi.mock('../theme', () => ({
useAdminTheme: () => ({ useAdminTheme: () => ({
text: '#111827', text: '#111827',
@@ -77,7 +89,7 @@ vi.mock('../theme', () => ({
}), }),
})); }));
import { fetchTenantProfile, updateTenantProfile } from '../../api'; import { fetchTenantProfile, updateTenantProfile, getTenantPackagesOverview, getTenantSettings } from '../../api';
import MobileProfileAccountPage from '../ProfileAccountPage'; import MobileProfileAccountPage from '../ProfileAccountPage';
const profileFixture = { const profileFixture = {
@@ -90,6 +102,14 @@ const profileFixture = {
}; };
describe('MobileProfileAccountPage', () => { describe('MobileProfileAccountPage', () => {
beforeEach(() => {
vi.mocked(getTenantPackagesOverview).mockResolvedValue({
packages: [],
activePackage: { branding_allowed: false },
});
vi.mocked(getTenantSettings).mockResolvedValue({ id: 1, settings: {}, updated_at: null });
});
it('submits account updates with name, email, and locale', async () => { it('submits account updates with name, email, and locale', async () => {
vi.mocked(fetchTenantProfile).mockResolvedValue(profileFixture); vi.mocked(fetchTenantProfile).mockResolvedValue(profileFixture);
vi.mocked(updateTenantProfile).mockResolvedValue(profileFixture); vi.mocked(updateTenantProfile).mockResolvedValue(profileFixture);

View File

@@ -84,7 +84,7 @@ class EventBrandingResponseTest extends TestCase
$response->assertJsonPath('branding.mode', 'dark'); $response->assertJsonPath('branding.mode', 'dark');
} }
public function test_it_uses_tenant_branding_when_use_default_flag_is_enabled(): void public function test_it_does_not_override_event_branding_with_tenant_defaults(): void
{ {
$package = Package::factory()->create([ $package = Package::factory()->create([
'branding_allowed' => true, 'branding_allowed' => true,
@@ -129,9 +129,8 @@ class EventBrandingResponseTest extends TestCase
$response->assertOk(); $response->assertOk();
$response->assertJsonPath('branding.use_default_branding', true); $response->assertJsonPath('branding.use_default_branding', true);
$response->assertJsonPath('branding.primary_color', '#abcdef'); $response->assertJsonPath('branding.primary_color', '#000000');
$response->assertJsonPath('branding.secondary_color', '#fedcba'); $response->assertJsonPath('branding.secondary_color', '#111111');
$response->assertJsonPath('branding.buttons.radius', 8);
} }
public function test_branding_asset_uses_public_disk(): void public function test_branding_asset_uses_public_disk(): void