ü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:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user