Wire guest branding theme

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

View File

@@ -160,48 +160,32 @@ function applyThemeMode(mode: EventBranding['mode']) {
}
const root = document.documentElement;
const prefersDark = typeof window !== 'undefined'
const prefersDark = typeof window !== 'undefined' && typeof window.matchMedia === 'function'
? window.matchMedia('(prefers-color-scheme: dark)').matches
: false;
let storedTheme: 'light' | 'dark' | 'system' | null = null;
try {
const raw = localStorage.getItem('theme');
storedTheme = raw === 'light' || raw === 'dark' || raw === 'system' ? raw : null;
} catch {
storedTheme = null;
}
const applyDark = () => root.classList.add('dark');
const applyLight = () => root.classList.remove('dark');
if (mode === 'dark') {
applyDark();
root.style.colorScheme = 'dark';
return;
}
if (mode === 'light') {
applyLight();
return;
}
if (storedTheme === 'dark') {
applyDark();
return;
}
if (storedTheme === 'light') {
applyLight();
root.style.colorScheme = 'light';
return;
}
if (prefersDark) {
applyDark();
root.style.colorScheme = 'dark';
return;
}
applyLight();
root.style.colorScheme = 'light';
}
export function EventBrandingProvider({
@@ -214,6 +198,9 @@ export function EventBrandingProvider({
const resolved = useMemo(() => resolveBranding(branding), [branding]);
useEffect(() => {
if (typeof document !== 'undefined') {
document.documentElement.classList.add('guest-theme');
}
applyCssVariables(resolved);
const previousDark = typeof document !== 'undefined' ? document.documentElement.classList.contains('dark') : false;
applyThemeMode(resolved.mode ?? 'auto');
@@ -225,6 +212,7 @@ export function EventBrandingProvider({
} else {
document.documentElement.classList.remove('dark');
}
document.documentElement.classList.remove('guest-theme');
}
resetCssVariables();
applyCssVariables(DEFAULT_EVENT_BRANDING);

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { render, waitFor } from '@testing-library/react';
import { EventBrandingProvider } from '../EventBrandingContext';
import type { EventBranding } from '../../types/event-branding';
const sampleBranding: EventBranding = {
primaryColor: '#ff3366',
secondaryColor: '#ff99aa',
backgroundColor: '#fef2f2',
fontFamily: 'Montserrat, sans-serif',
logoUrl: null,
mode: 'dark',
};
describe('EventBrandingProvider', () => {
afterEach(() => {
document.documentElement.classList.remove('guest-theme', 'dark');
document.documentElement.style.removeProperty('color-scheme');
document.documentElement.style.removeProperty('--guest-background');
});
it('applies guest theme classes and variables', async () => {
const { unmount } = render(
<EventBrandingProvider branding={sampleBranding}>
<div>Guest</div>
</EventBrandingProvider>
);
await waitFor(() => {
expect(document.documentElement.classList.contains('guest-theme')).toBe(true);
expect(document.documentElement.classList.contains('dark')).toBe(true);
expect(document.documentElement.style.colorScheme).toBe('dark');
expect(document.documentElement.style.getPropertyValue('--guest-background')).toBe(sampleBranding.backgroundColor);
});
unmount();
expect(document.documentElement.classList.contains('guest-theme')).toBe(false);
});
});

View File

@@ -0,0 +1,35 @@
import { describe, expect, it, afterEach } from 'vitest';
import { applyGuestTheme } from '../guestTheme';
const baseTheme = {
primary: '#ff3366',
secondary: '#ff99aa',
background: '#111111',
surface: '#222222',
mode: 'dark' as const,
};
describe('applyGuestTheme', () => {
afterEach(() => {
const root = document.documentElement;
root.classList.remove('guest-theme', 'dark');
root.style.removeProperty('color-scheme');
root.style.removeProperty('--guest-primary');
root.style.removeProperty('--guest-secondary');
root.style.removeProperty('--guest-background');
root.style.removeProperty('--guest-surface');
});
it('applies and restores guest theme settings', () => {
const cleanup = applyGuestTheme(baseTheme);
expect(document.documentElement.classList.contains('guest-theme')).toBe(true);
expect(document.documentElement.classList.contains('dark')).toBe(true);
expect(document.documentElement.style.colorScheme).toBe('dark');
expect(document.documentElement.style.getPropertyValue('--guest-background')).toBe('#111111');
cleanup();
expect(document.documentElement.classList.contains('guest-theme')).toBe(false);
});
});

View File

@@ -0,0 +1,103 @@
export type GuestThemePayload = {
primary: string;
secondary: string;
background: string;
surface: string;
mode?: 'light' | 'dark' | 'auto';
};
type GuestThemeCleanup = () => void;
const prefersDarkScheme = (): boolean => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return false;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches;
};
const applyColorScheme = (root: HTMLElement, theme: 'light' | 'dark') => {
if (theme === 'dark') {
root.classList.add('dark');
root.style.colorScheme = 'dark';
} else {
root.classList.remove('dark');
root.style.colorScheme = 'light';
}
};
export function applyGuestTheme(payload: GuestThemePayload): GuestThemeCleanup {
if (typeof document === 'undefined') {
return () => {};
}
const root = document.documentElement;
const hadGuestTheme = root.classList.contains('guest-theme');
const wasDark = root.classList.contains('dark');
const previousColorScheme = root.style.colorScheme;
const previousVars = {
primary: root.style.getPropertyValue('--guest-primary'),
secondary: root.style.getPropertyValue('--guest-secondary'),
background: root.style.getPropertyValue('--guest-background'),
surface: root.style.getPropertyValue('--guest-surface'),
};
root.classList.add('guest-theme');
root.style.setProperty('--guest-primary', payload.primary);
root.style.setProperty('--guest-secondary', payload.secondary);
root.style.setProperty('--guest-background', payload.background);
root.style.setProperty('--guest-surface', payload.surface);
const mode = payload.mode ?? 'auto';
if (mode === 'dark') {
applyColorScheme(root, 'dark');
} else if (mode === 'light') {
applyColorScheme(root, 'light');
} else {
applyColorScheme(root, prefersDarkScheme() ? 'dark' : 'light');
}
return () => {
if (hadGuestTheme) {
root.classList.add('guest-theme');
} else {
root.classList.remove('guest-theme');
}
if (wasDark) {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
if (previousColorScheme) {
root.style.colorScheme = previousColorScheme;
} else {
root.style.removeProperty('color-scheme');
}
if (previousVars.primary) {
root.style.setProperty('--guest-primary', previousVars.primary);
} else {
root.style.removeProperty('--guest-primary');
}
if (previousVars.secondary) {
root.style.setProperty('--guest-secondary', previousVars.secondary);
} else {
root.style.removeProperty('--guest-secondary');
}
if (previousVars.background) {
root.style.setProperty('--guest-background', previousVars.background);
} else {
root.style.removeProperty('--guest-background');
}
if (previousVars.surface) {
root.style.setProperty('--guest-surface', previousVars.surface);
} else {
root.style.removeProperty('--guest-surface');
}
};
}

View File

@@ -1,7 +1,6 @@
import React, { Suspense } from 'react';
import { createRoot } from 'react-dom/client';
import '../../css/app.css';
import { AppearanceProvider, initializeTheme } from '@/hooks/use-appearance';
import { enableGuestDemoMode, shouldEnableGuestDemoMode } from './demo/demoMode';
import { Sentry, initSentry } from '@/lib/sentry';
@@ -11,7 +10,6 @@ const GuestFallback: React.FC<{ message: string }> = ({ message }) => (
</div>
);
initializeTheme();
initSentry('guest');
if (shouldEnableGuestDemoMode()) {
enableGuestDemoMode();
@@ -24,9 +22,7 @@ const shareRoot = async () => {
createRoot(rootEl).render(
<Sentry.ErrorBoundary fallback={<GuestFallback message="Dieses Foto kann gerade nicht geladen werden." />}>
<React.StrictMode>
<AppearanceProvider>
<SharedPhotoStandalone />
</AppearanceProvider>
<SharedPhotoStandalone />
</React.StrictMode>
</Sentry.ErrorBoundary>
);
@@ -51,17 +47,15 @@ const appRoot = async () => {
createRoot(rootEl).render(
<Sentry.ErrorBoundary fallback={<GuestFallback message="Erlebnisse können nicht geladen werden." />}>
<React.StrictMode>
<AppearanceProvider>
<LocaleProvider>
<ToastProvider>
<MatomoTracker config={matomoConfig} />
<PwaManager />
<Suspense fallback={<GuestFallback message="Erlebnisse werden geladen …" />}>
<RouterProvider router={router} />
</Suspense>
</ToastProvider>
</LocaleProvider>
</AppearanceProvider>
<LocaleProvider>
<ToastProvider>
<MatomoTracker config={matomoConfig} />
<PwaManager />
<Suspense fallback={<GuestFallback message="Erlebnisse werden geladen …" />}>
<RouterProvider router={router} />
</Suspense>
</ToastProvider>
</LocaleProvider>
</React.StrictMode>
</Sentry.ErrorBoundary>
);

View File

@@ -9,9 +9,8 @@ import { useTranslation } from '../i18n/useTranslation';
import { DEFAULT_LOCALE, isLocaleCode } from '../i18n/messages';
import { AlertTriangle, Download, Loader2, Share, X } from 'lucide-react';
import { createPhotoShareLink } from '../services/photosApi';
import { Share } from 'lucide-react';
import { createPhotoShareLink } from '../services/photosApi';
import { getContrastingTextColor } from '../lib/color';
import { applyGuestTheme } from '../lib/guestTheme';
interface GalleryState {
meta: GalleryMetaResponse | null;
@@ -95,28 +94,34 @@ export default function PublicGalleryPage(): React.ReactElement | null {
loadInitial();
}, [loadInitial]);
const resolvedBranding = useMemo(() => {
if (!state.meta) {
return null;
}
const palette = state.meta.branding.palette ?? {};
const primary = palette.primary ?? state.meta.branding.primary_color ?? '#f43f5e';
const secondary = palette.secondary ?? state.meta.branding.secondary_color ?? '#fb7185';
const background = palette.background ?? state.meta.branding.background_color ?? '#ffffff';
const surface = palette.surface ?? state.meta.branding.surface_color ?? background;
const mode = state.meta.branding.mode ?? 'auto';
return {
primary,
secondary,
background,
surface,
mode,
};
}, [state.meta]);
useEffect(() => {
const mode = state.meta?.branding.mode;
if (!mode || typeof document === 'undefined') {
if (!resolvedBranding) {
return;
}
const wasDark = document.documentElement.classList.contains('dark');
if (mode === 'dark') {
document.documentElement.classList.add('dark');
} else if (mode === 'light') {
document.documentElement.classList.remove('dark');
}
return () => {
if (wasDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
};
}, [state.meta?.branding.mode]);
return applyGuestTheme(resolvedBranding);
}, [resolvedBranding]);
const loadMore = useCallback(async () => {
if (!token || !state.cursor || state.loadingMore) {
@@ -164,55 +169,46 @@ export default function PublicGalleryPage(): React.ReactElement | null {
}, [state.cursor, loadMore]);
const themeStyles = useMemo(() => {
if (!state.meta) {
if (!resolvedBranding) {
return {} as React.CSSProperties;
}
const palette = state.meta.branding.palette ?? {};
const primary = palette.primary ?? state.meta.branding.primary_color;
const secondary = palette.secondary ?? state.meta.branding.secondary_color;
const background = palette.background ?? state.meta.branding.background_color;
const surface = palette.surface ?? state.meta.branding.surface_color ?? background;
return {
'--gallery-primary': primary,
'--gallery-secondary': secondary,
'--gallery-background': background,
'--gallery-surface': surface,
'--gallery-primary': resolvedBranding.primary,
'--gallery-secondary': resolvedBranding.secondary,
'--gallery-background': resolvedBranding.background,
'--gallery-surface': resolvedBranding.surface,
} as React.CSSProperties & Record<string, string>;
}, [state.meta]);
}, [resolvedBranding]);
const headerStyle = useMemo(() => {
if (!state.meta) {
if (!resolvedBranding) {
return {};
}
const palette = state.meta.branding.palette ?? {};
const primary = palette.primary ?? state.meta.branding.primary_color;
const secondary = palette.secondary ?? state.meta.branding.secondary_color ?? primary;
const textColor = getContrastingTextColor(primary ?? '#f43f5e', '#0f172a', '#ffffff');
const textColor = getContrastingTextColor(resolvedBranding.primary, '#0f172a', '#ffffff');
return {
background: `linear-gradient(135deg, ${primary}, ${secondary})`,
background: `linear-gradient(135deg, ${resolvedBranding.primary}, ${resolvedBranding.secondary})`,
color: textColor,
} satisfies React.CSSProperties;
}, [state.meta]);
}, [resolvedBranding]);
const accentStyle = useMemo(() => {
if (!state.meta) {
if (!resolvedBranding) {
return {};
}
return {
color: (state.meta.branding.palette?.primary ?? state.meta.branding.primary_color),
color: resolvedBranding.primary,
} satisfies React.CSSProperties;
}, [state.meta]);
}, [resolvedBranding]);
const backgroundStyle = useMemo(() => {
if (!state.meta) {
if (!resolvedBranding) {
return {};
}
return {
backgroundColor: state.meta.branding.palette?.background ?? state.meta.branding.background_color,
backgroundColor: resolvedBranding.background,
} satisfies React.CSSProperties;
}, [state.meta]);
}, [resolvedBranding]);
const openLightbox = useCallback((photo: GalleryPhotoResource) => {
setSelectedPhoto(photo);

View File

@@ -1488,7 +1488,7 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
</div>
</div>
) : (
<div className="relative h-24 w-24">
<div className="relative flex h-24 w-24 items-center justify-center">
{!isCountdownActive && mode !== 'uploading' && (
<span className="pointer-events-none absolute inset-0 rounded-full border border-white/30 opacity-60 animate-ping" />
)}

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import UploadPage from '../UploadPage';
vi.mock('react-router-dom', () => ({
@@ -73,4 +73,15 @@ describe('UploadPage immersive mode', () => {
expect(document.body.classList.contains('guest-immersive')).toBe(true);
});
});
it('centers the capture button within the countdown ring', () => {
render(<UploadPage />);
const captureButton = screen.getByRole('button', { name: 'upload.buttons.startCamera' });
const wrapper = captureButton.parentElement;
expect(wrapper).not.toBeNull();
expect(wrapper?.className).toContain('items-center');
expect(wrapper?.className).toContain('justify-center');
});
});