Files
fotospiel-app/resources/js/admin/pages/components/InviteLayoutCustomizerPanel.tsx

1318 lines
53 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 { useTranslation } from 'react-i18next';
import { Download, Loader2, Plus, Printer, RotateCcw, Save, UploadCloud } from 'lucide-react';
import { Rnd } from 'react-rnd';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
import { Textarea } from '@/components/ui/textarea';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { cn } from '@/lib/utils';
import type { EventQrInvite, EventQrInviteLayout } from '../../api';
import { authorizedFetch, isAuthError } 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;
mode?: 'standard' | 'advanced';
elements?: AdvancedLayoutElementPayload[];
};
type AdvancedLayoutElement = {
id: string;
type: 'qr' | 'headline' | 'subtitle' | 'description' | 'link' | 'badge' | 'logo';
x: number;
y: number;
width: number;
height: number;
fontSize?: number;
align?: 'left' | 'center' | 'right';
content?: string | null;
};
type AdvancedLayoutElementPayload = {
id: string;
type: AdvancedLayoutElement['type'];
x: number;
y: number;
width: number;
height: number;
font_size?: number;
align?: 'left' | 'center' | 'right';
content?: string | null;
};
type AdvancedSerializationContext = {
form: QrLayoutCustomization;
eventName: string;
inviteUrl: string;
instructions: string[];
qrSize: number;
badgeFallback: string;
logoUrl: string | null;
};
const ADVANCED_CANVAS_WIDTH = 1080;
const ADVANCED_CANVAS_HEIGHT = 1520;
const ADVANCED_MIN_QR = 240;
const ADVANCED_MAX_QR = 720;
const ADVANCED_MIN_TEXT_WIDTH = 160;
const ADVANCED_MIN_TEXT_HEIGHT = 80;
function clamp(value: number, min: number, max: number): number {
if (Number.isNaN(value)) {
return min;
}
return Math.min(Math.max(value, min), max);
}
function buildDefaultAdvancedElements(
layout: EventQrInviteLayout | null,
form: QrLayoutCustomization,
eventName: string,
qrSize: number
): AdvancedLayoutElement[] {
const resolvedQrSize = Math.min(Math.max(qrSize, ADVANCED_MIN_QR), ADVANCED_MAX_QR);
return [
{
id: 'headline',
type: 'headline',
x: 80,
y: 120,
width: 520,
height: 160,
fontSize: 60,
content: form.headline ?? eventName,
},
{
id: 'description',
type: 'description',
x: 80,
y: 320,
width: 520,
height: 240,
fontSize: 28,
content: form.description ?? layout?.description ?? '',
},
{
id: 'qr',
type: 'qr',
x: 640,
y: 320,
width: resolvedQrSize,
height: resolvedQrSize,
},
{
id: 'link',
type: 'link',
x: 640,
y: 320 + resolvedQrSize + 40,
width: 360,
height: 140,
fontSize: 26,
content: form.link_label ?? '',
},
{
id: 'badge',
type: 'badge',
x: 80,
y: 60,
width: 260,
height: 70,
fontSize: 28,
content: form.badge_label ?? 'Digitale Gästebox',
},
];
}
function normalizeAdvancedElements(elements: AdvancedLayoutElement[]): AdvancedLayoutElement[] {
return (elements ?? []).map((element) => ({
...element,
x: Number(element.x ?? 0),
y: Number(element.y ?? 0),
width: Number(element.width ?? 0),
height: Number(element.height ?? 0),
fontSize: element.fontSize ? Number(element.fontSize) : undefined,
}));
}
function convertPayloadToElements(payload: AdvancedLayoutElementPayload[] | undefined | null): AdvancedLayoutElement[] {
if (!Array.isArray(payload)) {
return [];
}
return payload.map((item) => ({
id: item.id,
type: item.type,
x: Number(item.x ?? 0),
y: Number(item.y ?? 0),
width: Number(item.width ?? 0),
height: Number(item.height ?? 0),
fontSize: item.font_size ? Number(item.font_size) : undefined,
align: item.align ?? 'left',
content: item.content ?? null,
}));
}
function clampElementToCanvas(element: AdvancedLayoutElement): AdvancedLayoutElement {
const minWidth = element.type === 'qr' ? ADVANCED_MIN_QR : ADVANCED_MIN_TEXT_WIDTH;
const minHeight = element.type === 'qr' ? ADVANCED_MIN_QR : ADVANCED_MIN_TEXT_HEIGHT;
const width = clamp(element.width, minWidth, ADVANCED_CANVAS_WIDTH);
const height = clamp(element.height, minHeight, ADVANCED_CANVAS_HEIGHT);
const maxX = Math.max(ADVANCED_CANVAS_WIDTH - width, 0);
const maxY = Math.max(ADVANCED_CANVAS_HEIGHT - height, 0);
return {
...element,
width,
height,
x: clamp(element.x, 0, maxX),
y: clamp(element.y, 0, maxY),
};
}
function serializeAdvancedElements(
elements: AdvancedLayoutElement[],
context: AdvancedSerializationContext
): AdvancedLayoutElementPayload[] {
return normalizeAdvancedElements(elements).map((element) => {
const base = clampElementToCanvas(element);
let content: string | null = base.content ?? null;
switch (base.type) {
case 'headline':
content = context.form.headline ?? context.eventName;
break;
case 'subtitle':
content = context.form.subtitle ?? '';
break;
case 'description':
content = context.form.description ?? '';
break;
case 'link':
content = context.form.link_label ?? context.inviteUrl;
break;
case 'badge':
content = context.form.badge_label ?? context.badgeFallback;
break;
case 'logo':
content = context.logoUrl ?? context.form.logo_url ?? null;
break;
default:
break;
}
return {
id: base.id,
type: base.type,
x: Math.round(base.x),
y: Math.round(base.y),
width: Math.round(base.width),
height: Math.round(base.height),
font_size: base.fontSize ? Math.round(base.fontSize) : undefined,
align: base.align,
content,
};
});
}
type InviteLayoutCustomizerPanelProps = {
invite: EventQrInvite | null;
eventName: string;
saving: boolean;
resetting: boolean;
onSave: (customization: QrLayoutCustomization) => Promise<void>;
onReset: () => Promise<void>;
initialCustomization: QrLayoutCustomization | null;
mode: 'standard' | 'advanced';
};
const MAX_INSTRUCTIONS = 5;
export function InviteLayoutCustomizerPanel({
invite,
eventName,
saving,
resetting,
onSave,
onReset,
initialCustomization,
mode,
}: InviteLayoutCustomizerPanelProps): React.JSX.Element {
const { t } = useTranslation('management');
const inviteUrl = invite?.url ?? '';
const qrCodeDataUrl = invite?.qr_code_data_url ?? null;
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);
const [downloadBusy, setDownloadBusy] = React.useState<string | null>(null);
const [printBusy, setPrintBusy] = React.useState(false);
const [activeLayoutIndex, setActiveLayoutIndex] = React.useState(() => {
if (!availableLayouts.length) {
return 0;
}
const initialIndex = availableLayouts.findIndex((layout) => layout.id === selectedLayoutId);
return initialIndex >= 0 ? initialIndex : 0;
});
const isAdvanced = mode === 'advanced';
const [elements, setElements] = React.useState<AdvancedLayoutElement[]>([]);
const [activeElementId, setActiveElementId] = React.useState<string | null>(null);
const [canvasScale, setCanvasScale] = React.useState(0.52);
const [mobilePreviewOpen, setMobilePreviewOpen] = React.useState(false);
const [showFloatingActions, setShowFloatingActions] = React.useState(false);
const actionsSentinelRef = React.useRef<HTMLDivElement | null>(null);
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[activeLayoutIndex] ?? availableLayouts[0];
}, [availableLayouts, selectedLayoutId, activeLayoutIndex]);
const activeLayoutQrSize = React.useMemo(() => {
if (initialCustomization?.mode === 'advanced' && Array.isArray(initialCustomization.elements)) {
const qrElement = initialCustomization.elements.find((element) => element?.type === 'qr');
if (qrElement && typeof qrElement.width === 'number' && qrElement.width > 0) {
return qrElement.width;
}
}
return activeLayout?.preview?.qr_size_px ?? 500;
}, [initialCustomization?.mode, initialCustomization?.elements, activeLayout?.preview?.qr_size_px]);
const updateElement = React.useCallback(
(
id: string,
updater: Partial<AdvancedLayoutElement> | ((element: AdvancedLayoutElement) => Partial<AdvancedLayoutElement>)
) => {
setElements((current) =>
current.map((element) => {
if (element.id !== id) {
return element;
}
const patch = typeof updater === 'function' ? updater(element) : updater;
return clampElementToCanvas({ ...element, ...patch });
})
);
},
[]
);
const handleResetAdvanced = React.useCallback(() => {
if (!activeLayout) {
setElements([]);
return;
}
setElements(buildDefaultAdvancedElements(activeLayout, form, eventName, activeLayoutQrSize));
setActiveElementId(null);
}, [activeLayout, form, eventName, activeLayoutQrSize]);
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;
})();
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[]) : [];
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]);
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]);
React.useEffect(() => {
if (!isAdvanced) {
setActiveElementId(null);
return;
}
if (!activeLayout) {
setElements([]);
return;
}
if (initialCustomization?.mode === 'advanced' && Array.isArray(initialCustomization.elements) && initialCustomization.elements.length) {
setElements(normalizeAdvancedElements(convertPayloadToElements(initialCustomization.elements)));
return;
}
setElements(buildDefaultAdvancedElements(activeLayout, form, eventName, activeLayoutQrSize));
}, [isAdvanced, activeLayout?.id, invite?.id, activeLayoutQrSize]);
React.useEffect(() => {
if (typeof IntersectionObserver === 'undefined') {
setShowFloatingActions(false);
return;
}
const node = actionsSentinelRef.current;
if (!node) {
setShowFloatingActions(false);
return;
}
const observer = new IntersectionObserver(([entry]) => {
setShowFloatingActions(!entry.isIntersecting);
});
observer.observe(node);
return () => {
observer.disconnect();
};
}, [invite?.id, activeLayout?.id]);
const effectiveInstructions = instructions.filter((entry) => entry.trim().length > 0);
const advancedPreview = React.useMemo(() => ({
headline: form.headline ?? eventName,
subtitle: form.subtitle ?? '',
description: form.description ?? activeLayout?.description ?? '',
link: form.link_label ?? inviteUrl,
badge: form.badge_label ?? t('tasks.customizer.defaults.badgeLabel'),
background: form.background_color ?? activeLayout?.preview?.background ?? '#FFFFFF',
text: form.text_color ?? activeLayout?.preview?.text ?? '#111827',
accent: form.accent_color ?? activeLayout?.preview?.accent ?? '#6366F1',
logo: form.logo_data_url ?? form.logo_url ?? null,
instructions: effectiveInstructions,
}), [form.headline, form.subtitle, form.description, form.link_label, form.badge_label, form.background_color, form.text_color, form.accent_color, form.logo_data_url, form.logo_url, eventName, activeLayout?.description, activeLayout?.preview?.background, activeLayout?.preview?.text, activeLayout?.preview?.accent, effectiveInstructions, inviteUrl, t]);
const renderActionButtons = (mode: 'inline' | 'floating') => (
<>
<Button
type="button"
variant="outline"
onClick={() => void handleResetClick()}
disabled={resetting || saving}
className={cn('w-full sm:w-auto', mode === 'floating' ? 'sm:w-auto' : '')}
>
{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"
disabled={saving || resetting}
onClick={() => {
if (formRef.current) {
if (typeof formRef.current.requestSubmit === 'function') {
formRef.current.requestSubmit();
} else {
formRef.current.submit();
}
}
}}
className={cn('w-full bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 text-white sm:w-auto', mode === 'floating' ? 'sm:w-auto' : '')}
>
{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>
</>
);
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]);
const previewStack = (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">{t('invites.customizer.preview.title', 'Live-Vorschau')}</h3>
<p className="text-xs text-muted-foreground">{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={() => void handlePrint(activeLayout?.download_urls?.pdf ?? activeLayout?.download_urls?.a4)}
disabled={printBusy || Boolean(downloadBusy)}
>
{printBusy ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <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"
disabled={printBusy || downloadBusy !== null}
onClick={() => void handleDownload(key, url)}
>
{downloadBusy === key ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Download className="mr-1 h-4 w-4" />}
{key.toUpperCase()}
</Button>
);
})}
</div>
</div>
<div className="space-y-4 rounded-xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)] p-5 shadow-inner transition-colors">
<div className="rounded-xl p-5 text-foreground" 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-[var(--tenant-layer)] 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 border border-[var(--tenant-border-strong)] bg-[var(--tenant-layer)] p-4 shadow-sm">
<div className="text-xs font-semibold uppercase tracking-wide text-[var(--tenant-foreground-soft)]">{form.instructions_heading}</div>
<ol className="grid gap-2 text-sm text-muted-foreground">
{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 rounded-2xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-layer-strong)] p-5 text-sm text-[var(--tenant-foreground-soft)] shadow-sm">
<div className="flex items-center justify-between gap-3">
<span className="text-xs font-semibold uppercase tracking-wide text-[var(--tenant-foreground-soft)]">{form.link_heading}</span>
<Badge variant="outline" className="rounded-full border border-[var(--tenant-border-strong)] bg-[var(--tenant-layer)] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide">
{t('invites.customizer.preview.readyForGuests', 'Bereit für Gäste')}
</Badge>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-[auto,minmax(0,1fr)] lg:items-center">
<div className="flex justify-center">
{qrCodeDataUrl ? (
<img
src={qrCodeDataUrl}
alt={t('invites.customizer.preview.qrAlt', 'QR-Code der Einladung')}
className="h-44 w-44 rounded-3xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-layer)] p-4 shadow-md md:h-64 md:w-64"
/>
) : (
<div className="flex h-44 w-44 items-center justify-center rounded-3xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-4 text-center text-xs text-muted-foreground md:h-64 md:w-64">
{t('invites.customizer.preview.qrPlaceholder', 'QR-Code folgt nach dem Speichern')}
</div>
)}
</div>
<div className="flex flex-col gap-3">
<div className="rounded-xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-layer)] px-3 py-2">
<span className="block truncate font-medium" style={{ color: form.accent_color ?? '#2563EB' }}>
{form.link_label || inviteUrl}
</span>
</div>
<p className="text-xs leading-relaxed text-muted-foreground">
{t('invites.customizer.preview.instructions', 'Dieser Link führt Gäste direkt zur Galerie und funktioniert zusammen mit dem QR-Code auf dem Ausdruck.')}
</p>
<Button size="sm" className="w-full bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 text-white sm:w-auto">
{form.cta_label || t('tasks.customizer.defaults.ctaLabel')}
</Button>
</div>
</div>
</div>
</div>
{form.logo_data_url ? (
<div className="flex items-center justify-center rounded-lg border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] py-4">
<img src={form.logo_data_url} alt="Logo preview" className="max-h-16 object-contain" />
</div>
) : null}
</div>
</div>
);
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,
}));
if (isAdvanced) {
setElements(buildDefaultAdvancedElements(layout, form, eventName, layout.preview?.qr_size_px ?? activeLayoutQrSize));
setActiveElementId(null);
}
}
React.useEffect(() => {
if (!availableLayouts.length) {
return;
}
const index = availableLayouts.findIndex((layout) => layout.id === selectedLayoutId);
if (index >= 0 && index !== activeLayoutIndex) {
setActiveLayoutIndex(index);
}
}, [availableLayouts, selectedLayoutId, activeLayoutIndex]);
function rotateLayout(delta: number) {
if (!availableLayouts.length) {
return;
}
const nextIndex = (activeLayoutIndex + delta + availableLayouts.length) % availableLayouts.length;
setActiveLayoutIndex(nextIndex);
handleLayoutSelect(availableLayouts[nextIndex]!);
}
function selectLayoutAt(index: number) {
if (index < 0 || index >= availableLayouts.length) {
return;
}
setActiveLayoutIndex(index);
handleLayoutSelect(availableLayouts[index]!);
}
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,
};
if (isAdvanced) {
const serializationContext: AdvancedSerializationContext = {
form,
eventName,
inviteUrl,
instructions: effectiveInstructions,
qrSize: activeLayoutQrSize,
badgeFallback: t('tasks.customizer.defaults.badgeLabel'),
logoUrl: form.logo_url ?? null,
};
payload.mode = 'advanced';
payload.elements = serializeAdvancedElements(elements.length ? elements : buildDefaultAdvancedElements(activeLayout, form, eventName, activeLayoutQrSize), serializationContext);
} else {
payload.mode = 'standard';
payload.elements = undefined;
}
await onSave(payload);
}
async function handleResetClick() {
await onReset();
}
function resolveInternalUrl(rawUrl: string): string {
try {
const parsed = new URL(rawUrl, window.location.origin);
if (parsed.origin === window.location.origin) {
return parsed.pathname + parsed.search;
}
} catch (resolveError) {
console.warn('[Invites] Unable to resolve download url', resolveError);
}
return rawUrl;
}
async function handleDownload(format: string, rawUrl: string): Promise<void> {
if (!rawUrl || !invite) {
return;
}
const normalizedFormat = format.toLowerCase();
const filenameStem = invite.token || 'invite';
setDownloadBusy(normalizedFormat);
setError(null);
try {
const response = await authorizedFetch(resolveInternalUrl(rawUrl), {
headers: {
Accept: normalizedFormat === 'pdf' ? 'application/pdf' : 'image/svg+xml',
},
});
if (!response.ok) {
throw new Error(`Unexpected status ${response.status}`);
}
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = `${filenameStem}-${normalizedFormat}.${normalizedFormat}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
} catch (downloadError) {
console.error('[Invites] Download failed', downloadError);
const message = isAuthError(downloadError)
? t('invites.customizer.errors.auth', 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.')
: t('invites.customizer.errors.downloadFailed', 'Download fehlgeschlagen. Bitte versuche es erneut.');
setError(message);
} finally {
setDownloadBusy(null);
}
}
async function handlePrint(preferredUrl?: string | null): Promise<void> {
const rawUrl = preferredUrl ?? activeLayout?.download_urls?.pdf ?? activeLayout?.download_urls?.a4 ?? null;
if (!rawUrl) {
setError(t('invites.labels.noPrintSource', 'Keine druckbare Version verfügbar.'));
return;
}
setPrintBusy(true);
setError(null);
try {
const response = await authorizedFetch(resolveInternalUrl(rawUrl), {
headers: { Accept: 'application/pdf' },
});
if (!response.ok) {
throw new Error(`Unexpected status ${response.status}`);
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const printWindow = window.open(blobUrl, '_blank', 'noopener,noreferrer');
if (!printWindow) {
throw new Error('window-blocked');
}
printWindow.onload = () => {
try {
printWindow.focus();
printWindow.print();
} catch (printError) {
console.error('[Invites] Browser print failed', printError);
}
};
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
} catch (printError) {
console.error('[Invites] Print failed', printError);
const message = isAuthError(printError)
? t('invites.customizer.errors.auth', 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.')
: t('invites.customizer.errors.printFailed', 'Druck konnte nicht gestartet werden.');
setError(message);
} finally {
setPrintBusy(false);
}
}
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-foreground">{t('invites.customizer.heading', 'Layout anpassen')}</h2>
<p className="text-sm text-muted-foreground">{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}
{invite ? (
<div className="lg:hidden">
<Sheet open={mobilePreviewOpen} onOpenChange={setMobilePreviewOpen}>
<SheetTrigger asChild>
<Button variant="outline" className="w-full justify-between">
<span>{t('invites.customizer.preview.mobileOpen', 'Vorschau anzeigen')}</span>
<span className="text-xs text-muted-foreground">{t('invites.customizer.preview.mobileHint', 'Öffnet eine Vorschau in einem Overlay')}</span>
</Button>
</SheetTrigger>
<SheetContent side="bottom" className="lg:hidden max-h-[85vh] overflow-y-auto border-t bg-[var(--tenant-surface)] p-0">
<SheetHeader className="px-6 pt-6 pb-2">
<SheetTitle>{t('invites.customizer.preview.mobileTitle', 'Einladungsvorschau')}</SheetTitle>
</SheetHeader>
<div className="space-y-4 overflow-y-auto px-6 pb-8">
{previewStack}
</div>
</SheetContent>
</Sheet>
</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-border bg-[var(--tenant-surface)] p-5 shadow-sm transition-colors">
<header className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">{t('invites.customizer.sections.layouts', 'Layouts')}</h3>
<p className="text-xs text-muted-foreground">{t('invites.customizer.sections.layoutsHint', 'Wähle eine Vorlage als Basis aus. Du kannst jederzeit wechseln.')}</p>
</div>
<div className="text-xs text-muted-foreground">
{availableLayouts.length > 1
? t('invites.customizer.carousel.hint', {
defaultValue: 'Nutze die Pfeile oder Pfeiltasten, um weitere Layouts zu entdecken.',
})
: null}
</div>
</header>
<div className="space-y-3">
<Select value={selectedLayoutId} onValueChange={(value) => handleLayoutSelect(availableLayouts.find(l => l.id === value)!)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Layout auswählen">
{activeLayout && (
<div className="flex items-center gap-2">
<span className="font-medium">{activeLayout.name}</span>
{activeLayout.formats?.length ? (
<div className="flex gap-1">
{activeLayout.formats.slice(0, 2).map((format) => (
<span
key={`${activeLayout.id}-${format}`}
className="text-[10px] font-medium uppercase tracking-wide bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded"
>
{String(format).toUpperCase()}
</span>
))}
</div>
) : null}
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{availableLayouts.map((layout) => (
<SelectItem key={layout.id} value={layout.id}>
<div className="flex items-center justify-between w-full">
<div className="flex-1">
<div className="font-medium">{layout.name}</div>
{layout.subtitle && (
<div className="text-xs text-muted-foreground">{layout.subtitle}</div>
)}
</div>
{layout.formats?.length ? (
<div className="flex gap-1 ml-2">
{layout.formats.slice(0, 3).map((format) => (
<span
key={`${layout.id}-${format}`}
className="text-[10px] font-medium uppercase tracking-wide bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded"
>
{String(format).toUpperCase()}
</span>
))}
{layout.formats.length > 3 && (
<span className="text-[10px] text-muted-foreground">+{layout.formats.length - 3}</span>
)}
</div>
) : null}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="rounded-xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-4 text-sm text-[var(--tenant-foreground-soft)] transition-colors mt-4">
<p className="font-medium text-foreground">{activeLayout.name}</p>
{activeLayout.subtitle ? <p className="mt-1 text-xs text-muted-foreground">{activeLayout.subtitle}</p> : null}
{activeLayout.description ? <p className="mt-2 leading-relaxed text-muted-foreground">{activeLayout.description}</p> : null}
</div>
</section>
<section className="space-y-4 rounded-2xl border border-border bg-[var(--tenant-surface)] p-5 shadow-sm transition-colors">
<Tabs defaultValue="text" className="space-y-4">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="text">{t('invites.customizer.sections.text', 'Texte')}</TabsTrigger>
<TabsTrigger value="instructions">{t('invites.customizer.sections.instructions', 'Schritt-für-Schritt')}</TabsTrigger>
<TabsTrigger value="branding">{t('invites.customizer.sections.branding', 'Farbgebung')}</TabsTrigger>
</TabsList>
<TabsContent value="text" className="space-y-4">
<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>
</TabsContent>
<TabsContent value="instructions" className="space-y-4">
<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-muted-foreground hover:text-destructive"
onClick={() => handleRemoveInstruction(index)}
>
×
</Button>
</div>
))}
</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>
</TabsContent>
<TabsContent value="branding" className="space-y-4">
<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-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-3 text-[var(--tenant-foreground-soft)]">
<img src={form.logo_data_url} alt="Logo" className="h-12 w-12 rounded border border-[var(--tenant-border-strong)] object-contain" />
<Button type="button" variant="ghost" onClick={handleLogoRemove} className="text-destructive hover:text-destructive/80">
{t('invites.customizer.actions.removeLogo', 'Logo entfernen')}
</Button>
</div>
) : (
<label className="flex cursor-pointer items-center gap-3 rounded-lg border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] px-4 py-3 text-sm text-muted-foreground hover:border-primary">
<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>
</TabsContent>
</Tabs>
</section>
<div className={cn('mt-6 flex flex-col gap-2 sm:flex-row sm:justify-end', showFloatingActions ? 'hidden' : 'flex')}>
{renderActionButtons('inline')}
</div>
<div ref={actionsSentinelRef} className="h-1 w-full" />
</form>
<div className="hidden lg:block">
{previewStack}
</div>
</div>
{showFloatingActions ? (
<div className="pointer-events-auto fixed inset-x-4 bottom-6 z-40 flex flex-col gap-2 rounded-2xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-layer-strong)]/95 p-4 shadow-2xl backdrop-blur-sm sm:inset-x-auto sm:right-6 sm:w-auto sm:flex-row sm:items-center sm:gap-3">
{renderActionButtons('floating')}
</div>
) : null}
</div>
);
}
function CardPlaceholder({ title, description }: { title: string; description: string }) {
return (
<div className="rounded-2xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-10 text-center text-sm text-[var(--tenant-foreground-soft)] transition-colors">
<h3 className="text-base font-semibold text-foreground">{title}</h3>
<p className="mt-2 text-sm text-muted-foreground">{description}</p>
</div>
);
}
function SmileIcon(): React.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>
);
}