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:
Codex Agent
2025-11-25 19:31:52 +01:00
parent 4d31eb4d42
commit 9bde8f3f32
38 changed files with 2420 additions and 104 deletions

View File

@@ -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();
}

View File

@@ -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'),

View 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];
}