337 lines
12 KiB
TypeScript
337 lines
12 KiB
TypeScript
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 FONT_SCALE_MAP: Record<'s' | 'm' | 'l', number> = {
|
|
s: 0.94,
|
|
m: 1,
|
|
l: 1.08,
|
|
};
|
|
|
|
const EventBrandingContext = createContext<EventBrandingContextValue | undefined>(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,
|
|
};
|
|
}
|
|
|
|
function applyCssVariables(branding: EventBranding) {
|
|
if (typeof document === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
const root = document.documentElement;
|
|
const background = branding.backgroundColor;
|
|
const surfaceCandidate = branding.palette?.surface ?? background;
|
|
const backgroundLuminance = relativeLuminance(background);
|
|
const surfaceLuminance = relativeLuminance(surfaceCandidate);
|
|
const surface = Math.abs(surfaceLuminance - backgroundLuminance) < 0.06
|
|
? backgroundLuminance >= 0.6
|
|
? '#ffffff'
|
|
: '#0f172a'
|
|
: surfaceCandidate;
|
|
const isLight = backgroundLuminance >= 0.6;
|
|
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,
|
|
) {
|
|
if (typeof document === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
const root = document.documentElement;
|
|
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 >= 0.65
|
|
? 'light'
|
|
: backgroundLuminance <= 0.35
|
|
? 'dark'
|
|
: 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();
|
|
root.style.colorScheme = 'light';
|
|
return;
|
|
}
|
|
|
|
if (appearanceOverride) {
|
|
if (appearanceOverride === 'dark') {
|
|
applyDark();
|
|
root.style.colorScheme = 'dark';
|
|
} else {
|
|
applyLight();
|
|
root.style.colorScheme = 'light';
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (backgroundPrefers) {
|
|
if (backgroundPrefers === 'dark') {
|
|
applyDark();
|
|
root.style.colorScheme = 'dark';
|
|
} else {
|
|
applyLight();
|
|
root.style.colorScheme = 'light';
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (prefersDark) {
|
|
applyDark();
|
|
root.style.colorScheme = 'dark';
|
|
return;
|
|
}
|
|
|
|
applyLight();
|
|
root.style.colorScheme = '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');
|
|
}
|
|
applyCssVariables(resolved);
|
|
const previousDark = typeof document !== 'undefined' ? document.documentElement.classList.contains('dark') : false;
|
|
applyThemeMode(resolved.mode ?? 'auto', resolved.backgroundColor, appearanceOverride);
|
|
|
|
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();
|
|
applyCssVariables(DEFAULT_EVENT_BRANDING);
|
|
applyThemeMode(DEFAULT_EVENT_BRANDING.mode ?? 'auto', DEFAULT_EVENT_BRANDING.backgroundColor, appearanceOverride);
|
|
};
|
|
}, [appearanceOverride, resolved]);
|
|
|
|
const value = useMemo<EventBrandingContextValue>(() => ({
|
|
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 <EventBrandingContext.Provider value={value}>{children}</EventBrandingContext.Provider>;
|
|
}
|
|
|
|
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);
|
|
}
|