import React, { createContext, useContext, useEffect, useMemo } from 'react'; import type { EventBranding } from '../types/event-branding'; import { useAppearance } from '@/hooks/use-appearance'; import { getContrastingTextColor, relativeLuminance } from '../lib/color'; type EventBrandingContextValue = { branding: EventBranding; isCustom: boolean; }; export const DEFAULT_EVENT_BRANDING: EventBranding = { primaryColor: '#E94B5A', secondaryColor: '#F7C7CF', backgroundColor: '#FFF6F2', fontFamily: 'Montserrat, Inter, "Helvetica Neue", system-ui, -apple-system, BlinkMacSystemFont, sans-serif', logoUrl: null, palette: { primary: '#E94B5A', secondary: '#F7C7CF', background: '#FFF6F2', surface: '#FFFFFF', }, typography: { heading: 'Playfair Display, "Times New Roman", serif', body: 'Montserrat, Inter, "Helvetica Neue", system-ui, -apple-system, BlinkMacSystemFont, sans-serif', sizePreset: 'm', }, logo: { mode: 'emoticon', value: null, position: 'left', size: 'm', }, buttons: { style: 'filled', radius: 12, }, mode: 'auto', }; const DEFAULT_PRIMARY = DEFAULT_EVENT_BRANDING.primaryColor.toLowerCase(); const DEFAULT_SECONDARY = DEFAULT_EVENT_BRANDING.secondaryColor.toLowerCase(); const DEFAULT_BACKGROUND = DEFAULT_EVENT_BRANDING.backgroundColor.toLowerCase(); const LIGHT_LUMINANCE_THRESHOLD = 0.65; const DARK_LUMINANCE_THRESHOLD = 0.35; const DARK_FALLBACK_SURFACE = '#0f172a'; const LIGHT_FALLBACK_SURFACE = '#ffffff'; const FONT_SCALE_MAP: Record<'s' | 'm' | 'l', number> = { s: 0.94, m: 1, l: 1.08, }; const EventBrandingContext = createContext(undefined); function normaliseHexColor(value: string | null | undefined, fallback: string): string { if (typeof value !== 'string') { return fallback; } const trimmed = value.trim(); return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(trimmed) ? trimmed : fallback; } function resolveBranding(input?: EventBranding | null): EventBranding { if (!input) { return DEFAULT_EVENT_BRANDING; } const palettePrimary = input.palette?.primary ?? input.primaryColor; const paletteSecondary = input.palette?.secondary ?? input.secondaryColor; const paletteBackground = input.palette?.background ?? input.backgroundColor; const paletteSurface = input.palette?.surface ?? input.backgroundColor; const headingFont = input.typography?.heading ?? input.fontFamily ?? null; const bodyFont = input.typography?.body ?? input.fontFamily ?? null; const rawSize = input.typography?.sizePreset ?? 'm'; const sizePreset = rawSize === 's' || rawSize === 'm' || rawSize === 'l' ? rawSize : 'm'; const logoMode = input.logo?.mode ?? (input.logoUrl ? 'upload' : 'emoticon'); const logoValue = input.logo?.value ?? input.logoUrl ?? null; return { primaryColor: normaliseHexColor(palettePrimary, DEFAULT_EVENT_BRANDING.primaryColor), secondaryColor: normaliseHexColor(paletteSecondary, DEFAULT_EVENT_BRANDING.secondaryColor), backgroundColor: normaliseHexColor(paletteBackground, DEFAULT_EVENT_BRANDING.backgroundColor), fontFamily: bodyFont?.trim() || DEFAULT_EVENT_BRANDING.fontFamily, logoUrl: logoMode === 'upload' ? (logoValue?.trim() || null) : null, palette: { primary: normaliseHexColor(palettePrimary, DEFAULT_EVENT_BRANDING.primaryColor), secondary: normaliseHexColor(paletteSecondary, DEFAULT_EVENT_BRANDING.secondaryColor), background: normaliseHexColor(paletteBackground, DEFAULT_EVENT_BRANDING.backgroundColor), surface: normaliseHexColor(paletteSurface, paletteBackground ?? DEFAULT_EVENT_BRANDING.backgroundColor), }, typography: { heading: headingFont?.trim() || DEFAULT_EVENT_BRANDING.typography?.heading || null, body: bodyFont?.trim() || DEFAULT_EVENT_BRANDING.typography?.body || DEFAULT_EVENT_BRANDING.fontFamily, sizePreset, }, logo: { mode: logoMode, value: logoMode === 'upload' ? (logoValue?.trim() || null) : (logoValue ?? null), position: input.logo?.position ?? 'left', size: input.logo?.size ?? 'm', }, buttons: { style: input.buttons?.style ?? 'filled', radius: typeof input.buttons?.radius === 'number' ? input.buttons.radius : 12, primary: input.buttons?.primary ?? normaliseHexColor(palettePrimary, DEFAULT_EVENT_BRANDING.primaryColor), secondary: input.buttons?.secondary ?? normaliseHexColor(paletteSecondary, DEFAULT_EVENT_BRANDING.secondaryColor), linkColor: input.buttons?.linkColor ?? normaliseHexColor(paletteSecondary, DEFAULT_EVENT_BRANDING.secondaryColor), }, mode: input.mode ?? 'auto', useDefaultBranding: input.useDefaultBranding ?? undefined, }; } type ThemeVariant = 'light' | 'dark'; function resolveThemeVariant( mode: EventBranding['mode'], backgroundColor: string, appearanceOverride: 'light' | 'dark' | null, ): ThemeVariant { const prefersDark = typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia('(prefers-color-scheme: dark)').matches : false; const backgroundLuminance = relativeLuminance(backgroundColor || DEFAULT_EVENT_BRANDING.backgroundColor); const backgroundPrefers = backgroundLuminance >= LIGHT_LUMINANCE_THRESHOLD ? 'light' : backgroundLuminance <= DARK_LUMINANCE_THRESHOLD ? 'dark' : null; if (mode === 'dark') { return 'dark'; } if (mode === 'light') { return 'light'; } if (appearanceOverride) { return appearanceOverride; } if (backgroundPrefers) { return backgroundPrefers; } return prefersDark ? 'dark' : 'light'; } function clampToTheme(color: string, theme: ThemeVariant): string { const luminance = relativeLuminance(color); if (theme === 'dark' && luminance >= LIGHT_LUMINANCE_THRESHOLD) { return DARK_FALLBACK_SURFACE; } if (theme === 'light' && luminance <= DARK_LUMINANCE_THRESHOLD) { return LIGHT_FALLBACK_SURFACE; } return color; } function applyCssVariables(branding: EventBranding, theme: ThemeVariant) { if (typeof document === 'undefined') { return; } const root = document.documentElement; const background = clampToTheme(branding.backgroundColor, theme); const surfaceCandidate = clampToTheme(branding.palette?.surface ?? background, theme); const backgroundLuminance = relativeLuminance(background); const surfaceLuminance = relativeLuminance(surfaceCandidate); const surface = Math.abs(surfaceLuminance - backgroundLuminance) < 0.06 ? theme === 'light' ? LIGHT_FALLBACK_SURFACE : DARK_FALLBACK_SURFACE : surfaceCandidate; const isLight = theme === 'light'; const foreground = isLight ? '#1f2937' : '#f8fafc'; const mutedForeground = isLight ? '#6b7280' : '#cbd5e1'; const muted = isLight ? '#f6efec' : '#1f2937'; const border = isLight ? '#e6d9d6' : '#334155'; const input = isLight ? '#eadfda' : '#273247'; const primaryForeground = getContrastingTextColor(branding.primaryColor, '#ffffff', '#0f172a'); const secondaryForeground = getContrastingTextColor(branding.secondaryColor, '#ffffff', '#0f172a'); root.style.setProperty('--guest-primary', branding.primaryColor); root.style.setProperty('--guest-secondary', branding.secondaryColor); root.style.setProperty('--guest-background', background); root.style.setProperty('--guest-surface', surface); root.style.setProperty('--guest-button-radius', `${branding.buttons?.radius ?? 12}px`); root.style.setProperty('--guest-radius', `${branding.buttons?.radius ?? 12}px`); root.style.setProperty('--guest-link', branding.buttons?.linkColor ?? branding.secondaryColor); root.style.setProperty('--guest-button-style', branding.buttons?.style ?? 'filled'); root.style.setProperty('--guest-font-scale', String(FONT_SCALE_MAP[branding.typography?.sizePreset ?? 'm'] ?? 1)); root.style.setProperty('--foreground', foreground); root.style.setProperty('--card-foreground', foreground); root.style.setProperty('--popover-foreground', foreground); root.style.setProperty('--muted', muted); root.style.setProperty('--muted-foreground', mutedForeground); root.style.setProperty('--border', border); root.style.setProperty('--input', input); root.style.setProperty('--primary', branding.primaryColor); root.style.setProperty('--primary-foreground', primaryForeground); root.style.setProperty('--secondary', branding.secondaryColor); root.style.setProperty('--secondary-foreground', secondaryForeground); root.style.setProperty('--accent', branding.secondaryColor); root.style.setProperty('--accent-foreground', secondaryForeground); root.style.setProperty('--ring', branding.primaryColor); const headingFont = branding.typography?.heading ?? branding.fontFamily; const bodyFont = branding.typography?.body ?? branding.fontFamily; if (bodyFont) { root.style.setProperty('--guest-font-family', bodyFont); root.style.setProperty('--guest-body-font', bodyFont); } else { root.style.removeProperty('--guest-font-family'); root.style.removeProperty('--guest-body-font'); } if (headingFont) { root.style.setProperty('--guest-heading-font', headingFont); } else { root.style.removeProperty('--guest-heading-font'); } } function resetCssVariables() { if (typeof document === 'undefined') { return; } const root = document.documentElement; root.style.removeProperty('--guest-primary'); root.style.removeProperty('--guest-secondary'); root.style.removeProperty('--guest-background'); root.style.removeProperty('--guest-surface'); root.style.removeProperty('--guest-button-radius'); root.style.removeProperty('--guest-radius'); root.style.removeProperty('--guest-link'); root.style.removeProperty('--guest-button-style'); root.style.removeProperty('--guest-font-scale'); root.style.removeProperty('--guest-font-family'); root.style.removeProperty('--guest-body-font'); root.style.removeProperty('--guest-heading-font'); root.style.removeProperty('--foreground'); root.style.removeProperty('--card-foreground'); root.style.removeProperty('--popover-foreground'); root.style.removeProperty('--muted'); root.style.removeProperty('--muted-foreground'); root.style.removeProperty('--border'); root.style.removeProperty('--input'); root.style.removeProperty('--primary'); root.style.removeProperty('--primary-foreground'); root.style.removeProperty('--secondary'); root.style.removeProperty('--secondary-foreground'); root.style.removeProperty('--accent'); root.style.removeProperty('--accent-foreground'); root.style.removeProperty('--ring'); } function applyThemeMode( mode: EventBranding['mode'], backgroundColor: string, appearanceOverride: 'light' | 'dark' | null, ): ThemeVariant { if (typeof document === 'undefined') { return 'light'; } const root = document.documentElement; const theme = resolveThemeVariant(mode, backgroundColor, appearanceOverride); if (theme === 'dark') { root.classList.add('dark'); root.style.colorScheme = 'dark'; return 'dark'; } root.classList.remove('dark'); root.style.colorScheme = 'light'; return 'light'; } export function EventBrandingProvider({ branding, children, }: { branding?: EventBranding | null; children: React.ReactNode; }) { const resolved = useMemo(() => resolveBranding(branding), [branding]); const { appearance } = useAppearance(); const appearanceOverride = appearance === 'light' || appearance === 'dark' ? appearance : null; useEffect(() => { if (typeof document !== 'undefined') { document.documentElement.classList.add('guest-theme'); } const previousDark = typeof document !== 'undefined' ? document.documentElement.classList.contains('dark') : false; const theme = applyThemeMode(resolved.mode ?? 'auto', resolved.backgroundColor, appearanceOverride); applyCssVariables(resolved, theme); return () => { if (typeof document !== 'undefined') { if (previousDark) { document.documentElement.classList.add('dark'); } else { document.documentElement.classList.remove('dark'); } document.documentElement.classList.remove('guest-theme'); } resetCssVariables(); const fallbackTheme = applyThemeMode( DEFAULT_EVENT_BRANDING.mode ?? 'auto', DEFAULT_EVENT_BRANDING.backgroundColor, appearanceOverride, ); applyCssVariables(DEFAULT_EVENT_BRANDING, fallbackTheme); }; }, [appearanceOverride, resolved]); const value = useMemo(() => ({ branding: resolved, isCustom: resolved.primaryColor.toLowerCase() !== DEFAULT_PRIMARY || resolved.secondaryColor.toLowerCase() !== DEFAULT_SECONDARY || resolved.backgroundColor.toLowerCase() !== DEFAULT_BACKGROUND, // legacy surface check omitted by intent }), [resolved]); return {children}; } export function useEventBranding(): EventBrandingContextValue { const context = useContext(EventBrandingContext); if (!context) { throw new Error('useEventBranding must be used within an EventBrandingProvider'); } return context; } export function useOptionalEventBranding(): EventBrandingContextValue | undefined { return useContext(EventBrandingContext); }