events werden nun erfolgreich gespeichert, branding wird nun erfolgreich gespeichert, emotionen können nun angelegt werden. Task Ansicht im Event admin verbessert, Buttons in FAB umgewandelt und vereinheitlicht. Teilen-Link Guest PWA schicker gemacht, SynGoogleFonts ausgebaut (mit Einzel-Family-Download).

This commit is contained in:
Codex Agent
2025-11-27 16:08:08 +01:00
parent bfa15cc48e
commit 96f8c5d63c
39 changed files with 1970 additions and 640 deletions

View File

@@ -2,11 +2,12 @@ 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 { ArrowLeft, Moon, RotateCcw, Save, Sparkles, Sun, UploadCloud } from 'lucide-react';
import toast from 'react-hot-toast';
import { AdminLayout } from '../components/AdminLayout';
import { SectionCard, SectionHeader } from '../components/tenant';
import { FloatingActionBar, type FloatingAction } from '../components/FloatingActionBar';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -14,6 +15,7 @@ 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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { ADMIN_EVENT_VIEW_PATH } from '../constants';
import { getEvent, getTenantSettings, updateEvent, type TenantEvent } from '../api';
import { cn } from '@/lib/utils';
@@ -23,6 +25,20 @@ import { ensureFontLoaded, useTenantFonts } from '../lib/fonts';
const DEFAULT_FONT_VALUE = '__default';
const CUSTOM_FONT_VALUE = '__custom';
const MAX_LOGO_UPLOAD_BYTES = 1024 * 1024;
const EMOTICON_GRID: string[] = [
'✨', '🎉', '🎊', '🥳', '🎈', '🎁', '🎂', '🍾', '🥂', '🍻',
'😀', '😃', '😄', '😁', '😆', '😅', '😂', '🤣', '😊', '😇',
'🙂', '🙃', '😉', '😌', '😍', '🥰', '😘', '😗', '😙', '😚',
'😎', '🤩', '🤗', '🤝', '👍', '🙌', '👏', '👐', '🤲', '🙏',
'🤍', '❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤎', '🤍',
'⭐', '🌟', '💫', '🔥', '⚡', '🌈', '☀️', '🌅', '🌠', '🌌',
'🎵', '🎶', '🎤', '🎧', '🎸', '🥁', '🎺', '🎹', '🎻', '🪩',
'🍕', '🍔', '🌮', '🌯', '🍣', '🍱', '🍰', '🍪', '🍫', '🍩',
'☕', '🍵', '🥤', '🍹', '🍸', '🍷', '🍺', '🍻', '🥂', '🍾',
'📸', '🎥', '📹', '📱', '💡', '🛎️', '🪄', '🎯', '🏆', '🥇',
];
type BrandingForm = {
useDefault: boolean;
@@ -225,6 +241,43 @@ function resolvePreviewBranding(form: BrandingForm, tenantBranding: BrandingForm
return form;
}
function coerceEventName(value: unknown): string {
if (typeof value === 'string') {
return value;
}
if (value && typeof value === 'object') {
const record = value as Record<string, unknown>;
const preferred = record.de ?? record.en ?? Object.values(record)[0];
if (typeof preferred === 'string') {
return preferred;
}
}
return '';
}
function coerceEventDate(value: unknown): string | null {
if (typeof value === 'string' && value.trim()) {
const raw = value.trim();
// If ISO with timezone, convert to local date (validation uses server local date)
const parsed = new Date(raw);
if (!Number.isNaN(parsed.valueOf())) {
const year = parsed.getFullYear();
const month = String(parsed.getMonth() + 1).padStart(2, '0');
const day = String(parsed.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
if (raw.length >= 10) {
return raw.slice(0, 10);
}
return raw;
}
return null;
}
export default function EventBrandingPage(): React.ReactElement {
const { slug } = useParams<{ slug?: string }>();
const navigate = useNavigate();
@@ -232,6 +285,7 @@ export default function EventBrandingPage(): React.ReactElement {
const queryClient = useQueryClient();
const [form, setForm] = useState<BrandingForm>(DEFAULT_BRANDING_FORM);
const [previewTheme, setPreviewTheme] = useState<'light' | 'dark'>('light');
const [emoticonDialogOpen, setEmoticonDialogOpen] = useState(false);
const { fonts: availableFonts, isLoading: fontsLoading } = useTenantFonts();
const title = t('branding.title', 'Branding & Fonts');
@@ -290,11 +344,32 @@ export default function EventBrandingPage(): React.ReactElement {
const mutation = useMutation({
mutationFn: async (payload: BrandingForm) => {
if (!slug) throw new Error('Missing event slug');
if (!slug) throw new Error('Missing event context');
// Fetch a fresh snapshot to ensure required fields are sent and settings are merged instead of overwritten.
const latest = await getEvent(slug);
const eventTypeId = latest.event_type_id ?? latest.event_type?.id;
const eventDate = coerceEventDate(latest.event_date ?? loadedEvent?.event_date);
const eventName = coerceEventName(latest.name ?? loadedEvent?.name);
if (!eventTypeId || !eventName || !eventDate) {
throw new Error('Missing required event fields');
}
const mergedSettings = {
...(latest.settings ?? {}),
branding: buildPayload(payload),
} as Record<string, unknown>;
const response = await updateEvent(slug, {
settings: {
branding: buildPayload(payload),
},
name: eventName,
slug: latest.slug,
event_type_id: eventTypeId,
event_date: eventDate,
status: latest.status,
is_active: latest.is_active,
package_id: latest.package?.id,
settings: mergedSettings,
});
return response;
},
@@ -304,10 +379,20 @@ export default function EventBrandingPage(): React.ReactElement {
},
onError: (error: unknown) => {
console.error('[branding] save failed', error);
if ((error as { meta?: { errors?: Record<string, string[]> } })?.meta?.errors) {
const errors = (error as { meta?: { errors?: Record<string, string[]> } }).meta?.errors ?? {};
const flat = Object.entries(errors)
.map(([key, messages]) => `${key}: ${Array.isArray(messages) ? messages.join(', ') : String(messages)}`)
.join('\n');
toast.error(flat || t('branding.saveError', 'Branding konnte nicht gespeichert werden.'));
return;
}
toast.error(t('branding.saveError', 'Branding konnte nicht gespeichert werden.'));
},
});
const { mutate, isPending } = mutation;
if (!slug) {
return (
<AdminLayout title={title} subtitle={subtitle} tabs={[]} currentTabKey="branding">
@@ -334,8 +419,61 @@ export default function EventBrandingPage(): React.ReactElement {
}
};
const handleFontPreview = (family: string) => {
const font = availableFonts.find((entry) => entry.family === family);
if (font) {
void ensureFontLoaded(font);
}
};
const handleLogoUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (file.size > MAX_LOGO_UPLOAD_BYTES) {
toast.error(t('branding.logoTooLarge', 'Das Logo darf maximal 1 MB groß sein.'));
event.target.value = '';
return;
}
const reader = new FileReader();
reader.onload = () => {
const dataUrl = typeof reader.result === 'string' ? reader.result : '';
setForm((prev) => ({ ...prev, logo: { ...prev.logo, mode: 'upload', value: dataUrl } }));
};
reader.readAsDataURL(file);
};
const handleEmoticonSelect = (value: string) => {
setForm((prev) => ({ ...prev, logo: { ...prev.logo, mode: 'emoticon', value } }));
};
const previewBranding = resolvePreviewBranding(form, tenantBranding);
const fabActions = React.useMemo<FloatingAction[]>(() => {
if (!slug) return [];
return [
{
key: 'save',
label: isPending ? t('branding.saving', 'Speichern...') : t('branding.save', 'Branding speichern'),
icon: Save,
onClick: () => mutate(form),
loading: isPending,
disabled: isPending || eventLoading,
tone: 'primary',
},
{
key: 'reset',
label: t('branding.reset', 'Auf Standard zurücksetzen'),
icon: RotateCcw,
onClick: () => setForm({ ...(tenantBranding ?? DEFAULT_BRANDING_FORM), useDefault: true }),
disabled: isPending,
tone: 'secondary',
},
];
}, [slug, mutate, form, isPending, eventLoading, t, tenantBranding]);
return (
<AdminLayout
title={title}
@@ -349,7 +487,7 @@ export default function EventBrandingPage(): React.ReactElement {
</Button>
)}
>
<div className="space-y-4">
<div className="space-y-4 pb-28">
<SectionCard className="space-y-3">
<SectionHeader
eyebrow={t('branding.sections.mode', 'Standard vs. Event-spezifisch')}
@@ -451,7 +589,17 @@ export default function EventBrandingPage(): React.ReactElement {
<SelectContent>
<SelectItem value={DEFAULT_FONT_VALUE}>{t('branding.fontDefault', 'Standard (Tenant)')}</SelectItem>
{availableFonts.map((font) => (
<SelectItem key={font.family} value={font.family}>{font.family}</SelectItem>
<SelectItem
key={font.family}
value={font.family}
onMouseEnter={() => handleFontPreview(font.family)}
onFocus={() => handleFontPreview(font.family)}
>
<span className="flex items-center justify-between gap-3">
<span className="truncate" style={{ fontFamily: font.family }}>{font.family}</span>
<span className="text-xs text-muted-foreground" style={{ fontFamily: font.family }}>AaBb</span>
</span>
</SelectItem>
))}
<SelectItem value={CUSTOM_FONT_VALUE}>{t('branding.fontCustom', 'Eigene Schrift eingeben')}</SelectItem>
</SelectContent>
@@ -461,6 +609,7 @@ export default function EventBrandingPage(): React.ReactElement {
onChange={(e) => setForm((prev) => ({ ...prev, typography: { ...prev.typography, heading: e.target.value } }))}
disabled={form.useDefault}
placeholder="z. B. Playfair Display"
style={form.typography.heading ? { fontFamily: form.typography.heading } : undefined}
/>
</div>
<div className="space-y-2">
@@ -476,7 +625,17 @@ export default function EventBrandingPage(): React.ReactElement {
<SelectContent>
<SelectItem value={DEFAULT_FONT_VALUE}>{t('branding.fontDefault', 'Standard (Tenant)')}</SelectItem>
{availableFonts.map((font) => (
<SelectItem key={font.family} value={font.family}>{font.family}</SelectItem>
<SelectItem
key={font.family}
value={font.family}
onMouseEnter={() => handleFontPreview(font.family)}
onFocus={() => handleFontPreview(font.family)}
>
<span className="flex items-center justify-between gap-3">
<span className="truncate" style={{ fontFamily: font.family }}>{font.family}</span>
<span className="text-xs text-muted-foreground" style={{ fontFamily: font.family }}>AaBb</span>
</span>
</SelectItem>
))}
<SelectItem value={CUSTOM_FONT_VALUE}>{t('branding.fontCustom', 'Eigene Schrift eingeben')}</SelectItem>
</SelectContent>
@@ -486,6 +645,7 @@ export default function EventBrandingPage(): React.ReactElement {
onChange={(e) => setForm((prev) => ({ ...prev, typography: { ...prev.typography, body: e.target.value } }))}
disabled={form.useDefault}
placeholder="z. B. Inter, sans-serif"
style={form.typography.body ? { fontFamily: form.typography.body } : undefined}
/>
</div>
<div className="space-y-2">
@@ -528,6 +688,72 @@ export default function EventBrandingPage(): React.ReactElement {
</SelectContent>
</Select>
</div>
{form.logo.mode === 'emoticon' && (
<div className="space-y-2">
<Label>{t('branding.logoEmoticonGrid', 'Schnellauswahl (Emoticons)')}</Label>
<Dialog open={emoticonDialogOpen} onOpenChange={setEmoticonDialogOpen}>
<DialogTrigger asChild>
<Button type="button" variant="outline" size="sm" disabled={form.useDefault}>
{t('branding.openEmoticons', 'Emoticon-Gitter öffnen')}
</Button>
</DialogTrigger>
<DialogContent className="max-w-xl bg-white/95 p-4 text-left dark:bg-slate-950">
<DialogHeader>
<DialogTitle className="text-base font-semibold">
{t('branding.logoEmoticonGrid', 'Schnellauswahl (Emoticons)')}
</DialogTitle>
</DialogHeader>
<div className="mt-3 grid grid-cols-8 gap-2 sm:grid-cols-10">
{EMOTICON_GRID.map((emoji) => {
const isActive = form.logo.value === emoji;
return (
<button
key={emoji}
type="button"
className={cn(
'flex h-12 w-full items-center justify-center rounded-lg text-3xl leading-none transition sm:h-12',
isActive
? 'bg-slate-900 text-white shadow-sm ring-2 ring-slate-900 dark:bg-white/90 dark:text-slate-900 dark:ring-white/90'
: 'bg-white text-slate-900 hover:bg-slate-100 dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700',
)}
onClick={() => {
handleEmoticonSelect(emoji);
setEmoticonDialogOpen(false);
}}
disabled={form.useDefault}
title={emoji}
>
<span aria-hidden>{emoji}</span>
</button>
);
})}
</div>
</DialogContent>
</Dialog>
</div>
)}
{form.logo.mode === 'upload' && (
<div className="space-y-2">
<Label>{t('branding.logoUpload', 'Logo hochladen')}</Label>
<div className="flex flex-wrap items-center gap-3">
<Button type="button" variant="outline" size="sm" disabled={form.useDefault} onClick={() => document.getElementById('branding-logo-upload')?.click()}>
<UploadCloud className="mr-2 h-4 w-4" />
{t('branding.logoUploadButton', 'Datei auswählen')}
</Button>
<input
id="branding-logo-upload"
type="file"
accept="image/*"
className="hidden"
onChange={handleLogoUpload}
disabled={form.useDefault}
/>
<span className="text-xs text-slate-600 dark:text-slate-300">
{t('branding.logoUploadHint', 'Max. 1 MB, PNG/SVG/JPG. Aktueller Wert wird ersetzt.')}
</span>
</div>
</div>
)}
<div className="space-y-2">
<Label>{t('branding.logoPosition', 'Position')}</Label>
<Select
@@ -649,33 +875,7 @@ export default function EventBrandingPage(): React.ReactElement {
</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>
<FloatingActionBar actions={fabActions} />
</AdminLayout>
);
}
@@ -688,6 +888,14 @@ function BrandingPreview({ branding, theme }: { branding: BrandingForm; theme: '
color: textColor,
};
const logoVisual = useMemo(() => {
const looksLikeImage = branding.logo.mode === 'upload' && branding.logo.value && /^(data:|https?:)/i.test(branding.logo.value);
if (looksLikeImage) {
return <img src={branding.logo.value} alt="Logo" className="h-10 w-10 rounded-full object-cover" />;
}
return <span className="text-xl">{branding.logo.value || '✨'}</span>;
}, [branding.logo.mode, branding.logo.value]);
const buttonStyle: React.CSSProperties = branding.buttons.style === 'outline'
? {
border: `2px solid ${branding.buttons.primary || branding.palette.primary}`,
@@ -705,8 +913,8 @@ function BrandingPreview({ branding, theme }: { branding: BrandingForm; theme: '
<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 className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-white/90 text-xl">
{logoVisual}
</div>
<div className="flex flex-col">
<CardTitle className="text-base font-semibold" style={{ fontFamily: branding.typography.heading || undefined }}>