feat(admin): eigene Vorlage fuer die Willkommensmail mit Platzhaltern, Vorschau und Testmail
Tessera CI/CD / Lint & Type Check (push) Successful in 50s
Tessera CI/CD / Tests (push) Successful in 1m22s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m10s

Administrator -> Willkommensmail: Betreff, Ueberschrift, Einleitung, Abschluss je
Mandant (Tabelle WelcomeMailTemplate, RLS je Mandant, Migration 20260930150000);
Platzhalter {{name}} {{vorname}} {{benutzername}} {{email}} {{adresse}} {{firma}},
unbekannte -> 400 bzw. Hinweis beim Tippen; Werte escaped, Vorlage reiner Text.
Live-Vorschau per API gerendert, Testmail an die eigene Adresse ohne Token,
Zuruecksetzen auf Standard. Feste Bausteine (Kopf, Zugangsdaten, Anmeldehinweis,
Knoepfe, Fusszeile) bleiben immer drin.
Kopf: Wellenzelle dunkel statt weiss, Streifen 600x40, Inhalt 24 px naeher –
keine weisse Luecke, wenn OWA das CID-Bild nicht zeigt.
Lokal nachgewiesen: Hinweis/Sperre bei {{xyz}}, Speichern, Testmail (Link nur
/login), echte Mail mit eigener Vorlage und 7-Tage-Link.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 16:49:17 +02:00
parent 31d514b7ca
commit e10da76259
27 changed files with 2471 additions and 82 deletions
@@ -83,6 +83,18 @@ export function AdminSidebar() {
</svg>
),
},
{
label: t('admin.welcomeMail'),
href: '/admin/welcome-mail',
show: true,
icon: (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 13V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
<path d="m16 19 2 2 4-4" />
</svg>
),
},
{
label: t('admin.groups'),
href: '/admin/groups',
@@ -0,0 +1,227 @@
import { DEFAULT_WELCOME_MAIL_TEXTS } from '@tessera/shared';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import de from '@/messages/de.json';
// Namensraumfaehiger next-intl-Mock gegen die echten deutschen Texte:
// Punktschluessel werden aufgeloest, {param} wird eingesetzt.
vi.mock('next-intl', () => ({
useLocale: () => 'de',
useTranslations: (ns: string) => (key: string, params?: Record<string, unknown>) => {
let val: unknown = de;
for (const part of `${ns}.${key}`.split('.')) {
val = (val as Record<string, unknown> | undefined)?.[part];
}
if (typeof val !== 'string') return `${ns}.${key}`;
return params
? val.replace(/\{(\w+)\}/g, (_m, name: string) => String(params[name] ?? ''))
: val;
},
}));
const api = vi.hoisted(() => ({
fetchWelcomeMailTemplate: vi.fn(),
saveWelcomeMailTemplate: vi.fn(),
resetWelcomeMailTemplate: vi.fn(),
previewWelcomeMail: vi.fn(),
sendWelcomeMailTest: vi.fn(),
}));
vi.mock('@/lib/welcome-mail-template-api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/welcome-mail-template-api')>();
return { ...actual, ...api };
});
import { WelcomeMailTemplateRequestError } from '@/lib/welcome-mail-template-api';
import { WelcomeMailTemplateEditor } from './welcome-mail-template-editor';
const defaults = { ...DEFAULT_WELCOME_MAIL_TEXTS };
const custom = {
subject: 'Hallo {{vorname}}',
heading: 'Willkommen, {{name}}!',
intro: 'Eigene Einleitung',
closing: 'Ihr IT-Team',
};
function state(isCustom: boolean) {
return {
custom: isCustom,
texts: isCustom ? { ...custom } : { ...defaults },
defaults: { ...defaults },
updatedAt: isCustom ? '2026-09-30T10:00:00.000Z' : null,
updatedBy: isCustom ? 'ada' : null,
};
}
beforeEach(() => {
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0);
return 0;
});
api.fetchWelcomeMailTemplate.mockResolvedValue(state(false));
api.previewWelcomeMail.mockImplementation(async (texts: typeof defaults, account: string) => ({
subject: texts.subject,
html: `<html><body>Vorschau ${account}</body></html>`,
text: '',
}));
api.saveWelcomeMailTemplate.mockImplementation(async (texts: typeof defaults) => ({
...state(true),
texts: { ...texts },
}));
api.resetWelcomeMailTemplate.mockResolvedValue(state(false));
api.sendWelcomeMailTest.mockResolvedValue({ success: true, to: 'ada@example.invalid' });
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.unstubAllGlobals();
});
async function renderLoaded() {
render(<WelcomeMailTemplateEditor />);
return (await screen.findByLabelText('Betreff')) as HTMLInputElement;
}
describe('WelcomeMailTemplateEditor', () => {
it('laedt die Standardtexte ins Formular und zeigt Hinweis auf die festen Bausteine', async () => {
const subject = await renderLoaded();
expect(subject.value).toBe(defaults.subject);
expect((screen.getByLabelText('Überschrift') as HTMLInputElement).value).toBe(defaults.heading);
expect((screen.getByLabelText('Einleitungstext') as HTMLTextAreaElement).value).toBe(
defaults.intro,
);
expect((screen.getByLabelText('Abschlusstext') as HTMLTextAreaElement).value).toBe(
defaults.closing,
);
expect(screen.getByTestId('welcome-template-status').textContent).toContain(
'Zurzeit gelten die Standardtexte.',
);
expect(
screen.getByText(/So kann eine eigene Vorlage die Anmeldung nicht kaputt machen/),
).toBeTruthy();
});
it('Hinweisbox Platzhalter: Tabelle mit allen sechs Platzhaltern, Bedeutung und Beispiel', async () => {
await renderLoaded();
const box = screen.getByRole('region', { name: 'Platzhalter' });
const rows = within(box).getAllByRole('row');
expect(rows).toHaveLength(7); // Kopf + 6
for (const token of [
'{{name}}',
'{{vorname}}',
'{{benutzername}}',
'{{email}}',
'{{adresse}}',
'{{firma}}',
]) {
expect(within(box).getByRole('button', { name: `${token} einfügen` })).toBeTruthy();
}
expect(within(box).getByText('Name Ihres Mandanten')).toBeTruthy();
expect(within(box).getByText('max.mustermann@example.com')).toBeTruthy();
});
it('Klick auf einen Platzhalter fuegt ihn an der Cursorposition des zuletzt fokussierten Feldes ein', async () => {
const subject = await renderLoaded();
fireEvent.change(subject, { target: { value: 'Hallo bei uns' } });
subject.focus();
subject.setSelectionRange(6, 6);
fireEvent.click(screen.getByRole('button', { name: '{{vorname}} einfügen' }));
expect(subject.value).toBe('Hallo {{vorname}} bei uns');
});
it('unbekannter Platzhalter wird benannt, Speichern und Testmail sind gesperrt', async () => {
const user = userEvent.setup();
await renderLoaded();
const heading = screen.getByLabelText('Überschrift');
await user.clear(heading);
fireEvent.change(heading, { target: { value: 'Hallo {{xyz}}' } });
expect(await screen.findByText('Unbekannter Platzhalter {{xyz}}')).toBeTruthy();
expect((screen.getByRole('button', { name: 'Speichern' }) as HTMLButtonElement).disabled).toBe(
true,
);
expect(
(screen.getByRole('button', { name: 'Testmail an mich senden' }) as HTMLButtonElement)
.disabled,
).toBe(true);
});
it('Speichern schickt die vier Texte und meldet Erfolg; Status zeigt die eigene Vorlage', async () => {
const user = userEvent.setup();
const subject = await renderLoaded();
fireEvent.change(subject, { target: { value: 'Neu für {{firma}}' } });
await user.click(screen.getByRole('button', { name: 'Speichern' }));
expect(api.saveWelcomeMailTemplate).toHaveBeenCalledWith({
...defaults,
subject: 'Neu für {{firma}}',
});
expect((await screen.findByRole('status')).textContent).toContain(
'Die Vorlage wurde gespeichert.',
);
expect(screen.getByTestId('welcome-template-status').textContent).toContain(
'Eigene Vorlage aktiv',
);
expect(screen.getByTestId('welcome-template-status').textContent).toContain('von ada');
});
it('Fehler der API beim Speichern wird angezeigt (400 mit Meldung)', async () => {
const user = userEvent.setup();
api.saveWelcomeMailTemplate.mockRejectedValueOnce(
new WelcomeMailTemplateRequestError(400, ['Betreff: Unbekannter Platzhalter {{abc}}']),
);
await renderLoaded();
await user.click(screen.getByRole('button', { name: 'Speichern' }));
expect((await screen.findByRole('alert')).textContent).toBe(
'Betreff: Unbekannter Platzhalter {{abc}}',
);
});
it('Auf Standard zuruecksetzen: Rueckfrage, DELETE, danach Standardtexte im Formular', async () => {
const user = userEvent.setup();
api.fetchWelcomeMailTemplate.mockResolvedValue(state(true));
const subject = await renderLoaded();
expect(subject.value).toBe(custom.subject);
await user.click(screen.getByRole('button', { name: 'Auf Standard zurücksetzen' }));
const dialog = screen.getByRole('alertdialog');
expect(api.resetWelcomeMailTemplate).not.toHaveBeenCalled();
await user.click(within(dialog).getByRole('button', { name: 'Zurücksetzen' }));
expect(api.resetWelcomeMailTemplate).toHaveBeenCalledTimes(1);
await waitFor(() => expect(subject.value).toBe(defaults.subject));
expect(screen.getByTestId('welcome-template-status').textContent).toContain('Standardtexte');
expect(screen.queryByRole('alertdialog')).toBeNull();
});
it('Vorschau: iframe mit dem von der API gerenderten HTML, Umschalter lokales Konto', async () => {
const user = userEvent.setup();
await renderLoaded();
const frame = screen.getByTitle('Vorschau der Willkommensmail') as HTMLIFrameElement;
await waitFor(() => expect(frame.getAttribute('srcdoc')).toContain('Vorschau directory'));
expect(frame.getAttribute('sandbox')).toBe('');
await user.click(screen.getByRole('button', { name: 'Lokales Konto' }));
await waitFor(() => expect(frame.getAttribute('srcdoc')).toContain('Vorschau local'));
expect(api.previewWelcomeMail).toHaveBeenLastCalledWith(defaults, 'local', expect.anything());
expect(screen.getByTestId('welcome-preview-subject').textContent).toBe(defaults.subject);
});
it('Testmail an mich: sendet den aktuellen (ungespeicherten) Inhalt und nennt die Adresse', async () => {
const user = userEvent.setup();
const subject = await renderLoaded();
fireEvent.change(subject, { target: { value: 'Ungespeichert' } });
await user.click(screen.getByRole('button', { name: 'Testmail an mich senden' }));
expect(api.sendWelcomeMailTest).toHaveBeenCalledWith({ ...defaults, subject: 'Ungespeichert' });
expect(api.saveWelcomeMailTemplate).not.toHaveBeenCalled();
expect((await screen.findByRole('status')).textContent).toBe(
'Die Testmail wurde an ada@example.invalid gesendet.',
);
});
it('Ladefehler wird angezeigt', async () => {
api.fetchWelcomeMailTemplate.mockRejectedValueOnce(new Error('down'));
render(<WelcomeMailTemplateEditor />);
expect((await screen.findByRole('alert')).textContent).toContain('konnte nicht geladen werden');
});
});
@@ -0,0 +1,481 @@
'use client';
import {
findUnknownWelcomeMailPlaceholders,
WELCOME_MAIL_LIMITS,
WELCOME_MAIL_PLACEHOLDERS,
type WelcomeMailPlaceholder,
type WelcomeMailTexts,
} from '@tessera/shared';
import { useLocale, useTranslations } from 'next-intl';
import { useEffect, useRef, useState } from 'react';
import {
fetchWelcomeMailTemplate,
previewWelcomeMail,
resetWelcomeMailTemplate,
saveWelcomeMailTemplate,
sendWelcomeMailTest,
type WelcomeMailPreview,
WelcomeMailTemplateRequestError,
type WelcomeMailTemplateState,
} from '@/lib/welcome-mail-template-api';
type Field = keyof WelcomeMailTexts;
type PreviewAccount = 'directory' | 'local';
type Feedback = { type: 'success' | 'error'; message: string } | null;
const FIELDS: Field[] = ['subject', 'heading', 'intro', 'closing'];
const MULTILINE: Record<Field, boolean> = {
subject: false,
heading: false,
intro: true,
closing: true,
};
/** Verzoegerung der Live-Vorschau nach der letzten Eingabe. */
const PREVIEW_DELAY_MS = 400;
/**
* Administrator → Willkommensmail: eigene Vorlage je Mandant.
*
* Vier Textfelder (Betreff, Ueberschrift, Einleitung, Abschluss), eine
* Hinweisbox mit den Platzhaltern (Klick fuegt an der Cursorposition des
* zuletzt benutzten Feldes ein), eine Live-Vorschau (von der API gerendert,
* also dieselbe Mail wie im Versand) mit Umschalter Verzeichniskonto/lokales
* Konto, "Testmail an mich senden" und "Auf Standard zuruecksetzen".
*
* Unbekannte Platzhalter benennt das Formular schon beim Tippen; die API
* lehnt sie beim Speichern ohnehin mit 400 ab (Meldung wird angezeigt).
*/
export function WelcomeMailTemplateEditor() {
const t = useTranslations('admin.welcomeMail');
const locale = useLocale();
const [state, setState] = useState<WelcomeMailTemplateState | null>(null);
const [form, setForm] = useState<WelcomeMailTexts | null>(null);
const [loadFailed, setLoadFailed] = useState(false);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [resetting, setResetting] = useState(false);
const [confirmReset, setConfirmReset] = useState(false);
const [feedback, setFeedback] = useState<Feedback>(null);
const [account, setAccount] = useState<PreviewAccount>('directory');
const [preview, setPreview] = useState<WelcomeMailPreview | null>(null);
const [previewFailed, setPreviewFailed] = useState(false);
const fieldRefs = useRef<Partial<Record<Field, HTMLInputElement | HTMLTextAreaElement | null>>>(
{},
);
const lastFocused = useRef<Field>('intro');
useEffect(() => {
fetchWelcomeMailTemplate()
.then((loaded) => {
setState(loaded);
setForm({ ...loaded.texts });
})
.catch(() => setLoadFailed(true));
}, []);
// ── Pruefung im Formular (dieselben Regeln wie die API) ───────────────
const fieldErrors = (field: Field, value: string): string[] => {
const errors: string[] = [];
if ((field === 'subject' || field === 'heading') && !value.trim()) {
errors.push(t('errors.required'));
}
if (value.length > WELCOME_MAIL_LIMITS[field]) {
errors.push(t('errors.tooLong', { max: WELCOME_MAIL_LIMITS[field] }));
}
const unknown = findUnknownWelcomeMailPlaceholders(value);
if (unknown.length === 1) errors.push(t('errors.unknownPlaceholder', { token: unknown[0] }));
if (unknown.length > 1) {
errors.push(t('errors.unknownPlaceholders', { tokens: unknown.join(', ') }));
}
return errors;
};
const errorsByField = form
? (Object.fromEntries(FIELDS.map((f) => [f, fieldErrors(f, form[f])])) as Record<
Field,
string[]
>)
: null;
const hasErrors = errorsByField ? FIELDS.some((f) => errorsByField[f].length > 0) : false;
const dirty = !!form && !!state && FIELDS.some((f) => form[f] !== state.texts[f]);
// ── Live-Vorschau ─────────────────────────────────────────────────────
useEffect(() => {
if (!form || hasErrors) return;
const controller = new AbortController();
const timer = setTimeout(() => {
previewWelcomeMail(form, account, controller.signal)
.then((result) => {
setPreview(result);
setPreviewFailed(false);
})
.catch((error: unknown) => {
if ((error as { name?: string })?.name === 'AbortError') return;
setPreviewFailed(true);
});
}, PREVIEW_DELAY_MS);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [form, account, hasErrors]);
// ── Aktionen ──────────────────────────────────────────────────────────
const serverMessage = (error: unknown, fallback: string): string => {
if (error instanceof WelcomeMailTemplateRequestError && error.messages.length > 0) {
return error.messages.join(' ');
}
return fallback;
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!form || hasErrors) return;
setSaving(true);
setFeedback(null);
try {
const saved = await saveWelcomeMailTemplate(form);
setState(saved);
setForm({ ...saved.texts });
setFeedback({ type: 'success', message: t('saved') });
} catch (error) {
setFeedback({ type: 'error', message: serverMessage(error, t('errors.save')) });
} finally {
setSaving(false);
}
};
const handleReset = async () => {
if (!state) return;
setResetting(true);
setFeedback(null);
try {
const next = state.custom ? await resetWelcomeMailTemplate() : state;
setState(next);
setForm({ ...next.defaults });
setConfirmReset(false);
setFeedback({ type: 'success', message: t('resetDone') });
} catch (error) {
setFeedback({ type: 'error', message: serverMessage(error, t('errors.reset')) });
} finally {
setResetting(false);
}
};
const handleTest = async () => {
if (!form || hasErrors) return;
setTesting(true);
setFeedback(null);
try {
const result = await sendWelcomeMailTest(form);
setFeedback({ type: 'success', message: t('testSent', { email: result.to }) });
} catch (error) {
setFeedback({ type: 'error', message: serverMessage(error, t('errors.test')) });
} finally {
setTesting(false);
}
};
/** Fuegt `{{name}}` an der Cursorposition des zuletzt benutzten Feldes ein. */
const insertPlaceholder = (placeholder: WelcomeMailPlaceholder) => {
if (!form) return;
const field = lastFocused.current;
const token = `{{${placeholder}}}`;
const el = fieldRefs.current[field];
const value = form[field];
const start = el?.selectionStart ?? value.length;
const end = el?.selectionEnd ?? value.length;
setForm({ ...form, [field]: value.slice(0, start) + token + value.slice(end) });
const caret = start + token.length;
requestAnimationFrame(() => {
el?.focus();
el?.setSelectionRange(caret, caret);
});
};
// ── Darstellung ───────────────────────────────────────────────────────
if (loadFailed) {
return (
<p role="alert" className="text-sm text-destructive">
{t('errors.load')}
</p>
);
}
if (!form || !state || !errorsByField) {
return <p className="text-sm text-muted-foreground">{t('loading')}</p>;
}
const inputClass =
'w-full rounded border border-border bg-background px-3 text-sm text-foreground';
const statusText = state.custom
? state.updatedAt
? t(state.updatedBy ? 'statusCustomBy' : 'statusCustom', {
date: new Date(state.updatedAt).toLocaleString(locale === 'en' ? 'en-GB' : 'de-DE', {
dateStyle: 'medium',
timeStyle: 'short',
}),
user: state.updatedBy ?? '',
})
: t('statusCustom', { date: '' })
: t('statusDefault');
return (
<div className="space-y-6">
<div>
<h1 className="mb-2 text-lg font-semibold text-foreground">{t('title')}</h1>
<p className="max-w-3xl text-sm text-muted-foreground">{t('description')}</p>
</div>
{/* Feste Bausteine */}
<section
aria-labelledby="welcome-fixed-title"
className="max-w-3xl rounded-lg border border-border bg-muted/40 p-4"
>
<h2 id="welcome-fixed-title" className="mb-1 text-sm font-semibold text-foreground">
{t('fixed.title')}
</h2>
<p className="text-sm text-muted-foreground">{t('fixed.body')}</p>
</section>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
{/* Formular */}
<form onSubmit={handleSave} className="min-w-0 space-y-4" noValidate>
<p
className={`text-sm ${state.custom ? 'text-foreground' : 'text-muted-foreground'}`}
data-testid="welcome-template-status"
>
{statusText}
{dirty && <span className="ml-2 font-medium">· {t('unsaved')}</span>}
</p>
{FIELDS.map((field) => {
const id = `welcome-${field}`;
const errors = errorsByField[field];
const describedBy = [`${id}-help`, errors.length ? `${id}-error` : '']
.filter(Boolean)
.join(' ');
const common = {
id,
value: form[field],
'aria-invalid': errors.length > 0 || undefined,
'aria-describedby': describedBy,
onFocus: () => {
lastFocused.current = field;
},
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const value = e.target.value;
setForm((prev) => (prev ? { ...prev, [field]: value } : prev));
},
};
return (
<div key={field}>
<label htmlFor={id} className="mb-1 block text-sm text-foreground">
{t(`fields.${field}`)}
</label>
{MULTILINE[field] ? (
<textarea
{...common}
ref={(el) => {
fieldRefs.current[field] = el;
}}
rows={field === 'intro' ? 6 : 5}
className={`${inputClass} py-2`}
/>
) : (
<input
{...common}
ref={(el) => {
fieldRefs.current[field] = el;
}}
type="text"
className={`${inputClass} h-9`}
/>
)}
<div className="mt-1 flex justify-between gap-3 text-xs text-muted-foreground">
<span id={`${id}-help`}>{t(`fields.${field}Help`)}</span>
<span className="shrink-0 tabular-nums">
{t('counter', { count: form[field].length, max: WELCOME_MAIL_LIMITS[field] })}
</span>
</div>
{errors.length > 0 && (
<p id={`${id}-error`} className="mt-1 text-sm text-destructive">
{errors.join(' ')}
</p>
)}
</div>
);
})}
<div className="flex flex-wrap gap-3 pt-2">
<button type="submit" disabled={saving || hasErrors} className="btn btn-primary">
{saving ? t('saving') : t('save')}
</button>
<button
type="button"
disabled={testing || hasErrors}
onClick={handleTest}
className="btn btn-secondary"
>
{testing ? t('testSending') : t('test')}
</button>
<button
type="button"
disabled={resetting}
onClick={() => setConfirmReset(true)}
className="btn btn-secondary"
>
{t('reset')}
</button>
</div>
<p className="text-xs text-muted-foreground">{t('testHelp')}</p>
{confirmReset && (
<div
role="alertdialog"
aria-labelledby="welcome-reset-title"
aria-describedby="welcome-reset-body"
className="max-w-xl rounded-lg border border-border bg-background p-4"
>
<h2 id="welcome-reset-title" className="mb-1 text-sm font-semibold text-foreground">
{t('resetConfirm.title')}
</h2>
<p id="welcome-reset-body" className="mb-3 text-sm text-muted-foreground">
{t('resetConfirm.body')}
</p>
<div className="flex gap-3">
<button
type="button"
disabled={resetting}
onClick={handleReset}
className="btn btn-primary"
>
{t('resetConfirm.confirm')}
</button>
<button
type="button"
onClick={() => setConfirmReset(false)}
className="btn btn-secondary"
>
{t('resetConfirm.cancel')}
</button>
</div>
</div>
)}
{feedback && (
<p
role={feedback.type === 'error' ? 'alert' : 'status'}
className={`text-sm ${feedback.type === 'error' ? 'text-destructive' : ''}`}
style={feedback.type === 'success' ? { color: 'oklch(0.40 0.15 148)' } : undefined}
>
{feedback.message}
</p>
)}
{/* Platzhalter */}
<section
aria-labelledby="welcome-placeholders-title"
className="rounded-lg border border-border p-4"
>
<h2
id="welcome-placeholders-title"
className="mb-1 text-sm font-semibold text-foreground"
>
{t('placeholders.title')}
</h2>
<p className="mb-3 text-sm text-muted-foreground">{t('placeholders.intro')}</p>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th scope="col" className="py-2 pr-3 font-medium">
{t('placeholders.colPlaceholder')}
</th>
<th scope="col" className="py-2 pr-3 font-medium">
{t('placeholders.colMeaning')}
</th>
<th scope="col" className="py-2 font-medium">
{t('placeholders.colExample')}
</th>
</tr>
</thead>
<tbody>
{WELCOME_MAIL_PLACEHOLDERS.map((name) => (
<tr key={name} className="border-b border-border last:border-0 align-top">
<td className="py-2 pr-3">
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => insertPlaceholder(name)}
title={t('placeholders.insertHint')}
aria-label={t('placeholders.insert', { token: `{{${name}}}` })}
className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground hover:bg-sidebar-hover"
>
{`{{${name}}}`}
</button>
</td>
<td className="py-2 pr-3 text-foreground">
{t(`placeholders.items.${name}.meaning`)}
</td>
<td className="py-2 text-muted-foreground">
{t(`placeholders.items.${name}.example`)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mt-3 text-xs text-muted-foreground">{t('placeholders.rules')}</p>
</section>
</form>
{/* Vorschau */}
<section aria-labelledby="welcome-preview-title" className="min-w-0">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<h2 id="welcome-preview-title" className="text-sm font-semibold text-foreground">
{t('preview.title')}
</h2>
<fieldset className="flex gap-1">
<legend className="sr-only">{t('preview.accountLabel')}</legend>
{(['directory', 'local'] as const).map((kind) => (
<button
key={kind}
type="button"
aria-pressed={account === kind}
onClick={() => setAccount(kind)}
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
account === kind
? 'border-foreground bg-foreground text-background'
: 'border-border text-foreground hover:bg-sidebar-hover'
}`}
>
{t(`preview.${kind}`)}
</button>
))}
</fieldset>
</div>
<p className="mb-2 text-sm text-muted-foreground">
{t('preview.subject')}{' '}
<span className="font-medium text-foreground" data-testid="welcome-preview-subject">
{preview?.subject ?? ''}
</span>
</p>
{hasErrors && <p className="mb-2 text-sm text-destructive">{t('preview.blocked')}</p>}
{previewFailed && <p className="mb-2 text-sm text-destructive">{t('preview.error')}</p>}
<iframe
title={t('preview.frameTitle')}
sandbox=""
srcDoc={preview?.html ?? ''}
className="h-[760px] w-full rounded-lg border border-border bg-white"
/>
<p className="mt-2 text-xs text-muted-foreground">{t('preview.sampleHint')}</p>
</section>
</div>
</div>
);
}