mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +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 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"
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1414,9 +1414,13 @@
|
|||||||
"resetted": "قالب به پیشفرض بازگردانده شد",
|
"resetted": "قالب به پیشفرض بازگردانده شد",
|
||||||
"testSent": "ایمیل آزمایشی ارسال شد",
|
"testSent": "ایمیل آزمایشی ارسال شد",
|
||||||
"variables": "متغیرهای موجود",
|
"variables": "متغیرهای موجود",
|
||||||
"clickToCopy": "برای کپی کلیک کنید",
|
"editorTab": "ویرایشگر",
|
||||||
"previewDesktopOnly": "پیشنمایش فقط در نسخه وب دسکتاپ در دسترس است",
|
"previewHint": "پیشنمایش ایمیل را با مقادیر نمونه به جای متغیرها نشان میدهد.",
|
||||||
"previewNotAvailable": "پیشنمایش در دسترس نیست"
|
"clickToInsert": "برای درج در متن کلیک کنید",
|
||||||
|
"insertDefault": "درج قالب پیشفرض",
|
||||||
|
"insertDefaultConfirm": "محتوای فعلی با قالب پیشفرض جایگزین شود؟",
|
||||||
|
"testRecipientPlaceholder": "ایمیل گیرنده (پیشفرض: ایمیل شما)",
|
||||||
|
"testHint": "محتوای فعلی ویرایشگر با مقادیر نمونه به جای متغیرها ارسال میشود."
|
||||||
},
|
},
|
||||||
"wheel": {
|
"wheel": {
|
||||||
"title": "تنظیمات چرخ شانس",
|
"title": "تنظیمات چرخ شانس",
|
||||||
|
|||||||
@@ -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": "Платежи",
|
||||||
|
|||||||
@@ -1455,9 +1455,13 @@
|
|||||||
"resetted": "模板已恢复默认",
|
"resetted": "模板已恢复默认",
|
||||||
"testSent": "测试邮件已发送",
|
"testSent": "测试邮件已发送",
|
||||||
"variables": "可用变量",
|
"variables": "可用变量",
|
||||||
"clickToCopy": "点击复制",
|
"editorTab": "编辑器",
|
||||||
"previewDesktopOnly": "预览仅在桌面网页版可用",
|
"previewHint": "预览以示例值代替变量显示邮件。",
|
||||||
"previewNotAvailable": "预览不可用"
|
"clickToInsert": "点击插入到文本中",
|
||||||
|
"insertDefault": "插入默认模板",
|
||||||
|
"insertDefaultConfirm": "用默认模板替换当前内容?",
|
||||||
|
"testRecipientPlaceholder": "收件人邮箱(默认为您的邮箱)",
|
||||||
|
"testHint": "发送当前编辑器内容,变量将替换为示例值。"
|
||||||
},
|
},
|
||||||
"payments": {
|
"payments": {
|
||||||
"title": "支付",
|
"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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user