Wire guest branding theme
This commit is contained in:
@@ -511,6 +511,20 @@ h4,
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
}
|
||||
|
||||
html.guest-theme {
|
||||
--background: var(--guest-background);
|
||||
--card: var(--guest-surface);
|
||||
--popover: var(--guest-surface);
|
||||
background-color: var(--guest-background);
|
||||
}
|
||||
|
||||
html.guest-theme.dark {
|
||||
--background: var(--guest-background);
|
||||
--card: var(--guest-surface);
|
||||
--popover: var(--guest-surface);
|
||||
background-color: var(--guest-background);
|
||||
}
|
||||
|
||||
@keyframes mobile-shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
|
||||
@@ -1920,9 +1920,17 @@
|
||||
"previewSubtitle": "Aktuelle Farben & Schriften",
|
||||
"primary": "Primärfarbe",
|
||||
"accent": "Akzentfarbe",
|
||||
"background": "Hintergrund",
|
||||
"surface": "Fläche",
|
||||
"mode": "Theme",
|
||||
"modeLight": "Hell",
|
||||
"modeAuto": "Auto",
|
||||
"modeDark": "Dunkel",
|
||||
"colors": "Farben",
|
||||
"primaryColor": "Primärfarbe",
|
||||
"accentColor": "Akzentfarbe",
|
||||
"backgroundColor": "Hintergrundfarbe",
|
||||
"surfaceColor": "Flächenfarbe",
|
||||
"fonts": "Schriften",
|
||||
"headingFont": "Überschrift-Schrift",
|
||||
"headingFontPlaceholder": "SF Pro Display",
|
||||
|
||||
@@ -1924,9 +1924,17 @@
|
||||
"previewSubtitle": "Current colors & fonts",
|
||||
"primary": "Primary",
|
||||
"accent": "Accent",
|
||||
"background": "Background",
|
||||
"surface": "Surface",
|
||||
"mode": "Theme",
|
||||
"modeLight": "Light",
|
||||
"modeAuto": "Auto",
|
||||
"modeDark": "Dark",
|
||||
"colors": "Colors",
|
||||
"primaryColor": "Primary color",
|
||||
"accentColor": "Accent color",
|
||||
"backgroundColor": "Background color",
|
||||
"surfaceColor": "Surface color",
|
||||
"fonts": "Fonts",
|
||||
"headingFont": "Headline font",
|
||||
"headingFontPlaceholder": "SF Pro Display",
|
||||
|
||||
56
resources/js/admin/lib/__tests__/brandingForm.test.ts
Normal file
56
resources/js/admin/lib/__tests__/brandingForm.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { extractBrandingForm } from '../brandingForm';
|
||||
|
||||
const defaults = {
|
||||
primary: '#111111',
|
||||
accent: '#222222',
|
||||
background: '#ffffff',
|
||||
surface: '#f0f0f0',
|
||||
mode: 'auto' as const,
|
||||
};
|
||||
|
||||
describe('extractBrandingForm', () => {
|
||||
it('prefers palette values when available', () => {
|
||||
const settings = {
|
||||
branding: {
|
||||
palette: {
|
||||
primary: '#aa0000',
|
||||
secondary: '#00aa00',
|
||||
background: '#000000',
|
||||
surface: '#111111',
|
||||
},
|
||||
primary_color: '#bbbbbb',
|
||||
secondary_color: '#cccccc',
|
||||
background_color: '#dddddd',
|
||||
surface_color: '#eeeeee',
|
||||
mode: 'dark',
|
||||
},
|
||||
};
|
||||
|
||||
const result = extractBrandingForm(settings, defaults);
|
||||
|
||||
expect(result.primary).toBe('#aa0000');
|
||||
expect(result.accent).toBe('#00aa00');
|
||||
expect(result.background).toBe('#000000');
|
||||
expect(result.surface).toBe('#111111');
|
||||
expect(result.mode).toBe('dark');
|
||||
});
|
||||
|
||||
it('falls back to legacy keys and defaults', () => {
|
||||
const settings = {
|
||||
branding: {
|
||||
accent_color: '#123456',
|
||||
background_color: '#abcdef',
|
||||
mode: 'light',
|
||||
},
|
||||
};
|
||||
|
||||
const result = extractBrandingForm(settings, defaults);
|
||||
|
||||
expect(result.primary).toBe(defaults.primary);
|
||||
expect(result.accent).toBe('#123456');
|
||||
expect(result.background).toBe('#abcdef');
|
||||
expect(result.surface).toBe('#abcdef');
|
||||
expect(result.mode).toBe('light');
|
||||
});
|
||||
});
|
||||
56
resources/js/admin/lib/brandingForm.ts
Normal file
56
resources/js/admin/lib/brandingForm.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
export type BrandingFormValues = {
|
||||
primary: string;
|
||||
accent: string;
|
||||
background: string;
|
||||
surface: string;
|
||||
headingFont: string;
|
||||
bodyFont: string;
|
||||
logoDataUrl: string;
|
||||
mode: 'light' | 'dark' | 'auto';
|
||||
};
|
||||
|
||||
export type BrandingFormDefaults = Pick<BrandingFormValues, 'primary' | 'accent' | 'background' | 'surface' | 'mode'>;
|
||||
|
||||
type BrandingRecord = Record<string, unknown>;
|
||||
|
||||
const isRecord = (value: unknown): value is BrandingRecord =>
|
||||
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const readHexColor = (value: unknown, fallback: string): string => {
|
||||
if (typeof value === 'string' && value.trim().startsWith('#')) {
|
||||
return value.trim();
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
export function extractBrandingForm(settings: unknown, defaults: BrandingFormDefaults): BrandingFormValues {
|
||||
const settingsRecord = isRecord(settings) ? settings : {};
|
||||
const branding = isRecord(settingsRecord.branding) ? (settingsRecord.branding as BrandingRecord) : settingsRecord;
|
||||
const palette = isRecord(branding.palette) ? (branding.palette as BrandingRecord) : {};
|
||||
const typography = isRecord(branding.typography) ? (branding.typography as BrandingRecord) : {};
|
||||
|
||||
const primary = readHexColor(palette.primary, readHexColor(branding.primary_color, defaults.primary));
|
||||
const accent = readHexColor(
|
||||
palette.secondary,
|
||||
readHexColor(branding.secondary_color, readHexColor(branding.accent_color, defaults.accent))
|
||||
);
|
||||
const background = readHexColor(palette.background, readHexColor(branding.background_color, defaults.background));
|
||||
const surface = readHexColor(palette.surface, readHexColor(branding.surface_color, background));
|
||||
|
||||
const headingFont = typeof typography.heading === 'string' ? typography.heading : (branding.heading_font as string | undefined);
|
||||
const bodyFont = typeof typography.body === 'string' ? typography.body : (branding.body_font as string | undefined);
|
||||
const mode = branding.mode === 'light' || branding.mode === 'dark' || branding.mode === 'auto'
|
||||
? branding.mode
|
||||
: defaults.mode;
|
||||
|
||||
return {
|
||||
primary,
|
||||
accent,
|
||||
background,
|
||||
surface,
|
||||
headingFont: headingFont ?? '',
|
||||
bodyFont: bodyFont ?? '',
|
||||
logoDataUrl: typeof branding.logo_data_url === 'string' ? branding.logo_data_url : '',
|
||||
mode,
|
||||
};
|
||||
}
|
||||
@@ -16,13 +16,22 @@ import toast from 'react-hot-toast';
|
||||
import { adminPath } from '../constants';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
import { ADMIN_COLORS, ADMIN_GRADIENTS, useAdminTheme } from './theme';
|
||||
import { extractBrandingForm, type BrandingFormValues } from '../lib/brandingForm';
|
||||
import { getContrastingTextColor } from '@/guest/lib/color';
|
||||
|
||||
type BrandingForm = {
|
||||
primary: string;
|
||||
accent: string;
|
||||
headingFont: string;
|
||||
bodyFont: string;
|
||||
logoDataUrl: string;
|
||||
const BRANDING_FORM_DEFAULTS = {
|
||||
primary: ADMIN_COLORS.primary,
|
||||
accent: ADMIN_COLORS.accent,
|
||||
background: '#ffffff',
|
||||
surface: '#ffffff',
|
||||
mode: 'auto' as const,
|
||||
};
|
||||
|
||||
const BRANDING_FORM_BASE: BrandingFormValues = {
|
||||
...BRANDING_FORM_DEFAULTS,
|
||||
headingFont: '',
|
||||
bodyFont: '',
|
||||
logoDataUrl: '',
|
||||
};
|
||||
|
||||
type WatermarkPosition =
|
||||
@@ -58,13 +67,7 @@ export default function MobileBrandingPage() {
|
||||
const { textStrong, muted, subtle, border, primary, accentSoft, danger, surfaceMuted, surface } = useAdminTheme();
|
||||
|
||||
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
||||
const [form, setForm] = React.useState<BrandingForm>({
|
||||
primary: ADMIN_COLORS.primary,
|
||||
accent: ADMIN_COLORS.accent,
|
||||
headingFont: '',
|
||||
bodyFont: '',
|
||||
logoDataUrl: '',
|
||||
});
|
||||
const [form, setForm] = React.useState<BrandingFormValues>(BRANDING_FORM_BASE);
|
||||
const [watermarkForm, setWatermarkForm] = React.useState<WatermarkForm>({
|
||||
mode: 'base',
|
||||
assetPath: '',
|
||||
@@ -94,7 +97,7 @@ export default function MobileBrandingPage() {
|
||||
try {
|
||||
const data = await getEvent(slug);
|
||||
setEvent(data);
|
||||
setForm(extractBranding(data));
|
||||
setForm(extractBrandingForm(data.settings ?? {}, BRANDING_FORM_DEFAULTS));
|
||||
setWatermarkForm(extractWatermark(data));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
@@ -122,6 +125,7 @@ export default function MobileBrandingPage() {
|
||||
const previewTitle = event ? renderName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event');
|
||||
const previewHeadingFont = form.headingFont || 'Fraunces';
|
||||
const previewBodyFont = form.bodyFont || 'Manrope';
|
||||
const previewSurfaceText = getContrastingTextColor(form.surface, '#ffffff', '#0f172a');
|
||||
const watermarkAllowed = event?.package?.watermark_allowed !== false;
|
||||
const brandingAllowed = isBrandingAllowed(event ?? null);
|
||||
const watermarkLocked = watermarkAllowed && !brandingAllowed;
|
||||
@@ -158,9 +162,13 @@ export default function MobileBrandingPage() {
|
||||
settings.branding = {
|
||||
...(typeof settings.branding === 'object' ? (settings.branding as Record<string, unknown>) : {}),
|
||||
primary_color: form.primary,
|
||||
secondary_color: form.accent,
|
||||
accent_color: form.accent,
|
||||
background_color: form.background,
|
||||
surface_color: form.surface,
|
||||
heading_font: form.headingFont,
|
||||
body_font: form.bodyFont,
|
||||
mode: form.mode,
|
||||
typography: {
|
||||
...(typeof (settings.branding as Record<string, unknown> | undefined)?.typography === 'object'
|
||||
? ((settings.branding as Record<string, unknown>).typography as Record<string, unknown>)
|
||||
@@ -174,6 +182,8 @@ export default function MobileBrandingPage() {
|
||||
: {}),
|
||||
primary: form.primary,
|
||||
secondary: form.accent,
|
||||
background: form.background,
|
||||
surface: form.surface,
|
||||
},
|
||||
logo_data_url: form.logoDataUrl || null,
|
||||
logo: form.logoDataUrl
|
||||
@@ -217,7 +227,7 @@ export default function MobileBrandingPage() {
|
||||
|
||||
function handleReset() {
|
||||
if (event) {
|
||||
setForm(extractBranding(event));
|
||||
setForm(extractBrandingForm(event.settings ?? {}, BRANDING_FORM_DEFAULTS));
|
||||
setWatermarkForm(extractWatermark(event));
|
||||
}
|
||||
}
|
||||
@@ -435,25 +445,50 @@ export default function MobileBrandingPage() {
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{t('events.branding.previewTitle', 'Guest App Preview')}
|
||||
</Text>
|
||||
<YStack borderRadius={16} borderWidth={1} borderColor={border} backgroundColor={surfaceMuted} padding="$3" space="$2" alignItems="center">
|
||||
<YStack width="100%" borderRadius={12} backgroundColor={surface} borderWidth={1} borderColor={border} overflow="hidden">
|
||||
<YStack borderRadius={16} borderWidth={1} borderColor={border} backgroundColor={form.background} padding="$3" space="$2" alignItems="center">
|
||||
<YStack width="100%" borderRadius={12} backgroundColor={form.surface} borderWidth={1} borderColor={border} overflow="hidden">
|
||||
<YStack backgroundColor={form.primary} height={64} />
|
||||
<YStack padding="$3" space="$1.5">
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong} style={{ fontFamily: previewHeadingFont }}>
|
||||
<Text fontSize="$md" fontWeight="800" color={previewSurfaceText} style={{ fontFamily: previewHeadingFont }}>
|
||||
{previewTitle}
|
||||
</Text>
|
||||
<Text fontSize="$sm" color={muted} style={{ fontFamily: previewBodyFont }}>
|
||||
<Text fontSize="$sm" color={previewSurfaceText} style={{ fontFamily: previewBodyFont, opacity: 0.7 }}>
|
||||
{t('events.branding.previewSubtitle', 'Aktuelle Farben & Schriften')}
|
||||
</Text>
|
||||
<XStack space="$2" marginTop="$1">
|
||||
<ColorSwatch color={form.primary} label={t('events.branding.primary', 'Primary')} />
|
||||
<ColorSwatch color={form.accent} label={t('events.branding.accent', 'Accent')} />
|
||||
<ColorSwatch color={form.background} label={t('events.branding.background', 'Background')} />
|
||||
<ColorSwatch color={form.surface} label={t('events.branding.surface', 'Surface')} />
|
||||
</XStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard space="$3">
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('events.branding.mode', 'Theme')}
|
||||
</Text>
|
||||
<XStack space="$2">
|
||||
<ModeButton
|
||||
label={t('events.branding.modeLight', 'Light')}
|
||||
active={form.mode === 'light'}
|
||||
onPress={() => setForm((prev) => ({ ...prev, mode: 'light' }))}
|
||||
/>
|
||||
<ModeButton
|
||||
label={t('events.branding.modeAuto', 'Auto')}
|
||||
active={form.mode === 'auto'}
|
||||
onPress={() => setForm((prev) => ({ ...prev, mode: 'auto' }))}
|
||||
/>
|
||||
<ModeButton
|
||||
label={t('events.branding.modeDark', 'Dark')}
|
||||
active={form.mode === 'dark'}
|
||||
onPress={() => setForm((prev) => ({ ...prev, mode: 'dark' }))}
|
||||
/>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard space="$3">
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('events.branding.colors', 'Colors')}
|
||||
@@ -468,6 +503,16 @@ export default function MobileBrandingPage() {
|
||||
value={form.accent}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, accent: value }))}
|
||||
/>
|
||||
<ColorField
|
||||
label={t('events.branding.backgroundColor', 'Background Color')}
|
||||
value={form.background}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, background: value }))}
|
||||
/>
|
||||
<ColorField
|
||||
label={t('events.branding.surfaceColor', 'Surface Color')}
|
||||
value={form.surface}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, surface: value }))}
|
||||
/>
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard space="$3">
|
||||
@@ -671,26 +716,6 @@ export default function MobileBrandingPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function extractBranding(event: TenantEvent): BrandingForm {
|
||||
const source = (event.settings as Record<string, unknown>) ?? {};
|
||||
const branding = (source.branding as Record<string, unknown>) ?? source;
|
||||
const readColor = (key: string, fallback: string) => {
|
||||
const value = branding[key];
|
||||
return typeof value === 'string' && value.startsWith('#') ? value : fallback;
|
||||
};
|
||||
const readText = (key: string) => {
|
||||
const value = branding[key];
|
||||
return typeof value === 'string' ? value : '';
|
||||
};
|
||||
return {
|
||||
primary: readColor('primary_color', ADMIN_COLORS.primary),
|
||||
accent: readColor('accent_color', ADMIN_COLORS.accent),
|
||||
headingFont: readText('heading_font'),
|
||||
bodyFont: readText('body_font'),
|
||||
logoDataUrl: readText('logo_data_url'),
|
||||
};
|
||||
}
|
||||
|
||||
function extractWatermark(event: TenantEvent): WatermarkForm {
|
||||
const settings = (event.settings as Record<string, unknown>) ?? {};
|
||||
const wm = (settings.watermark as Record<string, unknown>) ?? {};
|
||||
@@ -1078,3 +1103,24 @@ function TabButton({ label, active, onPress }: { label: string; active: boolean;
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeButton({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
|
||||
const { backdrop, surfaceMuted, border, surface } = useAdminTheme();
|
||||
return (
|
||||
<Pressable onPress={onPress} style={{ flex: 1 }}>
|
||||
<XStack
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
paddingVertical="$2"
|
||||
borderRadius={10}
|
||||
backgroundColor={active ? backdrop : surfaceMuted}
|
||||
borderWidth={1}
|
||||
borderColor={active ? backdrop : border}
|
||||
>
|
||||
<Text fontSize="$xs" color={active ? surface : backdrop} fontWeight="700">
|
||||
{label}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
35
resources/js/guest/lib/__tests__/guestTheme.test.ts
Normal file
35
resources/js/guest/lib/__tests__/guestTheme.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
103
resources/js/guest/lib/guestTheme.ts
Normal file
103
resources/js/guest/lib/guestTheme.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user