fix(admin): rework email template editor (#667043)

Companion to the bot-side fix: defaults now arrive with {placeholders}
instead of baked-in sample values (example.com links).

- inline preview tab (iframe srcDoc) — works on mobile and in Telegram;
  the separate desktop-only preview page and its route are removed
- preview/test render the CURRENT editor content with sample values
- test email: recipient input + detailed backend error messages
- variable chips insert at cursor instead of copying to clipboard
- 'insert default template' button to migrate broken saved overrides
- fa added to language tabs
This commit is contained in:
c0mrade
2026-06-10 22:53:08 +03:00
parent d7f7bc7c17
commit ac75a806d9
8 changed files with 251 additions and 289 deletions

View File

@@ -135,7 +135,6 @@ const AdminBroadcastDetail = lazyWithRetry(() => import('./pages/AdminBroadcastD
const AdminPinnedMessages = lazyWithRetry(() => import('./pages/AdminPinnedMessages')); const AdminPinnedMessages = lazyWithRetry(() => import('./pages/AdminPinnedMessages'));
const AdminPinnedMessageCreate = lazyWithRetry(() => import('./pages/AdminPinnedMessageCreate')); const AdminPinnedMessageCreate = lazyWithRetry(() => import('./pages/AdminPinnedMessageCreate'));
const AdminChannelSubscriptions = lazyWithRetry(() => import('./pages/AdminChannelSubscriptions')); const AdminChannelSubscriptions = lazyWithRetry(() => import('./pages/AdminChannelSubscriptions'));
const AdminEmailTemplatePreview = lazyWithRetry(() => import('./pages/AdminEmailTemplatePreview'));
const AdminRoles = lazyWithRetry(() => import('./pages/AdminRoles')); const AdminRoles = lazyWithRetry(() => import('./pages/AdminRoles'));
const AdminRoleEdit = lazyWithRetry(() => import('./pages/AdminRoleEdit')); const AdminRoleEdit = lazyWithRetry(() => import('./pages/AdminRoleEdit'));
const AdminRoleAssign = lazyWithRetry(() => import('./pages/AdminRoleAssign')); const AdminRoleAssign = lazyWithRetry(() => import('./pages/AdminRoleAssign'));
@@ -1212,17 +1211,6 @@ function App() {
</PermissionRoute> </PermissionRoute>
} }
/> />
<Route
path="/admin/email-templates/preview/:type/:lang"
element={
<PermissionRoute permission="email_templates:read">
<LazyPage>
<AdminEmailTemplatePreview />
</LazyPage>
</PermissionRoute>
}
/>
{/* RBAC routes */} {/* RBAC routes */}
<Route <Route
path="/admin/roles" path="/admin/roles"

View File

@@ -52,6 +52,9 @@ export interface EmailTemplatePreviewResponse {
export interface EmailTemplateSendTestRequest { export interface EmailTemplateSendTestRequest {
language: string; language: string;
email?: string; email?: string;
/** Current editor content — when set, the test sends it instead of the saved template */
subject?: string;
body_html?: string;
} }
export const adminEmailTemplatesApi = { export const adminEmailTemplatesApi = {

View File

@@ -1541,9 +1541,13 @@
"resetted": "Template reset to default", "resetted": "Template reset to default",
"testSent": "Test email sent", "testSent": "Test email sent",
"variables": "Available Variables", "variables": "Available Variables",
"clickToCopy": "Click to copy", "editorTab": "Editor",
"previewDesktopOnly": "Preview is only available in the desktop web version", "previewHint": "The preview shows the email with sample values in place of variables.",
"previewNotAvailable": "Preview not available" "clickToInsert": "Click to insert into the text",
"insertDefault": "Insert default template",
"insertDefaultConfirm": "Replace the current content with the default template?",
"testRecipientPlaceholder": "Recipient email (defaults to yours)",
"testHint": "Sends the current editor content with sample values in place of variables."
}, },
"payments": { "payments": {
"title": "Payments", "title": "Payments",

View File

@@ -1414,9 +1414,13 @@
"resetted": "قالب به پیش‌فرض بازگردانده شد", "resetted": "قالب به پیش‌فرض بازگردانده شد",
"testSent": "ایمیل آزمایشی ارسال شد", "testSent": "ایمیل آزمایشی ارسال شد",
"variables": "متغیرهای موجود", "variables": "متغیرهای موجود",
"clickToCopy": "برای کپی کلیک کنید", "editorTab": "ویرایشگر",
"previewDesktopOnly": "پیش‌نمایش فقط در نسخه وب دسکتاپ در دسترس است", "previewHint": "پیش‌نمایش ایمیل را با مقادیر نمونه به جای متغیرها نشان می‌دهد.",
"previewNotAvailable": "پیش‌نمایش در دسترس نیست" "clickToInsert": "برای درج در متن کلیک کنید",
"insertDefault": "درج قالب پیش‌فرض",
"insertDefaultConfirm": "محتوای فعلی با قالب پیش‌فرض جایگزین شود؟",
"testRecipientPlaceholder": "ایمیل گیرنده (پیش‌فرض: ایمیل شما)",
"testHint": "محتوای فعلی ویرایشگر با مقادیر نمونه به جای متغیرها ارسال می‌شود."
}, },
"wheel": { "wheel": {
"title": "تنظیمات چرخ شانس", "title": "تنظیمات چرخ شانس",

View File

@@ -1563,9 +1563,13 @@
"resetted": "Шаблон сброшен к значению по умолчанию", "resetted": "Шаблон сброшен к значению по умолчанию",
"testSent": "Тестовое письмо отправлено", "testSent": "Тестовое письмо отправлено",
"variables": "Доступные переменные", "variables": "Доступные переменные",
"clickToCopy": "Нажмите для копирования", "editorTab": "Редактор",
"previewDesktopOnly": "Предпросмотр доступен только в веб-версии на десктопе", "previewHint": "Превью показывает письмо с примерами значений вместо переменных.",
"previewNotAvailable": "Предпросмотр недоступен" "clickToInsert": "Нажмите, чтобы вставить в текст",
"insertDefault": "Вставить шаблон по умолчанию",
"insertDefaultConfirm": "Заменить текущее содержимое шаблоном по умолчанию?",
"testRecipientPlaceholder": "Email получателя (по умолчанию — ваш)",
"testHint": "Отправит текущее содержимое редактора с примерами значений вместо переменных."
}, },
"payments": { "payments": {
"title": "Платежи", "title": "Платежи",

View File

@@ -1455,9 +1455,13 @@
"resetted": "模板已恢复默认", "resetted": "模板已恢复默认",
"testSent": "测试邮件已发送", "testSent": "测试邮件已发送",
"variables": "可用变量", "variables": "可用变量",
"clickToCopy": "点击复制", "editorTab": "编辑器",
"previewDesktopOnly": "预览仅在桌面网页版可用", "previewHint": "预览以示例值代替变量显示邮件。",
"previewNotAvailable": "预览不可用" "clickToInsert": "点击插入到文本中",
"insertDefault": "插入默认模板",
"insertDefaultConfirm": "用默认模板替换当前内容?",
"testRecipientPlaceholder": "收件人邮箱(默认为您的邮箱)",
"testHint": "发送当前编辑器内容,变量将替换为示例值。"
}, },
"payments": { "payments": {
"title": "支付", "title": "支付",

View File

@@ -1,116 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { adminEmailTemplatesApi } from '../api/adminEmailTemplates';
import { BackIcon } from '../components/admin';
interface PreviewState {
subject: string;
body_html: string;
}
export default function AdminEmailTemplatePreview() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const { type, lang } = useParams<{ type: string; lang: string }>();
const iframeRef = useRef<HTMLIFrameElement>(null);
const [previewHtml, setPreviewHtml] = useState<string>('');
// Get data from navigation state
const state = location.state as PreviewState | null;
// Preview mutation
const {
mutate: loadPreview,
isPending: previewPending,
isError: previewError,
} = useMutation({
mutationFn: () => {
if (!type || !lang || !state) {
throw new Error('Missing required data');
}
return adminEmailTemplatesApi.previewTemplate(type, {
language: lang,
subject: state.subject,
body_html: state.body_html,
});
},
onSuccess: (data) => {
setPreviewHtml(data.body_html);
},
});
// Load preview on mount
useEffect(() => {
if (!type || !lang || !state) {
navigate('/admin/email-templates');
return;
}
loadPreview();
}, [type, lang, state, navigate, loadPreview]);
// Write preview HTML into iframe
useEffect(() => {
if (previewHtml && iframeRef.current) {
const doc = iframeRef.current.contentDocument;
if (doc) {
doc.open();
doc.write(previewHtml);
doc.close();
}
}
}, [previewHtml]);
if (!type || !lang || !state) {
return null;
}
return (
<div className="flex h-[calc(100dvh-120px)] flex-col">
{/* Header */}
<div className="mb-4 flex items-center gap-3">
<button
onClick={() => navigate(-1)}
className="rounded-xl border border-dark-700 bg-dark-800 p-2 transition-colors hover:bg-dark-700"
>
<BackIcon />
</button>
<div>
<h1 className="text-xl font-bold text-dark-100">{t('admin.emailTemplates.preview')}</h1>
<p className="text-sm text-dark-400">
{type} / {lang.toUpperCase()}
</p>
</div>
</div>
{/* Preview content */}
<div className="flex-1 overflow-hidden rounded-xl border border-dark-700 bg-white">
{previewPending ? (
<div className="flex h-full items-center justify-center bg-dark-800">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : previewError ? (
<div className="flex h-full flex-col items-center justify-center gap-4 bg-dark-800">
<p className="text-dark-400">{t('common.error')}</p>
<button
onClick={() => navigate(-1)}
className="rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
>
{t('common.back')}
</button>
</div>
) : (
<iframe
ref={iframeRef}
className="h-full w-full"
sandbox="allow-same-origin"
title="Email Preview"
/>
)}
</div>
</div>
);
}

View File

@@ -1,5 +1,4 @@
import { useState, useCallback, useRef, useEffect } from 'react'; import { useState, useCallback, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
@@ -9,33 +8,17 @@ import {
EmailTemplateLanguageData, EmailTemplateLanguageData,
} from '../api/adminEmailTemplates'; } from '../api/adminEmailTemplates';
import { AdminBackButton, BackIcon } from '../components/admin'; import { AdminBackButton, BackIcon } from '../components/admin';
import { useIsTelegram } from '../platform/hooks/usePlatform';
import { useNativeDialog } from '../platform/hooks/useNativeDialog'; import { useNativeDialog } from '../platform/hooks/useNativeDialog';
import { useNotify } from '@/platform'; import { useNotify } from '@/platform';
import { copyToClipboard } from '@/utils/clipboard'; import { getApiErrorMessage } from '@/utils/api-error';
import { MailIcon, SaveIcon, EyeIcon, SendIcon, ResetIcon } from '@/components/icons'; import { MailIcon, SaveIcon, EyeIcon, SendIcon, ResetIcon, EditIcon } from '@/components/icons';
// Hook to check if on mobile
function useIsMobile() {
const [isMobile, setIsMobile] = useState(() => {
if (typeof window === 'undefined') return false;
return window.innerWidth < 1024;
});
useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 1024);
window.addEventListener('resize', check);
return () => window.removeEventListener('resize', check);
}, []);
return isMobile;
}
const LANG_LABELS: Record<string, string> = { const LANG_LABELS: Record<string, string> = {
ru: 'RU', ru: 'RU',
en: 'EN', en: 'EN',
zh: 'ZH', zh: 'ZH',
ua: 'UA', ua: 'UA',
fa: 'FA',
}; };
const LANG_FULL_LABELS: Record<string, string> = { const LANG_FULL_LABELS: Record<string, string> = {
@@ -43,6 +26,7 @@ const LANG_FULL_LABELS: Record<string, string> = {
en: 'English', en: 'English',
zh: '中文', zh: '中文',
ua: 'Українська', ua: 'Українська',
fa: 'فارسی',
}; };
// ============ Template List View ============ // ============ Template List View ============
@@ -102,6 +86,17 @@ function TemplateCard({
// ============ Template Editor ============ // ============ Template Editor ============
// Extract body content from full HTML (strip base template wrapper)
function extractBodyContent(html: string): string {
const contentMatch = html.match(
/<div class="content">\s*([\s\S]*?)\s*<\/div>\s*<div class="footer">/,
);
if (contentMatch) {
return contentMatch[1].trim();
}
return html;
}
function TemplateEditor({ function TemplateEditor({
detail, detail,
onClose, onClose,
@@ -112,56 +107,40 @@ function TemplateEditor({
currentLang: string; currentLang: string;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const notify = useNotify(); const notify = useNotify();
const isTelegram = useIsTelegram();
const { confirm: confirmDialog } = useNativeDialog(); const { confirm: confirmDialog } = useNativeDialog();
const isMobile = useIsMobile();
const isPreviewDisabled = isTelegram || isMobile;
const [activeLang, setActiveLang] = useState('ru'); const [activeLang, setActiveLang] = useState('ru');
const [activeTab, setActiveTab] = useState<'editor' | 'preview'>('editor');
const [editSubject, setEditSubject] = useState(''); const [editSubject, setEditSubject] = useState('');
const [editBody, setEditBody] = useState(''); const [editBody, setEditBody] = useState('');
const [testEmail, setTestEmail] = useState('');
const [isDirty, setIsDirty] = useState(false); const [isDirty, setIsDirty] = useState(false);
const [previewHtml, setPreviewHtml] = useState('');
const [previewSubject, setPreviewSubject] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const langData: EmailTemplateLanguageData | undefined = detail.languages[activeLang]; const langData: EmailTemplateLanguageData | undefined = detail.languages[activeLang];
// Load data for current language // Load data for current language (defaults arrive with {placeholders} intact)
useEffect(() => { useEffect(() => {
if (langData) { if (langData) {
setEditSubject(langData.subject); setEditSubject(langData.subject);
setEditBody(langData.is_default ? langData.body_html : langData.body_html); setEditBody(
langData.is_default ? extractBodyContent(langData.body_html) : langData.body_html,
);
setIsDirty(false); setIsDirty(false);
setActiveTab('editor');
} }
}, [activeLang, langData]); }, [activeLang, langData]);
// Extract body content from full HTML (strip base template wrapper) const notifyError = useCallback(
const extractBodyContent = useCallback((html: string): string => { (error: unknown) => {
// If it's wrapped in the base template, extract just the content div notify.error(getApiErrorMessage(error, t('common.error')));
const contentMatch = html.match( },
/<div class="content">\s*([\s\S]*?)\s*<\/div>\s*<div class="footer">/, [notify, t],
); );
if (contentMatch) {
return contentMatch[1].trim();
}
return html;
}, []);
// When langData changes (e.g., after refetch), update the body content
useEffect(() => {
if (langData) {
if (langData.is_default) {
// For default templates, extract just the content portion
setEditBody(extractBodyContent(langData.body_html));
} else {
setEditBody(langData.body_html);
}
setEditSubject(langData.subject);
setIsDirty(false);
}
}, [activeLang, langData, extractBodyContent]);
// Save mutation // Save mutation
const saveMutation = useMutation({ const saveMutation = useMutation({
@@ -178,9 +157,7 @@ function TemplateEditor({
setIsDirty(false); setIsDirty(false);
notify.success(t('admin.emailTemplates.saved')); notify.success(t('admin.emailTemplates.saved'));
}, },
onError: () => { onError: notifyError,
notify.error(t('common.error'));
},
}); });
// Reset mutation // Reset mutation
@@ -194,33 +171,70 @@ function TemplateEditor({
setIsDirty(false); setIsDirty(false);
notify.success(t('admin.emailTemplates.resetted')); notify.success(t('admin.emailTemplates.resetted'));
}, },
onError: () => { onError: notifyError,
notify.error(t('common.error'));
},
}); });
// Send test mutation // Send test mutation — sends the CURRENT editor content (even unsaved)
const testMutation = useMutation({ const testMutation = useMutation({
mutationFn: () => mutationFn: () =>
adminEmailTemplatesApi.sendTestEmail(detail.notification_type, { adminEmailTemplatesApi.sendTestEmail(detail.notification_type, {
language: activeLang, language: activeLang,
email: testEmail.trim(),
subject: editSubject,
body_html: editBody,
}), }),
onSuccess: (data) => { onSuccess: (data) => {
notify.success(`${t('admin.emailTemplates.testSent')}${data.sent_to}`); notify.success(`${t('admin.emailTemplates.testSent')}${data.sent_to}`);
}, },
onError: () => { onError: notifyError,
notify.error(t('common.error'));
},
}); });
const handleSubjectChange = (value: string) => { // Preview mutation — renders current content with sample values
setEditSubject(value); const previewMutation = useMutation({
setIsDirty(true); mutationFn: () =>
adminEmailTemplatesApi.previewTemplate(detail.notification_type, {
language: activeLang,
subject: editSubject,
body_html: editBody,
}),
onSuccess: (data) => {
setPreviewHtml(data.body_html);
setPreviewSubject(data.subject);
},
onError: notifyError,
});
const openPreview = () => {
setActiveTab('preview');
previewMutation.mutate();
}; };
const handleBodyChange = (value: string) => { const insertVariable = (variable: string) => {
setEditBody(value); const token = `{${variable}}`;
const textarea = textareaRef.current;
if (!textarea) {
setEditBody(editBody + token);
setIsDirty(true); setIsDirty(true);
return;
}
const start = textarea.selectionStart ?? editBody.length;
const end = textarea.selectionEnd ?? editBody.length;
const next = editBody.slice(0, start) + token + editBody.slice(end);
setEditBody(next);
setIsDirty(true);
requestAnimationFrame(() => {
textarea.focus();
textarea.setSelectionRange(start + token.length, start + token.length);
});
};
const insertDefaultTemplate = async () => {
if (!langData) return;
if (!(await confirmDialog(t('admin.emailTemplates.insertDefaultConfirm')))) return;
setEditSubject(langData.default_subject);
setEditBody(extractBodyContent(langData.default_body_html));
setIsDirty(true);
setActiveTab('editor');
}; };
const label = detail.label[interfaceLang] || detail.label['en'] || detail.notification_type; const label = detail.label[interfaceLang] || detail.label['en'] || detail.notification_type;
@@ -279,6 +293,58 @@ function TemplateEditor({
})} })}
</div> </div>
{/* Editor / Preview tabs */}
<div className="flex items-center gap-1 rounded-lg bg-dark-900 p-1">
<button
onClick={() => setActiveTab('editor')}
className={`flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-2 text-xs font-medium transition-all duration-150 sm:text-sm ${
activeTab === 'editor'
? 'bg-dark-700 text-dark-100 shadow-sm'
: 'text-dark-400 hover:bg-dark-800 hover:text-dark-200'
}`}
>
<EditIcon className="h-4 w-4" />
{t('admin.emailTemplates.editorTab')}
</button>
<button
onClick={openPreview}
className={`flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-2 text-xs font-medium transition-all duration-150 sm:text-sm ${
activeTab === 'preview'
? 'bg-dark-700 text-dark-100 shadow-sm'
: 'text-dark-400 hover:bg-dark-800 hover:text-dark-200'
}`}
>
<EyeIcon className="h-4 w-4" />
{t('admin.emailTemplates.preview')}
</button>
</div>
{activeTab === 'preview' ? (
<div className="space-y-2">
{previewSubject && (
<div className="rounded-lg border border-dark-700 bg-dark-900/60 px-3 py-2">
<span className="text-xs text-dark-400">{t('admin.emailTemplates.subject')}: </span>
<span className="text-sm text-dark-100">{previewSubject}</span>
</div>
)}
<div className="overflow-hidden rounded-xl border border-dark-700 bg-white">
{previewMutation.isPending ? (
<div className="flex h-[420px] items-center justify-center bg-dark-800">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : (
<iframe
srcDoc={previewHtml}
sandbox=""
className="h-[420px] w-full sm:h-[560px]"
title="Email Preview"
/>
)}
</div>
<p className="text-2xs text-dark-500">{t('admin.emailTemplates.previewHint')}</p>
</div>
) : (
<>
{/* Subject */} {/* Subject */}
<div> <div>
<label className="mb-2 block text-sm font-medium text-dark-300"> <label className="mb-2 block text-sm font-medium text-dark-300">
@@ -287,7 +353,10 @@ function TemplateEditor({
<input <input
type="text" type="text"
value={editSubject} value={editSubject}
onChange={(e) => handleSubjectChange(e.target.value)} onChange={(e) => {
setEditSubject(e.target.value);
setIsDirty(true);
}}
className="input" className="input"
placeholder={t('admin.emailTemplates.subjectPlaceholder')} placeholder={t('admin.emailTemplates.subjectPlaceholder')}
/> />
@@ -304,11 +373,8 @@ function TemplateEditor({
<code <code
key={v} key={v}
className="cursor-pointer rounded bg-dark-700 px-2 py-0.5 font-mono text-xs text-accent-400 transition-colors hover:bg-dark-600" className="cursor-pointer rounded bg-dark-700 px-2 py-0.5 font-mono text-xs text-accent-400 transition-colors hover:bg-dark-600"
title={t('admin.emailTemplates.clickToCopy')} title={t('admin.emailTemplates.clickToInsert')}
onClick={() => { onClick={() => insertVariable(v)}
void copyToClipboard(`{${v}}`);
notify.success(`Copied {${v}}`);
}}
> >
{`{${v}}`} {`{${v}}`}
</code> </code>
@@ -319,13 +385,24 @@ function TemplateEditor({
{/* Body HTML editor */} {/* Body HTML editor */}
<div> <div>
<label className="mb-2 block text-sm font-medium text-dark-300"> <div className="mb-2 flex items-center justify-between gap-2">
<label className="block text-sm font-medium text-dark-300">
{t('admin.emailTemplates.body')} {t('admin.emailTemplates.body')}
</label> </label>
<button
onClick={insertDefaultTemplate}
className="text-xs text-dark-400 underline-offset-2 transition-colors hover:text-accent-400 hover:underline"
>
{t('admin.emailTemplates.insertDefault')}
</button>
</div>
<textarea <textarea
ref={textareaRef} ref={textareaRef}
value={editBody} value={editBody}
onChange={(e) => handleBodyChange(e.target.value)} onChange={(e) => {
setEditBody(e.target.value);
setIsDirty(true);
}}
rows={12} rows={12}
className="input min-h-[200px] resize-y font-mono text-xs leading-relaxed sm:min-h-[300px] sm:text-sm" className="input min-h-[200px] resize-y font-mono text-xs leading-relaxed sm:min-h-[300px] sm:text-sm"
placeholder="<h2>Title</h2><p>Content...</p>" placeholder="<h2>Title</h2><p>Content...</p>"
@@ -333,6 +410,8 @@ function TemplateEditor({
/> />
<p className="mt-1 text-2xs text-dark-500">{t('admin.emailTemplates.bodyHint')}</p> <p className="mt-1 text-2xs text-dark-500">{t('admin.emailTemplates.bodyHint')}</p>
</div> </div>
</>
)}
{/* Actions */} {/* Actions */}
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center"> <div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
@@ -346,39 +425,6 @@ function TemplateEditor({
{saveMutation.isPending ? t('common.loading') : t('common.save')} {saveMutation.isPending ? t('common.loading') : t('common.save')}
</button> </button>
<button
onClick={() => {
if (isPreviewDisabled) {
notify.warning(
t('admin.emailTemplates.previewDesktopOnly'),
t('admin.emailTemplates.previewNotAvailable'),
);
return;
}
navigate(`/admin/email-templates/preview/${detail.notification_type}/${activeLang}`, {
state: {
subject: editSubject,
body_html: editBody,
},
});
}}
className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-dark-700 px-3 py-2.5 text-sm font-medium text-dark-200 transition-colors hover:bg-dark-600 sm:px-4 sm:py-2"
>
<EyeIcon className="h-4 w-4" />
{t('admin.emailTemplates.preview')}
</button>
</div>
<div className="grid grid-cols-2 gap-2 sm:flex">
<button
onClick={() => testMutation.mutate()}
disabled={testMutation.isPending}
className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-dark-700 px-3 py-2.5 text-sm font-medium text-dark-200 transition-colors hover:bg-dark-600 disabled:opacity-40 sm:px-4 sm:py-2"
>
<SendIcon className="h-4 w-4" />
{testMutation.isPending ? t('common.loading') : t('admin.emailTemplates.sendTest')}
</button>
{langData && !langData.is_default && ( {langData && !langData.is_default && (
<button <button
onClick={async () => { onClick={async () => {
@@ -387,7 +433,7 @@ function TemplateEditor({
} }
}} }}
disabled={resetMutation.isPending} disabled={resetMutation.isPending}
className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-dark-700 px-3 py-2.5 text-sm font-medium text-warning-400 transition-colors hover:bg-dark-600 disabled:opacity-40 sm:ml-auto sm:px-4 sm:py-2" className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-dark-700 px-3 py-2.5 text-sm font-medium text-warning-400 transition-colors hover:bg-dark-600 disabled:opacity-40 sm:px-4 sm:py-2"
> >
<ResetIcon className="h-4 w-4" /> <ResetIcon className="h-4 w-4" />
<span className="truncate">{t('admin.emailTemplates.resetDefault')}</span> <span className="truncate">{t('admin.emailTemplates.resetDefault')}</span>
@@ -395,6 +441,31 @@ function TemplateEditor({
)} )}
</div> </div>
</div> </div>
{/* Test email */}
<div className="rounded-lg border border-dark-700 bg-dark-900/60 p-2.5 sm:p-3">
<p className="mb-2 text-xs font-medium text-dark-300">
{t('admin.emailTemplates.sendTest')}
</p>
<div className="flex flex-col gap-2 sm:flex-row">
<input
type="email"
value={testEmail}
onChange={(e) => setTestEmail(e.target.value)}
className="input flex-1"
placeholder={t('admin.emailTemplates.testRecipientPlaceholder')}
/>
<button
onClick={() => testMutation.mutate()}
disabled={testMutation.isPending}
className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-dark-700 px-3 py-2.5 text-sm font-medium text-dark-200 transition-colors hover:bg-dark-600 disabled:opacity-40 sm:px-4 sm:py-2"
>
<SendIcon className="h-4 w-4" />
{testMutation.isPending ? t('common.loading') : t('admin.emailTemplates.sendTest')}
</button>
</div>
<p className="mt-1.5 text-2xs text-dark-500">{t('admin.emailTemplates.testHint')}</p>
</div>
</div> </div>
); );
} }