Files
fotospiel-app/resources/js/admin/pages/EventBrandingPage.tsx
2025-12-10 15:49:08 +01:00

326 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Image as ImageIcon, RefreshCcw, Save, Sparkles } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { AppCard, PrimaryCTA, BottomNav } from '../tamagui/primitives';
import { AdminLayout } from '../components/AdminLayout';
import { TenantEvent, getEvent, updateEvent } from '../api';
import { getApiErrorMessage } from '../lib/apiError';
import { isAuthError } from '../auth/tokens';
import { adminPath } from '../constants';
type BrandingForm = {
primary: string;
accent: string;
headingFont: string;
bodyFont: string;
};
export default function EventBrandingPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const slug = slugParam ?? null;
const navigate = useNavigate();
const { t } = useTranslation('management');
const [event, setEvent] = React.useState<TenantEvent | null>(null);
const [form, setForm] = React.useState<BrandingForm>({
primary: '#007AFF',
accent: '#5AD2F4',
headingFont: '',
bodyFont: '',
});
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
if (!slug) return;
setLoading(true);
(async () => {
try {
const data = await getEvent(slug);
setEvent(data);
const branding = extractBranding(data);
setForm(branding);
setError(null);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Branding konnte nicht geladen werden.')));
}
} finally {
setLoading(false);
}
})();
}, [slug, t]);
async function handleSave() {
if (!event?.slug) return;
setSaving(true);
setError(null);
try {
const nextSettings = { ...(event.settings ?? {}) };
nextSettings.branding = {
...(typeof nextSettings.branding === 'object' ? (nextSettings.branding as Record<string, unknown>) : {}),
primary_color: form.primary,
accent_color: form.accent,
heading_font: form.headingFont,
body_font: form.bodyFont,
};
const updated = await updateEvent(event.slug, { settings: nextSettings });
setEvent(updated);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.saveFailed', 'Branding konnte nicht gespeichert werden.')));
}
} finally {
setSaving(false);
}
}
function handleReset() {
if (event) {
setForm(extractBranding(event));
}
}
const previewTitle = event ? resolveName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event');
return (
<AdminLayout title={t('events.branding.title', 'Branding & Customization')} subtitle={t('events.branding.subtitle', 'Gib der Gäste-App dein Branding.')} disableCommandShelf>
<YStack space="$3" maxWidth={640} marginHorizontal="auto" paddingBottom="$9">
<XStack alignItems="center" justifyContent="space-between" space="$2">
<Button backgroundColor="white" borderColor="$muted" borderWidth={1} onPress={() => navigate(-1)}>
<XStack alignItems="center" space="$1.5">
<RefreshCcw size={16} />
<Text>{t('events.actions.back', 'Zurück')}</Text>
</XStack>
</Button>
<Button backgroundColor="white" borderColor="$muted" borderWidth={1} onPress={() => navigate(adminPath('/settings'))}>
<XStack alignItems="center" space="$1.5">
<Sparkles size={16} />
<Text>{t('events.branding.settings', 'Einstellungen')}</Text>
</XStack>
</Button>
</XStack>
{error ? (
<AppCard>
<Text fontWeight="700" color="#b91c1c">
{error}
</Text>
</AppCard>
) : null}
<AppCard space="$3">
<Text fontSize="$sm" fontWeight="700">
{t('events.branding.previewTitle', 'Guest App Preview')}
</Text>
<YStack
borderRadius="$card"
borderWidth={1}
borderColor="$muted"
backgroundColor="#f8fafc"
padding="$3"
alignItems="center"
space="$2"
>
<YStack width="100%" borderRadius="$card" overflow="hidden" backgroundColor="white" borderWidth={1} borderColor="$muted">
<YStack backgroundColor={form.primary} padding="$3" />
<YStack padding="$3" space="$2">
<Text fontSize="$md" fontWeight="800">
{previewTitle}
</Text>
<Text fontSize="$sm" color="$color">
{t('events.branding.previewSubtitle', 'Aktuelle Branding-Farben und Schriften')}
</Text>
<XStack space="$2">
<ColorSwatch color={form.primary} label={t('events.branding.primary', 'Primary')} />
<ColorSwatch color={form.accent} label={t('events.branding.accent', 'Accent')} />
</XStack>
</YStack>
</YStack>
</YStack>
</AppCard>
<AppCard space="$3">
<Text fontSize="$md" fontWeight="800">
{t('events.branding.colors', 'Colors')}
</Text>
<ColorField
label={t('events.branding.primary', 'Primary Color')}
value={form.primary}
onChange={(value) => setForm((prev) => ({ ...prev, primary: value }))}
/>
<ColorField
label={t('events.branding.accent', 'Accent Color')}
value={form.accent}
onChange={(value) => setForm((prev) => ({ ...prev, accent: value }))}
/>
</AppCard>
<AppCard space="$3">
<Text fontSize="$md" fontWeight="800">
{t('events.branding.fonts', 'Fonts')}
</Text>
<InputField
label={t('events.branding.headingFont', 'Headline Font')}
value={form.headingFont}
placeholder="SF Pro Display"
onChange={(value) => setForm((prev) => ({ ...prev, headingFont: value }))}
/>
<InputField
label={t('events.branding.bodyFont', 'Body Font')}
value={form.bodyFont}
placeholder="SF Pro Text"
onChange={(value) => setForm((prev) => ({ ...prev, bodyFont: value }))}
/>
</AppCard>
<AppCard space="$3">
<Text fontSize="$md" fontWeight="800">
{t('events.branding.logo', 'Logo')}
</Text>
<YStack
borderRadius="$card"
borderWidth={1}
borderColor="$muted"
backgroundColor="#f8fafc"
padding="$3"
alignItems="center"
justifyContent="center"
space="$2"
>
<ImageIcon size={28} color="#94a3b8" />
<Text fontSize="$sm" color="$color">
{t('events.branding.logoHint', 'Logo Upload folgt nutze aktuelle Farben.')}
</Text>
</YStack>
</AppCard>
<YStack space="$2">
<PrimaryCTA label={t('events.branding.save', 'Save Branding')} onPress={() => handleSave()} />
<Button
height={52}
borderRadius="$card"
backgroundColor="white"
borderWidth={1}
borderColor="$muted"
color="$color"
onPress={handleReset}
disabled={loading || saving}
>
<XStack alignItems="center" justifyContent="center" space="$2">
<RefreshCcw size={16} />
<Text>{t('events.branding.reset', 'Reset to Defaults')}</Text>
</XStack>
</Button>
</YStack>
</YStack>
<BottomNav
active="settings"
onNavigate={(key) => {
if (key === 'events') navigate(adminPath('/events'));
if (key === 'analytics') navigate(adminPath('/dashboard'));
if (key === 'settings') navigate(adminPath('/settings'));
}}
/>
</AdminLayout>
);
}
function extractBranding(event: TenantEvent): BrandingForm {
const source = (event.settings as Record<string, unknown>) ?? {};
const branding = (source.branding as Record<string, unknown>) ?? source;
const readColor = (key: string, fallback: string) => {
const value = branding[key];
return typeof value === 'string' && value.startsWith('#') ? value : fallback;
};
const readText = (key: string) => {
const value = branding[key];
return typeof value === 'string' ? value : '';
};
return {
primary: readColor('primary_color', '#007AFF'),
accent: readColor('accent_color', '#5AD2F4'),
headingFont: readText('heading_font'),
bodyFont: readText('body_font'),
};
}
function resolveName(name: TenantEvent['name']): string {
if (typeof name === 'string') return name;
if (name && typeof name === 'object') {
return name.de ?? name.en ?? Object.values(name)[0] ?? '';
}
return '';
}
function ColorField({ label, value, onChange }: { label: string; value: string; onChange: (next: string) => void }) {
return (
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700">
{label}
</Text>
<XStack alignItems="center" space="$2">
<input
type="color"
value={value}
onChange={(event) => onChange(event.target.value)}
style={{ width: 52, height: 52, borderRadius: 12, border: '1px solid #e5e7eb', background: 'white' }}
/>
<Text fontSize="$sm" color="$color">
{value}
</Text>
</XStack>
</YStack>
);
}
function ColorSwatch({ color, label }: { color: string; label: string }) {
return (
<YStack alignItems="center" space="$1">
<YStack width={44} height={44} borderRadius="$pill" borderWidth={1} borderColor="$muted" backgroundColor={color} />
<Text fontSize="$xs" color="$color">
{label}
</Text>
</YStack>
);
}
function InputField({
label,
value,
placeholder,
onChange,
}: {
label: string;
value: string;
placeholder?: string;
onChange: (next: string) => void;
}) {
return (
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700">
{label}
</Text>
<input
type="text"
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
style={{
width: '100%',
height: 48,
borderRadius: 14,
border: '1px solid #e5e7eb',
padding: '0 12px',
fontSize: 14,
}}
/>
</YStack>
);
}