633 lines
27 KiB
TypeScript
633 lines
27 KiB
TypeScript
'use client';
|
|
|
|
import { useTranslation } from '../../../i18n/useTranslation';
|
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
import { authFetch } from '../../../utils/authFetch';
|
|
import { useToast } from '../../../components/toast/toastComponent';
|
|
|
|
const API_BASE = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '');
|
|
const MAIL_TEMPLATES_BASE = `${API_BASE}/api/admin/mail-templates`;
|
|
|
|
/** Convert kebab-case to camelCase for use as i18n key segment */
|
|
function toCamelCase(str: string): string {
|
|
return str.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
|
|
}
|
|
|
|
/** Derive the i18n translation key for a mail template's subject */
|
|
function subjectI18nKey(templateType: string): string {
|
|
return `mailTemplates.${toCamelCase(templateType)}.subject`;
|
|
}
|
|
|
|
/** Derive the i18n translation key for a mail template's HTML content */
|
|
function htmlI18nKey(templateType: string): string {
|
|
return `mailTemplates.${toCamelCase(templateType)}.htmlContent`;
|
|
}
|
|
|
|
/**
|
|
* Sync mail template content to the i18n translation files so it appears
|
|
* in Language Management and can be translated to other languages.
|
|
*/
|
|
async function syncToI18n(templateType: string, subject: string, htmlContent: string): Promise<void> {
|
|
const res = await fetch('/api/i18n/translations', { cache: 'no-store' });
|
|
const data = await res.json();
|
|
if (!res.ok || !data?.ok) throw new Error(data?.message || 'Failed to load translations');
|
|
|
|
const translations: Record<string, Record<string, string>> = data.translations ?? {};
|
|
const sKey = subjectI18nKey(templateType);
|
|
const hKey = htmlI18nKey(templateType);
|
|
|
|
const updated = {
|
|
...translations,
|
|
en: { ...(translations.en ?? {}), [sKey]: subject, [hKey]: htmlContent },
|
|
};
|
|
|
|
const putRes = await fetch('/api/i18n/translations', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ translations: updated }),
|
|
});
|
|
const putData = await putRes.json();
|
|
if (!putRes.ok || !putData?.ok) throw new Error(putData?.message || 'Failed to sync to Language Management');
|
|
}
|
|
|
|
type MailTemplate = {
|
|
id: number;
|
|
template_type: string;
|
|
name: string;
|
|
subject: string | null;
|
|
html_content: string;
|
|
is_active: boolean;
|
|
is_archived: boolean;
|
|
archived_at: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
};
|
|
|
|
type EditorState = {
|
|
template_type: string;
|
|
name: string;
|
|
subject: string;
|
|
html_content: string;
|
|
};
|
|
|
|
function createBlankEditor(t: (key: string) => string): EditorState {
|
|
return {
|
|
template_type: '',
|
|
name: '',
|
|
subject: '',
|
|
html_content: `<div style="font-family:Arial,sans-serif;line-height:1.5;"><h2>${t('autofix.k88f0d12a')}</h2><p>${t('autofix.k4f530782')}</p></div>`,
|
|
};
|
|
}
|
|
|
|
export default function MailTemplatesManager() {
|
|
const { t } = useTranslation();
|
|
const { showToast } = useToast();
|
|
const blankEditor = useMemo(() => createBlankEditor(t), [t]);
|
|
|
|
const [tab, setTab] = useState<'active' | 'archived'>('active');
|
|
const [templates, setTemplates] = useState<MailTemplate[]>([]);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [fetchError, setFetchError] = useState<string | null>(null);
|
|
|
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
|
const [isCreating, setIsCreating] = useState(false);
|
|
const [i18nSyncStatus, setI18nSyncStatus] = useState<'idle' | 'syncing' | 'synced' | 'error'>('idle');
|
|
const [editor, setEditor] = useState<EditorState>(blankEditor);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null);
|
|
|
|
const fetchTemplates = useCallback(async (includeArchived: boolean) => {
|
|
setIsLoading(true);
|
|
setFetchError(null);
|
|
try {
|
|
const url = `${MAIL_TEMPLATES_BASE}${includeArchived ? '?includeArchived=true' : ''}`;
|
|
const res = await authFetch(url);
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
throw new Error((err as any)?.message || `Error ${res.status}`);
|
|
}
|
|
const raw = await res.json();
|
|
// API may return a bare array or a wrapper object { data: [...] } / { templates: [...] }
|
|
const data: MailTemplate[] = Array.isArray(raw)
|
|
? raw
|
|
: Array.isArray(raw?.data)
|
|
? raw.data
|
|
: Array.isArray(raw?.templates)
|
|
? raw.templates
|
|
: [];
|
|
setTemplates(data);
|
|
} catch (e: any) {
|
|
setFetchError(e?.message || 'Failed to load templates');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchTemplates(tab === 'archived');
|
|
setSelectedId(null);
|
|
setIsCreating(false);
|
|
setEditor(blankEditor);
|
|
}, [tab, fetchTemplates, blankEditor]);
|
|
|
|
const visibleTemplates = tab === 'archived'
|
|
? templates.filter((tmpl) => tmpl.is_archived)
|
|
: templates.filter((tmpl) => !tmpl.is_archived);
|
|
|
|
const selectedTemplate = templates.find((tmpl) => tmpl.id === selectedId) ?? null;
|
|
|
|
const openEditor = (template: MailTemplate) => {
|
|
setIsCreating(false);
|
|
setSelectedId(template.id);
|
|
setI18nSyncStatus('idle');
|
|
setEditor({
|
|
template_type: template.template_type,
|
|
name: template.name,
|
|
subject: template.subject ?? '',
|
|
html_content: template.html_content,
|
|
});
|
|
};
|
|
|
|
const startCreate = () => {
|
|
setIsCreating(true);
|
|
setSelectedId(null);
|
|
setI18nSyncStatus('idle');
|
|
setEditor(blankEditor);
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (isSaving) return;
|
|
setIsSaving(true);
|
|
setI18nSyncStatus('idle');
|
|
let savedTemplateType = editor.template_type;
|
|
let savedSubject = editor.subject;
|
|
let savedHtmlContent = editor.html_content;
|
|
try {
|
|
if (isCreating) {
|
|
const res = await authFetch(MAIL_TEMPLATES_BASE, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
template_type: editor.template_type,
|
|
name: editor.name,
|
|
subject: editor.subject || undefined,
|
|
html_content: editor.html_content,
|
|
}),
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
throw new Error((err as any)?.message || `Error ${res.status}`);
|
|
}
|
|
const created: MailTemplate = await res.json();
|
|
savedTemplateType = created.template_type;
|
|
savedSubject = created.subject ?? editor.subject;
|
|
savedHtmlContent = created.html_content;
|
|
showToast({ variant: 'success', message: t('autofix.ke8a3bd92') });
|
|
await fetchTemplates(false);
|
|
setTab('active');
|
|
setIsCreating(false);
|
|
setSelectedId(created.id);
|
|
setEditor({
|
|
template_type: created.template_type,
|
|
name: created.name,
|
|
subject: created.subject ?? '',
|
|
html_content: created.html_content,
|
|
});
|
|
} else if (selectedId !== null) {
|
|
const res = await authFetch(`${MAIL_TEMPLATES_BASE}/${selectedId}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
template_type: editor.template_type,
|
|
name: editor.name,
|
|
subject: editor.subject || undefined,
|
|
html_content: editor.html_content,
|
|
}),
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
throw new Error((err as any)?.message || `Error ${res.status}`);
|
|
}
|
|
showToast({ variant: 'success', message: t('autofix.k9535ed27') });
|
|
await fetchTemplates(tab === 'archived');
|
|
}
|
|
|
|
// Sync content to i18n system so it's translatable in Language Management
|
|
if (savedTemplateType?.trim()) {
|
|
setI18nSyncStatus('syncing');
|
|
syncToI18n(savedTemplateType, savedSubject, savedHtmlContent)
|
|
.then(() => setI18nSyncStatus('synced'))
|
|
.catch(() => setI18nSyncStatus('error'));
|
|
}
|
|
} catch (e: any) {
|
|
showToast({ variant: 'error', message: e?.message || t('autofix.kb743b7c2') });
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleActivate = async (id: number) => {
|
|
setActionLoadingId(id);
|
|
try {
|
|
const res = await authFetch(`${MAIL_TEMPLATES_BASE}/${id}/activate`, { method: 'PATCH' });
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
throw new Error((err as any)?.message || `Error ${res.status}`);
|
|
}
|
|
showToast({ variant: 'success', message: t('autofix.ke1a18ce6') });
|
|
await fetchTemplates(tab === 'archived');
|
|
} catch (e: any) {
|
|
showToast({ variant: 'error', message: e?.message || t('autofix.k1c5f641a') });
|
|
} finally {
|
|
setActionLoadingId(null);
|
|
}
|
|
};
|
|
|
|
const handleArchive = async (id: number) => {
|
|
setActionLoadingId(id);
|
|
try {
|
|
const res = await authFetch(`${MAIL_TEMPLATES_BASE}/${id}/archive`, { method: 'PATCH' });
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
throw new Error((err as any)?.message || `Error ${res.status}`);
|
|
}
|
|
showToast({ variant: 'success', message: t('autofix.k2f162f5d') });
|
|
if (selectedId === id) {
|
|
setSelectedId(null);
|
|
setEditor(blankEditor);
|
|
}
|
|
await fetchTemplates(tab === 'archived');
|
|
} catch (e: any) {
|
|
showToast({ variant: 'error', message: e?.message || t('autofix.kff7b3b21') });
|
|
} finally {
|
|
setActionLoadingId(null);
|
|
}
|
|
};
|
|
|
|
const handleUnarchive = async (id: number) => {
|
|
setActionLoadingId(id);
|
|
try {
|
|
const res = await authFetch(`${MAIL_TEMPLATES_BASE}/${id}/unarchive`, { method: 'PATCH' });
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
throw new Error((err as any)?.message || (err as any)?.error || `Error ${res.status}`);
|
|
}
|
|
showToast({ variant: 'success', message: t('autofix.kc3a71e92') });
|
|
setSelectedId(null);
|
|
setEditor(blankEditor);
|
|
setTab('active');
|
|
await fetchTemplates(false);
|
|
} catch (e: any) {
|
|
showToast({ variant: 'error', message: e?.message || t('autofix.ka91f3c05') });
|
|
} finally {
|
|
setActionLoadingId(null);
|
|
}
|
|
};
|
|
|
|
const removeI18nKeys = async (templateType: string) => {
|
|
try {
|
|
const res = await fetch('/api/i18n/translations', { cache: 'no-store' });
|
|
const data = await res.json();
|
|
if (!res.ok || !data?.ok) return;
|
|
const translations: Record<string, Record<string, string>> = data.translations ?? {};
|
|
const sKey = subjectI18nKey(templateType);
|
|
const hKey = htmlI18nKey(templateType);
|
|
const updated: Record<string, Record<string, string>> = {};
|
|
for (const lang of Object.keys(translations)) {
|
|
const copy = { ...translations[lang] };
|
|
delete copy[sKey];
|
|
delete copy[hKey];
|
|
updated[lang] = copy;
|
|
}
|
|
await fetch('/api/i18n/translations', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ translations: updated }),
|
|
});
|
|
} catch {
|
|
// best-effort — don't block the delete flow
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id: number) => {
|
|
if (!window.confirm(t('autofix.ka63bb731'))) return;
|
|
const templateType = templates.find((tmpl) => tmpl.id === id)?.template_type ?? '';
|
|
setActionLoadingId(id);
|
|
try {
|
|
const res = await authFetch(`${MAIL_TEMPLATES_BASE}/${id}`, { method: 'DELETE' });
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
throw new Error((err as any)?.message || `Error ${res.status}`);
|
|
}
|
|
showToast({ variant: 'success', message: t('autofix.kf6b83106') });
|
|
if (selectedId === id) {
|
|
setSelectedId(null);
|
|
setEditor(blankEditor);
|
|
}
|
|
await fetchTemplates(tab === 'archived');
|
|
if (templateType.trim()) {
|
|
await removeI18nKeys(templateType);
|
|
}
|
|
} catch (e: any) {
|
|
showToast({ variant: 'error', message: e?.message || t('autofix.kccf6593a') });
|
|
} finally {
|
|
setActionLoadingId(null);
|
|
}
|
|
};
|
|
|
|
const editorOpen = isCreating || selectedId !== null;
|
|
|
|
return (
|
|
<section className="rounded-[28px] border border-white/80 bg-white/85 p-4 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.38)] backdrop-blur md:p-6 xl:p-7">
|
|
{/* Header */}
|
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
|
<h2 className="flex items-center gap-2 text-xl font-semibold text-slate-900">
|
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7l9 6 9-6M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
|
</svg>
|
|
{t('autofix.kd93a60af')}
|
|
</h2>
|
|
<div className="flex flex-wrap gap-2">
|
|
{tab === 'active' && (
|
|
<button
|
|
type="button"
|
|
onClick={startCreate}
|
|
className="inline-flex items-center rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800 transition"
|
|
>
|
|
+ New Mail Template
|
|
</button>
|
|
)}
|
|
{editorOpen && (
|
|
<button
|
|
type="button"
|
|
onClick={handleSave}
|
|
disabled={isSaving}
|
|
className="inline-flex items-center rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-800 hover:bg-slate-50 transition disabled:opacity-50"
|
|
>{isSaving ? t('autofix.kac6cedc7') : (isCreating ? t('autofix.k987f2b90') : t('autofix.k9f7c3d1e'))}</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => fetchTemplates(tab === 'archived')}
|
|
disabled={isLoading}
|
|
title="Refresh"
|
|
className="inline-flex items-center rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-600 hover:bg-slate-50 transition disabled:opacity-50"
|
|
>
|
|
<svg className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="mb-5 flex gap-1 rounded-2xl border border-slate-200 bg-slate-100/60 p-1 w-fit">
|
|
<button
|
|
type="button"
|
|
onClick={() => setTab('active')}
|
|
className={`rounded-xl px-4 py-2 text-sm font-semibold transition ${
|
|
tab === 'active' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-800'
|
|
}`}
|
|
>{t('autofix.kbdcb654a')}</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setTab('archived')}
|
|
className={`rounded-xl px-4 py-2 text-sm font-semibold transition ${
|
|
tab === 'archived' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-800'
|
|
}`}
|
|
>
|
|
Archived
|
|
</button>
|
|
</div>
|
|
|
|
{fetchError && (
|
|
<div className="mb-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
|
|
{fetchError}
|
|
</div>
|
|
)}
|
|
|
|
<div className={`grid gap-4 ${editorOpen ? 'lg:grid-cols-[290px_minmax(0,1fr)]' : ''}`}>
|
|
{/* Template List */}
|
|
<aside className="rounded-2xl border border-slate-200 bg-white/80 p-3">
|
|
<h3 className="px-2 pb-2 text-sm font-semibold text-slate-800">{tab === 'archived' ? t('autofix.kc097ece0') : t('autofix.k2fbe0857')}</h3>
|
|
|
|
{isLoading && (
|
|
<div className="px-2 py-4 text-sm text-slate-400 text-center">{t('autofix.k832387c5')}</div>
|
|
)}
|
|
|
|
{!isLoading && visibleTemplates.length === 0 && !isCreating && (
|
|
<div className="rounded-lg border border-dashed border-slate-200 p-3 text-xs text-slate-500">{tab === 'archived' ? t('autofix.k245ba4af') : t('autofix.k247b74e1')}</div>
|
|
)}
|
|
|
|
{isCreating && (
|
|
<div className="mb-2 rounded-xl border border-slate-900 bg-slate-900 px-3 py-2 text-white">
|
|
<div className="text-sm font-semibold truncate">{t('autofix.k2f343849')}</div>
|
|
<div className="mt-0.5 text-[11px] text-slate-300">Draft</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
{visibleTemplates.map((template) => {
|
|
const isSelected = !isCreating && selectedId === template.id;
|
|
const isBusy = actionLoadingId === template.id;
|
|
return (
|
|
<div
|
|
key={template.id}
|
|
className={`rounded-xl border transition ${
|
|
isSelected ? 'border-slate-900 bg-slate-900' : 'border-slate-200 bg-white hover:bg-slate-50'
|
|
}`}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => openEditor(template)}
|
|
className="w-full px-3 pt-2 pb-1 text-left"
|
|
>
|
|
<div className={`text-sm font-semibold truncate ${isSelected ? 'text-white' : 'text-slate-800'}`}>
|
|
{template.name}
|
|
</div>
|
|
<div className={`mt-0.5 text-[11px] flex items-center gap-1.5 ${isSelected ? 'text-slate-300' : 'text-slate-500'}`}>
|
|
<span className="truncate">{template.template_type}</span>
|
|
{template.is_active && !template.is_archived && (
|
|
<span className={`shrink-0 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold ${isSelected ? 'bg-emerald-500 text-white' : 'bg-emerald-100 text-emerald-700'}`}>
|
|
Active
|
|
</span>
|
|
)}
|
|
</div>
|
|
</button>
|
|
|
|
<div className="flex flex-wrap items-center gap-1 px-3 pb-2">
|
|
<button
|
|
type="button"
|
|
title="Edit"
|
|
onClick={() => openEditor(template)}
|
|
className={`rounded-lg px-2 py-1 text-[11px] font-semibold transition ${
|
|
isSelected ? 'bg-slate-700 text-slate-200 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
|
}`}
|
|
>
|
|
Edit
|
|
</button>
|
|
|
|
{!template.is_archived && !template.is_active && (
|
|
<button
|
|
type="button"
|
|
title="Activate"
|
|
disabled={isBusy}
|
|
onClick={() => handleActivate(template.id)}
|
|
className="rounded-lg px-2 py-1 text-[11px] font-semibold bg-emerald-50 text-emerald-700 hover:bg-emerald-100 transition disabled:opacity-50"
|
|
>
|
|
Activate
|
|
</button>
|
|
)}
|
|
|
|
{!template.is_archived && template.is_active && (
|
|
<span className="rounded-lg px-2 py-1 text-[11px] font-semibold bg-emerald-100 text-emerald-700 cursor-default select-none">{t('autofix.k26404a1a')}</span>
|
|
)}
|
|
|
|
{!template.is_archived && (
|
|
<button
|
|
type="button"
|
|
title="Archive"
|
|
disabled={isBusy}
|
|
onClick={() => handleArchive(template.id)}
|
|
className="rounded-lg px-2 py-1 text-[11px] font-semibold bg-amber-50 text-amber-700 hover:bg-amber-100 transition disabled:opacity-50"
|
|
>
|
|
Archive
|
|
</button>
|
|
)}
|
|
|
|
{template.is_archived && (
|
|
<button
|
|
type="button"
|
|
title="Unarchive"
|
|
disabled={isBusy}
|
|
onClick={() => handleUnarchive(template.id)}
|
|
className="rounded-lg px-2 py-1 text-[11px] font-semibold bg-sky-50 text-sky-700 hover:bg-sky-100 transition disabled:opacity-50"
|
|
>
|
|
Unarchive
|
|
</button>
|
|
)}
|
|
|
|
<button
|
|
type="button"
|
|
title="Delete"
|
|
disabled={isBusy}
|
|
onClick={() => handleDelete(template.id)}
|
|
className="rounded-lg px-2 py-1 text-[11px] font-semibold bg-rose-50 text-rose-700 hover:bg-rose-100 transition disabled:opacity-50"
|
|
>
|
|
Delete
|
|
</button>
|
|
|
|
{isBusy && (
|
|
<svg className="ml-1 w-3.5 h-3.5 animate-spin text-slate-400" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
|
</svg>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Editor + Preview */}
|
|
{editorOpen && (
|
|
<div className="space-y-4">
|
|
<div className="rounded-2xl border border-slate-200 bg-white/85 p-4">
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
<label className="block">
|
|
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-600">{t('autofix.k2fb166ad')}</div>
|
|
<input
|
|
value={editor.template_type}
|
|
onChange={(e) => { setEditor((s) => ({ ...s, template_type: e.target.value })); setI18nSyncStatus('idle'); }}
|
|
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
|
placeholder={t('autofix.k1a7aa84d')}
|
|
/>
|
|
</label>
|
|
|
|
<label className="block">
|
|
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-600">{t('autofix.k2fc164d2')}</div>
|
|
<input
|
|
value={editor.name}
|
|
onChange={(e) => setEditor((s) => ({ ...s, name: e.target.value }))}
|
|
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
|
placeholder={t('autofix.k32764a91')}
|
|
/>
|
|
</label>
|
|
|
|
{/* i18n key info + sync status */}
|
|
{editor.template_type?.trim() && (
|
|
<div className="md:col-span-2 rounded-xl border border-sky-200 bg-sky-50/60 px-4 py-3 space-y-1.5">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-1.5 text-xs font-semibold text-sky-700">
|
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
|
|
</svg>{t('autofix.k48b366e4')}</div>
|
|
{i18nSyncStatus === 'syncing' && (
|
|
<span className="text-[11px] text-sky-500 flex items-center gap-1">
|
|
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/></svg>{t('autofix.ke4326584')}</span>
|
|
)}
|
|
{i18nSyncStatus === 'synced' && (
|
|
<span className="text-[11px] text-emerald-600 font-semibold">{t('autofix.kf5fec72a')}</span>
|
|
)}
|
|
{i18nSyncStatus === 'error' && (
|
|
<span className="text-[11px] text-rose-600 font-semibold">{t('autofix.k5321f8f0')}</span>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-col gap-1 text-[11px] font-mono text-sky-800">
|
|
<span className="flex items-center gap-1.5">
|
|
<span className="text-sky-400 select-none">{t('autofix.k8aea9103')}</span>
|
|
<span className="select-all bg-sky-100 rounded px-1.5 py-0.5">{subjectI18nKey(editor.template_type)}</span>
|
|
</span>
|
|
<span className="flex items-center gap-1.5">
|
|
<span className="text-sky-400 select-none">{t('autofix.k0aa53382')}</span>
|
|
<span className="select-all bg-sky-100 rounded px-1.5 py-0.5">{htmlI18nKey(editor.template_type)}</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<label className="block md:col-span-2">
|
|
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-600">Subject</div>
|
|
<input
|
|
value={editor.subject}
|
|
onChange={(e) => setEditor((s) => ({ ...s, subject: e.target.value }))}
|
|
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
|
placeholder="Subject with placeholders like {{firstName}}"
|
|
/>
|
|
</label>
|
|
|
|
<label className="block md:col-span-2">
|
|
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-600">{t('autofix.kb56e3ea8')}</div>
|
|
<textarea
|
|
value={editor.html_content}
|
|
onChange={(e) => setEditor((s) => ({ ...s, html_content: e.target.value }))}
|
|
className="min-h-[220px] w-full rounded-lg border border-slate-200 px-3 py-2 text-sm font-mono text-slate-900 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
|
placeholder={t('autofix.k8735e9a4')}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-2xl border border-slate-200 bg-white/85 p-4">
|
|
<div className="mb-2 text-sm font-semibold text-slate-900">{t('autofix.kefc3a3f9')}</div>
|
|
<div className="rounded-lg border border-slate-200 bg-white p-4">
|
|
<div className="mb-3 text-xs text-slate-500">
|
|
{t('autofix.k64efb463')}
|
|
<span className="font-medium text-slate-800">{editor.subject || t('autofix.k1b76fc38')}</span>
|
|
{selectedTemplate && !isCreating && (
|
|
<span className="ml-3">Last update: {new Date(selectedTemplate.updated_at).toLocaleString()}</span>
|
|
)}
|
|
</div>
|
|
<div
|
|
className="prose prose-sm max-w-none text-slate-800"
|
|
dangerouslySetInnerHTML={{ __html: editor.html_content || `<p class="text-slate-500">${t('autofix.k5201934d')}</p>` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|