refactor: migrate to eslint flat config and format codebase with prettier

- Remove legacy .eslintrc.cjs and .eslintignore
- Add eslint.config.js with flat config, security rules (no-eval, no-implied-eval, no-new-func, no-script-url)
- Add .prettierrc and .prettierignore
- Format entire codebase with prettier
This commit is contained in:
c0mrade
2026-01-27 17:37:31 +03:00
parent 111ccc4e7a
commit bc90ba3779
133 changed files with 19972 additions and 15523 deletions

View File

@@ -1,71 +1,93 @@
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 { 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'
} from '../api/adminEmailTemplates';
// Icons
const BackIcon = () => (
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg
className="h-5 w-5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
)
);
const MailIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
/>
</svg>
)
);
const SaveIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
)
);
const EyeIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
)
);
const SendIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5"
/>
</svg>
)
);
const ResetIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
)
);
const XIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)
);
const LANG_LABELS: Record<string, string> = {
ru: 'RU',
en: 'EN',
zh: 'ZH',
ua: 'UA',
}
};
const LANG_FULL_LABELS: Record<string, string> = {
ru: 'Русский',
en: 'English',
zh: '中文',
ua: 'Українська',
}
};
// ============ Template List View ============
@@ -74,31 +96,31 @@ function TemplateCard({
currentLang,
onClick,
}: {
template: EmailTemplateType
currentLang: string
onClick: () => void
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
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 (
<button
onClick={onClick}
className="w-full text-left p-3 sm:p-4 bg-dark-800 rounded-xl border border-dark-700 hover:border-accent-500/50 transition-all duration-200 group"
className="group w-full rounded-xl border border-dark-700 bg-dark-800 p-3 text-left transition-all duration-200 hover:border-accent-500/50 sm:p-4"
>
<div className="flex items-start justify-between gap-2 sm:gap-3">
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-dark-100 group-hover:text-accent-400 transition-colors truncate">
<div className="min-w-0 flex-1">
<h3 className="truncate text-sm font-medium text-dark-100 transition-colors group-hover:text-accent-400">
{label}
</h3>
<p className="text-xs text-dark-400 mt-1 line-clamp-2">{description}</p>
<p className="mt-1 line-clamp-2 text-xs text-dark-400">{description}</p>
</div>
<div className="flex items-center gap-1 sm:gap-1.5 flex-shrink-0 mt-0.5">
<div className="mt-0.5 flex flex-shrink-0 items-center gap-1 sm:gap-1.5">
{Object.entries(template.languages).map(([lang, status]) => (
<span
key={lang}
className={`inline-flex items-center justify-center w-6 sm:w-7 h-5 rounded text-2xs font-medium ${
className={`inline-flex h-5 w-6 items-center justify-center rounded text-2xs font-medium sm:w-7 ${
status.has_custom
? 'bg-accent-500/20 text-accent-400 ring-1 ring-accent-500/30'
: 'bg-dark-700 text-dark-400'
@@ -112,14 +134,14 @@ function TemplateCard({
</div>
{customCount > 0 && (
<div className="mt-2">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-2xs bg-accent-500/10 text-accent-400">
<span className="w-1.5 h-1.5 rounded-full bg-accent-400" />
<span className="inline-flex items-center gap-1 rounded-full bg-accent-500/10 px-2 py-0.5 text-2xs text-accent-400">
<span className="h-1.5 w-1.5 rounded-full bg-accent-400" />
{customCount} custom
</span>
</div>
)}
</button>
)
);
}
// ============ Template Editor ============
@@ -129,225 +151,249 @@ function TemplateEditor({
onClose,
currentLang: interfaceLang,
}: {
detail: EmailTemplateDetail
onClose: () => void
currentLang: string
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<HTMLTextAreaElement>(null)
const iframeRef = useRef<HTMLIFrameElement>(null)
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<HTMLTextAreaElement>(null);
const iframeRef = useRef<HTMLIFrameElement>(null);
const langData: EmailTemplateLanguageData | undefined = detail.languages[activeLang]
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)
setEditSubject(langData.subject);
setEditBody(langData.is_default ? langData.body_html : langData.body_html);
setIsDirty(false);
}
}, [activeLang, langData])
}, [activeLang, langData]);
const showToast = useCallback((type: 'success' | 'error', message: string) => {
setToast({ type, message })
setTimeout(() => setToast(null), 3000)
}, [])
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(/<div class="content">\s*([\s\S]*?)\s*<\/div>\s*<div class="footer">/)
const contentMatch = html.match(
/<div class="content">\s*([\s\S]*?)\s*<\/div>\s*<div class="footer">/,
);
if (contentMatch) {
return contentMatch[1].trim()
return contentMatch[1].trim();
}
return html
}, [])
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))
setEditBody(extractBodyContent(langData.body_html));
} else {
setEditBody(langData.body_html)
setEditBody(langData.body_html);
}
setEditSubject(langData.subject)
setIsDirty(false)
setEditSubject(langData.subject);
setIsDirty(false);
}
}, [activeLang, langData, extractBodyContent])
}, [activeLang, langData, extractBodyContent]);
// Save mutation
const saveMutation = useMutation({
mutationFn: () => adminEmailTemplatesApi.updateTemplate(detail.notification_type, activeLang, {
subject: editSubject,
body_html: editBody,
}),
mutationFn: () =>
adminEmailTemplatesApi.updateTemplate(detail.notification_type, activeLang, {
subject: editSubject,
body_html: editBody,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'email-templates'] })
queryClient.invalidateQueries({ queryKey: ['admin', 'email-template', detail.notification_type] })
setIsDirty(false)
showToast('success', t('admin.emailTemplates.saved', 'Template saved'))
queryClient.invalidateQueries({ queryKey: ['admin', 'email-templates'] });
queryClient.invalidateQueries({
queryKey: ['admin', 'email-template', detail.notification_type],
});
setIsDirty(false);
showToast('success', t('admin.emailTemplates.saved', 'Template saved'));
},
onError: () => {
showToast('error', t('common.error', 'Error'))
showToast('error', t('common.error', 'Error'));
},
})
});
// Reset mutation
const resetMutation = useMutation({
mutationFn: () => adminEmailTemplatesApi.deleteTemplate(detail.notification_type, activeLang),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'email-templates'] })
queryClient.invalidateQueries({ queryKey: ['admin', 'email-template', detail.notification_type] })
setIsDirty(false)
showToast('success', t('admin.emailTemplates.resetted', 'Template reset to default'))
queryClient.invalidateQueries({ queryKey: ['admin', 'email-templates'] });
queryClient.invalidateQueries({
queryKey: ['admin', 'email-template', detail.notification_type],
});
setIsDirty(false);
showToast('success', t('admin.emailTemplates.resetted', 'Template reset to default'));
},
onError: () => {
showToast('error', t('common.error', 'Error'))
showToast('error', t('common.error', 'Error'));
},
})
});
// Preview mutation
const previewMutation = useMutation({
mutationFn: () => adminEmailTemplatesApi.previewTemplate(detail.notification_type, {
language: activeLang,
subject: editSubject,
body_html: editBody,
}),
mutationFn: () =>
adminEmailTemplatesApi.previewTemplate(detail.notification_type, {
language: activeLang,
subject: editSubject,
body_html: editBody,
}),
onSuccess: (data) => {
setPreviewHtml(data.body_html)
setShowPreview(true)
setPreviewHtml(data.body_html);
setShowPreview(true);
},
onError: () => {
showToast('error', t('common.error', 'Error'))
showToast('error', t('common.error', 'Error'));
},
})
});
// Send test mutation
const testMutation = useMutation({
mutationFn: () => adminEmailTemplatesApi.sendTestEmail(detail.notification_type, {
language: activeLang,
}),
mutationFn: () =>
adminEmailTemplatesApi.sendTestEmail(detail.notification_type, {
language: activeLang,
}),
onSuccess: (data) => {
showToast('success', `${t('admin.emailTemplates.testSent', 'Test email sent')}${data.sent_to}`)
showToast(
'success',
`${t('admin.emailTemplates.testSent', 'Test email sent')}${data.sent_to}`,
);
},
onError: () => {
showToast('error', t('common.error', 'Error'))
showToast('error', t('common.error', 'Error'));
},
})
});
// Write preview HTML into iframe
useEffect(() => {
if (showPreview && iframeRef.current && previewHtml) {
const doc = iframeRef.current.contentDocument
const doc = iframeRef.current.contentDocument;
if (doc) {
doc.open()
doc.write(previewHtml)
doc.close()
doc.open();
doc.write(previewHtml);
doc.close();
}
}
}, [showPreview, previewHtml])
}, [showPreview, previewHtml]);
const handleSubjectChange = (value: string) => {
setEditSubject(value)
setIsDirty(true)
}
setEditSubject(value);
setIsDirty(true);
};
const handleBodyChange = (value: string) => {
setEditBody(value)
setIsDirty(true)
}
setEditBody(value);
setIsDirty(true);
};
const label = detail.label[interfaceLang] || detail.label['en'] || detail.notification_type
const label = detail.label[interfaceLang] || detail.label['en'] || detail.notification_type;
return (
<div className="space-y-3 sm:space-y-4">
{/* Header */}
<div className="flex items-start sm:items-center justify-between gap-2">
<div className="flex items-start sm:items-center gap-2 sm:gap-3 min-w-0">
<button onClick={onClose} className="p-1 rounded-lg hover:bg-dark-700 transition-colors flex-shrink-0 mt-0.5 sm:mt-0">
<div className="flex items-start justify-between gap-2 sm:items-center">
<div className="flex min-w-0 items-start gap-2 sm:items-center sm:gap-3">
<button
onClick={onClose}
className="mt-0.5 flex-shrink-0 rounded-lg p-1 transition-colors hover:bg-dark-700 sm:mt-0"
>
<BackIcon />
</button>
<div className="min-w-0">
<h2 className="text-base sm:text-lg font-semibold text-dark-100 truncate">{label}</h2>
<p className="text-xs text-dark-400 line-clamp-2">
<h2 className="truncate text-base font-semibold text-dark-100 sm:text-lg">{label}</h2>
<p className="line-clamp-2 text-xs text-dark-400">
{detail.description[interfaceLang] || detail.description['en'] || ''}
</p>
</div>
</div>
{langData && !langData.is_default && (
<span className="px-2 sm:px-2.5 py-1 rounded-full text-2xs sm:text-xs font-medium bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/25 flex-shrink-0">
<span className="flex-shrink-0 rounded-full bg-accent-500/15 px-2 py-1 text-2xs font-medium text-accent-400 ring-1 ring-accent-500/25 sm:px-2.5 sm:text-xs">
Custom
</span>
)}
</div>
{/* Language tabs */}
<div className="flex items-center gap-1 p-1 bg-dark-900 rounded-lg overflow-x-auto">
{Object.keys(detail.languages).map(lang => {
const isActive = lang === activeLang
const langInfo = detail.languages[lang]
<div className="flex items-center gap-1 overflow-x-auto rounded-lg bg-dark-900 p-1">
{Object.keys(detail.languages).map((lang) => {
const isActive = lang === activeLang;
const langInfo = detail.languages[lang];
return (
<button
key={lang}
onClick={() => {
if (isDirty && !window.confirm(t('admin.emailTemplates.unsavedWarning', 'Unsaved changes will be lost. Continue?'))) return
setActiveLang(lang)
if (
isDirty &&
!window.confirm(
t(
'admin.emailTemplates.unsavedWarning',
'Unsaved changes will be lost. Continue?',
),
)
)
return;
setActiveLang(lang);
}}
className={`flex-1 px-2 sm:px-3 py-2 rounded-md text-xs sm:text-sm font-medium transition-all duration-150 flex items-center justify-center gap-1 sm:gap-1.5 whitespace-nowrap ${
className={`flex flex-1 items-center justify-center gap-1 whitespace-nowrap rounded-md px-2 py-2 text-xs font-medium transition-all duration-150 sm:gap-1.5 sm:px-3 sm:text-sm ${
isActive
? 'bg-dark-700 text-dark-100 shadow-sm'
: 'text-dark-400 hover:text-dark-200 hover:bg-dark-800'
: 'text-dark-400 hover:bg-dark-800 hover:text-dark-200'
}`}
>
<span className="sm:hidden">{LANG_LABELS[lang] || lang}</span>
<span className="hidden sm:inline">{LANG_FULL_LABELS[lang] || lang}</span>
{!langInfo.is_default && (
<span className="w-1.5 h-1.5 rounded-full bg-accent-400 flex-shrink-0" />
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-accent-400" />
)}
</button>
)
);
})}
</div>
{/* Subject */}
<div>
<label className="block text-xs font-medium text-dark-300 mb-1.5">
<label className="mb-1.5 block text-xs font-medium text-dark-300">
{t('admin.emailTemplates.subject', 'Subject')}
</label>
<input
type="text"
value={editSubject}
onChange={e => handleSubjectChange(e.target.value)}
className="w-full px-3 py-2.5 bg-dark-900 border border-dark-600 rounded-lg text-sm text-dark-100 placeholder-dark-500 focus:outline-none focus:ring-1 focus:ring-accent-500 focus:border-accent-500 transition-colors"
onChange={(e) => handleSubjectChange(e.target.value)}
className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2.5 text-sm text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500"
placeholder={t('admin.emailTemplates.subjectPlaceholder', 'Email subject line...')}
/>
</div>
{/* Context variables hint */}
{detail.context_vars.length > 0 && (
<div className="p-2.5 sm:p-3 bg-dark-900/60 border border-dark-700 rounded-lg">
<p className="text-xs font-medium text-dark-300 mb-1.5">
<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', 'Available Variables')}
</p>
<div className="flex flex-wrap gap-1 sm:gap-1.5">
{detail.context_vars.map(v => (
{detail.context_vars.map((v) => (
<code
key={v}
className="px-2 py-0.5 rounded bg-dark-700 text-accent-400 text-xs font-mono cursor-pointer hover:bg-dark-600 transition-colors"
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', 'Click to copy')}
onClick={() => {
navigator.clipboard.writeText(`{${v}}`)
showToast('success', `Copied {${v}}`)
navigator.clipboard.writeText(`{${v}}`);
showToast('success', `Copied {${v}}`);
}}
>
{`{${v}}`}
@@ -359,30 +405,33 @@ function TemplateEditor({
{/* Body HTML editor */}
<div>
<label className="block text-xs font-medium text-dark-300 mb-1.5">
<label className="mb-1.5 block text-xs font-medium text-dark-300">
{t('admin.emailTemplates.body', 'Body (HTML)')}
</label>
<textarea
ref={textareaRef}
value={editBody}
onChange={e => handleBodyChange(e.target.value)}
onChange={(e) => handleBodyChange(e.target.value)}
rows={12}
className="w-full px-3 py-2.5 bg-dark-900 border border-dark-600 rounded-lg text-xs sm:text-sm text-dark-100 placeholder-dark-500 font-mono leading-relaxed focus:outline-none focus:ring-1 focus:ring-accent-500 focus:border-accent-500 transition-colors resize-y min-h-[200px] sm:min-h-[300px]"
className="min-h-[200px] w-full resize-y rounded-lg border border-dark-600 bg-dark-900 px-3 py-2.5 font-mono text-xs leading-relaxed text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500 sm:min-h-[300px] sm:text-sm"
placeholder="<h2>Title</h2><p>Content...</p>"
spellCheck={false}
/>
<p className="text-2xs text-dark-500 mt-1">
{t('admin.emailTemplates.bodyHint', 'HTML content that will be wrapped in the base email template with header and footer.')}
<p className="mt-1 text-2xs text-dark-500">
{t(
'admin.emailTemplates.bodyHint',
'HTML content that will be wrapped in the base email template with header and footer.',
)}
</p>
</div>
{/* Actions */}
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-2">
<div className="grid grid-cols-2 sm:flex gap-2">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
<div className="grid grid-cols-2 gap-2 sm:flex">
<button
onClick={() => saveMutation.mutate()}
disabled={!isDirty || saveMutation.isPending}
className="inline-flex items-center justify-center gap-1.5 px-3 sm:px-4 py-2.5 sm:py-2 rounded-lg text-sm font-medium bg-accent-500 text-white hover:bg-accent-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-accent-500 px-3 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-40 sm:px-4 sm:py-2"
>
<SaveIcon />
{saveMutation.isPending ? t('common.loading', 'Loading...') : t('common.save', 'Save')}
@@ -391,35 +440,46 @@ function TemplateEditor({
<button
onClick={() => previewMutation.mutate()}
disabled={previewMutation.isPending}
className="inline-flex items-center justify-center gap-1.5 px-3 sm:px-4 py-2.5 sm:py-2 rounded-lg text-sm font-medium bg-dark-700 text-dark-200 hover:bg-dark-600 disabled:opacity-40 transition-colors"
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"
>
<EyeIcon />
{t('admin.emailTemplates.preview', 'Preview')}
</button>
</div>
<div className="grid grid-cols-2 sm:flex gap-2">
<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 px-3 sm:px-4 py-2.5 sm:py-2 rounded-lg text-sm font-medium bg-dark-700 text-dark-200 hover:bg-dark-600 disabled:opacity-40 transition-colors"
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 />
{testMutation.isPending ? t('common.loading', 'Loading...') : t('admin.emailTemplates.sendTest', 'Send Test')}
{testMutation.isPending
? t('common.loading', 'Loading...')
: t('admin.emailTemplates.sendTest', 'Send Test')}
</button>
{langData && !langData.is_default && (
<button
onClick={() => {
if (window.confirm(t('admin.emailTemplates.resetConfirm', 'Reset this template to the default version?'))) {
resetMutation.mutate()
if (
window.confirm(
t(
'admin.emailTemplates.resetConfirm',
'Reset this template to the default version?',
),
)
) {
resetMutation.mutate();
}
}}
disabled={resetMutation.isPending}
className="inline-flex items-center justify-center gap-1.5 px-3 sm:px-4 py-2.5 sm:py-2 rounded-lg text-sm font-medium bg-dark-700 text-warning-400 hover:bg-dark-600 disabled:opacity-40 transition-colors sm:ml-auto"
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"
>
<ResetIcon />
<span className="truncate">{t('admin.emailTemplates.resetDefault', 'Reset to Default')}</span>
<span className="truncate">
{t('admin.emailTemplates.resetDefault', 'Reset to Default')}
</span>
</button>
)}
</div>
@@ -427,26 +487,26 @@ function TemplateEditor({
{/* Toast */}
{toast && (
<div className={`fixed bottom-4 left-4 right-4 sm:left-auto sm:right-6 sm:bottom-6 z-50 px-4 py-3 rounded-xl text-sm font-medium shadow-lg animate-fade-in text-center sm:text-left ${
toast.type === 'success'
? 'bg-emerald-500/90 text-white'
: 'bg-red-500/90 text-white'
}`}>
<div
className={`fixed bottom-4 left-4 right-4 z-50 animate-fade-in rounded-xl px-4 py-3 text-center text-sm font-medium shadow-lg sm:bottom-6 sm:left-auto sm:right-6 sm:text-left ${
toast.type === 'success' ? 'bg-emerald-500/90 text-white' : 'bg-red-500/90 text-white'
}`}
>
{toast.message}
</div>
)}
{/* Preview Modal */}
{showPreview && (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center sm:p-4 bg-black/60 backdrop-blur-sm">
<div className="bg-dark-800 rounded-t-2xl sm:rounded-2xl w-full sm:max-w-2xl max-h-[90vh] sm:max-h-[85vh] flex flex-col border border-dark-600 shadow-2xl">
<div className="flex items-center justify-between px-4 sm:px-5 py-3 sm:py-4 border-b border-dark-700">
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/60 backdrop-blur-sm sm:items-center sm:p-4">
<div className="flex max-h-[90vh] w-full flex-col rounded-t-2xl border border-dark-600 bg-dark-800 shadow-2xl sm:max-h-[85vh] sm:max-w-2xl sm:rounded-2xl">
<div className="flex items-center justify-between border-b border-dark-700 px-4 py-3 sm:px-5 sm:py-4">
<h3 className="text-base font-semibold text-dark-100">
{t('admin.emailTemplates.preview', 'Preview')}
</h3>
<button
onClick={() => setShowPreview(false)}
className="p-1.5 rounded-lg hover:bg-dark-700 transition-colors"
className="rounded-lg p-1.5 transition-colors hover:bg-dark-700"
>
<XIcon />
</button>
@@ -454,7 +514,7 @@ function TemplateEditor({
<div className="flex-1 overflow-hidden p-1">
<iframe
ref={iframeRef}
className="w-full h-full min-h-[50vh] sm:min-h-[400px] rounded-lg bg-white"
className="h-full min-h-[50vh] w-full rounded-lg bg-white sm:min-h-[400px]"
sandbox="allow-same-origin"
title="Email Preview"
/>
@@ -463,48 +523,48 @@ function TemplateEditor({
</div>
)}
</div>
)
);
}
// ============ Main Page ============
export default function AdminEmailTemplates() {
const { t, i18n } = useTranslation()
const currentLang = i18n.language || 'ru'
const [selectedType, setSelectedType] = useState<string | null>(null)
const { t, i18n } = useTranslation();
const currentLang = i18n.language || 'ru';
const [selectedType, setSelectedType] = useState<string | null>(null);
// Fetch template types list
const { data: typesData, isLoading: typesLoading } = useQuery({
queryKey: ['admin', 'email-templates'],
queryFn: adminEmailTemplatesApi.getTemplateTypes,
})
});
// Fetch detail for selected type
const { data: detailData, isLoading: detailLoading } = useQuery({
queryKey: ['admin', 'email-template', selectedType],
queryFn: () => adminEmailTemplatesApi.getTemplate(selectedType!),
enabled: !!selectedType,
})
});
return (
<div className="max-w-4xl mx-auto px-3 sm:px-4 py-4 sm:py-6 space-y-4 sm:space-y-6">
<div className="mx-auto max-w-4xl space-y-4 px-3 py-4 sm:space-y-6 sm:px-4 sm:py-6">
{/* Page Header */}
<div className="flex items-center gap-2 sm:gap-3">
<Link
to="/admin"
className="p-1.5 sm:p-2 rounded-xl bg-dark-800 hover:bg-dark-700 transition-colors border border-dark-700 flex-shrink-0"
className="flex-shrink-0 rounded-xl border border-dark-700 bg-dark-800 p-1.5 transition-colors hover:bg-dark-700 sm:p-2"
>
<BackIcon />
</Link>
<div className="flex items-center gap-2 sm:gap-2.5 min-w-0">
<div className="p-1.5 sm:p-2 rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/10 text-blue-400 flex-shrink-0">
<div className="flex min-w-0 items-center gap-2 sm:gap-2.5">
<div className="flex-shrink-0 rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/10 p-1.5 text-blue-400 sm:p-2">
<MailIcon />
</div>
<div className="min-w-0">
<h1 className="text-lg sm:text-xl font-bold text-dark-100 truncate">
<h1 className="truncate text-lg font-bold text-dark-100 sm:text-xl">
{t('admin.emailTemplates.title', 'Email Templates')}
</h1>
<p className="text-xs text-dark-400 truncate">
<p className="truncate text-xs text-dark-400">
{t('admin.emailTemplates.description', 'Manage email notification templates')}
</p>
</div>
@@ -524,12 +584,12 @@ export default function AdminEmailTemplates() {
{typesLoading ? (
<div className="space-y-3">
{[...Array(6)].map((_, i) => (
<div key={i} className="h-20 bg-dark-800 rounded-xl animate-pulse" />
<div key={i} className="h-20 animate-pulse rounded-xl bg-dark-800" />
))}
</div>
) : (
<div className="grid gap-2 sm:gap-3 grid-cols-1 sm:grid-cols-2">
{typesData?.items.map(template => (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-3">
{typesData?.items.map((template) => (
<TemplateCard
key={template.type}
template={template}
@@ -545,9 +605,9 @@ export default function AdminEmailTemplates() {
{/* Detail loading overlay */}
{selectedType && detailLoading && (
<div className="flex items-center justify-center py-16">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
)}
</div>
)
);
}