1984 lines
74 KiB
TypeScript
1984 lines
74 KiB
TypeScript
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 { 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,
|
||
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 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 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);
|
||
|
||
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 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 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 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([]);
|
||
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));
|
||
commitElements(() => initialElements, { silent: true });
|
||
resetHistory(initialElements);
|
||
} else {
|
||
const defaults = buildDefaultElements(activeLayout, newForm, eventName, fallbackQrSize);
|
||
commitElements(() => defaults, { silent: true });
|
||
resetHistory(defaults);
|
||
}
|
||
|
||
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;
|
||
|
||
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="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 === 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>
|
||
</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 lg:hidden', 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={previewMode === 'full'}
|
||
aria-label={t('invites.customizer.controls.zoom', 'Zoom')}
|
||
/>
|
||
<span className="tabular-nums text-sm text-muted-foreground">{zoomPercent}%</span>
|
||
<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>
|
||
<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>
|
||
</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>
|
||
);
|
||
}
|