diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php index 381b92c..adec057 100644 --- a/app/Http/Controllers/Api/EventPublicController.php +++ b/app/Http/Controllers/Api/EventPublicController.php @@ -1093,12 +1093,8 @@ class EventPublicController extends BaseController $brandingAllowed = $this->determineBrandingAllowed($event); $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)); - $sources = $brandingAllowed - ? ($useDefault ? [$tenantBranding] : [$eventBranding, $tenantBranding]) - : [[]]; + $sources = $brandingAllowed ? [$eventBranding] : [[]]; $primary = $this->normalizeHexColor( $this->firstStringFromSources($sources, ['palette.primary', 'primary_color']), diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index 21e7bfe..090b5fb 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -2448,6 +2448,28 @@ export async function getTenantSettings(): Promise { }; } +export async function updateTenantSettings(settings: Record): Promise { + 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; updated_at?: string | null } }>( + response, + 'Failed to update tenant settings', + ); + const payload = (data.data ?? {}) as Record; + + return { + id: Number(payload.id ?? 0), + settings: (payload.settings ?? {}) as Record, + updated_at: (payload.updated_at ?? null) as string | null, + }; +} + export async function getTenantPackagesOverview(options?: { force?: boolean }): Promise<{ packages: TenantPackageSummary[]; activePackage: TenantPackageSummary | null; diff --git a/resources/js/admin/lib/__tests__/brandingForm.test.ts b/resources/js/admin/lib/__tests__/brandingForm.test.ts index 4015c1b..d484258 100644 --- a/resources/js/admin/lib/__tests__/brandingForm.test.ts +++ b/resources/js/admin/lib/__tests__/brandingForm.test.ts @@ -6,6 +6,8 @@ const defaults = { accent: '#222222', background: '#ffffff', surface: '#f0f0f0', + headingFont: 'Default Heading', + bodyFont: 'Default Body', mode: 'auto' as const, buttonStyle: 'filled' as const, buttonRadius: 12, @@ -87,7 +89,6 @@ describe('extractBrandingForm', () => { position: 'center', size: 'l', }, - use_default_branding: true, }, }; @@ -105,7 +106,6 @@ describe('extractBrandingForm', () => { expect(result.logoValue).toBe('🎉'); expect(result.logoPosition).toBe('center'); expect(result.logoSize).toBe('l'); - expect(result.useDefaultBranding).toBe(true); }); it('normalizes stored logo paths for previews', () => { diff --git a/resources/js/admin/lib/brandingForm.ts b/resources/js/admin/lib/brandingForm.ts index 747c1d1..b8b36a7 100644 --- a/resources/js/admin/lib/brandingForm.ts +++ b/resources/js/admin/lib/brandingForm.ts @@ -17,7 +17,6 @@ export type BrandingFormValues = { buttonPrimary: string; buttonSecondary: string; linkColor: string; - useDefaultBranding: boolean; }; export type BrandingFormDefaults = Pick< @@ -26,6 +25,8 @@ export type BrandingFormDefaults = Pick< | 'accent' | 'background' | 'surface' + | 'headingFont' + | 'bodyFont' | 'mode' | 'buttonStyle' | 'buttonRadius' @@ -130,8 +131,8 @@ export function extractBrandingForm(settings: unknown, defaults: BrandingFormDef accent, background, surface, - headingFont: headingFont ?? '', - bodyFont: bodyFont ?? '', + headingFont: headingFont ?? defaults.headingFont ?? '', + bodyFont: bodyFont ?? defaults.bodyFont ?? '', fontSize, logoDataUrl: resolveAssetPreviewUrl(logoUploadValue), logoValue, @@ -144,6 +145,5 @@ export function extractBrandingForm(settings: unknown, defaults: BrandingFormDef buttonPrimary, buttonSecondary, linkColor, - useDefaultBranding: branding.use_default_branding === true, }; } diff --git a/resources/js/admin/mobile/BrandingPage.tsx b/resources/js/admin/mobile/BrandingPage.tsx index 0cb5a00..6c10253 100644 --- a/resources/js/admin/mobile/BrandingPage.tsx +++ b/resources/js/admin/mobile/BrandingPage.tsx @@ -28,6 +28,8 @@ const BRANDING_FORM_DEFAULTS = { 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, @@ -40,14 +42,11 @@ const BRANDING_FORM_DEFAULTS = { logoSize: DEFAULT_EVENT_BRANDING.logo?.size ?? 'm', }; -const BRANDING_FORM_BASE: BrandingFormValues = { - ...BRANDING_FORM_DEFAULTS, - headingFont: '', - bodyFont: '', +const buildBrandingFormBase = (defaults: typeof BRANDING_FORM_DEFAULTS): BrandingFormValues => ({ + ...defaults, logoDataUrl: '', logoValue: '', - useDefaultBranding: false, -}; +}); const FONT_SIZE_SCALE: Record = { s: 0.94, @@ -61,6 +60,38 @@ const LOGO_SIZE_PREVIEW: Record = { 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 = | 'top-left' | 'top-center' @@ -95,7 +126,7 @@ export default function MobileBrandingPage() { const { textStrong, muted, subtle, border, primary, accentSoft, danger, surfaceMuted, surface } = useAdminTheme(); const [event, setEvent] = React.useState(null); - const [form, setForm] = React.useState(BRANDING_FORM_BASE); + const [form, setForm] = React.useState(() => buildBrandingFormBase(BRANDING_FORM_DEFAULTS)); const [watermarkForm, setWatermarkForm] = React.useState({ mode: 'base', assetPath: '', @@ -120,10 +151,13 @@ export default function MobileBrandingPage() { const [fontsLoaded, setFontsLoaded] = React.useState(false); const [tenantBranding, setTenantBranding] = React.useState(null); const [tenantBrandingLoaded, setTenantBrandingLoaded] = React.useState(false); + const [formInitialized, setFormInitialized] = React.useState(false); + const resolvedDefaults = React.useMemo(() => resolveBrandingDefaults(tenantBranding), [tenantBranding]); React.useEffect(() => { if (!slug) return; (async () => { + setFormInitialized(false); setLoading(true); try { const data = await getEvent(slug); @@ -132,7 +166,6 @@ export default function MobileBrandingPage() { return; } setEvent(data); - setForm(extractBrandingForm(data.settings ?? {}, BRANDING_FORM_DEFAULTS)); setWatermarkForm(extractWatermark(data)); setError(null); } catch (err) { @@ -145,6 +178,12 @@ export default function MobileBrandingPage() { })(); }, [slug, t]); + React.useEffect(() => { + if (!event || !tenantBrandingLoaded || formInitialized) return; + setForm(extractBrandingForm(event.settings ?? {}, resolvedDefaults)); + setFormInitialized(true); + }, [event, tenantBrandingLoaded, formInitialized, resolvedDefaults]); + React.useEffect(() => { if (!showFontsSheet || fontsLoaded) return; setFontsLoading(true); @@ -177,7 +216,7 @@ export default function MobileBrandingPage() { }; }, [tenantBrandingLoaded]); - const previewForm = form.useDefaultBranding && tenantBranding ? tenantBranding : form; + const previewForm = form; const previewBackground = previewForm.background; const previewSurfaceCandidate = previewForm.surface || previewBackground; const backgroundLuminance = relativeLuminance(previewBackground); @@ -206,7 +245,7 @@ export default function MobileBrandingPage() { const brandingAllowed = isBrandingAllowed(event ?? null); const customWatermarkAllowed = watermarkAllowed && brandingAllowed; const watermarkLocked = watermarkAllowed && !brandingAllowed; - const brandingDisabled = !brandingAllowed || form.useDefaultBranding; + const brandingDisabled = !brandingAllowed; React.useEffect(() => { setWatermarkForm((prev) => { @@ -243,7 +282,6 @@ export default function MobileBrandingPage() { settings.branding = { ...(typeof settings.branding === 'object' ? (settings.branding as Record) : {}), - use_default_branding: form.useDefaultBranding, primary_color: form.primary, secondary_color: form.accent, accent_color: form.accent, @@ -334,7 +372,7 @@ export default function MobileBrandingPage() { function handleReset() { if (event) { - setForm(extractBrandingForm(event.settings ?? {}, BRANDING_FORM_DEFAULTS)); + setForm(extractBrandingForm(event.settings ?? {}, resolvedDefaults)); setWatermarkForm(extractWatermark(event)); } } @@ -662,36 +700,7 @@ export default function MobileBrandingPage() { /> ) : null} - - - {t('events.branding.source', 'Branding Source')} - - - {t('events.branding.sourceHint', 'Use the default branding or customize this event only.')} - - - setForm((prev) => ({ ...prev, useDefaultBranding: true }))} - disabled={!brandingAllowed} - /> - setForm((prev) => ({ ...prev, useDefaultBranding: false }))} - disabled={!brandingAllowed} - /> - - - {form.useDefaultBranding - ? t('events.branding.usingDefault', 'Account-Branding aktiv') - : t('events.branding.usingCustom', 'Event-Branding aktiv')} - - - - {form.useDefaultBranding ? null : ( - <> + <> {t('events.branding.mode', 'Theme')} @@ -1024,8 +1033,7 @@ export default function MobileBrandingPage() { disabled={brandingDisabled} /> - - )} + ) : ( renderWatermarkTab() diff --git a/resources/js/admin/mobile/ProfileAccountPage.tsx b/resources/js/admin/mobile/ProfileAccountPage.tsx index 918ce4b..12ea23d 100644 --- a/resources/js/admin/mobile/ProfileAccountPage.tsx +++ b/resources/js/admin/mobile/ProfileAccountPage.tsx @@ -3,16 +3,26 @@ import { useTranslation } from 'react-i18next'; import { CheckCircle2, Lock, MailWarning, User } from 'lucide-react'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; +import { Pressable } from '@tamagui/react-native-web-lite'; import toast from 'react-hot-toast'; import { MobileShell } from './components/MobileShell'; import { MobileCard, CTAButton, PillBadge } from './components/Primitives'; -import { MobileField, MobileInput, MobileSelect } from './components/FormControls'; -import { fetchTenantProfile, updateTenantProfile, type TenantAccountProfile } from '../api'; +import { MobileField, MobileInput, MobileSelect, MobileColorInput } from './components/FormControls'; +import { + fetchTenantProfile, + updateTenantProfile, + getTenantPackagesOverview, + getTenantSettings, + updateTenantSettings, + type TenantAccountProfile, +} from '../api'; import { getApiErrorMessage, getApiValidationMessage } from '../lib/apiError'; import { ADMIN_PROFILE_PATH } from '../constants'; import { useBackNavigation } from './hooks/useBackNavigation'; import { useAdminTheme } from './theme'; import i18n from '../i18n'; +import { extractBrandingForm, type BrandingFormValues } from '../lib/brandingForm'; +import { DEFAULT_EVENT_BRANDING } from '@/guest/context/EventBrandingContext'; type ProfileFormState = { name: string; @@ -29,12 +39,46 @@ const LOCALE_OPTIONS = [ { 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() { const { t } = useTranslation('settings'); const { text, muted, danger, subtle, primary, accentSoft } = useAdminTheme(); const back = useBackNavigation(ADMIN_PROFILE_PATH); const [profile, setProfile] = React.useState(null); + const [activeTab, setActiveTab] = React.useState('account'); + const [brandingAllowed, setBrandingAllowed] = React.useState(false); + const [brandingLoading, setBrandingLoading] = React.useState(true); + const [brandingSaving, setBrandingSaving] = React.useState(false); + const [brandingError, setBrandingError] = React.useState(null); + const [tenantSettings, setTenantSettings] = React.useState | null>(null); + const [brandingForm, setBrandingForm] = React.useState(buildTenantBrandingFormBase); const [form, setForm] = React.useState({ name: '', email: '', @@ -48,6 +92,8 @@ export default function MobileProfileAccountPage() { const [savingPassword, setSavingPassword] = React.useState(false); const [error, setError] = React.useState(null); 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( () => @@ -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; + 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 verifiedDate = verifiedAt ? dateFormatter.format(verifiedAt) : null; const emailStatusLabel = profile?.email_verified @@ -88,6 +180,13 @@ export default function MobileProfileAccountPage() { const emailHint = profile?.email_verified ? 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.'); + const brandingTabEnabled = brandingAllowed; + + React.useEffect(() => { + if (!brandingTabEnabled && activeTab === 'branding') { + setActiveTab('account'); + } + }, [brandingTabEnabled, activeTab]); const buildPayload = (includePassword: boolean) => ({ 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).branding === 'object' + ? ((tenantSettings as Record).branding as Record) + : {}; + 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) : {}), + heading: brandingForm.headingFont, + body: brandingForm.bodyFont, + size: brandingForm.fontSize, + }, + palette: { + ...(typeof existingBranding.palette === 'object' ? (existingBranding.palette as Record) : {}), + primary: brandingForm.primary, + secondary: brandingForm.accent, + background: brandingForm.background, + surface: brandingForm.surface, + }, + }, + }; + const updated = await updateTenantSettings(settings); + const nextSettings = (updated.settings ?? {}) as Record; + 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 = form.currentPassword.trim().length > 0 && form.password.trim().length > 0 && form.passwordConfirmation.trim().length > 0; + const brandingDisabled = brandingLoading || brandingSaving; return ( - {error ? ( - - - {error} - - + {brandingTabEnabled ? ( + + setActiveTab('account')} + /> + setActiveTab('branding')} + /> + ) : null} - - - - - - + {activeTab === 'branding' && brandingTabEnabled ? ( + <> + {brandingError ? ( + + + {brandingError} + + + ) : null} + + - {form.name || profile?.email || t('profile.title', 'Profil')} + {t('profile.branding.title', 'Standard-Branding')} - {form.email || profile?.email || '—'} + {t('profile.branding.hint', 'Diese Werte dienen als Vorschlag für neue Events und beschleunigen die Einrichtung.')} - - - - {profile?.email_verified ? ( - - ) : ( - - )} - - {emailStatusLabel} - - - {emailHint} - - - + - - - - - {t('profile.sections.account.heading', 'Account-Informationen')} - - - - {t('profile.sections.account.description', 'Passe Anzeigename, E-Mail-Adresse und Sprache der Admin-Oberfläche an.')} - - {loading ? ( - - {t('profile.loading', 'Lädt ...')} - - ) : ( - - - setForm((prev) => ({ ...prev, name: event.target.value }))} - placeholder={t('profile.placeholders.name', 'z. B. Hochzeitsplanung Schmidt')} - hasError={false} - /> - - - setForm((prev) => ({ ...prev, email: event.target.value }))} - placeholder="mail@beispiel.de" - type="email" - hasError={false} - /> - - - setForm((prev) => ({ ...prev, preferredLocale: event.target.value }))} - > - {LOCALE_OPTIONS.map((option) => ( - - ))} - - - - - )} - + + + {t('profile.branding.theme', 'Theme')} + + + + setBrandingForm((prev) => ({ ...prev, mode: event.target.value as BrandingFormValues['mode'] }))} + disabled={brandingDisabled} + > + + + + + + + setBrandingForm((prev) => ({ ...prev, fontSize: event.target.value as BrandingFormValues['fontSize'] }))} + disabled={brandingDisabled} + > + + + + + + + + + + + {t('events.branding.colors', 'Colors')} + + + setBrandingForm((prev) => ({ ...prev, primary: value }))} + disabled={brandingDisabled} + /> + setBrandingForm((prev) => ({ ...prev, accent: value }))} + disabled={brandingDisabled} + /> + setBrandingForm((prev) => ({ ...prev, background: value }))} + disabled={brandingDisabled} + /> + setBrandingForm((prev) => ({ ...prev, surface: value }))} + disabled={brandingDisabled} + /> + + + + + + {t('events.branding.fonts', 'Fonts')} + + + + setBrandingForm((prev) => ({ ...prev, headingFont: event.target.value }))} + placeholder={t('events.branding.headingFontPlaceholder', 'Playfair Display')} + hasError={false} + disabled={brandingDisabled} + /> + + + setBrandingForm((prev) => ({ ...prev, bodyFont: event.target.value }))} + placeholder={t('events.branding.bodyFontPlaceholder', 'Montserrat')} + hasError={false} + disabled={brandingDisabled} + /> + + + - - - - - {t('profile.sections.password.heading', 'Passwort ändern')} - - - - {t('profile.sections.password.description', 'Wähle ein sicheres Passwort, um deinen Admin-Zugang zu schützen.')} - - - - setForm((prev) => ({ ...prev, currentPassword: event.target.value }))} - placeholder="••••••••" - type="password" - hasError={false} - /> - - - setForm((prev) => ({ ...prev, password: event.target.value }))} - placeholder="••••••••" - type="password" - hasError={false} - /> - - - setForm((prev) => ({ ...prev, passwordConfirmation: event.target.value }))} - placeholder="••••••••" - type="password" - hasError={false} - /> - - - + + ) : ( + <> + {error ? ( + + + {error} + + + ) : null} + + + + + + + + + {form.name || profile?.email || t('profile.title', 'Profil')} + + + {form.email || profile?.email || '—'} + + + + + {profile?.email_verified ? ( + + ) : ( + + )} + + {emailStatusLabel} + + + {emailHint} + + + + + + + + + {t('profile.sections.account.heading', 'Account-Informationen')} + + + + {t('profile.sections.account.description', 'Passe Anzeigename, E-Mail-Adresse und Sprache der Admin-Oberfläche an.')} + + {loading ? ( + + {t('profile.loading', 'Lädt ...')} + + ) : ( + + + setForm((prev) => ({ ...prev, name: event.target.value }))} + placeholder={t('profile.placeholders.name', 'z. B. Hochzeitsplanung Schmidt')} + hasError={false} + /> + + + setForm((prev) => ({ ...prev, email: event.target.value }))} + placeholder="mail@beispiel.de" + type="email" + hasError={false} + /> + + + setForm((prev) => ({ ...prev, preferredLocale: event.target.value }))} + > + {LOCALE_OPTIONS.map((option) => ( + + ))} + + + + + )} + + + + + + + {t('profile.sections.password.heading', 'Passwort ändern')} + + + + {t('profile.sections.password.description', 'Wähle ein sicheres Passwort, um deinen Admin-Zugang zu schützen.')} + + + + setForm((prev) => ({ ...prev, currentPassword: event.target.value }))} + placeholder="••••••••" + type="password" + hasError={false} + /> + + + setForm((prev) => ({ ...prev, password: event.target.value }))} + placeholder="••••••••" + type="password" + hasError={false} + /> + + + setForm((prev) => ({ ...prev, passwordConfirmation: event.target.value }))} + placeholder="••••••••" + type="password" + hasError={false} + /> + + + + + + )} ); } + +function TabButton({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) { + const { primary, surfaceMuted, border, surface, text } = useAdminTheme(); + return ( + + + + {label} + + + + ); +} + +function ColorField({ + label, + value, + onChange, + disabled, +}: { + label: string; + value: string; + onChange: (next: string) => void; + disabled?: boolean; +}) { + const { text, muted } = useAdminTheme(); + return ( + + + {label} + + + onChange(event.target.value)} + disabled={disabled} + /> + + {value} + + + + ); +} diff --git a/resources/js/admin/mobile/__tests__/BrandingPage.test.tsx b/resources/js/admin/mobile/__tests__/BrandingPage.test.tsx index 6b4ea7c..c1131ea 100644 --- a/resources/js/admin/mobile/__tests__/BrandingPage.test.tsx +++ b/resources/js/admin/mobile/__tests__/BrandingPage.test.tsx @@ -10,9 +10,11 @@ vi.mock('react-router-dom', () => ({ useParams: () => ({ slug: 'demo-event' }), })); +const tMock = (key: string, fallback?: string) => fallback ?? key; + vi.mock('react-i18next', () => ({ useTranslation: () => ({ - t: (key: string, fallback?: string) => fallback ?? key, + t: tMock, }), initReactI18next: { type: '3rdParty', @@ -152,35 +154,18 @@ describe('MobileBrandingPage', () => { 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({ ...baseEvent, - settings: { branding: { use_default_branding: true } }, + settings: { branding: {} }, }); render(); 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(); - - await waitFor(() => { - expect(screen.getByText('Branding Source')).toBeInTheDocument(); - }); - - expect(screen.getByText('Theme')).toBeInTheDocument(); expect(screen.getByText('Colors')).toBeInTheDocument(); }); @@ -210,7 +195,7 @@ describe('MobileBrandingPage', () => { render(); await waitFor(() => { - expect(screen.getByText('Branding Source')).toBeInTheDocument(); + expect(screen.getByText('Theme')).toBeInTheDocument(); }); fireEvent.click(screen.getByText('Wasserzeichen')); diff --git a/resources/js/admin/mobile/__tests__/ProfileAccountPage.test.tsx b/resources/js/admin/mobile/__tests__/ProfileAccountPage.test.tsx index 4461ef6..880d958 100644 --- a/resources/js/admin/mobile/__tests__/ProfileAccountPage.test.tsx +++ b/resources/js/admin/mobile/__tests__/ProfileAccountPage.test.tsx @@ -11,6 +11,9 @@ vi.mock('../hooks/useBackNavigation', () => ({ vi.mock('../../api', () => ({ fetchTenantProfile: vi.fn(), updateTenantProfile: vi.fn(), + getTenantPackagesOverview: vi.fn(), + getTenantSettings: vi.fn(), + updateTenantSettings: vi.fn(), })); vi.mock('react-hot-toast', () => ({ @@ -47,6 +50,7 @@ vi.mock('../components/FormControls', () => ({ MobileInput: ({ hasError, compact, ...props }: React.InputHTMLAttributes & { hasError?: boolean; compact?: boolean }) => ( ), + MobileColorInput: (props: React.InputHTMLAttributes) => , MobileSelect: ({ children, ...props }: { children: React.ReactNode }) => , })); @@ -59,6 +63,14 @@ vi.mock('@tamagui/text', () => ({ SizableText: ({ children }: { children: React.ReactNode }) => {children}, })); +vi.mock('@tamagui/react-native-web-lite', () => ({ + Pressable: ({ children, ...props }: { children: React.ReactNode } & React.ButtonHTMLAttributes) => ( + + ), +})); + vi.mock('../theme', () => ({ useAdminTheme: () => ({ 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'; const profileFixture = { @@ -90,6 +102,14 @@ const profileFixture = { }; 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 () => { vi.mocked(fetchTenantProfile).mockResolvedValue(profileFixture); vi.mocked(updateTenantProfile).mockResolvedValue(profileFixture); diff --git a/tests/Feature/Api/Event/EventBrandingResponseTest.php b/tests/Feature/Api/Event/EventBrandingResponseTest.php index ca76c42..7c5cdc3 100644 --- a/tests/Feature/Api/Event/EventBrandingResponseTest.php +++ b/tests/Feature/Api/Event/EventBrandingResponseTest.php @@ -84,7 +84,7 @@ class EventBrandingResponseTest extends TestCase $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([ 'branding_allowed' => true, @@ -129,9 +129,8 @@ class EventBrandingResponseTest extends TestCase $response->assertOk(); $response->assertJsonPath('branding.use_default_branding', true); - $response->assertJsonPath('branding.primary_color', '#abcdef'); - $response->assertJsonPath('branding.secondary_color', '#fedcba'); - $response->assertJsonPath('branding.buttons.radius', 8); + $response->assertJsonPath('branding.primary_color', '#000000'); + $response->assertJsonPath('branding.secondary_color', '#111111'); } public function test_branding_asset_uses_public_disk(): void