1318 lines
53 KiB
TypeScript
1318 lines
53 KiB
TypeScript
import React from 'react';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { Download, Loader2, Plus, Printer, RotateCcw, Save, UploadCloud } from 'lucide-react';
|
||
import { Rnd } from 'react-rnd';
|
||
|
||
import { Badge } from '@/components/ui/badge';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Label } from '@/components/ui/label';
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
|
||
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, isAuthError } from '../../auth/tokens';
|
||
|
||
export type QrLayoutCustomization = {
|
||
layout_id?: string;
|
||
headline?: string;
|
||
subtitle?: string;
|
||
description?: string;
|
||
badge_label?: string;
|
||
instructions_heading?: string;
|
||
instructions?: string[];
|
||
link_heading?: string;
|
||
link_label?: string;
|
||
cta_label?: string;
|
||
accent_color?: string;
|
||
text_color?: string;
|
||
background_color?: string;
|
||
secondary_color?: string;
|
||
badge_color?: string;
|
||
background_gradient?: { angle?: number; stops?: string[] } | null;
|
||
logo_data_url?: string | null;
|
||
logo_url?: string | null;
|
||
mode?: 'standard' | 'advanced';
|
||
elements?: AdvancedLayoutElementPayload[];
|
||
};
|
||
|
||
type AdvancedLayoutElement = {
|
||
id: string;
|
||
type: 'qr' | 'headline' | 'subtitle' | 'description' | 'link' | 'badge' | 'logo';
|
||
x: number;
|
||
y: number;
|
||
width: number;
|
||
height: number;
|
||
fontSize?: number;
|
||
align?: 'left' | 'center' | 'right';
|
||
content?: string | null;
|
||
};
|
||
|
||
type AdvancedLayoutElementPayload = {
|
||
id: string;
|
||
type: AdvancedLayoutElement['type'];
|
||
x: number;
|
||
y: number;
|
||
width: number;
|
||
height: number;
|
||
font_size?: number;
|
||
align?: 'left' | 'center' | 'right';
|
||
content?: string | null;
|
||
};
|
||
|
||
type AdvancedSerializationContext = {
|
||
form: QrLayoutCustomization;
|
||
eventName: string;
|
||
inviteUrl: string;
|
||
instructions: string[];
|
||
qrSize: number;
|
||
badgeFallback: string;
|
||
logoUrl: string | null;
|
||
};
|
||
|
||
const ADVANCED_CANVAS_WIDTH = 1080;
|
||
const ADVANCED_CANVAS_HEIGHT = 1520;
|
||
const ADVANCED_MIN_QR = 240;
|
||
const ADVANCED_MAX_QR = 720;
|
||
const ADVANCED_MIN_TEXT_WIDTH = 160;
|
||
const ADVANCED_MIN_TEXT_HEIGHT = 80;
|
||
|
||
function clamp(value: number, min: number, max: number): number {
|
||
if (Number.isNaN(value)) {
|
||
return min;
|
||
}
|
||
return Math.min(Math.max(value, min), max);
|
||
}
|
||
|
||
function buildDefaultAdvancedElements(
|
||
layout: EventQrInviteLayout | null,
|
||
form: QrLayoutCustomization,
|
||
eventName: string,
|
||
qrSize: number
|
||
): AdvancedLayoutElement[] {
|
||
const resolvedQrSize = Math.min(Math.max(qrSize, ADVANCED_MIN_QR), ADVANCED_MAX_QR);
|
||
return [
|
||
{
|
||
id: 'headline',
|
||
type: 'headline',
|
||
x: 80,
|
||
y: 120,
|
||
width: 520,
|
||
height: 160,
|
||
fontSize: 60,
|
||
content: form.headline ?? eventName,
|
||
},
|
||
{
|
||
id: 'description',
|
||
type: 'description',
|
||
x: 80,
|
||
y: 320,
|
||
width: 520,
|
||
height: 240,
|
||
fontSize: 28,
|
||
content: form.description ?? layout?.description ?? '',
|
||
},
|
||
{
|
||
id: 'qr',
|
||
type: 'qr',
|
||
x: 640,
|
||
y: 320,
|
||
width: resolvedQrSize,
|
||
height: resolvedQrSize,
|
||
},
|
||
{
|
||
id: 'link',
|
||
type: 'link',
|
||
x: 640,
|
||
y: 320 + resolvedQrSize + 40,
|
||
width: 360,
|
||
height: 140,
|
||
fontSize: 26,
|
||
content: form.link_label ?? '',
|
||
},
|
||
{
|
||
id: 'badge',
|
||
type: 'badge',
|
||
x: 80,
|
||
y: 60,
|
||
width: 260,
|
||
height: 70,
|
||
fontSize: 28,
|
||
content: form.badge_label ?? 'Digitale Gästebox',
|
||
},
|
||
];
|
||
}
|
||
|
||
function normalizeAdvancedElements(elements: AdvancedLayoutElement[]): AdvancedLayoutElement[] {
|
||
return (elements ?? []).map((element) => ({
|
||
...element,
|
||
x: Number(element.x ?? 0),
|
||
y: Number(element.y ?? 0),
|
||
width: Number(element.width ?? 0),
|
||
height: Number(element.height ?? 0),
|
||
fontSize: element.fontSize ? Number(element.fontSize) : undefined,
|
||
}));
|
||
}
|
||
|
||
function convertPayloadToElements(payload: AdvancedLayoutElementPayload[] | undefined | null): AdvancedLayoutElement[] {
|
||
if (!Array.isArray(payload)) {
|
||
return [];
|
||
}
|
||
|
||
return payload.map((item) => ({
|
||
id: item.id,
|
||
type: item.type,
|
||
x: Number(item.x ?? 0),
|
||
y: Number(item.y ?? 0),
|
||
width: Number(item.width ?? 0),
|
||
height: Number(item.height ?? 0),
|
||
fontSize: item.font_size ? Number(item.font_size) : undefined,
|
||
align: item.align ?? 'left',
|
||
content: item.content ?? null,
|
||
}));
|
||
}
|
||
|
||
function clampElementToCanvas(element: AdvancedLayoutElement): AdvancedLayoutElement {
|
||
const minWidth = element.type === 'qr' ? ADVANCED_MIN_QR : ADVANCED_MIN_TEXT_WIDTH;
|
||
const minHeight = element.type === 'qr' ? ADVANCED_MIN_QR : ADVANCED_MIN_TEXT_HEIGHT;
|
||
const width = clamp(element.width, minWidth, ADVANCED_CANVAS_WIDTH);
|
||
const height = clamp(element.height, minHeight, ADVANCED_CANVAS_HEIGHT);
|
||
const maxX = Math.max(ADVANCED_CANVAS_WIDTH - width, 0);
|
||
const maxY = Math.max(ADVANCED_CANVAS_HEIGHT - height, 0);
|
||
|
||
return {
|
||
...element,
|
||
width,
|
||
height,
|
||
x: clamp(element.x, 0, maxX),
|
||
y: clamp(element.y, 0, maxY),
|
||
};
|
||
}
|
||
|
||
function serializeAdvancedElements(
|
||
elements: AdvancedLayoutElement[],
|
||
context: AdvancedSerializationContext
|
||
): AdvancedLayoutElementPayload[] {
|
||
return normalizeAdvancedElements(elements).map((element) => {
|
||
const base = clampElementToCanvas(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.inviteUrl;
|
||
break;
|
||
case 'badge':
|
||
content = context.form.badge_label ?? context.badgeFallback;
|
||
break;
|
||
case 'logo':
|
||
content = context.logoUrl ?? context.form.logo_url ?? null;
|
||
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),
|
||
font_size: base.fontSize ? Math.round(base.fontSize) : undefined,
|
||
align: base.align,
|
||
content,
|
||
};
|
||
});
|
||
}
|
||
|
||
type InviteLayoutCustomizerPanelProps = {
|
||
invite: EventQrInvite | null;
|
||
eventName: string;
|
||
saving: boolean;
|
||
resetting: boolean;
|
||
onSave: (customization: QrLayoutCustomization) => Promise<void>;
|
||
onReset: () => Promise<void>;
|
||
initialCustomization: QrLayoutCustomization | null;
|
||
mode: 'standard' | 'advanced';
|
||
};
|
||
|
||
const MAX_INSTRUCTIONS = 5;
|
||
|
||
export function InviteLayoutCustomizerPanel({
|
||
invite,
|
||
eventName,
|
||
saving,
|
||
resetting,
|
||
onSave,
|
||
onReset,
|
||
initialCustomization,
|
||
mode,
|
||
}: InviteLayoutCustomizerPanelProps): React.JSX.Element {
|
||
const { t } = useTranslation('management');
|
||
|
||
const inviteUrl = invite?.url ?? '';
|
||
const qrCodeDataUrl = invite?.qr_code_data_url ?? null;
|
||
const defaultInstructions = React.useMemo(() => {
|
||
const value = t('tasks.customizer.defaults.instructions', { returnObjects: true }) as unknown;
|
||
return Array.isArray(value) ? (value as string[]) : ['QR-Code scannen', 'Profil anlegen', 'Fotos teilen'];
|
||
}, [t]);
|
||
|
||
const [availableLayouts, setAvailableLayouts] = React.useState<EventQrInviteLayout[]>(invite?.layouts ?? []);
|
||
const [layoutsLoading, setLayoutsLoading] = React.useState(false);
|
||
const [layoutsError, setLayoutsError] = React.useState<string | null>(null);
|
||
const [selectedLayoutId, setSelectedLayoutId] = React.useState<string | undefined>(initialCustomization?.layout_id ?? invite?.layouts?.[0]?.id);
|
||
const [form, setForm] = React.useState<QrLayoutCustomization>({});
|
||
const [instructions, setInstructions] = React.useState<string[]>([]);
|
||
const [error, setError] = React.useState<string | null>(null);
|
||
const formRef = React.useRef<HTMLFormElement | null>(null);
|
||
const [downloadBusy, setDownloadBusy] = React.useState<string | null>(null);
|
||
const [printBusy, setPrintBusy] = React.useState(false);
|
||
const [activeLayoutIndex, setActiveLayoutIndex] = React.useState(() => {
|
||
if (!availableLayouts.length) {
|
||
return 0;
|
||
}
|
||
const initialIndex = availableLayouts.findIndex((layout) => layout.id === selectedLayoutId);
|
||
return initialIndex >= 0 ? initialIndex : 0;
|
||
});
|
||
const isAdvanced = mode === 'advanced';
|
||
const [elements, setElements] = React.useState<AdvancedLayoutElement[]>([]);
|
||
const [activeElementId, setActiveElementId] = React.useState<string | null>(null);
|
||
const [canvasScale, setCanvasScale] = React.useState(0.52);
|
||
const [mobilePreviewOpen, setMobilePreviewOpen] = React.useState(false);
|
||
const [showFloatingActions, setShowFloatingActions] = React.useState(false);
|
||
const actionsSentinelRef = React.useRef<HTMLDivElement | null>(null);
|
||
|
||
const activeLayout = React.useMemo(() => {
|
||
if (!availableLayouts.length) {
|
||
return null;
|
||
}
|
||
|
||
if (selectedLayoutId) {
|
||
const match = availableLayouts.find((layout) => layout.id === selectedLayoutId);
|
||
if (match) {
|
||
return match;
|
||
}
|
||
}
|
||
|
||
return availableLayouts[activeLayoutIndex] ?? availableLayouts[0];
|
||
}, [availableLayouts, selectedLayoutId, activeLayoutIndex]);
|
||
|
||
const activeLayoutQrSize = React.useMemo(() => {
|
||
if (initialCustomization?.mode === 'advanced' && Array.isArray(initialCustomization.elements)) {
|
||
const qrElement = initialCustomization.elements.find((element) => element?.type === 'qr');
|
||
if (qrElement && typeof qrElement.width === 'number' && qrElement.width > 0) {
|
||
return qrElement.width;
|
||
}
|
||
}
|
||
|
||
return activeLayout?.preview?.qr_size_px ?? 500;
|
||
}, [initialCustomization?.mode, initialCustomization?.elements, activeLayout?.preview?.qr_size_px]);
|
||
|
||
const updateElement = React.useCallback(
|
||
(
|
||
id: string,
|
||
updater: Partial<AdvancedLayoutElement> | ((element: AdvancedLayoutElement) => Partial<AdvancedLayoutElement>)
|
||
) => {
|
||
setElements((current) =>
|
||
current.map((element) => {
|
||
if (element.id !== id) {
|
||
return element;
|
||
}
|
||
const patch = typeof updater === 'function' ? updater(element) : updater;
|
||
return clampElementToCanvas({ ...element, ...patch });
|
||
})
|
||
);
|
||
},
|
||
[]
|
||
);
|
||
|
||
const handleResetAdvanced = React.useCallback(() => {
|
||
if (!activeLayout) {
|
||
setElements([]);
|
||
return;
|
||
}
|
||
|
||
setElements(buildDefaultAdvancedElements(activeLayout, form, eventName, activeLayoutQrSize));
|
||
setActiveElementId(null);
|
||
}, [activeLayout, form, eventName, activeLayoutQrSize]);
|
||
|
||
React.useEffect(() => {
|
||
if (!invite) {
|
||
setAvailableLayouts([]);
|
||
setSelectedLayoutId(undefined);
|
||
return;
|
||
}
|
||
|
||
const layouts = invite.layouts ?? [];
|
||
setAvailableLayouts(layouts);
|
||
setLayoutsError(null);
|
||
setSelectedLayoutId((current) => {
|
||
if (current && layouts.some((layout) => layout.id === current)) {
|
||
return current;
|
||
}
|
||
if (initialCustomization?.layout_id && layouts.some((layout) => layout.id === initialCustomization.layout_id)) {
|
||
return initialCustomization.layout_id;
|
||
}
|
||
return layouts[0]?.id;
|
||
});
|
||
}, [invite?.id, initialCustomization?.layout_id]);
|
||
|
||
React.useEffect(() => {
|
||
let cancelled = false;
|
||
|
||
async function loadLayouts(url: string) {
|
||
try {
|
||
setLayoutsLoading(true);
|
||
setLayoutsError(null);
|
||
const target = (() => {
|
||
try {
|
||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||
const parsed = new URL(url);
|
||
return parsed.pathname + parsed.search;
|
||
}
|
||
} catch (parseError) {
|
||
console.warn('[Invites] Failed to parse layout URL', parseError);
|
||
}
|
||
return url;
|
||
})();
|
||
const response = await authorizedFetch(target, {
|
||
method: 'GET',
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
|
||
if (!response.ok) {
|
||
console.error('[Invites] Layout request failed', response.status, response.statusText);
|
||
throw new Error(`Failed with status ${response.status}`);
|
||
}
|
||
|
||
const json = await response.json();
|
||
const items = Array.isArray(json?.data) ? (json.data as EventQrInviteLayout[]) : [];
|
||
|
||
if (!cancelled) {
|
||
setAvailableLayouts(items);
|
||
setSelectedLayoutId((current) => {
|
||
if (current && items.some((layout) => layout.id === current)) {
|
||
return current;
|
||
}
|
||
if (initialCustomization?.layout_id && items.some((layout) => layout.id === initialCustomization.layout_id)) {
|
||
return initialCustomization.layout_id;
|
||
}
|
||
return items[0]?.id;
|
||
});
|
||
}
|
||
} catch (err) {
|
||
if (!cancelled) {
|
||
console.error('[Invites] Failed to load layouts', err);
|
||
setLayoutsError(t('invites.customizer.loadingError', 'Layouts konnten nicht geladen werden.'));
|
||
}
|
||
} finally {
|
||
if (!cancelled) {
|
||
setLayoutsLoading(false);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!invite || availableLayouts.length > 0 || !invite.layouts_url) {
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}
|
||
|
||
void loadLayouts(invite.layouts_url);
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [invite, availableLayouts.length, initialCustomization?.layout_id, t]);
|
||
|
||
React.useEffect(() => {
|
||
if (!availableLayouts.length) {
|
||
return;
|
||
}
|
||
|
||
setSelectedLayoutId((current) => {
|
||
if (current && availableLayouts.some((layout) => layout.id === current)) {
|
||
return current;
|
||
}
|
||
if (initialCustomization?.layout_id && availableLayouts.some((layout) => layout.id === initialCustomization.layout_id)) {
|
||
return initialCustomization.layout_id;
|
||
}
|
||
return availableLayouts[0].id;
|
||
});
|
||
}, [availableLayouts, initialCustomization?.layout_id]);
|
||
|
||
React.useEffect(() => {
|
||
if (!invite || !activeLayout) {
|
||
setForm({});
|
||
setInstructions([]);
|
||
return;
|
||
}
|
||
|
||
const baseInstructions = Array.isArray(initialCustomization?.instructions) && initialCustomization.instructions?.length
|
||
? [...(initialCustomization.instructions as string[])]
|
||
: [...defaultInstructions];
|
||
|
||
setInstructions(baseInstructions);
|
||
|
||
setForm({
|
||
layout_id: activeLayout.id,
|
||
headline: initialCustomization?.headline ?? eventName,
|
||
subtitle: initialCustomization?.subtitle ?? activeLayout.subtitle ?? '',
|
||
description: initialCustomization?.description ?? activeLayout.description ?? '',
|
||
badge_label: initialCustomization?.badge_label ?? t('tasks.customizer.defaults.badgeLabel'),
|
||
instructions_heading: initialCustomization?.instructions_heading ?? t('tasks.customizer.defaults.instructionsHeading'),
|
||
link_heading: initialCustomization?.link_heading ?? t('tasks.customizer.defaults.linkHeading'),
|
||
link_label: initialCustomization?.link_label ?? inviteUrl,
|
||
cta_label: initialCustomization?.cta_label ?? t('tasks.customizer.defaults.ctaLabel'),
|
||
accent_color: initialCustomization?.accent_color ?? activeLayout.preview?.accent ?? '#6366F1',
|
||
text_color: initialCustomization?.text_color ?? activeLayout.preview?.text ?? '#111827',
|
||
background_color: initialCustomization?.background_color ?? activeLayout.preview?.background ?? '#FFFFFF',
|
||
secondary_color: initialCustomization?.secondary_color ?? 'rgba(15,23,42,0.08)',
|
||
badge_color: initialCustomization?.badge_color ?? activeLayout.preview?.accent ?? '#2563EB',
|
||
background_gradient: initialCustomization?.background_gradient ?? activeLayout.preview?.background_gradient ?? null,
|
||
logo_data_url: initialCustomization?.logo_data_url ?? initialCustomization?.logo_url ?? null,
|
||
});
|
||
setError(null);
|
||
}, [invite?.id, activeLayout?.id, defaultInstructions, initialCustomization, eventName, inviteUrl, t]);
|
||
|
||
React.useEffect(() => {
|
||
if (!isAdvanced) {
|
||
setActiveElementId(null);
|
||
return;
|
||
}
|
||
|
||
if (!activeLayout) {
|
||
setElements([]);
|
||
return;
|
||
}
|
||
|
||
if (initialCustomization?.mode === 'advanced' && Array.isArray(initialCustomization.elements) && initialCustomization.elements.length) {
|
||
setElements(normalizeAdvancedElements(convertPayloadToElements(initialCustomization.elements)));
|
||
return;
|
||
}
|
||
|
||
setElements(buildDefaultAdvancedElements(activeLayout, form, eventName, activeLayoutQrSize));
|
||
}, [isAdvanced, activeLayout?.id, invite?.id, activeLayoutQrSize]);
|
||
|
||
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 effectiveInstructions = instructions.filter((entry) => entry.trim().length > 0);
|
||
|
||
const advancedPreview = React.useMemo(() => ({
|
||
headline: form.headline ?? eventName,
|
||
subtitle: form.subtitle ?? '',
|
||
description: form.description ?? activeLayout?.description ?? '',
|
||
link: form.link_label ?? inviteUrl,
|
||
badge: form.badge_label ?? t('tasks.customizer.defaults.badgeLabel'),
|
||
background: form.background_color ?? activeLayout?.preview?.background ?? '#FFFFFF',
|
||
text: form.text_color ?? activeLayout?.preview?.text ?? '#111827',
|
||
accent: form.accent_color ?? activeLayout?.preview?.accent ?? '#6366F1',
|
||
logo: form.logo_data_url ?? form.logo_url ?? null,
|
||
instructions: effectiveInstructions,
|
||
}), [form.headline, form.subtitle, form.description, form.link_label, form.badge_label, form.background_color, form.text_color, form.accent_color, form.logo_data_url, form.logo_url, eventName, activeLayout?.description, activeLayout?.preview?.background, activeLayout?.preview?.text, activeLayout?.preview?.accent, effectiveInstructions, inviteUrl, t]);
|
||
|
||
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 previewStyles = React.useMemo(() => {
|
||
const gradient = form.background_gradient ?? activeLayout?.preview?.background_gradient ?? null;
|
||
if (gradient?.stops && gradient.stops.length > 0) {
|
||
const angle = gradient.angle ?? 180;
|
||
const stops = gradient.stops.join(', ');
|
||
return { backgroundImage: `linear-gradient(${angle}deg, ${stops})` };
|
||
}
|
||
return { backgroundColor: form.background_color ?? activeLayout?.preview?.background ?? '#F8FAFC' };
|
||
}, [form.background_color, form.background_gradient, activeLayout]);
|
||
|
||
const previewStack = (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">{t('invites.customizer.preview.title', 'Live-Vorschau')}</h3>
|
||
<p className="text-xs text-muted-foreground">{t('invites.customizer.preview.subtitle', 'So sieht dein Layout beim Export aus.')}</p>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => void handlePrint(activeLayout?.download_urls?.pdf ?? activeLayout?.download_urls?.a4)}
|
||
disabled={printBusy || Boolean(downloadBusy)}
|
||
>
|
||
{printBusy ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Printer className="mr-1 h-4 w-4" />}
|
||
{t('invites.customizer.actions.print', 'Drucken')}
|
||
</Button>
|
||
{activeLayout?.formats?.map((format) => {
|
||
const key = String(format ?? '').toLowerCase();
|
||
const url = activeLayout.download_urls?.[key];
|
||
if (!url) return null;
|
||
return (
|
||
<Button
|
||
key={`${activeLayout.id}-${key}`}
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={printBusy || downloadBusy !== null}
|
||
onClick={() => void handleDownload(key, url)}
|
||
>
|
||
{downloadBusy === key ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Download className="mr-1 h-4 w-4" />}
|
||
{key.toUpperCase()}
|
||
</Button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-4 rounded-xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)] p-5 shadow-inner transition-colors">
|
||
<div className="rounded-xl p-5 text-foreground" style={previewStyles}>
|
||
<div className="flex items-start justify-between">
|
||
<span
|
||
className="rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-wide"
|
||
style={{ backgroundColor: form.badge_color ?? form.accent_color ?? '#2563EB', color: '#ffffff' }}
|
||
>
|
||
{form.badge_label || t('tasks.customizer.defaults.badgeLabel')}
|
||
</span>
|
||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-[var(--tenant-layer)] shadow" style={{ color: form.accent_color ?? '#2563EB' }}>
|
||
<SmileIcon />
|
||
</div>
|
||
</div>
|
||
<div className="mt-6 space-y-2">
|
||
<h4 className="text-lg font-semibold leading-tight" style={{ color: form.text_color ?? '#111827' }}>
|
||
{form.headline || eventName}
|
||
</h4>
|
||
{form.subtitle ? (
|
||
<p className="text-sm" style={{ color: form.text_color ?? '#111827', opacity: 0.75 }}>
|
||
{form.subtitle}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
{form.description ? (
|
||
<p className="mt-4 text-sm" style={{ color: form.text_color ?? '#111827' }}>
|
||
{form.description}
|
||
</p>
|
||
) : null}
|
||
<div className="mt-5 grid gap-3 rounded-xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-layer)] p-4 shadow-sm">
|
||
<div className="text-xs font-semibold uppercase tracking-wide text-[var(--tenant-foreground-soft)]">{form.instructions_heading}</div>
|
||
<ol className="grid gap-2 text-sm text-muted-foreground">
|
||
{effectiveInstructions.slice(0, 4).map((item, index) => (
|
||
<li key={`preview-instruction-${index}`} className="flex gap-2">
|
||
<span className="font-semibold" style={{ color: form.accent_color ?? '#2563EB' }}>{index + 1}.</span>
|
||
<span>{item}</span>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
</div>
|
||
<div className="mt-5 rounded-2xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-layer-strong)] p-5 text-sm text-[var(--tenant-foreground-soft)] shadow-sm">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="text-xs font-semibold uppercase tracking-wide text-[var(--tenant-foreground-soft)]">{form.link_heading}</span>
|
||
<Badge variant="outline" className="rounded-full border border-[var(--tenant-border-strong)] bg-[var(--tenant-layer)] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide">
|
||
{t('invites.customizer.preview.readyForGuests', 'Bereit für Gäste')}
|
||
</Badge>
|
||
</div>
|
||
<div className="mt-4 grid gap-4 lg:grid-cols-[auto,minmax(0,1fr)] lg:items-center">
|
||
<div className="flex justify-center">
|
||
{qrCodeDataUrl ? (
|
||
<img
|
||
src={qrCodeDataUrl}
|
||
alt={t('invites.customizer.preview.qrAlt', 'QR-Code der Einladung')}
|
||
className="h-44 w-44 rounded-3xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-layer)] p-4 shadow-md md:h-64 md:w-64"
|
||
/>
|
||
) : (
|
||
<div className="flex h-44 w-44 items-center justify-center rounded-3xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-4 text-center text-xs text-muted-foreground md:h-64 md:w-64">
|
||
{t('invites.customizer.preview.qrPlaceholder', 'QR-Code folgt nach dem Speichern')}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex flex-col gap-3">
|
||
<div className="rounded-xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-layer)] px-3 py-2">
|
||
<span className="block truncate font-medium" style={{ color: form.accent_color ?? '#2563EB' }}>
|
||
{form.link_label || inviteUrl}
|
||
</span>
|
||
</div>
|
||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||
{t('invites.customizer.preview.instructions', 'Dieser Link führt Gäste direkt zur Galerie und funktioniert zusammen mit dem QR-Code auf dem Ausdruck.')}
|
||
</p>
|
||
<Button size="sm" className="w-full bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 text-white sm:w-auto">
|
||
{form.cta_label || t('tasks.customizer.defaults.ctaLabel')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{form.logo_data_url ? (
|
||
<div className="flex items-center justify-center rounded-lg border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] py-4">
|
||
<img src={form.logo_data_url} alt="Logo preview" className="max-h-16 object-contain" />
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
function updateForm<T extends keyof QrLayoutCustomization>(key: T, value: QrLayoutCustomization[T]) {
|
||
setForm((prev) => ({ ...prev, [key]: value }));
|
||
}
|
||
|
||
function handleLayoutSelect(layout: EventQrInviteLayout) {
|
||
setSelectedLayoutId(layout.id);
|
||
updateForm('layout_id', layout.id);
|
||
setForm((prev) => ({
|
||
...prev,
|
||
accent_color: prev.accent_color ?? layout.preview?.accent ?? '#6366F1',
|
||
text_color: prev.text_color ?? layout.preview?.text ?? '#111827',
|
||
background_color: prev.background_color ?? layout.preview?.background ?? '#FFFFFF',
|
||
background_gradient: prev.background_gradient ?? layout.preview?.background_gradient ?? null,
|
||
}));
|
||
if (isAdvanced) {
|
||
setElements(buildDefaultAdvancedElements(layout, form, eventName, layout.preview?.qr_size_px ?? activeLayoutQrSize));
|
||
setActiveElementId(null);
|
||
}
|
||
}
|
||
|
||
React.useEffect(() => {
|
||
if (!availableLayouts.length) {
|
||
return;
|
||
}
|
||
const index = availableLayouts.findIndex((layout) => layout.id === selectedLayoutId);
|
||
if (index >= 0 && index !== activeLayoutIndex) {
|
||
setActiveLayoutIndex(index);
|
||
}
|
||
}, [availableLayouts, selectedLayoutId, activeLayoutIndex]);
|
||
|
||
function rotateLayout(delta: number) {
|
||
if (!availableLayouts.length) {
|
||
return;
|
||
}
|
||
const nextIndex = (activeLayoutIndex + delta + availableLayouts.length) % availableLayouts.length;
|
||
setActiveLayoutIndex(nextIndex);
|
||
handleLayoutSelect(availableLayouts[nextIndex]!);
|
||
}
|
||
|
||
function selectLayoutAt(index: number) {
|
||
if (index < 0 || index >= availableLayouts.length) {
|
||
return;
|
||
}
|
||
setActiveLayoutIndex(index);
|
||
handleLayoutSelect(availableLayouts[index]!);
|
||
}
|
||
|
||
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,
|
||
};
|
||
|
||
if (isAdvanced) {
|
||
const serializationContext: AdvancedSerializationContext = {
|
||
form,
|
||
eventName,
|
||
inviteUrl,
|
||
instructions: effectiveInstructions,
|
||
qrSize: activeLayoutQrSize,
|
||
badgeFallback: t('tasks.customizer.defaults.badgeLabel'),
|
||
logoUrl: form.logo_url ?? null,
|
||
};
|
||
payload.mode = 'advanced';
|
||
payload.elements = serializeAdvancedElements(elements.length ? elements : buildDefaultAdvancedElements(activeLayout, form, eventName, activeLayoutQrSize), serializationContext);
|
||
} else {
|
||
payload.mode = 'standard';
|
||
payload.elements = undefined;
|
||
}
|
||
|
||
await onSave(payload);
|
||
}
|
||
|
||
async function handleResetClick() {
|
||
await onReset();
|
||
}
|
||
|
||
function resolveInternalUrl(rawUrl: string): string {
|
||
try {
|
||
const parsed = new URL(rawUrl, window.location.origin);
|
||
if (parsed.origin === window.location.origin) {
|
||
return parsed.pathname + parsed.search;
|
||
}
|
||
} catch (resolveError) {
|
||
console.warn('[Invites] Unable to resolve download url', resolveError);
|
||
}
|
||
return rawUrl;
|
||
}
|
||
|
||
async function handleDownload(format: string, rawUrl: string): Promise<void> {
|
||
if (!rawUrl || !invite) {
|
||
return;
|
||
}
|
||
|
||
const normalizedFormat = format.toLowerCase();
|
||
const filenameStem = invite.token || 'invite';
|
||
setDownloadBusy(normalizedFormat);
|
||
setError(null);
|
||
|
||
try {
|
||
const response = await authorizedFetch(resolveInternalUrl(rawUrl), {
|
||
headers: {
|
||
Accept: normalizedFormat === 'pdf' ? 'application/pdf' : 'image/svg+xml',
|
||
},
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`Unexpected status ${response.status}`);
|
||
}
|
||
|
||
const blob = await response.blob();
|
||
const objectUrl = URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = objectUrl;
|
||
link.download = `${filenameStem}-${normalizedFormat}.${normalizedFormat}`;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
||
} catch (downloadError) {
|
||
console.error('[Invites] Download failed', downloadError);
|
||
const message = isAuthError(downloadError)
|
||
? t('invites.customizer.errors.auth', 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.')
|
||
: t('invites.customizer.errors.downloadFailed', 'Download fehlgeschlagen. Bitte versuche es erneut.');
|
||
setError(message);
|
||
} finally {
|
||
setDownloadBusy(null);
|
||
}
|
||
}
|
||
|
||
async function handlePrint(preferredUrl?: string | null): Promise<void> {
|
||
const rawUrl = preferredUrl ?? activeLayout?.download_urls?.pdf ?? activeLayout?.download_urls?.a4 ?? null;
|
||
if (!rawUrl) {
|
||
setError(t('invites.labels.noPrintSource', 'Keine druckbare Version verfügbar.'));
|
||
return;
|
||
}
|
||
|
||
setPrintBusy(true);
|
||
setError(null);
|
||
|
||
try {
|
||
const response = await authorizedFetch(resolveInternalUrl(rawUrl), {
|
||
headers: { Accept: 'application/pdf' },
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`Unexpected status ${response.status}`);
|
||
}
|
||
|
||
const blob = await response.blob();
|
||
const blobUrl = URL.createObjectURL(blob);
|
||
const printWindow = window.open(blobUrl, '_blank', 'noopener,noreferrer');
|
||
|
||
if (!printWindow) {
|
||
throw new Error('window-blocked');
|
||
}
|
||
|
||
printWindow.onload = () => {
|
||
try {
|
||
printWindow.focus();
|
||
printWindow.print();
|
||
} catch (printError) {
|
||
console.error('[Invites] Browser print failed', printError);
|
||
}
|
||
};
|
||
|
||
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
|
||
} catch (printError) {
|
||
console.error('[Invites] Print failed', printError);
|
||
const message = isAuthError(printError)
|
||
? t('invites.customizer.errors.auth', 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.')
|
||
: t('invites.customizer.errors.printFailed', 'Druck konnte nicht gestartet werden.');
|
||
setError(message);
|
||
} finally {
|
||
setPrintBusy(false);
|
||
}
|
||
}
|
||
|
||
if (!invite) {
|
||
return (
|
||
<CardPlaceholder
|
||
title={t('invites.customizer.placeholderTitle', 'Kein Layout verfügbar')}
|
||
description={t('invites.customizer.placeholderCopy', 'Erstelle eine Einladung, damit du Texte, Farben und Drucklayouts bearbeiten kannst.')}
|
||
/>
|
||
);
|
||
}
|
||
|
||
if (!availableLayouts.length) {
|
||
if (layoutsLoading) {
|
||
return (
|
||
<CardPlaceholder
|
||
title={t('invites.customizer.loadingTitle', 'Layouts werden geladen')}
|
||
description={t('invites.customizer.loadingDescription', 'Bitte warte einen Moment, wir bereiten die Drucklayouts vor.')}
|
||
/>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<CardPlaceholder
|
||
title={layoutsError ?? t('invites.customizer.placeholderTitle', 'Kein Layout verfügbar')}
|
||
description={layoutsError ?? t('invites.customizer.placeholderCopy', 'Erstelle eine Einladung, damit du Texte, Farben und Drucklayouts bearbeiten kannst.')}
|
||
/>
|
||
);
|
||
}
|
||
|
||
if (!activeLayout) {
|
||
return (
|
||
<CardPlaceholder
|
||
title={t('invites.customizer.placeholderTitle', 'Kein Layout verfügbar')}
|
||
description={t('invites.customizer.placeholderCopy', 'Erstelle eine Einladung, damit du Texte, Farben und Drucklayouts bearbeiten kannst.')}
|
||
/>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||
<div>
|
||
<h2 className="text-xl font-semibold text-foreground">{t('invites.customizer.heading', 'Layout anpassen')}</h2>
|
||
<p className="text-sm text-muted-foreground">{t('invites.customizer.copy', 'Verleihe der Einladung euren Ton: Texte, Farben und Logo lassen sich live bearbeiten.')}</p>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button variant="outline" onClick={handleResetClick} disabled={resetting || saving}>
|
||
{resetting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RotateCcw className="mr-2 h-4 w-4" />}
|
||
{t('invites.customizer.actions.reset', 'Zurücksetzen')}
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
onClick={() => {
|
||
if (formRef.current) {
|
||
if (typeof formRef.current.requestSubmit === 'function') {
|
||
formRef.current.requestSubmit();
|
||
} else {
|
||
formRef.current.submit();
|
||
}
|
||
}
|
||
}}
|
||
className="bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 text-white shadow-lg shadow-rose-500/20"
|
||
disabled={saving || resetting}
|
||
>
|
||
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
|
||
{t('invites.customizer.actions.save', 'Layout speichern')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{error ? (
|
||
<div className="rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{error}</div>
|
||
) : null}
|
||
|
||
{invite ? (
|
||
<div className="lg:hidden">
|
||
<Sheet open={mobilePreviewOpen} onOpenChange={setMobilePreviewOpen}>
|
||
<SheetTrigger asChild>
|
||
<Button variant="outline" className="w-full justify-between">
|
||
<span>{t('invites.customizer.preview.mobileOpen', 'Vorschau anzeigen')}</span>
|
||
<span className="text-xs text-muted-foreground">{t('invites.customizer.preview.mobileHint', 'Öffnet eine Vorschau in einem Overlay')}</span>
|
||
</Button>
|
||
</SheetTrigger>
|
||
<SheetContent side="bottom" className="lg:hidden max-h-[85vh] overflow-y-auto border-t bg-[var(--tenant-surface)] p-0">
|
||
<SheetHeader className="px-6 pt-6 pb-2">
|
||
<SheetTitle>{t('invites.customizer.preview.mobileTitle', 'Einladungsvorschau')}</SheetTitle>
|
||
</SheetHeader>
|
||
<div className="space-y-4 overflow-y-auto px-6 pb-8">
|
||
{previewStack}
|
||
</div>
|
||
</SheetContent>
|
||
</Sheet>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,1fr)]">
|
||
<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="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||
<div>
|
||
<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>
|
||
</div>
|
||
<div className="text-xs text-muted-foreground">
|
||
{availableLayouts.length > 1
|
||
? t('invites.customizer.carousel.hint', {
|
||
defaultValue: 'Nutze die Pfeile oder Pfeiltasten, um weitere Layouts zu entdecken.',
|
||
})
|
||
: null}
|
||
</div>
|
||
</header>
|
||
|
||
<div className="space-y-3">
|
||
<Select value={selectedLayoutId} onValueChange={(value) => handleLayoutSelect(availableLayouts.find(l => l.id === value)!)}>
|
||
<SelectTrigger className="w-full">
|
||
<SelectValue placeholder="Layout auswählen">
|
||
{activeLayout && (
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-medium">{activeLayout.name}</span>
|
||
{activeLayout.formats?.length ? (
|
||
<div className="flex gap-1">
|
||
{activeLayout.formats.slice(0, 2).map((format) => (
|
||
<span
|
||
key={`${activeLayout.id}-${format}`}
|
||
className="text-[10px] font-medium uppercase tracking-wide bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded"
|
||
>
|
||
{String(format).toUpperCase()}
|
||
</span>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
</SelectValue>
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{availableLayouts.map((layout) => (
|
||
<SelectItem key={layout.id} value={layout.id}>
|
||
<div className="flex items-center justify-between w-full">
|
||
<div className="flex-1">
|
||
<div className="font-medium">{layout.name}</div>
|
||
{layout.subtitle && (
|
||
<div className="text-xs text-muted-foreground">{layout.subtitle}</div>
|
||
)}
|
||
</div>
|
||
{layout.formats?.length ? (
|
||
<div className="flex gap-1 ml-2">
|
||
{layout.formats.slice(0, 3).map((format) => (
|
||
<span
|
||
key={`${layout.id}-${format}`}
|
||
className="text-[10px] font-medium uppercase tracking-wide bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded"
|
||
>
|
||
{String(format).toUpperCase()}
|
||
</span>
|
||
))}
|
||
{layout.formats.length > 3 && (
|
||
<span className="text-[10px] text-muted-foreground">+{layout.formats.length - 3}</span>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<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 mt-4">
|
||
<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>
|
||
</section>
|
||
|
||
<section className="space-y-4 rounded-2xl border border-border bg-[var(--tenant-surface)] p-5 shadow-sm transition-colors">
|
||
<Tabs defaultValue="text" className="space-y-4">
|
||
<TabsList className="grid w-full grid-cols-3">
|
||
<TabsTrigger value="text">{t('invites.customizer.sections.text', 'Texte')}</TabsTrigger>
|
||
<TabsTrigger value="instructions">{t('invites.customizer.sections.instructions', 'Schritt-für-Schritt')}</TabsTrigger>
|
||
<TabsTrigger value="branding">{t('invites.customizer.sections.branding', 'Farbgebung')}</TabsTrigger>
|
||
</TabsList>
|
||
|
||
<TabsContent value="text" className="space-y-4">
|
||
<div className="grid gap-4">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="invite-headline">{t('invites.customizer.fields.headline', 'Überschrift')}</Label>
|
||
<Input
|
||
id="invite-headline"
|
||
value={form.headline ?? ''}
|
||
onChange={(event) => updateForm('headline', event.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="invite-subtitle">{t('invites.customizer.fields.subtitle', 'Unterzeile')}</Label>
|
||
<Input
|
||
id="invite-subtitle"
|
||
value={form.subtitle ?? ''}
|
||
onChange={(event) => updateForm('subtitle', event.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="invite-description">{t('invites.customizer.fields.description', 'Beschreibung')}</Label>
|
||
<Textarea
|
||
id="invite-description"
|
||
value={form.description ?? ''}
|
||
onChange={(event) => updateForm('description', event.target.value)}
|
||
className="min-h-[96px]"
|
||
/>
|
||
</div>
|
||
<div className="grid gap-4 sm:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="invite-badge">{t('invites.customizer.fields.badge', 'Badge-Label')}</Label>
|
||
<Input
|
||
id="invite-badge"
|
||
value={form.badge_label ?? ''}
|
||
onChange={(event) => updateForm('badge_label', event.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="invite-cta">{t('invites.customizer.fields.cta', 'Call-to-Action')}</Label>
|
||
<Input
|
||
id="invite-cta"
|
||
value={form.cta_label ?? ''}
|
||
onChange={(event) => updateForm('cta_label', event.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="grid gap-4 sm:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="invite-link-heading">{t('invites.customizer.fields.linkHeading', 'Link-Überschrift')}</Label>
|
||
<Input
|
||
id="invite-link-heading"
|
||
value={form.link_heading ?? ''}
|
||
onChange={(event) => updateForm('link_heading', event.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="invite-link-label">{t('invites.customizer.fields.linkLabel', 'Link/Begleittext')}</Label>
|
||
<Input
|
||
id="invite-link-label"
|
||
value={form.link_label ?? ''}
|
||
onChange={(event) => updateForm('link_label', event.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="instructions" className="space-y-4">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="invite-instruction-heading">{t('invites.customizer.fields.instructionsHeading', 'Abschnittsüberschrift')}</Label>
|
||
<Input
|
||
id="invite-instruction-heading"
|
||
value={form.instructions_heading ?? ''}
|
||
onChange={(event) => updateForm('instructions_heading', event.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="space-y-3">
|
||
{instructions.map((entry, index) => (
|
||
<div key={`instruction-${index}`} className="flex gap-2">
|
||
<Input
|
||
value={entry}
|
||
onChange={(event) => handleInstructionChange(index, event.target.value)}
|
||
placeholder={t('invites.customizer.fields.instructionPlaceholder', 'Beschreibung des Schritts')}
|
||
/>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
className="text-muted-foreground hover:text-destructive"
|
||
onClick={() => handleRemoveInstruction(index)}
|
||
>
|
||
×
|
||
</Button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<Button type="button" variant="outline" size="sm" onClick={handleAddInstruction} disabled={instructions.length >= MAX_INSTRUCTIONS}>
|
||
<Plus className="mr-1 h-4 w-4" />
|
||
{t('invites.customizer.actions.addInstruction', 'Punkt hinzufügen')}
|
||
</Button>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="branding" className="space-y-4">
|
||
<div className="grid gap-4 sm:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="invite-accent">{t('invites.customizer.fields.accentColor', 'Akzentfarbe')}</Label>
|
||
<Input
|
||
id="invite-accent"
|
||
type="color"
|
||
value={form.accent_color ?? '#6366F1'}
|
||
onChange={(event) => updateForm('accent_color', event.target.value)}
|
||
className="h-11"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="invite-text-color">{t('invites.customizer.fields.textColor', 'Textfarbe')}</Label>
|
||
<Input
|
||
id="invite-text-color"
|
||
type="color"
|
||
value={form.text_color ?? '#111827'}
|
||
onChange={(event) => updateForm('text_color', event.target.value)}
|
||
className="h-11"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="invite-background-color">{t('invites.customizer.fields.backgroundColor', 'Hintergrund')}</Label>
|
||
<Input
|
||
id="invite-background-color"
|
||
type="color"
|
||
value={form.background_color ?? '#FFFFFF'}
|
||
onChange={(event) => updateForm('background_color', event.target.value)}
|
||
className="h-11"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="invite-badge-color">{t('invites.customizer.fields.badgeColor', 'Badge')}</Label>
|
||
<Input
|
||
id="invite-badge-color"
|
||
type="color"
|
||
value={form.badge_color ?? '#2563EB'}
|
||
onChange={(event) => updateForm('badge_color', event.target.value)}
|
||
className="h-11"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label>{t('invites.customizer.fields.logo', 'Logo')}</Label>
|
||
{form.logo_data_url ? (
|
||
<div className="flex items-center gap-4 rounded-lg border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-3 text-[var(--tenant-foreground-soft)]">
|
||
<img src={form.logo_data_url} alt="Logo" className="h-12 w-12 rounded border border-[var(--tenant-border-strong)] object-contain" />
|
||
<Button type="button" variant="ghost" onClick={handleLogoRemove} className="text-destructive hover:text-destructive/80">
|
||
{t('invites.customizer.actions.removeLogo', 'Logo entfernen')}
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<label className="flex cursor-pointer items-center gap-3 rounded-lg border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] px-4 py-3 text-sm text-muted-foreground hover:border-primary">
|
||
<UploadCloud className="h-4 w-4" />
|
||
<span>{t('invites.customizer.actions.uploadLogo', 'Logo hochladen (max. 1 MB)')}</span>
|
||
<input type="file" accept="image/*" className="hidden" onChange={handleLogoUpload} />
|
||
</label>
|
||
)}
|
||
</div>
|
||
</TabsContent>
|
||
</Tabs>
|
||
</section>
|
||
|
||
<div className={cn('mt-6 flex flex-col gap-2 sm:flex-row sm:justify-end', showFloatingActions ? 'hidden' : 'flex')}>
|
||
{renderActionButtons('inline')}
|
||
</div>
|
||
<div ref={actionsSentinelRef} className="h-1 w-full" />
|
||
</form>
|
||
<div className="hidden lg:block">
|
||
{previewStack}
|
||
</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>
|
||
);
|
||
}
|
||
|
||
function SmileIcon(): React.JSX.Element {
|
||
return (
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="h-5 w-5">
|
||
<circle cx="12" cy="12" r="10" opacity="0.4" />
|
||
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
|
||
<path d="M9 9h.01M15 9h.01" strokeLinecap="round" strokeLinejoin="round" />
|
||
</svg>
|
||
);
|
||
}
|