Files
fotospiel-app/resources/js/admin/pages/components/InviteLayoutCustomizerPanel.tsx
2025-11-01 13:19:07 +01:00

1938 lines
74 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 {
AlignLeft,
BadgeCheck,
Download,
Heading,
Link as LinkIcon,
Loader2,
Megaphone,
Plus,
Printer,
QrCode,
RotateCcw,
Save,
Trash2,
Type,
Undo2,
Redo2,
UploadCloud,
} from 'lucide-react';
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 { 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 } from '../../auth/tokens';
import {
CANVAS_HEIGHT,
CANVAS_WIDTH,
QrLayoutCustomization,
LayoutElement,
LayoutElementPayload,
LayoutElementType,
LayoutSerializationContext,
buildDefaultElements,
clamp,
clampElement,
elementsToPayload,
normalizeElements,
payloadToElements,
} from './invite-layout/schema';
import { DesignerCanvas } from './invite-layout/DesignerCanvas';
import {
generatePdfBytes,
generatePngDataUrl,
openPdfInNewTab,
triggerDownloadFromBlob,
triggerDownloadFromDataUrl,
} from './invite-layout/export-utils';
export type { QrLayoutCustomization } from './invite-layout/schema';
function sanitizeColor(value: string | null | undefined): string | null {
if (!value) {
return null;
}
const trimmed = value.trim();
const hexMatch = trimmed.match(/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/);
if (hexMatch) {
if (trimmed.length === 4) {
const r = trimmed[1];
const g = trimmed[2];
const b = trimmed[3];
return `#${r}${r}${g}${g}${b}${b}`.toUpperCase();
}
return trimmed.toUpperCase();
}
return null;
}
function sanitizeGradientStops(stops: unknown): string[] | null {
if (!Array.isArray(stops)) {
return null;
}
const normalized = stops
.map((stop) => (typeof stop === 'string' ? sanitizeColor(stop) : null))
.filter((stop): stop is string => !!stop);
return normalized.length ? normalized : null;
}
function sanitizePayload(payload: QrLayoutCustomization): QrLayoutCustomization {
const normalized: QrLayoutCustomization = { ...payload };
normalized.accent_color = sanitizeColor(payload.accent_color ?? null) ?? undefined;
normalized.text_color = sanitizeColor(payload.text_color ?? null) ?? undefined;
normalized.background_color = sanitizeColor(payload.background_color ?? null) ?? undefined;
normalized.secondary_color = sanitizeColor(payload.secondary_color ?? null) ?? undefined;
normalized.badge_color = sanitizeColor(payload.badge_color ?? null) ?? undefined;
if (payload.background_gradient && typeof payload.background_gradient === 'object') {
const { angle, stops } = payload.background_gradient as { angle?: number; stops?: unknown };
const normalizedStops = sanitizeGradientStops(stops);
normalized.background_gradient = normalizedStops
? { angle: typeof angle === 'number' ? angle : 180, stops: normalizedStops }
: null;
}
if (Array.isArray(payload.instructions)) {
normalized.instructions = payload.instructions
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
.filter((entry) => entry.length > 0);
}
if (typeof payload.link_label === 'string') {
normalized.link_label = payload.link_label.trim();
}
return normalized;
}
function serializeElements(elements: LayoutElement[], context: LayoutSerializationContext): LayoutElementPayload[] {
return normalizeElements(elements).map((element) => {
const base = clampElement(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.form.link_label.trim().length > 0 ? 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;
case 'cta':
content = context.form.cta_label ?? context.form.link_label ?? context.inviteUrl;
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),
rotation: Math.round(base.rotation ?? 0),
font_size: base.fontSize ? Math.round(base.fontSize) : undefined,
align: base.align,
content,
font_family: base.fontFamily ?? null,
letter_spacing: base.letterSpacing,
line_height: base.lineHeight,
fill: base.fill ?? null,
locked: Boolean(base.locked),
};
});
}
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;
const ZOOM_MIN = 0.1;
const ZOOM_MAX = 2;
const ZOOM_STEP = 0.05;
export function InviteLayoutCustomizerPanel({
invite,
eventName,
saving,
resetting,
onSave,
onReset,
initialCustomization,
}: 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 [elements, setElements] = React.useState<LayoutElement[]>([]);
const [activeElementId, setActiveElementId] = React.useState<string | null>(null);
const [showFloatingActions, setShowFloatingActions] = React.useState(false);
const [zoomScale, setZoomScale] = React.useState(1);
const [fitScale, setFitScale] = React.useState(1);
const fitScaleRef = React.useRef(1);
const manualZoomRef = React.useRef(false);
const actionsSentinelRef = React.useRef<HTMLDivElement | null>(null);
const historyRef = React.useRef<LayoutElement[][]>([]);
const historyIndexRef = React.useRef(-1);
const restoringRef = React.useRef(false);
const [canUndo, setCanUndo] = React.useState(false);
const [canRedo, setCanRedo] = React.useState(false);
const designerViewportRef = React.useRef<HTMLDivElement | null>(null);
const canvasContainerRef = React.useRef<HTMLDivElement | null>(null);
const isAdvanced = true;
const clampZoom = React.useCallback(
(value: number) => clamp(Number.isFinite(value) ? value : 1, ZOOM_MIN, ZOOM_MAX),
[],
);
const recomputeFitScale = React.useCallback(() => {
const viewport = designerViewportRef.current;
if (!viewport) {
return;
}
const { clientWidth, clientHeight } = viewport;
if (!clientWidth || !clientHeight) {
return;
}
const style = window.getComputedStyle(viewport);
const paddingX = parseFloat(style.paddingLeft ?? '0') + parseFloat(style.paddingRight ?? '0');
const paddingY = parseFloat(style.paddingTop ?? '0') + parseFloat(style.paddingBottom ?? '0');
const availableWidth = clientWidth - paddingX;
const availableHeight = clientHeight - paddingY;
if (availableWidth <= 0 || availableHeight <= 0) {
return;
}
const widthScale = availableWidth / CANVAS_WIDTH;
const heightScale = availableHeight / CANVAS_HEIGHT;
const nextRaw = Math.min(widthScale, heightScale);
const baseScale = Number.isFinite(nextRaw) && nextRaw > 0 ? Math.min(nextRaw, 1) : 1;
const clamped = clampZoom(baseScale);
fitScaleRef.current = clamped;
setFitScale((prev) => (Math.abs(prev - clamped) < 0.001 ? prev : clamped));
if (!manualZoomRef.current) {
setZoomScale((prev) => (Math.abs(prev - clamped) < 0.001 ? prev : clamped));
}
console.debug('[Invites][Zoom] viewport size', {
availableWidth,
availableHeight,
widthScale,
heightScale,
clamped,
});
}, [clampZoom]);
React.useLayoutEffect(() => {
recomputeFitScale();
}, [recomputeFitScale]);
React.useEffect(() => {
const viewport = designerViewportRef.current;
const handleResize = () => {
recomputeFitScale();
};
window.addEventListener('resize', handleResize);
let observer: ResizeObserver | null = null;
if (viewport && typeof ResizeObserver === 'function') {
observer = new ResizeObserver(() => recomputeFitScale());
observer.observe(viewport);
}
recomputeFitScale();
return () => {
window.removeEventListener('resize', handleResize);
if (observer) {
observer.disconnect();
}
};
}, [recomputeFitScale]);
const cloneElements = React.useCallback(
(items: LayoutElement[]): LayoutElement[] => items.map((item) => ({ ...item })),
[]
);
const elementsAreEqual = React.useCallback((a: LayoutElement[], b: LayoutElement[]): boolean => {
if (a.length !== b.length) {
return false;
}
for (let index = 0; index < a.length; index += 1) {
const left = a[index];
const right = b[index];
if (
left.id !== right.id ||
left.type !== right.type ||
left.x !== right.x ||
left.y !== right.y ||
left.width !== right.width ||
left.height !== right.height ||
(left.rotation ?? 0) !== (right.rotation ?? 0) ||
(left.fontSize ?? null) !== (right.fontSize ?? null) ||
(left.align ?? null) !== (right.align ?? null) ||
(left.content ?? null) !== (right.content ?? null) ||
(left.fontFamily ?? null) !== (right.fontFamily ?? null) ||
(left.letterSpacing ?? null) !== (right.letterSpacing ?? null) ||
(left.lineHeight ?? null) !== (right.lineHeight ?? null) ||
(left.fill ?? null) !== (right.fill ?? null) ||
Boolean(left.locked) !== Boolean(right.locked) ||
Boolean(left.initial) !== Boolean(right.initial)
) {
return false;
}
}
return true;
}, []);
const pushHistory = React.useCallback(
(snapshot: LayoutElement[]) => {
const copy = cloneElements(snapshot);
let history = historyRef.current.slice(0, historyIndexRef.current + 1);
history.push(copy);
if (history.length > 60) {
history = history.slice(history.length - 60);
}
historyRef.current = history;
historyIndexRef.current = history.length - 1;
setCanUndo(historyIndexRef.current > 0);
setCanRedo(false);
},
[cloneElements]
);
const resetHistory = React.useCallback(
(snapshot: LayoutElement[]) => {
const copy = cloneElements(snapshot);
historyRef.current = [copy];
historyIndexRef.current = 0;
setCanUndo(false);
setCanRedo(false);
},
[cloneElements]
);
const commitElements = React.useCallback(
(producer: (current: LayoutElement[]) => LayoutElement[], options?: { silent?: boolean }) => {
setElements((prev) => {
const base = cloneElements(prev);
const produced = producer(base);
const normalized = normalizeElements(produced);
if (elementsAreEqual(prev, normalized)) {
return prev;
}
if (!options?.silent && !restoringRef.current) {
pushHistory(normalized);
}
return normalized;
});
},
[cloneElements, pushHistory, elementsAreEqual]
);
const handleUndo = React.useCallback(() => {
if (historyIndexRef.current <= 0) {
return;
}
restoringRef.current = true;
historyIndexRef.current -= 1;
const snapshot = cloneElements(historyRef.current[historyIndexRef.current] ?? []);
setElements(snapshot);
setCanUndo(historyIndexRef.current > 0);
setCanRedo(historyRef.current.length > historyIndexRef.current + 1);
restoringRef.current = false;
}, [cloneElements]);
const handleRedo = React.useCallback(() => {
if (historyIndexRef.current === -1) {
return;
}
if (historyIndexRef.current >= historyRef.current.length - 1) {
return;
}
restoringRef.current = true;
historyIndexRef.current += 1;
const snapshot = cloneElements(historyRef.current[historyIndexRef.current] ?? []);
setElements(snapshot);
setCanUndo(historyIndexRef.current > 0);
setCanRedo(historyRef.current.length > historyIndexRef.current + 1);
restoringRef.current = false;
}, [cloneElements]);
const formStateRef = React.useRef(form);
React.useEffect(() => {
formStateRef.current = form;
}, [form]);
const prevFormRef = React.useRef(form);
const initializedLayoutsRef = React.useRef<Record<string, boolean>>({});
const prevInviteRef = React.useRef<number | string | 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[0];
}, [availableLayouts, selectedLayoutId]);
React.useEffect(() => {
manualZoomRef.current = false;
recomputeFitScale();
}, [recomputeFitScale, activeLayout?.id, invite?.id]);
const activeLayoutQrSize = React.useMemo(() => {
const qrElement = elements.find((element) => element.type === 'qr');
if (qrElement && typeof qrElement.width === 'number' && qrElement.width > 0) {
return qrElement.width;
}
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;
}, [elements, initialCustomization?.mode, initialCustomization?.elements, activeLayout?.preview?.qr_size_px]);
const effectiveScale = React.useMemo(
() => clampZoom(Number.isFinite(zoomScale) && zoomScale > 0 ? zoomScale : fitScale),
[clampZoom, zoomScale, fitScale],
);
const zoomPercent = Math.round(effectiveScale * 100);
const updateElement = React.useCallback(
(id: string, updater: Partial<LayoutElement> | ((element: LayoutElement) => Partial<LayoutElement>), options?: { silent?: boolean }) => {
commitElements(
(current) =>
current.map((element) => {
if (element.id !== id) {
return element;
}
const patch = typeof updater === 'function' ? updater(element) : updater;
return clampElement({ ...element, ...patch });
}),
options
);
},
[commitElements]
);
const handleResetAdvanced = React.useCallback(() => {
if (!activeLayout) {
const cleared: LayoutElement[] = [];
commitElements(() => cleared, { silent: true });
resetHistory(cleared);
setActiveElementId(null);
return;
}
const defaults = buildDefaultElements(activeLayout, formStateRef.current, eventName, activeLayoutQrSize);
if (elements.length && elementsAreEqual(elements, defaults)) {
initializedLayoutsRef.current[activeLayout.id] = true;
return;
}
commitElements(() => defaults, { silent: true });
resetHistory(defaults);
setActiveElementId(null);
initializedLayoutsRef.current[activeLayout.id] = true;
}, [activeLayout, eventName, activeLayoutQrSize, commitElements, elements, elementsAreEqual, resetHistory]);
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 reuseCustomization = initialCustomization?.layout_id === activeLayout.id;
const baseInstructions = reuseCustomization && Array.isArray(initialCustomization?.instructions) && initialCustomization.instructions?.length
? [...(initialCustomization.instructions as string[])]
: ((activeLayout.instructions && activeLayout.instructions.length)
? [...activeLayout.instructions]
: [...defaultInstructions]);
setInstructions(baseInstructions);
setForm({
layout_id: activeLayout.id,
headline: reuseCustomization ? initialCustomization?.headline ?? eventName : eventName,
subtitle: reuseCustomization ? initialCustomization?.subtitle ?? activeLayout.subtitle ?? '' : activeLayout.subtitle ?? '',
description: reuseCustomization ? initialCustomization?.description ?? activeLayout.description ?? '' : activeLayout.description ?? '',
badge_label: reuseCustomization ? initialCustomization?.badge_label ?? t('tasks.customizer.defaults.badgeLabel') : (activeLayout.badge_label ?? t('tasks.customizer.defaults.badgeLabel')),
instructions_heading: reuseCustomization ? initialCustomization?.instructions_heading ?? t('tasks.customizer.defaults.instructionsHeading') : t('tasks.customizer.defaults.instructionsHeading'),
link_heading: reuseCustomization ? initialCustomization?.link_heading ?? t('tasks.customizer.defaults.linkHeading') : t('tasks.customizer.defaults.linkHeading'),
link_label: reuseCustomization ? initialCustomization?.link_label ?? inviteUrl : inviteUrl,
cta_label: reuseCustomization ? initialCustomization?.cta_label ?? t('tasks.customizer.defaults.ctaLabel') : (activeLayout.cta_label ?? t('tasks.customizer.defaults.ctaLabel')),
accent_color: sanitizeColor((reuseCustomization ? initialCustomization?.accent_color : activeLayout.preview?.accent) ?? null) ?? '#6366F1',
text_color: sanitizeColor((reuseCustomization ? initialCustomization?.text_color : activeLayout.preview?.text) ?? null) ?? '#111827',
background_color: sanitizeColor((reuseCustomization ? initialCustomization?.background_color : activeLayout.preview?.background) ?? null) ?? '#FFFFFF',
secondary_color: sanitizeColor((reuseCustomization ? initialCustomization?.secondary_color : activeLayout.preview?.secondary) ?? null) ?? '#1F2937',
badge_color: sanitizeColor((reuseCustomization ? initialCustomization?.badge_color : activeLayout.preview?.badge ?? activeLayout.preview?.accent) ?? null) ?? '#2563EB',
background_gradient: reuseCustomization ? initialCustomization?.background_gradient ?? activeLayout.preview?.background_gradient ?? null : activeLayout.preview?.background_gradient ?? null,
logo_data_url: reuseCustomization ? initialCustomization?.logo_data_url ?? initialCustomization?.logo_url ?? null : null,
});
setError(null);
}, [invite?.id, activeLayout?.id, defaultInstructions, initialCustomization, eventName, inviteUrl, t]);
React.useEffect(() => {
if (!activeLayout) {
const cleared: LayoutElement[] = [];
commitElements(() => cleared, { silent: true });
resetHistory(cleared);
return;
}
const layoutKey = activeLayout.id ?? '__default';
const inviteKey = invite?.id ?? null;
if (prevInviteRef.current !== inviteKey) {
initializedLayoutsRef.current = {};
prevInviteRef.current = inviteKey;
}
if (!initializedLayoutsRef.current[layoutKey]) {
if (initialCustomization?.mode === 'advanced' && Array.isArray(initialCustomization.elements) && initialCustomization.elements.length) {
const initialElements = normalizeElements(payloadToElements(initialCustomization.elements));
commitElements(() => initialElements, { silent: true });
resetHistory(initialElements);
} else {
const defaults = buildDefaultElements(activeLayout, formStateRef.current, eventName, activeLayoutQrSize);
commitElements(() => defaults, { silent: true });
resetHistory(defaults);
}
initializedLayoutsRef.current[layoutKey] = true;
return;
}
if (historyIndexRef.current === -1 && elements.length > 0) {
resetHistory(cloneElements(elements));
}
}, [
activeLayout,
invite?.id,
activeLayoutQrSize,
initialCustomization?.mode,
initialCustomization?.elements,
commitElements,
resetHistory,
elements,
cloneElements,
eventName,
]);
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 nonRemovableIds = React.useMemo(
() => new Set(['headline', 'qr']),
[]
);
const effectiveInstructions = instructions.filter((entry) => entry.trim().length > 0);
const canvasElements = React.useMemo(() => {
if (!activeLayout) {
console.debug('[Invites][CanvasElements] No active layout', {
inviteId: invite?.id ?? null,
availableLayouts: availableLayouts.length,
});
return [] as LayoutElement[];
}
console.debug('[Invites][CanvasElements] Layout preview', {
layoutId: activeLayout.id,
preview: activeLayout.preview,
});
const base = elements.length
? elements
: buildDefaultElements(activeLayout, form, eventName, activeLayoutQrSize);
console.debug('[Invites][CanvasElements] Base elements', {
layoutId: activeLayout.id,
existing: elements.length,
generated: base.length,
hasCustomization: Boolean(initialCustomization?.elements?.length),
});
const boundContent: Record<string, string | null> = {
headline: form.headline ?? eventName,
subtitle: form.subtitle ?? '',
description: form.description ?? activeLayout.description ?? '',
link: form.link_label && form.link_label.trim().length > 0 ? form.link_label : inviteUrl,
badge: form.badge_label ?? t('tasks.customizer.defaults.badgeLabel'),
cta: form.cta_label ?? t('tasks.customizer.defaults.ctaLabel'),
};
return base.map((element) => {
let content = element.content ?? null;
if (Object.prototype.hasOwnProperty.call(boundContent, element.id)) {
content = boundContent[element.id] ?? '';
}
if (element.type === 'text' && (!content || content.trim().length === 0)) {
content = t('invites.customizer.defaults.textBlock', 'Neuer Textblock hier kannst du eigene Hinweise ergänzen.');
}
return {
...element,
content,
initial: element.initial ?? nonRemovableIds.has(element.id),
};
});
}, [
activeLayout,
elements,
form.headline,
form.subtitle,
form.description,
form.link_label,
form.badge_label,
form.cta_label,
eventName,
inviteUrl,
t,
activeLayout,
activeLayoutQrSize,
]);
const elementLabelFor = React.useCallback(
(element: LayoutElement): string => {
switch (element.type) {
case 'headline':
return t('invites.customizer.elements.headline', 'Überschrift');
case 'subtitle':
return t('invites.customizer.elements.subtitle', 'Untertitel');
case 'description':
return t('invites.customizer.elements.description', 'Beschreibung');
case 'badge':
return t('invites.customizer.elements.badge', 'Badge');
case 'link':
return t('invites.customizer.elements.link', 'Linkfeld');
case 'cta':
return t('invites.customizer.elements.cta', 'Call-to-Action');
case 'qr':
return t('invites.customizer.elements.qr', 'QR-Code');
case 'logo':
return t('invites.customizer.elements.logo', 'Logo');
case 'text':
default:
if (element.content && element.content.trim().length) {
const preview = element.content.trim();
return preview.length > 28 ? `${preview.slice(0, 28)}` : preview;
}
return t('invites.customizer.elements.text', 'Freier Textblock');
}
},
[t]
);
const elementIconFor = React.useCallback((element: LayoutElement) => {
switch (element.type) {
case 'headline':
case 'subtitle':
return Heading;
case 'description':
case 'text':
return AlignLeft;
case 'badge':
return BadgeCheck;
case 'link':
return LinkIcon;
case 'cta':
return Megaphone;
case 'qr':
return QrCode;
case 'logo':
return Type;
default:
return AlignLeft;
}
}, []);
const createPresetElement = React.useCallback(
(type: LayoutElementType, preferredId?: string): LayoutElement | null => {
if (!activeLayout) {
return null;
}
const baseId = preferredId ?? `text-${Date.now()}`;
const currentForm = formStateRef.current ?? {};
switch (type) {
case 'subtitle':
return { ...clampElement({
id: 'subtitle',
type: 'subtitle',
x: 80,
y: 240,
width: 520,
height: 110,
fontSize: 32,
content: (currentForm.subtitle as string) ?? '',
align: 'left',
}), initial: false };
case 'badge':
return { ...clampElement({
id: 'badge',
type: 'badge',
x: 80,
y: 40,
width: 220,
height: 70,
align: 'center',
fontSize: 22,
content: (currentForm.badge_label as string) ?? t('tasks.customizer.defaults.badgeLabel'),
}), initial: false };
case 'link':
return { ...clampElement({
id: 'link',
type: 'link',
x: 660,
y: 340 + activeLayoutQrSize,
width: 360,
height: 110,
fontSize: 26,
align: 'center',
content:
currentForm.link_label && String(currentForm.link_label).trim().length > 0 ? String(currentForm.link_label) : inviteUrl,
}), initial: false };
case 'cta':
return { ...clampElement({
id: 'cta',
type: 'cta',
x: 660,
y: 340 + activeLayoutQrSize + 130,
width: 360,
height: 100,
align: 'center',
fontSize: 24,
content: (currentForm.cta_label as string) ?? t('tasks.customizer.defaults.ctaLabel'),
}), initial: false };
case 'text':
return { ...clampElement({
id: baseId,
type: 'text',
x: 120,
y: 520,
width: 520,
height: 140,
fontSize: 26,
align: 'left',
content: t('invites.customizer.defaults.textBlock', 'Neuer Textblock hier kannst du eigene Hinweise ergänzen.'),
}), initial: false };
default:
return null;
}
},
[activeLayout, activeLayoutQrSize, inviteUrl, t]
);
const addElementFromPreset = React.useCallback(
(type: LayoutElementType, preferredId?: string) => {
const preset = createPresetElement(type, preferredId);
if (!preset) {
return;
}
commitElements((current) => {
const next = preferredId ? current.filter((item) => item.id !== preferredId) : [...current];
next.push({ ...preset, initial: false });
return next;
});
setActiveElementId(preset.id);
},
[createPresetElement, commitElements]
);
const removeElement = React.useCallback(
(id: string) => {
if (nonRemovableIds.has(id)) {
return;
}
commitElements((current) => current.filter((item) => item.id !== id));
if (activeElementId === id) {
setActiveElementId(null);
}
},
[activeElementId, nonRemovableIds, commitElements]
);
const updateElementContent = React.useCallback((id: string, value: string) => {
commitElements((current) => current.map((item) => (item.id === id ? { ...item, content: value } : item)));
}, [commitElements]);
const updateElementAlign = React.useCallback(
(id: string, align: 'left' | 'center' | 'right') => {
updateElement(id, { align });
},
[updateElement]
);
const elementTypeOrder: Record<LayoutElementType, number> = React.useMemo(
() => ({
headline: 1,
subtitle: 2,
description: 3,
text: 4,
badge: 5,
link: 6,
cta: 7,
qr: 8,
logo: 9,
}),
[]
);
const sortedElements = React.useMemo(() => {
return [...elements].sort((a, b) => {
const aOrder = elementTypeOrder[a.type] ?? 99;
const bOrder = elementTypeOrder[b.type] ?? 99;
if (aOrder === bOrder) {
return a.id.localeCompare(b.id);
}
return aOrder - bOrder;
});
}, [elements, elementTypeOrder]);
const additionOptions = React.useMemo(() => {
const existingIds = new Set(elements.map((item) => item.id));
return [
{
key: 'subtitle',
type: 'subtitle' as LayoutElementType,
label: t('invites.customizer.elements.addSubtitle', 'Untertitel einblenden'),
icon: Heading,
unique: true,
},
{
key: 'badge',
type: 'badge' as LayoutElementType,
label: t('invites.customizer.elements.addBadge', 'Badge anzeigen'),
icon: BadgeCheck,
unique: true,
},
{
key: 'link',
type: 'link' as LayoutElementType,
label: t('invites.customizer.elements.addLink', 'Linkfeld hinzufügen'),
icon: LinkIcon,
unique: true,
},
{
key: 'cta',
type: 'cta' as LayoutElementType,
label: t('invites.customizer.elements.addCta', 'Call-to-Action einfügen'),
icon: Megaphone,
unique: true,
},
{
key: 'text',
type: 'text' as LayoutElementType,
label: t('invites.customizer.elements.addText', 'Freien Textblock hinzufügen'),
icon: Type,
unique: false,
},
].filter((option) => {
if (!option.unique) {
return true;
}
return !existingIds.has(option.key);
});
}, [elements, t]);
const elementBindings = React.useMemo(
() => ({
headline: {
field: 'headline' as const,
label: t('invites.customizer.fields.headline', 'Überschrift'),
multiline: false,
},
subtitle: {
field: 'subtitle' as const,
label: t('invites.customizer.fields.subtitle', 'Unterzeile'),
multiline: false,
},
description: {
field: 'description' as const,
label: t('invites.customizer.fields.description', 'Beschreibung'),
multiline: true,
},
badge: {
field: 'badge_label' as const,
label: t('invites.customizer.fields.badge', 'Badge-Label'),
multiline: false,
},
link: {
field: 'link_label' as const,
label: t('invites.customizer.fields.linkLabel', 'Linktext'),
multiline: false,
},
cta: {
field: 'cta_label' as const,
label: t('invites.customizer.fields.cta', 'Call-to-Action'),
multiline: false,
},
}),
[t]
);
React.useEffect(() => {
const previous = prevFormRef.current;
prevFormRef.current = form;
const entries = Object.entries(elementBindings) as Array<[
keyof typeof elementBindings,
{ field: keyof QrLayoutCustomization; multiline: boolean }
]>;
entries.forEach(([elementId, binding]) => {
if (!elements.some((element) => element.id === elementId)) {
return;
}
const previousValue = previous?.[binding.field] ?? null;
const nextValue = form[binding.field] ?? null;
if (previousValue !== nextValue) {
updateElement(
elementId,
{
content: typeof nextValue === 'string' ? nextValue : nextValue ?? null,
},
{ silent: true }
);
}
});
}, [form, elementBindings, elements, updateElement]);
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 renderElementDetail = React.useCallback(
(element: LayoutElement): React.ReactNode => {
const binding = elementBindings[element.id as keyof typeof elementBindings];
const blocks: React.ReactNode[] = [];
if (binding) {
const value = (form[binding.field] as string) ?? '';
if (binding.multiline) {
blocks.push(
<div className="space-y-2" key={`${element.id}-binding`}>
<Label>{binding.label}</Label>
<Textarea
value={value}
onChange={(event) => updateForm(binding.field, event.target.value as never)}
className="min-h-[96px]"
/>
</div>
);
} else {
blocks.push(
<div className="space-y-2" key={`${element.id}-binding`}>
<Label>{binding.label}</Label>
<Input
value={value}
onChange={(event) => updateForm(binding.field, event.target.value as never)}
/>
</div>
);
}
} else if (element.type === 'text') {
blocks.push(
<div className="space-y-2" key={`${element.id}-content`}>
<Label>{t('invites.customizer.elements.customText', 'Textinhalt')}</Label>
<Textarea
value={element.content ?? ''}
onChange={(event) => updateElementContent(element.id, event.target.value)}
className="min-h-[96px]"
/>
</div>
);
} else if (element.type === 'qr') {
blocks.push(
<p className="text-xs text-muted-foreground" key={`${element.id}-hint`}>
{t('invites.customizer.elements.qrHint', 'Der QR-Code lässt sich im Canvas in Größe und Position verändern. Weitere Einstellungen sind nicht erforderlich.')}
</p>
);
} else if (element.type === 'logo') {
blocks.push(
<p className="text-xs text-muted-foreground" key={`${element.id}-logo`}>
{t('invites.customizer.elements.logoHint', 'Passe Logos im Bereich „Branding“ an. Hier kannst du das Element nur verschieben und skalieren.')}
</p>
);
}
if (element.type !== 'qr') {
blocks.push(
<div className="grid gap-4 sm:grid-cols-2" key={`${element.id}-appearance`}>
<div className="space-y-2">
<Label>{t('invites.customizer.elements.align', 'Ausrichtung')}</Label>
<ToggleGroup
type="single"
value={element.align ?? 'left'}
onValueChange={(value) => value && updateElementAlign(element.id, value as 'left' | 'center' | 'right')}
>
<ToggleGroupItem value="left" className="px-3">
{t('invites.customizer.elements.alignLeft', 'Links')}
</ToggleGroupItem>
<ToggleGroupItem value="center" className="px-3">
{t('invites.customizer.elements.alignCenter', 'Zentriert')}
</ToggleGroupItem>
<ToggleGroupItem value="right" className="px-3">
{t('invites.customizer.elements.alignRight', 'Rechts')}
</ToggleGroupItem>
</ToggleGroup>
</div>
<div className="space-y-2">
<Label>{t('invites.customizer.elements.fontSize', 'Schriftgröße')}</Label>
<Input
type="number"
min={10}
max={120}
value={Math.round(element.fontSize ?? 26)}
onChange={(event) => {
const nextSize = Number(event.target.value) || 0;
updateElement(element.id, { fontSize: Math.max(10, Math.min(120, nextSize)) });
}}
/>
</div>
</div>
);
}
if (!blocks.length) {
blocks.push(
<p className="text-xs text-muted-foreground" key={`${element.id}-fallback`}>
{t('invites.customizer.elements.selectHint', 'Tippe ein Element im Canvas oder in der Liste an, um Details zu bearbeiten.')}
</p>
);
}
return (
<div className="mt-3 w-full rounded-xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-layer)]/80 p-4 text-sm text-[var(--tenant-foreground-soft)]">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('invites.customizer.elements.details', 'Element-Details')}
</div>
<div className="mt-3 space-y-4">{blocks}</div>
</div>
);
},
[elementBindings, form, t, updateElement, updateElementAlign, updateElementContent, updateForm]
);
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: sanitizeColor(layout.preview?.accent ?? prev.accent_color ?? null) ?? '#6366F1',
text_color: sanitizeColor(layout.preview?.text ?? prev.text_color ?? null) ?? '#111827',
background_color: sanitizeColor(layout.preview?.background ?? prev.background_color ?? null) ?? '#FFFFFF',
secondary_color: sanitizeColor(layout.preview?.secondary ?? prev.secondary_color ?? null) ?? '#1F2937',
badge_color: sanitizeColor(layout.preview?.badge ?? prev.badge_color ?? layout.preview?.accent ?? null) ?? '#2563EB',
background_gradient: layout.preview?.background_gradient ?? null,
}));
setInstructions((layout.instructions ?? []).length ? [...(layout.instructions as string[])] : [...defaultInstructions]);
const defaults = buildDefaultElements(layout, formStateRef.current, eventName, layout.preview?.qr_size_px ?? activeLayoutQrSize);
commitElements(() => defaults, { silent: true });
resetHistory(defaults);
setActiveElementId(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,
};
const serializationContext: LayoutSerializationContext = {
form,
eventName,
inviteUrl,
instructions: effectiveInstructions,
qrSize: activeLayoutQrSize,
badgeFallback: t('tasks.customizer.defaults.badgeLabel'),
logoUrl: form.logo_url ?? null,
};
payload.mode = 'advanced';
payload.elements = serializeElements(
elements.length ? elements : buildDefaultElements(activeLayout, form, eventName, activeLayoutQrSize),
serializationContext
);
await onSave(sanitizePayload(payload));
}
async function handleResetClick() {
await onReset();
}
async function handleDownload(format: string): Promise<void> {
if (!invite) {
return;
}
const normalizedFormat = format.toLowerCase();
const filenameStem = `${invite.token || 'invite'}-${normalizedFormat}`;
setDownloadBusy(normalizedFormat);
setError(null);
try {
const exportOptions = {
elements: canvasElements,
accentColor: form.accent_color ?? activeLayout?.preview?.accent ?? '#6366F1',
textColor: form.text_color ?? activeLayout?.preview?.text ?? '#111827',
secondaryColor: form.secondary_color ?? activeLayout?.preview?.secondary ?? '#1F2937',
badgeColor: form.badge_color ?? form.accent_color ?? '#2563EB',
qrCodeDataUrl,
logoDataUrl: form.logo_data_url ?? form.logo_url ?? null,
backgroundColor: form.background_color ?? activeLayout?.preview?.background ?? '#FFFFFF',
backgroundGradient: form.background_gradient ?? activeLayout?.preview?.background_gradient ?? null,
readOnly: true,
selectedId: null,
} as const;
if (normalizedFormat === 'png') {
const dataUrl = await generatePngDataUrl(exportOptions);
await triggerDownloadFromDataUrl(dataUrl, `${filenameStem}.png`);
} else if (normalizedFormat === 'pdf') {
const pdfBytes = await generatePdfBytes(
exportOptions,
activeLayout?.paper ?? 'a4',
activeLayout?.orientation ?? 'portrait',
);
triggerDownloadFromBlob(new Blob([pdfBytes], { type: 'application/pdf' }), `${filenameStem}.pdf`);
} else {
throw new Error(`Unsupported format: ${normalizedFormat}`);
}
} catch (downloadError) {
console.error('[Invites] Layout export failed', downloadError);
setError(t('invites.customizer.errors.downloadFailed', 'Download fehlgeschlagen. Bitte versuche es erneut.'));
} finally {
setDownloadBusy(null);
}
}
async function handlePrint(): Promise<void> {
if (!invite) {
return;
}
setPrintBusy(true);
setError(null);
try {
const exportOptions = {
elements: canvasElements,
accentColor: form.accent_color ?? activeLayout?.preview?.accent ?? '#6366F1',
textColor: form.text_color ?? activeLayout?.preview?.text ?? '#111827',
secondaryColor: form.secondary_color ?? activeLayout?.preview?.secondary ?? '#1F2937',
badgeColor: form.badge_color ?? form.accent_color ?? '#2563EB',
qrCodeDataUrl,
logoDataUrl: form.logo_data_url ?? form.logo_url ?? null,
backgroundColor: form.background_color ?? activeLayout?.preview?.background ?? '#FFFFFF',
backgroundGradient: form.background_gradient ?? activeLayout?.preview?.background_gradient ?? null,
readOnly: true,
selectedId: null,
} as const;
const pdfBytes = await generatePdfBytes(
exportOptions,
activeLayout?.paper ?? 'a4',
activeLayout?.orientation ?? 'portrait',
);
await openPdfInNewTab(pdfBytes);
} catch (printError) {
console.error('[Invites] Print failed', printError);
setError(t('invites.customizer.errors.printFailed', 'Drucken konnte nicht gestartet werden.'));
} 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', 'Bearbeite Texte, Farben und Positionen direkt neben der Live-Vorschau. Änderungen werden sofort sichtbar.')}</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 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
<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="space-y-1">
<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>
</header>
<Select
value={activeLayout?.id ?? undefined}
onValueChange={(value) => {
const layout = availableLayouts.find((item) => item.id === value);
if (layout) {
handleLayoutSelect(layout);
}
}}
disabled={!availableLayouts.length}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={t('invites.customizer.layoutFallback', 'Layout')} />
</SelectTrigger>
<SelectContent className="max-h-60">
{availableLayouts.map((layout) => (
<SelectItem key={layout.id} value={layout.id}>
<div className="flex w-full flex-col gap-1 text-left">
<span className="text-sm font-medium text-foreground">{layout.name || t('invites.customizer.layoutFallback', 'Layout')}</span>
{layout.subtitle ? <span className="text-xs text-muted-foreground">{layout.subtitle}</span> : null}
{layout.formats?.length ? (
<span className="text-[10px] font-medium uppercase tracking-wide text-amber-700">
{layout.formats.map((format) => String(format).toUpperCase()).join(' · ')}
</span>
) : null}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{activeLayout ? (
<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">
<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>
) : null}
</section>
<section className="space-y-4 rounded-2xl border border-border bg-[var(--tenant-surface)] p-5 shadow-sm transition-colors">
<header className="space-y-1">
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{t('invites.customizer.elements.title', 'Elemente & Positionierung')}
</h3>
<p className="text-xs text-muted-foreground">
{t('invites.customizer.elements.hint', 'Wähle ein Element aus, um es zu verschieben, anzupassen oder zu entfernen.')}
</p>
</header>
<div className="space-y-2">
{sortedElements.map((element) => {
const Icon = elementIconFor(element);
const isSelected = element.id === activeElementId;
const removable = !nonRemovableIds.has(element.id);
return (
<div
key={element.id}
className={cn(
'rounded-xl border px-3 py-2 transition-colors',
isSelected
? 'border-primary bg-primary/10 text-primary'
: 'border-[var(--tenant-border-strong)] bg-[var(--tenant-layer)] text-foreground'
)}
>
<div className="flex items-center justify-between gap-3">
<button
type="button"
onClick={() => setActiveElementId(element.id)}
className="flex flex-1 items-center gap-3 text-left"
>
<Icon className={cn('h-4 w-4', isSelected ? 'text-primary' : 'text-muted-foreground')} />
<span className="text-sm font-medium">{elementLabelFor(element)}</span>
</button>
{removable ? (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeElement(element.id)}
aria-label={t('invites.customizer.elements.remove', 'Element entfernen')}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
) : null}
</div>
{isSelected ? renderElementDetail(element) : null}
</div>
);
})}
</div>
{additionOptions.length ? (
<div className="flex flex-wrap gap-2 pt-1">
{additionOptions.map((option) => {
const Icon = option.icon;
return (
<Button
key={option.key}
type="button"
variant="outline"
size="sm"
onClick={() => addElementFromPreset(option.type, option.unique ? option.key : undefined)}
className="flex items-center gap-2"
>
<Icon className="h-4 w-4" />
<span>{option.label}</span>
</Button>
);
})}
</div>
) : null}
<div className="rounded-xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-layer)] p-4">
<p className="text-xs text-muted-foreground">
{t('invites.customizer.elements.listHint', 'Wähle ein Element aus, um Einstellungen direkt unter dem Eintrag anzuzeigen.')}
</p>
</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="flex flex-col gap-4 rounded-2xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface)] p-5 shadow-sm transition-colors">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm font-medium text-muted-foreground">
{t('invites.customizer.controls.zoom', 'Zoom')}
</span>
<input
type="range"
min={ZOOM_MIN}
max={ZOOM_MAX}
step={ZOOM_STEP}
value={effectiveScale}
onChange={(event) => {
manualZoomRef.current = true;
setZoomScale(clampZoom(Number(event.target.value)));
}}
className="h-1 w-36 overflow-hidden rounded-full"
disabled={false}
aria-label={t('invites.customizer.controls.zoom', 'Zoom')}
/>
<span className="tabular-nums text-sm text-muted-foreground">{zoomPercent}%</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
manualZoomRef.current = false;
const fitValue = clampZoom(fitScaleRef.current);
setZoomScale(fitValue);
}}
disabled={Math.abs(effectiveScale - clampZoom(fitScaleRef.current)) < 0.001}
>
{t('invites.customizer.actions.zoomFit', 'Auf Bildschirm')}
</Button>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleUndo}
disabled={!canUndo}
>
<Undo2 className="mr-1 h-4 w-4" />
{t('invites.customizer.actions.undo', 'Rückgängig')}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRedo}
disabled={!canRedo}
>
<Redo2 className="mr-1 h-4 w-4" />
{t('invites.customizer.actions.redo', 'Wiederholen')}
</Button>
</div>
</div>
<div className="flex justify-center">
<div
ref={designerViewportRef}
className="max-h-[75vh] w-full overflow-auto rounded-3xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-layer)] p-4"
>
<div ref={canvasContainerRef} className="relative flex justify-center">
<DesignerCanvas
elements={canvasElements}
selectedId={activeElementId}
onSelect={setActiveElementId}
onChange={updateElement}
background={form.background_color ?? activeLayout.preview?.background ?? '#FFFFFF'}
gradient={form.background_gradient ?? activeLayout.preview?.background_gradient ?? null}
accent={form.accent_color ?? activeLayout.preview?.accent ?? '#6366F1'}
text={form.text_color ?? activeLayout.preview?.text ?? '#111827'}
secondary={form.secondary_color ?? activeLayout.preview?.secondary ?? '#1F2937'}
badge={form.badge_color ?? form.accent_color ?? '#2563EB'}
qrCodeDataUrl={qrCodeDataUrl}
logoDataUrl={form.logo_data_url ?? form.logo_url ?? null}
scale={effectiveScale}
layoutKey={`designer:${invite?.id ?? 'unknown'}:${activeLayout?.id ?? 'default'}`}
/>
</div>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void handlePrint()}
disabled={printBusy || Boolean(downloadBusy)}
className="flex w-full items-center justify-center gap-2 sm:w-auto"
>
{printBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Printer className="h-4 w-4" />}
{t('invites.customizer.actions.print', 'Drucken')}
</Button>
<div className="flex flex-wrap gap-2">
{(activeLayout.formats ?? []).map((format) => {
const key = String(format ?? '').toLowerCase();
const isBusy = downloadBusy === key;
return (
<Button
key={`${activeLayout.id}-${key}`}
type="button"
variant="outline"
size="sm"
disabled={printBusy || (downloadBusy !== null && !isBusy)}
onClick={() => void handleDownload(key)}
className="flex items-center gap-2"
>
<Download className="h-4 w-4" />
{isBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : key.toUpperCase()}
</Button>
);
})}
</div>
</div>
</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>
);
}