feat: extend event toolkit and polish guest pwa

This commit is contained in:
Codex Agent
2025-10-28 18:28:22 +01:00
parent f29067f570
commit a7bbf230fd
45 changed files with 3809 additions and 351 deletions

View File

@@ -0,0 +1,514 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import type { EventQrInviteLayout } from '../../api';
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;
};
const MAX_INSTRUCTIONS = 5;
type Props = {
open: boolean;
onClose: () => void;
onSubmit: (customization: QrLayoutCustomization) => Promise<void>;
onReset: () => Promise<void>;
saving: boolean;
inviteUrl: string;
eventName: string;
layouts: EventQrInviteLayout[];
initialCustomization: QrLayoutCustomization | null;
};
export function QrInviteCustomizationDialog({
open,
onClose,
onSubmit,
onReset,
saving,
inviteUrl,
eventName,
layouts,
initialCustomization,
}: Props) {
const { t } = useTranslation('management');
const [selectedLayoutId, setSelectedLayoutId] = React.useState<string | undefined>();
const [form, setForm] = React.useState<QrLayoutCustomization>({});
const [instructions, setInstructions] = React.useState<string[]>([]);
const [error, setError] = React.useState<string | null>(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 selectedLayout = React.useMemo(() => {
if (layouts.length === 0) {
return undefined;
}
const fallback = layouts[0];
if (!selectedLayoutId) {
return fallback;
}
return layouts.find((layout) => layout.id === selectedLayoutId) ?? fallback;
}, [layouts, selectedLayoutId]);
React.useEffect(() => {
if (!open) {
return;
}
const defaultLayout = initialCustomization?.layout_id
? layouts.find((layout) => layout.id === initialCustomization.layout_id)
: undefined;
const layout = defaultLayout ?? layouts[0];
setSelectedLayoutId(layout?.id);
const nextInstructions = Array.isArray(initialCustomization?.instructions)
? initialCustomization!.instructions!
: [];
setInstructions(nextInstructions.length > 0 ? nextInstructions : defaultInstructions);
setForm({
layout_id: layout?.id,
headline: initialCustomization?.headline ?? eventName,
subtitle: initialCustomization?.subtitle ?? layout?.subtitle ?? '',
description: initialCustomization?.description ?? layout?.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 ?? layout?.preview?.accent ?? '#6366F1',
text_color: initialCustomization?.text_color ?? layout?.preview?.text ?? '#111827',
background_color: initialCustomization?.background_color ?? layout?.preview?.background ?? '#FFFFFF',
secondary_color: initialCustomization?.secondary_color ?? 'rgba(15,23,42,0.08)',
badge_color: initialCustomization?.badge_color ?? layout?.preview?.accent ?? '#2563EB',
background_gradient: initialCustomization?.background_gradient ?? layout?.preview?.background_gradient ?? null,
logo_data_url: initialCustomization?.logo_data_url ?? initialCustomization?.logo_url ?? null,
});
setError(null);
}, [open, layouts, initialCustomization, inviteUrl, eventName, t]);
React.useEffect(() => {
if (!selectedLayout) {
return;
}
setForm((prev) => ({
...prev,
layout_id: selectedLayout.id,
accent_color: prev.accent_color ?? selectedLayout.preview?.accent ?? '#6366F1',
text_color: prev.text_color ?? selectedLayout.preview?.text ?? '#111827',
background_color: prev.background_color ?? selectedLayout.preview?.background ?? '#FFFFFF',
background_gradient: prev.background_gradient ?? selectedLayout.preview?.background_gradient ?? null,
}));
}, [selectedLayout]);
const handleColorChange = (key: keyof QrLayoutCustomization) =>
(event: React.ChangeEvent<HTMLInputElement>) => {
setForm((prev) => ({ ...prev, [key]: event.target.value }));
};
const handleInputChange = (key: keyof QrLayoutCustomization) =>
(event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setForm((prev) => ({ ...prev, [key]: event.target.value }));
};
const handleInstructionChange = (index: number, value: string) => {
setInstructions((prev) => {
const next = [...prev];
next[index] = value;
return next;
});
};
const handleAddInstruction = () => {
setInstructions((prev) => (prev.length < MAX_INSTRUCTIONS ? [...prev, ''] : prev));
};
const handleRemoveInstruction = (index: number) => {
setInstructions((prev) => prev.filter((_, idx) => idx !== index));
};
const 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 = () => {
setForm((prev) => ({ ...prev, logo_data_url: typeof reader.result === 'string' ? reader.result : null }));
setError(null);
};
reader.readAsDataURL(file);
};
const handleLogoRemove = () => {
setForm((prev) => ({ ...prev, logo_data_url: null }));
};
const effectiveInstructions = instructions.filter((entry) => entry.trim().length > 0);
const preview = React.useMemo(() => {
const backgroundStyle = form.background_gradient?.stops && form.background_gradient.stops.length > 0
? `linear-gradient(${form.background_gradient.angle ?? 180}deg, ${form.background_gradient.stops.join(',')})`
: form.background_color ?? selectedLayout?.preview?.background ?? '#FFFFFF';
return {
background: backgroundStyle,
accent: form.accent_color ?? selectedLayout?.preview?.accent ?? '#6366F1',
text: form.text_color ?? selectedLayout?.preview?.text ?? '#111827',
secondary: form.secondary_color ?? 'rgba(15,23,42,0.08)',
};
}, [form, selectedLayout]);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!selectedLayout) {
setError(t('tasks.customizer.errors.noLayout', 'Bitte wähle ein Layout aus.'));
return;
}
setError(null);
await onSubmit({
...form,
layout_id: selectedLayout.id,
instructions: effectiveInstructions,
});
};
const handleReset = async () => {
setError(null);
await onReset();
};
return (
<Dialog open={open} onOpenChange={(value) => (!value ? onClose() : null)}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>{t('tasks.customizer.title', 'QR-Einladung anpassen')}</DialogTitle>
<DialogDescription>{t('tasks.customizer.description', 'Passe Layout, Texte und Farben deiner QR-Einladung an.')}</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="grid gap-6 md:grid-cols-[2fr,1fr]">
<div className="space-y-5">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="qr-layout">{t('tasks.customizer.layout', 'Layout')}</Label>
<Select
value={selectedLayout?.id}
onValueChange={(value) => setSelectedLayoutId(value)}
disabled={layouts.length === 0 || saving}
>
<SelectTrigger id="qr-layout">
<SelectValue placeholder={t('tasks.customizer.selectLayout', 'Layout auswählen')} />
</SelectTrigger>
<SelectContent>
{layouts.map((layout) => (
<SelectItem key={layout.id} value={layout.id}>
{layout.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="headline">{t('tasks.customizer.headline', 'Überschrift')}</Label>
<Input
id="headline"
value={form.headline ?? ''}
onChange={handleInputChange('headline')}
maxLength={120}
disabled={saving}
/>
</div>
<div className="space-y-2">
<Label htmlFor="subtitle">{t('tasks.customizer.subtitle', 'Unterzeile')}</Label>
<Input
id="subtitle"
value={form.subtitle ?? ''}
onChange={handleInputChange('subtitle')}
maxLength={160}
disabled={saving}
/>
</div>
<div className="space-y-2">
<Label htmlFor="badgeLabel">{t('tasks.customizer.badgeLabel', 'Badge')}</Label>
<Input
id="badgeLabel"
value={form.badge_label ?? ''}
onChange={handleInputChange('badge_label')}
maxLength={80}
disabled={saving}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">{t('tasks.customizer.descriptionLabel', 'Beschreibung')}</Label>
<Textarea
id="description"
rows={3}
value={form.description ?? ''}
onChange={handleInputChange('description')}
maxLength={500}
disabled={saving}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="instructionsHeading">{t('tasks.customizer.instructionsHeading', "Anleitungstitel")}</Label>
<Input
id="instructionsHeading"
value={form.instructions_heading ?? ''}
onChange={handleInputChange('instructions_heading')}
maxLength={120}
disabled={saving}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ctaLabel">{t('tasks.customizer.ctaLabel', 'CTA')}</Label>
<Input
id="ctaLabel"
value={form.cta_label ?? ''}
onChange={handleInputChange('cta_label')}
maxLength={120}
disabled={saving}
/>
</div>
<div className="space-y-2">
<Label htmlFor="linkHeading">{t('tasks.customizer.linkHeading', 'Link-Titel')}</Label>
<Input
id="linkHeading"
value={form.link_heading ?? ''}
onChange={handleInputChange('link_heading')}
maxLength={120}
disabled={saving}
/>
</div>
<div className="space-y-2">
<Label htmlFor="linkLabel">{t('tasks.customizer.linkLabel', 'Link')}</Label>
<Input
id="linkLabel"
value={form.link_label ?? ''}
onChange={handleInputChange('link_label')}
maxLength={160}
disabled={saving}
/>
</div>
</div>
<div className="space-y-2">
<Label>{t('tasks.customizer.instructionsLabel', 'Hinweise')}</Label>
<div className="space-y-2">
{instructions.map((instruction, index) => (
<div key={`instruction-${index}`} className="flex items-start gap-2">
<Textarea
rows={2}
value={instruction}
onChange={(event) => handleInstructionChange(index, event.target.value)}
maxLength={160}
disabled={saving}
/>
<Button type="button" variant="outline" onClick={() => handleRemoveInstruction(index)} disabled={saving}>
{t('tasks.customizer.removeInstruction', 'Entfernen')}
</Button>
</div>
))}
<Button
type="button"
variant="outline"
onClick={handleAddInstruction}
disabled={instructions.length >= MAX_INSTRUCTIONS || saving}
>
{t('tasks.customizer.addInstruction', 'Hinweis hinzufügen')}
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<ColorField
label={t('tasks.customizer.colors.accent', 'Akzentfarbe')}
value={form.accent_color ?? '#6366F1'}
onChange={handleColorChange('accent_color')}
disabled={saving}
/>
<ColorField
label={t('tasks.customizer.colors.text', 'Textfarbe')}
value={form.text_color ?? '#111827'}
onChange={handleColorChange('text_color')}
disabled={saving}
/>
<ColorField
label={t('tasks.customizer.colors.background', 'Hintergrund')}
value={form.background_color ?? '#FFFFFF'}
onChange={handleColorChange('background_color')}
disabled={saving}
/>
<ColorField
label={t('tasks.customizer.colors.secondary', 'Sekundärfarbe')}
value={form.secondary_color ?? '#CBD5F5'}
onChange={handleColorChange('secondary_color')}
disabled={saving}
/>
<ColorField
label={t('tasks.customizer.colors.badge', 'Badge-Farbe')}
value={form.badge_color ?? '#2563EB'}
onChange={handleColorChange('badge_color')}
disabled={saving}
/>
</div>
<div className="space-y-2">
<Label>{t('tasks.customizer.logo.label', 'Logo')}</Label>
<div className="flex flex-wrap items-center gap-3">
<Input type="file" accept="image/png,image/jpeg,image/svg+xml" onChange={handleLogoUpload} disabled={saving} />
{form.logo_data_url ? (
<Button type="button" variant="outline" onClick={handleLogoRemove} disabled={saving}>
{t('tasks.customizer.logo.remove', 'Logo entfernen')}
</Button>
) : null}
</div>
<p className="text-xs text-muted-foreground">
{t('tasks.customizer.logo.hint', 'PNG oder SVG, max. 1 MB. Wird oben rechts platziert.')}
</p>
</div>
</div>
<aside className="space-y-4">
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
<h4 className="text-sm font-semibold text-slate-900">
{t('tasks.customizer.preview.title', 'Vorschau')}
</h4>
<p className="text-xs text-slate-600">
{t('tasks.customizer.preview.hint', 'Farben und Texte, wie sie im Layout erscheinen. Speichere, um neue PDFs/SVGs zu erhalten.')}
</p>
</div>
<div
className="space-y-3 rounded-3xl border border-slate-200 p-4 text-xs text-slate-700 shadow-sm"
style={{
background: preview.background,
color: preview.text,
}}
>
<div className="flex items-center justify-between gap-3">
<span className="rounded-full bg-[var(--badge-color,#1f2937)] px-3 py-1 text-[10px] font-semibold uppercase tracking-wide"
style={{ background: form.badge_color ?? preview.accent }}
>
{form.badge_label ?? t('tasks.customizer.defaults.badgeLabel')}
</span>
{form.logo_data_url ? (
<img src={form.logo_data_url} alt="Logo" className="h-12 w-auto object-contain" />
) : null}
</div>
<div className="space-y-1">
<p className="text-base font-semibold">{form.headline ?? eventName}</p>
{form.subtitle ? <p className="text-sm opacity-80">{form.subtitle}</p> : null}
</div>
<div className="space-y-1">
<p className="text-[11px] font-semibold uppercase tracking-wide" style={{ color: preview.accent }}>
{form.instructions_heading ?? t('tasks.customizer.defaults.instructionsHeading')}
</p>
<ul className="space-y-1 text-xs">
{(effectiveInstructions.length > 0 ? effectiveInstructions : defaultInstructions).map((item, index) => (
<li key={`preview-instruction-${index}`}> {item}</li>
))}
</ul>
</div>
<div className="space-y-1">
<p className="text-[11px] font-semibold uppercase tracking-wide" style={{ color: preview.accent }}>
{form.link_heading ?? t('tasks.customizer.defaults.linkHeading')}
</p>
<div className="rounded-lg border border-white/40 bg-white/80 p-2 text-[11px]" style={{ color: preview.text }}>
{form.link_label ?? inviteUrl}
</div>
</div>
<div className="text-[11px] font-semibold uppercase tracking-wide" style={{ color: preview.accent }}>
{form.cta_label ?? t('tasks.customizer.defaults.ctaLabel')}
</div>
</div>
</aside>
<input type="hidden" value={form.layout_id ?? ''} />
<DialogFooter className="md:col-span-2">
<div className="flex flex-1 flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" onClick={handleReset} disabled={saving}>
{t('tasks.customizer.actions.reset', 'Zurücksetzen')}
</Button>
<Button type="button" variant="outline" onClick={onClose} disabled={saving}>
{t('tasks.customizer.actions.cancel', 'Abbrechen')}
</Button>
</div>
<div className="flex items-center gap-3">
{error ? <span className="text-sm text-destructive">{error}</span> : null}
<Button type="submit" disabled={saving}>
{t('tasks.customizer.actions.save', 'Speichern')}
</Button>
</div>
</div>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function ColorField({
label,
value,
onChange,
disabled,
}: {
label: string;
value: string;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
disabled: boolean;
}) {
return (
<div className="space-y-2">
<Label>{label}</Label>
<div className="flex items-center gap-2">
<Input type="color" value={value} onChange={onChange} disabled={disabled} className="h-10 w-14 p-1" />
<Input value={value} onChange={onChange} disabled={disabled} pattern="^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$" />
</div>
</div>
);
}
export default QrInviteCustomizationDialog;