feat(admin): eigene Vorlage fuer die Willkommensmail mit Platzhaltern, Vorschau und Testmail
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user