Neue Branding-Page und Gäste-PWA reagiert nun auf Branding-Einstellungen vom event-admin. Implemented local Google Fonts pipeline and admin UI selects for branding and invites.
- Added fonts:sync-google command (uses GOOGLE_FONTS_API_KEY, generates /public/fonts/google files, manifest, CSS, cache flush) and
exposed manifest via new GET /api/v1/tenant/fonts endpoint with fallbacks for existing local fonts.
- Imported generated fonts CSS, added API client + font loader hook, and wired branding page font fields to searchable selects (with
custom override) that auto-load selected fonts.
- Invites layout editor now offers font selection per element with runtime font loading for previews/export alignment.
- New tests cover font sync command and font manifest API.
Tests run: php artisan test --filter=Fonts --testsuite=Feature.
Note: repository already has other modified files (e.g., EventPublicController, SettingsStoreRequest, guest components, etc.); left
untouched. Run php artisan fonts:sync-google after setting the API key to populate /public/fonts/google.
This commit is contained in:
@@ -181,6 +181,19 @@ export type EventAddonCatalogItem = {
|
||||
increments?: Record<string, number>;
|
||||
};
|
||||
|
||||
export type TenantFontVariant = {
|
||||
variant: string | null;
|
||||
weight: number;
|
||||
style: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type TenantFont = {
|
||||
family: string;
|
||||
category?: string | null;
|
||||
variants: TenantFontVariant[];
|
||||
};
|
||||
|
||||
export type EventAddonSummary = {
|
||||
id: number;
|
||||
key: string;
|
||||
@@ -1266,6 +1279,18 @@ export async function getAddonCatalog(): Promise<EventAddonCatalogItem[]> {
|
||||
return data.data ?? [];
|
||||
}
|
||||
|
||||
export async function getTenantFonts(): Promise<TenantFont[]> {
|
||||
return cachedFetch(
|
||||
CacheKeys.fonts,
|
||||
async () => {
|
||||
const response = await authorizedFetch('/api/v1/tenant/fonts');
|
||||
const data = await jsonOrThrow<{ data?: TenantFont[] }>(response, 'Failed to load fonts');
|
||||
return data.data ?? [];
|
||||
},
|
||||
6 * 60 * 60 * 1000,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getEventTypes(): Promise<TenantEventType[]> {
|
||||
const response = await authorizedFetch('/api/v1/tenant/event-types');
|
||||
const data = await jsonOrThrow<{ data?: JsonValue[] }>(response, 'Failed to load event types');
|
||||
@@ -1672,6 +1697,27 @@ export async function getDashboardSummary(options?: { force?: boolean }): Promis
|
||||
);
|
||||
}
|
||||
|
||||
export type TenantSettingsPayload = {
|
||||
id: number;
|
||||
settings: Record<string, unknown>;
|
||||
updated_at: string | null;
|
||||
};
|
||||
|
||||
export async function getTenantSettings(): Promise<TenantSettingsPayload> {
|
||||
const response = await authorizedFetch('/api/v1/tenant/settings');
|
||||
const data = await jsonOrThrow<{ data?: { id?: number; settings?: Record<string, unknown>; updated_at?: string | null } }>(
|
||||
response,
|
||||
'Failed to load tenant settings',
|
||||
);
|
||||
const payload = (data.data ?? {}) as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
id: Number(payload.id ?? 0),
|
||||
settings: (payload.settings ?? {}) as Record<string, unknown>,
|
||||
updated_at: (payload.updated_at ?? null) as string | null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getTenantPackagesOverview(options?: { force?: boolean }): Promise<{
|
||||
packages: TenantPackageSummary[];
|
||||
activePackage: TenantPackageSummary | null;
|
||||
@@ -2236,6 +2282,7 @@ const CacheKeys = {
|
||||
dashboard: 'tenant:dashboard',
|
||||
events: 'tenant:events',
|
||||
packages: 'tenant:packages',
|
||||
fonts: 'tenant:fonts',
|
||||
} as const;
|
||||
|
||||
function cachedFetch<T>(
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
ADMIN_EVENT_TASKS_PATH,
|
||||
ADMIN_EVENT_VIEW_PATH,
|
||||
ADMIN_EVENT_PHOTOBOOTH_PATH,
|
||||
ADMIN_EVENT_BRANDING_PATH,
|
||||
} from '../constants';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { resolveEventDisplayName, formatEventDate } from '../lib/events';
|
||||
@@ -36,6 +37,7 @@ function buildEventLinks(slug: string, t: ReturnType<typeof useTranslation>['t']
|
||||
{ key: 'guests', label: t('eventMenu.guests', 'Team & Gäste'), href: ADMIN_EVENT_MEMBERS_PATH(slug) },
|
||||
{ key: 'tasks', label: t('eventMenu.tasks', 'Aufgaben'), href: ADMIN_EVENT_TASKS_PATH(slug) },
|
||||
{ key: 'invites', label: t('eventMenu.invites', 'Einladungen'), href: ADMIN_EVENT_INVITES_PATH(slug) },
|
||||
{ key: 'branding', label: t('eventMenu.branding', 'Branding & Fonts'), href: ADMIN_EVENT_BRANDING_PATH(slug) },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -32,3 +32,4 @@ export const ADMIN_EVENT_TASKS_PATH = (slug: string): string => adminPath(`/even
|
||||
export const ADMIN_EVENT_INVITES_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/invites`);
|
||||
export const ADMIN_EVENT_PHOTOBOOTH_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/photobooth`);
|
||||
export const ADMIN_EVENT_RECAP_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/recap`);
|
||||
export const ADMIN_EVENT_BRANDING_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/branding`);
|
||||
|
||||
@@ -12,7 +12,8 @@ export function extractBrandingPalette(settings: TenantEvent['settings'] | null
|
||||
if (settings && typeof settings === 'object') {
|
||||
const brand = (settings as Record<string, unknown>).branding;
|
||||
if (brand && typeof brand === 'object') {
|
||||
const colorPalette = (brand as Record<string, unknown>).colors;
|
||||
const palette = (brand as Record<string, unknown>).palette as Record<string, unknown> | undefined;
|
||||
const colorPalette = palette ?? (brand as Record<string, unknown>).colors;
|
||||
if (colorPalette && typeof colorPalette === 'object') {
|
||||
const paletteRecord = colorPalette as Record<string, unknown>;
|
||||
for (const key of Object.keys(paletteRecord)) {
|
||||
@@ -22,7 +23,22 @@ export function extractBrandingPalette(settings: TenantEvent['settings'] | null
|
||||
}
|
||||
}
|
||||
}
|
||||
const fontValue = (brand as Record<string, unknown>).font_family;
|
||||
|
||||
const directColors = [
|
||||
(brand as Record<string, unknown>).primary_color,
|
||||
(brand as Record<string, unknown>).secondary_color,
|
||||
(brand as Record<string, unknown>).background_color,
|
||||
];
|
||||
directColors.forEach((value) => {
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
colors.push(value);
|
||||
}
|
||||
});
|
||||
|
||||
const typography = (brand as Record<string, unknown>).typography as Record<string, unknown> | undefined;
|
||||
const fontValue = (brand as Record<string, unknown>).font_family
|
||||
?? (typography?.body as string | undefined)
|
||||
?? (typography?.heading as string | undefined);
|
||||
if (typeof fontValue === 'string' && fontValue.trim()) {
|
||||
font = fontValue.trim();
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ADMIN_EVENT_TASKS_PATH,
|
||||
ADMIN_EVENT_VIEW_PATH,
|
||||
ADMIN_EVENT_RECAP_PATH,
|
||||
ADMIN_EVENT_BRANDING_PATH,
|
||||
} from '../constants';
|
||||
|
||||
export type EventTabCounts = Partial<{
|
||||
@@ -52,6 +53,11 @@ export function buildEventTabs(event: TenantEvent, translate: Translator, counts
|
||||
href: ADMIN_EVENT_INVITES_PATH(event.slug),
|
||||
badge: formatBadge(counts.invites ?? event.active_invites_count ?? event.total_invites_count ?? null),
|
||||
},
|
||||
{
|
||||
key: 'branding',
|
||||
label: translate('eventMenu.branding', 'Branding'),
|
||||
href: ADMIN_EVENT_BRANDING_PATH(event.slug),
|
||||
},
|
||||
{
|
||||
key: 'photobooth',
|
||||
label: translate('eventMenu.photobooth', 'Photobooth'),
|
||||
|
||||
77
resources/js/admin/lib/fonts.ts
Normal file
77
resources/js/admin/lib/fonts.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { getTenantFonts, type TenantFont, type TenantFontVariant } from '../api';
|
||||
|
||||
const fontLoaders = new Map<string, Promise<void>>();
|
||||
|
||||
export function useTenantFonts() {
|
||||
const { data, isLoading, isFetching, refetch } = useQuery({
|
||||
queryKey: ['tenant', 'fonts'],
|
||||
queryFn: getTenantFonts,
|
||||
staleTime: 6 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
return {
|
||||
fonts: data ?? [],
|
||||
isLoading: isLoading || isFetching,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
export function ensureFontLoaded(font: TenantFont, preferred?: { weight?: number; style?: string }): Promise<void> {
|
||||
if (typeof document === 'undefined' || typeof window === 'undefined') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const variant = pickVariant(font, preferred);
|
||||
if (!variant) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const key = `${font.family}-${variant.weight}-${variant.style}`;
|
||||
|
||||
if (document.fonts?.check(`1rem "${font.family}"`)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (! fontLoaders.has(key)) {
|
||||
const loader = new FontFace(font.family, `url(${variant.url})`, {
|
||||
weight: String(variant.weight ?? ''),
|
||||
style: variant.style ?? 'normal',
|
||||
display: 'swap',
|
||||
})
|
||||
.load()
|
||||
.then((fontFace) => {
|
||||
document.fonts.add(fontFace);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[fonts] failed to load font', font.family, variant, error);
|
||||
fontLoaders.delete(key);
|
||||
});
|
||||
|
||||
fontLoaders.set(key, loader);
|
||||
}
|
||||
|
||||
return fontLoaders.get(key) ?? Promise.resolve();
|
||||
}
|
||||
|
||||
function pickVariant(font: TenantFont, preferred?: { weight?: number; style?: string }): TenantFontVariant | null {
|
||||
const variants = font.variants ?? [];
|
||||
if (! variants.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preferred?.weight || preferred?.style) {
|
||||
const found = variants.find((variant) => {
|
||||
const matchesWeight = preferred.weight ? Number(variant.weight) === Number(preferred.weight) : true;
|
||||
const matchesStyle = preferred.style ? variant.style === preferred.style : true;
|
||||
return matchesWeight && matchesStyle;
|
||||
});
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
return variants[0];
|
||||
}
|
||||
|
||||
745
resources/js/admin/pages/EventBrandingPage.tsx
Normal file
745
resources/js/admin/pages/EventBrandingPage.tsx
Normal file
@@ -0,0 +1,745 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowLeft, Loader2, Moon, Sparkles, Sun } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { SectionCard, SectionHeader } from '../components/tenant';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { ADMIN_EVENT_VIEW_PATH } from '../constants';
|
||||
import { getEvent, getTenantSettings, updateEvent, type TenantEvent } from '../api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getContrastingTextColor } from '../../guest/lib/color';
|
||||
import { buildEventTabs } from '../lib/eventTabs';
|
||||
import { ensureFontLoaded, useTenantFonts } from '../lib/fonts';
|
||||
|
||||
type BrandingForm = {
|
||||
useDefault: boolean;
|
||||
palette: {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
background: string;
|
||||
surface: string;
|
||||
};
|
||||
typography: {
|
||||
heading: string;
|
||||
body: string;
|
||||
size: 's' | 'm' | 'l';
|
||||
};
|
||||
logo: {
|
||||
mode: 'emoticon' | 'upload';
|
||||
value: string;
|
||||
position: 'left' | 'right' | 'center';
|
||||
size: 's' | 'm' | 'l';
|
||||
};
|
||||
buttons: {
|
||||
style: 'filled' | 'outline';
|
||||
radius: number;
|
||||
primary: string;
|
||||
secondary: string;
|
||||
linkColor: string;
|
||||
};
|
||||
mode: 'light' | 'dark' | 'auto';
|
||||
};
|
||||
|
||||
type BrandingSource = Record<string, unknown> | null | undefined;
|
||||
|
||||
const DEFAULT_BRANDING_FORM: BrandingForm = {
|
||||
useDefault: false,
|
||||
palette: {
|
||||
primary: '#f43f5e',
|
||||
secondary: '#fb7185',
|
||||
background: '#ffffff',
|
||||
surface: '#ffffff',
|
||||
},
|
||||
typography: {
|
||||
heading: '',
|
||||
body: '',
|
||||
size: 'm',
|
||||
},
|
||||
logo: {
|
||||
mode: 'emoticon',
|
||||
value: '✨',
|
||||
position: 'left',
|
||||
size: 'm',
|
||||
},
|
||||
buttons: {
|
||||
style: 'filled',
|
||||
radius: 12,
|
||||
primary: '#f43f5e',
|
||||
secondary: '#fb7185',
|
||||
linkColor: '#fb7185',
|
||||
},
|
||||
mode: 'auto',
|
||||
};
|
||||
|
||||
function asString(value: unknown, fallback = ''): string {
|
||||
return typeof value === 'string' ? value : fallback;
|
||||
}
|
||||
|
||||
function asHex(value: unknown, 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 asNumber(value: unknown, fallback: number): number {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function coerceSize(value: unknown, fallback: 's' | 'm' | 'l'): 's' | 'm' | 'l' {
|
||||
return value === 's' || value === 'm' || value === 'l' ? value : fallback;
|
||||
}
|
||||
|
||||
function coerceLogoMode(value: unknown, fallback: 'emoticon' | 'upload'): 'emoticon' | 'upload' {
|
||||
return value === 'upload' || value === 'emoticon' ? value : fallback;
|
||||
}
|
||||
|
||||
function coercePosition(value: unknown, fallback: 'left' | 'right' | 'center'): 'left' | 'right' | 'center' {
|
||||
return value === 'left' || value === 'right' || value === 'center' ? value : fallback;
|
||||
}
|
||||
|
||||
function coerceButtonStyle(value: unknown, fallback: 'filled' | 'outline'): 'filled' | 'outline' {
|
||||
return value === 'outline' || value === 'filled' ? value : fallback;
|
||||
}
|
||||
|
||||
function mapBranding(source: BrandingSource, fallback: BrandingForm = DEFAULT_BRANDING_FORM): BrandingForm {
|
||||
const paletteSource = (source?.palette as Record<string, unknown> | undefined) ?? {};
|
||||
const typographySource = (source?.typography as Record<string, unknown> | undefined) ?? {};
|
||||
const logoSource = (source?.logo as Record<string, unknown> | undefined) ?? {};
|
||||
const buttonSource = (source?.buttons as Record<string, unknown> | undefined) ?? {};
|
||||
|
||||
const palette = {
|
||||
primary: asHex(paletteSource.primary ?? source?.primary_color, fallback.palette.primary),
|
||||
secondary: asHex(paletteSource.secondary ?? source?.secondary_color, fallback.palette.secondary),
|
||||
background: asHex(paletteSource.background ?? source?.background_color, fallback.palette.background),
|
||||
surface: asHex(paletteSource.surface ?? source?.surface_color ?? paletteSource.background ?? source?.background_color, fallback.palette.surface ?? fallback.palette.background),
|
||||
};
|
||||
|
||||
const typography = {
|
||||
heading: asString(typographySource.heading ?? source?.heading_font ?? source?.font_family, fallback.typography.heading),
|
||||
body: asString(typographySource.body ?? source?.body_font ?? source?.font_family, fallback.typography.body),
|
||||
size: coerceSize(typographySource.size ?? source?.font_size, fallback.typography.size),
|
||||
};
|
||||
|
||||
const logoMode = coerceLogoMode(logoSource.mode ?? source?.logo_mode, fallback.logo.mode);
|
||||
const logoValue = asString(logoSource.value ?? source?.logo_value ?? source?.logo_url ?? fallback.logo.value, fallback.logo.value);
|
||||
|
||||
const logo = {
|
||||
mode: logoMode,
|
||||
value: logoValue,
|
||||
position: coercePosition(logoSource.position ?? source?.logo_position, fallback.logo.position),
|
||||
size: coerceSize(logoSource.size ?? source?.logo_size, fallback.logo.size),
|
||||
};
|
||||
|
||||
const buttons = {
|
||||
style: coerceButtonStyle(buttonSource.style ?? source?.button_style, fallback.buttons.style),
|
||||
radius: asNumber(buttonSource.radius ?? source?.button_radius, fallback.buttons.radius),
|
||||
primary: asHex(buttonSource.primary ?? source?.button_primary_color, fallback.buttons.primary ?? palette.primary),
|
||||
secondary: asHex(buttonSource.secondary ?? source?.button_secondary_color, fallback.buttons.secondary ?? palette.secondary),
|
||||
linkColor: asHex(buttonSource.link_color ?? source?.link_color, fallback.buttons.linkColor ?? palette.secondary),
|
||||
};
|
||||
|
||||
return {
|
||||
useDefault: Boolean(source?.use_default_branding ?? source?.use_default ?? fallback.useDefault),
|
||||
palette,
|
||||
typography,
|
||||
logo,
|
||||
buttons,
|
||||
mode: (source?.mode as BrandingForm['mode']) ?? fallback.mode,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPayload(form: BrandingForm) {
|
||||
return {
|
||||
use_default_branding: form.useDefault,
|
||||
primary_color: form.palette.primary,
|
||||
secondary_color: form.palette.secondary,
|
||||
background_color: form.palette.background,
|
||||
surface_color: form.palette.surface,
|
||||
heading_font: form.typography.heading || null,
|
||||
body_font: form.typography.body || null,
|
||||
font_size: form.typography.size,
|
||||
logo_mode: form.logo.mode,
|
||||
logo_value: form.logo.value || null,
|
||||
logo_position: form.logo.position,
|
||||
logo_size: form.logo.size,
|
||||
button_style: form.buttons.style,
|
||||
button_radius: form.buttons.radius,
|
||||
button_primary_color: form.buttons.primary || null,
|
||||
button_secondary_color: form.buttons.secondary || null,
|
||||
link_color: form.buttons.linkColor || null,
|
||||
mode: form.mode,
|
||||
palette: {
|
||||
primary: form.palette.primary,
|
||||
secondary: form.palette.secondary,
|
||||
background: form.palette.background,
|
||||
surface: form.palette.surface,
|
||||
},
|
||||
typography: {
|
||||
heading: form.typography.heading || null,
|
||||
body: form.typography.body || null,
|
||||
size: form.typography.size,
|
||||
},
|
||||
logo: {
|
||||
mode: form.logo.mode,
|
||||
value: form.logo.value || null,
|
||||
position: form.logo.position,
|
||||
size: form.logo.size,
|
||||
},
|
||||
buttons: {
|
||||
style: form.buttons.style,
|
||||
radius: form.buttons.radius,
|
||||
primary: form.buttons.primary || null,
|
||||
secondary: form.buttons.secondary || null,
|
||||
link_color: form.buttons.linkColor || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePreviewBranding(form: BrandingForm, tenantBranding: BrandingForm | null): BrandingForm {
|
||||
if (form.useDefault && tenantBranding) {
|
||||
return { ...tenantBranding, useDefault: true };
|
||||
}
|
||||
if (form.useDefault) {
|
||||
return { ...DEFAULT_BRANDING_FORM, useDefault: true };
|
||||
}
|
||||
return form;
|
||||
}
|
||||
|
||||
export default function EventBrandingPage(): React.ReactElement {
|
||||
const { slug } = useParams<{ slug?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
const queryClient = useQueryClient();
|
||||
const [form, setForm] = useState<BrandingForm>(DEFAULT_BRANDING_FORM);
|
||||
const [previewTheme, setPreviewTheme] = useState<'light' | 'dark'>('light');
|
||||
const { fonts: availableFonts, isLoading: fontsLoading } = useTenantFonts();
|
||||
|
||||
const title = t('branding.title', 'Branding & Fonts');
|
||||
const subtitle = t('branding.subtitle', 'Farben, Typografie, Logo/Emoticon und Schaltflächen für die Gäste-App anpassen.');
|
||||
|
||||
const { data: tenantSettings } = useQuery({
|
||||
queryKey: ['tenant', 'settings', 'branding'],
|
||||
queryFn: getTenantSettings,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const tenantBranding = useMemo(
|
||||
() => mapBranding((tenantSettings?.settings as Record<string, unknown> | undefined)?.branding as BrandingSource, DEFAULT_BRANDING_FORM),
|
||||
[tenantSettings],
|
||||
);
|
||||
|
||||
const {
|
||||
data: loadedEvent,
|
||||
isLoading: eventLoading,
|
||||
} = useQuery<TenantEvent>({
|
||||
queryKey: ['tenant', 'events', slug],
|
||||
queryFn: () => getEvent(slug!),
|
||||
enabled: Boolean(slug),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const eventTabs = useMemo(() => {
|
||||
if (!loadedEvent) return [];
|
||||
const translateMenu = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
return buildEventTabs(loadedEvent, translateMenu);
|
||||
}, [loadedEvent, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loadedEvent) return;
|
||||
|
||||
const brandingSource = (loadedEvent.settings as Record<string, unknown> | undefined)?.branding as BrandingSource;
|
||||
const mapped = mapBranding(brandingSource, tenantBranding ?? DEFAULT_BRANDING_FORM);
|
||||
setForm(mapped);
|
||||
setPreviewTheme(mapped.mode === 'dark' ? 'dark' : 'light');
|
||||
}, [loadedEvent, tenantBranding]);
|
||||
|
||||
useEffect(() => {
|
||||
const resolved = resolvePreviewBranding(form, tenantBranding);
|
||||
setPreviewTheme(resolved.mode === 'dark' ? 'dark' : 'light');
|
||||
}, [form.mode, form.useDefault, tenantBranding]);
|
||||
|
||||
useEffect(() => {
|
||||
const families = [form.typography.heading, form.typography.body].filter(Boolean) as string[];
|
||||
families.forEach((family) => {
|
||||
const font = availableFonts.find((entry) => entry.family === family);
|
||||
if (font) {
|
||||
void ensureFontLoaded(font);
|
||||
}
|
||||
});
|
||||
}, [availableFonts, form.typography.body, form.typography.heading]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (payload: BrandingForm) => {
|
||||
if (!slug) throw new Error('Missing event slug');
|
||||
const response = await updateEvent(slug, {
|
||||
settings: {
|
||||
branding: buildPayload(payload),
|
||||
},
|
||||
});
|
||||
return response;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tenant', 'events', slug] });
|
||||
toast.success(t('branding.saved', 'Branding gespeichert.'));
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
console.error('[branding] save failed', error);
|
||||
toast.error(t('branding.saveError', 'Branding konnte nicht gespeichert werden.'));
|
||||
},
|
||||
});
|
||||
|
||||
if (!slug) {
|
||||
return (
|
||||
<AdminLayout title={title} subtitle={subtitle} tabs={[]} currentTabKey="branding">
|
||||
<SectionCard>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
{t('branding.errors.missingSlug', 'Kein Event ausgewählt – bitte über die Eventliste öffnen.')}
|
||||
</p>
|
||||
</SectionCard>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const resolveFontSelectValue = (current: string): string => {
|
||||
if (!current) return '';
|
||||
return availableFonts.some((font) => font.family === current) ? current : '__custom';
|
||||
};
|
||||
|
||||
const handleFontSelect = (key: 'heading' | 'body', value: string) => {
|
||||
const resolved = value === '__custom' ? '' : value;
|
||||
setForm((prev) => ({ ...prev, typography: { ...prev.typography, [key]: resolved } }));
|
||||
const font = availableFonts.find((entry) => entry.family === resolved);
|
||||
if (font) {
|
||||
void ensureFontLoaded(font);
|
||||
}
|
||||
};
|
||||
|
||||
const previewBranding = resolvePreviewBranding(form, tenantBranding);
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
tabs={eventTabs}
|
||||
currentTabKey="branding"
|
||||
actions={(
|
||||
<Button variant="outline" size="sm" onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(slug))}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{t('branding.actions.back', 'Zurück zum Event')}
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<SectionCard className="space-y-3">
|
||||
<SectionHeader
|
||||
eyebrow={t('branding.sections.mode', 'Standard vs. Event-spezifisch')}
|
||||
title={t('branding.sections.toggleTitle', 'Branding-Quelle wählen')}
|
||||
description={t('branding.sections.toggleDescription', 'Nutze das Standard-Branding oder überschreibe es nur für dieses Event.')}
|
||||
/>
|
||||
<div className="flex items-center justify-between rounded-2xl border border-slate-200 bg-white/80 p-4 dark:border-white/10 dark:bg-slate-900/40">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900 dark:text-white">
|
||||
{form.useDefault
|
||||
? t('branding.useDefault', 'Standard nutzen')
|
||||
: t('branding.useCustom', 'Event-spezifisch')}
|
||||
</p>
|
||||
<p className="text-xs text-slate-600 dark:text-slate-300">
|
||||
{t('branding.toggleHint', 'Standard übernimmt die Tenant-Farben, Event-spezifisch überschreibt sie.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-slate-500 dark:text-slate-300">{t('branding.standard', 'Standard')}</span>
|
||||
<Switch
|
||||
checked={!form.useDefault}
|
||||
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, useDefault: !checked ? true : false }))}
|
||||
aria-label={t('branding.toggleAria', 'Event-spezifisches Branding aktivieren')}
|
||||
/>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-300">{t('branding.custom', 'Event')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<SectionCard className="space-y-4">
|
||||
<SectionHeader
|
||||
eyebrow={t('branding.sections.palette', 'Palette & Modus')}
|
||||
title={t('branding.sections.colorsTitle', 'Farben & Light/Dark')}
|
||||
description={t('branding.sections.colorsDescription', 'Primär-, Sekundär-, Hintergrund- und Surface-Farbe festlegen.')}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{(['primary', 'secondary', 'background', 'surface'] as const).map((key) => (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label htmlFor={`color-${key}`}>{key === 'primary' ? 'Primary' : key === 'secondary' ? 'Secondary' : key === 'background' ? 'Background' : 'Surface'}</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
id={`color-${key}`}
|
||||
type="color"
|
||||
value={form.palette[key]}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, palette: { ...prev.palette, [key]: e.target.value } }))}
|
||||
disabled={form.useDefault}
|
||||
className="h-10 w-16 p-1"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={form.palette[key]}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, palette: { ...prev.palette, [key]: e.target.value } }))}
|
||||
disabled={form.useDefault}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="space-y-2">
|
||||
<Label>{t('branding.mode', 'Modus')}</Label>
|
||||
<Select
|
||||
value={form.mode}
|
||||
onValueChange={(value) => {
|
||||
setForm((prev) => ({ ...prev, mode: value as BrandingForm['mode'] }));
|
||||
setPreviewTheme(value === 'dark' ? 'dark' : 'light');
|
||||
}}
|
||||
disabled={form.useDefault}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="auto" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t('branding.modeAuto', 'Auto')}</SelectItem>
|
||||
<SelectItem value="light">{t('branding.modeLight', 'Hell')}</SelectItem>
|
||||
<SelectItem value="dark">{t('branding.modeDark', 'Dunkel')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard className="space-y-4">
|
||||
<SectionHeader
|
||||
eyebrow={t('branding.sections.typography', 'Typografie & Logo')}
|
||||
title={t('branding.sections.fonts', 'Schriften & Logo/Emoticon')}
|
||||
description={t('branding.sections.fontDescription', 'Heading- und Body-Font sowie Logo/Emoji und Ausrichtung festlegen.')}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('branding.headingFont', 'Heading Font')}</Label>
|
||||
<Select
|
||||
value={resolveFontSelectValue(form.typography.heading)}
|
||||
onValueChange={(value) => handleFontSelect('heading', value)}
|
||||
disabled={form.useDefault || fontsLoading}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('branding.fontDefault', 'Standard (Tenant)')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">{t('branding.fontDefault', 'Standard (Tenant)')}</SelectItem>
|
||||
{availableFonts.map((font) => (
|
||||
<SelectItem key={font.family} value={font.family}>{font.family}</SelectItem>
|
||||
))}
|
||||
<SelectItem value="__custom">{t('branding.fontCustom', 'Eigene Schrift eingeben')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={form.typography.heading}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, typography: { ...prev.typography, heading: e.target.value } }))}
|
||||
disabled={form.useDefault}
|
||||
placeholder="z. B. Playfair Display"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('branding.bodyFont', 'Body Font')}</Label>
|
||||
<Select
|
||||
value={resolveFontSelectValue(form.typography.body)}
|
||||
onValueChange={(value) => handleFontSelect('body', value)}
|
||||
disabled={form.useDefault || fontsLoading}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('branding.fontDefault', 'Standard (Tenant)')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">{t('branding.fontDefault', 'Standard (Tenant)')}</SelectItem>
|
||||
{availableFonts.map((font) => (
|
||||
<SelectItem key={font.family} value={font.family}>{font.family}</SelectItem>
|
||||
))}
|
||||
<SelectItem value="__custom">{t('branding.fontCustom', 'Eigene Schrift eingeben')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={form.typography.body}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, typography: { ...prev.typography, body: e.target.value } }))}
|
||||
disabled={form.useDefault}
|
||||
placeholder="z. B. Inter, sans-serif"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('branding.size', 'Schriftgröße')}</Label>
|
||||
<Select
|
||||
value={form.typography.size}
|
||||
onValueChange={(value) => setForm((prev) => ({ ...prev, typography: { ...prev.typography, size: value as BrandingForm['typography']['size'] } }))}
|
||||
disabled={form.useDefault}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="s">S</SelectItem>
|
||||
<SelectItem value="m">M</SelectItem>
|
||||
<SelectItem value="l">L</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('branding.logoValue', 'Emoticon/Logo-URL')}</Label>
|
||||
<Input
|
||||
value={form.logo.value}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, logo: { ...prev.logo, value: e.target.value } }))}
|
||||
disabled={form.useDefault}
|
||||
placeholder="✨ oder https://..."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('branding.logoMode', 'Logo-Modus')}</Label>
|
||||
<Select
|
||||
value={form.logo.mode}
|
||||
onValueChange={(value) => setForm((prev) => ({ ...prev, logo: { ...prev.logo, mode: value as BrandingForm['logo']['mode'] } }))}
|
||||
disabled={form.useDefault}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="emoticon">{t('branding.emoticon', 'Emoticon/Text')}</SelectItem>
|
||||
<SelectItem value="upload">{t('branding.upload', 'Upload/URL')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('branding.logoPosition', 'Position')}</Label>
|
||||
<Select
|
||||
value={form.logo.position}
|
||||
onValueChange={(value) => setForm((prev) => ({ ...prev, logo: { ...prev.logo, position: value as BrandingForm['logo']['position'] } }))}
|
||||
disabled={form.useDefault}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">{t('branding.left', 'Links')}</SelectItem>
|
||||
<SelectItem value="center">{t('branding.center', 'Zentriert')}</SelectItem>
|
||||
<SelectItem value="right">{t('branding.right', 'Rechts')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
<SectionCard className="space-y-4">
|
||||
<SectionHeader
|
||||
eyebrow={t('branding.sections.buttons', 'Buttons & Links')}
|
||||
title={t('branding.sections.buttonsTitle', 'Buttons, Links & Radius')}
|
||||
description={t('branding.sections.buttonsDescription', 'Stil, Radius und optionale Link-Farbe festlegen.')}
|
||||
/>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('branding.buttonStyle', 'Stil')}</Label>
|
||||
<Select
|
||||
value={form.buttons.style}
|
||||
onValueChange={(value) => setForm((prev) => ({ ...prev, buttons: { ...prev.buttons, style: value as BrandingForm['buttons']['style'] } }))}
|
||||
disabled={form.useDefault}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="filled">{t('branding.filled', 'Filled')}</SelectItem>
|
||||
<SelectItem value="outline">{t('branding.outline', 'Outline')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('branding.radius', 'Radius')}</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={32}
|
||||
value={form.buttons.radius}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, buttons: { ...prev.buttons, radius: Number(e.target.value) } }))}
|
||||
disabled={form.useDefault}
|
||||
className="w-full"
|
||||
/>
|
||||
<span className="w-10 text-right text-sm text-slate-600 dark:text-slate-200">{form.buttons.radius}px</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('branding.linkColor', 'Link-Farbe')}</Label>
|
||||
<Input
|
||||
value={form.buttons.linkColor}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, buttons: { ...prev.buttons, linkColor: e.target.value } }))}
|
||||
disabled={form.useDefault}
|
||||
placeholder="#fb7185"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('branding.buttonPrimary', 'Button Primary')}</Label>
|
||||
<Input
|
||||
value={form.buttons.primary}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, buttons: { ...prev.buttons, primary: e.target.value } }))}
|
||||
disabled={form.useDefault}
|
||||
placeholder={form.palette.primary}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('branding.buttonSecondary', 'Button Secondary')}</Label>
|
||||
<Input
|
||||
value={form.buttons.secondary}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, buttons: { ...prev.buttons, secondary: e.target.value } }))}
|
||||
disabled={form.useDefault}
|
||||
placeholder={form.palette.secondary}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard className="space-y-4">
|
||||
<SectionHeader
|
||||
eyebrow={t('branding.sections.preview', 'Preview')}
|
||||
title={t('branding.sections.previewTitle', 'Mini-Gastansicht')}
|
||||
description={t('branding.sections.previewCopy', 'Header, CTA und Bottom-Navigation nach Branding visualisiert.')}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-200">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<span>{form.useDefault ? t('branding.usingDefault', 'Standard-Branding aktiv') : t('branding.usingCustom', 'Event-Branding aktiv')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="icon"
|
||||
variant={previewTheme === 'light' ? 'secondary' : 'ghost'}
|
||||
onClick={() => setPreviewTheme('light')}
|
||||
aria-label="Light Preview"
|
||||
>
|
||||
<Sun className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant={previewTheme === 'dark' ? 'secondary' : 'ghost'}
|
||||
onClick={() => setPreviewTheme('dark')}
|
||||
aria-label="Dark Preview"
|
||||
>
|
||||
<Moon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<BrandingPreview branding={previewBranding} theme={previewTheme} />
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 z-40 mt-6 bg-gradient-to-t from-white via-white to-white/70 py-3 backdrop-blur dark:from-slate-900 dark:via-slate-900 dark:to-slate-900/60">
|
||||
<div className="mx-auto flex max-w-5xl items-center justify-between gap-3 px-2 sm:px-0">
|
||||
<div className="text-sm text-slate-600 dark:text-slate-200">
|
||||
{form.useDefault
|
||||
? t('branding.footer.default', 'Standard-Farben des Tenants aktiv.')
|
||||
: t('branding.footer.custom', 'Event-spezifisches Branding aktiv.')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setForm({ ...(tenantBranding ?? DEFAULT_BRANDING_FORM), useDefault: true })}
|
||||
>
|
||||
{t('branding.reset', 'Auf Standard zurücksetzen')}
|
||||
</Button>
|
||||
<Button onClick={() => mutation.mutate(form)} disabled={mutation.isPending || eventLoading}>
|
||||
{mutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('branding.saving', 'Speichern...')}
|
||||
</>
|
||||
) : (
|
||||
t('branding.save', 'Branding speichern')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function BrandingPreview({ branding, theme }: { branding: BrandingForm; theme: 'light' | 'dark' }) {
|
||||
const textColor = getContrastingTextColor(branding.palette.primary, '#0f172a', '#ffffff');
|
||||
const headerStyle: React.CSSProperties = {
|
||||
background: `linear-gradient(135deg, ${branding.palette.primary}, ${branding.palette.secondary})`,
|
||||
color: textColor,
|
||||
};
|
||||
|
||||
const buttonStyle: React.CSSProperties = branding.buttons.style === 'outline'
|
||||
? {
|
||||
border: `2px solid ${branding.buttons.primary || branding.palette.primary}`,
|
||||
color: branding.buttons.primary || branding.palette.primary,
|
||||
background: 'transparent',
|
||||
}
|
||||
: {
|
||||
background: branding.buttons.primary || branding.palette.primary,
|
||||
color: getContrastingTextColor(branding.buttons.primary || branding.palette.primary, '#0f172a', '#ffffff'),
|
||||
border: 'none',
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={cn('overflow-hidden', theme === 'dark' ? 'bg-slate-900 text-white' : 'bg-white text-slate-900')}>
|
||||
<CardHeader className="p-0">
|
||||
<div className="px-4 py-3" style={headerStyle}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/90 text-xl">
|
||||
{branding.logo.value || '✨'}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<CardTitle className="text-base font-semibold" style={{ fontFamily: branding.typography.heading || undefined }}>
|
||||
Demo Event
|
||||
</CardTitle>
|
||||
<span className="text-xs opacity-80">Gastansicht · {branding.mode}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 bg-[var(--surface)] px-4 py-5" style={{ ['--surface' as string]: branding.palette.surface }}>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-200" style={{ fontFamily: branding.typography.body || undefined }}>
|
||||
CTA & Buttons spiegeln den gewählten Stil wider.
|
||||
</p>
|
||||
<Button
|
||||
className="shadow-md transition"
|
||||
style={{
|
||||
...buttonStyle,
|
||||
borderRadius: branding.buttons.radius,
|
||||
paddingInline: '18px',
|
||||
paddingBlock: '10px',
|
||||
}}
|
||||
>
|
||||
Jetzt Fotos hochladen
|
||||
</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between rounded-2xl border border-dashed border-slate-200 p-3 text-sm dark:border-slate-700" style={{ borderRadius: branding.buttons.radius }}>
|
||||
<span style={{ color: branding.buttons.linkColor || branding.palette.secondary }}>Bottom Navigation</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1.5 w-10 rounded-full" style={{ background: branding.palette.primary }} />
|
||||
<div className="h-1.5 w-10 rounded-full" style={{ background: branding.palette.secondary }} />
|
||||
<div className="h-1.5 w-10 rounded-full" style={{ background: branding.palette.surface }} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
ADMIN_EVENT_PHOTOBOOTH_PATH,
|
||||
ADMIN_EVENT_PHOTOS_PATH,
|
||||
ADMIN_EVENT_TASKS_PATH,
|
||||
ADMIN_EVENT_BRANDING_PATH,
|
||||
buildEngagementTabPath,
|
||||
} from '../constants';
|
||||
import {
|
||||
@@ -491,7 +492,7 @@ const shownWarningToasts = React.useRef<Set<string>>(new Set());
|
||||
event={event}
|
||||
invites={toolkitData?.invites}
|
||||
emotions={emotions}
|
||||
onOpenBranding={() => navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)}
|
||||
onOpenBranding={() => navigate(ADMIN_EVENT_BRANDING_PATH(event.slug))}
|
||||
onOpenCollections={() => navigate(buildEngagementTabPath('collections'))}
|
||||
onOpenTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))}
|
||||
onOpenEmotions={() => navigate(buildEngagementTabPath('emotions'))}
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
TenantEmotion,
|
||||
} from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { ADMIN_EVENTS_PATH, ADMIN_EVENT_INVITES_PATH, buildEngagementTabPath } from '../constants';
|
||||
import { ADMIN_EVENTS_PATH, ADMIN_EVENT_BRANDING_PATH, buildEngagementTabPath } from '../constants';
|
||||
import { extractBrandingPalette } from '../lib/branding';
|
||||
import { filterEmotionsByEventType } from '../lib/emotions';
|
||||
import { buildEventTabs } from '../lib/eventTabs';
|
||||
@@ -506,7 +506,7 @@ export default function EventTasksPage() {
|
||||
collections={collections}
|
||||
onOpenBranding={() => {
|
||||
if (!slug) return;
|
||||
navigate(`${ADMIN_EVENT_INVITES_PATH(slug)}?tab=layout`);
|
||||
navigate(ADMIN_EVENT_BRANDING_PATH(slug));
|
||||
}}
|
||||
onOpenEmotions={() => navigate(buildEngagementTabPath('emotions'))}
|
||||
onOpenCollections={() => navigate(buildEngagementTabPath('collections'))}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
ADMIN_EVENT_TASKS_PATH,
|
||||
ADMIN_EVENT_INVITES_PATH,
|
||||
ADMIN_EVENT_PHOTOBOOTH_PATH,
|
||||
ADMIN_EVENT_BRANDING_PATH,
|
||||
} from '../constants';
|
||||
import { buildLimitWarnings } from '../lib/limitWarnings';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -182,6 +183,7 @@ function EventCard({
|
||||
{ key: 'members', label: translate('events.list.actions.members', 'Mitglieder'), to: ADMIN_EVENT_MEMBERS_PATH(slug) },
|
||||
{ key: 'tasks', label: translate('events.list.actions.tasks', 'Tasks'), to: ADMIN_EVENT_TASKS_PATH(slug) },
|
||||
{ key: 'invites', label: translate('events.list.actions.invites', 'QR-Einladungen'), to: ADMIN_EVENT_INVITES_PATH(slug) },
|
||||
{ key: 'branding', label: translate('events.list.actions.branding', 'Branding'), to: ADMIN_EVENT_BRANDING_PATH(slug) },
|
||||
{ key: 'photobooth', label: translate('events.list.actions.photobooth', 'Photobooth'), to: ADMIN_EVENT_PHOTOBOOTH_PATH(slug) },
|
||||
{ key: 'toolkit', label: translate('events.list.actions.toolkit', 'Toolkit'), to: ADMIN_EVENT_VIEW_PATH(slug) },
|
||||
];
|
||||
|
||||
@@ -31,6 +31,7 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ensureFontLoaded, useTenantFonts } from '../../lib/fonts';
|
||||
|
||||
import type { EventQrInvite, EventQrInviteLayout } from '../../api';
|
||||
import { authorizedFetch } from '../../auth/tokens';
|
||||
@@ -214,6 +215,7 @@ export function InviteLayoutCustomizerPanel({
|
||||
onDraftChange,
|
||||
}: InviteLayoutCustomizerPanelProps): React.JSX.Element {
|
||||
const { t } = useTranslation('management');
|
||||
const { fonts: availableFonts, isLoading: fontsLoading } = useTenantFonts();
|
||||
|
||||
const inviteUrl = invite?.url ?? '';
|
||||
const qrCodeDataUrl = invite?.qr_code_data_url ?? null;
|
||||
@@ -269,6 +271,40 @@ export function InviteLayoutCustomizerPanel({
|
||||
const appliedLayoutRef = React.useRef<string | null>(null);
|
||||
const appliedInviteRef = React.useRef<number | string | null>(null);
|
||||
|
||||
const handleElementFontChange = React.useCallback(
|
||||
(id: string, family: string) => {
|
||||
updateElement(id, { fontFamily: family || null });
|
||||
const font = availableFonts.find((entry) => entry.family === family);
|
||||
if (font) {
|
||||
void ensureFontLoaded(font).then(() => {
|
||||
fabricCanvasRef.current?.requestRenderAll();
|
||||
});
|
||||
}
|
||||
},
|
||||
[availableFonts, updateElement]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!availableFonts.length || !elements.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const families = Array.from(
|
||||
new Set(
|
||||
elements
|
||||
.map((element) => element.fontFamily)
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
),
|
||||
);
|
||||
|
||||
families.forEach((family) => {
|
||||
const font = availableFonts.find((entry) => entry.family === family);
|
||||
if (font) {
|
||||
void ensureFontLoaded(font);
|
||||
}
|
||||
});
|
||||
}, [availableFonts, elements]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
setIsCompact(false);
|
||||
@@ -1269,7 +1305,7 @@ export function InviteLayoutCustomizerPanel({
|
||||
|
||||
if (element.type !== 'qr') {
|
||||
blocks.push(
|
||||
<div className="grid gap-4 sm:grid-cols-2" key={`${element.id}-appearance`}>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3" key={`${element.id}-appearance`}>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('invites.customizer.elements.align', 'Ausrichtung')}</Label>
|
||||
<ToggleGroup
|
||||
@@ -1301,6 +1337,29 @@ export function InviteLayoutCustomizerPanel({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('invites.customizer.elements.fontFamily', 'Schriftart')}</Label>
|
||||
<Select
|
||||
value={availableFonts.some((font) => font.family === element.fontFamily) ? element.fontFamily ?? '' : ''}
|
||||
onValueChange={(value) => handleElementFontChange(element.id, value)}
|
||||
disabled={fontsLoading}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('invites.customizer.elements.fontPlaceholder', 'Standard')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">{t('invites.customizer.elements.fontPlaceholder', 'Standard')}</SelectItem>
|
||||
{availableFonts.map((font) => (
|
||||
<SelectItem key={font.family} value={font.family}>{font.family}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={element.fontFamily ?? ''}
|
||||
onChange={(event) => handleElementFontChange(element.id, event.target.value)}
|
||||
placeholder="z. B. Playfair Display"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1322,7 +1381,7 @@ export function InviteLayoutCustomizerPanel({
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[elementBindings, form, t, updateElement, updateElementAlign, updateElementContent, updateForm]
|
||||
[availableFonts, elementBindings, form, handleElementFontChange, t, updateElement, updateElementAlign, updateElementContent, updateForm]
|
||||
);
|
||||
|
||||
const renderActionButtons = (mode: 'inline' | 'floating') => (
|
||||
|
||||
@@ -20,6 +20,7 @@ const EventTasksPage = React.lazy(() => import('./pages/EventTasksPage'));
|
||||
const EventToolkitPage = React.lazy(() => import('./pages/EventToolkitPage'));
|
||||
const EventInvitesPage = React.lazy(() => import('./pages/EventInvitesPage'));
|
||||
const EventPhotoboothPage = React.lazy(() => import('./pages/EventPhotoboothPage'));
|
||||
const EventBrandingPage = React.lazy(() => import('./pages/EventBrandingPage'));
|
||||
const EngagementPage = React.lazy(() => import('./pages/EngagementPage'));
|
||||
const BillingPage = React.lazy(() => import('./pages/BillingPage'));
|
||||
const TasksPage = React.lazy(() => import('./pages/TasksPage'));
|
||||
@@ -105,6 +106,7 @@ export const router = createBrowserRouter([
|
||||
{ path: 'events/:slug/members', element: <RequireAdminAccess><EventMembersPage /></RequireAdminAccess> },
|
||||
{ path: 'events/:slug/tasks', element: <EventTasksPage /> },
|
||||
{ path: 'events/:slug/invites', element: <EventInvitesPage /> },
|
||||
{ path: 'events/:slug/branding', element: <RequireAdminAccess><EventBrandingPage /></RequireAdminAccess> },
|
||||
{ path: 'events/:slug/photobooth', element: <RequireAdminAccess><EventPhotoboothPage /></RequireAdminAccess> },
|
||||
{ path: 'events/:slug/toolkit', element: <EventToolkitPage /> },
|
||||
{ path: 'engagement', element: <EngagementPage /> },
|
||||
|
||||
Reference in New Issue
Block a user