Adjust branding defaults and tenant presets
This commit is contained in:
@@ -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']),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,35 +700,6 @@ 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}>
|
||||||
@@ -1025,7 +1034,6 @@ export default function MobileBrandingPage() {
|
|||||||
/>
|
/>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
</>
|
</>
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
renderWatermarkTab()
|
renderWatermarkTab()
|
||||||
|
|||||||
@@ -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,6 +305,137 @@ export default function MobileProfileAccountPage() {
|
|||||||
title={t('profile.title', 'Profil')}
|
title={t('profile.title', 'Profil')}
|
||||||
onBack={back}
|
onBack={back}
|
||||||
>
|
>
|
||||||
|
{brandingTabEnabled ? (
|
||||||
|
<XStack space="$2">
|
||||||
|
<TabButton
|
||||||
|
label={t('profile.tabs.account', 'Account')}
|
||||||
|
active={activeTab === 'account'}
|
||||||
|
onPress={() => setActiveTab('account')}
|
||||||
|
/>
|
||||||
|
<TabButton
|
||||||
|
label={t('profile.tabs.branding', 'Standard-Branding')}
|
||||||
|
active={activeTab === 'branding'}
|
||||||
|
onPress={() => setActiveTab('branding')}
|
||||||
|
/>
|
||||||
|
</XStack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeTab === 'branding' && brandingTabEnabled ? (
|
||||||
|
<>
|
||||||
|
{brandingError ? (
|
||||||
|
<MobileCard>
|
||||||
|
<Text fontWeight="700" color={danger}>
|
||||||
|
{brandingError}
|
||||||
|
</Text>
|
||||||
|
</MobileCard>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<MobileCard space="$3">
|
||||||
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||||
|
{t('profile.branding.title', 'Standard-Branding')}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="$sm" color={muted}>
|
||||||
|
{t('profile.branding.hint', 'Diese Werte dienen als Vorschlag für neue Events und beschleunigen die Einrichtung.')}
|
||||||
|
</Text>
|
||||||
|
</MobileCard>
|
||||||
|
|
||||||
|
<MobileCard space="$3">
|
||||||
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||||
|
{t('profile.branding.theme', 'Theme')}
|
||||||
|
</Text>
|
||||||
|
<YStack space="$3">
|
||||||
|
<MobileField label={t('events.branding.mode', 'Theme')}>
|
||||||
|
<MobileSelect
|
||||||
|
value={brandingForm.mode}
|
||||||
|
onChange={(event) => setBrandingForm((prev) => ({ ...prev, mode: event.target.value as BrandingFormValues['mode'] }))}
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
>
|
||||||
|
<option value="light">{t('events.branding.modeLight', 'Light')}</option>
|
||||||
|
<option value="auto">{t('events.branding.modeAuto', 'Auto')}</option>
|
||||||
|
<option value="dark">{t('events.branding.modeDark', 'Dark')}</option>
|
||||||
|
</MobileSelect>
|
||||||
|
</MobileField>
|
||||||
|
<MobileField label={t('events.branding.fontSize', 'Font Size')}>
|
||||||
|
<MobileSelect
|
||||||
|
value={brandingForm.fontSize}
|
||||||
|
onChange={(event) => setBrandingForm((prev) => ({ ...prev, fontSize: event.target.value as BrandingFormValues['fontSize'] }))}
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
>
|
||||||
|
<option value="s">{t('events.branding.fontSizeSmall', 'S')}</option>
|
||||||
|
<option value="m">{t('events.branding.fontSizeMedium', 'M')}</option>
|
||||||
|
<option value="l">{t('events.branding.fontSizeLarge', 'L')}</option>
|
||||||
|
</MobileSelect>
|
||||||
|
</MobileField>
|
||||||
|
</YStack>
|
||||||
|
</MobileCard>
|
||||||
|
|
||||||
|
<MobileCard space="$3">
|
||||||
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||||
|
{t('events.branding.colors', 'Colors')}
|
||||||
|
</Text>
|
||||||
|
<YStack space="$3">
|
||||||
|
<ColorField
|
||||||
|
label={t('events.branding.primary', 'Primary Color')}
|
||||||
|
value={brandingForm.primary}
|
||||||
|
onChange={(value) => setBrandingForm((prev) => ({ ...prev, primary: value }))}
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
/>
|
||||||
|
<ColorField
|
||||||
|
label={t('events.branding.accent', 'Accent Color')}
|
||||||
|
value={brandingForm.accent}
|
||||||
|
onChange={(value) => setBrandingForm((prev) => ({ ...prev, accent: value }))}
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
/>
|
||||||
|
<ColorField
|
||||||
|
label={t('events.branding.backgroundColor', 'Background Color')}
|
||||||
|
value={brandingForm.background}
|
||||||
|
onChange={(value) => setBrandingForm((prev) => ({ ...prev, background: value }))}
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
/>
|
||||||
|
<ColorField
|
||||||
|
label={t('events.branding.surfaceColor', 'Surface Color')}
|
||||||
|
value={brandingForm.surface}
|
||||||
|
onChange={(value) => setBrandingForm((prev) => ({ ...prev, surface: value }))}
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
/>
|
||||||
|
</YStack>
|
||||||
|
</MobileCard>
|
||||||
|
|
||||||
|
<MobileCard 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>
|
||||||
|
|
||||||
|
<CTAButton
|
||||||
|
label={brandingSaving ? t('profile.branding.saving', 'Saving...') : t('profile.branding.save', 'Save defaults')}
|
||||||
|
onPress={handleBrandingSave}
|
||||||
|
disabled={brandingDisabled}
|
||||||
|
loading={brandingSaving}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{error ? (
|
{error ? (
|
||||||
<MobileCard>
|
<MobileCard>
|
||||||
<Text fontWeight="700" color={danger}>
|
<Text fontWeight="700" color={danger}>
|
||||||
@@ -270,7 +555,10 @@ export default function MobileProfileAccountPage() {
|
|||||||
hasError={false}
|
hasError={false}
|
||||||
/>
|
/>
|
||||||
</MobileField>
|
</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.')}>
|
<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
|
<MobileInput
|
||||||
value={form.password}
|
value={form.password}
|
||||||
onChange={(event) => setForm((prev) => ({ ...prev, password: event.target.value }))}
|
onChange={(event) => setForm((prev) => ({ ...prev, password: event.target.value }))}
|
||||||
@@ -297,6 +585,60 @@ export default function MobileProfileAccountPage() {
|
|||||||
/>
|
/>
|
||||||
</YStack>
|
</YStack>
|
||||||
</MobileCard>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.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('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'));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user