diff --git a/resources/css/app.css b/resources/css/app.css index fb27d0c..efc1407 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -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; diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index c62a5f9..b07e7c3 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -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", diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index 9a9c827..29b3349 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -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", diff --git a/resources/js/admin/lib/__tests__/brandingForm.test.ts b/resources/js/admin/lib/__tests__/brandingForm.test.ts new file mode 100644 index 0000000..3854114 --- /dev/null +++ b/resources/js/admin/lib/__tests__/brandingForm.test.ts @@ -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'); + }); +}); diff --git a/resources/js/admin/lib/brandingForm.ts b/resources/js/admin/lib/brandingForm.ts new file mode 100644 index 0000000..6658fbf --- /dev/null +++ b/resources/js/admin/lib/brandingForm.ts @@ -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; + +type BrandingRecord = Record; + +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, + }; +} diff --git a/resources/js/admin/mobile/BrandingPage.tsx b/resources/js/admin/mobile/BrandingPage.tsx index d50be7f..2e9cdf4 100644 --- a/resources/js/admin/mobile/BrandingPage.tsx +++ b/resources/js/admin/mobile/BrandingPage.tsx @@ -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(null); - const [form, setForm] = React.useState({ - primary: ADMIN_COLORS.primary, - accent: ADMIN_COLORS.accent, - headingFont: '', - bodyFont: '', - logoDataUrl: '', - }); + const [form, setForm] = React.useState(BRANDING_FORM_BASE); const [watermarkForm, setWatermarkForm] = React.useState({ 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) : {}), 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 | undefined)?.typography === 'object' ? ((settings.branding as Record).typography as Record) @@ -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() { {t('events.branding.previewTitle', 'Guest App Preview')} - - + + - + {previewTitle} - + {t('events.branding.previewSubtitle', 'Aktuelle Farben & Schriften')} + + + + + {t('events.branding.mode', 'Theme')} + + + setForm((prev) => ({ ...prev, mode: 'light' }))} + /> + setForm((prev) => ({ ...prev, mode: 'auto' }))} + /> + setForm((prev) => ({ ...prev, mode: 'dark' }))} + /> + + + {t('events.branding.colors', 'Colors')} @@ -468,6 +503,16 @@ export default function MobileBrandingPage() { value={form.accent} onChange={(value) => setForm((prev) => ({ ...prev, accent: value }))} /> + setForm((prev) => ({ ...prev, background: value }))} + /> + setForm((prev) => ({ ...prev, surface: value }))} + /> @@ -671,26 +716,6 @@ export default function MobileBrandingPage() { ); } -function extractBranding(event: TenantEvent): BrandingForm { - const source = (event.settings as Record) ?? {}; - const branding = (source.branding as Record) ?? 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) ?? {}; const wm = (settings.watermark as Record) ?? {}; @@ -1078,3 +1103,24 @@ function TabButton({ label, active, onPress }: { label: string; active: boolean; ); } + +function ModeButton({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) { + const { backdrop, surfaceMuted, border, surface } = useAdminTheme(); + return ( + + + + {label} + + + + ); +} diff --git a/resources/js/guest/context/EventBrandingContext.tsx b/resources/js/guest/context/EventBrandingContext.tsx index 30c374a..5b82eb2 100644 --- a/resources/js/guest/context/EventBrandingContext.tsx +++ b/resources/js/guest/context/EventBrandingContext.tsx @@ -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); diff --git a/resources/js/guest/context/__tests__/EventBrandingContext.test.tsx b/resources/js/guest/context/__tests__/EventBrandingContext.test.tsx new file mode 100644 index 0000000..17be86c --- /dev/null +++ b/resources/js/guest/context/__tests__/EventBrandingContext.test.tsx @@ -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( + +
Guest
+
+ ); + + 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); + }); +}); diff --git a/resources/js/guest/lib/__tests__/guestTheme.test.ts b/resources/js/guest/lib/__tests__/guestTheme.test.ts new file mode 100644 index 0000000..9e70d8a --- /dev/null +++ b/resources/js/guest/lib/__tests__/guestTheme.test.ts @@ -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); + }); +}); diff --git a/resources/js/guest/lib/guestTheme.ts b/resources/js/guest/lib/guestTheme.ts new file mode 100644 index 0000000..9541a8a --- /dev/null +++ b/resources/js/guest/lib/guestTheme.ts @@ -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'); + } + }; +} diff --git a/resources/js/guest/main.tsx b/resources/js/guest/main.tsx index dda82c0..5efe91e 100644 --- a/resources/js/guest/main.tsx +++ b/resources/js/guest/main.tsx @@ -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 }) => ( ); -initializeTheme(); initSentry('guest'); if (shouldEnableGuestDemoMode()) { enableGuestDemoMode(); @@ -24,9 +22,7 @@ const shareRoot = async () => { createRoot(rootEl).render( }> - - - + ); @@ -51,17 +47,15 @@ const appRoot = async () => { createRoot(rootEl).render( }> - - - - - - }> - - - - - + + + + + }> + + + + ); diff --git a/resources/js/guest/pages/PublicGalleryPage.tsx b/resources/js/guest/pages/PublicGalleryPage.tsx index bcea209..17e0890 100644 --- a/resources/js/guest/pages/PublicGalleryPage.tsx +++ b/resources/js/guest/pages/PublicGalleryPage.tsx @@ -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; - }, [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); diff --git a/resources/js/guest/pages/UploadPage.tsx b/resources/js/guest/pages/UploadPage.tsx index 189d29c..42c5d6e 100644 --- a/resources/js/guest/pages/UploadPage.tsx +++ b/resources/js/guest/pages/UploadPage.tsx @@ -1488,7 +1488,7 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[ ) : ( -
+
{!isCountdownActive && mode !== 'uploading' && ( )} diff --git a/resources/js/guest/pages/__tests__/UploadPageImmersive.test.tsx b/resources/js/guest/pages/__tests__/UploadPageImmersive.test.tsx index b95d981..c2ac109 100644 --- a/resources/js/guest/pages/__tests__/UploadPageImmersive.test.tsx +++ b/resources/js/guest/pages/__tests__/UploadPageImmersive.test.tsx @@ -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(); + + 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'); + }); });