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:
@@ -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 }}>
|
||||
|
||||
Reference in New Issue
Block a user