mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
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:
12
src/App.tsx
12
src/App.tsx
@@ -135,7 +135,6 @@ const AdminBroadcastDetail = lazyWithRetry(() => import('./pages/AdminBroadcastD
|
||||
const AdminPinnedMessages = lazyWithRetry(() => import('./pages/AdminPinnedMessages'));
|
||||
const AdminPinnedMessageCreate = lazyWithRetry(() => import('./pages/AdminPinnedMessageCreate'));
|
||||
const AdminChannelSubscriptions = lazyWithRetry(() => import('./pages/AdminChannelSubscriptions'));
|
||||
const AdminEmailTemplatePreview = lazyWithRetry(() => import('./pages/AdminEmailTemplatePreview'));
|
||||
const AdminRoles = lazyWithRetry(() => import('./pages/AdminRoles'));
|
||||
const AdminRoleEdit = lazyWithRetry(() => import('./pages/AdminRoleEdit'));
|
||||
const AdminRoleAssign = lazyWithRetry(() => import('./pages/AdminRoleAssign'));
|
||||
@@ -1212,17 +1211,6 @@ function App() {
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/email-templates/preview/:type/:lang"
|
||||
element={
|
||||
<PermissionRoute permission="email_templates:read">
|
||||
<LazyPage>
|
||||
<AdminEmailTemplatePreview />
|
||||
</LazyPage>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* RBAC routes */}
|
||||
<Route
|
||||
path="/admin/roles"
|
||||
|
||||
@@ -52,6 +52,9 @@ export interface EmailTemplatePreviewResponse {
|
||||
export interface EmailTemplateSendTestRequest {
|
||||
language: 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 = {
|
||||
|
||||
@@ -1541,9 +1541,13 @@
|
||||
"resetted": "Template reset to default",
|
||||
"testSent": "Test email sent",
|
||||
"variables": "Available Variables",
|
||||
"clickToCopy": "Click to copy",
|
||||
"previewDesktopOnly": "Preview is only available in the desktop web version",
|
||||
"previewNotAvailable": "Preview not available"
|
||||
"editorTab": "Editor",
|
||||
"previewHint": "The preview shows the email with sample values in place of variables.",
|
||||
"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": {
|
||||
"title": "Payments",
|
||||
|
||||
@@ -1414,9 +1414,13 @@
|
||||
"resetted": "قالب به پیشفرض بازگردانده شد",
|
||||
"testSent": "ایمیل آزمایشی ارسال شد",
|
||||
"variables": "متغیرهای موجود",
|
||||
"clickToCopy": "برای کپی کلیک کنید",
|
||||
"previewDesktopOnly": "پیشنمایش فقط در نسخه وب دسکتاپ در دسترس است",
|
||||
"previewNotAvailable": "پیشنمایش در دسترس نیست"
|
||||
"editorTab": "ویرایشگر",
|
||||
"previewHint": "پیشنمایش ایمیل را با مقادیر نمونه به جای متغیرها نشان میدهد.",
|
||||
"clickToInsert": "برای درج در متن کلیک کنید",
|
||||
"insertDefault": "درج قالب پیشفرض",
|
||||
"insertDefaultConfirm": "محتوای فعلی با قالب پیشفرض جایگزین شود؟",
|
||||
"testRecipientPlaceholder": "ایمیل گیرنده (پیشفرض: ایمیل شما)",
|
||||
"testHint": "محتوای فعلی ویرایشگر با مقادیر نمونه به جای متغیرها ارسال میشود."
|
||||
},
|
||||
"wheel": {
|
||||
"title": "تنظیمات چرخ شانس",
|
||||
|
||||
@@ -1563,9 +1563,13 @@
|
||||
"resetted": "Шаблон сброшен к значению по умолчанию",
|
||||
"testSent": "Тестовое письмо отправлено",
|
||||
"variables": "Доступные переменные",
|
||||
"clickToCopy": "Нажмите для копирования",
|
||||
"previewDesktopOnly": "Предпросмотр доступен только в веб-версии на десктопе",
|
||||
"previewNotAvailable": "Предпросмотр недоступен"
|
||||
"editorTab": "Редактор",
|
||||
"previewHint": "Превью показывает письмо с примерами значений вместо переменных.",
|
||||
"clickToInsert": "Нажмите, чтобы вставить в текст",
|
||||
"insertDefault": "Вставить шаблон по умолчанию",
|
||||
"insertDefaultConfirm": "Заменить текущее содержимое шаблоном по умолчанию?",
|
||||
"testRecipientPlaceholder": "Email получателя (по умолчанию — ваш)",
|
||||
"testHint": "Отправит текущее содержимое редактора с примерами значений вместо переменных."
|
||||
},
|
||||
"payments": {
|
||||
"title": "Платежи",
|
||||
|
||||
@@ -1455,9 +1455,13 @@
|
||||
"resetted": "模板已恢复默认",
|
||||
"testSent": "测试邮件已发送",
|
||||
"variables": "可用变量",
|
||||
"clickToCopy": "点击复制",
|
||||
"previewDesktopOnly": "预览仅在桌面网页版可用",
|
||||
"previewNotAvailable": "预览不可用"
|
||||
"editorTab": "编辑器",
|
||||
"previewHint": "预览以示例值代替变量显示邮件。",
|
||||
"clickToInsert": "点击插入到文本中",
|
||||
"insertDefault": "插入默认模板",
|
||||
"insertDefaultConfirm": "用默认模板替换当前内容?",
|
||||
"testRecipientPlaceholder": "收件人邮箱(默认为您的邮箱)",
|
||||
"testHint": "发送当前编辑器内容,变量将替换为示例值。"
|
||||
},
|
||||
"payments": {
|
||||
"title": "支付",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -9,33 +8,17 @@ import {
|
||||
EmailTemplateLanguageData,
|
||||
} from '../api/adminEmailTemplates';
|
||||
import { AdminBackButton, BackIcon } from '../components/admin';
|
||||
import { useIsTelegram } from '../platform/hooks/usePlatform';
|
||||
import { useNativeDialog } from '../platform/hooks/useNativeDialog';
|
||||
import { useNotify } from '@/platform';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import { MailIcon, SaveIcon, EyeIcon, SendIcon, ResetIcon } 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;
|
||||
}
|
||||
import { getApiErrorMessage } from '@/utils/api-error';
|
||||
import { MailIcon, SaveIcon, EyeIcon, SendIcon, ResetIcon, EditIcon } from '@/components/icons';
|
||||
|
||||
const LANG_LABELS: Record<string, string> = {
|
||||
ru: 'RU',
|
||||
en: 'EN',
|
||||
zh: 'ZH',
|
||||
ua: 'UA',
|
||||
fa: 'FA',
|
||||
};
|
||||
|
||||
const LANG_FULL_LABELS: Record<string, string> = {
|
||||
@@ -43,6 +26,7 @@ const LANG_FULL_LABELS: Record<string, string> = {
|
||||
en: 'English',
|
||||
zh: '中文',
|
||||
ua: 'Українська',
|
||||
fa: 'فارسی',
|
||||
};
|
||||
|
||||
// ============ Template List View ============
|
||||
@@ -102,6 +86,17 @@ function TemplateCard({
|
||||
|
||||
// ============ 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({
|
||||
detail,
|
||||
onClose,
|
||||
@@ -112,56 +107,40 @@ function TemplateEditor({
|
||||
currentLang: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const notify = useNotify();
|
||||
const isTelegram = useIsTelegram();
|
||||
const { confirm: confirmDialog } = useNativeDialog();
|
||||
const isMobile = useIsMobile();
|
||||
const isPreviewDisabled = isTelegram || isMobile;
|
||||
|
||||
const [activeLang, setActiveLang] = useState('ru');
|
||||
const [activeTab, setActiveTab] = useState<'editor' | 'preview'>('editor');
|
||||
const [editSubject, setEditSubject] = useState('');
|
||||
const [editBody, setEditBody] = useState('');
|
||||
const [testEmail, setTestEmail] = useState('');
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [previewHtml, setPreviewHtml] = useState('');
|
||||
const [previewSubject, setPreviewSubject] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const langData: EmailTemplateLanguageData | undefined = detail.languages[activeLang];
|
||||
|
||||
// Load data for current language
|
||||
// Load data for current language (defaults arrive with {placeholders} intact)
|
||||
useEffect(() => {
|
||||
if (langData) {
|
||||
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);
|
||||
setActiveTab('editor');
|
||||
}
|
||||
}, [activeLang, langData]);
|
||||
|
||||
// Extract body content from full HTML (strip base template wrapper)
|
||||
const extractBodyContent = useCallback((html: string): string => {
|
||||
// If it's wrapped in the base template, extract just the content div
|
||||
const contentMatch = html.match(
|
||||
/<div class="content">\s*([\s\S]*?)\s*<\/div>\s*<div class="footer">/,
|
||||
);
|
||||
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]);
|
||||
const notifyError = useCallback(
|
||||
(error: unknown) => {
|
||||
notify.error(getApiErrorMessage(error, t('common.error')));
|
||||
},
|
||||
[notify, t],
|
||||
);
|
||||
|
||||
// Save mutation
|
||||
const saveMutation = useMutation({
|
||||
@@ -178,9 +157,7 @@ function TemplateEditor({
|
||||
setIsDirty(false);
|
||||
notify.success(t('admin.emailTemplates.saved'));
|
||||
},
|
||||
onError: () => {
|
||||
notify.error(t('common.error'));
|
||||
},
|
||||
onError: notifyError,
|
||||
});
|
||||
|
||||
// Reset mutation
|
||||
@@ -194,33 +171,70 @@ function TemplateEditor({
|
||||
setIsDirty(false);
|
||||
notify.success(t('admin.emailTemplates.resetted'));
|
||||
},
|
||||
onError: () => {
|
||||
notify.error(t('common.error'));
|
||||
},
|
||||
onError: notifyError,
|
||||
});
|
||||
|
||||
// Send test mutation
|
||||
// Send test mutation — sends the CURRENT editor content (even unsaved)
|
||||
const testMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
adminEmailTemplatesApi.sendTestEmail(detail.notification_type, {
|
||||
language: activeLang,
|
||||
email: testEmail.trim(),
|
||||
subject: editSubject,
|
||||
body_html: editBody,
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
notify.success(`${t('admin.emailTemplates.testSent')} → ${data.sent_to}`);
|
||||
},
|
||||
onError: () => {
|
||||
notify.error(t('common.error'));
|
||||
},
|
||||
onError: notifyError,
|
||||
});
|
||||
|
||||
const handleSubjectChange = (value: string) => {
|
||||
setEditSubject(value);
|
||||
setIsDirty(true);
|
||||
// Preview mutation — renders current content with sample values
|
||||
const previewMutation = useMutation({
|
||||
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) => {
|
||||
setEditBody(value);
|
||||
const insertVariable = (variable: string) => {
|
||||
const token = `{${variable}}`;
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) {
|
||||
setEditBody(editBody + token);
|
||||
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;
|
||||
@@ -279,60 +293,125 @@ function TemplateEditor({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Subject */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.emailTemplates.subject')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editSubject}
|
||||
onChange={(e) => handleSubjectChange(e.target.value)}
|
||||
className="input"
|
||||
placeholder={t('admin.emailTemplates.subjectPlaceholder')}
|
||||
/>
|
||||
{/* 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>
|
||||
|
||||
{/* Context variables hint */}
|
||||
{detail.context_vars.length > 0 && (
|
||||
<div className="rounded-lg border border-dark-700 bg-dark-900/60 p-2.5 sm:p-3">
|
||||
<p className="mb-1.5 text-xs font-medium text-dark-300">
|
||||
{t('admin.emailTemplates.variables')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1 sm:gap-1.5">
|
||||
{detail.context_vars.map((v) => (
|
||||
<code
|
||||
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"
|
||||
title={t('admin.emailTemplates.clickToCopy')}
|
||||
onClick={() => {
|
||||
void copyToClipboard(`{${v}}`);
|
||||
notify.success(`Copied {${v}}`);
|
||||
}}
|
||||
>
|
||||
{`{${v}}`}
|
||||
</code>
|
||||
))}
|
||||
{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 */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.emailTemplates.subject')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editSubject}
|
||||
onChange={(e) => {
|
||||
setEditSubject(e.target.value);
|
||||
setIsDirty(true);
|
||||
}}
|
||||
className="input"
|
||||
placeholder={t('admin.emailTemplates.subjectPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Body HTML editor */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.emailTemplates.body')}
|
||||
</label>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={editBody}
|
||||
onChange={(e) => handleBodyChange(e.target.value)}
|
||||
rows={12}
|
||||
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>"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="mt-1 text-2xs text-dark-500">{t('admin.emailTemplates.bodyHint')}</p>
|
||||
</div>
|
||||
{/* Context variables hint */}
|
||||
{detail.context_vars.length > 0 && (
|
||||
<div className="rounded-lg border border-dark-700 bg-dark-900/60 p-2.5 sm:p-3">
|
||||
<p className="mb-1.5 text-xs font-medium text-dark-300">
|
||||
{t('admin.emailTemplates.variables')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1 sm:gap-1.5">
|
||||
{detail.context_vars.map((v) => (
|
||||
<code
|
||||
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"
|
||||
title={t('admin.emailTemplates.clickToInsert')}
|
||||
onClick={() => insertVariable(v)}
|
||||
>
|
||||
{`{${v}}`}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body HTML editor */}
|
||||
<div>
|
||||
<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')}
|
||||
</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
|
||||
ref={textareaRef}
|
||||
value={editBody}
|
||||
onChange={(e) => {
|
||||
setEditBody(e.target.value);
|
||||
setIsDirty(true);
|
||||
}}
|
||||
rows={12}
|
||||
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>"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="mt-1 text-2xs text-dark-500">{t('admin.emailTemplates.bodyHint')}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<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')}
|
||||
</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 && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
@@ -387,7 +433,7 @@ function TemplateEditor({
|
||||
}
|
||||
}}
|
||||
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" />
|
||||
<span className="truncate">{t('admin.emailTemplates.resetDefault')}</span>
|
||||
@@ -395,6 +441,31 @@ function TemplateEditor({
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user