feat: add visual WYSIWYG email template editor (#229)
Replace raw HTML textarea with TipTap-based rich text editor for email templates. Includes formatting toolbar, variable insertion dropdown, source/visual toggle, and dark mode support. Add Mailhog service to docker-compose for local email testing.
This commit is contained in:
@@ -98,6 +98,16 @@ services:
|
||||
networks:
|
||||
- picpeak-network
|
||||
|
||||
mailhog:
|
||||
image: mailhog/mailhog:latest
|
||||
container_name: picpeak-mailhog
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${MAILHOG_SMTP_PORT:-1025}:1025"
|
||||
- "${MAILHOG_UI_PORT:-8025}:8025"
|
||||
networks:
|
||||
- picpeak-network
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import HardBreak from '@tiptap/extension-hard-break';
|
||||
import TextAlign from '@tiptap/extension-text-align';
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
List,
|
||||
ListOrdered,
|
||||
Link as LinkIcon,
|
||||
Heading2,
|
||||
Heading3,
|
||||
Quote,
|
||||
Minus,
|
||||
Undo,
|
||||
Redo,
|
||||
RemoveFormatting,
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
Code2,
|
||||
Variable,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface EmailTemplateEditorProps {
|
||||
content: string;
|
||||
onChange: (content: string) => void;
|
||||
variables?: string[];
|
||||
}
|
||||
|
||||
export const EmailTemplateEditor: React.FC<EmailTemplateEditorProps> = ({
|
||||
content,
|
||||
onChange,
|
||||
variables = [],
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isSourceMode, setIsSourceMode] = useState(false);
|
||||
const [sourceContent, setSourceContent] = useState(content);
|
||||
const [linkUrl, setLinkUrl] = useState('');
|
||||
const [showLinkDialog, setShowLinkDialog] = useState(false);
|
||||
const [showVariables, setShowVariables] = useState(false);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
hardBreak: false,
|
||||
}),
|
||||
HardBreak.configure({
|
||||
keepMarks: true,
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: {
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
}),
|
||||
TextAlign.configure({
|
||||
types: ['heading', 'paragraph'],
|
||||
alignments: ['left', 'center', 'right'],
|
||||
defaultAlignment: 'left',
|
||||
}),
|
||||
],
|
||||
content,
|
||||
onUpdate: ({ editor }) => {
|
||||
const html = editor.getHTML();
|
||||
onChange(html);
|
||||
setSourceContent(html);
|
||||
},
|
||||
});
|
||||
|
||||
// Sync editor when content prop changes externally
|
||||
React.useEffect(() => {
|
||||
if (editor && !isSourceMode && content !== editor.getHTML()) {
|
||||
editor.commands.setContent(content);
|
||||
setSourceContent(content);
|
||||
}
|
||||
}, [content, editor, isSourceMode]);
|
||||
|
||||
const handleSourceChange = useCallback((value: string) => {
|
||||
setSourceContent(value);
|
||||
onChange(value);
|
||||
}, [onChange]);
|
||||
|
||||
const switchToVisual = useCallback(() => {
|
||||
if (editor) {
|
||||
editor.commands.setContent(sourceContent);
|
||||
}
|
||||
setIsSourceMode(false);
|
||||
}, [editor, sourceContent]);
|
||||
|
||||
const switchToSource = useCallback(() => {
|
||||
if (editor) {
|
||||
setSourceContent(editor.getHTML());
|
||||
}
|
||||
setIsSourceMode(true);
|
||||
}, [editor]);
|
||||
|
||||
const insertVariable = useCallback((variable: string) => {
|
||||
const tag = `{{${variable}}}`;
|
||||
if (isSourceMode) {
|
||||
// Insert at cursor in textarea
|
||||
const textarea = document.querySelector('[data-email-source]') as HTMLTextAreaElement;
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const newContent = sourceContent.substring(0, start) + tag + sourceContent.substring(end);
|
||||
setSourceContent(newContent);
|
||||
onChange(newContent);
|
||||
// Restore cursor position after React re-render
|
||||
requestAnimationFrame(() => {
|
||||
textarea.selectionStart = textarea.selectionEnd = start + tag.length;
|
||||
textarea.focus();
|
||||
});
|
||||
}
|
||||
} else if (editor) {
|
||||
editor.chain().focus().insertContent(tag).run();
|
||||
}
|
||||
setShowVariables(false);
|
||||
}, [editor, isSourceMode, sourceContent, onChange]);
|
||||
|
||||
const addLink = useCallback(() => {
|
||||
if (linkUrl && editor) {
|
||||
editor.chain().focus().setLink({ href: linkUrl }).run();
|
||||
setLinkUrl('');
|
||||
setShowLinkDialog(false);
|
||||
}
|
||||
}, [editor, linkUrl]);
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const MenuButton: React.FC<{
|
||||
onClick: () => void;
|
||||
active?: boolean;
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
disabled?: boolean;
|
||||
}> = ({ onClick, active, children, title, disabled }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`p-1.5 rounded hover:bg-neutral-100 dark:hover:bg-neutral-600 transition-colors ${
|
||||
active
|
||||
? 'bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-300'
|
||||
: 'text-neutral-700 dark:text-neutral-300'
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
title={title}
|
||||
type="button"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border border-neutral-300 dark:border-neutral-600 rounded-lg overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="border-b border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800">
|
||||
<div className="flex items-center justify-between p-2">
|
||||
{/* Formatting buttons */}
|
||||
<div className="flex items-center gap-0.5 flex-wrap">
|
||||
{!isSourceMode && (
|
||||
<>
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
active={editor.isActive('heading', { level: 2 })}
|
||||
title="Heading 2"
|
||||
>
|
||||
<Heading2 className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
active={editor.isActive('heading', { level: 3 })}
|
||||
title="Heading 3"
|
||||
>
|
||||
<Heading3 className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<div className="w-px h-5 bg-neutral-300 dark:bg-neutral-600 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
active={editor.isActive('bold')}
|
||||
title={`${t('email.editor.bold')} (Ctrl+B)`}
|
||||
>
|
||||
<Bold className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
active={editor.isActive('italic')}
|
||||
title={`${t('email.editor.italic')} (Ctrl+I)`}
|
||||
>
|
||||
<Italic className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<div className="w-px h-5 bg-neutral-300 dark:bg-neutral-600 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
active={editor.isActive('bulletList')}
|
||||
title={t('email.editor.bulletList')}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
active={editor.isActive('orderedList')}
|
||||
title={t('email.editor.numberedList')}
|
||||
>
|
||||
<ListOrdered className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
active={editor.isActive('blockquote')}
|
||||
title={t('email.editor.blockquote')}
|
||||
>
|
||||
<Quote className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<div className="w-px h-5 bg-neutral-300 dark:bg-neutral-600 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => setShowLinkDialog(true)}
|
||||
active={editor.isActive('link')}
|
||||
title={t('email.editor.link')}
|
||||
>
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().setHorizontalRule().run()}
|
||||
title={t('email.editor.horizontalRule')}
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<div className="w-px h-5 bg-neutral-300 dark:bg-neutral-600 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('left').run()}
|
||||
active={editor.isActive({ textAlign: 'left' })}
|
||||
title={t('email.editor.alignLeft')}
|
||||
>
|
||||
<AlignLeft className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('center').run()}
|
||||
active={editor.isActive({ textAlign: 'center' })}
|
||||
title={t('email.editor.alignCenter')}
|
||||
>
|
||||
<AlignCenter className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('right').run()}
|
||||
active={editor.isActive({ textAlign: 'right' })}
|
||||
title={t('email.editor.alignRight')}
|
||||
>
|
||||
<AlignRight className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<div className="w-px h-5 bg-neutral-300 dark:bg-neutral-600 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
|
||||
title={t('email.editor.clearFormatting')}
|
||||
>
|
||||
<RemoveFormatting className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
disabled={!editor.can().undo()}
|
||||
title={`${t('email.editor.undo')} (Ctrl+Z)`}
|
||||
>
|
||||
<Undo className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={!editor.can().redo()}
|
||||
title={`${t('email.editor.redo')} (Ctrl+Y)`}
|
||||
>
|
||||
<Redo className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right side: Variables + Source toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
{variables.length > 0 && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowVariables(!showVariables)}
|
||||
className={`flex items-center gap-1 px-2 py-1 text-xs font-medium rounded transition-colors ${
|
||||
showVariables
|
||||
? 'bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-300'
|
||||
: 'bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600'
|
||||
}`}
|
||||
type="button"
|
||||
>
|
||||
<Variable className="w-3.5 h-3.5" />
|
||||
{t('email.editor.insertVariable')}
|
||||
</button>
|
||||
|
||||
{showVariables && (
|
||||
<div className="absolute right-0 top-full mt-1 z-10 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-600 rounded-lg shadow-lg py-1 min-w-[200px] max-h-[240px] overflow-auto">
|
||||
{variables.map(variable => (
|
||||
<button
|
||||
key={variable}
|
||||
onClick={() => insertVariable(variable)}
|
||||
className="w-full text-left px-3 py-1.5 text-sm hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors"
|
||||
type="button"
|
||||
>
|
||||
<code className="text-primary-600 dark:text-primary-400">{`{{${variable}}}`}</code>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={isSourceMode ? switchToVisual : switchToSource}
|
||||
className={`flex items-center gap-1 px-2 py-1 text-xs font-medium rounded transition-colors ${
|
||||
isSourceMode
|
||||
? 'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300'
|
||||
: 'bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600'
|
||||
}`}
|
||||
type="button"
|
||||
>
|
||||
<Code2 className="w-3.5 h-3.5" />
|
||||
{isSourceMode ? t('email.editor.visualMode') : t('email.editor.sourceMode')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Link Dialog */}
|
||||
{showLinkDialog && (
|
||||
<div className="p-3 bg-primary-50 dark:bg-primary-900/20 border-b border-primary-200 dark:border-primary-800 flex items-center gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={linkUrl}
|
||||
onChange={(e) => setLinkUrl(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addLink()}
|
||||
placeholder={t('email.editor.enterUrl')}
|
||||
className="flex-1 px-3 py-1 text-sm border border-primary-300 dark:border-primary-700 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-md focus:ring-2 focus:ring-primary-500"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={addLink}
|
||||
className="px-3 py-1 text-sm bg-primary-600 text-white rounded-md hover:bg-primary-700"
|
||||
type="button"
|
||||
>
|
||||
{t('email.editor.addLink')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowLinkDialog(false); setLinkUrl(''); }}
|
||||
className="px-3 py-1 text-sm bg-neutral-200 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded-md hover:bg-neutral-300 dark:hover:bg-neutral-600"
|
||||
type="button"
|
||||
>
|
||||
{t('email.editor.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Editor / Source Content Area */}
|
||||
{isSourceMode ? (
|
||||
<textarea
|
||||
data-email-source=""
|
||||
value={sourceContent}
|
||||
onChange={(e) => handleSourceChange(e.target.value)}
|
||||
rows={15}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 font-mono text-sm focus:outline-none resize-y"
|
||||
spellCheck={false}
|
||||
/>
|
||||
) : (
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
className="min-h-[300px] p-4 prose prose-neutral dark:prose-invert max-w-none bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 focus:outline-none [&_.ProseMirror]:min-h-[300px] [&_.ProseMirror]:outline-none [&_.ProseMirror]:text-neutral-900 [&_.ProseMirror]:dark:text-neutral-100 [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.ProseMirror_p.is-editor-empty:first-child::before]:text-neutral-400 [&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-left [&_.ProseMirror_p.is-editor-empty:first-child::before]:h-0"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
EmailTemplateEditor.displayName = 'EmailTemplateEditor';
|
||||
@@ -1959,7 +1959,28 @@
|
||||
"enterPassword": "Passwort eingeben",
|
||||
"required": "erforderlich",
|
||||
"ignoreSslErrors": "SSL/TLS-Zertifikatfehler ignorieren",
|
||||
"ignoreSslWarning": "Warnung: Das Deaktivieren der Zertifikatüberprüfung macht die Verbindung anfällig für Man-in-the-Middle-Angriffe. Aktivieren Sie dies nur, wenn Sie dem SMTP-Server vertrauen und die Sicherheitsrisiken verstehen."
|
||||
"ignoreSslWarning": "Warnung: Das Deaktivieren der Zertifikatüberprüfung macht die Verbindung anfällig für Man-in-the-Middle-Angriffe. Aktivieren Sie dies nur, wenn Sie dem SMTP-Server vertrauen und die Sicherheitsrisiken verstehen.",
|
||||
"editor": {
|
||||
"bold": "Fett",
|
||||
"italic": "Kursiv",
|
||||
"bulletList": "Aufzählung",
|
||||
"numberedList": "Nummerierte Liste",
|
||||
"blockquote": "Zitat",
|
||||
"link": "Link hinzufügen",
|
||||
"horizontalRule": "Trennlinie",
|
||||
"alignLeft": "Linksbündig",
|
||||
"alignCenter": "Zentriert",
|
||||
"alignRight": "Rechtsbündig",
|
||||
"clearFormatting": "Formatierung entfernen",
|
||||
"undo": "Rückgängig",
|
||||
"redo": "Wiederholen",
|
||||
"insertVariable": "Variable einfügen",
|
||||
"sourceMode": "Quellcode",
|
||||
"visualMode": "Visuell",
|
||||
"enterUrl": "URL eingeben...",
|
||||
"addLink": "Hinzufügen",
|
||||
"cancel": "Abbrechen"
|
||||
}
|
||||
},
|
||||
"cms": {
|
||||
"title": "CMS-Seiten",
|
||||
|
||||
@@ -1656,7 +1656,28 @@
|
||||
"fromName": "From Name",
|
||||
"required": "required",
|
||||
"ignoreSslErrors": "Ignore SSL/TLS certificate errors",
|
||||
"ignoreSslWarning": "Warning: Disabling certificate verification makes the connection vulnerable to man-in-the-middle attacks. Only enable this if you trust the SMTP server and understand the security implications."
|
||||
"ignoreSslWarning": "Warning: Disabling certificate verification makes the connection vulnerable to man-in-the-middle attacks. Only enable this if you trust the SMTP server and understand the security implications.",
|
||||
"editor": {
|
||||
"bold": "Bold",
|
||||
"italic": "Italic",
|
||||
"bulletList": "Bullet List",
|
||||
"numberedList": "Numbered List",
|
||||
"blockquote": "Blockquote",
|
||||
"link": "Add Link",
|
||||
"horizontalRule": "Horizontal Rule",
|
||||
"alignLeft": "Align Left",
|
||||
"alignCenter": "Align Center",
|
||||
"alignRight": "Align Right",
|
||||
"clearFormatting": "Clear Formatting",
|
||||
"undo": "Undo",
|
||||
"redo": "Redo",
|
||||
"insertVariable": "Insert Variable",
|
||||
"sourceMode": "Source",
|
||||
"visualMode": "Visual",
|
||||
"enterUrl": "Enter URL...",
|
||||
"addLink": "Add",
|
||||
"cancel": "Cancel"
|
||||
}
|
||||
},
|
||||
"cms": {
|
||||
"title": "CMS Pages",
|
||||
|
||||
@@ -1632,7 +1632,28 @@
|
||||
"enterPassword": "Введите пароль",
|
||||
"required": "обязательно",
|
||||
"ignoreSslErrors": "Игнорировать ошибки SSL/TLS-сертификата",
|
||||
"ignoreSslWarning": "Предупреждение: Отключение проверки сертификата делает соединение уязвимым для атак «человек посередине». Включайте только если доверяете SMTP-серверу и понимаете риски."
|
||||
"ignoreSslWarning": "Предупреждение: Отключение проверки сертификата делает соединение уязвимым для атак «человек посередине». Включайте только если доверяете SMTP-серверу и понимаете риски.",
|
||||
"editor": {
|
||||
"bold": "Жирный",
|
||||
"italic": "Курсив",
|
||||
"bulletList": "Маркированный список",
|
||||
"numberedList": "Нумерованный список",
|
||||
"blockquote": "Цитата",
|
||||
"link": "Добавить ссылку",
|
||||
"horizontalRule": "Горизонтальная линия",
|
||||
"alignLeft": "По левому краю",
|
||||
"alignCenter": "По центру",
|
||||
"alignRight": "По правому краю",
|
||||
"clearFormatting": "Очистить форматирование",
|
||||
"undo": "Отменить",
|
||||
"redo": "Повторить",
|
||||
"insertVariable": "Вставить переменную",
|
||||
"sourceMode": "Код",
|
||||
"visualMode": "Визуально",
|
||||
"enterUrl": "Введите URL...",
|
||||
"addLink": "Добавить",
|
||||
"cancel": "Отмена"
|
||||
}
|
||||
},
|
||||
"cms": {
|
||||
"title": "Страницы CMS",
|
||||
|
||||
@@ -16,6 +16,7 @@ import { toast } from 'react-toastify';
|
||||
|
||||
import { Button, Input, Card, Loading } from '../../components/common';
|
||||
import { EmailPreviewModal } from '../../components/admin/EmailPreviewModal';
|
||||
import { EmailTemplateEditor } from '../../components/admin/EmailTemplateEditor';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { emailService, type EmailConfig, type EmailTemplate } from '../../services/email.service';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -253,25 +254,6 @@ export const EmailConfigPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const renderVariableHelp = () => {
|
||||
const variables = editedTemplate.variables || [];
|
||||
return (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<h4 className="text-sm font-semibold text-blue-900 dark:text-blue-200 mb-2">{t('email.templateVariables')}</h4>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
{variables.map(variable => (
|
||||
<code key={variable} className="text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/50 px-2 py-1 rounded">
|
||||
{`{{${variable}}}`}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-blue-700 dark:text-blue-300 mt-2">
|
||||
{t('email.variableHelp')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (configLoading || templatesLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -632,22 +614,19 @@ export const EmailConfigPage: React.FC = () => {
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('email.emailBody')} ({editingLang === 'en' ? 'English' : 'German'})
|
||||
</label>
|
||||
<textarea
|
||||
value={
|
||||
<EmailTemplateEditor
|
||||
content={
|
||||
editingLang === 'en'
|
||||
? (editedTemplate.body_html_en || editedTemplate.body_html || '')
|
||||
: (editedTemplate.body_html_de || '')
|
||||
}
|
||||
onChange={(e) => setEditedTemplate(prev => ({
|
||||
onChange={(value) => setEditedTemplate(prev => ({
|
||||
...prev,
|
||||
[editingLang === 'en' ? 'body_html_en' : 'body_html_de']: e.target.value
|
||||
[editingLang === 'en' ? 'body_html_en' : 'body_html_de']: value
|
||||
}))}
|
||||
rows={15}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 font-mono text-sm"
|
||||
variables={editedTemplate.variables || []}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{renderVariableHelp()}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user