feat: extend event toolkit and polish guest pwa
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user