mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
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:
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user