diff --git a/src/App.tsx b/src/App.tsx index e02e997..6bf60f8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -44,6 +44,7 @@ const AdminUsers = lazy(() => import('./pages/AdminUsers')) const AdminPayments = lazy(() => import('./pages/AdminPayments')) const AdminPromoOffers = lazy(() => import('./pages/AdminPromoOffers')) const AdminRemnawave = lazy(() => import('./pages/AdminRemnawave')) +const AdminEmailTemplates = lazy(() => import('./pages/AdminEmailTemplates')) function ProtectedRoute({ children }: { children: React.ReactNode }) { const { isAuthenticated, isLoading } = useAuthStore() @@ -332,6 +333,14 @@ function App() { } /> + + + + } + /> {/* Catch all */} } /> diff --git a/src/api/adminEmailTemplates.ts b/src/api/adminEmailTemplates.ts new file mode 100644 index 0000000..4c0b225 --- /dev/null +++ b/src/api/adminEmailTemplates.ts @@ -0,0 +1,85 @@ +import apiClient from './client' + +export interface EmailTemplateLanguageStatus { + has_custom: boolean +} + +export interface EmailTemplateType { + type: string + label: Record + description: Record + context_vars: string[] + languages: Record +} + +export interface EmailTemplateListResponse { + items: EmailTemplateType[] + available_languages: string[] +} + +export interface EmailTemplateLanguageData { + subject: string + body_html: string + is_default: boolean + default_subject: string + default_body_html: string +} + +export interface EmailTemplateDetail { + notification_type: string + label: Record + description: Record + context_vars: string[] + languages: Record +} + +export interface EmailTemplateUpdateRequest { + subject: string + body_html: string +} + +export interface EmailTemplatePreviewRequest { + language: string + subject?: string + body_html?: string +} + +export interface EmailTemplatePreviewResponse { + subject: string + body_html: string +} + +export interface EmailTemplateSendTestRequest { + language: string + email?: string +} + +export const adminEmailTemplatesApi = { + getTemplateTypes: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/email-templates') + return response.data + }, + + getTemplate: async (notificationType: string): Promise => { + const response = await apiClient.get(`/cabinet/admin/email-templates/${notificationType}`) + return response.data + }, + + updateTemplate: async (notificationType: string, language: string, data: EmailTemplateUpdateRequest): Promise => { + await apiClient.put(`/cabinet/admin/email-templates/${notificationType}/${language}`, data) + }, + + deleteTemplate: async (notificationType: string, language: string): Promise => { + await apiClient.delete(`/cabinet/admin/email-templates/${notificationType}/${language}`) + }, + + previewTemplate: async (notificationType: string, data: EmailTemplatePreviewRequest): Promise => { + const response = await apiClient.post(`/cabinet/admin/email-templates/${notificationType}/preview`, data) + return response.data + }, + + sendTestEmail: async (notificationType: string, data: EmailTemplateSendTestRequest): Promise<{ sent_to: string }> => { + const response = await apiClient.post<{ status: string; sent_to: string }>(`/cabinet/admin/email-templates/${notificationType}/test`, data) + return response.data + }, +} diff --git a/src/locales/en.json b/src/locales/en.json index 24c61a0..ac661f4 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -501,7 +501,8 @@ "broadcasts": "Broadcasts", "users": "Users", "payments": "Payments", - "remnawave": "RemnaWave" + "remnawave": "RemnaWave", + "emailTemplates": "Email Templates" }, "panel": { "title": "Admin Panel", @@ -517,7 +518,28 @@ "broadcastsDesc": "Mass messaging to users", "usersDesc": "Manage bot users", "paymentsDesc": "Payment verification", - "remnawaveDesc": "Panel management and statistics" + "remnawaveDesc": "Panel management and statistics", + "emailTemplatesDesc": "Manage email notification templates" + }, + "emailTemplates": { + "title": "Email Templates", + "description": "Manage email notification templates", + "subject": "Subject", + "subjectPlaceholder": "Email subject line...", + "body": "Body (HTML)", + "bodyHint": "HTML content that will be wrapped in the base email template with header and footer.", + "preview": "Preview", + "sendTest": "Send Test", + "resetDefault": "Reset to Default", + "resetConfirm": "Reset this template to the default version?", + "unsavedWarning": "Unsaved changes will be lost. Continue?", + "custom": "Custom", + "default": "Default", + "saved": "Template saved", + "resetted": "Template reset to default", + "testSent": "Test email sent", + "variables": "Available Variables", + "clickToCopy": "Click to copy" }, "payments": { "title": "Payment Verification", diff --git a/src/locales/fa.json b/src/locales/fa.json index 1f0e56d..ff875b6 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -367,7 +367,8 @@ "wheel": "چرخ", "tariffs": "تعرفه‌ها", "servers": "سرورها", - "broadcasts": "پیام‌رسانی" + "broadcasts": "پیام‌رسانی", + "emailTemplates": "قالب‌های ایمیل" }, "panel": { "title": "پنل مدیریت", @@ -379,7 +380,28 @@ "wheelDesc": "تنظیم چرخ شانس و جوایز", "tariffsDesc": "مدیریت طرح‌های تعرفه", "serversDesc": "تنظیم سرورهای VPN", - "broadcastsDesc": "ارسال پیام گروهی به کاربران" + "broadcastsDesc": "ارسال پیام گروهی به کاربران", + "emailTemplatesDesc": "مدیریت قالب‌های اعلان ایمیل" + }, + "emailTemplates": { + "title": "قالب‌های ایمیل", + "description": "مدیریت قالب‌های اعلان ایمیل", + "subject": "موضوع", + "subjectPlaceholder": "موضوع ایمیل...", + "body": "محتوا (HTML)", + "bodyHint": "محتوای HTML که در قالب پایه ایمیل با سربرگ و پاورقی قرار می‌گیرد.", + "preview": "پیش‌نمایش", + "sendTest": "ارسال آزمایشی", + "resetDefault": "بازگشت به پیش‌فرض", + "resetConfirm": "این قالب به نسخه پیش‌فرض بازگردانده شود؟", + "unsavedWarning": "تغییرات ذخیره نشده از بین خواهند رفت. ادامه؟", + "custom": "سفارشی", + "default": "پیش‌فرض", + "saved": "قالب ذخیره شد", + "resetted": "قالب به پیش‌فرض بازگردانده شد", + "testSent": "ایمیل آزمایشی ارسال شد", + "variables": "متغیرهای موجود", + "clickToCopy": "برای کپی کلیک کنید" }, "wheel": { "title": "تنظیمات چرخ شانس", diff --git a/src/locales/ru.json b/src/locales/ru.json index bab7920..10ce003 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -514,7 +514,8 @@ "broadcasts": "Рассылки", "users": "Пользователи", "payments": "Платежи", - "remnawave": "RemnaWave" + "remnawave": "RemnaWave", + "emailTemplates": "Email-шаблоны" }, "panel": { "title": "Панель администратора", @@ -530,7 +531,28 @@ "broadcastsDesc": "Массовая отправка сообщений", "usersDesc": "Управление пользователями бота", "paymentsDesc": "Проверка платежей", - "remnawaveDesc": "Управление панелью и статистика" + "remnawaveDesc": "Управление панелью и статистика", + "emailTemplatesDesc": "Шаблоны email-уведомлений" + }, + "emailTemplates": { + "title": "Email-шаблоны", + "description": "Управление шаблонами email-уведомлений", + "subject": "Тема письма", + "subjectPlaceholder": "Тема письма...", + "body": "Содержимое (HTML)", + "bodyHint": "HTML-контент, который будет обёрнут в базовый шаблон письма с шапкой и подвалом.", + "preview": "Предпросмотр", + "sendTest": "Тестовое письмо", + "resetDefault": "Сбросить по умолчанию", + "resetConfirm": "Сбросить этот шаблон к версии по умолчанию?", + "unsavedWarning": "Несохранённые изменения будут потеряны. Продолжить?", + "custom": "Кастомный", + "default": "По умолчанию", + "saved": "Шаблон сохранён", + "resetted": "Шаблон сброшен к значению по умолчанию", + "testSent": "Тестовое письмо отправлено", + "variables": "Доступные переменные", + "clickToCopy": "Нажмите для копирования" }, "payments": { "title": "Проверка платежей", diff --git a/src/locales/zh.json b/src/locales/zh.json index 7577b9f..f600804 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -368,7 +368,8 @@ "wheel": "转盘", "tariffs": "套餐", "servers": "服务器", - "broadcasts": "群发" + "broadcasts": "群发", + "emailTemplates": "邮件模板" }, "panel": { "title": "管理面板", @@ -380,7 +381,28 @@ "wheelDesc": "配置幸运转盘和奖品", "tariffsDesc": "管理套餐计划", "serversDesc": "配置VPN服务器", - "broadcastsDesc": "向用户群发消息" + "broadcastsDesc": "向用户群发消息", + "emailTemplatesDesc": "管理邮件通知模板" + }, + "emailTemplates": { + "title": "邮件模板", + "description": "管理邮件通知模板", + "subject": "主题", + "subjectPlaceholder": "邮件主题...", + "body": "内容 (HTML)", + "bodyHint": "HTML内容将被包裹在基础邮件模板中,包含页眉和页脚。", + "preview": "预览", + "sendTest": "发送测试", + "resetDefault": "恢复默认", + "resetConfirm": "将此模板恢复为默认版本?", + "unsavedWarning": "未保存的更改将丢失。继续?", + "custom": "自定义", + "default": "默认", + "saved": "模板已保存", + "resetted": "模板已恢复默认", + "testSent": "测试邮件已发送", + "variables": "可用变量", + "clickToCopy": "点击复制" }, "wheel": { "title": "幸运转盘设置", diff --git a/src/pages/AdminEmailTemplates.tsx b/src/pages/AdminEmailTemplates.tsx new file mode 100644 index 0000000..44d6c15 --- /dev/null +++ b/src/pages/AdminEmailTemplates.tsx @@ -0,0 +1,548 @@ +import { useState, useCallback, useRef, useEffect } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { Link } from 'react-router-dom' +import { + adminEmailTemplatesApi, + EmailTemplateType, + EmailTemplateDetail, + EmailTemplateLanguageData, +} from '../api/adminEmailTemplates' + +// Icons +const BackIcon = () => ( + + + +) + +const MailIcon = () => ( + + + +) + +const SaveIcon = () => ( + + + +) + +const EyeIcon = () => ( + + + + +) + +const SendIcon = () => ( + + + +) + +const ResetIcon = () => ( + + + +) + +const XIcon = () => ( + + + +) + +const LANG_LABELS: Record = { + ru: 'RU', + en: 'EN', + zh: 'ZH', + ua: 'UA', +} + +const LANG_FULL_LABELS: Record = { + ru: 'Русский', + en: 'English', + zh: '中文', + ua: 'Українська', +} + +// ============ Template List View ============ + +function TemplateCard({ + template, + currentLang, + onClick, +}: { + template: EmailTemplateType + currentLang: string + onClick: () => void +}) { + const label = template.label[currentLang] || template.label['en'] || template.type + const description = template.description[currentLang] || template.description['en'] || '' + const customCount = Object.values(template.languages).filter(l => l.has_custom).length + + return ( + + ) +} + +// ============ Template Editor ============ + +function TemplateEditor({ + detail, + onClose, + currentLang: interfaceLang, +}: { + detail: EmailTemplateDetail + onClose: () => void + currentLang: string +}) { + const { t } = useTranslation() + const queryClient = useQueryClient() + const [activeLang, setActiveLang] = useState('ru') + const [editSubject, setEditSubject] = useState('') + const [editBody, setEditBody] = useState('') + const [isDirty, setIsDirty] = useState(false) + const [showPreview, setShowPreview] = useState(false) + const [previewHtml, setPreviewHtml] = useState('') + const [toast, setToast] = useState<{ type: 'success' | 'error'; message: string } | null>(null) + const textareaRef = useRef(null) + const iframeRef = useRef(null) + + const langData: EmailTemplateLanguageData | undefined = detail.languages[activeLang] + + // Load data for current language + useEffect(() => { + if (langData) { + setEditSubject(langData.subject) + setEditBody(langData.is_default ? langData.body_html : langData.body_html) + setIsDirty(false) + } + }, [activeLang, langData]) + + const showToast = useCallback((type: 'success' | 'error', message: string) => { + setToast({ type, message }) + setTimeout(() => setToast(null), 3000) + }, []) + + // 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(/
\s*([\s\S]*?)\s*<\/div>\s*