Files
fotospiel-app/resources/js/admin/pages/components/InviteLayoutCustomizerPanel.tsx

728 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Download, Loader2, Plus, Printer, RotateCcw, Save, UploadCloud } from 'lucide-react';
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 type { EventQrInvite, EventQrInviteLayout } from '../../api';
import { authorizedFetch } 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;
};
type InviteLayoutCustomizerPanelProps = {
invite: EventQrInvite | null;
eventName: string;
saving: boolean;
resetting: boolean;
onSave: (customization: QrLayoutCustomization) => Promise<void>;
onReset: () => Promise<void>;
initialCustomization: QrLayoutCustomization | null;
};
const MAX_INSTRUCTIONS = 5;
export function InviteLayoutCustomizerPanel({
invite,
eventName,
saving,
resetting,
onSave,
onReset,
initialCustomization,
}: InviteLayoutCustomizerPanelProps): JSX.Element {
const { t } = useTranslation('management');
const inviteUrl = invite?.url ?? '';
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);
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;
})();
console.debug('[Invites] Fetching layouts', target);
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[]) : [];
console.debug('[Invites] Layout response items', items);
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]);
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[0];
}, [availableLayouts, selectedLayoutId]);
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]);
const effectiveInstructions = instructions.filter((entry) => entry.trim().length > 0);
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,
}));
}
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,
};
await onSave(payload);
}
async function handleResetClick() {
await onReset();
}
function handleDownload(format: string, url: string) {
const link = document.createElement('a');
link.href = url;
link.download = `${invite?.token ?? 'invite'}-${format.toLowerCase()}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function handlePrint(preferredUrl?: string | null) {
const url = preferredUrl ?? activeLayout?.download_urls?.pdf ?? activeLayout?.download_urls?.a4 ?? null;
if (!url) {
setError(t('invites.labels.noPrintSource', 'Keine druckbare Version verfügbar.'));
return;
}
const printWindow = window.open(url, '_blank', 'noopener,noreferrer');
printWindow?.focus();
}
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]);
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-slate-900">{t('invites.customizer.heading', 'Layout anpassen')}</h2>
<p className="text-sm text-slate-600">{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}
<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-slate-100 bg-white/90 p-5 shadow-sm">
<header>
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-500">{t('invites.customizer.sections.layouts', 'Layouts')}</h3>
<p className="text-xs text-slate-500">{t('invites.customizer.sections.layoutsHint', 'Wähle eine Vorlage als Basis aus. Du kannst jederzeit wechseln.')}</p>
</header>
<div className="flex snap-x gap-3 overflow-x-auto pb-2">
{availableLayouts.map((layout) => (
<button
key={layout.id}
type="button"
onClick={() => handleLayoutSelect(layout)}
className={`min-w-[200px] shrink-0 rounded-xl border p-3 text-left transition-all ${layout.id === selectedLayoutId ? 'border-amber-400 bg-amber-50 shadow' : 'border-slate-200 bg-white hover:border-amber-200'}`}
>
<div className="text-sm font-semibold text-slate-900">{layout.name || t('invites.customizer.layoutFallback', 'Layout')}</div>
{layout.description ? <div className="mt-1 text-xs text-slate-500">{layout.description}</div> : null}
<div className="mt-3 flex flex-wrap gap-2 text-xs text-slate-500">
{layout.formats?.map((format) => (
<span key={`${layout.id}-${format}`} className="rounded-full border border-amber-200 px-2 py-0.5 text-amber-600">{String(format).toUpperCase()}</span>
))}
</div>
</button>
))}
</div>
</section>
<section className="space-y-4 rounded-2xl border border-slate-100 bg-white/90 p-5 shadow-sm">
<header>
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-500">{t('invites.customizer.sections.text', 'Texte')}</h3>
</header>
<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>
</section>
<section className="space-y-4 rounded-2xl border border-slate-100 bg-white/90 p-5 shadow-sm">
<header>
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-500">{t('invites.customizer.sections.instructions', 'Schritt-für-Schritt')}</h3>
<p className="text-xs text-slate-500">{t('invites.customizer.sections.instructionsHint', 'Helft euren Gästen mit klaren Aufgaben. Maximal fünf Punkte.')}</p>
</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>
</div>
</header>
<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-slate-500 hover:text-rose-500"
onClick={() => handleRemoveInstruction(index)}
>
×
</Button>
</div>
))}
</div>
</section>
<section className="space-y-4 rounded-2xl border border-slate-100 bg-white/90 p-5 shadow-sm">
<header>
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-500">{t('invites.customizer.sections.branding', 'Branding')}</h3>
</header>
<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-slate-200 bg-slate-50 p-3">
<img src={form.logo_data_url} alt="Logo" className="h-12 w-12 rounded border border-slate-200 object-contain" />
<Button type="button" variant="ghost" onClick={handleLogoRemove} className="text-rose-500 hover:text-rose-600">
{t('invites.customizer.actions.removeLogo', 'Logo entfernen')}
</Button>
</div>
) : (
<label className="flex cursor-pointer items-center gap-3 rounded-lg border border-dashed border-slate-300 bg-slate-50/80 px-4 py-3 text-sm text-slate-500 hover:border-amber-200">
<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>
</section>
<div className="flex justify-end gap-2">
<Button type="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="submit" disabled={saving || resetting} className="bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 text-white">
{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>
</form>
<aside className="space-y-4 rounded-2xl border border-slate-100 bg-white/90 p-5 shadow-sm">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-500">{t('invites.customizer.preview.title', 'Live-Vorschau')}</h3>
<p className="text-xs text-slate-500">{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={() => handlePrint(activeLayout.download_urls?.pdf ?? activeLayout.download_urls?.a4)}>
<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" onClick={() => handleDownload(key, url)}>
<Download className="mr-1 h-4 w-4" />
{key.toUpperCase()}
</Button>
);
})}
</div>
</div>
<div className="space-y-4 rounded-xl border border-slate-200 bg-white p-5 shadow-inner">
<div className="rounded-xl p-5 text-slate-900" 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-white/80 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 bg-white/80 p-4">
<div className="text-xs font-semibold uppercase tracking-wide text-slate-500">{form.instructions_heading}</div>
<ol className="grid gap-2 text-sm text-slate-700">
{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 flex flex-col gap-2 rounded-xl bg-white/80 p-4 text-sm text-slate-700">
<span className="text-xs font-semibold uppercase tracking-wide text-slate-500">{form.link_heading}</span>
<div className="flex items-center justify-between gap-2">
<span className="truncate font-medium" style={{ color: form.accent_color ?? '#2563EB' }}>
{form.link_label || inviteUrl}
</span>
<span className="rounded-full bg-slate-900/90 px-3 py-1 text-xs text-white">QR</span>
</div>
<Button size="sm" className="w-full bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 text-white">
{form.cta_label || t('tasks.customizer.defaults.ctaLabel')}
</Button>
</div>
</div>
{form.logo_data_url ? (
<div className="flex items-center justify-center rounded-lg border border-dashed border-slate-200 bg-slate-50 py-4">
<img src={form.logo_data_url} alt="Logo preview" className="max-h-16 object-contain" />
</div>
) : null}
</div>
</aside>
</div>
</div>
);
}
function CardPlaceholder({ title, description }: { title: string; description: string }) {
return (
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50/80 p-10 text-center text-sm text-slate-500">
<h3 className="text-base font-semibold text-slate-700">{title}</h3>
<p className="mt-2 text-sm text-slate-500">{description}</p>
</div>
);
}
function SmileIcon(): 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>
);
}