überarbeitet: 300 neue tasks von gemini erzeugen lassen. dazu event types "Konfirmation" und "Schulabschluss" ergänzt. alles in Kollektionen gepackt und die seeder angepasst.

Des weiteren: neue Blogartikel und howto-Artikel von ChatGPT.
Das QR-Code-Canvas funktioniert nun noch besser. die Layouts sehen besser aus.
Der PaketSeeder enthält nun die Paddle Sandbox ProductIDs
This commit is contained in:
Codex Agent
2025-11-02 21:52:38 +01:00
parent 792b5dfe8b
commit 073b51e2d5
33 changed files with 3013 additions and 961 deletions

View File

@@ -169,6 +169,7 @@ export default function EventInvitesPage(): React.ReactElement {
const [copiedInviteId, setCopiedInviteId] = React.useState<number | null>(null);
const [customizerSaving, setCustomizerSaving] = React.useState(false);
const [customizerResetting, setCustomizerResetting] = React.useState(false);
const [customizerDraft, setCustomizerDraft] = React.useState<QrLayoutCustomization | null>(null);
const [searchParams, setSearchParams] = useSearchParams();
const tabParam = searchParams.get('tab');
const initialTab = tabParam === 'export' || tabParam === 'links' ? (tabParam as TabKey) : 'layout';
@@ -277,6 +278,10 @@ export default function EventInvitesPage(): React.ReactElement {
});
}, [state.invites]);
React.useEffect(() => {
setCustomizerDraft(null);
}, [selectedInviteId]);
const currentCustomization = React.useMemo(() => {
if (!selectedInvite) {
return null;
@@ -286,12 +291,14 @@ export default function EventInvitesPage(): React.ReactElement {
return raw && typeof raw === 'object' ? (raw as QrLayoutCustomization) : null;
}, [selectedInvite]);
const effectiveCustomization = customizerDraft ?? currentCustomization;
const exportLayout = React.useMemo(() => {
if (!selectedInvite || selectedInvite.layouts.length === 0) {
return null;
}
const targetId = currentCustomization?.layout_id;
const targetId = effectiveCustomization?.layout_id;
if (targetId) {
const match = selectedInvite.layouts.find((layout) => layout.id === targetId);
if (match) {
@@ -300,14 +307,14 @@ export default function EventInvitesPage(): React.ReactElement {
}
return selectedInvite.layouts[0];
}, [selectedInvite, currentCustomization?.layout_id]);
}, [selectedInvite, effectiveCustomization?.layout_id]);
const exportPreview = React.useMemo(() => {
if (!exportLayout || !selectedInvite) {
return null;
}
const customization = currentCustomization ?? null;
const customization = effectiveCustomization ?? null;
const layoutPreview = exportLayout.preview ?? {};
const backgroundColor = normalizeHexColor(customization?.background_color ?? (layoutPreview.background as string | undefined)) ?? '#F8FAFC';
@@ -370,26 +377,26 @@ export default function EventInvitesPage(): React.ReactElement {
t('invites.export.tips.default3', 'Fotografiere den gedruckten QR-Code testweise, um die Lesbarkeit zu prüfen.'),
],
};
}, [exportLayout, currentCustomization, selectedInvite, eventName, t]);
}, [exportLayout, effectiveCustomization, selectedInvite, eventName, t]);
const exportElements = React.useMemo<LayoutElement[]>(() => {
if (!exportLayout) {
return [];
}
if (currentCustomization?.mode === 'advanced' && Array.isArray(currentCustomization.elements) && currentCustomization.elements.length) {
return normalizeElements(payloadToElements(currentCustomization.elements));
if (effectiveCustomization?.mode === 'advanced' && Array.isArray(effectiveCustomization.elements) && effectiveCustomization.elements.length) {
return normalizeElements(payloadToElements(effectiveCustomization.elements));
}
const baseForm: QrLayoutCustomization = {
...currentCustomization,
...effectiveCustomization,
layout_id: exportLayout.id,
link_label: currentCustomization?.link_label ?? selectedInvite?.url ?? '',
badge_label: currentCustomization?.badge_label ?? exportLayout.badge_label ?? undefined,
instructions: ensureInstructionList(currentCustomization?.instructions, exportLayout.instructions ?? []),
instructions_heading: currentCustomization?.instructions_heading ?? exportLayout.instructions_heading ?? undefined,
logo_data_url: currentCustomization?.logo_data_url ?? undefined,
logo_url: currentCustomization?.logo_url ?? undefined,
link_label: effectiveCustomization?.link_label ?? selectedInvite?.url ?? '',
badge_label: effectiveCustomization?.badge_label ?? exportLayout.badge_label ?? undefined,
instructions: ensureInstructionList(effectiveCustomization?.instructions, exportLayout.instructions ?? []),
instructions_heading: effectiveCustomization?.instructions_heading ?? exportLayout.instructions_heading ?? undefined,
logo_data_url: effectiveCustomization?.logo_data_url ?? undefined,
logo_url: effectiveCustomization?.logo_url ?? undefined,
};
return buildDefaultElements(
@@ -398,7 +405,7 @@ export default function EventInvitesPage(): React.ReactElement {
eventName,
exportLayout.preview?.qr_size_px ?? 480
);
}, [exportLayout, currentCustomization, selectedInvite?.url, eventName]);
}, [exportLayout, effectiveCustomization, selectedInvite?.url, eventName]);
React.useEffect(() => {
if (activeTab !== 'export') {
@@ -427,12 +434,23 @@ export default function EventInvitesPage(): React.ReactElement {
[selectedInvite?.id, exportLayout?.id, exportPreview?.mode]
);
const exportLogo = currentCustomization?.logo_data_url ?? currentCustomization?.logo_url ?? null;
const exportLogo = effectiveCustomization?.logo_data_url ?? effectiveCustomization?.logo_url ?? null;
const exportQr = selectedInvite?.qr_code_data_url ?? null;
const handlePreviewSelect = React.useCallback((_id: string | null) => undefined, []);
const handlePreviewChange = React.useCallback((_id: string, _patch: Partial<LayoutElement>) => undefined, []);
const handleCustomizerDraftChange = React.useCallback((draft: QrLayoutCustomization | null) => {
setCustomizerDraft((previous) => {
const prevSignature = previous ? JSON.stringify(previous) : null;
const nextSignature = draft ? JSON.stringify(draft) : null;
if (prevSignature === nextSignature) {
return previous;
}
return draft;
});
}, []);
const inviteCountSummary = React.useMemo(() => {
const active = state.invites.filter((invite) => invite.is_active && !invite.revoked_at).length;
const total = state.invites.length;
@@ -523,6 +541,7 @@ export default function EventInvitesPage(): React.ReactElement {
...prev,
invites: prev.invites.map((invite) => (invite.id === updated.id ? updated : invite)),
}));
setCustomizerDraft(null);
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({ ...prev, error: 'Anpassung konnte nicht gespeichert werden.' }));
@@ -547,6 +566,7 @@ export default function EventInvitesPage(): React.ReactElement {
...prev,
invites: prev.invites.map((invite) => (invite.id === updated.id ? updated : invite)),
}));
setCustomizerDraft(null);
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({ ...prev, error: 'Anpassungen konnten nicht zurückgesetzt werden.' }));
@@ -794,6 +814,8 @@ export default function EventInvitesPage(): React.ReactElement {
onSave={handleSaveCustomization}
onReset={handleResetCustomization}
initialCustomization={currentCustomization}
draftCustomization={customizerDraft}
onDraftChange={handleCustomizerDraftChange}
/>
)}
</section>

