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

2101 lines
78 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,
ChevronDown,
Download,
Heading,
Link as LinkIcon,
Loader2,
Megaphone,
Minus,
Plus,
Printer,
QrCode,
RotateCcw,
Save,
Trash2,
Type,
Undo2,
Redo2,
UploadCloud,
} from 'lucide-react';
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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
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,
normalizeElements,
payloadToElements,
} from './invite-layout/schema';
import { DesignerCanvas } from './invite-layout/DesignerCanvas';
import {
generatePdfBytes,
generatePngDataUrl,
openPdfInNewTab,
triggerDownloadFromBlob,
triggerDownloadFromDataUrl,
} from './invite-layout/export-utils';
import { buildDownloadFilename, normalizeEventDateSegment } from './invite-layout/fileNames';
export type { QrLayoutCustomization } from './invite-layout/schema';
const ELEMENT_BINDING_FIELD: Partial<Record<string, keyof QrLayoutCustomization>> = {
headline: 'headline',
subtitle: 'subtitle',
description: 'description',
badge: 'badge_label',
link: 'link_label',
cta: 'cta_label',
};
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;
eventDate: string | null;
saving: boolean;
resetting: boolean;
onSave: (customization: QrLayoutCustomization) => Promise<void>;
onReset: () => Promise<void>;
initialCustomization: QrLayoutCustomization | null;
draftCustomization?: QrLayoutCustomization | null;
onDraftChange?: (draft: QrLayoutCustomization | null) => void;
};
const MAX_INSTRUCTIONS = 5;
const ZOOM_MIN = 0.1;
const ZOOM_MAX = 2;
const ZOOM_STEP = 0.05;
export function InviteLayoutCustomizerPanel({
invite,
eventName,
eventDate,
saving,
resetting,
onSave,
onReset,
initialCustomization,
draftCustomization,
onDraftChange,
}: InviteLayoutCustomizerPanelProps): React.JSX.Element {
const { t } = useTranslation('management');
const inviteUrl = invite?.url ?? '';
const qrCodeDataUrl = invite?.qr_code_data_url ?? null;
if (!qrCodeDataUrl) {
console.warn('QR DataURL is null - using fallback in canvas');
}
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>(
draftCustomization?.layout_id ?? 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 [inspectorElementId, setInspectorElementId] = React.useState<string | null>(null);
const [showFloatingActions, setShowFloatingActions] = React.useState(false);
const [zoomScale, setZoomScale] = React.useState(1);
const [fitScale, setFitScale] = React.useState(1);
const [previewMode, setPreviewMode] = React.useState<'fit' | 'full'>('fit');
const [isCompact, setIsCompact] = React.useState(false);
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 draftSignatureRef = React.useRef<string | null>(null);
const initialElementsRef = React.useRef<LayoutElement[]>([]);
const activeCustomization = React.useMemo(
() => draftCustomization ?? initialCustomization ?? null,
[draftCustomization, initialCustomization],
);
const customizationSignature = React.useMemo(
() => (activeCustomization ? JSON.stringify(activeCustomization) : null),
[activeCustomization],
);
const appliedSignatureRef = React.useRef<string | null>(null);
const appliedLayoutRef = React.useRef<string | null>(null);
const appliedInviteRef = React.useRef<number | string | null>(null);
React.useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
setIsCompact(false);
return;
}
const query = window.matchMedia('(max-width: 1023px)');
const update = (event?: MediaQueryListEvent) => {
if (typeof event?.matches === 'boolean') {
setIsCompact(event.matches);
return;
}
setIsCompact(query.matches);
};
update();
if (typeof query.addEventListener === 'function') {
const listener = (event: MediaQueryListEvent) => update(event);
query.addEventListener('change', listener);
return () => query.removeEventListener('change', listener);
}
const legacyListener = (event: MediaQueryListEvent) => update(event);
query.addListener(legacyListener);
return () => query.removeListener(legacyListener);
}, []);
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);
let baseScale = Number.isFinite(nextRaw) && nextRaw > 0 ? nextRaw : 1;
const minScale = 0.3;
baseScale = Math.max(baseScale, minScale);
const limitedScale = Math.min(baseScale, 1);
const clamped = clampZoom(limitedScale);
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 source = prev.length ? prev : initialElementsRef.current;
const base = cloneElements(source.length ? source : []);
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 selectElement = React.useCallback((id: string | null, options: { preserveInspector?: boolean } = {}) => {
setActiveElementId(id);
if (id) {
setInspectorElementId(id);
return;
}
if (!options.preserveInspector) {
setInspectorElementId(null);
}
}, []);
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 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 (activeCustomization?.mode === 'advanced' && Array.isArray(activeCustomization.elements)) {
const qrElement = activeCustomization.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, activeCustomization?.mode, activeCustomization?.elements, activeLayout?.preview?.qr_size_px]);
const effectiveScale = React.useMemo(() => {
if (previewMode === 'full') {
return 1.0;
}
return clampZoom(Number.isFinite(zoomScale) && zoomScale > 0 ? zoomScale : fitScale);
}, [clampZoom, zoomScale, fitScale, previewMode]);
const zoomPercent = Math.round(effectiveScale * 100);
const handleZoomStep = React.useCallback(
(direction: 1 | -1) => {
manualZoomRef.current = true;
setZoomScale((current) => clampZoom(current + direction * ZOOM_STEP));
},
[clampZoom]
);
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]
);
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 (activeCustomization?.layout_id && layouts.some((layout) => layout.id === activeCustomization.layout_id)) {
return activeCustomization.layout_id;
}
return layouts[0]?.id;
});
}, [invite, activeCustomization?.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 (activeCustomization?.layout_id && items.some((layout) => layout.id === activeCustomization.layout_id)) {
return activeCustomization.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, activeCustomization?.layout_id, t]);
React.useEffect(() => {
if (!availableLayouts.length) {
return;
}
setSelectedLayoutId((current) => {
if (current && availableLayouts.some((layout) => layout.id === current)) {
return current;
}
if (activeCustomization?.layout_id && availableLayouts.some((layout) => layout.id === activeCustomization.layout_id)) {
return activeCustomization.layout_id;
}
return availableLayouts[0].id;
});
}, [availableLayouts, activeCustomization?.layout_id]);
React.useEffect(() => {
const inviteKey = invite?.id ?? null;
const layoutId = activeLayout?.id ?? null;
const incomingSignature = customizationSignature;
if (!invite || !activeLayout) {
setForm({});
setInstructions([]);
commitElements(() => [], { silent: true });
resetHistory([]);
initialElementsRef.current = [];
appliedSignatureRef.current = null;
appliedLayoutRef.current = layoutId;
appliedInviteRef.current = inviteKey;
return;
}
if (
draftCustomization &&
incomingSignature &&
incomingSignature === draftSignatureRef.current &&
appliedLayoutRef.current === layoutId &&
appliedInviteRef.current === inviteKey
) {
appliedSignatureRef.current = incomingSignature;
appliedLayoutRef.current = layoutId;
appliedInviteRef.current = inviteKey;
return;
}
const alreadyApplied =
appliedSignatureRef.current === incomingSignature &&
appliedLayoutRef.current === layoutId &&
appliedInviteRef.current === inviteKey;
if (alreadyApplied) {
return;
}
const reuseCustomization = activeCustomization?.layout_id === activeLayout.id;
const baseInstructions = reuseCustomization && Array.isArray(activeCustomization?.instructions) && activeCustomization.instructions?.length
? [...(activeCustomization.instructions as string[])]
: ((activeLayout.instructions && activeLayout.instructions.length)
? [...activeLayout.instructions]
: [...defaultInstructions]);
setInstructions(baseInstructions);
const newForm: QrLayoutCustomization = {
layout_id: activeLayout.id,
headline: reuseCustomization ? activeCustomization?.headline ?? eventName : eventName,
subtitle: reuseCustomization ? activeCustomization?.subtitle ?? activeLayout.subtitle ?? '' : activeLayout.subtitle ?? '',
description: reuseCustomization ? activeCustomization?.description ?? activeLayout.description ?? '' : activeLayout.description ?? '',
badge_label: reuseCustomization ? activeCustomization?.badge_label ?? t('tasks.customizer.defaults.badgeLabel') : (activeLayout.badge_label ?? t('tasks.customizer.defaults.badgeLabel')),
instructions_heading: reuseCustomization ? activeCustomization?.instructions_heading ?? t('tasks.customizer.defaults.instructionsHeading') : t('tasks.customizer.defaults.instructionsHeading'),
link_heading: reuseCustomization ? activeCustomization?.link_heading ?? t('tasks.customizer.defaults.linkHeading') : t('tasks.customizer.defaults.linkHeading'),
link_label: reuseCustomization ? activeCustomization?.link_label ?? inviteUrl : inviteUrl,
cta_label: reuseCustomization ? activeCustomization?.cta_label ?? t('tasks.customizer.defaults.ctaLabel') : (activeLayout.cta_label ?? t('tasks.customizer.defaults.ctaLabel')),
accent_color: sanitizeColor((reuseCustomization ? activeCustomization?.accent_color : activeLayout.preview?.accent) ?? null) ?? '#6366F1',
text_color: sanitizeColor((reuseCustomization ? activeCustomization?.text_color : activeLayout.preview?.text) ?? null) ?? '#111827',
background_color: sanitizeColor((reuseCustomization ? activeCustomization?.background_color : activeLayout.preview?.background) ?? null) ?? '#FFFFFF',
secondary_color: reuseCustomization ? activeCustomization?.secondary_color ?? '#1F2937' : '#1F2937',
badge_color: reuseCustomization ? activeCustomization?.badge_color ?? '#2563EB' : '#2563EB',
background_gradient: reuseCustomization ? activeCustomization?.background_gradient ?? activeLayout.preview?.background_gradient ?? null : activeLayout.preview?.background_gradient ?? null,
logo_data_url: reuseCustomization ? activeCustomization?.logo_data_url ?? activeCustomization?.logo_url ?? null : null,
mode: reuseCustomization ? activeCustomization?.mode : 'standard',
elements: reuseCustomization ? activeCustomization?.elements : undefined,
};
setForm(newForm);
setError(null);
const isCustomizedAdvanced = newForm.mode === 'advanced' && Array.isArray(newForm.elements) && newForm.elements.length > 0;
const fallbackQrSize = (() => {
if (Array.isArray(newForm.elements)) {
const qrElement = newForm.elements.find((element) => element?.type === 'qr');
if (qrElement && typeof qrElement.width === 'number' && qrElement.width > 0) {
return qrElement.width;
}
}
if (typeof activeLayout.preview?.qr_size_px === 'number' && activeLayout.preview.qr_size_px > 0) {
return activeLayout.preview.qr_size_px;
}
return 500;
})();
if (isCustomizedAdvanced) {
const initialElements = normalizeElements(payloadToElements(newForm.elements));
initialElementsRef.current = initialElements;
commitElements(() => initialElements, { silent: true });
resetHistory(initialElements);
} else {
const defaults = buildDefaultElements(activeLayout, newForm, eventName, fallbackQrSize);
const normalizedDefaults = normalizeElements(defaults);
initialElementsRef.current = normalizedDefaults;
commitElements(() => normalizedDefaults, { silent: true });
resetHistory(normalizedDefaults);
}
appliedSignatureRef.current = incomingSignature ?? null;
appliedLayoutRef.current = layoutId;
appliedInviteRef.current = inviteKey;
selectElement(null);
}, [
activeLayout,
invite,
invite?.id,
activeCustomization,
draftCustomization,
customizationSignature,
defaultInstructions,
eventName,
inviteUrl,
t,
commitElements,
resetHistory,
selectElement,
]);
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) {
return [] as LayoutElement[];
}
const base = elements.length
? elements
: buildDefaultElements(activeLayout, form, eventName, activeLayoutQrSize);
return base.map((element) => ({
...element,
initial: element.initial ?? nonRemovableIds.has(element.id),
}));
}, [activeLayout, elements, form, eventName, activeLayoutQrSize, nonRemovableIds]);
React.useEffect(() => {
if (!onDraftChange) {
return;
}
if (!invite || !activeLayout) {
if (draftSignatureRef.current !== null) {
draftSignatureRef.current = null;
onDraftChange(null);
}
return;
}
const serializationContext: LayoutSerializationContext = {
form,
eventName,
inviteUrl,
instructions: effectiveInstructions,
qrSize: activeLayoutQrSize,
badgeFallback: t('tasks.customizer.defaults.badgeLabel'),
logoUrl: form.logo_url ?? null,
};
const advancedElements = elements.length
? elements
: buildDefaultElements(activeLayout, form, eventName, activeLayoutQrSize);
const draftPayload: QrLayoutCustomization = {
...form,
layout_id: activeLayout.id,
instructions: effectiveInstructions,
mode: 'advanced',
elements: serializeElements(advancedElements, serializationContext),
};
const sanitizedDraft = sanitizePayload(draftPayload);
const signature = JSON.stringify(sanitizedDraft);
if (signature !== draftSignatureRef.current) {
draftSignatureRef.current = signature;
onDraftChange(sanitizedDraft);
}
}, [
onDraftChange,
invite,
invite?.id,
activeLayout,
activeLayout?.id,
form,
elements,
effectiveInstructions,
eventName,
inviteUrl,
activeLayoutQrSize,
t,
]);
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;
});
selectElement(preset.id);
},
[createPresetElement, commitElements, selectElement]
);
const removeElement = React.useCallback(
(id: string) => {
if (nonRemovableIds.has(id)) {
return;
}
commitElements((current) => current.filter((item) => item.id !== id));
if (activeElementId === id || inspectorElementId === id) {
selectElement(null);
}
},
[activeElementId, inspectorElementId, nonRemovableIds, commitElements, selectElement]
);
const updateElementAlign = React.useCallback(
(id: string, align: 'left' | 'center' | 'right') => {
selectElement(id, { preserveInspector: true });
updateElement(id, { align });
},
[selectElement, 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]
);
const updateForm = React.useCallback(
<T extends keyof QrLayoutCustomization>(key: T, value: QrLayoutCustomization[T]) => {
setForm((prev) => ({ ...prev, [key]: value }));
const bindingEntry = Object.entries(elementBindings).find(([, binding]) => binding.field === key);
if (!bindingEntry) {
return;
}
const [elementId] = bindingEntry;
selectElement(elementId, { preserveInspector: true });
commitElements(
(current) =>
current.map((el) =>
el.id === elementId ? { ...el, content: String(value ?? '') } : el
),
{ silent: true },
);
},
[commitElements, elementBindings, selectElement],
);
const updateElementContent = React.useCallback(
(id: string, value: string) => {
selectElement(id, { preserveInspector: true });
commitElements((current) => current.map((item) => (item.id === id ? { ...item, content: value } : item)));
const bindingField = ELEMENT_BINDING_FIELD[id];
if (bindingField) {
updateForm(bindingField, value);
}
},
[commitElements, selectElement, updateForm],
);
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]
);
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>
</>
);
function handleLayoutSelect(layout: EventQrInviteLayout) {
setSelectedLayoutId(layout.id);
}
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 eventDateSegment = normalizeEventDateSegment(eventDate);
const filename = buildDownloadFilename(
['Einladungslayout', eventName, activeLayout?.name ?? null, eventDateSegment],
normalizedFormat,
'einladungslayout',
);
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 ?? '#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, filename);
} else if (normalizedFormat === 'pdf') {
const pdfBytes = await generatePdfBytes(
exportOptions,
'a4',
'portrait',
);
triggerDownloadFromBlob(new Blob([pdfBytes.buffer as ArrayBuffer], { type: 'application/pdf' }), filename);
} 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 ?? '#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,
'a4',
'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.')}
/>
);
}
const highlightedElementId = activeElementId ?? inspectorElementId;
const renderResponsiveSection = (id: string, title: string, description: string, content: React.ReactNode) => {
const body = <div className="space-y-4">{content}</div>;
if (!isCompact) {
return (
<section key={id} 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">{title}</h3>
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
</header>
{body}
</section>
);
}
return (
<Collapsible key={id} defaultOpen className="rounded-2xl border border-border bg-[var(--tenant-surface)] p-3 shadow-sm transition-colors">
<CollapsibleTrigger type="button" className="flex w-full items-center justify-between gap-3 text-left">
<div className="space-y-1">
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
</div>
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="pt-4">{body}</CollapsibleContent>
</Collapsible>
);
};
return (
<div className="space-y-4">
<div className="hidden flex-wrap items-center justify-end gap-2 lg:flex">
{renderActionButtons('inline')}
</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="flex flex-col gap-6 xl:grid xl:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
<form ref={formRef} onSubmit={handleSubmit} className={cn('order-2 space-y-6', 'xl:order-1')}>
{renderResponsiveSection(
'layouts',
t('invites.customizer.sections.layouts', 'Layouts'),
t('invites.customizer.sections.layoutsHint', 'Wähle eine Vorlage als Basis aus. Du kannst jederzeit wechseln.'),
<>
<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}
</>
)}
{renderResponsiveSection(
'elements',
t('invites.customizer.elements.title', 'Elemente & Positionierung'),
t('invites.customizer.elements.hint', 'Wähle ein Element aus, um es zu verschieben, anzupassen oder zu entfernen.'),
<>
<div className="space-y-2">
{sortedElements.map((element) => {
const Icon = elementIconFor(element);
const isSelected = element.id === highlightedElementId;
const isInspectorVisible = element.id === inspectorElementId;
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={() => selectElement(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>
{isInspectorVisible ? 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>
</>
)}
{renderResponsiveSection(
'content',
t('invites.customizer.sections.content', 'Texte & Branding'),
t('invites.customizer.sections.contentHint', 'Passe Texte, Anleitungsschritte und Farben deiner Einladung an.'),
<Tabs defaultValue="text" className="space-y-4">
<TabsList className="grid w-full grid-cols-3 gap-1 text-xs sm:text-sm">
<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>
)}
<div className={cn('mt-6 flex flex-col gap-2 sm:flex-row sm:justify-end lg:hidden', showFloatingActions ? 'hidden' : 'flex')}>
{renderActionButtons('inline')}
</div>
<div ref={actionsSentinelRef} className="h-1 w-full" />
</form>
<div className={cn('order-1 flex flex-col gap-4 rounded-2xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface)] p-5 shadow-sm transition-colors', 'xl:order-2')}>
<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>
{!isCompact ? (
<>
<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={previewMode === 'full'}
aria-label={t('invites.customizer.controls.zoom', 'Zoom')}
/>
<span className="tabular-nums text-sm text-muted-foreground">{zoomPercent}%</span>
</>
) : (
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="icon"
onClick={() => handleZoomStep(-1)}
aria-label={t('invites.customizer.controls.zoomOut', 'Verkleinern')}
>
<Minus className="h-4 w-4" />
</Button>
<span className="w-12 text-center text-xs font-medium tabular-nums text-muted-foreground">{zoomPercent}%</span>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => handleZoomStep(1)}
aria-label={t('invites.customizer.controls.zoomIn', 'Vergrößern')}
>
<Plus className="h-4 w-4" />
</Button>
</div>
)}
<ToggleGroup type="single" value={previewMode} onValueChange={(val) => setPreviewMode(val as 'fit' | 'full')} className="flex">
<ToggleGroupItem value="fit" className="px-2 text-xs">
Fit
</ToggleGroupItem>
<ToggleGroupItem value="full" className="px-2 text-xs">
100%
</ToggleGroupItem>
</ToggleGroup>
{!isCompact ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
manualZoomRef.current = false;
const fitValue = clampZoom(fitScaleRef.current);
setZoomScale(fitValue);
setPreviewMode('fit');
}}
disabled={previewMode === 'full' || Math.abs(effectiveScale - clampZoom(fitScaleRef.current)) < 0.001}
>
{t('invites.customizer.actions.zoomFit', 'Auf Bildschirm')}
</Button>
) : (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
manualZoomRef.current = false;
const fitValue = clampZoom(fitScaleRef.current);
setZoomScale(fitValue);
setPreviewMode('fit');
}}
aria-label={t('invites.customizer.actions.zoomFit', 'Auf Bildschirm')}
>
<RotateCcw className="h-4 w-4" />
</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={cn(
"w-full rounded-3xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-layer)] p-4 overflow-auto",
previewMode === 'full' ? "max-h-none h-[90vh]" : "max-h-[75vh]"
)}
>
<div ref={canvasContainerRef} className="relative flex justify-center aspect-[1240/1754] mx-auto max-w-full">
<DesignerCanvas
elements={canvasElements}
selectedId={highlightedElementId}
onSelect={selectElement}
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 ?? '#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>
);
}