rearranged tenant admin layout, invite layouts now visible and manageable
This commit is contained in:
@@ -0,0 +1,727 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Download, Loader2, Plus, Printer, RotateCcw, Save, UploadCloud } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
import type { EventQrInvite, EventQrInviteLayout } from '../../api';
|
||||
import { authorizedFetch } from '../../auth/tokens';
|
||||
|
||||
export type QrLayoutCustomization = {
|
||||
layout_id?: string;
|
||||
headline?: string;
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
badge_label?: string;
|
||||
instructions_heading?: string;
|
||||
instructions?: string[];
|
||||
link_heading?: string;
|
||||
link_label?: string;
|
||||
cta_label?: string;
|
||||
accent_color?: string;
|
||||
text_color?: string;
|
||||
background_color?: string;
|
||||
secondary_color?: string;
|
||||
badge_color?: string;
|
||||
background_gradient?: { angle?: number; stops?: string[] } | null;
|
||||
logo_data_url?: string | null;
|
||||
logo_url?: string | null;
|
||||
};
|
||||
|
||||
type InviteLayoutCustomizerPanelProps = {
|
||||
invite: EventQrInvite | null;
|
||||
eventName: string;
|
||||
saving: boolean;
|
||||
resetting: boolean;
|
||||
onSave: (customization: QrLayoutCustomization) => Promise<void>;
|
||||
onReset: () => Promise<void>;
|
||||
initialCustomization: QrLayoutCustomization | null;
|
||||
};
|
||||
|
||||
const MAX_INSTRUCTIONS = 5;
|
||||
|
||||
export function InviteLayoutCustomizerPanel({
|
||||
invite,
|
||||
eventName,
|
||||
saving,
|
||||
resetting,
|
||||
onSave,
|
||||
onReset,
|
||||
initialCustomization,
|
||||
}: InviteLayoutCustomizerPanelProps): JSX.Element {
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
const inviteUrl = invite?.url ?? '';
|
||||
const defaultInstructions = React.useMemo(() => {
|
||||
const value = t('tasks.customizer.defaults.instructions', { returnObjects: true }) as unknown;
|
||||
return Array.isArray(value) ? (value as string[]) : ['QR-Code scannen', 'Profil anlegen', 'Fotos teilen'];
|
||||
}, [t]);
|
||||
|
||||
const [availableLayouts, setAvailableLayouts] = React.useState<EventQrInviteLayout[]>(invite?.layouts ?? []);
|
||||
const [layoutsLoading, setLayoutsLoading] = React.useState(false);
|
||||
const [layoutsError, setLayoutsError] = React.useState<string | null>(null);
|
||||
const [selectedLayoutId, setSelectedLayoutId] = React.useState<string | undefined>(initialCustomization?.layout_id ?? invite?.layouts?.[0]?.id);
|
||||
const [form, setForm] = React.useState<QrLayoutCustomization>({});
|
||||
const [instructions, setInstructions] = React.useState<string[]>([]);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const formRef = React.useRef<HTMLFormElement | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!invite) {
|
||||
setAvailableLayouts([]);
|
||||
setSelectedLayoutId(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const layouts = invite.layouts ?? [];
|
||||
setAvailableLayouts(layouts);
|
||||
setLayoutsError(null);
|
||||
setSelectedLayoutId((current) => {
|
||||
if (current && layouts.some((layout) => layout.id === current)) {
|
||||
return current;
|
||||
}
|
||||
if (initialCustomization?.layout_id && layouts.some((layout) => layout.id === initialCustomization.layout_id)) {
|
||||
return initialCustomization.layout_id;
|
||||
}
|
||||
return layouts[0]?.id;
|
||||
});
|
||||
}, [invite?.id, initialCustomization?.layout_id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadLayouts(url: string) {
|
||||
try {
|
||||
setLayoutsLoading(true);
|
||||
setLayoutsError(null);
|
||||
const target = (() => {
|
||||
try {
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
const parsed = new URL(url);
|
||||
return parsed.pathname + parsed.search;
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.warn('[Invites] Failed to parse layout URL', parseError);
|
||||
}
|
||||
return url;
|
||||
})();
|
||||
console.debug('[Invites] Fetching layouts', target);
|
||||
const response = await authorizedFetch(target, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[Invites] Layout request failed', response.status, response.statusText);
|
||||
throw new Error(`Failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
const items = Array.isArray(json?.data) ? (json.data as EventQrInviteLayout[]) : [];
|
||||
console.debug('[Invites] Layout response items', items);
|
||||
|
||||
if (!cancelled) {
|
||||
setAvailableLayouts(items);
|
||||
setSelectedLayoutId((current) => {
|
||||
if (current && items.some((layout) => layout.id === current)) {
|
||||
return current;
|
||||
}
|
||||
if (initialCustomization?.layout_id && items.some((layout) => layout.id === initialCustomization.layout_id)) {
|
||||
return initialCustomization.layout_id;
|
||||
}
|
||||
return items[0]?.id;
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
console.error('[Invites] Failed to load layouts', err);
|
||||
setLayoutsError(t('invites.customizer.loadingError', 'Layouts konnten nicht geladen werden.'));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLayoutsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!invite || availableLayouts.length > 0 || !invite.layouts_url) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
void loadLayouts(invite.layouts_url);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [invite, availableLayouts.length, initialCustomization?.layout_id, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!availableLayouts.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedLayoutId((current) => {
|
||||
if (current && availableLayouts.some((layout) => layout.id === current)) {
|
||||
return current;
|
||||
}
|
||||
if (initialCustomization?.layout_id && availableLayouts.some((layout) => layout.id === initialCustomization.layout_id)) {
|
||||
return initialCustomization.layout_id;
|
||||
}
|
||||
return availableLayouts[0].id;
|
||||
});
|
||||
}, [availableLayouts, initialCustomization?.layout_id]);
|
||||
|
||||
const activeLayout = React.useMemo(() => {
|
||||
if (!availableLayouts.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (selectedLayoutId) {
|
||||
const match = availableLayouts.find((layout) => layout.id === selectedLayoutId);
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
|
||||
return availableLayouts[0];
|
||||
}, [availableLayouts, selectedLayoutId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!invite || !activeLayout) {
|
||||
setForm({});
|
||||
setInstructions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const baseInstructions = Array.isArray(initialCustomization?.instructions) && initialCustomization.instructions?.length
|
||||
? [...(initialCustomization.instructions as string[])]
|
||||
: [...defaultInstructions];
|
||||
|
||||
setInstructions(baseInstructions);
|
||||
|
||||
setForm({
|
||||
layout_id: activeLayout.id,
|
||||
headline: initialCustomization?.headline ?? eventName,
|
||||
subtitle: initialCustomization?.subtitle ?? activeLayout.subtitle ?? '',
|
||||
description: initialCustomization?.description ?? activeLayout.description ?? '',
|
||||
badge_label: initialCustomization?.badge_label ?? t('tasks.customizer.defaults.badgeLabel'),
|
||||
instructions_heading: initialCustomization?.instructions_heading ?? t('tasks.customizer.defaults.instructionsHeading'),
|
||||
link_heading: initialCustomization?.link_heading ?? t('tasks.customizer.defaults.linkHeading'),
|
||||
link_label: initialCustomization?.link_label ?? inviteUrl,
|
||||
cta_label: initialCustomization?.cta_label ?? t('tasks.customizer.defaults.ctaLabel'),
|
||||
accent_color: initialCustomization?.accent_color ?? activeLayout.preview?.accent ?? '#6366F1',
|
||||
text_color: initialCustomization?.text_color ?? activeLayout.preview?.text ?? '#111827',
|
||||
background_color: initialCustomization?.background_color ?? activeLayout.preview?.background ?? '#FFFFFF',
|
||||
secondary_color: initialCustomization?.secondary_color ?? 'rgba(15,23,42,0.08)',
|
||||
badge_color: initialCustomization?.badge_color ?? activeLayout.preview?.accent ?? '#2563EB',
|
||||
background_gradient: initialCustomization?.background_gradient ?? activeLayout.preview?.background_gradient ?? null,
|
||||
logo_data_url: initialCustomization?.logo_data_url ?? initialCustomization?.logo_url ?? null,
|
||||
});
|
||||
setError(null);
|
||||
}, [invite?.id, activeLayout?.id, defaultInstructions, initialCustomization, eventName, inviteUrl, t]);
|
||||
|
||||
const effectiveInstructions = instructions.filter((entry) => entry.trim().length > 0);
|
||||
|
||||
function updateForm<T extends keyof QrLayoutCustomization>(key: T, value: QrLayoutCustomization[T]) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
function handleLayoutSelect(layout: EventQrInviteLayout) {
|
||||
setSelectedLayoutId(layout.id);
|
||||
updateForm('layout_id', layout.id);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
accent_color: prev.accent_color ?? layout.preview?.accent ?? '#6366F1',
|
||||
text_color: prev.text_color ?? layout.preview?.text ?? '#111827',
|
||||
background_color: prev.background_color ?? layout.preview?.background ?? '#FFFFFF',
|
||||
background_gradient: prev.background_gradient ?? layout.preview?.background_gradient ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
function handleInstructionChange(index: number, value: string) {
|
||||
setInstructions((prev) => {
|
||||
const next = [...prev];
|
||||
next[index] = value;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleAddInstruction() {
|
||||
setInstructions((prev) => (prev.length < MAX_INSTRUCTIONS ? [...prev, ''] : prev));
|
||||
}
|
||||
|
||||
function handleRemoveInstruction(index: number) {
|
||||
setInstructions((prev) => prev.filter((_, idx) => idx !== index));
|
||||
}
|
||||
|
||||
function handleLogoUpload(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 1024 * 1024) {
|
||||
setError(t('tasks.customizer.errors.logoTooLarge', 'Das Logo darf maximal 1 MB groß sein.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
updateForm('logo_data_url', typeof reader.result === 'string' ? reader.result : null);
|
||||
setError(null);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function handleLogoRemove() {
|
||||
updateForm('logo_data_url', null);
|
||||
}
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!invite || !activeLayout) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: QrLayoutCustomization = {
|
||||
...form,
|
||||
layout_id: activeLayout.id,
|
||||
instructions: effectiveInstructions,
|
||||
};
|
||||
|
||||
await onSave(payload);
|
||||
}
|
||||
|
||||
async function handleResetClick() {
|
||||
await onReset();
|
||||
}
|
||||
|
||||
function handleDownload(format: string, url: string) {
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${invite?.token ?? 'invite'}-${format.toLowerCase()}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
function handlePrint(preferredUrl?: string | null) {
|
||||
const url = preferredUrl ?? activeLayout?.download_urls?.pdf ?? activeLayout?.download_urls?.a4 ?? null;
|
||||
if (!url) {
|
||||
setError(t('invites.labels.noPrintSource', 'Keine druckbare Version verfügbar.'));
|
||||
return;
|
||||
}
|
||||
const printWindow = window.open(url, '_blank', 'noopener,noreferrer');
|
||||
printWindow?.focus();
|
||||
}
|
||||
|
||||
const previewStyles = React.useMemo(() => {
|
||||
const gradient = form.background_gradient ?? activeLayout?.preview?.background_gradient ?? null;
|
||||
if (gradient?.stops && gradient.stops.length > 0) {
|
||||
const angle = gradient.angle ?? 180;
|
||||
const stops = gradient.stops.join(', ');
|
||||
return { backgroundImage: `linear-gradient(${angle}deg, ${stops})` };
|
||||
}
|
||||
return { backgroundColor: form.background_color ?? activeLayout?.preview?.background ?? '#F8FAFC' };
|
||||
}, [form.background_color, form.background_gradient, activeLayout]);
|
||||
|
||||
if (!invite) {
|
||||
return (
|
||||
<CardPlaceholder
|
||||
title={t('invites.customizer.placeholderTitle', 'Kein Layout verfügbar')}
|
||||
description={t('invites.customizer.placeholderCopy', 'Erstelle eine Einladung, damit du Texte, Farben und Drucklayouts bearbeiten kannst.')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!availableLayouts.length) {
|
||||
if (layoutsLoading) {
|
||||
return (
|
||||
<CardPlaceholder
|
||||
title={t('invites.customizer.loadingTitle', 'Layouts werden geladen')}
|
||||
description={t('invites.customizer.loadingDescription', 'Bitte warte einen Moment, wir bereiten die Drucklayouts vor.')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CardPlaceholder
|
||||
title={layoutsError ?? t('invites.customizer.placeholderTitle', 'Kein Layout verfügbar')}
|
||||
description={layoutsError ?? t('invites.customizer.placeholderCopy', 'Erstelle eine Einladung, damit du Texte, Farben und Drucklayouts bearbeiten kannst.')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeLayout) {
|
||||
return (
|
||||
<CardPlaceholder
|
||||
title={t('invites.customizer.placeholderTitle', 'Kein Layout verfügbar')}
|
||||
description={t('invites.customizer.placeholderCopy', 'Erstelle eine Einladung, damit du Texte, Farben und Drucklayouts bearbeiten kannst.')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">{t('invites.customizer.heading', 'Layout anpassen')}</h2>
|
||||
<p className="text-sm text-slate-600">{t('invites.customizer.copy', 'Verleihe der Einladung euren Ton: Texte, Farben und Logo lassen sich live bearbeiten.')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={handleResetClick} disabled={resetting || saving}>
|
||||
{resetting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RotateCcw className="mr-2 h-4 w-4" />}
|
||||
{t('invites.customizer.actions.reset', 'Zurücksetzen')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (formRef.current) {
|
||||
if (typeof formRef.current.requestSubmit === 'function') {
|
||||
formRef.current.requestSubmit();
|
||||
} else {
|
||||
formRef.current.submit();
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 text-white shadow-lg shadow-rose-500/20"
|
||||
disabled={saving || resetting}
|
||||
>
|
||||
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
|
||||
{t('invites.customizer.actions.save', 'Layout speichern')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{error}</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,1fr)]">
|
||||
<form ref={formRef} onSubmit={handleSubmit} className="space-y-6">
|
||||
<section className="space-y-4 rounded-2xl border border-slate-100 bg-white/90 p-5 shadow-sm">
|
||||
<header>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-500">{t('invites.customizer.sections.layouts', 'Layouts')}</h3>
|
||||
<p className="text-xs text-slate-500">{t('invites.customizer.sections.layoutsHint', 'Wähle eine Vorlage als Basis aus. Du kannst jederzeit wechseln.')}</p>
|
||||
</header>
|
||||
<div className="flex snap-x gap-3 overflow-x-auto pb-2">
|
||||
{availableLayouts.map((layout) => (
|
||||
<button
|
||||
key={layout.id}
|
||||
type="button"
|
||||
onClick={() => handleLayoutSelect(layout)}
|
||||
className={`min-w-[200px] shrink-0 rounded-xl border p-3 text-left transition-all ${layout.id === selectedLayoutId ? 'border-amber-400 bg-amber-50 shadow' : 'border-slate-200 bg-white hover:border-amber-200'}`}
|
||||
>
|
||||
<div className="text-sm font-semibold text-slate-900">{layout.name || t('invites.customizer.layoutFallback', 'Layout')}</div>
|
||||
{layout.description ? <div className="mt-1 text-xs text-slate-500">{layout.description}</div> : null}
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs text-slate-500">
|
||||
{layout.formats?.map((format) => (
|
||||
<span key={`${layout.id}-${format}`} className="rounded-full border border-amber-200 px-2 py-0.5 text-amber-600">{String(format).toUpperCase()}</span>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 rounded-2xl border border-slate-100 bg-white/90 p-5 shadow-sm">
|
||||
<header>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-500">{t('invites.customizer.sections.text', 'Texte')}</h3>
|
||||
</header>
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-headline">{t('invites.customizer.fields.headline', 'Überschrift')}</Label>
|
||||
<Input
|
||||
id="invite-headline"
|
||||
value={form.headline ?? ''}
|
||||
onChange={(event) => updateForm('headline', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-subtitle">{t('invites.customizer.fields.subtitle', 'Unterzeile')}</Label>
|
||||
<Input
|
||||
id="invite-subtitle"
|
||||
value={form.subtitle ?? ''}
|
||||
onChange={(event) => updateForm('subtitle', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-description">{t('invites.customizer.fields.description', 'Beschreibung')}</Label>
|
||||
<Textarea
|
||||
id="invite-description"
|
||||
value={form.description ?? ''}
|
||||
onChange={(event) => updateForm('description', event.target.value)}
|
||||
className="min-h-[96px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-badge">{t('invites.customizer.fields.badge', 'Badge-Label')}</Label>
|
||||
<Input
|
||||
id="invite-badge"
|
||||
value={form.badge_label ?? ''}
|
||||
onChange={(event) => updateForm('badge_label', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-cta">{t('invites.customizer.fields.cta', 'Call-to-Action')}</Label>
|
||||
<Input
|
||||
id="invite-cta"
|
||||
value={form.cta_label ?? ''}
|
||||
onChange={(event) => updateForm('cta_label', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-link-heading">{t('invites.customizer.fields.linkHeading', 'Link-Überschrift')}</Label>
|
||||
<Input
|
||||
id="invite-link-heading"
|
||||
value={form.link_heading ?? ''}
|
||||
onChange={(event) => updateForm('link_heading', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-link-label">{t('invites.customizer.fields.linkLabel', 'Link/Begleittext')}</Label>
|
||||
<Input
|
||||
id="invite-link-label"
|
||||
value={form.link_label ?? ''}
|
||||
onChange={(event) => updateForm('link_label', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 rounded-2xl border border-slate-100 bg-white/90 p-5 shadow-sm">
|
||||
<header>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-500">{t('invites.customizer.sections.instructions', 'Schritt-für-Schritt')}</h3>
|
||||
<p className="text-xs text-slate-500">{t('invites.customizer.sections.instructionsHint', 'Helft euren Gästen mit klaren Aufgaben. Maximal fünf Punkte.')}</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAddInstruction} disabled={instructions.length >= MAX_INSTRUCTIONS}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
{t('invites.customizer.actions.addInstruction', 'Punkt hinzufügen')}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-instruction-heading">{t('invites.customizer.fields.instructionsHeading', 'Abschnittsüberschrift')}</Label>
|
||||
<Input
|
||||
id="invite-instruction-heading"
|
||||
value={form.instructions_heading ?? ''}
|
||||
onChange={(event) => updateForm('instructions_heading', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{instructions.map((entry, index) => (
|
||||
<div key={`instruction-${index}`} className="flex gap-2">
|
||||
<Input
|
||||
value={entry}
|
||||
onChange={(event) => handleInstructionChange(index, event.target.value)}
|
||||
placeholder={t('invites.customizer.fields.instructionPlaceholder', 'Beschreibung des Schritts')}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="text-slate-500 hover:text-rose-500"
|
||||
onClick={() => handleRemoveInstruction(index)}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 rounded-2xl border border-slate-100 bg-white/90 p-5 shadow-sm">
|
||||
<header>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-500">{t('invites.customizer.sections.branding', 'Branding')}</h3>
|
||||
</header>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-accent">{t('invites.customizer.fields.accentColor', 'Akzentfarbe')}</Label>
|
||||
<Input
|
||||
id="invite-accent"
|
||||
type="color"
|
||||
value={form.accent_color ?? '#6366F1'}
|
||||
onChange={(event) => updateForm('accent_color', event.target.value)}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-text-color">{t('invites.customizer.fields.textColor', 'Textfarbe')}</Label>
|
||||
<Input
|
||||
id="invite-text-color"
|
||||
type="color"
|
||||
value={form.text_color ?? '#111827'}
|
||||
onChange={(event) => updateForm('text_color', event.target.value)}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-background-color">{t('invites.customizer.fields.backgroundColor', 'Hintergrund')}</Label>
|
||||
<Input
|
||||
id="invite-background-color"
|
||||
type="color"
|
||||
value={form.background_color ?? '#FFFFFF'}
|
||||
onChange={(event) => updateForm('background_color', event.target.value)}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-badge-color">{t('invites.customizer.fields.badgeColor', 'Badge')}</Label>
|
||||
<Input
|
||||
id="invite-badge-color"
|
||||
type="color"
|
||||
value={form.badge_color ?? '#2563EB'}
|
||||
onChange={(event) => updateForm('badge_color', event.target.value)}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('invites.customizer.fields.logo', 'Logo')}</Label>
|
||||
{form.logo_data_url ? (
|
||||
<div className="flex items-center gap-4 rounded-lg border border-slate-200 bg-slate-50 p-3">
|
||||
<img src={form.logo_data_url} alt="Logo" className="h-12 w-12 rounded border border-slate-200 object-contain" />
|
||||
<Button type="button" variant="ghost" onClick={handleLogoRemove} className="text-rose-500 hover:text-rose-600">
|
||||
{t('invites.customizer.actions.removeLogo', 'Logo entfernen')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<label className="flex cursor-pointer items-center gap-3 rounded-lg border border-dashed border-slate-300 bg-slate-50/80 px-4 py-3 text-sm text-slate-500 hover:border-amber-200">
|
||||
<UploadCloud className="h-4 w-4" />
|
||||
<span>{t('invites.customizer.actions.uploadLogo', 'Logo hochladen (max. 1 MB)')}</span>
|
||||
<input type="file" accept="image/*" className="hidden" onChange={handleLogoUpload} />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={handleResetClick} disabled={resetting || saving}>
|
||||
{resetting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RotateCcw className="mr-2 h-4 w-4" />}
|
||||
{t('invites.customizer.actions.reset', 'Zurücksetzen')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving || resetting} className="bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 text-white">
|
||||
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
|
||||
{t('invites.customizer.actions.save', 'Layout speichern')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<aside className="space-y-4 rounded-2xl border border-slate-100 bg-white/90 p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-500">{t('invites.customizer.preview.title', 'Live-Vorschau')}</h3>
|
||||
<p className="text-xs text-slate-500">{t('invites.customizer.preview.subtitle', 'So sieht dein Layout beim Export aus.')}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => handlePrint(activeLayout.download_urls?.pdf ?? activeLayout.download_urls?.a4)}>
|
||||
<Printer className="mr-1 h-4 w-4" />
|
||||
{t('invites.customizer.actions.print', 'Drucken')}
|
||||
</Button>
|
||||
{activeLayout.formats?.map((format) => {
|
||||
const key = String(format ?? '').toLowerCase();
|
||||
const url = activeLayout.download_urls?.[key];
|
||||
if (!url) return null;
|
||||
return (
|
||||
<Button key={`${activeLayout.id}-${key}`} type="button" variant="outline" size="sm" onClick={() => handleDownload(key, url)}>
|
||||
<Download className="mr-1 h-4 w-4" />
|
||||
{key.toUpperCase()}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-xl border border-slate-200 bg-white p-5 shadow-inner">
|
||||
<div className="rounded-xl p-5 text-slate-900" style={previewStyles}>
|
||||
<div className="flex items-start justify-between">
|
||||
<span
|
||||
className="rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-wide"
|
||||
style={{ backgroundColor: form.badge_color ?? form.accent_color ?? '#2563EB', color: '#ffffff' }}
|
||||
>
|
||||
{form.badge_label || t('tasks.customizer.defaults.badgeLabel')}
|
||||
</span>
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-white/80 shadow" style={{ color: form.accent_color ?? '#2563EB' }}>
|
||||
<SmileIcon />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 space-y-2">
|
||||
<h4 className="text-lg font-semibold leading-tight" style={{ color: form.text_color ?? '#111827' }}>
|
||||
{form.headline || eventName}
|
||||
</h4>
|
||||
{form.subtitle ? (
|
||||
<p className="text-sm" style={{ color: form.text_color ?? '#111827', opacity: 0.75 }}>
|
||||
{form.subtitle}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{form.description ? (
|
||||
<p className="mt-4 text-sm" style={{ color: form.text_color ?? '#111827' }}>
|
||||
{form.description}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-5 grid gap-3 rounded-xl bg-white/80 p-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-slate-500">{form.instructions_heading}</div>
|
||||
<ol className="grid gap-2 text-sm text-slate-700">
|
||||
{effectiveInstructions.slice(0, 4).map((item, index) => (
|
||||
<li key={`preview-instruction-${index}`} className="flex gap-2">
|
||||
<span className="font-semibold" style={{ color: form.accent_color ?? '#2563EB' }}>{index + 1}.</span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
<div className="mt-5 flex flex-col gap-2 rounded-xl bg-white/80 p-4 text-sm text-slate-700">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-slate-500">{form.link_heading}</span>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate font-medium" style={{ color: form.accent_color ?? '#2563EB' }}>
|
||||
{form.link_label || inviteUrl}
|
||||
</span>
|
||||
<span className="rounded-full bg-slate-900/90 px-3 py-1 text-xs text-white">QR</span>
|
||||
</div>
|
||||
<Button size="sm" className="w-full bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 text-white">
|
||||
{form.cta_label || t('tasks.customizer.defaults.ctaLabel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{form.logo_data_url ? (
|
||||
<div className="flex items-center justify-center rounded-lg border border-dashed border-slate-200 bg-slate-50 py-4">
|
||||
<img src={form.logo_data_url} alt="Logo preview" className="max-h-16 object-contain" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardPlaceholder({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50/80 p-10 text-center text-sm text-slate-500">
|
||||
<h3 className="text-base font-semibold text-slate-700">{title}</h3>
|
||||
<p className="mt-2 text-sm text-slate-500">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SmileIcon(): JSX.Element {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="h-5 w-5">
|
||||
<circle cx="12" cy="12" r="10" opacity="0.4" />
|
||||
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
|
||||
<path d="M9 9h.01M15 9h.01" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user