View File

@@ -20,7 +20,6 @@ import {
UploadCloud,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -44,7 +43,6 @@ import {
buildDefaultElements,
clamp,
clampElement,
elementsToPayload,
normalizeElements,
payloadToElements,
} from './invite-layout/schema';
@@ -59,6 +57,15 @@ import {
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;
@@ -180,6 +187,8 @@ type InviteLayoutCustomizerPanelProps = {
onSave: (customization: QrLayoutCustomization) => Promise<void>;
onReset: () => Promise<void>;
initialCustomization: QrLayoutCustomization | null;
draftCustomization?: QrLayoutCustomization | null;
onDraftChange?: (draft: QrLayoutCustomization | null) => void;
};
const MAX_INSTRUCTIONS = 5;
@@ -195,6 +204,8 @@ export function InviteLayoutCustomizerPanel({
onSave,
onReset,
initialCustomization,
draftCustomization,
onDraftChange,
}: InviteLayoutCustomizerPanelProps): React.JSX.Element {
const { t } = useTranslation('management');
@@ -211,7 +222,9 @@ export function InviteLayoutCustomizerPanel({
const [availableLayouts, setAvailableLayouts] = React.useState<EventQrInviteLayout[]>(invite?.layouts ?? []);
const [layoutsLoading, setLayoutsLoading] = React.useState(false);
const [layoutsError, setLayoutsError] = React.useState<string | null>(null);
const [selectedLayoutId, setSelectedLayoutId] = React.useState<string | undefined>(initialCustomization?.layout_id ?? invite?.layouts?.[0]?.id);
const [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);
@@ -220,6 +233,7 @@ export function InviteLayoutCustomizerPanel({
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);
@@ -234,7 +248,18 @@ export function InviteLayoutCustomizerPanel({
const [canRedo, setCanRedo] = React.useState(false);
const designerViewportRef = React.useRef<HTMLDivElement | null>(null);
const canvasContainerRef = React.useRef<HTMLDivElement | null>(null);
const isAdvanced = true;
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),
@@ -269,7 +294,8 @@ export function InviteLayoutCustomizerPanel({
let baseScale = Number.isFinite(nextRaw) && nextRaw > 0 ? nextRaw : 1;
const minScale = 0.3;
baseScale = Math.max(baseScale, minScale);
const clamped = clampZoom(baseScale);
const limitedScale = Math.min(baseScale, 1);
const clamped = clampZoom(limitedScale);
fitScaleRef.current = clamped;
setFitScale((prev) => (Math.abs(prev - clamped) < 0.001 ? prev : clamped));
@@ -396,6 +422,18 @@ export function InviteLayoutCustomizerPanel({
[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;
@@ -429,9 +467,6 @@ export function InviteLayoutCustomizerPanel({
React.useEffect(() => {
formStateRef.current = form;
}, [form]);
const prevFormRef = React.useRef(form);
const initializedLayoutsRef = React.useRef<Record<string, boolean>>({});
const prevInviteRef = React.useRef<number | string | null>(null);
const activeLayout = React.useMemo(() => {
if (!availableLayouts.length) {
return null;
@@ -458,15 +493,15 @@ export function InviteLayoutCustomizerPanel({
return qrElement.width;
}
if (initialCustomization?.mode === 'advanced' && Array.isArray(initialCustomization.elements)) {
const qrElement = initialCustomization.elements.find((element) => element?.type === 'qr');
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, initialCustomization?.mode, initialCustomization?.elements, activeLayout?.preview?.qr_size_px]);
}, [elements, activeCustomization?.mode, activeCustomization?.elements, activeLayout?.preview?.qr_size_px]);
const effectiveScale = React.useMemo(() => {
if (previewMode === 'full') {
@@ -493,26 +528,6 @@ export function InviteLayoutCustomizerPanel({
[commitElements]
);
const handleResetAdvanced = React.useCallback(() => {
if (!activeLayout) {
const cleared: LayoutElement[] = [];
commitElements(() => cleared, { silent: true });
resetHistory(cleared);
setActiveElementId(null);
return;
}
const defaults = buildDefaultElements(activeLayout, formStateRef.current, eventName, activeLayoutQrSize);
if (elements.length && elementsAreEqual(elements, defaults)) {
initializedLayoutsRef.current[activeLayout.id] = true;
return;
}
commitElements(() => defaults, { silent: true });
resetHistory(defaults);
setActiveElementId(null);
initializedLayoutsRef.current[activeLayout.id] = true;
}, [activeLayout, eventName, activeLayoutQrSize, commitElements, elements, elementsAreEqual, resetHistory]);
React.useEffect(() => {
if (!invite) {
setAvailableLayouts([]);
@@ -527,12 +542,12 @@ export function InviteLayoutCustomizerPanel({
if (current && layouts.some((layout) => layout.id === current)) {
return current;
}
if (initialCustomization?.layout_id && layouts.some((layout) => layout.id === initialCustomization.layout_id)) {
return initialCustomization.layout_id;
if (activeCustomization?.layout_id && layouts.some((layout) => layout.id === activeCustomization.layout_id)) {
return activeCustomization.layout_id;
}
return layouts[0]?.id;
});
}, [invite?.id, initialCustomization?.layout_id]);
}, [invite, activeCustomization?.layout_id]);
React.useEffect(() => {
let cancelled = false;
@@ -571,8 +586,8 @@ export function InviteLayoutCustomizerPanel({
if (current && items.some((layout) => layout.id === current)) {
return current;
}
if (initialCustomization?.layout_id && items.some((layout) => layout.id === initialCustomization.layout_id)) {
return initialCustomization.layout_id;
if (activeCustomization?.layout_id && items.some((layout) => layout.id === activeCustomization.layout_id)) {
return activeCustomization.layout_id;
}
return items[0]?.id;
});
@@ -600,7 +615,7 @@ export function InviteLayoutCustomizerPanel({
return () => {
cancelled = true;
};
}, [invite, availableLayouts.length, initialCustomization?.layout_id, t]);
}, [invite, availableLayouts.length, activeCustomization?.layout_id, t]);
React.useEffect(() => {
if (!availableLayouts.length) {
@@ -611,26 +626,55 @@ export function InviteLayoutCustomizerPanel({
if (current && availableLayouts.some((layout) => layout.id === current)) {
return current;
}
if (initialCustomization?.layout_id && availableLayouts.some((layout) => layout.id === initialCustomization.layout_id)) {
return initialCustomization.layout_id;
if (activeCustomization?.layout_id && availableLayouts.some((layout) => layout.id === activeCustomization.layout_id)) {
return activeCustomization.layout_id;
}
return availableLayouts[0].id;
});
}, [availableLayouts, initialCustomization?.layout_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;
}
const reuseCustomization = initialCustomization?.layout_id === activeLayout.id;
if (
draftCustomization &&
incomingSignature &&
incomingSignature === draftSignatureRef.current &&
appliedLayoutRef.current === layoutId &&
appliedInviteRef.current === inviteKey
) {
appliedSignatureRef.current = incomingSignature;
appliedLayoutRef.current = layoutId;
appliedInviteRef.current = inviteKey;
return;
}
const baseInstructions = reuseCustomization && Array.isArray(initialCustomization?.instructions) && initialCustomization.instructions?.length
? [...(initialCustomization.instructions as string[])]
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]);
@@ -639,50 +683,69 @@ export function InviteLayoutCustomizerPanel({
const newForm: QrLayoutCustomization = {
layout_id: activeLayout.id,
headline: reuseCustomization ? initialCustomization?.headline ?? eventName : eventName,
subtitle: reuseCustomization ? initialCustomization?.subtitle ?? activeLayout.subtitle ?? '' : activeLayout.subtitle ?? '',
description: reuseCustomization ? initialCustomization?.description ?? activeLayout.description ?? '' : activeLayout.description ?? '',
badge_label: reuseCustomization ? initialCustomization?.badge_label ?? t('tasks.customizer.defaults.badgeLabel') : (activeLayout.badge_label ?? t('tasks.customizer.defaults.badgeLabel')),
instructions_heading: reuseCustomization ? initialCustomization?.instructions_heading ?? t('tasks.customizer.defaults.instructionsHeading') : t('tasks.customizer.defaults.instructionsHeading'),
link_heading: reuseCustomization ? initialCustomization?.link_heading ?? t('tasks.customizer.defaults.linkHeading') : t('tasks.customizer.defaults.linkHeading'),
link_label: reuseCustomization ? initialCustomization?.link_label ?? inviteUrl : inviteUrl,
cta_label: reuseCustomization ? initialCustomization?.cta_label ?? t('tasks.customizer.defaults.ctaLabel') : (activeLayout.cta_label ?? t('tasks.customizer.defaults.ctaLabel')),
accent_color: sanitizeColor((reuseCustomization ? initialCustomization?.accent_color : activeLayout.preview?.accent) ?? null) ?? '#6366F1',
text_color: sanitizeColor((reuseCustomization ? initialCustomization?.text_color : activeLayout.preview?.text) ?? null) ?? '#111827',
background_color: sanitizeColor((reuseCustomization ? initialCustomization?.background_color : activeLayout.preview?.background) ?? null) ?? '#FFFFFF',
secondary_color: reuseCustomization ? initialCustomization?.secondary_color ?? '#1F2937' : '#1F2937',
badge_color: reuseCustomization ? initialCustomization?.badge_color ?? '#2563EB' : '#2563EB',
background_gradient: reuseCustomization ? initialCustomization?.background_gradient ?? activeLayout.preview?.background_gradient ?? null : activeLayout.preview?.background_gradient ?? null,
logo_data_url: reuseCustomization ? initialCustomization?.logo_data_url ?? initialCustomization?.logo_url ?? null : null,
mode: initialCustomization?.layout_id === activeLayout.id ? initialCustomization?.mode : 'standard',
elements: initialCustomization?.layout_id === activeLayout.id ? initialCustomization?.elements : undefined,
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, activeLayoutQrSize);
const defaults = buildDefaultElements(activeLayout, newForm, eventName, fallbackQrSize);
commitElements(() => defaults, { silent: true });
resetHistory(defaults);
}
setActiveElementId(null);
}, [
appliedSignatureRef.current = incomingSignature ?? null;
appliedLayoutRef.current = layoutId;
appliedInviteRef.current = inviteKey;
selectElement(null);
}, [
activeLayout,
invite,
invite?.id,
initialCustomization,
activeCustomization,
draftCustomization,
customizationSignature,
defaultInstructions,
eventName,
inviteUrl,
t,
activeLayoutQrSize,
commitElements,
resetHistory,
selectElement,
]);
React.useEffect(() => {
@@ -717,79 +780,73 @@ export function InviteLayoutCustomizerPanel({
const canvasElements = React.useMemo(() => {
if (!activeLayout) {
console.debug('[Invites][CanvasElements] No active layout', {
inviteId: invite?.id ?? null,
availableLayouts: availableLayouts.length,
});
return [] as LayoutElement[];
}
console.debug('[Invites][CanvasElements] Layout preview', {
layoutId: activeLayout.id,
preview: activeLayout.preview,
});
const base = elements.length
? elements
: buildDefaultElements(activeLayout, form, eventName, activeLayoutQrSize);
console.debug('[Invites][CanvasElements] Base elements', {
layoutId: activeLayout.id,
existing: elements.length,
generated: base.length,
hasCustomization: Boolean(initialCustomization?.elements?.length),
});
return base.map((element) => ({
...element,
initial: element.initial ?? nonRemovableIds.has(element.id),
}));
}, [activeLayout, elements, form, eventName, activeLayoutQrSize, nonRemovableIds]);
// Erweiterter Log für Duplikate-Check
const idCounts = base.reduce((acc, e) => {
acc[e.id] = (acc[e.id] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const duplicates = Object.entries(idCounts).filter(([_, count]) => count > 1);
if (duplicates.length > 0) {
console.warn('[Invites][CanvasElements] Duplicates detected in base', { duplicates, baseIds: base.map(e => ({ id: e.id, type: e.type, y: e.y })) });
React.useEffect(() => {
if (!onDraftChange) {
return;
}
console.debug('[Invites][CanvasElements] Base IDs overview', base.map(e => ({ id: e.id, type: e.type, y: e.y })));
const boundContent: Record<string, string | null> = {
headline: form.headline ?? eventName,
subtitle: form.subtitle ?? '',
description: form.description ?? activeLayout.description ?? '',
link: form.link_label && form.link_label.trim().length > 0 ? form.link_label : inviteUrl,
badge: form.badge_label ?? t('tasks.customizer.defaults.badgeLabel'),
cta: form.cta_label ?? t('tasks.customizer.defaults.ctaLabel'),
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,
};
return base.map((element) => {
let content = element.content ?? null;
const advancedElements = elements.length
? elements
: buildDefaultElements(activeLayout, form, eventName, activeLayoutQrSize);
if (Object.prototype.hasOwnProperty.call(boundContent, element.id)) {
content = boundContent[element.id] ?? '';
}
const draftPayload: QrLayoutCustomization = {
...form,
layout_id: activeLayout.id,
instructions: effectiveInstructions,
mode: 'advanced',
elements: serializeElements(advancedElements, serializationContext),
};
if (element.type === 'text' && (!content || content.trim().length === 0)) {
content = t('invites.customizer.defaults.textBlock', 'Neuer Textblock hier kannst du eigene Hinweise ergänzen.');
}
return {
...element,
content,
initial: element.initial ?? nonRemovableIds.has(element.id),
};
});
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,
form.headline,
form.subtitle,
form.description,
form.link_label,
form.badge_label,
form.cta_label,
effectiveInstructions,
eventName,
inviteUrl,
t,
activeLayoutQrSize,
t,
]);
const elementLabelFor = React.useCallback(
@@ -936,9 +993,9 @@ export function InviteLayoutCustomizerPanel({
next.push({ ...preset, initial: false });
return next;
});
setActiveElementId(preset.id);
selectElement(preset.id);
},
[createPresetElement, commitElements]
[createPresetElement, commitElements, selectElement]
);
const removeElement = React.useCallback(
@@ -947,22 +1004,19 @@ export function InviteLayoutCustomizerPanel({
return;
}
commitElements((current) => current.filter((item) => item.id !== id));
if (activeElementId === id) {
setActiveElementId(null);
if (activeElementId === id || inspectorElementId === id) {
selectElement(null);
}
},
[activeElementId, nonRemovableIds, commitElements]
[activeElementId, inspectorElementId, nonRemovableIds, commitElements, selectElement]
);
const updateElementContent = React.useCallback((id: string, value: string) => {
commitElements((current) => current.map((item) => (item.id === id ? { ...item, content: value } : item)));
}, [commitElements]);
const updateElementAlign = React.useCallback(
(id: string, align: 'left' | 'center' | 'right') => {
selectElement(id, { preserveInspector: true });
updateElement(id, { align });
},
[updateElement]
[selectElement, updateElement]
);
const elementTypeOrder: Record<LayoutElementType, number> = React.useMemo(
@@ -1073,63 +1127,43 @@ export function InviteLayoutCustomizerPanel({
[t]
);
React.useEffect(() => {
const previous = prevFormRef.current;
prevFormRef.current = form;
const entries = Object.entries(elementBindings) as Array<[
keyof typeof elementBindings,
{ field: keyof QrLayoutCustomization; multiline: boolean }
]>;
entries.forEach(([elementId, binding]) => {
if (!elements.some((element) => element.id === elementId)) {
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 previousValue = previous?.[binding.field] ?? null;
const nextValue = form[binding.field] ?? null;
if (previousValue !== nextValue) {
updateElement(
elementId,
{
content: (typeof nextValue === 'string' ? nextValue : String(nextValue ?? '')) as string,
},
{ silent: true }
);
}
});
}, [form, elementBindings, elements, updateElement]);
const renderActionButtons = (mode: 'inline' | 'floating') => (
<>
<Button
type="button"
variant="outline"
onClick={() => void handleResetClick()}
disabled={resetting || saving}
className={cn('w-full sm:w-auto', mode === 'floating' ? 'sm:w-auto' : '')}
>
{resetting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RotateCcw className="mr-2 h-4 w-4" />}
{t('invites.customizer.actions.reset', 'Zurücksetzen')}
</Button>
<Button
type="button"
disabled={saving || resetting}
onClick={() => {
if (formRef.current) {
if (typeof formRef.current.requestSubmit === 'function') {
formRef.current.requestSubmit();
} else {
formRef.current.submit();
}
}
}}
className={cn('w-full bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 text-white sm:w-auto', mode === 'floating' ? 'sm:w-auto' : '')}
>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
{t('invites.customizer.actions.save', 'Layout speichern')}
</Button>
</>
const [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];
@@ -1242,9 +1276,37 @@ export function InviteLayoutCustomizerPanel({
[elementBindings, form, t, updateElement, updateElementAlign, updateElementContent, updateForm]
);
function updateForm<T extends keyof QrLayoutCustomization>(key: T, value: QrLayoutCustomization[T]) {
setForm((prev) => ({ ...prev, [key]: value }));
}
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);
@@ -1443,6 +1505,8 @@ export function InviteLayoutCustomizerPanel({
);
}
const highlightedElementId = activeElementId ?? inspectorElementId;
return (
<div className="space-y-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
@@ -1539,7 +1603,8 @@ export function InviteLayoutCustomizerPanel({
<div className="space-y-2">
{sortedElements.map((element) => {
const Icon = elementIconFor(element);
const isSelected = element.id === activeElementId;
const isSelected = element.id === highlightedElementId;
const isInspectorVisible = element.id === inspectorElementId;
const removable = !nonRemovableIds.has(element.id);
return (
<div
@@ -1554,7 +1619,7 @@ export function InviteLayoutCustomizerPanel({
<div className="flex items-center justify-between gap-3">
<button
type="button"
onClick={() => setActiveElementId(element.id)}
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')} />
@@ -1573,7 +1638,7 @@ export function InviteLayoutCustomizerPanel({
</Button>
) : null}
</div>
{isSelected ? renderElementDetail(element) : null}
{isInspectorVisible ? renderElementDetail(element) : null}
</div>
);
})}
@@ -1864,8 +1929,8 @@ export function InviteLayoutCustomizerPanel({
<div ref={canvasContainerRef} className="relative flex justify-center aspect-[1240/1754] mx-auto max-w-full">
<DesignerCanvas
elements={canvasElements}
selectedId={activeElementId}
onSelect={setActiveElementId}
selectedId={highlightedElementId}
onSelect={selectElement}
onChange={updateElement}
background={form.background_color ?? activeLayout.preview?.background ?? '#FFFFFF'}
gradient={form.background_gradient ?? activeLayout.preview?.background_gradient ?? null}

View File

@@ -53,6 +53,11 @@ export function DesignerCanvas({
const pendingDisposeRef = React.useRef<number | null>(null);
const pendingTimeoutRef = React.useRef<number | null>(null);
const lastRenderSignatureRef = React.useRef<string | null>(null);
const requestedSelectionRef = React.useRef<string | null>(selectedId);
React.useEffect(() => {
requestedSelectionRef.current = selectedId;
}, [selectedId]);
const destroyCanvas = React.useCallback((canvas: fabric.Canvas | null) => {
if (!canvas) {
@@ -207,13 +212,19 @@ export function DesignerCanvas({
onSelect(null);
return;
}
requestedSelectionRef.current = active.elementId ?? null;
onSelect(active.elementId);
};
const handleSelectionCleared = () => {
const handleSelectionCleared = (event?: fabric.IEvent<MouseEvent>) => {
if (readOnly) {
return;
}
const triggeredByPointer = Boolean(event?.e);
if (!triggeredByPointer && requestedSelectionRef.current) {
return;
}
requestedSelectionRef.current = null;
onSelect(null);
};